Skip to content
Merged
1 change: 1 addition & 0 deletions app/javascript/packages/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export { default as TroubleshootingOptions } from './troubleshooting-options';

export type { ButtonProps } from './button';
export type { FullScreenRefHandle } from './full-screen';
export type { LinkProps } from './link';
export type { TextInputProps } from './text-input';
8 changes: 8 additions & 0 deletions app/javascript/packages/components/link.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ describe('Link', () => {
expect([...link.classList.values()]).to.have.all.members(['usa-link']);
});

it('forwards extra props to underlying anchor element', () => {
const { getByRole } = render(<Link data-foo="bar" href="/" />);

const link = getByRole('link');

expect(link.getAttribute('data-foo')).to.equal('bar');
});

context('with custom css class', () => {
it('renders link with class', () => {
const { getByRole } = render(<Link href="/" className="my-custom-class" />);
Expand Down
5 changes: 3 additions & 2 deletions app/javascript/packages/components/link.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ReactNode, AnchorHTMLAttributes } from 'react';
import { t } from '@18f/identity-i18n';

export interface LinkProps {
export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
/**
* Link destination.
*/
Expand Down Expand Up @@ -42,6 +42,7 @@ function Link({
isNewTab = isExternal,
className,
children,
...anchorProps
}: LinkProps) {
const classes = ['usa-link', className, isExternal && 'usa-link--external']
.filter(Boolean)
Expand All @@ -53,7 +54,7 @@ function Link({
}

return (
<a href={href} {...newTabProps} className={classes}>
<a href={href} {...newTabProps} {...anchorProps} className={classes}>
{children}
{isNewTab && <span className="usa-sr-only"> {t('links.new_window')}</span>}
</a>
Expand Down
77 changes: 77 additions & 0 deletions app/javascript/packages/form-steps/history-link.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { render, fireEvent, createEvent } from '@testing-library/react';
import HistoryLink from './history-link';

describe('HistoryLink', () => {
it('renders a link to the intended step', () => {
const { getByRole } = render(<HistoryLink basePath="/base" step="step" />);

const link = getByRole('link');

expect(link.getAttribute('href')).to.equal('/base/step');
});

it('renders a visual button to the intended step', () => {
const { getByRole } = render(<HistoryLink basePath="/base" step="step" isVisualButton isBig />);

const link = getByRole('link');

expect(link.getAttribute('href')).to.equal('/base/step');
expect(link.classList.contains('usa-button')).to.be.true();
expect(link.classList.contains('usa-button--big')).to.be.true();
});

it('intercepts navigation to route using client-side routing', () => {
const { getByRole } = render(<HistoryLink step="step" />);

const beforeHash = window.location.hash;
const link = getByRole('link');

const didPreventDefault = !fireEvent.click(link);

expect(didPreventDefault).to.be.true();
expect(window.location.hash).to.not.equal(beforeHash);
expect(window.location.hash).to.equal('#step');
});

it('does not intercept navigation when holding modifier key', () => {
const { getByRole } = render(<HistoryLink step="step" />);

const beforeHash = window.location.hash;
const link = getByRole('link');

for (const mod of ['metaKey', 'shiftKey', 'ctrlKey', 'altKey']) {
const didPreventDefault = !fireEvent.click(link, { [mod]: true });

expect(didPreventDefault).to.be.false();
expect(window.location.hash).to.equal(beforeHash);
}
});

it('does not intercept navigation when clicking with other than main button', () => {
const { getByRole } = render(<HistoryLink step="step" />);

const beforeHash = window.location.hash;
const link = getByRole('link');

// Reference: https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button#value
for (const button of [1, 2, 3, 4]) {
const didPreventDefault = !fireEvent.click(link, { button });

expect(didPreventDefault).to.be.false();
expect(window.location.hash).to.equal(beforeHash);
}
});

it('does not intercept navigation if event was already default-prevented', () => {
const { getByRole } = render(<HistoryLink step="step" />);

const beforeHash = window.location.hash;
const link = getByRole('link');
const event = createEvent('click', link);
event.preventDefault();

fireEvent(link, event);

expect(window.location.hash).to.equal(beforeHash);
});
});
62 changes: 62 additions & 0 deletions app/javascript/packages/form-steps/history-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useCallback } from 'react';
import type { MouseEventHandler } from 'react';
import { Link, Button } from '@18f/identity-components';
import type { LinkProps, ButtonProps } from '@18f/identity-components';
import useHistoryParam, { getParamURL } from './use-history-param';
import type { ParamValue } from './use-history-param';

type HistoryLinkProps = (Partial<Exclude<LinkProps, 'href'>> | Partial<ButtonProps>) & {
/**
* When using path fragments for maintaining history, the base path to which the current step name
* is appended.
*/
basePath?: string;

/**
* The step to which the link should navigate.
*/
step: ParamValue;

/**
* Whether to render the link with the appearance of a button.
*/
isVisualButton?: boolean;
};

/**
* Renders a link to the given step. Enhances a Link or Button to perform client-side routing using
* useHistoryParam hook.
*
* @param props Props object.
*
* @return Link element.
*/
function HistoryLink({ basePath, step, isVisualButton = false, ...extraProps }: HistoryLinkProps) {
const [, setPath] = useHistoryParam(undefined, { basePath });
const handleClick = useCallback<MouseEventHandler<Element>>(
(event) => {
if (
!event.defaultPrevented &&
!event.metaKey &&
!event.shiftKey &&
!event.ctrlKey &&
!event.altKey &&
event.button === 0
) {
event.preventDefault();
setPath(step);
}
},
[basePath],
);

const href = getParamURL(step, { basePath });

if (isVisualButton) {
return <Button {...(extraProps as Partial<ButtonProps>)} href={href} onClick={handleClick} />;
}

return <Link {...(extraProps as Partial<LinkProps>)} href={href} onClick={handleClick} />;
}

export default HistoryLink;
3 changes: 2 additions & 1 deletion app/javascript/packages/form-steps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ export { default as FormError } from './form-error';
export { default as RequiredValueMissingError } from './required-value-missing-error';
export { default as FormStepsContext } from './form-steps-context';
export { default as FormStepsButton } from './form-steps-button';
export { default as HistoryLink } from './history-link';
export { default as PromptOnNavigate } from './prompt-on-navigate';
export { default as useHistoryParam, getStepParam } from './use-history-param';
export { default as useHistoryParam, getStepParam, getParamURL } from './use-history-param';

export type {
FormStepError,
Expand Down
20 changes: 17 additions & 3 deletions app/javascript/packages/form-steps/use-history-param.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import sinon from 'sinon';
import { render } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import userEvent from '@testing-library/user-event';
import { useDefineProperty } from '@18f/identity-test-helpers';
import useHistoryParam, { getStepParam } from './use-history-param';
Expand Down Expand Up @@ -138,10 +139,23 @@ describe('useHistoryParam', () => {
const { getByDisplayValue } = render(<TestComponent />);

const input = getByDisplayValue('0');
await userEvent.clear(input);
await userEvent.type(input, 'one hundred');
await userEvent.type(input, ' 1');

expect(window.location.hash).to.equal('#one%20hundred');
expect(window.location.hash).to.equal('#0%201');
});

it('syncs across instances', () => {
const inst1 = renderHook(() => useHistoryParam());
const inst2 = renderHook(() => useHistoryParam());
const inst3 = renderHook(() => useHistoryParam(undefined, { basePath: '/base' }));

const [, setPath1] = inst1.result.current;
setPath1('root');

const [path2] = inst2.result.current;
const [path3] = inst3.result.current;
expect(path2).to.equal('root');
expect(path3).to.be.undefined();
});

Object.entries({
Expand Down
37 changes: 25 additions & 12 deletions app/javascript/packages/form-steps/use-history-param.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';

type ParamValue = string | undefined;
export type ParamValue = string | undefined;

interface HistoryOptions {
basePath?: string;
Expand All @@ -13,7 +13,18 @@ interface HistoryOptions {
*
* @return Step name.
*/
export const getStepParam = (path: string): string => path.split('/').filter(Boolean)[0];
export const getStepParam = (path: string): string =>
decodeURIComponent(path.split('/').filter(Boolean)[0]);

export function getParamURL(value: ParamValue, { basePath }: HistoryOptions): string {
let prefix = typeof basePath === 'string' ? basePath.replace(/\/$/, '') : '#';
if (value && basePath) {
prefix += '/';
}

return [prefix, encodeURIComponent(value || '')].filter(Boolean).join('');
}
const subscribers: Array<() => void> = [];

/**
* Returns a hook which syncs a querystring parameter by the given name using History pushState.
Expand Down Expand Up @@ -43,18 +54,14 @@ function useHistoryParam(
}

const [value, setValue] = useState(initialValue ?? getCurrentValue);

function getValueURL(nextValue: ParamValue) {
const prefix = typeof basePath === 'string' ? `${basePath.replace(/\/$/, '')}/` : '#';
return [prefix, nextValue].filter(Boolean).join('');
}
const syncValue = useCallback(() => setValue(getCurrentValue), [setValue]);

function setParamValue(nextValue: ParamValue) {
// 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).
if (nextValue !== value) {
window.history.pushState(null, '', getValueURL(nextValue));
setValue(nextValue);
window.history.pushState(null, '', getParamURL(nextValue, { basePath }));
subscribers.forEach((sync) => sync());
}

if (window.scrollY > 0) {
Expand All @@ -64,14 +71,20 @@ function useHistoryParam(

useEffect(() => {
if (initialValue && initialValue !== getCurrentValue()) {
window.history.replaceState(null, '', getValueURL(initialValue));
window.history.replaceState(null, '', getParamURL(initialValue, { basePath }));
}

const syncValue = () => setValue(getCurrentValue());
window.addEventListener('popstate', syncValue);
return () => window.removeEventListener('popstate', syncValue);
}, []);

useEffect(() => {
subscribers.push(syncValue);
return () => {
subscribers.splice(subscribers.indexOf(syncValue), 1);
};
}, []);

return [value, setParamValue];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { PageHeading, Button } from '@18f/identity-components';
import { PageHeading } from '@18f/identity-components';
import { t } from '@18f/identity-i18n';
import { getAssetPath } from '@18f/identity-assets';
import { HistoryLink } from '@18f/identity-form-steps';
import PasswordResetButton from './password-reset-button';

interface ForgotPasswordProps {
goBack: () => void;
stepPath: string;
}

export function ForgotPassword({ goBack }: ForgotPasswordProps) {
export function ForgotPassword({ stepPath }: ForgotPasswordProps) {
return (
<>
<img
Expand All @@ -24,9 +25,9 @@ export function ForgotPassword({ goBack }: ForgotPasswordProps) {
))}
</ul>
<div className="margin-top-4">
<Button isBig isWide onClick={goBack}>
<HistoryLink basePath={stepPath} step={undefined} isVisualButton isBig isWide>
{t('idv.forgot_password.try_again')}
</Button>
</HistoryLink>
</div>
<div className="margin-top-2">
<PasswordResetButton />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,18 @@ describe('PasswordConfirmStep', () => {
it('navigates to forgot password subpage', async () => {
const { getByRole } = render(<PasswordConfirmStep {...DEFAULT_PROPS} />);

await userEvent.click(getByRole('button', { name: 'idv.forgot_password.link_text' }));
await userEvent.click(getByRole('link', { name: 'idv.forgot_password.link_text' }));

expect(window.location.pathname).to.equal('/password_confirm/forgot_password');
});

it('navigates back from forgot password subpage', async () => {
const { getByRole } = render(<PasswordConfirmStep {...DEFAULT_PROPS} />);

await userEvent.click(getByRole('button', { name: 'idv.forgot_password.link_text' }));
await userEvent.click(getByRole('button', { name: 'idv.forgot_password.try_again' }));
await userEvent.click(getByRole('link', { name: 'idv.forgot_password.link_text' }));
await userEvent.click(getByRole('link', { name: 'idv.forgot_password.try_again' }));

expect(window.location.pathname).to.equal('/password_confirm/');
expect(window.location.pathname).to.equal('/password_confirm');
});
});

Expand Down
Loading