From 1b3935569e692c84279d7fec72ceafe72cab4431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ribeiro?= Date: Sat, 27 Apr 2024 23:08:18 -0300 Subject: [PATCH 1/5] fix(RichTextEditor): refactor JoditEditor import --- src/components/RichTextEditor/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/RichTextEditor/index.tsx b/src/components/RichTextEditor/index.tsx index 5a26c3f..04590a3 100644 --- a/src/components/RichTextEditor/index.tsx +++ b/src/components/RichTextEditor/index.tsx @@ -4,7 +4,10 @@ import React, { Suspense, lazy } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useTheme } from "#/components/ThemeToggle/context"; -const JoditEditor = lazy(() => import("jodit-react")); +const JoditEditor = lazy(async () => { + const module = await import("jodit-react"); + return { default: module.default }; +}); const EDITOR_BUTTONS = [ "undo", From 8f938042410e14246c08ebb9983fe7734345aadd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ribeiro?= Date: Sat, 27 Apr 2024 23:08:51 -0300 Subject: [PATCH 2/5] refactor(Form): refactor to make more performant --- src/components/ui/Form.tsx | 159 +++++++++++++++++++++++++++++-------- 1 file changed, 125 insertions(+), 34 deletions(-) diff --git a/src/components/ui/Form.tsx b/src/components/ui/Form.tsx index 2cbd7ee..7b3e539 100644 --- a/src/components/ui/Form.tsx +++ b/src/components/ui/Form.tsx @@ -12,8 +12,16 @@ import { import { InfoCircledIcon } from "@radix-ui/react-icons"; import { cn } from "#/lib/utils"; import { Label } from "#/components/ui/Label"; -import { useRailsApp } from "../RailsApp/context"; -import { Tooltip } from "."; +import { Tooltip } from "#/components/ui"; +import { useRailsApp } from "#/components/RailsApp"; + +// Create separate contexts for form field state and updater +const FormFieldStateContext = React.createContext< + FormFieldContextValue | undefined +>(undefined); +const FormFieldUpdaterContext = React.createContext< + React.Dispatch> | undefined +>(undefined); type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, @@ -52,15 +60,38 @@ const Form = ({ > {children} {csrfToken && ( - + )} ); }; -const FormFieldContext = React.createContext( - {} as FormFieldContextValue -); + +// Create a provider component for form field context +export const FormFieldProvider = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + children, + ...props +}: React.PropsWithChildren>) => { + const [fieldState, setFieldState] = React.useState({ + name: props.name, + }); + + return ( + + + {children} + + + ); +}; const FormField = < TFieldValues extends FieldValues = FieldValues, @@ -68,54 +99,114 @@ const FormField = < >({ ...props }: ControllerProps) => ( - // eslint-disable-next-line react/jsx-no-constructed-context-values - + - + ); -const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext); - const itemContext = React.useContext(FormItemContext); - const { getFieldState, formState } = useFormContext(); - - const fieldState = getFieldState(fieldContext.name, formState); - - if (!fieldContext) { - throw new Error("useFormField should be used within "); +// Use hooks to access form field state and updater +export const useFormFieldState = () => { + const fieldState = React.useContext(FormFieldStateContext); + if (typeof fieldState === "undefined") { + throw new Error( + "useFormFieldState must be used within a FormFieldProvider" + ); } + return fieldState; +}; - const { id } = itemContext; - - return { - id, - name: fieldContext.name, - formItemId: `${id}-form-item`, - formDescriptionId: `${id}-form-item-description`, - formMessageId: `${id}-form-item-message`, - ...fieldState, - }; +export const useFormFieldUpdater = () => { + const setFieldState = React.useContext(FormFieldUpdaterContext); + if (typeof setFieldState === "undefined") { + throw new Error( + "useFormFieldUpdater must be used within a FormFieldProvider" + ); + } + return setFieldState; }; +// Create separate contexts for form item state and updater +const FormItemStateContext = React.createContext< + FormItemContextValue | undefined +>(undefined); +const FormItemUpdaterContext = React.createContext< + React.Dispatch> | undefined +>(undefined); + type FormItemContextValue = { id: string; }; -const FormItemContext = React.createContext( - {} as FormItemContextValue -); +// Create a provider component for form item context +export const FormItemProvider = ({ + children, + ...props +}: React.HTMLAttributes) => { + const [itemState, setItemState] = React.useState({ + id: React.useId(), + }); + + return ( + + +
{children}
+
+
+ ); +}; const FormItem = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - // eslint-disable-next-line react/jsx-no-constructed-context-values - +
- + )); FormItem.displayName = "FormItem"; +// Use hooks to access form item state and updater +const useFormItemState = () => { + const itemState = React.useContext(FormItemStateContext); + if (typeof itemState === "undefined") { + throw new Error("useFormItemState must be used within a FormItemProvider"); + } + return itemState; +}; + +// const useFormItemUpdater = () => { +// const setItemState = React.useContext(FormItemUpdaterContext); +// if (typeof setItemState === "undefined") { +// throw new Error( +// "useFormItemUpdater must be used within a FormItemProvider" +// ); +// } +// return setItemState; +// }; + +const useFormField = () => { + const fieldState = useFormFieldState(); + const itemState = useFormItemState(); + const { getFieldState, formState } = useFormContext(); + + const fieldStateFromForm = getFieldState(fieldState.name, formState); + + if (!fieldState) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemState; + + return { + id, + name: fieldState.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldStateFromForm, + }; +}; + const FormLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { From d980c85bc08389047c4f99c02a9f6e3681691121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ribeiro?= Date: Sat, 27 Apr 2024 23:09:25 -0300 Subject: [PATCH 3/5] test(FormBuilder): add tests to FormBuilder elements --- .../FormBuilder/fields/DatePickerInput.tsx | 1 + .../FormBuilder/fields/FieldArray.tsx | 13 ++- .../FormBuilder/fields/HiddenField.tsx | 6 +- .../FormBuilder/fields/InputField.tsx | 1 + .../FormBuilder/CheckboxField.test.tsx | 28 ++++++ .../FormBuilder/DatePickerInput.test.tsx | 32 +++++++ .../FormBuilder/FieldArray.test.tsx | 53 +++++++++++ .../FormBuilder/FileUploadField.test.tsx | 29 +++++++ .../components/FormBuilder/FormField.test.tsx | 40 +++++++++ .../FormBuilder/FormFieldProvider.test.tsx | 41 +++++++++ .../FormBuilder/HiddenField.test.tsx | 15 ++++ .../FormBuilder/InputField.test.tsx | 35 ++++++++ .../MultiSelectCheckboxesField.test.tsx | 34 ++++++++ .../FormBuilder/RadioGroupField.test.tsx | 36 ++++++++ .../FormBuilder/RichTextEditorField.test.tsx | 32 +++++++ .../FormBuilder/SwitchField.test.tsx | 22 +++++ .../FormBuilder/TextAreaField.test.tsx | 30 +++++++ .../components/FormBuilder/buildForm.test.ts | 36 ++++++++ .../FormBuilder/useFormField.test.tsx | 40 +++++++++ .../FormBuilder/withConditional.test.tsx | 41 +++++++++ tests/components/ui/Form.test.tsx | 87 +++++++++++++++++++ tests/helpers/renderFormField.tsx | 21 +++++ tests/setup.js | 9 -- tests/setup.ts | 69 +++++++++++++++ tsconfig.json | 4 +- vitest.config.mts | 10 ++- 26 files changed, 744 insertions(+), 21 deletions(-) create mode 100644 tests/components/FormBuilder/CheckboxField.test.tsx create mode 100644 tests/components/FormBuilder/DatePickerInput.test.tsx create mode 100644 tests/components/FormBuilder/FieldArray.test.tsx create mode 100644 tests/components/FormBuilder/FileUploadField.test.tsx create mode 100644 tests/components/FormBuilder/FormField.test.tsx create mode 100644 tests/components/FormBuilder/FormFieldProvider.test.tsx create mode 100644 tests/components/FormBuilder/HiddenField.test.tsx create mode 100644 tests/components/FormBuilder/InputField.test.tsx create mode 100644 tests/components/FormBuilder/MultiSelectCheckboxesField.test.tsx create mode 100644 tests/components/FormBuilder/RadioGroupField.test.tsx create mode 100644 tests/components/FormBuilder/RichTextEditorField.test.tsx create mode 100644 tests/components/FormBuilder/SwitchField.test.tsx create mode 100644 tests/components/FormBuilder/TextAreaField.test.tsx create mode 100644 tests/components/FormBuilder/buildForm.test.ts create mode 100644 tests/components/FormBuilder/useFormField.test.tsx create mode 100644 tests/components/FormBuilder/withConditional.test.tsx create mode 100644 tests/components/ui/Form.test.tsx create mode 100644 tests/helpers/renderFormField.tsx delete mode 100644 tests/setup.js create mode 100644 tests/setup.ts diff --git a/src/components/FormBuilder/fields/DatePickerInput.tsx b/src/components/FormBuilder/fields/DatePickerInput.tsx index 38caa3c..c688e9d 100644 --- a/src/components/FormBuilder/fields/DatePickerInput.tsx +++ b/src/components/FormBuilder/fields/DatePickerInput.tsx @@ -34,6 +34,7 @@ export const DatePickerInput = withConditional( control={form.control} name={field.name} rules={field.required ? { required: true } : undefined} + defaultValue={field.defaultValue} render={({ field: formField }) => ( {field.label} diff --git a/src/components/FormBuilder/fields/FieldArray.tsx b/src/components/FormBuilder/fields/FieldArray.tsx index 3818d44..8350fdb 100644 --- a/src/components/FormBuilder/fields/FieldArray.tsx +++ b/src/components/FormBuilder/fields/FieldArray.tsx @@ -125,8 +125,7 @@ export const FieldArray = withConditional(
    - {/* eslint-disable-next-line no-shadow, @typescript-eslint/no-unused-vars */} - {(provided, snapshot) => ( + {(provided) => (
    {fields.map((rhfField, index) => { // @ts-expect-error @@ -139,11 +138,10 @@ export const FieldArray = withConditional( draggableId={`item-${index}`} index={index} > - {/* eslint-disable-next-line no-shadow, @typescript-eslint/no-unused-vars */} - {(provided, snapshot) => ( + {(innerProvided) => (
  • ( {field.hasSequence && (
    @@ -167,6 +165,7 @@ export const FieldArray = withConditional( // eslint-disable-next-line jsx-a11y/control-has-associated-label + + ); +}; + +describe("Form Components", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the form correctly", () => { + render(); + expect(screen.getByText("Test Field")).toBeInTheDocument(); + expect(screen.getByText("This is a test field")).toBeInTheDocument(); + expect(screen.getByText("Submit")).toBeInTheDocument(); + }); + + // it("displays error message when field is invalid", async () => { + // render(); + // fireEvent.submit(screen.getByText("Submit")); + // expect(await screen.findByRole("alert")).toHaveTextContent( + // "Field is required" + // ); + // }); + + it("passes validation when field is valid", async () => { + render(); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Valid input" }, + }); + fireEvent.submit(screen.getByText("Submit")); + expect(screen.queryByText("Field is required")).not.toBeInTheDocument(); + }); + + it("renders the CSRF token input", () => { + render(); + expect(screen.getByTestId("csrf-token")).toHaveValue("mocked-csrf-token"); + }); +}); diff --git a/tests/helpers/renderFormField.tsx b/tests/helpers/renderFormField.tsx new file mode 100644 index 0000000..08488f6 --- /dev/null +++ b/tests/helpers/renderFormField.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { render, renderHook } from "@testing-library/react"; +import { useForm, FormProvider } from "react-hook-form"; + +export const renderFormField = ( + FieldComponent: React.ComponentType, + field: any, + formProps: any = {} +) => { + const { result } = renderHook(() => useForm(formProps)); + const form = result.current; + + return { + form, + ...render( + + + + ), + }; +}; diff --git a/tests/setup.js b/tests/setup.js deleted file mode 100644 index 971941d..0000000 --- a/tests/setup.js +++ /dev/null @@ -1,9 +0,0 @@ -import { expect, afterEach } from "vitest"; -import { cleanup } from "@testing-library/react"; -import * as matchers from "@testing-library/jest-dom/matchers"; - -expect.extend(matchers); - -afterEach(() => { - cleanup(); -}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..fdd50f9 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,69 @@ +import React from "react"; +import { expect, afterEach, vi } from "vitest"; +import { cleanup, renderHook } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; + +declare module "vitest" { + interface Assertion + extends jest.Matchers, + matchers.TestingLibraryMatchers {} +} +const mockI18n = () => { + vi.mock("react-i18next", () => ({ + // Mock hook + useTranslation: () => ({ + t: (k: string) => k, + i18n: { + changeLanguage: () => new Promise(() => {}), + }, + }), + // High Order Component as a function returning a function + withTranslation: () => (Component) => (props) => + React.createElement(Component, { ...props, t: (k) => k }), + // Trans component as a functional component + Trans: ({ children, i18nKey }) => children || i18nKey, + // Provider can be simplified to just return children for testing purposes + I18nextProvider: ({ children }) => children, + initReactI18next: { + type: "3rdParty", + init: vi.fn(), + }, + })); +}; + +expect.extend(matchers); + +beforeEach(() => { + mockI18n(); +}); + +afterEach(() => { + cleanup(); +}); + +const suppressConsoleError = () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + return () => { + errorSpy.mockRestore(); + logSpy.mockRestore(); + }; +}; + +export const renderHookWithError = (...args: unknown[]) => { + let error: unknown; + const restore = suppressConsoleError(); + + try { + // @ts-ignore + renderHook(...args); + } catch (ex) { + error = ex; + } + + restore(); + throw error; +}; diff --git a/tsconfig.json b/tsconfig.json index b7204cc..223b67f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "baseUrl": ".", - "paths": { "#/*": ["./src/*"] }, + "paths": { "#/*": ["./src/*"], "tests/*": ["./tests/*"] }, "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, @@ -15,6 +15,8 @@ "skipLibCheck": true, "strict": true, "lib": ["dom", "dom.iterable", "esnext"], + "types": ["vitest/globals"], + "plugins": [ { "transform": "typescript-transform-paths" }, { "transform": "typescript-transform-paths", "afterDeclarations": true } diff --git a/vitest.config.mts b/vitest.config.mts index 270ff1f..fc32652 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -2,14 +2,22 @@ import { defineConfig as viteDefineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { defineConfig, mergeConfig } from "vitest/config"; +import path from "path"; export default mergeConfig( viteDefineConfig({ plugins: [react()] }), defineConfig({ test: { + globals: true, environment: "jsdom", - setupFiles: "./tests/setup.js", + setupFiles: "./tests/setup.ts", passWithNoTests: true, }, + resolve: { + alias: { + "#": path.resolve(__dirname, "./src"), + tests: path.resolve(__dirname, "./tests"), + }, + }, }) ); From 221d30e7b14a64a61aba92d3be97a4e7b29fc221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ribeiro?= Date: Sun, 28 Apr 2024 08:21:18 -0300 Subject: [PATCH 4/5] test(FormBuilder): check whether disabled is being respected --- .../FormBuilder/fields/CheckboxField.tsx | 2 + .../FormBuilder/fields/TextAreaField.tsx | 2 + .../fields/selects/SelectField.tsx | 80 +++++++++---------- src/components/FormBuilder/isFieldDisabled.ts | 12 +++ .../FormBuilder/CheckboxField.test.tsx | 12 +++ .../FormBuilder/DatePickerInput.test.tsx | 7 ++ .../components/FormBuilder/FormField.test.tsx | 14 ++++ .../FormBuilder/HiddenField.test.tsx | 11 +++ .../FormBuilder/InputField.test.tsx | 28 ++++--- .../FormBuilder/SwitchField.test.tsx | 6 ++ .../FormBuilder/TextAreaField.test.tsx | 5 ++ tests/helpers/renderFormField.tsx | 17 ++-- tsconfig.json | 1 - 13 files changed, 137 insertions(+), 60 deletions(-) create mode 100644 src/components/FormBuilder/isFieldDisabled.ts diff --git a/src/components/FormBuilder/fields/CheckboxField.tsx b/src/components/FormBuilder/fields/CheckboxField.tsx index f7eb022..d1ad1d0 100644 --- a/src/components/FormBuilder/fields/CheckboxField.tsx +++ b/src/components/FormBuilder/fields/CheckboxField.tsx @@ -9,6 +9,7 @@ import { } from "#/components/ui/Form"; import { BaseField, withConditional } from "../fields"; +import isFieldDisabled from "../isFieldDisabled"; export interface CheckboxFieldProps extends BaseField { type: "checkbox"; @@ -31,6 +32,7 @@ export const CheckboxField = withConditional(
    diff --git a/src/components/FormBuilder/fields/TextAreaField.tsx b/src/components/FormBuilder/fields/TextAreaField.tsx index 3ed0b6b..532ee02 100644 --- a/src/components/FormBuilder/fields/TextAreaField.tsx +++ b/src/components/FormBuilder/fields/TextAreaField.tsx @@ -11,6 +11,7 @@ import { import { Textarea } from "#/components/ui/Textarea"; import { BaseField, withConditional } from "../fields"; +import isFieldDisabled from "../isFieldDisabled"; export interface TextAreaFieldProps extends BaseField { length?: { @@ -47,6 +48,7 @@ export const TextAreaField = withConditional( {field.description}