18F/identity-idp

View on GitHub
app/javascript/packages/form-steps/form-steps.spec.tsx

Summary

Maintainability
A
0 mins
Test Coverage
import { useContext, useCallback } from 'react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/dom';
import sinon from 'sinon';
import { PageHeading } from '@18f/identity-components';
import * as analytics from '@18f/identity-analytics';
import { t } from '@18f/identity-i18n';
import FormSteps, { FormStepComponentProps, getStepIndexByName } from './form-steps';
import FormError from './form-error';
import FormStepsContext from './form-steps-context';
import FormStepsButton from './form-steps-button';
import type { FormStep } from './form-steps';

interface StepValues {
  secondInputOne?: string;

  secondInputTwo?: string;

  changed?: boolean;
}

const sleep = (ms: number) => () => new Promise<void>((resolve) => setTimeout(resolve, ms));

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

  beforeEach(() => {
    sandbox.spy(analytics, 'trackEvent');
  });

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

  const STEPS: FormStep[] = [
    {
      name: 'first',
      title: 'First Title',
      form: ({ errors }) => (
        <>
          <PageHeading>First Title</PageHeading>
          <span>First</span>
          <FormStepsButton.Continue />
          <span data-testid="context-value">{JSON.stringify(useContext(FormStepsContext))}</span>
          <span>Errors: {errors.map(({ error }) => error.message).join(',')}</span>
        </>
      ),
    },
    {
      name: 'second',
      form: ({
        value = {},
        errors = [],
        onChange,
        onError,
        registerField,
        toPreviousStep,
      }: FormStepComponentProps<StepValues>) => (
        <>
          <PageHeading>Second Title</PageHeading>
          <input
            aria-label="Second Input One"
            ref={registerField('secondInputOne', { isRequired: true })}
            value={value.secondInputOne || ''}
            data-is-error={errors.some(({ field }) => field === 'secondInputOne') || undefined}
            onChange={(event) => {
              if (event.target.validationMessage) {
                onError(new Error(event.target.validationMessage), { field: 'secondInputOne' });
              } else {
                onChange({ changed: true });
                onChange({ secondInputOne: event.target.value });
              }
            }}
          />
          <input
            aria-label="Second Input Two"
            ref={registerField('secondInputTwo', { isRequired: true })}
            value={value.secondInputTwo || ''}
            data-is-error={errors.some(({ field }) => field === 'secondInputTwo') || undefined}
            onChange={(event) => {
              onChange({ changed: true });
              onChange({ secondInputTwo: event.target.value });
            }}
          />
          <button type="button" onClick={toPreviousStep}>
            Back
          </button>
          <button type="button" onClick={() => onError(new Error())}>
            Create Step Error
          </button>
          <FormStepsButton.Continue />
          <span data-testid="context-value">{JSON.stringify(useContext(FormStepsContext))}</span>
        </>
      ),
    },
    {
      name: 'last',
      form: () => (
        <>
          <PageHeading>Last Title</PageHeading>
          <span>Last</span>
          <FormStepsButton.Submit />
          <span data-testid="context-value">{JSON.stringify(useContext(FormStepsContext))}</span>
        </>
      ),
    },
  ];

  describe('getStepIndexByName', () => {
    it('returns -1 if no step by name', () => {
      const result = getStepIndexByName(STEPS, 'third');

      expect(result).to.be.equal(-1);
    });

    it('returns index of step by name', () => {
      const result = getStepIndexByName(STEPS, 'second');

      expect(result).to.be.equal(1);
    });
  });

  it('renders nothing if given empty steps array', () => {
    const { container } = render(<FormSteps steps={[]} />);

    expect(container.childNodes).to.have.lengthOf(0);
  });

  it('renders the first step initially', () => {
    const { getByText } = render(<FormSteps steps={STEPS} />);

    expect(getByText('First')).to.be.ok();
  });

  it('sets the browser page title using titleFormat', () => {
    render(<FormSteps steps={STEPS} titleFormat="%{step} - Example" />);

    expect(document.title).to.equal('First Title - Example');
  });

  it('renders continue button at first step', () => {
    const { getByText } = render(<FormSteps steps={STEPS} />);

    expect(getByText('forms.buttons.continue')).to.be.ok();
  });

  it('proceeds after resolving step submit implementation, if provided', async () => {
    sandbox.useFakeTimers();
    const steps = [{ ...STEPS[0], submit: sleep(1000) }, STEPS[1]];
    const { getByText } = render(<FormSteps steps={steps} />);

    const continueButton = getByText('forms.buttons.continue');
    await userEvent.click(continueButton, { advanceTimers: sandbox.clock.tick });

    expect(getByText('First Title')).to.be.ok();
    expect(
      continueButton
        .closest('lg-spinner-button')!
        .classList.contains('spinner-button--spinner-active'),
    ).to.be.true();
    await sandbox.clock.tickAsync(1000);

    expect(getByText('Second Title')).to.be.ok();
  });

  it('uses submit implementation return value as patch to form values', async () => {
    const steps = [
      { ...STEPS[0], submit: () => Promise.resolve({ secondInputOne: 'received' }) },
      STEPS[1],
    ];
    const { getByText, findByDisplayValue } = render(<FormSteps steps={steps} />);

    const continueButton = getByText('forms.buttons.continue');
    await userEvent.click(continueButton);

    expect(await findByDisplayValue('received')).to.be.ok();
  });

  it('does not proceed if step submit implementation throws an error', async () => {
    sandbox.useFakeTimers();
    const steps = [
      {
        ...STEPS[0],
        submit: () =>
          sleep(1000)().then(() => {
            throw new Error('oops');
          }),
      },
      STEPS[1],
    ];
    const { getByText } = render(<FormSteps steps={steps} />);

    const continueButton = getByText('forms.buttons.continue');
    await userEvent.click(continueButton, { advanceTimers: sandbox.clock.tick });

    await sandbox.clock.tickAsync(1000);

    expect(getByText('Errors: oops')).to.be.ok();
  });

  it('renders the active step', async () => {
    const { getByText } = render(<FormSteps steps={STEPS} />);

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

    expect(getByText('Second Title')).to.be.ok();
  });

  it('calls onStepChange callback on step change', async () => {
    const onStepChange = sinon.spy();
    const { getByText } = render(<FormSteps steps={STEPS} onStepChange={onStepChange} />);

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

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

  it('does not call onStepChange if step does not progress due to validation error', async () => {
    const onStepChange = sinon.spy();
    const { getByText } = render(<FormSteps steps={STEPS} onStepChange={onStepChange} />);

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

    expect(onStepChange.callCount).to.equal(1);
  });

  it('calls onChange with updated form values', async () => {
    const onChange = sinon.spy();
    const { getByText, getByLabelText } = render(<FormSteps steps={STEPS} onChange={onChange} />);

    await userEvent.click(getByText('forms.buttons.continue'));
    await userEvent.type(getByLabelText('Second Input One'), 'one');

    expect(onChange).to.have.been.calledWith({ changed: true, secondInputOne: 'o' });
    expect(onChange).to.have.been.calledWith({ changed: true, secondInputOne: 'on' });
    expect(onChange).to.have.been.calledWith({ changed: true, secondInputOne: 'one' });
  });

  it('provides set onChange option for non-patch value change', async () => {
    const steps = [
      {
        name: 'first',
        title: 'First Title',
        form: ({ onChange, value }) => (
          <>
            <button
              type="button"
              onClick={useCallback(
                sinon
                  .stub()
                  .onFirstCall()
                  .callsFake(() => onChange({ a: 1 }))
                  .onSecondCall()
                  .callsFake(() => onChange({ b: 2 }, { patch: false })),
                [],
              )}
            >
              Change Value
            </button>
            <span data-testid="value">{JSON.stringify(value)}</span>
          </>
        ),
      },
    ];

    const { getByRole, getByTestId } = render(<FormSteps steps={steps} />);

    const button = getByRole('button', { name: 'Change Value' });
    await userEvent.click(button);
    await userEvent.click(button);

    const value = getByTestId('value');
    expect(value.textContent).to.equal('{"b":2}');
  });

  it('submits with form values', async () => {
    const onComplete = sinon.spy();
    const { getByText, getByLabelText } = render(
      <FormSteps steps={STEPS} onComplete={onComplete} />,
    );

    await userEvent.click(getByText('forms.buttons.continue'));
    await userEvent.type(getByLabelText('Second Input One'), 'one');
    await userEvent.type(getByLabelText('Second Input Two'), 'two');
    await userEvent.click(getByText('forms.buttons.continue'));
    await userEvent.click(getByText('forms.buttons.submit.default'));

    expect(onComplete.getCall(0).args[0]).to.eql({
      secondInputOne: 'one',
      secondInputTwo: 'two',
      changed: true,
    });
  });

  it('will submit the form by enter press in an input', async () => {
    const onComplete = sinon.spy();
    const { getByText, getByLabelText } = render(
      <FormSteps steps={STEPS} onComplete={onComplete} />,
    );

    await userEvent.click(getByText('forms.buttons.continue'));
    await userEvent.type(getByLabelText('Second Input One'), 'one');
    await userEvent.type(getByLabelText('Second Input Two'), 'two{Enter}');

    expect(getByText('Last Title')).to.be.ok();
  });

  it('prompts on navigate if values have been assigned', async () => {
    const { getByText, getByLabelText } = render(<FormSteps steps={STEPS} />);

    await userEvent.click(getByText('forms.buttons.continue'));
    await userEvent.type(getByLabelText('Second Input One'), 'one');

    const event = new window.Event('beforeunload', { cancelable: true, bubbles: false });
    window.dispatchEvent(event);

    expect(event.defaultPrevented).to.be.true();
    expect(event.returnValue).to.be.false();
  });

  it('does not prompt on navigate if no values have been assigned', () => {
    render(<FormSteps steps={STEPS} />);

    const event = new window.Event('beforeunload', { cancelable: true, bubbles: false });
    window.dispatchEvent(event);

    expect(event.defaultPrevented).to.be.false();
    expect(event.returnValue).to.be.true();
  });

  context('promptOnNavigate prop is set to false', () => {
    it('does not prompt on navigate', () => {
      render(<FormSteps steps={STEPS} promptOnNavigate={false} />);

      const event = new window.Event('beforeunload', { cancelable: true, bubbles: false });
      window.dispatchEvent(event);

      expect(event.defaultPrevented).to.be.false();
      expect(event.returnValue).to.be.true();
    });
  });

  it('pushes step to URL', async () => {
    const { getByText } = render(<FormSteps steps={STEPS} />);

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

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

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

  it('syncs step by history events', async () => {
    const { getByText, findByText, getByLabelText } = render(<FormSteps steps={STEPS} />);

    await userEvent.click(getByText('forms.buttons.continue'));
    await userEvent.type(getByLabelText('Second Input One'), 'one');
    await userEvent.type(getByLabelText('Second Input Two'), 'two');

    window.history.back();

    expect(await findByText('First Title')).to.be.ok();
    expect(window.location.hash).to.equal('');

    window.history.forward();

    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('#second');
  });

  it('retains errors from prior steps', async () => {
    const errors = [
      {
        field: 'nonExistentField1',
        error: new FormError('abcde'),
      },
      {
        field: 'nonExistentField2',
        error: new FormError('12345'),
      },
    ];
    const { getByText, findByText } = render(
      <FormSteps steps={STEPS} initialActiveErrors={errors} />,
    );

    const checkFormHasExpectedErrors = () =>
      findByText('Errors:', { exact: false }).then((e) =>
        expect(e.parentElement?.textContent).contains('abcde,12345'),
      );

    await expect(checkFormHasExpectedErrors()).to.be.fulfilled();

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

    await findByText('Second Title');
    await expect(checkFormHasExpectedErrors()).to.be.rejected();

    window.history.back();

    await expect(checkFormHasExpectedErrors()).to.be.fulfilled();
  });

  it('shifts focus to next heading on step change', async () => {
    const { getByText } = render(<FormSteps steps={STEPS} />);

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

    expect(document.activeElement).to.equal(getByText('Second Title'));
  });

  it("doesn't assign focus on mount", () => {
    const { activeElement: originalActiveElement } = document;
    render(<FormSteps steps={STEPS} />);
    expect(document.activeElement).to.equal(originalActiveElement);
  });

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

    expect(document.activeElement).to.equal(getByText('First Title'));
  });

  it('accepts initial values', async () => {
    const { getByText, getByLabelText } = render(
      <FormSteps steps={STEPS} initialValues={{ secondInputOne: 'prefilled' }} />,
    );

    await userEvent.click(getByText('forms.buttons.continue'));
    const input = getByLabelText('Second Input One') as HTMLInputElement;

    expect(input.value).to.equal('prefilled');
  });

  it('prevents submission if step is invalid', async () => {
    const { getByText, getByLabelText, container } = render(<FormSteps steps={STEPS} />);

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

    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);

    await userEvent.type(document.activeElement as HTMLInputElement, 'one');
    expect(container.querySelectorAll('[data-is-error]')).to.have.lengthOf(1);

    await userEvent.click(getByText('forms.buttons.continue'));
    expect(document.activeElement).to.equal(getByLabelText('Second Input Two'));
    expect(container.querySelectorAll('[data-is-error]')).to.have.lengthOf(1);

    await userEvent.type(document.activeElement as HTMLInputElement, 'two');
    expect(container.querySelectorAll('[data-is-error]')).to.have.lengthOf(0);
    await userEvent.click(getByText('forms.buttons.continue'));

    expect(document.activeElement).to.equal(getByText('Last Title'));
  });

  it('respects native custom input validity', async () => {
    const { getByRole } = render(<FormSteps steps={STEPS} />);

    await userEvent.click(getByRole('button', { name: 'forms.buttons.continue' }));
    const inputOne = getByRole('textbox', { name: 'Second Input One' }) as HTMLInputElement;
    const inputTwo = getByRole('textbox', { name: 'Second Input Two' }) as HTMLInputElement;

    // Make inputs otherwise valid.
    await userEvent.type(inputOne, 'one');
    await userEvent.type(inputTwo, 'two');

    // Add custom validity error.
    const checkValidity = () => {
      inputOne.setCustomValidity('Custom Error');
      return false;
    };
    inputOne.reportValidity = checkValidity;
    inputOne.checkValidity = checkValidity;

    await userEvent.click(getByRole('button', { name: 'forms.buttons.continue' }));

    expect(inputOne.hasAttribute('data-is-error')).to.be.true();
    expect(document.activeElement).to.equal(inputOne);
  });

  it('supports ref assignment to arbitrary (non-input) elements', async () => {
    const onComplete = sandbox.stub();
    const { getByRole } = render(
      <FormSteps
        onComplete={onComplete}
        steps={[
          {
            name: 'first',
            form({ registerField }) {
              return (
                <div ref={registerField('element')}>
                  <FormStepsButton.Submit />
                </div>
              );
            },
          },
        ]}
      />,
    );

    await userEvent.click(getByRole('button', { name: 'forms.buttons.submit.default' }));

    expect(onComplete).to.have.been.called();
  });

  it('distinguishes empty errors from progressive error removal', async () => {
    const { getByText, getByLabelText, container } = render(<FormSteps steps={STEPS} />);

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

    await userEvent.type(getByLabelText('Second Input One'), 'one');
    expect(container.querySelectorAll('[data-is-error]')).to.have.lengthOf(0);
  });

  it('renders with initial active errors', async () => {
    // Assumption: initialActiveErrors are only shown in combination with a flow of a single step.
    const steps = [STEPS[1]];
    const onComplete = sinon.spy();

    const { getByLabelText, getByText, getByRole } = render(
      <FormSteps
        steps={steps}
        initialValues={{
          secondInputTwo: 'two',
        }}
        initialActiveErrors={[
          {
            field: 'unknown',
            error: new FormError(),
          },
          {
            field: 'secondInputOne',
            error: new FormError(),
          },
          {
            field: 'secondInputTwo',
            error: new FormError(),
          },
        ]}
        onComplete={onComplete}
      />,
    );

    // Field associated errors are handled by the field.
    const inputOne = getByLabelText('Second Input One');
    const inputTwo = getByLabelText('Second Input Two');
    expect(inputOne.matches('[data-is-error]')).to.be.true();
    expect(inputTwo.matches('[data-is-error]')).to.be.true();

    // Attempting to submit without adjusting field value does not submit and shows error.
    await userEvent.click(getByText('forms.buttons.continue'));
    expect(onComplete.called).to.be.false();
    await waitFor(() => expect(document.activeElement).to.equal(inputOne));

    // Changing the value for the first field should unset the first error.
    await userEvent.type(inputOne, 'one');
    expect(inputOne.matches('[data-is-error]')).to.be.false();
    expect(inputTwo.matches('[data-is-error]')).to.be.true();

    // Default required validation should still happen and take the place of any unknown errors.
    await userEvent.click(getByText('forms.buttons.continue'));
    expect(onComplete.called).to.be.false();
    await waitFor(() => expect(document.activeElement).to.equal(inputTwo));
    expect(inputOne.matches('[data-is-error]')).to.be.false();
    expect(inputTwo.matches('[data-is-error]')).to.be.true();
    expect(() => getByRole('alert')).to.throw();

    // Changing the value for the second field should unset the second error.
    await userEvent.type(inputTwo, 'two');
    expect(inputOne.matches('[data-is-error]')).to.be.false();
    expect(inputTwo.matches('[data-is-error]')).to.be.false();

    // The user can submit once all errors have been resolved.
    await userEvent.click(getByText('forms.buttons.continue'));
    expect(onComplete.calledOnce).to.be.true();
  });

  it('renders field-emitted errors', async () => {
    const steps = [STEPS[1]];

    const { getByLabelText } = render(<FormSteps steps={steps} />);
    const inputOne = getByLabelText('Second Input One') as HTMLInputElement;
    inputOne.setCustomValidity('uh oh');
    await userEvent.type(inputOne, 'one');

    expect(inputOne.hasAttribute('data-is-error')).to.be.true();
  });

  it('renders and moves focus to step errors', async () => {
    const steps = [STEPS[1]];

    const { getByRole } = render(<FormSteps steps={steps} />);
    const button = getByRole('button', { name: 'Create Step Error' });
    await await userEvent.click(button);

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

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

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

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

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

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

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

  it('allows context consumers to trigger content reset', async () => {
    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;
    await userEvent.click(getByRole('button', { name: 'Replace' }));
    sandbox.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();
  });

  it('provides the step implementation the option to navigate to the previous step', async () => {
    const { getByText } = render(<FormSteps steps={STEPS} />);

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

    expect(getByText('First Title')).to.be.ok();
  });

  it('supports starting at a specific step', () => {
    const { getByText } = render(<FormSteps steps={STEPS} initialStep="second" />);

    expect(getByText('Second Title')).to.be.ok();
  });
});