refactor(web): extract multi-select session
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user