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.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 11:13:25 -03:00
parent 0a64f2dec7
commit 6d22d039ef
5 changed files with 49 additions and 8 deletions

View file

@ -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,
},
}}
/>
)}
/>

View file

@ -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" } }}
/>
)}

View file

@ -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…",

View file

@ -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) {

View file

@ -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", () => {