diff --git a/packages/common/src/locales/en/app.ts b/packages/common/src/locales/en/app.ts index 54792326..2fe77e69 100644 --- a/packages/common/src/locales/en/app.ts +++ b/packages/common/src/locales/en/app.ts @@ -31,7 +31,7 @@ export const en = { fieldLabel: 'Page title', }, paragraph: { - fieldLabel: 'Paragraph Text', + fieldLabel: 'Paragraph text', displayName: 'Paragraph', errorTextMustContainChar: 'String must contain at least 1 character(s)', }, @@ -48,14 +48,14 @@ export const en = { }, selectDropdown: { ...defaults, - displayName: 'Select Dropdown label', - fieldLabel: 'Select Dropdown label', + displayName: 'Select dropdown label', + fieldLabel: 'Select dropdown label', errorTextMustContainChar: 'String must contain at least 1 character(s)', }, dateOfBirth: { ...defaults, - displayName: 'Date of Birth label', - fieldLabel: 'Date Of Birth label', + displayName: 'Date of birth label', + fieldLabel: 'Date of birth label', hintLabel: 'Date of Birth Hint label', hint: 'For example: January 19 2000', errorTextMustContainChar: 'String must contain at least 1 character(s)', @@ -66,5 +66,13 @@ export const en = { fieldLabel: 'Email Input label', errorTextMustContainChar: 'String must contain at least 1 character(s)', }, + phoneNumber: { + ...defaults, + displayName: 'Phone number label', + fieldLabel: 'Phone number label', + hintLabel: 'Phone number hint label', + hint: '10-digit, U.S. only, for example 999-999-9999', + errorTextMustContainChar: 'String must contain at least 1 character(s)', + }, }, }; diff --git a/packages/design/src/Form/components/PhoneNumber/PhoneNumber.stories.tsx b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.stories.tsx new file mode 100644 index 00000000..8418f038 --- /dev/null +++ b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.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 { PhoneNumberPattern } from './PhoneNumber.js'; + +const meta: Meta = { + title: 'patterns/PhoneNumberPattern', + component: PhoneNumberPattern, + decorators: [ + (Story, args) => { + const FormDecorator = () => { + const formMethods = useForm(); + return ( + + + + ); + }; + return ; + }, + ], + tags: ['autodocs'], +}; + +export default meta; + +export const Default: StoryObj = { + args: { + phoneId: 'phone', + label: 'Phone number', + required: false, + }, +}; + +export const WithRequired: StoryObj = { + args: { + phoneId: 'phone', + label: 'Phone number', + required: true, + }, +}; + +export const WithError: StoryObj = { + args: { + phoneId: 'phone', + label: 'Phone number with error', + required: true, + error: { + type: 'custom', + message: 'This field has an error', + }, + }, +}; + +export const WithHint: StoryObj = { + args: { + phoneId: 'phone', + label: 'Phone number', + hint: '10-digit, U.S. only, for example 999-999-9999', + required: true, + }, +}; + +export const WithHintAndError: StoryObj = { + args: { + phoneId: 'phone', + label: 'Phone number', + hint: '10-digit, U.S. only, for example 999-999-9999', + required: true, + error: { + type: 'custom', + message: 'This field has an error', + }, + }, +}; diff --git a/packages/design/src/Form/components/PhoneNumber/PhoneNumber.test.tsx b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.test.tsx new file mode 100644 index 00000000..fb57ad66 --- /dev/null +++ b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './PhoneNumber.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx new file mode 100644 index 00000000..ee4adc1d --- /dev/null +++ b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import classNames from 'classnames'; +import { useFormContext } from 'react-hook-form'; +import { type PhoneNumberProps } from '@atj/forms'; +import { type PatternComponent } from '../../index.js'; + +export const PhoneNumberPattern: PatternComponent = ({ + phoneId, + hint, + label, + required, + error, + value, +}) => { + const { register } = useFormContext(); + + return ( +
+
+ + {hint && ( +
+ {hint} +
+ )} + {error && ( + + )} + +
+
+ ); +}; diff --git a/packages/design/src/Form/components/PhoneNumber/index.tsx b/packages/design/src/Form/components/PhoneNumber/index.tsx new file mode 100644 index 00000000..a9232364 --- /dev/null +++ b/packages/design/src/Form/components/PhoneNumber/index.tsx @@ -0,0 +1,3 @@ +import { PhoneNumberPattern } from './PhoneNumber.js'; + +export default PhoneNumberPattern; diff --git a/packages/design/src/Form/components/SelectDropdown/SelectDropdown.stories.tsx b/packages/design/src/Form/components/SelectDropdown/SelectDropdown.stories.tsx index 51fe214c..5d9eaa93 100644 --- a/packages/design/src/Form/components/SelectDropdown/SelectDropdown.stories.tsx +++ b/packages/design/src/Form/components/SelectDropdown/SelectDropdown.stories.tsx @@ -5,7 +5,7 @@ import { type Meta, type StoryObj } from '@storybook/react'; import { SelectDropdownPattern } from './SelectDropdown.js'; const meta: Meta = { - title: 'patterns/SelectPattern', + title: 'patterns/SelectDropdownPattern', component: SelectDropdownPattern, decorators: [ (Story, args) => { diff --git a/packages/design/src/Form/components/index.tsx b/packages/design/src/Form/components/index.tsx index 6bf483f2..4df8d393 100644 --- a/packages/design/src/Form/components/index.tsx +++ b/packages/design/src/Form/components/index.tsx @@ -10,6 +10,7 @@ import PackageDownload from './PackageDownload/index.js'; import Page from './Page/index.js'; import PageSet from './PageSet/index.js'; import Paragraph from './Paragraph/index.js'; +import PhoneNumber from './PhoneNumber/index.js'; import RadioGroup from './RadioGroup/index.js'; import RichText from './RichText/index.js'; import Sequence from './Sequence/index.js'; @@ -29,6 +30,7 @@ export const defaultPatternComponents: ComponentForPattern = { page: Page as PatternComponent, 'page-set': PageSet as PatternComponent, paragraph: Paragraph as PatternComponent, + 'phone-number': PhoneNumber as PatternComponent, 'radio-group': RadioGroup as PatternComponent, 'rich-text': RichText as PatternComponent, 'select-dropdown': SelectDropdown as PatternComponent, diff --git a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx index 1a33464f..c836b6ea 100644 --- a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx +++ b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx @@ -13,6 +13,7 @@ import dropDownOptionIcon from './images/dropdownoption-icon.svg'; import emailInputIcon from './images/email-icon.svg'; import longanswerIcon from './images/longanswer-icon.svg'; 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 singleselectIcon from './images/singleselect-icon.svg'; @@ -30,6 +31,7 @@ const icons: Record = { 'email-icon.svg': emailInputIcon, 'longanswer-icon.svg': longanswerIcon, 'page-icon.svg': pageIcon, + 'phone-icon.svg': phoneIcon, 'richtext-icon.svg': richTextIcon, 'shortanswer-icon.svg': shortanswerIcon, 'singleselect-icon.svg': singleselectIcon, @@ -98,6 +100,7 @@ const sidebarPatterns: DropdownPattern[] = [ ['input', defaultFormConfig.patterns['input']], ['package-download', defaultFormConfig.patterns['package-download']], ['paragraph', defaultFormConfig.patterns['paragraph']], + ['phone-number', defaultFormConfig.patterns['phone-number']], ['radio-group', defaultFormConfig.patterns['radio-group']], ['rich-text', defaultFormConfig.patterns['rich-text']], ['select-dropdown', defaultFormConfig.patterns['select-dropdown']], @@ -110,6 +113,7 @@ export const fieldsetPatterns: DropdownPattern[] = [ ['input', defaultFormConfig.patterns['input']], ['package-download', defaultFormConfig.patterns['package-download']], ['paragraph', defaultFormConfig.patterns['paragraph']], + ['phone-number', defaultFormConfig.patterns['phone-number']], ['radio-group', defaultFormConfig.patterns['radio-group']], ['rich-text', defaultFormConfig.patterns['rich-text']], ['select-dropdown', defaultFormConfig.patterns['select-dropdown']], diff --git a/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.tsx b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.tsx index d738e042..876deb5b 100644 --- a/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.tsx +++ b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.tsx @@ -26,7 +26,7 @@ const EmailInputEdit: PatternEditComponent = ({ > ) : (
diff --git a/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.stories.tsx new file mode 100644 index 00000000..a5755a28 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent } from '@storybook/test'; +import { within } from '@testing-library/react'; + +import { type PhoneNumberPattern } from '@atj/forms'; +import { createPatternEditStoryMeta } from './common/story-helper.js'; +import FormEdit from '../index.js'; +import { enLocale as message } from '@atj/common'; + +const pattern: PhoneNumberPattern = { + id: 'phone-number-1', + type: 'phone-number', + data: { + label: message.patterns.phoneNumber.displayName, + required: false, + hint: undefined, + }, +}; + +const storyConfig: Meta = { + title: 'Edit components/PhoneNumberPattern', + ...createPatternEditStoryMeta({ + pattern, + }), +} as Meta; + +export default storyConfig; + +export const Basic: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const updatedLabel = 'Phone Number update'; + const updatedHint = 'Updated hint for Phone Number'; + + await userEvent.click( + canvas.getByText(message.patterns.phoneNumber.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.phoneNumber.fieldLabel + ); + await userEvent.clear(labelInput); + await userEvent.type(labelInput, updatedLabel); + + const hintInput = canvas.getByLabelText( + message.patterns.phoneNumber.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 = 'Phone Number update'; + + await userEvent.click( + canvas.getByText(message.patterns.phoneNumber.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.phoneNumber.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.phoneNumber.hintLabel) + ).toBeNull(); + }, +}; + +export const Error: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click( + canvas.getByText(message.patterns.phoneNumber.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.phoneNumber.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/PhoneNumberPatternEdit.test.tsx b/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.test.tsx new file mode 100644 index 00000000..29ccbe4a --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './PhoneNumberPatternEdit.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.tsx b/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.tsx new file mode 100644 index 00000000..e2e2b6a9 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/PhoneNumberPatternEdit.tsx @@ -0,0 +1,117 @@ +import classnames from 'classnames'; +import React from 'react'; + +import { type PhoneNumberProps } from '@atj/forms'; +import { type PhoneNumberPattern } from '@atj/forms'; + +import PhoneNumber from '../../../Form/components/PhoneNumber/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 PhoneNumberPatternEdit: PatternEditComponent = ({ + focus, + previewProps, +}) => { + return ( + <> + {focus ? ( + } + > + ) : ( +
+ +
+ )} + + ); +}; + +const EditComponent = ({ pattern }: { pattern: PhoneNumberPattern }) => { + const { fieldId, getFieldState, register } = + usePatternEditFormContext(pattern.id); + const label = getFieldState('label'); + const hint = getFieldState('hint'); + + return ( +
+
+ +
+
+ +
+
+ + + + + + +
+
+ ); +}; + +export default PhoneNumberPatternEdit; diff --git a/packages/design/src/FormManager/FormEdit/components/index.ts b/packages/design/src/FormManager/FormEdit/components/index.ts index 98e05395..4138c5c5 100644 --- a/packages/design/src/FormManager/FormEdit/components/index.ts +++ b/packages/design/src/FormManager/FormEdit/components/index.ts @@ -14,6 +14,7 @@ import PageSetEdit from './PageSetEdit.js'; import { PageEdit } from './PageEdit.js'; import ParagraphPatternEdit from './ParagraphPatternEdit.js'; import { PatternPreviewSequence } from './PreviewSequencePattern/index.js'; +import PhoneNumberPatternEdit from './PhoneNumberPatternEdit.js'; import RadioGroupPatternEdit from './RadioGroupPatternEdit.js'; import RichTextPatternEdit from './RichTextPatternEdit/index.js'; import SelectDropdownPatternEdit from './SelectDropdownPatternEdit.js'; @@ -30,6 +31,7 @@ export const defaultPatternEditComponents: EditComponentForPattern = { page: PageEdit as PatternEditComponent, 'page-set': PageSetEdit as PatternEditComponent, paragraph: ParagraphPatternEdit as PatternEditComponent, + 'phone-number': PhoneNumberPatternEdit as PatternEditComponent, 'radio-group': RadioGroupPatternEdit as PatternEditComponent, 'rich-text': RichTextPatternEdit as PatternEditComponent, 'select-dropdown': SelectDropdownPatternEdit as PatternEditComponent, diff --git a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css index 96e5c258..f58c699b 100644 --- a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css +++ b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css @@ -53,7 +53,8 @@ .draggableListItemWrapper .radioFormPattern legend, .draggableListItemWrapper .dateOfBirthPattern legend, -.draggableListItemWrapper .emailInput legend { +.draggableListItemWrapper .phoneNumberPattern 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 new file mode 100644 index 00000000..ef0386c7 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/images/phone-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/forms/src/components.ts b/packages/forms/src/components.ts index b5c65a95..5c9d87f5 100644 --- a/packages/forms/src/components.ts +++ b/packages/forms/src/components.ts @@ -120,6 +120,16 @@ export type EmailInputProps = PatternProps<{ error?: FormError; }>; +export type PhoneNumberProps = PatternProps<{ + type: 'phone-number'; + phoneId: string; + hint?: string; + label: string; + required: boolean; + error?: FormError; + value: string; +}>; + export type SequenceProps = PatternProps<{ type: 'sequence'; }>; diff --git a/packages/forms/src/pattern.ts b/packages/forms/src/pattern.ts index b0a39eec..36c337ca 100644 --- a/packages/forms/src/pattern.ts +++ b/packages/forms/src/pattern.ts @@ -146,7 +146,7 @@ const aggregateValuesByPrefix = ( return aggregatedValues; }; -export const validatePatternAndChildren = ( +export const aggregatePatternSessionValues = ( config: FormConfig, form: Blueprint, patternConfig: PatternConfig, @@ -155,7 +155,7 @@ export const validatePatternAndChildren = ( result: { values: Record; errors: Record; - } = { values: {}, errors: {} } + } ) => { const aggregatedValues = aggregateValuesByPrefix(values); @@ -165,6 +165,7 @@ export const validatePatternAndChildren = ( if (parseResult.success) { result.values[pattern.id] = parseResult.data; + delete result.errors[pattern.id]; } else { result.values[pattern.id] = values[pattern.id]; result.errors[pattern.id] = parseResult.error; @@ -172,7 +173,7 @@ export const validatePatternAndChildren = ( } for (const child of patternConfig.getChildren(pattern, form.patterns)) { const childPatternConfig = getPatternConfig(config, child.type); - validatePatternAndChildren( + aggregatePatternSessionValues( config, form, childPatternConfig, diff --git a/packages/forms/src/patterns/index.ts b/packages/forms/src/patterns/index.ts index 05ae1c7c..9da65332 100644 --- a/packages/forms/src/patterns/index.ts +++ b/packages/forms/src/patterns/index.ts @@ -11,6 +11,7 @@ import { packageDownloadConfig } from './package-download/index.js'; import { pageConfig } from './page/index.js'; import { pageSetConfig } from './page-set/index.js'; import { paragraphConfig } from './paragraph.js'; +import { phoneNumberConfig } from './phone-number/phone-number.js'; import { radioGroupConfig } from './radio-group.js'; import { richTextConfig } from './rich-text.js'; import { selectDropdownConfig } from './select-dropdown/select-dropdown.js'; @@ -32,6 +33,7 @@ export const defaultFormConfig: FormConfig = { page: pageConfig, 'page-set': pageSetConfig, paragraph: paragraphConfig, + 'phone-number': phoneNumberConfig, 'radio-group': radioGroupConfig, 'rich-text': richTextConfig, 'select-dropdown': selectDropdownConfig, @@ -54,6 +56,7 @@ export { type PagePattern } from './page/config.js'; export * from './page-set/index.js'; export { type PageSetPattern } from './page-set/config.js'; 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 './sequence.js'; diff --git a/packages/forms/src/patterns/page-set/submit.ts b/packages/forms/src/patterns/page-set/submit.ts index 9cadb644..3aa22ae4 100644 --- a/packages/forms/src/patterns/page-set/submit.ts +++ b/packages/forms/src/patterns/page-set/submit.ts @@ -3,8 +3,8 @@ import { failure, success } from '@atj/common'; import { getPatternConfig, getPatternSafely, - validatePatternAndChildren, -} from '../../pattern'; + aggregatePatternSessionValues, +} from '../../pattern.js'; import { type FormSession } from '../../session'; import { type SubmitHandler } from '../../submission'; import { type PagePattern } from '../page/config'; @@ -35,12 +35,16 @@ export const submitPage: SubmitHandler = async ( return failure(pagePattern.error); } - const result = validatePatternAndChildren( + const result = aggregatePatternSessionValues( config, opts.session.form, pagePatternConfig, pagePattern.data, - opts.data + opts.data, + { + values: { ...opts.session.data.values }, + errors: { ...opts.session.data.errors }, + } ); // Increment the page number if there are no errors and this isn't the last page. @@ -53,17 +57,7 @@ export const submitPage: SubmitHandler = async ( return success({ session: { ...opts.session, - data: { - ...opts.session.data, - values: { - ...opts.session.data.values, - ...result.values, - }, - errors: { - ...opts.session.data.errors, - ...result.errors, - }, - }, + data: result, route: opts.session.route ? { ...opts.session.route, diff --git a/packages/forms/src/patterns/phone-number/phone-number.test.ts b/packages/forms/src/patterns/phone-number/phone-number.test.ts new file mode 100644 index 00000000..8d5ab3b1 --- /dev/null +++ b/packages/forms/src/patterns/phone-number/phone-number.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { + createPhoneSchema, + phoneNumberConfig, + type PhoneNumberPattern, +} from './phone-number'; + +describe('PhoneNumberPattern tests', () => { + describe('createPhoneSchema', () => { + it('should create schema for required phone input', () => { + const data: PhoneNumberPattern['data'] = { + label: 'Test Phone Input Label', + required: true, + }; + + const schema = createPhoneSchema(data); + const validInput = '+12223334444'; + const invalidInput = '123456abc'; + + expect(schema.safeParse(validInput).success).toBe(true); + const invalidResult = schema.safeParse(invalidInput); + expect(invalidResult.success).toBe(false); + expect(invalidResult.error?.issues[0].message).toBe( + 'Phone number may only contain digits, spaces, parentheses, hyphens, and periods.' + ); + }); + + it('should create schema for optional phone input', () => { + const data: PhoneNumberPattern['data'] = { + label: 'Test Phone Input Label', + required: false, + }; + + const schema = createPhoneSchema(data); + const validInput = '+12223334444'; + const emptyInput = ''; + const invalidInput = '123456abc'; + + 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( + 'Phone number may only contain digits, spaces, parentheses, hyphens, and periods.' + ); + }); + + it('should fail with less than 10 digits', () => { + const data: PhoneNumberPattern['data'] = { + label: 'Test Phone Input Label', + required: true, + }; + + const schema = createPhoneSchema(data); + const shortInput = '123456789'; + + const shortInputResult = schema.safeParse(shortInput); + expect(shortInputResult.success).toBe(false); + expect(shortInputResult.error?.issues[0].message).toBe( + 'Phone number must contain at least 10 digits' + ); + }); + }); + + describe('phoneNumberConfig', () => { + it('should parse user input correctly', () => { + const pattern: PhoneNumberPattern = { + type: 'phone-number', + id: 'test', + data: { + label: 'Test Phone Input Label', + required: true, + }, + }; + + const inputValue = '+12223334444'; + if (!phoneNumberConfig.parseUserInput) { + expect.fail('phoneNumberConfig.parseUserInput is undefined'); + } + const result = phoneNumberConfig.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: PhoneNumberPattern = { + type: 'phone-number', + id: 'test', + data: { + label: 'Test Phone Input Label', + required: true, + }, + }; + + const invalidInput = '123456abc'; + if (!phoneNumberConfig.parseUserInput) { + expect.fail('phoneNumberConfig.parseUserInput is undefined'); + } + const result = phoneNumberConfig.parseUserInput(pattern, invalidInput); + if (!result.success) { + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain( + 'Phone number may only contain digits, spaces, parentheses, hyphens, and periods.' + ); + } else { + expect.fail('Unexpected validation success'); + } + }); + + it('should parse config data correctly', () => { + const obj = { + label: 'Test Phone Input Label', + required: true, + }; + + if (!phoneNumberConfig.parseConfigData) { + expect.fail('phoneNumberConfig.parseConfigData is undefined'); + } + const result = phoneNumberConfig.parseConfigData(obj); + if (result.success) { + expect(result.data.label).toBe('Test Phone 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 (!phoneNumberConfig.parseConfigData) { + expect.fail('phoneNumberConfig.parseConfigData is undefined'); + } + const result = phoneNumberConfig.parseConfigData(obj); + if (!result.success) { + expect(result.error).toBeDefined(); + } else { + expect.fail('Unexpected validation success'); + } + }); + }); +}); diff --git a/packages/forms/src/patterns/phone-number/phone-number.ts b/packages/forms/src/patterns/phone-number/phone-number.ts new file mode 100644 index 00000000..ba842720 --- /dev/null +++ b/packages/forms/src/patterns/phone-number/phone-number.ts @@ -0,0 +1,107 @@ +import * as z from 'zod'; + +import { type PhoneNumberProps } from '../../components.js'; +import { + type Pattern, + type PatternConfig, + validatePattern, +} 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 PhoneNumberPattern = Pattern>; + +export type PhoneNumberPatternOutput = z.infer< + ReturnType +>; + +export const createPhoneSchema = (data: PhoneNumberPattern['data']) => { + let phoneSchema = z + .string() + .regex( + /^[\d+(). -]{1,}$/, + 'Phone number may only contain digits, spaces, parentheses, hyphens, and periods.' + ) + .refine(value => { + const stripped = value.replace(/[^\d]/g, ''); + return stripped.length >= 10; + }, 'Phone number must contain at least 10 digits'); + + if (!data.required) { + // Allow empty strings for optional fields + return phoneSchema.or(z.literal('').optional()).optional(); + } + + return phoneSchema; +}; + +export const phoneNumberConfig: PatternConfig< + PhoneNumberPattern, + PhoneNumberPatternOutput +> = { + displayName: 'Phone Number', + iconPath: 'phone-icon.svg', + initial: { + label: 'Phone Number', + required: true, + hint: '10-digit, U.S. only, for example 999-999-9999', + }, + + parseUserInput: (pattern, inputValue) => { + const result = safeZodParseToFormError( + createPhoneSchema(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]; + + /* + if (options.validate) { + const isValidResult = validatePattern( + phoneNumberConfig, + pattern, + sessionValue + ); + if (!isValidResult.success) { + extraAttributes['error'] = isValidResult.error; + } + } + */ + + return { + props: { + _patternId: pattern.id, + type: 'phone-number', + label: pattern.data.label, + phoneId: pattern.id, + required: pattern.data.required, + hint: pattern.data.hint, + value: sessionValue, + error, + ...extraAttributes, + } as PhoneNumberProps, + children: [], + }; + }, +}; diff --git a/packages/forms/src/patterns/select-dropdown/select-dropdown.test.ts b/packages/forms/src/patterns/select-dropdown/select-dropdown.test.ts index 3d728330..abe653b0 100644 --- a/packages/forms/src/patterns/select-dropdown/select-dropdown.test.ts +++ b/packages/forms/src/patterns/select-dropdown/select-dropdown.test.ts @@ -79,7 +79,7 @@ describe('SelectDropdownPattern tests', () => { if (result.success) { expect(result.data).toBe('value1'); } else { - throw new Error('Unexpected validation failure'); + expect.fail('Unexpected validation failure'); } }); @@ -105,11 +105,11 @@ describe('SelectDropdownPattern tests', () => { console.log('Test parse result (error case):', result); if (!result.success) { expect(result.error).toBeDefined(); - expect(result.error.message).toBe( + expect(result.error?.message).toBe( "Invalid enum value. Expected 'value1' | 'value2', received 'invalid'" ); } else { - throw new Error('Unexpected validation success'); + expect.fail('Unexpected validation success'); } }); @@ -132,7 +132,7 @@ describe('SelectDropdownPattern tests', () => { expect(result.data.required).toBe(true); expect(result.data.options.length).toBe(2); } else { - throw new Error('Unexpected validation failure'); + expect.fail('Unexpected validation failure'); } }); @@ -150,7 +150,7 @@ describe('SelectDropdownPattern tests', () => { if (!result.success) { expect(result.error).toBeDefined(); } else { - throw new Error('Unexpected validation success'); + expect.fail('Unexpected validation success'); } }); });