Skip to content

Commit

Permalink
feat(form): better defaults for validation
Browse files Browse the repository at this point in the history
  • Loading branch information
mlaursen committed Nov 24, 2020
1 parent e8fb252 commit 4003a07
Show file tree
Hide file tree
Showing 6 changed files with 79 additions and 70 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe("PasswordWithMessage", () => {
expect(field).toHaveAttribute("maxLength", "20");
});

it("should enable the error state if the value is less than the minLength or more than the maxLength", () => {
it("should enable the error state if the value is greater than the maxLength", () => {
const { getByPlaceholderText, getByRole } = render(
<Test minLength={5} maxLength={20} messageRole="alert" />
);
Expand All @@ -116,8 +116,8 @@ describe("PasswordWithMessage", () => {
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "1" } });
expect(container.className).toContain("--error");
expect(message.className).toContain("--error");
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "Valid" } });
expect(container.className).not.toContain("--error");
Expand Down
21 changes: 10 additions & 11 deletions packages/form/src/text-field/__tests__/TextAreaWithMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ describe("TextAreaWithMessage", () => {

expect(field).toHaveAttribute("aria-describedby", "field-id-message");
expect(field.textContent).toBe("");
/* expect(field.value).toBe(""); */

fireEvent.change(field, { target: { value: "a" } });
expect(field.textContent).toBe("a");
Expand Down Expand Up @@ -99,7 +98,7 @@ describe("TextAreaWithMessage", () => {
expect(field).toHaveAttribute("maxLength", "20");
});

it("should enable the error state if the value is less than the minLength or more than the maxLength", () => {
it("should enable the error state if the value is greater than the maxLength", () => {
const { getByRole } = render(
<Test minLength={5} maxLength={20} messageRole="alert" />
);
Expand All @@ -111,8 +110,8 @@ describe("TextAreaWithMessage", () => {
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "1" } });
expect(container.className).toContain("--error");
expect(message.className).toContain("--error");
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "Valid" } });
expect(container.className).not.toContain("--error");
Expand All @@ -134,7 +133,7 @@ describe("TextAreaWithMessage", () => {
it("should not update the error state on change or update the value if the custon onChange event stopped propagation", () => {
const { getByRole } = render(
<Test
minLength={10}
maxLength={3}
onChange={(event) => event.stopPropagation()}
messageRole="alert"
/>
Expand All @@ -154,7 +153,7 @@ describe("TextAreaWithMessage", () => {

it("should not update the error state on change if `validateOnChange` is false", () => {
const { getByRole } = render(
<Test minLength={10} validateOnChange={false} messageRole="alert" />
<Test maxLength={3} validateOnChange={false} messageRole="alert" />
);

const field = getByRole("textbox");
Expand All @@ -171,7 +170,7 @@ describe("TextAreaWithMessage", () => {

it("should not update the error state on change if `validateOnChange` is an empty array", () => {
const { getByRole } = render(
<Test minLength={10} validateOnChange={[]} messageRole="alert" />
<Test maxLength={3} validateOnChange={[]} messageRole="alert" />
);

const field = getByRole("textbox");
Expand Down Expand Up @@ -214,7 +213,7 @@ describe("TextAreaWithMessage", () => {

it("should render an icon next to the text field when there is an error by default", () => {
const { getByRole, getByText } = render(
<Test minLength={10} errorIcon={<ErrorOutlineFontIcon />} />
<Test maxLength={3} errorIcon={<ErrorOutlineFontIcon />} />
);
const field = getByRole("textbox");

Expand All @@ -226,7 +225,7 @@ describe("TextAreaWithMessage", () => {
it("should default to the icon from the IconProvider", () => {
const { getByText, getByRole } = render(
<IconProvider>
<Test minLength={10} />
<Test maxLength={3} />
</IconProvider>
);
const field = getByRole("textbox");
Expand All @@ -239,7 +238,7 @@ describe("TextAreaWithMessage", () => {
it("should override the IconProvider error icon when the errorIcon prop is defined", () => {
const { getByRole, getByText, rerender } = render(
<IconProvider>
<Test minLength={10} errorIcon={null} />
<Test maxLength={3} errorIcon={null} />
</IconProvider>
);
const field = getByRole("textbox");
Expand All @@ -250,7 +249,7 @@ describe("TextAreaWithMessage", () => {

rerender(
<IconProvider>
<Test minLength={10} errorIcon={<span>My Icon!</span>} />
<Test maxLength={3} errorIcon={<span>My Icon!</span>} />
</IconProvider>
);
expect(() => getByText("My Icon!")).not.toThrow();
Expand Down
26 changes: 13 additions & 13 deletions packages/form/src/text-field/__tests__/TextFieldWithMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe("TextFieldWithMessage", () => {
expect(field).toHaveAttribute("maxLength", "20");
});

it("should enable the error state if the value is less than the minLength or more than the maxLength", () => {
it("should enable the error state if the value is greater than the maxLength", () => {
const { getByRole } = render(
<Test minLength={5} maxLength={20} messageRole="alert" />
);
Expand All @@ -112,8 +112,8 @@ describe("TextFieldWithMessage", () => {
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "1" } });
expect(container.className).toContain("--error");
expect(message.className).toContain("--error");
expect(container.className).not.toContain("--error");
expect(message.className).not.toContain("--error");

fireEvent.change(field, { target: { value: "Valid" } });
expect(container.className).not.toContain("--error");
Expand Down Expand Up @@ -215,7 +215,7 @@ describe("TextFieldWithMessage", () => {

it("should render an icon next to the text field when there is an error by default", () => {
const { getByRole, getByText } = render(
<Test minLength={10} errorIcon={<ErrorOutlineFontIcon />} />
<Test maxLength={3} errorIcon={<ErrorOutlineFontIcon />} />
);
const field = getByRole("textbox");

Expand All @@ -227,7 +227,7 @@ describe("TextFieldWithMessage", () => {
it("should default to the icon from the IconProvider", () => {
const { getByText, getByRole } = render(
<IconProvider>
<Test minLength={10} />
<Test maxLength={3} />
</IconProvider>
);
const field = getByRole("textbox");
Expand All @@ -240,7 +240,7 @@ describe("TextFieldWithMessage", () => {
it("should override the IconProvider error icon when the errorIcon prop is defined", () => {
const { getByRole, getByText, rerender } = render(
<IconProvider>
<Test minLength={10} errorIcon={null} />
<Test maxLength={3} errorIcon={null} />
</IconProvider>
);
const field = getByRole("textbox");
Expand All @@ -251,7 +251,7 @@ describe("TextFieldWithMessage", () => {

rerender(
<IconProvider>
<Test minLength={10} errorIcon={<span>My Icon!</span>} />
<Test maxLength={3} errorIcon={<span>My Icon!</span>} />
</IconProvider>
);
expect(() => getByText("My Icon!")).not.toThrow();
Expand Down Expand Up @@ -306,7 +306,7 @@ describe("TextFieldWithMessage", () => {
<Test
onBlur={(event) => event.stopPropagation()}
messageRole="alert"
minLength={10}
maxLength={3}
validateOnChange={false}
/>
);
Expand Down Expand Up @@ -354,7 +354,7 @@ describe("TextFieldWithMessage", () => {
it("should allow for a custom isErrored function", () => {
const isErrored = jest.fn(() => false);
const { getByRole } = render(
<Test isErrored={isErrored} messageRole="alert" minLength={10} />
<Test isErrored={isErrored} messageRole="alert" maxLength={3} />
);

expect(isErrored).not.toBeCalled();
Expand All @@ -371,7 +371,7 @@ describe("TextFieldWithMessage", () => {
expect(isErrored).toBeCalledWith({
value: "invalid",
errorMessage: "",
minLength: 10,
maxLength: 3,
isBlurEvent: false,
validateOnChange: "recommended",
validationMessage: "",
Expand All @@ -382,15 +382,15 @@ describe("TextFieldWithMessage", () => {
it("should call the onErrorChange option correctly", () => {
const onErrorChange = jest.fn();
const { getByRole } = render(
<Test onErrorChange={onErrorChange} minLength={10} />
<Test onErrorChange={onErrorChange} maxLength={3} />
);

expect(onErrorChange).not.toBeCalled();
const field = getByRole("textbox");
fireEvent.change(field, { target: { value: "invalid" } });
expect(onErrorChange).toBeCalledWith("field-id", true);

fireEvent.change(field, { target: { value: "this is a valid string" } });
fireEvent.change(field, { target: { value: "v" } });
expect(onErrorChange).toBeCalledWith("field-id", false);
expect(onErrorChange).toBeCalledTimes(2);
});
Expand All @@ -403,7 +403,7 @@ describe("TextFieldWithMessage", () => {
const errorIcon = <span data-testid="error-icon" />;

const { getByTestId, getByRole } = render(
<Test minLength={10} errorIcon={errorIcon} getErrorIcon={getErrorIcon} />
<Test maxLength={3} errorIcon={errorIcon} getErrorIcon={getErrorIcon} />
);
const field = getByRole("textbox");

Expand Down
20 changes: 10 additions & 10 deletions packages/form/src/text-field/__tests__/getErrorMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe("defaultGetErrorMessage", () => {
).toBe("");
});

it("should only return the validity message when the validateOnChange is set to recommeded and one of the non RECOMMEDNED_IGNORED_KEYS are errored", () => {
it("should only return the validity message when the validateOnChange is set to recommeded and one of the RECOMMENDED_STATE_KEYS are errored", () => {
const validate = (key: keyof ValidityState, expected: string): void => {
expect(
defaultGetErrorMessage({
Expand All @@ -98,16 +98,16 @@ describe("defaultGetErrorMessage", () => {
).toBe(expected);
};

validate("badInput", "");
validate("customError", validationMessage);
validate("patternMismatch", validationMessage);
validate("rangeOverflow", validationMessage);
validate("rangeUnderflow", validationMessage);
validate("stepMismatch", validationMessage);
validate("tooLong", "");
validate("badInput", validationMessage);
validate("customError", "");
validate("patternMismatch", "");
validate("rangeOverflow", "");
validate("rangeUnderflow", "");
validate("stepMismatch", "");
validate("tooLong", validationMessage);
validate("tooShort", "");
validate("typeMismatch", validationMessage);
validate("valueMissing", "");
validate("typeMismatch", "");
validate("valueMissing", validationMessage);
});

it("should only return the validation message for the provided validity state key", () => {
Expand Down
71 changes: 40 additions & 31 deletions packages/form/src/text-field/getErrorMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,24 +67,45 @@ export interface ErrorMessageOptions extends TextConstraints {
*/
export type GetErrorMessage = (options: ErrorMessageOptions) => string;

/** @internal */
const RECOMMENDED_IGNORED_KEYS: readonly (keyof ValidityState)[] = [
const VALIDITY_STATE_KEYS: readonly (keyof ValidityState)[] = [
"badInput",
"customError",
"patternMismatch",
"rangeOverflow",
"rangeUnderflow",
"stepMismatch",
"tooLong",
"tooShort",
"typeMismatch",
"valueMissing",
];

/** @internal */
const RECOMMENDED_STATE_KEYS: readonly (keyof ValidityState)[] = [
"badInput",
"tooLong",
"valueMissing",
];

/**
* The default implementation for getting an error message for the `TextField`
* or `TextArea` components that:
*
* - prevents the browser `minLength` and `tooLong` error text from appearing
* during change events since the message is extremely verbose
* - prevents the `valueMissing` and `badInput` error text from appearing during
* change events since it's better to wait for the blur event.
* The validation message is actually kind of weird since it's possible for a
* form element to have multiple errors at once. The validation message will be
* the first error that appears, so need to make sure that the first error is
* one of the recommended state keys so the message appears for only those types
* of errors.
*
* The above behavior is also configured by the {@link ChangeValidationBehavior}.
* @internal
*/
const isRecommended = (validity: ValidityState): boolean =>
VALIDITY_STATE_KEYS.every((key) => {
const errored = validity[key];
return !errored || RECOMMENDED_STATE_KEYS.includes(key);
});

/**
* The default implementation for getting an error message for the `TextField`
* or `TextArea` components that relies on the behavior of the
* {@link ChangeValidationBehavior}
*/
export const defaultGetErrorMessage: GetErrorMessage = ({
isBlurEvent,
Expand All @@ -101,28 +122,16 @@ export const defaultGetErrorMessage: GetErrorMessage = ({
}

if (validateOnChange === "recommended") {
return Object.entries(validity).some(
([key, errored]) =>
errored &&
!RECOMMENDED_IGNORED_KEYS.includes(key as keyof ValidityState)
)
? validationMessage
: "";
return isRecommended(validity) ? validationMessage : "";
}

if (typeof validateOnChange === "string") {
return validity[validateOnChange] ? validationMessage : "";
}

if (
!validateOnChange.length ||
!Object.entries(validity).some(
([key, errored]) =>
errored && validateOnChange.includes(key as keyof ValidityState)
)
) {
return "";
}
const keys =
typeof validateOnChange === "string"
? [validateOnChange]
: validateOnChange;

return validationMessage;
return keys.length &&
VALIDITY_STATE_KEYS.some((key) => validity[key] && keys.includes(key))
? validationMessage
: "";
};
5 changes: 3 additions & 2 deletions packages/form/src/text-field/isErrored.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export const defaultIsErrored: IsErrored = ({
errorMessage,
minLength,
maxLength,
isBlurEvent,
}) =>
!!errorMessage ||
(typeof minLength === "number" && value.length < minLength) ||
(typeof maxLength === "number" && value.length > maxLength);
(typeof maxLength === "number" && value.length > maxLength) ||
(isBlurEvent && typeof minLength === "number" && value.length < minLength);

0 comments on commit 4003a07

Please sign in to comment.