diff --git a/__docs__/wonder-blocks-labeled-field/react-hook-form.stories.tsx b/__docs__/wonder-blocks-labeled-field/react-hook-form.stories.tsx new file mode 100644 index 000000000..5e69bb512 --- /dev/null +++ b/__docs__/wonder-blocks-labeled-field/react-hook-form.stories.tsx @@ -0,0 +1,515 @@ +/* eslint-disable no-console */ +import * as React from "react"; +import { + useForm, + SubmitHandler, + Controller, + SubmitErrorHandler, + useController, +} from "react-hook-form"; +import {Meta} from "@storybook/react"; +import {LabeledField} from "@khanacademy/wonder-blocks-labeled-field"; +import { + CheckboxGroup, + Choice, + RadioGroup, + TextArea, + TextField, +} from "@khanacademy/wonder-blocks-form"; +import { + MultiSelect, + OptionItem, + SingleSelect, +} from "@khanacademy/wonder-blocks-dropdown"; +import {spacing} from "@khanacademy/wonder-blocks-tokens"; +import {View} from "@khanacademy/wonder-blocks-core"; +import Button from "@khanacademy/wonder-blocks-button"; + +export default { + title: "Test/ReactHookForm", + argTypes: { + mode: { + control: { + type: "select", + }, + options: ["onBlur", "onChange", "onSubmit", "onTouched", "all"], + }, + disableFields: { + control: { + type: "boolean", + }, + }, + }, +} as Meta; + +type Inputs = { + exampleTextField: string; + exampleTextArea: string; + exampleSingleSelect: string; + exampleMultiSelect: string[]; + exampleRadioGroup: string; + exampleCheckboxGroup: string[]; +}; + +const textRules = { + required: "This field is required", + maxLength: {value: 5, message: "Max length is 5"}, + minLength: {value: 3, message: "Min length is 3"}, +}; +const selectedValueRules = { + required: "This field is required", + validate: (value: string) => { + if (value === "banana") { + return "Banana is not allowed"; + } + return true; + }, +}; + +const selectedValuesRules = { + required: "This field is required", + validate: (value: string[]) => { + if (value.includes("banana")) { + return "Banana is not allowed"; + } + return true; + }, +}; + +const defaultValues = { + exampleTextField: "textfield", + exampleTextArea: "textarea", + exampleSingleSelect: "banana", + exampleMultiSelect: ["banana"], + exampleCheckboxGroup: ["banana"], + exampleRadioGroup: "banana", +}; + +type StoryArgs = { + mode: "onBlur" | "onChange" | "onSubmit" | "onTouched" | "all" | undefined; + disableFields: boolean; +}; + +/** + * Example of using the Controller component from React Hook Form with controlled + * UI components from Wonder Blocks. + * + * By default, validation is triggered on submit. To change validation mode, + * see other stories or update the story control (you'll need to refresh the page + * after though) + * + * To trigger error messages: + * - TextField, TextArea: + * - No value (because it is required) + * - Text is < 3 or > 5 characters + * - SingleSelect, MultiSelect, RadioGroup, CheckboxGroup: + * - No selected value (because it is required) + * - Include "Banana" in the selection + */ +export const ExampleController = (args: StoryArgs) => { + const { + handleSubmit, + formState: {errors}, + control, + } = useForm({ + mode: args.mode, + defaultValues, + disabled: args.disableFields, + }); + + const onSubmit: SubmitHandler = (data) => { + console.log("successful submit", data); + }; + + const onInvalidSubmit: SubmitErrorHandler = (data) => { + console.log("invalid submit", data); + }; + + return ( +
+ + { + return ( + } + /> + ); + }} + /> + { + return ( + } + /> + ); + }} + /> + { + return ( + + + + + + } + /> + ); + }} + /> + { + return ( + + + + + + } + /> + ); + }} + /> + { + return ( + + + + + + ); + }} + /> + { + return ( + + + + + + ); + }} + /> + + +
+ ); +}; + +/** + * Same example as above but using the useController hook instead. + */ +export const UseController = (args: StoryArgs) => { + const { + handleSubmit, + formState: {errors}, + control, + } = useForm({ + mode: args.mode, + defaultValues, + disabled: args.disableFields, + }); + + const {field: tfField} = useController({ + name: "exampleTextField", + control, + rules: textRules, + }); + + const {field: taField} = useController({ + name: "exampleTextArea", + control, + rules: textRules, + }); + + const {field: ssField} = useController({ + name: "exampleSingleSelect", + control, + rules: selectedValueRules, + }); + + const {field: msField} = useController({ + name: "exampleMultiSelect", + control, + rules: selectedValuesRules, + }); + + const {field: rgField} = useController({ + name: "exampleRadioGroup", + control, + rules: selectedValueRules, + }); + + const {field: cbgField} = useController({ + name: "exampleCheckboxGroup", + control, + rules: selectedValuesRules, + }); + + const onSubmit: SubmitHandler = (data) => { + console.log("successful submit", data.exampleTextField); + }; + + const onInvalidSubmit: SubmitErrorHandler = (data) => { + console.log("invalid submit", data); + }; + + return ( +
+ + } + /> + } + /> + + + + + + + } + /> + + + + + + } + /> + + + + + + + + + + + + + +
+ ); +}; + +/** + * [React Hook Form modes](https://react-hook-form.com/docs/useform#mode) + */ +export const ValidationOnBlur = { + render: UseController, + args: {mode: "onBlur"}, +}; + +/** + * [React Hook Form modes](https://react-hook-form.com/docs/useform#mode) + */ +export const ValidationOnTouched = { + render: UseController, + args: {mode: "onTouched"}, +}; + +/** + * [React Hook Form modes](https://react-hook-form.com/docs/useform#mode) + */ +export const ValidationOnChange = { + render: UseController, + args: {mode: "onChange"}, +}; + +/** + * When we set `required` as part of the form field rules for React Hook Form, + * it does not pass the required message to the field. We need to pass in information + * about the field being required to either LabeledField or the field (TextField) + * component so that the Labeledfield can show the required indicator (*) and + * the field can properly mark input with `aria-required`. + * + * When we pass in the required message to the field, since the fields also implemet + * their own validationg logic around the required prop, there could be a mix of + * validation logic occuring. + * + * In this example, the form is set to validate on blur and TextField is set to + * validate onChange (instantValidation=true). Because of this, when you erase + * the value, the field is in an error state and the error message isn't shown + * yet. + * + * We could simplify WB components and avoid a mix of validation logic by + * removing validation related props from the components and rely on RHF to + * handle validation. For the `required` case, we would still have the prop, but + * all it would need to do is mark the field as required without doing any validation. + */ +export const Required = { + render: function RequiredStory(args: StoryArgs) { + const { + handleSubmit, + formState: {errors}, + control, + } = useForm({ + mode: args.mode, + defaultValues, + }); + + const {field: tfField} = useController({ + name: "exampleTextField", + control, + rules: textRules, + disabled: args.disableFields, + }); + + const onSubmit: SubmitHandler = (data) => { + console.log("successful submit", data.exampleTextField); + }; + + const onInvalidSubmit: SubmitErrorHandler = (data) => { + console.log("invalid submit", data); + }; + + return ( +
+ + + } + /> + + +
+ ); + }, + args: {mode: "onBlur"}, +}; diff --git a/package.json b/package.json index eed2daec7..795b17606 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,7 @@ "node-fetch": "^2.6.7", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.54.2", "react-popper": "^2.3.0", "react-router": "5.3.4", "react-router-dom": "5.3.4", diff --git a/yarn.lock b/yarn.lock index 24b1a332f..031017c24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11837,6 +11837,11 @@ react-fast-compare@^3.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== +react-hook-form@^7.54.2: + version "7.54.2" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.54.2.tgz#8c26ed54c71628dff57ccd3c074b1dd377cfb211" + integrity sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg== + react-is@18.1.0: version "18.1.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.1.0.tgz#61aaed3096d30eacf2a2127118b5b41387d32a67" @@ -12913,7 +12918,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -13013,7 +13027,14 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -14154,7 +14175,7 @@ wordwrap@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -14172,6 +14193,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"