refactor(web): extract allocation release effects
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js";
|
||||
|
||||
const BASE_ALLOC = {
|
||||
isActive: true,
|
||||
mode: "move" as const,
|
||||
scope: "allocation" as const,
|
||||
allocationId: "alloc-1",
|
||||
mutationAllocationId: "alloc-1",
|
||||
projectId: "project-1",
|
||||
projectName: "Project One",
|
||||
allocationStartDate: new Date("2025-01-01"),
|
||||
allocationEndDate: new Date("2025-01-03"),
|
||||
originalStartDate: new Date("2025-01-01"),
|
||||
originalEndDate: new Date("2025-01-03"),
|
||||
currentStartDate: new Date("2025-01-01"),
|
||||
currentEndDate: new Date("2025-01-03"),
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
};
|
||||
|
||||
function createOptimisticStateHarness() {
|
||||
let optimistic = new Map<string, { startDate: Date; endDate: Date }>();
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return optimistic;
|
||||
},
|
||||
setOptimisticAllocations(
|
||||
updater: (
|
||||
prev: Map<string, { startDate: Date; endDate: Date }>,
|
||||
) => Map<string, { startDate: Date; endDate: Date }>,
|
||||
) {
|
||||
optimistic = updater(optimistic);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("timelineAllocationReleaseEffects", () => {
|
||||
it("ignores inactive releases without touching preview or optimistic state", async () => {
|
||||
const updatePosition = vi.fn();
|
||||
const preserve = vi.fn();
|
||||
const clear = vi.fn();
|
||||
const onShiftClick = vi.fn();
|
||||
const onBlockClick = vi.fn();
|
||||
const optimisticHarness = createOptimisticStateHarness();
|
||||
const pendingSnapshotRef = { current: null };
|
||||
const pendingOptimisticAllocationIdRef = { current: null as string | null };
|
||||
|
||||
await finalizeAllocationReleaseEffects({
|
||||
clientX: 24,
|
||||
allocRef: { current: { ...BASE_ALLOC, isActive: false } },
|
||||
previewRef: { current: {} as never },
|
||||
updatePosition,
|
||||
clickThresholdPx: 5,
|
||||
wasShift: false,
|
||||
onShiftClick,
|
||||
onBlockClick,
|
||||
pendingSnapshotRef,
|
||||
pendingOptimisticAllocationIdRef,
|
||||
setOptimisticAllocations: optimisticHarness.setOptimisticAllocations,
|
||||
extractAllocationFragment: vi.fn(),
|
||||
updateAllocation: vi.fn(),
|
||||
clearPendingOptimisticAllocation: vi.fn(),
|
||||
previewOps: { preserve, clear },
|
||||
});
|
||||
|
||||
expect(updatePosition).toHaveBeenCalledWith(24);
|
||||
expect(preserve).not.toHaveBeenCalled();
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
expect(onShiftClick).not.toHaveBeenCalled();
|
||||
expect(onBlockClick).not.toHaveBeenCalled();
|
||||
expect(pendingSnapshotRef.current).toBeNull();
|
||||
expect(pendingOptimisticAllocationIdRef.current).toBeNull();
|
||||
expect(optimisticHarness.value.size).toBe(0);
|
||||
});
|
||||
|
||||
it("clears preview and dispatches click releases without starting mutations", async () => {
|
||||
const clear = vi.fn();
|
||||
const onBlockClick = vi.fn();
|
||||
const optimisticHarness = createOptimisticStateHarness();
|
||||
const previewRef = { current: {} as never };
|
||||
|
||||
await finalizeAllocationReleaseEffects({
|
||||
clientX: 40,
|
||||
allocRef: { current: BASE_ALLOC },
|
||||
previewRef,
|
||||
updatePosition: vi.fn(),
|
||||
clickThresholdPx: 5,
|
||||
wasShift: false,
|
||||
onBlockClick,
|
||||
pendingSnapshotRef: { current: null },
|
||||
pendingOptimisticAllocationIdRef: { current: null },
|
||||
setOptimisticAllocations: optimisticHarness.setOptimisticAllocations,
|
||||
extractAllocationFragment: vi.fn(),
|
||||
updateAllocation: vi.fn(),
|
||||
clearPendingOptimisticAllocation: vi.fn(),
|
||||
resolveRelease: () => ({
|
||||
kind: "click",
|
||||
clickInfo: {
|
||||
allocationId: "alloc-1",
|
||||
projectId: "project-1",
|
||||
projectName: "Project One",
|
||||
startDate: new Date("2025-01-01"),
|
||||
endDate: new Date("2025-01-03"),
|
||||
},
|
||||
preservePreview: true,
|
||||
}),
|
||||
previewOps: { preserve: vi.fn(), clear },
|
||||
});
|
||||
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
expect(onBlockClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ allocationId: "alloc-1", projectId: "project-1" }),
|
||||
);
|
||||
expect(previewRef.current).toBeNull();
|
||||
expect(optimisticHarness.value.size).toBe(0);
|
||||
});
|
||||
|
||||
it("starts optimistic mutation flow and rewrites the pending snapshot after extraction", async () => {
|
||||
const preserve = vi.fn();
|
||||
const clear = vi.fn();
|
||||
const extractAllocationFragment = vi.fn().mockResolvedValue({
|
||||
extractedAllocationId: "alloc-fragment-2",
|
||||
});
|
||||
const updateAllocation = vi.fn();
|
||||
const optimisticHarness = createOptimisticStateHarness();
|
||||
const pendingSnapshotRef = {
|
||||
current: null as {
|
||||
allocationId: string;
|
||||
mutationAllocationId: string;
|
||||
projectName: string;
|
||||
before: { startDate: Date; endDate: Date };
|
||||
after: { startDate: Date; endDate: Date };
|
||||
} | null,
|
||||
};
|
||||
const pendingOptimisticAllocationIdRef = { current: null as string | null };
|
||||
|
||||
await finalizeAllocationReleaseEffects({
|
||||
clientX: 44,
|
||||
allocRef: { current: BASE_ALLOC },
|
||||
previewRef: { current: {} as never },
|
||||
updatePosition: vi.fn(),
|
||||
clickThresholdPx: 5,
|
||||
wasShift: false,
|
||||
pendingSnapshotRef,
|
||||
pendingOptimisticAllocationIdRef,
|
||||
setOptimisticAllocations: optimisticHarness.setOptimisticAllocations,
|
||||
extractAllocationFragment,
|
||||
updateAllocation,
|
||||
clearPendingOptimisticAllocation: vi.fn(),
|
||||
resolveRelease: () => ({
|
||||
kind: "mutation",
|
||||
preservePreview: true,
|
||||
mutationPlan: {
|
||||
activeAllocationId: "alloc-1",
|
||||
currentStartDate: new Date("2025-01-05"),
|
||||
currentEndDate: new Date("2025-01-07"),
|
||||
baseMutationAllocationId: "alloc-1",
|
||||
requiresExtraction: true,
|
||||
pendingSnapshot: {
|
||||
allocationId: "alloc-1",
|
||||
mutationAllocationId: "alloc-1",
|
||||
projectName: "Project One",
|
||||
before: {
|
||||
startDate: new Date("2025-01-01"),
|
||||
endDate: new Date("2025-01-03"),
|
||||
},
|
||||
after: {
|
||||
startDate: new Date("2025-01-05"),
|
||||
endDate: new Date("2025-01-07"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
previewOps: { preserve, clear },
|
||||
});
|
||||
|
||||
expect(preserve).toHaveBeenCalledOnce();
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
expect(pendingOptimisticAllocationIdRef.current).toBe("alloc-1");
|
||||
expect(optimisticHarness.value.get("alloc-1")).toEqual({
|
||||
startDate: new Date("2025-01-05"),
|
||||
endDate: new Date("2025-01-07"),
|
||||
});
|
||||
expect(extractAllocationFragment).toHaveBeenCalledWith({
|
||||
allocationId: "alloc-1",
|
||||
startDate: new Date("2025-01-01"),
|
||||
endDate: new Date("2025-01-03"),
|
||||
});
|
||||
expect(pendingSnapshotRef.current).toEqual(
|
||||
expect.objectContaining({ mutationAllocationId: "alloc-fragment-2" }),
|
||||
);
|
||||
expect(updateAllocation).toHaveBeenCalledWith({
|
||||
allocationId: "alloc-fragment-2",
|
||||
startDate: new Date("2025-01-05"),
|
||||
endDate: new Date("2025-01-07"),
|
||||
});
|
||||
});
|
||||
|
||||
it("clears optimistic state when extraction fails", async () => {
|
||||
const optimisticHarness = createOptimisticStateHarness();
|
||||
const pendingSnapshotRef = {
|
||||
current: null as {
|
||||
allocationId: string;
|
||||
mutationAllocationId: string;
|
||||
projectName: string;
|
||||
before: { startDate: Date; endDate: Date };
|
||||
after: { startDate: Date; endDate: Date };
|
||||
} | null,
|
||||
};
|
||||
const pendingOptimisticAllocationIdRef = { current: null as string | null };
|
||||
const clearPendingOptimisticAllocation = vi.fn((allocationId: string) => {
|
||||
pendingSnapshotRef.current = null;
|
||||
pendingOptimisticAllocationIdRef.current = null;
|
||||
optimisticHarness.setOptimisticAllocations((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(allocationId);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
const updateAllocation = vi.fn();
|
||||
|
||||
await finalizeAllocationReleaseEffects({
|
||||
clientX: 52,
|
||||
allocRef: { current: BASE_ALLOC },
|
||||
previewRef: { current: {} as never },
|
||||
updatePosition: vi.fn(),
|
||||
clickThresholdPx: 5,
|
||||
wasShift: false,
|
||||
pendingSnapshotRef,
|
||||
pendingOptimisticAllocationIdRef,
|
||||
setOptimisticAllocations: optimisticHarness.setOptimisticAllocations,
|
||||
extractAllocationFragment: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
updateAllocation,
|
||||
clearPendingOptimisticAllocation,
|
||||
resolveRelease: () => ({
|
||||
kind: "mutation",
|
||||
preservePreview: true,
|
||||
mutationPlan: {
|
||||
activeAllocationId: "alloc-1",
|
||||
currentStartDate: new Date("2025-01-05"),
|
||||
currentEndDate: new Date("2025-01-07"),
|
||||
baseMutationAllocationId: "alloc-1",
|
||||
requiresExtraction: true,
|
||||
pendingSnapshot: {
|
||||
allocationId: "alloc-1",
|
||||
mutationAllocationId: "alloc-1",
|
||||
projectName: "Project One",
|
||||
before: {
|
||||
startDate: new Date("2025-01-01"),
|
||||
endDate: new Date("2025-01-03"),
|
||||
},
|
||||
after: {
|
||||
startDate: new Date("2025-01-05"),
|
||||
endDate: new Date("2025-01-07"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
previewOps: { preserve: vi.fn(), clear: vi.fn() },
|
||||
});
|
||||
|
||||
expect(clearPendingOptimisticAllocation).toHaveBeenCalledWith("alloc-1");
|
||||
expect(updateAllocation).not.toHaveBeenCalled();
|
||||
expect(pendingSnapshotRef.current).toBeNull();
|
||||
expect(pendingOptimisticAllocationIdRef.current).toBeNull();
|
||||
expect(optimisticHarness.value.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { type AllocationMovedSnapshotLike } from "./timelineAllocationFinalize.js";
|
||||
import { clearLivePreview, preserveLivePreview, type LivePreviewSession } from "./timelineLivePreview.js";
|
||||
import { resolveAllocationRelease, type AllocationReleaseOutcome } from "./timelineAllocationRelease.js";
|
||||
|
||||
type MutableCurrent<T> = { current: T };
|
||||
type AllocationDragReleaseLike = Parameters<typeof resolveAllocationRelease>[0];
|
||||
type AllocationBlockClickInfo = Extract<AllocationReleaseOutcome, { kind: "click" }>["clickInfo"];
|
||||
type OptimisticAllocationOverride = { startDate: Date; endDate: Date };
|
||||
type SetOptimisticAllocations = (
|
||||
updater: (prev: Map<string, OptimisticAllocationOverride>) => Map<string, OptimisticAllocationOverride>,
|
||||
) => void;
|
||||
type MutationInput = { allocationId: string; startDate: Date; endDate: Date };
|
||||
type PreviewOps = {
|
||||
preserve: (preview: LivePreviewSession | null) => void;
|
||||
clear: (preview: LivePreviewSession | null) => void;
|
||||
};
|
||||
|
||||
type FinalizeAllocationReleaseEffectsParams = {
|
||||
clientX: number;
|
||||
allocRef: MutableCurrent<AllocationDragReleaseLike>;
|
||||
previewRef: MutableCurrent<LivePreviewSession | null>;
|
||||
updatePosition: (clientX: number) => void;
|
||||
clickThresholdPx: number;
|
||||
wasShift: boolean;
|
||||
onShiftClick?: ((allocationId: string) => void) | undefined;
|
||||
onBlockClick?: ((clickInfo: AllocationBlockClickInfo) => void) | undefined;
|
||||
pendingSnapshotRef: MutableCurrent<AllocationMovedSnapshotLike | null>;
|
||||
pendingOptimisticAllocationIdRef: MutableCurrent<string | null>;
|
||||
setOptimisticAllocations: SetOptimisticAllocations;
|
||||
extractAllocationFragment: (input: MutationInput) => Promise<{ extractedAllocationId: string }>;
|
||||
updateAllocation: (input: MutationInput) => void;
|
||||
clearPendingOptimisticAllocation: (allocationId: string) => void;
|
||||
resolveRelease?: (
|
||||
alloc: AllocationDragReleaseLike,
|
||||
options: { clickThresholdPx: number; wasShift: boolean },
|
||||
) => AllocationReleaseOutcome;
|
||||
previewOps?: PreviewOps;
|
||||
};
|
||||
|
||||
export async function finalizeAllocationReleaseEffects({
|
||||
clientX,
|
||||
allocRef,
|
||||
previewRef,
|
||||
updatePosition,
|
||||
clickThresholdPx,
|
||||
wasShift,
|
||||
onShiftClick,
|
||||
onBlockClick,
|
||||
pendingSnapshotRef,
|
||||
pendingOptimisticAllocationIdRef,
|
||||
setOptimisticAllocations,
|
||||
extractAllocationFragment,
|
||||
updateAllocation,
|
||||
clearPendingOptimisticAllocation,
|
||||
resolveRelease = resolveAllocationRelease,
|
||||
previewOps = { preserve: preserveLivePreview, clear: clearLivePreview },
|
||||
}: FinalizeAllocationReleaseEffectsParams): Promise<void> {
|
||||
updatePosition(clientX);
|
||||
const alloc = allocRef.current;
|
||||
const release = resolveRelease(alloc, { clickThresholdPx, wasShift });
|
||||
if (release.kind === "ignore") return;
|
||||
if (release.preservePreview) previewOps.preserve(previewRef.current);
|
||||
previewOps.clear(previewRef.current);
|
||||
previewRef.current = null;
|
||||
if (release.kind === "shift-click") return void onShiftClick?.(release.allocationId);
|
||||
if (release.kind === "click") return void onBlockClick?.(release.clickInfo);
|
||||
if (release.kind !== "mutation") return;
|
||||
|
||||
const {
|
||||
activeAllocationId,
|
||||
currentStartDate,
|
||||
currentEndDate,
|
||||
baseMutationAllocationId,
|
||||
requiresExtraction,
|
||||
pendingSnapshot,
|
||||
} = release.mutationPlan;
|
||||
|
||||
pendingSnapshotRef.current = pendingSnapshot;
|
||||
pendingOptimisticAllocationIdRef.current = activeAllocationId;
|
||||
setOptimisticAllocations((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(activeAllocationId, { startDate: currentStartDate, endDate: currentEndDate });
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
let mutationAllocationId = baseMutationAllocationId;
|
||||
if (requiresExtraction) {
|
||||
if (!alloc.originalStartDate || !alloc.originalEndDate) throw new Error("missing original allocation dates");
|
||||
const extracted = await extractAllocationFragment({
|
||||
allocationId: mutationAllocationId,
|
||||
startDate: alloc.originalStartDate,
|
||||
endDate: alloc.originalEndDate,
|
||||
});
|
||||
mutationAllocationId = extracted.extractedAllocationId;
|
||||
}
|
||||
pendingSnapshotRef.current = pendingSnapshotRef.current
|
||||
? { ...pendingSnapshotRef.current, mutationAllocationId }
|
||||
: null;
|
||||
updateAllocation({ allocationId: mutationAllocationId, startDate: currentStartDate, endDate: currentEndDate });
|
||||
} catch {
|
||||
clearPendingOptimisticAllocation(activeAllocationId);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
scheduleLivePreview,
|
||||
type LivePreviewSession,
|
||||
} from "./timelineLivePreview.js";
|
||||
import { buildAllocationMovedSnapshot } from "./timelineAllocationFinalize.js";
|
||||
import {
|
||||
finalizeAllocationMultiDrag,
|
||||
isAllocationMultiSelected,
|
||||
@@ -19,9 +18,9 @@ import {
|
||||
updateAllocationMultiDrag,
|
||||
} from "./timelineAllocationMultiDrag.js";
|
||||
import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSession.js";
|
||||
import { resolveAllocationRelease } from "./timelineAllocationRelease.js";
|
||||
import { createAllocationDragState } from "./timelineAllocationDragState.js";
|
||||
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
|
||||
import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js";
|
||||
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
||||
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
|
||||
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
|
||||
@@ -640,75 +639,22 @@ export function useTimelineDrag({
|
||||
attachDrag: attachDocumentMouseDrag,
|
||||
updatePosition: updateAllocationDragPosition,
|
||||
finalize: (clientX) => {
|
||||
updateAllocationDragPosition(clientX);
|
||||
const alloc = allocDragRef.current;
|
||||
const release = resolveAllocationRelease(alloc, {
|
||||
void finalizeAllocationReleaseEffects({
|
||||
clientX,
|
||||
allocRef: allocDragRef,
|
||||
previewRef: allocPreviewRef,
|
||||
updatePosition: updateAllocationDragPosition,
|
||||
clickThresholdPx: DRAG_CLICK_THRESHOLD_PX,
|
||||
wasShift,
|
||||
onShiftClick: onShiftClickAllocRef.current,
|
||||
onBlockClick: onBlockClickRef.current,
|
||||
pendingSnapshotRef,
|
||||
pendingOptimisticAllocationIdRef,
|
||||
setOptimisticAllocations,
|
||||
extractAllocationFragment: extractAllocFragmentMutation.mutateAsync,
|
||||
updateAllocation: updateAllocMutation.mutate,
|
||||
clearPendingOptimisticAllocation,
|
||||
});
|
||||
if (release.kind === "ignore") return;
|
||||
|
||||
if (release.preservePreview) {
|
||||
preserveLivePreview(allocPreviewRef.current);
|
||||
}
|
||||
clearLivePreview(allocPreviewRef.current);
|
||||
allocPreviewRef.current = null;
|
||||
|
||||
if (release.kind === "shift-click") {
|
||||
onShiftClickAllocRef.current?.(release.allocationId);
|
||||
} else if (release.kind === "click") {
|
||||
onBlockClickRef.current?.(release.clickInfo);
|
||||
} else if (release.kind === "mutation") {
|
||||
const { mutationPlan } = release;
|
||||
const {
|
||||
activeAllocationId,
|
||||
currentStartDate,
|
||||
currentEndDate,
|
||||
baseMutationAllocationId,
|
||||
requiresExtraction,
|
||||
pendingSnapshot,
|
||||
} = mutationPlan;
|
||||
|
||||
pendingSnapshotRef.current = pendingSnapshot;
|
||||
pendingOptimisticAllocationIdRef.current = activeAllocationId;
|
||||
setOptimisticAllocations((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(activeAllocationId, {
|
||||
startDate: currentStartDate,
|
||||
endDate: currentEndDate,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
let mutationAllocationId = baseMutationAllocationId;
|
||||
|
||||
if (requiresExtraction) {
|
||||
const extracted = await extractAllocFragmentMutation.mutateAsync({
|
||||
allocationId: mutationAllocationId,
|
||||
startDate: alloc.originalStartDate!,
|
||||
endDate: alloc.originalEndDate!,
|
||||
});
|
||||
mutationAllocationId = extracted.extractedAllocationId;
|
||||
}
|
||||
|
||||
pendingSnapshotRef.current = pendingSnapshotRef.current
|
||||
? {
|
||||
...pendingSnapshotRef.current,
|
||||
mutationAllocationId,
|
||||
}
|
||||
: null;
|
||||
|
||||
updateAllocMutation.mutate({
|
||||
allocationId: mutationAllocationId,
|
||||
startDate: currentStartDate,
|
||||
endDate: currentEndDate,
|
||||
});
|
||||
} catch {
|
||||
clearPendingOptimisticAllocation(activeAllocationId);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
allocDragRef.current = INITIAL_ALLOC_DRAG;
|
||||
setAllocDragState(INITIAL_ALLOC_DRAG);
|
||||
|
||||
Reference in New Issue
Block a user