From 6d22d039efcd70f6a5d0c9b4b69c92840b09a8af Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:13:25 -0300 Subject: [PATCH] fix(completion-docs): keep other rejected values out of the title field Only the server's TitleRequired rejection is shown on the Document Title field; any other rejected value is reported in a toast. Procedure names and the extra safety note stop at the server's length limits. --- .../completion-template-procedures-editor.tsx | 8 ++++- .../completion-template-safety-note-field.tsx | 8 ++++- .../completion-template-constants.ts | 4 +++ .../use-completion-template-mutations.ts | 6 +++- .../completion-template-dialog.test.tsx | 31 ++++++++++++++++--- 5 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx b/src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx index 2bcd70b0..d35a2ec4 100644 --- a/src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx +++ b/src/app/(protected)/completion-docs/_components/completion-template-procedures-editor.tsx @@ -1,6 +1,7 @@ import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { Text } from "@/components/ui/text"; import { TextField } from "@/components/ui/text-field"; +import { PROCEDURE_NAME_MAX_LENGTH } from "@/domain/completion-templates/constants/completion-template-constants"; import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema"; import { CompletionTemplateAddLink } from "./completion-template-add-link"; import { CompletionTemplateRemoveButton } from "./completion-template-remove-button"; @@ -25,7 +26,12 @@ function ProcedureFields({ form, index }: ProceduresEditorProps & { index: numbe onBlur={field.onBlur} inputRef={field.ref} placeholder="Procedure name (e.g. Backflow Procedure)" - slotProps={{ htmlInput: { "aria-label": `Procedure ${index + 1} name` } }} + slotProps={{ + htmlInput: { + "aria-label": `Procedure ${index + 1} name`, + maxLength: PROCEDURE_NAME_MAX_LENGTH, + }, + }} /> )} /> diff --git a/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx b/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx index c2c62a62..fad8cac5 100644 --- a/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx +++ b/src/app/(protected)/completion-docs/_components/completion-template-safety-note-field.tsx @@ -1,5 +1,6 @@ import { Controller, useWatch, type UseFormReturn } from "react-hook-form"; import { TextField } from "@/components/ui/text-field"; +import { EXTRA_SAFETY_NOTE_MAX_LENGTH } from "@/domain/completion-templates/constants/completion-template-constants"; import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema"; import { CompletionTemplateAddLink } from "./completion-template-add-link"; import { CompletionTemplateRemoveButton } from "./completion-template-remove-button"; @@ -36,7 +37,12 @@ export function CompletionTemplateSafetyNoteField({ form }: SafetyNoteFieldProps onBlur={field.onBlur} inputRef={field.ref} placeholder="Type safety note…" - slotProps={{ htmlInput: { "aria-label": "Extra safety note" } }} + slotProps={{ + htmlInput: { + "aria-label": "Extra safety note", + maxLength: EXTRA_SAFETY_NOTE_MAX_LENGTH, + }, + }} sx={{ "& .MuiOutlinedInput-root": { height: "auto" } }} /> )} diff --git a/src/domain/completion-templates/constants/completion-template-constants.ts b/src/domain/completion-templates/constants/completion-template-constants.ts index 01e88186..b1f79c66 100644 --- a/src/domain/completion-templates/constants/completion-template-constants.ts +++ b/src/domain/completion-templates/constants/completion-template-constants.ts @@ -1,6 +1,10 @@ /** Filled in on every generated document; shown read-only so authors don't retype them. */ export const AUTO_POPULATED_FIELDS = ["WO#", "Site Code", "Address", "Service Type"] as const; +/** Server limits; the inputs stop there instead of failing the save. */ +export const PROCEDURE_NAME_MAX_LENGTH = 200; +export const EXTRA_SAFETY_NOTE_MAX_LENGTH = 2000; + export const COMPLETION_TEMPLATE_COPY = { pageTitle: "Completion Docs", searchPlaceholder: "Search document title…", diff --git a/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts b/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts index 2e9db1e4..83010329 100644 --- a/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts +++ b/src/domain/completion-templates/use-cases/use-completion-template-mutations.ts @@ -21,7 +21,11 @@ interface UpdateCompletionTemplateVariables { /** The server's blank-title rejection, which the form shows on the title field instead. */ export function isTitleRequiredError(error: Error): boolean { - return isHTTPError(error) && error.response.status === 422; + if (!isHTTPError(error) || error.response.status !== 422) return false; + const body: unknown = error.data; + return ( + typeof body === "object" && body !== null && "code" in body && body.code === "TitleRequired" + ); } function invalidateTemplates(queryClient: QueryClient) { diff --git a/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx index 09348782..f8991612 100644 --- a/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx +++ b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx @@ -56,6 +56,18 @@ function previewHeadings(dialog: HTMLElement) { .map((heading) => heading.textContent); } +/** A 422 as the API layer delivers it: ky's parsed body on `data`, the mapped text on `message`. */ +function unprocessable(body: { code: string; message: string }, message = body.message) { + const error = new HTTPError( + new Response(null, { status: 422 }), + new Request("http://api/workorders/completion-templates"), + {} as never, + ); + error.data = body; + error.message = message; + return error; +} + async function addProcedure(dialog: HTMLElement, index: number, name: string) { await userEvent.click(within(dialog).getByRole("button", { name: "+ Add procedure" })); await userEvent.type(within(dialog).getByLabelText(`Procedure ${index} name`), name); @@ -145,11 +157,7 @@ describe("Add Template", () => { it("shows the server's blank-title rejection on the title field", async () => { api.apiPost.mockRejectedValue( - new HTTPError( - new Response(null, { status: 422 }), - new Request("http://api/workorders/completion-templates"), - {} as never, - ), + unprocessable({ code: "TitleRequired", message: "Document Title is required." }), ); const dialog = await openNewTemplate(); await userEvent.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X"); @@ -159,6 +167,19 @@ describe("Add Template", () => { expect(await within(dialog).findByText("Document Title is required")).toBeInTheDocument(); expect(toast.error).not.toHaveBeenCalled(); }); + + it("reports any other rejected value in a toast and keeps the form open", async () => { + const message = "Procedure name must be 200 characters or fewer."; + api.apiPost.mockRejectedValue(unprocessable({ code: "InvalidValue", message }, message)); + const dialog = await openNewTemplate(); + await userEvent.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X"); + + await userEvent.click(within(dialog).getByRole("button", { name: /Add Template/ })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message)); + expect(within(dialog).queryByText("Document Title is required")).toBeNull(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); }); describe("Edit template", () => {