From 7369d3da43e0f05f686ee524e6db74fe9d58c6b7 Mon Sep 17 00:00:00 2001 From: vasileios Date: Wed, 29 Jan 2025 16:04:20 +0100 Subject: [PATCH] [open-formulieren/open-forms#5006] Updated addressNL component to manually fill in city and streetname Until now the addressNL component would retrieve the city and street name based on the postcode and housenumber. These fields were always read-only but now we want to be able to fill in city and street name if something goes wrong with the API call. --- src/components/FormStep/FormStep.stories.jsx | 33 +++++ src/formio/components/AddressNL.jsx | 119 +++++++++++++------ src/formio/components/AddressNL.spec.jsx | 70 +++++++++++ src/formio/components/AddressNL.stories.js | 106 ++++++++++------- src/i18n/compiled/en.json | 36 ++++-- src/i18n/compiled/nl.json | 36 ++++-- src/i18n/messages/en.json | 30 +++-- src/i18n/messages/nl.json | 30 +++-- 8 files changed, 340 insertions(+), 120 deletions(-) create mode 100644 src/formio/components/AddressNL.spec.jsx diff --git a/src/components/FormStep/FormStep.stories.jsx b/src/components/FormStep/FormStep.stories.jsx index 58a0cb0bb..ec54cae31 100644 --- a/src/components/FormStep/FormStep.stories.jsx +++ b/src/components/FormStep/FormStep.stories.jsx @@ -422,3 +422,36 @@ export const SummaryProgressNotVisible = { expect(canvas.queryByText(/Stap 1 van 1/)).toBeNull(); }, }; + +export const AddressNLManuallyTriggeredValidation = { + render, + args: { + formioConfiguration: { + display: 'form', + components: [ + { + key: 'addressnl', + type: 'addressNL', + label: 'Address NL', + validate: { + required: false, + }, + }, + ], + }, + form: buildForm(), + submission: buildSubmission(), + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const postcodeInput = await canvas.findByLabelText('Postcode'); + const submitButton = await canvas.findByRole('button', {name: 'Next'}); + + await userEvent.type(postcodeInput, '1017 CJ'); + + // wait for the check logic api call + await sleep(1800); + await userEvent.click(submitButton); + }, +}; diff --git a/src/formio/components/AddressNL.jsx b/src/formio/components/AddressNL.jsx index c92fb0b5e..492fb7906 100644 --- a/src/formio/components/AddressNL.jsx +++ b/src/formio/components/AddressNL.jsx @@ -3,7 +3,7 @@ */ import {Formik, useFormikContext} from 'formik'; import debounce from 'lodash/debounce'; -import {useContext, useEffect} from 'react'; +import {createRef, useContext, useEffect, useState} from 'react'; import {createRoot} from 'react-dom/client'; import {Formio} from 'react-formio'; import {FormattedMessage, IntlProvider, defineMessages, useIntl} from 'react-intl'; @@ -26,6 +26,8 @@ export default class AddressNL extends Field { // the edit grid renderRow otherwise wraps the result of getValueAsString in a // readonly input... this.component.template = 'hack'; + // needed for manually triggering the formik validate method + this.formikInnerRef = createRef(); } static schema(...extend) { @@ -56,11 +58,37 @@ export default class AddressNL extends Field { }; } - checkComponentValidity(data, dirty, row, options = {}) { + async checkComponentValidity(data, dirty, row, options = {}) { let updatedOptions = {...options}; if (this.component.validate.plugins && this.component.validate.plugins.length) { updatedOptions.async = true; } + + if (!dirty) { + return super.checkComponentValidity(data, dirty, row, updatedOptions); + } + + // Trigger again formik validation in order to show the generic error along with the + // nested fields errors and prevent the form from being submitted. + // Tried to go deeper for this in formio but this will be properly handled in the new + // form renderer. + if (this.formikInnerRef.current) { + const errors = await this.formikInnerRef.current.validateForm(); + + if (Object.keys(errors).length > 0) { + this.setComponentValidity( + [ + { + message: this.t('There are errors concerning the nested fields.'), + level: 'error', + }, + ], + true, + false + ); + return false; + } + } return super.checkComponentValidity(data, dirty, row, updatedOptions); } @@ -77,6 +105,7 @@ export default class AddressNL extends Field { city: '', streetName: '', secretStreetCity: '', + autoPopulated: false, }; } @@ -169,6 +198,7 @@ export default class AddressNL extends Field { deriveAddress={this.component.deriveAddress} layout={this.component.layout} setFormioValues={this.onFormikChange.bind(this)} + formikInnerRef={this.formikInnerRef} /> @@ -204,9 +234,25 @@ const FIELD_LABELS = defineMessages({ description: 'Label for addressNL houseNumber input', defaultMessage: 'House number', }, + houseLetter: { + description: 'Label for addressNL houseLetter input', + defaultMessage: 'House letter', + }, + houseNumberAddition: { + description: 'Label for addressNL houseNumberAddition input', + defaultMessage: 'House number addition', + }, + streetName: { + description: 'Label for addressNL streetName input', + defaultMessage: 'Street name', + }, + city: { + description: 'Label for addressNL city input', + defaultMessage: 'City', + }, }); -const addressNLSchema = (required, intl, {postcode = {}, city = {}}) => { +const addressNLSchema = (required, deriveAddress, intl, {postcode = {}, city = {}}) => { // Optionally use a user-supplied pattern/regex for more fine grained pattern // validation, and if a custom error message was supplied, use it. const postcodeRegex = postcode?.pattern @@ -221,6 +267,7 @@ const addressNLSchema = (required, intl, {postcode = {}, city = {}}) => { }); let postcodeSchema = z.string().regex(postcodeRegex, {message: postcodeErrorMessage}); + let streetNameSchema = z.string(); const {pattern: cityPattern = '', errorMessage: cityErrorMessage = ''} = city; let citySchema = z.string(); if (cityPattern) { @@ -237,15 +284,22 @@ const addressNLSchema = (required, intl, {postcode = {}, city = {}}) => { defaultMessage: 'House number must be a number with up to five digits (e.g. 456).', }), }); + if (!required) { postcodeSchema = postcodeSchema.optional(); houseNumberSchema = houseNumberSchema.optional(); + streetNameSchema = streetNameSchema.optional(); + citySchema = citySchema.optional(); + } else if (!deriveAddress) { + streetNameSchema = streetNameSchema.optional(); + citySchema = citySchema.optional(); } return z .object({ postcode: postcodeSchema, - city: citySchema.optional(), + streetName: streetNameSchema, + city: citySchema, houseNumber: houseNumberSchema, houseLetter: z .string() @@ -297,7 +351,14 @@ const addressNLSchema = (required, intl, {postcode = {}, city = {}}) => { }); }; -const AddressNLForm = ({initialValues, required, deriveAddress, layout, setFormioValues}) => { +const AddressNLForm = ({ + initialValues, + required, + deriveAddress, + layout, + setFormioValues, + formikInnerRef, +}) => { const intl = useIntl(); const { @@ -340,14 +401,16 @@ const AddressNLForm = ({initialValues, required, deriveAddress, layout, setFormi return ( { const {values, isValid, setFieldValue} = useFormikContext(); const {baseUrl} = useContext(ConfigContext); + const [isAddressAutoFilled, setAddressAutoFilled] = useState(true); const useColumns = layout === 'doubleColumn'; useEffect(() => { @@ -399,6 +463,12 @@ const FormikAddress = ({required, setFormioValues, deriveAddress, layout}) => { setFieldValue('city', data['city']); setFieldValue('streetName', data['streetName']); setFieldValue('secretStreetCity', data['secretStreetCity']); + + // mark the auto-filled fields as populated and disabled when they have been both + // retrieved from the API and they do have a value + const dataRetrieved = !!(data['city'] && data['streetName']); + setAddressAutoFilled(dataRetrieved); + setFieldValue('autoPopulated', dataRetrieved); }; return ( @@ -411,45 +481,24 @@ const FormikAddress = ({required, setFormioValues, deriveAddress, layout}) => { > - - } - /> + } /> - } + label={} /> {deriveAddress && ( <> - } - disabled + label={} + disabled={isAddressAutoFilled} + isRequired={required} /> - } - disabled + label={} + disabled={isAddressAutoFilled} + isRequired={required} /> )} diff --git a/src/formio/components/AddressNL.spec.jsx b/src/formio/components/AddressNL.spec.jsx new file mode 100644 index 000000000..05439dde4 --- /dev/null +++ b/src/formio/components/AddressNL.spec.jsx @@ -0,0 +1,70 @@ +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import {renderForm} from 'jstests/formio/utils'; + +const addressNLForm = { + type: 'form', + components: [ + { + key: 'addressnl', + type: 'addressNL', + label: 'Address NL', + validate: { + required: true, + }, + }, + ], +}; + +describe('The addressNL component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + test('Postcode and housenumber provided', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(addressNLForm, { + evalContext: { + requiredFieldsWithAsterisk: true, + }, + }); + const postcode = screen.getByLabelText('Postcode'); + const houseNumber = screen.getByLabelText('House number'); + + await user.type(postcode, '1017 CJ'); + await user.type(houseNumber, '22'); + + expect(form.isValid()).toBeTruthy(); + }); + test('Postcode provided and missing housenumber', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(addressNLForm, { + evalContext: { + requiredFieldsWithAsterisk: true, + }, + }); + const postcode = screen.getByLabelText('Postcode'); + const houseNumber = screen.getByLabelText('House number'); + + await user.type(postcode, '1017 CJ'); + + expect(houseNumber).toHaveClass('utrecht-textbox--invalid'); + expect(houseNumber).toHaveAttribute('aria-describedby'); + expect(houseNumber).toHaveAttribute('aria-invalid'); + }); + test('Postcode missing and housenumber provided', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(addressNLForm, { + evalContext: { + requiredFieldsWithAsterisk: true, + }, + }); + const postcode = screen.getByLabelText('Postcode'); + const houseNumber = screen.getByLabelText('House number'); + + await user.type(houseNumber, '22'); + + expect(postcode).toHaveClass('utrecht-textbox--invalid'); + expect(postcode).toHaveAttribute('aria-describedby'); + expect(postcode).toHaveAttribute('aria-invalid'); + }); +}); diff --git a/src/formio/components/AddressNL.stories.js b/src/formio/components/AddressNL.stories.js index 20bd619c8..42b9de533 100644 --- a/src/formio/components/AddressNL.stories.js +++ b/src/formio/components/AddressNL.stories.js @@ -1,6 +1,7 @@ import {expect, userEvent, waitFor, within} from '@storybook/test'; import {ConfigDecorator, withUtrechtDocument} from 'story-utils/decorators'; +import {sleep} from 'utils'; import { mockBAGDataGet, @@ -84,6 +85,27 @@ export const ClientSideValidation = { }, }; +export const Required = { + args: { + extraComponentProperties: { + validate: { + required: true, + }, + }, + }, + render: SingleFormioComponent, + play: async ({canvasElement}) => { + await sleep(500); + const canvas = within(canvasElement); + + (await canvas.findByLabelText('Huisnummer')).focus(); + await userEvent.tab(); + + expect(await canvas.findByText('Postcode is verplicht.')).toBeVisible(); + expect(await canvas.findByText('Huisnummer is verplicht.')).toBeVisible(); + }, +}; + export const NotRequired = { args: { extraComponentProperties: { @@ -112,9 +134,46 @@ export const NotRequired = { }, }; +export const IncorrectPostcode = { + render: SingleFormioComponent, + args: { + type: 'addressNL', + key: 'addressNL', + label: 'Address NL', + extraComponentProperties: { + validate: { + required: false, + }, + deriveAddress: true, + openForms: { + components: { + postcode: { + validate: {pattern: '1017 [A-Za-z]{2}'}, + translatedErrors: { + nl: { + pattern: 'De postcode moet 1017 XX zijn', + }, + }, + }, + }, + }, + }, + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement); + + const postcodeInput = await canvas.findByLabelText('Postcode'); + await userEvent.type(postcodeInput, '1234 AB'); + + await userEvent.tab(); + + await canvas.findByText('De postcode moet 1017 XX zijn'); + }, +}; + // const EXPECTED_VALIDATION_ERROR = 'User is not a zaakgerechtigde for property.'; -export const WithPassingBRKValidation = { +export const NotRequiredWithPassingBRKValidation = { render: SingleFormioComponent, parameters: { msw: { @@ -234,7 +293,7 @@ export const WithDeriveCityStreetNameWithData = { const houseNumberInput = await canvas.findByLabelText('Huisnummer'); await userEvent.type(houseNumberInput, '1'); - const city = await canvas.findByLabelText('Stad'); + const city = await canvas.findByLabelText('Plaats'); const streetName = await canvas.findByLabelText('Straatnaam'); await userEvent.tab(); @@ -246,43 +305,6 @@ export const WithDeriveCityStreetNameWithData = { }, }; -export const IncorrectPostcode = { - render: SingleFormioComponent, - args: { - type: 'addressNL', - key: 'addressNL', - label: 'Address NL', - extraComponentProperties: { - validate: { - required: false, - }, - deriveAddress: true, - openForms: { - components: { - postcode: { - validate: {pattern: '1017 [A-Za-z]{2}'}, - translatedErrors: { - nl: { - pattern: 'De postcode moet 1017 XX zijn', - }, - }, - }, - }, - }, - }, - }, - play: async ({canvasElement}) => { - const canvas = within(canvasElement); - - const postcodeInput = await canvas.findByLabelText('Postcode'); - await userEvent.type(postcodeInput, '1234 AB'); - - await userEvent.tab(); - - await canvas.findByText('De postcode moet 1017 XX zijn'); - }, -}; - export const WithDeriveCityStreetNameWithDataIncorrectCity = { render: SingleFormioComponent, parameters: { @@ -322,7 +344,7 @@ export const WithDeriveCityStreetNameWithDataIncorrectCity = { const houseNumberInput = await canvas.findByLabelText('Huisnummer'); await userEvent.type(houseNumberInput, '1'); - const city = await canvas.findByLabelText('Stad'); + const city = await canvas.findByLabelText('Plaats'); const streetName = await canvas.findByLabelText('Straatnaam'); await userEvent.tab(); @@ -365,14 +387,16 @@ export const WithDeriveCityStreetNameNoData = { const houseNumberInput = await canvas.findByLabelText('Huisnummer'); await userEvent.type(houseNumberInput, '1'); - const city = await canvas.findByLabelText('Stad'); + const city = await canvas.findByLabelText('Plaats'); const streetName = await canvas.findByLabelText('Straatnaam'); await userEvent.tab(); await waitFor(() => { expect(city.value).toBe(''); + expect(city).not.toBeDisabled(); expect(streetName.value).toBe(''); + expect(streetName).not.toBeDisabled(); }); }, }; diff --git a/src/i18n/compiled/en.json b/src/i18n/compiled/en.json index a8d22545c..697e001c2 100644 --- a/src/i18n/compiled/en.json +++ b/src/i18n/compiled/en.json @@ -439,6 +439,12 @@ "value": "Check and confirm" } ], + "AKhmW+": [ + { + "type": 0, + "value": "Street name" + } + ], "AM6xqd": [ { "type": 0, @@ -545,12 +551,6 @@ "value": "Remove" } ], - "DEetjI": [ - { - "type": 0, - "value": "Street name" - } - ], "DK2ewv": [ { "type": 0, @@ -663,6 +663,12 @@ "value": "The verification code may only contain letters (A-Z) and numbers (0-9)." } ], + "FKWQuw": [ + { + "type": 0, + "value": "Unfortunately, this form is currently unavailable due to an outage. Please try again later." + } + ], "FrFeZj": [ { "type": 0, @@ -2045,12 +2051,6 @@ "value": "." } ], - "osSl3z": [ - { - "type": 0, - "value": "City" - } - ], "ovI+W7": [ { "type": 0, @@ -2183,12 +2183,24 @@ "value": "Postcode must be four digits followed by two letters (e.g. 1234 AB)." } ], + "rWbce4": [ + { + "type": 0, + "value": "Form unavailable" + } + ], "riuSfc": [ { "type": 0, "value": "Send code" } ], + "s4+4p2": [ + { + "type": 0, + "value": "City" + } + ], "sSmY1N": [ { "type": 0, diff --git a/src/i18n/compiled/nl.json b/src/i18n/compiled/nl.json index d1524570a..8b1cc3208 100644 --- a/src/i18n/compiled/nl.json +++ b/src/i18n/compiled/nl.json @@ -439,6 +439,12 @@ "value": "Controleer en bevestig" } ], + "AKhmW+": [ + { + "type": 0, + "value": "Straatnaam" + } + ], "AM6xqd": [ { "type": 0, @@ -545,12 +551,6 @@ "value": "Verwijderen" } ], - "DEetjI": [ - { - "type": 0, - "value": "Straatnaam" - } - ], "DK2ewv": [ { "type": 0, @@ -663,6 +663,12 @@ "value": "De bevestigingscode bestaat uit hoofdletters (A-Z) en getallen (0-9)." } ], + "FKWQuw": [ + { + "type": 0, + "value": "Unfortunately, this form is currently unavailable due to an outage. Please try again later." + } + ], "FrFeZj": [ { "type": 0, @@ -2049,12 +2055,6 @@ "value": " zijn." } ], - "osSl3z": [ - { - "type": 0, - "value": "Stad" - } - ], "ovI+W7": [ { "type": 0, @@ -2187,12 +2187,24 @@ "value": "Postcode moet bestaan uit vier cijfers gevolgd door twee letters (bijv. 1234 AB)." } ], + "rWbce4": [ + { + "type": 0, + "value": "Form unavailable" + } + ], "riuSfc": [ { "type": 0, "value": "Verstuur code" } ], + "s4+4p2": [ + { + "type": 0, + "value": "Plaats" + } + ], "sSmY1N": [ { "type": 0, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c075c5ed0..c07c5ca2b 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -209,6 +209,11 @@ "description": "Check overview and confirm", "originalDefault": "Check and confirm" }, + "AKhmW+": { + "defaultMessage": "Street name", + "description": "Label for addressNL streetName input", + "originalDefault": "Street name" + }, "AM6xqd": { "defaultMessage": "House number must be a number with up to five digits (e.g. 456).", "description": "ZOD error message when AddressNL house number does not match the house number regular expression", @@ -294,11 +299,6 @@ "description": "Appointments: remove product/service button text", "originalDefault": "Remove" }, - "DEetjI": { - "defaultMessage": "Street name", - "description": "Label for addressNL streetName read only result", - "originalDefault": "Street name" - }, "DK2ewv": { "defaultMessage": "Authentication problem", "description": "'Permission denied' error title", @@ -369,6 +369,11 @@ "description": "Validation error message for verification code pattern", "originalDefault": "The verification code may only contain letters (A-Z) and numbers (0-9)." }, + "FKWQuw": { + "defaultMessage": "Unfortunately, this form is currently unavailable due to an outage. Please try again later.", + "description": "Open Forms service unavailable error message", + "originalDefault": "Unfortunately, this form is currently unavailable due to an outage. Please try again later." + }, "FrFeZj": { "defaultMessage": "You haven't entered an email address yet.", "description": "Email verification modal: warning that no email is specified", @@ -1034,11 +1039,6 @@ "description": "ZOD 'too_big' error message, for BigInt", "originalDefault": "BigInt must be {exact, select, true {exactly equal to} other {{inclusive, select, true {less than or equal to} other {less than}}} } {maximum}." }, - "osSl3z": { - "defaultMessage": "City", - "description": "Label for addressNL city read only result", - "originalDefault": "City" - }, "ovI+W7": { "defaultMessage": "Use ⌘ + scroll to zoom the map", "description": "Gesturehandeling mac scroll message.", @@ -1079,11 +1079,21 @@ "description": "ZOD error message when AddressNL postcode does not match the postcode regular expression", "originalDefault": "Postcode must be four digits followed by two letters (e.g. 1234 AB)." }, + "rWbce4": { + "defaultMessage": "Form unavailable", + "description": "Open Forms service unavailable error title", + "originalDefault": "Form unavailable" + }, "riuSfc": { "defaultMessage": "Send code", "description": "Email verification: send code button text", "originalDefault": "Send code" }, + "s4+4p2": { + "defaultMessage": "City", + "description": "Label for addressNL city input", + "originalDefault": "City" + }, "sSmY1N": { "defaultMessage": "Find address", "description": "The leaflet map's input fields placeholder message.", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index b2e835870..a85be756c 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -211,6 +211,11 @@ "description": "Check overview and confirm", "originalDefault": "Check and confirm" }, + "AKhmW+": { + "defaultMessage": "Straatnaam", + "description": "Label for addressNL streetName input", + "originalDefault": "Street name" + }, "AM6xqd": { "defaultMessage": "Huisnummer moet een nummer zijn met maximaal 5 cijfers (bijv. 456).", "description": "ZOD error message when AddressNL house number does not match the house number regular expression", @@ -297,11 +302,6 @@ "description": "Appointments: remove product/service button text", "originalDefault": "Remove" }, - "DEetjI": { - "defaultMessage": "Straatnaam", - "description": "Label for addressNL streetName read only result", - "originalDefault": "Street name" - }, "DK2ewv": { "defaultMessage": "Inlogprobleem", "description": "'Permission denied' error title", @@ -373,6 +373,11 @@ "description": "Validation error message for verification code pattern", "originalDefault": "The verification code may only contain letters (A-Z) and numbers (0-9)." }, + "FKWQuw": { + "defaultMessage": "Unfortunately, this form is currently unavailable due to an outage. Please try again later.", + "description": "Open Forms service unavailable error message", + "originalDefault": "Unfortunately, this form is currently unavailable due to an outage. Please try again later." + }, "FrFeZj": { "defaultMessage": "Je hebt nog geen e-mailadres ingevuld.", "description": "Email verification modal: warning that no email is specified", @@ -1046,11 +1051,6 @@ "description": "ZOD 'too_big' error message, for BigInt", "originalDefault": "BigInt must be {exact, select, true {exactly equal to} other {{inclusive, select, true {less than or equal to} other {less than}}} } {maximum}." }, - "osSl3z": { - "defaultMessage": "Stad", - "description": "Label for addressNL city read only result", - "originalDefault": "City" - }, "ovI+W7": { "defaultMessage": "Gebruik ⌘ + scroll om te zoomen in de kaart", "description": "Gesturehandeling mac scroll message.", @@ -1091,11 +1091,21 @@ "description": "ZOD error message when AddressNL postcode does not match the postcode regular expression", "originalDefault": "Postcode must be four digits followed by two letters (e.g. 1234 AB)." }, + "rWbce4": { + "defaultMessage": "Form unavailable", + "description": "Open Forms service unavailable error title", + "originalDefault": "Form unavailable" + }, "riuSfc": { "defaultMessage": "Verstuur code", "description": "Email verification: send code button text", "originalDefault": "Send code" }, + "s4+4p2": { + "defaultMessage": "Plaats", + "description": "Label for addressNL city input", + "originalDefault": "City" + }, "sSmY1N": { "defaultMessage": "Zoek adres", "description": "The leaflet map's input fields placeholder message.",