From 5a2e23333de617ec602c36d5811ce72af3b6e65a Mon Sep 17 00:00:00 2001 From: Joshua Graber Date: Thu, 25 Jul 2024 08:25:00 -0400 Subject: [PATCH 01/42] chore(workflows): skip commit message linting [no ci] --- .github/workflows/pull.yaml | 4 ++-- .github/workflows/release.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pull.yaml b/.github/workflows/pull.yaml index ad944a9..a3dfdf4 100644 --- a/.github/workflows/pull.yaml +++ b/.github/workflows/pull.yaml @@ -34,8 +34,8 @@ jobs: node-version: '20.x' cache: 'npm' - run: npm ci - - name: Lint commit messages 🗂️ - run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose + # - name: Lint commit messages 🗂️ + # run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose - name: Lint TS and CSS 🎨 run: npm run lint diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ab7849a..77c83e2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -43,8 +43,8 @@ jobs: - name: Lint TS and CSS 🎨 run: npm run lint - - name: Lint commit messages 🗂️ - run: npx commitlint --from HEAD~1 --to HEAD + # - name: Lint commit messages 🗂️ + # run: npx commitlint --from HEAD~1 --to HEAD - name: Run tests 📋 run: npm run test:ci From 08965c12493105b885b5f433cbde7b68eeb184a7 Mon Sep 17 00:00:00 2001 From: Joshua Graber <68428039+joshuagraber@users.noreply.github.com> Date: Tue, 20 Aug 2024 14:43:33 -0400 Subject: [PATCH 02/42] refactor(components): miscellaneous form updates (#100) --- src/components/Form/PdapForm.vue | 47 +++++++++++++++++++------------- src/components/Form/form.spec.ts | 43 +++++++++++++++++++++++++++++ src/demo/pages/ComponentDemo.vue | 30 +++++++------------- 3 files changed, 81 insertions(+), 39 deletions(-) diff --git a/src/components/Form/PdapForm.vue b/src/components/Form/PdapForm.vue index 647e02c..cf3c4bf 100644 --- a/src/components/Form/PdapForm.vue +++ b/src/components/Form/PdapForm.vue @@ -47,6 +47,11 @@ const props = withDefaults(defineProps(), { // Emits const emit = defineEmits(['submit', 'change']); +// Expose +defineExpose({ + setValues, +}); + // State const data = computed(() => props.schema.map((input) => { @@ -91,23 +96,6 @@ const v$ = useVuelidate(rules, values, { $autoDirty: false, $lazy: true }); // Vars const errorMessage = ref(props.error); -// Handlers -function updateForm(field: PdapInputProps, event: Event) { - const target = event.target as HTMLInputElement; - - const update = (() => { - switch (field.type) { - case PdapInputTypes.CHECKBOX: - return target.checked ? 'true' : 'false'; - default: - return target.value; - } - })(); - - values.value[field.name] = update; - emit('change', values.value); -} - // Effects // Effect - Updates form error state based on input error state and/or props watchEffect(() => { @@ -124,6 +112,23 @@ watchEffect(() => { } }); +// Handlers +function updateForm(field: PdapInputProps, event: Event) { + const target = event.target as HTMLInputElement; + + const update = (() => { + switch (field.type) { + case PdapInputTypes.CHECKBOX: + return target.checked ? 'true' : 'false'; + default: + return target.value; + } + })(); + + values.value[field.name] = update; + emit('change', values.value, event); +} + /** * Reset vuelidate and wipe values state */ @@ -134,12 +139,16 @@ function resetForm() { }, {}); } -async function submit() { +function setValues(update: T) { + values.value = update; +} + +async function submit(e: Event) { // Check form submission const isValidSubmission = await v$.value.$validate(); if (isValidSubmission) { // Emit submit event (spread to new object to create new object, this allows us to reset `values` without messing with the data returned) - emit('submit', { ...values.value }); + emit('submit', { ...values.value }, e); if (props.resetOn === 'submit') { resetForm(); diff --git a/src/components/Form/form.spec.ts b/src/components/Form/form.spec.ts index 19f15cc..fe683a2 100644 --- a/src/components/Form/form.spec.ts +++ b/src/components/Form/form.spec.ts @@ -289,6 +289,49 @@ describe('Form component', () => { expect(wrapper.find('.pdap-form-error-message').exists()).toBe(false); }); + test('Form updates on call of setValues', async () => { + const wrapper = mount(PdapForm, base); + + const inputTextOne = wrapper.find('#test-1'); + const inputTextTwo = wrapper.find('#test-2'); + const inputEmail = wrapper.find('#test-email'); + const inputPassword = wrapper.find('#test-password'); + const inputCheckboxDefaultChecked = wrapper.find( + '#checkbox-default-checked' + ); + const inputCheckboxDefaultUnchecked = wrapper.find( + '#checkbox-default-unchecked' + ); + + await nextTick(); + + wrapper.vm.setValues({ + testOne: 'foo', + testTwo: 'bar', + email: 'XXXXXXXXXXXXXXX', + password: 'XXXXXXXXXX', + checkboxDefaultChecked: 'false', + checkboxDefaultUnchecked: 'true', + }); + + await nextTick(); + + expect((inputTextOne.element as HTMLInputElement).value).toBe('foo'); + expect((inputTextTwo.element as HTMLInputElement).value).toBe('bar'); + expect((inputEmail.element as HTMLInputElement).value).toBe( + 'XXXXXXXXXXXXXXX' + ); + expect((inputPassword.element as HTMLInputElement).value).toBe( + 'XXXXXXXXXX' + ); + expect( + (inputCheckboxDefaultChecked.element as HTMLInputElement).value + ).toBe('false'); + expect( + (inputCheckboxDefaultUnchecked.element as HTMLInputElement).value + ).toBe('true'); + }); + test('Form waits to reset until resetOn prop switches to `true` and error is falsy', async () => { const wrapper = mount(PdapForm, { ...base, diff --git a/src/demo/pages/ComponentDemo.vue b/src/demo/pages/ComponentDemo.vue index 5ac7ff1..64dd2ee 100644 --- a/src/demo/pages/ComponentDemo.vue +++ b/src/demo/pages/ComponentDemo.vue @@ -195,6 +195,7 @@

Form

) { alert(alertString); } -function change(values: Record<'firstName' | 'lastName' | 'iceCream', string>) { - console.debug('onChange', { values }); +function change( + values: Record<'firstName' | 'lastName' | 'iceCream', string>, + event: Event +) { + if (formRef.value) { + console.debug({ ref: formRef.value }); + } + console.debug('onChange', { values, event }); } onMounted(updateLoadingText); From 7ac0e6ec8a6cbdbcb96bccf00d3cc9a0480468f9 Mon Sep 17 00:00:00 2001 From: Joshua Graber <68428039+joshuagraber@users.noreply.github.com> Date: Thu, 12 Sep 2024 11:01:09 -0400 Subject: [PATCH 03/42] feat: add record type icon to form input labels (#103) --- package-lock.json | 109 ++++++++++++++++++ package.json | 5 + .../Form/__snapshots__/form.spec.ts.snap | 72 ++++++++++-- src/components/Input/PdapInput.vue | 14 ++- .../Input/__snapshots__/input.spec.ts.snap | 36 +++++- .../quick-search-form.spec.ts.snap | 12 +- .../RecordTypeIcon/RecordTypeIcon.vue | 104 +++++++++++++++++ .../__snapshots__/recordTypeIcon.spec.ts.snap | 45 ++++++++ src/components/RecordTypeIcon/index.ts | 1 + .../RecordTypeIcon/recordTypeIcon.spec.ts | 96 +++++++++++++++ src/components/RecordTypeIcon/util.ts | 14 +++ src/components/index.ts | 1 + 12 files changed, 488 insertions(+), 21 deletions(-) create mode 100644 src/components/RecordTypeIcon/RecordTypeIcon.vue create mode 100644 src/components/RecordTypeIcon/__snapshots__/recordTypeIcon.spec.ts.snap create mode 100644 src/components/RecordTypeIcon/index.ts create mode 100644 src/components/RecordTypeIcon/recordTypeIcon.spec.ts create mode 100644 src/components/RecordTypeIcon/util.ts diff --git a/package-lock.json b/package-lock.json index bbacb0f..c891ce5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,11 @@ "eslint-config" ], "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.6.0", + "@fortawesome/free-brands-svg-icons": "^6.6.0", + "@fortawesome/free-regular-svg-icons": "^6.6.0", + "@fortawesome/free-solid-svg-icons": "^6.6.0", + "@fortawesome/vue-fontawesome": "^3.0.8", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", "fs-extra": "^11.1.1", @@ -1339,6 +1344,67 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", + "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", + "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz", + "integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz", + "integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", + "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/vue-fontawesome": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.8.tgz", + "integrity": "sha512-yyHHAj4G8pQIDfaIsMvQpwKMboIZtcHTUvPqXjOHyldh1O1vZfH4W03VDPv5RvI9P6DLTzJQlmVgj9wCf7c2Fw==", + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "vue": ">= 3.0.0 < 4" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -18038,6 +18104,49 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.53.0.tgz", "integrity": "sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==" }, + "@fortawesome/fontawesome-common-types": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", + "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==" + }, + "@fortawesome/fontawesome-svg-core": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", + "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", + "requires": { + "@fortawesome/fontawesome-common-types": "6.6.0" + } + }, + "@fortawesome/free-brands-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz", + "integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==", + "requires": { + "@fortawesome/fontawesome-common-types": "6.6.0" + } + }, + "@fortawesome/free-regular-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.6.0.tgz", + "integrity": "sha512-Yv9hDzL4aI73BEwSEh20clrY8q/uLxawaQ98lekBx6t9dQKDHcDzzV1p2YtBGTtolYtNqcWdniOnhzB+JPnQEQ==", + "requires": { + "@fortawesome/fontawesome-common-types": "6.6.0" + } + }, + "@fortawesome/free-solid-svg-icons": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", + "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", + "requires": { + "@fortawesome/fontawesome-common-types": "6.6.0" + } + }, + "@fortawesome/vue-fontawesome": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.8.tgz", + "integrity": "sha512-yyHHAj4G8pQIDfaIsMvQpwKMboIZtcHTUvPqXjOHyldh1O1vZfH4W03VDPv5RvI9P6DLTzJQlmVgj9wCf7c2Fw==", + "requires": {} + }, "@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", diff --git a/package.json b/package.json index acac0a1..25b427a 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,11 @@ "vue-tsc": "^1.8.22" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.6.0", + "@fortawesome/free-brands-svg-icons": "^6.6.0", + "@fortawesome/free-regular-svg-icons": "^6.6.0", + "@fortawesome/free-solid-svg-icons": "^6.6.0", + "@fortawesome/vue-fontawesome": "^3.0.8", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", "fs-extra": "^11.1.1", diff --git a/src/components/Form/__snapshots__/form.spec.ts.snap b/src/components/Form/__snapshots__/form.spec.ts.snap index d3f1646..5f4d03f 100644 --- a/src/components/Form/__snapshots__/form.spec.ts.snap +++ b/src/components/Form/__snapshots__/form.spec.ts.snap @@ -6,32 +6,56 @@ exports[`Form component > Renders component in form error state 1`] = `
- +
- +
- +
- +
- +
- +
@@ -43,32 +67,56 @@ exports[`Form component > Renders component in static state 1`] = `
- +
- +
- +
- +
- +
- +
diff --git a/src/components/Input/PdapInput.vue b/src/components/Input/PdapInput.vue index fe71a60..fdcf99e 100644 --- a/src/components/Input/PdapInput.vue +++ b/src/components/Input/PdapInput.vue @@ -23,7 +23,12 @@ {{ error }} - + @@ -37,6 +42,7 @@ import { } from './types'; import PdapInputText from './Text/InputText.vue'; import PdapInputCheckbox from './Checkbox/InputCheckbox.vue'; +import RecordTypeIcon from '../RecordTypeIcon/RecordTypeIcon.vue'; const props = withDefaults(defineProps(), {}); @@ -117,4 +123,10 @@ const errorMessageId = computed(() => `pdap-${props.name}-input-error`); @apply cursor-pointer; } } + +/* stylelint-disable */ +.svg-inline--fa { + @apply ml-2; +} +/* stylelint-enable */ diff --git a/src/components/Input/__snapshots__/input.spec.ts.snap b/src/components/Input/__snapshots__/input.spec.ts.snap index f0913c8..52e20c3 100644 --- a/src/components/Input/__snapshots__/input.spec.ts.snap +++ b/src/components/Input/__snapshots__/input.spec.ts.snap @@ -4,7 +4,11 @@ exports[`Input component > Renders checkbox input in error state 1`] = `
error message
- +
`; @@ -12,7 +16,11 @@ exports[`Input component > Renders checkbox input in okay state 1`] = `
- +
`; @@ -20,7 +28,11 @@ exports[`Input component > Renders password input in error state 1`] = `
error message
- +
`; @@ -28,7 +40,11 @@ exports[`Input component > Renders password input in okay state 1`] = `
- +
`; @@ -36,7 +52,11 @@ exports[`Input component > Renders text input in error state 1`] = `
error message
- +
`; @@ -44,6 +64,10 @@ exports[`Input component > Renders text input in okay state 1`] = `
- +
`; diff --git a/src/components/QuickSearchForm/__snapshots__/quick-search-form.spec.ts.snap b/src/components/QuickSearchForm/__snapshots__/quick-search-form.spec.ts.snap index 7a250fd..ee3186a 100644 --- a/src/components/QuickSearchForm/__snapshots__/quick-search-form.spec.ts.snap +++ b/src/components/QuickSearchForm/__snapshots__/quick-search-form.spec.ts.snap @@ -12,12 +12,20 @@ exports[`QuickSearchForm component > Renders a QuickSearchForm 1`] = `
- +
- +
diff --git a/src/components/RecordTypeIcon/RecordTypeIcon.vue b/src/components/RecordTypeIcon/RecordTypeIcon.vue new file mode 100644 index 0000000..41d36d5 --- /dev/null +++ b/src/components/RecordTypeIcon/RecordTypeIcon.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/components/RecordTypeIcon/__snapshots__/recordTypeIcon.spec.ts.snap b/src/components/RecordTypeIcon/__snapshots__/recordTypeIcon.spec.ts.snap new file mode 100644 index 0000000..10481c5 --- /dev/null +++ b/src/components/RecordTypeIcon/__snapshots__/recordTypeIcon.spec.ts.snap @@ -0,0 +1,45 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`RecordTypeIcon > does not render an icon for an invalid record type 1`] = ``; + +exports[`RecordTypeIcon > handles record types with ampersand correctly 1`] = ` + +`; + +exports[`RecordTypeIcon > handles top-level record types correctly 1`] = ` + +`; + +exports[`RecordTypeIcon > handles top-level record types correctly 2`] = ` + +`; + +exports[`RecordTypeIcon > handles top-level record types correctly 3`] = ` + +`; + +exports[`RecordTypeIcon > handles top-level record types correctly 4`] = ` + +`; + +exports[`RecordTypeIcon > handles top-level record types correctly 5`] = ` + +`; + +exports[`RecordTypeIcon > renders the correct icon for a valid record type 1`] = ` + +`; diff --git a/src/components/RecordTypeIcon/index.ts b/src/components/RecordTypeIcon/index.ts new file mode 100644 index 0000000..1cea958 --- /dev/null +++ b/src/components/RecordTypeIcon/index.ts @@ -0,0 +1 @@ +export { default as RecordTypeIcon } from './RecordTypeIcon.vue'; diff --git a/src/components/RecordTypeIcon/recordTypeIcon.spec.ts b/src/components/RecordTypeIcon/recordTypeIcon.spec.ts new file mode 100644 index 0000000..c2f13d1 --- /dev/null +++ b/src/components/RecordTypeIcon/recordTypeIcon.spec.ts @@ -0,0 +1,96 @@ +import { shallowMount } from '@vue/test-utils'; +import RecordTypeIcon from './RecordTypeIcon.vue'; +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; +import { + faPersonMilitaryToPerson, + faPersonMilitaryPointing, + faBuildingShield, + faFileShield, + faBuildingColumns, +} from '@fortawesome/free-solid-svg-icons'; +import { describe, expect, it } from 'vitest'; + +describe('RecordTypeIcon', () => { + it('renders the correct icon for a valid record type', () => { + const recordType = 'Incident Reports'; + const wrapper = shallowMount(RecordTypeIcon, { + props: { recordType }, + global: { + stubs: { + FontAwesomeIcon, + }, + }, + }); + + expect(wrapper.findComponent(FontAwesomeIcon).props('icon')).toEqual( + faPersonMilitaryToPerson + ); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('does not render an icon for an invalid record type', () => { + const recordType = 'Invalid Record Type'; + const wrapper = shallowMount(RecordTypeIcon, { + props: { recordType }, + global: { + stubs: { + FontAwesomeIcon, + }, + }, + }); + + expect(wrapper.findComponent(FontAwesomeIcon).exists()).toBe(false); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('handles record types with ampersand correctly', () => { + const recordType = 'Police & public interactions'; + const wrapper = shallowMount(RecordTypeIcon, { + props: { recordType }, + global: { + stubs: { + FontAwesomeIcon, + }, + }, + }); + + expect(wrapper.findComponent(FontAwesomeIcon).props('icon')).toEqual( + faPersonMilitaryToPerson + ); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('handles top-level record types correctly', () => { + const recordTypes = [ + 'Police & public interactions', + 'Info about officers', + 'Info about agencies', + 'Agency-published resources', + 'Jails & Courts specific', + ]; + + const expectedIcons = [ + faPersonMilitaryToPerson, + faPersonMilitaryPointing, + faBuildingShield, + faFileShield, + faBuildingColumns, + ]; + + recordTypes.forEach((recordType, index) => { + const wrapper = shallowMount(RecordTypeIcon, { + props: { recordType }, + global: { + stubs: { + FontAwesomeIcon, + }, + }, + }); + + expect(wrapper.findComponent(FontAwesomeIcon).props('icon')).toEqual( + expectedIcons[index] + ); + expect(wrapper.html()).toMatchSnapshot(); + }); + }); +}); diff --git a/src/components/RecordTypeIcon/util.ts b/src/components/RecordTypeIcon/util.ts new file mode 100644 index 0000000..4385800 --- /dev/null +++ b/src/components/RecordTypeIcon/util.ts @@ -0,0 +1,14 @@ +/** + * Creates map with type inference + * + * UNUSED FOR NOW, but we may need it later + */ +// export function ReadonlyMapWithStringKeys( +// iterable: Iterable<[K, V]> +// ): ReadonlyMap { +// return new Map(iterable); +// } + +// type MapKeys = typeof <> extends ReadonlyMap +// ? K +// : never; diff --git a/src/components/index.ts b/src/components/index.ts index 14e16a2..d54b1d3 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -10,3 +10,4 @@ export { TileIcon } from './TileIcon'; export { Dropdown } from './Dropdown'; export { Breadcrumbs } from './Breadcrumbs'; export { Spinner } from './Spinner'; +export { RecordTypeIcon } from './RecordTypeIcon'; From a4491b0c10d028855523df51e1cc412cbce64b2d Mon Sep 17 00:00:00 2001 From: Joshua Graber <68428039+joshuagraber@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:19:22 -0400 Subject: [PATCH 04/42] fix(colors): update color-neutral-200 (#104) --- src/styles/variables.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/styles/variables.css b/src/styles/variables.css index 0dcd640..1ce324a 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -11,7 +11,7 @@ /* Neutral */ --color-neutral-50: 255 255 255; --color-neutral-100: 255 253 253; - --color-neutral-200: 255 251 250; + --color-neutral-200: 240 240 240; --color-neutral-300: 204 204 204; --color-neutral-400: 191 192 192; --color-neutral-500: 153 153 153; @@ -92,7 +92,7 @@ --color-neutral-500: 153 153 153; --color-neutral-600: 191 192 192; --color-neutral-700: 204 204 204; - --color-neutral-800: 255 251 250; + --color-neutral-800: 240 240 240; --color-neutral-800: 255 253 253; --color-neutral-950: 255 255 255; 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 05/42] 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" ], From 3cf95f6d1accb8dfd42d43175956eee310132ba9 Mon Sep 17 00:00:00 2001 From: Joshua Graber <68428039+joshuagraber@users.noreply.github.com> Date: Tue, 8 Oct 2024 20:06:37 -0400 Subject: [PATCH 06/42] fix: miscellaneous form v2 bugs (#108) --- src/components/FormV2/PdapFormV2.vue | 12 +++++++++--- src/demo/pages/FormV2Demo.vue | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/FormV2/PdapFormV2.vue b/src/components/FormV2/PdapFormV2.vue index 5eb43b3..4e4ae6e 100644 --- a/src/components/FormV2/PdapFormV2.vue +++ b/src/components/FormV2/PdapFormV2.vue @@ -1,5 +1,11 @@ diff --git a/src/components/InputPassword/PdapInputPassword.vue b/src/components/InputPassword/PdapInputPassword.vue index 9e22d29..f54bf82 100644 --- a/src/components/InputPassword/PdapInputPassword.vue +++ b/src/components/InputPassword/PdapInputPassword.vue @@ -26,7 +26,7 @@ - + diff --git a/src/components/InputText/PdapInputText.vue b/src/components/InputText/PdapInputText.vue index 0d0c05d..e42066f 100644 --- a/src/components/InputText/PdapInputText.vue +++ b/src/components/InputText/PdapInputText.vue @@ -13,7 +13,7 @@ @input="onInput" /> - + From ad35c5718bd1dc42185b5426732ca2081245744e Mon Sep 17 00:00:00 2001 From: Joshua Graber <68428039+joshuagraber@users.noreply.github.com> Date: Thu, 10 Oct 2024 21:33:30 -0400 Subject: [PATCH 08/42] feat(components): create SelectInput (#110) --- .husky/post-commit | 6 + .husky/pre-push | 2 +- docs/components.md | 1 + scripts/update-docs.sh | 4 +- .../FormV2/__snapshots__/formv2.spec.ts.snap | 24 +- .../InputCheckbox/PdapInputCheckbox.vue | 4 +- .../InputPassword/PdapInputPassword.vue | 9 +- .../InputSelect/PdapInputSelect.vue | 281 ++++++++++++++++++ src/components/InputSelect/README.md | 84 ++++++ src/components/InputSelect/index.ts | 1 + .../InputSelect/input-select.spec.ts | 275 +++++++++++++++++ src/components/InputSelect/types.ts | 12 + src/components/InputText/PdapInputText.vue | 9 +- src/demo/pages/FormV2Demo.vue | 61 +++- src/styles/components.css | 3 +- 15 files changed, 749 insertions(+), 27 deletions(-) create mode 100755 .husky/post-commit create mode 100644 src/components/InputSelect/PdapInputSelect.vue create mode 100644 src/components/InputSelect/README.md create mode 100644 src/components/InputSelect/index.ts create mode 100644 src/components/InputSelect/input-select.spec.ts create mode 100644 src/components/InputSelect/types.ts diff --git a/.husky/post-commit b/.husky/post-commit new file mode 100755 index 0000000..6bff3d4 --- /dev/null +++ b/.husky/post-commit @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +[ -n "$CI" ] && exit 0 + +. "$(dirname -- "$0")/_/husky.sh" + +npm run docs \ No newline at end of file diff --git a/.husky/pre-push b/.husky/pre-push index 858630c..115cd65 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -npm run docs && npm run test:ci +npm run test:ci diff --git a/docs/components.md b/docs/components.md index d573de4..3c9303f 100644 --- a/docs/components.md +++ b/docs/components.md @@ -9,6 +9,7 @@ - [Form](../src/components/Form//README.md) - [Header](../src/components/Header//README.md) - [Input](../src/components/Input//README.md) +- [InputSelect](../src/components/InputSelect//README.md) - [Nav](../src/components/Nav//README.md) - [QuickSearchForm](../src/components/QuickSearchForm//README.md) - [Spinner](../src/components/Spinner//README.md) diff --git a/scripts/update-docs.sh b/scripts/update-docs.sh index c17de7f..e00374f 100644 --- a/scripts/update-docs.sh +++ b/scripts/update-docs.sh @@ -20,11 +20,11 @@ done # create a commit, only if there are changes if git diff --quiet docs/$output_file; then - echo -e "No new component README files detected.\nProceeding with push" + echo -e "No new component README files detected.\nNo new commit will be created." exit 0 else commit_msg="chore(docs): auto-update to component docs" echo "New README files detected, committing updated docs/$output_file file..." - git add docs/$output_file && git commit -m "$commit_msg" --no-verify && echo "Commit finished, proceeding with push" + git add docs/$output_file && git commit -m "$commit_msg" --no-verify && echo "Updated documentation added to the TOC in \`docs/components.md\` and committed." fi diff --git a/src/components/FormV2/__snapshots__/formv2.spec.ts.snap b/src/components/FormV2/__snapshots__/formv2.spec.ts.snap index b04c310..096aa7d 100644 --- a/src/components/FormV2/__snapshots__/formv2.spec.ts.snap +++ b/src/components/FormV2/__snapshots__/formv2.spec.ts.snap @@ -4,16 +4,17 @@ exports[`PdapFormV2 > calls submit event with form values on valid submission 1`
+ -
+ -
+
@@ -23,7 +24,6 @@ exports[`PdapFormV2 > calls submit event with form values on valid submission 1`
-
@@ -37,16 +37,17 @@ 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
-
+
@@ -56,7 +57,6 @@ exports[`PdapFormV2 > renders default error message when form has errors 1`] = `
-
@@ -76,16 +76,17 @@ exports[`PdapFormV2 > renders error message when errorMessage prop is provided 1
Form Error
+ -
+ -
+
@@ -95,7 +96,6 @@ exports[`PdapFormV2 > renders error message when errorMessage prop is provided 1
-
@@ -109,16 +109,17 @@ exports[`PdapFormV2 > renders the form element 1`] = `
+ -
+ -
+
@@ -128,7 +129,6 @@ exports[`PdapFormV2 > renders the form element 1`] = `
-
diff --git a/src/components/InputCheckbox/PdapInputCheckbox.vue b/src/components/InputCheckbox/PdapInputCheckbox.vue index 76d784f..c47c6a9 100644 --- a/src/components/InputCheckbox/PdapInputCheckbox.vue +++ b/src/components/InputCheckbox/PdapInputCheckbox.vue @@ -3,7 +3,9 @@ class="pdap-input pdap-input-checkbox" :class="{ ['pdap-input-error']: error }" > - +
+ +
{{ error }}
- + + +
+ +
{{ error }}
@@ -25,9 +29,6 @@
- - -
diff --git a/src/components/InputSelect/PdapInputSelect.vue b/src/components/InputSelect/PdapInputSelect.vue new file mode 100644 index 0000000..9640adf --- /dev/null +++ b/src/components/InputSelect/PdapInputSelect.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/src/components/InputSelect/README.md b/src/components/InputSelect/README.md new file mode 100644 index 0000000..02956ce --- /dev/null +++ b/src/components/InputSelect/README.md @@ -0,0 +1,84 @@ +# InputSelect +Accessible, flexible custom select component. + +_Note: Only works with `FormV2`. The `FormV1` schema system is not set up to handle this input._ + +## Props + +| name | required? | types | description | default | +| ------------- | ----------------------------- | --------------------------------------- | ---------------- | ------------------ | +| `id` | yes | `string` | id attr | | +| `label` | yes, if label slot not passed | `string` | label content | | +| `name` | yes | `string` | name attr | | +| `placeholder` | no | `string` | placeholder attr | "Select an option" | +| `options` | yes | `Array<{value: string; label: string}>` | options | | + +## Slots + +| name | required? | types | description | default | +| ------- | ----------------------------- | --------- | ------------------------------------ | ------- | +| `error` | no* | `Element` | slot content to be rendered as error | | +| `label` | yes, if label prop not passed | `Element` | slot content to be rendered as label | | + +* Note: The error message is determined by Vuelidate via our form validation schema. If the error UI needs to be more complicated than a string that can be passed with the schema, pass an `\#error` slot and it will override the string. + +## Example + +```vue + + + + +... +``` diff --git a/src/components/InputSelect/index.ts b/src/components/InputSelect/index.ts new file mode 100644 index 0000000..8472aa3 --- /dev/null +++ b/src/components/InputSelect/index.ts @@ -0,0 +1 @@ +export { default as InputSelect } from './PdapInputSelect.vue'; diff --git a/src/components/InputSelect/input-select.spec.ts b/src/components/InputSelect/input-select.spec.ts new file mode 100644 index 0000000..012de6c --- /dev/null +++ b/src/components/InputSelect/input-select.spec.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import PdapInputSelect from './PdapInputSelect.vue'; +import { nextTick } from 'vue'; +import { provideKey } from '../FormV2/util'; + +const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, +]; + +const defaultProps = { + name: 'testSelect', + options, + id: 'testId', + label: 'Test Label', +}; + +const mockFormProvide = { + setValues: vi.fn(), + values: {}, + v$: { value: {} }, +}; + +const BASE_DEFAULT = { + props: defaultProps, + global: { + provide: { + [provideKey as symbol]: mockFormProvide, + }, + }, +}; + +describe('PdapInputSelect', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly with default props', () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + expect(wrapper.find('label').text()).toBe('Test Label'); + expect(wrapper.find('.selected-value').text()).toBe('Select an option'); + expect(wrapper.findAll('.pdap-custom-select-option').length).toBe(3); + }); + + it('opens options when clicked', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper.find('.pdap-custom-select').trigger('click'); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + expect(wrapper.find('.pdap-custom-select-options').isVisible()).toBe(true); + }); + + it('selects an option when clicked', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper.find('.pdap-custom-select').trigger('click'); + await wrapper.findAll('.pdap-custom-select-option')[1].trigger('click'); + + expect(wrapper.find('.selected-value').text()).toBe('Option 2'); + expect(mockFormProvide.setValues).toHaveBeenCalledWith({ + testSelect: 'option2', + }); + }); + + it('handles keyboard navigation', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(0); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(1); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.selected-value').text()).toBe('Option 2'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Escape' }); + expect(wrapper.find('.pdap-custom-select').classes()).not.toContain('open'); + }); + + it('displays error message when provided', async () => { + const wrapper = mount(PdapInputSelect, { + ...BASE_DEFAULT, + global: { + ...BASE_DEFAULT.global, + provide: { + ...BASE_DEFAULT.global.provide, + [provideKey as symbol]: { + ...mockFormProvide, + v$: { + value: { + testSelect: { + $error: true, + $errors: [{ $message: 'Error message' }], + }, + }, + }, + }, + }, + }, + }); + + await nextTick(); + expect(wrapper.find('.pdap-input-error-message').exists()).toBe(true); + expect(wrapper.find('.pdap-input-error-message').text()).toBe( + 'Error message' + ); + }); + + // it('updates when form values change', async () => { + // const wrapper = mount(PdapInputSelect, { + // ...BASE_DEFAULT, + // props: defaultProps, + // global: { + // ...BASE_DEFAULT.global, + // provide: { + // [provideKey as symbol]: { + // ...mockFormProvide, + // values: { testSelect: 'option3' }, + // }, + // }, + // }, + // }); + + // await wrapper.vm.$forceUpdate(); + // expect(wrapper.find('.selected-value').text()).toBe('Option 3'); + // }); + + it('handles Tab key navigation', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + // const options = wrapper.findAll('.pdap-custom-select-option'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Tab' }); + expect(wrapper.find('.pdap-custom-select').classes()).not.toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + // TODO: figure out why this test isn't working + // await wrapper.find('.pdap-custom-select').trigger('keydown', { key: 'Tab' }); + // expect(options[0].classes()).toContain('selected'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Tab' }); + expect(wrapper.emitted('keydown')).toBeTruthy(); + }); + + it('handles ArrowDown key navigation', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(0); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(1); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(2); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(2); + }); + + it('handles ArrowUp key navigation', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowUp' }); + expect(wrapper.find('.pdap-custom-select').classes()).not.toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(0); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowUp' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(-1); + }); + + it('handles Enter key navigation', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'ArrowDown' }); + // @ts-expect-error vm doesn't play well with TS + expect(wrapper.vm.focusedOptionIndex).toBe(0); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.selected-value').text()).toBe('Option 1'); + expect(wrapper.find('.pdap-custom-select').classes()).not.toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + }); + + it('handles Escape key navigation', async () => { + const wrapper = mount(PdapInputSelect, BASE_DEFAULT); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Enter' }); + expect(wrapper.find('.pdap-custom-select').classes()).toContain('open'); + + await wrapper + .find('.pdap-custom-select') + .trigger('keydown', { key: 'Escape' }); + expect(wrapper.find('.pdap-custom-select').classes()).not.toContain('open'); + }); +}); diff --git a/src/components/InputSelect/types.ts b/src/components/InputSelect/types.ts new file mode 100644 index 0000000..f286f9c --- /dev/null +++ b/src/components/InputSelect/types.ts @@ -0,0 +1,12 @@ +export interface PdapSelectOption { + value: string; + label: string; +} + +export interface PdapInputSelectProps { + id: string; + label?: string; + name: string; + placeholder?: string; + options: PdapSelectOption[]; +} diff --git a/src/components/InputText/PdapInputText.vue b/src/components/InputText/PdapInputText.vue index e42066f..d8952d9 100644 --- a/src/components/InputText/PdapInputText.vue +++ b/src/components/InputText/PdapInputText.vue @@ -1,6 +1,10 @@ diff --git a/src/demo/pages/FormV2Demo.vue b/src/demo/pages/FormV2Demo.vue index 441733e..0a4ace3 100644 --- a/src/demo/pages/FormV2Demo.vue +++ b/src/demo/pages/FormV2Demo.vue @@ -11,7 +11,7 @@ :id="INPUT_TEXT_NAME" autocomplete="off" :name="INPUT_TEXT_NAME" - :placeholder="PLACEHOLDER" + :placeholder="INPUT_TEXT_PLACEHOLDER" >