Skip to content

Commit

Permalink
[open-formulieren/open-forms#5006] Updated addressNL component to man…
Browse files Browse the repository at this point in the history
…ually 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.
  • Loading branch information
vaszig committed Jan 29, 2025
1 parent 263df05 commit 40eba22
Show file tree
Hide file tree
Showing 8 changed files with 344 additions and 120 deletions.
35 changes: 35 additions & 0 deletions src/components/FormStep/FormStep.stories.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -422,3 +422,38 @@ 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(),
validationErrors: {
addressNl: 'There are errors concerning the nested fields.',
},
},
play: async ({canvasElement}) => {
const canvas = within(canvasElement);

await sleep(1000 + 200);

const submitButton = await canvas.findByRole('button', {name: 'Next'});

await userEvent.click(submitButton);
await sleep(1000 + 200);
expect(await canvas.findByText(/There are errors concerning the nested fields./)).toBeVisible();
},
};
119 changes: 84 additions & 35 deletions src/formio/components/AddressNL.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}

Expand All @@ -77,6 +105,7 @@ export default class AddressNL extends Field {
city: '',
streetName: '',
secretStreetCity: '',
autoPopulated: false,
};
}

Expand Down Expand Up @@ -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}
/>
</ConfigContext.Provider>
</IntlProvider>
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -340,14 +401,16 @@ const AddressNLForm = ({initialValues, required, deriveAddress, layout, setFormi

return (
<Formik
innerRef={formikInnerRef}
initialValues={initialValues}
initialTouched={{
postcode: true,
houseNumber: true,
city: true,
streetName: true,
}}
validationSchema={toFormikValidationSchema(
addressNLSchema(required, intl, {
addressNLSchema(required, deriveAddress, intl, {
postcode: {
pattern: postcodePattern,
errorMessage: postcodeError,
Expand All @@ -373,6 +436,7 @@ const AddressNLForm = ({initialValues, required, deriveAddress, layout, setFormi
const FormikAddress = ({required, setFormioValues, deriveAddress, layout}) => {
const {values, isValid, setFieldValue} = useFormikContext();
const {baseUrl} = useContext(ConfigContext);
const [isAddressAutoFilled, setAddressAutoFilled] = useState(true);
const useColumns = layout === 'doubleColumn';

useEffect(() => {
Expand All @@ -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 (
Expand All @@ -411,45 +481,24 @@ const FormikAddress = ({required, setFormioValues, deriveAddress, layout}) => {
>
<PostCodeField required={required} autoFillAddress={autofillAddress} />
<HouseNumberField required={required} autoFillAddress={autofillAddress} />
<TextField
name="houseLetter"
label={
<FormattedMessage
description="Label for addressNL houseLetter input"
defaultMessage="House letter"
/>
}
/>
<TextField name="houseLetter" label={<FormattedMessage {...FIELD_LABELS.houseLetter} />} />
<TextField
name="houseNumberAddition"
label={
<FormattedMessage
description="Label for addressNL houseNumberAddition input"
defaultMessage="House number addition"
/>
}
label={<FormattedMessage {...FIELD_LABELS.houseNumberAddition} />}
/>
{deriveAddress && (
<>
<TextField
name="streetName"
label={
<FormattedMessage
description="Label for addressNL streetName read only result"
defaultMessage="Street name"
/>
}
disabled
label={<FormattedMessage {...FIELD_LABELS.streetName} />}
disabled={isAddressAutoFilled}
isRequired={required}
/>
<TextField
name="city"
label={
<FormattedMessage
description="Label for addressNL city read only result"
defaultMessage="City"
/>
}
disabled
label={<FormattedMessage {...FIELD_LABELS.city} />}
disabled={isAddressAutoFilled}
isRequired={required}
/>
</>
)}
Expand Down
72 changes: 72 additions & 0 deletions src/formio/components/AddressNL.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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: false,
},
},
],
};

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');
expect(form.isValid()).not.toBeTruthy();

Check failure on line 53 in src/formio/components/AddressNL.spec.jsx

View workflow job for this annotation

GitHub Actions / Run Javascript tests

src/formio/components/AddressNL.spec.jsx > The addressNL component > Postcode provided and missing housenumber

AssertionError: expected true to not be truthy ❯ src/formio/components/AddressNL.spec.jsx:53:32
});
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');
expect(form.isValid()).not.toBeTruthy();

Check failure on line 70 in src/formio/components/AddressNL.spec.jsx

View workflow job for this annotation

GitHub Actions / Run Javascript tests

src/formio/components/AddressNL.spec.jsx > The addressNL component > Postcode missing and housenumber provided

AssertionError: expected true to not be truthy ❯ src/formio/components/AddressNL.spec.jsx:70:32
});
});
Loading

0 comments on commit 40eba22

Please sign in to comment.