diff --git a/src/components/Form/DynamicForm.test.tsx b/src/components/Form/DynamicForm.test.tsx new file mode 100644 index 00000000..adf7956a --- /dev/null +++ b/src/components/Form/DynamicForm.test.tsx @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@core/utils/test"; +import { DynamicForm } from "./DynamicForm"; +import { z } from "zod/v4"; +import { useAppStore } from "@core/stores/appStore.ts"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key), + }), +})); + +const addErrorMock = vi.fn(); +const removeErrorMock = vi.fn(); + +vi.mock("@core/stores/appStore.ts", () => ({ + useAppStore: () => ({ + addError: addErrorMock, + removeError: removeErrorMock, + }), +})); + +describe("DynamicForm", () => { + const schema = z.object({ + name: z.string().min(3, { message: "Too short" }), + }); + + const fieldGroups = [ + { + label: "Test Group", + description: "Testing validation", + fields: [ + { + type: "text", + id: "name", + name: "name", + label: "Name", + description: "Enter your name", + properties: {}, + }, + ], + }, + ]; + + it("shows validation error when input is too short", async () => { + render( + > + onSubmit={vi.fn()} + validationSchema={schema} + defaultValues={{ name: "" }} + fieldGroups={fieldGroups} + />, + ); + const input = screen.getByLabelText("Name") as HTMLInputElement; + + fireEvent.input(input, { target: { value: "ab" } }); + + const error = await screen.findByText( + "formValidation.tooSmall.string", + ); + expect(error).toBeVisible(); + }); + + it("clears validation error when input becomes valid", async () => { + render( + > + onSubmit={vi.fn()} + validationSchema={schema} + defaultValues={{ name: "" }} + fieldGroups={fieldGroups} + />, + ); + const input = screen.getByLabelText("Name") as HTMLInputElement; + + fireEvent.input(input, { target: { value: "ab" } }); + expect( + await screen.findByText("formValidation.tooSmall.string"), + ).toBeVisible(); + + fireEvent.input(input, { target: { value: "abcd" } }); + await waitFor(() => + expect( + screen.queryByText("formValidation.tooSmall.string"), + ).toBeNull() + ); + }); + + it("calls onSubmit when form is valid onChange", async () => { + const onSubmit = vi.fn(); + render( + > + onSubmit={onSubmit} + validationSchema={schema} + defaultValues={{ name: "" }} + fieldGroups={fieldGroups} + />, + ); + + const input = screen.getByLabelText("Name") as HTMLInputElement; + + fireEvent.input(input, { target: { value: "ab" } }); + expect( + await screen.findByText("formValidation.tooSmall.string"), + ).toBeVisible(); + + fireEvent.input(input, { target: { value: "abcd" } }); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + expect(onSubmit).toHaveBeenCalledWith( + { name: "abcd" }, + expect.any(Object), + ); + }); + + it("renders a button and only calls onSubmit on click with submitType='onSubmit'", async () => { + const onSubmit = vi.fn(); + render( + > + onSubmit={onSubmit} + submitType="onSubmit" + hasSubmitButton={true} + validationSchema={schema} + defaultValues={{ name: "" }} + fieldGroups={fieldGroups} + />, + ); + + const btn = screen.getByRole("button", { name: /submit/i }); + expect(btn).toBeInTheDocument(); + + fireEvent.input(screen.getByLabelText("Name"), { target: { value: "ab" } }); + await screen.findByText("formValidation.tooSmall.string"); + fireEvent.click(btn); + expect(onSubmit).not.toHaveBeenCalled(); + + fireEvent.input(screen.getByLabelText("Name"), { + target: { value: "abcd" }, + }); + await waitFor(() => + expect(screen.queryByText("formValidation.tooSmall.string")).toBeNull() + ); + fireEvent.click(btn); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith({ name: "abcd" }, expect.any(Object)); + }); + + it("renders defaultValues correctly", () => { + render( + + onSubmit={vi.fn()} + // no validationSchema + defaultValues={{ name: "Alice" }} + fieldGroups={[ + { + label: "Group", + description: "", + fields: [ + { + type: "text", + name: "name", + label: "Name", + description: "", + properties: {}, + }, + ], + }, + ]} + />, + ); + const input = screen.getByLabelText("Name") as HTMLInputElement; + expect(input.value).toBe("Alice"); + }); + + it("toggles disabled state based on disabledBy rules", async () => { + const schema = z.object({ + enable: z.boolean(), + follow: z.string(), + }); + render( + > + onSubmit={vi.fn()} + validationSchema={schema} + defaultValues={{ enable: false, follow: "" }} + fieldGroups={[ + { + label: "Group", + description: "", + fields: [ + { + type: "toggle", + name: "enable", + label: "enable", + description: "", + }, + { + type: "text", + name: "follow", + label: "follow", + description: "", + disabledBy: [{ fieldName: "enable" }], + properties: {}, + }, + ], + }, + ]} + />, + ); + const enable = screen.getByRole("switch", { + name: "enable", + }) as HTMLInputElement; + + const follow = screen.getByLabelText("follow") as HTMLInputElement; + await waitFor(() => { + expect(enable.getAttribute("aria-checked")).toBe("false"); + expect(follow).toBeDisabled(); + }); + + fireEvent.click(enable); + await waitFor(() => { + expect(enable.getAttribute("aria-checked")).toBe("true"); + expect(follow).not.toBeDisabled(); + }); + }); + + it("always calls onSubmit onChange when no validationSchema is provided", async () => { + const onSubmit = vi.fn(); + render( + + onSubmit={onSubmit} + // no validationSchema + defaultValues={{ foo: "" }} + fieldGroups={[ + { + label: "G", + description: "", + fields: [ + { + type: "text", + name: "foo", + label: "Foo", + description: "", + properties: {}, + }, + ], + }, + ]} + />, + ); + const input = screen.getByLabelText("Foo") as HTMLInputElement; + fireEvent.input(input, { target: { value: "bar" } }); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ foo: "bar" }, expect.any(Object)); + }); + }); + + it("syncs errors to appStore when formId is set", async () => { + const { addError, removeError } = useAppStore(); + const schema = z.object({ foo: z.string().min(2) }); + const groups = [ + { + label: "G", + description: "", + fields: [ + { + type: "text", + name: "foo", + label: "Foo", + description: "", + properties: {}, + }, + ], + }, + ]; + + render( + > + onSubmit={vi.fn()} + formId="myForm" + validationSchema={schema} + defaultValues={{ foo: "" }} + fieldGroups={groups} + />, + ); + const input = screen.getByLabelText("Foo") as HTMLInputElement; + + fireEvent.input(input, { target: { value: "a" } }); + await screen.findByText(/tooSmall/i); + + expect(addError).toHaveBeenCalledWith("foo", ""); + expect(addError).toHaveBeenCalledWith("myForm", ""); + + fireEvent.input(input, { target: { value: "abc" } }); + await waitFor(() => { + expect(removeError).toHaveBeenCalledWith("foo"); + expect(removeError).toHaveBeenCalledWith("myForm"); + }); + }); +});