diff --git a/app/javascript/app/document-capture/components/document-capture.jsx b/app/javascript/app/document-capture/components/document-capture.jsx index adbb506c05b..a851a62aff4 100644 --- a/app/javascript/app/document-capture/components/document-capture.jsx +++ b/app/javascript/app/document-capture/components/document-capture.jsx @@ -3,7 +3,7 @@ import AcuantCapture from './acuant-capture'; import DocumentTips from './document-tips'; import Image from './image'; import FormSteps from './form-steps'; -import DocumentsStep from './documents-step'; +import DocumentsStep, { isValid as isDocumentsStepValid } from './documents-step'; import Submission from './submission'; function DocumentCapture() { @@ -29,6 +29,7 @@ function DocumentCapture() { { name: 'documents', component: DocumentsStep, + isValid: isDocumentsStepValid, }, { name: 'selfie', component: () => 'Selfie' }, { name: 'confirm', component: () => 'Confirm?' }, diff --git a/app/javascript/app/document-capture/components/documents-step.jsx b/app/javascript/app/document-capture/components/documents-step.jsx index 812290e59fc..8383347977e 100644 --- a/app/javascript/app/document-capture/components/documents-step.jsx +++ b/app/javascript/app/document-capture/components/documents-step.jsx @@ -53,6 +53,6 @@ DocumentsStep.defaultProps = { * * @return {boolean} Whether step is valid. */ -DocumentsStep.isValid = (value) => Boolean(value.front_image && value.back_image); +export const isValid = (value) => Boolean(value.front_image && value.back_image); export default DocumentsStep; diff --git a/app/javascript/app/document-capture/components/form-steps.jsx b/app/javascript/app/document-capture/components/form-steps.jsx index 30e689bb58a..f4a3071cee2 100644 --- a/app/javascript/app/document-capture/components/form-steps.jsx +++ b/app/javascript/app/document-capture/components/form-steps.jsx @@ -1,19 +1,81 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import Button from './button'; import useI18n from '../hooks/use-i18n'; import useHistoryParam from '../hooks/use-history-param'; +/** + * @typedef FormStep + * + * @prop {string} name Step name, used in history parameter. + * @prop {import('react').Component} component Step component implementation. + * @prop {(values:object)=>boolean} isValid Step validity function. Given set of form values, + * returns true if values satisfy requirements. + */ + +/** + * Given a step object and current set of form values, returns true if the form values would satisfy + * the validity requirements of the step. + * + * @param {FormStep} step Form step. + * @param {object} values Current form values. + */ +export function isStepValid(step, values) { + const { isValid = () => true } = step; + return isValid(values); +} + +/** + * Returns the index of the step in the array which matches the given name. Returns `-1` if there is + * no step found by that name. + * + * @param {FormStep[]} steps Form steps. + * @param {string} name Step to search. + * + * @return {number} Step index. + */ +export function getStepIndexByName(steps, name) { + return steps.findIndex((step) => step.name === name); +} + +/** + * Returns the index of the last step in the array where the values satisfy the requirements of the + * step. If all steps are valid, returns the index of the last member. Returns `-1` if all steps are + * invalid, or if the array is empty. + * + * @param {FormStep[]} steps Form steps. + * @param {object} values Current form values. + * + * @return {number} Step index. + */ +export function getLastValidStepIndex(steps, values) { + const index = steps.findIndex((step) => !isStepValid(step, values)); + return index === -1 ? steps.length - 1 : index - 1; +} + function FormSteps({ steps, onComplete }) { const [values, setValues] = useState({}); const [stepName, setStepName] = useHistoryParam('step'); const t = useI18n(); - const stepIndex = stepName ? steps.findIndex((_step) => _step.name === stepName) : 0; - const step = steps[stepIndex]; + // An "effective" step is computed in consideration of the facts that (1) there may be no history + // parameter present, in which case the first step should be used, and (2) the values may not be + // valid for previous steps, in which case the furthest valid step should be set. + const effectiveStepIndex = Math.max( + Math.min(getStepIndexByName(steps, stepName), getLastValidStepIndex(steps, values) + 1), + 0, + ); + const effectiveStep = steps[effectiveStepIndex]; + useEffect(() => { + // The effective step is used in the initial render, but since it may be out of sync with the + // history parameter, it is synced after mount. + if (effectiveStep && stepName && effectiveStep.name !== stepName) { + setStepName(effectiveStep.name); + } + }, []); // An empty steps array is allowed, in which case there is nothing to render. - if (!step) { + if (!effectiveStep) { return null; } @@ -22,7 +84,7 @@ function FormSteps({ steps, onComplete }) { * step. */ function toNextStep() { - const nextStepIndex = stepIndex + 1; + const nextStepIndex = effectiveStepIndex + 1; const isComplete = nextStepIndex === steps.length; if (isComplete) { // Clear step parameter from URL. @@ -34,10 +96,8 @@ function FormSteps({ steps, onComplete }) { } } - const { component: Component, name } = step; - /** @type {{isValid:(values:object)=>boolean}} */ - const { isValid = () => true } = Component; - const isLastStep = stepIndex + 1 === steps.length; + const { component: Component, name } = effectiveStep; + const isLastStep = effectiveStepIndex + 1 === steps.length; return ( <> @@ -46,7 +106,7 @@ function FormSteps({ steps, onComplete }) { value={values} onChange={(nextValuesPatch) => setValues({ ...values, ...nextValuesPatch })} /> - diff --git a/spec/javascripts/app/document-capture/components/form-steps-spec.jsx b/spec/javascripts/app/document-capture/components/form-steps-spec.jsx index 13a06e28725..2111d62c28b 100644 --- a/spec/javascripts/app/document-capture/components/form-steps-spec.jsx +++ b/spec/javascripts/app/document-capture/components/form-steps-spec.jsx @@ -2,7 +2,11 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import sinon from 'sinon'; import render from '../../../support/render'; -import FormSteps from '../../../../../app/javascript/app/document-capture/components/form-steps'; +import FormSteps, { + isStepValid, + getStepIndexByName, + getLastValidStepIndex, +} from '../../../../../app/javascript/app/document-capture/components/form-steps'; describe('document-capture/components/form-steps', () => { const STEPS = [ @@ -18,6 +22,7 @@ describe('document-capture/components/form-steps', () => { /> ), + isValid: (value) => Boolean(value.second), }, { name: 'last', component: () => Last }, ]; @@ -32,6 +37,59 @@ describe('document-capture/components/form-steps', () => { window.location.hash = originalHash; }); + describe('isStepValid', () => { + it('defaults to true if there is no specified validity function', () => { + const step = { name: 'example' }; + + const result = isStepValid(step, {}); + + expect(result).to.be.true(); + }); + + it('returns the result of the validity function given form values', () => { + const step = { name: 'example', isValid: (value) => value.ok }; + + const result = isStepValid(step, { ok: false }); + + expect(result).to.be.false(); + }); + }); + + describe('getStepIndexByName', () => { + it('returns -1 if no step by name', () => { + const result = getStepIndexByName(STEPS, 'third'); + + expect(result).to.be.equal(-1); + }); + + it('returns index of step by name', () => { + const result = getStepIndexByName(STEPS, 'second'); + + expect(result).to.be.equal(1); + }); + }); + + describe('getLastValidStepIndex', () => { + it('returns -1 if array is empty', () => { + const result = getLastValidStepIndex([], {}); + + expect(result).to.be.equal(-1); + }); + + it('returns -1 if all steps are invalid', () => { + const steps = [...STEPS].map((step) => ({ ...step, isValid: () => false })); + const result = getLastValidStepIndex(steps, {}); + + expect(result).to.be.equal(-1); + }); + + it('returns index of the last valid step', () => { + const result = getLastValidStepIndex(STEPS, { second: 'valid' }); + + expect(result).to.be.equal(2); + }); + }); + it('renders nothing if given empty steps array', () => { const { container } = render(); @@ -67,9 +125,10 @@ describe('document-capture/components/form-steps', () => { }); it('renders submit button at last step', () => { - const { getByText } = render(); + const { getByText, getByRole } = render(); userEvent.click(getByText('forms.buttons.continue')); + userEvent.type(getByRole('textbox'), 'val'); userEvent.click(getByText('forms.buttons.continue')); expect(getByText('forms.buttons.submit.default')).to.be.ok(); @@ -92,6 +151,8 @@ describe('document-capture/components/form-steps', () => { it('pushes step to URL', () => { const { getByText } = render(); + expect(window.location.hash).to.equal(''); + userEvent.click(getByText('forms.buttons.continue')); expect(window.location.hash).to.equal('#step=second'); @@ -121,9 +182,10 @@ describe('document-capture/components/form-steps', () => { done(); }); - const { getByText } = render(); + const { getByText, getByRole } = render(); userEvent.click(getByText('forms.buttons.continue')); + userEvent.type(getByRole('textbox'), 'val'); userEvent.click(getByText('forms.buttons.continue')); userEvent.click(getByText('forms.buttons.submit.default')); }); @@ -135,4 +197,12 @@ describe('document-capture/components/form-steps', () => { expect(document.activeElement).to.equal(getByText('forms.buttons.continue')); }); + + it('validates step completion', () => { + window.location.hash = '#step=last'; + + render(); + + expect(window.location.hash).to.equal('#step=second'); + }); });