From c94af23433ce6339b06928401799f0d8760a189f Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Fri, 7 Feb 2025 12:55:53 -0500 Subject: [PATCH 1/6] #28 deps --- package-lock.json | 12 ++++++------ package.json | 2 +- src/common/api/__tests__/useGetUserTokens.test.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b08228..5e5b27a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,7 @@ "storybook": "8.5.3", "typescript": "5.7.3", "typescript-eslint": "8.23.0", - "vite": "6.1.0", + "vite": "6.0.11", "vitest": "3.0.5" } }, @@ -9252,15 +9252,15 @@ } }, "node_modules/vite": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", - "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz", + "integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.24.2", - "postcss": "^8.5.1", - "rollup": "^4.30.1" + "postcss": "^8.4.49", + "rollup": "^4.23.0" }, "bin": { "vite": "bin/vite.js" diff --git a/package.json b/package.json index 4be967d..c37ae1d 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "storybook": "8.5.3", "typescript": "5.7.3", "typescript-eslint": "8.23.0", - "vite": "6.1.0", + "vite": "6.0.11", "vitest": "3.0.5" } } diff --git a/src/common/api/__tests__/useGetUserTokens.test.ts b/src/common/api/__tests__/useGetUserTokens.test.ts index fbcaaf1..6c5c52b 100644 --- a/src/common/api/__tests__/useGetUserTokens.test.ts +++ b/src/common/api/__tests__/useGetUserTokens.test.ts @@ -51,7 +51,7 @@ describe('useGetTokens', () => { getItemSpy.mockReturnValue(null); // use a specific wrapper to avoid test side effects from "AuthProvider" const { result } = renderHook(() => useGetUserTokens(), { wrapper: WithQueryClientProvider }); - await waitFor(() => expect(result.current.isError).toBe(true)); + await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 2000 }); // ASSERT expect(result.current.error).toBeInstanceOf(Error); From 25991b887b6e562474492039b5b68e5e2250ad77 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Mon, 10 Feb 2025 06:30:24 -0500 Subject: [PATCH 2/6] #28 RFH useController --- src/common/components/Form/Input.tsx | 29 +++-- src/common/components/Form/Select.tsx | 29 +++-- .../Auth/Signin/components/SigninForm.tsx | 105 +++++++++--------- 3 files changed, 79 insertions(+), 84 deletions(-) diff --git a/src/common/components/Form/Input.tsx b/src/common/components/Form/Input.tsx index ea3ad90..f420e57 100644 --- a/src/common/components/Form/Input.tsx +++ b/src/common/components/Form/Input.tsx @@ -1,5 +1,5 @@ import { InputHTMLAttributes } from 'react'; -import { useFormContext } from 'react-hook-form'; +import { Control, FieldValues, Path, useController } from 'react-hook-form'; import { cn } from 'common/utils/css'; import { PropsWithTestId } from 'common/utils/types'; @@ -13,9 +13,12 @@ import { PropsWithTestId } from 'common/utils/types'; * @see {@link PropsWithTestId} * @see {@link InputHTMLAttributes} */ -export interface InputProps extends InputHTMLAttributes, PropsWithTestId { +export interface InputProps + extends InputHTMLAttributes, + PropsWithTestId { + control: Control; label?: string; - name: string; + name: Path; supportingText?: string; } @@ -25,20 +28,16 @@ export interface InputProps extends InputHTMLAttributes, Props * @param {InputProps} props - Component properties. * @returns {JSX.Element} JSX */ -const Input = ({ +const Input = ({ className, + control, label, name, supportingText, testId = 'input', ...props -}: InputProps): JSX.Element => { - const { - formState: { errors }, - register, - } = useFormContext(); - const hasError = !!errors[name]; - const errorMessage: string = errors[name]?.message as string; +}: InputProps): JSX.Element => { + const { field, fieldState } = useController({ control, name }); const isDisabled = props.disabled || props.readOnly; return ( @@ -55,11 +54,11 @@ const Input = ({ - {hasError && ( + {fieldState.error && (
- {errorMessage} + {fieldState.error.message}
)} {!!supportingText && ( diff --git a/src/common/components/Form/Select.tsx b/src/common/components/Form/Select.tsx index de54bc2..c3707e5 100644 --- a/src/common/components/Form/Select.tsx +++ b/src/common/components/Form/Select.tsx @@ -1,11 +1,13 @@ import { InputHTMLAttributes, PropsWithChildren } from 'react'; -import { useFormContext } from 'react-hook-form'; +import { Control, FieldValues, Path, useController } from 'react-hook-form'; import { cn } from 'common/utils/css'; import { PropsWithTestId } from 'common/utils/types'; /** * Properties for the `Select` component. + * @param {Control} control - Object containing methods for registering components + * into React Hook Form. * @param {string} [label] - Optional. The text to display. If omitted, the * `value` is displayed. * @param {string} name - Name of the form control. @@ -14,12 +16,13 @@ import { PropsWithTestId } from 'common/utils/types'; * @see {@link PropsWithChildren} * @see {@link InputHTMLAttributes} */ -interface SelectProps +interface SelectProps extends PropsWithTestId, PropsWithChildren, InputHTMLAttributes { + control: Control; label?: string; - name: string; + name: Path; supportingText?: string; } @@ -31,21 +34,17 @@ interface SelectProps * @param {SelectProps} props - Component properties. * @returns JSX */ -const Select = ({ +const Select = ({ children, className, + control, label, name, supportingText, testId = 'select', ...props -}: SelectProps): JSX.Element => { - const { - formState: { errors }, - register, - } = useFormContext(); - const hasError = !!errors[name]; - const errorMessage: string = errors[name]?.message as string; +}: SelectProps): JSX.Element => { + const { field, fieldState } = useController({ control, name }); const isDisabled = props.disabled || props.readOnly; return ( @@ -62,11 +61,11 @@ const Select = ({ - {hasError && ( + {fieldState.error && (
- {errorMessage} + {fieldState.error.message}
)} {!!supportingText && ( diff --git a/src/pages/Auth/Signin/components/SigninForm.tsx b/src/pages/Auth/Signin/components/SigninForm.tsx index 131cb22..764a982 100644 --- a/src/pages/Auth/Signin/components/SigninForm.tsx +++ b/src/pages/Auth/Signin/components/SigninForm.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; -import { object, string } from 'yup'; +import { InferType, object, string } from 'yup'; import { useNavigate } from 'react-router-dom'; -import { FormProvider, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; -import { useTranslation } from 'react-i18next'; +import { t } from 'i18next'; import { cn } from 'common/utils/css'; import { BaseComponentProps } from 'common/utils/types'; @@ -13,13 +13,21 @@ import FAIcon from 'common/components/Icon/FAIcon'; import Alert from 'common/components/Alert/Alert'; import Button from 'common/components/Button/Button'; +/** + * Signin form validation schema. + */ +const validationSchema = object({ + password: string().required(t('validation.required')), + username: string() + .required(t('validation.required')) + .max(30, t('validation.max', { count: 30 })), +}); + /** * Signin form values. */ -interface SigninFormValues { - username: string; - password: string; -} + +type SigninFormValues = InferType; /** * The `SigninForm` component renders a form for user authentication. @@ -33,25 +41,14 @@ interface SigninFormValues { * @returns {JSX.Element} JSX */ const SigninForm = ({ className, testId = 'form-signin' }: BaseComponentProps): JSX.Element => { - const { t } = useTranslation(); const [error, setError] = useState(''); const { mutate: signin } = useSignin(); const navigate = useNavigate(); - /** - * Signin form validation schema. - */ - const validationSchema = object({ - password: string().required(t('validation.required')), - username: string() - .required(t('validation.required')) - .max(30, t('validation.max', { count: 30 })), - }); - /** * Initialize management of the form. */ - const formMethods = useForm({ + const { control, formState, handleSubmit } = useForm({ defaultValues: { username: '', password: '' }, resolver: yupResolver(validationSchema), }); @@ -59,7 +56,7 @@ const SigninForm = ({ className, testId = 'form-signin' }: BaseComponentProps): /** * Handles the form submission. */ - const handleFormSubmit = formMethods.handleSubmit((data: SigninFormValues) => { + const onFormSubmit = (data: SigninFormValues) => { setError(''); signin(data.username, { onSuccess: () => { @@ -69,7 +66,7 @@ const SigninForm = ({ className, testId = 'form-signin' }: BaseComponentProps): setError(err.message); }, }); - }); + }; return (
@@ -80,41 +77,41 @@ const SigninForm = ({ className, testId = 'form-signin' }: BaseComponentProps): )} - -
- + + - + - -
-
+ +
); }; From 3841422fff6544a898c39acd30a4b93e103ce953 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Mon, 10 Feb 2025 07:48:48 -0500 Subject: [PATCH 3/6] #28 storybook --- src/common/components/Form/Select.tsx | 2 +- .../Form/__stories__/Input.stories.tsx | 38 +++++++++++-------- .../Form/__stories__/Select.stories.tsx | 38 +++++++++++-------- 3 files changed, 47 insertions(+), 31 deletions(-) diff --git a/src/common/components/Form/Select.tsx b/src/common/components/Form/Select.tsx index c3707e5..a166699 100644 --- a/src/common/components/Form/Select.tsx +++ b/src/common/components/Form/Select.tsx @@ -16,7 +16,7 @@ import { PropsWithTestId } from 'common/utils/types'; * @see {@link PropsWithChildren} * @see {@link InputHTMLAttributes} */ -interface SelectProps +export interface SelectProps extends PropsWithTestId, PropsWithChildren, InputHTMLAttributes { diff --git a/src/common/components/Form/__stories__/Input.stories.tsx b/src/common/components/Form/__stories__/Input.stories.tsx index c00fbbc..1b12861 100644 --- a/src/common/components/Form/__stories__/Input.stories.tsx +++ b/src/common/components/Form/__stories__/Input.stories.tsx @@ -1,36 +1,44 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { FieldValues, useForm } from 'react-hook-form'; -import Input from '../Input'; +import { default as MyInput } from '../Input'; +import { InputProps } from '../Input'; + +/** + * A wrapper for the `Input` component. Provides the RHF form `control` + * to the `Input` component. + */ +const Input = (props: Omit, 'control'>) => { + const form = useForm(); + + const onSubmit = () => {}; + + return ( +
+ + + ); +}; const meta = { title: 'Common/Form/Input', component: Input, - decorators: [ - (Story) => { - const formMethods = useForm({ defaultValues: { color: '' } }); - return ( - -
- - -
- ); - }, - ], parameters: { layout: 'centered', }, tags: ['autodocs'], argTypes: { className: { description: 'Additional CSS classes.' }, + control: { + description: 'Object containing methods for registering components into React Hook Form.', + }, label: { description: 'The field label.' }, name: { description: 'The form field name.' }, supportingText: { description: 'Additional field instructions.' }, testId: { description: 'The test identifier.' }, }, args: {}, -} satisfies Meta; +} satisfies Meta; export default meta; diff --git a/src/common/components/Form/__stories__/Select.stories.tsx b/src/common/components/Form/__stories__/Select.stories.tsx index 28609e5..5b0692a 100644 --- a/src/common/components/Form/__stories__/Select.stories.tsx +++ b/src/common/components/Form/__stories__/Select.stories.tsx @@ -1,36 +1,44 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { FormProvider, useForm } from 'react-hook-form'; +import { FieldValues, useForm } from 'react-hook-form'; -import Select from '../Select'; +import { default as MySelect } from '../Select'; +import { SelectProps } from '../Select'; + +/** + * A wrapper for the `Select` component. Provides the RHF form `control` + * to the `Select` component. + */ +const Select = (props: Omit, 'control'>) => { + const form = useForm(); + + const onSubmit = () => {}; + + return ( +
+ +
+ ); +}; const meta = { title: 'Common/Form/Select', component: Select, - decorators: [ - (Story) => { - const formMethods = useForm({ defaultValues: { color: 'blue' } }); - return ( - -
- - -
- ); - }, - ], parameters: { layout: 'centered', }, tags: ['autodocs'], argTypes: { className: { description: 'Additional CSS classes.' }, + control: { + description: 'Object containing methods for registering components into React Hook Form.', + }, label: { description: 'The field label.' }, name: { description: 'The form field name.' }, supportingText: { description: 'Additional field instructions.' }, testId: { description: 'The test identifier.' }, }, args: {}, -} satisfies Meta; +} satisfies Meta; export default meta; From 4d276fef9d27a927ec6b82b08b6ef59baba17529 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Mon, 10 Feb 2025 11:35:04 -0500 Subject: [PATCH 4/6] #28 tests --- .../components/Form/__tests__/Input.test.tsx | 68 ++++++++------ .../components/Form/__tests__/Select.test.tsx | 90 ++++++++++++------- src/test/wrappers/WithFormProvider.tsx | 29 ------ 3 files changed, 96 insertions(+), 91 deletions(-) delete mode 100644 src/test/wrappers/WithFormProvider.tsx diff --git a/src/common/components/Form/__tests__/Input.test.tsx b/src/common/components/Form/__tests__/Input.test.tsx index 128fe13..4b111e3 100644 --- a/src/common/components/Form/__tests__/Input.test.tsx +++ b/src/common/components/Form/__tests__/Input.test.tsx @@ -1,18 +1,41 @@ import { describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { InferType, object, string } from 'yup'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; import { render, screen } from 'test/test-utils'; -import WithFormProvider from 'test/wrappers/WithFormProvider'; -import Input from '../Input'; +import Input, { InputProps } from '../Input'; + +const formSchema = object({ + color: string().required('Required'), +}); + +type FormValues = InferType; + +const InputWrapper = (props: Omit, 'control'>) => { + const form = useForm({ + defaultValues: { color: '' }, + resolver: yupResolver(formSchema), + }); + + const onSubmit = () => {}; + + return ( +
+ + +
+ ); +}; describe('Input', () => { it('should render successfully', async () => { // ARRANGE - render( - - - , - ); + render(); await screen.findByTestId('input'); // ASSERT @@ -22,11 +45,7 @@ describe('Input', () => { it('should show label', async () => { // ARRANGE const label = 'Label text'; - render( - - - , - ); + render(); await screen.findByTestId('input-label'); // ASSERT @@ -36,11 +55,7 @@ describe('Input', () => { it('should show supporting text', async () => { // ARRANGE const supportingText = 'Supporting text'; - render( - - - , - ); + render(); await screen.findByTestId('input-supporting-text'); // ASSERT @@ -49,20 +64,15 @@ describe('Input', () => { it('should show error message', async () => { // ARRANGE - const errorMessage = 'error message'; - render( - - - , - ); + const user = userEvent.setup(); + render(); + await screen.findByTestId('button-submit'); + + // ACT + await user.click(screen.getByTestId('button-submit')); await screen.findByTestId('input-error'); // ASSERT - expect(screen.getByTestId('input-error')).toHaveTextContent(errorMessage); + expect(screen.getByTestId('input-error')).toHaveTextContent(/required/i); }); }); diff --git a/src/common/components/Form/__tests__/Select.test.tsx b/src/common/components/Form/__tests__/Select.test.tsx index 5bda20d..20ef3db 100644 --- a/src/common/components/Form/__tests__/Select.test.tsx +++ b/src/common/components/Form/__tests__/Select.test.tsx @@ -1,20 +1,44 @@ import { render, screen } from 'test/test-utils'; import { describe, expect, it } from 'vitest'; +import { useForm } from 'react-hook-form'; +import { InferType, object, string } from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; -import WithFormProvider from 'test/wrappers/WithFormProvider'; +import Select, { SelectProps } from '../Select'; +import userEvent from '@testing-library/user-event'; -import Select from '../Select'; +const formSchema = object({ + color: string().oneOf(['blue'], 'Must select a value in the list.'), +}); + +type FormValues = InferType; + +const SelectWrapper = (props: Omit, 'control'>) => { + const form = useForm({ + defaultValues: { color: '' }, + resolver: yupResolver(formSchema), + }); + + const onSubmit = () => {}; + + return ( +
+ - - - - , + + + + , ); await screen.findByTestId('select'); @@ -26,12 +50,10 @@ describe('Select', () => { // ARRANGE const label = 'label value'; render( - - - , + + + + , ); await screen.findByTestId('select-label'); @@ -43,12 +65,10 @@ describe('Select', () => { // ARRANGE const supportingText = 'supporting text content'; render( - - - , + + + + , ); await screen.findByTestId('select-supporting-text'); @@ -58,23 +78,27 @@ describe('Select', () => { it('should show error message', async () => { // ARRANGE - const error = 'error message'; + const user = userEvent.setup(); render( - - - , + + + + , ); + await screen.findByTestId('select-select'); + + // ACT + await user.click(screen.getByTestId('option-red')); + await user.click(screen.getByTestId('button-submit')); await screen.findByTestId('select-error'); // ASSERT - expect(screen.getByTestId('select-error')).toHaveTextContent(error); + expect(screen.getByTestId('select-error')).toHaveTextContent( + /must select a value in the list/i, + ); }); }); diff --git a/src/test/wrappers/WithFormProvider.tsx b/src/test/wrappers/WithFormProvider.tsx deleted file mode 100644 index 32f4441..0000000 --- a/src/test/wrappers/WithFormProvider.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { PropsWithChildren } from 'react'; -import { FormProvider, useForm, UseFormProps } from 'react-hook-form'; - -/** - * Properties for the `WithFormProvider` test wrapper component. - * @param [formProps] - Optional. `UseFormProps` object to supply configuration to - * the `useForm` hook which, in turn, configures `FormProvider`. - * @see {@link PropsWithChildren} - */ -interface WithFormProviderProps extends PropsWithChildren { - formProps?: UseFormProps; -} - -/** - * A React test wrapper. Wraps the component under test with a bespoke set - * of React components, typically providers. - * - * Wraps the component with the React Hook Form `FormProvider` - * and nothing more. Removes other providers to minimize side effects on the - * component under test. - * @param {WithFormProviderProps} props - Component properties. - * @returns {JSX.Element} JSX - */ -const WithFormProvider = ({ children, formProps }: WithFormProviderProps): JSX.Element => { - const formMethods = useForm(formProps); - return {children}; -}; - -export default WithFormProvider; From 601c9530c9917985464015f7b5fa85c611142588 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Mon, 10 Feb 2025 11:37:33 -0500 Subject: [PATCH 5/6] #28 stories --- src/common/components/Form/__stories__/Select.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/components/Form/__stories__/Select.stories.tsx b/src/common/components/Form/__stories__/Select.stories.tsx index 5b0692a..206d154 100644 --- a/src/common/components/Form/__stories__/Select.stories.tsx +++ b/src/common/components/Form/__stories__/Select.stories.tsx @@ -64,7 +64,7 @@ export const WithSupportingText: Story = { export const WithLabel: Story = { args: { children: options, - name: 'myField', - label: 'Fruit', + name: 'color', + label: 'Color', }, }; From ef46611e6af86ee9833de96ad86e6e94174c5bc6 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Mon, 10 Feb 2025 11:56:36 -0500 Subject: [PATCH 6/6] #28 pr fixes --- src/common/components/Form/__tests__/Input.test.tsx | 4 ++++ src/common/components/Form/__tests__/Select.test.tsx | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/common/components/Form/__tests__/Input.test.tsx b/src/common/components/Form/__tests__/Input.test.tsx index 4b111e3..154d63d 100644 --- a/src/common/components/Form/__tests__/Input.test.tsx +++ b/src/common/components/Form/__tests__/Input.test.tsx @@ -14,6 +14,10 @@ const formSchema = object({ type FormValues = InferType; +/** + * A wrapper for testing the `Input` component which requires some + * react-hook-form objects passed as props. + */ const InputWrapper = (props: Omit, 'control'>) => { const form = useForm({ defaultValues: { color: '' }, diff --git a/src/common/components/Form/__tests__/Select.test.tsx b/src/common/components/Form/__tests__/Select.test.tsx index 20ef3db..57eedc9 100644 --- a/src/common/components/Form/__tests__/Select.test.tsx +++ b/src/common/components/Form/__tests__/Select.test.tsx @@ -1,11 +1,12 @@ -import { render, screen } from 'test/test-utils'; +import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import { useForm } from 'react-hook-form'; import { InferType, object, string } from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; +import { render, screen } from 'test/test-utils'; + import Select, { SelectProps } from '../Select'; -import userEvent from '@testing-library/user-event'; const formSchema = object({ color: string().oneOf(['blue'], 'Must select a value in the list.'), @@ -13,6 +14,10 @@ const formSchema = object({ type FormValues = InferType; +/** + * A wrapper for testing the `Select` component which requires some + * react-hook-form objects passed as props. + */ const SelectWrapper = (props: Omit, 'control'>) => { const form = useForm({ defaultValues: { color: '' },