refactor(web): extract allocation multi-drag helpers
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
finalizeAllocationMultiDrag,
|
||||||
|
isAllocationMultiSelected,
|
||||||
|
startAllocationMultiDrag,
|
||||||
|
updateAllocationMultiDrag,
|
||||||
|
} from "./timelineAllocationMultiDrag.js";
|
||||||
|
|
||||||
|
type TestMultiDragState = {
|
||||||
|
selectedAllocationIds: string[];
|
||||||
|
multiDragDaysDelta: number;
|
||||||
|
isMultiDragging: boolean;
|
||||||
|
multiDragMode: "move" | "resize-start" | "resize-end";
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseState: TestMultiDragState = {
|
||||||
|
selectedAllocationIds: ["alloc-1", "alloc-2"],
|
||||||
|
multiDragDaysDelta: 3,
|
||||||
|
isMultiDragging: false,
|
||||||
|
multiDragMode: "move",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("timelineAllocationMultiDrag", () => {
|
||||||
|
it("requires the allocation to be in a real multi-selection", () => {
|
||||||
|
expect(isAllocationMultiSelected(baseState, "alloc-1")).toBe(true);
|
||||||
|
expect(isAllocationMultiSelected({ ...baseState, selectedAllocationIds: ["alloc-1"] }, "alloc-1")).toBe(false);
|
||||||
|
expect(isAllocationMultiSelected(baseState, "alloc-9")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts multi-dragging by resetting stale delta state and preserving selection ids", () => {
|
||||||
|
expect(startAllocationMultiDrag(baseState, "resize-end")).toEqual({
|
||||||
|
selectedAllocationIds: ["alloc-1", "alloc-2"],
|
||||||
|
multiDragDaysDelta: 0,
|
||||||
|
isMultiDragging: true,
|
||||||
|
multiDragMode: "resize-end",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses same-day delta updates to avoid redundant state churn", () => {
|
||||||
|
expect(updateAllocationMultiDrag({ ...baseState, multiDragDaysDelta: 2 }, 2)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the tracked delta when the drag crosses into a new day bucket", () => {
|
||||||
|
expect(updateAllocationMultiDrag({ ...baseState, multiDragDaysDelta: 1 }, -2)).toEqual({
|
||||||
|
...baseState,
|
||||||
|
multiDragDaysDelta: -2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finalizes multi-dragging by clearing transient drag state while keeping the selection", () => {
|
||||||
|
expect(finalizeAllocationMultiDrag({ ...baseState, isMultiDragging: true, multiDragMode: "resize-start" })).toEqual({
|
||||||
|
selectedAllocationIds: ["alloc-1", "alloc-2"],
|
||||||
|
multiDragDaysDelta: 0,
|
||||||
|
isMultiDragging: false,
|
||||||
|
multiDragMode: "resize-start",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
type MultiDragStateLike<TMode extends string = string> = {
|
||||||
|
selectedAllocationIds: string[];
|
||||||
|
multiDragDaysDelta: number;
|
||||||
|
isMultiDragging: boolean;
|
||||||
|
multiDragMode: TMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAllocationMultiSelected<TState extends MultiDragStateLike>(
|
||||||
|
state: TState,
|
||||||
|
allocationId: string,
|
||||||
|
): boolean {
|
||||||
|
return state.selectedAllocationIds.length > 1 && state.selectedAllocationIds.includes(allocationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAllocationMultiDrag<TState extends MultiDragStateLike, TMode extends string>(
|
||||||
|
state: TState,
|
||||||
|
dragMode: TMode,
|
||||||
|
): TState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isMultiDragging: true,
|
||||||
|
multiDragDaysDelta: 0,
|
||||||
|
multiDragMode: dragMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAllocationMultiDrag<TState extends MultiDragStateLike>(
|
||||||
|
state: TState,
|
||||||
|
nextDaysDelta: number,
|
||||||
|
): TState | null {
|
||||||
|
if (nextDaysDelta === state.multiDragDaysDelta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
multiDragDaysDelta: nextDaysDelta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finalizeAllocationMultiDrag<TState extends MultiDragStateLike>(state: TState): TState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isMultiDragging: false,
|
||||||
|
multiDragDaysDelta: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ import {
|
|||||||
requiresAllocationFragmentExtraction,
|
requiresAllocationFragmentExtraction,
|
||||||
shouldTreatAllocationDragAsClick,
|
shouldTreatAllocationDragAsClick,
|
||||||
} from "./timelineAllocationFinalize.js";
|
} from "./timelineAllocationFinalize.js";
|
||||||
|
import {
|
||||||
|
finalizeAllocationMultiDrag,
|
||||||
|
isAllocationMultiSelected,
|
||||||
|
startAllocationMultiDrag,
|
||||||
|
updateAllocationMultiDrag,
|
||||||
|
} from "./timelineAllocationMultiDrag.js";
|
||||||
import {
|
import {
|
||||||
createMultiSelectState,
|
createMultiSelectState,
|
||||||
finalizeMultiSelectDraft,
|
finalizeMultiSelectDraft,
|
||||||
@@ -648,28 +654,26 @@ export function useTimelineDrag({
|
|||||||
|
|
||||||
// Check if this allocation is part of a multi-selection → multi-drag mode
|
// Check if this allocation is part of a multi-selection → multi-drag mode
|
||||||
const ms = multiSelectRef.current;
|
const ms = multiSelectRef.current;
|
||||||
const isMultiSelected =
|
const isMultiSelected = isAllocationMultiSelected(ms, opts.allocationId);
|
||||||
ms.selectedAllocationIds.length > 1 &&
|
|
||||||
ms.selectedAllocationIds.includes(opts.allocationId);
|
|
||||||
|
|
||||||
if (isMultiSelected) {
|
if (isMultiSelected) {
|
||||||
// ── Multi-drag: move/resize all selected allocations together ──
|
// ── Multi-drag: move/resize all selected allocations together ──
|
||||||
const startMouseX = e.clientX;
|
const startMouseX = e.clientX;
|
||||||
let currentDaysDelta = 0;
|
|
||||||
const dragMode = opts.mode;
|
const dragMode = opts.mode;
|
||||||
|
const initialMultiDragState = startAllocationMultiDrag(ms, dragMode);
|
||||||
|
|
||||||
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode }));
|
setMultiSelectState(initialMultiDragState);
|
||||||
multiSelectRef.current = { ...ms, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode };
|
multiSelectRef.current = initialMultiDragState;
|
||||||
multiSelectCleanupRef.current?.();
|
multiSelectCleanupRef.current?.();
|
||||||
|
|
||||||
function handleMultiMove(ev: MouseEvent) {
|
function handleMultiMove(ev: MouseEvent) {
|
||||||
const deltaX = ev.clientX - startMouseX;
|
const deltaX = ev.clientX - startMouseX;
|
||||||
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
||||||
if (daysDelta === currentDaysDelta) return;
|
const updated = updateAllocationMultiDrag(multiSelectRef.current, daysDelta);
|
||||||
currentDaysDelta = daysDelta;
|
if (!updated) return;
|
||||||
|
|
||||||
setMultiSelectState((prev) => ({ ...prev, multiDragDaysDelta: daysDelta }));
|
setMultiSelectState(updated);
|
||||||
multiSelectRef.current = { ...multiSelectRef.current, multiDragDaysDelta: daysDelta };
|
multiSelectRef.current = updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMultiUp(ev: MouseEvent) {
|
function handleMultiUp(ev: MouseEvent) {
|
||||||
@@ -677,13 +681,14 @@ export function useTimelineDrag({
|
|||||||
multiSelectCleanupRef.current = null;
|
multiSelectCleanupRef.current = null;
|
||||||
|
|
||||||
const finalDelta = pixelsToDays(ev.clientX - startMouseX, cellWidthRef.current);
|
const finalDelta = pixelsToDays(ev.clientX - startMouseX, cellWidthRef.current);
|
||||||
|
const finalized = finalizeAllocationMultiDrag(multiSelectRef.current);
|
||||||
|
|
||||||
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: false, multiDragDaysDelta: 0 }));
|
setMultiSelectState(finalized);
|
||||||
multiSelectRef.current = { ...multiSelectRef.current, isMultiDragging: false, multiDragDaysDelta: 0 };
|
multiSelectRef.current = finalized;
|
||||||
|
|
||||||
if (finalDelta !== 0) {
|
if (finalDelta !== 0) {
|
||||||
// Pass IDs from ref to avoid stale closure in the callback
|
// Pass IDs from ref to avoid stale closure in the callback
|
||||||
const ids = multiSelectRef.current.selectedAllocationIds;
|
const ids = finalized.selectedAllocationIds;
|
||||||
onMultiDragCompleteRef.current?.(finalDelta, dragMode, ids);
|
onMultiDragCompleteRef.current?.(finalDelta, dragMode, ids);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,25 @@ export const rules = [
|
|||||||
],
|
],
|
||||||
forbidden: [],
|
forbidden: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
file: "apps/web/src/hooks/timelineAllocationMultiDrag.ts",
|
||||||
|
maxLines: 90,
|
||||||
|
required: [
|
||||||
|
{
|
||||||
|
pattern: /\bexport function isAllocationMultiSelected\b/,
|
||||||
|
message: "timeline allocation multi-drag helpers must keep multi-selection eligibility centralized",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bexport function updateAllocationMultiDrag\b/,
|
||||||
|
message: "timeline allocation multi-drag helpers must keep same-day delta suppression centralized",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bexport function finalizeAllocationMultiDrag\b/,
|
||||||
|
message: "timeline allocation multi-drag helpers must keep reset-on-release behavior centralized",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
forbidden: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
file: "apps/web/src/hooks/useTimelineDrag.ts",
|
file: "apps/web/src/hooks/useTimelineDrag.ts",
|
||||||
required: [
|
required: [
|
||||||
@@ -225,6 +244,10 @@ export const rules = [
|
|||||||
pattern: /from "\.\/timelineAllocationFinalize\.js"/,
|
pattern: /from "\.\/timelineAllocationFinalize\.js"/,
|
||||||
message: "timeline drag must keep allocation drag completion rules delegated to the extracted helper module",
|
message: "timeline drag must keep allocation drag completion rules delegated to the extracted helper module",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
pattern: /from "\.\/timelineAllocationMultiDrag\.js"/,
|
||||||
|
message: "timeline drag must keep allocation multi-drag rules delegated to the extracted helper module",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
forbidden: [
|
forbidden: [
|
||||||
{
|
{
|
||||||
@@ -239,6 +262,10 @@ export const rules = [
|
|||||||
pattern: /\bfunction (?:hasAllocationDateChange|shouldTreatAllocationDragAsClick|requiresAllocationFragmentExtraction|buildAllocationMovedSnapshot|reconcileOptimisticEntries)\b/,
|
pattern: /\bfunction (?:hasAllocationDateChange|shouldTreatAllocationDragAsClick|requiresAllocationFragmentExtraction|buildAllocationMovedSnapshot|reconcileOptimisticEntries)\b/,
|
||||||
message: "timeline drag must not re-inline extracted optimistic or allocation finalize helper implementations",
|
message: "timeline drag must not re-inline extracted optimistic or allocation finalize helper implementations",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bfunction (?:isAllocationMultiSelected|startAllocationMultiDrag|updateAllocationMultiDrag|finalizeAllocationMultiDrag)\b/,
|
||||||
|
message: "timeline drag must not re-inline extracted allocation multi-drag helper implementations",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ describe("architecture guardrails", () => {
|
|||||||
const rangeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineRangeSelection.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 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");
|
const allocationFinalizeRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationFinalize.ts");
|
||||||
|
const allocationMultiDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationMultiDrag.ts");
|
||||||
|
|
||||||
assert.ok(dragRule);
|
assert.ok(dragRule);
|
||||||
assert.ok(livePreviewRule);
|
assert.ok(livePreviewRule);
|
||||||
@@ -84,6 +85,7 @@ describe("architecture guardrails", () => {
|
|||||||
assert.ok(rangeRule);
|
assert.ok(rangeRule);
|
||||||
assert.ok(optimisticRule);
|
assert.ok(optimisticRule);
|
||||||
assert.ok(allocationFinalizeRule);
|
assert.ok(allocationFinalizeRule);
|
||||||
|
assert.ok(allocationMultiDragRule);
|
||||||
|
|
||||||
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",
|
||||||
@@ -92,6 +94,7 @@ describe("architecture guardrails", () => {
|
|||||||
"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 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 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",
|
"apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag completion 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: 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",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -121,5 +124,10 @@ describe("architecture guardrails", () => {
|
|||||||
"apps/web/src/hooks/timelineAllocationFinalize.ts: missing guardrail anchor: timeline allocation finalize helpers must keep segment extraction rules centralized",
|
"apps/web/src/hooks/timelineAllocationFinalize.ts: missing guardrail anchor: timeline allocation finalize helpers must keep segment extraction rules centralized",
|
||||||
"apps/web/src/hooks/timelineAllocationFinalize.ts: missing guardrail anchor: timeline allocation finalize helpers must keep mutation snapshot creation centralized",
|
"apps/web/src/hooks/timelineAllocationFinalize.ts: missing guardrail anchor: timeline allocation finalize helpers must keep mutation snapshot creation centralized",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(evaluateRule(allocationMultiDragRule, "export function isAllocationMultiSelected() {}\n"), [
|
||||||
|
"apps/web/src/hooks/timelineAllocationMultiDrag.ts: missing guardrail anchor: timeline allocation multi-drag helpers must keep same-day delta suppression centralized",
|
||||||
|
"apps/web/src/hooks/timelineAllocationMultiDrag.ts: missing guardrail anchor: timeline allocation multi-drag helpers must keep reset-on-release behavior centralized",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user