mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 19:03:15 +00:00
Merge pull request #194 from Sea-Haven-Industries/feat/ab/sh-222-create-error-copy
Some checks are pending
Some checks are pending
SH-222: keep backend validation text out of create error toasts
This commit is contained in:
commit
ffaa91bb1b
4 changed files with 141 additions and 2 deletions
45
src/domain/work-orders/errors/board-create-errors.ts
Normal file
45
src/domain/work-orders/errors/board-create-errors.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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. */
|
/** why: backend field names must never be exposed through user-facing board toasts. */
|
||||||
export function boardPatchValidationMessage(error: BoardPatchValidationError): string {
|
export function boardPatchValidationMessage(error: BoardPatchValidationError): string {
|
||||||
if (error.code === "DispatchRequired") {
|
if (error.code === "DispatchRequired") {
|
||||||
return "Select a vendor company before saving.";
|
return DISPATCH_REQUIRED_MESSAGE;
|
||||||
}
|
}
|
||||||
return error.message;
|
return error.message;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { toast } from "react-toastify";
|
||||||
|
|
||||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
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 { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
@ -27,7 +29,7 @@ export function useCreateWorkOrderFromWizard(
|
||||||
},
|
},
|
||||||
|
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message || "Failed to create work order");
|
toast.error(boardCreateErrorMessage(error));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue