diff --git a/frontend/bun.lock b/frontend/bun.lock index 2e28eba374..992d41f25f 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@fontsource/geist-mono": "^5.3.0", "@fontsource/inter": "^5.3.0", + "@hookform/resolvers": "^7.0.3", "@monaco-editor/react": "^4.7.0", "@react-awesome-query-builder/ui": "^6.7.0-alpha.0", "@react-pdf-viewer/core": "^3.12.0", @@ -67,6 +68,7 @@ "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", "uuid": "^9.0.1", + "zod": "^4.6.7", "zustand": "^4.3.8", }, "devDependencies": { @@ -263,6 +265,8 @@ "@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="], + "@hookform/resolvers": ["@hookform/resolvers@7.0.3", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0", "ajv": "^8.12.0", "ajv-errors": "^3.0.0", "ajv-formats": "^2.1.1", "arktype": "^2.0.0", "ata-validator": "^1.23.0", "class-transformer": ">=0.4.0", "class-validator": ">=0.12.0", "computed-types": "^1.0.0", "effect": "^4.0.0", "fluentvalidation-ts": "^3.0.0", "fp-ts": "^2.7.0", "io-ts": "^2.0.0", "joi": "^17.0.0 || ^18.0.0", "nope-validator": ">=0.12.0", "react-hook-form": "^7.55.0", "superstruct": ">=0.12.0", "typanion": "^3.3.2", "typebox": "^1.0.0", "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", "vest": ">=6.0.0", "yup": "^1.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "@vinejs/vine", "ajv", "ajv-errors", "ajv-formats", "arktype", "ata-validator", "class-transformer", "class-validator", "computed-types", "effect", "fluentvalidation-ts", "fp-ts", "io-ts", "joi", "nope-validator", "superstruct", "typanion", "typebox", "valibot", "vest", "yup", "zod"] }, "sha512-beGY1JY6K5cyWlswD4BVf7UTb5MxSjKkjljrRyLPT3KIQzsTU0jOibBKqtdcKqIZYQUUyURed7roLWZtSdAtiQ=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.10", "", {}, "sha512-dwrc+1aqVZpHNkNDswXGtn1typ+Pxpo5FHZRyBw1le7onEJUkPUkYOY1FIFC1LvFMYUqqLuWBwElhAmencL7MQ=="], "@inquirer/confirm": ["@inquirer/confirm@6.3.5", "", { "dependencies": { "@inquirer/core": "^12.0.6", "@inquirer/type": "4.2.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BJ59BOyURiZiAxdr9AuRu38/k+rV+DcTIM2fUF/c/BXrab6H8zMZTTOl3LgkSJH96Xob2vOJxDiBkHiz32Xx2w=="], @@ -1755,6 +1759,8 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], diff --git a/frontend/package.json b/frontend/package.json index f91327c514..844868cbec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "dependencies": { "@fontsource/geist-mono": "^5.3.0", "@fontsource/inter": "^5.3.0", + "@hookform/resolvers": "^7.0.3", "@monaco-editor/react": "^4.7.0", "@react-awesome-query-builder/ui": "^6.7.0-alpha.0", "@react-pdf-viewer/core": "^3.12.0", @@ -65,6 +66,7 @@ "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", "uuid": "^9.0.1", + "zod": "^4.6.7", "zustand": "^4.3.8" }, "scripts": { diff --git a/frontend/src/v1/components/patterns/form/FormField.tsx b/frontend/src/v1/components/patterns/form/FormField.tsx new file mode 100644 index 0000000000..068430ac2d --- /dev/null +++ b/frontend/src/v1/components/patterns/form/FormField.tsx @@ -0,0 +1,39 @@ +import { Field, type FieldProps } from "@v1/components/ui/form"; +import { type JSX, type ReactNode, useEffect } from "react"; +import { + type ControllerRenderProps, + type FieldPath, + type FieldValues, + type UseFormReturn, + useController, +} from "react-hook-form"; + +export type FormFieldProps< + T extends FieldValues, + N extends FieldPath, +> = Omit & + Readonly<{ + form: UseFormReturn; + name: N; + /** Renders the control from the field's value, handlers and ref. */ + children: (field: ControllerRenderProps) => ReactNode; + }>; + +/** A Field bound to one form value: its error shows under the control and marks it invalid. */ +export function FormField>({ + form, + name, + children, + ...fieldProps +}: FormFieldProps): JSX.Element { + const { field, fieldState } = useController({ control: form.control, name }); + // A hidden field cannot show its error, so drop it; a submit validates again + // and an error that still stands shows in the footer. + const { clearErrors } = form; + useEffect(() => () => clearErrors(name), [clearErrors, name]); + return ( + + {children(field)} + + ); +} diff --git a/frontend/src/v1/components/patterns/form/FormFooter.tsx b/frontend/src/v1/components/patterns/form/FormFooter.tsx new file mode 100644 index 0000000000..16f8e7c30d --- /dev/null +++ b/frontend/src/v1/components/patterns/form/FormFooter.tsx @@ -0,0 +1,111 @@ +import { ErrorText } from "@v1/components/ui/form"; +import type { JSX } from "react"; +import { + type Control, + type FieldErrors, + type FieldValues, + get, + type UseFormReturn, + useFormState, +} from "react-hook-form"; +import { isMounted, SERVER_ERROR } from "./use-app-form"; + +/** Leaves (true values) in react-hook-form's nested dirtyFields. */ +function countDirty(node: unknown): number { + if (node === true) { + return 1; + } + if (node && typeof node === "object") { + return Object.values(node).reduce((n, v) => n + countDirty(v), 0); + } + return 0; +} + +/** Every field error as [path, message]; a schema refine with no path sits at "". */ +function errorLeaves(node: unknown, path = ""): [string, string][] { + if (!node || typeof node !== "object") { + return []; + } + const error = node as { type?: unknown; message?: unknown }; + if (typeof error.type === "string") { + return [[path, typeof error.message === "string" ? error.message : ""]]; + } + return Object.entries(node).flatMap(([key, child]) => + errorLeaves(child, path ? `${path}.${key}` : key), + ); +} + +function status( + submitting: boolean, + dirty: number, + fieldErrors: boolean, +): string { + if (submitting) { + return "Saving"; + } + if (dirty === 0) { + return "No changes"; + } + if (fieldErrors) { + return "Cannot save"; + } + return `${dirty} unsaved change${dirty === 1 ? "" : "s"}`; +} + +const button = + "ui:h-8 ui:cursor-pointer ui:rounded-md ui:px-3 ui:text-body ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:focus-visible:focus-ring"; + +/** + * The save bar: what changed, Save changes (submits the form) and Discard + * (back to the form's default values). Errors with no field on screen, and the + * server's form-level error, show here while the form has changes; they do not + * disable Save, since only a submit or Discard clears them. + */ +// ponytail: plain buttons; swap to the v1 Button when it exists. +export function FormFooter({ + form, +}: Readonly<{ form: UseFormReturn }>): JSX.Element { + const { dirtyFields, errors, isSubmitting } = useFormState({ + control: form.control, + }); + const dirty = countDirty(dirtyFields); + const control = form.control as Control; + const { root: _root, ...rest } = errors as FieldErrors; + const leaves = errorLeaves(rest); + const hasFieldErrors = leaves.some(([path]) => isMounted(control, path)); + const formLevel = [ + ...leaves.filter(([path]) => !isMounted(control, path)).map(([, m]) => m), + get(errors, SERVER_ERROR)?.message, + ].filter((m): m is string => Boolean(m)); + const text = status(isSubmitting, dirty, hasFieldErrors); + return ( +
+
+ + {text} + + {dirty > 0 && formLevel.length > 0 ? ( + {formLevel.join(" ")} + ) : null} +
+ + +
+ ); +} diff --git a/frontend/src/v1/components/patterns/form/form.test.tsx b/frontend/src/v1/components/patterns/form/form.test.tsx new file mode 100644 index 0000000000..0f7bc6a595 --- /dev/null +++ b/frontend/src/v1/components/patterns/form/form.test.tsx @@ -0,0 +1,552 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Input } from "@v1/components/ui/form"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { + applyServerErrors, + FormField, + FormFooter, + type ServerErrorBody, + useAppForm, + useLeaveGuard, +} from "."; + +const schema = z.object({ + name: z.string().min(1, "Enter a name"), + url: z.string().url("Enter a URL"), +}); +type Values = z.infer; + +let lastForm: ReturnType>; + +function Sample({ + onSave = async () => undefined, + defaults = { name: "a", url: "https://x.io" }, +}: { + onSave?: (v: Values) => Promise; + defaults?: Values; +}) { + const form = useAppForm({ schema, defaultValues: defaults }); + lastForm = form; + return ( +
{ + const error = await onSave(values); + if (error === undefined) { + form.reset(values); + } else { + applyServerErrors(form, error); + } + })} + > + + {(field) => } + + + {(field) => } + + + + ); +} + +const saveButton = () => + screen.getByRole("button", { name: "Save changes" }) as HTMLButtonElement; +const discardButton = () => + screen.getByRole("button", { name: "Discard" }) as HTMLButtonElement; + +const extraSchema = z.object({ + name: z.string().min(1), + note: z.string().optional(), + ownerId: z.string(), +}); +type Extra = z.infer; + +/** Holds ownerId without rendering it, and renders note with no default. */ +function Extra({ onSave }: { onSave: (v: Extra) => Promise }) { + const form = useAppForm({ + schema: extraSchema, + defaultValues: { name: "a", ownerId: "u1" }, + }); + return ( +
+ applyServerErrors(form, await onSave(values)), + )} + > + + {(field) => } + + + {(field) => } + + + + ); +} + +const refinedSchema = z + .object({ name: z.string(), url: z.string() }) + .refine((v) => v.name !== v.url, "Name and URL must differ"); + +/** A whole-form refine with no path: its error belongs to no field. */ +function Refined() { + const form = useAppForm({ + schema: refinedSchema, + defaultValues: { name: "", url: "b" }, + }); + return ( +
undefined)}> + + {(field) => } + + + + ); +} + +describe("useAppForm and FormField", () => { + it("validates a field on blur and wires its error to the control", async () => { + render(); + const name = screen.getByLabelText("Name"); + await userEvent.clear(name); + await act(async () => undefined); + expect(screen.queryByText("Enter a name")).toBeNull(); + fireEvent.blur(name); + await screen.findByText("Enter a name"); + expect(name.getAttribute("aria-invalid")).toBe("true"); + expect(name.getAttribute("aria-describedby")).toContain( + screen.getByText("Enter a name").id, + ); + }); + + it("focuses the first invalid field on submit and does not save", async () => { + const onSave = vi.fn(); + render(); + // Name was never blurred, so no error shows until the submit. + await userEvent.type(screen.getByLabelText("URL"), "x"); + const form = screen.getByLabelText("URL").closest("form"); + fireEvent.submit(form as HTMLFormElement); + await screen.findByText("Enter a name"); + screen.getByText("Enter a URL"); + expect(document.activeElement).toBe(screen.getByLabelText("Name")); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("moves focus to the first field a server error names, off the disabled Save", async () => { + const onSave = vi.fn(async () => ({ + type: "validation_error", + errors: [ + { code: "taken", detail: "That URL is taken", attr: "url" }, + { code: "taken", detail: "That name is taken", attr: "name" }, + ], + })); + render(); + await userEvent.type(screen.getByLabelText("Name"), "b"); + await userEvent.click(saveButton()); + await screen.findByText("That URL is taken"); + expect(saveButton().disabled).toBe(true); + expect(document.activeElement).toBe(screen.getByLabelText("URL")); + }); + + it("clears a blur error as soon as the field is fixed", async () => { + render(); + const name = screen.getByLabelText("Name"); + await userEvent.clear(name); + fireEvent.blur(name); + await screen.findByText("Cannot save"); + await userEvent.type(name, "fixed"); + await screen.findByText("1 unsaved change"); + expect(screen.queryByText("Enter a name")).toBeNull(); + expect(saveButton().disabled).toBe(false); + }); +}); + +describe("applyServerErrors", () => { + it("puts a known attr on its field and the rest at form level", async () => { + const onSave = async () => ({ + type: "validation_error" as const, + errors: [ + { code: "unique", detail: "Name is taken", attr: "name" }, + { code: "invalid", detail: "Quota reached", attr: "non_field_errors" }, + { code: "invalid", detail: "Bad owner", attr: "owner" }, + ], + }); + render(); + await userEvent.type(screen.getByLabelText("Name"), "b"); + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + await screen.findByText("Name is taken"); + expect(screen.queryByText("Could not save. Try again.")).toBeNull(); + expect( + screen.getByText("Quota reached Bad owner").getAttribute("role"), + ).toBe("alert"); + }); + + it("maps a dotted attr onto a nested path", () => { + const setError = vi.fn(); + const form = { + control: { _fields: { rows: [{ key: { _f: { mount: true } } }] } }, + setError, + } as unknown as Parameters[0]; + applyServerErrors(form, { + type: "validation_error", + errors: [{ code: "x", detail: "Bad key", attr: "rows.0.key" }], + }); + expect(setError).toHaveBeenCalledWith( + "rows.0.key", + { type: "server", message: "Bad key" }, + { shouldFocus: true }, + ); + }); + + it("puts every error of a non-validation response at form level", () => { + const setError = vi.fn(); + const form = { + control: { _fields: { name: { _f: { mount: true } } } }, + setError, + } as unknown as Parameters[0]; + applyServerErrors(form, { + type: "server_error", + errors: [{ code: "error", detail: "Something broke", attr: "name" }], + }); + expect(setError).toHaveBeenCalledWith("root.server", { + type: "server", + message: "Something broke", + }); + }); + + it("shows every error the server sent for one field", async () => { + render( + ({ + type: "validation_error", + errors: [ + { code: "a", detail: "Too short.", attr: "name" }, + { code: "b", detail: "Taken.", attr: "name" }, + ], + })} + />, + ); + await userEvent.type(screen.getByLabelText("Name"), "b"); + await userEvent.click(saveButton()); + await screen.findByText("Too short. Taken."); + expect(screen.queryByText("Could not save. Try again.")).toBeNull(); + }); + + it("puts a field's error under it even when its value is undefined", async () => { + render( + ({ + type: "validation_error", + errors: [{ code: "x", detail: "Bad note", attr: "note" }], + })} + />, + ); + await userEvent.type(screen.getByLabelText("Name"), "b"); + await userEvent.click(saveButton()); + await screen.findByText("Bad note"); + expect(screen.getByLabelText("Note").getAttribute("aria-invalid")).toBe( + "true", + ); + }); + + it("shows an error for a value no field renders at form level and keeps Save enabled", async () => { + render( + ({ + type: "validation_error", + errors: [{ code: "x", detail: "Owner gone", attr: "ownerId" }], + })} + />, + ); + await userEvent.type(screen.getByLabelText("Name"), "b"); + await userEvent.click(saveButton()); + await screen.findByText("Owner gone"); + screen.getByText("1 unsaved change"); + expect(saveButton().disabled).toBe(false); + }); + + it("shows a form-level-only error without blocking Save", async () => { + render( + ({ + type: "validation_error", + errors: [ + { code: "x", detail: "Quota reached", attr: "non_field_errors" }, + ], + })} + />, + ); + await userEvent.type(screen.getByLabelText("Name"), "b"); + await userEvent.click(saveButton()); + await screen.findByText("Quota reached"); + screen.getByText("1 unsaved change"); + expect(saveButton().disabled).toBe(false); + }); + + it.each([ + [{ detail: "Request failed" }, "Request failed"], + [{ type: "sdk_error", error: "Adapter down" }, "Adapter down"], + [{ type: "validation_error", errors: [] }, "Could not save. Try again."], + [ + { + type: "validation_error", + errors: [{ code: "x", detail: "", attr: null }], + }, + "Could not save. Try again.", + ], + [{ detail: " ", error: "SDK failed" }, "SDK failed"], + [{ message: "Gateway timeout" }, "Gateway timeout"], + [ + { + type: "validation_error", + errors: [{ code: "x", detail: " ", attr: null }], + }, + "Could not save. Try again.", + ], + [ + { + type: "validation_error", + errors: [null, { code: "x", detail: 42, attr: null }], + }, + "Could not save. Try again.", + ], + [ + { type: "validation_error", errors: { name: "bad" } }, + "Could not save. Try again.", + ], + [null, "Could not save. Try again."], + ])("always shows something for the body %j", async (body, message) => { + render( body as never} />); + await userEvent.type(screen.getByLabelText("Name"), "b"); + await userEvent.click(saveButton()); + await screen.findByText(message); + }); +}); + +describe("hidden and nested fields", () => { + const nested = z.object({ + name: z.string().min(1), + rows: z.array(z.object({ key: z.string().min(1, "Enter a key") })), + }); + type Nested = z.infer; + + /** URL can be hidden; rows.0.key is a nested field. */ + function Toggle({ + onSave, + }: { + onSave?: (v: Nested) => Promise; + }) { + const [shown, setShown] = useState(true); + const form = useAppForm({ + schema: nested, + defaultValues: { name: "a", rows: [{ key: "k" }] }, + }); + return ( +
{ + if (onSave) { + applyServerErrors(form, await onSave(values)); + } + })} + > + + {(field) => } + + {shown ? ( + + {(field) => } + + ) : null} + + + + ); + } + + it("blocks Save on a nested field's error and shows it under the field", async () => { + render(); + const key = screen.getByLabelText("Key"); + await userEvent.clear(key); + fireEvent.blur(key); + await screen.findByText("Cannot save"); + expect(screen.getAllByText("Enter a key")).toHaveLength(1); + expect(saveButton().disabled).toBe(true); + }); + + it("frees Save when a field holding an error is hidden", async () => { + render(); + const key = screen.getByLabelText("Key"); + await userEvent.clear(key); + fireEvent.blur(key); + await screen.findByText("Cannot save"); + await userEvent.click(screen.getByRole("button", { name: "Hide" })); + await screen.findByText("1 unsaved change"); + expect(saveButton().disabled).toBe(false); + }); + + it("sends a server error for a hidden field to the footer", async () => { + render( + ({ + type: "validation_error", + errors: [{ code: "x", detail: "Key taken", attr: "rows.0.key" }], + })} + />, + ); + await userEvent.type(screen.getByLabelText("Name"), "b"); + await userEvent.click(screen.getByRole("button", { name: "Hide" })); + await userEvent.click(saveButton()); + await screen.findByText("Key taken"); + expect(saveButton().disabled).toBe(false); + }); +}); + +describe("FormFooter", () => { + it("shows No changes with Save disabled on a clean form", () => { + render(); + screen.getByText("No changes"); + expect(saveButton().disabled).toBe(true); + expect(discardButton().disabled).toBe(true); + }); + + it("counts dirty fields, and Discard restores the saved values", async () => { + render(); + await userEvent.type(screen.getByLabelText("Name"), "b"); + await screen.findByText("1 unsaved change"); + await userEvent.type(screen.getByLabelText("URL"), "/p"); + await screen.findByText("2 unsaved changes"); + await userEvent.click(screen.getByRole("button", { name: "Discard" })); + await screen.findByText("No changes"); + expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("a"); + }); + + it("shows Cannot save while a field error stands", async () => { + render(); + await userEvent.clear(screen.getByLabelText("Name")); + fireEvent.blur(screen.getByLabelText("Name")); + await screen.findByText("Cannot save"); + expect(saveButton().disabled).toBe(true); + }); + + it("shows No changes on a clean form even with a field error", async () => { + render(); + fireEvent.blur(screen.getByLabelText("Name")); + await screen.findByText("Enter a name"); + screen.getByText("No changes"); + }); + + it("shows a schema error that no field owns, without blocking Save", async () => { + render(); + await userEvent.type(screen.getByLabelText("Name"), "b"); + await userEvent.click(saveButton()); + await screen.findByText("Name and URL must differ"); + expect(saveButton().disabled).toBe(false); + }); + + it("hides a form-level error once the change is reverted", async () => { + render( + ({ + type: "client_error", + errors: [{ code: "x", detail: "Locked", attr: null }], + })} + />, + ); + const name = screen.getByLabelText("Name"); + await userEvent.type(name, "b"); + await userEvent.click(saveButton()); + await screen.findByText("Locked"); + await userEvent.type(name, "{backspace}"); + await screen.findByText("No changes"); + expect(screen.queryByText("Locked")).toBeNull(); + }); + + it("shows Saving while the save runs, then No changes", async () => { + let finish: () => void = () => undefined; + const onSave = () => + new Promise((resolve) => { + finish = () => resolve(undefined); + }); + render(); + await userEvent.type(screen.getByLabelText("Name"), "b"); + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + await screen.findByText("Saving"); + expect(saveButton().disabled).toBe(true); + expect(discardButton().disabled).toBe(true); + await act(async () => finish()); + await screen.findByText("No changes"); + expect(lastForm.getValues("name")).toBe("ab"); + }); +}); + +describe("useLeaveGuard", () => { + function Guarded({ + dirty, + confirm, + onLeave, + }: { + dirty: boolean; + confirm: () => Promise; + onLeave: (ok: boolean) => void; + }) { + const confirmLeave = useLeaveGuard(dirty, confirm); + return ( + + ); + } + + it("leaves a clean form without asking", async () => { + const confirm = vi.fn(async () => false); + const onLeave = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button")); + expect(confirm).not.toHaveBeenCalled(); + expect(onLeave).toHaveBeenCalledWith(true); + }); + + it.each([ + true, + false, + ])("asks on a dirty form and returns the answer %s", async (answer) => { + const onLeave = vi.fn(); + render( answer} onLeave={onLeave} />); + await userEvent.click(screen.getByRole("button")); + expect(onLeave).toHaveBeenCalledWith(answer); + }); + + it("asks once the form turns dirty", async () => { + const confirm = vi.fn(async () => false); + const onLeave = vi.fn(); + const { rerender } = render( + , + ); + rerender(); + await userEvent.click(screen.getByRole("button")); + expect(confirm).toHaveBeenCalledOnce(); + expect(onLeave).toHaveBeenCalledWith(false); + }); + + it("asks the browser before a tab close only while dirty", () => { + const confirm = async () => true; + const { rerender } = render( + undefined} />, + ); + const dirtyClose = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(dirtyClose); + expect(dirtyClose.defaultPrevented).toBe(true); + + rerender( + undefined} />, + ); + const cleanClose = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(cleanClose); + expect(cleanClose.defaultPrevented).toBe(false); + }); +}); diff --git a/frontend/src/v1/components/patterns/form/index.ts b/frontend/src/v1/components/patterns/form/index.ts new file mode 100644 index 0000000000..2ee5f0f0dc --- /dev/null +++ b/frontend/src/v1/components/patterns/form/index.ts @@ -0,0 +1,9 @@ +export { FormField, type FormFieldProps } from "./FormField"; +export { FormFooter } from "./FormFooter"; +export { + applyServerErrors, + SERVER_ERROR, + type ServerErrorBody, + useAppForm, + useLeaveGuard, +} from "./use-app-form"; diff --git a/frontend/src/v1/components/patterns/form/use-app-form.ts b/frontend/src/v1/components/patterns/form/use-app-form.ts new file mode 100644 index 0000000000..b4e34c9556 --- /dev/null +++ b/frontend/src/v1/components/patterns/form/use-app-form.ts @@ -0,0 +1,146 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useCallback, useEffect } from "react"; +import { + type Control, + type DefaultValues, + type FieldValues, + get, + type Path, + type UseFormReturn, + useForm, +} from "react-hook-form"; +import type { z } from "zod"; + +/** + * react-hook-form with the schema as resolver. A field validates when it first + * loses focus, then on every change; after a submit every field validates on + * change. A failed submit focuses the first invalid field. + * + * `defaultValues` are the saved values: the change count and Discard compare + * against them. They are read once, so call `form.reset(saved)` after a + * successful save, and `form.reset(record)` when a record loads after mount. + */ +export function useAppForm({ + schema, + defaultValues, +}: { + schema: z.ZodType; + defaultValues: DefaultValues; +}): UseFormReturn { + return useForm({ + resolver: zodResolver(schema), + defaultValues, + mode: "onTouched", + shouldFocusError: true, + }); +} + +/** The backend's error body (drf_standardized_errors). */ +export type ServerErrorBody = Readonly<{ + type: "validation_error" | "client_error" | "server_error"; + errors: readonly Readonly<{ + code: string; + detail: string; + attr: string | null; + }>[]; +}>; + +/** The form-level error key that applyServerErrors sets; FormFooter shows it. */ +export const SERVER_ERROR = "root.server"; + +const FALLBACK = "Could not save. Try again."; + +/** True when a FormField (or any registered control) for `path` is on screen. */ +export function isMounted( + control: Control, + path: string, +): boolean { + // ponytail: reads react-hook-form's field registry; form.test.tsx pins the behaviour across upgrades. + return get(control._fields, path)?._f?.mount === true; +} + +function isStandardBody(body: unknown): body is ServerErrorBody { + const b = body as ServerErrorBody | null; + return typeof b?.type === "string" && Array.isArray(b.errors); +} + +/** Any other error body: `{detail}` from the middleware, `{error}` from the SDK handler. */ +function otherMessage(body: unknown): string | undefined { + const b = body as Record | null; + return [b?.detail, b?.error, b?.message].find( + (text): text is string => typeof text === "string" && text.trim() !== "", + ); +} + +/** + * Puts each server error under the field its `attr` names (a dotted attr such + * as `rows.0.key` is a react-hook-form path), when that field is on screen, + * and focuses the first such field. + * Every other error (no attr, non_field_errors, an attr with no field shown, or + * a non-validation response) joins one form-level message. A body of any other + * shape, or one with nothing to show, still sets a form-level message. + */ +// ponytail: reads the raw backend body; switch to the shared HTTP client's normalised error when it exists. +export function applyServerErrors( + form: Pick, "control" | "setError">, + body: unknown, +): void { + const byField = new Map(); + const formLevel: string[] = []; + const standard = isStandardBody(body) ? body : undefined; + for (const entry of standard?.errors ?? []) { + const { attr, detail } = entry ?? {}; + if (typeof detail !== "string" || !detail.trim()) { + continue; + } + const onField = + standard?.type === "validation_error" && + typeof attr === "string" && + isMounted(form.control as Control, attr); + if (onField) { + byField.set(attr, [...(byField.get(attr) ?? []), detail]); + } else { + formLevel.push(detail); + } + } + // Focus the first field the server names (in its order): Save disables on + // a field error, so focus left on it would fall to the page body. + [...byField].forEach(([attr, details], index) => { + form.setError( + attr as Path, + { type: "server", message: details.join(" ") }, + { shouldFocus: index === 0 }, + ); + }); + if (byField.size === 0 && formLevel.length === 0) { + formLevel.push(otherMessage(body) ?? FALLBACK); + } + if (formLevel.length > 0) { + form.setError(SERVER_ERROR as Path, { + type: "server", + message: formLevel.join(" "), + }); + } +} + +/** + * While `dirty`, the browser asks before the tab closes. Route changes and + * dialog closes are not caught: the caller awaits the returned function before + * leaving, and it asks `confirm` only while dirty. It resolves true when + * leaving is fine. + */ +// ponytail: no router blocking; v0 mounts a BrowserRouter, which has no blocker. Add useBlocker when the app moves to a data router. +export function useLeaveGuard( + dirty: boolean, + confirm: () => Promise, +): () => Promise { + useEffect(() => { + if (!dirty) { + return; + } + const ask = (event: BeforeUnloadEvent) => event.preventDefault(); + window.addEventListener("beforeunload", ask); + return () => window.removeEventListener("beforeunload", ask); + }, [dirty]); + return useCallback(async () => (dirty ? confirm() : true), [dirty, confirm]); +}