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

Refactor form step validation as required field #4243

Merged
merged 3 commits into from
Sep 29, 2020
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"no-confusing-arrow": "off",
"no-plusplus": "off",
"no-unused-expressions": "off",
"no-use-before-define": "off",
aduth marked this conversation as resolved.
Show resolved Hide resolved
"implicit-arrow-linebreak": "off",
"object-curly-newline": "off",
"operator-linebreak": "off",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import React, { useState, useContext } from 'react';
import { Alert } from '@18f/identity-components';
import FormSteps from './form-steps';
import { UploadFormEntriesError } from '../services/upload';
import DocumentsStep, { validate as validateDocumentsStep } from './documents-step';
import SelfieStep, { validate as validateSelfieStep } from './selfie-step';
import DocumentsStep from './documents-step';
import SelfieStep from './selfie-step';
import MobileIntroStep from './mobile-intro-step';
import DeviceContext from '../context/device';
import Submission from './submission';
Expand Down Expand Up @@ -53,13 +53,11 @@ function DocumentCapture({ isLivenessEnabled = true }) {
footer: isMobile
? undefined
: () => <p>{t('doc_auth.info.document_capture_upload_image')}</p>,
validate: validateDocumentsStep,
},
isLivenessEnabled && {
name: 'selfie',
title: t('doc_auth.headings.selfie'),
form: SelfieStep,
validate: validateSelfieStep,
},
].filter(Boolean));

Expand All @@ -74,10 +72,6 @@ function DocumentCapture({ isLivenessEnabled = true }) {
}

const isFormEntriesError = submissionError && submissionError instanceof UploadFormEntriesError;
let initialStep;
if (submissionError) {
initialStep = isFormEntriesError || !isLivenessEnabled ? 'documents' : 'selfie';
}

return formValues && !submissionError ? (
<Submission
Expand All @@ -98,8 +92,8 @@ function DocumentCapture({ isLivenessEnabled = true }) {
<FormSteps
steps={steps}
initialValues={submissionError && formValues ? formValues : undefined}
initialStep={initialStep}
onComplete={submitForm}
autoFocus={!!submissionError}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import React, { useContext } from 'react';
import AcuantCapture from './acuant-capture';
import FormErrorMessage from './form-error-message';
import { RequiredValueMissingError } from './form-steps';
import useI18n from '../hooks/use-i18n';
import DeviceContext from '../context/device';

/**
* @template V
* @typedef {import('./form-steps').FormStepValidateResult<V>} FormStepValidateResult
*/

/**
* @typedef DocumentsStepValue
*
Expand Down Expand Up @@ -51,7 +45,7 @@ function DocumentsStep({
return (
<AcuantCapture
key={side}
ref={registerField(side)}
ref={registerField(side, { isRequired: true })}
/* i18n-tasks-use t('doc_auth.headings.document_capture_back') */
/* i18n-tasks-use t('doc_auth.headings.document_capture_front') */
label={t(`doc_auth.headings.document_capture_${side}`)}
Expand All @@ -69,14 +63,4 @@ function DocumentsStep({
);
}

/**
* @type {import('./form-steps').FormStepValidate<DocumentsStepValue>}
*/
export function validate(values) {
return DOCUMENT_SIDES.filter((side) => !values[side]).map((side) => ({
field: side,
error: new RequiredValueMissingError(),
}));
}

export default DocumentsStep;
126 changes: 45 additions & 81 deletions app/javascript/packages/document-capture/components/form-steps.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,47 +13,42 @@ import useHistoryParam from '../hooks/use-history-param';
* @template V
*/

/**
* @typedef FormStepRegisterFieldOptions
*
* @prop {boolean} isRequired Whether field is required.
*/

/**
* @typedef FormStepComponentProps
*
* @prop {(nextValues:Partial<V>)=>void} onChange Values change callback, merged with
* existing values.
* @prop {Partial<V>} value Current values.
* @prop {FormStepError<V>[]=} errors Current active errors.
* @prop {(field:string)=>undefined|((fieldNode:HTMLElement?)=>void)} registerField Registers field
* @prop {(
* field:string,
* options?:Partial<FormStepRegisterFieldOptions>
* )=>undefined|import('react').RefCallback<HTMLElement>} registerField Registers field
* by given name, returning ref assignment function.
*
* @template V
*/

/**
* @template {Record<string,any>} V
*
* @typedef {FormStepError<V>[]} FormStepValidateResult
*/

/**
* @template {Record<string,any>} V
*
* @typedef {(values:V)=>undefined|FormStepValidateResult<V>} FormStepValidate
*/

/**
* @typedef FormStep
*
* @prop {string} name Step name, used in history parameter.
* @prop {string} title Step title, shown as heading.
* @prop {import('react').FC<FormStepComponentProps<Record<string,any>>>} form Step form component.
* @prop {import('react').FC=} footer Optional step footer component.
* @prop {FormStepValidate<Record<string,any>>} validate Step validity function. Given set of form
* values, returns an object with keys from form values mapped to an error, if applicable. Returns
* undefined or an empty object if there are no errors.
*/

/**
* @typedef FieldsRefEntry
*
* @prop {import('react').RefCallback<HTMLElement>} refCallback Ref callback.
* @prop {boolean} isRequired Whether field is required.
* @prop {HTMLElement?=} element Element assigned by ref callback.
*/

Expand All @@ -62,7 +57,7 @@ import useHistoryParam from '../hooks/use-history-param';
*
* @prop {FormStep[]=} steps Form steps.
* @prop {Record<string,any>=} initialValues Form values to populate initial state.
* @prop {string=} initialStep Step to start from.
* @prop {boolean=} autoFocus Whether to automatically focus heading on mount.
* @prop {(values:Record<string,any>)=>void=} onComplete Form completion callback.
*/

Expand All @@ -71,30 +66,6 @@ import useHistoryParam from '../hooks/use-history-param';
*/
export class RequiredValueMissingError extends Error {}

/**
* 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 {Record<string,any>} values Current form values.
*/
function getValidationErrors(step, values) {
const { validate = () => undefined } = step;
return validate(values);
}

/**
* 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 {Record<string,any>} values Current form values.
*/
export function isStepValid(step, values) {
const errors = getValidationErrors(step, values);
return !errors || !Object.keys(errors).length;
}

/**
* 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.
Expand All @@ -108,32 +79,17 @@ 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;
}

/**
* @param {FormStepsProps} props Props object.
*/
function FormSteps({ steps = [], onComplete = () => {}, initialValues = {}, initialStep }) {
function FormSteps({ steps = [], onComplete = () => {}, initialValues = {}, autoFocus }) {
const [values, setValues] = useState(initialValues);
const [activeErrors, setActiveErrors] = useState(
/** @type {FormStepValidateResult<Record<string,Error>>=} */ (undefined),
/** @type {FormStepError<Record<string,Error>>[]=} */ (undefined),
);
const formRef = useRef(/** @type {?HTMLFormElement} */ (null));
const headingRef = useRef(/** @type {?HTMLHeadingElement} */ (null));
const [stepName, setStepName] = useHistoryParam('step', initialStep);
const [stepName, setStepName] = useHistoryParam('step', null);
const { t } = useI18n();
const fields = useRef(/** @type {Record<string,FieldsRefEntry>} */ ({}));
const didSubmitWithErrors = useRef(false);
Expand All @@ -146,23 +102,12 @@ function FormSteps({ steps = [], onComplete = () => {}, initialValues = {}, init
didSubmitWithErrors.current = false;
}, [activeErrors]);

// 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);
}
const stepIndex = Math.max(getStepIndexByName(steps, stepName), 0);
const step = steps[stepIndex];

useEffect(() => {
// Treat explicit initial step the same as step transition, placing focus to header.
if (initialStep && headingRef.current) {
if (autoFocus && headingRef.current) {
headingRef.current.focus();
}
}, []);
Expand All @@ -171,16 +116,34 @@ function FormSteps({ steps = [], onComplete = () => {}, initialValues = {}, init
// Errors are assigned at the first attempt to submit. Once errors are assigned, track value
// changes to remove validation errors as they become resolved.
if (activeErrors) {
const nextActiveErrors = getValidationErrors(effectiveStep, values);
const nextActiveErrors = getValidationErrors();
setActiveErrors(nextActiveErrors);
}
}, [values]);

// An empty steps array is allowed, in which case there is nothing to render.
if (!effectiveStep) {
if (!step) {
return null;
}

/**
* Returns array of form errors for the current set of values.
*
* @return {FormStepError<Record<string,Error>>[]}
*/
function getValidationErrors() {
return Object.keys(fields.current).reduce((result, key) => {
const { element, isRequired } = fields.current[key];
const isActive = !!element;

if (isActive && isRequired && !values[key]) {
result = result.concat({ field: key, error: new RequiredValueMissingError() });
}

return result;
}, /** @type {FormStepError<Record<string,Error>>[]} */ ([]));
}

/**
* Increments state to the next step, or calls onComplete callback if the current step is the last
* step.
Expand All @@ -190,14 +153,14 @@ function FormSteps({ steps = [], onComplete = () => {}, initialValues = {}, init
function toNextStep(event) {
event.preventDefault();

const nextActiveErrors = getValidationErrors(effectiveStep, values);
const nextActiveErrors = getValidationErrors();
setActiveErrors(nextActiveErrors);
didSubmitWithErrors.current = true;
if (nextActiveErrors?.length) {
return;
}

const nextStepIndex = effectiveStepIndex + 1;
const nextStepIndex = stepIndex + 1;
const isComplete = nextStepIndex === steps.length;
if (isComplete) {
// Clear step parameter from URL.
Expand All @@ -211,8 +174,8 @@ function FormSteps({ steps = [], onComplete = () => {}, initialValues = {}, init
headingRef.current?.focus();
}

const { form: Form, footer: Footer, name, title } = effectiveStep;
const isLastStep = effectiveStepIndex + 1 === steps.length;
const { form: Form, footer: Footer, name, title } = step;
const isLastStep = stepIndex + 1 === steps.length;
aduth marked this conversation as resolved.
Show resolved Hide resolved

return (
<form ref={formRef} onSubmit={toNextStep}>
Expand All @@ -226,12 +189,13 @@ function FormSteps({ steps = [], onComplete = () => {}, initialValues = {}, init
onChange={(nextValuesPatch) => {
setValues((prevValues) => ({ ...prevValues, ...nextValuesPatch }));
}}
registerField={(field) => {
registerField={(field, options = {}) => {
if (!fields.current[field]) {
fields.current[field] = {
refCallback(fieldNode) {
fields.current[field].element = fieldNode;
},
isRequired: !!options.isRequired,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import React, { useContext } from 'react';
import { hasMediaAccess } from '@18f/identity-device';
import { RequiredValueMissingError } from './form-steps';
import useI18n from '../hooks/use-i18n';
import DeviceContext from '../context/device';
import AcuantCapture from './acuant-capture';
import SelfieCapture from './selfie-capture';
import FormErrorMessage from './form-error-message';

/**
* @template V
* @typedef {import('./form-steps').FormStepValidateResult<V>} FormStepValidateResult
*/

/**
* @typedef SelfieStepValue
*
Expand Down Expand Up @@ -42,7 +36,7 @@ function SelfieStep({
</ul>
{isMobile || !hasMediaAccess() ? (
<AcuantCapture
ref={registerField('selfie')}
ref={registerField('selfie', { isRequired: true })}
capture="user"
label={t('doc_auth.headings.document_capture_selfie')}
bannerText={t('doc_auth.headings.photo')}
Expand All @@ -54,7 +48,7 @@ function SelfieStep({
/>
) : (
<SelfieCapture
ref={registerField('selfie')}
ref={registerField('selfie', { isRequired: true })}
value={value.selfie}
onChange={(nextSelfie) => onChange({ selfie: nextSelfie })}
errorMessage={error ? <FormErrorMessage error={error} /> : undefined}
Expand All @@ -64,11 +58,4 @@ function SelfieStep({
);
}

/**
* @type {import('./form-steps').FormStepValidate<SelfieStepValue>}
*/
export function validate(values) {
return values.selfie ? [] : [{ field: 'selfie', error: new RequiredValueMissingError() }];
}

export default SelfieStep;
Loading