-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ [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.
- Loading branch information
1 parent
775bb98
commit 1355641
Showing
10 changed files
with
434 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
import {expect, fn, userEvent, within} from '@storybook/test'; | ||
import {withRouter} from 'storybook-addon-remix-react-router'; | ||
|
||
import {buildForm} from 'api-mocks'; | ||
import { | ||
buildSubmission, | ||
mockSubmissionCompleteInvalidPost, | ||
mockSubmissionGet, | ||
mockSubmissionSummaryGet, | ||
} from 'api-mocks/submissions'; | ||
import SubmissionProvider from 'components/SubmissionProvider'; | ||
import {ConfigDecorator, withForm} from 'story-utils/decorators'; | ||
|
||
import SubmissionSummary from './SubmissionSummary'; | ||
|
||
const form = buildForm(); | ||
const submission = buildSubmission(); | ||
|
||
export default { | ||
title: 'Private API / SubmissionSummary', | ||
component: SubmissionSummary, | ||
decorators: [ | ||
(Story, {args}) => ( | ||
<SubmissionProvider | ||
submission={args.submission} | ||
onSubmissionObtained={fn()} | ||
onDestroySession={fn()} | ||
removeSubmissionId={fn()} | ||
> | ||
<Story /> | ||
</SubmissionProvider> | ||
), | ||
withRouter, | ||
ConfigDecorator, | ||
withForm, | ||
], | ||
args: { | ||
form, | ||
submission, | ||
}, | ||
argTypes: { | ||
form: {table: {disable: true}}, | ||
submission: {table: {disable: true}}, | ||
}, | ||
parameters: { | ||
msw: { | ||
handlers: { | ||
loadSubmission: [mockSubmissionGet(submission), mockSubmissionSummaryGet()], | ||
}, | ||
}, | ||
}, | ||
}; | ||
|
||
export const Overview = {}; | ||
|
||
export const BackendValidationErrors = { | ||
parameters: { | ||
msw: { | ||
handlers: { | ||
completeSubmission: [ | ||
mockSubmissionCompleteInvalidPost([ | ||
{ | ||
name: 'steps.0.nonFieldErrors.0', | ||
code: 'invalid', | ||
reason: 'Your carpet is ugly.', | ||
}, | ||
{ | ||
name: 'steps.0.nonFieldErrors.1', | ||
code: 'invalid', | ||
reason: "And your veg ain't in season.", | ||
}, | ||
{ | ||
name: 'steps.0.data.component1', | ||
code: 'existential-nightmare', | ||
reason: 'I was waiting in line for ten whole minutes.', | ||
}, | ||
]), | ||
], | ||
}, | ||
}, | ||
}, | ||
play: async ({canvasElement, step}) => { | ||
const canvas = within(canvasElement); | ||
|
||
await step('Submit form submission to backend', async () => { | ||
await userEvent.click( | ||
await canvas.findByRole('checkbox', {name: /I accept the privacy policy/}) | ||
); | ||
await userEvent.click(canvas.getByRole('button', {name: 'Confirm'})); | ||
}); | ||
|
||
await step('Check validation errors from backend', async () => { | ||
const genericMessage = await canvas.findByText('There are problems with the submitted data.'); | ||
expect(genericMessage).toBeVisible(); | ||
|
||
expect(await canvas.findByText(/Your carpet is ugly/)).toBeVisible(); | ||
expect(await canvas.findByText(/And your veg/)).toBeVisible(); | ||
expect(await canvas.findByText(/I was waiting in line for ten whole minutes/)).toBeVisible(); | ||
}); | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
import {UnorderedList, UnorderedListItem} from '@utrecht/component-library-react'; | ||
import PropTypes from 'prop-types'; | ||
import {FormattedMessage, useIntl} from 'react-intl'; | ||
|
||
const ErrorType = PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]); | ||
|
||
const normalizeError = error => (Array.isArray(error) ? error : [error]); | ||
|
||
const StepValidationErrors = ({errors, name, stepData}) => { | ||
const intl = useIntl(); | ||
const {nonFieldErrors = [], data = {}} = errors; | ||
|
||
const allErrorMessages = [...normalizeError(nonFieldErrors)]; | ||
|
||
// related the data errors to their matching components | ||
Object.entries(data).map(([key, errorMessage]) => { | ||
const normalizedErrorMessage = normalizeError(errorMessage); | ||
const stepDataItem = stepData.find(item => item.component.key === key); | ||
|
||
for (const error of normalizedErrorMessage) { | ||
const message = intl.formatMessage( | ||
{ | ||
description: 'Overview page, validation error message for step field.', | ||
defaultMessage: "Field ''{label}'': {error}", | ||
}, | ||
{ | ||
label: stepDataItem.name, | ||
error: error, | ||
} | ||
); | ||
allErrorMessages.push(message); | ||
} | ||
}); | ||
|
||
return ( | ||
<> | ||
<FormattedMessage | ||
description="Overview page, title before listing the validation errors for a step" | ||
defaultMessage="Problems in form step ''{name}''" | ||
values={{name}} | ||
/> | ||
<UnorderedList className="utrecht-unordered-list--distanced"> | ||
{allErrorMessages.map(error => ( | ||
<UnorderedListItem key={error}>{error}</UnorderedListItem> | ||
))} | ||
</UnorderedList> | ||
</> | ||
); | ||
}; | ||
|
||
StepValidationErrors.propTypes = { | ||
errors: PropTypes.shape({ | ||
nonFieldErrors: ErrorType, | ||
// keys are the component key names | ||
data: PropTypes.objectOf(ErrorType), | ||
}).isRequired, | ||
name: PropTypes.string.isRequired, | ||
stepData: PropTypes.arrayOf( | ||
PropTypes.shape({ | ||
name: PropTypes.string.isRequired, | ||
value: PropTypes.oneOfType([ | ||
PropTypes.string, | ||
PropTypes.object, | ||
PropTypes.array, | ||
PropTypes.number, | ||
PropTypes.bool, | ||
]), | ||
component: PropTypes.object.isRequired, | ||
}) | ||
).isRequired, | ||
}; | ||
|
||
/** | ||
* Render the validation errors received from the backend. | ||
*/ | ||
const ValidationErrors = ({errors, summaryData}) => { | ||
const {steps = []} = errors; | ||
if (steps.length === 0) return null; | ||
return ( | ||
<UnorderedList className="utrecht-unordered-list--distanced"> | ||
{steps.map((stepErrors, index) => ( | ||
<UnorderedListItem key={summaryData[index].slug}> | ||
<StepValidationErrors | ||
errors={stepErrors} | ||
name={summaryData[index].name} | ||
stepData={summaryData[index].data} | ||
/> | ||
</UnorderedListItem> | ||
))} | ||
</UnorderedList> | ||
); | ||
}; | ||
|
||
ValidationErrors.propTypes = { | ||
/** | ||
* Validation errors as reconstructed from the backend invalidParams | ||
*/ | ||
errors: PropTypes.shape({ | ||
steps: PropTypes.arrayOf( | ||
PropTypes.shape({ | ||
nonFieldErrors: ErrorType, | ||
// keys are the component key names | ||
data: PropTypes.objectOf(ErrorType), | ||
}) | ||
), | ||
}), | ||
/** | ||
* Array of summary data per step. | ||
*/ | ||
summaryData: PropTypes.arrayOf( | ||
PropTypes.shape({ | ||
name: PropTypes.string.isRequired, | ||
slug: PropTypes.string.isRequired, | ||
data: PropTypes.arrayOf( | ||
PropTypes.shape({ | ||
name: PropTypes.string.isRequired, | ||
value: PropTypes.oneOfType([ | ||
PropTypes.string, | ||
PropTypes.object, | ||
PropTypes.array, | ||
PropTypes.number, | ||
PropTypes.bool, | ||
]), | ||
component: PropTypes.object.isRequired, | ||
}) | ||
).isRequired, | ||
}) | ||
), | ||
}; | ||
|
||
export default ValidationErrors; |
Oops, something went wrong.