Skip to content
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
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { useEffect } from 'react';

/** @typedef {import('react').ReactNode} ReactNode */

/**
* @typedef CallbackOnMountProps
*
* @prop {()=>void} onMount Callback to trigger on mount.
* @prop {JSX.Element?=} children Element children.
*/

/**
* @param {CallbackOnMountProps} props Props object.
*
* @return {JSX.Element?}
*/
function CallbackOnMount({ onMount }) {
function CallbackOnMount({ onMount, children = null }) {
useEffect(() => {
onMount();
}, []);

return null;
return children;
}

export default CallbackOnMount;
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ function CaptureAdvice({ onTryAgain, isAssessedAsGlare, isAssessedAsBlurry }) {
return (
<Warning
heading={t('doc_auth.headings.capture_troubleshooting_tips')}
autoFocus
actionText={t('idv.failure.button.warning')}
actionOnClick={onTryAgain}
troubleshootingHeading={t('idv.troubleshooting.headings.still_having_trouble')}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useContext, useState } from 'react';
import FailedCaptureAttemptsContext from '../context/failed-capture-attempts';
import CallbackOnMount from './callback-on-mount';
import CaptureAdvice from './capture-advice';
import { FormStepsContext } from './form-steps';
import useDidUpdateEffect from '../hooks/use-did-update-effect';

/** @typedef {import('react').ReactNode} ReactNode */

Expand All @@ -18,14 +21,18 @@ function CaptureTroubleshooting({ children }) {
const { failedCaptureAttempts, maxFailedAttemptsBeforeTips, lastAttemptMetadata } = useContext(
FailedCaptureAttemptsContext,
);
const { onPageTransition } = useContext(FormStepsContext);
useDidUpdateEffect(onPageTransition, [didShowTroubleshooting]);
const { isAssessedAsGlare, isAssessedAsBlurry } = lastAttemptMetadata;

return failedCaptureAttempts >= maxFailedAttemptsBeforeTips && !didShowTroubleshooting ? (
<CaptureAdvice
onTryAgain={() => setDidShowTroubleshooting(true)}
isAssessedAsGlare={isAssessedAsGlare}
isAssessedAsBlurry={isAssessedAsBlurry}
/>
<CallbackOnMount onMount={onPageTransition}>
<CaptureAdvice
onTryAgain={() => setDidShowTroubleshooting(true)}
isAssessedAsGlare={isAssessedAsGlare}
isAssessedAsBlurry={isAssessedAsBlurry}
/>
</CallbackOnMount>
) : (
<>{children}</>
);
Expand Down
20 changes: 12 additions & 8 deletions app/javascript/packages/document-capture/components/form-steps.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,16 @@ import './form-steps.scss';
/**
* @typedef FormStepsContext
*
* @prop {boolean} isLastStep
* @prop {boolean} canContinueToNextStep
* @prop {boolean} isLastStep Whether the current step is the last step in the flow.
* @prop {boolean} canContinueToNextStep Whether the user can proceed to the next step.
* @prop {() => void} onPageTransition Callback invoked when content is reset in a page transition.
*/

const FormStepsContext = createContext(
export const FormStepsContext = createContext(
/** @type {FormStepsContext} */ ({
isLastStep: true,
canContinueToNextStep: true,
onPageTransition: () => {},
}),
);

Expand Down Expand Up @@ -153,30 +155,32 @@ function FormSteps({
/**
* After a change in content, maintain focus by resetting to the beginning of the new content.
*/
function focusFirstContent() {
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);
}

useEffect(() => {
// Treat explicit initial step the same as step transition, placing focus to header.
if (autoFocus) {
focusFirstContent();
onPageTransition();
}
}, []);

useEffect(() => {
if (stepErrors.length) {
focusFirstContent();
onPageTransition();
}
}, [stepErrors]);

useDidUpdateEffect(onStepChange, [step]);
useDidUpdateEffect(focusFirstContent, [step]);
useDidUpdateEffect(onPageTransition, [step]);

/**
* Returns array of form errors for the current set of values.
Expand Down Expand Up @@ -253,7 +257,7 @@ function FormSteps({
<FormErrorMessage error={error} isDetail />
</Alert>
))}
<FormStepsContext.Provider value={{ isLastStep, canContinueToNextStep }}>
<FormStepsContext.Provider value={{ isLastStep, canContinueToNextStep, onPageTransition }}>
<Form
key={name}
value={values}
Expand Down
13 changes: 1 addition & 12 deletions app/javascript/packages/document-capture/components/warning.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useEffect, useRef } from 'react';
import { useI18n } from '@18f/identity-react-i18n';
import { TroubleshootingOptions } from '@18f/identity-components';
import useAsset from '../hooks/use-asset';
Expand All @@ -12,7 +11,6 @@ import PageHeading from './page-heading';
* @prop {string=} heading Heading text.
* @prop {string=} actionText Primary action button text.
* @prop {(() => void)=} actionOnClick Primary action button text.
* @prop {boolean=} autoFocus Whether to focus heading on mount.
* @prop {import('react').ReactNode} children Component children.
* @prop {string=} troubleshootingHeading Heading text preceding troubleshooting options.
* @prop {(TroubleshootingOption[])=} troubleshootingOptions Array of troubleshooting options.
Expand All @@ -26,18 +24,11 @@ function Warning({
actionText,
actionOnClick,
children,
autoFocus = false,
troubleshootingHeading,
troubleshootingOptions,
}) {
const { t } = useI18n();
const { getAssetPath } = useAsset();
const headingRef = useRef(/** @type {HTMLHeadingElement?} */ (null));
useEffect(() => {
if (autoFocus) {
headingRef.current?.focus();
}
}, []);

return (
<>
Expand All @@ -48,9 +39,7 @@ function Warning({
height={54}
className="display-block margin-bottom-4"
/>
<PageHeading ref={headingRef} tabIndex={-1}>
{heading}
</PageHeading>
<PageHeading>{heading}</PageHeading>
{children}
{actionText && actionOnClick && (
<div className="margin-y-5">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,6 @@ export function getQueryParam(queryString, name) {
return null;
}

/**
* Scrolls the page to the given set of X and Y coordinates. Progressively enhances to use smooth
* scrolling if supported.
*
* @param {number} left Left (X) coordinate.
* @param {number} top Top (Y) coordinate.
*/
function scrollTo(left, top) {
try {
window.scrollTo({ left, top, behavior: 'smooth' });
} catch {
window.scrollTo(left, top);
}
}

/**
* Returns a hook which syncs a querystring parameter by the given name using History pushState.
* Returns a `useState`-like tuple of the current value and a setter to assign the next parameter
Expand Down Expand Up @@ -75,11 +60,14 @@ function useHistoryParam(name, initialValue) {
function setParamValue(nextValue) {
// Push the next value to history, both to update the URL, and to allow the user to return to
// an earlier value (see `popstate` sync behavior).
window.history.pushState(null, '', getValueURL(nextValue));

scrollTo(0, 0);
if (nextValue !== value) {
window.history.pushState(null, '', getValueURL(nextValue));
setValue(nextValue);
}

setValue(nextValue);
if (window.scrollY > 0) {
window.scrollTo(0, 0);
}
}

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ describe('document-capture/components/callback-on-mount', () => {

expect(callback.calledOnce).to.be.true();
});

it('renders children', () => {
const { getByText } = render(<CallbackOnMount onMount={() => {}}>Children</CallbackOnMount>);

expect(getByText('Children')).to.be.ok();
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import sinon from 'sinon';
import { useContext } from 'react';
import userEvent from '@testing-library/user-event';
import { FailedCaptureAttemptsContextProvider } from '@18f/identity-document-capture';
import {
FailedCaptureAttemptsContext,
FailedCaptureAttemptsContextProvider,
} from '@18f/identity-document-capture';
import CaptureTroubleshooting from '@18f/identity-document-capture/components/capture-troubleshooting';
import { FormStepsContext } from '@18f/identity-document-capture/components/form-steps';
import { render } from '../../../support/document-capture';

describe('document-capture/context/capture-troubleshooting', () => {
Expand Down Expand Up @@ -37,4 +43,35 @@ describe('document-capture/context/capture-troubleshooting', () => {

expect(getByText('Default children')).to.be.ok();
});

it('triggers content resets', () => {
const onPageTransition = sinon.spy();
const FailButton = () => (
<button
type="button"
onClick={useContext(FailedCaptureAttemptsContext).onFailedCaptureAttempt}
>
Fail
</button>
);
const { getByRole } = render(
<FormStepsContext.Provider value={{ onPageTransition }}>
<FailedCaptureAttemptsContextProvider maxFailedAttemptsBeforeTips={1}>
<CaptureTroubleshooting>
<FailButton />
</CaptureTroubleshooting>
</FailedCaptureAttemptsContextProvider>
</FormStepsContext.Provider>,
);

expect(onPageTransition).not.to.have.been.called();

const failButton = getByRole('button', { name: 'Fail' });
userEvent.click(failButton);
expect(onPageTransition).to.have.been.calledOnce();

const tryAgainButton = getByRole('button', { name: 'idv.failure.button.warning' });
userEvent.click(tryAgainButton);
expect(onPageTransition).to.have.been.calledTwice();
});
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { useContext } from 'react';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/dom';
import sinon from 'sinon';
import PageHeading from '@18f/identity-document-capture/components/page-heading';
import FormSteps, {
FormStepsContext,
getStepIndexByName,
FormStepsContinueButton,
} from '@18f/identity-document-capture/components/form-steps';
import { toFormEntryError } from '@18f/identity-document-capture/services/upload';
import { render } from '../../../support/document-capture';
import { useSandbox } from '../../../support/sinon';

describe('document-capture/components/form-steps', () => {
const { spy } = useSandbox();

const STEPS = [
{
name: 'first',
Expand All @@ -18,6 +23,7 @@ describe('document-capture/components/form-steps', () => {
<PageHeading>First Title</PageHeading>
<span>First</span>
<FormStepsContinueButton />
<span data-testid="context-value">{JSON.stringify(useContext(FormStepsContext))}</span>
</>
),
},
Expand Down Expand Up @@ -54,6 +60,7 @@ describe('document-capture/components/form-steps', () => {
Create Step Error
</button>
<FormStepsContinueButton />
<span data-testid="context-value">{JSON.stringify(useContext(FormStepsContext))}</span>
</>
),
},
Expand All @@ -64,6 +71,7 @@ describe('document-capture/components/form-steps', () => {
<PageHeading>Last Title</PageHeading>
<span>Last</span>
<FormStepsContinueButton />
<span data-testid="context-value">{JSON.stringify(useContext(FormStepsContext))}</span>
</>
),
},
Expand Down Expand Up @@ -400,4 +408,62 @@ describe('document-capture/components/form-steps', () => {

expect(getByRole('alert')).to.equal(document.activeElement);
});

it('provides context', () => {
const { getByTestId, getByRole, getByLabelText } = render(<FormSteps steps={STEPS} />);

expect(JSON.parse(getByTestId('context-value').textContent)).to.deep.equal({
isLastStep: false,
canContinueToNextStep: true,
});

userEvent.click(getByRole('button', { name: 'forms.buttons.continue' }));
expect(window.location.hash).to.equal('#step=second');

// Trigger validation errors on second step.
userEvent.click(getByRole('button', { name: 'forms.buttons.continue' }));
expect(window.location.hash).to.equal('#step=second');
expect(JSON.parse(getByTestId('context-value').textContent)).to.deep.equal({
isLastStep: false,
canContinueToNextStep: false,
});

userEvent.type(getByLabelText('Second Input One'), 'one');
userEvent.type(getByLabelText('Second Input Two'), 'two');

userEvent.click(getByRole('button', { name: 'forms.buttons.continue' }));
expect(window.location.hash).to.equal('#step=last');
expect(JSON.parse(getByTestId('context-value').textContent)).to.deep.equal({
isLastStep: true,
canContinueToNextStep: true,
});
});

it('allows context consumers to trigger content reset', () => {
const { getByRole } = render(
<FormSteps
steps={[
{
name: 'content-reset',
form: () => (
<>
<h1>Content Title</h1>
<button type="button" onClick={useContext(FormStepsContext).onPageTransition}>
Replace
</button>
</>
),
},
]}
/>,
);

window.scrollY = 100;
userEvent.click(getByRole('button', { name: 'Replace' }));
spy(window.history, 'pushState');

expect(window.scrollY).to.equal(0);
expect(document.activeElement).to.equal(getByRole('heading', { name: 'Content Title' }));
expect(window.history.pushState).not.to.have.been.called();
});
});
3 changes: 0 additions & 3 deletions spec/javascripts/support/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,6 @@ export function createDOM() {
// with global log error capturing. This suppresses said logging.
sinon
.stub(dom.window, 'scrollTo')
.withArgs(sinon.match.object)
.throws(new Error())
.withArgs(sinon.match.number, sinon.match.number)
.callsFake((scrollX, scrollY) => Object.assign(dom.window, { scrollX, scrollY }));

return dom;
Expand Down