From e7df38a0583d12b0ab1855cbf42b22eccf73191e Mon Sep 17 00:00:00 2001 From: Joshua Graber Date: Tue, 8 Oct 2024 19:21:29 -0400 Subject: [PATCH] feat(components): build FormV2 Create new form component that is more flexible in its rendering fix #102 --- src/components/Form/PdapForm.vue | 3 + src/components/FormV2/PdapFormV2.vue | 99 ++++++++++ .../FormV2/__snapshots__/formv2.spec.ts.snap | 139 +++++++++++++ src/components/FormV2/formv2.spec.ts | 183 ++++++++++++++++++ src/components/FormV2/index.ts | 1 + src/components/FormV2/types.ts | 47 +++++ src/components/FormV2/util.ts | 25 +++ 7 files changed, 497 insertions(+) create mode 100644 src/components/FormV2/PdapFormV2.vue create mode 100644 src/components/FormV2/__snapshots__/formv2.spec.ts.snap create mode 100644 src/components/FormV2/formv2.spec.ts create mode 100644 src/components/FormV2/index.ts create mode 100644 src/components/FormV2/types.ts create mode 100644 src/components/FormV2/util.ts diff --git a/src/components/Form/PdapForm.vue b/src/components/Form/PdapForm.vue index cf3c4bf..bd16378 100644 --- a/src/components/Form/PdapForm.vue +++ b/src/components/Form/PdapForm.vue @@ -163,6 +163,9 @@ async function submit(e: Event) { * The `Form` component is powerful. All you need to do is pass a few props, and the component will generate inputs and render them in the UI, complete with customizable form validation and both form-level and input-level error states. * * + * @deprecated use FormV2 with the PdapInputCheckbox, ...Text, and ...Password components instead + * + * * ## Props * @prop {string | undefined | null} error Error state. Only a non-falsy string results in a form-level error being displayed * @prop {string} id Passed through to the `form` element as its `id` diff --git a/src/components/FormV2/PdapFormV2.vue b/src/components/FormV2/PdapFormV2.vue new file mode 100644 index 0000000..49b8587 --- /dev/null +++ b/src/components/FormV2/PdapFormV2.vue @@ -0,0 +1,99 @@ + + + + diff --git a/src/components/FormV2/__snapshots__/formv2.spec.ts.snap b/src/components/FormV2/__snapshots__/formv2.spec.ts.snap new file mode 100644 index 0000000..b04c310 --- /dev/null +++ b/src/components/FormV2/__snapshots__/formv2.spec.ts.snap @@ -0,0 +1,139 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PdapFormV2 > calls submit event with form values on valid submission 1`] = ` +
+ +
+ + + +
+
+ + + +
+
+ +
+ + +
+ +
+
+ + + +
+
+`; + +exports[`PdapFormV2 > renders default error message when form has errors 1`] = ` +
+
Please update this form to correct the errors
+
+
Value is required
+ + +
+
+
Value is required
+ + +
+
+ +
+ + +
+ +
+
+ + + +
+
+`; + +exports[`PdapFormV2 > renders error message slot when provided 1`] = ` +
+
Custom Error Message
+
+`; + +exports[`PdapFormV2 > renders error message when errorMessage prop is provided 1`] = ` +
+
Form Error
+
+ + + +
+
+ + + +
+
+ +
+ + +
+ +
+
+ + + +
+
+`; + +exports[`PdapFormV2 > renders the form element 1`] = ` +
+ +
+ + + +
+
+ + + +
+
+ +
+ + +
+ +
+
+ + + +
+
+`; diff --git a/src/components/FormV2/formv2.spec.ts b/src/components/FormV2/formv2.spec.ts new file mode 100644 index 0000000..bd46453 --- /dev/null +++ b/src/components/FormV2/formv2.spec.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; +import PdapFormV2 from './PdapFormV2.vue'; +import InputCheckbox from '../InputCheckbox/PdapInputCheckbox.vue'; +import InputText from '../InputText/PdapInputText.vue'; +import InputPassword from '../InputPassword/PdapInputPassword.vue'; + +vi.mock('vue-router'); +vi.mock('vue', async () => { + const actual: Record = await vi.importActual('vue'); + return { + ...actual, + /** Shim for `exportHelper` function which throws errors on Input because actual + * Vue impl does not have optional chaining on `sfc.__vccOpts` check */ + exportHelper: function ( + sfc: Record, + props: [string, string][] + ) { + const newObject = sfc?.vccOpts ?? sfc; + for (const [key, val] of props) { + newObject[key] = val; + } + return newObject; + }, + }; +}); +vi.mock('@vuelidate/core', async () => { + const actual: Record = + await vi.importActual('@vuelidate/core'); + + return { + ...actual, + }; +}); + +const submit = vi.fn((values: Record, e: Event) => ({ + values, + e, +})); + +const BASE_CONFIG = { + props: { + defaultValues: { + name: '', + email: '', + password: '', + 'ice-cream': false, + }, + schema: [ + { + name: 'name', + validators: { + required: { + value: true, + }, + }, + }, + { + name: 'email', + validators: { + required: { value: true }, + email: { value: true }, + }, + }, + { + name: 'password', + validators: { + password: { value: true, message: 'Password is too weak' }, + }, + }, + ], + id: 'test', + name: 'test', + }, + attrs: { + onSubmit: submit, + }, + slots: { + default: ` + + + + + `, + }, + global: { + stubs: { + InputCheckbox, + InputText, + InputPassword, + }, + }, +}; + +describe('PdapFormV2', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(PdapFormV2, BASE_CONFIG); + }); + + it('renders the form element', () => { + expect(wrapper.find('form').exists()).toBe(true); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('renders error message slot when provided', () => { + wrapper = mount(PdapFormV2, { + ...BASE_CONFIG, + slots: { + error: '
Custom Error Message
', + }, + }); + expect(wrapper.find('.pdap-form-error-message').exists()).toBe(false); + expect(wrapper.text()).toContain('Custom Error Message'); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('renders error message when errorMessage prop is provided', () => { + wrapper = mount(PdapFormV2, { + ...BASE_CONFIG, + props: { + ...BASE_CONFIG.props, + error: 'Form Error', + }, + }); + expect(wrapper.find('.pdap-form-error-message').text()).toBe('Form Error'); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('renders default error message when form has errors', async () => { + wrapper.find('form').trigger('submit'); + await nextTick(); + expect(wrapper.find('.pdap-form-error-message').text()).toBe( + 'Please update this form to correct the errors' + ); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('calls submit event with form values on valid submission', async () => { + const form = await wrapper.find('form'); + await form.find('input[name="name"]').setValue('John Doe'); + await form.find('input[name="email"]').setValue('john@example.com'); + await form.find('input[name="password"]').setValue('Password123!'); + await form.find('input[name="ice-cream"]').setChecked(); + + await form.trigger('submit'); + await wrapper.vm.$forceUpdate(); + await wrapper.vm.$nextTick(); + + expect(submit).toHaveBeenCalledWith( + { + name: 'John Doe', + email: 'john@example.com', + password: 'Password123!', + 'ice-cream': true, + }, + expect.any(Event) + ); + expect(wrapper.html()).toMatchSnapshot(); + }); +}); diff --git a/src/components/FormV2/index.ts b/src/components/FormV2/index.ts new file mode 100644 index 0000000..d3f6fd0 --- /dev/null +++ b/src/components/FormV2/index.ts @@ -0,0 +1 @@ +export { default as FormV2 } from './PdapFormV2.vue'; diff --git a/src/components/FormV2/types.ts b/src/components/FormV2/types.ts new file mode 100644 index 0000000..6d1ae4e --- /dev/null +++ b/src/components/FormV2/types.ts @@ -0,0 +1,47 @@ +// TODO: remove the V2 from all of these types when Form is removed and FormV2 -> Form + +import useVuelidate from '@vuelidate/core'; +import { makeRules } from './util'; +import { Ref } from 'vue'; + +export interface PdapFormValidatorV2 { + message?: string; + value: T; +} + +/** + * Keyed by currently used validators. + * Add any Vuelidate validators or custom ones here as we need them. + * See https://vuelidate-next.netlify.app/validators.html#using-builtin-validators for more. + * + */ +export interface PdapFormValidatorsV2 { + maxLength: PdapFormValidatorV2; + minLength: PdapFormValidatorV2; + required: PdapFormValidatorV2; + email: PdapFormValidatorV2; + password: PdapFormValidatorV2; +} + +export type ValidationSchemaV2 = { + name: string; + validators: Partial; +}[]; +/** + * PDAP Form props interface. + */ +export interface PdapFormPropsV2 { + defaultValues?: Record; + error?: string | undefined | null; + // Adds id and name in order to make required + id: string; + name: string; + schema: ValidationSchemaV2; +} + +export interface PdapFormProvideV2 { + values: Ref>; + setValues: (values: Record) => void; + rules: ReturnType; + v$: ReturnType; +} diff --git a/src/components/FormV2/util.ts b/src/components/FormV2/util.ts new file mode 100644 index 0000000..ca682fb --- /dev/null +++ b/src/components/FormV2/util.ts @@ -0,0 +1,25 @@ +import { createRule } from '../../utils/vuelidate'; +import { PdapFormProvideV2, ValidationSchemaV2 } from './types'; +import { InjectionKey } from 'vue'; + +export function makeRules( + schema: ValidationSchemaV2 +): Record> { + return schema.reduce((acc, { name, validators }) => { + const toAdd = Object.entries(validators ?? {}).reduce((acc, [key, val]) => { + return { + ...acc, + ...createRule(key, val), + }; + }, {}); + + return { + ...acc, + [name]: { + ...toAdd, + }, + }; + }, {}); +} + +export const provideKey = Symbol() as InjectionKey;