diff --git a/app/javascript/packages/form-steps/form-steps.spec.tsx b/app/javascript/packages/form-steps/form-steps.spec.tsx index a562f057d76..dd96c348e90 100644 --- a/app/javascript/packages/form-steps/form-steps.spec.tsx +++ b/app/javascript/packages/form-steps/form-steps.spec.tsx @@ -5,6 +5,7 @@ import { waitFor } from '@testing-library/dom'; import sinon from 'sinon'; import { PageHeading } from '@18f/identity-components'; import * as analytics from '@18f/identity-analytics'; +import { t } from '@18f/identity-i18n'; import FormSteps, { FormStepComponentProps, getStepIndexByName } from './form-steps'; import FormError from './form-error'; import FormStepsContext from './form-steps-context'; @@ -390,6 +391,38 @@ describe('FormSteps', () => { expect(window.location.hash).to.equal('#second'); }); + it('retains errors from prior steps', async () => { + const errors = [ + { + field: 'nonExistentField1', + error: new FormError('abcde'), + }, + { + field: 'nonExistentField2', + error: new FormError('12345'), + }, + ]; + const { getByText, findByText } = render( + , + ); + + const checkFormHasExpectedErrors = () => + findByText('Errors:', { exact: false }).then((e) => + expect(e.parentElement?.textContent).contains('abcde,12345'), + ); + + await expect(checkFormHasExpectedErrors()).to.be.fulfilled(); + + await userEvent.click(getByText(t('forms.buttons.continue'))); + + await expect(findByText('Second Title')).to.be.fulfilled(); + await expect(checkFormHasExpectedErrors()).to.be.rejected(); + + window.history.back(); + + await expect(checkFormHasExpectedErrors()).to.be.fulfilled(); + }); + it('shifts focus to next heading on step change', async () => { const { getByText } = render(); diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx index 72486e91b8c..87e6af1bd84 100644 --- a/app/javascript/packages/form-steps/form-steps.tsx +++ b/app/javascript/packages/form-steps/form-steps.tsx @@ -177,6 +177,10 @@ interface FormStepsProps { titleFormat?: string; } +interface PreviousStepErrorsLookup { + [stepName: string]: FormStepError>[] | undefined; +} + /** * React hook which sets page title for the current step. * @@ -246,6 +250,7 @@ function FormSteps({ const didSubmitWithErrors = useRef(false); const forceRender = useForceRender(); const ifStillMounted = useIfStillMounted(); + useEffect(() => { if (activeErrors.length && didSubmitWithErrors.current) { const activeErrorFieldElement = getFieldActiveErrorFieldElement(activeErrors, fields.current); @@ -271,6 +276,17 @@ function FormSteps({ const stepIndex = Math.max(getStepIndexByName(steps, stepName), 0); const step = steps[stepIndex] as FormStep | undefined; + // Preserve/restore non-blocking errors for each step regardless of field association + const [previousStepErrors, setPreviousStepErrors] = useState({}); + useEffect(() => { + if (step?.name) { + const prevErrs = previousStepErrors[step?.name]; + if (prevErrs && prevErrs.length > 0) { + setActiveErrors(prevErrs); + } + } + }, [step?.name, previousStepErrors]); + /** * After a change in content, maintain focus by resetting to the beginning of the new content. */ @@ -361,6 +377,10 @@ function FormSteps({ } const nextActiveErrors = getValidationErrors(); + setPreviousStepErrors((prev) => ({ + ...prev, + [stepName || steps[0].name]: activeErrors, + })); setActiveErrors(nextActiveErrors); if (nextActiveErrors.length) { didSubmitWithErrors.current = true;