From 5a0e907067d50f3003407343da5c3b5ded1fd714 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 26 Aug 2026 18:42:56 -0300 Subject: [PATCH] feat(work-orders): add consistent save/delete toast feedback Cancel WO and vendor assignment toast after a successful board PATCH. --- .../detail/use-slide-over-edit-state.ts | 2 + .../list/table/use-wo-table-row-handlers.ts | 16 +-- .../list/table/wo-table-row-service-cells.tsx | 4 +- .../_components/list/table/wo-table-row.tsx | 11 +- .../_components/list/table/wo-table.tsx | 8 +- .../_hooks/use-work-orders-list-page.ts | 8 +- .../use-work-order-table-mutations.ts | 21 +-- .../utils/work-order-feedback-toasts.ts | 67 ++++++++++ ...e-slide-over-edit-state-completed.test.tsx | 78 ++++++++++- ...se-wo-table-row-handlers-duplicate.test.ts | 21 +++ .../use-work-orders-list-page-sh-119.test.ts | 122 ++++++++++++++++++ ...le-row-service-cells-vendor-toast.test.tsx | 82 ++++++++++++ .../use-work-order-table-mutations.test.tsx | 4 +- .../utils/work-order-feedback-toasts.test.ts | 98 ++++++++++++++ 14 files changed, 512 insertions(+), 30 deletions(-) create mode 100644 src/domain/work-orders/utils/work-order-feedback-toasts.ts create mode 100644 src/test/app/(protected)/workorders/use-work-orders-list-page-sh-119.test.ts create mode 100644 src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx create mode 100644 src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts 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 e8b1b8b9..4d9be588 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,6 +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 { isWorkOrderCoreLocked, isWorkOrderFullyLocked, @@ -116,6 +117,7 @@ export function useSlideOverEditState({ } onSave(row.id, patch, { onSuccess: () => { + notifyVendorAssignedIfPatched(patch); setBaseline(activeDraft); setEditing(false); setShowUnsaved(false); diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts index 54d56554..20efccf6 100644 --- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts @@ -1,9 +1,9 @@ import { useMemo } from "react"; -import type { - WorkOrderTablePatch, - WorkOrderTableRow, -} from "@/domain/work-orders/types/work-order-table-row"; -import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + applySchedulePatch, + type WorkOrderTablePatchFn, +} from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify"; @@ -19,7 +19,7 @@ type UseWoTableRowHandlersOptions = { onOpenCompDoc?: (row: WorkOrderTableRow) => void; onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void; - onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; + onPatch: WorkOrderTablePatchFn; onScheduleHighlight: (id: string | number | null) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; }; @@ -76,8 +76,8 @@ export function useWoTableRowHandlers({ tableData.setSelectedId(row.id); onOpenMedia(row); }, - onPatch: (id: string | number, patch: Partial) => { - onPatch(id, patch); + onPatch: (id, patch, options) => { + onPatch(id, patch, options); }, onScheduleDate: (id: string | number, date: string) => { onPatch(id, applySchedulePatch(date, false)); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx index 3e41ccf9..bb6d8d6c 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells.tsx @@ -10,7 +10,7 @@ import { UpliftCell } from "./cells/uplift-cell"; import { VendorCell } from "./cells/vendor-cell"; import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils"; -import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; +import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts"; import type { WoTableRowHandlers } from "./wo-table-row"; type WoTableRowServiceCellsProps = { @@ -77,7 +77,7 @@ export function WoTableRowServiceCells({ vendors={vendors} q={search} viewOnly={viewOnly} - onSave={(p) => onPatchRow(toVendorTablePatch(p))} + onSave={(p) => applyVendorTableSave(handlers.onPatch, row.id, p)} /> void; onOpenUplifts?: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void; - onPatch: (id: string | number, patch: Partial) => void; + onPatch: ( + id: string | number, + patch: Partial, + options?: WorkOrderTablePatchOptions, + ) => void; onScheduleDate: (id: string | number, date: string) => void; onScheduleWeek: (id: string | number, week: string) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void; @@ -84,9 +89,9 @@ export function WoTableRow({ const guardedHandlers: WoTableRowHandlers = { ...handlers, - onPatch: (id, patch) => { + onPatch: (id, patch, options) => { if (!canApplyWorkOrderTablePatch(row.status, patch)) return; - handlers.onPatch(id, patch); + handlers.onPatch(id, patch, options); }, onScheduleDate: (id, date) => { if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index 8af5b8b7..226dab4a 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -5,10 +5,8 @@ import type { VendorDropdownItem, WorkOrderUser, } from "@/domain/work-orders/types/work-order"; -import type { - WorkOrderTablePatch, - WorkOrderTableRow, -} from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderTablePatchFn } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog"; import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton"; import { WoDayPaginationFooter } from "./wo-day-pagination-footer"; @@ -39,7 +37,7 @@ type WoTableProps = { onOpenMedia: (row: WorkOrderTableRow) => void; onClearSearch?: () => void; onOpenAdvancedFilters?: () => void; - onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; + onPatch: WorkOrderTablePatchFn; }; type WoTableEmptyShellProps = { diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 4ddf763e..266d84e3 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -8,6 +8,10 @@ import { } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list"; import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { + patchWorkOrderAsCanceled, + patchWorkOrderAsCompleted, +} from "@/domain/work-orders/utils/work-order-feedback-toasts"; import { guardDialogClose } from "@/lib/dialog-guard"; import { useWorkOrdersListFilters } from "./use-work-orders-list-filters"; import { useWorkOrdersTableData } from "./use-work-orders-table-data"; @@ -82,14 +86,14 @@ export function useWorkOrdersListPage() { const handleConfirmCancel = () => { if (!confirmCancel) return; - patchField(confirmCancel.id, { status: "Canceled" }); + patchWorkOrderAsCanceled(patchField, confirmCancel); setConfirmCancel(null); handleCloseSlideOver(); }; const handleConfirmComplete = () => { if (!confirmComplete) return; - patchField(confirmComplete.id, { status: "Completed" }); + patchWorkOrderAsCompleted(patchField, confirmComplete.id); setConfirmComplete(null); }; 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 a15abc76..9b27e18b 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 @@ -24,6 +24,17 @@ import { queryKeys } from "@/infra/query-key/query-key"; export type { WorkOrderTablePatch }; +export type WorkOrderTablePatchOptions = { + onSuccess?: () => void; + onError?: (error: Error) => void; +}; + +export type WorkOrderTablePatchFn = ( + id: string | number, + patch: WorkOrderTablePatch, + options?: WorkOrderTablePatchOptions, +) => void; + export type WorkOrderTablePatchHandlers = { onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; clearPatch: (id: string | number) => void; @@ -112,6 +123,7 @@ export function useWorkOrderTableMutations( }; const patchMutation = useMutation({ + meta: { suppressErrorToast: true }, mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)), onMutate: async ({ id, patch }) => { @@ -146,14 +158,7 @@ export function useWorkOrderTableMutations( }, }); - const patchField = ( - id: string | number, - patch: WorkOrderTablePatch, - options?: { - onSuccess?: () => void; - onError?: (error: Error) => void; - }, - ) => { + const patchField: WorkOrderTablePatchFn = (id, patch, options) => { patchMutation.mutate( { id, patch }, { diff --git a/src/domain/work-orders/utils/work-order-feedback-toasts.ts b/src/domain/work-orders/utils/work-order-feedback-toasts.ts new file mode 100644 index 00000000..9477b24b --- /dev/null +++ b/src/domain/work-orders/utils/work-order-feedback-toasts.ts @@ -0,0 +1,67 @@ +import { toast } from "react-toastify"; +import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row"; +import type { VendorAssignmentFields } from "@/domain/work-orders/utils/vendor-assignment-patch"; +import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; + +type PersistPatchFn = ( + id: string | number, + patch: WorkOrderTablePatch, + options?: { + onSuccess?: () => void; + onError?: (error: Error) => void; + }, +) => void; + +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 notifyWorkOrderCanceled(woNumber: string) { + toast.warning(workOrderCanceledMessage(woNumber)); +} + +export function notifyVendorAssigned() { + toast.success(vendorAssignedMessage()); +} + +export function isVendorAssignmentPatch(patch: WorkOrderTablePatch): boolean { + return Object.prototype.hasOwnProperty.call(patch, "vendorId"); +} + +export function notifyVendorAssignedIfPatched(patch: WorkOrderTablePatch) { + if (isVendorAssignmentPatch(patch)) notifyVendorAssigned(); +} + +export function patchWorkOrderAsCanceled( + patchField: PersistPatchFn, + row: { id: string | number; woNumber: string }, +) { + patchField( + row.id, + { status: "Canceled" }, + { + onSuccess: () => notifyWorkOrderCanceled(row.woNumber), + }, + ); +} + +/** AAP completes via document generate toast, not a dedicated Complete WO toast. */ +export function patchWorkOrderAsCompleted(patchField: PersistPatchFn, id: string | number) { + patchField(id, { status: "Completed" }); +} + +export function applyVendorTableSave( + onPatch: PersistPatchFn, + id: string | number, + fields: VendorAssignmentFields, +) { + onPatch(id, toVendorTablePatch(fields), { onSuccess: notifyVendorAssigned }); +} 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 bde4cad9..29279245 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 @@ -1,7 +1,17 @@ import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +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"; + +vi.mock("react-toastify", () => ({ + toast: { + warning: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})); function baseRow(overrides: Partial = {}): WorkOrderTableRow { return { @@ -56,6 +66,10 @@ function baseRow(overrides: Partial = {}): WorkOrderTableRow } describe("useSlideOverEditState completed lock", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("leaves edit mode and refuses commit when the same work order becomes completed", () => { const onSave = vi.fn(); const onClose = vi.fn(); @@ -93,4 +107,66 @@ describe("useSlideOverEditState completed lock", () => { expect(onSave).not.toHaveBeenCalled(); }); + + it("toasts vendor assigned after a successful save that includes vendorId", () => { + const onSave = vi.fn(); + const open = baseRow(); + + const { result } = renderHook(() => + useSlideOverEditState({ + row: open, + editMode: true, + infoSource: open, + onSave, + onClose: vi.fn(), + closeDisabled: false, + setTab: vi.fn(), + }), + ); + + act(() => { + result.current.handleDraftChange({ vendorId: "v2", company: "New Co", tech: "Lee" }); + }); + act(() => { + result.current.commit(); + }); + + expect(onSave).toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + const options = onSave.mock.calls[0][2] as { onSuccess: () => void }; + act(() => { + options.onSuccess(); + }); + expect(toast.success).toHaveBeenCalledWith(vendorAssignedMessage()); + }); + + it("does not toast on slide-over save when vendorId did not change", () => { + const onSave = vi.fn(); + const open = baseRow(); + + const { result } = renderHook(() => + useSlideOverEditState({ + row: open, + editMode: true, + infoSource: open, + onSave, + onClose: vi.fn(), + closeDisabled: false, + setTab: vi.fn(), + }), + ); + + act(() => { + result.current.handleDraftChange({ dispatcherName: "Blair" }); + }); + act(() => { + result.current.commit(); + }); + + const options = onSave.mock.calls[0][2] as { onSuccess: () => void }; + act(() => { + options.onSuccess(); + }); + expect(toast.success).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts index daad10bd..78f3911c 100644 --- a/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts +++ b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts @@ -41,4 +41,25 @@ describe("useWoTableRowHandlers onDuplicateFound", () => { expect(onDuplicateFound).toHaveBeenCalledWith(existingRow); expect(onOpenSlideOver).not.toHaveBeenCalled(); }); + + it("forwards patch options so vendor persist can toast on success", () => { + const onPatch = vi.fn(); + const onSuccess = vi.fn(); + + const { result } = renderHook(() => + useWoTableRowHandlers({ + tableData: buildTableData(), + weekMonday: "2026-07-20", + onNavigateToDay: vi.fn(), + onOpenSlideOver: vi.fn(), + onOpenMedia: vi.fn(), + onPatch, + onScheduleHighlight: vi.fn(), + }), + ); + + result.current.onPatch("wo-1", { vendorId: "9" }, { onSuccess }); + + expect(onPatch).toHaveBeenCalledWith("wo-1", { vendorId: "9" }, { onSuccess }); + }); }); 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-sh-119.test.ts new file mode 100644 index 00000000..08973f0c --- /dev/null +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-sh-119.test.ts @@ -0,0 +1,122 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { workOrderCanceledMessage } from "@/domain/work-orders/utils/work-order-feedback-toasts"; + +const patchField = vi.hoisted(() => vi.fn()); + +vi.mock("react-toastify", () => ({ + toast: { + warning: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ + useWorkOrdersListFilters: () => ({ + search: "", + handleSearchChange: vi.fn(), + advOpen: false, + setAdvOpen: vi.fn(), + advApplied: null, + advInitial: EMPTY_ADVANCED_FILTERS, + applyAdvancedFilters: vi.fn(), + clearAdvancedFilters: vi.fn(), + clearAdvancedFilterChip: vi.fn(), + activeFilterCount: 0, + weekMonday: "2026-07-20", + selectedDayIso: "2026-07-20", + setWeekMonday: vi.fn(), + setSelectedDayIso: vi.fn(), + navigateToDay: vi.fn(), + dispatcherIds: new Set(), + setDispatcherIds: vi.fn(), + types: new Set(), + setTypes: vi.fn(), + }), +})); + +vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({ + useWorkOrdersTableData: () => ({ + rows: [], + totalCount: 0, + scopeTotalCount: 0, + isFetching: false, + isLoading: false, + setSelectedId: vi.fn(), + patchRow: vi.fn(), + clearRowPatch: vi.fn(), + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ + useWorkOrderTableMutations: () => ({ + patchField, + patchFieldAsync: vi.fn(), + isPending: false, + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({ + useWorkOrderUsers: () => ({ data: [], isLoading: false }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ data: [] }), + useWorkOrderVendors: () => ({ data: [] }), +})); + +vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({ + useDropdownOptionsByCategory: () => ({ data: [] }), +})); + +import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; + +const row = { id: 77, woNumber: "WO-77" } as WorkOrderTableRow; + +describe("useWorkOrdersListPage SH-119 toasts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("wires Cancel WO to warning toast only after patch onSuccess", () => { + const { result } = renderHook(() => useWorkOrdersListPage()); + + act(() => { + result.current.setConfirmCancel(row); + }); + act(() => { + result.current.handleConfirmCancel(); + }); + + expect(patchField).toHaveBeenCalledWith( + 77, + { status: "Canceled" }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + expect(toast.warning).not.toHaveBeenCalled(); + + const { onSuccess } = patchField.mock.calls[0][2] as { onSuccess: () => void }; + onSuccess(); + expect(toast.warning).toHaveBeenCalledWith(workOrderCanceledMessage("WO-77")); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("completes a work order without a confirmation toast", () => { + const { result } = renderHook(() => useWorkOrdersListPage()); + + act(() => { + result.current.setConfirmComplete(row); + }); + act(() => { + result.current.handleConfirmComplete(); + }); + + expect(patchField).toHaveBeenCalledWith(77, { status: "Completed" }); + expect(toast.success).not.toHaveBeenCalled(); + expect(toast.warning).not.toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 00000000..77d5801e --- /dev/null +++ b/src/test/app/(protected)/workorders/wo-table-row-service-cells-vendor-toast.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WoTableRowServiceCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells"; +import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; +import { notifyVendorAssigned } from "@/domain/work-orders/utils/work-order-feedback-toasts"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({ + PMTypeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({ + ApptTimeCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({ + StatusCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell", () => ({ + CompDocCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/uplift-cell", () => ({ + UpliftCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({ + DateCell: () => null, +})); +vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({ + VendorCell: ({ + onSave, + }: { + onSave: (patch: { vendorId: string; company: string; tech: string; techPhone: string }) => void; + }) => ( + + ), +})); + +const row = { id: 7, upliftSummary: { hasUplift: false, pendingCount: 0 } } as WorkOrderTableRow; + +describe("WoTableRowServiceCells vendor persist (SH-119)", () => { + it("forwards vendor save through patch onSuccess instead of onPatchRow", () => { + const onPatch = vi.fn(); + const onPatchRow = vi.fn(); + const handlers = { onPatch } as unknown as WoTableRowHandlers; + + renderWithProviders( + + + + + + +
, + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: "persist-vendor" })); + + expect(onPatchRow).not.toHaveBeenCalled(); + expect(onPatch).toHaveBeenCalledWith( + 7, + toVendorTablePatch({ + vendorId: "9", + company: "Gateway", + tech: "Pat", + techPhone: "555", + }), + { onSuccess: notifyVendorAssigned }, + ); + }); +}); 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 1f629b09..7cb81232 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 @@ -19,7 +19,9 @@ vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ }, })); -vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } })); +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; import { 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 new file mode 100644 index 00000000..80623aa2 --- /dev/null +++ b/src/test/domain/work-orders/utils/work-order-feedback-toasts.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; +import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; +import { + applyVendorTableSave, + isVendorAssignmentPatch, + notifyVendorAssigned, + notifyVendorAssignedIfPatched, + patchWorkOrderAsCanceled, + patchWorkOrderAsCompleted, + vendorAssignedMessage, + workOrderCanceledMessage, +} from "@/domain/work-orders/utils/work-order-feedback-toasts"; + +vi.mock("react-toastify", () => ({ + toast: { + warning: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe("work-order-feedback-toasts (SH-119)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("builds the AAP cancel copy with the work order number", () => { + expect(workOrderCanceledMessage("20260819001")).toBe("Work order #20260819001 canceled."); + }); + + it("warns on cancel only after patch onSuccess", () => { + const patchField = vi.fn(); + patchWorkOrderAsCanceled(patchField, { id: 42, woNumber: "20260819001" }); + + expect(patchField).toHaveBeenCalledWith( + 42, + { status: "Canceled" }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + expect(toast.warning).not.toHaveBeenCalled(); + + const { onSuccess } = patchField.mock.calls[0][2] as { onSuccess: () => void }; + 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 = { + vendorId: "9", + company: "Gateway", + tech: "Pat", + techPhone: "555", + }; + applyVendorTableSave(onPatch, 7, fields); + + expect(onPatch).toHaveBeenCalledWith( + 7, + toVendorTablePatch(fields), + expect.objectContaining({ onSuccess: notifyVendorAssigned }), + ); + expect(toast.success).not.toHaveBeenCalled(); + + const { onSuccess } = onPatch.mock.calls[0][2] as { onSuccess: () => void }; + onSuccess(); + expect(toast.success).toHaveBeenCalledWith(vendorAssignedMessage()); + 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("does not toast vendor assignment for non-vendor slide-over patches", () => { + expect(isVendorAssignmentPatch({ dispatcherId: "d1" })).toBe(false); + notifyVendorAssignedIfPatched({ dispatcherId: "d1" }); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("completes a work order without a success toast (AAP has no Complete WO toast)", () => { + const patchField = vi.fn(); + patchWorkOrderAsCompleted(patchField, 42); + expect(patchField).toHaveBeenCalledWith(42, { status: "Completed" }); + expect(toast.success).not.toHaveBeenCalled(); + expect(toast.warning).not.toHaveBeenCalled(); + }); +});