mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 12:32:06 +00:00
Merge pull request #290 from Sea-Haven-Industries/fix/ab/edit-wo-second-save-thr_s6m2gkkjv5
Some checks failed
Frontend checks / static (push) Has been cancelled
Frontend checks / build (push) Has been cancelled
Frontend checks / unit (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Frontend checks / browser-smoke (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Deploy Web / Resolve target (push) Has been cancelled
Frontend checks / ci-complete (push) Has been cancelled
Deploy Web / Deploy SPA to (push) Has been cancelled
Some checks failed
Frontend checks / static (push) Has been cancelled
Frontend checks / build (push) Has been cancelled
Frontend checks / unit (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Frontend checks / browser-smoke (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Deploy Web / Resolve target (push) Has been cancelled
Frontend checks / ci-complete (push) Has been cancelled
Deploy Web / Deploy SPA to (push) Has been cancelled
fix(workorders): save from the detail panel after the order leaves the list
This commit is contained in:
commit
b8aeda637c
10 changed files with 460 additions and 26 deletions
|
|
@ -77,6 +77,29 @@ function useSlideOverInitialization({
|
|||
}, [row?.id, editMode]);
|
||||
}
|
||||
|
||||
// Re-apply the requested tab on every open, including a second notification for the work order
|
||||
// already on screen (same row id) where the identity effect does not re-run. Tab-only so an
|
||||
// in-flight edit draft is never discarded.
|
||||
function useSlideOverOpenRequestTab({
|
||||
row,
|
||||
editMode,
|
||||
initialTab,
|
||||
setTab,
|
||||
openRequestKey,
|
||||
}: {
|
||||
row: WorkOrderTableRow | null;
|
||||
editMode: boolean;
|
||||
initialTab: SlideOverTab;
|
||||
setTab: (tab: SlideOverTab) => void;
|
||||
openRequestKey: number;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!row) return;
|
||||
setTab(editMode ? "info" : initialTab);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- openRequestKey gates each open
|
||||
}, [openRequestKey]);
|
||||
}
|
||||
|
||||
export function useSlideOverEditState({
|
||||
row,
|
||||
editMode,
|
||||
|
|
@ -109,14 +132,7 @@ export function useSlideOverEditState({
|
|||
setUnsavedIntent,
|
||||
});
|
||||
|
||||
// Re-apply the requested tab on every open, including a second notification for the work order
|
||||
// already on screen (same row id) where the identity effect above does not re-run. Tab-only so
|
||||
// an in-flight edit draft is never discarded.
|
||||
useEffect(() => {
|
||||
if (!row) return;
|
||||
setTab(editMode ? "info" : initialTab);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- openRequestKey gates each open
|
||||
}, [openRequestKey]);
|
||||
useSlideOverOpenRequestTab({ row, editMode, initialTab, setTab, openRequestKey });
|
||||
|
||||
const lockStatus = infoSource?.status ?? row?.status;
|
||||
const isInfoLocked = isSlideOverInfoLocked(lockStatus);
|
||||
|
|
@ -167,6 +183,7 @@ export function useSlideOverEditState({
|
|||
return;
|
||||
}
|
||||
onSave(row.id, patch, {
|
||||
fallbackRow: infoSource ?? row,
|
||||
onSuccess: () => {
|
||||
notifySlideOverSaveSuccess(patch);
|
||||
setBaseline(activeDraft);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
|
|||
export type SlideOverSaveOptions = {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
/** The panel's server row, used when the loaded list no longer holds this work order. */
|
||||
fallbackRow?: WorkOrderTableRow;
|
||||
};
|
||||
|
||||
type WorkOrderSlideOverProps = {
|
||||
|
|
|
|||
|
|
@ -131,14 +131,14 @@ export function useWorkOrdersListPage() {
|
|||
|
||||
const handleConfirmCancel = () => {
|
||||
if (!confirmCancel) return;
|
||||
patchWorkOrderAsCanceled(patchField, confirmCancel);
|
||||
patchWorkOrderAsCanceled(patchField, confirmCancel, confirmCancel);
|
||||
setConfirmCancel(null);
|
||||
handleCloseSlideOver();
|
||||
};
|
||||
|
||||
const handleConfirmComplete = () => {
|
||||
if (!confirmComplete) return;
|
||||
patchWorkOrderAsCompleted(patchField, confirmComplete.id);
|
||||
patchWorkOrderAsCompleted(patchField, confirmComplete.id, confirmComplete);
|
||||
setConfirmComplete(null);
|
||||
};
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ export function useWorkOrdersListPage() {
|
|||
generatedAt: data.generatedAt,
|
||||
};
|
||||
setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData }));
|
||||
const saved = await patchFieldAsync(row.id, { docStatus: status });
|
||||
const saved = await patchFieldAsync(row.id, { docStatus: status }, undefined, row);
|
||||
if (saved.kind !== "board" || !shouldCompleteAfterCompletionDoc(status, saved.row)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { type QueryClient, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import {
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
type BoardOp,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
|
|
@ -37,6 +38,8 @@ export type { WorkOrderTablePatch };
|
|||
export type WorkOrderTablePatchOptions = {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
/** Patch base when the loaded list no longer holds the work order (e.g. moved to another week). */
|
||||
fallbackRow?: WorkOrderTableRow;
|
||||
};
|
||||
|
||||
export type WorkOrderTablePatchFn = (
|
||||
|
|
@ -124,6 +127,68 @@ export async function runWorkOrderTablePatch(
|
|||
return { kind: "board", row: boardRow };
|
||||
}
|
||||
|
||||
function readDetailRow(
|
||||
queryClient: QueryClient,
|
||||
id: string | number,
|
||||
): WorkOrderTableRow | undefined {
|
||||
return queryClient.getQueryData<WorkOrderBoardDetail>(queryKeys.workOrders.boardDetail(id))?.info;
|
||||
}
|
||||
|
||||
/** Carry a saved row into the open panel's detail before the next queued patch runs. */
|
||||
function syncDetailRow(queryClient: QueryClient, id: string | number, row: WorkOrderTableRow) {
|
||||
queryClient.setQueryData<WorkOrderBoardDetail>(queryKeys.workOrders.boardDetail(id), (detail) =>
|
||||
detail && row.rowVersion
|
||||
? {
|
||||
...detail,
|
||||
// The whole saved row, not just its version: a queued patch built from fresh
|
||||
// version + stale fields would silently revert what the previous save wrote.
|
||||
info: {
|
||||
...detail.info,
|
||||
...row,
|
||||
dispatchRowVersion: row.dispatchRowVersion || detail.info.dispatchRowVersion,
|
||||
mediaCount: detail.info.mediaCount,
|
||||
},
|
||||
}
|
||||
: detail,
|
||||
);
|
||||
}
|
||||
|
||||
type SavedRowSync = {
|
||||
queryClient: QueryClient;
|
||||
/** Last server row each patch returned, for panels whose detail query never cached data. */
|
||||
savedRows: Map<string, WorkOrderTableRow>;
|
||||
};
|
||||
|
||||
function recordSavedRow(
|
||||
{ queryClient, savedRows }: SavedRowSync,
|
||||
id: string | number,
|
||||
row: WorkOrderTableRow,
|
||||
) {
|
||||
savedRows.set(String(id), row);
|
||||
syncDetailRow(queryClient, id, row);
|
||||
}
|
||||
|
||||
async function runPatchAndSyncDetail(
|
||||
sync: SavedRowSync,
|
||||
id: string | number,
|
||||
patch: WorkOrderTablePatch,
|
||||
readBase: (id: string | number) => WorkOrderTableRow | undefined,
|
||||
): Promise<PatchResult> {
|
||||
try {
|
||||
const result = await runWorkOrderTablePatch(id, patch, readBase);
|
||||
if (result.kind === "board") recordSavedRow(sync, id, result.row);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof BoardPatchPartialSaveError) {
|
||||
recordSavedRow(sync, id, error.partialRow);
|
||||
} else if (error instanceof BoardPatchConflictError && error.currentState) {
|
||||
// A retry before the refetch lands must patch the server's current version.
|
||||
recordSavedRow(sync, id, error.currentState);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function useWorkOrderTableMutations(
|
||||
onPatchOrHandlers:
|
||||
((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers,
|
||||
|
|
@ -141,6 +206,7 @@ export function useWorkOrderTableMutations(
|
|||
const { onPatch, clearPatch, getRow } = handlers;
|
||||
const queryClient = useQueryClient();
|
||||
const enqueuePatch = useMemo(() => createWorkOrderPatchQueue(), []);
|
||||
const savedRows = useMemo(() => new Map<string, WorkOrderTableRow>(), []);
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
|
|
@ -152,15 +218,29 @@ export function useWorkOrderTableMutations(
|
|||
id,
|
||||
persistPatch,
|
||||
baseRow,
|
||||
fallbackRow,
|
||||
}: {
|
||||
id: string | number;
|
||||
patch: WorkOrderTablePatch;
|
||||
persistPatch: WorkOrderTablePatch;
|
||||
/** Server row from a just-settled patch; its rowVersion is fresher than the table snapshot. */
|
||||
baseRow?: WorkOrderTableRow;
|
||||
/** Used when neither the table nor the detail cache holds the work order. */
|
||||
fallbackRow?: WorkOrderTableRow;
|
||||
}) =>
|
||||
enqueuePatch(id, () =>
|
||||
runWorkOrderTablePatch(id, persistPatch, baseRow ? () => baseRow : getRow),
|
||||
runPatchAndSyncDetail(
|
||||
{ queryClient, savedRows },
|
||||
id,
|
||||
persistPatch,
|
||||
baseRow
|
||||
? () => baseRow
|
||||
: (rowId) =>
|
||||
getRow?.(rowId) ??
|
||||
readDetailRow(queryClient, rowId) ??
|
||||
savedRows.get(String(rowId)) ??
|
||||
fallbackRow,
|
||||
),
|
||||
),
|
||||
onMutate: async ({ id, persistPatch }) => {
|
||||
onPatch(id, persistPatch);
|
||||
|
|
@ -190,12 +270,10 @@ export function useWorkOrderTableMutations(
|
|||
},
|
||||
});
|
||||
|
||||
const resolvePatch = (id: string | number, patch: WorkOrderTablePatch) =>
|
||||
mergeAutoSchedulePatch(getRow?.(id), patch);
|
||||
|
||||
const patchField: WorkOrderTablePatchFn = (id, patch, options) => {
|
||||
const persistPatch = resolvePatch(id, patch);
|
||||
void patchMutation.mutateAsync({ id, patch, persistPatch }).then(
|
||||
const fallbackRow = options?.fallbackRow;
|
||||
const persistPatch = mergeAutoSchedulePatch(getRow?.(id) ?? fallbackRow, patch);
|
||||
void patchMutation.mutateAsync({ id, patch, persistPatch, fallbackRow }).then(
|
||||
() => options?.onSuccess?.(),
|
||||
(error: Error) => options?.onError?.(error),
|
||||
);
|
||||
|
|
@ -205,9 +283,10 @@ export function useWorkOrderTableMutations(
|
|||
id: string | number,
|
||||
patch: WorkOrderTablePatch,
|
||||
baseRow?: WorkOrderTableRow,
|
||||
fallbackRow?: WorkOrderTableRow,
|
||||
) => {
|
||||
const persistPatch = baseRow ? mergeAutoSchedulePatch(baseRow, patch) : resolvePatch(id, patch);
|
||||
return patchMutation.mutateAsync({ id, patch, persistPatch, baseRow });
|
||||
const persistPatch = mergeAutoSchedulePatch(baseRow ?? getRow?.(id) ?? fallbackRow, patch);
|
||||
return patchMutation.mutateAsync({ id, patch, persistPatch, baseRow, fallbackRow });
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { toast } from "react-toastify";
|
||||
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} 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";
|
||||
|
||||
|
|
@ -9,6 +12,7 @@ type PersistPatchFn = (
|
|||
options?: {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
fallbackRow?: WorkOrderTableRow;
|
||||
},
|
||||
) => void;
|
||||
|
||||
|
|
@ -57,17 +61,27 @@ export function notifySlideOverSaveSuccess(patch: WorkOrderTablePatch) {
|
|||
export function patchWorkOrderAsCanceled(
|
||||
patchField: PersistPatchFn,
|
||||
row: { id: string | number; woNumber: string },
|
||||
fallbackRow?: WorkOrderTableRow,
|
||||
) {
|
||||
patchField(
|
||||
row.id,
|
||||
{ status: "Canceled" },
|
||||
{
|
||||
onSuccess: () => notifyWorkOrderCanceled(row.woNumber),
|
||||
fallbackRow,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function patchWorkOrderAsCompleted(patchField: PersistPatchFn, id: string | number) {
|
||||
export function patchWorkOrderAsCompleted(
|
||||
patchField: PersistPatchFn,
|
||||
id: string | number,
|
||||
fallbackRow?: WorkOrderTableRow,
|
||||
) {
|
||||
if (fallbackRow) {
|
||||
patchField(id, { status: "Completed" }, { fallbackRow });
|
||||
return;
|
||||
}
|
||||
patchField(id, { status: "Completed" });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -203,4 +203,35 @@ describe("useSlideOverEditState completed lock", () => {
|
|||
expect(toast.success).toHaveBeenCalledWith(workOrderSavedMessage());
|
||||
expect(toast.success).not.toHaveBeenCalledWith(vendorAssignedMessage());
|
||||
});
|
||||
|
||||
it("sends the panel's server row so a save works after the work order left the list", () => {
|
||||
const onSave = vi.fn();
|
||||
const opened = baseRow({ rowVersion: "opened" });
|
||||
const fresh = baseRow({ scheduledOn: "2026-07-22", rowVersion: "after-first-save" });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverEditState({
|
||||
row: opened,
|
||||
editMode: true,
|
||||
infoSource: fresh,
|
||||
onSave,
|
||||
onClose: vi.fn(),
|
||||
closeDisabled: false,
|
||||
setTab: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDraftChange({ dispatcherName: "Blair" });
|
||||
});
|
||||
act(() => {
|
||||
result.current.commit();
|
||||
});
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
100,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ fallbackRow: fresh }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ describe("useWorkOrdersListPage cancel toast", () => {
|
|||
expect(patchField).toHaveBeenCalledWith(
|
||||
77,
|
||||
{ status: "Canceled" },
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
expect.objectContaining({ onSuccess: expect.any(Function), fallbackRow: row }),
|
||||
);
|
||||
expect(toast.warning).not.toHaveBeenCalled();
|
||||
|
||||
|
|
@ -137,7 +137,7 @@ describe("useWorkOrdersListPage cancel toast", () => {
|
|||
result.current.handleConfirmComplete();
|
||||
});
|
||||
|
||||
expect(patchField).toHaveBeenCalledWith(77, { status: "Completed" });
|
||||
expect(patchField).toHaveBeenCalledWith(77, { status: "Completed" }, { fallbackRow: row });
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(toast.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -131,10 +131,11 @@ describe("useWorkOrdersListPage completion document (SH-341)", () => {
|
|||
row: { ...saved, status: "Completed" },
|
||||
});
|
||||
const { result } = renderHook(() => useWorkOrdersListPage());
|
||||
const docRow = closableRow();
|
||||
|
||||
await result.current.saveCompletionDoc(closableRow(), { notes: "", generatedAt: "now" }, "Yes");
|
||||
await result.current.saveCompletionDoc(docRow, { notes: "", generatedAt: "now" }, "Yes");
|
||||
|
||||
expect(patchFieldAsync).toHaveBeenNthCalledWith(1, 77, { docStatus: "Yes" });
|
||||
expect(patchFieldAsync).toHaveBeenNthCalledWith(1, 77, { docStatus: "Yes" }, undefined, docRow);
|
||||
expect(patchFieldAsync).toHaveBeenNthCalledWith(2, 77, { status: "Completed" }, saved);
|
||||
expect(patchField).not.toHaveBeenCalled();
|
||||
expect(result.current.confirmComplete).toBeNull();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BoardPatchConflictError } from "@/domain/work-orders/errors/board-patch-errors";
|
||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
const patchBoardField = vi.fn();
|
||||
const updatePoc = vi.fn();
|
||||
|
||||
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
|
||||
workOrdersApi: {
|
||||
patchBoardField: (...args: unknown[]) => patchBoardField(...args),
|
||||
updatePoc: (...args: unknown[]) => updatePoc(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
applySchedulePatch,
|
||||
useWorkOrderTableMutations,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
}
|
||||
|
||||
function makeWrapper(client = makeClient()) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
const DETAIL_ROW = {
|
||||
id: 9,
|
||||
woNumber: "00000000009",
|
||||
scheduledOn: "2026-10-07",
|
||||
status: "Scheduled",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
rowVersion: "AAAAAAAAB9E=",
|
||||
dispatchRowVersion: "",
|
||||
primaryDispatchId: null,
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
describe("useWorkOrderTableMutations for a work order outside the loaded list", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
patchBoardField.mockImplementation(async () => ({ ...DETAIL_ROW, rowVersion: "next" }));
|
||||
});
|
||||
|
||||
it("patches against the fallback row when the table no longer holds the work order", async () => {
|
||||
const onSuccess = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch: vi.fn(),
|
||||
clearPatch: vi.fn(),
|
||||
getRow: () => undefined,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(9, applySchedulePatch("2026-10-02", false), {
|
||||
onSuccess,
|
||||
onError,
|
||||
fallbackRow: DETAIL_ROW,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
expect(patchBoardField).toHaveBeenCalledWith(
|
||||
9,
|
||||
expect.objectContaining({ workOrderVersion: "AAAAAAAAB9E=" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs a queued second save on the first save's version, not the stale panel row", async () => {
|
||||
const client = makeClient();
|
||||
client.setQueryData<WorkOrderBoardDetail>(queryKeys.workOrders.boardDetail(9), {
|
||||
info: DETAIL_ROW,
|
||||
} as WorkOrderBoardDetail);
|
||||
let version = 0;
|
||||
patchBoardField.mockImplementation(async () => {
|
||||
version += 1;
|
||||
return { ...DETAIL_ROW, rowVersion: `saved-${version}` };
|
||||
});
|
||||
const onSecondSuccess = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch: vi.fn(),
|
||||
clearPatch: vi.fn(),
|
||||
getRow: () => undefined,
|
||||
}),
|
||||
{ wrapper: makeWrapper(client) },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(9, { dueDate: "2026-10-09" }, { fallbackRow: DETAIL_ROW });
|
||||
result.current.patchField(
|
||||
9,
|
||||
{ dueDate: "2026-10-16" },
|
||||
{ fallbackRow: DETAIL_ROW, onSuccess: onSecondSuccess },
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onSecondSuccess).toHaveBeenCalled());
|
||||
expect(patchBoardField.mock.calls[0][1]).toMatchObject({ workOrderVersion: "AAAAAAAAB9E=" });
|
||||
expect(patchBoardField.mock.calls[1][1]).toMatchObject({ workOrderVersion: "saved-1" });
|
||||
expect(
|
||||
client.getQueryData<WorkOrderBoardDetail>(queryKeys.workOrders.boardDetail(9))?.info
|
||||
.rowVersion,
|
||||
).toBe("saved-2");
|
||||
});
|
||||
|
||||
it("keeps the first save's fields when a queued second panel save patches the same group", async () => {
|
||||
const client = makeClient();
|
||||
client.setQueryData<WorkOrderBoardDetail>(queryKeys.workOrders.boardDetail(9), {
|
||||
info: { ...DETAIL_ROW, pocName: "Old POC", pocPhone: "111", pocNotes: "" },
|
||||
} as WorkOrderBoardDetail);
|
||||
let version = 0;
|
||||
updatePoc.mockImplementation(
|
||||
async (_id: unknown, payload: { pocName: string; pocPhone: string; pocNotes: string }) => {
|
||||
version += 1;
|
||||
return { ...DETAIL_ROW, ...payload, rowVersion: `saved-${version}` };
|
||||
},
|
||||
);
|
||||
const onSecondSuccess = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch: vi.fn(),
|
||||
clearPatch: vi.fn(),
|
||||
getRow: () => undefined,
|
||||
}),
|
||||
{ wrapper: makeWrapper(client) },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(9, { pocName: "New POC" }, { fallbackRow: DETAIL_ROW });
|
||||
result.current.patchField(
|
||||
9,
|
||||
{ pocPhone: "555" },
|
||||
{ fallbackRow: DETAIL_ROW, onSuccess: onSecondSuccess },
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onSecondSuccess).toHaveBeenCalled());
|
||||
expect(updatePoc.mock.calls[1][1]).toMatchObject({
|
||||
pocName: "New POC",
|
||||
pocPhone: "555",
|
||||
workOrderVersion: "saved-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("chains the saved version when the panel's detail query never cached data", async () => {
|
||||
let version = 0;
|
||||
patchBoardField.mockImplementation(async () => {
|
||||
version += 1;
|
||||
return { ...DETAIL_ROW, rowVersion: `saved-${version}` };
|
||||
});
|
||||
const onSecondSuccess = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch: vi.fn(),
|
||||
clearPatch: vi.fn(),
|
||||
getRow: () => undefined,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(9, { dueDate: "2026-10-09" }, { fallbackRow: DETAIL_ROW });
|
||||
result.current.patchField(
|
||||
9,
|
||||
{ dueDate: "2026-10-16" },
|
||||
{ fallbackRow: DETAIL_ROW, onSuccess: onSecondSuccess },
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onSecondSuccess).toHaveBeenCalled());
|
||||
expect(patchBoardField.mock.calls[1][1]).toMatchObject({ workOrderVersion: "saved-1" });
|
||||
});
|
||||
|
||||
it("retries an off-list save on the conflict's current version, not the stale panel row", async () => {
|
||||
const client = makeClient();
|
||||
client.setQueryData<WorkOrderBoardDetail>(queryKeys.workOrders.boardDetail(9), {
|
||||
info: DETAIL_ROW,
|
||||
} as WorkOrderBoardDetail);
|
||||
patchBoardField.mockRejectedValueOnce(
|
||||
new BoardPatchConflictError("Work order was modified.", {
|
||||
...DETAIL_ROW,
|
||||
rowVersion: "server-current",
|
||||
}),
|
||||
);
|
||||
const onRetrySuccess = vi.fn();
|
||||
const onFirstError = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch: vi.fn(),
|
||||
clearPatch: vi.fn(),
|
||||
getRow: () => undefined,
|
||||
}),
|
||||
{ wrapper: makeWrapper(client) },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(
|
||||
9,
|
||||
{ dueDate: "2026-10-09" },
|
||||
{ fallbackRow: DETAIL_ROW, onError: onFirstError },
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(onFirstError).toHaveBeenCalled());
|
||||
act(() => {
|
||||
result.current.patchField(
|
||||
9,
|
||||
{ dueDate: "2026-10-09" },
|
||||
{ fallbackRow: DETAIL_ROW, onSuccess: onRetrySuccess },
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onRetrySuccess).toHaveBeenCalled());
|
||||
expect(patchBoardField.mock.calls[1][1]).toMatchObject({ workOrderVersion: "server-current" });
|
||||
});
|
||||
|
||||
it("prefers the table row when it still holds the work order", async () => {
|
||||
const tableRow = { ...DETAIL_ROW, rowVersion: "table-version" };
|
||||
const onSuccess = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch: vi.fn(),
|
||||
clearPatch: vi.fn(),
|
||||
getRow: () => tableRow,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(9, applySchedulePatch("2026-10-02", false), {
|
||||
onSuccess,
|
||||
fallbackRow: DETAIL_ROW,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
|
||||
expect(patchBoardField).toHaveBeenCalledWith(
|
||||
9,
|
||||
expect.objectContaining({ workOrderVersion: "table-version" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "react-toastify";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||
import {
|
||||
applyAssignedToTableSave,
|
||||
|
|
@ -185,4 +186,25 @@ describe("work-order-feedback-toasts", () => {
|
|||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(toast.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards the panel row to cancel and complete patches", () => {
|
||||
const patchField = vi.fn();
|
||||
const row = { id: 42, woNumber: "20260819001", rowVersion: "v7" } as WorkOrderTableRow;
|
||||
|
||||
patchWorkOrderAsCanceled(patchField, row, row);
|
||||
patchWorkOrderAsCompleted(patchField, 42, row);
|
||||
|
||||
expect(patchField).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
42,
|
||||
{ status: "Canceled" },
|
||||
expect.objectContaining({ fallbackRow: row }),
|
||||
);
|
||||
expect(patchField).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
42,
|
||||
{ status: "Completed" },
|
||||
{ fallbackRow: row },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue