feat(FN-3941): separate graph pan from selection-gated drag

Separates graph panning from selection-gated drag by updating `useNodeDrag` and `useGraphInteraction` hooks to handle the two interaction modes independently, with `GraphTaskNode` now gating drag on selection state. Tests were added across the graph interaction, node drag, persistence, and integrati

Fusion-Task-Id: FN-3941
This commit is contained in:
Fusion
2026-05-10 11:34:17 -07:00
committed by gsxdsm
parent b046d70730
commit e2bd5000cf
12 changed files with 124 additions and 17 deletions

View File

@@ -227,6 +227,7 @@ export function DependencyGraph({
key={node.task.id} key={node.task.id}
task={node.task} task={node.task}
projectId={projectId} projectId={projectId}
isSelected={selectedTaskId === node.task.id}
style={{ minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }} style={{ minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
position={position} position={position}
scale={zoom} scale={zoom}

View File

@@ -17,6 +17,11 @@
z-index: 2; z-index: 2;
} }
.graph-task-node--selected {
box-shadow: var(--focus-ring-strong), var(--shadow-md);
z-index: 2;
}
.graph-task-node--highlighted { .graph-task-node--highlighted {
box-shadow: 0 0 0 var(--btn-border-width) var(--todo), var(--shadow-md); box-shadow: 0 0 0 var(--btn-border-width) var(--todo), var(--shadow-md);
z-index: 2; z-index: 2;

View File

@@ -33,6 +33,7 @@ export interface GraphTaskNodeProps extends TaskCardBridgeProps, Pick<HTMLAttrib
style?: CSSProperties; style?: CSSProperties;
position: GraphPosition; position: GraphPosition;
scale: number; scale: number;
isSelected?: boolean;
isHighlighted?: boolean; isHighlighted?: boolean;
isDimmed?: boolean; isDimmed?: boolean;
onNodePositionChange: (taskId: string, position: GraphPosition) => void; onNodePositionChange: (taskId: string, position: GraphPosition) => void;
@@ -54,6 +55,7 @@ export function GraphTaskNode({
style, style,
position, position,
scale, scale,
isSelected = false,
isHighlighted = false, isHighlighted = false,
isDimmed = false, isDimmed = false,
onMouseEnter, onMouseEnter,
@@ -88,6 +90,7 @@ export function GraphTaskNode({
taskId: task.id, taskId: task.id,
position, position,
scale, scale,
canDrag: isSelected,
onPositionChange: onNodePositionChange, onPositionChange: onNodePositionChange,
onDragStateChange: onNodeDragStateChange, onDragStateChange: onNodeDragStateChange,
onDragEnd: onNodeDragEnd, onDragEnd: onNodeDragEnd,
@@ -95,7 +98,7 @@ export function GraphTaskNode({
return ( return (
<div <div
className={`graph-task-node graph-node--draggable${drag.isDragging ? " graph-node--dragging" : ""}${isHighlighted ? " graph-task-node--highlighted graph-node--highlighted" : ""}${isDimmed ? " graph-task-node--dimmed graph-node--dimmed" : ""}${isActive ? " graph-task-node--active" : ""}${isInReview ? " graph-task-node--in-review" : ""}`} className={`graph-task-node${isSelected ? " graph-node--draggable graph-task-node--selected" : ""}${drag.isDragging ? " graph-node--dragging" : ""}${isHighlighted ? " graph-task-node--highlighted graph-node--highlighted" : ""}${isDimmed ? " graph-task-node--dimmed graph-node--dimmed" : ""}${isActive ? " graph-task-node--active" : ""}${isInReview ? " graph-task-node--in-review" : ""}`}
style={style} style={style}
draggable={false} draggable={false}
data-testid={`graph-task-node-${task.id}`} data-testid={`graph-task-node-${task.id}`}

View File

@@ -70,6 +70,7 @@ describe("DependencyGraph persistence", () => {
const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />); const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
const node = screen.getByTestId("graph-task-node-A"); const node = screen.getByTestId("graph-task-node-A");
fireEvent.click(node);
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 }); fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 }); fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 }); fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });

View File

@@ -8,6 +8,9 @@ const zoomIn = vi.fn();
const zoomOut = vi.fn(); const zoomOut = vi.fn();
const resetView = vi.fn(); const resetView = vi.fn();
const handleKeyDown = vi.fn(); const handleKeyDown = vi.fn();
const onPointerDown = vi.fn();
const onPointerMove = vi.fn();
const onPointerUp = vi.fn();
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({ vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
TaskCard: ({ task, onOpenDetail, disableDrag }: { task: Task; onOpenDetail: (task: Task) => void; disableDrag?: boolean }) => ( TaskCard: ({ task, onOpenDetail, disableDrag }: { task: Task; onOpenDetail: (task: Task) => void; disableDrag?: boolean }) => (
@@ -24,9 +27,9 @@ vi.mock("../useGraphInteraction", () => ({
zoomOut, zoomOut,
resetView, resetView,
fitToGraph, fitToGraph,
onPointerDown: vi.fn(), onPointerDown,
onPointerMove: vi.fn(), onPointerMove,
onPointerUp: vi.fn(), onPointerUp,
onWheelZoom: vi.fn(), onWheelZoom: vi.fn(),
handleKeyDown, handleKeyDown,
}), }),
@@ -43,6 +46,9 @@ describe("DependencyGraph", () => {
zoomOut.mockReset(); zoomOut.mockReset();
resetView.mockReset(); resetView.mockReset();
handleKeyDown.mockReset(); handleKeyDown.mockReset();
onPointerDown.mockReset();
onPointerMove.mockReset();
onPointerUp.mockReset();
}); });
afterEach(() => { afterEach(() => {
@@ -125,4 +131,26 @@ describe("DependencyGraph", () => {
fireEvent.click(screen.getByTestId("task-A")); fireEvent.click(screen.getByTestId("task-A"));
expect(onOpenTaskDetail).toHaveBeenCalledWith("A"); expect(onOpenTaskDetail).toHaveBeenCalledWith("A");
}); });
it("requires selection before node drag class is enabled", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
const node = screen.getByTestId("graph-task-node-A");
expect(node.className).not.toContain("graph-node--draggable");
fireEvent.click(node);
expect(node.className).toContain("graph-node--draggable");
});
it("allows pane panning from an unselected node surface", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
const node = screen.getByTestId("graph-task-node-A");
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
fireEvent.pointerMove(node, { pointerId: 1, clientX: 30, clientY: 20, isPrimary: true });
fireEvent.pointerUp(node, { pointerId: 1, clientX: 30, clientY: 20, isPrimary: true });
expect(onPointerDown).toHaveBeenCalled();
expect(onPointerMove).toHaveBeenCalled();
expect(onPointerUp).toHaveBeenCalled();
});
}); });

View File

@@ -14,6 +14,7 @@ function props(overrides: Partial<React.ComponentProps<typeof GraphTaskNode>> =
projectId: "p1", projectId: "p1",
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
scale: 1, scale: 1,
isSelected: false,
isHighlighted: false, isHighlighted: false,
isDimmed: false, isDimmed: false,
onNodePositionChange: vi.fn(), onNodePositionChange: vi.fn(),
@@ -42,7 +43,7 @@ afterEach(() => {
describe("GraphTaskNode drag", () => { describe("GraphTaskNode drag", () => {
it("does not open detail after drag threshold is exceeded", () => { it("does not open detail after drag threshold is exceeded", () => {
const onOpenDetail = vi.fn(); const onOpenDetail = vi.fn();
render(<GraphTaskNode {...props({ onOpenDetail })} />); render(<GraphTaskNode {...props({ onOpenDetail, isSelected: true })} />);
const node = screen.getByTestId("graph-task-node-FN-1"); const node = screen.getByTestId("graph-task-node-FN-1");
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true }); fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
@@ -55,7 +56,7 @@ describe("GraphTaskNode drag", () => {
it("applies dragging class only after threshold move", () => { it("applies dragging class only after threshold move", () => {
const onNodePositionChange = vi.fn(); const onNodePositionChange = vi.fn();
render(<GraphTaskNode {...props({ onNodePositionChange })} />); render(<GraphTaskNode {...props({ onNodePositionChange, isSelected: true })} />);
const node = screen.getByTestId("graph-task-node-FN-1"); const node = screen.getByTestId("graph-task-node-FN-1");
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true }); fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
@@ -71,7 +72,7 @@ describe("GraphTaskNode drag", () => {
}); });
it("composes highlight and dragging classes", () => { it("composes highlight and dragging classes", () => {
render(<GraphTaskNode {...props({ isHighlighted: true })} />); render(<GraphTaskNode {...props({ isSelected: true, isHighlighted: true })} />);
const node = screen.getByTestId("graph-task-node-FN-1"); const node = screen.getByTestId("graph-task-node-FN-1");
fireEvent.pointerDown(node, { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true }); fireEvent.pointerDown(node, { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true });
fireEvent.pointerMove(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true }); fireEvent.pointerMove(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
@@ -79,4 +80,17 @@ describe("GraphTaskNode drag", () => {
expect(node.className).toContain("graph-task-node--highlighted"); expect(node.className).toContain("graph-task-node--highlighted");
expect(node.className).toContain("graph-node--dragging"); expect(node.className).toContain("graph-node--dragging");
}); });
it("does not drag when node is not selected", () => {
const onNodePositionChange = vi.fn();
render(<GraphTaskNode {...props({ onNodePositionChange, isSelected: false })} />);
const node = screen.getByTestId("graph-task-node-FN-1");
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
fireEvent.pointerMove(node, { pointerId: 1, clientX: 25, clientY: 25, isPrimary: true });
expect(node.className).not.toContain("graph-node--dragging");
expect(node.className).not.toContain("graph-node--draggable");
expect(onNodePositionChange).not.toHaveBeenCalled();
});
}); });

View File

@@ -44,6 +44,18 @@ describe("dependency graph interactions", () => {
expect(result.current.zoom).toBeGreaterThan(1); expect(result.current.zoom).toBeGreaterThan(1);
}); });
it("keeps single-pointer moves as pan-only without zoom changes", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.onPointerDown(1, { x: 10, y: 10 });
result.current.onPointerMove(1, { x: 40, y: 30 }, 800, 600);
});
expect(result.current.pan).toEqual({ x: 30, y: 20 });
expect(result.current.zoom).toBe(1);
});
it("fit-to-graph computes bounds from actual node positions", () => { it("fit-to-graph computes bounds from actual node positions", () => {
const { result } = renderHook(() => useGraphInteraction()); const { result } = renderHook(() => useGraphInteraction());
const positions = new Map([ const positions = new Map([
@@ -78,6 +90,7 @@ describe("dependency graph interactions", () => {
task={createTask("A")} task={createTask("A")}
position={{ x: 0, y: 0 }} position={{ x: 0, y: 0 }}
scale={1} scale={1}
isSelected={true}
isHighlighted={false} isHighlighted={false}
isDimmed={false} isDimmed={false}
onNodePositionChange={onNodePositionChange} onNodePositionChange={onNodePositionChange}

View File

@@ -64,6 +64,7 @@ describe("dependency graph position persistence", () => {
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />); render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
const node = screen.getByTestId("graph-task-node-A"); const node = screen.getByTestId("graph-task-node-A");
fireEvent.click(node);
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 }); fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 }); fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 }); fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
@@ -75,6 +76,7 @@ describe("dependency graph position persistence", () => {
it("clearing localStorage causes fresh auto-layout on remount", () => { it("clearing localStorage causes fresh auto-layout on remount", () => {
const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />); const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
const node = screen.getByTestId("graph-task-node-A"); const node = screen.getByTestId("graph-task-node-A");
fireEvent.click(node);
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 }); fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 }); fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 });
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 }); fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 });

View File

@@ -64,6 +64,21 @@ describe("useGraphInteraction", () => {
expect(result.current.pan).toEqual({ x: -50, y: -50 }); expect(result.current.pan).toEqual({ x: -50, y: -50 });
}); });
it("continues with pan after pinch when one pointer remains", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.onPointerDown(1, { x: 100, y: 100 });
result.current.onPointerDown(2, { x: 200, y: 100 });
result.current.onPointerMove(2, { x: 250, y: 100 }, 800, 600);
result.current.onPointerUp(2);
result.current.onPointerMove(1, { x: 120, y: 130 }, 800, 600);
});
expect(result.current.zoom).toBe(1.5);
expect(result.current.pan).toEqual({ x: 20, y: 30 });
});
it("applies animation state for fit and reset", () => { it("applies animation state for fit and reset", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { result } = renderHook(() => useGraphInteraction()); const { result } = renderHook(() => useGraphInteraction());

View File

@@ -25,7 +25,7 @@ describe("useNodeDrag", () => {
const onPositionChange = vi.fn(); const onPositionChange = vi.fn();
const onDragStateChange = vi.fn(); const onDragStateChange = vi.fn();
const { result } = renderHook(() => const { result } = renderHook(() =>
useNodeDrag({ taskId: "A", position: { x: 10, y: 10 }, scale: 1, onPositionChange, onDragStateChange }), useNodeDrag({ taskId: "A", position: { x: 10, y: 10 }, scale: 1, canDrag: true, onPositionChange, onDragStateChange }),
); );
act(() => result.current.onPointerDown(pointerEvent({ clientX: 10, clientY: 20 }))); act(() => result.current.onPointerDown(pointerEvent({ clientX: 10, clientY: 20 })));
@@ -42,7 +42,7 @@ describe("useNodeDrag", () => {
it("stays click-only below threshold", () => { it("stays click-only below threshold", () => {
const onPositionChange = vi.fn(); const onPositionChange = vi.fn();
const { result } = renderHook(() => const { result } = renderHook(() =>
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, onPositionChange }), useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: true, onPositionChange }),
); );
act(() => result.current.onPointerDown(pointerEvent())); act(() => result.current.onPointerDown(pointerEvent()));
@@ -56,7 +56,7 @@ describe("useNodeDrag", () => {
it("divides pointer delta by zoom scale", () => { it("divides pointer delta by zoom scale", () => {
const onPositionChange = vi.fn(); const onPositionChange = vi.fn();
const { result } = renderHook(() => const { result } = renderHook(() =>
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 2, onPositionChange }), useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 2, canDrag: true, onPositionChange }),
); );
act(() => result.current.onPointerDown(pointerEvent())); act(() => result.current.onPointerDown(pointerEvent()));
@@ -68,7 +68,7 @@ describe("useNodeDrag", () => {
it("cancels drag cleanly on pointer cancel", () => { it("cancels drag cleanly on pointer cancel", () => {
const onPositionChange = vi.fn(); const onPositionChange = vi.fn();
const { result } = renderHook(() => const { result } = renderHook(() =>
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, onPositionChange }), useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: true, onPositionChange }),
); );
act(() => result.current.onPointerDown(pointerEvent())); act(() => result.current.onPointerDown(pointerEvent()));
@@ -78,4 +78,17 @@ describe("useNodeDrag", () => {
act(() => result.current.onPointerCancel(pointerEvent({ clientX: 8, clientY: 0 }))); act(() => result.current.onPointerCancel(pointerEvent({ clientX: 8, clientY: 0 })));
expect(result.current.isDragging).toBe(false); expect(result.current.isDragging).toBe(false);
}); });
it("ignores pointer interactions when dragging is disabled", () => {
const onPositionChange = vi.fn();
const { result } = renderHook(() =>
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange }),
);
act(() => result.current.onPointerDown(pointerEvent()));
act(() => result.current.onPointerMove(pointerEvent({ clientX: 8, clientY: 0 })));
expect(result.current.isDragging).toBe(false);
expect(onPositionChange).not.toHaveBeenCalled();
});
}); });

View File

@@ -8,6 +8,7 @@ interface UseNodeDragOptions {
taskId: string; taskId: string;
position: GraphPosition; position: GraphPosition;
scale: number; scale: number;
canDrag: boolean;
onPositionChange: (taskId: string, position: GraphPosition) => void; onPositionChange: (taskId: string, position: GraphPosition) => void;
onDragStateChange?: (isDragging: boolean) => void; onDragStateChange?: (isDragging: boolean) => void;
onDragEnd?: () => void; onDragEnd?: () => void;
@@ -19,7 +20,7 @@ interface PendingState {
startPosition: GraphPosition; startPosition: GraphPosition;
} }
export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragStateChange, onDragEnd }: UseNodeDragOptions) { export function useNodeDrag({ taskId, position, scale, canDrag, onPositionChange, onDragStateChange, onDragEnd }: UseNodeDragOptions) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const pendingRef = useRef<PendingState | null>(null); const pendingRef = useRef<PendingState | null>(null);
const positionRef = useRef(position); const positionRef = useRef(position);
@@ -38,7 +39,7 @@ export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragS
}, [onDragEnd, onDragStateChange]); }, [onDragEnd, onDragStateChange]);
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => { const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
if (!event.isPrimary) return; if (!event.isPrimary || !canDrag) return;
event.stopPropagation(); event.stopPropagation();
const currentTarget = event.currentTarget; const currentTarget = event.currentTarget;
if (typeof currentTarget.setPointerCapture === "function") { if (typeof currentTarget.setPointerCapture === "function") {
@@ -49,7 +50,7 @@ export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragS
startPointer: { x: event.clientX, y: event.clientY }, startPointer: { x: event.clientX, y: event.clientY },
startPosition: positionRef.current, startPosition: positionRef.current,
}; };
}, []); }, [canDrag]);
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => { const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
const pending = pendingRef.current; const pending = pendingRef.current;

View File

@@ -186,7 +186,7 @@ export function useGraphInteraction() {
const onPointerMove = useCallback((pointerId: number, point: PointerPoint, viewportWidth: number, viewportHeight: number) => { const onPointerMove = useCallback((pointerId: number, point: PointerPoint, viewportWidth: number, viewportHeight: number) => {
if (pointersRef.current.has(pointerId)) pointersRef.current.set(pointerId, point); if (pointersRef.current.has(pointerId)) pointersRef.current.set(pointerId, point);
if (pointersRef.current.size >= 2 && pinchRef.current) { if (pointersRef.current.size === 2 && pinchRef.current) {
setAnimate(false); setAnimate(false);
const [a, b] = Array.from(pointersRef.current.values()); const [a, b] = Array.from(pointersRef.current.values());
const distance = Math.hypot(a.x - b.x, a.y - b.y); const distance = Math.hypot(a.x - b.x, a.y - b.y);
@@ -215,8 +215,19 @@ export function useGraphInteraction() {
const onPointerUp = useCallback((pointerId: number) => { const onPointerUp = useCallback((pointerId: number) => {
pointersRef.current.delete(pointerId); pointersRef.current.delete(pointerId);
if (pointersRef.current.size < 2) pinchRef.current = null; if (pointersRef.current.size < 2) {
if (pointersRef.current.size === 0) dragStateRef.current = null; pinchRef.current = null;
}
if (pointersRef.current.size === 1) {
const [remainingPoint] = Array.from(pointersRef.current.values());
dragStateRef.current = { start: remainingPoint, panStart: panRef.current };
return;
}
if (pointersRef.current.size === 0) {
dragStateRef.current = null;
}
}, []); }, []);
const onWheelZoom = useCallback(( const onWheelZoom = useCallback((