From c362b2e199f67f8a9b182016deae2f0cdf05a118 Mon Sep 17 00:00:00 2001 From: Joshua Graber <68428039+joshuagraber@users.noreply.github.com> Date: Tue, 8 Oct 2024 19:35:30 -0400 Subject: [PATCH] feat: form v2 and inputs v2 (#105) resolves #102 --- commitlint.config.js | 2 +- 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 +++ src/components/Input/PdapInput.vue | 4 +- .../InputCheckbox/PdapInputCheckbox.vue | 47 +++++ src/components/InputCheckbox/index.ts | 1 + src/components/InputCheckbox/types.ts | 6 + .../InputPassword/PdapInputPassword.vue | 83 ++++++++ src/components/InputPassword/index.ts | 1 + src/components/InputText/PdapInputText.vue | 44 +++++ src/components/InputText/index.ts | 1 + src/components/InputText/types.ts | 6 + src/components/index.ts | 4 + src/demo/pages/FormV2Demo.vue | 80 ++++++++ src/demo/router.js | 6 + src/index.ts | 1 + src/styles/components.css | 70 +++++++ tsconfig.json | 4 +- 23 files changed, 851 insertions(+), 6 deletions(-) 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 create mode 100644 src/components/InputCheckbox/PdapInputCheckbox.vue create mode 100644 src/components/InputCheckbox/index.ts create mode 100644 src/components/InputCheckbox/types.ts create mode 100644 src/components/InputPassword/PdapInputPassword.vue create mode 100644 src/components/InputPassword/index.ts create mode 100644 src/components/InputText/PdapInputText.vue create mode 100644 src/components/InputText/index.ts create mode 100644 src/components/InputText/types.ts create mode 100644 src/demo/pages/FormV2Demo.vue diff --git a/commitlint.config.js b/commitlint.config.js index 4698a12..e40d319 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,6 +1,6 @@ export default { extends: ['@commitlint/config-conventional'], rules: { - 'footer-max-length': [2, 'never'] + 'footer-max-length': [0] } }; 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..5eb43b3 --- /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; diff --git a/src/components/Input/PdapInput.vue b/src/components/Input/PdapInput.vue index fdcf99e..f4ff281 100644 --- a/src/components/Input/PdapInput.vue +++ b/src/components/Input/PdapInput.vue @@ -78,9 +78,7 @@ const errorMessageId = computed(() => `pdap-${props.name}-input-error`); /* Error state */ .pdap-input-error { - @apply flex-wrap; - - row-gap: 0; + @apply flex-wrap gap-x-0; } .pdap-input-error label { diff --git a/src/components/InputCheckbox/PdapInputCheckbox.vue b/src/components/InputCheckbox/PdapInputCheckbox.vue new file mode 100644 index 0000000..6387859 --- /dev/null +++ b/src/components/InputCheckbox/PdapInputCheckbox.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/components/InputCheckbox/index.ts b/src/components/InputCheckbox/index.ts new file mode 100644 index 0000000..ebdb38b --- /dev/null +++ b/src/components/InputCheckbox/index.ts @@ -0,0 +1 @@ +export { default as InputCheckbox } from './PdapInputCheckbox.vue'; diff --git a/src/components/InputCheckbox/types.ts b/src/components/InputCheckbox/types.ts new file mode 100644 index 0000000..691e4ab --- /dev/null +++ b/src/components/InputCheckbox/types.ts @@ -0,0 +1,6 @@ +export interface PdapInputCheckboxProps { + id: string; + label?: string; + name: string; + defaultChecked?: boolean; +} diff --git a/src/components/InputPassword/PdapInputPassword.vue b/src/components/InputPassword/PdapInputPassword.vue new file mode 100644 index 0000000..9e22d29 --- /dev/null +++ b/src/components/InputPassword/PdapInputPassword.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/src/components/InputPassword/index.ts b/src/components/InputPassword/index.ts new file mode 100644 index 0000000..0e15271 --- /dev/null +++ b/src/components/InputPassword/index.ts @@ -0,0 +1 @@ +export { default as InputPassword } from './PdapInputPassword.vue'; diff --git a/src/components/InputText/PdapInputText.vue b/src/components/InputText/PdapInputText.vue new file mode 100644 index 0000000..0d0c05d --- /dev/null +++ b/src/components/InputText/PdapInputText.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/components/InputText/index.ts b/src/components/InputText/index.ts new file mode 100644 index 0000000..9cfa813 --- /dev/null +++ b/src/components/InputText/index.ts @@ -0,0 +1 @@ +export { default as InputText } from './PdapInputText.vue'; diff --git a/src/components/InputText/types.ts b/src/components/InputText/types.ts new file mode 100644 index 0000000..99ba44b --- /dev/null +++ b/src/components/InputText/types.ts @@ -0,0 +1,6 @@ +export interface PdapInputTextProps { + id: string; + label?: string; + name: string; + placeholder?: string; +} diff --git a/src/components/index.ts b/src/components/index.ts index d54b1d3..45b1027 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,7 +2,11 @@ export { Button } from './Button'; export { ErrorBoundary } from './ErrorBoundary'; export { Footer } from './Footer'; export { Form } from './Form'; +export { FormV2 } from './FormV2'; export { Input } from './Input'; +export { InputCheckbox } from './InputCheckbox'; +export { InputPassword } from './InputPassword'; +export { InputText } from './InputText'; export { Header } from './Header'; export { Nav } from './Nav'; export { QuickSearchForm } from './QuickSearchForm'; diff --git a/src/demo/pages/FormV2Demo.vue b/src/demo/pages/FormV2Demo.vue new file mode 100644 index 0000000..5f9a1f1 --- /dev/null +++ b/src/demo/pages/FormV2Demo.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/src/demo/router.js b/src/demo/router.js index 1dd200d..89fd514 100644 --- a/src/demo/router.js +++ b/src/demo/router.js @@ -1,6 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import ComponentDemo from './pages/ComponentDemo.vue'; import SignupFormDemo from './pages/SignupFormDemo.vue'; +import FormV2Demo from './pages/FormV2Demo.vue'; const routes = [ { @@ -46,6 +47,11 @@ const routes = [ component: SignupFormDemo, name: 'Login Demo', }, + { + path: '/form-v2-demo', + component: FormV2Demo, + name: 'FormV2 Demo', + }, ]; const router = createRouter({ diff --git a/src/index.ts b/src/index.ts index cf2bcaf..0be052d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export * from './components/Dropdown/types'; export * from './components/ErrorBoundary/types'; export * from './components/Footer/types'; export * from './components/Form/types'; +export * from './components/FormV2/types'; export * from './components/Header/types'; export * from './components/Input/types'; export * from './components/Nav/types'; diff --git a/src/styles/components.css b/src/styles/components.css index 79fb44d..3f9946e 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -35,4 +35,74 @@ .pdap-flex-container-center { @apply pdap-flex-container items-center justify-center; } + + /* Input styles */ + .pdap-input { + @apply h-[max-content] gap-1 leading-normal mb-3 w-full flex flex-col; + } + + .pdap-input input { + @apply dark:bg-neutral-950 border border-neutral-500 border-solid px-3 py-2 text-[rgba(0,0,0)]; + } + + .pdap-input input::placeholder { + @apply text-neutral-600 text-lg; + } + + .pdap-input input:focus, + .pdap-input input:focus-within, + .pdap-input input:focus-visible { + @apply border-2 border-blue-light border-solid outline-none; + } + + .pdap-input label { + @apply max-w-[max-content] text-lg py-1 font-medium; + } + + /* Error state */ + .pdap-input-error { + @apply flex-wrap gap-x-0; + } + + .pdap-input-error label { + @apply justify-start; + } + + .pdap-input-error input { + @apply border-red-800 dark:border-red-300; + } + + .pdap-input-error-message { + @apply items-center justify-start flex bg-red-300 text-red-800 p-1 text-xs; + } + + /* Specific inputs */ + /* Input - text */ + .pdap-input input[type='text'], + .pdap-input input[type='password'] { + @apply h-12 text-lg; + } + + /* Input - checkbox */ + .pdap-input-checkbox { + @apply border-2 border-transparent items-center gap-4 flex-row py-1 px-2 w-auto; + } + + .pdap-input-checkbox:has(input:checked) { + @apply border-2 border-brand-gold border-solid rounded-md; + } + + .pdap-input input[type='checkbox'] { + @apply h-6 w-6 accent-brand-gold; + } + + .pdap-input input[type='checkbox'] ~ label { + @apply pl-0 w-full max-w-full; + } + + .pdap-input input[type='checkbox'], + .pdap-input input[type='checkbox'] ~ label { + @apply cursor-pointer; + } + } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 9bcaa38..270fba7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "useDefineForClassFields": true, "module": "ESNext", "lib": [ - "ES2020", + "ES2022", "DOM", "DOM.Iterable" ],