mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 06:59:01 +00:00
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:
parent
0a64f2dec7
commit
6d22d039ef
5 changed files with 49 additions and 8 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { TextField } from "@/components/ui/text-field";
|
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 type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||||
import { CompletionTemplateAddLink } from "./completion-template-add-link";
|
import { CompletionTemplateAddLink } from "./completion-template-add-link";
|
||||||
import { CompletionTemplateRemoveButton } from "./completion-template-remove-button";
|
import { CompletionTemplateRemoveButton } from "./completion-template-remove-button";
|
||||||
|
|
@ -25,7 +26,12 @@ function ProcedureFields({ form, index }: ProceduresEditorProps & { index: numbe
|
||||||
onBlur={field.onBlur}
|
onBlur={field.onBlur}
|
||||||
inputRef={field.ref}
|
inputRef={field.ref}
|
||||||
placeholder="Procedure name (e.g. Backflow Procedure)"
|
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,
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Controller, useWatch, type UseFormReturn } from "react-hook-form";
|
import { Controller, useWatch, type UseFormReturn } from "react-hook-form";
|
||||||
import { TextField } from "@/components/ui/text-field";
|
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 type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||||
import { CompletionTemplateAddLink } from "./completion-template-add-link";
|
import { CompletionTemplateAddLink } from "./completion-template-add-link";
|
||||||
import { CompletionTemplateRemoveButton } from "./completion-template-remove-button";
|
import { CompletionTemplateRemoveButton } from "./completion-template-remove-button";
|
||||||
|
|
@ -36,7 +37,12 @@ export function CompletionTemplateSafetyNoteField({ form }: SafetyNoteFieldProps
|
||||||
onBlur={field.onBlur}
|
onBlur={field.onBlur}
|
||||||
inputRef={field.ref}
|
inputRef={field.ref}
|
||||||
placeholder="Type safety note…"
|
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" } }}
|
sx={{ "& .MuiOutlinedInput-root": { height: "auto" } }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
/** Filled in on every generated document; shown read-only so authors don't retype them. */
|
/** 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;
|
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 = {
|
export const COMPLETION_TEMPLATE_COPY = {
|
||||||
pageTitle: "Completion Docs",
|
pageTitle: "Completion Docs",
|
||||||
searchPlaceholder: "Search document title…",
|
searchPlaceholder: "Search document title…",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,11 @@ interface UpdateCompletionTemplateVariables {
|
||||||
|
|
||||||
/** The server's blank-title rejection, which the form shows on the title field instead. */
|
/** The server's blank-title rejection, which the form shows on the title field instead. */
|
||||||
export function isTitleRequiredError(error: Error): boolean {
|
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) {
|
function invalidateTemplates(queryClient: QueryClient) {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,18 @@ function previewHeadings(dialog: HTMLElement) {
|
||||||
.map((heading) => heading.textContent);
|
.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) {
|
async function addProcedure(dialog: HTMLElement, index: number, name: string) {
|
||||||
await userEvent.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
|
await userEvent.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
|
||||||
await userEvent.type(within(dialog).getByLabelText(`Procedure ${index} name`), name);
|
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 () => {
|
it("shows the server's blank-title rejection on the title field", async () => {
|
||||||
api.apiPost.mockRejectedValue(
|
api.apiPost.mockRejectedValue(
|
||||||
new HTTPError(
|
unprocessable({ code: "TitleRequired", message: "Document Title is required." }),
|
||||||
new Response(null, { status: 422 }),
|
|
||||||
new Request("http://api/workorders/completion-templates"),
|
|
||||||
{} as never,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const dialog = await openNewTemplate();
|
const dialog = await openNewTemplate();
|
||||||
await userEvent.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X");
|
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(await within(dialog).findByText("Document Title is required")).toBeInTheDocument();
|
||||||
expect(toast.error).not.toHaveBeenCalled();
|
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", () => {
|
describe("Edit template", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue