From 1e9c209ce00821eab22766132b72103fb64e53e0 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 8 Mar 2025 11:49:14 +0100 Subject: [PATCH 1/7] :white_check_mark: [#69] Add test for desired on-blur validation behaviour --- src/components/FormioForm.stories.tsx | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/components/FormioForm.stories.tsx b/src/components/FormioForm.stories.tsx index 9ceaae0..bb0b530 100644 --- a/src/components/FormioForm.stories.tsx +++ b/src/components/FormioForm.stories.tsx @@ -229,3 +229,48 @@ export const WithErrors: Story = { expect(await canvas.findByText('Nested textfield error')).toBeVisible(); }, }; + +// existing errors of untouched fields should not be cleared when another field is +// touched +export const InitialErrorsRevalidation: Story = { + args: { + components: [ + { + id: 'component1', + type: 'textfield', + key: 'component1', + label: 'Field 1', + } satisfies TextFieldComponentSchema, + { + id: 'component2', + type: 'textfield', + key: 'component2', + label: 'Field 2', + validate: { + pattern: '[0-9]+', + }, + } satisfies TextFieldComponentSchema, + ], + errors: { + component1: 'External error for field 1', + component2: 'External error for field 2', + }, + }, + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + await step('Initial errors', async () => { + expect(await canvas.findByText('External error for field 1')).toBeVisible(); + expect(await canvas.findByText('External error for field 2')).toBeVisible(); + }); + + await step('Edit field 2', async () => { + const input = canvas.getByLabelText('Field 2'); + await userEvent.type(input, 'invalid input'); + input.blur(); + + expect(await canvas.findByText('Invalid')).toBeVisible(); + expect(canvas.queryByText('External error for field 2')).not.toBeInTheDocument(); + }); + }, +}; From 713bf2af1a5bbddced095cd659d9593ff64dd7ad Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 8 Mar 2025 13:11:07 +0100 Subject: [PATCH 2/7] :technologist: Enable decorator for render settings --- .storybook/decorators.tsx | 10 +++++++ src/components/FormioForm.stories.tsx | 6 +++- .../forms/RadioField/RadioField.stories.ts | 30 +++++++++---------- src/components/forms/TextField/TextField.mdx | 2 +- .../forms/TextField/TextField.stories.ts | 30 +++++++++---------- src/components/forms/TextField/TextField.tsx | 10 ++++++- tsconfig.json | 2 +- 7 files changed, 56 insertions(+), 34 deletions(-) diff --git a/.storybook/decorators.tsx b/.storybook/decorators.tsx index f40c145..b553191 100644 --- a/.storybook/decorators.tsx +++ b/.storybook/decorators.tsx @@ -3,6 +3,8 @@ import {fn} from '@storybook/test'; import {Form, Formik} from 'formik'; import {CSSProperties} from 'react'; +import RendererSettingsProvider from '../src/components/RendererSettingsProvider'; + /** * Wrap stories so that they are inside a container with the class name "utrecht-document", used * to wrap some 'page-global' styling. @@ -50,3 +52,11 @@ export const withFormik: Decorator = (Story, context) => { ); }; + +export const withRenderSettingsProvider: Decorator = (Story, {parameters}) => ( + + + +); diff --git a/src/components/FormioForm.stories.tsx b/src/components/FormioForm.stories.tsx index bb0b530..7f67a85 100644 --- a/src/components/FormioForm.stories.tsx +++ b/src/components/FormioForm.stories.tsx @@ -264,13 +264,17 @@ export const InitialErrorsRevalidation: Story = { expect(await canvas.findByText('External error for field 2')).toBeVisible(); }); + // NOTE - this doesn't work properly if the browser window is not focused. Chrome + // and Firefox appear not to dispatch the focus/blur events if another window on your + // device has focus. await step('Edit field 2', async () => { const input = canvas.getByLabelText('Field 2'); await userEvent.type(input, 'invalid input'); input.blur(); - expect(await canvas.findByText('Invalid')).toBeVisible(); expect(canvas.queryByText('External error for field 2')).not.toBeInTheDocument(); + // may not be removed + expect(canvas.getByText('External error for field 1')).toBeVisible(); }); }, }; diff --git a/src/components/forms/RadioField/RadioField.stories.ts b/src/components/forms/RadioField/RadioField.stories.ts index a3fc9ad..1db7300 100644 --- a/src/components/forms/RadioField/RadioField.stories.ts +++ b/src/components/forms/RadioField/RadioField.stories.ts @@ -1,7 +1,7 @@ import {Meta, StoryObj} from '@storybook/react'; import {expect, userEvent, within} from '@storybook/test'; -import {withFormik} from '@/sb-decorators'; +import {withFormik, withRenderSettingsProvider} from '@/sb-decorators'; import RadioField from './RadioField'; @@ -74,17 +74,17 @@ export const ValidationError: Story = { }, }; -// export const NoAsterisks: Story = { -// name: 'No asterisk for required', -// decorators: [ConfigDecorator], -// parameters: { -// config: { -// requiredFieldsWithAsterisk: false, -// }, -// }, -// args: { -// name: 'test', -// label: 'Default required', -// isRequired: true, -// }, -// }; +export const NoAsterisks: Story = { + name: 'No asterisk for required', + decorators: [withRenderSettingsProvider], + parameters: { + renderSettings: { + requiredFieldsWithAsterisk: false, + }, + }, + args: { + name: 'test', + label: 'Default required', + isRequired: true, + }, +}; diff --git a/src/components/forms/TextField/TextField.mdx b/src/components/forms/TextField/TextField.mdx index b485756..73fa719 100644 --- a/src/components/forms/TextField/TextField.mdx +++ b/src/components/forms/TextField/TextField.mdx @@ -29,4 +29,4 @@ Validation errors are tracked in the Formik state and displayed if any are prese The backend can be configured to treat fields as required by default and instead mark optional fields explicitly, through the `ConfigContext`. -{/* */} + diff --git a/src/components/forms/TextField/TextField.stories.ts b/src/components/forms/TextField/TextField.stories.ts index 9372f99..838ba9d 100644 --- a/src/components/forms/TextField/TextField.stories.ts +++ b/src/components/forms/TextField/TextField.stories.ts @@ -1,7 +1,7 @@ import {Meta, StoryObj} from '@storybook/react'; import {expect, userEvent, within} from '@storybook/test'; -import {withFormik} from '@/sb-decorators'; +import {withFormik, withRenderSettingsProvider} from '@/sb-decorators'; import TextField from './TextField'; @@ -68,17 +68,17 @@ export const ValidationError: Story = { }, }; -// export const NoAsterisks = { -// name: 'No asterisk for required', -// decorators: [ConfigDecorator], -// parameters: { -// config: { -// requiredFieldsWithAsterisk: false, -// }, -// }, -// args: { -// name: 'test', -// label: 'Default required', -// isRequired: true, -// }, -// }; +export const NoAsterisks = { + name: 'No asterisk for required', + decorators: [withRenderSettingsProvider], + parameters: { + renderSettings: { + requiredFieldsWithAsterisk: false, + }, + }, + args: { + name: 'test', + label: 'Default required', + isRequired: true, + }, +}; diff --git a/src/components/forms/TextField/TextField.tsx b/src/components/forms/TextField/TextField.tsx index 063c5e8..b2d2962 100644 --- a/src/components/forms/TextField/TextField.tsx +++ b/src/components/forms/TextField/TextField.tsx @@ -1,6 +1,6 @@ import {FormField, Paragraph, Textbox} from '@utrecht/component-library-react'; import type {TextboxProps} from '@utrecht/component-library-react/dist/Textbox'; -import {useField} from 'formik'; +import {useField, useFormikContext} from 'formik'; import {useId} from 'react'; import HelpText from '@/components/forms/HelpText'; @@ -56,6 +56,7 @@ const TextField: React.FC = ({ placeholder, ...extraProps }) => { + const {validateField} = useFormikContext(); const [props, {error = '', touched}] = useField({name, type: 'text'}); const id = useId(); @@ -70,6 +71,13 @@ const TextField: React.FC = ({ { + console.log('onBlur'); + props.onBlur(e); + console.log('validating'); + await validateField(name); + console.log('validation done'); + }} className="utrecht-textbox--openforms" id={id} disabled={isDisabled} diff --git a/tsconfig.json b/tsconfig.json index cedbf76..fb0cf4a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,6 @@ }, "skipLibCheck": true }, - "include": ["src"], + "include": ["src/**/*", ".storybook/**/*"], "exclude": ["node_modules", "dist"] } From 8d87e6e3027f1ced981a7a2365dd33fb9d654c7e Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 8 Mar 2025 13:27:25 +0100 Subject: [PATCH 3/7] :hammer: Add tooling and test for low-level component validation behaviour Added story + test to document onBlur behaviour of TextField component. --- .storybook/decorators.tsx | 5 +++ .../forms/TextField/TextField.stories.ts | 33 ++++++++++++++++++- src/components/forms/TextField/TextField.tsx | 3 -- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/.storybook/decorators.tsx b/.storybook/decorators.tsx index b553191..25b0c1d 100644 --- a/.storybook/decorators.tsx +++ b/.storybook/decorators.tsx @@ -2,6 +2,7 @@ import type {Decorator} from '@storybook/react'; import {fn} from '@storybook/test'; import {Form, Formik} from 'formik'; import {CSSProperties} from 'react'; +import {toFormikValidationSchema} from 'zod-formik-adapter'; import RendererSettingsProvider from '../src/components/RendererSettingsProvider'; @@ -34,6 +35,7 @@ export const withFormik: Decorator = (Story, context) => { const initialTouched = context.parameters?.formik?.initialTouched || {}; const wrapForm = context.parameters?.formik?.wrapForm ?? true; const onSubmit = context.parameters?.formik?.onSubmit || fn(); + const zodSchema = context.parameters?.formik?.zodSchema; return ( { initialTouched={initialTouched} enableReinitialize onSubmit={async values => onSubmit(values)} + validateOnBlur={false} + validateOnChange={false} + validationSchema={zodSchema ? toFormikValidationSchema(zodSchema) : undefined} > {wrapForm ? (
diff --git a/src/components/forms/TextField/TextField.stories.ts b/src/components/forms/TextField/TextField.stories.ts index 838ba9d..519696e 100644 --- a/src/components/forms/TextField/TextField.stories.ts +++ b/src/components/forms/TextField/TextField.stories.ts @@ -1,5 +1,6 @@ import {Meta, StoryObj} from '@storybook/react'; import {expect, userEvent, within} from '@storybook/test'; +import {z} from 'zod'; import {withFormik, withRenderSettingsProvider} from '@/sb-decorators'; @@ -68,7 +69,7 @@ export const ValidationError: Story = { }, }; -export const NoAsterisks = { +export const NoAsterisks: Story = { name: 'No asterisk for required', decorators: [withRenderSettingsProvider], parameters: { @@ -82,3 +83,33 @@ export const NoAsterisks = { isRequired: true, }, }; + +export const ValidateOnBlur: Story = { + args: { + name: 'validateOnBlur', + label: 'Validate on blur', + }, + parameters: { + formik: { + initialValues: { + validateOnBlur: '', + }, + zodSchema: z.object({ + validateOnBlur: z.any().refine(() => false, {message: 'Always invalid'}), + }), + }, + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + const input = await canvas.findByLabelText('Validate on blur'); + expect(input).not.toHaveAttribute('aria-invalid'); + + await userEvent.type(input, 'foo'); + expect(input).toHaveFocus(); + expect(input).not.toHaveAttribute('aria-invalid'); + + input.blur(); + expect(await canvas.findByText('Always invalid')).toBeVisible(); + expect(input).toHaveAttribute('aria-invalid', 'true'); + }, +}; diff --git a/src/components/forms/TextField/TextField.tsx b/src/components/forms/TextField/TextField.tsx index b2d2962..9431c6d 100644 --- a/src/components/forms/TextField/TextField.tsx +++ b/src/components/forms/TextField/TextField.tsx @@ -72,11 +72,8 @@ const TextField: React.FC = ({ { - console.log('onBlur'); props.onBlur(e); - console.log('validating'); await validateField(name); - console.log('validation done'); }} className="utrecht-textbox--openforms" id={id} From 1c61ef568eeebe64b4c459fdd8bf3e0028a757d6 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 8 Mar 2025 13:34:08 +0100 Subject: [PATCH 4/7] :safety_vest: [#69] Ensure that radio field validates onBlur --- .../forms/RadioField/RadioField.stories.ts | 33 +++++++++++++++++++ .../forms/RadioField/RadioOption.tsx | 7 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/components/forms/RadioField/RadioField.stories.ts b/src/components/forms/RadioField/RadioField.stories.ts index 1db7300..46f3b7c 100644 --- a/src/components/forms/RadioField/RadioField.stories.ts +++ b/src/components/forms/RadioField/RadioField.stories.ts @@ -1,5 +1,6 @@ import {Meta, StoryObj} from '@storybook/react'; import {expect, userEvent, within} from '@storybook/test'; +import {z} from 'zod'; import {withFormik, withRenderSettingsProvider} from '@/sb-decorators'; @@ -88,3 +89,35 @@ export const NoAsterisks: Story = { isRequired: true, }, }; + +export const ValidateOnBlur: Story = { + args: { + name: 'validateOnBlur', + label: 'Validate on blur', + }, + parameters: { + formik: { + initialValues: { + validateOnBlur: '', + }, + zodSchema: z.object({ + validateOnBlur: z.any().refine(() => false, {message: 'Always invalid'}), + }), + }, + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + const radioGroup = canvas.getByRole('radiogroup'); + + const input = await canvas.findByLabelText('Ziggy'); + expect(radioGroup).not.toHaveAttribute('aria-invalid'); + + await userEvent.click(input); + expect(input).toHaveFocus(); + expect(radioGroup).not.toHaveAttribute('aria-invalid'); + + input.blur(); + expect(await canvas.findByText('Always invalid')).toBeVisible(); + expect(radioGroup).toHaveAttribute('aria-invalid', 'true'); + }, +}; diff --git a/src/components/forms/RadioField/RadioOption.tsx b/src/components/forms/RadioField/RadioOption.tsx index f78d31e..a43b2cb 100644 --- a/src/components/forms/RadioField/RadioOption.tsx +++ b/src/components/forms/RadioField/RadioOption.tsx @@ -1,5 +1,5 @@ import {FormField, FormLabel, Paragraph, RadioButton} from '@utrecht/component-library-react'; -import {useField} from 'formik'; +import {useField, useFormikContext} from 'formik'; export interface RadioOptionProps { name: string; @@ -20,6 +20,7 @@ const RadioOption: React.FC = ({ errorMessageId, isDisabled, }) => { + const {validateField} = useFormikContext(); const [props] = useField({name, value, type: 'radio'}); return ( @@ -29,6 +30,10 @@ const RadioOption: React.FC = ({ id={`${id}-opt-${index}`} aria-describedby={errorMessageId} {...props} + onBlur={async e => { + props.onBlur(e); + await validateField(name); + }} value={value} /> From 8b434426e9d30bd4f45a34a685cd2338a2ea77e5 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sat, 8 Mar 2025 20:13:39 +0100 Subject: [PATCH 5/7] :green_heart: [#69] Fix type checking in storybook config files --- .github/workflows/ci.yml | 1 + tsconfig.json | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a5f73a..362bcfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,7 @@ jobs: - name: Build library run: | + npm run compilemessages npm run build:typecheck npm run build diff --git a/tsconfig.json b/tsconfig.json index fb0cf4a..98d79d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "strictBindCallApply": true, "strictNullChecks": true, "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, "noErrorTruncation": true, "paths": { "@/*": ["./*"], From 96ba257b29ae7c0cca019aa3541cbf938d45deab Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 10 Mar 2025 08:35:29 +0100 Subject: [PATCH 6/7] :white_check_mark: [#69] Add test for desired onBur validation behaviour of date field --- .../DateField-InputGroup.stories.tsx | 44 +++++++++++++++++++ .../forms/InputGroup/InputGroup.tsx | 1 + 2 files changed, 45 insertions(+) diff --git a/src/components/forms/DateField/DateField-InputGroup.stories.tsx b/src/components/forms/DateField/DateField-InputGroup.stories.tsx index 9dc9753..51c7196 100644 --- a/src/components/forms/DateField/DateField-InputGroup.stories.tsx +++ b/src/components/forms/DateField/DateField-InputGroup.stories.tsx @@ -2,6 +2,7 @@ import {Meta, StoryObj} from '@storybook/react'; import {expect, fn, userEvent, within} from '@storybook/test'; import {PrimaryActionButton, SecondaryActionButton} from '@utrecht/component-library-react'; import {useFormikContext} from 'formik'; +import {z} from 'zod'; import {withFormik} from '@/sb-decorators'; @@ -228,3 +229,46 @@ export const LeavesInvalidInputAlone: Story = { }); }, }; + +export const ValidateOnBlur: Story = { + args: { + name: 'validateOnBlur', + label: 'Validate on blur', + }, + parameters: { + formik: { + initialValues: { + validateOnBlur: '', + }, + zodSchema: z.object({ + validateOnBlur: z.any().refine(() => false, {message: 'Always invalid'}), + }), + }, + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + const container = (await canvas.findByTestId('inputgroup-container')).querySelector( + 'fieldset' + )!; + expect(container).not.toHaveAttribute('aria-invalid'); + + const dayInput = canvas.getByLabelText('Day'); + await userEvent.type(dayInput, '9'); + expect(canvas.queryByText('Always invalid')).not.toBeInTheDocument(); + expect(container).not.toHaveAttribute('aria-invalid'); + + const monthInput = canvas.getByLabelText('Month'); + await userEvent.type(monthInput, '13'); + expect(canvas.queryByText('Always invalid')).not.toBeInTheDocument(); + expect(container).not.toHaveAttribute('aria-invalid'); + + const yearInput = canvas.getByLabelText('Year'); + await userEvent.type(yearInput, '55555'); + expect(canvas.queryByText('Always invalid')).not.toBeInTheDocument(); + expect(container).not.toHaveAttribute('aria-invalid'); + + yearInput.blur(); + expect(await canvas.findByText('Always invalid')).toBeVisible(); + expect(container).toHaveAttribute('aria-invalid', 'true'); + }, +}; diff --git a/src/components/forms/InputGroup/InputGroup.tsx b/src/components/forms/InputGroup/InputGroup.tsx index 2273ed7..5c26d1b 100644 --- a/src/components/forms/InputGroup/InputGroup.tsx +++ b/src/components/forms/InputGroup/InputGroup.tsx @@ -24,6 +24,7 @@ const InputGroup: React.FC = ({ invalid={isInvalid} className="utrecht-form-fieldset--openforms" aria-describedby={ariaDescribedBy} + data-testid="inputgroup-container" > From 5f13b5a2000095a2d0043344c509458f873dccae Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Mon, 10 Mar 2025 08:44:25 +0100 Subject: [PATCH 7/7] :safety_vest: [#69] Validate date field on blur ... but only when all three parts of the date have been entered, as anything less is by definition invalid. This does imply that the field does not clear the error message whenever one of the inputs is cleared, as we can't tell the difference between correcting an input error vs someone entering an incomplete date. --- src/components/forms/DateField/DateInputGroup.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/forms/DateField/DateInputGroup.tsx b/src/components/forms/DateField/DateInputGroup.tsx index da6492f..74c4235 100644 --- a/src/components/forms/DateField/DateInputGroup.tsx +++ b/src/components/forms/DateField/DateInputGroup.tsx @@ -1,4 +1,4 @@ -import {useField} from 'formik'; +import {useField, useFormikContext} from 'formik'; import {useEffect, useState} from 'react'; import {InputGroup} from '@/components/forms/InputGroup'; @@ -67,6 +67,7 @@ const DateInputGroup: React.FC = ({ autoComplete, 'aria-describedby': ariaDescribedBy, }) => { + const {validateField} = useFormikContext(); // value is an ISO-8601 string _if_ a valid date was provided at some point. const [{value}, {error, touched}, {setTouched, setValue}] = useField(name); @@ -131,7 +132,12 @@ const DateInputGroup: React.FC = ({ day={day} isDisabled={isDisabled} onChange={onPartChange} - onBlur={() => setTouched(true)} + onBlur={async () => { + setTouched(true); + if (year && month && day) { + await validateField(name); + } + }} autoComplete={autoComplete} />