From 633f9d208b193c22adb549115fa79520d01708d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D5=A1=D3=84=D5=A1?= Date: Sun, 5 May 2024 00:18:33 +0800 Subject: [PATCH] feat(test): react hook form tests & stories (#2931) * feat(input): add Input with React Hook Form tests * refactor(input): add missing types * feat(checkbox): add checkbox with React Hook Form tests * feat(select): add react-hook-form to dev dep * feat(select): add react hook form story * feat(select): react hook form tests * fix(select): incorrect button reference * feat(deps): add react-hook-form to dev dep in autocomplete * feat(autocomplete): react hook form story * feat(autocomplete): react hook form tests * fix(autocomplete): rollback wrapper type * feat(switch): add react hook form tests * refactor(stories): reorder stories items --- .../__tests__/autocomplete.test.tsx | 105 +++++++++- packages/components/autocomplete/package.json | 3 +- .../stories/autocomplete.stories.tsx | 110 +++++++--- .../checkbox/__tests__/checkbox.test.tsx | 74 ++++++- .../components/input/__tests__/input.test.tsx | 78 +++++++- .../select/__tests__/select.test.tsx | 94 ++++++++- packages/components/select/package.json | 3 +- .../select/stories/select.stories.tsx | 188 +++++++++++------- .../switch/__tests__/switch.test.tsx | 77 ++++++- .../switch/stories/switch.stories.tsx | 16 +- pnpm-lock.yaml | 6 + 11 files changed, 638 insertions(+), 116 deletions(-) diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index 1c4c47103f..f9b166f245 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -1,6 +1,7 @@ import * as React from "react"; -import {act, render} from "@testing-library/react"; +import {render, renderHook, act} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import {useForm} from "react-hook-form"; import {Autocomplete, AutocompleteItem, AutocompleteSection} from "../src"; import {Modal, ModalContent, ModalBody, ModalHeader, ModalFooter} from "../../modal/src"; @@ -220,3 +221,105 @@ describe("Autocomplete", () => { expect(autocomplete).toHaveAttribute("aria-expanded", "false"); }); }); + +describe("Autocomplete with React Hook Form", () => { + let autocomplete1: HTMLInputElement; + let autocomplete2: HTMLInputElement; + let autocomplete3: HTMLInputElement; + let submitButton: HTMLButtonElement; + let wrapper: any; + let onSubmit: () => void; + + beforeEach(() => { + const {result} = renderHook(() => + useForm({ + defaultValues: { + withDefaultValue: "cat", + withoutDefaultValue: "", + requiredField: "", + }, + }), + ); + + const { + handleSubmit, + register, + formState: {errors}, + } = result.current; + + onSubmit = jest.fn(); + + wrapper = render( +
+ + {(item) => {item.label}} + + + {(item) => {item.label}} + + + {(item) => {item.label}} + + {errors.requiredField && This field is required} + +
, + ); + + autocomplete1 = wrapper.getByTestId("autocomplete-1"); + autocomplete2 = wrapper.getByTestId("autocomplete-2"); + autocomplete3 = wrapper.getByTestId("autocomplete-3"); + submitButton = wrapper.getByTestId("submit-button"); + }); + + it("should work with defaultValues", () => { + expect(autocomplete1).toHaveValue("Cat"); + expect(autocomplete2).toHaveValue(""); + expect(autocomplete3).toHaveValue(""); + }); + + it("should not submit form when required field is empty", async () => { + const user = userEvent.setup(); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(0); + }); + + it("should submit form when required field is not empty", async () => { + const user = userEvent.setup(); + + await user.click(autocomplete3); + + expect(autocomplete3).toHaveAttribute("aria-expanded", "true"); + + let listboxItems = wrapper.getAllByRole("option"); + + await user.click(listboxItems[1]); + + expect(autocomplete3).toHaveValue("Dog"); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/components/autocomplete/package.json b/packages/components/autocomplete/package.json index 4b7f275969..ffb4947e73 100644 --- a/packages/components/autocomplete/package.json +++ b/packages/components/autocomplete/package.json @@ -74,7 +74,8 @@ "framer-motion": "^11.0.28", "clean-package": "2.2.0", "react": "^18.0.0", - "react-dom": "^18.0.0" + "react-dom": "^18.0.0", + "react-hook-form": "^7.51.3" }, "clean-package": "../../../clean-package.config.json" } \ No newline at end of file diff --git a/packages/components/autocomplete/stories/autocomplete.stories.tsx b/packages/components/autocomplete/stories/autocomplete.stories.tsx index a3b422bbcc..4ea9dae2db 100644 --- a/packages/components/autocomplete/stories/autocomplete.stories.tsx +++ b/packages/components/autocomplete/stories/autocomplete.stories.tsx @@ -2,6 +2,7 @@ import type {ValidationResult} from "@react-types/shared"; import React, {Key} from "react"; import {Meta} from "@storybook/react"; +import {useForm} from "react-hook-form"; import {autocomplete, input, button} from "@nextui-org/theme"; import { Pokemon, @@ -686,6 +687,45 @@ const CustomStylesWithCustomItemsTemplate = ({color, ...args}: AutocompleteProps ); }; +const WithReactHookFormTemplate = (args: AutocompleteProps) => { + const { + register, + formState: {errors}, + handleSubmit, + } = useForm({ + defaultValues: { + withDefaultValue: "cat", + withoutDefaultValue: "", + requiredField: "", + }, + }); + + const onSubmit = (data: any) => { + // eslint-disable-next-line no-console + console.log(data); + alert("Submitted value: " + JSON.stringify(data)); + }; + + return ( +
+ + {items} + + + {items} + + + {items} + + + {errors.requiredField && This field is required} + +
+ ); +}; + export const Default = { render: Template, args: { @@ -733,15 +773,6 @@ export const DisabledOptions = { }, }; -export const WithDescription = { - render: MirrorTemplate, - - args: { - ...defaultProps, - description: "Select your favorite animal", - }, -}; - export const LabelPlacement = { render: LabelPlacementTemplate, @@ -782,6 +813,27 @@ export const EndContent = { }, }; +export const IsInvalid = { + render: Template, + + args: { + ...defaultProps, + isInvalid: true, + variant: "bordered", + defaultSelectedKey: "dog", + errorMessage: "Please select a valid animal", + }, +}; + +export const WithDescription = { + render: MirrorTemplate, + + args: { + ...defaultProps, + description: "Select your favorite animal", + }, +}; + export const WithoutScrollShadow = { render: Template, @@ -847,67 +899,63 @@ export const WithValidation = { }, }; -export const IsInvalid = { - render: Template, +export const WithSections = { + render: WithSectionsTemplate, args: { ...defaultProps, - isInvalid: true, - variant: "bordered", - defaultSelectedKey: "dog", - errorMessage: "Please select a valid animal", }, }; -export const Controlled = { - render: ControlledTemplate, +export const WithCustomSectionsStyles = { + render: WithCustomSectionsStylesTemplate, args: { ...defaultProps, }, }; -export const CustomSelectorIcon = { - render: Template, +export const WithAriaLabel = { + render: WithAriaLabelTemplate, args: { ...defaultProps, - disableSelectorIconRotation: true, - selectorIcon: , + label: "Select an animal 🐹", + "aria-label": "Select an animal", }, }; -export const CustomItems = { - render: CustomItemsTemplate, +export const WithReactHookForm = { + render: WithReactHookFormTemplate, args: { ...defaultProps, }, }; -export const WithSections = { - render: WithSectionsTemplate, +export const Controlled = { + render: ControlledTemplate, args: { ...defaultProps, }, }; -export const WithCustomSectionsStyles = { - render: WithCustomSectionsStylesTemplate, +export const CustomSelectorIcon = { + render: Template, args: { ...defaultProps, + disableSelectorIconRotation: true, + selectorIcon: , }, }; -export const WithAriaLabel = { - render: WithAriaLabelTemplate, +export const CustomItems = { + render: CustomItemsTemplate, args: { ...defaultProps, - label: "Select an animal 🐹", - "aria-label": "Select an animal", }, }; diff --git a/packages/components/checkbox/__tests__/checkbox.test.tsx b/packages/components/checkbox/__tests__/checkbox.test.tsx index c3e7308b74..8e854a4a38 100644 --- a/packages/components/checkbox/__tests__/checkbox.test.tsx +++ b/packages/components/checkbox/__tests__/checkbox.test.tsx @@ -1,6 +1,7 @@ import * as React from "react"; -import {render, act} from "@testing-library/react"; +import {render, renderHook, act} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import {useForm} from "react-hook-form"; import {Checkbox, CheckboxProps} from "../src"; @@ -128,3 +129,74 @@ describe("Checkbox", () => { expect(onChange).toBeCalled(); }); }); + +describe("Checkbox with React Hook Form", () => { + let checkbox1: HTMLInputElement; + let checkbox2: HTMLInputElement; + let checkbox3: HTMLInputElement; + let submitButton: HTMLButtonElement; + let onSubmit: () => void; + + beforeEach(() => { + const {result} = renderHook(() => + useForm({ + defaultValues: { + withDefaultValue: true, + withoutDefaultValue: false, + requiredField: false, + }, + }), + ); + + const { + handleSubmit, + register, + formState: {errors}, + } = result.current; + + onSubmit = jest.fn(); + + render( +
+ + + + {errors.requiredField && This field is required} + + , + ); + + checkbox1 = document.querySelector("input[name=withDefaultValue]")!; + checkbox2 = document.querySelector("input[name=withoutDefaultValue]")!; + checkbox3 = document.querySelector("input[name=requiredField]")!; + submitButton = document.querySelector("button")!; + }); + + it("should work with defaultValues", () => { + expect(checkbox1.checked).toBe(true); + expect(checkbox2.checked).toBe(false); + expect(checkbox3.checked).toBe(false); + }); + + it("should not submit form when required field is empty", async () => { + const user = userEvent.setup(); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(0); + }); + + it("should submit form when required field is not empty", async () => { + act(() => { + checkbox3.click(); + }); + + expect(checkbox3.checked).toBe(true); + + const user = userEvent.setup(); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/components/input/__tests__/input.test.tsx b/packages/components/input/__tests__/input.test.tsx index d9ab83d694..a6da5128f4 100644 --- a/packages/components/input/__tests__/input.test.tsx +++ b/packages/components/input/__tests__/input.test.tsx @@ -1,6 +1,7 @@ import * as React from "react"; -import {render} from "@testing-library/react"; +import {render, renderHook, fireEvent} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import {useForm} from "react-hook-form"; import {Input} from "../src"; @@ -146,3 +147,78 @@ describe("Input", () => { expect(onClear).toHaveBeenCalledTimes(1); }); }); + +describe("Input with React Hook Form", () => { + let input1: HTMLInputElement; + let input2: HTMLInputElement; + let input3: HTMLInputElement; + let submitButton: HTMLButtonElement; + let onSubmit: () => void; + + beforeEach(() => { + const {result} = renderHook(() => + useForm({ + defaultValues: { + withDefaultValue: "wkw", + withoutDefaultValue: "", + requiredField: "", + }, + }), + ); + + const { + handleSubmit, + register, + formState: {errors}, + } = result.current; + + onSubmit = jest.fn(); + + render( +
+ + + + {errors.requiredField && This field is required} + +
, + ); + + input1 = document.querySelector("input[name=withDefaultValue]")!; + input2 = document.querySelector("input[name=withoutDefaultValue]")!; + input3 = document.querySelector("input[name=requiredField]")!; + submitButton = document.querySelector("button")!; + }); + + it("should work with defaultValues", () => { + expect(input1).toHaveValue("wkw"); + expect(input2).toHaveValue(""); + expect(input3).toHaveValue(""); + }); + + it("should not submit form when required field is empty", async () => { + const user = userEvent.setup(); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(0); + }); + + it("should submit form when required field is not empty", async () => { + fireEvent.change(input3, {target: {value: "updated"}}); + + const user = userEvent.setup(); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/components/select/__tests__/select.test.tsx b/packages/components/select/__tests__/select.test.tsx index 654a58e484..3c46a4f59f 100644 --- a/packages/components/select/__tests__/select.test.tsx +++ b/packages/components/select/__tests__/select.test.tsx @@ -1,6 +1,7 @@ import * as React from "react"; -import {act, render} from "@testing-library/react"; +import {render, renderHook, act} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import {useForm} from "react-hook-form"; import {Select, SelectItem, SelectSection, type SelectProps} from "../src"; import {Modal, ModalContent, ModalHeader, ModalBody, ModalFooter} from "../../modal/src"; @@ -397,3 +398,94 @@ describe("Select", () => { }); }); }); + +describe("Select with React Hook Form", () => { + let select1: HTMLElement; + let select2: HTMLElement; + let select3: HTMLElement; + let submitButton: HTMLButtonElement; + let wrapper: any; + let onSubmit: () => void; + + beforeEach(() => { + const {result} = renderHook(() => + useForm({ + defaultValues: { + withDefaultValue: "cat", + withoutDefaultValue: "", + requiredField: "", + }, + }), + ); + + const { + register, + formState: {errors}, + handleSubmit, + } = result.current; + + onSubmit = jest.fn(); + + wrapper = render( +
+ + + + + + + {errors.requiredField && This field is required} + +
, + ); + + select1 = wrapper.getByTestId("select-1"); + select2 = wrapper.getByTestId("select-2"); + select3 = wrapper.getByTestId("select-3"); + submitButton = wrapper.getByTestId("submit-button"); + }); + + it("should work with defaultValues", () => { + expect(select1).toHaveTextContent("Cat"); + expect(select2).toHaveTextContent(""); + expect(select3).toHaveTextContent(""); + }); + + it("should not submit form when required field is empty", async () => { + const user = userEvent.setup(); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(0); + }); + + it("should submit form when required field is not empty", async () => { + const user = userEvent.setup(); + + await user.click(select3); + + expect(select3).toHaveAttribute("aria-expanded", "true"); + + let listboxItems = wrapper.getAllByRole("option"); + + await user.click(listboxItems[1]); + + expect(select3).toHaveTextContent("Dog"); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/components/select/package.json b/packages/components/select/package.json index 3d3b78d62f..4c2a1fdec5 100644 --- a/packages/components/select/package.json +++ b/packages/components/select/package.json @@ -73,7 +73,8 @@ "@react-stately/data": "3.11.2", "clean-package": "2.2.0", "react": "^18.0.0", - "react-dom": "^18.0.0" + "react-dom": "^18.0.0", + "react-hook-form": "^7.51.3" }, "clean-package": "../../../clean-package.config.json" } \ No newline at end of file diff --git a/packages/components/select/stories/select.stories.tsx b/packages/components/select/stories/select.stories.tsx index 26dd2710b8..a30224cf3e 100644 --- a/packages/components/select/stories/select.stories.tsx +++ b/packages/components/select/stories/select.stories.tsx @@ -2,6 +2,7 @@ import type {ValidationResult} from "@react-types/shared"; import React, {ChangeEvent} from "react"; +import {useForm} from "react-hook-form"; import {Meta} from "@storybook/react"; import {select, button} from "@nextui-org/theme"; import {PetBoldIcon, SelectorIcon} from "@nextui-org/shared-icons"; @@ -585,6 +586,47 @@ const AsyncLoadingTemplate = ({color, variant, ...args}: SelectProps) = ); }; +const WithReactHookFormTemplate = (args: SelectProps) => { + const { + register, + formState: {errors}, + handleSubmit, + } = useForm({ + defaultValues: { + withDefaultValue: "cat", + withoutDefaultValue: "", + requiredField: "", + }, + }); + + const onSubmit = (data: any) => { + // eslint-disable-next-line no-console + console.log(data); + alert("Submitted value: " + JSON.stringify(data)); + }; + + return ( +
+ + + + + + + {errors.requiredField && This field is required} + +
+ ); +}; + export const Default = { render: MirrorTemplate, @@ -631,23 +673,15 @@ export const DisabledOptions = { }, }; -export const WithDescription = { - render: MirrorTemplate, - - args: { - ...defaultProps, - description: "Select your favorite animal", - }, -}; - -export const WithoutLabel = { +export const IsInvalid = { render: Template, args: { ...defaultProps, - label: null, - "aria-label": "Select an animal", - placeholder: "Select an animal", + isInvalid: true, + variant: "bordered", + defaultSelectedKeys: ["dog"], + errorMessage: "Please select a valid animal", }, }; @@ -675,6 +709,26 @@ export const StartContent = { }, }; +export const WithDescription = { + render: MirrorTemplate, + + args: { + ...defaultProps, + description: "Select your favorite animal", + }, +}; + +export const WithoutLabel = { + render: Template, + + args: { + ...defaultProps, + label: null, + "aria-label": "Select an animal", + placeholder: "Select an animal", + }, +}; + export const WithoutScrollShadow = { render: Template, @@ -726,15 +780,62 @@ export const WithErrorMessageFunction = { }, }; -export const IsInvalid = { - render: Template, +export const WithChips = { + render: CustomItemsTemplate, args: { ...defaultProps, - isInvalid: true, variant: "bordered", - defaultSelectedKeys: ["dog"], - errorMessage: "Please select a valid animal", + selectionMode: "multiple", + isMultiline: true, + labelPlacement: "outside", + classNames: { + base: "max-w-xs", + trigger: "min-h-12 py-2", + }, + renderValue: (items: SelectedItems) => { + return ( +
+ {items.map((item) => ( + {item.data?.name} + ))} +
+ ); + }, + }, +}; + +export const WithSections = { + render: WithSectionsTemplate, + + args: { + ...defaultProps, + }, +}; + +export const WithCustomSectionsStyles = { + render: WithCustomSectionsStylesTemplate, + + args: { + ...defaultProps, + }, +}; + +export const WithAriaLabel = { + render: WithAriaLabelTemplate, + + args: { + ...defaultProps, + label: "Select an animal 🐹", + "aria-label": "Select an animal", + }, +}; + +export const WithReactHookForm = { + render: WithReactHookFormTemplate, + + args: { + ...defaultProps, }, }; @@ -808,57 +909,6 @@ export const CustomRenderValue = { }, }; -export const WithChips = { - render: CustomItemsTemplate, - - args: { - ...defaultProps, - variant: "bordered", - selectionMode: "multiple", - isMultiline: true, - labelPlacement: "outside", - classNames: { - base: "max-w-xs", - trigger: "min-h-12 py-2", - }, - renderValue: (items: SelectedItems) => { - return ( -
- {items.map((item) => ( - {item.data?.name} - ))} -
- ); - }, - }, -}; - -export const WithSections = { - render: WithSectionsTemplate, - - args: { - ...defaultProps, - }, -}; - -export const WithCustomSectionsStyles = { - render: WithCustomSectionsStylesTemplate, - - args: { - ...defaultProps, - }, -}; - -export const WithAriaLabel = { - render: WithAriaLabelTemplate, - - args: { - ...defaultProps, - label: "Select an animal 🐹", - "aria-label": "Select an animal", - }, -}; - export const CustomStyles = { render: CustomStylesTemplate, diff --git a/packages/components/switch/__tests__/switch.test.tsx b/packages/components/switch/__tests__/switch.test.tsx index 870ef8933c..d9b76e9092 100644 --- a/packages/components/switch/__tests__/switch.test.tsx +++ b/packages/components/switch/__tests__/switch.test.tsx @@ -1,5 +1,7 @@ import * as React from "react"; -import {act, render} from "@testing-library/react"; +import {render, renderHook, act} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import {useForm} from "react-hook-form"; import {Switch} from "../src"; @@ -11,7 +13,7 @@ describe("Switch", () => { }); it("ref should be forwarded", () => { - const ref = React.createRef(); + const ref = React.createRef(); render(); expect(ref.current).not.toBeNull(); @@ -198,3 +200,74 @@ describe("Switch", () => { expect(wrapper.getByTestId("end-icon")).toBeInTheDocument(); }); }); + +describe("Switch with React Hook Form", () => { + let switch1: HTMLInputElement; + let switch2: HTMLInputElement; + let switch3: HTMLInputElement; + let submitButton: HTMLButtonElement; + let onSubmit: () => void; + + beforeEach(() => { + const {result} = renderHook(() => + useForm({ + defaultValues: { + defaultTrue: true, + defaultFalse: false, + requiredField: false, + }, + }), + ); + + const { + register, + formState: {errors}, + handleSubmit, + } = result.current; + + onSubmit = jest.fn(); + + render( +
+ By default this switch is true + By default this switch is false + This switch is required + {errors.requiredField && This switch is required} + +
, + ); + + switch1 = document.querySelector("input[name=defaultTrue]")!; + switch2 = document.querySelector("input[name=defaultFalse]")!; + switch3 = document.querySelector("input[name=requiredField]")!; + submitButton = document.querySelector("button")!; + }); + + it("should work with defaultValues", () => { + expect(switch1.checked).toBe(true); + expect(switch2.checked).toBe(false); + expect(switch3.checked).toBe(false); + }); + + it("should not submit form when required field is empty", async () => { + const user = userEvent.setup(); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(0); + }); + + it("should submit form when required field is not empty", async () => { + act(() => { + switch3.click(); + }); + + expect(switch3.checked).toBe(true); + + const user = userEvent.setup(); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/components/switch/stories/switch.stories.tsx b/packages/components/switch/stories/switch.stories.tsx index 8564dfd7e3..408762247f 100644 --- a/packages/components/switch/stories/switch.stories.tsx +++ b/packages/components/switch/stories/switch.stories.tsx @@ -221,32 +221,32 @@ export const WithIcons = { }, }; -export const Controlled = { - render: ControlledTemplate, +export const WithReactHookForm = { + render: WithReactHookFormTemplate, args: { ...defaultProps, }, }; -export const CustomWithClassNames = { - render: CustomWithClassNamesTemplate, +export const Controlled = { + render: ControlledTemplate, args: { ...defaultProps, }, }; -export const CustomWithHooks = { - render: CustomWithHooksTemplate, +export const CustomWithClassNames = { + render: CustomWithClassNamesTemplate, args: { ...defaultProps, }, }; -export const WithReactHookForm = { - render: WithReactHookFormTemplate, +export const CustomWithHooks = { + render: CustomWithHooksTemplate, args: { ...defaultProps, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf57e9a904..2dedf4fc0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -783,6 +783,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.51.3 + version: 7.51.3(react@18.2.0) packages/components/avatar: dependencies: @@ -2311,6 +2314,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.51.3 + version: 7.51.3(react@18.2.0) packages/components/skeleton: dependencies: