app/javascript/packages/form-steps/form-steps.tsx
import { useEffect, useRef, useState } from 'react';
import type { FC, FormEventHandler, RefCallback } from 'react';
import { Alert } from '@18f/identity-components';
import { replaceVariables } from '@18f/identity-i18n';
import { useDidUpdateEffect, useIfStillMounted } from '@18f/identity-react-hooks';
import RequiredValueMissingError from './required-value-missing-error';
import FormStepsContext from './form-steps-context';
import PromptOnNavigate from './prompt-on-navigate';
import useHistoryParam from './use-history-param';
import useForceRender from './use-force-render';
import FormError from './form-error';
export interface FormStepError<V> {
/**
* Name of field for which error occurred.
*/
field?: keyof V;
/**
* Error object.
*/
error: Error;
}
interface FormStepRegisterFieldOptions {
/**
* Whether field is required.
*/
isRequired: boolean;
}
export type RegisterFieldCallback = (
field: string,
options?: Partial<FormStepRegisterFieldOptions>,
) => undefined | RefCallback<HTMLElement>;
export type OnErrorCallback = (error: Error, options?: { field?: string | null }) => void;
type FormValues = Record<string, any>;
export interface FormStepComponentProps<V> {
/**
* Update values, merging with existing values if configured as a patch.
*/
onChange: (nextValues: Partial<V>, options?: { patch: boolean }) => void;
/**
* Trigger a field error.
*/
onError: OnErrorCallback;
/**
* Current values.
*/
value: Partial<V>;
/**
* Current active errors.
*/
errors: FormStepError<V>[];
/**
* Current top-level errors.
*/
unknownFieldErrors: FormStepError<V>[];
/**
* Registers field by given name, returning ref assignment function.
*/
registerField: RegisterFieldCallback;
/**
* Callback to navigate to the previous step.
*/
toPreviousStep: () => void;
}
export interface FormStep<V extends FormValues = {}> {
/**
* Step name, used in history parameter.
*/
name: string;
/**
* Step form component.
*/
form: FC<FormStepComponentProps<V>>;
/**
* Optionally-asynchronous submission behavior, expected to throw any submission error.
*/
submit?: (values: V) => void | Record<string, any> | Promise<void | Record<string, any>>;
/**
* Human-readable step label.
*/
title?: string;
}
interface FieldsRefEntry {
/**
* Ref callback.
*/
refCallback: RefCallback<HTMLElement>;
/**
* Whether field is required.
*/
isRequired: boolean;
/**
* Element assigned by ref callback.
*/
element: HTMLElement | null;
}
interface FormStepsProps {
/**
* Form steps.
*/
steps?: FormStep<any>[];
/**
* Step at which to start form.
*/
initialStep?: string;
/**
* Form values to populate initial state.
*/
initialValues?: Record<string, any>;
/**
* Errors to initialize state.
*/
initialActiveErrors?: FormStepError<Record<string, Error>>[];
/**
* Whether to automatically focus heading on mount.
*/
autoFocus?: boolean;
/**
* Form values change callback.
*/
onChange?: (values: FormValues) => void;
/**
* Form completion callback.
*/
onComplete?: (values: FormValues) => void;
/**
* Callback triggered on step change.
*/
onStepChange?: (stepName: string) => void;
/**
* Callback triggered on step submit.
*/
onStepSubmit?: (stepName: string) => void;
/**
* Whether to prompt the user about unsaved changes when navigating away from an in-progress form.
* Defaults to true.
*/
promptOnNavigate?: boolean;
/**
* Format string for page title, interpolated with step title as `%{step}` parameter.
*/
titleFormat?: string;
}
interface PreviousStepErrorsLookup {
[stepName: string]: FormStepError<Record<string, Error>>[] | undefined;
}
/**
* React hook which sets page title for the current step.
*
* @param step Current step.
* @param titleFormat Format string for page title.
*/
function useStepTitle(step?: FormStep<any>, titleFormat?: string) {
useEffect(() => {
if (titleFormat && step?.title) {
document.title = replaceVariables(titleFormat, { step: step.title });
}
}, [step]);
}
/**
* 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 steps Form steps.
* @param name Step to search.
*
* @return Step index.
*/
export function getStepIndexByName(steps: FormStep<any>[], name?: string) {
return name ? steps.findIndex((step) => step.name === name) : -1;
}
/**
* Returns the first element matched to a field from a set of errors, if exists.
*
* @param errors Active form step errors.
* @param fields Current fields.
*/
function getFieldActiveErrorFieldElement(
errors: FormStepError<Record<string, Error>>[],
fields: Record<string, FieldsRefEntry>,
) {
const error = errors.find(({ field }) => field && fields[field]?.element);
if (error) {
return fields[error.field!].element || undefined;
}
}
export function StepErrorAlert({ error }: { error: Error }) {
const { message } = error;
const messageProcessor = error instanceof FormError ? error.messageProcessor : null;
const transformedMessage = messageProcessor ? messageProcessor(message) : message;
return (
<Alert key={message} type="error" className="margin-bottom-4">
{transformedMessage}
</Alert>
);
}
function FormSteps({
steps = [],
onChange = () => {},
onComplete = () => {},
onStepChange = () => {},
onStepSubmit = () => {},
initialStep,
initialValues = {},
initialActiveErrors = [],
autoFocus,
promptOnNavigate = true,
titleFormat,
}: FormStepsProps) {
const stepNames = steps.map((step) => step.name);
const [values, setValues] = useState(initialValues);
const [activeErrors, setActiveErrors] = useState(initialActiveErrors);
const formRef = useRef(null as HTMLFormElement | null);
const [stepName, setStepName] = useHistoryParam(initialStep, stepNames);
const [stepErrors, setStepErrors] = useState([] as Error[]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [stepCanComplete, setStepCanComplete] = useState<boolean | undefined>(undefined);
const fields = useRef({} as Record<string, FieldsRefEntry>);
const didSubmitWithErrors = useRef(false);
const forceRender = useForceRender();
const ifStillMounted = useIfStillMounted();
useEffect(() => {
if (activeErrors.length && didSubmitWithErrors.current) {
const activeErrorFieldElement = getFieldActiveErrorFieldElement(activeErrors, fields.current);
if (activeErrorFieldElement) {
if (activeErrorFieldElement instanceof HTMLInputElement) {
activeErrorFieldElement.reportValidity();
}
activeErrorFieldElement.focus();
}
}
didSubmitWithErrors.current = false;
}, [activeErrors]);
useEffect(() => {
// reset stepName if it doesn't correspond to an existing step
const stepsCheck = steps.map((step) => step?.name).filter(Boolean);
if (stepName && !stepsCheck.includes(stepName)) {
setStepName(undefined);
}
}, [stepName, steps]);
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<PreviousStepErrorsLookup>({});
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.
*/
function onPageTransition() {
const firstElementChild = formRef.current?.firstElementChild;
if (firstElementChild instanceof window.HTMLElement) {
firstElementChild.classList.add('form-steps__focus-anchor');
firstElementChild.setAttribute('tabindex', '-1');
firstElementChild.focus();
}
setStepName(stepName);
}
useStepTitle(step, titleFormat);
useDidUpdateEffect(() => onStepChange(stepName!), [step]);
useDidUpdateEffect(onPageTransition, [step]);
useDidUpdateEffect(() => onChange(values), [values]);
useEffect(() => {
// Treat explicit initial step the same as step transition, placing focus to header.
if (autoFocus) {
onPageTransition();
}
}, []);
useEffect(() => {
if (stepErrors.length) {
onPageTransition();
}
}, [stepErrors]);
/**
* Returns array of form errors for the current set of values.
*/
function getValidationErrors(): FormStepError<Record<string, Error>>[] {
return Object.keys(fields.current).reduce(
(result, key) => {
const { element, isRequired } = fields.current[key];
const isActive = !!element;
let error: Error | undefined;
if (isActive) {
if (element instanceof HTMLInputElement) {
element.checkValidity();
}
if (element instanceof HTMLInputElement && element.validationMessage) {
error = new Error(element.validationMessage);
} else if (isRequired && !values[key]) {
error = new RequiredValueMissingError();
}
}
if (error) {
result = result.concat({ field: key, error });
}
return result;
},
[] as FormStepError<Record<string, Error>>[],
);
}
// An empty steps array is allowed, in which case there is nothing to render.
if (!step) {
return null;
}
const setPatchValues = (patch: Partial<FormValues>) =>
setValues((prevValues) => ({ ...prevValues, ...patch }));
const unknownFieldErrors = activeErrors.filter(
({ field }) => !field || !fields.current[field]?.element,
);
const hasUnresolvedFieldErrors =
activeErrors.length && activeErrors.length > unknownFieldErrors.length;
const { form: Form, submit, name } = step;
/**
* Increments state to the next step, or calls onComplete callback
* if the current step is the last step.
*/
const toNextStep: FormEventHandler = async (event) => {
event.preventDefault();
// Don't proceed if field errors have yet to be resolved.
if (hasUnresolvedFieldErrors) {
setActiveErrors(Array.from(activeErrors));
didSubmitWithErrors.current = true;
return;
}
const nextActiveErrors = getValidationErrors();
setPreviousStepErrors((prev) => ({
...prev,
[stepName || steps[0].name]: activeErrors,
}));
setActiveErrors(nextActiveErrors);
if (nextActiveErrors.length) {
didSubmitWithErrors.current = true;
return;
}
if (submit) {
try {
setIsSubmitting(true);
const patchValues = await submit(values);
if (patchValues) {
setPatchValues(patchValues);
}
setIsSubmitting(false);
} catch (error) {
setActiveErrors([{ error }]);
setIsSubmitting(false);
return;
}
}
onStepSubmit(step?.name);
const nextStepIndex = stepIndex + 1;
const isComplete =
stepCanComplete !== undefined ? stepCanComplete : nextStepIndex === steps.length;
if (isComplete) {
onComplete(values);
} else {
const { name: nextStepName } = steps[nextStepIndex];
setStepName(nextStepName);
}
// unset stepCanComplete so the next step that needs to can set it
setStepCanComplete(undefined);
};
const toPreviousStep = () => {
const previousStepIndex = Math.max(stepIndex - 1, 0);
const { name: nextStepName } = steps[previousStepIndex];
setStepName(nextStepName);
};
// wrap setter in a function to pass to FormStepsContext
const changeStepCanComplete = (isComplete?: boolean) => {
setStepCanComplete(isComplete);
};
const isLastStep = stepIndex + 1 === steps.length;
return (
<form ref={formRef} onSubmit={toNextStep} noValidate>
{promptOnNavigate && Object.keys(values).length > 0 && <PromptOnNavigate />}
{stepErrors.map((error) => (
<StepErrorAlert key={error.message} error={error} />
))}
<FormStepsContext.Provider
value={{ isLastStep, changeStepCanComplete, isSubmitting, onPageTransition }}
>
<Form
key={name}
value={values}
errors={activeErrors}
unknownFieldErrors={unknownFieldErrors}
onChange={ifStillMounted((nextValues, { patch } = { patch: true }) => {
setActiveErrors((prevActiveErrors) =>
prevActiveErrors.filter(({ field }) => !field || !(field in nextValues)),
);
if (patch) {
setPatchValues(nextValues);
} else {
setValues(nextValues);
}
})}
onError={ifStillMounted((error, { field } = {}) => {
if (field) {
setActiveErrors((prevActiveErrors) => prevActiveErrors.concat({ field, error }));
} else {
setStepErrors([error]);
}
})}
registerField={(field, options = {}) => {
if (!fields.current[field]) {
fields.current[field] = {
refCallback(fieldNode) {
fields.current[field].element = fieldNode;
if (activeErrors.length) {
forceRender();
}
},
element: null,
isRequired: !!options.isRequired,
};
}
return fields.current[field].refCallback;
}}
toPreviousStep={toPreviousStep}
/>
</FormStepsContext.Provider>
</form>
);
}
export default FormSteps;