refactor(web): extract project drag session
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { beginProjectDragSession } from "./timelineProjectDragSession.js";
|
||||||
|
|
||||||
|
describe("timelineProjectDragSession", () => {
|
||||||
|
it("starts the session, forwards movement, and finalizes on mouseup", () => {
|
||||||
|
const previousCleanup = vi.fn();
|
||||||
|
const setState = vi.fn();
|
||||||
|
const updatePosition = vi.fn();
|
||||||
|
const finalize = vi.fn();
|
||||||
|
const state = { projectId: "project-1", isDragging: true };
|
||||||
|
const cleanupRef = { current: previousCleanup as (() => void) | null };
|
||||||
|
const stateRef = { current: { projectId: null, isDragging: false } };
|
||||||
|
const handlers: {
|
||||||
|
move?: (event: MouseEvent) => void;
|
||||||
|
up?: (event: MouseEvent) => void;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
const attachDrag = vi.fn((_: Document, onMove: (event: MouseEvent) => void, onUp: (event: MouseEvent) => void) => {
|
||||||
|
handlers.move = onMove;
|
||||||
|
handlers.up = onUp;
|
||||||
|
return vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
beginProjectDragSession({
|
||||||
|
state,
|
||||||
|
cleanupRef,
|
||||||
|
stateRef,
|
||||||
|
setState,
|
||||||
|
documentTarget: {} as Document,
|
||||||
|
attachDrag,
|
||||||
|
updatePosition,
|
||||||
|
finalize,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(previousCleanup).toHaveBeenCalledOnce();
|
||||||
|
expect(stateRef.current).toBe(state);
|
||||||
|
expect(setState).toHaveBeenCalledWith(state);
|
||||||
|
|
||||||
|
handlers.move?.({ clientX: 42 } as MouseEvent);
|
||||||
|
expect(updatePosition).toHaveBeenCalledWith(42);
|
||||||
|
|
||||||
|
const preventDefault = vi.fn();
|
||||||
|
const attachedCleanup = cleanupRef.current;
|
||||||
|
handlers.up?.({ clientX: 54, preventDefault } as unknown as MouseEvent);
|
||||||
|
|
||||||
|
expect(attachedCleanup).toHaveBeenCalledOnce();
|
||||||
|
expect(cleanupRef.current).toBeNull();
|
||||||
|
expect(finalize).toHaveBeenCalledWith(54);
|
||||||
|
expect(preventDefault).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works when no prior cleanup is registered", () => {
|
||||||
|
const setState = vi.fn();
|
||||||
|
const updatePosition = vi.fn();
|
||||||
|
const finalize = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const cleanupRef = { current: null as (() => void) | null };
|
||||||
|
const stateRef = { current: { projectId: null, isDragging: false } };
|
||||||
|
let upHandler: ((event: MouseEvent) => void) | undefined;
|
||||||
|
|
||||||
|
beginProjectDragSession({
|
||||||
|
state: { projectId: "project-2", isDragging: true },
|
||||||
|
cleanupRef,
|
||||||
|
stateRef,
|
||||||
|
setState,
|
||||||
|
documentTarget: {} as Document,
|
||||||
|
attachDrag: (_documentTarget, _onMove, onUp) => {
|
||||||
|
upHandler = onUp;
|
||||||
|
return vi.fn();
|
||||||
|
},
|
||||||
|
updatePosition,
|
||||||
|
finalize,
|
||||||
|
});
|
||||||
|
|
||||||
|
upHandler?.({ clientX: 12, preventDefault() {} } as MouseEvent);
|
||||||
|
|
||||||
|
expect(updatePosition).not.toHaveBeenCalled();
|
||||||
|
expect(finalize).toHaveBeenCalledWith(12);
|
||||||
|
expect(cleanupRef.current).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
type MutableCurrent<T> = {
|
||||||
|
current: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AttachDocumentMouseDrag = (
|
||||||
|
documentTarget: Document,
|
||||||
|
onMove: (event: MouseEvent) => void,
|
||||||
|
onUp: (event: MouseEvent) => void,
|
||||||
|
) => () => void;
|
||||||
|
|
||||||
|
type BeginProjectDragSessionParams<TState> = {
|
||||||
|
state: TState;
|
||||||
|
cleanupRef: MutableCurrent<(() => void) | null>;
|
||||||
|
stateRef: MutableCurrent<TState>;
|
||||||
|
setState: (state: TState) => void;
|
||||||
|
documentTarget: Document;
|
||||||
|
attachDrag: AttachDocumentMouseDrag;
|
||||||
|
updatePosition: (clientX: number) => void;
|
||||||
|
finalize: (clientX: number) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function beginProjectDragSession<TState>({
|
||||||
|
state,
|
||||||
|
cleanupRef,
|
||||||
|
stateRef,
|
||||||
|
setState,
|
||||||
|
documentTarget,
|
||||||
|
attachDrag,
|
||||||
|
updatePosition,
|
||||||
|
finalize,
|
||||||
|
}: BeginProjectDragSessionParams<TState>) {
|
||||||
|
stateRef.current = state;
|
||||||
|
setState(state);
|
||||||
|
cleanupRef.current?.();
|
||||||
|
|
||||||
|
function handleMove(event: MouseEvent) {
|
||||||
|
updatePosition(event.clientX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUp(event: MouseEvent) {
|
||||||
|
cleanupRef.current?.();
|
||||||
|
cleanupRef.current = null;
|
||||||
|
void finalize(event.clientX);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupRef.current = attachDrag(documentTarget, handleMove, handleUp);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { createAllocationDragState } from "./timelineAllocationDragState.js";
|
|||||||
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
import { cleanupTimelineDragState } from "./timelineDragCleanup.js";
|
||||||
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
|
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
|
||||||
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
|
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
|
||||||
|
import { beginProjectDragSession } from "./timelineProjectDragSession.js";
|
||||||
import {
|
import {
|
||||||
completeMultiSelectDraft,
|
completeMultiSelectDraft,
|
||||||
createMultiSelectState,
|
createMultiSelectState,
|
||||||
@@ -550,24 +551,19 @@ export function useTimelineDrag({
|
|||||||
endDate: opts.endDate,
|
endDate: opts.endDate,
|
||||||
startMouseX: e.clientX,
|
startMouseX: e.clientX,
|
||||||
});
|
});
|
||||||
dragStateRef.current = state;
|
|
||||||
setDragState(state);
|
|
||||||
|
|
||||||
setProjectPreviewTargets(opts.projectId, e.currentTarget);
|
setProjectPreviewTargets(opts.projectId, e.currentTarget);
|
||||||
projectDragCleanupRef.current?.();
|
beginProjectDragSession({
|
||||||
|
state,
|
||||||
function handleMove(ev: MouseEvent) {
|
cleanupRef: projectDragCleanupRef,
|
||||||
updateProjectDragPosition(ev.clientX);
|
stateRef: dragStateRef,
|
||||||
}
|
setState: setDragState,
|
||||||
|
documentTarget: document,
|
||||||
function handleUp(ev: MouseEvent) {
|
attachDrag: attachDocumentMouseDrag,
|
||||||
projectDragCleanupRef.current?.();
|
updatePosition: updateProjectDragPosition,
|
||||||
projectDragCleanupRef.current = null;
|
finalize: (clientX) => {
|
||||||
void finalizeProjectDrag(ev.clientX);
|
void finalizeProjectDrag(clientX);
|
||||||
ev.preventDefault();
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
projectDragCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp);
|
|
||||||
},
|
},
|
||||||
[finalizeProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
|
[finalizeProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -329,6 +329,17 @@ export const rules = [
|
|||||||
],
|
],
|
||||||
forbidden: [],
|
forbidden: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
file: "apps/web/src/hooks/timelineProjectDragSession.ts",
|
||||||
|
maxLines: 70,
|
||||||
|
required: [
|
||||||
|
{
|
||||||
|
pattern: /\bexport function beginProjectDragSession\b/,
|
||||||
|
message: "timeline project drag session helpers must keep document drag lifecycle centralized",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
forbidden: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
file: "apps/web/src/hooks/useTimelineDrag.ts",
|
file: "apps/web/src/hooks/useTimelineDrag.ts",
|
||||||
required: [
|
required: [
|
||||||
@@ -388,6 +399,10 @@ export const rules = [
|
|||||||
pattern: /from "\.\/timelineProjectDrag\.js"/,
|
pattern: /from "\.\/timelineProjectDrag\.js"/,
|
||||||
message: "timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
|
message: "timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
pattern: /from "\.\/timelineProjectDragSession\.js"/,
|
||||||
|
message: "timeline drag must keep project drag document session wiring delegated to the extracted helper module",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
forbidden: [
|
forbidden: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ describe("architecture guardrails", () => {
|
|||||||
const documentDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineDocumentDrag.ts");
|
const documentDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineDocumentDrag.ts");
|
||||||
const allocationDragStateRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationDragState.ts");
|
const allocationDragStateRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationDragState.ts");
|
||||||
const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts");
|
const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts");
|
||||||
|
const projectDragSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDragSession.ts");
|
||||||
|
|
||||||
assert.ok(dragRule);
|
assert.ok(dragRule);
|
||||||
assert.ok(livePreviewRule);
|
assert.ok(livePreviewRule);
|
||||||
@@ -102,6 +103,7 @@ describe("architecture guardrails", () => {
|
|||||||
assert.ok(documentDragRule);
|
assert.ok(documentDragRule);
|
||||||
assert.ok(allocationDragStateRule);
|
assert.ok(allocationDragStateRule);
|
||||||
assert.ok(projectDragRule);
|
assert.ok(projectDragRule);
|
||||||
|
assert.ok(projectDragSessionRule);
|
||||||
|
|
||||||
assert.deepEqual(evaluateRule(dragRule, "function clearLivePreview() {}\n"), [
|
assert.deepEqual(evaluateRule(dragRule, "function clearLivePreview() {}\n"), [
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep live preview behavior delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep live preview behavior delegated to the extracted helper module",
|
||||||
@@ -118,6 +120,7 @@ describe("architecture guardrails", () => {
|
|||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag rules delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag rules delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
|
||||||
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline live preview helper implementations",
|
"apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline live preview helper implementations",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -189,6 +192,10 @@ describe("architecture guardrails", () => {
|
|||||||
"apps/web/src/hooks/timelineProjectDrag.ts: missing guardrail anchor: timeline project drag helpers must keep no-op project-shift mutation gating centralized",
|
"apps/web/src/hooks/timelineProjectDrag.ts: missing guardrail anchor: timeline project drag helpers must keep no-op project-shift mutation gating centralized",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(evaluateRule(projectDragSessionRule, ""), [
|
||||||
|
"apps/web/src/hooks/timelineProjectDragSession.ts: missing guardrail anchor: timeline project drag session helpers must keep document drag lifecycle centralized",
|
||||||
|
]);
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
evaluateRule(
|
evaluateRule(
|
||||||
dragRule,
|
dragRule,
|
||||||
@@ -208,6 +215,7 @@ describe("architecture guardrails", () => {
|
|||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag rules delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag rules delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module",
|
||||||
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep project drag document session wiring delegated to the extracted helper module",
|
||||||
"apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline synthetic touch pointer adapters",
|
"apps/web/src/hooks/useTimelineDrag.ts: forbidden pattern matched: timeline drag must not re-inline synthetic touch pointer adapters",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user