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/.storybook/decorators.tsx b/.storybook/decorators.tsx
index f40c145..25b0c1d 100644
--- a/.storybook/decorators.tsx
+++ b/.storybook/decorators.tsx
@@ -2,6 +2,9 @@ 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';
/**
* Wrap stories so that they are inside a container with the class name "utrecht-document", used
@@ -32,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 ? (
);
};
+
+export const withRenderSettingsProvider: Decorator = (Story, {parameters}) => (
+
+
+
+);
diff --git a/src/components/FormioForm.stories.tsx b/src/components/FormioForm.stories.tsx
index 9ceaae0..7f67a85 100644
--- a/src/components/FormioForm.stories.tsx
+++ b/src/components/FormioForm.stories.tsx
@@ -229,3 +229,52 @@ 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();
+ });
+
+ // 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/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/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}
/>
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"
>
diff --git a/src/components/forms/RadioField/RadioField.stories.ts b/src/components/forms/RadioField/RadioField.stories.ts
index a3fc9ad..46f3b7c 100644
--- a/src/components/forms/RadioField/RadioField.stories.ts
+++ b/src/components/forms/RadioField/RadioField.stories.ts
@@ -1,7 +1,8 @@
import {Meta, StoryObj} from '@storybook/react';
import {expect, userEvent, within} from '@storybook/test';
+import {z} from 'zod';
-import {withFormik} from '@/sb-decorators';
+import {withFormik, withRenderSettingsProvider} from '@/sb-decorators';
import RadioField from './RadioField';
@@ -74,17 +75,49 @@ 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,
+ },
+};
+
+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}
/>
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..519696e 100644
--- a/src/components/forms/TextField/TextField.stories.ts
+++ b/src/components/forms/TextField/TextField.stories.ts
@@ -1,7 +1,8 @@
import {Meta, StoryObj} from '@storybook/react';
import {expect, userEvent, within} from '@storybook/test';
+import {z} from 'zod';
-import {withFormik} from '@/sb-decorators';
+import {withFormik, withRenderSettingsProvider} from '@/sb-decorators';
import TextField from './TextField';
@@ -68,17 +69,47 @@ 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: Story = {
+ name: 'No asterisk for required',
+ decorators: [withRenderSettingsProvider],
+ parameters: {
+ renderSettings: {
+ requiredFieldsWithAsterisk: false,
+ },
+ },
+ args: {
+ name: 'test',
+ label: 'Default required',
+ 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 063c5e8..9431c6d 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,10 @@ const TextField: React.FC = ({
{
+ props.onBlur(e);
+ await validateField(name);
+ }}
className="utrecht-textbox--openforms"
id={id}
disabled={isDisabled}
diff --git a/tsconfig.json b/tsconfig.json
index cedbf76..98d79d3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -16,6 +16,7 @@
"strictBindCallApply": true,
"strictNullChecks": true,
"allowSyntheticDefaultImports": true,
+ "resolveJsonModule": true,
"noErrorTruncation": true,
"paths": {
"@/*": ["./*"],
@@ -23,6 +24,6 @@
},
"skipLibCheck": true
},
- "include": ["src"],
+ "include": ["src/**/*", ".storybook/**/*"],
"exclude": ["node_modules", "dist"]
}