From aee52237579711f054aeb1c7ce0dab85a3b81d47 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 17:34:50 -0300 Subject: [PATCH] fix(work-orders): preserve slide-over draft baseline on failed save --- .../detail/work-order-slide-over.tsx | 49 +++-- ...work-order-slide-over-failed-save.test.tsx | 167 ++++++++++++++++++ .../slide-over-failed-save-baseline.test.ts | 96 ++++++++++ 3 files changed, 299 insertions(+), 13 deletions(-) create mode 100644 src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx create mode 100644 src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx index 9e21e6ba..86864503 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over.tsx @@ -102,6 +102,8 @@ export function WorkOrderSlideOver({ const [tab, setTab] = useState("info"); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(null); + /** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */ + const [baseline, setBaseline] = useState(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"); }} diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx new file mode 100644 index 00000000..33450627 --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx @@ -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) => void; + }) => ( + + ), +})); + +vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +function baseRow(overrides: Partial = {}): 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, + 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( + , + ); + + rerenderWithRow = (row) => { + view.rerender( + , + ); + }; + + 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(); + }); + }); +}); diff --git a/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts new file mode 100644 index 00000000..9c4f8c07 --- /dev/null +++ b/src/test/domain/work-orders/utils/slide-over-failed-save-baseline.test.ts @@ -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 { + 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" }); + }); +});