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.
This commit is contained in:
Alexandre Brandizzi 2026-10-01 23:27:17 -03:00
parent e6548ffa17
commit 522fea4db7
2 changed files with 107 additions and 7 deletions

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,
@ -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<WorkOrderBoardDetail>(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<WorkOrderBoardDetail>(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<PatchResult> {
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),
);

View file

@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
@ -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<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("prefers the table row when it still holds the work order", async () => {
const tableRow = { ...DETAIL_ROW, rowVersion: "table-version" };
const onSuccess = vi.fn();