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
1 change: 1 addition & 0 deletions app/controllers/verify_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def show

def app_data
{
base_path: idv_app_root_path,
initial_values: { 'personalKey' => '0000-0000-0000-0000' },
}
end
Expand Down
43 changes: 12 additions & 31 deletions app/javascript/packages/form-steps/form-steps.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ interface StepValues {
}

describe('FormSteps', () => {
const { spy } = sinon.createSandbox();
const sandbox = sinon.createSandbox();

afterEach(() => {
sandbox.restore();
});

const STEPS = [
{
Expand Down Expand Up @@ -237,7 +241,7 @@ describe('FormSteps', () => {

userEvent.click(getByText('forms.buttons.continue'));

expect(window.location.hash).to.equal('#step=second');
expect(window.location.hash).to.equal('#second');
});

it('syncs step by history events', async () => {
Expand All @@ -257,22 +261,7 @@ describe('FormSteps', () => {
expect(await findByText('Second Title')).to.be.ok();
expect((getByLabelText('Second Input One') as HTMLInputElement).value).to.equal('one');
expect((getByLabelText('Second Input Two') as HTMLInputElement).value).to.equal('two');
expect(window.location.hash).to.equal('#step=second');
});

it('clear URL parameter after submission', async () => {
const onComplete = sinon.spy();
const { getByText, getByLabelText } = render(
<FormSteps steps={STEPS} onComplete={onComplete} />,
);

userEvent.click(getByText('forms.buttons.continue'));
await userEvent.type(getByLabelText('Second Input One'), 'one');
await userEvent.type(getByLabelText('Second Input Two'), 'two');
userEvent.click(getByText('forms.buttons.continue'));
userEvent.click(getByText('forms.buttons.submit.default'));
await waitFor(() => expect(onComplete.calledOnce).to.be.true());
expect(window.location.hash).to.equal('');
expect(window.location.hash).to.equal('#second');
});

it('shifts focus to next heading on step change', () => {
Expand All @@ -289,14 +278,6 @@ describe('FormSteps', () => {
expect(document.activeElement).to.equal(originalActiveElement);
});

it('resets to first step at mount', () => {
window.location.hash = '#step=last';

render(<FormSteps steps={STEPS} />);

expect(window.location.hash).to.equal('');
});

it('optionally auto-focuses', () => {
const { getByText } = render(<FormSteps steps={STEPS} autoFocus />);

Expand All @@ -320,7 +301,7 @@ describe('FormSteps', () => {
userEvent.click(getByText('forms.buttons.continue'));
userEvent.click(getByText('forms.buttons.continue'));

expect(window.location.hash).to.equal('#step=second');
expect(window.location.hash).to.equal('#second');
expect(document.activeElement).to.equal(getByLabelText('Second Input One'));
expect(container.querySelectorAll('[data-is-error]')).to.have.lengthOf(2);

Expand Down Expand Up @@ -432,11 +413,11 @@ describe('FormSteps', () => {
});

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

// Trigger validation errors on second step.
userEvent.click(getByRole('button', { name: 'forms.buttons.continue' }));
expect(window.location.hash).to.equal('#step=second');
expect(window.location.hash).to.equal('#second');
expect(JSON.parse(getByTestId('context-value').textContent!)).to.deep.equal({
isLastStep: false,
});
Expand All @@ -445,7 +426,7 @@ describe('FormSteps', () => {
userEvent.type(getByLabelText('Second Input Two'), 'two');

userEvent.click(getByRole('button', { name: 'forms.buttons.continue' }));
expect(window.location.hash).to.equal('#step=last');
expect(window.location.hash).to.equal('#last');
expect(JSON.parse(getByTestId('context-value').textContent!)).to.deep.equal({
isLastStep: true,
});
Expand All @@ -472,7 +453,7 @@ describe('FormSteps', () => {

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

expect(window.scrollY).to.equal(0);
expect(document.activeElement).to.equal(getByRole('heading', { name: 'Content Title' }));
Expand Down
15 changes: 10 additions & 5 deletions app/javascript/packages/form-steps/form-steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ interface FormStepsProps {
* Defaults to true.
*/
promptOnNavigate?: boolean;

/**
* When using path fragments for maintaining history, the base path to which the current step name
* is appended.
*/
basePath?: string;
}

/**
Expand All @@ -142,8 +148,8 @@ interface FormStepsProps {
*
* @return Step index.
*/
export function getStepIndexByName(steps: FormStep[], name: string) {
return steps.findIndex((step) => step.name === name);
export function getStepIndexByName(steps: FormStep[], name?: string) {
return name ? steps.findIndex((step) => step.name === name) : -1;
}

/**
Expand Down Expand Up @@ -171,11 +177,12 @@ function FormSteps({
initialActiveErrors = [],
autoFocus,
promptOnNavigate = true,
basePath,
}: FormStepsProps) {
const [values, setValues] = useState(initialValues);
const [activeErrors, setActiveErrors] = useState(initialActiveErrors);
const formRef = useRef(null as HTMLFormElement | null);
const [stepName, setStepName] = useHistoryParam('step', null);
const [stepName, setStepName] = useHistoryParam({ basePath });
const [stepErrors, setStepErrors] = useState([] as Error[]);
const fields = useRef({} as Record<string, FieldsRefEntry>);
const didSubmitWithErrors = useRef(false);
Expand Down Expand Up @@ -271,8 +278,6 @@ function FormSteps({
const nextStepIndex = stepIndex + 1;
const isComplete = nextStepIndex === steps.length;
if (isComplete) {
// Clear step parameter from URL.
setStepName(null);
onComplete(values);
} else {
const { name: nextStepName } = steps[nextStepIndex];
Expand Down
174 changes: 127 additions & 47 deletions app/javascript/packages/form-steps/use-history-param.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,15 @@
import sinon from 'sinon';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import useHistoryParam, { getQueryParam } from './use-history-param';

describe('getQueryParam', () => {
const queryString = 'a&b=Hello%20world&c';

it('returns null does not exist', () => {
const value = getQueryParam(queryString, 'd');

expect(value).to.be.null();
});

it('returns decoded value of parameter', () => {
const value = getQueryParam(queryString, 'b');

expect(value).to.equal('Hello world');
});

it('defaults to empty string for empty value', () => {
const value = getQueryParam(queryString, 'c');

expect(value).to.equal('');
});
});
import { useDefineProperty } from '@18f/identity-test-helpers';
import useHistoryParam from './use-history-param';

describe('useHistoryParam', () => {
function TestComponent({ initialValue }: { initialValue?: string | null }) {
const [count = 0, setCount] = useHistoryParam('the count', initialValue);
const sandbox = sinon.createSandbox();
const defineProperty = useDefineProperty();

function TestComponent({ basePath }: { basePath?: string }) {
const [count = 0, setCount] = useHistoryParam({ basePath });

return (
<>
Expand All @@ -35,7 +18,7 @@ describe('useHistoryParam', () => {
<label>
Count: <input value={count} onChange={(event) => setCount(event.target.value)} />
</label>
<button type="button" onClick={() => setCount(count + 1)}>
<button type="button" onClick={() => setCount(String(Number(count) + 1))}>
Increment
</button>
</>
Expand All @@ -50,6 +33,7 @@ describe('useHistoryParam', () => {

afterEach(() => {
window.location.hash = originalHash;
sandbox.restore();
});

it('returns undefined value if absent from initial URL', () => {
Expand All @@ -59,38 +43,24 @@ describe('useHistoryParam', () => {
});

it('returns initial value if present in initial URL', () => {
window.location.hash = '#the%20count=5';
window.location.hash = '#5';
const { getByDisplayValue } = render(<TestComponent />);

expect(getByDisplayValue('5')).to.be.ok();
});

it('accepts an initial value', () => {
const { getByDisplayValue } = render(<TestComponent initialValue="5" />);

expect(window.location.hash).to.equal('#the%20count=5');
expect(getByDisplayValue('5')).to.be.ok();
});

it('accepts empty initial value', () => {
const { getByDisplayValue } = render(<TestComponent initialValue={null} />);

expect(window.location.hash).to.equal('');
expect(getByDisplayValue('0')).to.be.ok();
});

it('syncs by setter', () => {
const { getByText, getByDisplayValue } = render(<TestComponent />);

userEvent.click(getByText('Increment'));

expect(getByDisplayValue('1')).to.be.ok();
expect(window.location.hash).to.equal('#the%20count=1');
expect(window.location.hash).to.equal('#1');

userEvent.click(getByText('Increment'));

expect(getByDisplayValue('2')).to.be.ok();
expect(window.location.hash).to.equal('#the%20count=2');
expect(window.location.hash).to.equal('#2');
});

it('scrolls to top on programmatic history manipulation', () => {
Expand Down Expand Up @@ -119,17 +89,17 @@ describe('useHistoryParam', () => {
userEvent.click(getByText('Increment'));

expect(getByDisplayValue('1')).to.be.ok();
expect(window.location.hash).to.equal('#the%20count=1');
expect(window.location.hash).to.equal('#1');

userEvent.click(getByText('Increment'));

expect(getByDisplayValue('2')).to.be.ok();
expect(window.location.hash).to.equal('#the%20count=2');
expect(window.location.hash).to.equal('#2');

window.history.back();

expect(await findByDisplayValue('1')).to.be.ok();
expect(window.location.hash).to.equal('#the%20count=1');
expect(window.location.hash).to.equal('#1');

window.history.back();

Expand All @@ -144,6 +114,116 @@ describe('useHistoryParam', () => {
userEvent.clear(input);
userEvent.type(input, 'one hundred');

expect(window.location.hash).to.equal('#the%20count=one%20hundred');
expect(window.location.hash).to.equal('#one%20hundred');
});

Object.entries({
'with basePath': '/base/',
'with basePath, no trailing slash': '/base',
}).forEach(([description, basePath]) => {
context(description, () => {
context('without initial value', () => {
beforeEach(() => {
const history: string[] = [basePath];
defineProperty(window, 'location', {
value: {
get pathname() {
return history[history.length - 1];
},
},
});

sandbox.stub(window.history, 'pushState').callsFake((_data, _unused, url) => {
history.push(url as string);
});
sandbox.stub(window.history, 'back').callsFake(() => {
history.pop();
window.dispatchEvent(new CustomEvent('popstate'));
});
});

it('returns undefined value', () => {
const { getByDisplayValue } = render(<TestComponent basePath={basePath} />);

expect(getByDisplayValue('0')).to.be.ok();
});

it('syncs by setter', () => {
const { getByText, getByDisplayValue } = render(<TestComponent basePath={basePath} />);

userEvent.click(getByText('Increment'));

expect(getByDisplayValue('1')).to.be.ok();
expect(window.location.pathname).to.equal('/base/1');

userEvent.click(getByText('Increment'));

expect(getByDisplayValue('2')).to.be.ok();
expect(window.location.pathname).to.equal('/base/2');
});

it('syncs by history events', async () => {
const { getByText, getByDisplayValue, findByDisplayValue } = render(
<TestComponent basePath="/base/" />,
);

userEvent.click(getByText('Increment'));

expect(getByDisplayValue('1')).to.be.ok();
expect(window.location.pathname).to.equal('/base/1');

userEvent.click(getByText('Increment'));

expect(getByDisplayValue('2')).to.be.ok();
expect(window.location.pathname).to.equal('/base/2');

window.history.back();

expect(await findByDisplayValue('1')).to.be.ok();
expect(window.location.pathname).to.equal('/base/1');

window.history.back();

expect(await findByDisplayValue('0')).to.be.ok();
expect(window.location.pathname).to.equal(basePath);
});
});

context('with initial value', () => {
beforeEach(() => {
defineProperty(window, 'location', {
value: {
get pathname() {
return '/base/5/';
},
},
});
});

it('returns initial value', () => {
const { getByDisplayValue } = render(<TestComponent basePath={basePath} />);

expect(getByDisplayValue('5')).to.be.ok();
});
});

context('with initial value, no trailing slash', () => {
beforeEach(() => {
defineProperty(window, 'location', {
value: {
get pathname() {
return '/base/5';
},
},
});
});

it('returns initial value', () => {
const { getByDisplayValue } = render(<TestComponent basePath={basePath} />);

expect(getByDisplayValue('5')).to.be.ok();
});
});
});
});
});
Loading