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:
5
.changeset/fn-3087-graph-card-detail-open.md
Normal file
5
.changeset/fn-3087-graph-card-detail-open.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{
|
||||
"viewId": "graph",
|
||||
"label": "Graph",
|
||||
"componentPath": "./src/DependencyGraphView.tsx",
|
||||
"componentPath": "./dashboard-view",
|
||||
"icon": "Network",
|
||||
"placement": "more",
|
||||
"order": 40
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })} />);
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user