fix(work-orders): preserve slide-over draft baseline on failed save

This commit is contained in:
Arthur Bassi 2026-07-22 17:34:50 -03:00 • committed by Arthur Bassi
parent 475862003c
commit aee5223757
3 changed files with 299 additions and 13 deletions

View file

@ -102,6 +102,8 @@ export function WorkOrderSlideOver({
const [tab, setTab] = useState<SlideOverTab>("info");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
/** Tracks multi-file upload batches; mutation.isPending alone can flip false early. */
const [mediaBatchPending, setMediaBatchPending] = useState(false);
@ -113,27 +115,45 @@ export function WorkOrderSlideOver({
const isCancelled = (infoSource?.status as string) === "Canceled";
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
// not on every optimistic parent-row field update during a failed/in-flight save.
useEffect(() => {
if (!row) return;
if (!row) {
setDraft(null);
setBaseline(null);
setEditing(false);
setShowUnsaved(false);
return;
}
setDraft(row);
setBaseline(row);
setEditing(editMode && (row.status as string) !== "Canceled");
setTab("info");
setShowUnsaved(false);
}, [row, editMode]);
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]);
useEffect(() => {
if (!infoSource) return;
if (!editing) {
setDraft(infoSource);
setBaseline(infoSource);
return;
}
// While editing, freeze baseline so optimistic parent-row patches cannot clear dirty.
setDraft((prev) => {
if (editing && prev && isSlideOverDraftDirty(prev, infoSource)) return prev;
if (prev && baseline && isSlideOverDraftDirty(prev, baseline)) return prev;
return infoSource;
});
}, [infoSource, editing]);
}, [infoSource, editing, baseline]);
const activeDraft = draft ?? infoSource;
const dirtyBaseline = baseline ?? infoSource;
const isDirty = useMemo(() => {
if (!infoSource || !activeDraft || !editing) return false;
return isSlideOverDraftDirty(activeDraft, infoSource);
}, [activeDraft, editing, infoSource]);
if (!dirtyBaseline || !activeDraft || !editing) return false;
return isSlideOverDraftDirty(activeDraft, dirtyBaseline);
}, [activeDraft, dirtyBaseline, editing]);
const completionMedia = useMemo(
() =>
@ -174,8 +194,8 @@ export function WorkOrderSlideOver({
};
const commit = () => {
if (!row || !activeDraft || !infoSource) return;
const patch = buildSlideOverPatch(activeDraft, infoSource);
if (!row || !activeDraft || !dirtyBaseline) return;
const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
if (Object.keys(patch).length === 0) {
setEditing(false);
setShowUnsaved(false);
@ -183,6 +203,7 @@ export function WorkOrderSlideOver({
}
onSave(row.id, patch, {
onSuccess: () => {
setBaseline(activeDraft);
setEditing(false);
setShowUnsaved(false);
},
@ -190,8 +211,8 @@ export function WorkOrderSlideOver({
};
const discard = () => {
if (!infoSource) return;
setDraft(infoSource);
if (!dirtyBaseline) return;
setDraft(dirtyBaseline);
setEditing(false);
setShowUnsaved(false);
};
@ -210,7 +231,7 @@ export function WorkOrderSlideOver({
setShowUnsaved(true);
return;
}
if (infoSource) setDraft(infoSource);
if (dirtyBaseline) setDraft(dirtyBaseline);
setEditing(false);
};
@ -382,7 +403,9 @@ export function WorkOrderSlideOver({
onCancelEdit={handleCancelEdit}
onClose={requestClose}
onEdit={() => {
setDraft(infoSource);
const source = infoSource;
setDraft(source);
setBaseline(source);
setEditing(true);
setTab("info");
}}

View file

@ -0,0 +1,167 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
vi.mock("@/providers/auth-context", () => ({
useAuthContext: () => ({ user: { id: "u1" } }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({
data: undefined,
isLoading: true,
isFetching: true,
error: null,
refetch: vi.fn(),
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
useUploadWorkOrderMedia: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }),
useDeleteWorkOrderMedia: () => ({ mutate: vi.fn(), isPending: false }),
useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab", () => ({
SlideOverInfoTab: ({
draft,
onDraftChange,
}: {
draft: WorkOrderTableRow;
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
}) => (
<label>
WO number
<input
aria-label="WO number"
value={draft.woNumber}
onChange={(event) => onDraftChange({ woNumber: event.target.value })}
/>
</label>
),
}));
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-07-20",
completedDate: "",
targetWeek: "",
dayGroup: "07/20",
dayLabel: "Mon",
location: "Seattle",
locationId: "loc-1",
pm: "HVAC",
serviceNotes: "",
extraServices: [],
company: "Vendor Co",
tech: "Tech",
techPhone: "",
vendorId: "v1",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "PM — SEA1",
priority: "Sev 3",
rowVersion: "v1",
...overrides,
};
}
describe("WorkOrderSlideOver failed-save retry (no detail yet)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("keeps draft dirty and Save enabled after optimistic parent row + failed save", async () => {
const initial = baseRow();
let currentRow = initial;
const onSave = vi.fn(
(
_id: string | number,
patch: Partial<WorkOrderTableRow>,
options?: { onSuccess?: () => void; onError?: (error: Error) => void },
) => {
currentRow = { ...currentRow, ...patch };
rerenderWithRow(currentRow);
currentRow = initial;
rerenderWithRow(currentRow);
options?.onError?.(new Error("save failed"));
},
);
let rerenderWithRow = (_row: WorkOrderTableRow) => {
/* assigned after first render */
};
const view = render(
<WorkOrderSlideOver
row={currentRow}
editMode
users={[{ id: "u1", name: "Dispatcher" }]}
sites={[{ id: "loc-1", name: "Seattle" }]}
vendors={[]}
pmTypes={["HVAC"]}
allOrders={[currentRow]}
onSave={onSave}
onOpenCompletionDoc={vi.fn()}
onCancelWO={vi.fn()}
onClose={vi.fn()}
/>,
);
rerenderWithRow = (row) => {
view.rerender(
<WorkOrderSlideOver
row={row}
editMode
users={[{ id: "u1", name: "Dispatcher" }]}
sites={[{ id: "loc-1", name: "Seattle" }]}
vendors={[]}
pmTypes={["HVAC"]}
allOrders={[row]}
onSave={onSave}
onOpenCompletionDoc={vi.fn()}
onCancelWO={vi.fn()}
onClose={vi.fn()}
/>,
);
};
fireEvent.change(screen.getByLabelText("WO number"), { target: { value: "WO-99" } });
expect(screen.getByDisplayValue("WO-99")).toBeInTheDocument();
const saveButton = screen.getByRole("button", { name: /^save$/i });
expect(saveButton).not.toBeDisabled();
fireEvent.click(saveButton);
expect(onSave).toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByDisplayValue("WO-99")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^save$/i })).not.toBeDisabled();
});
});
});

View file

@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
buildSlideOverPatch,
isSlideOverDraftDirty,
} from "@/domain/work-orders/utils/slide-over-draft";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
/**
* Mirrors the failed-save path: optimistic parent row absorbs the patch, then
* rolls back, while draft is compared against a frozen pre-save baseline.
*/
function afterFailedOptimisticSave(input: {
baseline: WorkOrderTableRow;
draft: WorkOrderTableRow;
optimisticParent: WorkOrderTableRow;
rolledBackParent: WorkOrderTableRow;
}) {
const dirtyAgainstOptimistic = isSlideOverDraftDirty(input.draft, input.optimisticParent);
const dirtyAgainstBaseline = isSlideOverDraftDirty(input.draft, input.baseline);
const dirtyAgainstRolledBack = isSlideOverDraftDirty(input.draft, input.rolledBackParent);
const retryPatch = buildSlideOverPatch(input.draft, input.baseline);
return {
dirtyAgainstOptimistic,
dirtyAgainstBaseline,
dirtyAgainstRolledBack,
retryPatch,
canRetry: dirtyAgainstBaseline && Object.keys(retryPatch).length > 0,
};
}
function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 1,
woNumber: "1001",
site: "SITE-A",
type: "PM",
dispatcherId: "10",
dispatcherName: "Alex",
scheduledOn: "2026-07-16",
targetWeek: "",
dayGroup: "2026-07-16",
dayLabel: "Thu",
location: "Main",
locationId: 5,
pm: "Plumbing",
serviceNotes: "",
extraServices: [],
vendorId: "v1",
company: "Acme",
tech: "Sam",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "09:00",
status: "Incomplete",
docStatus: "",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "2026-07-20",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
rowVersion: "rv1",
completedDate: "",
...overrides,
} as WorkOrderTableRow;
}
describe("slide-over failed-save baseline contract", () => {
it("stays dirty vs frozen baseline after optimistic parent absorbs the edit", () => {
const baseline = row({ woNumber: "1001" });
const draft = row({ woNumber: "2002" });
const optimisticParent = row({ woNumber: "2002" });
const result = afterFailedOptimisticSave({
baseline,
draft,
optimisticParent,
rolledBackParent: baseline,
});
// Bug the review caught: comparing against optimistic parent clears dirty.
expect(result.dirtyAgainstOptimistic).toBe(false);
expect(result.dirtyAgainstBaseline).toBe(true);
expect(result.dirtyAgainstRolledBack).toBe(true);
expect(result.canRetry).toBe(true);
expect(result.retryPatch).toEqual({ woNumber: "2002" });
});
});