feat(FN-3157): add plugin dashboard view registry with nav integration
Merged FN-3157 to add a plugin dashboard views system, including a plugin view registry with lazy loading, navigation integration for Header and MobileNavBar, a usePluginDashboardViews hook with cache and refetch support, and tests covering the no-loader path. Also added documentation in `docs/PLUGI Fusion-Task-Id: FN-3157
This commit is contained in:
5
.changeset/fn-3157-plugin-dashboard-views.md
Normal file
5
.changeset/fn-3157-plugin-dashboard-views.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add plugin dashboard view discovery and navigation integration via `GET /api/plugins/dashboard-views`, plugin view ID persistence (`plugin:${pluginId}:${viewId}`), and static host-side plugin view registry rendering.
|
||||
@@ -555,6 +555,28 @@ Current host constraints:
|
||||
- `componentPath` is stored for authoring symmetry/future expansion, but render resolution is currently done through a host-side static registry (`pluginId + viewId`)
|
||||
- Use stable IDs; runtime view key format is `plugin:${pluginId}:${viewId}`
|
||||
|
||||
### Static host registry model
|
||||
|
||||
Dashboard view components are resolved from a host-side registry and must be explicitly registered:
|
||||
|
||||
```ts
|
||||
import { lazy } from "react";
|
||||
import { registerPluginView } from "../app/plugins/pluginViewRegistry";
|
||||
|
||||
registerPluginView(
|
||||
"fusion-plugin-dependency-graph",
|
||||
"graph",
|
||||
lazy(() => import("@fusion-plugin-examples/dependency-graph/dashboard-view")),
|
||||
);
|
||||
```
|
||||
|
||||
The host then renders plugin views via `PluginDashboardViewHost` using the composite ID.
|
||||
|
||||
Placement guidance:
|
||||
- `primary`: top-level nav tab (host may limit count on mobile)
|
||||
- `overflow`: desktop header overflow menu
|
||||
- `more`: mobile More sheet / secondary nav surfaces
|
||||
|
||||
---
|
||||
|
||||
## 9. Registering Agent Runtimes
|
||||
|
||||
@@ -49,6 +49,7 @@ import { useViewState, type TaskView } from "./hooks/useViewState";
|
||||
import { useNavigationHistory } from "./hooks/useNavigationHistory";
|
||||
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
|
||||
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
|
||||
import { isPluginViewId } from "./plugins/pluginViewRegistry";
|
||||
import { useProjectActions } from "./hooks/useProjectActions";
|
||||
import { useTaskHandlers } from "./hooks/useTaskHandlers";
|
||||
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||
@@ -448,6 +449,7 @@ function AppInner() {
|
||||
// Redirect to board if feature-gated views are disabled.
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return;
|
||||
if (isPluginViewId(taskView)) return;
|
||||
if (taskView === "skills" && !skillsEnabled) {
|
||||
handleChangeTaskView("board");
|
||||
}
|
||||
@@ -867,7 +869,7 @@ function AppInner() {
|
||||
}
|
||||
|
||||
// Project view
|
||||
if (taskView.startsWith("plugin:")) {
|
||||
if (isPluginViewId(taskView)) {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<PluginDashboardViewHost
|
||||
@@ -1319,7 +1321,7 @@ function AppInner() {
|
||||
<NativeShellConnectionStatus state={shellState} onManage={() => setShellConnectionManagerOpen(true)} />
|
||||
) : undefined}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !taskView.startsWith("plugin:") && (
|
||||
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !isPluginViewId(taskView) && (
|
||||
<QuickChatFAB
|
||||
projectId={currentProject.id}
|
||||
addToast={addToast}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
|
||||
import { getTrailingPath } from "../utils/pathDisplay";
|
||||
import type { TaskView } from "../hooks/useViewState";
|
||||
import type { PluginDashboardViewEntry } from "../api";
|
||||
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
||||
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
|
||||
import { getPluginNavIcon } from "./pluginNavIcon";
|
||||
|
||||
export { useViewportMode };
|
||||
@@ -1152,7 +1152,7 @@ export function Header({
|
||||
<>
|
||||
<button
|
||||
ref={viewOverflowTriggerRef}
|
||||
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || view.startsWith("plugin:") ? " active" : ""}`}
|
||||
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
|
||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||
title="More views"
|
||||
aria-label="More views"
|
||||
|
||||
@@ -35,7 +35,7 @@ import { fetchScripts } from "../api";
|
||||
import type { PluginDashboardViewEntry } from "../api";
|
||||
import { useViewportMode } from "./Header";
|
||||
import type { TaskView } from "../hooks/useViewState";
|
||||
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
||||
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
|
||||
import { getPluginNavIcon } from "./pluginNavIcon";
|
||||
|
||||
export interface MobileNavBarProps {
|
||||
@@ -247,7 +247,7 @@ export function MobileNavBar({
|
||||
|| (todosOpen && todoViewEnabled)
|
||||
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
||||
|| (view === "skills" && !showSkillsTopLevel)
|
||||
|| (view.startsWith("plugin:") && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
||||
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1645,8 +1645,7 @@ describe("App view switching", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Zoom In")).toBeInTheDocument();
|
||||
expect(screen.getByText("Zoom Out")).toBeInTheDocument();
|
||||
expect(screen.getByText("Plugin view unavailable")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
localStorage.removeItem(taskViewStorageKey());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { usePluginDashboardViews, __test_clearDashboardViewsCache } from "../usePluginDashboardViews";
|
||||
import * as api from "../../api";
|
||||
|
||||
@@ -15,6 +15,14 @@ describe("usePluginDashboardViews", () => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it("returns empty array when no dashboard views are registered", async () => {
|
||||
mockFetch.mockResolvedValueOnce([]);
|
||||
const { result } = renderHook(() => usePluginDashboardViews());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.views).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("fetches and returns dashboard views", async () => {
|
||||
mockFetch.mockResolvedValueOnce([
|
||||
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
|
||||
@@ -25,6 +33,60 @@ describe("usePluginDashboardViews", () => {
|
||||
expect(result.current.views).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("caches results and doesn't re-fetch within ttl", async () => {
|
||||
mockFetch.mockResolvedValueOnce([
|
||||
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
|
||||
]);
|
||||
|
||||
const first = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
await waitFor(() => expect(first.result.current.loading).toBe(false));
|
||||
|
||||
mockFetch.mockClear();
|
||||
const second = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
await waitFor(() => expect(second.result.current.loading).toBe(false));
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets loading only on initial fetch, not on cache-hit", async () => {
|
||||
mockFetch.mockResolvedValueOnce([
|
||||
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
|
||||
]);
|
||||
|
||||
const first = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
expect(first.result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(first.result.current.loading).toBe(false));
|
||||
|
||||
mockFetch.mockClear();
|
||||
const second = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
expect(second.result.current.loading).toBe(false);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles fetch errors gracefully", async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error("boom"));
|
||||
const { result } = renderHook(() => usePluginDashboardViews());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.views).toEqual([]);
|
||||
expect(result.current.error).toBe("boom");
|
||||
});
|
||||
|
||||
it("refetch invalidates cache and fetches again", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce([{ pluginId: "a", view: { viewId: "x", label: "X", componentPath: "./x.js" } }])
|
||||
.mockResolvedValueOnce([{ pluginId: "a", view: { viewId: "y", label: "Y", componentPath: "./y.js" } }]);
|
||||
|
||||
const { result } = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.views[0]?.view.viewId).toBe("x");
|
||||
|
||||
await act(async () => {
|
||||
result.current.refetch();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.views[0]?.view.viewId).toBe("y"));
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses project-scoped cache keys", async () => {
|
||||
mockFetch.mockResolvedValueOnce([{ pluginId: "a", view: { viewId: "x", label: "X", componentPath: "./x.js" } }]);
|
||||
const first = renderHook(() => usePluginDashboardViews("project-a"));
|
||||
|
||||
@@ -329,6 +329,22 @@ describe("useViewState", () => {
|
||||
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("plugin:fusion-plugin-dependency-graph:graph");
|
||||
});
|
||||
|
||||
it("rejects invalid plugin view IDs and falls back to board", async () => {
|
||||
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:only-one-segment");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useViewState(
|
||||
createOptions({
|
||||
currentProject: PROJECT,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.taskView).toBe("board");
|
||||
});
|
||||
});
|
||||
|
||||
it("restores legacy views (board/list/agents/missions/chat) from scoped storage", async () => {
|
||||
const legacyViews = ["board", "list", "agents", "missions", "chat"] as const;
|
||||
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { fetchPluginDashboardViews } from "../api";
|
||||
import type { PluginDashboardViewEntry } from "../api";
|
||||
|
||||
const dashboardViewsCache = new Map<string, { views: PluginDashboardViewEntry[]; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
/** Clear module cache for deterministic hook tests. */
|
||||
export function __test_clearDashboardViewsCache(): void {
|
||||
dashboardViewsCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch plugin dashboard views with a 60s project-scoped cache.
|
||||
* Loading is only true for the first fetch of each hook lifecycle.
|
||||
*/
|
||||
export function usePluginDashboardViews(projectId?: string): {
|
||||
views: PluginDashboardViewEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
} {
|
||||
const [views, setViews] = useState<PluginDashboardViewEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
const cacheKey = projectId ?? "default";
|
||||
dashboardViewsCache.delete(cacheKey);
|
||||
setReloadKey((key) => key + 1);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const cacheKey = projectId ?? "default";
|
||||
let cancelled = false;
|
||||
@@ -57,7 +70,7 @@ export function usePluginDashboardViews(projectId?: string): {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
}, [projectId, reloadKey]);
|
||||
|
||||
return useMemo(() => ({ views, loading, error }), [views, loading, error]);
|
||||
return useMemo(() => ({ views, loading, error, refetch }), [views, loading, error, refetch]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ThemeMode } from "@fusion/core";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { isPluginViewId } from "../plugins/pluginViewRegistry";
|
||||
|
||||
export type ViewMode = "overview" | "project";
|
||||
export type BuiltInTaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
|
||||
@@ -29,12 +30,8 @@ function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {
|
||||
return value !== null && BUILT_IN_TASK_VIEWS.includes(value as BuiltInTaskView);
|
||||
}
|
||||
|
||||
function isPluginTaskView(value: string | null): value is PluginTaskView {
|
||||
return value !== null && /^plugin:[^:]+:.+$/u.test(value);
|
||||
}
|
||||
|
||||
function isTaskView(value: string | null): value is TaskView {
|
||||
return isBuiltInTaskView(value) || isPluginTaskView(value);
|
||||
return value !== null && (isBuiltInTaskView(value) || isPluginViewId(value));
|
||||
}
|
||||
|
||||
function normalizeTaskView(value: TaskView): TaskView {
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import { resolvePluginDashboardView, MissingPluginDashboardView, parsePluginTaskViewId } from "./pluginViewRegistry";
|
||||
import type { PluginDashboardHostContext, PluginTaskView } from "./pluginViewRegistry";
|
||||
import { PluginDashboardViewHost as RegistryPluginDashboardViewHost } from "./pluginViewRegistry";
|
||||
import type { PluginTaskView } from "./pluginViewRegistry";
|
||||
|
||||
export function PluginDashboardViewHost({
|
||||
taskView,
|
||||
context,
|
||||
}: {
|
||||
taskView: PluginTaskView;
|
||||
context: PluginDashboardHostContext;
|
||||
}) {
|
||||
const parsed = parsePluginTaskViewId(taskView);
|
||||
if (!parsed) return null;
|
||||
|
||||
const ViewComponent = resolvePluginDashboardView(parsed.pluginId, parsed.viewId);
|
||||
if (!ViewComponent) {
|
||||
return <>{MissingPluginDashboardView({ pluginId: parsed.pluginId, viewId: parsed.viewId })}</>;
|
||||
}
|
||||
|
||||
return <ViewComponent context={context} />;
|
||||
export function PluginDashboardViewHost({ taskView }: { taskView: PluginTaskView; context?: unknown }) {
|
||||
return <RegistryPluginDashboardViewHost viewId={taskView} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { lazy } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
__test_clearPluginViewRegistry,
|
||||
getPluginViewComponent,
|
||||
getPluginViewId,
|
||||
isPluginViewId,
|
||||
parsePluginViewId,
|
||||
PluginDashboardViewHost,
|
||||
registerPluginView,
|
||||
} from "../pluginViewRegistry";
|
||||
|
||||
describe("pluginViewRegistry", () => {
|
||||
beforeEach(() => {
|
||||
__test_clearPluginViewRegistry();
|
||||
});
|
||||
|
||||
it("builds plugin IDs", () => {
|
||||
expect(getPluginViewId("plugin-a", "main")).toBe("plugin:plugin-a:main");
|
||||
});
|
||||
|
||||
it("parses and validates plugin IDs", () => {
|
||||
expect(parsePluginViewId("plugin:plugin-a:main")).toEqual({ pluginId: "plugin-a", viewId: "main" });
|
||||
expect(parsePluginViewId("board")).toBeNull();
|
||||
expect(isPluginViewId("plugin:plugin-a:main")).toBe(true);
|
||||
expect(isPluginViewId("plugin:only-one-segment")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers and resolves view components", () => {
|
||||
const View = lazy(async () => ({ default: () => <div>Plugin View</div> }));
|
||||
registerPluginView("plugin-a", "main", View);
|
||||
expect(getPluginViewComponent("plugin-a", "main")).toBe(View);
|
||||
expect(getPluginViewComponent("plugin-b", "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders registered components", async () => {
|
||||
const View = lazy(async () => ({ default: () => <div>Rendered Plugin View</div> }));
|
||||
registerPluginView("plugin-a", "main", View);
|
||||
render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:main" })}</>);
|
||||
expect(await screen.findByText("Rendered Plugin View")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders unavailable fallback for unregistered views", () => {
|
||||
render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:missing" })}</>);
|
||||
expect(screen.getByTestId("plugin-view-unavailable")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,64 +1,82 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
|
||||
import { DependencyGraphView } from "@fusion-plugin-examples/dependency-graph/dashboard-view";
|
||||
import { lazy, Suspense, type LazyExoticComponent, type ReactNode } from "react";
|
||||
import { ErrorBoundary } from "../components/ErrorBoundary";
|
||||
import "./pluginViewRegistry.css";
|
||||
|
||||
export type PluginTaskView = `plugin:${string}:${string}`;
|
||||
|
||||
export interface PluginDashboardHostContext {
|
||||
projectId?: string;
|
||||
tasks: Task[];
|
||||
workflowSteps: WorkflowStep[];
|
||||
openTaskDetail: (task: Task | TaskDetail, initialTab?: "logs" | "changes") => void;
|
||||
renderTaskCard: (task: Task) => ReactNode;
|
||||
}
|
||||
type PluginViewComponent = LazyExoticComponent<() => JSX.Element>;
|
||||
|
||||
export interface PluginDashboardViewComponentProps {
|
||||
context: PluginDashboardHostContext;
|
||||
}
|
||||
const registry = new Map<string, PluginViewComponent>();
|
||||
|
||||
export interface PluginDashboardViewRegistration {
|
||||
pluginId: string;
|
||||
viewId: string;
|
||||
component: ComponentType<PluginDashboardViewComponentProps>;
|
||||
}
|
||||
|
||||
const REGISTRY: PluginDashboardViewRegistration[] = [
|
||||
{
|
||||
pluginId: "fusion-plugin-dependency-graph",
|
||||
viewId: "graph",
|
||||
component: DependencyGraphView as ComponentType<PluginDashboardViewComponentProps>,
|
||||
},
|
||||
];
|
||||
|
||||
export function buildPluginTaskViewId(pluginId: string, viewId: string): PluginTaskView {
|
||||
/** Build composite plugin task view ID: plugin:{pluginId}:{viewId}. */
|
||||
export function getPluginViewId(pluginId: string, viewId: string): PluginTaskView {
|
||||
return `plugin:${pluginId}:${viewId}`;
|
||||
}
|
||||
|
||||
export function parsePluginTaskViewId(taskView: string): { pluginId: string; viewId: string } | null {
|
||||
if (!taskView.startsWith("plugin:")) return null;
|
||||
const [, pluginId, ...viewParts] = taskView.split(":");
|
||||
const viewId = viewParts.join(":");
|
||||
if (!pluginId || !viewId) return null;
|
||||
return { pluginId, viewId };
|
||||
/** Parse composite plugin task view ID. Returns null for non-plugin IDs. */
|
||||
export function parsePluginViewId(value: string): { pluginId: string; viewId: string } | null {
|
||||
const match = /^plugin:([^:]+):(.+)$/u.exec(value);
|
||||
if (!match) return null;
|
||||
return { pluginId: match[1], viewId: match[2] };
|
||||
}
|
||||
|
||||
export function resolvePluginDashboardView(pluginId: string, viewId: string): ComponentType<PluginDashboardViewComponentProps> | null {
|
||||
const hit = REGISTRY.find((entry) => entry.pluginId === pluginId && entry.viewId === viewId);
|
||||
return hit?.component ?? null;
|
||||
/** True when a view ID matches the plugin composite ID format. */
|
||||
export function isPluginViewId(value: string): value is PluginTaskView {
|
||||
return parsePluginViewId(value) !== null;
|
||||
}
|
||||
|
||||
export function MissingPluginDashboardView({ pluginId, viewId }: { pluginId: string; viewId: string }): ReactNode {
|
||||
/** Register a lazy plugin dashboard view component in the static host registry. */
|
||||
export function registerPluginView(pluginId: string, viewId: string, lazyComponent: PluginViewComponent): void {
|
||||
registry.set(getPluginViewId(pluginId, viewId), lazyComponent);
|
||||
}
|
||||
|
||||
/** Resolve a plugin dashboard lazy component from the static host registry. */
|
||||
export function getPluginViewComponent(pluginId: string, viewId: string): PluginViewComponent | null {
|
||||
return registry.get(getPluginViewId(pluginId, viewId)) ?? null;
|
||||
}
|
||||
|
||||
/** Test helper for clearing global registry state. */
|
||||
export function __test_clearPluginViewRegistry(): void {
|
||||
registry.clear();
|
||||
}
|
||||
|
||||
function PluginViewUnavailable({ viewId }: { viewId: string }): ReactNode {
|
||||
return (
|
||||
<section className="card plugin-dashboard-view-missing">
|
||||
<section className="card plugin-dashboard-view-missing" data-testid="plugin-view-unavailable">
|
||||
<h2 className="plugin-dashboard-view-missing-title">
|
||||
<AlertTriangle />
|
||||
Plugin view unavailable
|
||||
</h2>
|
||||
<p className="plugin-dashboard-view-missing-description">
|
||||
The dashboard could not resolve <code>{pluginId}:{viewId}</code> from the host registry.
|
||||
No host registration found for <code>{viewId}</code>.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginDashboardViewHost({ viewId }: { viewId: PluginTaskView }): ReactNode {
|
||||
const parsed = parsePluginViewId(viewId);
|
||||
if (!parsed) return <PluginViewUnavailable viewId={viewId} />;
|
||||
|
||||
const ViewComponent = getPluginViewComponent(parsed.pluginId, parsed.viewId);
|
||||
if (!ViewComponent) {
|
||||
return <PluginViewUnavailable viewId={viewId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={<PluginViewUnavailable viewId={viewId} />}>
|
||||
<Suspense fallback={null}>
|
||||
<ViewComponent />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Backward-compatible aliases.
|
||||
export const buildPluginTaskViewId = getPluginViewId;
|
||||
export const parsePluginTaskViewId = parsePluginViewId;
|
||||
export const resolvePluginDashboardView = getPluginViewComponent;
|
||||
|
||||
// Ensure lazy is referenced for plugin authors importing only this module pattern.
|
||||
void lazy;
|
||||
|
||||
@@ -703,6 +703,16 @@ describe("GET /api/plugins/dashboard-views", () => {
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns empty array when pluginLoader is not available", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { pluginStore }));
|
||||
|
||||
const res = await performGet(app, "/api/plugins/dashboard-views");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns 200 with empty array when no plugins have dashboard views", async () => {
|
||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
|
||||
|
||||
Reference in New Issue
Block a user