diff --git a/app/controllers/verify_controller.rb b/app/controllers/verify_controller.rb index b60c4f9968a..24f5dc44cc3 100644 --- a/app/controllers/verify_controller.rb +++ b/app/controllers/verify_controller.rb @@ -11,6 +11,7 @@ def show def app_data { + base_path: idv_app_root_path, initial_values: { 'personalKey' => '0000-0000-0000-0000' }, } end diff --git a/app/javascript/packages/form-steps/form-steps.spec.tsx b/app/javascript/packages/form-steps/form-steps.spec.tsx index bc3466eed0d..e683351077a 100644 --- a/app/javascript/packages/form-steps/form-steps.spec.tsx +++ b/app/javascript/packages/form-steps/form-steps.spec.tsx @@ -18,7 +18,11 @@ interface StepValues { } describe('FormSteps', () => { - const { spy } = sinon.createSandbox(); + const sandbox = sinon.createSandbox(); + + afterEach(() => { + sandbox.restore(); + }); const STEPS = [ { @@ -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 () => { @@ -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( - , - ); - - 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', () => { @@ -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(); - - expect(window.location.hash).to.equal(''); - }); - it('optionally auto-focuses', () => { const { getByText } = render(); @@ -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); @@ -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, }); @@ -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, }); @@ -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' })); diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx index de5d15b1420..a456cc12aa9 100644 --- a/app/javascript/packages/form-steps/form-steps.tsx +++ b/app/javascript/packages/form-steps/form-steps.tsx @@ -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; } /** @@ -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; } /** @@ -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); const didSubmitWithErrors = useRef(false); @@ -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]; diff --git a/app/javascript/packages/form-steps/use-history-param.spec.tsx b/app/javascript/packages/form-steps/use-history-param.spec.tsx index b31c40a68ea..99130db0e3e 100644 --- a/app/javascript/packages/form-steps/use-history-param.spec.tsx +++ b/app/javascript/packages/form-steps/use-history-param.spec.tsx @@ -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 ( <> @@ -35,7 +18,7 @@ describe('useHistoryParam', () => { - @@ -50,6 +33,7 @@ describe('useHistoryParam', () => { afterEach(() => { window.location.hash = originalHash; + sandbox.restore(); }); it('returns undefined value if absent from initial URL', () => { @@ -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(); expect(getByDisplayValue('5')).to.be.ok(); }); - it('accepts an initial value', () => { - const { getByDisplayValue } = render(); - - expect(window.location.hash).to.equal('#the%20count=5'); - expect(getByDisplayValue('5')).to.be.ok(); - }); - - it('accepts empty initial value', () => { - const { getByDisplayValue } = render(); - - expect(window.location.hash).to.equal(''); - expect(getByDisplayValue('0')).to.be.ok(); - }); - it('syncs by setter', () => { const { getByText, getByDisplayValue } = render(); 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', () => { @@ -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(); @@ -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(); + + expect(getByDisplayValue('0')).to.be.ok(); + }); + + it('syncs by setter', () => { + const { getByText, getByDisplayValue } = render(); + + 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( + , + ); + + 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(); + + 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(); + + expect(getByDisplayValue('5')).to.be.ok(); + }); + }); + }); }); }); diff --git a/app/javascript/packages/form-steps/use-history-param.ts b/app/javascript/packages/form-steps/use-history-param.ts index d776162b98e..2300d7f4d0f 100644 --- a/app/javascript/packages/form-steps/use-history-param.ts +++ b/app/javascript/packages/form-steps/use-history-param.ts @@ -1,33 +1,7 @@ import { useState, useEffect } from 'react'; -/** - * Given a query string and parameter name, returns the decoded value associated with that parameter - * in the query string, or null if the value cannot be found. The query string should be provided - * without a leading "?". - * - * This is intended to polyfill a behavior equivalent to: - * - * ``` - * new URLSearchParams(queryString).get(name) - * ``` - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/get - * - * @param queryString Query string to search within. - * @param name Parameter name to search for. - * - * @return Decoded parameter value if found, or null otherwise. - */ -export function getQueryParam(queryString: string, name: string): string | null { - const pairs = queryString.split('&'); - for (let i = 0; i < pairs.length; i += 1) { - const [key, value = ''] = pairs[i].split('=').map(decodeURIComponent); - if (key === name) { - return value; - } - } - - return null; +interface HistoryOptions { + basePath?: string; } /** @@ -40,24 +14,22 @@ export function getQueryParam(queryString: string, name: string): string | null * * @see https://developer.mozilla.org/en-US/docs/Web/API/History/pushState * - * @param name Parameter name to sync. - * @param initialValue Value to use as initial in absence of another value. - * * @return Tuple of current state, state setter. */ -function useHistoryParam( - name: string, - initialValue?: string | null, -): [any, (nextParamValue: any) => void] { - const getCurrentQueryParam = () => - getQueryParam(window.location.hash.slice(1), name) ?? undefined; +function useHistoryParam({ basePath }: HistoryOptions = {}): [ + string | undefined, + (nextParamValue?: string) => void, +] { + const getCurrentValue = () => + (typeof basePath === 'string' + ? window.location.pathname.split(basePath)[1]?.replace(/^\/|\/$/g, '') + : window.location.hash.slice(1)) || undefined; - const [value, setValue] = useState(getCurrentQueryParam); + const [value, setValue] = useState(getCurrentValue); function getValueURL(nextValue) { - return nextValue - ? `#${[name, nextValue].map(encodeURIComponent).join('=')}` - : window.location.pathname + window.location.search; + const prefix = typeof basePath === 'string' ? `${basePath.replace(/\/$/, '')}/` : '#'; + return nextValue ? `${prefix}${nextValue}` : window.location.pathname + window.location.search; } function setParamValue(nextValue) { @@ -74,15 +46,7 @@ function useHistoryParam( } useEffect(() => { - function syncValue() { - setValue(getCurrentQueryParam()); - } - - if (initialValue !== undefined) { - setValue(initialValue ?? undefined); - window.history.replaceState(null, '', getValueURL(initialValue)); - } - + const syncValue = () => setValue(getCurrentValue()); window.addEventListener('popstate', syncValue); return () => window.removeEventListener('popstate', syncValue); }, []); diff --git a/app/javascript/packages/verify-flow/index.tsx b/app/javascript/packages/verify-flow/index.tsx index 991f273d421..45cbe226dfe 100644 --- a/app/javascript/packages/verify-flow/index.tsx +++ b/app/javascript/packages/verify-flow/index.tsx @@ -7,10 +7,18 @@ export interface VerifyFlowValues { } interface VerifyFlowProps { + /** + * Initial values for the form, if applicable. + */ initialValues?: Partial; + + /** + * The path to which the current step is appended to create the current step URL. + */ + basePath: string; } -export function VerifyFlow({ initialValues = {} }: VerifyFlowProps) { +export function VerifyFlow({ initialValues = {}, basePath }: VerifyFlowProps) { return ( <> @@ -20,7 +28,12 @@ export function VerifyFlow({ initialValues = {} }: VerifyFlowProps) { - + ); } diff --git a/app/javascript/packages/verify-flow/steps/index.ts b/app/javascript/packages/verify-flow/steps/index.ts index ed500d87f7f..d3c0b406d19 100644 --- a/app/javascript/packages/verify-flow/steps/index.ts +++ b/app/javascript/packages/verify-flow/steps/index.ts @@ -4,11 +4,11 @@ import PersonalKeyConfirmStep from './personal-key-confirm/personal-key-confirm- export const STEPS: FormStep[] = [ { - name: 'personal-key', + name: 'personal_key', form: PersonalKeyStep, }, { - name: 'personal-key-confirm', + name: 'personal_key_confirm', form: PersonalKeyConfirmStep, }, ]; diff --git a/app/javascript/packs/verify-flow.tsx b/app/javascript/packs/verify-flow.tsx index 32a0f51a014..c9b418344ba 100644 --- a/app/javascript/packs/verify-flow.tsx +++ b/app/javascript/packs/verify-flow.tsx @@ -1,10 +1,28 @@ import { render } from 'react-dom'; import { VerifyFlow } from '@18f/identity-verify-flow'; -const appRoot = document.getElementById('app-root')!; -let initialValues; +interface AppRootValues { + /** + * JSON-encoded object of initial application data. + */ + initialValues: string; + + /** + * The path to which the current step is appended to create the current step URL. + */ + basePath: string; +} + +interface AppRootElement extends HTMLElement { + dataset: DOMStringMap & AppRootValues; +} + +const appRoot = document.getElementById('app-root') as AppRootElement; +const { initialValues, basePath } = appRoot.dataset; + +let parsedInitialValues; try { - initialValues = JSON.parse(appRoot.dataset.initialValues!); + parsedInitialValues = JSON.parse(initialValues); } catch {} -render(, appRoot); +render(, appRoot); diff --git a/config/routes.rb b/config/routes.rb index 47987b4ceba..63eefcef040 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -326,7 +326,11 @@ end scope '/verify/v2' do - get '/personal_key' => 'verify#show' + get '/' => 'verify#show', as: :idv_app_root + %w[ + /personal_key + /personal_key_confirm + ].each { |step_path| get step_path => 'verify#show' } end get '/account/verify' => 'idv/gpo_verify#index', as: :idv_gpo_verify