mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(work-orders): preserve slide-over draft baseline on failed save
This commit is contained in:
parent
475862003c
commit
aee5223757
3 changed files with 299 additions and 13 deletions
|
|
@ -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");
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue