Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display backend validation errors when submitting the form #792

Merged
merged 4 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/api-mocks/submissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export const mockSubmissionSummaryGet = () =>
data: [
{
name: SUBMISSION_STEP_DETAILS.formStep.configuration.components[0].label,
value: 'Compnent 1 value',
value: 'Component 1 value',
component: SUBMISSION_STEP_DETAILS.formStep.configuration.components[0],
},
],
Expand All @@ -119,6 +119,22 @@ export const mockSubmissionCompletePost = () =>
})
);

export const mockSubmissionCompleteInvalidPost = invalidParams =>
http.post(`${BASE_URL}submissions/:uuid/_complete`, () =>
HttpResponse.json(
{
type: 'http://localhost:8000/fouten/ValidationError/',
code: 'invalid',
title: 'Does not validate.',
status: 400,
detail: '',
instance: 'urn:uuid:41e0174a-efc2-4cc0-9bf2-8366242a4e75',
invalidParams,
},
{status: 400}
)
);

/**
* Simulate a successful backend processing status without payment.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/components/CoSign/Cosign.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,5 @@ test('Load submission summary after backend authentication', async () => {

await screen.findByRole('heading', {name: 'Check and co-sign submission', level: 1});
// wait for summary to load from the backend
await screen.findByText('Compnent 1 value');
await screen.findByText('Component 1 value');
});
13 changes: 11 additions & 2 deletions src/components/Form.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {useContext, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {Navigate, Outlet, useLocation, useNavigate, useSearchParams} from 'react-router';
import {Navigate, Outlet, useLocation, useMatch, useNavigate, useSearchParams} from 'react-router';
import {usePrevious} from 'react-use';

import {ConfigContext} from 'Context';
Expand Down Expand Up @@ -35,6 +35,7 @@ const Form = () => {
const intl = useIntl();
const prevLocale = usePrevious(intl.locale);
const {state: routerState} = useLocation();
const confirmationMatch = useMatch('/bevestiging');

// extract the declared properties and configuration
const config = useContext(ConfigContext);
Expand Down Expand Up @@ -102,10 +103,18 @@ const Form = () => {
return <Loader modifiers={['centered']} />;
}

// don't render the PI if the form is configured to never display the progress
// indicator, or we're on the final confirmation page
const showProgressIndicator = form.showProgressIndicator && !confirmationMatch;

// render the container for the router and necessary context providers for deeply
// nested child components
return (
<FormDisplay progressIndicator={<FormProgressIndicator submission={submission} />}>
<FormDisplay
progressIndicator={
showProgressIndicator ? <FormProgressIndicator submission={submission} /> : null
}
>
<AnalyticsToolsConfigProvider>
<SubmissionProvider
submission={submission}
Expand Down
9 changes: 1 addition & 8 deletions src/components/FormProgressIndicator.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {useIntl} from 'react-intl';
import {matchPath, useLocation, useMatch} from 'react-router';
import {matchPath, useLocation} from 'react-router';

import ProgressIndicator from 'components/ProgressIndicator';
import {addFixedSteps, getStepsInfo} from 'components/ProgressIndicator/utils';
Expand Down Expand Up @@ -76,15 +76,8 @@ const getMobileStepTitle = (intl, pathname, form) => {
const FormProgressIndicator = ({submission}) => {
const form = useFormContext();
const {pathname: currentPathname, state: routerState} = useLocation();
const confirmationMatch = useMatch('/bevestiging');
const intl = useIntl();

// don't render anything if the form is configured to never display the progress
// indicator, or we're on the final confirmation page
if (!form.showProgressIndicator || confirmationMatch) {
return null;
}

// otherwise collect the necessary information to render the PI.
const isCompleted = !!routerState?.statusUrl;
const steps = getProgressIndicatorSteps({intl, form, submission, currentPathname, isCompleted});
Expand Down
14 changes: 10 additions & 4 deletions src/components/Summary/GenericSummary.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,19 @@ const GenericSummary = ({
const Wrapper = submissionAllowed === SUBMISSION_ALLOWED.yes ? Form : 'div';

if (isLoading) {
return <Loader modifiers={['centered']} />;
return (
<Card title={title}>
<Loader modifiers={['centered']} />
</Card>
);
}

return (
<Card title={title}>
{errors.map(error => (
<ErrorMessage key={error}>{error}</ErrorMessage>
{errors.map((error, index) => (
<div className="openforms-card__alert" key={`error-${index}`}>
<ErrorMessage>{error}</ErrorMessage>
</div>
))}
<Formik
initialValues={{privacyPolicyAccepted: false, statementOfTruthAccepted: false}}
Expand Down Expand Up @@ -102,7 +108,7 @@ GenericSummary.propTypes = {
editStepText: PropTypes.string,
isLoading: PropTypes.bool,
isAuthenticated: PropTypes.bool,
errors: PropTypes.arrayOf(PropTypes.string),
errors: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.node, PropTypes.string])),
prevPage: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
onDestroySession: PropTypes.func.isRequired,
Expand Down
26 changes: 24 additions & 2 deletions src/components/Summary/SubmissionSummary.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
import {useSubmissionContext} from 'components/SubmissionProvider';
import {SUBMISSION_ALLOWED} from 'components/constants';
import {findPreviousApplicableStep} from 'components/utils';
import {ValidationError} from 'errors';
import useFormContext from 'hooks/useFormContext';
import useRefreshSubmission from 'hooks/useRefreshSubmission';
import useTitle from 'hooks/useTitle';

import GenericSummary from './GenericSummary';
import ValidationErrors from './ValidationErrors';
import {loadSummaryData} from './data';

const completeSubmission = async (submission, statementValues) => {
Expand All @@ -29,7 +31,7 @@
const {submission, onDestroySession, removeSubmissionId} = useSubmissionContext();
const refreshedSubmission = useRefreshSubmission(submission);

const [submitError, setSubmitError] = useState('');
const [submitErrors, setSubmitErrors] = useState(null);

const pageTitle = intl.formatMessage({
description: 'Summary page title',
Expand All @@ -52,11 +54,17 @@

const onSubmit = async statementValues => {
if (refreshedSubmission.submissionAllowed !== SUBMISSION_ALLOWED.yes) return;

let statusUrl;
try {
statusUrl = await completeSubmission(refreshedSubmission, statementValues);
} catch (e) {
setSubmitError(e.message);
if (e instanceof ValidationError) {
const {initialErrors} = e.asFormikProps();
setSubmitErrors(initialErrors);
} else {
setSubmitErrors(e.message);

Check warning on line 66 in src/components/Summary/SubmissionSummary.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/Summary/SubmissionSummary.jsx#L65-L66

Added lines #L65 - L66 were not covered by tests
}
return;
}

Expand Down Expand Up @@ -86,6 +94,20 @@
navigate(getPreviousPage());
};

const submitError =
submitErrors &&
(typeof submitErrors === 'string' ? (
submitErrors

Check warning on line 100 in src/components/Summary/SubmissionSummary.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/Summary/SubmissionSummary.jsx#L100

Added line #L100 was not covered by tests
) : (
<>
<FormattedMessage
description="Summary page generic validation error message"
defaultMessage="There are problems with the submitted data."
/>
<ValidationErrors errors={submitErrors} summaryData={summaryData} />
</>
));

const errorMessages = [location.state?.errorMessage, submitError].filter(Boolean);

return (
Expand Down
101 changes: 101 additions & 0 deletions src/components/Summary/SubmissionSummary.stories.jsx
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();
});
},
};
Loading