diff --git a/.gitignore b/.gitignore index 0f41fb70..41dc02f9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ _site .turbo/ .vscode/ +.idea/ coverage/ html/ node_modules/ diff --git a/package.json b/package.json index e2f40dbd..c2d3bc67 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test": "vitest run", "test:ci": "vitest run # --coverage.enabled --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=json-summary --coverage.reporter=json --coverage.reportOnFailure", "test:infra": "turbo run --filter=infra-cdktf test", - "typecheck": "tsc --build", + "typecheck": "tsc --build --noEmit", "prepare": "husky" }, "hooks": { diff --git a/packages/common/src/locales/en/app.ts b/packages/common/src/locales/en/app.ts index bb5f7c68..2e5558a5 100644 --- a/packages/common/src/locales/en/app.ts +++ b/packages/common/src/locales/en/app.ts @@ -99,5 +99,10 @@ export const en = { preferNotToAnswerTextLabel: 'Prefer not to share my gender identity checkbox label', }, + repeater: { + ...defaults, + displayName: 'Repeatable Group', + errorTextMustContainChar: 'String must contain at least 1 character(s)', + }, }, }; diff --git a/packages/design/src/Form/components/DateOfBirth/DateOfBirth.tsx b/packages/design/src/Form/components/DateOfBirth/DateOfBirth.tsx index ff14afad..9309f8dd 100644 --- a/packages/design/src/Form/components/DateOfBirth/DateOfBirth.tsx +++ b/packages/design/src/Form/components/DateOfBirth/DateOfBirth.tsx @@ -35,6 +35,7 @@ export const DateOfBirthPattern: PatternComponent = ({ hint, required, error, + value, }) => { const { register } = useFormContext(); const errorId = `input-error-message-${monthId}`; @@ -42,87 +43,96 @@ export const DateOfBirthPattern: PatternComponent = ({ return (
- - {label} - {required && *} - - {hint && ( - - {hint} - - )} - {error && ( - - )} -
-
- - + - ))} - -
-
- - -
-
- - + {months.map((option, index) => ( + + ))} + +
+
+ + +
+
+ + +
diff --git a/packages/design/src/Form/components/EmailInput/EmailInput.tsx b/packages/design/src/Form/components/EmailInput/EmailInput.tsx index 2f2d9cc8..ee58e2d7 100644 --- a/packages/design/src/Form/components/EmailInput/EmailInput.tsx +++ b/packages/design/src/Form/components/EmailInput/EmailInput.tsx @@ -9,14 +9,20 @@ export const EmailInputPattern: PatternComponent = ({ label, required, error, + value, }) => { const { register } = useFormContext(); const errorId = `input-error-message-${emailId}`; return (
-
-
diff --git a/packages/design/src/Form/components/GenderId/index.tsx b/packages/design/src/Form/components/GenderId/index.tsx index 565b4e35..3047cf7f 100644 --- a/packages/design/src/Form/components/GenderId/index.tsx +++ b/packages/design/src/Form/components/GenderId/index.tsx @@ -10,7 +10,7 @@ const GenderIdPattern: PatternComponent = ({ label, required, error, - value = '', + value, preferNotToAnswerText, preferNotToAnswerChecked: initialPreferNotToAnswerChecked = false, }) => { @@ -22,7 +22,7 @@ const GenderIdPattern: PatternComponent = ({ const errorId = `input-error-message-${genderId}`; const hintId = `hint-${genderId}`; const preferNotToAnswerId = `${genderId}.preferNotToAnswer`; - const inputId = `${genderId}.input`; + const inputId = `${genderId}.gender`; const watchedValue = useWatch({ name: inputId, defaultValue: value }); diff --git a/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx index 2661a3d2..dbe47e91 100644 --- a/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx +++ b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx @@ -4,6 +4,15 @@ import { useFormContext } from 'react-hook-form'; import { type PhoneNumberProps } from '@atj/forms'; import { type PatternComponent } from '../../index.js'; +const formatPhoneNumber = (value: string) => { + const rawValue = value.replace(/[^\d]/g, ''); // Remove non-digit characters + + if (rawValue.length <= 3) return rawValue; + if (rawValue.length <= 6) + return `${rawValue.slice(0, 3)}-${rawValue.slice(3)}`; + return `${rawValue.slice(0, 3)}-${rawValue.slice(3, 6)}-${rawValue.slice(6, 10)}`; +}; + export const PhoneNumberPattern: PatternComponent = ({ phoneId, hint, @@ -12,10 +21,15 @@ export const PhoneNumberPattern: PatternComponent = ({ error, value, }) => { - const { register } = useFormContext(); + const { register, setValue } = useFormContext(); const errorId = `input-error-message-${phoneId}`; const hintId = `hint-${phoneId}`; + const handlePhoneChange = (e: React.ChangeEvent) => { + const formattedPhone = formatPhoneNumber(e.target.value); + setValue(phoneId, formattedPhone, { shouldValidate: true }); + }; + return (
@@ -39,13 +53,14 @@ export const PhoneNumberPattern: PatternComponent = ({
)} = props => { {props.legend} {props.options.map((option, index) => { + const id = option.id; return (
-
diff --git a/packages/design/src/Form/components/Repeater/Repeater.stories.tsx b/packages/design/src/Form/components/Repeater/Repeater.stories.tsx new file mode 100644 index 00000000..305ec9d6 --- /dev/null +++ b/packages/design/src/Form/components/Repeater/Repeater.stories.tsx @@ -0,0 +1,150 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import Repeater from './index.js'; +import { FormProvider, useForm } from 'react-hook-form'; +import { defaultPatternComponents } from '../index.js'; +import type { + DateOfBirthProps, + EmailInputProps, + RepeaterProps, +} from '@atj/forms'; +import { expect, within } from '@storybook/test'; + +const defaultArgs = { + legend: 'Default Heading', + _patternId: 'test-id', + type: 'repeater', +} satisfies RepeaterProps; + +const mockChildComponents = (index: number, withError = false) => [ + { + props: { + _patternId: `3fdb2cb6-5d65-4de1-b773-3fb8636f5d09.${index}.a6c217f0-fe84-44ef-b606-69142ecb3365`, + type: 'date-of-birth', + label: 'Date of Birth', + hint: 'For example: January 19 2000', + dayId: `3fdb2cb6-5d65-4de1-b773-3fb8636f5d09.${index}.a6c217f0-fe84-44ef-b606-69142ecb3365.day`, + monthId: `3fdb2cb6-5d65-4de1-b773-3fb8636f5d09.${index}.a6c217f0-fe84-44ef-b606-69142ecb3365.month`, + yearId: `3fdb2cb6-5d65-4de1-b773-3fb8636f5d09.${index}.a6c217f0-fe84-44ef-b606-69142ecb3365.year`, + required: false, + error: withError + ? { + type: 'custom', + message: 'Invalid date of birth', + } + : undefined, + } as DateOfBirthProps, + children: [], + }, + { + props: { + _patternId: `3fdb2cb6-5d65-4de1-b773-3fb8636f5d09.${index}.7d5df1c1-ca92-488c-81ca-8bb180f952b6`, + type: 'email-input', + label: 'Email Input', + emailId: `3fdb2cb6-5d65-4de1-b773-3fb8636f5d09.${index}.7d5df1c1-ca92-488c-81ca-8bb180f952b6.email`, + required: false, + error: withError + ? { + type: 'custom', + message: 'Invalid email address', + } + : undefined, + } as EmailInputProps, + children: [], + }, +]; + +export default { + title: 'patterns/Repeater', + component: Repeater, + decorators: [ + (Story, args) => { + const FormDecorator = () => { + const formMethods = useForm(); + return ( + +
+ +
+
+ ); + }; + return ; + }, + ], + tags: ['autodocs'], +} satisfies Meta; + +export const Default = { + args: { + ...defaultArgs, + }, +} satisfies StoryObj; + +export const WithContents = { + args: { + ...defaultArgs, + childComponents: mockChildComponents(0), + context: { + components: { + 'date-of-birth': defaultPatternComponents['date-of-birth'], + 'email-input': defaultPatternComponents['email-input'], + }, + config: { + patterns: {}, + }, + uswdsRoot: '/uswds/', + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const legend = await canvas.findByText('Default Heading'); + expect(legend).toBeInTheDocument(); + + const listItems = canvas.getAllByRole('list'); + expect(listItems.length).toBe(1); + + const dobLabel = await canvas.findByText('Date of Birth'); + expect(dobLabel).toBeInTheDocument(); + + const emailLabel = await canvas.findByText('Email Input'); + expect(emailLabel).toBeInTheDocument(); + }, +} satisfies StoryObj; + +export const ErrorState = { + args: { + ...defaultArgs, + childComponents: mockChildComponents(0, true), + error: { + type: 'custom', + message: 'This field has an error', + }, + context: { + components: { + 'date-of-birth': defaultPatternComponents['date-of-birth'], + 'email-input': defaultPatternComponents['email-input'], + }, + config: { + patterns: {}, + }, + uswdsRoot: '/uswds/', + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const legend = await canvas.findByText('Default Heading'); + expect(legend).toBeInTheDocument(); + + const listItems = canvas.getAllByRole('list'); + expect(listItems.length).toBe(1); + + const dobError = await canvas.findByText('Invalid date of birth'); + expect(dobError).toBeInTheDocument(); + + const emailError = await canvas.findByText('Invalid email address'); + expect(emailError).toBeInTheDocument(); + }, +} satisfies StoryObj; diff --git a/packages/design/src/Form/components/Repeater/index.tsx b/packages/design/src/Form/components/Repeater/index.tsx new file mode 100644 index 00000000..3f11494d --- /dev/null +++ b/packages/design/src/Form/components/Repeater/index.tsx @@ -0,0 +1,223 @@ +import React, { Children, useMemo } from 'react'; +import { useFieldArray } from 'react-hook-form'; +import { type RepeaterProps, type PromptComponent } from '@atj/forms'; + +import { type PatternComponent } from '../../index.js'; +import { renderPromptComponents } from '../../form-common.js'; + +interface RepeaterRowProps { + children: React.ReactNode[]; + index: number; + fieldsLength: number; + patternId: string; + onDelete: (index: number) => void; +} + +const RepeaterRow = ({ + children, + index, + fieldsLength, + patternId, + onDelete, +}: RepeaterRowProps) => { + const handleDelete = React.useCallback(() => { + onDelete(index); + }, [onDelete, index]); + + return ( +
  • + {children.map((child, i) => ( + {child} + ))} + {index !== fieldsLength - 1 && fieldsLength > 1 && ( + + )} +
  • + ); +}; + +interface ChildrenGroups { + [key: string]: React.ReactNode[]; +} + +type RepeaterValue = Record[]; + +interface ComponentProps { + props: { + _patternId?: string; + id?: string; + value?: unknown; + error?: unknown; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface ChildElementProps { + component: ComponentProps; +} + +const Repeater: PatternComponent = props => { + const { fields, append } = useFieldArray({ + name: `${props._patternId}.fields`, + }); + + const groupChildrenByIndex = useMemo(() => { + const groups: ChildrenGroups = {}; + + const children = renderPromptComponents( + props.context, + props.childComponents + ); + Children.forEach(children, (child, idx) => { + if (!React.isValidElement(child)) return; + const childProps = (props.childComponents as PromptComponent[])[idx] + .props; + const patternId = childProps._patternId; + + const parts = patternId.split('.'); + const index = parts[1]; + const childId = parts[2]; + + const repeaterValues = (props.value as RepeaterValue) || []; + const rowData = repeaterValues[Number(index)] || {}; + const childValue = rowData[childId]; + + const childError = props.error?.fields?.[patternId]; + + if (!groups[index]) groups[index] = []; + + const enrichedChild = React.cloneElement(child, { + ...child.props, + component: { + ...child.props.component, + props: { + ...childProps, + value: childValue, + error: childError, + }, + }, + }); + groups[index].push(enrichedChild); + }); + + return groups; + }, [props.childComponents, props.value, props.error, props._patternId]); + + const hasFields = Object.keys(groupChildrenByIndex).length > 0; + + React.useEffect(() => { + const groupCount = Object.keys(groupChildrenByIndex).length; + if (groupCount > fields.length) { + const diff = groupCount - fields.length; + Array(diff) + .fill({}) + .forEach(() => { + append({}); + }); + } else if (fields.length === 0) { + append({}); + } + }, [groupChildrenByIndex, fields.length, append]); + + const handleDelete = React.useCallback( + (index: number) => { + const input = document.getElementById( + `${props._patternId}-delete-index` + ) as HTMLInputElement; + if (input) { + input.value = index.toString(); + } + }, + [props._patternId] + ); + + const handleDeleteLast = React.useCallback( + (e: React.MouseEvent) => { + const form = e.currentTarget.form; + if (form) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'deleteIndex'; + input.value = (fields.length - 1).toString(); + form.appendChild(input); + } + }, + [fields.length] + ); + + const renderRows = useMemo( + () => + fields.map((field, index) => ( + + {groupChildrenByIndex[index.toString()] || []} + + )), + [fields, groupChildrenByIndex, props._patternId, handleDelete] + ); + + return ( +
    + + + {props.legend && ( + + {props.legend} + + )} + {props.error && ( + + )} + {hasFields && ( + <> +
      {renderRows}
    +
    + + +
    + + )} +
    + ); +}; + +export default Repeater; diff --git a/packages/design/src/Form/components/SelectDropdown/SelectDropdown.tsx b/packages/design/src/Form/components/SelectDropdown/SelectDropdown.tsx index 9ae3a59a..88f46123 100644 --- a/packages/design/src/Form/components/SelectDropdown/SelectDropdown.tsx +++ b/packages/design/src/Form/components/SelectDropdown/SelectDropdown.tsx @@ -16,32 +16,39 @@ export const SelectDropdownPattern: PatternComponent = ({ return (
    - - {error && ( - - )} - + - ))} - + {options.map((option, index) => ( + + ))} + +
    ); }; diff --git a/packages/design/src/Form/components/SubmissionConfirmation/index.tsx b/packages/design/src/Form/components/SubmissionConfirmation/index.tsx index 7e422663..19a33255 100644 --- a/packages/design/src/Form/components/SubmissionConfirmation/index.tsx +++ b/packages/design/src/Form/components/SubmissionConfirmation/index.tsx @@ -40,30 +40,35 @@ const SubmissionConfirmation: PatternComponent< Submission details - + {/* + EG: turn this off for now. Will need some design perhaps to see what the presentation + should look like. This was a minimal blocker for the repeater field due to the flat data structure + that was there previously. + */} + {/*