mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
fix(work-orders): address PR review on toast feedback
Scope vendor and save toasts to successful assigned-vendor and slide-over saves. Bind patch success callbacks per operation instead of shared mutate observers.
This commit is contained in:
parent
97b5e287aa
commit
8d755bcb11
8 changed files with 146 additions and 44 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<string, WorkOrderTableRow>([
|
||||
["1", { ...BASE_ROW, id: 1, rowVersion: "v1" }],
|
||||
["2", { ...BASE_ROW, id: 2, rowVersion: "v2" }],
|
||||
]);
|
||||
|
||||
let releaseFirst: () => void = () => undefined;
|
||||
const firstGate = new Promise<void>((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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue