feat(FN-3089): add draggable dependency graph nodes

Adds draggable interaction to dependency graph nodes (FN-3089), introducing a `useNodeDrag` hook to manage drag state and positioning, with corresponding tests and a new drag stylesheet; the `GraphTaskNode` and `DependencyGraph` components are updated to wire up the drag behavior.

Fusion-Task-Id: FN-3089
This commit is contained in:
Fusion
2026-05-07 05:21:13 -07:00
committed by gsxdsm
parent 7abfe7f9d1
commit a4824309ea
8 changed files with 341 additions and 6 deletions

View File

@@ -9,7 +9,7 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
- **Orphan dependency handling**: if a visible task depends on an excluded/missing dependency (for example `done`/`archived` after filtering), the missing edge is silently dropped and graph rendering continues without broken connectors
- **Auto-layout**: Sugiyama-style layered layout (`computeAutoLayout`) groups nodes by dependency depth and spaces layers consistently
- **Edge drawing**: SVG bezier curves from source bottom-center to target top-center, with arrowheads showing dependent → dependency direction
- **Interaction**: drag-to-pan canvas, cursor-centered wheel zoom, pinch-to-zoom with stationary midpoint, keyboard shortcuts, zoom toolbar, reset, and fit-to-graph
- **Interaction**: drag-to-pan canvas background, drag-to-reposition nodes, cursor-centered wheel zoom, pinch-to-zoom with stationary midpoint, keyboard shortcuts, zoom toolbar, reset, and fit-to-graph
- **Fit-to-graph**: computes node bounding box with layout node dimensions and applies zoom/pan so the graph fits in viewport with padding
- **Initial auto-fit**: when no saved scoped positions exist, the first non-empty render auto-fits once; subsequent updates preserve user navigation state
- **Animated transitions**: fit/reset operations animate `transform` (`var(--transition-normal)`), while continuous drag/wheel/pinch stays transition-free for responsiveness

View File

@@ -74,10 +74,22 @@ export function DependencyGraph({
const activeTaskId = hoveredTaskId ?? selectedTaskId;
const highlightedTaskIds = useMemo(() => (activeTaskId ? getChain(activeTaskId) : new Set<string>()), [activeTaskId, getChain]);
const positions = useMemo(
const autoLayoutPositions = useMemo(
() => computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 }),
[graphData],
);
const [positions, setPositions] = useState<Map<string, { x: number; y: number }>>(autoLayoutPositions);
const [isNodeDragging, setIsNodeDragging] = useState(false);
useEffect(() => {
setPositions((current) => {
const next = new Map<string, { x: number; y: number }>();
for (const [taskId, layoutPosition] of autoLayoutPositions.entries()) {
next.set(taskId, current.get(taskId) ?? layoutPosition);
}
return next;
});
}, [autoLayoutPositions]);
const {
transform,
@@ -125,11 +137,13 @@ export function DependencyGraph({
ref={viewportRef}
className="dependency-graph__viewport"
onPointerDown={(event) => {
if (isNodeDragging) return;
pointerDownRef.current = { x: event.clientX, y: event.clientY };
pointerDraggedRef.current = false;
onPointerDown(event.pointerId, { x: event.clientX, y: event.clientY });
}}
onPointerMove={(event) => {
if (isNodeDragging) return;
const viewport = viewportRef.current;
if (!viewport) return;
const pointerDown = pointerDownRef.current;
@@ -143,11 +157,15 @@ export function DependencyGraph({
onPointerMove(event.pointerId, { x: event.clientX, y: event.clientY }, viewport.clientWidth, viewport.clientHeight);
}}
onPointerUp={(event) => {
onPointerUp(event.pointerId);
if (!isNodeDragging) {
onPointerUp(event.pointerId);
}
pointerDownRef.current = null;
}}
onPointerCancel={(event) => {
onPointerUp(event.pointerId);
if (!isNodeDragging) {
onPointerUp(event.pointerId);
}
pointerDownRef.current = null;
pointerDraggedRef.current = false;
}}
@@ -166,7 +184,7 @@ export function DependencyGraph({
tabIndex={0}
style={{ outline: "none" }}
onClick={() => {
if (pointerDraggedRef.current) return;
if (pointerDraggedRef.current || isNodeDragging) return;
setSelectedTaskId(null);
}}
>
@@ -200,6 +218,18 @@ export function DependencyGraph({
task={node.task}
projectId={projectId}
style={{ minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
position={position}
scale={zoom}
onNodePositionChange={(taskId, nextPosition) => {
setPositions((current) => {
const existing = current.get(taskId);
if (existing && existing.x === nextPosition.x && existing.y === nextPosition.y) return current;
const next = new Map(current);
next.set(taskId, nextPosition);
return next;
});
}}
onNodeDragStateChange={setIsNodeDragging}
isHighlighted={highlightedTaskIds.size > 0 && highlightedTaskIds.has(node.task.id)}
isDimmed={highlightedTaskIds.size > 0 && !highlightedTaskIds.has(node.task.id)}
onOpenDetail={onOpenDetail ?? ((task) => onOpenTaskDetail?.(task.id))}

View File

@@ -1,8 +1,11 @@
import type { CSSProperties, ComponentProps, HTMLAttributes } from "react";
import type { GraphPosition } from "./types";
import { useNodeDrag } from "./hooks/useNodeDrag";
import { TaskCard } from "@fusion/dashboard/app/components/TaskCard";
import { isTaskStuck } from "@fusion/dashboard/app/utils/taskStuck";
import "./GraphTaskNode.css";
import "./GraphHighlight.css";
import "./styles/drag.css";
type TaskCardComponentProps = ComponentProps<typeof TaskCard>;
@@ -28,8 +31,12 @@ type TaskCardBridgeProps = Pick<
export interface GraphTaskNodeProps extends TaskCardBridgeProps, Pick<HTMLAttributes<HTMLDivElement>, "onMouseEnter" | "onMouseLeave" | "onClick"> {
style?: CSSProperties;
position: GraphPosition;
scale: number;
isHighlighted?: boolean;
isDimmed?: boolean;
onNodePositionChange: (taskId: string, position: GraphPosition) => void;
onNodeDragStateChange?: (isDragging: boolean) => void;
}
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
@@ -44,11 +51,15 @@ function getStatusLabel(status?: string): string {
export function GraphTaskNode({
style,
position,
scale,
isHighlighted = false,
isDimmed = false,
onMouseEnter,
onMouseLeave,
onClick,
onNodePositionChange,
onNodeDragStateChange,
...taskCardProps
}: GraphTaskNodeProps) {
const { task, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs } = taskCardProps;
@@ -71,9 +82,17 @@ export function GraphTaskNode({
task.currentStep < task.steps.length;
const isInReview = task.column === "in-review";
const drag = useNodeDrag({
taskId: task.id,
position,
scale,
onPositionChange: onNodePositionChange,
onDragStateChange: onNodeDragStateChange,
});
return (
<div
className={`graph-task-node${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 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" : ""}`}
style={style}
draggable={false}
data-testid={`graph-task-node-${task.id}`}
@@ -81,6 +100,11 @@ export function GraphTaskNode({
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
onClickCapture={drag.onClickCapture}
onPointerDown={drag.onPointerDown}
onPointerMove={drag.onPointerMove}
onPointerUp={drag.onPointerUp}
onPointerCancel={drag.onPointerCancel}
>
{isActive ? (
<div className="graph-task-active-indicator">

View File

@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import type { Task } from "@fusion/core";
import { GraphTaskNode } from "../GraphTaskNode";
function task(id = "FN-1"): Task {
return { id, description: id, column: "todo", dependencies: [], steps: [], currentStep: 0, log: [] } as Task;
}
function props(overrides: Partial<React.ComponentProps<typeof GraphTaskNode>> = {}): React.ComponentProps<typeof GraphTaskNode> {
return {
task: task(),
projectId: "p1",
position: { x: 0, y: 0 },
scale: 1,
isHighlighted: false,
isDimmed: false,
onNodePositionChange: vi.fn(),
onNodeDragStateChange: vi.fn(),
onOpenDetail: vi.fn(),
addToast: vi.fn(),
onUpdateTask: vi.fn(),
onArchiveTask: vi.fn(),
onUnarchiveTask: vi.fn(),
onDeleteTask: vi.fn(),
onRetryTask: vi.fn(),
onOpenDetailWithTab: vi.fn(),
onMoveTask: vi.fn(),
onOpenMission: vi.fn(),
taskStuckTimeoutMs: 1000,
lastFetchTimeMs: Date.now(),
workflowStepNameLookup: new Map<string, string>(),
...overrides,
};
}
afterEach(() => {
cleanup();
});
describe("GraphTaskNode drag", () => {
it("applies dragging class only after threshold move", () => {
const onNodePositionChange = vi.fn();
render(<GraphTaskNode {...props({ onNodePositionChange })} />);
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: 12, clientY: 12, isPrimary: true });
expect(node.className).not.toContain("graph-node--dragging");
fireEvent.pointerMove(node, { pointerId: 1, clientX: 20, clientY: 20, isPrimary: true });
expect(node.className).toContain("graph-node--dragging");
expect(onNodePositionChange).toHaveBeenCalled();
fireEvent.pointerUp(node, { pointerId: 1, clientX: 20, clientY: 20, isPrimary: true });
expect(node.className).not.toContain("graph-node--dragging");
});
it("composes highlight and dragging classes", () => {
render(<GraphTaskNode {...props({ isHighlighted: true })} />);
const node = screen.getByTestId("graph-task-node-FN-1");
fireEvent.pointerDown(node, { pointerId: 1, clientX: 0, clientY: 0, isPrimary: true });
fireEvent.pointerMove(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
expect(node.className).toContain("graph-task-node--highlighted");
expect(node.className).toContain("graph-node--dragging");
});
});

View File

@@ -20,6 +20,10 @@ function createTask(overrides: Partial<Task> = {}): Task {
function createProps(task: Task) {
return {
task,
position: { x: 0, y: 0 },
scale: 1,
onNodePositionChange: vi.fn(),
onNodeDragStateChange: vi.fn(),
projectId: "proj-1",
onOpenDetail: vi.fn(),
addToast: vi.fn(),

View File

@@ -0,0 +1,81 @@
import { act, renderHook } from "@testing-library/react";
import type React from "react";
import { describe, expect, it, vi } from "vitest";
import { __internal, useNodeDrag } from "../hooks/useNodeDrag";
function pointerEvent(overrides: Partial<PointerEvent> = {}) {
const target = {
setPointerCapture: vi.fn(),
releasePointerCapture: vi.fn(),
hasPointerCapture: vi.fn(() => true),
};
return {
isPrimary: true,
pointerId: 1,
clientX: 0,
clientY: 0,
stopPropagation: vi.fn(),
currentTarget: target,
...overrides,
} as unknown as React.PointerEvent<HTMLElement>;
}
describe("useNodeDrag", () => {
it("transitions pending to dragging and back on pointer up", () => {
const onPositionChange = vi.fn();
const onDragStateChange = vi.fn();
const { result } = renderHook(() =>
useNodeDrag({ taskId: "A", position: { x: 10, y: 10 }, scale: 1, onPositionChange, onDragStateChange }),
);
act(() => result.current.onPointerDown(pointerEvent({ clientX: 10, clientY: 20 })));
act(() => result.current.onPointerMove(pointerEvent({ clientX: 16, clientY: 26 })));
expect(result.current.isDragging).toBe(true);
expect(onPositionChange).toHaveBeenCalledWith("A", { x: 16, y: 16 });
act(() => result.current.onPointerUp(pointerEvent({ clientX: 16, clientY: 26 })));
expect(result.current.isDragging).toBe(false);
expect(onDragStateChange).toHaveBeenCalledWith(true);
expect(onDragStateChange).toHaveBeenCalledWith(false);
});
it("stays click-only below threshold", () => {
const onPositionChange = vi.fn();
const { result } = renderHook(() =>
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, onPositionChange }),
);
act(() => result.current.onPointerDown(pointerEvent()));
act(() => result.current.onPointerMove(pointerEvent({ clientX: __internal.DRAG_THRESHOLD_PX - 1, clientY: 0 })));
act(() => result.current.onPointerUp(pointerEvent({ clientX: __internal.DRAG_THRESHOLD_PX - 1, clientY: 0 })));
expect(result.current.isDragging).toBe(false);
expect(onPositionChange).not.toHaveBeenCalled();
});
it("divides pointer delta by zoom scale", () => {
const onPositionChange = vi.fn();
const { result } = renderHook(() =>
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 2, onPositionChange }),
);
act(() => result.current.onPointerDown(pointerEvent()));
act(() => result.current.onPointerMove(pointerEvent({ clientX: 10, clientY: 6 })));
expect(onPositionChange).toHaveBeenCalledWith("A", { x: 5, y: 3 });
});
it("cancels drag cleanly on pointer cancel", () => {
const onPositionChange = vi.fn();
const { result } = renderHook(() =>
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, onPositionChange }),
);
act(() => result.current.onPointerDown(pointerEvent()));
act(() => result.current.onPointerMove(pointerEvent({ clientX: 8, clientY: 0 })));
expect(result.current.isDragging).toBe(true);
act(() => result.current.onPointerCancel(pointerEvent({ clientX: 8, clientY: 0 })));
expect(result.current.isDragging).toBe(false);
});
});

View File

@@ -0,0 +1,112 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
import type { GraphPosition } from "../types";
const DRAG_THRESHOLD_PX = 4;
interface UseNodeDragOptions {
taskId: string;
position: GraphPosition;
scale: number;
onPositionChange: (taskId: string, position: GraphPosition) => void;
onDragStateChange?: (isDragging: boolean) => void;
}
interface PendingState {
pointerId: number;
startPointer: { x: number; y: number };
startPosition: GraphPosition;
}
export function useNodeDrag({ taskId, position, scale, onPositionChange, onDragStateChange }: UseNodeDragOptions) {
const [isDragging, setIsDragging] = useState(false);
const pendingRef = useRef<PendingState | null>(null);
const positionRef = useRef(position);
const suppressClickRef = useRef(false);
positionRef.current = position;
const endDrag = useCallback((dragging: boolean) => {
pendingRef.current = null;
setIsDragging(false);
if (dragging) {
onDragStateChange?.(false);
suppressClickRef.current = true;
}
}, [onDragStateChange]);
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
if (!event.isPrimary) return;
event.stopPropagation();
const currentTarget = event.currentTarget;
if (typeof currentTarget.setPointerCapture === "function") {
currentTarget.setPointerCapture(event.pointerId);
}
pendingRef.current = {
pointerId: event.pointerId,
startPointer: { x: event.clientX, y: event.clientY },
startPosition: positionRef.current,
};
}, []);
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
const pending = pendingRef.current;
if (!pending || pending.pointerId !== event.pointerId) return;
event.stopPropagation();
const deltaX = event.clientX - pending.startPointer.x;
const deltaY = event.clientY - pending.startPointer.y;
const distance = Math.hypot(deltaX, deltaY);
if (!isDragging && distance >= DRAG_THRESHOLD_PX) {
setIsDragging(true);
onDragStateChange?.(true);
}
if (distance < DRAG_THRESHOLD_PX) return;
const safeScale = scale > 0 ? scale : 1;
onPositionChange(taskId, {
x: pending.startPosition.x + deltaX / safeScale,
y: pending.startPosition.y + deltaY / safeScale,
});
}, [isDragging, onDragStateChange, onPositionChange, scale, taskId]);
const onPointerUp = useCallback((event: ReactPointerEvent<HTMLElement>) => {
const pending = pendingRef.current;
if (!pending || pending.pointerId !== event.pointerId) return;
event.stopPropagation();
if (typeof event.currentTarget.hasPointerCapture === "function" && event.currentTarget.hasPointerCapture(event.pointerId) && typeof event.currentTarget.releasePointerCapture === "function") {
event.currentTarget.releasePointerCapture(event.pointerId);
}
endDrag(isDragging);
}, [endDrag, isDragging]);
const onPointerCancel = useCallback((event: ReactPointerEvent<HTMLElement>) => {
const pending = pendingRef.current;
if (!pending || pending.pointerId !== event.pointerId) return;
event.stopPropagation();
if (typeof event.currentTarget.hasPointerCapture === "function" && event.currentTarget.hasPointerCapture(event.pointerId) && typeof event.currentTarget.releasePointerCapture === "function") {
event.currentTarget.releasePointerCapture(event.pointerId);
}
endDrag(isDragging);
}, [endDrag, isDragging]);
const onClickCapture = useCallback((event: ReactMouseEvent<HTMLElement>) => {
if (!suppressClickRef.current) return;
suppressClickRef.current = false;
event.preventDefault();
event.stopPropagation();
}, []);
return useMemo(() => ({
isDragging,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onClickCapture,
}), [isDragging, onClickCapture, onPointerCancel, onPointerDown, onPointerMove, onPointerUp]);
}
export const __internal = { DRAG_THRESHOLD_PX };

View File

@@ -0,0 +1,15 @@
.graph-node--draggable {
cursor: grab;
touch-action: none;
}
.graph-node--dragging {
z-index: 3;
cursor: grabbing;
box-shadow: var(--shadow-lg);
transform: scale(1.02);
transition:
transform var(--transition-fast),
box-shadow var(--transition-fast),
z-index var(--transition-fast);
}