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 213ed79d5f
commit e7deab16b8
14 changed files with 104 additions and 15 deletions

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>
);