diff --git a/src/domain/work-orders/errors/board-patch-errors.ts b/src/domain/work-orders/errors/board-patch-errors.ts index 2335502b..1dc215f5 100644 --- a/src/domain/work-orders/errors/board-patch-errors.ts +++ b/src/domain/work-orders/errors/board-patch-errors.ts @@ -1,3 +1,5 @@ +import { isNetworkError, isTimeoutError } from "ky"; +import { ApiError, mapHttpStatusToMessage } from "@/api/api-error"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; export class BoardPatchConflictError extends Error { @@ -41,3 +43,35 @@ const KNOWN_VALIDATION_COPY: ReadonlyMap = new Map([ export function boardPatchValidationMessage(error: BoardPatchValidationError): string { return KNOWN_VALIDATION_COPY.get(error.code) ?? BOARD_PATCH_FAILED_MESSAGE; } + +export const PARTIAL_SAVE_PREFIX = "Some changes were saved, but the rest were not."; + +/** Statuses whose mapped copy is fixed product text, never the response body. */ +const FIXED_COPY_STATUSES = new Set([401, 403, 404, 500]); + +/** why: any board edit failure toast must use product copy, never backend text or field keys. */ +export function boardPatchErrorMessage(error: unknown): string { + if (error instanceof BoardPatchValidationError) { + return boardPatchValidationMessage(error); + } + if (error instanceof ApiError && FIXED_COPY_STATUSES.has(error.status)) { + return mapHttpStatusToMessage(error.status); + } + if (isNetworkError(error) || isTimeoutError(error)) { + return error.message; + } + return BOARD_PATCH_FAILED_MESSAGE; +} + +/** Thrown when a multi-field board edit fails after some fields already persisted. */ +export class BoardPatchPartialSaveError extends Error { + readonly partialRow: WorkOrderTableRow; + readonly failure: unknown; + + constructor(partialRow: WorkOrderTableRow, failure: unknown) { + super(`${PARTIAL_SAVE_PREFIX} ${boardPatchErrorMessage(failure)}`); + this.name = "BoardPatchPartialSaveError"; + this.partialRow = partialRow; + this.failure = failure; + } +} diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index 0b17b328..ce60ca81 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -3,8 +3,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "react-toastify"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import { - boardPatchValidationMessage, + boardPatchErrorMessage, BoardPatchConflictError, + BoardPatchPartialSaveError, BoardPatchValidationError, NO_PERSISTABLE_FIELDS_MESSAGE, } from "@/domain/work-orders/errors/board-patch-errors"; @@ -75,14 +76,7 @@ async function applyBoardFieldOps( completedOps += 1; } catch (error) { if (completedOps > 0) { - const field = op.field; - const partial = new Error( - `Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${ - error instanceof Error ? error.message : "Unknown error" - }`, - ); - (partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest; - throw partial; + throw new BoardPatchPartialSaveError(latest, error); } throw error; } @@ -153,9 +147,8 @@ export function useWorkOrderTableMutations( invalidate(); }, onError: (error: Error, { id }) => { - const partialRow = (error as Error & { partialRow?: WorkOrderTableRow }).partialRow; - if (partialRow) { - onPatch(id, partialRow); + if (error instanceof BoardPatchPartialSaveError) { + onPatch(id, error.partialRow); toast.error(error.message); invalidate(); return; @@ -164,12 +157,9 @@ export function useWorkOrderTableMutations( if (error instanceof BoardPatchConflictError && error.currentState) { onPatch(id, error.currentState); toast.error(error.message || "Conflict — refreshed with latest data"); - } else if (error instanceof BoardPatchValidationError) { - clearPatch(id); - toast.error(boardPatchValidationMessage(error)); } else { clearPatch(id); - toast.error(error.message || "Failed to update work order"); + toast.error(boardPatchErrorMessage(error)); } invalidate(); }, diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-errors.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-errors.test.tsx index b9664f4b..3dee6976 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-errors.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-errors.test.tsx @@ -6,8 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BOARD_PATCH_FAILED_MESSAGE, DISPATCH_REQUIRED_MESSAGE, + PARTIAL_SAVE_PREFIX, } from "@/domain/work-orders/errors/board-patch-errors"; -import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { + WorkOrderTablePatch, + WorkOrderTableRow, +} from "@/domain/work-orders/types/work-order-table-row"; import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; import { createTestQueryClient } from "@/test/test-utils"; @@ -21,32 +25,39 @@ function wrapper({ children }: { children: ReactNode }) { return {children}; } -/** Board PATCH goes through the real ky client; only the network response is faked. */ -function respondWith(status: number, body: Record) { - globalThis.fetch = vi.fn( - async () => - new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }), - ) as typeof fetch; +type FakeResponse = { status: number; body: Record }; + +/** Board PATCH goes through the real ky client; only the network responses are faked. */ +function respondWith(...responses: FakeResponse[]) { + const queue = [...responses]; + globalThis.fetch = vi.fn(async () => { + const next = queue.shift() ?? responses[responses.length - 1]; + return new Response(JSON.stringify(next.body), { + status: next.status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; } -async function patchAndGetToast(): Promise { +async function patchAndGetToast( + patch: WorkOrderTablePatch = { woNumber: "WO-100" }, + expectedCalls = 1, + onPatch: (id: string | number, patch: WorkOrderTablePatch) => void = () => undefined, +): Promise { const { result } = renderHook( () => useWorkOrderTableMutations({ - onPatch: () => undefined, + onPatch, clearPatch: () => undefined, getRow: () => ROW, }), { wrapper }, ); act(() => { - result.current.patchField(7, { woNumber: "WO-100" }); + result.current.patchField(7, patch); }); await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); - expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledTimes(expectedCalls); return vi.mocked(toast.error).mock.calls[0]?.[0]; } @@ -62,9 +73,12 @@ describe("useWorkOrderTableMutations edit error copy", () => { }); it("shows generic copy instead of backend text for an unknown 422 code", async () => { - respondWith(422, { - code: "InvalidLifecycleStatus", - message: "lifecycleStatus cannot move from Completed for workOrderId 7.", + respondWith({ + status: 422, + body: { + code: "InvalidLifecycleStatus", + message: "lifecycleStatus cannot move from Completed for workOrderId 7.", + }, }); const message = await patchAndGetToast(); @@ -74,13 +88,58 @@ describe("useWorkOrderTableMutations edit error copy", () => { }); it("keeps specific copy for DispatchRequired", async () => { - respondWith(422, { - code: "DispatchRequired", - message: "vendorId is required when setting appointment fields.", + respondWith({ + status: 422, + body: { + code: "DispatchRequired", + message: "vendorId is required when setting appointment fields.", + }, }); const message = await patchAndGetToast(); expect(message).toBe(DISPATCH_REQUIRED_MESSAGE); }); + + it("shows generic copy instead of backend text for a non-422 failure", async () => { + respondWith({ status: 400, body: { message: "siteCode is required for accountId 42." } }); + + const message = await patchAndGetToast(); + + expect(message).toBe(BOARD_PATCH_FAILED_MESSAGE); + }); + + it("reports a partial save in plain language without backend field keys", async () => { + const onPatch = vi.fn(); + respondWith( + { status: 200, body: { id: 7, workOrderNumber: "WO-100", rowVersion: "v2" } }, + { + status: 422, + body: { + code: "SeverityInvalid", + message: "severity 3 is not allowed for workOrderType Emergency.", + }, + }, + ); + + const message = await patchAndGetToast({ woNumber: "WO-100", severity: 3 }, 2, onPatch); + + expect(message).toBe(`${PARTIAL_SAVE_PREFIX} ${BOARD_PATCH_FAILED_MESSAGE}`); + expect(message).not.toMatch(/severity|woNumber|workOrderType|SeverityInvalid|\d\/\d/); + expect(onPatch).toHaveBeenCalledWith(7, expect.objectContaining({ rowVersion: "v2" })); + }); + + it("keeps DispatchRequired copy inside a partial save", async () => { + respondWith( + { status: 200, body: { id: 7, workOrderNumber: "WO-100", rowVersion: "v2" } }, + { + status: 422, + body: { code: "DispatchRequired", message: "vendorId is required." }, + }, + ); + + const message = await patchAndGetToast({ woNumber: "WO-100", severity: 2 }, 2); + + expect(message).toBe(`${PARTIAL_SAVE_PREFIX} ${DISPATCH_REQUIRED_MESSAGE}`); + }); });