diff --git a/i18n/messages/en.json b/i18n/messages/en.json index 2802a651..772ee91c 100644 --- a/i18n/messages/en.json +++ b/i18n/messages/en.json @@ -49,6 +49,16 @@ "description": "Tooltip for 'showInPDF' builder field", "originalDefault": "Whether to show this value in the confirmation PDF" }, + "0D+m56": { + "defaultMessage": "Regular expression for city", + "description": "Placeholder for 'validate.pattern' builder field", + "originalDefault": "Regular expression for city" + }, + "0FD2pY": { + "defaultMessage": "Validation for the postcode field", + "description": "Tooltip postcode field validation panel", + "originalDefault": "Validation for the postcode field" + }, "0OP7ho": { "defaultMessage": "Receives confirmation email", "description": "Label for 'confirmationRecipient' builder field", @@ -154,6 +164,11 @@ "description": "Label for 'delta.days' in relative delta date constraint validation", "originalDefault": "Days" }, + "4DrI94": { + "defaultMessage": "Regular expression for postcode", + "description": "Label for 'validate.pattern' builder field", + "originalDefault": "Regular expression for postcode" + }, "4HBnrF": { "defaultMessage": "Drag or select files to upload.", "description": "file component: drag/select files to upload text", @@ -774,6 +789,11 @@ "description": "Tooltip for 'prefill.identifierRole' builder field", "originalDefault": "In case that multiple identifiers are returned (in the case of eHerkenning bewindvoering and DigiD Machtigen), should the prefill data related to the main identifier be used, or that related to the authorised person?" }, + "WxwqZJ": { + "defaultMessage": "The regular expression pattern test that the postcode field value must pass before the form can be submitted.", + "description": "Tooltip for 'validate.pattern' builder field", + "originalDefault": "The regular expression pattern test that the postcode field value must pass before the form can be submitted." + }, "XMV3t2": { "defaultMessage": "Info", "description": "Label for content component CSS class 'info' option", @@ -929,6 +949,11 @@ "description": "'Remove item' screenreader button text", "originalDefault": "Remove item" }, + "dD9O3Q": { + "defaultMessage": "Regular expression for postcode", + "description": "Placeholder for 'validate.pattern' builder field", + "originalDefault": "Regular expression for postcode" + }, "dJNdhr": { "defaultMessage": "Cancel", "description": "Cancel component configuration button", @@ -1029,6 +1054,11 @@ "description": "Description for the 'openForms.itemsExpression' builder field", "originalDefault": "A JSON logic expression returning a variable (of array type) whose items should be used as the options for this component." }, + "fhVFUY": { + "defaultMessage": "City", + "description": "Title of city field validation panel", + "originalDefault": "City" + }, "gCQtSJ": { "defaultMessage": "Set the text of the 'Remove row' button.", "description": "Tooltip for 'removeRow' builder field", @@ -1049,6 +1079,11 @@ "description": "Fieldset preview content description", "originalDefault": "Fieldset content" }, + "hEVgKd": { + "defaultMessage": "Error message for \"{key}\"", + "description": "Accessible label for error message input field", + "originalDefault": "Error message for \"{key}\"" + }, "hJtTwo": { "defaultMessage": "Form", "description": "Component 'Rich' preview mode", @@ -1189,6 +1224,11 @@ "description": "Tooltip for option/choice description", "originalDefault": "Optionally provide additional information to explain the meaning of the option." }, + "mck25o": { + "defaultMessage": "Validation for the city field", + "description": "Tooltip city field validation panel", + "originalDefault": "Validation for the city field" + }, "mf9eF+": { "defaultMessage": "House number", "description": "Label for address housenumber", @@ -1254,6 +1294,11 @@ "description": "Label for addressNL city read only result", "originalDefault": "City" }, + "osl4X2": { + "defaultMessage": "The regular expression pattern test that the city field value must pass before the form can be submitted.", + "description": "Tooltip for 'validate.pattern' builder field", + "originalDefault": "The regular expression pattern test that the city field value must pass before the form can be submitted." + }, "p7g2h+": { "defaultMessage": "Add another", "description": "Add another option button label", @@ -1299,6 +1344,11 @@ "description": "Component edit form tab title for 'Registration' tab", "originalDefault": "Registration" }, + "rW1edF": { + "defaultMessage": "Postcode", + "description": "Title of postcode field validation panel", + "originalDefault": "Postcode" + }, "rkJLBr": { "defaultMessage": "'Add another' text", "description": "Label for 'addAnother' builder field", @@ -1324,6 +1374,11 @@ "description": "Edit grid preview content description", "originalDefault": "{groupLabel} 3" }, + "swKpQE": { + "defaultMessage": "Regular expression for city", + "description": "Label for 'validate.pattern' builder field", + "originalDefault": "Regular expression for city" + }, "tU1UVF": { "defaultMessage": "The earliest possible value that can be entered.", "description": "Tooltip for 'validate.minTime' builder field", diff --git a/i18n/messages/nl.json b/i18n/messages/nl.json index 6e6bbaa1..6270a48a 100644 --- a/i18n/messages/nl.json +++ b/i18n/messages/nl.json @@ -49,6 +49,16 @@ "description": "Tooltip for 'showInPDF' builder field", "originalDefault": "Whether to show this value in the confirmation PDF" }, + "0D+m56": { + "defaultMessage": "Regular expression for city", + "description": "Placeholder for 'validate.pattern' builder field", + "originalDefault": "Regular expression for city" + }, + "0FD2pY": { + "defaultMessage": "Validation for the postcode field", + "description": "Tooltip postcode field validation panel", + "originalDefault": "Validation for the postcode field" + }, "0OP7ho": { "defaultMessage": "Ontvangt bevestigingsmail", "description": "Label for 'confirmationRecipient' builder field", @@ -156,6 +166,11 @@ "description": "Label for 'delta.days' in relative delta date constraint validation", "originalDefault": "Days" }, + "4DrI94": { + "defaultMessage": "Regular expression for postcode", + "description": "Label for 'validate.pattern' builder field", + "originalDefault": "Regular expression for postcode" + }, "4HBnrF": { "defaultMessage": "Sleep of selecteer bestanden om te uploaden.", "description": "file component: drag/select files to upload text", @@ -784,6 +799,11 @@ "description": "Tooltip for 'prefill.identifierRole' builder field", "originalDefault": "In case that multiple identifiers are returned (in the case of eHerkenning bewindvoering and DigiD Machtigen), should the prefill data related to the main identifier be used, or that related to the authorised person?" }, + "WxwqZJ": { + "defaultMessage": "The regular expression pattern test that the postcode field value must pass before the form can be submitted.", + "description": "Tooltip for 'validate.pattern' builder field", + "originalDefault": "The regular expression pattern test that the postcode field value must pass before the form can be submitted." + }, "XMV3t2": { "defaultMessage": "Info", "description": "Label for content component CSS class 'info' option", @@ -941,6 +961,11 @@ "description": "'Remove item' screenreader button text", "originalDefault": "Remove item" }, + "dD9O3Q": { + "defaultMessage": "Regular expression for postcode", + "description": "Placeholder for 'validate.pattern' builder field", + "originalDefault": "Regular expression for postcode" + }, "dJNdhr": { "defaultMessage": "Annuleren", "description": "Cancel component configuration button", @@ -1042,6 +1067,11 @@ "description": "Description for the 'openForms.itemsExpression' builder field", "originalDefault": "A JSON logic expression returning a variable (of array type) whose items should be used as the options for this component." }, + "fhVFUY": { + "defaultMessage": "City", + "description": "Title of city field validation panel", + "originalDefault": "City" + }, "gCQtSJ": { "defaultMessage": "Geef de knoptekst om een groep te verwijderen op.", "description": "Tooltip for 'removeRow' builder field", @@ -1063,6 +1093,11 @@ "description": "Fieldset preview content description", "originalDefault": "Fieldset content" }, + "hEVgKd": { + "defaultMessage": "Error message for \"{key}\"", + "description": "Accessible label for error message input field", + "originalDefault": "Error message for \"{key}\"" + }, "hJtTwo": { "defaultMessage": "Formulierveld", "description": "Component 'Rich' preview mode", @@ -1205,6 +1240,11 @@ "description": "Tooltip for option/choice description", "originalDefault": "Optionally provide additional information to explain the meaning of the option." }, + "mck25o": { + "defaultMessage": "Validation for the city field", + "description": "Tooltip city field validation panel", + "originalDefault": "Validation for the city field" + }, "mf9eF+": { "defaultMessage": "Huisnummer", "description": "Label for address housenumber", @@ -1272,6 +1312,11 @@ "description": "Label for addressNL city read only result", "originalDefault": "City" }, + "osl4X2": { + "defaultMessage": "The regular expression pattern test that the city field value must pass before the form can be submitted.", + "description": "Tooltip for 'validate.pattern' builder field", + "originalDefault": "The regular expression pattern test that the city field value must pass before the form can be submitted." + }, "p7g2h+": { "defaultMessage": "Nog één toevoegen", "description": "Add another option button label", @@ -1317,6 +1362,11 @@ "description": "Component edit form tab title for 'Registration' tab", "originalDefault": "Registration" }, + "rW1edF": { + "defaultMessage": "Postcode", + "description": "Title of postcode field validation panel", + "originalDefault": "Postcode" + }, "rkJLBr": { "defaultMessage": "'Groep toevoegen'-tekst", "description": "Label for 'addAnother' builder field", @@ -1343,6 +1393,11 @@ "isTranslated": true, "originalDefault": "{groupLabel} 3" }, + "swKpQE": { + "defaultMessage": "Regular expression for city", + "description": "Label for 'validate.pattern' builder field", + "originalDefault": "Regular expression for city" + }, "tU1UVF": { "defaultMessage": "De minimale tijd die gekozen kan worden.", "description": "Tooltip for 'validate.minTime' builder field", diff --git a/src/components/ComponentConfiguration.stories.tsx b/src/components/ComponentConfiguration.stories.tsx index 8b4e5c8e..94a7679f 100644 --- a/src/components/ComponentConfiguration.stories.tsx +++ b/src/components/ComponentConfiguration.stories.tsx @@ -2241,6 +2241,18 @@ export const AddressNL: Story = { }, deriveAddress: false, layout: 'singleColumn', + openForms: { + components: { + postcode: { + validate: {pattern: '1015 [a-zA-Z]{2}'}, + translatedErrors: {}, + }, + city: { + validate: {pattern: 'Amsterdam'}, + translatedErrors: {}, + }, + }, + }, }, builderInfo: { title: 'Address Field', diff --git a/src/components/builder/validate/i18n.tsx b/src/components/builder/validate/i18n.tsx index 1565c1f9..a19de2a5 100644 --- a/src/components/builder/validate/i18n.tsx +++ b/src/components/builder/validate/i18n.tsx @@ -2,19 +2,20 @@ import {PossibleValidatorErrorKeys, SchemaWithValidation} from '@open-formuliere import {useField} from 'formik'; import {isEqual} from 'lodash'; import {useContext, useEffect} from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; +import {FormattedMessage, defineMessage, useIntl} from 'react-intl'; import {BuilderContext} from '@/context'; import {DataMap, Panel, Tab, TabList, TabPanel, Tabs, TextField} from '../../formio'; export function useManageValidatorsTranslations( - keys: PossibleValidatorErrorKeys[] + keys: PossibleValidatorErrorKeys[], + prefix: string = '' ): void { + const fieldName = `${prefix}${prefix ? '.' : ''}translatedErrors`; const {supportedLanguageCodes} = useContext(BuilderContext); - const [{value}, , {setValue}] = useField('translatedErrors'); + const [{value}, , {setValue}] = useField(fieldName); - // set any missing translations useEffect(() => { const newValue = value ? {...value} @@ -68,6 +69,10 @@ const ValidationErrorTranslations = () => { defaultMessage="Error code" /> } + ariaLabelMessage={defineMessage({ + description: 'Accessible label for error message input field', + defaultMessage: 'Error message for "{key}"', + })} valueComponent={ = ({name, valueComponent, keyLabel = 'Key'}) => { +export const DataMap: React.FC = ({ + name, + valueComponent, + ariaLabelMessage, + keyLabel = 'Key', +}) => { + const intl = useIntl(); const [{value}, , {setValue}] = useField(name); const transformedValue = Object.entries(value).map(([key, value]) => ({key, value})); const columns = [keyLabel, valueComponent.props.label]; @@ -30,6 +38,9 @@ export const DataMap: React.FC = ({name, valueComponent, keyLabel const newValue = {...value, [item.key]: event.target.value}; setValue(newValue); }, + 'aria-label': ariaLabelMessage + ? intl.formatMessage(ariaLabelMessage, {key: item.key}) + : undefined, })} ))} diff --git a/src/registry/addressNL/edit.stories.ts b/src/registry/addressNL/edit.stories.ts new file mode 100644 index 00000000..65b61376 --- /dev/null +++ b/src/registry/addressNL/edit.stories.ts @@ -0,0 +1,150 @@ +import {Meta, StoryObj} from '@storybook/react'; +import {expect, userEvent, within} from '@storybook/test'; + +import ComponentEditForm from '@/components/ComponentEditForm'; + +export default { + title: 'Builder components/AddressNL', + component: ComponentEditForm, + parameters: {}, + args: { + isNew: true, + component: { + id: 'wekruya', + type: 'addressNL', + key: 'address', + label: 'An address field', + }, + builderInfo: { + title: 'Address NL', + icon: 'home', + group: 'basic', + weight: 10, + schema: {}, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const PostcodeValidationTabWithoutConfiguration: Story = { + name: 'AddressNL postcode validation tab (no prior configuration)', + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + await step('Navigate to validation tab and open Postcode validation', async () => { + await userEvent.click(canvas.getByRole('link', {name: 'Validation'})); + await userEvent.click(canvas.getAllByText('Postcode')[0]); + expect(await canvas.findByLabelText('Regular expression for postcode')).toBeVisible(); + expect(await canvas.findByText('NL')).toBeVisible(); + expect(await canvas.findByText('EN')).toBeVisible(); + expect(await canvas.findByText('Error code')).toBeVisible(); + const errorMessageInput = await canvas.findByLabelText('Error message for "pattern"'); + expect(errorMessageInput).toHaveDisplayValue(''); + }); + }, +}; + +export const CityValidationTabWithoutConfiguration: Story = { + name: 'AddressNL city validation tab (no prior configuration)', + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + await step('Navigate to validation tab and open City validation', async () => { + await userEvent.click(canvas.getByRole('link', {name: 'Validation'})); + await userEvent.click(canvas.getAllByText('City')[0]); + expect(await canvas.findByLabelText('Regular expression for city')).toBeVisible(); + expect(await canvas.findByText('NL')).toBeVisible(); + expect(await canvas.findByText('EN')).toBeVisible(); + expect(await canvas.findByText('Error code')).toBeVisible(); + const errorMessageInput = await canvas.findByLabelText('Error message for "pattern"'); + expect(errorMessageInput).toHaveDisplayValue(''); + }); + }, +}; + +export const PostcodeValidationTabWithConfiguration: Story = { + name: 'AddressNL postcode validation tab (with prior configuration)', + args: { + component: { + id: 'wekruya', + type: 'addressNL', + key: 'address', + label: 'An address field', + openForms: { + components: { + postcode: { + validate: { + pattern: '1017 [A-Za-z]{2}', + }, + translatedErrors: { + nl: {pattern: 'Postcode moet 1017 XX zijn'}, + en: {pattern: 'Postal code must be 1017 XX'}, + }, + }, + }, + }, + }, + }, + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + await step('Navigate to validation tab and open Postcode validation', async () => { + await userEvent.click(canvas.getByRole('link', {name: 'Validation'})); + await userEvent.click(canvas.getAllByText('Postcode')[0]); + const patternInput = await canvas.findByLabelText('Regular expression for postcode'); + expect(patternInput).toBeVisible(); + expect(patternInput).toHaveValue('1017 [A-Za-z]{2}'); + + expect(await canvas.findByDisplayValue('pattern')).toBeVisible(); + expect(await canvas.findByDisplayValue('Postcode moet 1017 XX zijn')).toBeVisible(); + + await userEvent.click(await canvas.findByText('EN')); + const errorMessageInput = await canvas.findByLabelText('Error message for "pattern"'); + expect(errorMessageInput).toHaveDisplayValue('Postal code must be 1017 XX'); + }); + }, +}; + +export const CityValidationTabWithConfiguration: Story = { + name: 'AddressNL city validation tab (with prior configuration)', + args: { + component: { + id: 'wekruya', + type: 'addressNL', + key: 'address', + label: 'An address field', + openForms: { + components: { + city: { + validate: { + pattern: 'Amsterdam', + }, + translatedErrors: { + nl: {pattern: 'De stad moet Amsterdam zijn'}, + en: {pattern: 'The city must be Amsterdam'}, + }, + }, + }, + }, + }, + }, + play: async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + await step('Navigate to validation tab and open City validation', async () => { + await userEvent.click(canvas.getByRole('link', {name: 'Validation'})); + await userEvent.click(canvas.getAllByText('City')[0]); + const patternInput = await canvas.findByLabelText('Regular expression for city'); + expect(patternInput).toBeVisible(); + expect(patternInput).toHaveValue('Amsterdam'); + + expect(await canvas.findByDisplayValue('pattern')).toBeVisible(); + expect(await canvas.findByDisplayValue('De stad moet Amsterdam zijn')).toBeVisible(); + + await userEvent.click(await canvas.findByText('EN')); + const errorMessageInput = await canvas.findByLabelText('Error message for "pattern"'); + expect(errorMessageInput).toHaveDisplayValue('The city must be Amsterdam'); + }); + }, +}; diff --git a/src/registry/addressNL/edit.tsx b/src/registry/addressNL/edit.tsx index 3951c84f..d87248f3 100644 --- a/src/registry/addressNL/edit.tsx +++ b/src/registry/addressNL/edit.tsx @@ -1,5 +1,7 @@ import {AddressNLComponentSchema} from '@open-formulieren/types'; -import {FormattedMessage, useIntl} from 'react-intl'; +import {TextField} from 'components/formio'; +import {useContext} from 'react'; +import {FormattedMessage, defineMessage, useIntl} from 'react-intl'; import { BuilderTabs, @@ -19,12 +21,86 @@ import { } from '@/components/builder'; import {LABELS} from '@/components/builder/messages'; import {Checkbox} from '@/components/formio'; -import {TabList, TabPanel, Tabs} from '@/components/formio'; +import {DataMap, Panel, Tab, TabList, TabPanel, Tabs} from '@/components/formio'; import {Select} from '@/components/formio'; +import {BuilderContext} from '@/context'; import {useErrorChecker} from '@/utils/errors'; import {EditFormDefinition} from '../types'; +/** + * Helper type to extract information from existing types. + */ +type AddressSubComponents = Required< + Required['openForms']>['components'] +>; +type PostcodeSchema = AddressSubComponents['postcode']; +type CitySchema = AddressSubComponents['city']; + +export interface SubcomponentValidationProps { + prefix: string; + component: keyof AddressSubComponents; + label: React.ReactNode; + tooltip: string; + placeholder: string; +} + +export const SubcomponentValidation: React.FC = ({ + prefix, + component, + label, + tooltip, + placeholder, +}) => { + const {supportedLanguageCodes} = useContext(BuilderContext); + return ( + <> + + + + {supportedLanguageCodes.map(code => ( + {code.toUpperCase()} + ))} + + + {supportedLanguageCodes.map(code => ( + + + } + ariaLabelMessage={defineMessage({ + description: 'Accessible label for error message input field', + defaultMessage: 'Error message for "{key}"', + })} + valueComponent={ + + } + /> + } + /> + + ))} + + + ); +}; + const DeriveAddress = () => { const intl = useIntl(); const tooltip = intl.formatMessage({ @@ -81,6 +157,13 @@ const EditForm: EditFormDefinition = () => { const [isKeyManuallySetRef, generatedKey] = useDeriveComponentKey(); const {hasAnyError} = useErrorChecker(); Validate.useManageValidatorsTranslations(['required']); + + Validate.useManageValidatorsTranslations( + ['pattern'], + `openForms.components.postcode` + ); + Validate.useManageValidatorsTranslations(['pattern'], `openForms.components.city`); + return ( @@ -128,6 +211,78 @@ const EditForm: EditFormDefinition = () => { + + {/* Postcode field validation */} + + } + tooltip={intl.formatMessage({ + description: 'Tooltip postcode field validation panel', + defaultMessage: 'Validation for the postcode field', + })} + collapsible + initialCollapsed + > + + } + tooltip={intl.formatMessage({ + description: "Tooltip for 'validate.pattern' builder field", + defaultMessage: + 'The regular expression pattern test that the postcode field value must pass before the form can be submitted.', + })} + placeholder={intl.formatMessage({ + description: "Placeholder for 'validate.pattern' builder field", + defaultMessage: 'Regular expression for postcode', + })} + /> + + + {/* City field validation */} + + } + tooltip={intl.formatMessage({ + description: 'Tooltip city field validation panel', + defaultMessage: 'Validation for the city field', + })} + collapsible + initialCollapsed + > + + } + tooltip={intl.formatMessage({ + description: "Tooltip for 'validate.pattern' builder field", + defaultMessage: + 'The regular expression pattern test that the city field value must pass before the form can be submitted.', + })} + placeholder={intl.formatMessage({ + description: "Placeholder for 'validate.pattern' builder field", + defaultMessage: 'Regular expression for city', + })} + /> + {/* Registration tab */} @@ -192,6 +347,19 @@ EditForm.defaultValues = { registration: { attribute: '', }, + openForms: { + translations: {}, + components: { + postcode: { + validate: {pattern: ''}, + translatedErrors: {}, + }, + city: { + validate: {pattern: ''}, + translatedErrors: {}, + }, + }, + }, }; export default EditForm;