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
8e0737f7fb
commit
ba80dbf961
3 changed files with 299 additions and 13 deletions
|
|
@ -102,6 +102,8 @@ export function WorkOrderSlideOver({
|
||||||
const [tab, setTab] = useState<SlideOverTab>("info");
|
const [tab, setTab] = useState<SlideOverTab>("info");
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
|
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);
|
const [showUnsaved, setShowUnsaved] = useState(false);
|
||||||
/** Tracks multi-file upload batches; mutation.isPending alone can flip false early. */
|
/** Tracks multi-file upload batches; mutation.isPending alone can flip false early. */
|
||||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||||
|
|
@ -113,27 +115,45 @@ export function WorkOrderSlideOver({
|
||||||
const isCancelled = (infoSource?.status as string) === "Canceled";
|
const isCancelled = (infoSource?.status as string) === "Canceled";
|
||||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!row) return;
|
if (!row) {
|
||||||
|
setDraft(null);
|
||||||
|
setBaseline(null);
|
||||||
|
setEditing(false);
|
||||||
|
setShowUnsaved(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDraft(row);
|
setDraft(row);
|
||||||
|
setBaseline(row);
|
||||||
setEditing(editMode && (row.status as string) !== "Canceled");
|
setEditing(editMode && (row.status as string) !== "Canceled");
|
||||||
setTab("info");
|
setTab("info");
|
||||||
setShowUnsaved(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!infoSource) return;
|
if (!infoSource) return;
|
||||||
|
if (!editing) {
|
||||||
|
setDraft(infoSource);
|
||||||
|
setBaseline(infoSource);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// While editing, freeze baseline so optimistic parent-row patches cannot clear dirty.
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
if (editing && prev && isSlideOverDraftDirty(prev, infoSource)) return prev;
|
if (prev && baseline && isSlideOverDraftDirty(prev, baseline)) return prev;
|
||||||
return infoSource;
|
return infoSource;
|
||||||
});
|
});
|
||||||
}, [infoSource, editing]);
|
}, [infoSource, editing, baseline]);
|
||||||
|
|
||||||
const activeDraft = draft ?? infoSource;
|
const activeDraft = draft ?? infoSource;
|
||||||
|
const dirtyBaseline = baseline ?? infoSource;
|
||||||
const isDirty = useMemo(() => {
|
const isDirty = useMemo(() => {
|
||||||
if (!infoSource || !activeDraft || !editing) return false;
|
if (!dirtyBaseline || !activeDraft || !editing) return false;
|
||||||
return isSlideOverDraftDirty(activeDraft, infoSource);
|
return isSlideOverDraftDirty(activeDraft, dirtyBaseline);
|
||||||
}, [activeDraft, editing, infoSource]);
|
}, [activeDraft, dirtyBaseline, editing]);
|
||||||
|
|
||||||
const completionMedia = useMemo(
|
const completionMedia = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -174,8 +194,8 @@ export function WorkOrderSlideOver({
|
||||||
};
|
};
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (!row || !activeDraft || !infoSource) return;
|
if (!row || !activeDraft || !dirtyBaseline) return;
|
||||||
const patch = buildSlideOverPatch(activeDraft, infoSource);
|
const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
|
||||||
if (Object.keys(patch).length === 0) {
|
if (Object.keys(patch).length === 0) {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setShowUnsaved(false);
|
setShowUnsaved(false);
|
||||||
|
|
@ -183,6 +203,7 @@ export function WorkOrderSlideOver({
|
||||||
}
|
}
|
||||||
onSave(row.id, patch, {
|
onSave(row.id, patch, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setBaseline(activeDraft);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setShowUnsaved(false);
|
setShowUnsaved(false);
|
||||||
},
|
},
|
||||||
|
|
@ -190,8 +211,8 @@ export function WorkOrderSlideOver({
|
||||||
};
|
};
|
||||||
|
|
||||||
const discard = () => {
|
const discard = () => {
|
||||||
if (!infoSource) return;
|
if (!dirtyBaseline) return;
|
||||||
setDraft(infoSource);
|
setDraft(dirtyBaseline);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setShowUnsaved(false);
|
setShowUnsaved(false);
|
||||||
};
|
};
|
||||||
|
|
@ -210,7 +231,7 @@ export function WorkOrderSlideOver({
|
||||||
setShowUnsaved(true);
|
setShowUnsaved(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (infoSource) setDraft(infoSource);
|
if (dirtyBaseline) setDraft(dirtyBaseline);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -382,7 +403,9 @@ export function WorkOrderSlideOver({
|
||||||
onCancelEdit={handleCancelEdit}
|
onCancelEdit={handleCancelEdit}
|
||||||
onClose={requestClose}
|
onClose={requestClose}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setDraft(infoSource);
|
const source = infoSource;
|
||||||
|
setDraft(source);
|
||||||
|
setBaseline(source);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setTab("info");
|
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