diff --git a/packages/common/src/locales/en/app.ts b/packages/common/src/locales/en/app.ts index aa7c383e..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,17 +48,31 @@ 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)', }, + emailInput: { + ...defaults, + displayName: 'Email Input label', + 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/EmailInput/EmailInput.stories.tsx b/packages/design/src/Form/components/EmailInput/EmailInput.stories.tsx new file mode 100644 index 00000000..281628e1 --- /dev/null +++ b/packages/design/src/Form/components/EmailInput/EmailInput.stories.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { type Meta, type StoryObj } from '@storybook/react'; + +import { EmailInputPattern } from './EmailInput.js'; + +const meta: Meta = { + title: 'patterns/EmailInputPattern', + component: EmailInputPattern, + decorators: [ + (Story, args) => { + const FormDecorator = () => { + const formMethods = useForm({ + defaultValues: { + email: '', + }, + }); + return ( + + + + ); + }; + return ; + }, + ], + tags: ['autodocs'], +}; + +export default meta; + +export const Default: StoryObj = { + args: { + emailId: 'email', + label: 'Email address', + required: true, + }, +}; + +export const WithoutRequired: StoryObj = { + args: { + emailId: 'email', + label: 'Email address', + required: false, + }, +}; + +export const WithError: StoryObj = { + args: { + emailId: 'email', + label: 'Email address with error', + required: true, + error: { + type: 'custom', + message: 'This field has an error', + }, + }, +}; diff --git a/packages/design/src/Form/components/EmailInput/EmailInput.test.tsx b/packages/design/src/Form/components/EmailInput/EmailInput.test.tsx new file mode 100644 index 00000000..a70bb448 --- /dev/null +++ b/packages/design/src/Form/components/EmailInput/EmailInput.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './EmailInput.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/Form/components/EmailInput/EmailInput.tsx b/packages/design/src/Form/components/EmailInput/EmailInput.tsx new file mode 100644 index 00000000..31588db4 --- /dev/null +++ b/packages/design/src/Form/components/EmailInput/EmailInput.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { useFormContext } from 'react-hook-form'; +import { type EmailInputProps } from '@atj/forms'; +import { type PatternComponent } from '../../index.js'; + +export const EmailInputPattern: PatternComponent = ({ + emailId, + label, + required, + error, +}) => { + const { register } = useFormContext(); + + return ( +
+
+ + +
+ {error && ( + + {error.message} + + )} +
+ ); +}; diff --git a/packages/design/src/Form/components/EmailInput/index.tsx b/packages/design/src/Form/components/EmailInput/index.tsx new file mode 100644 index 00000000..bc02ffc7 --- /dev/null +++ b/packages/design/src/Form/components/EmailInput/index.tsx @@ -0,0 +1,3 @@ +import { EmailInputPattern } from './EmailInput.js'; + +export default EmailInputPattern; 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 87d376fa..4df8d393 100644 --- a/packages/design/src/Form/components/index.tsx +++ b/packages/design/src/Form/components/index.tsx @@ -2,23 +2,27 @@ import { PatternComponent, type ComponentForPattern } from '../index.js'; import Address from './Address/index.js'; import Checkbox from './Checkbox/index.js'; +import DateOfBirth from './DateOfBirth/index.js'; +import EmailInput from './EmailInput/index.js'; import Fieldset from './Fieldset/index.js'; import FormSummary from './FormSummary/index.js'; 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'; import SelectDropdown from './SelectDropdown/index.js'; -import DateOfBirth from './DateOfBirth/index.js'; import SubmissionConfirmation from './SubmissionConfirmation/index.js'; import TextInput from './TextInput/index.js'; export const defaultPatternComponents: ComponentForPattern = { address: Address as PatternComponent, checkbox: Checkbox as PatternComponent, + 'date-of-birth': DateOfBirth as PatternComponent, + 'email-input': EmailInput as PatternComponent, fieldset: Fieldset as PatternComponent, 'form-summary': FormSummary as PatternComponent, input: TextInput as PatternComponent, @@ -26,10 +30,10 @@ 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, - 'date-of-birth': DateOfBirth as PatternComponent, sequence: Sequence 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 f542356d..c836b6ea 100644 --- a/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx +++ b/packages/design/src/FormManager/FormEdit/AddPatternDropdown.tsx @@ -10,12 +10,15 @@ import checkboxIcon from './images/checkbox-icon.svg'; import dateIcon from './images/date-icon.svg'; import dropDownIcon from './images/dropdown-icon.svg'; import dropDownOptionIcon from './images/dropdownoption-icon.svg'; -import richTextIcon from './images/richtext-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'; import templateIcon from './images/template-icon.svg'; + import classNames from 'classnames'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -25,9 +28,11 @@ const icons: Record = { 'date-icon.svg': dateIcon, 'dropdown-icon.svg': dropDownIcon, 'dropdownoption-icon.svg': dropDownOptionIcon, - 'richtext-icon.svg': richTextIcon, + '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, 'template-icon.svg': templateIcon, @@ -87,27 +92,31 @@ export const AddPatternMenu = () => { type DropdownPattern = [string, PatternConfig]; const sidebarPatterns: DropdownPattern[] = [ - ['form-summary', defaultFormConfig.patterns['form-summary']], + ['checkbox', defaultFormConfig.patterns['checkbox']], + ['date-of-birth', defaultFormConfig.patterns['date-of-birth']], + ['email-input', defaultFormConfig.patterns['email-input']], ['fieldset', defaultFormConfig.patterns['fieldset']], + ['form-summary', defaultFormConfig.patterns['form-summary']], ['input', defaultFormConfig.patterns['input']], - ['checkbox', defaultFormConfig.patterns['checkbox']], + ['package-download', defaultFormConfig.patterns['package-download']], ['paragraph', defaultFormConfig.patterns['paragraph']], - ['rich-text', defaultFormConfig.patterns['rich-text']], + ['phone-number', defaultFormConfig.patterns['phone-number']], ['radio-group', defaultFormConfig.patterns['radio-group']], - ['package-download', defaultFormConfig.patterns['package-download']], + ['rich-text', defaultFormConfig.patterns['rich-text']], ['select-dropdown', defaultFormConfig.patterns['select-dropdown']], - ['date-of-birth', defaultFormConfig.patterns['date-of-birth']], ] as const; export const fieldsetPatterns: DropdownPattern[] = [ + ['checkbox', defaultFormConfig.patterns['checkbox']], + ['date-of-birth', defaultFormConfig.patterns['date-of-birth']], + ['email-input', defaultFormConfig.patterns['email-input']], ['form-summary', defaultFormConfig.patterns['form-summary']], ['input', defaultFormConfig.patterns['input']], - ['checkbox', defaultFormConfig.patterns['checkbox']], + ['package-download', defaultFormConfig.patterns['package-download']], ['paragraph', defaultFormConfig.patterns['paragraph']], - ['rich-text', defaultFormConfig.patterns['rich-text']], + ['phone-number', defaultFormConfig.patterns['phone-number']], ['radio-group', defaultFormConfig.patterns['radio-group']], - ['package-download', defaultFormConfig.patterns['package-download']], + ['rich-text', defaultFormConfig.patterns['rich-text']], ['select-dropdown', defaultFormConfig.patterns['select-dropdown']], - ['date-of-birth', defaultFormConfig.patterns['date-of-birth']], ] as const; export const SidebarAddPatternMenuItem = ({ diff --git a/packages/design/src/FormManager/FormEdit/components/DateOfBirthPatternEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/components/DateOfBirthPatternEdit.stories.tsx index c686994b..8f9acc50 100644 --- a/packages/design/src/FormManager/FormEdit/components/DateOfBirthPatternEdit.stories.tsx +++ b/packages/design/src/FormManager/FormEdit/components/DateOfBirthPatternEdit.stories.tsx @@ -97,7 +97,7 @@ export const Error: StoryObj = { await expect( await canvas.findByText( - message.patterns.selectDropdown.errorTextMustContainChar + message.patterns.dateOfBirth.errorTextMustContainChar ) ).toBeInTheDocument(); }, diff --git a/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.stories.tsx new file mode 100644 index 00000000..e9f3f372 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent } from '@storybook/test'; +import { within } from '@testing-library/react'; + +import { type EmailInputPattern } from '@atj/forms'; +import { createPatternEditStoryMeta } from './common/story-helper.js'; +import FormEdit from '../index.js'; +import { enLocale as message } from '@atj/common'; + +const pattern: EmailInputPattern = { + id: 'email-input-1', + type: 'email-input', + data: { + label: message.patterns.emailInput.displayName, + required: false, + }, +}; + +const storyConfig: Meta = { + title: 'Edit components/EmailInputPattern', + ...createPatternEditStoryMeta({ + pattern, + }), +} as Meta; + +export default storyConfig; + +export const Basic: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const updatedLabel = 'Test Email Update Label'; + + await userEvent.click( + canvas.getByText(message.patterns.emailInput.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.emailInput.fieldLabel + ); + await userEvent.clear(labelInput); + await userEvent.type(labelInput, updatedLabel); + + const form = labelInput?.closest('form'); + form?.requestSubmit(); + + await expect(await canvas.findByText(updatedLabel)).toBeInTheDocument(); + }, +}; + +export const Error: StoryObj = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click( + canvas.getByText(message.patterns.emailInput.displayName) + ); + + const labelInput = canvas.getByLabelText( + message.patterns.emailInput.fieldLabel + ); + await userEvent.clear(labelInput); + labelInput.blur(); + + await expect( + await canvas.findByText( + message.patterns.emailInput.errorTextMustContainChar + ) + ).toBeInTheDocument(); + }, +}; diff --git a/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.test.tsx b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.test.tsx new file mode 100644 index 00000000..c4df65a2 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.test.tsx @@ -0,0 +1,7 @@ +/** + * @vitest-environment jsdom + */ +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './EmailInputPatternEdit.stories.js'; + +describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.tsx b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.tsx new file mode 100644 index 00000000..876deb5b --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/EmailInputPatternEdit.tsx @@ -0,0 +1,92 @@ +import classnames from 'classnames'; +import React from 'react'; + +import { type EmailInputProps } from '@atj/forms'; +import { type EmailInputPattern } from '@atj/forms'; + +import EmailInput from '../../../Form/components/EmailInput/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 EmailInputEdit: PatternEditComponent = ({ + focus, + previewProps, +}) => { + return ( + <> + {focus ? ( + } + > + ) : ( +
+ +
+ )} + + ); +}; + +const EditComponent = ({ pattern }: { pattern: EmailInputPattern }) => { + const { fieldId, getFieldState, register } = + usePatternEditFormContext(pattern.id); + const label = getFieldState('label'); + + return ( +
+
+ +
+
+ + + + + + +
+
+ ); +}; + +export default EmailInputEdit; 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 4e4f355a..4138c5c5 100644 --- a/packages/design/src/FormManager/FormEdit/components/index.ts +++ b/packages/design/src/FormManager/FormEdit/components/index.ts @@ -5,14 +5,16 @@ import { import CheckboxPatternEdit from './CheckboxPatternEdit.js'; import DateOfBirthPatternEdit from './DateOfBirthPatternEdit.js'; +import EmailInputPatternEdit from './EmailInputPatternEdit.js'; import FieldsetEdit from './FieldsetEdit.js'; import FormSummaryEdit from './FormSummaryEdit.js'; import InputPatternEdit from './InputPatternEdit.js'; import PackageDownloadPatternEdit from './PackageDownloadPatternEdit.js'; -import { PageEdit } from './PageEdit.js'; 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'; @@ -21,13 +23,15 @@ import SubmissionConfirmationEdit from './SubmissionConfirmationEdit.js'; export const defaultPatternEditComponents: EditComponentForPattern = { checkbox: CheckboxPatternEdit as PatternEditComponent, 'date-of-birth': DateOfBirthPatternEdit as PatternEditComponent, - paragraph: ParagraphPatternEdit as PatternEditComponent, - input: InputPatternEdit as PatternEditComponent, - 'form-summary': FormSummaryEdit as PatternEditComponent, + 'email-input': EmailInputPatternEdit as PatternEditComponent, fieldset: FieldsetEdit as PatternEditComponent, + 'form-summary': FormSummaryEdit as PatternEditComponent, + input: InputPatternEdit as PatternEditComponent, 'package-download': PackageDownloadPatternEdit as PatternEditComponent, 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 36f46de2..f58c699b 100644 --- a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css +++ b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css @@ -51,11 +51,10 @@ padding-left: 1.5rem; } -.draggableListItemWrapper .radioFormPattern legend { - padding-left: 0; -} - -.draggableListItemWrapper .dateOfBirthPattern legend { +.draggableListItemWrapper .radioFormPattern legend, +.draggableListItemWrapper .dateOfBirthPattern legend, +.draggableListItemWrapper .phoneNumberPattern legend, +.draggableListItemWrapper .emailInputPattern legend { padding-left: 0; } diff --git a/packages/design/src/FormManager/FormEdit/images/email-icon.svg b/packages/design/src/FormManager/FormEdit/images/email-icon.svg new file mode 100644 index 00000000..46bc2d51 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/images/email-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file 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 a4f4b5da..5c9d87f5 100644 --- a/packages/forms/src/components.ts +++ b/packages/forms/src/components.ts @@ -112,6 +112,24 @@ export type DateOfBirthProps = PatternProps<{ error?: FormError; }>; +export type EmailInputProps = PatternProps<{ + type: 'email-input'; + emailId: string; + label: string; + required: boolean; + 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 bae0d55e..ddeb495b 100644 --- a/packages/forms/src/pattern.ts +++ b/packages/forms/src/pattern.ts @@ -153,7 +153,7 @@ const aggregateValuesByPrefix = ( return aggregatedValues; }; -export const validatePatternAndChildren = ( +export const aggregatePatternSessionValues = ( config: FormConfig, form: Blueprint, patternConfig: PatternConfig, @@ -162,7 +162,7 @@ export const validatePatternAndChildren = ( result: { values: Record; errors: Record; - } = { values: {}, errors: {} } + } ) => { const aggregatedValues = aggregateValuesByPrefix(values); @@ -172,6 +172,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; @@ -179,7 +180,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/date-of-birth/date-of-birth.test.ts b/packages/forms/src/patterns/date-of-birth/date-of-birth.test.ts index 15d4b34a..77204553 100644 --- a/packages/forms/src/patterns/date-of-birth/date-of-birth.test.ts +++ b/packages/forms/src/patterns/date-of-birth/date-of-birth.test.ts @@ -58,7 +58,7 @@ describe('DateOfBirthPattern tests', () => { if (result.success) { expect(result.data).toEqual(inputValue); } else { - throw new Error('Unexpected validation failure'); + expect.fail('Unexpected validation failure'); } }); @@ -81,7 +81,7 @@ describe('DateOfBirthPattern tests', () => { if (!result.success) { expect(result.error).toBeDefined(); } else { - throw new Error('Unexpected validation success'); + expect.fail('Unexpected validation success'); } }); @@ -101,7 +101,7 @@ describe('DateOfBirthPattern tests', () => { expect(result.data.required).toBe(true); expect(result.data.hint).toBe('Enter your date of birth'); } else { - throw new Error('Unexpected validation failure'); + expect.fail('Unexpected validation failure'); } }); @@ -119,7 +119,7 @@ describe('DateOfBirthPattern tests', () => { if (!result.success) { expect(result.error).toBeDefined(); } else { - throw new Error('Unexpected validation success'); + expect.fail('Unexpected validation success'); } }); }); diff --git a/packages/forms/src/patterns/email-input/email-input.test.ts b/packages/forms/src/patterns/email-input/email-input.test.ts new file mode 100644 index 00000000..4c984d21 --- /dev/null +++ b/packages/forms/src/patterns/email-input/email-input.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { + createEmailSchema, + emailInputConfig, + type EmailInputPattern, +} from './email-input'; + +describe('EmailInputPattern tests', () => { + describe('createEmailSchema', () => { + it('should create schema for required email input', () => { + const data: EmailInputPattern['data'] = { + label: 'Test Email Input Label', + required: true, + }; + + const schema = createEmailSchema(data); + const validInput = { email: 'testEmail@test.com' }; + const invalidInput = { email: 'testEmail.com' }; + + expect(schema.safeParse(validInput).success).toBe(true); + expect(schema.safeParse(invalidInput).success).toBe(false); + }); + + it('should create schema for optional email input', () => { + const data: EmailInputPattern['data'] = { + label: 'Test Email Input Label', + required: false, + }; + + const schema = createEmailSchema(data); + const validInput = { email: 'testEmail@test.com' }; + const emptyInput = {}; + + expect(schema.safeParse(validInput).success).toBe(true); + expect(schema.safeParse(emptyInput).success).toBe(true); + }); + }); + + describe('emailInputConfig', () => { + it('should parse user input correctly', () => { + const pattern: EmailInputPattern = { + type: 'email-input', + id: 'test', + data: { + label: 'Test Email Input Label', + required: true, + }, + }; + + const inputValue = { email: 'testEmail@test.com' }; + if (!emailInputConfig.parseUserInput) { + expect.fail('emailInputConfig.parseUserInput is undefined'); + } + const result = emailInputConfig.parseUserInput(pattern, inputValue); + if (result.success) { + expect(result.data).toEqual(inputValue); + } else { + expect.fail('Unexpected validation failure'); + } + }); + + it('should handle validation error for user input', () => { + const pattern: EmailInputPattern = { + type: 'email-input', + id: 'test', + data: { + label: 'Test Email Input Label', + required: true, + }, + }; + + const inputValue = { email: 'testEmail.co' }; + if (!emailInputConfig.parseUserInput) { + expect.fail('emailInputConfig.parseUserInput is undefined'); + } + const result = emailInputConfig.parseUserInput(pattern, inputValue); + if (!result.success) { + expect(result.error).toBeDefined(); + } else { + expect.fail('Unexpected validation success'); + } + }); + + it('should parse config data correctly', () => { + const obj = { + label: 'Test Email Input Label', + required: true, + }; + + if (!emailInputConfig.parseConfigData) { + expect.fail('emailInputConfig.parseConfigData is undefined'); + } + const result = emailInputConfig.parseConfigData(obj); + if (result.success) { + expect(result.data.label).toBe('Test Email 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 (!emailInputConfig.parseConfigData) { + expect.fail('emailInputConfig.parseConfigData is undefined'); + } + const result = emailInputConfig.parseConfigData(obj); + if (!result.success) { + expect(result.error).toBeDefined(); + } else { + expect.fail('Unexpected validation success'); + } + }); + }); +}); diff --git a/packages/forms/src/patterns/email-input/email-input.ts b/packages/forms/src/patterns/email-input/email-input.ts new file mode 100644 index 00000000..1572706f --- /dev/null +++ b/packages/forms/src/patterns/email-input/email-input.ts @@ -0,0 +1,97 @@ +import * as z from 'zod'; + +import { type EmailInputProps } 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(), +}); + +export type EmailInputPattern = Pattern>; + +export type EmailInputPatternOutput = z.infer< + ReturnType +>; + +export const createEmailSchema = (data: EmailInputPattern['data']) => { + const emailSchema = z + .string() + .regex( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + 'Invalid email format' + ) + .optional(); + + if (!data.required) { + return z + .object({ + email: emailSchema, + }) + .optional(); + } + + return z.object({ + email: emailSchema, + }); +}; + +export const emailInputConfig: PatternConfig< + EmailInputPattern, + EmailInputPatternOutput +> = { + displayName: 'Email Input', + iconPath: 'email-icon.svg', + initial: { + label: 'Email Input', + required: true, + }, + + parseUserInput: (pattern, inputValue) => { + console.log('TEST Parsing user input:', inputValue); + return safeZodParseToFormError(createEmailSchema(pattern.data), inputValue); + }, + + parseConfigData: obj => { + return safeZodParseFormErrors(configSchema, obj); + }, + getChildren() { + return []; + }, + + createPrompt(_, session, pattern, options) { + const extraAttributes: Record = {}; + const sessionValue = getFormSessionValue(session, pattern.id); + if (options.validate) { + const isValidResult = validatePattern( + emailInputConfig, + pattern, + sessionValue + ); + if (!isValidResult.success) { + extraAttributes['error'] = isValidResult.error; + } + } + + return { + props: { + _patternId: pattern.id, + type: 'email-input', + label: pattern.data.label, + emailId: `${pattern.id}.email`, + required: pattern.data.required, + ...extraAttributes, + } as EmailInputProps, + children: [], + }; + }, +}; diff --git a/packages/forms/src/patterns/index.ts b/packages/forms/src/patterns/index.ts index 06719745..af26ff3c 100644 --- a/packages/forms/src/patterns/index.ts +++ b/packages/forms/src/patterns/index.ts @@ -3,6 +3,7 @@ import { type FormConfig } from '../pattern.js'; import { addressConfig } from './address/index.js'; import { checkboxConfig } from './checkbox.js'; import { dateOfBirthConfig } from './date-of-birth/date-of-birth.js'; +import { emailInputConfig } from './email-input/email-input.js'; import { fieldsetConfig } from './fieldset/index.js'; import { formSummaryConfig } from './form-summary.js'; import { inputConfig } from './input/index.js'; @@ -10,6 +11,7 @@ import { packageDownloadConfig } from './package-download/index.js'; import { pageConfig } from './pages/page/index.js'; import { pageSetConfig } from './pages/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'; @@ -23,6 +25,7 @@ export const defaultFormConfig: FormConfig = { address: addressConfig, checkbox: checkboxConfig, 'date-of-birth': dateOfBirthConfig, + 'email-input': emailInputConfig, fieldset: fieldsetConfig, 'form-summary': formSummaryConfig, input: inputConfig, @@ -30,8 +33,9 @@ export const defaultFormConfig: FormConfig = { page: pageConfig, 'page-set': pageSetConfig, paragraph: paragraphConfig, - 'rich-text': richTextConfig, + 'phone-number': phoneNumberConfig, 'radio-group': radioGroupConfig, + 'rich-text': richTextConfig, 'select-dropdown': selectDropdownConfig, sequence: sequenceConfig, }, @@ -40,6 +44,7 @@ export const defaultFormConfig: FormConfig = { export * from './address/index.js'; export * from './checkbox.js'; export * from './date-of-birth/date-of-birth.js'; +export * from './email-input/email-input.js'; export * from './fieldset/index.js'; export { type FieldsetPattern } from './fieldset/config.js'; export * from './form-summary.js'; @@ -51,6 +56,7 @@ export { type PagePattern } from './pages/page/config.js'; export * from './pages/page-set/index.js'; export { type PageSetPattern } from './pages/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/pages/submit.test.ts b/packages/forms/src/patterns/pages/submit.test.ts index 9308f77c..403cd620 100644 --- a/packages/forms/src/patterns/pages/submit.test.ts +++ b/packages/forms/src/patterns/pages/submit.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { type Blueprint, defaultFormConfig } from '../../..'; -import { Input } from '../../input/builder'; -import { Page } from '../page/builder'; -import { createFormSession } from '../../../session'; +import { Input } from '../input/builder'; +import { createFormSession } from '../../session'; -import { PageSet } from './builder'; +import { PageSet } from './page-set/builder'; import { submitPage } from './submit'; +import { defaultFormConfig } from '..'; +import type { Blueprint } from '../../types'; +import { Page } from './page/builder'; describe('Page-set submission', () => { it('stores session data for valid page data', async () => { diff --git a/packages/forms/src/patterns/pages/submit.ts b/packages/forms/src/patterns/pages/submit.ts index 90d083f2..a595ae8b 100644 --- a/packages/forms/src/patterns/pages/submit.ts +++ b/packages/forms/src/patterns/pages/submit.ts @@ -3,12 +3,12 @@ import { failure, success } from '@atj/common'; import { getPatternConfig, getPatternSafely, - validatePatternAndChildren, -} from '../../../pattern'; -import { type FormSession } from '../../../session'; -import { type SubmitHandler } from '../../../submission'; -import { type PagePattern } from '../page/config'; -import { type PageSetPattern } from './config'; + aggregatePatternSessionValues, +} from '../../pattern.js'; +import { type FormSession } from '../../session'; +import { type SubmitHandler } from '../../submission'; +import { type PagePattern } from './page/config'; +import type { PageSetPattern } from './page-set/config.js'; const getPage = (formSession: FormSession) => { const page = formSession.route?.params.page?.toString(); @@ -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 }, + } ); // Evaluate page rules @@ -72,17 +76,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'); } }); });