From f01e7fe0910a2f57b316e9951b86ef833e5b3b83 Mon Sep 17 00:00:00 2001 From: Athul Date: Thu, 5 Nov 2026 13:51:19 +0530 Subject: [PATCH 1/4] UN-4288 [FEAT] Add the v1 form system: schema forms, bound fields, server errors, save footer and leave guard Co-Authored-By: Claude Opus 5.5 --- frontend/bun.lock | 12 +- frontend/package.json | 2 + .../v1/components/patterns/form/FormField.tsx | 35 +++ .../components/patterns/form/FormFooter.tsx | 82 ++++++ .../v1/components/patterns/form/form.test.tsx | 240 ++++++++++++++++++ .../src/v1/components/patterns/form/index.ts | 9 + .../components/patterns/form/use-app-form.ts | 93 +++++++ 7 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 frontend/src/v1/components/patterns/form/FormField.tsx create mode 100644 frontend/src/v1/components/patterns/form/FormFooter.tsx create mode 100644 frontend/src/v1/components/patterns/form/form.test.tsx create mode 100644 frontend/src/v1/components/patterns/form/index.ts create mode 100644 frontend/src/v1/components/patterns/form/use-app-form.ts diff --git a/frontend/bun.lock b/frontend/bun.lock index 8c7974d908..76259dd333 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", @@ -63,6 +64,7 @@ "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", "uuid": "^9.0.1", + "zod": "^4.6.7", "zustand": "^4.3.8", }, "devDependencies": { @@ -252,6 +254,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=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -1404,6 +1408,8 @@ "redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], "remark-gfm": ["remark-gfm@3.0.1", "", { "dependencies": { "@types/mdast": "^3.0.0", "mdast-util-gfm": "^2.0.0", "micromark-extension-gfm": "^2.0.0", "unified": "^10.0.0" } }, "sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig=="], @@ -1606,6 +1612,8 @@ "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], + "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=="], @@ -1632,8 +1640,6 @@ "@reduxjs/toolkit/redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], - "@reduxjs/toolkit/redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], - "@svgr/hast-util-to-babel-ast/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], @@ -1686,6 +1692,8 @@ "recharts/react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + "redux-thunk/redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + "refractor/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/frontend/package.json b/frontend/package.json index ef078f0bb8..5e3954e86d 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", @@ -61,6 +62,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..10c7857e5b --- /dev/null +++ b/frontend/src/v1/components/patterns/form/FormField.tsx @@ -0,0 +1,35 @@ +import { Field, type FieldProps } from "@v1/components/ui/form"; +import type { JSX, ReactNode } 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 }); + 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..a8695e9f0c --- /dev/null +++ b/frontend/src/v1/components/patterns/form/FormFooter.tsx @@ -0,0 +1,82 @@ +import { ErrorText } from "@v1/components/ui/form"; +import type { JSX } from "react"; +import { + type FieldValues, + type UseFormReturn, + useFormState, +} from "react-hook-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; +} + +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 last saved values). */ +// ponytail: plain buttons; swap to the v1 Button when P1-T01 lands. +export function FormFooter({ + form, +}: Readonly<{ form: UseFormReturn }>): JSX.Element { + const { dirtyFields, errors, isSubmitting } = useFormState({ + control: form.control, + }); + const dirty = countDirty(dirtyFields); + const { root, ...fieldErrors } = errors; + const hasFieldErrors = Object.keys(fieldErrors).length > 0; + const text = status(isSubmitting, dirty, hasFieldErrors); + return ( +
+
+ + {text} + + {root?.server?.message ? ( + {root.server.message} + ) : 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..bf988e2367 --- /dev/null +++ b/frontend/src/v1/components/patterns/form/form.test.tsx @@ -0,0 +1,240 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Input } from "@v1/components/ui/form"; +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) { + applyServerErrors(form, error); + } else { + form.reset(values); + } + })} + > + + {(field) => } + + + {(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); + 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(); + }); +}); + +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.getByText("Quota reached Bad owner").getAttribute("role"), + ).toBe("alert"); + }); + + it("maps a dotted attr onto a nested path", () => { + const setError = vi.fn(); + const form = { + getValues: () => ({ rows: [{ key: "k" }] }), + 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", + }); + }); + + it("puts every error of a non-validation response at form level", () => { + const setError = vi.fn(); + const form = { + getValues: () => ({ name: "" }), + 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", + }); + }); +}); + +describe("FormFooter", () => { + it("shows No changes with Save disabled on a clean form", () => { + render(); + screen.getByText("No changes"); + expect( + ( + screen.getByRole("button", { + name: "Save changes", + }) as HTMLButtonElement + ).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( + ( + screen.getByRole("button", { + name: "Save changes", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + }); + + 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"); + 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 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..8376ed56dc --- /dev/null +++ b/frontend/src/v1/components/patterns/form/use-app-form.ts @@ -0,0 +1,93 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useCallback, useEffect } from "react"; +import { + 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: fields validate on blur, and a failed submit focuses the first invalid field. */ +export function useAppForm({ + schema, + defaultValues, +}: { + schema: z.ZodType; + defaultValues: DefaultValues; +}): UseFormReturn { + return useForm({ + resolver: zodResolver(schema), + defaultValues, + mode: "onBlur", + 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"; + +/** + * Puts each server error on the field its `attr` names (a dotted attr such as + * `rows.0.key` is a react-hook-form path). An error with no attr, a + * non_field_errors attr, an attr the form does not hold, or any error of a + * non-validation response becomes one form-level message. + */ +// ponytail: reads the raw backend body; switch to P0-T05's normalised error when it lands. +export function applyServerErrors( + form: Pick, "getValues" | "setError">, + body: ServerErrorBody, +): void { + const values = form.getValues(); + const formLevel: string[] = []; + for (const { attr, detail } of body.errors) { + const onField = + body.type === "validation_error" && + attr !== null && + get(values, attr) !== undefined; + if (onField) { + form.setError(attr as Path, { type: "server", message: detail }); + } else { + formLevel.push(detail); + } + } + if (formLevel.length > 0) { + form.setError(SERVER_ERROR as Path, { + type: "server", + message: formLevel.join(" "), + }); + } +} + +/** + * While `dirty`, the browser asks before the tab closes, and the returned + * function asks `confirm` before a dialog closes or the caller navigates away. + * It resolves true when leaving is fine. + */ +// ponytail: no router blocking; v0 mounts a BrowserRouter, which has no blocker. Add useBlocker when P0-T01 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]); +} From fb26c768a767a94620cda39464f41cd8be2151b1 Mon Sep 17 00:00:00 2001 From: Athul Date: Mon, 9 Nov 2026 21:48:00 +0530 Subject: [PATCH 2/4] UN-4288 [FIX] Show every form error somewhere, re-validate a fixed field, accept any error body - Route server errors by mounted field, not by value: an error for a value no field renders, or a schema refine with no path, now shows in the footer and no longer locks Save. - mode onTouched: a field that failed on blur clears as soon as it is fixed. - Join several server errors on one attr; accept {detail}, {error} and malformed bodies with a fallback message. - Hide the form-level error once the change is reverted. - Docs: reset(saved) after save, defaultValues read once, the leave guard is caller-awaited; no plan task codes in comments. - Tests for each, plus Saving/clean button states; drop unrelated bun.lock churn. Co-Authored-By: Claude Opus 5.5 --- frontend/bun.lock | 8 +- .../components/patterns/form/FormFooter.tsx | 41 +++- .../v1/components/patterns/form/form.test.tsx | 230 ++++++++++++++++-- .../components/patterns/form/use-app-form.ts | 83 +++++-- 4 files changed, 314 insertions(+), 48 deletions(-) diff --git a/frontend/bun.lock b/frontend/bun.lock index bd6d84e635..992d41f25f 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -1371,7 +1371,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msw": ["msw@2.15.0", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ=="], + "msw": ["msw@2.15.0", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "hasInstallScript": true, "bin": { "msw": "cli/index.js" } }, "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ=="], "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], @@ -1517,8 +1517,6 @@ "redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], - "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], - "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], "remark-gfm": ["remark-gfm@3.0.1", "", { "dependencies": { "@types/mdast": "^3.0.0", "mdast-util-gfm": "^2.0.0", "micromark-extension-gfm": "^2.0.0", "unified": "^10.0.0" } }, "sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig=="], @@ -1799,6 +1797,8 @@ "@reduxjs/toolkit/redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + "@reduxjs/toolkit/redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@svgr/hast-util-to-babel-ast/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -1863,8 +1863,6 @@ "recharts/react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], - "redux-thunk/redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], - "refractor/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], diff --git a/frontend/src/v1/components/patterns/form/FormFooter.tsx b/frontend/src/v1/components/patterns/form/FormFooter.tsx index a8695e9f0c..72faa877c7 100644 --- a/frontend/src/v1/components/patterns/form/FormFooter.tsx +++ b/frontend/src/v1/components/patterns/form/FormFooter.tsx @@ -1,10 +1,14 @@ 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 { @@ -17,6 +21,20 @@ function countDirty(node: unknown): number { 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, @@ -37,8 +55,13 @@ function status( 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 last saved values). */ -// ponytail: plain buttons; swap to the v1 Button when P1-T01 lands. +/** + * 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 can clear them. + */ +// ponytail: plain buttons; swap to the v1 Button when it exists. export function FormFooter({ form, }: Readonly<{ form: UseFormReturn }>): JSX.Element { @@ -46,8 +69,14 @@ export function FormFooter({ control: form.control, }); const dirty = countDirty(dirtyFields); - const { root, ...fieldErrors } = errors; - const hasFieldErrors = Object.keys(fieldErrors).length > 0; + 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} - {root?.server?.message ? ( - {root.server.message} + {dirty > 0 && formLevel.length > 0 ? ( + {formLevel.join(" ")} ) : 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(); diff --git a/frontend/src/v1/components/patterns/form/use-app-form.ts b/frontend/src/v1/components/patterns/form/use-app-form.ts index 5cb5bed208..e0ec10643d 100644 --- a/frontend/src/v1/components/patterns/form/use-app-form.ts +++ b/frontend/src/v1/components/patterns/form/use-app-form.ts @@ -66,9 +66,10 @@ function isStandardBody(body: unknown): body is ServerErrorBody { /** Any other error body: `{detail}` from the middleware, `{error}` from the SDK handler. */ function otherMessage(body: unknown): string | undefined { - const b = body as { detail?: unknown; error?: unknown } | null; - const text = b?.detail ?? b?.error; - return typeof text === "string" && text.trim() ? text : undefined; + const b = body as Record | null; + return [b?.detail, b?.error, b?.message].find( + (text): text is string => typeof text === "string" && text.trim() !== "", + ); } /** @@ -86,13 +87,14 @@ export function applyServerErrors( const byField = new Map(); const formLevel: string[] = []; const standard = isStandardBody(body) ? body : undefined; - for (const { attr, detail } of standard?.errors ?? []) { - if (!detail?.trim()) { + for (const entry of standard?.errors ?? []) { + const { attr, detail } = entry ?? {}; + if (typeof detail !== "string" || !detail.trim()) { continue; } const onField = standard?.type === "validation_error" && - attr !== null && + typeof attr === "string" && isMounted(form.control as Control, attr); if (onField) { byField.set(attr, [...(byField.get(attr) ?? []), detail]); From 008f2c9bf7d13190bed0a02dc275515e338f79cf Mon Sep 17 00:00:00 2001 From: Athul Date: Wed, 11 Nov 2026 16:40:30 +0530 Subject: [PATCH 4/4] UN-4288 [FIX] Focus the first field a server error names A server field error disables Save, so focus left on the button fell to the page body (seen in Chrome on the dev namespace). applyServerErrors now focuses the first field it marks, as a failed client-side submit already does. Co-Authored-By: Claude Opus 5.5 --- .../v1/components/patterns/form/form.test.tsx | 25 ++++++++++++++++--- .../components/patterns/form/use-app-form.ts | 18 +++++++------ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/frontend/src/v1/components/patterns/form/form.test.tsx b/frontend/src/v1/components/patterns/form/form.test.tsx index d3ad0d8842..0f7bc6a595 100644 --- a/frontend/src/v1/components/patterns/form/form.test.tsx +++ b/frontend/src/v1/components/patterns/form/form.test.tsx @@ -135,6 +135,22 @@ describe("useAppForm and FormField", () => { 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"); @@ -178,10 +194,11 @@ describe("applyServerErrors", () => { type: "validation_error", errors: [{ code: "x", detail: "Bad key", attr: "rows.0.key" }], }); - expect(setError).toHaveBeenCalledWith("rows.0.key", { - type: "server", - message: "Bad 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", () => { diff --git a/frontend/src/v1/components/patterns/form/use-app-form.ts b/frontend/src/v1/components/patterns/form/use-app-form.ts index e0ec10643d..b4e34c9556 100644 --- a/frontend/src/v1/components/patterns/form/use-app-form.ts +++ b/frontend/src/v1/components/patterns/form/use-app-form.ts @@ -74,7 +74,8 @@ function otherMessage(body: unknown): string | undefined { /** * 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. + * 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. @@ -102,12 +103,15 @@ export function applyServerErrors( formLevel.push(detail); } } - for (const [attr, details] of byField) { - form.setError(attr as Path, { - type: "server", - message: details.join(" "), - }); - } + // 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); }