feat(FN-3079): add plugin dashboard views and dependency graph plugin

- Add plugin dashboard view registration and hosting across core, dashboard routes, and plugin SDK exports
- Integrate plugin-provided views into app navigation, mobile/header UI, and view state hooks with coverage
- Add fusion-plugin-dependency-graph example plugin with persisted storage, dashboard view UI, and manifest wiring
- Update plugin authoring and architecture docs for dashboard view extension points
- Add a changeset for @runfusion/fusion covering plugin dashboard view support

Fusion-Task-Id: FN-3079
This commit is contained in:
Fusion
2026-05-01 23:32:32 -07:00
committed by gsxdsm
parent a284138167
commit 0597b34eea
38 changed files with 1264 additions and 16 deletions

View File

@@ -0,0 +1,11 @@
# fusion-plugin-dependency-graph
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`
The first version uses a lightweight custom SVG/HTML renderer (no React Flow dependency).

View File

@@ -0,0 +1,16 @@
{
"id": "fusion-plugin-dependency-graph",
"name": "Dependency Graph",
"version": "0.1.0",
"description": "Top-level dependency graph dashboard view",
"dashboardViews": [
{
"viewId": "graph",
"label": "Graph",
"componentPath": "./src/DependencyGraphView.tsx",
"icon": "Network",
"placement": "more",
"order": 40
}
]
}

View File

@@ -0,0 +1,32 @@
{
"name": "@fusion-plugin-examples/dependency-graph",
"version": "0.1.0",
"type": "module",
"description": "Dependency graph dashboard view plugin for Fusion",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./dashboard-view": {
"types": "./src/DependencyGraphView.tsx",
"import": "./src/DependencyGraphView.tsx"
}
},
"scripts": {
"build": "tsc",
"test": "vitest run --silent=passed-only --reporter=dot"
},
"dependencies": {
"@fusion/plugin-sdk": "workspace:*",
"@fusion/core": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.5.2",
"@types/react": "^19.0.0",
"typescript": "^5.7.0",
"vitest": "^3.2.4",
"react": "^19.0.0"
}
}

View File

@@ -0,0 +1,101 @@
.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;
}
.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);
}
.dependency-graph-edge.is-dimmed {
opacity: 0.4;
}
.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);
}
.dependency-graph-node.is-related:not(.is-selected) .card {
border-color: var(--in-progress);
}
.dependency-graph-node.is-dimmed {
opacity: 0.5;
filter: saturate(0.8);
}
@media (max-width: 768px) {
.dependency-graph-view {
padding: var(--space-md);
}
.dependency-graph-canvas {
min-height: var(--dependency-graph-canvas-min-height-mobile);
}
.dependency-graph-node {
width: min(100%, var(--dependency-graph-node-max-width-mobile)) !important;
}
}

View File

@@ -0,0 +1,320 @@
import { useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent, ReactNode } 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;
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 {
const deltaX = a.x - b.x;
const deltaY = a.y - b.y;
return Math.hypot(deltaX, deltaY);
}
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);
const interactionRef = useRef<
| { kind: "node"; taskId: string; startPointer: Position; startNode: Position; moved: boolean }
| { kind: "pan"; startPointer: Position; startPan: Position; moved: boolean }
| null
>(null);
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 = () => {
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 });
};
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;
interactionRef.current = {
kind: "node",
taskId,
startPointer: { x: event.clientX, y: event.clientY },
startNode: { x: hit.x, y: hit.y },
moved: false,
};
event.currentTarget.setPointerCapture(event.pointerId);
};
const handleCanvasPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
interactionRef.current = {
kind: "pan",
startPointer: { x: event.clientX, y: event.clientY },
startPan: pan,
moved: false,
};
event.currentTarget.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
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>) => {
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 });
}
event.currentTarget.releasePointerCapture(event.pointerId);
};
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}
>
<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,31 @@
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", () => {
expect(projectScopedKey("proj_123")).toBe("kb:proj_123: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 } });
});
});

View File

@@ -0,0 +1,25 @@
import { definePlugin } from "@fusion/plugin-sdk";
const plugin = definePlugin({
manifest: {
id: "fusion-plugin-dependency-graph",
name: "Dependency Graph",
version: "0.1.0",
description: "Top-level dependency graph dashboard view",
},
state: "installed",
hooks: {},
dashboardViews: [
{
viewId: "graph",
label: "Graph",
componentPath: "./src/DependencyGraphView.tsx",
icon: "Network",
placement: "more",
order: 40,
},
],
});
export default plugin;
export { DependencyGraphView } from "./DependencyGraphView";

View File

@@ -0,0 +1,23 @@
const BASE_KEY = "dependency-graph-positions";
export function projectScopedKey(projectId?: string): string {
const suffix = projectId ?? "default";
return `kb:${suffix}:${BASE_KEY}`;
}
export function loadPositions(projectId?: string): Record<string, { x: number; y: number }> {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(projectScopedKey(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 {
if (typeof window === "undefined") return;
window.localStorage.setItem(projectScopedKey(projectId), JSON.stringify(positions));
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"module": "esnext",
"moduleResolution": "bundler",
"types": ["react"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/__tests__/**"]
}