From 47ec6d40203616c3765992b4bc1ed451dda9a63d Mon Sep 17 00:00:00 2001 From: Khayal Alasgarov Date: Mon, 25 Nov 2024 08:26:06 -0800 Subject: [PATCH] Feature/364 ssn input (#386) * feat: cretae ssn pattern tckt-364 * feat: create ssn pattern edit form tckt-364 * feat: create ssn icon and phone icons tckt-364 * feat: add ssn input and schema validations tckt-364 * test: add tests for ssn input and schema validations tckt-364 * feat: update ssn validation criteria based on USWDS recommendations * feat: address accessibility issues tckt-364 * feat: improve SSN validation error messages for clarity tckt-364 * feat: improve accessibility for ssn input tckt-364 * feat: add input masking to guide correct entry of the Social Security Number tckt-364 --------- Co-authored-by: kalasgarov --- packages/common/src/locales/en/app.ts | 7 + .../SocialSecurityNumber.stories.tsx | 76 +++++++ .../SocialSecurityNumber.test.tsx | 7 + .../SocialSecurityNumber.tsx | 67 ++++++ .../components/SocialSecurityNumber/index.tsx | 3 + packages/design/src/Form/components/index.tsx | 2 + .../FormEdit/AddPatternDropdown.tsx | 10 + ...ocialSecurityNumberPatternEdit.stories.tsx | 90 ++++++++ .../SocialSecurityNumberPatternEdit.tests.tsx | 7 + .../SocialSecurityNumberPatternEdit.tsx | 118 +++++++++++ .../FormManager/FormEdit/components/index.ts | 3 + .../FormEdit/formEditStyles.module.css | 1 + .../FormEdit/images/phone-icon.svg | 12 +- .../FormManager/FormEdit/images/ssn-icon.svg | 3 + packages/forms/src/components.ts | 10 + packages/forms/src/patterns/index.ts | 3 + .../social-security-number.test.ts | 199 ++++++++++++++++++ .../social-security-number.ts | 134 ++++++++++++ 18 files changed, 743 insertions(+), 9 deletions(-) create mode 100644 packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.stories.tsx create mode 100644 packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.test.tsx create mode 100644 packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.tsx create mode 100644 packages/design/src/Form/components/SocialSecurityNumber/index.tsx create mode 100644 packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.stories.tsx create mode 100644 packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tests.tsx create mode 100644 packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tsx create mode 100644 packages/design/src/FormManager/FormEdit/images/ssn-icon.svg create mode 100644 packages/forms/src/patterns/social-security-number/social-security-number.test.ts create mode 100644 packages/forms/src/patterns/social-security-number/social-security-number.ts diff --git a/packages/common/src/locales/en/app.ts b/packages/common/src/locales/en/app.ts index 5c786dd2..fa715580 100644 --- a/packages/common/src/locales/en/app.ts +++ b/packages/common/src/locales/en/app.ts @@ -80,6 +80,13 @@ export const en = { fieldLabel: 'Phone number label', hintLabel: 'Phone number hint label', hint: '10-digit, U.S. only, for example 999-999-9999', + }, + ssn: { + ...defaults, + displayName: 'Social Security Number label', + fieldLabel: 'Social Security Number label', + hintLabel: 'Social Security Number hint label', + hint: 'For example, 555-11-0000', errorTextMustContainChar: 'String must contain at least 1 character(s)', }, }, diff --git a/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.stories.tsx b/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.stories.tsx new file mode 100644 index 00000000..8979759c --- /dev/null +++ b/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.stories.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { type Meta, type StoryObj } from '@storybook/react'; + +import { SocialSecurityNumberPattern } from './SocialSecurityNumber.js'; + +const meta: Meta = { + title: 'patterns/SocialSecurityNumberPattern', + component: SocialSecurityNumberPattern, + decorators: [ + (Story, args) => { + const FormDecorator = () => { + const formMethods = useForm(); + return ( + + + + ); + }; + return ; + }, + ], + tags: ['autodocs'], +}; + +export default meta; + +export const Default: StoryObj = { + args: { + ssnId: 'ssn', + label: 'Social Security Number', + required: false, + }, +}; + +export const WithRequired: StoryObj = { + args: { + ssnId: 'ssn', + label: 'Social Security Number', + required: true, + }, +}; + +export const WithError: StoryObj = { + args: { + ssnId: 'ssn', + label: 'Social Security Number with error', + required: true, + error: { + type: 'custom', + message: 'This field has an error', + }, + }, +}; + +export const WithHint: StoryObj = { + args: { + ssnId: 'ssn', + label: 'Social Security Number', + hint: 'For example, 555-11-0000', + required: true, + }, +}; + +export const WithHintAndError: StoryObj = { + args: { + ssnId: 'ssn', + label: 'Social Security Number', + hint: 'For example, 555-11-0000', + required: true, + error: { + type: 'custom', + message: 'This field has an error', + }, + }, +}; diff --git a/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.test.tsx b/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.test.tsx new file mode 100644 index 00000000..c7522acb --- /dev/null +++ b/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './SocialSecurityNumber.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.tsx b/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.tsx new file mode 100644 index 00000000..7074998e --- /dev/null +++ b/packages/design/src/Form/components/SocialSecurityNumber/SocialSecurityNumber.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import classNames from 'classnames'; +import { useFormContext } from 'react-hook-form'; +import { type SocialSecurityNumberProps } from '@atj/forms'; + +import { type PatternComponent } from '../../index.js'; + +const formatSSN = (value: string) => { + const rawValue = value.replace(/[^\d]/g, ''); + if (rawValue.length <= 3) return rawValue; + if (rawValue.length <= 5) + return `${rawValue.slice(0, 3)}-${rawValue.slice(3)}`; + return `${rawValue.slice(0, 3)}-${rawValue.slice(3, 5)}-${rawValue.slice(5, 9)}`; +}; + +export const SocialSecurityNumberPattern: PatternComponent< + SocialSecurityNumberProps +> = ({ ssnId, hint, label, required, error, value }) => { + const { register, setValue } = useFormContext(); + const errorId = `input-error-message-${ssnId}`; + const hintId = `hint-${ssnId}`; + + const handleSSNChange = (e: React.ChangeEvent) => { + const formattedSSN = formatSSN(e.target.value); + setValue(ssnId, formattedSSN, { shouldValidate: true }); + }; + + return ( +
+
+ + {hint && ( +
+ {hint} +
+ )} + {error && ( + + )} + +
+
+ ); +}; diff --git a/packages/design/src/Form/components/SocialSecurityNumber/index.tsx b/packages/design/src/Form/components/SocialSecurityNumber/index.tsx new file mode 100644 index 00000000..241c5d49 --- /dev/null +++ b/packages/design/src/Form/components/SocialSecurityNumber/index.tsx @@ -0,0 +1,3 @@ +import { SocialSecurityNumberPattern } from './SocialSecurityNumber.js'; + +export default SocialSecurityNumberPattern; diff --git a/packages/design/src/Form/components/index.tsx b/packages/design/src/Form/components/index.tsx index 4d3b3d8e..1dfb8adc 100644 --- a/packages/design/src/Form/components/index.tsx +++ b/packages/design/src/Form/components/index.tsx @@ -16,6 +16,7 @@ import RadioGroup from './RadioGroup/index.js'; import RichText from './RichText/index.js'; import Sequence from './Sequence/index.js'; import SelectDropdown from './SelectDropdown/index.js'; +import SocialSecurityNumber from './SocialSecurityNumber/index.js'; import SubmissionConfirmation from './SubmissionConfirmation/index.js'; import TextInput from './TextInput/index.js'; @@ -37,5 +38,6 @@ export const defaultPatternComponents: ComponentForPattern = { 'rich-text': RichText as PatternComponent, 'select-dropdown': SelectDropdown as PatternComponent, sequence: Sequence as PatternComponent, + 'social-security-number': SocialSecurityNumber as PatternComponent, 'submission-confirmation': SubmissionConfirmation as PatternComponent, }; diff --git a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx index 5eae0326..d4104e29 100644 --- a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx +++ b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx @@ -17,6 +17,7 @@ import pageIcon from './images/page-icon.svg'; import phoneIcon from './images/phone-icon.svg'; import richTextIcon from './images/richtext-icon.svg'; import shortanswerIcon from './images/shortanswer-icon.svg'; +import ssnIcon from './images/ssn-icon.svg'; import singleselectIcon from './images/singleselect-icon.svg'; import templateIcon from './images/template-icon.svg'; @@ -36,6 +37,7 @@ const icons: Record = { 'phone-icon.svg': phoneIcon, 'richtext-icon.svg': richTextIcon, 'shortanswer-icon.svg': shortanswerIcon, + 'ssn-icon.svg': ssnIcon, 'singleselect-icon.svg': singleselectIcon, 'template-icon.svg': templateIcon, }; @@ -108,6 +110,10 @@ const sidebarPatterns: DropdownPattern[] = [ ['select-dropdown', defaultFormConfig.patterns['select-dropdown']], ['date-of-birth', defaultFormConfig.patterns['date-of-birth']], ['attachment', defaultFormConfig.patterns['attachment']], + [ + 'social-security-number', + defaultFormConfig.patterns['social-security-number'], + ], ] as const; export const fieldsetPatterns: DropdownPattern[] = [ ['checkbox', defaultFormConfig.patterns['checkbox']], @@ -123,6 +129,10 @@ export const fieldsetPatterns: DropdownPattern[] = [ ['select-dropdown', defaultFormConfig.patterns['select-dropdown']], ['date-of-birth', defaultFormConfig.patterns['date-of-birth']], ['attachment', defaultFormConfig.patterns['attachment']], + [ + 'social-security-number', + defaultFormConfig.patterns['social-security-number'], + ], ] as const; export const SidebarAddPatternMenuItem = ({ diff --git a/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.stories.tsx new file mode 100644 index 00000000..483a700e --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent } from '@storybook/test'; +import { within } from '@testing-library/react'; + +import { type SocialSecurityNumberPattern } from '@atj/forms'; +import { createPatternEditStoryMeta } from './common/story-helper.js'; +import FormEdit from '../index.js'; +import { enLocale as message } from '@atj/common'; + +const pattern: SocialSecurityNumberPattern = { + id: 'social-security-number-1', + type: 'social-security-number', + data: { + label: message.patterns.ssn.displayName, + required: false, + hint: undefined, + }, +}; + +const storyConfig: Meta = { + title: 'Edit components/SocialSecurityNumberPattern', + ...createPatternEditStoryMeta({ + pattern, + }), +} as Meta; + +export default storyConfig; + +export const Basic: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const updatedLabel = 'Social Security Number update'; + const updatedHint = 'Updated hint for Social Security Number'; + + await userEvent.click(canvas.getByText(message.patterns.ssn.displayName)); + + const labelInput = canvas.getByLabelText(message.patterns.ssn.fieldLabel); + await userEvent.clear(labelInput); + await userEvent.type(labelInput, updatedLabel); + + const hintInput = canvas.getByLabelText(message.patterns.ssn.hintLabel); + await userEvent.clear(hintInput); + await userEvent.type(hintInput, updatedHint); + + const form = labelInput?.closest('form'); + form?.requestSubmit(); + + await expect(await canvas.findByText(updatedLabel)).toBeInTheDocument(); + await expect(await canvas.findByText(updatedHint)).toBeInTheDocument(); + }, +}; + +export const WithoutHint: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const updatedLabel = 'Social Security Number update'; + + await userEvent.click(canvas.getByText(message.patterns.ssn.displayName)); + + const labelInput = canvas.getByLabelText(message.patterns.ssn.fieldLabel); + await userEvent.clear(labelInput); + await userEvent.type(labelInput, updatedLabel); + + const form = labelInput?.closest('form'); + form?.requestSubmit(); + + await expect(await canvas.findByText(updatedLabel)).toBeInTheDocument(); + await expect( + await canvas.queryByLabelText(message.patterns.ssn.hintLabel) + ).toBeNull(); + }, +}; + +export const Error: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByText(message.patterns.ssn.displayName)); + + const labelInput = canvas.getByLabelText(message.patterns.ssn.fieldLabel); + await userEvent.clear(labelInput); + labelInput.blur(); + + await expect( + await canvas.findByText( + message.patterns.selectDropdown.errorTextMustContainChar + ) + ).toBeInTheDocument(); + }, +}; diff --git a/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tests.tsx b/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tests.tsx new file mode 100644 index 00000000..77b05af4 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tests.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './SocialSecurityNumberPatternEdit.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tsx b/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tsx new file mode 100644 index 00000000..8f5aa818 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/SocialSecurityNumberPatternEdit.tsx @@ -0,0 +1,118 @@ +import classnames from 'classnames'; +import React from 'react'; + +import { type SocialSecurityNumberProps } from '@atj/forms'; +import { type SocialSecurityNumberPattern } from '@atj/forms'; + +import SocialSecurityNumber from '../../../Form/components/SocialSecurityNumber/index.js'; +import { PatternEditComponent } from '../types.js'; + +import { PatternEditActions } from './common/PatternEditActions.js'; +import { PatternEditForm } from './common/PatternEditForm.js'; +import { usePatternEditFormContext } from './common/hooks.js'; +import { enLocale as message } from '@atj/common'; +import styles from '../formEditStyles.module.css'; + +const SocialSecurityNumberPatternEdit: PatternEditComponent< + SocialSecurityNumberProps +> = ({ focus, previewProps }) => { + return ( + <> + {focus ? ( + } + > + ) : ( +
+ +
+ )} + + ); +}; + +const EditComponent = ({ + pattern, +}: { + pattern: SocialSecurityNumberPattern; +}) => { + const { fieldId, getFieldState, register } = + usePatternEditFormContext(pattern.id); + const label = getFieldState('label'); + const hint = getFieldState('hint'); + + return ( +
+
+ +
+
+ +
+
+ + + + + + +
+
+ ); +}; + +export default SocialSecurityNumberPatternEdit; diff --git a/packages/design/src/FormManager/FormEdit/components/index.ts b/packages/design/src/FormManager/FormEdit/components/index.ts index cc2cf1d9..ade8e567 100644 --- a/packages/design/src/FormManager/FormEdit/components/index.ts +++ b/packages/design/src/FormManager/FormEdit/components/index.ts @@ -19,6 +19,7 @@ import PhoneNumberPatternEdit from './PhoneNumberPatternEdit.js'; import RadioGroupPatternEdit from './RadioGroupPatternEdit.js'; import RichTextPatternEdit from './RichTextPatternEdit/index.js'; import SelectDropdownPatternEdit from './SelectDropdownPatternEdit.js'; +import SocialSecurityNumberPatternEdit from './SocialSecurityNumberPatternEdit.js'; import SubmissionConfirmationEdit from './SubmissionConfirmationEdit.js'; export const defaultPatternEditComponents: EditComponentForPattern = { @@ -38,5 +39,7 @@ export const defaultPatternEditComponents: EditComponentForPattern = { 'rich-text': RichTextPatternEdit as PatternEditComponent, 'select-dropdown': SelectDropdownPatternEdit as PatternEditComponent, sequence: PatternPreviewSequence as PatternEditComponent, + 'social-security-number': + SocialSecurityNumberPatternEdit as PatternEditComponent, 'submission-confirmation': SubmissionConfirmationEdit as PatternEditComponent, }; diff --git a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css index f58c699b..9d7dbef5 100644 --- a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css +++ b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css @@ -54,6 +54,7 @@ .draggableListItemWrapper .radioFormPattern legend, .draggableListItemWrapper .dateOfBirthPattern legend, .draggableListItemWrapper .phoneNumberPattern legend, +.draggableListItemWrapper .ssnPattern legend, .draggableListItemWrapper .emailInputPattern legend { padding-left: 0; } diff --git a/packages/design/src/FormManager/FormEdit/images/phone-icon.svg b/packages/design/src/FormManager/FormEdit/images/phone-icon.svg index ef0386c7..a047f931 100644 --- a/packages/design/src/FormManager/FormEdit/images/phone-icon.svg +++ b/packages/design/src/FormManager/FormEdit/images/phone-icon.svg @@ -1,10 +1,4 @@ - - - - - - - - - + + + \ No newline at end of file diff --git a/packages/design/src/FormManager/FormEdit/images/ssn-icon.svg b/packages/design/src/FormManager/FormEdit/images/ssn-icon.svg new file mode 100644 index 00000000..dbacd843 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/images/ssn-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/forms/src/components.ts b/packages/forms/src/components.ts index b565df77..c5364c04 100644 --- a/packages/forms/src/components.ts +++ b/packages/forms/src/components.ts @@ -142,6 +142,16 @@ export type PhoneNumberProps = PatternProps<{ value: string; }>; +export type SocialSecurityNumberProps = PatternProps<{ + type: 'social-security-number'; + ssnId: string; + hint?: string; + label: string; + required: boolean; + error?: FormError; + value: string; +}>; + export type SequenceProps = PatternProps<{ type: 'sequence'; }>; diff --git a/packages/forms/src/patterns/index.ts b/packages/forms/src/patterns/index.ts index 2f65b192..bb58d988 100644 --- a/packages/forms/src/patterns/index.ts +++ b/packages/forms/src/patterns/index.ts @@ -17,6 +17,7 @@ import { radioGroupConfig } from './radio-group.js'; import { richTextConfig } from './rich-text.js'; import { selectDropdownConfig } from './select-dropdown/select-dropdown.js'; import { sequenceConfig } from './sequence.js'; +import { socialSecurityNumberConfig } from './social-security-number/social-security-number.js'; // This configuration reflects what a user of this library would provide for // their usage scenarios. For now, keep here in the form service until we @@ -39,6 +40,7 @@ export const defaultFormConfig: FormConfig = { 'radio-group': radioGroupConfig, 'rich-text': richTextConfig, 'select-dropdown': selectDropdownConfig, + 'social-security-number': socialSecurityNumberConfig, sequence: sequenceConfig, }, } as const; @@ -64,4 +66,5 @@ export * from './paragraph.js'; export * from './phone-number/phone-number.js'; export * from './radio-group.js'; export * from './select-dropdown/select-dropdown.js'; +export * from './social-security-number/social-security-number.js'; export * from './sequence.js'; diff --git a/packages/forms/src/patterns/social-security-number/social-security-number.test.ts b/packages/forms/src/patterns/social-security-number/social-security-number.test.ts new file mode 100644 index 00000000..fad769be --- /dev/null +++ b/packages/forms/src/patterns/social-security-number/social-security-number.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; +import { + createSSNSchema, + socialSecurityNumberConfig, + type SocialSecurityNumberPattern, +} from './social-security-number'; + +describe('SocialSecurityNumberPattern tests', () => { + describe('createSSNSchema', () => { + it('should create schema for required SSN input', () => { + const data: SocialSecurityNumberPattern['data'] = { + label: 'Test SSN Input Label', + required: true, + }; + + const schema = createSSNSchema(data); + const validInput = '555-11-1234'; + const invalidInput = '444-44-56as'; + + expect(schema.safeParse(validInput).success).toBe(true); + + const invalidResult = schema.safeParse(invalidInput); + expect(invalidResult.success).toBe(false); + expect(invalidResult.error?.issues[0].message).toBe( + 'Social Security Number must have exactly 9 digits' + ); + }); + + it('should create schema for optional SSN input', () => { + const data: SocialSecurityNumberPattern['data'] = { + label: 'Test SSN Input Label', + required: false, + }; + + const schema = createSSNSchema(data); + const validInput = '555-11-1234'; + const emptyInput = ''; + const invalidInput = '444-44-56as'; + + expect(schema.safeParse(validInput).success).toBe(true); + expect(schema.safeParse(emptyInput).success).toBe(true); + + const invalidResult = schema.safeParse(invalidInput); + expect(invalidResult.success).toBe(false); + expect(invalidResult.error?.issues[0].message).toBe( + 'Social Security Number must have exactly 9 digits' + ); + }); + + it('should fail with less than 9 digits', () => { + const data: SocialSecurityNumberPattern['data'] = { + label: 'Test SSN Input Label', + required: true, + }; + + const schema = createSSNSchema(data); + const shortInput = '123-45-678'; + + const shortInputResult = schema.safeParse(shortInput); + expect(shortInputResult.success).toBe(false); + expect(shortInputResult.error?.issues[0].message).toBe( + 'Social Security Number must have exactly 9 digits' + ); + }); + + it('should fail with invalid SSN prefixes', () => { + const data: SocialSecurityNumberPattern['data'] = { + label: 'Test SSN Input Label', + required: true, + }; + + const schema = createSSNSchema(data); + const invalidSSNs = ['966-45-6789', '666-45-6789', '000-12-3456']; + + invalidSSNs.forEach(ssn => { + const result = schema.safeParse(ssn); + expect(result.success).toBe(false); + expect(result.error?.issues[0].message).toBe( + 'Social Security Number must start with a valid prefix (not 9, 666, or 000)' + ); + }); + }); + + it('should fail with invalid middle and suffix digits', () => { + const data: SocialSecurityNumberPattern['data'] = { + label: 'Test SSN Input Label', + required: true, + }; + + const schema = createSSNSchema(data); + const invalidSSNs = ['555-00-6789', '555-12-0000']; + + invalidSSNs.forEach(ssn => { + const result = schema.safeParse(ssn); + expect(result.success).toBe(false); + const errorMessage = result.error?.issues[0].message; + if (ssn === '555-00-6789') { + expect(errorMessage).toBe( + 'Social Security Number must have a valid middle segment (not 00)' + ); + } else if (ssn === '555-12-0000') { + expect(errorMessage).toBe( + 'Social Security Number must have a valid suffix (not 0000)' + ); + } + }); + }); + }); + + describe('socialSecurityNumberConfig', () => { + it('should parse user input correctly', () => { + const pattern: SocialSecurityNumberPattern = { + type: 'social-security-number', + id: 'test', + data: { + label: 'Test SSN Input Label', + required: true, + }, + }; + + const inputValue = '555-11-1234'; + if (!socialSecurityNumberConfig.parseUserInput) { + expect.fail('socialSecurityNumberConfig.parseUserInput is undefined'); + } + const result = socialSecurityNumberConfig.parseUserInput( + pattern, + inputValue + ); + if (result.success) { + expect(result.data).toBe(inputValue); + } else { + expect.fail('Unexpected validation failure'); + } + }); + + it('should handle validation error for user input', () => { + const pattern: SocialSecurityNumberPattern = { + type: 'social-security-number', + id: 'test', + data: { + label: 'Test SSN Input Label', + required: true, + }, + }; + + const invalidInput = '444-44-56as'; + if (!socialSecurityNumberConfig.parseUserInput) { + expect.fail('socialSecurityNumberConfig.parseUserInput is undefined'); + } + const result = socialSecurityNumberConfig.parseUserInput( + pattern, + invalidInput + ); + if (!result.success) { + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain( + 'Social Security Number must have exactly 9 digits' + ); + } else { + expect.fail('Unexpected validation success'); + } + }); + + it('should parse config data correctly', () => { + const obj = { + label: 'Test SSN Input Label', + required: true, + }; + + if (!socialSecurityNumberConfig.parseConfigData) { + expect.fail('socialSecurityNumberConfig.parseConfigData is undefined'); + } + const result = socialSecurityNumberConfig.parseConfigData(obj); + if (result.success) { + expect(result.data.label).toBe('Test SSN Input Label'); + expect(result.data.required).toBe(true); + } else { + expect.fail('Unexpected validation failure'); + } + }); + + it('should handle invalid config data', () => { + const obj = { + label: '', + required: true, + }; + + if (!socialSecurityNumberConfig.parseConfigData) { + expect.fail('socialSecurityNumberConfig.parseConfigData is undefined'); + } + const result = socialSecurityNumberConfig.parseConfigData(obj); + if (!result.success) { + expect(result.error).toBeDefined(); + } else { + expect.fail('Unexpected validation success'); + } + }); + }); +}); diff --git a/packages/forms/src/patterns/social-security-number/social-security-number.ts b/packages/forms/src/patterns/social-security-number/social-security-number.ts new file mode 100644 index 00000000..a03b550d --- /dev/null +++ b/packages/forms/src/patterns/social-security-number/social-security-number.ts @@ -0,0 +1,134 @@ +import * as z from 'zod'; +import { type SocialSecurityNumberProps } from '../../components.js'; +import { type Pattern, type PatternConfig } from '../../pattern.js'; +import { getFormSessionValue } from '../../session.js'; +import { + safeZodParseFormErrors, + safeZodParseToFormError, +} from '../../util/zod.js'; + +const configSchema = z.object({ + label: z.string().min(1), + required: z.boolean(), + hint: z.string().optional(), +}); + +export type SocialSecurityNumberPattern = Pattern>; + +export type SocialSecurityNumberPatternOutput = z.infer< + ReturnType +>; + +export const createSSNSchema = (data: SocialSecurityNumberPattern['data']) => { + const baseSchema = z + .string() + .transform(value => value.replace(/[^0-9]/g, '')) + .superRefine((value, ctx) => { + if (!data.required && value === '') { + return; + } + + let issues = []; + + if (value.length !== 9) { + issues.push('have exactly 9 digits'); + } else { + if ( + value.startsWith('9') || + value.startsWith('666') || + value.startsWith('000') + ) { + issues.push('start with a valid prefix (not 9, 666, or 000)'); + } + + if (value.slice(3, 5) === '00') { + issues.push('have a valid middle segment (not 00)'); + } + + if (value.slice(5) === '0000') { + issues.push('have a valid suffix (not 0000)'); + } + } + + if (issues.length > 0) { + let enhancedMessage = 'Social Security Number must '; + if (issues.length === 1) { + enhancedMessage += issues[0]; + } else if (issues.length === 2) { + enhancedMessage += `${issues[0]} and ${issues[1]}`; + } else { + enhancedMessage += `${issues.slice(0, -1).join(', ')}, and ${issues[issues.length - 1]}`; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: enhancedMessage, + }); + } + }); + + if (data.required) { + return z + .string() + .refine(value => value.trim().length > 0, { + message: 'This field is required', + }) + .superRefine((value, ctx) => { + const result = baseSchema.safeParse(value.trim()); + if (!result.success) { + result.error.issues.forEach(issue => ctx.addIssue(issue)); + } + }); + } else { + return baseSchema.optional(); + } +}; + +export const socialSecurityNumberConfig: PatternConfig< + SocialSecurityNumberPattern, + SocialSecurityNumberPatternOutput +> = { + displayName: 'Social Security Number', + iconPath: 'ssn-icon.svg', + initial: { + label: 'Social Security Number', + required: true, + hint: 'For example, 555-11-0000', + }, + + parseUserInput: (pattern, inputValue) => { + const result = safeZodParseToFormError( + createSSNSchema(pattern.data), + inputValue + ); + return result; + }, + + parseConfigData: obj => { + return safeZodParseFormErrors(configSchema, obj); + }, + getChildren() { + return []; + }, + + createPrompt(_, session, pattern, options) { + const extraAttributes: Record = {}; + const sessionValue = getFormSessionValue(session, pattern.id); + const error = session.data.errors[pattern.id]; + + return { + props: { + _patternId: pattern.id, + type: 'social-security-number', + label: pattern.data.label, + ssnId: pattern.id, + required: pattern.data.required, + hint: pattern.data.hint, + value: sessionValue, + error, + ...extraAttributes, + } as SocialSecurityNumberProps, + children: [], + }; + }, +};