Skip to content

Commit 1355641

Browse files
✨ [open-formulieren/open-forms#4510] Display backend validation errors
I've opted to collect all the errors at the top and group them by step, rather than trying to weave this in the summary table. User research from Utrecht showed that this was the best way to show validation errors. Ideally, there would be some aria-describedby options, but that requires a lot more work to link everything together.
1 parent 775bb98 commit 1355641

File tree

10 files changed

+434
-6
lines changed

10 files changed

+434
-6
lines changed

src/api-mocks/submissions.js

+17-1
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export const mockSubmissionSummaryGet = () =>
102102
data: [
103103
{
104104
name: SUBMISSION_STEP_DETAILS.formStep.configuration.components[0].label,
105-
value: 'Compnent 1 value',
105+
value: 'Component 1 value',
106106
component: SUBMISSION_STEP_DETAILS.formStep.configuration.components[0],
107107
},
108108
],
@@ -119,6 +119,22 @@ export const mockSubmissionCompletePost = () =>
119119
})
120120
);
121121

122+
export const mockSubmissionCompleteInvalidPost = invalidParams =>
123+
http.post(`${BASE_URL}submissions/:uuid/_complete`, () =>
124+
HttpResponse.json(
125+
{
126+
type: 'http://localhost:8000/fouten/ValidationError/',
127+
code: 'invalid',
128+
title: 'Does not validate.',
129+
status: 400,
130+
detail: '',
131+
instance: 'urn:uuid:41e0174a-efc2-4cc0-9bf2-8366242a4e75',
132+
invalidParams,
133+
},
134+
{status: 400}
135+
)
136+
);
137+
122138
/**
123139
* Simulate a successful backend processing status without payment.
124140
*/

src/components/Summary/GenericSummary.jsx

+5-3
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ const GenericSummary = ({
3434

3535
return (
3636
<Card title={title}>
37-
{errors.map(error => (
38-
<ErrorMessage key={error}>{error}</ErrorMessage>
37+
{errors.map((error, index) => (
38+
<div className="openforms-card__alert" key={`error-${index}`}>
39+
<ErrorMessage>{error}</ErrorMessage>
40+
</div>
3941
))}
4042
<Formik
4143
initialValues={{privacyPolicyAccepted: false, statementOfTruthAccepted: false}}
@@ -102,7 +104,7 @@ GenericSummary.propTypes = {
102104
editStepText: PropTypes.string,
103105
isLoading: PropTypes.bool,
104106
isAuthenticated: PropTypes.bool,
105-
errors: PropTypes.arrayOf(PropTypes.string),
107+
errors: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.node, PropTypes.string])),
106108
prevPage: PropTypes.string,
107109
onSubmit: PropTypes.func.isRequired,
108110
onDestroySession: PropTypes.func.isRequired,

src/components/Summary/SubmissionSummary.jsx

+24-2
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import {LiteralsProvider} from 'components/Literal';
88
import {useSubmissionContext} from 'components/SubmissionProvider';
99
import {SUBMISSION_ALLOWED} from 'components/constants';
1010
import {findPreviousApplicableStep} from 'components/utils';
11+
import {ValidationError} from 'errors';
1112
import useFormContext from 'hooks/useFormContext';
1213
import useRefreshSubmission from 'hooks/useRefreshSubmission';
1314
import useTitle from 'hooks/useTitle';
1415

1516
import GenericSummary from './GenericSummary';
17+
import ValidationErrors from './ValidationErrors';
1618
import {loadSummaryData} from './data';
1719

1820
const completeSubmission = async (submission, statementValues) => {
@@ -29,7 +31,7 @@ const SubmissionSummary = () => {
2931
const {submission, onDestroySession, removeSubmissionId} = useSubmissionContext();
3032
const refreshedSubmission = useRefreshSubmission(submission);
3133

32-
const [submitError, setSubmitError] = useState('');
34+
const [submitErrors, setSubmitErrors] = useState(null);
3335

3436
const pageTitle = intl.formatMessage({
3537
description: 'Summary page title',
@@ -52,11 +54,17 @@ const SubmissionSummary = () => {
5254

5355
const onSubmit = async statementValues => {
5456
if (refreshedSubmission.submissionAllowed !== SUBMISSION_ALLOWED.yes) return;
57+
5558
let statusUrl;
5659
try {
5760
statusUrl = await completeSubmission(refreshedSubmission, statementValues);
5861
} catch (e) {
59-
setSubmitError(e.message);
62+
if (e instanceof ValidationError) {
63+
const {initialErrors} = e.asFormikProps();
64+
setSubmitErrors(initialErrors);
65+
} else {
66+
setSubmitErrors(e.message);
67+
}
6068
return;
6169
}
6270

@@ -86,6 +94,20 @@ const SubmissionSummary = () => {
8694
navigate(getPreviousPage());
8795
};
8896

97+
const submitError =
98+
submitErrors &&
99+
(typeof submitErrors === 'string' ? (
100+
submitErrors
101+
) : (
102+
<>
103+
<FormattedMessage
104+
description="Summary page generic validation error message"
105+
defaultMessage="There are problems with the submitted data."
106+
/>
107+
<ValidationErrors errors={submitErrors} summaryData={summaryData} />
108+
</>
109+
));
110+
89111
const errorMessages = [location.state?.errorMessage, submitError].filter(Boolean);
90112

91113
return (
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {expect, fn, userEvent, within} from '@storybook/test';
2+
import {withRouter} from 'storybook-addon-remix-react-router';
3+
4+
import {buildForm} from 'api-mocks';
5+
import {
6+
buildSubmission,
7+
mockSubmissionCompleteInvalidPost,
8+
mockSubmissionGet,
9+
mockSubmissionSummaryGet,
10+
} from 'api-mocks/submissions';
11+
import SubmissionProvider from 'components/SubmissionProvider';
12+
import {ConfigDecorator, withForm} from 'story-utils/decorators';
13+
14+
import SubmissionSummary from './SubmissionSummary';
15+
16+
const form = buildForm();
17+
const submission = buildSubmission();
18+
19+
export default {
20+
title: 'Private API / SubmissionSummary',
21+
component: SubmissionSummary,
22+
decorators: [
23+
(Story, {args}) => (
24+
<SubmissionProvider
25+
submission={args.submission}
26+
onSubmissionObtained={fn()}
27+
onDestroySession={fn()}
28+
removeSubmissionId={fn()}
29+
>
30+
<Story />
31+
</SubmissionProvider>
32+
),
33+
withRouter,
34+
ConfigDecorator,
35+
withForm,
36+
],
37+
args: {
38+
form,
39+
submission,
40+
},
41+
argTypes: {
42+
form: {table: {disable: true}},
43+
submission: {table: {disable: true}},
44+
},
45+
parameters: {
46+
msw: {
47+
handlers: {
48+
loadSubmission: [mockSubmissionGet(submission), mockSubmissionSummaryGet()],
49+
},
50+
},
51+
},
52+
};
53+
54+
export const Overview = {};
55+
56+
export const BackendValidationErrors = {
57+
parameters: {
58+
msw: {
59+
handlers: {
60+
completeSubmission: [
61+
mockSubmissionCompleteInvalidPost([
62+
{
63+
name: 'steps.0.nonFieldErrors.0',
64+
code: 'invalid',
65+
reason: 'Your carpet is ugly.',
66+
},
67+
{
68+
name: 'steps.0.nonFieldErrors.1',
69+
code: 'invalid',
70+
reason: "And your veg ain't in season.",
71+
},
72+
{
73+
name: 'steps.0.data.component1',
74+
code: 'existential-nightmare',
75+
reason: 'I was waiting in line for ten whole minutes.',
76+
},
77+
]),
78+
],
79+
},
80+
},
81+
},
82+
play: async ({canvasElement, step}) => {
83+
const canvas = within(canvasElement);
84+
85+
await step('Submit form submission to backend', async () => {
86+
await userEvent.click(
87+
await canvas.findByRole('checkbox', {name: /I accept the privacy policy/})
88+
);
89+
await userEvent.click(canvas.getByRole('button', {name: 'Confirm'}));
90+
});
91+
92+
await step('Check validation errors from backend', async () => {
93+
const genericMessage = await canvas.findByText('There are problems with the submitted data.');
94+
expect(genericMessage).toBeVisible();
95+
96+
expect(await canvas.findByText(/Your carpet is ugly/)).toBeVisible();
97+
expect(await canvas.findByText(/And your veg/)).toBeVisible();
98+
expect(await canvas.findByText(/I was waiting in line for ten whole minutes/)).toBeVisible();
99+
});
100+
},
101+
};
+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {UnorderedList, UnorderedListItem} from '@utrecht/component-library-react';
2+
import PropTypes from 'prop-types';
3+
import {FormattedMessage, useIntl} from 'react-intl';
4+
5+
const ErrorType = PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]);
6+
7+
const normalizeError = error => (Array.isArray(error) ? error : [error]);
8+
9+
const StepValidationErrors = ({errors, name, stepData}) => {
10+
const intl = useIntl();
11+
const {nonFieldErrors = [], data = {}} = errors;
12+
13+
const allErrorMessages = [...normalizeError(nonFieldErrors)];
14+
15+
// related the data errors to their matching components
16+
Object.entries(data).map(([key, errorMessage]) => {
17+
const normalizedErrorMessage = normalizeError(errorMessage);
18+
const stepDataItem = stepData.find(item => item.component.key === key);
19+
20+
for (const error of normalizedErrorMessage) {
21+
const message = intl.formatMessage(
22+
{
23+
description: 'Overview page, validation error message for step field.',
24+
defaultMessage: "Field ''{label}'': {error}",
25+
},
26+
{
27+
label: stepDataItem.name,
28+
error: error,
29+
}
30+
);
31+
allErrorMessages.push(message);
32+
}
33+
});
34+
35+
return (
36+
<>
37+
<FormattedMessage
38+
description="Overview page, title before listing the validation errors for a step"
39+
defaultMessage="Problems in form step ''{name}''"
40+
values={{name}}
41+
/>
42+
<UnorderedList className="utrecht-unordered-list--distanced">
43+
{allErrorMessages.map(error => (
44+
<UnorderedListItem key={error}>{error}</UnorderedListItem>
45+
))}
46+
</UnorderedList>
47+
</>
48+
);
49+
};
50+
51+
StepValidationErrors.propTypes = {
52+
errors: PropTypes.shape({
53+
nonFieldErrors: ErrorType,
54+
// keys are the component key names
55+
data: PropTypes.objectOf(ErrorType),
56+
}).isRequired,
57+
name: PropTypes.string.isRequired,
58+
stepData: PropTypes.arrayOf(
59+
PropTypes.shape({
60+
name: PropTypes.string.isRequired,
61+
value: PropTypes.oneOfType([
62+
PropTypes.string,
63+
PropTypes.object,
64+
PropTypes.array,
65+
PropTypes.number,
66+
PropTypes.bool,
67+
]),
68+
component: PropTypes.object.isRequired,
69+
})
70+
).isRequired,
71+
};
72+
73+
/**
74+
* Render the validation errors received from the backend.
75+
*/
76+
const ValidationErrors = ({errors, summaryData}) => {
77+
const {steps = []} = errors;
78+
if (steps.length === 0) return null;
79+
return (
80+
<UnorderedList className="utrecht-unordered-list--distanced">
81+
{steps.map((stepErrors, index) => (
82+
<UnorderedListItem key={summaryData[index].slug}>
83+
<StepValidationErrors
84+
errors={stepErrors}
85+
name={summaryData[index].name}
86+
stepData={summaryData[index].data}
87+
/>
88+
</UnorderedListItem>
89+
))}
90+
</UnorderedList>
91+
);
92+
};
93+
94+
ValidationErrors.propTypes = {
95+
/**
96+
* Validation errors as reconstructed from the backend invalidParams
97+
*/
98+
errors: PropTypes.shape({
99+
steps: PropTypes.arrayOf(
100+
PropTypes.shape({
101+
nonFieldErrors: ErrorType,
102+
// keys are the component key names
103+
data: PropTypes.objectOf(ErrorType),
104+
})
105+
),
106+
}),
107+
/**
108+
* Array of summary data per step.
109+
*/
110+
summaryData: PropTypes.arrayOf(
111+
PropTypes.shape({
112+
name: PropTypes.string.isRequired,
113+
slug: PropTypes.string.isRequired,
114+
data: PropTypes.arrayOf(
115+
PropTypes.shape({
116+
name: PropTypes.string.isRequired,
117+
value: PropTypes.oneOfType([
118+
PropTypes.string,
119+
PropTypes.object,
120+
PropTypes.array,
121+
PropTypes.number,
122+
PropTypes.bool,
123+
]),
124+
component: PropTypes.object.isRequired,
125+
})
126+
).isRequired,
127+
})
128+
),
129+
};
130+
131+
export default ValidationErrors;

0 commit comments

Comments
 (0)