Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d3a2b14
TypeScript-ify ValidatedField
aduth Apr 19, 2022
baf7a18
Rename ValidatedField to ValidatedFieldElement
aduth Apr 19, 2022
fc5dd00
Move ValidatedFieldElement out of index
aduth Apr 19, 2022
7c530bf
Auto-register ValidatedFieldElement custom element
aduth Apr 19, 2022
656a322
Add ValidatedField React component
aduth Apr 19, 2022
36ba583
Enhance FormSteps to work with ValidatedFieldElement
aduth Apr 19, 2022
fbbea96
LG-6160: Add personal key validation behavior
aduth Apr 19, 2022
44a9918
Type narrowing via typeof function
aduth Apr 19, 2022
6d8e8e2
Use native checkValidity for setting custom validity
aduth Apr 19, 2022
4f32ae0
Add specs for ValidatedField React component
aduth Apr 19, 2022
8a8927b
Combine checkValidity + reportValidity
aduth Apr 19, 2022
a0818f6
Update spec stubbing
aduth Apr 19, 2022
ac0fbc3
Add spec for PersonalKeyInput validation
aduth Apr 20, 2022
a5b03ce
Add spec for PersonalKeyConfirmStep validation
aduth Apr 20, 2022
0efc340
Submit FormSteps using context
aduth Apr 20, 2022
c49a383
Add failing spec for missing "Enter"-to-submit behavior
aduth Apr 20, 2022
b1ae97c
Add missing value to fake FormStepsContext
aduth Apr 20, 2022
51336e7
Shim fake form for modal input submission-by-enter
aduth Apr 20, 2022
5a8df6c
Remove unused import
aduth Apr 20, 2022
532154e
Add (failling) regression spec for expected initial errors behavior
aduth Apr 20, 2022
d053069
Restore initial active error handling, field-level error clearing
aduth Apr 20, 2022
e38860d
Handle submit bubbling via empty form tag
aduth Apr 21, 2022
4dd1263
Restore FormStepsContext default value
aduth Apr 21, 2022
4110e4e
Update specs for new version of testing-library/user-event
aduth Apr 21, 2022
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
4 changes: 1 addition & 3 deletions app/components/validated_field_component.js
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
import { ValidatedField } from '@18f/identity-validated-field';

customElements.define('lg-validated-field', ValidatedField);
import '@18f/identity-validated-field';
55 changes: 50 additions & 5 deletions app/javascript/packages/form-steps/form-steps.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,19 @@ describe('FormSteps', () => {
});
});

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

Expand Down Expand Up @@ -323,6 +336,31 @@ describe('FormSteps', () => {
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('distinguishes empty errors from progressive error removal', async () => {
const { getByText, getByLabelText, container } = render(<FormSteps steps={STEPS} />);

Expand All @@ -340,6 +378,9 @@ describe('FormSteps', () => {
const { getByLabelText, getByText, getByRole } = render(
<FormSteps
steps={steps}
initialValues={{
secondInputTwo: 'two',
}}
initialActiveErrors={[
{
field: 'unknown',
Expand All @@ -349,26 +390,30 @@ describe('FormSteps', () => {
field: 'secondInputOne',
error: new FormError(),
},
{
field: 'secondInputTwo',
error: new FormError(),
},
]}
onComplete={onComplete}
/>,
);

// Field associated errors are handled by the field. There should only be one.
// 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.false();
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.submit.default'));
expect(onComplete.called).to.be.false();
await waitFor(() => expect(document.activeElement).to.equal(inputOne));

// Changing the value for the field should unset the error.
// 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.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.submit.default'));
Expand All @@ -378,7 +423,7 @@ describe('FormSteps', () => {
expect(inputTwo.matches('[data-is-error]')).to.be.true();
expect(() => getByRole('alert')).to.throw();

// Changing the value for the field should unset the error.
// 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();
Expand Down
33 changes: 24 additions & 9 deletions app/javascript/packages/form-steps/form-steps.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import type { RefCallback, FormEventHandler, FC } from 'react';
import type { FormEventHandler, RefCallback, FC } from 'react';
import { Alert } from '@18f/identity-components';
import { useDidUpdateEffect, useIfStillMounted } from '@18f/identity-react-hooks';
import RequiredValueMissingError from './required-value-missing-error';
Expand Down Expand Up @@ -30,7 +30,7 @@ interface FormStepRegisterFieldOptions {
export type RegisterFieldCallback = (
field: string,
options?: Partial<FormStepRegisterFieldOptions>,
) => undefined | RefCallback<HTMLElement>;
) => undefined | RefCallback<HTMLInputElement>;

export type OnErrorCallback = (error: Error, options?: { field?: string | null }) => void;

Expand Down Expand Up @@ -87,7 +87,7 @@ interface FieldsRefEntry {
/**
* Ref callback.
*/
refCallback: RefCallback<HTMLElement>;
refCallback: RefCallback<HTMLInputElement>;

/**
* Whether field is required.
Expand All @@ -97,7 +97,7 @@ interface FieldsRefEntry {
/**
* Element assigned by ref callback.
*/
element: HTMLElement | null;
element: HTMLInputElement | null;
}

interface FormStepsProps {
Expand Down Expand Up @@ -195,7 +195,11 @@ function FormSteps({
const ifStillMounted = useIfStillMounted();
useEffect(() => {
if (activeErrors.length && didSubmitWithErrors.current) {
getFieldActiveErrorFieldElement(activeErrors, fields.current)?.focus();
const activeErrorFieldElement = getFieldActiveErrorFieldElement(activeErrors, fields.current);
if (activeErrorFieldElement) {
activeErrorFieldElement.reportValidity();
activeErrorFieldElement.focus();
}
}

didSubmitWithErrors.current = false;
Expand Down Expand Up @@ -242,8 +246,19 @@ function FormSteps({
const { element, isRequired } = fields.current[key];
const isActive = !!element;

if (isActive && isRequired && !values[key]) {
result = result.concat({ field: key, error: new RequiredValueMissingError() });
let error: Error | undefined;
if (isActive) {
element.checkValidity();

if (element.validationMessage) {
error = new Error(element.validationMessage);
} else if (isRequired && !values[key]) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future refactoring could see about eliminating this custom idea of isRequired and just use element.validityState.valueMissing in combination with <input required>.

error = new RequiredValueMissingError();
}
}

if (error) {
result = result.concat({ field: key, error });
}

return result;
Expand Down Expand Up @@ -275,8 +290,8 @@ function FormSteps({

const nextActiveErrors = getValidationErrors();
setActiveErrors(nextActiveErrors);
didSubmitWithErrors.current = true;
if (nextActiveErrors.length) {
didSubmitWithErrors.current = true;
return;
}

Expand All @@ -300,7 +315,7 @@ function FormSteps({
const isLastStep = stepIndex + 1 === steps.length;

return (
<form ref={formRef} onSubmit={toNextStep}>
<form ref={formRef} onSubmit={toNextStep} noValidate>
{promptOnNavigate && Object.keys(values).length > 0 && <PromptOnNavigate />}
{stepErrors.map((error) => (
<Alert key={error.message} type="error" className="margin-bottom-4">
Expand Down
5 changes: 5 additions & 0 deletions app/javascript/packages/validated-field/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import './validated-field-element';

export { default as ValidatedField } from './validated-field';

export type { ValidatedFieldValidator } from './validated-field';
10 changes: 9 additions & 1 deletion app/javascript/packages/validated-field/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
{
"name": "@18f/identity-validated-field",
"private": true,
"version": "1.0.0"
"version": "1.0.0",
"peerDependencies": {
"react": "^17.0.2"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import sinon from 'sinon';
import { getByRole, getByText } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { ValidatedField } from '.';

describe('ValidatedField', () => {
before(() => {
customElements.define('lg-validated-field', ValidatedField);
});
import './validated-field-element';

describe('ValidatedFieldElement', () => {
let idCounter = 0;

function createAndConnectElement({ hasInitialError = false } = {}) {
Expand Down Expand Up @@ -45,11 +41,9 @@ describe('ValidatedField', () => {
it('shows error state and focuses on form validation', () => {
const element = createAndConnectElement();

/** @type {HTMLInputElement} */
const input = getByRole(element, 'textbox');
const input = getByRole(element, 'textbox') as HTMLInputElement;

/** @type {HTMLFormElement} */
const form = element.parentNode;
const form = element.parentNode as HTMLFormElement;
form.checkValidity();

expect(input.classList.contains('usa-input--error')).to.be.true();
Expand All @@ -63,13 +57,11 @@ describe('ValidatedField', () => {
it('shows custom validity as message content', () => {
const element = createAndConnectElement();

/** @type {HTMLInputElement} */
const input = getByRole(element, 'textbox');
const input = getByRole(element, 'textbox') as HTMLInputElement;
input.value = 'a';
input.setCustomValidity('custom validity');

/** @type {HTMLFormElement} */
const form = element.parentNode;
const form = element.parentNode as HTMLFormElement;
form.checkValidity();

expect(getByText(element, 'custom validity')).to.be.ok();
Expand All @@ -78,11 +70,9 @@ describe('ValidatedField', () => {
it('clears existing validation state on input', async () => {
const element = createAndConnectElement();

/** @type {HTMLInputElement} */
const input = getByRole(element, 'textbox');
const input = getByRole(element, 'textbox') as HTMLInputElement;

/** @type {HTMLFormElement} */
const form = element.parentNode;
const form = element.parentNode as HTMLFormElement;
form.checkValidity();

await userEvent.type(input, '5');
Expand All @@ -96,11 +86,9 @@ describe('ValidatedField', () => {
const firstElement = createAndConnectElement();
createAndConnectElement();

/** @type {HTMLInputElement} */
const firstInput = getByRole(firstElement, 'textbox');
const firstInput = getByRole(firstElement, 'textbox') as HTMLInputElement;

/** @type {HTMLFormElement} */
const form = document.querySelector('form');
const form = document.querySelector('form') as HTMLFormElement;

form.checkValidity();

Expand All @@ -111,11 +99,9 @@ describe('ValidatedField', () => {
it('clears existing validation state on input', async () => {
const element = createAndConnectElement();

/** @type {HTMLInputElement} */
const input = getByRole(element, 'textbox');
const input = getByRole(element, 'textbox') as HTMLInputElement;

/** @type {HTMLFormElement} */
const form = element.parentNode;
const form = element.parentNode as HTMLFormElement;
form.checkValidity();

await userEvent.type(input, '5');
Expand All @@ -131,12 +117,10 @@ describe('ValidatedField', () => {
const inputWidth = 280;
const element = createAndConnectElement();

/** @type {HTMLInputElement} */
const input = getByRole(element, 'textbox');
const input = getByRole(element, 'textbox') as HTMLInputElement;
sinon.stub(input, 'offsetWidth').value(inputWidth);

/** @type {HTMLFormElement} */
const form = element.parentNode;
const form = element.parentNode as HTMLFormElement;
form.checkValidity();

const message = getByText(element, 'This field is required');
Expand All @@ -148,12 +132,10 @@ describe('ValidatedField', () => {
it('does not set max width on error message', () => {
const element = createAndConnectElement();

/** @type {HTMLInputElement} */
const input = getByRole(element, 'textbox');
const input = getByRole(element, 'textbox') as HTMLInputElement;
input.type = 'checkbox';

/** @type {HTMLFormElement} */
const form = element.parentNode;
const form = element.parentNode as HTMLFormElement;
form.checkValidity();

const message = getByText(element, 'This field is required');
Expand Down
Loading