Compare commits

...

4 commits

Author SHA1 Message Date
Alexandre Brandizzi
ffaa91bb1b
Merge pull request #194 from Sea-Haven-Industries/feat/ab/sh-222-create-error-copy
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy dev content / Deploy shoc-frontend-new-dev through Terraform (push) Waiting to run
SH-222: keep backend validation text out of create error toasts
2026-09-16 21:54:18 -03:00
Alexandre Brandizzi
440a3d0112
Merge branch 'dev' into feat/ab/sh-222-create-error-copy 2026-09-16 20:56:26 -03:00
Adam Moussa
e95a720630
Merge branch 'dev' into feat/ab/sh-222-create-error-copy 2026-09-16 16:46:27 -04:00
Codex Review Integration
3ad07c9e10 fix(work-orders): keep backend validation text out of create error toasts
Board create failures toasted the backend message verbatim, exposing
field names such as siteCode and vendorId from the inline row and the
wizard. Map known codes to product copy and fall back to generic copy
for every other failure.
2026-09-16 11:17:37 -03:00
4 changed files with 141 additions and 2 deletions

View file

@ -0,0 +1,45 @@
import { isHTTPError, isNetworkError, isTimeoutError } from "ky";
import { mapHttpStatusToMessage } from "@/api/api-error";
import { DISPATCH_REQUIRED_MESSAGE } from "@/domain/work-orders/errors/board-patch-errors";
export const BOARD_CREATE_FAILED_MESSAGE = "Unable to create the work order. Please try again.";
const DUPLICATE_WO_NUMBER_MESSAGE =
"This work order number is already assigned to another work order.";
/** Statuses whose mapped copy is fixed product text, never the response body. */
const FIXED_COPY_STATUSES = new Set([401, 403, 404, 500]);
function readErrorCode(data: unknown): string | undefined {
if (typeof data !== "object" || data === null) {
return undefined;
}
const record = data as Record<string, unknown>;
const code = record.code ?? record.Code;
return typeof code === "string" ? code : undefined;
}
/**
* why: board create returns backend validation text ("siteCode is required.",
* "vendorId is required when setting appointment fields.") that must never reach the
* screen. Unknown failures fall back to generic copy, not to `error.message`.
*/
export function boardCreateErrorMessage(error: unknown): string {
if (isHTTPError(error)) {
const code = readErrorCode(error.data);
if (code === "DispatchRequired") {
return DISPATCH_REQUIRED_MESSAGE;
}
if (code === "DuplicateWoNumber") {
return DUPLICATE_WO_NUMBER_MESSAGE;
}
const status = error.response.status;
return FIXED_COPY_STATUSES.has(status)
? mapHttpStatusToMessage(status)
: BOARD_CREATE_FAILED_MESSAGE;
}
if (isNetworkError(error) || isTimeoutError(error)) {
return error.message;
}
return BOARD_CREATE_FAILED_MESSAGE;
}

View file

@ -22,10 +22,12 @@ export class BoardPatchValidationError extends Error {
}
}
export const DISPATCH_REQUIRED_MESSAGE = "Select a vendor company before saving.";
/** why: backend field names must never be exposed through user-facing board toasts. */
export function boardPatchValidationMessage(error: BoardPatchValidationError): string {
if (error.code === "DispatchRequired") {
return "Select a vendor company before saving.";
return DISPATCH_REQUIRED_MESSAGE;
}
return error.message;
}

View file

@ -4,6 +4,8 @@ import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { boardCreateErrorMessage } from "@/domain/work-orders/errors/board-create-errors";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
@ -27,7 +29,7 @@ export function useCreateWorkOrderFromWizard(
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create work order");
toast.error(boardCreateErrorMessage(error));
},
});
}

View file

@ -0,0 +1,90 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { HTTPError } from "ky";
import type { ReactNode } from "react";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeApiRequestError } from "@/api/api-error";
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
import { createTestQueryClient } from "@/test/test-utils";
const createFromBoard = vi.fn();
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: { createFromBoard: (...args: unknown[]) => createFromBoard(...args) },
}));
vi.mock("react-toastify", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
/** Mirrors the ky client: HTTPError with parsed body, message normalized from it. */
function backendError(status: number, body: Record<string, unknown>): HTTPError {
const error = new HTTPError(
new Response(JSON.stringify(body), { status }),
new Request("https://api.example.test/workorders/board"),
{} as never,
);
error.data = body;
return normalizeApiRequestError(error) as HTTPError;
}
function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={createTestQueryClient()}>{children}</QueryClientProvider>;
}
async function createAndGetToast(error: unknown): Promise<unknown> {
createFromBoard.mockRejectedValueOnce(error);
const { result } = renderHook(() => useCreateWorkOrderFromWizard(), { wrapper });
act(() => {
result.current.mutate(EMPTY_WIZARD_DRAFT);
});
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
return vi.mocked(toast.error).mock.calls[0]?.[0];
}
describe("useCreateWorkOrderFromWizard error copy", () => {
beforeEach(() => {
vi.mocked(toast.error).mockClear();
createFromBoard.mockReset();
});
it("never surfaces raw backend validation text from a 400", async () => {
const message = await createAndGetToast(
backendError(400, { status: "Validation Error", message: "siteCode is required." }),
);
expect(message).toBe("Unable to create the work order. Please try again.");
});
it("maps a coded 422 to product copy instead of the backend field name", async () => {
const message = await createAndGetToast(
backendError(422, {
code: "DispatchRequired",
message: "vendorId is required when setting appointment fields.",
}),
);
expect(message).toBe("Select a vendor company before saving.");
});
it("falls back to generic copy for unknown 422 codes", async () => {
const message = await createAndGetToast(
backendError(422, {
code: "InvalidLifecycleStatus",
message: "lifecycleStatus must be Incomplete, Pending, or Scheduled at create.",
}),
);
expect(message).toBe("Unable to create the work order. Please try again.");
});
it("keeps fixed permission copy for a 403", async () => {
const message = await createAndGetToast(
backendError(403, { code: "Forbidden", message: "accountId not permitted" }),
);
expect(message).toBe("You do not have permission to perform this action.");
});
});