From 5eb467e3d662dacd74932f9819caa0a719a4d259 Mon Sep 17 00:00:00 2001 From: Daniel Naab Date: Thu, 9 May 2024 16:19:10 -0500 Subject: [PATCH] Edit component focus and error handling improvements (#126) * Remove unused store actions * Clean up form saving to save a max of every 5 seconds. * Prep for handling form manager errors by returning a result from updatePatternById * Replace focussedPattern state with "focus", which is an object that can store multiple bit of edit/focus context. * With updated focus handling, clean up props on pattern edit components. * Destructuring * Create a form errors map type, and wire Zod and react-hook-form usage to it. * Refactor edit form pattern bindings to an interface provided via a hook wrapping react-hook-form * Error presentation for checkbox edit * Maintain edit focus when there are errors. * Improve how patterns and the edit UI share errors; shore up the interface exposed by usePatternEditFormContext. * Remove unused import --- .../FormManager/FormEdit/FormEdit.stories.tsx | 53 +++++---- .../FormManager/FormEdit/PreviewPattern.tsx | 9 +- .../components/CheckboxPatternEdit.tsx | 74 ++++++------ .../FormEdit/components/FieldsetEdit.tsx | 49 ++++---- .../FormEdit/components/FormSummaryEdit.tsx | 31 ++--- .../FormEdit/components/InputPatternEdit.tsx | 99 +++++++++------- .../components/ParagraphPatternEdit.tsx | 76 ++++++------ .../components/RadioGroupPatternEdit.tsx | 111 +++++++++++------- .../components/SubmissionConfirmationEdit.tsx | 44 +++---- .../components/common/PatternEditForm.tsx | 47 ++++++-- .../FormEdit/components/common/hooks.ts | 28 +++++ .../design/src/FormManager/FormEdit/index.tsx | 14 +-- .../design/src/FormManager/FormEdit/store.ts | 68 +++++------ .../design/src/FormManager/FormEdit/types.ts | 8 +- packages/design/src/FormManager/index.tsx | 48 ++++++-- packages/design/src/FormManager/store.tsx | 61 ++++++++-- .../src/context/browser/form-repo.ts | 7 +- .../form-service/src/operations/add-form.ts | 2 +- packages/form-service/src/types.ts | 2 +- packages/forms/src/builder/index.ts | 26 ++-- packages/forms/src/error.ts | 8 ++ packages/forms/src/index.ts | 1 + packages/forms/src/pattern.ts | 30 ++--- packages/forms/src/patterns/checkbox.ts | 10 +- packages/forms/src/patterns/input.ts | 15 ++- packages/forms/src/patterns/paragraph.ts | 4 +- packages/forms/src/patterns/radio-group.ts | 25 +++- packages/forms/src/util/zod.ts | 25 +++- 28 files changed, 588 insertions(+), 387 deletions(-) create mode 100644 packages/design/src/FormManager/FormEdit/components/common/hooks.ts create mode 100644 packages/forms/src/error.ts diff --git a/packages/design/src/FormManager/FormEdit/FormEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/FormEdit.stories.tsx index 103454155..10b31f631 100644 --- a/packages/design/src/FormManager/FormEdit/FormEdit.stories.tsx +++ b/packages/design/src/FormManager/FormEdit/FormEdit.stories.tsx @@ -31,8 +31,8 @@ export default { export const FormEditTest: StoryObj = { play: async ({ canvasElement }) => { - await editFieldLabel(canvasElement, 'Pattern 1', 'First field label'); - await editFieldLabel(canvasElement, 'Pattern 2', 'Second field label'); + await editFieldLabel(canvasElement, 'Pattern 1', 'Pattern 1 (updated)'); + await editFieldLabel(canvasElement, 'Pattern 2', 'Pattern 2 (updated)'); }, }; @@ -51,6 +51,31 @@ export const FormEditAddPattern: StoryObj = { }, }; +const editFieldLabel = async ( + element: HTMLElement, + currentLabel: string, + updatedLabel: string +) => { + const canvas = within(element); + + // Give focus to the field matching `currentLabel` + await userEvent.click(await canvas.findByLabelText(currentLabel)); + + // Enter new text for first field + const input = canvas.getByLabelText('Field label'); + await userEvent.clear(input); + await userEvent.type(input, updatedLabel); + await userEvent.click(canvas.getByLabelText('Add a pattern')); + + waitFor( + async () => { + const newLabel = await canvas.getByLabelText(updatedLabel); + await expect(newLabel).toBeInTheDocument(); + }, + { interval: 5 } + ); +}; + // This test only works in a real browser, not via JSDOM as we use it. /* export const FormEditReorderPattern: StoryObj = { @@ -81,27 +106,3 @@ export const FormEditReorderPattern: StoryObj = { }, }; */ - -const editFieldLabel = async ( - element: HTMLElement, - currentLabel: string, - updatedLabel: string -) => { - const canvas = within(element); - - // Give focus to the field matching `currentLabel` - await userEvent.click(await canvas.findByLabelText(currentLabel)); - - // Enter new text for first field - const input = canvas.getByLabelText('Field label'); - await userEvent.clear(input); - await userEvent.type(input, updatedLabel); - - waitFor( - async () => { - const newLabel = await canvas.getByLabelText(updatedLabel); - await expect(newLabel).toBeInTheDocument(); - }, - { interval: 5 } - ); -}; diff --git a/packages/design/src/FormManager/FormEdit/PreviewPattern.tsx b/packages/design/src/FormManager/FormEdit/PreviewPattern.tsx index 7b109534a..82595a340 100644 --- a/packages/design/src/FormManager/FormEdit/PreviewPattern.tsx +++ b/packages/design/src/FormManager/FormEdit/PreviewPattern.tsx @@ -7,8 +7,13 @@ export const PreviewPattern: PatternComponent = function PreviewPattern(props) { const { context, setFocus } = useFormManagerStore(state => ({ context: state.context, setFocus: state.setFocus, - updatePatternById: state.updatePatternById, })); + const focus = useFormManagerStore(state => { + if (state.focus?.pattern.id === props._patternId) { + return state.focus; + } + }); + const EditComponent = context.editComponents[props.type]; return ( @@ -26,7 +31,7 @@ export const PreviewPattern: PatternComponent = function PreviewPattern(props) { } }} > - + ); }; diff --git a/packages/design/src/FormManager/FormEdit/components/CheckboxPatternEdit.tsx b/packages/design/src/FormManager/FormEdit/components/CheckboxPatternEdit.tsx index 939b0c256..7905b3bc3 100644 --- a/packages/design/src/FormManager/FormEdit/components/CheckboxPatternEdit.tsx +++ b/packages/design/src/FormManager/FormEdit/components/CheckboxPatternEdit.tsx @@ -1,32 +1,30 @@ +import classnames from 'classnames'; import React from 'react'; import { type CheckboxProps, type PatternId } from '@atj/forms'; +import { type CheckboxPattern } from '@atj/forms/src/patterns/checkbox'; import Checkbox from '../../../Form/components/Checkbox'; import { useFormManagerStore } from '../../store'; import { PatternEditComponent } from '../types'; import { PatternEditActions } from './common/PatternEditActions'; -import { - PatternEditForm, - usePatternEditFormContext, -} from './common/PatternEditForm'; +import { PatternEditForm } from './common/PatternEditForm'; +import { usePatternEditFormContext } from './common/hooks'; -const CheckboxPatternEdit: PatternEditComponent = props => { - const isSelected = useFormManagerStore( - state => state.focusedPattern?.id === props.previewProps._patternId - ); +const CheckboxPatternEdit: PatternEditComponent = ({ + focus, + previewProps, +}) => { return ( <> - {isSelected ? ( + {focus ? ( - } + pattern={focus.pattern} + editComponent={} > ) : ( - + )} ); @@ -34,57 +32,53 @@ const CheckboxPatternEdit: PatternEditComponent = props => { const CheckboxEditComponent = ({ patternId }: { patternId: PatternId }) => { const pattern = useFormManagerStore(state => state.form.patterns[patternId]); - const methods = usePatternEditFormContext(); + const { fieldId, getFieldState, register } = + usePatternEditFormContext(patternId); + + const label = getFieldState('label'); return (
-
- - - - - - +
); diff --git a/packages/design/src/FormManager/FormEdit/components/FieldsetEdit.tsx b/packages/design/src/FormManager/FormEdit/components/FieldsetEdit.tsx index 4a651f130..4665fe2eb 100644 --- a/packages/design/src/FormManager/FormEdit/components/FieldsetEdit.tsx +++ b/packages/design/src/FormManager/FormEdit/components/FieldsetEdit.tsx @@ -7,26 +7,23 @@ import { useFormManagerStore } from '../../store'; import { PatternEditComponent } from '../types'; import { PatternEditActions } from './common/PatternEditActions'; -import { - PatternEditForm, - usePatternEditFormContext, -} from './common/PatternEditForm'; +import { PatternEditForm } from './common/PatternEditForm'; +import { usePatternEditFormContext } from './common/hooks'; +import { FieldsetPattern } from '@atj/forms/src/patterns/fieldset'; -const FieldsetEdit: PatternEditComponent = props => { - const isSelected = useFormManagerStore( - state => state.focusedPattern?.id === props.previewProps._patternId - ); +const FieldsetEdit: PatternEditComponent = ({ + focus, + previewProps, +}) => { return ( <> - {isSelected ? ( + {focus ? ( - } + pattern={focus.pattern} + editComponent={} > ) : ( - + )} ); @@ -39,38 +36,42 @@ const FieldsetPreview = (props: FieldsetProps) => { return ( <>
- {pattern && pattern.data.patterns.length === 0 && + {pattern && pattern.data.patterns.length === 0 && (

- Empty sections will not display. + + Empty sections will not display. + - Add question + + Add question + - Remove section + + Remove section +

- } + )}
- ); }; const EditComponent = ({ patternId }: { patternId: PatternId }) => { - const { register } = usePatternEditFormContext(); + const { register } = usePatternEditFormContext(patternId); return ( -
@@ -81,4 +82,4 @@ const EditComponent = ({ patternId }: { patternId: PatternId }) => { ); }; -export default FieldsetEdit; \ No newline at end of file +export default FieldsetEdit; diff --git a/packages/design/src/FormManager/FormEdit/components/FormSummaryEdit.tsx b/packages/design/src/FormManager/FormEdit/components/FormSummaryEdit.tsx index 1f0d2c992..6cf5a887f 100644 --- a/packages/design/src/FormManager/FormEdit/components/FormSummaryEdit.tsx +++ b/packages/design/src/FormManager/FormEdit/components/FormSummaryEdit.tsx @@ -3,36 +3,31 @@ import React from 'react'; import { type FormSummaryProps, type PatternId } from '@atj/forms'; import FormSummary from '../../../Form/components/FormSummary'; -import { useFormManagerStore } from '../../store'; import { PatternEditComponent } from '../types'; -import { - PatternEditForm, - usePatternEditFormContext, -} from './common/PatternEditForm'; +import { PatternEditForm } from './common/PatternEditForm'; +import { usePatternEditFormContext } from './common/hooks'; -const FormSummaryEdit: PatternEditComponent = props => { - const isSelected = useFormManagerStore( - state => state.focusedPattern?.id === props.previewProps._patternId - ); +const FormSummaryEdit: PatternEditComponent = ({ + focus, + previewProps, +}) => { return ( <> - {isSelected ? ( + {focus ? ( - } + pattern={focus.pattern} + editComponent={} > ) : ( - + )} ); }; const EditComponent = ({ patternId }: { patternId: PatternId }) => { - const { register } = usePatternEditFormContext(); + const { register } = usePatternEditFormContext(patternId); return (
@@ -40,7 +35,7 @@ const EditComponent = ({ patternId }: { patternId: PatternId }) => { Title @@ -50,7 +45,7 @@ const EditComponent = ({ patternId }: { patternId: PatternId }) => { Description
diff --git a/packages/design/src/FormManager/FormEdit/components/InputPatternEdit.tsx b/packages/design/src/FormManager/FormEdit/components/InputPatternEdit.tsx index f8b7a11aa..f34f7a6b8 100644 --- a/packages/design/src/FormManager/FormEdit/components/InputPatternEdit.tsx +++ b/packages/design/src/FormManager/FormEdit/components/InputPatternEdit.tsx @@ -1,89 +1,104 @@ +import classnames from 'classnames'; import React from 'react'; import { PatternId, TextInputProps } from '@atj/forms'; +import { InputPattern } from '@atj/forms/src/patterns/input'; import TextInput from '../../../Form/components/TextInput'; import { useFormManagerStore } from '../../store'; import { PatternEditComponent } from '../types'; import { PatternEditActions } from './common/PatternEditActions'; -import { - PatternEditForm, - usePatternEditFormContext, -} from './common/PatternEditForm'; +import { PatternEditForm } from './common/PatternEditForm'; +import { usePatternEditFormContext } from './common/hooks'; -const InputPatternEdit: PatternEditComponent = props => { - const isSelected = useFormManagerStore( - state => state.focusedPattern?.id === props.previewProps._patternId - ); +const InputPatternEdit: PatternEditComponent = ({ + focus, + previewProps, +}) => { return ( <> - {isSelected ? ( + {focus ? ( - } + pattern={focus.pattern} + editComponent={} > ) : ( - + )} ); }; const EditComponent = ({ patternId }: { patternId: PatternId }) => { - const pattern = useFormManagerStore(state => state.form.patterns[patternId]); + const pattern = useFormManagerStore( + state => state.form.patterns[patternId] + ); + const { fieldId, register, getFieldState } = + usePatternEditFormContext(patternId); + + const initial = getFieldState('initial'); + const label = getFieldState('label'); + const maxLength = getFieldState('maxLength'); - const methods = usePatternEditFormContext(); return (
-
-
-
-
- - -
@@ -91,13 +106,13 @@ const EditComponent = ({ patternId }: { patternId: PatternId }) => { style={{ display: 'inline-block' }} className="usa-checkbox__input bg-primary-lighter" type="checkbox" - id={`${pattern.id}.data.required`} - {...methods.register(`${pattern.id}.data.required`)} + id={fieldId('required')} + {...register('required')} /> diff --git a/packages/design/src/FormManager/FormEdit/components/ParagraphPatternEdit.tsx b/packages/design/src/FormManager/FormEdit/components/ParagraphPatternEdit.tsx index 968349a32..9cc62b01c 100644 --- a/packages/design/src/FormManager/FormEdit/components/ParagraphPatternEdit.tsx +++ b/packages/design/src/FormManager/FormEdit/components/ParagraphPatternEdit.tsx @@ -1,61 +1,67 @@ +import classnames from 'classnames'; import React from 'react'; import { PatternId, type ParagraphProps } from '@atj/forms'; +import { type ParagraphPattern } from '@atj/forms/src/patterns/paragraph'; import Paragraph from '../../../Form/components/Paragraph'; -import { useFormManagerStore } from '../../store'; import { PatternEditComponent } from '../types'; import { PatternEditActions } from './common/PatternEditActions'; -import { - PatternEditForm, - usePatternEditFormContext, -} from './common/PatternEditForm'; +import { PatternEditForm } from './common/PatternEditForm'; +import { usePatternEditFormContext } from './common/hooks'; +import { useFormManagerStore } from '../../store'; -const ParagraphPatternEdit: PatternEditComponent = props => { - const isSelected = useFormManagerStore( - state => state.focusedPattern?.id === props.previewProps._patternId - ); +const ParagraphPatternEdit: PatternEditComponent = ({ + focus, + previewProps, +}) => { return ( <> - {isSelected ? ( + {focus ? ( - } + pattern={focus.pattern} + editComponent={} > ) : ( - + )} ); }; const EditComponent = ({ patternId }: { patternId: PatternId }) => { - const { register } = usePatternEditFormContext(); + const pattern = useFormManagerStore( + state => state.form.patterns[patternId] + ); + const { fieldId, getFieldState, register } = + usePatternEditFormContext(patternId); + const text = getFieldState('text'); + return (
-
- -
-
-
diff --git a/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.tsx b/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.tsx index 7be1bcf6d..aa479fdb8 100644 --- a/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.tsx +++ b/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.tsx @@ -1,74 +1,99 @@ +import classnames from 'classnames'; import React, { useState } from 'react'; -import { type RadioGroupProps, type PatternId } from '@atj/forms'; +import { type RadioGroupProps } from '@atj/forms'; +import { type RadioGroupPattern } from '@atj/forms/src/patterns/radio-group'; import RadioGroup from '../../../Form/components/RadioGroup'; -import { useFormManagerStore } from '../../store'; import { PatternEditComponent } from '../types'; import { PatternEditActions } from './common/PatternEditActions'; -import { - PatternEditForm, - usePatternEditFormContext, -} from './common/PatternEditForm'; -import { type RadioGroupPattern } from '@atj/forms/src/patterns/radio-group'; +import { PatternEditForm } from './common/PatternEditForm'; +import { usePatternEditFormContext } from './common/hooks'; -const RadioGroupPatternEdit: PatternEditComponent = props => { - const isSelected = useFormManagerStore( - state => state.focusedPattern?.id === props.previewProps._patternId - ); +const RadioGroupPatternEdit: PatternEditComponent = ({ + focus, + previewProps, +}) => { return ( <> - {isSelected ? ( + {focus ? ( - } + pattern={focus.pattern} + editComponent={} > ) : ( - + )} ); }; -const EditComponent = ({ patternId }: { patternId: PatternId }) => { - const pattern = useFormManagerStore( - state => state.form.patterns[patternId] - ) as RadioGroupPattern; - const methods = usePatternEditFormContext(); +const EditComponent = ({ pattern }: { pattern: RadioGroupPattern }) => { + const { fieldId, getFieldState, register } = + usePatternEditFormContext(pattern.id); const [options, setOptions] = useState(pattern.data.options); + const label = getFieldState('label'); return (
-
- {options.map((option, index) => ( -
- - -
- ))} + {options.map((option, index) => { + const optionId = getFieldState(`options.${index}.id`); + const optionLabel = getFieldState(`options.${index}.label`); + return ( +
+ {optionId.error ? ( + + {optionId.error.message} + + ) : null} + {optionLabel.error ? ( + + {optionLabel.error.message} + + ) : null} +
+ + +
+
+ ); + })}