Skip to content

Commit

Permalink
Add instantValidation prop for TextField & share logic with TextArea (
Browse files Browse the repository at this point in the history
#2357)

## Summary:
- Adds `instantValidation` prop to `TextField` component
- Create `useFieldValidation` hook to share validation logic between `TextField` and `TextArea`
- `TextArea` is updated to use `useFieldValidation` hook.
- Both `TextArea` and `TextField` will now validate on initialization if `value` is not empty and it is not disabled
- Stories: Use utility functions for validate functions

Issue: WB-1781

## Test plan:
- TextField
  - Instant Validation docs are reviewed (`/?path=/docs/packages-form-textfield--docs#instant%20validation`)
  - Instant Validation works as expected (see docs for expected behaviour) (`/?path=/story/packages-form-textfield--instant-validation`)
- TextArea
  - Instant Validation docs are reviewed (`/?path=/docs/packages-form-textarea--docs#instant%20validation`)
  - Instant Validation works as expected (see docs for expected behaviour) (`/?path=/story/packages-form-textarea--instant-validation`)

Author: beaesguerra

Reviewers: beaesguerra, jandrade

Required Reviewers:

Approved By: jandrade

Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test / Test (ubuntu-latest, 20.x, 2/2), ✅ Test / Test (ubuntu-latest, 20.x, 1/2), ✅ Lint / Lint (ubuntu-latest, 20.x), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ⏭️  Chromatic - Skip on Release PR (changesets), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ gerald, ⏭️  dependabot

Pull Request URL: #2357
  • Loading branch information
beaesguerra authored Nov 13, 2024
1 parent 21f6779 commit 486c6a8
Show file tree
Hide file tree
Showing 11 changed files with 1,982 additions and 187 deletions.
9 changes: 9 additions & 0 deletions .changeset/old-ghosts-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@khanacademy/wonder-blocks-form": minor
---

- `TextField`
- Add `instantValidation` prop
- No longer calls `validate` prop if the field is disabled during initialization and on change
- `TextArea`
- Validate the value during initialization if the field is not disabled
23 changes: 23 additions & 0 deletions __docs__/wonder-blocks-form/form-utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Checks if a value is a valid email and returns an error message if it is invalid.
* @param value the email to validate
* @returns An error message if there is a validation error
*/
export const validateEmail = (value: string) => {
const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/;
if (!emailRegex.test(value)) {
return "Please enter a valid email";
}
};

/**
* Checks if a value is a valid phone number and returns an error message if it is invalid.
* @param value the phone number to validate
* @returns An error message if there is a validation error
*/
export const validatePhoneNumber = (value: string) => {
const telRegex = /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/;
if (!telRegex.test(value)) {
return "Invalid US telephone number";
}
};
22 changes: 4 additions & 18 deletions __docs__/wonder-blocks-form/text-area.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {Strut} from "@khanacademy/wonder-blocks-layout";
import {PropsFor, View} from "@khanacademy/wonder-blocks-core";

import TextAreaArgTypes from "./text-area.argtypes";
import {validateEmail} from "./form-utilities";

/**
* A TextArea is an element used to accept text from the user.
Expand Down Expand Up @@ -200,12 +201,7 @@ export const Error: StoryComponentType = {
export const ErrorFromValidation: StoryComponentType = {
args: {
value: "khan",
validate(value: string) {
const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/;
if (!emailRegex.test(value)) {
return "Please enter a valid email";
}
},
validate: validateEmail,
},
render: ControlledTextArea,
parameters: {
Expand Down Expand Up @@ -255,12 +251,7 @@ export const ErrorFromPropAndValidation = (args: PropsFor<typeof TextArea>) => {
{...args}
value={value}
onChange={handleChange}
validate={(value: string) => {
const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/;
if (!emailRegex.test(value)) {
return "Please enter a valid email";
}
}}
validate={validateEmail}
onValidate={setValidationErrorMessage}
error={!!errorMessage}
/>
Expand Down Expand Up @@ -315,12 +306,7 @@ ErrorFromPropAndValidation.parameters = {
*/
export const InstantValidation: StoryComponentType = {
args: {
validate(value: string) {
const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/;
if (!emailRegex.test(value)) {
return "Please enter a valid email";
}
},
validate: validateEmail,
},
render: (args) => {
return (
Expand Down
13 changes: 13 additions & 0 deletions __docs__/wonder-blocks-form/text-field.argtypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,19 @@ export default {
},
},

instantValidation: {
description:
"If true, TextField is validated as the user types (onChange). If false, it is validated when the user's focus moves out of the field (onBlur). It is preferred that instantValidation is set to `false`, however, it defaults to `true` for backwards compatibility with existing implementations.",
table: {
type: {
summary: "boolean",
},
},
control: {
type: "boolean",
},
},

/**
* Number-specific props
*/
Expand Down
167 changes: 121 additions & 46 deletions __docs__/wonder-blocks-form/text-field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import packageConfig from "../../packages/wonder-blocks-form/package.json";

import ComponentInfo from "../../.storybook/components/component-info";
import TextFieldArgTypes from "./text-field.argtypes";
import {validateEmail, validatePhoneNumber} from "./form-utilities";

/**
* A TextField is an element used to accept a single line of text from the user.
Expand Down Expand Up @@ -269,13 +270,6 @@ export const Email: StoryComponentType = () => {
setValue(newValue);
};

const validate = (value: string) => {
const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/;
if (!emailRegex.test(value)) {
return "Please enter a valid email";
}
};

const handleValidate = (errorMessage?: string | null) => {
setErrorMessage(errorMessage);
};
Expand All @@ -301,7 +295,7 @@ export const Email: StoryComponentType = () => {
type="email"
value={value}
placeholder="Email"
validate={validate}
validate={validateEmail}
onValidate={handleValidate}
onChange={handleChange}
onKeyDown={handleKeyDown}
Expand Down Expand Up @@ -338,13 +332,6 @@ export const Telephone: StoryComponentType = () => {
setValue(newValue);
};

const validate = (value: string) => {
const telRegex = /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/;
if (!telRegex.test(value)) {
return "Invalid US telephone number";
}
};

const handleValidate = (errorMessage?: string | null) => {
setErrorMessage(errorMessage);
};
Expand All @@ -370,7 +357,7 @@ export const Telephone: StoryComponentType = () => {
type="tel"
value={value}
placeholder="Telephone"
validate={validate}
validate={validatePhoneNumber}
onValidate={handleValidate}
onChange={handleChange}
onKeyDown={handleKeyDown}
Expand Down Expand Up @@ -398,6 +385,32 @@ Telephone.parameters = {
},
};

const ControlledTextField = (args: PropsFor<typeof TextField>) => {
const [value, setValue] = React.useState(args.value || "");
const [error, setError] = React.useState<string | null | undefined>(null);

const handleChange = (newValue: string) => {
setValue(newValue);
};

return (
<View>
<TextField
{...args}
value={value}
onChange={handleChange}
onValidate={setError}
/>
<Strut size={spacing.xxSmall_6} />
{(error || args.error) && (
<LabelSmall style={styles.errorMessage}>
{error || "Error from error prop"}
</LabelSmall>
)}
</View>
);
};

function ErrorRender(args: PropsFor<typeof TextField>) {
const [value, setValue] = React.useState("khan");
const [errorMessage, setErrorMessage] = React.useState<any>();
Expand All @@ -406,13 +419,6 @@ function ErrorRender(args: PropsFor<typeof TextField>) {
setValue(newValue);
};

const validate = (value: string) => {
const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/;
if (!emailRegex.test(value)) {
return "Please enter a valid email";
}
};

const handleValidate = (errorMessage?: string | null) => {
setErrorMessage(errorMessage);
};
Expand All @@ -429,7 +435,7 @@ function ErrorRender(args: PropsFor<typeof TextField>) {
id="tf-7"
type="email"
placeholder="Email"
validate={validate}
validate={validateEmail}
onValidate={handleValidate}
onKeyDown={handleKeyDown}
{...args}
Expand Down Expand Up @@ -534,12 +540,7 @@ export const ErrorFromPropAndValidation = (
{...args}
value={value}
onChange={handleChange}
validate={(value: string) => {
const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/;
if (!emailRegex.test(value)) {
return "Please enter a valid email";
}
}}
validate={validateEmail}
onValidate={setValidationErrorMessage}
error={!!errorMessage}
/>
Expand Down Expand Up @@ -574,6 +575,94 @@ ErrorFromPropAndValidation.parameters = {
},
};

/**
* The `instantValidation` prop controls when validation is triggered. Validation
* is triggered if the `validate` or `required` props are set.
*
* It is preferred to set `instantValidation` to `false` so that the user isn't
* shown an error until they are done with a field. Note: if `instantValidation`
* is not explicitly set, it defaults to `true` since this is the current
* behaviour of existing usage. Validation on blur needs to be opted in.
*
* Validation is triggered:
* - On mount if the `value` prop is not empty
* - If `instantValidation` is `true`, validation occurs `onChange` (default)
* - If `instantValidation` is `false`, validation occurs `onBlur`
*
* When `required` is set to `true`:
* - If `instantValidation` is `true`, the required error message is shown after
* a value is cleared
* - If `instantValidation` is `false`, the required error message is shown
* whenever the user tabs away from the required field
*/
export const InstantValidation: StoryComponentType = {
args: {
validate: validateEmail,
},
render: (args) => {
return (
<View style={{gap: spacing.small_12}}>
<LabelSmall htmlFor="instant-validation-true-not-required">
Validation on mount if there is a value
</LabelSmall>
<ControlledTextField
{...args}
id="instant-validation-true-not-required"
value="invalid"
/>
<LabelSmall htmlFor="instant-validation-true-not-required">
Error shown immediately (instantValidation: true, required:
false)
</LabelSmall>
<ControlledTextField
{...args}
id="instant-validation-true-not-required"
instantValidation={true}
/>
<LabelSmall htmlFor="instant-validation-false-not-required">
Error shown onBlur (instantValidation: false, required:
false)
</LabelSmall>
<ControlledTextField
{...args}
id="instant-validation-false-not-required"
instantValidation={false}
/>

<LabelSmall htmlFor="instant-validation-true-required">
Error shown immediately after clearing the value
(instantValidation: true, required: true)
</LabelSmall>
<ControlledTextField
{...args}
validate={undefined}
value="T"
id="instant-validation-true-required"
instantValidation={true}
required="Required"
/>
<LabelSmall htmlFor="instant-validation-false-required">
Error shown on blur if it is empty (instantValidation:
false, required: true)
</LabelSmall>
<ControlledTextField
{...args}
validate={undefined}
id="instant-validation-false-required"
instantValidation={false}
required="Required"
/>
</View>
);
},
parameters: {
chromatic: {
// Disabling because this doesn't test anything visual.
disableSnapshot: true,
},
},
};

export const Light: StoryComponentType = () => {
const [value, setValue] = React.useState("khan@khanacademy.org");
const [errorMessage, setErrorMessage] = React.useState<any>();
Expand All @@ -583,13 +672,6 @@ export const Light: StoryComponentType = () => {
setValue(newValue);
};

const validate = (value: string) => {
const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/;
if (!emailRegex.test(value)) {
return "Please enter a valid email";
}
};

const handleValidate = (errorMessage?: string | null) => {
setErrorMessage(errorMessage);
};
Expand All @@ -616,7 +698,7 @@ export const Light: StoryComponentType = () => {
value={value}
placeholder="Email"
light={true}
validate={validate}
validate={validateEmail}
onValidate={handleValidate}
onChange={handleChange}
onKeyDown={handleKeyDown}
Expand Down Expand Up @@ -655,13 +737,6 @@ export const ErrorLight: StoryComponentType = () => {
setValue(newValue);
};

const validate = (value: string) => {
const emailRegex = /^[^@\s]+@[^@\s.]+\.[^@.\s]+$/;
if (!emailRegex.test(value)) {
return "Please enter a valid email";
}
};

const handleValidate = (errorMessage?: string | null) => {
setErrorMessage(errorMessage);
};
Expand All @@ -688,7 +763,7 @@ export const ErrorLight: StoryComponentType = () => {
value={value}
placeholder="Email"
light={true}
validate={validate}
validate={validateEmail}
onValidate={handleValidate}
onChange={handleChange}
onKeyDown={handleKeyDown}
Expand Down
Loading

0 comments on commit 486c6a8

Please sign in to comment.