refactor(web): centralize multi-select release handling
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
completeMultiSelectDraft,
|
||||||
createMultiSelectState,
|
createMultiSelectState,
|
||||||
finalizeMultiSelectDraft,
|
finalizeMultiSelectDraft,
|
||||||
updateMultiSelectDraft,
|
updateMultiSelectDraft,
|
||||||
@@ -98,4 +99,67 @@ describe("timelineMultiSelect", () => {
|
|||||||
currentY: 295,
|
currentY: 295,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns the provided empty state when the right-click drag never really started", () => {
|
||||||
|
const state = createMultiSelectState<TestMultiSelectState>(120, 240, {
|
||||||
|
selectedAllocationIds: ["alloc-1"],
|
||||||
|
selectedResourceIds: ["res-1"],
|
||||||
|
dateRange: null,
|
||||||
|
multiDragDaysDelta: 1,
|
||||||
|
isMultiDragging: true,
|
||||||
|
multiDragMode: "move",
|
||||||
|
});
|
||||||
|
const emptyState: TestMultiSelectState = {
|
||||||
|
isSelecting: false,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
currentX: 0,
|
||||||
|
currentY: 0,
|
||||||
|
selectedAllocationIds: [],
|
||||||
|
selectedResourceIds: [],
|
||||||
|
dateRange: null,
|
||||||
|
multiDragDaysDelta: 0,
|
||||||
|
isMultiDragging: false,
|
||||||
|
multiDragMode: "move",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(completeMultiSelectDraft(state, 123, 243, emptyState)).toEqual({
|
||||||
|
nextState: emptyState,
|
||||||
|
shouldClear: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the finished rectangle when the drag cleared the minimum distance", () => {
|
||||||
|
const state = createMultiSelectState<TestMultiSelectState>(120, 240, {
|
||||||
|
selectedAllocationIds: [],
|
||||||
|
selectedResourceIds: [],
|
||||||
|
dateRange: null,
|
||||||
|
multiDragDaysDelta: 0,
|
||||||
|
isMultiDragging: false,
|
||||||
|
multiDragMode: "move",
|
||||||
|
});
|
||||||
|
const emptyState: TestMultiSelectState = {
|
||||||
|
isSelecting: false,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
currentX: 0,
|
||||||
|
currentY: 0,
|
||||||
|
selectedAllocationIds: [],
|
||||||
|
selectedResourceIds: [],
|
||||||
|
dateRange: null,
|
||||||
|
multiDragDaysDelta: 0,
|
||||||
|
isMultiDragging: false,
|
||||||
|
multiDragMode: "move",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(completeMultiSelectDraft(state, 150, 295, emptyState)).toEqual({
|
||||||
|
nextState: {
|
||||||
|
...state,
|
||||||
|
isSelecting: false,
|
||||||
|
currentX: 150,
|
||||||
|
currentY: 295,
|
||||||
|
},
|
||||||
|
shouldClear: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,3 +65,24 @@ export function finalizeMultiSelectDraft<TState extends MultiSelectStateLike>(
|
|||||||
currentY,
|
currentY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function completeMultiSelectDraft<TState extends MultiSelectStateLike>(
|
||||||
|
state: TState,
|
||||||
|
currentX: number,
|
||||||
|
currentY: number,
|
||||||
|
emptyState: TState,
|
||||||
|
minDistancePx = 5,
|
||||||
|
): { nextState: TState; shouldClear: boolean } {
|
||||||
|
const finished = finalizeMultiSelectDraft(state, currentX, currentY, minDistancePx);
|
||||||
|
if (!finished) {
|
||||||
|
return {
|
||||||
|
nextState: emptyState,
|
||||||
|
shouldClear: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextState: finished,
|
||||||
|
shouldClear: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import { createAllocationDragState } from "./timelineAllocationDragState.js";
|
|||||||
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
|
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
|
||||||
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
|
import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js";
|
||||||
import {
|
import {
|
||||||
|
completeMultiSelectDraft,
|
||||||
createMultiSelectState,
|
createMultiSelectState,
|
||||||
finalizeMultiSelectDraft,
|
|
||||||
updateMultiSelectDraft,
|
updateMultiSelectDraft,
|
||||||
} from "./timelineMultiSelect.js";
|
} from "./timelineMultiSelect.js";
|
||||||
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
|
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
|
||||||
@@ -896,21 +896,9 @@ export function useTimelineDrag({
|
|||||||
const ms = multiSelectRef.current;
|
const ms = multiSelectRef.current;
|
||||||
if (!ms.isSelecting) return;
|
if (!ms.isSelecting) return;
|
||||||
|
|
||||||
const finished = finalizeMultiSelectDraft(ms, ev.clientX, ev.clientY);
|
const result = completeMultiSelectDraft(ms, ev.clientX, ev.clientY, INITIAL_MULTI_SELECT);
|
||||||
if (!finished) {
|
multiSelectRef.current = result.nextState;
|
||||||
// Minimal movement → not a drag selection, reset.
|
setMultiSelectState(result.nextState);
|
||||||
// Let existing onContextMenu handlers on allocation blocks handle right-click.
|
|
||||||
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
|
||||||
setMultiSelectState(INITIAL_MULTI_SELECT);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the rectangle coordinates for the parent to compute intersection.
|
|
||||||
// isSelecting is set to false to indicate the drag is done, but the
|
|
||||||
// rectangle data (startX/Y, currentX/Y) is preserved so TimelineView
|
|
||||||
// can resolve which allocations/resources fall within the selection.
|
|
||||||
multiSelectRef.current = finished;
|
|
||||||
setMultiSelectState(finished);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
multiSelectCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp);
|
multiSelectCleanupRef.current = attachDocumentMouseDrag(document, handleMove, handleUp);
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ export const rules = [
|
|||||||
pattern: /\bexport function finalizeMultiSelectDraft\b/,
|
pattern: /\bexport function finalizeMultiSelectDraft\b/,
|
||||||
message: "timeline multi-select helpers must keep minimal-drag reset logic centralized",
|
message: "timeline multi-select helpers must keep minimal-drag reset logic centralized",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bexport function completeMultiSelectDraft\b/,
|
||||||
|
message: "timeline multi-select helpers must keep right-click release completion centralized",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
forbidden: [],
|
forbidden: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ describe("architecture guardrails", () => {
|
|||||||
|
|
||||||
assert.deepEqual(evaluateRule(multiSelectRule, "export function createMultiSelectState() {}\n"), [
|
assert.deepEqual(evaluateRule(multiSelectRule, "export function createMultiSelectState() {}\n"), [
|
||||||
"apps/web/src/hooks/timelineMultiSelect.ts: missing guardrail anchor: timeline multi-select helpers must keep minimal-drag reset logic centralized",
|
"apps/web/src/hooks/timelineMultiSelect.ts: missing guardrail anchor: timeline multi-select helpers must keep minimal-drag reset logic centralized",
|
||||||
|
"apps/web/src/hooks/timelineMultiSelect.ts: missing guardrail anchor: timeline multi-select helpers must keep right-click release completion centralized",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.deepEqual(evaluateRule(rangeRule, "export function updateRangeSelectionDraft() {}\n"), [
|
assert.deepEqual(evaluateRule(rangeRule, "export function updateRangeSelectionDraft() {}\n"), [
|
||||||
|
|||||||
Reference in New Issue
Block a user