refactor(web): extract multi-select session

This commit is contained in:
2026-04-01 11:14:28 +02:00
parent b14be80e32
commit 0181f2b304
5 changed files with 238 additions and 33 deletions
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
type TestState = {
isSelecting: boolean;
cursor: string;
};
describe("timelineMultiSelectSession", () => {
it("starts a session, forwards move updates, and finalizes on mouseup", () => {
const previousCleanup = vi.fn();
const setState = vi.fn();
const createInitialState = vi.fn((clientX: number, clientY: number) => ({
isSelecting: true,
cursor: `${clientX}:${clientY}`,
}));
const updateState = vi.fn((_: TestState, clientX: number, clientY: number) => ({
isSelecting: true,
cursor: `${clientX}:${clientY}`,
}));
const completeState = vi.fn((_: TestState, clientX: number, clientY: number, initialState: TestState) => ({
nextState: {
...initialState,
isSelecting: false,
cursor: `done:${clientX}:${clientY}`,
},
}));
const cleanupRef = { current: previousCleanup as (() => void) | null };
const stateRef = { current: { isSelecting: false, cursor: "initial" } };
const initialState = { isSelecting: false, cursor: "idle" };
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();
});
beginCanvasMultiSelectSession({
clientX: 10,
clientY: 20,
documentTarget: {} as Document,
cleanupRef,
stateRef,
setState,
createInitialState,
updateState,
completeState,
initialState,
attachDrag,
});
expect(previousCleanup).toHaveBeenCalledOnce();
expect(setState).toHaveBeenCalledWith({ isSelecting: true, cursor: "10:20" });
expect(typeof handlers.move).toBe("function");
expect(typeof handlers.up).toBe("function");
handlers.move?.({ clientX: 14, clientY: 24 } as MouseEvent);
expect(updateState).toHaveBeenCalledWith({ isSelecting: true, cursor: "10:20" }, 14, 24);
expect(stateRef.current).toEqual({ isSelecting: true, cursor: "14:24" });
const attachedCleanup = cleanupRef.current;
expect(attachedCleanup).not.toBeNull();
handlers.up?.({ clientX: 18, clientY: 28 } as MouseEvent);
expect(completeState).toHaveBeenCalledWith({ isSelecting: true, cursor: "14:24" }, 18, 28, initialState);
expect(setState).toHaveBeenLastCalledWith({ isSelecting: false, cursor: "done:18:28" });
expect(stateRef.current).toEqual({ isSelecting: false, cursor: "done:18:28" });
expect(cleanupRef.current).toBeNull();
expect(attachedCleanup).toHaveBeenCalledOnce();
});
it("stops updating when selection is already inactive before move and mouseup", () => {
const setState = vi.fn();
const updateState = vi.fn();
const completeState = vi.fn();
const cleanupRef = { current: null as (() => void) | null };
const stateRef = { current: { isSelecting: false, cursor: "idle" } };
const handlers: {
move?: (event: MouseEvent) => void;
up?: (event: MouseEvent) => void;
} = {};
beginCanvasMultiSelectSession({
clientX: 1,
clientY: 2,
documentTarget: {} as Document,
cleanupRef,
stateRef,
setState,
createInitialState: () => ({ isSelecting: true, cursor: "start" }),
updateState,
completeState,
initialState: { isSelecting: false, cursor: "idle" },
attachDrag: (_documentTarget, onMove, onUp) => {
handlers.move = onMove;
handlers.up = onUp;
return vi.fn();
},
});
stateRef.current = { isSelecting: false, cursor: "cancelled" };
handlers.move?.({ clientX: 3, clientY: 4 } as MouseEvent);
handlers.up?.({ clientX: 5, clientY: 6 } as MouseEvent);
expect(updateState).not.toHaveBeenCalled();
expect(completeState).not.toHaveBeenCalled();
expect(setState).toHaveBeenCalledTimes(1);
expect(cleanupRef.current).toBeNull();
});
});
@@ -0,0 +1,78 @@
type MutableCurrent<T> = {
current: T;
};
type AttachDocumentMouseDrag = (
documentTarget: Document,
onMove: (event: MouseEvent) => void,
onUp: (event: MouseEvent) => void,
) => () => void;
type MultiSelectSessionState = {
isSelecting: boolean;
};
type CompleteMultiSelectResult<TState> = {
nextState: TState;
};
type BeginCanvasMultiSelectSessionParams<TState extends MultiSelectSessionState> = {
clientX: number;
clientY: number;
documentTarget: Document;
cleanupRef: MutableCurrent<(() => void) | null>;
stateRef: MutableCurrent<TState>;
setState: (state: TState) => void;
createInitialState: (clientX: number, clientY: number) => TState;
updateState: (state: TState, clientX: number, clientY: number) => TState;
completeState: (
state: TState,
clientX: number,
clientY: number,
initialState: TState,
) => CompleteMultiSelectResult<TState>;
initialState: TState;
attachDrag: AttachDocumentMouseDrag;
};
export function beginCanvasMultiSelectSession<TState extends MultiSelectSessionState>({
clientX,
clientY,
documentTarget,
cleanupRef,
stateRef,
setState,
createInitialState,
updateState,
completeState,
initialState,
attachDrag,
}: BeginCanvasMultiSelectSessionParams<TState>) {
const nextState = createInitialState(clientX, clientY);
stateRef.current = nextState;
setState(nextState);
cleanupRef.current?.();
function handleMove(event: MouseEvent) {
const currentState = stateRef.current;
if (!currentState.isSelecting) return;
const updated = updateState(currentState, event.clientX, event.clientY);
stateRef.current = updated;
setState(updated);
}
function handleUp(event: MouseEvent) {
cleanupRef.current?.();
cleanupRef.current = null;
const currentState = stateRef.current;
if (!currentState.isSelecting) return;
const result = completeState(currentState, event.clientX, event.clientY, initialState);
stateRef.current = result.nextState;
setState(result.nextState);
}
cleanupRef.current = attachDrag(documentTarget, handleMove, handleUp);
}
+21 -33
View File
@@ -28,6 +28,7 @@ import {
createMultiSelectState,
updateMultiSelectDraft,
} from "./timelineMultiSelect.js";
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
import { createRangeSelectionState, finalizeRangeSelection, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
import {
@@ -878,40 +879,27 @@ export function useTimelineDrag({
if (e.button !== 2) return;
e.preventDefault();
const initial = createMultiSelectState<MultiSelectState>(e.clientX, e.clientY, {
selectedAllocationIds: [],
selectedResourceIds: [],
dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
beginCanvasMultiSelectSession({
clientX: e.clientX,
clientY: e.clientY,
documentTarget: document,
cleanupRef: multiSelectCleanupRef,
stateRef: multiSelectRef,
setState: setMultiSelectState,
createInitialState: (clientX, clientY) =>
createMultiSelectState<MultiSelectState>(clientX, clientY, {
selectedAllocationIds: [],
selectedResourceIds: [],
dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
}),
updateState: updateMultiSelectDraft,
completeState: completeMultiSelectDraft,
initialState: INITIAL_MULTI_SELECT,
attachDrag: attachDocumentMouseDrag,
});
multiSelectRef.current = initial;
setMultiSelectState(initial);
multiSelectCleanupRef.current?.();
function handleMove(ev: MouseEvent) {
const ms = multiSelectRef.current;
if (!ms.isSelecting) return;
const updated = updateMultiSelectDraft(ms, ev.clientX, ev.clientY);
multiSelectRef.current = updated;
setMultiSelectState(updated);
}
function handleUp(ev: MouseEvent) {
multiSelectCleanupRef.current?.();
multiSelectCleanupRef.current = null;
const ms = multiSelectRef.current;
if (!ms.isSelecting) return;
const result = completeMultiSelectDraft(ms, ev.clientX, ev.clientY, INITIAL_MULTI_SELECT);
multiSelectRef.current = result.nextState;
setMultiSelectState(result.nextState);
}
multiSelectCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp);
}, []);
const clearMultiSelect = useCallback(() => {
+15
View File
@@ -168,6 +168,17 @@ export const rules = [
],
forbidden: [],
},
{
file: "apps/web/src/hooks/timelineMultiSelectSession.ts",
maxLines: 90,
required: [
{
pattern: /\bexport function beginCanvasMultiSelectSession\b/,
message: "timeline multi-select session helpers must keep right-click session lifecycle centralized",
},
],
forbidden: [],
},
{
file: "apps/web/src/hooks/timelineRangeSelection.ts",
maxLines: 90,
@@ -337,6 +348,10 @@ export const rules = [
pattern: /from "\.\/timelineMultiSelect\.js"/,
message: "timeline drag must keep multi-select rectangle lifecycle delegated to the extracted helper module",
},
{
pattern: /from "\.\/timelineMultiSelectSession\.js"/,
message: "timeline drag must keep multi-select document session wiring delegated to the extracted helper module",
},
{
pattern: /from "\.\/timelineRangeSelection\.js"/,
message: "timeline drag must keep range preview and finalization delegated to the extracted helper module",
@@ -74,6 +74,7 @@ describe("architecture guardrails", () => {
const touchRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineTouch.ts");
const touchAdaptersRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineTouchAdapters.ts");
const multiSelectRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineMultiSelect.ts");
const multiSelectSessionRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineMultiSelectSession.ts");
const rangeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineRangeSelection.ts");
const optimisticRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineOptimisticAllocations.ts");
const allocationFinalizeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationFinalize.ts");
@@ -90,6 +91,7 @@ describe("architecture guardrails", () => {
assert.ok(touchRule);
assert.ok(touchAdaptersRule);
assert.ok(multiSelectRule);
assert.ok(multiSelectSessionRule);
assert.ok(rangeRule);
assert.ok(optimisticRule);
assert.ok(allocationFinalizeRule);
@@ -106,6 +108,7 @@ describe("architecture guardrails", () => {
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep touch fallback and drag disambiguation delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep touch pointer adapter wiring delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select rectangle lifecycle delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select document session wiring delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range preview and finalization delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag completion rules delegated to the extracted helper module",
@@ -137,6 +140,10 @@ describe("architecture guardrails", () => {
"apps/web/src/hooks/timelineMultiSelect.ts: missing guardrail anchor: timeline multi-select helpers must keep right-click release completion centralized",
]);
assert.deepEqual(evaluateRule(multiSelectSessionRule, ""), [
"apps/web/src/hooks/timelineMultiSelectSession.ts: missing guardrail anchor: timeline multi-select session helpers must keep right-click session lifecycle centralized",
]);
assert.deepEqual(evaluateRule(rangeRule, "export function updateRangeSelectionDraft() {}\n"), [
"apps/web/src/hooks/timelineRangeSelection.ts: missing guardrail anchor: timeline range helpers must keep selection bootstrap centralized",
"apps/web/src/hooks/timelineRangeSelection.ts: missing guardrail anchor: timeline range helpers must keep ordered range finalization centralized",
@@ -191,6 +198,7 @@ describe("architecture guardrails", () => {
"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 touch pointer adapter wiring delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select rectangle lifecycle delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep multi-select document session wiring delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep range preview and finalization delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module",
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag completion rules delegated to the extracted helper module",