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

fix(workorders): save from the detail panel after the order leaves the list
This commit is contained in:
Alexandre Brandizzi 2026-10-02 03:24:10 +00:00 • committed by GitHub
commit b8aeda637c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 460 additions and 26 deletions

View file

@ -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);

View file

@ -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 = {

View file

@ -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;
}

View file

@ -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 {

View file

@ -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" });
}

View file

@ -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 }),
);
});
});

View file

@ -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();
});

View file

@ -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();

View file

@ -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" }),
);
});
});

View file

@ -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 },
);
});
});