mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
feat(work-orders): add consistent save/delete toast feedback
Cancel WO and vendor assignment toast after a successful board PATCH.
This commit is contained in:
parent
d908b7e1da
commit
5a0e907067
14 changed files with 512 additions and 30 deletions
|
|
@ -7,6 +7,7 @@ import {
|
||||||
buildSlideOverPatch,
|
buildSlideOverPatch,
|
||||||
isSlideOverDraftDirty,
|
isSlideOverDraftDirty,
|
||||||
} from "@/domain/work-orders/utils/slide-over-draft";
|
} from "@/domain/work-orders/utils/slide-over-draft";
|
||||||
|
import { notifyVendorAssignedIfPatched } from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
||||||
import {
|
import {
|
||||||
isWorkOrderCoreLocked,
|
isWorkOrderCoreLocked,
|
||||||
isWorkOrderFullyLocked,
|
isWorkOrderFullyLocked,
|
||||||
|
|
@ -116,6 +117,7 @@ export function useSlideOverEditState({
|
||||||
}
|
}
|
||||||
onSave(row.id, patch, {
|
onSave(row.id, patch, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
notifyVendorAssignedIfPatched(patch);
|
||||||
setBaseline(activeDraft);
|
setBaseline(activeDraft);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setShowUnsaved(false);
|
setShowUnsaved(false);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type {
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
WorkOrderTablePatch,
|
import {
|
||||||
WorkOrderTableRow,
|
applySchedulePatch,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
type WorkOrderTablePatchFn,
|
||||||
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
|
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
|
||||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
|
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
|
||||||
|
|
@ -19,7 +19,7 @@ type UseWoTableRowHandlersOptions = {
|
||||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
onPatch: WorkOrderTablePatchFn;
|
||||||
onScheduleHighlight: (id: string | number | null) => void;
|
onScheduleHighlight: (id: string | number | null) => void;
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
};
|
};
|
||||||
|
|
@ -76,8 +76,8 @@ export function useWoTableRowHandlers({
|
||||||
tableData.setSelectedId(row.id);
|
tableData.setSelectedId(row.id);
|
||||||
onOpenMedia(row);
|
onOpenMedia(row);
|
||||||
},
|
},
|
||||||
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
onPatch: (id, patch, options) => {
|
||||||
onPatch(id, patch);
|
onPatch(id, patch, options);
|
||||||
},
|
},
|
||||||
onScheduleDate: (id: string | number, date: string) => {
|
onScheduleDate: (id: string | number, date: string) => {
|
||||||
onPatch(id, applySchedulePatch(date, false));
|
onPatch(id, applySchedulePatch(date, false));
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { UpliftCell } from "./cells/uplift-cell";
|
||||||
import { VendorCell } from "./cells/vendor-cell";
|
import { VendorCell } from "./cells/vendor-cell";
|
||||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||||
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
|
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";
|
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||||
|
|
||||||
type WoTableRowServiceCellsProps = {
|
type WoTableRowServiceCellsProps = {
|
||||||
|
|
@ -77,7 +77,7 @@ export function WoTableRowServiceCells({
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
q={search}
|
q={search}
|
||||||
viewOnly={viewOnly}
|
viewOnly={viewOnly}
|
||||||
onSave={(p) => onPatchRow(toVendorTablePatch(p))}
|
onSave={(p) => applyVendorTableSave(handlers.onPatch, row.id, p)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
WorkOrderUser,
|
WorkOrderUser,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WorkOrderTablePatchOptions } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||||
import {
|
import {
|
||||||
canApplyWorkOrderTablePatch,
|
canApplyWorkOrderTablePatch,
|
||||||
|
|
@ -29,7 +30,11 @@ export type WoTableRowHandlers = {
|
||||||
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
onOpenUplifts?: (row: WorkOrderTableRow) => void;
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
|
onPatch: (
|
||||||
|
id: string | number,
|
||||||
|
patch: Partial<WorkOrderTableRow>,
|
||||||
|
options?: WorkOrderTablePatchOptions,
|
||||||
|
) => void;
|
||||||
onScheduleDate: (id: string | number, date: string) => void;
|
onScheduleDate: (id: string | number, date: string) => void;
|
||||||
onScheduleWeek: (id: string | number, week: string) => void;
|
onScheduleWeek: (id: string | number, week: string) => void;
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
|
|
@ -84,9 +89,9 @@ export function WoTableRow({
|
||||||
|
|
||||||
const guardedHandlers: WoTableRowHandlers = {
|
const guardedHandlers: WoTableRowHandlers = {
|
||||||
...handlers,
|
...handlers,
|
||||||
onPatch: (id, patch) => {
|
onPatch: (id, patch, options) => {
|
||||||
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||||
handlers.onPatch(id, patch);
|
handlers.onPatch(id, patch, options);
|
||||||
},
|
},
|
||||||
onScheduleDate: (id, date) => {
|
onScheduleDate: (id, date) => {
|
||||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
|
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,8 @@ import type {
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
WorkOrderUser,
|
WorkOrderUser,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
import type {
|
import type { WorkOrderTablePatchFn } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
WorkOrderTablePatch,
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
WorkOrderTableRow,
|
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
||||||
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
||||||
import { WoDayPaginationFooter } from "./wo-day-pagination-footer";
|
import { WoDayPaginationFooter } from "./wo-day-pagination-footer";
|
||||||
|
|
@ -39,7 +37,7 @@ type WoTableProps = {
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => void;
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
onClearSearch?: () => void;
|
onClearSearch?: () => void;
|
||||||
onOpenAdvancedFilters?: () => void;
|
onOpenAdvancedFilters?: () => void;
|
||||||
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
onPatch: WorkOrderTablePatchFn;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WoTableEmptyShellProps = {
|
type WoTableEmptyShellProps = {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ import {
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
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 { 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 { guardDialogClose } from "@/lib/dialog-guard";
|
||||||
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
|
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
|
||||||
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
|
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
|
||||||
|
|
@ -82,14 +86,14 @@ export function useWorkOrdersListPage() {
|
||||||
|
|
||||||
const handleConfirmCancel = () => {
|
const handleConfirmCancel = () => {
|
||||||
if (!confirmCancel) return;
|
if (!confirmCancel) return;
|
||||||
patchField(confirmCancel.id, { status: "Canceled" });
|
patchWorkOrderAsCanceled(patchField, confirmCancel);
|
||||||
setConfirmCancel(null);
|
setConfirmCancel(null);
|
||||||
handleCloseSlideOver();
|
handleCloseSlideOver();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmComplete = () => {
|
const handleConfirmComplete = () => {
|
||||||
if (!confirmComplete) return;
|
if (!confirmComplete) return;
|
||||||
patchField(confirmComplete.id, { status: "Completed" });
|
patchWorkOrderAsCompleted(patchField, confirmComplete.id);
|
||||||
setConfirmComplete(null);
|
setConfirmComplete(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,17 @@ import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
export type { WorkOrderTablePatch };
|
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 = {
|
export type WorkOrderTablePatchHandlers = {
|
||||||
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||||
clearPatch: (id: string | number) => void;
|
clearPatch: (id: string | number) => void;
|
||||||
|
|
@ -112,6 +123,7 @@ export function useWorkOrderTableMutations(
|
||||||
};
|
};
|
||||||
|
|
||||||
const patchMutation = useMutation({
|
const patchMutation = useMutation({
|
||||||
|
meta: { suppressErrorToast: true },
|
||||||
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
|
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
|
||||||
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
|
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
|
||||||
onMutate: async ({ id, patch }) => {
|
onMutate: async ({ id, patch }) => {
|
||||||
|
|
@ -146,14 +158,7 @@ export function useWorkOrderTableMutations(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const patchField = (
|
const patchField: WorkOrderTablePatchFn = (id, patch, options) => {
|
||||||
id: string | number,
|
|
||||||
patch: WorkOrderTablePatch,
|
|
||||||
options?: {
|
|
||||||
onSuccess?: () => void;
|
|
||||||
onError?: (error: Error) => void;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
patchMutation.mutate(
|
patchMutation.mutate(
|
||||||
{ id, patch },
|
{ id, patch },
|
||||||
{
|
{
|
||||||
|
|
|
||||||
67
src/domain/work-orders/utils/work-order-feedback-toasts.ts
Normal file
67
src/domain/work-orders/utils/work-order-feedback-toasts.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
import { act, renderHook } from "@testing-library/react";
|
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 { 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 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> = {}): WorkOrderTableRow {
|
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
return {
|
return {
|
||||||
|
|
@ -56,6 +66,10 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useSlideOverEditState completed lock", () => {
|
describe("useSlideOverEditState completed lock", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves edit mode and refuses commit when the same work order becomes completed", () => {
|
it("leaves edit mode and refuses commit when the same work order becomes completed", () => {
|
||||||
const onSave = vi.fn();
|
const onSave = vi.fn();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
|
|
@ -93,4 +107,66 @@ describe("useSlideOverEditState completed lock", () => {
|
||||||
|
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,25 @@ describe("useWoTableRowHandlers onDuplicateFound", () => {
|
||||||
expect(onDuplicateFound).toHaveBeenCalledWith(existingRow);
|
expect(onDuplicateFound).toHaveBeenCalledWith(existingRow);
|
||||||
expect(onOpenSlideOver).not.toHaveBeenCalled();
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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<string>(),
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSave({ vendorId: "9", company: "Gateway", tech: "Pat", techPhone: "555" })}
|
||||||
|
>
|
||||||
|
persist-vendor
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<WoTableRowServiceCells
|
||||||
|
row={row}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={[]}
|
||||||
|
handlers={handlers}
|
||||||
|
onPatchRow={onPatchRow}
|
||||||
|
/>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
{ 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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue