refactor(web): extract project drag finalize

This commit is contained in:
2026-04-01 11:49:14 +02:00
parent 463caedcfd
commit 1e2bd3d4eb
5 changed files with 218 additions and 30 deletions
@@ -0,0 +1,120 @@
import { describe, expect, it, vi } from "vitest";
import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js";
const BASE_DRAG = {
isDragging: true,
projectId: "project-1",
currentStartDate: new Date("2025-01-12T00:00:00.000Z"),
currentEndDate: new Date("2025-01-22T00:00:00.000Z"),
daysDelta: 2,
};
describe("timelineProjectDragFinalize", () => {
it("returns null without clearing when the drag is already inactive", () => {
const updatePosition = vi.fn();
const clearSession = vi.fn();
expect(
finalizeProjectDrag({
clientX: 20,
dragRef: { current: { ...BASE_DRAG, isDragging: false } },
previewRef: { current: null },
updatePosition,
clearSession,
mutate: vi.fn(),
mutateAsync: vi.fn(),
}),
).toBeNull();
expect(updatePosition).toHaveBeenCalledWith(20);
expect(clearSession).not.toHaveBeenCalled();
});
it("preserves preview, clears the session, and fires mutate for real drags", () => {
const clearSession = vi.fn();
const mutate = vi.fn();
const cancelAnimationFrameSpy = vi.fn();
vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrameSpy);
const previewRef = {
current: {
mode: "move" as const,
cellWidth: 48,
targets: [],
pointerDeltaX: 0,
daysDelta: 0,
frame: 7,
},
};
try {
expect(
finalizeProjectDrag({
clientX: 24,
dragRef: { current: BASE_DRAG },
previewRef,
updatePosition: vi.fn(),
clearSession,
mutate,
mutateAsync: vi.fn(),
}),
).toBeNull();
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7);
expect(previewRef.current.frame).toBeNull();
expect(clearSession).toHaveBeenCalledOnce();
expect(mutate).toHaveBeenCalledWith({
projectId: "project-1",
newStartDate: new Date("2025-01-12T00:00:00.000Z"),
newEndDate: new Date("2025-01-22T00:00:00.000Z"),
});
} finally {
vi.unstubAllGlobals();
}
});
it("clears but suppresses mutation for no-op drags", () => {
const clearSession = vi.fn();
const mutate = vi.fn();
const mutateAsync = vi.fn();
expect(
finalizeProjectDrag({
clientX: 30,
dragRef: { current: { ...BASE_DRAG, daysDelta: 0 } },
previewRef: { current: null },
updatePosition: vi.fn(),
clearSession,
mutate,
mutateAsync,
}),
).toBeNull();
expect(clearSession).toHaveBeenCalledOnce();
expect(mutate).not.toHaveBeenCalled();
expect(mutateAsync).not.toHaveBeenCalled();
});
it("returns the async mutation promise when requested", async () => {
const result = { ok: true };
const mutateAsync = vi.fn().mockResolvedValue(result);
await expect(
finalizeProjectDrag({
clientX: 36,
mode: "mutateAsync",
dragRef: { current: BASE_DRAG },
previewRef: { current: null },
updatePosition: vi.fn(),
clearSession: vi.fn(),
mutate: vi.fn(),
mutateAsync,
}),
).resolves.toEqual(result);
expect(mutateAsync).toHaveBeenCalledWith({
projectId: "project-1",
newStartDate: new Date("2025-01-12T00:00:00.000Z"),
newEndDate: new Date("2025-01-22T00:00:00.000Z"),
});
});
});
@@ -0,0 +1,38 @@
import { buildProjectShiftMutationInput } from "./timelineProjectDrag.js";
import { preserveLivePreview, type LivePreviewSession } from "./timelineLivePreview.js";
type MutableCurrent<T> = { current: T };
type ProjectDragFinalizeLike = Parameters<typeof buildProjectShiftMutationInput>[0] & { isDragging: boolean };
export function finalizeProjectDrag({
clientX,
mode = "mutate",
dragRef,
previewRef,
updatePosition,
clearSession,
mutate,
mutateAsync,
}: {
clientX: number;
mode?: "mutate" | "mutateAsync";
dragRef: MutableCurrent<ProjectDragFinalizeLike>;
previewRef: MutableCurrent<LivePreviewSession | null>;
updatePosition: (clientX: number) => void;
clearSession: () => void;
mutate: (input: { projectId: string; newStartDate: Date; newEndDate: Date }) => void;
mutateAsync: (input: { projectId: string; newStartDate: Date; newEndDate: Date }) => Promise<unknown>;
}) {
updatePosition(clientX);
const finalDrag = dragRef.current;
if (!finalDrag.isDragging) return null;
const mutationInput = buildProjectShiftMutationInput(finalDrag);
if (finalDrag.daysDelta !== 0) preserveLivePreview(previewRef.current);
clearSession();
if (!mutationInput) return null;
if (mode === "mutateAsync") return mutateAsync(mutationInput);
mutate(mutationInput);
return null;
}
+18 -27
View File
@@ -24,7 +24,8 @@ import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEff
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js";
import { createProjectDragState } from "./timelineProjectDrag.js";
import { beginProjectDragSession } from "./timelineProjectDragSession.js";
import {
forwardCanvasTouchEnd,
@@ -408,28 +409,18 @@ export function useTimelineDrag({
mutateAsync: (...args: unknown[]) => Promise<unknown>;
};
const finalizeProjectDrag = useCallback(
(clientX: number, mode: "mutate" | "mutateAsync" = "mutate") => {
updateProjectDragPosition(clientX);
const finalDrag = dragStateRef.current;
if (!finalDrag.isDragging) return null;
const mutationInput = buildProjectShiftMutationInput(finalDrag);
if (finalDrag.daysDelta !== 0) {
preserveLivePreview(projectPreviewRef.current);
}
clearProjectDragSession();
if (!mutationInput) return null;
if (mode === "mutateAsync") {
return applyShiftMutation.mutateAsync(mutationInput);
}
applyShiftMutation.mutate(mutationInput);
return null;
},
const finalizeActiveProjectDrag = useCallback(
(clientX: number, mode: "mutate" | "mutateAsync" = "mutate") =>
finalizeProjectDrag({
clientX,
mode,
dragRef: dragStateRef,
previewRef: projectPreviewRef,
updatePosition: updateProjectDragPosition,
clearSession: clearProjectDragSession,
mutate: applyShiftMutation.mutate,
mutateAsync: applyShiftMutation.mutateAsync,
}),
[applyShiftMutation, clearProjectDragSession, updateProjectDragPosition],
);
@@ -516,11 +507,11 @@ export function useTimelineDrag({
attachDrag: attachDocumentMouseDrag,
updatePosition: updateProjectDragPosition,
finalize: (clientX) => {
void finalizeProjectDrag(clientX);
void finalizeActiveProjectDrag(clientX);
},
});
},
[finalizeProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
[finalizeActiveProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
);
// Legacy — kept for backward compat (triggers project shift from allocation block)
@@ -717,7 +708,7 @@ export function useTimelineDrag({
const drag = dragStateRef.current;
if (drag.isDragging) {
try {
await finalizeProjectDrag(e.clientX, "mutateAsync");
await finalizeActiveProjectDrag(e.clientX, "mutateAsync");
} catch {
// Validation error — revert visually
}
@@ -734,7 +725,7 @@ export function useTimelineDrag({
setRangeState(INITIAL_RANGE_STATE);
}
},
[finalizeProjectDrag, onRangeSelected],
[finalizeActiveProjectDrag, onRangeSelected],
);
const onCanvasMouseLeave = useCallback(() => {