diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index d336b44d..9d6dccd2 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -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, @@ -126,6 +127,51 @@ export async function runWorkOrderTablePatch( return { kind: "board", row: boardRow }; } +function readDetailRow( + queryClient: QueryClient, + id: string | number, +): WorkOrderTableRow | undefined { + return queryClient.getQueryData(queryKeys.workOrders.boardDetail(id))?.info; +} + +/** Carry a saved row's version into the open panel's detail before the next queued patch runs. */ +function syncDetailRowVersion( + queryClient: QueryClient, + id: string | number, + row: WorkOrderTableRow, +) { + queryClient.setQueryData(queryKeys.workOrders.boardDetail(id), (detail) => + detail && row.rowVersion + ? { + ...detail, + info: { + ...detail.info, + rowVersion: row.rowVersion, + dispatchRowVersion: row.dispatchRowVersion || detail.info.dispatchRowVersion, + }, + } + : detail, + ); +} + +async function runPatchAndSyncDetail( + queryClient: QueryClient, + id: string | number, + patch: WorkOrderTablePatch, + readBase: (id: string | number) => WorkOrderTableRow | undefined, +): Promise { + try { + const result = await runWorkOrderTablePatch(id, patch, readBase); + if (result.kind === "board") syncDetailRowVersion(queryClient, id, result.row); + return result; + } catch (error) { + if (error instanceof BoardPatchPartialSaveError) { + syncDetailRowVersion(queryClient, id, error.partialRow); + } + throw error; + } +} + export function useWorkOrderTableMutations( onPatchOrHandlers: ((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers, @@ -154,15 +200,25 @@ 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, + id, + persistPatch, + baseRow + ? () => baseRow + : (rowId) => getRow?.(rowId) ?? readDetailRow(queryClient, rowId) ?? fallbackRow, + ), ), onMutate: async ({ id, persistPatch }) => { onPatch(id, persistPatch); @@ -196,9 +252,9 @@ export function useWorkOrderTableMutations( mergeAutoSchedulePatch(getRow?.(id), patch); const patchField: WorkOrderTablePatchFn = (id, patch, options) => { - const baseRow = getRow?.(id) ? undefined : options?.fallbackRow; - const persistPatch = baseRow ? mergeAutoSchedulePatch(baseRow, patch) : resolvePatch(id, patch); - void patchMutation.mutateAsync({ id, patch, persistPatch, baseRow }).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), ); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx index 0116b0d8..4e7ad698 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-off-list.test.tsx @@ -2,7 +2,9 @@ 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 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(); @@ -22,10 +24,13 @@ import { useWorkOrderTableMutations, } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; -function makeWrapper() { - const client = new QueryClient({ +function makeClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); +} + +function makeWrapper(client = makeClient()) { return function Wrapper({ children }: { children: ReactNode }) { return {children}; }; @@ -79,6 +84,45 @@ describe("useWorkOrderTableMutations for a work order outside the loaded list", ); }); + it("runs a queued second save on the first save's version, not the stale panel row", async () => { + const client = makeClient(); + client.setQueryData(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(queryKeys.workOrders.boardDetail(9))?.info + .rowVersion, + ).toBe("saved-2"); + }); + it("prefers the table row when it still holds the work order", async () => { const tableRow = { ...DETAIL_ROW, rowVersion: "table-version" }; const onSuccess = vi.fn();