Compare commits

...

6 commits

Author SHA1 Message Date
Codex Review Integration
49923c9a02 test(work-orders): target the Insert WO # placeholder in the SH number e2e
The inline create row now renders the empty WO # cell as a required
"Insert WO # *" field instead of "-", so the spec waited for a cell that
no longer exists.
2026-09-16 22:07:46 -03:00
Alexandre Brandizzi
e6f5199da9
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number 2026-09-16 21:56:20 -03:00
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
5 changed files with 145 additions and 4 deletions

View file

@ -97,8 +97,10 @@ test.describe("inline WO number SH placeholder", () => {
.getByRole("button", { name: /Add WO/ })
.first()
.click();
const inlineRow = page.locator("tr").filter({ has: page.getByText("-", { exact: true }) });
await inlineRow.getByText("-", { exact: true }).first().click();
await page
.getByText(/^Insert WO #/)
.first()
.click();
const draftInput = page.getByLabel("Work order number");
await draftInput.fill("SH00001");
await draftInput.press("Enter");

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.");
});
});