diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts index 4d9be588..4fe4a6b1 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts @@ -7,7 +7,7 @@ import { buildSlideOverPatch, isSlideOverDraftDirty, } from "@/domain/work-orders/utils/slide-over-draft"; -import { notifyVendorAssignedIfPatched } from "@/domain/work-orders/utils/work-order-feedback-toasts"; +import { notifySlideOverSaveSuccess } from "@/domain/work-orders/utils/work-order-feedback-toasts"; import { isWorkOrderCoreLocked, isWorkOrderFullyLocked, @@ -117,7 +117,7 @@ export function useSlideOverEditState({ } onSave(row.id, patch, { onSuccess: () => { - notifyVendorAssignedIfPatched(patch); + notifySlideOverSaveSuccess(patch); setBaseline(activeDraft); setEditing(false); setShowUnsaved(false); 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 9b27e18b..713ccd84 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 @@ -159,12 +159,9 @@ export function useWorkOrderTableMutations( }); const patchField: WorkOrderTablePatchFn = (id, patch, options) => { - patchMutation.mutate( - { id, patch }, - { - onSuccess: () => options?.onSuccess?.(), - onError: (error) => options?.onError?.(error), - }, + void patchMutation.mutateAsync({ id, patch }).then( + () => options?.onSuccess?.(), + (error: Error) => options?.onError?.(error), ); }; diff --git a/src/domain/work-orders/utils/work-order-feedback-toasts.ts b/src/domain/work-orders/utils/work-order-feedback-toasts.ts index 9477b24b..95f202bc 100644 --- a/src/domain/work-orders/utils/work-order-feedback-toasts.ts +++ b/src/domain/work-orders/utils/work-order-feedback-toasts.ts @@ -16,14 +16,14 @@ export function workOrderCanceledMessage(woNumber: string) { return `Work order #${woNumber} canceled.`; } -/** - * AAP Work Orders board has no vendor-assign toast (bundle audit 2026-08-26). - * SH-119 names that silence as the gap to close; this copy is WO-specific. - */ export function vendorAssignedMessage() { return "Vendor assigned"; } +export function workOrderSavedMessage() { + return "Work order saved successfully"; +} + export function notifyWorkOrderCanceled(woNumber: string) { toast.warning(workOrderCanceledMessage(woNumber)); } @@ -32,12 +32,26 @@ export function notifyVendorAssigned() { toast.success(vendorAssignedMessage()); } -export function isVendorAssignmentPatch(patch: WorkOrderTablePatch): boolean { - return Object.prototype.hasOwnProperty.call(patch, "vendorId"); +export function notifyWorkOrderSaved() { + toast.success(workOrderSavedMessage()); } -export function notifyVendorAssignedIfPatched(patch: WorkOrderTablePatch) { - if (isVendorAssignmentPatch(patch)) notifyVendorAssigned(); +function isAssignedVendorId(vendorId: unknown): boolean { + return vendorId != null && String(vendorId).trim() !== ""; +} + +export function isVendorAssignmentPatch(patch: WorkOrderTablePatch): boolean { + return ( + Object.prototype.hasOwnProperty.call(patch, "vendorId") && isAssignedVendorId(patch.vendorId) + ); +} + +export function notifySlideOverSaveSuccess(patch: WorkOrderTablePatch) { + if (isVendorAssignmentPatch(patch)) { + notifyVendorAssigned(); + return; + } + notifyWorkOrderSaved(); } export function patchWorkOrderAsCanceled( @@ -53,7 +67,6 @@ export function patchWorkOrderAsCanceled( ); } -/** AAP completes via document generate toast, not a dedicated Complete WO toast. */ export function patchWorkOrderAsCompleted(patchField: PersistPatchFn, id: string | number) { patchField(id, { status: "Completed" }); } @@ -63,5 +76,9 @@ export function applyVendorTableSave( id: string | number, fields: VendorAssignmentFields, ) { - onPatch(id, toVendorTablePatch(fields), { onSuccess: notifyVendorAssigned }); + onPatch( + id, + toVendorTablePatch(fields), + isAssignedVendorId(fields.vendorId) ? { onSuccess: notifyVendorAssigned } : undefined, + ); } diff --git a/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx index 29279245..1245a22f 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-edit-state-completed.test.tsx @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { toast } from "react-toastify"; import { useSlideOverEditState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-edit-state"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import { vendorAssignedMessage } from "@/domain/work-orders/utils/work-order-feedback-toasts"; +import { + vendorAssignedMessage, + workOrderSavedMessage, +} from "@/domain/work-orders/utils/work-order-feedback-toasts"; vi.mock("react-toastify", () => ({ toast: { @@ -138,9 +141,10 @@ describe("useSlideOverEditState completed lock", () => { options.onSuccess(); }); expect(toast.success).toHaveBeenCalledWith(vendorAssignedMessage()); + expect(toast.success).toHaveBeenCalledTimes(1); }); - it("does not toast on slide-over save when vendorId did not change", () => { + it("toasts generic save after a successful non-vendor slide-over patch", () => { const onSave = vi.fn(); const open = baseRow(); @@ -167,6 +171,7 @@ describe("useSlideOverEditState completed lock", () => { act(() => { options.onSuccess(); }); - expect(toast.success).not.toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(workOrderSavedMessage()); + expect(toast.success).not.toHaveBeenCalledWith(vendorAssignedMessage()); }); }); diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-page-sh-119.test.ts b/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts similarity index 98% rename from src/test/app/(protected)/workorders/use-work-orders-list-page-sh-119.test.ts rename to src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts index 08973f0c..73007eea 100644 --- a/src/test/app/(protected)/workorders/use-work-orders-list-page-sh-119.test.ts +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts @@ -77,7 +77,7 @@ import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-w const row = { id: 77, woNumber: "WO-77" } as WorkOrderTableRow; -describe("useWorkOrdersListPage SH-119 toasts", () => { +describe("useWorkOrdersListPage cancel toast", () => { beforeEach(() => { vi.clearAllMocks(); }); diff --git a/src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx b/src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx index 77d5801e..7b4653cc 100644 --- a/src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx @@ -42,7 +42,7 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", const row = { id: 7, upliftSummary: { hasUplift: false, pendingCount: 0 } } as WorkOrderTableRow; -describe("WoTableRowServiceCells vendor persist (SH-119)", () => { +describe("WoTableRowServiceCells vendor persist", () => { it("forwards vendor save through patch onSuccess instead of onPatchRow", () => { const onPatch = vi.fn(); const onPatchRow = vi.fn(); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index 7cb81232..68b694fe 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -485,3 +485,55 @@ describe("useWorkOrderTableMutations vendor assignment round-trip", () => { expect(getClosabilityGaps(tableRowToClosabilityInput(returnedRow))).toContain("Company"); }); }); + +describe("useWorkOrderTableMutations patchField success callbacks", () => { + beforeEach(() => { + getById.mockReset(); + update.mockReset(); + patchBoardField.mockReset(); + }); + + it("invokes each onSuccess when two work orders overlap", async () => { + const rows = new Map([ + ["1", { ...BASE_ROW, id: 1, rowVersion: "v1" }], + ["2", { ...BASE_ROW, id: 2, rowVersion: "v2" }], + ]); + + let releaseFirst: () => void = () => undefined; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + patchBoardField.mockImplementation(async (id: unknown) => { + if (String(id) === "1") await firstGate; + const current = rows.get(String(id)); + if (!current) throw new Error("missing row"); + const next = { ...current, rowVersion: `${current.rowVersion}-n` }; + rows.set(String(id), next); + return next; + }); + + const onSuccessFirst = vi.fn(); + const onSuccessSecond = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: () => undefined, + clearPatch: () => undefined, + getRow: (id) => rows.get(String(id)), + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(1, { dispatcherId: "d2" }, { onSuccess: onSuccessFirst }); + result.current.patchField(2, { dispatcherId: "d3" }, { onSuccess: onSuccessSecond }); + }); + + await waitFor(() => expect(onSuccessSecond).toHaveBeenCalledTimes(1)); + expect(onSuccessFirst).not.toHaveBeenCalled(); + + releaseFirst(); + await waitFor(() => expect(onSuccessFirst).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts b/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts index 80623aa2..ea1b7a5f 100644 --- a/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts +++ b/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts @@ -4,12 +4,13 @@ import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment import { applyVendorTableSave, isVendorAssignmentPatch, + notifySlideOverSaveSuccess, notifyVendorAssigned, - notifyVendorAssignedIfPatched, patchWorkOrderAsCanceled, patchWorkOrderAsCompleted, vendorAssignedMessage, workOrderCanceledMessage, + workOrderSavedMessage, } from "@/domain/work-orders/utils/work-order-feedback-toasts"; vi.mock("react-toastify", () => ({ @@ -20,12 +21,12 @@ vi.mock("react-toastify", () => ({ }, })); -describe("work-order-feedback-toasts (SH-119)", () => { +describe("work-order-feedback-toasts", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("builds the AAP cancel copy with the work order number", () => { + it("builds the cancel copy with the work order number", () => { expect(workOrderCanceledMessage("20260819001")).toBe("Work order #20260819001 canceled."); }); @@ -40,20 +41,13 @@ describe("work-order-feedback-toasts (SH-119)", () => { ); expect(toast.warning).not.toHaveBeenCalled(); - const { onSuccess } = patchField.mock.calls[0][2] as { onSuccess: () => void }; - onSuccess(); + const options = patchField.mock.calls[0][2] as { onSuccess: () => void; onError?: unknown }; + expect(options.onError).toBeUndefined(); + options.onSuccess(); expect(toast.warning).toHaveBeenCalledWith("Work order #20260819001 canceled."); expect(toast.success).not.toHaveBeenCalled(); }); - it("does not warn when the cancel patch reports onError", () => { - const patchField = vi.fn(); - patchWorkOrderAsCanceled(patchField, { id: 42, woNumber: "20260819001" }); - const options = patchField.mock.calls[0][2] as { onSuccess?: () => void; onError?: () => void }; - options.onError?.(new Error("conflict")); - expect(toast.warning).not.toHaveBeenCalled(); - }); - it("toasts vendor assignment only after persist onSuccess", () => { const onPatch = vi.fn(); const fields = { @@ -77,18 +71,55 @@ describe("work-order-feedback-toasts (SH-119)", () => { expect(toast.warning).not.toHaveBeenCalled(); }); - it("toasts vendor assignment when a slide-over patch includes vendorId", () => { - notifyVendorAssignedIfPatched({ vendorId: "9", dispatcherId: "d1" }); - expect(toast.success).toHaveBeenCalledWith(vendorAssignedMessage()); - }); + it("persists an empty vendor without an assigned toast", () => { + const onPatch = vi.fn(); + const fields = { + vendorId: "", + company: "", + tech: "", + techPhone: "", + }; + applyVendorTableSave(onPatch, 7, fields); - it("does not toast vendor assignment for non-vendor slide-over patches", () => { - expect(isVendorAssignmentPatch({ dispatcherId: "d1" })).toBe(false); - notifyVendorAssignedIfPatched({ dispatcherId: "d1" }); + expect(onPatch).toHaveBeenCalledWith(7, toVendorTablePatch(fields), undefined); expect(toast.success).not.toHaveBeenCalled(); }); - it("completes a work order without a success toast (AAP has no Complete WO toast)", () => { + it("persists a whitespace vendorId without an assigned toast", () => { + const onPatch = vi.fn(); + const fields = { + vendorId: " ", + company: "Gateway", + tech: "Pat", + techPhone: "555", + }; + applyVendorTableSave(onPatch, 7, fields); + + expect(onPatch).toHaveBeenCalledWith(7, toVendorTablePatch(fields), undefined); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("toasts vendor assignment when a slide-over patch includes a vendor id", () => { + notifySlideOverSaveSuccess({ vendorId: "9", dispatcherId: "d1" }); + expect(toast.success).toHaveBeenCalledTimes(1); + expect(toast.success).toHaveBeenCalledWith(vendorAssignedMessage()); + }); + + it("toasts generic save when a slide-over patch has no assigned vendor", () => { + expect(isVendorAssignmentPatch({ dispatcherId: "d1" })).toBe(false); + notifySlideOverSaveSuccess({ dispatcherId: "d1" }); + expect(toast.success).toHaveBeenCalledTimes(1); + expect(toast.success).toHaveBeenCalledWith(workOrderSavedMessage()); + }); + + it("does not treat an empty vendorId as an assignment", () => { + expect(isVendorAssignmentPatch({ vendorId: "" })).toBe(false); + notifySlideOverSaveSuccess({ vendorId: "", dispatcherId: "d1" }); + expect(toast.success).toHaveBeenCalledWith(workOrderSavedMessage()); + expect(toast.success).not.toHaveBeenCalledWith(vendorAssignedMessage()); + }); + + it("completes a work order without a success toast", () => { const patchField = vi.fn(); patchWorkOrderAsCompleted(patchField, 42); expect(patchField).toHaveBeenCalledWith(42, { status: "Completed" });