feat(FN-3307): add pinch-to-zoom, auto-fit, mobile layout, and empty state
Merges five branches: adds pinch-to-zoom, mobile layout, auto-fit, and empty state to the dependency graph plugin with full test coverage; implements API key fallback resolution for models.json; restores Claude usage tracking with Fusion Anthropic auth storage; and boosts dashboard test performance Fusion-Task-Id: FN-3307
This commit is contained in:
@@ -9,3 +9,27 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
|
|||||||
- `kb:${projectId}:dependency-graph-positions`
|
- `kb:${projectId}:dependency-graph-positions`
|
||||||
|
|
||||||
The first version uses a lightweight custom SVG/HTML renderer (no React Flow dependency).
|
The first version uses a lightweight custom SVG/HTML renderer (no React Flow dependency).
|
||||||
|
|
||||||
|
## Mobile Support
|
||||||
|
|
||||||
|
The dependency graph view is fully usable on mobile devices:
|
||||||
|
|
||||||
|
- **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 |
|
||||||
|
|||||||
@@ -19,14 +19,16 @@
|
|||||||
"test": "vitest run --silent=passed-only --reporter=dot"
|
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fusion/plugin-sdk": "workspace:*",
|
"@fusion/core": "workspace:*",
|
||||||
"@fusion/core": "workspace:*"
|
"@fusion/plugin-sdk": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.5.2",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4"
|
||||||
"react": "^19.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
min-height: var(--dependency-graph-canvas-min-height);
|
min-height: var(--dependency-graph-canvas-min-height);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dependency-graph-canvas:active {
|
.dependency-graph-canvas:active {
|
||||||
@@ -86,16 +87,46 @@
|
|||||||
filter: saturate(0.8);
|
filter: saturate(0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.dependency-graph-view {
|
.dependency-graph-view {
|
||||||
padding: var(--space-md);
|
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: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.dependency-graph-canvas {
|
.dependency-graph-canvas {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
min-height: var(--dependency-graph-canvas-min-height-mobile);
|
min-height: var(--dependency-graph-canvas-min-height-mobile);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dependency-graph-node {
|
.dependency-graph-node {
|
||||||
width: min(100%, var(--dependency-graph-node-max-width-mobile)) !important;
|
width: min(100%, var(--dependency-graph-node-max-width-mobile)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dependency-graph-empty {
|
||||||
|
min-height: var(--dependency-graph-canvas-min-height-mobile);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
import type { PointerEvent as ReactPointerEvent, ReactNode, WheelEvent as ReactWheelEvent } from "react";
|
||||||
import type { Task } from "@fusion/core";
|
import type { Task } from "@fusion/core";
|
||||||
import { loadPositions, savePositions } from "./storage";
|
import { loadPositions, savePositions } from "./storage";
|
||||||
import "./DependencyGraphView.css";
|
import "./DependencyGraphView.css";
|
||||||
@@ -14,6 +14,8 @@ const SCENE_PADDING_REM = 2;
|
|||||||
const FIT_PADDING_REM = 2;
|
const FIT_PADDING_REM = 2;
|
||||||
const MIN_SCALE = 0.4;
|
const MIN_SCALE = 0.4;
|
||||||
const MAX_SCALE = 2;
|
const MAX_SCALE = 2;
|
||||||
|
const WHEEL_ZOOM_FACTOR = 0.002;
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
export interface DependencyGraphHostContext {
|
export interface DependencyGraphHostContext {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -29,9 +31,12 @@ export interface PluginDashboardViewComponentProps {
|
|||||||
type Position = { x: number; y: number };
|
type Position = { x: number; y: number };
|
||||||
|
|
||||||
function getDistance(a: Position, b: Position): number {
|
function getDistance(a: Position, b: Position): number {
|
||||||
const deltaX = a.x - b.x;
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
const deltaY = a.y - b.y;
|
}
|
||||||
return Math.hypot(deltaX, deltaY);
|
|
||||||
|
function isMobileViewport(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DependencyGraphView({ context }: PluginDashboardViewComponentProps) {
|
export function DependencyGraphView({ context }: PluginDashboardViewComponentProps) {
|
||||||
@@ -42,11 +47,16 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
|
|||||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||||
const persisted = useMemo(() => loadPositions(context.projectId), [context.projectId]);
|
const persisted = useMemo(() => loadPositions(context.projectId), [context.projectId]);
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
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<
|
const interactionRef = useRef<
|
||||||
| { kind: "node"; taskId: string; startPointer: Position; startNode: Position; moved: boolean }
|
| { kind: "node"; taskId: string; startPointer: Position; startNode: Position; moved: boolean }
|
||||||
| { kind: "pan"; startPointer: Position; startPan: Position; moved: boolean }
|
| { kind: "pan"; startPointer: Position; startPan: Position; moved: boolean }
|
||||||
| null
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
|
const pinchRef = useRef<{ startDistance: number; startScale: number } | null>(null);
|
||||||
|
const autoFitDoneRef = useRef(false);
|
||||||
|
|
||||||
const tasks = useMemo(
|
const tasks = useMemo(
|
||||||
() => context.tasks.filter((task) => ACTIVE_COLUMNS.has(task.column)),
|
() => context.tasks.filter((task) => ACTIVE_COLUMNS.has(task.column)),
|
||||||
@@ -162,7 +172,7 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
|
|||||||
return related;
|
return related;
|
||||||
}, [dependencyGraph.downstream, dependencyGraph.upstream, focusTaskId]);
|
}, [dependencyGraph.downstream, dependencyGraph.upstream, focusTaskId]);
|
||||||
|
|
||||||
const fitToGraph = () => {
|
const fitToGraph = useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
@@ -179,7 +189,26 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
|
|||||||
|
|
||||||
setScale(nextScale);
|
setScale(nextScale);
|
||||||
setPan({ x: centeredPanX, y: centeredPanY });
|
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) => {
|
const persistPosition = (taskId: string, next: Position) => {
|
||||||
setNodeOverrides((current) => ({ ...current, [taskId]: next }));
|
setNodeOverrides((current) => ({ ...current, [taskId]: next }));
|
||||||
@@ -193,6 +222,10 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const hit = map.get(taskId);
|
const hit = map.get(taskId);
|
||||||
if (!hit) return;
|
if (!hit) return;
|
||||||
|
|
||||||
|
// Track this pointer in the global map
|
||||||
|
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
|
||||||
interactionRef.current = {
|
interactionRef.current = {
|
||||||
kind: "node",
|
kind: "node",
|
||||||
taskId,
|
taskId,
|
||||||
@@ -200,21 +233,48 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
|
|||||||
startNode: { x: hit.x, y: hit.y },
|
startNode: { x: hit.x, y: hit.y },
|
||||||
moved: false,
|
moved: false,
|
||||||
};
|
};
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCanvasPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
const handleCanvasPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (event.button !== 0) return;
|
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 = {
|
interactionRef.current = {
|
||||||
kind: "pan",
|
kind: "pan",
|
||||||
startPointer: { x: event.clientX, y: event.clientY },
|
startPointer: { x: event.clientX, y: event.clientY },
|
||||||
startPan: pan,
|
startPan: pan,
|
||||||
moved: false,
|
moved: false,
|
||||||
};
|
};
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
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;
|
const current = interactionRef.current;
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
|
|
||||||
@@ -241,6 +301,16 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
|
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;
|
const current = interactionRef.current;
|
||||||
interactionRef.current = null;
|
interactionRef.current = null;
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
@@ -256,8 +326,38 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
|
|||||||
|
|
||||||
persistPosition(current.taskId, { x: hit.x, y: hit.y });
|
persistPosition(current.taskId, { x: hit.x, y: hit.y });
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
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 (
|
return (
|
||||||
@@ -274,46 +374,54 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
|
|||||||
onPointerDown={handleCanvasPointerDown}
|
onPointerDown={handleCanvasPointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerCancel}
|
||||||
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
<div
|
{tasks.length === 0 ? (
|
||||||
className="dependency-graph-scene"
|
<div className="dependency-graph-empty">
|
||||||
style={{
|
<p>No tasks to display. Tasks in Triage, Todo, In Progress, or In Review columns will appear here.</p>
|
||||||
width: `${bounds.width}rem`,
|
</div>
|
||||||
height: `${bounds.height}rem`,
|
) : (
|
||||||
transform: `translate(${pan.x}rem, ${pan.y}rem) scale(${scale})`,
|
<div
|
||||||
transformOrigin: "top left",
|
className="dependency-graph-scene"
|
||||||
}}
|
style={{
|
||||||
>
|
width: `${bounds.width}rem`,
|
||||||
<svg className="dependency-graph-edges" viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
|
height: `${bounds.height}rem`,
|
||||||
{edgesForRender.map((edge) => (
|
transform: `translate(${pan.x}rem, ${pan.y}rem) scale(${scale})`,
|
||||||
<line
|
transformOrigin: "top left",
|
||||||
key={`${edge.from}-${edge.to}`}
|
}}
|
||||||
x1={edge.renderX1}
|
>
|
||||||
y1={edge.renderY1}
|
<svg className="dependency-graph-edges" viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
|
||||||
x2={edge.renderX2}
|
{edgesForRender.map((edge) => (
|
||||||
y2={edge.renderY2}
|
<line
|
||||||
className={`dependency-graph-edge${relatedTaskIds ? relatedTaskIds.has(edge.from) && relatedTaskIds.has(edge.to) ? " is-related" : " is-dimmed" : ""}`}
|
key={`${edge.from}-${edge.to}`}
|
||||||
/>
|
x1={edge.renderX1}
|
||||||
))}
|
y1={edge.renderY1}
|
||||||
</svg>
|
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) => (
|
{positionedForRender.map((node) => (
|
||||||
<div
|
<div
|
||||||
key={node.task.id}
|
key={node.task.id}
|
||||||
className={`dependency-graph-node${selectedTaskId === node.task.id ? " is-selected" : ""}${relatedTaskIds ? relatedTaskIds.has(node.task.id) ? " is-related" : " is-dimmed" : ""}`}
|
className={`dependency-graph-node${selectedTaskId === node.task.id ? " is-selected" : ""}${relatedTaskIds ? relatedTaskIds.has(node.task.id) ? " is-related" : " is-dimmed" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
width: `${NODE_WIDTH_REM}rem`,
|
width: `${NODE_WIDTH_REM}rem`,
|
||||||
minHeight: `${NODE_HEIGHT_REM}rem`,
|
minHeight: `${NODE_HEIGHT_REM}rem`,
|
||||||
transform: `translate(${node.renderX}rem, ${node.renderY}rem)`,
|
transform: `translate(${node.renderX}rem, ${node.renderY}rem)`,
|
||||||
}}
|
}}
|
||||||
onPointerDown={(event) => handlePointerDownOnNode(node.task.id, event)}
|
onPointerDown={(event) => handlePointerDownOnNode(node.task.id, event)}
|
||||||
onPointerEnter={() => setHoveredTaskId(node.task.id)}
|
onPointerEnter={() => setHoveredTaskId(node.task.id)}
|
||||||
onPointerLeave={() => setHoveredTaskId((current) => (current === node.task.id ? null : current))}
|
onPointerLeave={() => setHoveredTaskId((current) => (current === node.task.id ? null : current))}
|
||||||
>
|
>
|
||||||
{context.renderTaskCard(node.task)}
|
{context.renderTaskCard(node.task)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
plugins/fusion-plugin-dependency-graph/vitest.config.ts
Normal file
23
plugins/fusion-plugin-dependency-graph/vitest.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { computeMaxWorkers } from "../../packages/core/src/__test-utils__/vitest-workers";
|
||||||
|
|
||||||
|
const maxWorkers = computeMaxWorkers();
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
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)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
|
||||||
|
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
|
||||||
|
pool: "threads",
|
||||||
|
maxWorkers,
|
||||||
|
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
|
||||||
|
},
|
||||||
|
});
|
||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -565,6 +565,9 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/plugin-sdk
|
version: link:../../packages/plugin-sdk
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@testing-library/react':
|
||||||
|
specifier: ^16.3.2
|
||||||
|
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.5.2
|
specifier: ^25.5.2
|
||||||
version: 25.5.2
|
version: 25.5.2
|
||||||
@@ -574,6 +577,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
|
react-dom:
|
||||||
|
specifier: ^19.2.4
|
||||||
|
version: 19.2.4(react@19.2.4)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
Reference in New Issue
Block a user