feat(FN-3087): document graph plugin in plugin authoring guide

Documentation-only finish for FN-3087, adding changeset and README updates for the dependency graph plugin and plugin authoring guide.

Fusion-Task-Id: FN-3087
This commit is contained in:
Fusion
2026-05-07 08:19:24 -07:00
committed by gsxdsm
parent 41a1e1a2f2
commit 21b7d41680
14 changed files with 104 additions and 15 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Wire the bundled dependency graph dashboard view to host context so graph cards open the native task detail modal, and document the plugin dashboard view context contract/entrypoint alignment.

View File

@@ -634,6 +634,11 @@ registerPluginView(
The host then renders plugin views via `PluginDashboardViewHost` using the composite ID.
Runtime host context contract:
- Registered views receive a `context` object from the dashboard host (`PluginDashboardViewContext`).
- Context includes the active `projectId`, current visible `tasks`, optional `workflowSteps`, and `openTaskDetail` for launching the native task detail flow.
- Keep view-specific UI behavior in the plugin; treat host context as service/data injection only.
Placement guidance:
- `primary`: top-level nav tab (host may limit count on mobile)
- `overflow`: desktop header overflow menu

View File

@@ -93,6 +93,7 @@ Navigation placement in this iteration:
- **Mobile:** `MobileNavBar` More sheet
`fusion-plugin-dependency-graph` registers `graph` and is host-resolved through an explicit static registry (`app/plugins/pluginViewRegistry.tsx`) for bundle-safe rendering. CLI dashboard/serve/daemon startup now auto-installs this bundled plugin when missing.
Graph view cards now use the same host-provided `openTaskDetail` flow as board/list cards, so clicking a graph node opens the native task detail modal while preserving plugin-owned graph interactions (drag/pan/highlighting).
### Mobile Bottom Navigation
The dashboard now includes a dedicated bottom tab navigation pattern for mobile viewports (`≤768px`) via `MobileNavBar` (`app/components/MobileNavBar.tsx`). This pattern is designed for narrow screens and Capacitor-wrapped app usage where bottom-tab navigation is the primary interaction model.

View File

@@ -275,6 +275,10 @@ vi.mock("../../components/AgentsView", () => ({
AgentsView: () => <div className="agents-view">Agents view</div>,
}));
vi.mock("@fusion-plugin-examples/dependency-graph/dashboard-view", () => ({
DependencyGraphDashboardView: () => <div data-testid="dependency-graph">No active tasks to display in graph view.</div>,
}));
vi.mock("../../components/ResearchView", () => ({
ResearchView: ({ addToast }: { addToast?: (message: string, type?: "success" | "error" | "info") => void }) => (
<div data-testid="research-view">
@@ -1777,7 +1781,8 @@ describe("App view switching", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByText("Plugin view unavailable")).toBeInTheDocument();
expect(screen.getByTestId("dependency-graph")).toBeInTheDocument();
expect(screen.getByText("No active tasks to display in graph view.")).toBeInTheDocument();
});
localStorage.removeItem(taskViewStorageKey());
@@ -1797,7 +1802,7 @@ describe("App view switching", () => {
const first = render(<App />);
await waitFor(() => {
expect(screen.getByText("Plugin view unavailable")).toBeInTheDocument();
expect(screen.getByTestId("dependency-graph")).toBeInTheDocument();
});
first.unmount();
@@ -1818,7 +1823,7 @@ describe("App view switching", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByText("Plugin view unavailable")).toBeInTheDocument();
expect(screen.getByTestId("dependency-graph")).toBeInTheDocument();
});
localStorage.removeItem(taskViewStorageKey());

View File

@@ -1,6 +1,6 @@
import { PluginDashboardViewHost as RegistryPluginDashboardViewHost } from "./pluginViewRegistry";
import type { PluginTaskView } from "./pluginViewRegistry";
import type { PluginDashboardViewContext, PluginTaskView } from "./pluginViewRegistry";
export function PluginDashboardViewHost({ taskView }: { taskView: PluginTaskView; context?: unknown }) {
return <RegistryPluginDashboardViewHost viewId={taskView} />;
export function PluginDashboardViewHost({ taskView, context }: { taskView: PluginTaskView; context?: PluginDashboardViewContext }) {
return <RegistryPluginDashboardViewHost viewId={taskView} context={context} />;
}

View File

@@ -57,6 +57,17 @@ describe("pluginViewRegistry", () => {
expect(await screen.findByText("Rendered Plugin View")).toBeInTheDocument();
});
it("forwards host context to registered components", async () => {
const View = lazy(async () => ({
default: ({ context }: { context?: { projectId?: string } }) => <div>{context?.projectId ?? "none"}</div>,
}));
registerPluginView("plugin-a", "main", View);
render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:main", context: { projectId: "proj-1", tasks: [], workflowSteps: [], openTaskDetail: () => {} } })}</>);
expect(await screen.findByText("proj-1")).toBeInTheDocument();
});
it("renders unavailable fallback for unregistered views", () => {
render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:missing" })}</>);
expect(screen.getByTestId("plugin-view-unavailable")).toBeInTheDocument();

View File

@@ -1,11 +1,21 @@
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import { AlertTriangle } from "lucide-react";
import { lazy, Suspense, type LazyExoticComponent, type ReactElement, type ReactNode } from "react";
import { ErrorBoundary } from "../components/ErrorBoundary";
import type { DetailTaskTab } from "../hooks/useModalManager";
import "./pluginViewRegistry.css";
export type PluginTaskView = `plugin:${string}:${string}`;
type PluginViewComponent = LazyExoticComponent<() => ReactElement>;
export interface PluginDashboardViewContext {
projectId?: string;
tasks: Task[];
workflowSteps: WorkflowStep[];
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
}
type PluginViewComponent = LazyExoticComponent<({ context }: { context?: PluginDashboardViewContext }) => ReactElement>;
const registry = new Map<string, PluginViewComponent>();
@@ -39,8 +49,22 @@ export function getPluginViewComponent(pluginId: string, viewId: string): Plugin
/** Test helper for clearing global registry state. */
export function __test_clearPluginViewRegistry(): void {
registry.clear();
registerBundledPluginViews();
}
function registerBundledPluginViews(): void {
registerPluginView(
"fusion-plugin-dependency-graph",
"graph",
lazy(async () => {
const mod = await import("@fusion-plugin-examples/dependency-graph/dashboard-view");
return { default: mod.DependencyGraphDashboardView };
}),
);
}
registerBundledPluginViews();
function PluginViewUnavailable({ viewId }: { viewId: string }): ReactNode {
return (
<section className="card plugin-dashboard-view-missing" data-testid="plugin-view-unavailable">
@@ -55,7 +79,7 @@ function PluginViewUnavailable({ viewId }: { viewId: string }): ReactNode {
);
}
export function PluginDashboardViewHost({ viewId }: { viewId: PluginTaskView }): ReactNode {
export function PluginDashboardViewHost({ viewId, context }: { viewId: PluginTaskView; context?: PluginDashboardViewContext }): ReactNode {
const parsed = parsePluginViewId(viewId);
if (!parsed) return <PluginViewUnavailable viewId={viewId} />;
@@ -67,7 +91,7 @@ export function PluginDashboardViewHost({ viewId }: { viewId: PluginTaskView }):
return (
<ErrorBoundary fallback={<PluginViewUnavailable viewId={viewId} />}>
<Suspense fallback={null}>
<ViewComponent />
<ViewComponent context={context} />
</Suspense>
</ErrorBoundary>
);

View File

@@ -15,6 +15,7 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
- **Position persistence**: dragged node positions are stored per project in browser localStorage and restored on reload
- **Animated transitions**: fit/reset operations animate `transform` (`var(--transition-normal)`), while continuous drag/wheel/pinch stays transition-free for responsiveness
- **Node rendering**: each graph node renders the real dashboard `TaskCard` via `GraphTaskNode` (no duplicated card markup)
- **Task detail integration**: clicking a graph card opens the native dashboard task detail modal through host context (`openTaskDetail`), matching board/list behavior
- **In-progress behavior**: steps are visible by default and active-task glow (`agent-active`) is preserved because node cards reuse TaskCard directly
- **Active-state indicator bar**: active nodes render a compact top bar (`.graph-task-active-indicator`) with the current execution status label (for example `Executing`, `Planning`) and pulsing `--in-progress` emphasis
- **Current-step highlighting**: active nodes set `data-current-step` for valid native step indices so CSS selectors highlight the currently executing `.card-step-item` and pulse its step dot

View File

@@ -7,7 +7,7 @@
{
"viewId": "graph",
"label": "Graph",
"componentPath": "./src/DependencyGraphView.tsx",
"componentPath": "./dashboard-view",
"icon": "Network",
"placement": "more",
"order": 40

View File

@@ -10,8 +10,8 @@
"import": "./src/index.ts"
},
"./dashboard-view": {
"types": "./src/DependencyGraph.tsx",
"import": "./src/DependencyGraph.tsx"
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {

View File

@@ -117,4 +117,11 @@ describe("DependencyGraph", () => {
fireEvent.click(screen.getByTestId("task-A"));
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }));
});
it("falls back to onOpenTaskDetail when onOpenDetail is not provided", () => {
const onOpenTaskDetail = vi.fn();
render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenTaskDetail={onOpenTaskDetail} />);
fireEvent.click(screen.getByTestId("task-A"));
expect(onOpenTaskDetail).toHaveBeenCalledWith("A");
});
});

View File

@@ -40,6 +40,19 @@ afterEach(() => {
});
describe("GraphTaskNode drag", () => {
it("does not open detail after drag threshold is exceeded", () => {
const onOpenDetail = vi.fn();
render(<GraphTaskNode {...props({ onOpenDetail })} />);
const node = screen.getByTestId("graph-task-node-FN-1");
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
fireEvent.pointerMove(node, { pointerId: 1, clientX: 25, clientY: 25, isPrimary: true });
fireEvent.pointerUp(node, { pointerId: 1, clientX: 25, clientY: 25, isPrimary: true });
fireEvent.click(node);
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("applies dragging class only after threshold move", () => {
const onNodePositionChange = vi.fn();
render(<GraphTaskNode {...props({ onNodePositionChange })} />);

View File

@@ -11,7 +11,7 @@ describe("dependency graph plugin host integration contract", () => {
expect.objectContaining({
viewId: "graph",
label: "Graph",
componentPath: "./src/DependencyGraph.tsx",
componentPath: "./dashboard-view",
placement: "more",
}),
]);

View File

@@ -1,4 +1,8 @@
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/pluginViewRegistry";
import { definePlugin } from "@fusion/plugin-sdk";
import { createElement } from "react";
import { DependencyGraph } from "./DependencyGraph";
const plugin = definePlugin({
manifest: {
@@ -13,7 +17,7 @@ const plugin = definePlugin({
{
viewId: "graph",
label: "Graph",
componentPath: "./src/DependencyGraph.tsx",
componentPath: "./dashboard-view",
icon: "Network",
placement: "more",
order: 40,
@@ -21,5 +25,18 @@ const plugin = definePlugin({
],
});
function createWorkflowStepNameLookup(workflowSteps: WorkflowStep[] | undefined): ReadonlyMap<string, string> {
return new Map((workflowSteps ?? []).map((step) => [step.id, step.name] as const));
}
export function DependencyGraphDashboardView({ context }: { context?: PluginDashboardViewContext }) {
return createElement(DependencyGraph, {
tasks: context?.tasks ?? [],
projectId: context?.projectId,
workflowStepNameLookup: createWorkflowStepNameLookup(context?.workflowSteps),
onOpenDetail: context?.openTaskDetail as ((task: Task | TaskDetail) => void) | undefined,
});
}
export default plugin;
export { DependencyGraph } from "./DependencyGraph";
export { DependencyGraph };