feat(FN-3082): restructure dependency graph plugin with modular architectur

Implements a modular dependency graph feature for the Fusion dashboard plugin, replacing the monolithic `DependencyGraphView` component with a factored architecture: graph types and filtering (Step 1), a data hook (Step 2), auto-layout engine (Step 3), SVG edge rendering (Step 4), an interaction hoo

Fusion-Task-Id: FN-3082
This commit is contained in:
Fusion
2026-05-07 01:34:57 -07:00
committed by gsxdsm
parent fa57034c05
commit 12326eeb00
28 changed files with 1021 additions and 928 deletions

View File

@@ -2,34 +2,19 @@
Plugin-provided top-level **Graph** dashboard view for Fusion.
- Registers `dashboardViews: [{ viewId: "graph", placement: "more" }]`
- Renders active task dependency graph for `triage`, `todo`, `in-progress`, `in-review`
- Excludes `done` and `archived`
- Persists drag positions in browser localStorage at:
- `kb:${projectId}:dependency-graph-positions`
## Rendering approach
The first version uses a lightweight custom SVG/HTML renderer (no React Flow dependency).
- **Filtering**: includes `triage`, `todo`, `in-progress`, `in-review`; excludes `done`, `archived`
- **Graph build**: edges are resolved only from `task.dependencies` as `source=dependent`, `target=dependency`
- **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**: pan, wheel zoom, pinch zoom, zoom-in/out controls, reset, and fit-to-screen
- **Fit-to-screen**: computes node bounding box with layout node dimensions and applies zoom/pan so the graph fits in viewport with padding
## Mobile Support
## Controls
The dependency graph view is fully usable on mobile devices:
- **Fit to screen** (`Maximize`)
- **Zoom in** (`ZoomIn`)
- **Zoom out** (`ZoomOut`)
- **Pinch-to-zoom** — Two-finger pinch gestures scale the graph proportionally, clamped to `[0.4×, 2×]`
- **Mouse wheel zoom** — Desktop users can scroll-wheel to zoom toward the pointer position
- **Auto-fit on mobile** — On initial mobile load, the graph automatically fits all nodes into the viewport
- **Touch-friendly controls** — Zoom In, Zoom Out, and Fit buttons have 44px minimum touch targets on mobile
- **Sticky control bar** — Controls remain accessible at the top of the viewport while panning the graph on mobile
- **Touch-action isolation** — `touch-action: none` on the canvas prevents browser gesture interference with custom pan/zoom
- **Empty state** — When no active tasks exist, a centered message guides the user instead of showing an empty canvas
### Supported Interactions
| Input | Action |
|-------|--------|
| Single pointer drag on canvas | Pan the graph |
| Single pointer drag on node | Move the node |
| Single pointer click on node | Open task detail |
| Two-finger pinch | Zoom in/out |
| Mouse wheel | Zoom toward pointer |
| Zoom In / Zoom Out buttons | Step zoom ±0.1× |
| Fit button | Auto-fit all nodes to viewport |
All controls are rendered as floating `.btn-icon` actions in the bottom-right corner, with mobile-friendly sizing in the `@media (max-width: 768px)` override.

View File

@@ -10,8 +10,8 @@
"import": "./src/index.ts"
},
"./dashboard-view": {
"types": "./src/DependencyGraphView.tsx",
"import": "./src/DependencyGraphView.tsx"
"types": "./src/DependencyGraph.tsx",
"import": "./src/DependencyGraph.tsx"
}
},
"scripts": {
@@ -20,7 +20,9 @@
},
"dependencies": {
"@fusion/core": "workspace:*",
"@fusion/plugin-sdk": "workspace:*"
"@fusion/dashboard": "workspace:*",
"@fusion/plugin-sdk": "workspace:*",
"lucide-react": "^0.542.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",

View File

@@ -0,0 +1,70 @@
.dependency-graph {
position: relative;
height: 100%;
padding: var(--space-md);
}
.dependency-graph__viewport {
position: relative;
overflow: hidden;
height: 100%;
min-height: calc(var(--space-2xl) * 10);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
touch-action: none;
}
.dependency-graph__canvas {
position: relative;
transform-origin: top left;
}
.dependency-graph-edges {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: visible;
}
.dependency-graph__node {
position: absolute;
}
.dependency-graph__node .card {
height: 100%;
}
.dependency-graph__empty {
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.dependency-graph__toolbar {
position: absolute;
right: var(--space-md);
bottom: var(--space-md);
display: flex;
gap: var(--space-xs);
}
@media (max-width: 768px) {
.dependency-graph {
padding: var(--space-sm);
}
.dependency-graph__toolbar {
right: var(--space-sm);
bottom: var(--space-sm);
}
.dependency-graph__toolbar .btn-icon {
min-width: calc(var(--space-xs) * 11);
min-height: calc(var(--space-xs) * 11);
}
}

View File

@@ -0,0 +1,118 @@
import { Maximize, ZoomIn, ZoomOut } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import type { Task } from "@fusion/core";
import { TaskCard } from "@fusion/dashboard/app/components/TaskCard";
import { GraphEdges } from "./edges";
import { filterGraphTasks } from "./filters";
import { computeAutoLayout } from "./layout";
import { useGraphData } from "./useGraphData";
import { useGraphInteraction } from "./useGraphInteraction";
import "./DependencyGraph.css";
const NODE_WIDTH = 280;
const NODE_HEIGHT = 100;
export interface DependencyGraphProps {
tasks: Task[];
projectId?: string;
onOpenTaskDetail: (taskId: string) => void;
}
export function DependencyGraph({ tasks, projectId, onOpenTaskDetail }: DependencyGraphProps) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const filteredTasks = useMemo(() => filterGraphTasks(tasks), [tasks]);
const graphData = useGraphData(filteredTasks);
const positions = useMemo(
() => computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 }),
[graphData],
);
const {
transform,
zoomIn,
zoomOut,
fitToGraph,
onPointerDown,
onPointerMove,
onPointerUp,
onWheelZoom,
} = useGraphInteraction();
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
}, [fitToGraph, positions]);
const bounds = useMemo(() => {
const values = Array.from(positions.values());
if (values.length === 0) return { width: 0, height: 0 };
const maxX = Math.max(...values.map((pos) => pos.x + NODE_WIDTH));
const maxY = Math.max(...values.map((pos) => pos.y + NODE_HEIGHT));
return { width: maxX, height: maxY };
}, [positions]);
return (
<section className="dependency-graph" data-testid="dependency-graph">
<div
ref={viewportRef}
className="dependency-graph__viewport"
onPointerDown={(event) => onPointerDown(event.pointerId, { x: event.clientX, y: event.clientY })}
onPointerMove={(event) => {
const viewport = viewportRef.current;
if (!viewport) return;
onPointerMove(event.pointerId, { x: event.clientX, y: event.clientY }, viewport.clientWidth, viewport.clientHeight);
}}
onPointerUp={(event) => onPointerUp(event.pointerId)}
onPointerCancel={(event) => onPointerUp(event.pointerId)}
onWheel={(event) => {
event.preventDefault();
const viewport = viewportRef.current;
if (!viewport) return;
const rect = viewport.getBoundingClientRect();
onWheelZoom(event.deltaY, { x: event.clientX - rect.left, y: event.clientY - rect.top }, viewport.clientWidth, viewport.clientHeight);
}}
>
{filteredTasks.length === 0 ? (
<div className="dependency-graph__empty">No active tasks to display in graph view.</div>
) : (
<div className="dependency-graph__canvas" style={{ transform, width: `${bounds.width}px`, height: `${bounds.height}px` }}>
<GraphEdges edges={graphData.edges} positions={positions} nodeWidth={NODE_WIDTH} nodeHeight={NODE_HEIGHT} />
{graphData.nodes.map((node) => {
const position = positions.get(node.task.id);
if (!position) return null;
return (
<div
key={node.task.id}
className="dependency-graph__node"
style={{ width: `${NODE_WIDTH}px`, minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
>
<TaskCard
task={node.task}
projectId={projectId}
onOpenDetail={() => onOpenTaskDetail(node.task.id)}
addToast={() => {}}
disableDrag={true}
/>
</div>
);
})}
</div>
)}
</div>
<div className="dependency-graph__toolbar">
<button className="btn btn-icon" aria-label="Fit to screen" onClick={() => {
const viewport = viewportRef.current;
if (!viewport) return;
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
}}>
<Maximize size={16} />
</button>
<button className="btn btn-icon" aria-label="Zoom in" onClick={zoomIn}><ZoomIn size={16} /></button>
<button className="btn btn-icon" aria-label="Zoom out" onClick={zoomOut}><ZoomOut size={16} /></button>
</div>
</section>
);
}

View File

@@ -1,141 +0,0 @@
.dependency-graph-view {
--dependency-graph-canvas-min-height: calc(var(--space-2xl) * 10);
--dependency-graph-canvas-min-height-mobile: calc(var(--space-2xl) * 8);
--dependency-graph-edge-width: var(--btn-border-width);
--dependency-graph-node-max-width-mobile: calc(var(--space-2xl) * 10);
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-lg);
}
.dependency-graph-controls {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.dependency-graph-canvas {
overflow: auto;
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
min-height: var(--dependency-graph-canvas-min-height);
cursor: grab;
touch-action: none;
}
.dependency-graph-canvas:active {
cursor: grabbing;
}
.dependency-graph-scene {
position: relative;
transition: transform var(--transition-fast);
}
.dependency-graph-edges {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.dependency-graph-edge {
stroke: var(--border);
stroke-width: var(--dependency-graph-edge-width);
transition: stroke var(--transition-fast), opacity var(--transition-fast);
}
.dependency-graph-edge.is-related {
stroke: var(--todo);
opacity: 1;
}
.dependency-graph-edge.is-dimmed {
stroke: var(--border);
opacity: 0.35;
}
.dependency-graph-node {
position: absolute;
top: 0;
left: 0;
cursor: grab;
transition: opacity var(--transition-fast), filter var(--transition-fast), box-shadow var(--transition-fast);
}
.dependency-graph-node:active {
cursor: grabbing;
}
.dependency-graph-node .card {
height: 100%;
}
.dependency-graph-node.is-selected .card {
box-shadow: var(--focus-ring-strong);
border-color: var(--todo);
opacity: 1;
}
.dependency-graph-node.is-related:not(.is-selected) .card {
border-color: var(--in-progress);
box-shadow: var(--shadow-sm);
opacity: 1;
}
.dependency-graph-node.is-dimmed {
opacity: 0.5;
filter: saturate(0.8);
}
.dependency-graph-node.is-dimmed .card {
border-color: var(--border);
}
.dependency-graph-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: var(--dependency-graph-canvas-min-height);
padding: var(--space-xl);
color: var(--text-muted);
text-align: center;
}
@media (max-width: 768px) {
.dependency-graph-view {
padding: var(--space-md);
}
.dependency-graph-controls {
position: sticky;
top: 0;
z-index: 1;
background: var(--bg);
padding: var(--space-sm) 0;
}
.dependency-graph-controls .btn {
min-height: calc(var(--space-xs) * 11);
min-width: calc(var(--space-xs) * 11);
}
.dependency-graph-canvas {
flex: 1;
min-height: 0;
min-height: var(--dependency-graph-canvas-min-height-mobile);
}
.dependency-graph-node {
width: min(100%, var(--dependency-graph-node-max-width-mobile)) !important;
}
.dependency-graph-empty {
min-height: var(--dependency-graph-canvas-min-height-mobile);
}
}

View File

@@ -1,428 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent, ReactNode, WheelEvent as ReactWheelEvent } from "react";
import type { Task } from "@fusion/core";
import { loadPositions, savePositions } from "./storage";
import "./DependencyGraphView.css";
const ACTIVE_COLUMNS = new Set(["triage", "todo", "in-progress", "in-review"]);
const NODE_WIDTH_REM = 18;
const NODE_HEIGHT_REM = 9;
const GRID_GAP_X_REM = 3;
const GRID_GAP_Y_REM = 4;
const DRAG_THRESHOLD_REM = 0.5;
const SCENE_PADDING_REM = 2;
const FIT_PADDING_REM = 2;
const MIN_SCALE = 0.4;
const MAX_SCALE = 2;
const WHEEL_ZOOM_FACTOR = 0.002;
const MOBILE_BREAKPOINT = 768;
export interface DependencyGraphHostContext {
projectId?: string;
tasks: Task[];
openTaskDetail: (task: Task) => void;
renderTaskCard: (task: Task) => ReactNode;
}
export interface PluginDashboardViewComponentProps {
context: DependencyGraphHostContext;
}
type Position = { x: number; y: number };
function getDistance(a: Position, b: Position): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function isMobileViewport(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches;
}
export function DependencyGraphView({ context }: PluginDashboardViewComponentProps) {
const [scale, setScale] = useState(1);
const [pan, setPan] = useState<Position>({ x: 0, y: 0 });
const [nodeOverrides, setNodeOverrides] = useState<Record<string, Position>>({});
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const persisted = useMemo(() => loadPositions(context.projectId), [context.projectId]);
const canvasRef = useRef<HTMLDivElement | null>(null);
// Multi-pointer tracking (no setPointerCapture — it breaks two-finger gestures)
const pointersRef = useRef<Map<number, Position>>(new Map());
const interactionRef = useRef<
| { kind: "node"; taskId: string; startPointer: Position; startNode: Position; moved: boolean }
| { kind: "pan"; startPointer: Position; startPan: Position; moved: boolean }
| null
>(null);
const pinchRef = useRef<{ startDistance: number; startScale: number } | null>(null);
const autoFitDoneRef = useRef(false);
const tasks = useMemo(
() => context.tasks.filter((task) => ACTIVE_COLUMNS.has(task.column)),
[context.tasks],
);
const positioned = useMemo(() => {
return tasks.map((task, index) => {
const saved = nodeOverrides[task.id] ?? persisted[task.id];
return {
task,
x: saved?.x ?? (index % 4) * (NODE_WIDTH_REM + GRID_GAP_X_REM),
y: saved?.y ?? Math.floor(index / 4) * (NODE_HEIGHT_REM + GRID_GAP_Y_REM),
};
});
}, [nodeOverrides, persisted, tasks]);
const map = useMemo(() => new Map(positioned.map((node) => [node.task.id, node])), [positioned]);
const edges = useMemo(() => {
const lines: Array<{ from: string; to: string; x1: number; y1: number; x2: number; y2: number }> = [];
positioned.forEach((node) => {
(node.task.dependencies ?? []).forEach((dependencyId) => {
const dependency = map.get(dependencyId);
if (!dependency) return;
lines.push({
from: dependencyId,
to: node.task.id,
x1: dependency.x + NODE_WIDTH_REM,
y1: dependency.y + NODE_HEIGHT_REM / 2,
x2: node.x,
y2: node.y + NODE_HEIGHT_REM / 2,
});
});
});
return lines;
}, [map, positioned]);
const bounds = useMemo(() => {
if (positioned.length === 0) {
return { minX: 0, minY: 0, width: NODE_WIDTH_REM * 2, height: NODE_HEIGHT_REM * 2 };
}
const minX = Math.min(...positioned.map((node) => node.x)) - SCENE_PADDING_REM;
const minY = Math.min(...positioned.map((node) => node.y)) - SCENE_PADDING_REM;
const maxX = Math.max(...positioned.map((node) => node.x + NODE_WIDTH_REM)) + SCENE_PADDING_REM;
const maxY = Math.max(...positioned.map((node) => node.y + NODE_HEIGHT_REM)) + SCENE_PADDING_REM;
return {
minX,
minY,
width: Math.max(NODE_WIDTH_REM * 2, maxX - minX),
height: Math.max(NODE_HEIGHT_REM * 2, maxY - minY),
};
}, [positioned]);
const positionedForRender = useMemo(
() =>
positioned.map((node) => ({
...node,
renderX: node.x - bounds.minX,
renderY: node.y - bounds.minY,
})),
[bounds.minX, bounds.minY, positioned],
);
const edgesForRender = useMemo(
() =>
edges.map((edge) => ({
...edge,
renderX1: edge.x1 - bounds.minX,
renderY1: edge.y1 - bounds.minY,
renderX2: edge.x2 - bounds.minX,
renderY2: edge.y2 - bounds.minY,
})),
[bounds.minX, bounds.minY, edges],
);
const dependencyGraph = useMemo(() => {
const downstream = new Map<string, Set<string>>();
const upstream = new Map<string, Set<string>>();
edges.forEach((edge) => {
downstream.set(edge.from, (downstream.get(edge.from) ?? new Set<string>()).add(edge.to));
upstream.set(edge.to, (upstream.get(edge.to) ?? new Set<string>()).add(edge.from));
});
return { downstream, upstream };
}, [edges]);
const focusTaskId = hoveredTaskId ?? selectedTaskId;
const relatedTaskIds = useMemo(() => {
if (!focusTaskId) return null;
const related = new Set<string>([focusTaskId]);
const walk = (seed: string, map: Map<string, Set<string>>) => {
const queue = [seed];
while (queue.length > 0) {
const current = queue.shift();
if (!current) continue;
(map.get(current) ?? new Set<string>()).forEach((next) => {
if (related.has(next)) return;
related.add(next);
queue.push(next);
});
}
};
walk(focusTaskId, dependencyGraph.downstream);
walk(focusTaskId, dependencyGraph.upstream);
return related;
}, [dependencyGraph.downstream, dependencyGraph.upstream, focusTaskId]);
const fitToGraph = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const rootFontSize = Number.parseFloat(globalThis.getComputedStyle(document.documentElement).fontSize) || 16;
const widthPx = bounds.width * rootFontSize;
const heightPx = bounds.height * rootFontSize;
const paddingPx = FIT_PADDING_REM * rootFontSize;
const availableWidth = Math.max(1, canvas.clientWidth - paddingPx * 2);
const availableHeight = Math.max(1, canvas.clientHeight - paddingPx * 2);
const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, Math.min(availableWidth / widthPx, availableHeight / heightPx)));
const centeredPanX = (canvas.clientWidth - widthPx * nextScale) / (2 * rootFontSize * nextScale);
const centeredPanY = (canvas.clientHeight - heightPx * nextScale) / (2 * rootFontSize * nextScale);
setScale(nextScale);
setPan({ x: centeredPanX, y: centeredPanY });
}, [bounds.width, bounds.height]);
// Auto-fit on initial mobile load
useEffect(() => {
if (autoFitDoneRef.current) return;
if (!isMobileViewport()) return;
if (positioned.length === 0) return;
const canvas = canvasRef.current;
if (!canvas) return;
// Ensure the canvas has non-zero dimensions before fitting
if (canvas.clientWidth === 0 || canvas.clientHeight === 0) return;
autoFitDoneRef.current = true;
// Use rAF to ensure layout is settled
requestAnimationFrame(() => {
fitToGraph();
});
}, [fitToGraph, positioned.length]);
const persistPosition = (taskId: string, next: Position) => {
setNodeOverrides((current) => ({ ...current, [taskId]: next }));
savePositions(context.projectId, { ...persisted, ...nodeOverrides, [taskId]: next });
};
const handlePointerDownOnNode = (taskId: string, event: ReactPointerEvent<HTMLDivElement>) => {
setSelectedTaskId((current) => (current === taskId ? null : taskId));
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const hit = map.get(taskId);
if (!hit) return;
// Track this pointer in the global map
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
interactionRef.current = {
kind: "node",
taskId,
startPointer: { x: event.clientX, y: event.clientY },
startNode: { x: hit.x, y: hit.y },
moved: false,
};
};
const handleCanvasPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
// Track this pointer
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
// If we already have another pointer, this is the start of a pinch gesture
if (pointersRef.current.size === 2) {
// Cancel any ongoing pan interaction
interactionRef.current = null;
const [p1, p2] = Array.from(pointersRef.current.values());
const distance = getDistance(p1, p2);
pinchRef.current = { startDistance: distance, startScale: scale };
return;
}
interactionRef.current = {
kind: "pan",
startPointer: { x: event.clientX, y: event.clientY },
startPan: pan,
moved: false,
};
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
// Update tracked pointer position
if (pointersRef.current.has(event.pointerId)) {
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
}
// Handle pinch gesture when two pointers are active
if (pointersRef.current.size >= 2 && pinchRef.current) {
const [p1, p2] = Array.from(pointersRef.current.values());
const currentDistance = getDistance(p1, p2);
const scaleFactor = currentDistance / pinchRef.current.startDistance;
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, pinchRef.current.startScale * scaleFactor));
setScale(newScale);
return;
}
const current = interactionRef.current;
if (!current) return;
const delta = {
x: (event.clientX - current.startPointer.x) / 16,
y: (event.clientY - current.startPointer.y) / 16,
};
if (current.kind === "node") {
const moved = getDistance({ x: 0, y: 0 }, delta) > DRAG_THRESHOLD_REM;
if (moved && !current.moved) current.moved = true;
if (!current.moved) return;
setNodeOverrides((existing) => ({
...existing,
[current.taskId]: { x: current.startNode.x + delta.x / scale, y: current.startNode.y + delta.y / scale },
}));
return;
}
const moved = getDistance({ x: 0, y: 0 }, delta) > DRAG_THRESHOLD_REM;
if (moved && !current.moved) current.moved = true;
if (!current.moved) return;
setPan({ x: current.startPan.x + delta.x, y: current.startPan.y + delta.y });
};
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
pointersRef.current.delete(event.pointerId);
// If we had a pinch and one finger remains, end pinch mode
if (pinchRef.current) {
if (pointersRef.current.size < 2) {
pinchRef.current = null;
}
return;
}
const current = interactionRef.current;
interactionRef.current = null;
if (!current) return;
if (current.kind === "node") {
const hit = map.get(current.taskId);
if (!hit) return;
if (!current.moved) {
context.openTaskDetail(hit.task);
return;
}
persistPosition(current.taskId, { x: hit.x, y: hit.y });
}
};
const handlePointerCancel = (event: ReactPointerEvent<HTMLDivElement>) => {
pointersRef.current.delete(event.pointerId);
pinchRef.current = null;
interactionRef.current = null;
};
const handleWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
event.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const rootFontSize = Number.parseFloat(globalThis.getComputedStyle(document.documentElement).fontSize) || 16;
const delta = -event.deltaY * WHEEL_ZOOM_FACTOR;
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale * (1 + delta)));
// Zoom toward the pointer position
const rect = canvas.getBoundingClientRect();
const pointerX = event.clientX - rect.left;
const pointerY = event.clientY - rect.top;
// How much the point under the cursor should shift in rem
const scaleRatio = newScale / scale;
const panOffsetX = (pointerX / rootFontSize) * (1 - scaleRatio) / scale;
const panOffsetY = (pointerY / rootFontSize) * (1 - scaleRatio) / scale;
setScale(newScale);
setPan((prev) => ({
x: prev.x + panOffsetX * newScale / scale,
y: prev.y + panOffsetY * newScale / scale,
}));
};
return (
<section className="dependency-graph-view">
<div className="dependency-graph-controls">
<button className="btn btn-sm" onClick={() => setScale((value) => Math.min(value + 0.1, MAX_SCALE))}>Zoom In</button>
<button className="btn btn-sm" onClick={() => setScale((value) => Math.max(value - 0.1, MIN_SCALE))}>Zoom Out</button>
<button className="btn btn-sm" onClick={fitToGraph}>Fit</button>
</div>
<div
className="dependency-graph-canvas"
ref={canvasRef}
onPointerDown={handleCanvasPointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onWheel={handleWheel}
>
{tasks.length === 0 ? (
<div className="dependency-graph-empty">
<p>No tasks to display. Tasks in Triage, Todo, In Progress, or In Review columns will appear here.</p>
</div>
) : (
<div
className="dependency-graph-scene"
style={{
width: `${bounds.width}rem`,
height: `${bounds.height}rem`,
transform: `translate(${pan.x}rem, ${pan.y}rem) scale(${scale})`,
transformOrigin: "top left",
}}
>
<svg className="dependency-graph-edges" viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
{edgesForRender.map((edge) => (
<line
key={`${edge.from}-${edge.to}`}
x1={edge.renderX1}
y1={edge.renderY1}
x2={edge.renderX2}
y2={edge.renderY2}
className={`dependency-graph-edge${relatedTaskIds ? relatedTaskIds.has(edge.from) && relatedTaskIds.has(edge.to) ? " is-related" : " is-dimmed" : ""}`}
/>
))}
</svg>
{positionedForRender.map((node) => (
<div
key={node.task.id}
className={`dependency-graph-node${selectedTaskId === node.task.id ? " is-selected" : ""}${relatedTaskIds ? relatedTaskIds.has(node.task.id) ? " is-related" : " is-dimmed" : ""}`}
style={{
width: `${NODE_WIDTH_REM}rem`,
minHeight: `${NODE_HEIGHT_REM}rem`,
transform: `translate(${node.renderX}rem, ${node.renderY}rem)`,
}}
onPointerDown={(event) => handlePointerDownOnNode(node.task.id, event)}
onPointerEnter={() => setHoveredTaskId(node.task.id)}
onPointerLeave={() => setHoveredTaskId((current) => (current === node.task.id ? null : current))}
>
{context.renderTaskCard(node.task)}
</div>
))}
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { DependencyGraph } from "../DependencyGraph";
const fitToGraph = vi.fn();
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: () => void }) => (
<button data-testid={`task-${task.id}`} onClick={onOpenDetail}>{task.id}</button>
),
}));
vi.mock("../useGraphInteraction", () => ({
useGraphInteraction: () => ({
transform: "translate(0px, 0px) scale(1)",
zoomIn: vi.fn(),
zoomOut: vi.fn(),
fitToGraph,
onPointerDown: vi.fn(),
onPointerMove: vi.fn(),
onPointerUp: vi.fn(),
onWheelZoom: vi.fn(),
}),
}));
function createTask(id: string, column: Task["column"], dependencies: string[] = []): Task {
return { id, description: id, column, dependencies, steps: [], currentStep: 0, log: [] } as Task;
}
describe("DependencyGraph", () => {
beforeEach(() => {
fitToGraph.mockReset();
});
afterEach(() => {
cleanup();
});
it("renders empty state for empty list", () => {
render(<DependencyGraph tasks={[]} onOpenTaskDetail={vi.fn()} />);
expect(screen.getByText(/No active tasks/i)).toBeTruthy();
});
it("renders positioned nodes and edges for included tasks", () => {
render(<DependencyGraph tasks={[
createTask("A", "todo"),
createTask("B", "in-progress", ["A"]),
]} onOpenTaskDetail={vi.fn()} />);
expect(screen.getByTestId("task-A")).toBeTruthy();
expect(screen.getByTestId("task-B")).toBeTruthy();
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(1);
});
it("excludes done and archived nodes", () => {
render(<DependencyGraph tasks={[
createTask("A", "todo"),
createTask("B", "done"),
createTask("C", "archived"),
]} onOpenTaskDetail={vi.fn()} />);
expect(screen.getByTestId("task-A")).toBeTruthy();
expect(screen.queryByTestId("task-B")).toBeNull();
expect(screen.queryByTestId("task-C")).toBeNull();
});
it("fit-to-screen button triggers fitToGraph", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Fit to screen" }));
expect(fitToGraph).toHaveBeenCalled();
});
});

View File

@@ -1,261 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, screen, act, cleanup } from "@testing-library/react";
import * as React from "react";
import { DependencyGraphView } from "../DependencyGraphView";
import type { DependencyGraphHostContext, PluginDashboardViewComponentProps } from "../DependencyGraphView";
import type { Task } from "@fusion/core";
// Mock storage to avoid localStorage dependency
vi.mock("../storage", () => ({
loadPositions: () => ({}),
savePositions: () => {},
}));
// Helper to create a minimal Task
function createTask(overrides: Partial<Task> & { id: string; column: Task["column"] }): Task {
return {
description: overrides.description ?? `Task ${overrides.id}`,
column: overrides.column,
dependencies: overrides.dependencies ?? [],
steps: overrides.steps ?? [],
currentStep: overrides.currentStep ?? 0,
log: overrides.log ?? [],
...overrides,
} as Task;
}
function createMockContext(tasks: Task[] = []): DependencyGraphHostContext {
return {
projectId: "test-project",
tasks,
openTaskDetail: vi.fn(),
renderTaskCard: (task: Task) => React.createElement("div", { "data-testid": "task-card" }, task.id),
};
}
function renderView(context?: DependencyGraphHostContext) {
const props: PluginDashboardViewComponentProps = {
context: context ?? createMockContext(),
};
return render(React.createElement(DependencyGraphView, props));
}
describe("DependencyGraphView", () => {
beforeEach(() => {
// Mock getComputedStyle for rem calculations
vi.spyOn(window, "getComputedStyle").mockImplementation((() => {
return { fontSize: "16px" } as CSSStyleDeclaration;
}) as typeof window.getComputedStyle);
// Mock matchMedia to default to desktop
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("renders empty state when no active-column tasks provided", () => {
const { container } = renderView(createMockContext([]));
const empty = container.querySelector(".dependency-graph-empty");
expect(empty).toBeTruthy();
expect(empty?.textContent).toContain("No tasks to display");
});
it("renders nodes for active-column tasks", () => {
const tasks = [
createTask({ id: "FN-001", column: "todo" }),
createTask({ id: "FN-002", column: "in-progress" }),
];
const { container } = renderView(createMockContext(tasks));
const nodes = container.querySelectorAll(".dependency-graph-node");
expect(nodes.length).toBe(2);
});
it("filters out tasks not in active columns", () => {
const tasks = [
createTask({ id: "FN-001", column: "todo" }),
createTask({ id: "FN-002", column: "done" }),
createTask({ id: "FN-003", column: "archived" }),
];
const { container } = renderView(createMockContext(tasks));
const nodes = container.querySelectorAll(".dependency-graph-node");
expect(nodes.length).toBe(1);
});
it("renders edges for tasks with dependencies in the active set", () => {
const tasks = [
createTask({ id: "FN-001", column: "todo" }),
createTask({ id: "FN-002", column: "todo", dependencies: ["FN-001"] }),
];
const { container } = renderView(createMockContext(tasks));
const edges = container.querySelectorAll(".dependency-graph-edge");
expect(edges.length).toBe(1);
});
it("does not render edges for dependencies not in the active set", () => {
const tasks = [
createTask({ id: "FN-002", column: "todo", dependencies: ["FN-999"] }),
];
const { container } = renderView(createMockContext(tasks));
const edges = container.querySelectorAll(".dependency-graph-edge");
expect(edges.length).toBe(0);
});
it("zoom-in button increases scale", () => {
const tasks = [createTask({ id: "FN-001", column: "todo" })];
const { container, unmount } = renderView(createMockContext(tasks));
const zoomInBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom In")!;
fireEvent.click(zoomInBtn);
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
expect(scene).toBeTruthy();
const transform = scene.style.transform;
expect(transform).toMatch(/scale\([1-9]/);
});
it("zoom-out button decreases scale", () => {
const tasks = [createTask({ id: "FN-001", column: "todo" })];
const { container, unmount } = renderView(createMockContext(tasks));
const zoomOutBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom Out")!;
fireEvent.click(zoomOutBtn);
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
expect(scene).toBeTruthy();
const transform = scene.style.transform;
expect(transform).toMatch(/scale\(0\./);
});
it("fit-to-graph button produces a valid scale", () => {
const tasks = [createTask({ id: "FN-001", column: "todo" })];
const { container, unmount } = renderView(createMockContext(tasks));
// Mock canvas dimensions for fitToGraph
const canvas = container.querySelector(".dependency-graph-canvas") as HTMLElement;
if (canvas) {
Object.defineProperty(canvas, "clientWidth", { value: 800, configurable: true });
Object.defineProperty(canvas, "clientHeight", { value: 600, configurable: true });
}
const fitBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Fit")!;
fireEvent.click(fitBtn);
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
expect(scene).toBeTruthy();
const transform = scene.style.transform;
const scaleMatch = transform.match(/scale\(([\d.]+)\)/);
expect(scaleMatch).toBeTruthy();
const scaleValue = parseFloat(scaleMatch![1]);
expect(scaleValue).toBeGreaterThanOrEqual(0.4);
expect(scaleValue).toBeLessThanOrEqual(2);
});
it("wheel event zooms the graph", () => {
const tasks = [createTask({ id: "FN-001", column: "todo" })];
const { container } = renderView(createMockContext(tasks));
const canvas = container.querySelector(".dependency-graph-canvas") as HTMLElement;
expect(canvas).toBeTruthy();
// Mock getBoundingClientRect on the canvas
canvas.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, x: 0, y: 0,
});
const initialScene = container.querySelector(".dependency-graph-scene") as HTMLElement;
const initialTransform = initialScene.style.transform;
// Negative deltaY = zoom in
fireEvent.wheel(canvas, { deltaY: -100, clientX: 400, clientY: 300 });
const updatedScene = container.querySelector(".dependency-graph-scene") as HTMLElement;
const updatedTransform = updatedScene.style.transform;
expect(updatedTransform).not.toBe(initialTransform);
const scaleMatch = updatedTransform.match(/scale\(([\d.]+)\)/);
expect(scaleMatch).toBeTruthy();
expect(parseFloat(scaleMatch![1])).toBeGreaterThan(1);
});
it("pinch gesture changes scale", () => {
const tasks = [createTask({ id: "FN-001", column: "todo" })];
const { container } = renderView(createMockContext(tasks));
const canvas = container.querySelector(".dependency-graph-canvas") as HTMLElement;
expect(canvas).toBeTruthy();
// First pointer down (finger 1)
fireEvent.pointerDown(canvas, {
pointerId: 1,
pointerType: "touch",
button: 0,
clientX: 200,
clientY: 300,
});
// Second pointer down (finger 2) — starts pinch
fireEvent.pointerDown(canvas, {
pointerId: 2,
pointerType: "touch",
button: 0,
clientX: 600,
clientY: 300,
});
// Move fingers apart (increasing distance)
fireEvent.pointerMove(canvas, {
pointerId: 1,
pointerType: "touch",
clientX: 100,
clientY: 300,
});
fireEvent.pointerMove(canvas, {
pointerId: 2,
pointerType: "touch",
clientX: 700,
clientY: 300,
});
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
const transform = scene.style.transform;
const scaleMatch = transform.match(/scale\(([\d.]+)\)/);
expect(scaleMatch).toBeTruthy();
expect(parseFloat(scaleMatch![1])).toBeGreaterThan(1);
});
it("scale is clamped between MIN_SCALE and MAX_SCALE", () => {
const tasks = [createTask({ id: "FN-001", column: "todo" })];
const { container, unmount } = renderView(createMockContext(tasks));
// Try to zoom in beyond MAX_SCALE (2.0)
const zoomInBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom In")!;
for (let i = 0; i < 50; i++) {
fireEvent.click(zoomInBtn);
}
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
let scaleMatch = scene.style.transform.match(/scale\(([\d.]+)\)/);
expect(scaleMatch).toBeTruthy();
expect(parseFloat(scaleMatch![1])).toBeLessThanOrEqual(2);
// Try to zoom out beyond MIN_SCALE (0.4)
const zoomOutBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom Out")!;
for (let i = 0; i < 100; i++) {
fireEvent.click(zoomOutBtn);
}
const updatedScene = container.querySelector(".dependency-graph-scene") as HTMLElement;
scaleMatch = updatedScene.style.transform.match(/scale\(([\d.]+)\)/);
expect(scaleMatch).toBeTruthy();
expect(parseFloat(scaleMatch![1])).toBeGreaterThanOrEqual(0.4);
});
});

View File

@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { GraphEdges } from "../edges";
import type { GraphEdge } from "../types";
function renderEdges(edges: GraphEdge[], highlightedEdgeIds?: Set<string>) {
const positions = new Map([
["A", { x: 0, y: 0 }],
["B", { x: 320, y: 180 }],
["C", { x: 640, y: 180 }],
]);
return render(
<GraphEdges
edges={edges}
positions={positions}
highlightedEdgeIds={highlightedEdgeIds}
/>,
);
}
describe("GraphEdges", () => {
afterEach(() => {
cleanup();
});
it("renders single edge", () => {
renderEdges([{ source: "A", target: "B" }]);
const edge = screen.getAllByTestId("dependency-edge")[0];
expect(edge.getAttribute("opacity")).toBe("1");
expect(edge.getAttribute("stroke")).toBe("var(--border)");
});
it("renders multiple edges", () => {
renderEdges([
{ source: "A", target: "B" },
{ source: "A", target: "C" },
]);
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(2);
});
it("supports edges with same source", () => {
renderEdges([
{ source: "A", target: "B" },
{ source: "A", target: "C" },
]);
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(2);
});
it("supports edges with same target", () => {
renderEdges([
{ source: "B", target: "A" },
{ source: "C", target: "A" },
]);
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(2);
});
it("dims non-highlighted edges when highlight set provided", () => {
renderEdges(
[
{ source: "A", target: "B" },
{ source: "A", target: "C" },
],
new Set(["A->B"]),
);
const all = screen.getAllByTestId("dependency-edge");
const highlighted = all.find((edge) => edge.getAttribute("data-edge-id") === "A->B");
const dimmed = all.find((edge) => edge.getAttribute("data-edge-id") === "A->C");
expect(highlighted?.getAttribute("opacity")).toBe("1");
expect(dimmed?.getAttribute("opacity")).toBe("0.2");
});
});

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { Task } from "@fusion/core";
import { filterGraphTasks } from "../filters";
function createTask(id: string, column: Task["column"], dependencies: string[] = []): Task {
return {
id,
description: `Task ${id}`,
column,
dependencies,
steps: [],
currentStep: 0,
log: [],
} as Task;
}
describe("filterGraphTasks", () => {
it("returns empty for empty input", () => {
expect(filterGraphTasks([])).toEqual([]);
});
it("includes triage/todo/in-progress/in-review and excludes done/archived", () => {
const tasks = [
createTask("FN-1", "triage"),
createTask("FN-2", "todo"),
createTask("FN-3", "in-progress"),
createTask("FN-4", "in-review"),
createTask("FN-5", "done"),
createTask("FN-6", "archived"),
];
expect(filterGraphTasks(tasks).map((task) => task.id)).toEqual(["FN-1", "FN-2", "FN-3", "FN-4"]);
});
it("returns empty when only excluded columns are present", () => {
const tasks = [createTask("FN-1", "done"), createTask("FN-2", "archived")];
expect(filterGraphTasks(tasks)).toEqual([]);
});
it("keeps included tasks even when dependencies reference excluded tasks", () => {
const tasks = [
createTask("FN-1", "done"),
createTask("FN-2", "todo", ["FN-1"]),
createTask("FN-3", "in-review", ["FN-2", "FN-1"]),
createTask("FN-4", "archived", ["FN-2"]),
];
expect(filterGraphTasks(tasks).map((task) => task.id)).toEqual(["FN-2", "FN-3"]);
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import type { GraphData } from "../types";
import { computeAutoLayout } from "../layout";
function graph(nodeIds: string[], edges: Array<{ source: string; target: string }> = []): GraphData {
return {
nodes: nodeIds.map((id) => ({ task: { id } as never })),
edges,
};
}
describe("computeAutoLayout", () => {
it("returns empty map for empty graph", () => {
expect(computeAutoLayout({ nodes: [], edges: [] }).size).toBe(0);
});
it("positions single node", () => {
const positions = computeAutoLayout(graph(["A"]));
expect(positions.has("A")).toBe(true);
});
it("places linear chain in increasing depth", () => {
const positions = computeAutoLayout(graph(["A", "B", "C"], [
{ source: "A", target: "B" },
{ source: "B", target: "C" },
]));
expect((positions.get("C")?.y ?? 0)).toBeLessThan(positions.get("B")?.y ?? 0);
expect((positions.get("B")?.y ?? 0)).toBeLessThan(positions.get("A")?.y ?? 0);
});
it("spreads wide layer horizontally", () => {
const positions = computeAutoLayout(graph(["A", "B", "C"]));
const xs = [positions.get("A")?.x, positions.get("B")?.x, positions.get("C")?.x].filter((x): x is number => x !== undefined);
expect(new Set(xs).size).toBe(3);
});
it("handles diamond dependencies", () => {
const positions = computeAutoLayout(graph(["A", "B", "C", "D"], [
{ source: "A", target: "B" },
{ source: "A", target: "C" },
{ source: "B", target: "D" },
{ source: "C", target: "D" },
]));
expect((positions.get("D")?.y ?? 0)).toBeLessThan(positions.get("B")?.y ?? 0);
expect((positions.get("D")?.y ?? 0)).toBeLessThan(positions.get("C")?.y ?? 0);
expect((positions.get("B")?.y ?? 0)).toBeLessThan(positions.get("A")?.y ?? 0);
expect((positions.get("C")?.y ?? 0)).toBeLessThan(positions.get("A")?.y ?? 0);
});
it("handles cycles without crashing", () => {
const positions = computeAutoLayout(graph(["A", "B"], [
{ source: "A", target: "B" },
{ source: "B", target: "A" },
]));
expect(positions.size).toBe(2);
});
it("respects custom spacing options", () => {
const positions = computeAutoLayout(graph(["A", "B"]), {
nodeWidth: 200,
nodeHeight: 120,
horizontalGap: 100,
verticalGap: 20,
});
expect(Math.abs((positions.get("A")?.x ?? 0) - (positions.get("B")?.x ?? 0))).toBe(300);
});
});

View File

@@ -1,41 +0,0 @@
import { describe, expect, it, beforeEach } from "vitest";
import { projectScopedKey, loadPositions, savePositions } from "../storage";
const createMemoryStorage = () => {
const map = new Map<string, string>();
return {
getItem: (key: string) => map.get(key) ?? null,
setItem: (key: string, value: string) => {
map.set(key, value);
},
clear: () => map.clear(),
};
};
describe("storage", () => {
const localStorage = createMemoryStorage();
beforeEach(() => {
(globalThis as { window?: { localStorage?: typeof localStorage } }).window = { localStorage };
localStorage.clear();
});
it("builds project-scoped key with canonical base key", () => {
expect(projectScopedKey("proj_123")).toBe("kb:proj_123:fusion-plugin-dependency-graph:positions");
});
it("falls back to unscoped key when projectId is missing or empty", () => {
expect(projectScopedKey()).toBe("fusion-plugin-dependency-graph:positions");
expect(projectScopedKey("")).toBe("fusion-plugin-dependency-graph:positions");
});
it("persists and restores positions", () => {
savePositions("proj_123", { "FN-1": { x: 10, y: 20 } });
expect(loadPositions("proj_123")).toEqual({ "FN-1": { x: 10, y: 20 } });
});
it("returns empty object for invalid JSON", () => {
localStorage.setItem("kb:proj_123:fusion-plugin-dependency-graph:positions", "not-json");
expect(loadPositions("proj_123")).toEqual({});
});
});

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { useGraphData } from "../useGraphData";
function createTask(id: string, dependencies: string[] = []): Task {
return {
id,
description: id,
column: "todo",
dependencies,
steps: [],
currentStep: 0,
log: [],
} as Task;
}
describe("useGraphData", () => {
it("returns empty graph for empty tasks", () => {
const { result } = renderHook(() => useGraphData([]));
expect(result.current).toEqual({ nodes: [], edges: [] });
});
it("creates node for single task with no deps", () => {
const { result } = renderHook(() => useGraphData([createTask("A")]));
expect(result.current.nodes.map((node) => node.task.id)).toEqual(["A"]);
expect(result.current.edges).toEqual([]);
});
it("creates edges in dependent-to-dependency direction for chain", () => {
const tasks = [createTask("A", ["B"]), createTask("B", ["C"]), createTask("C")];
const { result } = renderHook(() => useGraphData(tasks));
expect(result.current.edges).toEqual([
{ source: "A", target: "B" },
{ source: "B", target: "C" },
]);
});
it("creates diamond dependency edges", () => {
const tasks = [
createTask("A", ["B", "C"]),
createTask("B", ["D"]),
createTask("C", ["D"]),
createTask("D"),
];
const { result } = renderHook(() => useGraphData(tasks));
expect(result.current.edges).toEqual([
{ source: "A", target: "B" },
{ source: "A", target: "C" },
{ source: "B", target: "D" },
{ source: "C", target: "D" },
]);
});
it("drops orphan dependency references", () => {
const { result } = renderHook(() => useGraphData([createTask("A", ["Z"]), createTask("B", ["A"])]));
expect(result.current.edges).toEqual([{ source: "B", target: "A" }]);
});
it("supports disconnected subgraphs", () => {
const tasks = [createTask("A", ["B"]), createTask("B"), createTask("X", ["Y"]), createTask("Y")];
const { result } = renderHook(() => useGraphData(tasks));
expect(result.current.edges).toEqual([
{ source: "A", target: "B" },
{ source: "X", target: "Y" },
]);
});
});

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useGraphInteraction } from "../useGraphInteraction";
describe("useGraphInteraction", () => {
it("starts with default pan/zoom", () => {
const { result } = renderHook(() => useGraphInteraction());
expect(result.current.zoom).toBe(1);
expect(result.current.pan).toEqual({ x: 0, y: 0 });
});
it("clamps zoom between 0.1 and 3", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
for (let i = 0; i < 100; i += 1) result.current.zoomOut();
});
expect(result.current.zoom).toBe(0.1);
act(() => {
for (let i = 0; i < 100; i += 1) result.current.zoomIn();
});
expect(result.current.zoom).toBe(3);
});
it("fits single node", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.fitToGraph(new Map([["A", { x: 0, y: 0 }]]), 800, 600);
});
expect(result.current.zoom).toBeGreaterThan(0.1);
expect(result.current.zoom).toBeLessThanOrEqual(3);
});
it("fits wide graph", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.fitToGraph(new Map([
["A", { x: 0, y: 0 }],
["B", { x: 2000, y: 0 }],
]), 800, 600);
});
expect(result.current.zoom).toBeLessThan(1);
});
it("fits tall graph", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.fitToGraph(new Map([
["A", { x: 0, y: 0 }],
["B", { x: 0, y: 2000 }],
]), 800, 600);
});
expect(result.current.zoom).toBeLessThan(1);
});
it("resets when positions are empty", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.zoomIn();
result.current.onPointerDown(1, { x: 10, y: 10 });
result.current.onPointerMove(1, { x: 110, y: 60 }, 800, 600);
result.current.onPointerUp(1);
result.current.fitToGraph(new Map(), 800, 600);
});
expect(result.current.zoom).toBe(1);
expect(result.current.pan).toEqual({ x: 0, y: 0 });
});
it("resetView restores defaults", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.zoomIn();
result.current.onPointerDown(1, { x: 0, y: 0 });
result.current.onPointerMove(1, { x: 200, y: 200 }, 800, 600);
result.current.onPointerUp(1);
result.current.resetView();
});
expect(result.current.zoom).toBe(1);
expect(result.current.pan).toEqual({ x: 0, y: 0 });
});
});

View File

@@ -0,0 +1,14 @@
declare module "@fusion/dashboard/app/components/TaskCard" {
import type { Task } from "@fusion/core";
import type { ReactElement } from "react";
interface TaskCardProps {
task: Task;
projectId?: string;
onOpenDetail: (task: Task) => void;
addToast: (message: string, type?: "success" | "error" | "info") => void;
disableDrag?: boolean;
}
export function TaskCard(props: TaskCardProps): ReactElement;
}

View File

@@ -0,0 +1,69 @@
import type { GraphEdge, GraphPosition } from "./types";
interface GraphEdgesProps {
edges: GraphEdge[];
positions: Map<string, GraphPosition>;
nodeWidth?: number;
nodeHeight?: number;
highlightedEdgeIds?: Set<string>;
}
const DEFAULT_NODE_WIDTH = 280;
const DEFAULT_NODE_HEIGHT = 100;
export function GraphEdges({
edges,
positions,
nodeWidth = DEFAULT_NODE_WIDTH,
nodeHeight = DEFAULT_NODE_HEIGHT,
highlightedEdgeIds,
}: GraphEdgesProps) {
const hasHighlights = Boolean(highlightedEdgeIds && highlightedEdgeIds.size > 0);
return (
<svg className="dependency-graph-edges" aria-hidden="true">
<defs>
<marker
id="dependency-graph-arrowhead"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="var(--border)" />
</marker>
</defs>
{edges.map((edge) => {
const source = positions.get(edge.source);
const target = positions.get(edge.target);
if (!source || !target) return null;
const edgeId = `${edge.source}->${edge.target}`;
const isActiveHighlight = hasHighlights && (highlightedEdgeIds?.has(edgeId) ?? false);
const x1 = source.x + nodeWidth / 2;
const y1 = source.y + nodeHeight;
const x2 = target.x + nodeWidth / 2;
const y2 = target.y;
const controlY = y1 + (y2 - y1) / 2;
return (
<path
key={edgeId}
data-testid="dependency-edge"
data-edge-id={edgeId}
className={`dependency-graph-edge${isActiveHighlight ? " is-related" : ""}${hasHighlights && !isActiveHighlight ? " is-dimmed" : ""}`}
d={`M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`}
fill="none"
stroke={isActiveHighlight ? "var(--text-muted)" : "var(--border)"}
strokeWidth="1"
opacity={hasHighlights && !isActiveHighlight ? 0.2 : 1}
markerEnd="url(#dependency-graph-arrowhead)"
style={{ transition: "opacity var(--transition-fast), stroke var(--transition-fast)" }}
/>
);
})}
</svg>
);
}

View File

@@ -0,0 +1,7 @@
import type { Task } from "@fusion/core";
const INCLUDED_COLUMNS = new Set<Task["column"]>(["triage", "todo", "in-progress", "in-review"]);
export function filterGraphTasks(tasks: Task[]): Task[] {
return tasks.filter((task) => INCLUDED_COLUMNS.has(task.column));
}

View File

@@ -13,7 +13,7 @@ const plugin = definePlugin({
{
viewId: "graph",
label: "Graph",
componentPath: "./src/DependencyGraphView.tsx",
componentPath: "./src/DependencyGraph.tsx",
icon: "Network",
placement: "more",
order: 40,
@@ -22,4 +22,4 @@ const plugin = definePlugin({
});
export default plugin;
export { DependencyGraphView } from "./DependencyGraphView";
export { DependencyGraph } from "./DependencyGraph";

View File

@@ -0,0 +1,91 @@
import type { GraphData, GraphPosition } from "./types";
export interface LayoutOptions {
nodeWidth?: number;
nodeHeight?: number;
horizontalGap?: number;
verticalGap?: number;
}
const DEFAULT_LAYOUT_OPTIONS: Required<LayoutOptions> = {
nodeWidth: 280,
nodeHeight: 100,
horizontalGap: 40,
verticalGap: 80,
};
export function computeAutoLayout(
graphData: GraphData,
options?: LayoutOptions,
): Map<string, GraphPosition> {
const settings = { ...DEFAULT_LAYOUT_OPTIONS, ...options };
const nodeIds = graphData.nodes.map((node) => node.task.id);
if (nodeIds.length === 0) return new Map();
const dependentsByDependency = new Map<string, string[]>();
const inDegree = new Map<string, number>();
for (const id of nodeIds) {
inDegree.set(id, 0);
dependentsByDependency.set(id, []);
}
for (const edge of graphData.edges) {
if (!inDegree.has(edge.source) || !inDegree.has(edge.target)) continue;
dependentsByDependency.get(edge.target)?.push(edge.source);
inDegree.set(edge.source, (inDegree.get(edge.source) ?? 0) + 1);
}
const queue = nodeIds.filter((id) => (inDegree.get(id) ?? 0) === 0);
const topologicalOrder: string[] = [];
while (queue.length > 0) {
const current = queue.shift()!;
topologicalOrder.push(current);
for (const dependent of dependentsByDependency.get(current) ?? []) {
const nextInDegree = (inDegree.get(dependent) ?? 0) - 1;
inDegree.set(dependent, nextInDegree);
if (nextInDegree === 0) queue.push(dependent);
}
}
for (const id of nodeIds) {
if (!topologicalOrder.includes(id)) topologicalOrder.push(id);
}
const depthByNode = new Map<string, number>();
for (const id of topologicalOrder) {
const parents = graphData.edges.filter((edge) => edge.source === id).map((edge) => edge.target);
let depth = 0;
for (const parent of parents) {
depth = Math.max(depth, (depthByNode.get(parent) ?? 0) + 1);
}
depthByNode.set(id, depth);
}
const layers = new Map<number, string[]>();
for (const id of nodeIds) {
const depth = depthByNode.get(id) ?? 0;
const layer = layers.get(depth) ?? [];
layer.push(id);
layers.set(depth, layer);
}
const positions = new Map<string, GraphPosition>();
const sortedDepths = Array.from(layers.keys()).sort((a, b) => a - b);
for (const depth of sortedDepths) {
const layer = layers.get(depth) ?? [];
layer.sort();
const layerWidth = layer.length * settings.nodeWidth + Math.max(0, layer.length - 1) * settings.horizontalGap;
const startX = -layerWidth / 2;
layer.forEach((id, index) => {
const x = startX + index * (settings.nodeWidth + settings.horizontalGap);
const y = depth * (settings.nodeHeight + settings.verticalGap);
positions.set(id, { x, y });
});
}
return positions;
}

View File

@@ -1,22 +0,0 @@
import { getScopedItem, scopedKey, setScopedItem } from "../../../packages/dashboard/app/utils/projectStorage";
const BASE_KEY = "fusion-plugin-dependency-graph:positions";
export function projectScopedKey(projectId?: string): string {
return scopedKey(BASE_KEY, projectId);
}
export function loadPositions(projectId?: string): Record<string, { x: number; y: number }> {
try {
const raw = getScopedItem(BASE_KEY, projectId);
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, { x: number; y: number }>;
return parsed ?? {};
} catch {
return {};
}
}
export function savePositions(projectId: string | undefined, positions: Record<string, { x: number; y: number }>): void {
setScopedItem(BASE_KEY, JSON.stringify(positions), projectId);
}

View File

@@ -0,0 +1,21 @@
import type { Task } from "@fusion/core";
export interface GraphPosition {
x: number;
y: number;
}
export interface GraphNode {
task: Task;
position?: GraphPosition;
}
export interface GraphEdge {
source: string;
target: string;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}

View File

@@ -0,0 +1,17 @@
import { useMemo } from "react";
import type { Task } from "@fusion/core";
import type { GraphData, GraphNode } from "./types";
export function useGraphData(tasks: Task[]): GraphData {
return useMemo(() => {
const nodes: GraphNode[] = tasks.map((task) => ({ task }));
const taskIds = new Set(tasks.map((task) => task.id));
const edges = tasks.flatMap((task) =>
(task.dependencies ?? [])
.filter((dependencyId) => taskIds.has(dependencyId))
.map((dependencyId) => ({ source: task.id, target: dependencyId })),
);
return { nodes, edges };
}, [tasks]);
}

View File

@@ -0,0 +1,146 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { LayoutOptions } from "./layout";
import type { GraphPosition } from "./types";
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 3;
const FIT_PADDING = 40;
interface PointerPoint {
x: number;
y: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export function useGraphInteraction() {
const [pan, setPan] = useState<PointerPoint>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const dragStateRef = useRef<{ start: PointerPoint; panStart: PointerPoint } | null>(null);
const pointersRef = useRef<Map<number, PointerPoint>>(new Map());
const pinchRef = useRef<{ distance: number; zoom: number } | null>(null);
const transform = useMemo(() => `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, [pan.x, pan.y, zoom]);
const zoomIn = useCallback(() => {
setZoom((current) => clamp(current + 0.1, MIN_ZOOM, MAX_ZOOM));
}, []);
const zoomOut = useCallback(() => {
setZoom((current) => clamp(current - 0.1, MIN_ZOOM, MAX_ZOOM));
}, []);
const resetView = useCallback(() => {
setPan({ x: 0, y: 0 });
setZoom(1);
}, []);
const clampPan = useCallback((nextPan: PointerPoint, viewportWidth: number, viewportHeight: number) => ({
x: clamp(nextPan.x, -viewportWidth, viewportWidth),
y: clamp(nextPan.y, -viewportHeight, viewportHeight),
}), []);
const fitToGraph = useCallback((
positions: Map<string, GraphPosition>,
viewportWidth: number,
viewportHeight: number,
layoutOptions?: LayoutOptions,
) => {
if (positions.size === 0) {
resetView();
return;
}
const nodeWidth = layoutOptions?.nodeWidth ?? 280;
const nodeHeight = layoutOptions?.nodeHeight ?? 100;
const entries = Array.from(positions.values());
const minX = Math.min(...entries.map((p) => p.x));
const minY = Math.min(...entries.map((p) => p.y));
const maxX = Math.max(...entries.map((p) => p.x + nodeWidth));
const maxY = Math.max(...entries.map((p) => p.y + nodeHeight));
const graphWidth = Math.max(1, maxX - minX);
const graphHeight = Math.max(1, maxY - minY);
const availableWidth = Math.max(1, viewportWidth - FIT_PADDING * 2);
const availableHeight = Math.max(1, viewportHeight - FIT_PADDING * 2);
const nextZoom = clamp(Math.min(availableWidth / graphWidth, availableHeight / graphHeight), MIN_ZOOM, MAX_ZOOM);
const panX = (viewportWidth - graphWidth * nextZoom) / 2 - minX * nextZoom;
const panY = (viewportHeight - graphHeight * nextZoom) / 2 - minY * nextZoom;
setZoom(nextZoom);
setPan(clampPan({ x: panX, y: panY }, viewportWidth, viewportHeight));
}, [clampPan, resetView]);
const onPointerDown = useCallback((pointerId: number, point: PointerPoint) => {
pointersRef.current.set(pointerId, point);
if (pointersRef.current.size === 2) {
const [a, b] = Array.from(pointersRef.current.values());
pinchRef.current = { distance: Math.hypot(a.x - b.x, a.y - b.y), zoom };
dragStateRef.current = null;
return;
}
dragStateRef.current = { start: point, panStart: pan };
}, [pan, zoom]);
const onPointerMove = useCallback((pointerId: number, point: PointerPoint, viewportWidth: number, viewportHeight: number) => {
if (pointersRef.current.has(pointerId)) pointersRef.current.set(pointerId, point);
if (pointersRef.current.size >= 2 && pinchRef.current) {
const [a, b] = Array.from(pointersRef.current.values());
const distance = Math.hypot(a.x - b.x, a.y - b.y);
const factor = distance / Math.max(1, pinchRef.current.distance);
setZoom(clamp(pinchRef.current.zoom * factor, MIN_ZOOM, MAX_ZOOM));
return;
}
const dragState = dragStateRef.current;
if (!dragState) return;
const nextPan = {
x: dragState.panStart.x + (point.x - dragState.start.x),
y: dragState.panStart.y + (point.y - dragState.start.y),
};
setPan(clampPan(nextPan, viewportWidth, viewportHeight));
}, [clampPan]);
const onPointerUp = useCallback((pointerId: number) => {
pointersRef.current.delete(pointerId);
if (pointersRef.current.size < 2) pinchRef.current = null;
if (pointersRef.current.size === 0) dragStateRef.current = null;
}, []);
const onWheelZoom = useCallback((
deltaY: number,
point: PointerPoint,
viewportWidth: number,
viewportHeight: number,
) => {
const factor = deltaY < 0 ? 1.1 : 0.9;
const nextZoom = clamp(zoom * factor, MIN_ZOOM, MAX_ZOOM);
const scaleRatio = nextZoom / zoom;
const nextPan = {
x: point.x - (point.x - pan.x) * scaleRatio,
y: point.y - (point.y - pan.y) * scaleRatio,
};
setZoom(nextZoom);
setPan(clampPan(nextPan, viewportWidth, viewportHeight));
}, [clampPan, pan.x, pan.y, zoom]);
return {
pan,
zoom,
transform,
zoomIn,
zoomOut,
resetView,
fitToGraph,
onPointerDown,
onPointerMove,
onPointerUp,
onWheelZoom,
};
}

View File

@@ -8,6 +8,6 @@
"moduleResolution": "bundler",
"types": ["react"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
"exclude": ["src/__tests__/**"]
}

View File

@@ -9,6 +9,7 @@ export default defineConfig({
alias: {
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
"@fusion/dashboard": fileURLToPath(new URL("../../packages/dashboard", import.meta.url)),
},
},
test: {