From 522fea4db7e1403d6068797c74239098f8166af4 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 1 Oct 2026 23:27:17 -0300 Subject: [PATCH] fix(workorders): carry each saved version into the panel before the next save A save from the detail panel left the cached detail on the pre-save version until its refetch settled, so a quick second save, Cancel WO or Complete could conflict. The saved row's version now updates the detail cache before the patch queue releases, and panel patches read their base at run time. --- .../use-work-order-table-mutations.ts | 66 +++++++++++++++++-- ...rk-order-table-mutations-off-list.test.tsx | 48 +++++++++++++- 2 files changed, 107 insertions(+), 7 deletions(-) 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();