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:
@@ -1288,6 +1288,42 @@ describe("PluginLoader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
describe("getPluginDashboardViews", () => {
|
||||
it("returns empty array when no plugins loaded", async () => {
|
||||
await pluginStore.init();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
expect(loader.getPluginDashboardViews()).toEqual([]);
|
||||
});
|
||||
|
||||
it("aggregates dashboard views from multiple plugins and keeps uiSlots separate", async () => {
|
||||
await pluginStore.init();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
(loader as any).plugins.set("views-a", {
|
||||
manifest: makeManifest({ id: "views-a" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
uiSlots: [{ slotId: "task-detail-tab", label: "Tab", componentPath: "./tab.js" }],
|
||||
dashboardViews: [{ viewId: "graph", label: "Graph", componentPath: "./graph.js", placement: "more" }],
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("views-b", {
|
||||
manifest: makeManifest({ id: "views-b" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
dashboardViews: [{ viewId: "timeline", label: "Timeline", componentPath: "./timeline.js" }],
|
||||
} as FusionPlugin);
|
||||
|
||||
const views = loader.getPluginDashboardViews();
|
||||
expect(views).toHaveLength(2);
|
||||
expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([
|
||||
"views-a:graph",
|
||||
"views-b:timeline",
|
||||
]);
|
||||
expect(loader.getPluginUiSlots()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getPluginRuntimes ─────────────────────────────────────────────
|
||||
|
||||
describe("getPluginRuntimes", () => {
|
||||
|
||||
@@ -140,6 +140,7 @@ export type {
|
||||
PluginRouteDefinition,
|
||||
PluginRouteMethod,
|
||||
PluginUiSlotDefinition,
|
||||
PluginDashboardViewDefinition,
|
||||
PluginRuntimeManifestMetadata,
|
||||
PluginRuntimeFactory,
|
||||
PluginRuntimeRegistration,
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
PluginToolDefinition,
|
||||
PluginRouteDefinition,
|
||||
PluginUiSlotDefinition,
|
||||
PluginDashboardViewDefinition,
|
||||
PluginRuntimeRegistration,
|
||||
PluginInstallation,
|
||||
PluginSkillContribution,
|
||||
@@ -775,6 +776,22 @@ export class PluginLoader extends EventEmitter<{
|
||||
return slots;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all top-level dashboard view definitions from loaded plugins.
|
||||
*/
|
||||
getPluginDashboardViews(): Array<{ pluginId: string; view: PluginDashboardViewDefinition }> {
|
||||
const views: Array<{ pluginId: string; view: PluginDashboardViewDefinition }> = [];
|
||||
for (const [pluginId, plugin] of this.plugins) {
|
||||
if (plugin.dashboardViews) {
|
||||
for (const view of plugin.dashboardViews) {
|
||||
views.push({ pluginId, view });
|
||||
}
|
||||
}
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all runtime registrations from loaded plugins.
|
||||
* Returns plugin ownership metadata along with the runtime registration.
|
||||
|
||||
@@ -179,6 +179,28 @@ export interface PluginUiSlotDefinition {
|
||||
componentPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level dashboard view definition for plugin-provided navigation destinations.
|
||||
* This is separate from embedded uiSlots and is rendered via host-managed registry.
|
||||
*/
|
||||
export interface PluginDashboardViewDefinition {
|
||||
/** Unique view identifier within a plugin namespace. */
|
||||
viewId: string;
|
||||
/** Human-readable label shown in dashboard navigation. */
|
||||
label: string;
|
||||
/**
|
||||
* Path to module exporting the dashboard view component.
|
||||
* Stored for authoring symmetry/future expansion; host currently resolves via static registry.
|
||||
*/
|
||||
componentPath: string;
|
||||
/** Optional icon name (lucide-react icon name or custom icon identifier). */
|
||||
icon?: string;
|
||||
/** Optional sort order for nav presentation. Lower numbers appear first. */
|
||||
order?: number;
|
||||
/** Preferred navigation placement for this top-level view. */
|
||||
placement?: "primary" | "more";
|
||||
}
|
||||
|
||||
// ── Plugin Runtimes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -356,6 +378,8 @@ export interface FusionPlugin {
|
||||
tools?: PluginToolDefinition[];
|
||||
routes?: PluginRouteDefinition[];
|
||||
uiSlots?: PluginUiSlotDefinition[];
|
||||
/** Plugin-contributed top-level dashboard views. */
|
||||
dashboardViews?: PluginDashboardViewDefinition[];
|
||||
/** Agent runtime registration for providing custom runtime implementations */
|
||||
runtime?: PluginRuntimeRegistration;
|
||||
/** Plugin-contributed skills surfaced by the skill resolver. */
|
||||
|
||||
@@ -68,6 +68,19 @@ The dashboard header adapts across three responsive tiers to remain usable witho
|
||||
- **Desktop (>1024px)**: Full header with all controls and the project selector inline. No overflow menu.
|
||||
- **Keyboard Accessible**: All controls across tiers expose proper ARIA attributes (aria-expanded, aria-haspopup, aria-label) and support keyboard navigation.
|
||||
|
||||
### Plugin Top-Level Views (Graph)
|
||||
|
||||
The dashboard now supports plugin-registered top-level views discovered from:
|
||||
- `GET /api/plugins/dashboard-views`
|
||||
|
||||
View identity is persisted as `plugin:${pluginId}:${viewId}` in scoped project storage (`kb:${projectId}:kb-dashboard-task-view`).
|
||||
|
||||
Navigation placement in this iteration:
|
||||
- **Desktop:** Header view overflow menu ("More views")
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef, lazy, Suspense } from "react";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
|
||||
import { Header, useViewportMode } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
import { TaskCard } from "./components/TaskCard";
|
||||
import { ListView } from "./components/ListView";
|
||||
import { ProjectOverview } from "./components/ProjectOverview";
|
||||
import { MissionManager } from "./components/MissionManager";
|
||||
@@ -44,13 +45,15 @@ import { useMobileKeyboard } from "./hooks/useMobileKeyboard";
|
||||
import { useSetupReadiness } from "./hooks/useSetupReadiness";
|
||||
import { useUpdateCheck } from "./hooks/useUpdateCheck";
|
||||
import { useViewState, type TaskView } from "./hooks/useViewState";
|
||||
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
|
||||
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
|
||||
import { useProjectActions } from "./hooks/useProjectActions";
|
||||
import { useTaskHandlers } from "./hooks/useTaskHandlers";
|
||||
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
|
||||
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||
import type { AiSessionSummary } from "./api";
|
||||
import { fetchUnreadCount, reportDashboardPerf, fetchTaskDetail } from "./api";
|
||||
import { fetchUnreadCount, reportDashboardPerf, fetchTaskDetail, fetchWorkflowSteps } from "./api";
|
||||
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
|
||||
import { subscribeSse } from "./sse-bus";
|
||||
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
|
||||
@@ -202,6 +205,8 @@ function AppInner() {
|
||||
setThemeMode,
|
||||
});
|
||||
|
||||
const { views: pluginDashboardViews } = usePluginDashboardViews(currentProject?.id);
|
||||
|
||||
const handleTaskViewChange = useCallback((newView: TaskView) => {
|
||||
if (newView === "missions") {
|
||||
setMissionResumeSessionId(undefined);
|
||||
@@ -502,6 +507,33 @@ function AppInner() {
|
||||
}
|
||||
}, [modalManager, currentProject?.id, addToast]);
|
||||
|
||||
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetchWorkflowSteps(currentProject?.id)
|
||||
.then((steps) => {
|
||||
if (!cancelled) {
|
||||
setWorkflowSteps(steps);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setWorkflowSteps([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentProject?.id]);
|
||||
|
||||
const workflowStepNameLookup = useMemo(
|
||||
() => new Map(workflowSteps.map((step) => [step.id, step.name] as const)),
|
||||
[workflowSteps],
|
||||
);
|
||||
|
||||
const handleOpenNodes = useCallback(() => {
|
||||
if (!nodesEnabled) return;
|
||||
setNodesOpen((prev) => !prev);
|
||||
@@ -608,6 +640,31 @@ function AppInner() {
|
||||
}
|
||||
|
||||
// Project view
|
||||
if (taskView.startsWith("plugin:")) {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<PluginDashboardViewHost
|
||||
taskView={taskView as `plugin:${string}:${string}`}
|
||||
context={{
|
||||
projectId: currentProject?.id,
|
||||
tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks,
|
||||
workflowSteps,
|
||||
openTaskDetail: (task, initialTab) => modalManager.openDetailTask(task, initialTab),
|
||||
renderTaskCard: (task) => (
|
||||
<TaskCard
|
||||
task={task}
|
||||
projectId={currentProject?.id}
|
||||
onOpenDetail={(value) => modalManager.openDetailTask(value)}
|
||||
addToast={addToast}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "skills") {
|
||||
if (!settingsLoaded || !skillsEnabled) {
|
||||
return null;
|
||||
@@ -940,6 +997,7 @@ function AppInner() {
|
||||
todoView: todosEnabled,
|
||||
researchView: researchEnabled,
|
||||
}}
|
||||
pluginDashboardViews={pluginDashboardViews}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
|
||||
<SessionNotificationBanner
|
||||
@@ -1032,8 +1090,9 @@ function AppInner() {
|
||||
todoView: todosEnabled,
|
||||
researchView: researchEnabled,
|
||||
}}
|
||||
pluginDashboardViews={pluginDashboardViews}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && (
|
||||
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !taskView.startsWith("plugin:") && (
|
||||
<QuickChatFAB
|
||||
projectId={currentProject.id}
|
||||
addToast={addToast}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
WorkflowStepResult,
|
||||
PluginInstallation,
|
||||
PluginUiSlotDefinition,
|
||||
PluginDashboardViewDefinition,
|
||||
TaskDocument,
|
||||
TaskDocumentRevision,
|
||||
TaskDocumentWithTask,
|
||||
@@ -7478,6 +7479,12 @@ export interface PluginUiSlotEntry {
|
||||
slot: PluginUiSlotDefinition;
|
||||
}
|
||||
|
||||
/** A dashboard view entry returned by GET /api/plugins/dashboard-views */
|
||||
export interface PluginDashboardViewEntry {
|
||||
pluginId: string;
|
||||
view: PluginDashboardViewDefinition;
|
||||
}
|
||||
|
||||
/** Plugin runtime metadata returned by GET /api/plugins/runtimes */
|
||||
export interface PluginRuntimeInfo {
|
||||
pluginId: string;
|
||||
@@ -7492,6 +7499,12 @@ export async function fetchPluginUiSlots(projectId?: string): Promise<PluginUiSl
|
||||
return api<PluginUiSlotEntry[]>(withProjectId("/plugins/ui-slots", projectId));
|
||||
}
|
||||
|
||||
|
||||
/** Fetch all top-level dashboard view definitions from active plugins */
|
||||
export async function fetchPluginDashboardViews(projectId?: string): Promise<PluginDashboardViewEntry[]> {
|
||||
return api<PluginDashboardViewEntry[]>(withProjectId("/plugins/dashboard-views", projectId));
|
||||
}
|
||||
|
||||
/** Fetch all plugin runtime metadata from active plugins */
|
||||
export async function fetchPluginRuntimes(projectId?: string): Promise<PluginRuntimeInfo[]> {
|
||||
return api<PluginRuntimeInfo[]>(withProjectId("/plugins/runtimes", projectId));
|
||||
|
||||
@@ -11,6 +11,9 @@ import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
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";
|
||||
|
||||
export { useViewportMode };
|
||||
|
||||
@@ -193,8 +196,8 @@ export interface HeaderProps {
|
||||
enginePaused?: boolean;
|
||||
onToggleGlobalPause?: () => void;
|
||||
onToggleEnginePause?: () => void;
|
||||
view?: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
|
||||
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
|
||||
view?: TaskView;
|
||||
onChangeView?: (view: TaskView) => void;
|
||||
/** Whether to show the skills tab in the view toggle */
|
||||
showSkillsTab?: boolean;
|
||||
/** When true, shows the Agents view tab button. Hidden by default (experimental feature). */
|
||||
@@ -220,6 +223,7 @@ export interface HeaderProps {
|
||||
isRemote?: boolean;
|
||||
/** Experimental feature flags controlling visibility of nav items. */
|
||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean; researchView?: boolean };
|
||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||
}
|
||||
|
||||
export function Header({
|
||||
@@ -265,6 +269,7 @@ export function Header({
|
||||
onSelectNode,
|
||||
isRemote = false,
|
||||
experimentalFeatures,
|
||||
pluginDashboardViews = [],
|
||||
}: HeaderProps) {
|
||||
const mode: ViewportMode = useViewportMode();
|
||||
const isMobile = mode === "mobile";
|
||||
@@ -335,9 +340,10 @@ export function Header({
|
||||
showSkillsTab ||
|
||||
experimentalFeatures?.memoryView ||
|
||||
experimentalFeatures?.devServerView ||
|
||||
!hideFullNav
|
||||
!hideFullNav ||
|
||||
pluginDashboardViews.some((entry) => entry.view.placement !== "primary")
|
||||
);
|
||||
}, [experimentalFeatures, showSkillsTab, hideFullNav]);
|
||||
}, [experimentalFeatures, showSkillsTab, hideFullNav, pluginDashboardViews]);
|
||||
|
||||
const getEffectiveViewport = useCallback(() => {
|
||||
const vv = window.visualViewport;
|
||||
@@ -1111,7 +1117,7 @@ export function Header({
|
||||
<>
|
||||
<button
|
||||
ref={viewOverflowTriggerRef}
|
||||
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (experimentalFeatures?.todoView && view === "todos") ? " active" : ""}`}
|
||||
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (experimentalFeatures?.todoView && view === "todos") || view.startsWith("plugin:") ? " active" : ""}`}
|
||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||
title="More views"
|
||||
aria-label="More views"
|
||||
@@ -1227,6 +1233,27 @@ export function Header({
|
||||
<span>Todos</span>
|
||||
</button>
|
||||
)}
|
||||
{pluginDashboardViews
|
||||
.filter((entry) => entry.view.placement !== "primary")
|
||||
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER))
|
||||
.map((entry) => {
|
||||
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
|
||||
return (
|
||||
<button
|
||||
key={`${entry.pluginId}:${entry.view.viewId}`}
|
||||
className={`view-toggle-overflow-item${view === pluginTaskView ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onChangeView(pluginTaskView);
|
||||
setIsViewOverflowOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
data-testid={`view-overflow-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
||||
>
|
||||
<Grid3X3 size={14} />
|
||||
<span>{entry.view.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -30,13 +30,16 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { fetchScripts } from "../api";
|
||||
import type { PluginDashboardViewEntry } from "../api";
|
||||
import { useViewportMode } from "./Header";
|
||||
import type { TaskView } from "../hooks/useViewState";
|
||||
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
||||
|
||||
export interface MobileNavBarProps {
|
||||
/** Current task view mode */
|
||||
view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
|
||||
view: TaskView;
|
||||
/** Change task view handler */
|
||||
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos") => void;
|
||||
onChangeView: (view: TaskView) => void;
|
||||
/** Whether the ExecutorStatusBar footer is visible */
|
||||
footerVisible: boolean;
|
||||
/** Whether any full-screen modal is currently open (hides the tab bar) */
|
||||
@@ -67,6 +70,7 @@ export interface MobileNavBarProps {
|
||||
showSkillsTab?: boolean;
|
||||
/** Experimental feature flags controlling visibility of nav items. */
|
||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean; researchView?: boolean };
|
||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||
}
|
||||
|
||||
function GitHubLogo({ size = 20 }: { size?: number }) {
|
||||
@@ -114,6 +118,7 @@ export function MobileNavBar({
|
||||
onViewAllProjects,
|
||||
showSkillsTab,
|
||||
experimentalFeatures,
|
||||
pluginDashboardViews = [],
|
||||
}: MobileNavBarProps) {
|
||||
const mode = useViewportMode();
|
||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||
@@ -197,7 +202,8 @@ export function MobileNavBar({
|
||||
|| view === "dev-server"
|
||||
|| (view === "todos" && todoViewEnabled)
|
||||
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
||||
|| (view === "skills" && !showSkillsTopLevel);
|
||||
|| (view === "skills" && !showSkillsTopLevel)
|
||||
|| view.startsWith("plugin:");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -626,6 +632,25 @@ export function MobileNavBar({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pluginDashboardViews
|
||||
.filter((entry) => entry.view.placement !== "primary")
|
||||
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER))
|
||||
.map((entry) => {
|
||||
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
|
||||
return (
|
||||
<button
|
||||
key={`${entry.pluginId}:${entry.view.viewId}`}
|
||||
type="button"
|
||||
className="mobile-more-item"
|
||||
data-testid={`mobile-more-item-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
||||
onClick={() => handleMoreAction(() => onChangeView(pluginTaskView))}
|
||||
>
|
||||
<Grid3X3 />
|
||||
<span>{entry.view.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mobile-more-separator" />
|
||||
|
||||
<button
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
fetchAgents: vi.fn(() => Promise.resolve([])),
|
||||
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
|
||||
fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })),
|
||||
fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])),
|
||||
fetchExecutorStats: vi.fn(() => Promise.resolve({
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
@@ -439,7 +440,7 @@ vi.mock("../../hooks/useNodes", () => ({
|
||||
|
||||
import { App } from "../../App";
|
||||
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
|
||||
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels } from "../../api";
|
||||
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api";
|
||||
import * as apiNodeModule from "../../hooks/useRemoteNodeData";
|
||||
|
||||
async function waitForAppShell(): Promise<void> {
|
||||
@@ -1480,6 +1481,27 @@ describe("App view switching", () => {
|
||||
localStorage.removeItem("kb-dashboard-view-mode");
|
||||
});
|
||||
|
||||
it("renders plugin-hosted dashboard view from persisted task view id", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
localStorage.setItem(taskViewStorageKey(), "plugin:fusion-plugin-dependency-graph:graph");
|
||||
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{
|
||||
pluginId: "fusion-plugin-dependency-graph",
|
||||
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", placement: "more" },
|
||||
},
|
||||
]);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Zoom In")).toBeInTheDocument();
|
||||
expect(screen.getByText("Zoom Out")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
localStorage.removeItem(taskViewStorageKey());
|
||||
localStorage.removeItem("kb-dashboard-view-mode");
|
||||
});
|
||||
|
||||
it("opens planning mode when TodoView triggers planning from todo item", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
localStorage.setItem(taskViewStorageKey(), "todos");
|
||||
|
||||
@@ -157,6 +157,25 @@ describe("Header", () => {
|
||||
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plugin dashboard views in desktop view overflow only", () => {
|
||||
const onChangeView = vi.fn();
|
||||
renderHeader({
|
||||
onChangeView,
|
||||
pluginDashboardViews: [
|
||||
{
|
||||
pluginId: "fusion-plugin-dependency-graph",
|
||||
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||
const graphItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-graph");
|
||||
expect(graphItem).toBeInTheDocument();
|
||||
fireEvent.click(graphItem);
|
||||
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
|
||||
});
|
||||
|
||||
it("renders view overflow trigger when an experimental overflow feature is enabled", () => {
|
||||
renderHeader({ onChangeView: noop, experimentalFeatures: { insights: true } });
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||
|
||||
@@ -104,6 +104,26 @@ describe("MobileNavBar", () => {
|
||||
expect(screen.queryByTestId("mobile-nav-tab-skills")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders plugin dashboard views in More sheet and not top-level tabs", () => {
|
||||
const props = createDefaultProps();
|
||||
render(
|
||||
<MobileNavBar
|
||||
{...props}
|
||||
pluginDashboardViews={[
|
||||
{
|
||||
pluginId: "fusion-plugin-dependency-graph",
|
||||
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView" },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("mobile-nav-tab-graph")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||
fireEvent.click(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-graph"));
|
||||
expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
|
||||
});
|
||||
|
||||
it("active tab is highlighted for mailbox", () => {
|
||||
render(<MobileNavBar {...createDefaultProps()} view="mailbox" />);
|
||||
expect(screen.getByTestId("mobile-nav-tab-mailbox").className).toContain("mobile-nav-tab--active");
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { usePluginDashboardViews, __test_clearDashboardViewsCache } from "../usePluginDashboardViews";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchPluginDashboardViews: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.mocked(api.fetchPluginDashboardViews);
|
||||
|
||||
describe("usePluginDashboardViews", () => {
|
||||
beforeEach(() => {
|
||||
__test_clearDashboardViewsCache();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it("fetches and returns dashboard views", async () => {
|
||||
mockFetch.mockResolvedValueOnce([
|
||||
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => usePluginDashboardViews());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.views).toHaveLength(1);
|
||||
});
|
||||
|
||||
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"));
|
||||
await waitFor(() => expect(first.result.current.loading).toBe(false));
|
||||
|
||||
mockFetch.mockClear();
|
||||
renderHook(() => usePluginDashboardViews("project-a"));
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
mockFetch.mockResolvedValueOnce([{ pluginId: "b", view: { viewId: "y", label: "Y", componentPath: "./y.js" } }]);
|
||||
const second = renderHook(() => usePluginDashboardViews("project-b"));
|
||||
await waitFor(() => expect(second.result.current.loading).toBe(false));
|
||||
expect(mockFetch).toHaveBeenCalledWith("project-b");
|
||||
});
|
||||
});
|
||||
@@ -275,6 +275,28 @@ describe("useViewState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("restores and persists plugin task views using the canonical composite key", async () => {
|
||||
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:fusion-plugin-dependency-graph:graph");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useViewState(
|
||||
createOptions({
|
||||
currentProject: PROJECT,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.taskView).toBe("plugin:fusion-plugin-dependency-graph:graph");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.setTaskView("plugin:fusion-plugin-dependency-graph:graph");
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("plugin:fusion-plugin-dependency-graph:graph");
|
||||
});
|
||||
|
||||
it("restores legacy views (board/list/agents/missions/chat) from scoped storage", async () => {
|
||||
const legacyViews = ["board", "list", "agents", "missions", "chat"] as const;
|
||||
|
||||
|
||||
63
packages/dashboard/app/hooks/usePluginDashboardViews.ts
Normal file
63
packages/dashboard/app/hooks/usePluginDashboardViews.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { 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;
|
||||
|
||||
export function __test_clearDashboardViewsCache(): void {
|
||||
dashboardViewsCache.clear();
|
||||
}
|
||||
|
||||
export function usePluginDashboardViews(projectId?: string): {
|
||||
views: PluginDashboardViewEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
} {
|
||||
const [views, setViews] = useState<PluginDashboardViewEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const cacheKey = projectId ?? "default";
|
||||
let cancelled = false;
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const cached = dashboardViewsCache.get(cacheKey);
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
if (cancelled) return;
|
||||
setViews(cached.views);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initialLoadCompleteRef.current) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchPluginDashboardViews(projectId);
|
||||
if (cancelled) return;
|
||||
dashboardViewsCache.set(cacheKey, { views: data, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
setViews(data);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch plugin dashboard views");
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
initialLoadCompleteRef.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
return useMemo(() => ({ views, loading, error }), [views, loading, error]);
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import type { ProjectInfo } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
export type ViewMode = "overview" | "project";
|
||||
export type TaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
|
||||
export type BuiltInTaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
|
||||
export type PluginTaskView = `plugin:${string}:${string}`;
|
||||
export type TaskView = BuiltInTaskView | PluginTaskView;
|
||||
|
||||
const TASK_VIEWS: readonly TaskView[] = [
|
||||
const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
|
||||
"board",
|
||||
"list",
|
||||
"agents",
|
||||
@@ -24,8 +26,16 @@ const TASK_VIEWS: readonly TaskView[] = [
|
||||
"todos",
|
||||
];
|
||||
|
||||
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 value !== null && TASK_VIEWS.includes(value as TaskView);
|
||||
return isBuiltInTaskView(value) || isPluginTaskView(value);
|
||||
}
|
||||
|
||||
function normalizeTaskView(value: TaskView): TaskView {
|
||||
|
||||
20
packages/dashboard/app/plugins/PluginDashboardViewHost.tsx
Normal file
20
packages/dashboard/app/plugins/PluginDashboardViewHost.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { resolvePluginDashboardView, MissingPluginDashboardView, parsePluginTaskViewId } from "./pluginViewRegistry";
|
||||
import type { PluginDashboardHostContext, 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} />;
|
||||
}
|
||||
27
packages/dashboard/app/plugins/pluginViewRegistry.css
Normal file
27
packages/dashboard/app/plugins/pluginViewRegistry.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.plugin-dashboard-view-missing {
|
||||
margin: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.plugin-dashboard-view-missing-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.plugin-dashboard-view-missing-description {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.plugin-dashboard-view-missing {
|
||||
margin: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
}
|
||||
64
packages/dashboard/app/plugins/pluginViewRegistry.tsx
Normal file
64
packages/dashboard/app/plugins/pluginViewRegistry.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
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 "./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;
|
||||
}
|
||||
|
||||
export interface PluginDashboardViewComponentProps {
|
||||
context: PluginDashboardHostContext;
|
||||
}
|
||||
|
||||
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 {
|
||||
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 };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function MissingPluginDashboardView({ pluginId, viewId }: { pluginId: string; viewId: string }): ReactNode {
|
||||
return (
|
||||
<section className="card plugin-dashboard-view-missing">
|
||||
<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.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.36.4",
|
||||
"@fusion-plugin-examples/dependency-graph": "workspace:*",
|
||||
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
|
||||
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
|
||||
"@fusion-plugin-examples/paperclip-runtime": "workspace:*",
|
||||
|
||||
@@ -92,6 +92,7 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
|
||||
getPluginTools: vi.fn().mockReturnValue([]),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
||||
getPluginDashboardViews: vi.fn().mockReturnValue([]),
|
||||
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
|
||||
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
|
||||
invokeHook: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -678,6 +679,58 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
describe("GET /api/plugins/dashboard-views", () => {
|
||||
let pluginStore: PluginStore;
|
||||
let pluginLoader: PluginLoader;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
getPluginStore: vi.fn().mockReturnValue(pluginStore),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader }));
|
||||
return app;
|
||||
}
|
||||
|
||||
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");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns aggregated dashboard views with pluginId and view", async () => {
|
||||
const mockViews = [
|
||||
{
|
||||
pluginId: "dep-graph",
|
||||
view: {
|
||||
viewId: "graph",
|
||||
label: "Graph",
|
||||
componentPath: "./views/Graph.js",
|
||||
icon: "Network",
|
||||
placement: "more",
|
||||
},
|
||||
},
|
||||
];
|
||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue(mockViews);
|
||||
|
||||
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(mockViews);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/plugins/ui-slots", () => {
|
||||
let pluginStore: PluginStore;
|
||||
let pluginLoader: PluginLoader;
|
||||
|
||||
@@ -3031,6 +3031,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
res.json(slots);
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* GET /api/plugins/dashboard-views
|
||||
* Get all plugin top-level dashboard view definitions from active plugins.
|
||||
* Returns aggregated array of { pluginId, view } objects.
|
||||
*/
|
||||
router.get("/plugins/dashboard-views", async (_req: Request, res: Response) => {
|
||||
const views = options?.pluginLoader?.getPluginDashboardViews() ?? [];
|
||||
res.json(views);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/runtimes
|
||||
* Get all plugin runtime metadata from active plugins.
|
||||
|
||||
@@ -240,6 +240,22 @@ describe("Plugin SDK", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("PluginDashboardViewDefinition can be used in FusionPlugin", () => {
|
||||
const plugin: FusionPlugin = {
|
||||
manifest: { id: "test", name: "Test", version: "1.0.0" },
|
||||
state: "installed",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
dashboardViews: [
|
||||
{ viewId: "graph", label: "Graph", componentPath: "./views/Graph.js", placement: "more" },
|
||||
],
|
||||
};
|
||||
expect(plugin.dashboardViews).toHaveLength(1);
|
||||
expect(plugin.dashboardViews?.[0].viewId).toBe("graph");
|
||||
});
|
||||
// ── validatePluginManifest ───────────────────────────────────────────
|
||||
|
||||
describe("validatePluginManifest", () => {
|
||||
|
||||
@@ -49,6 +49,7 @@ export type {
|
||||
PluginRouteDefinition,
|
||||
PluginRouteMethod,
|
||||
PluginUiSlotDefinition,
|
||||
PluginDashboardViewDefinition,
|
||||
PluginRuntimeManifestMetadata,
|
||||
PluginRuntimeFactory,
|
||||
PluginRuntimeRegistration,
|
||||
|
||||
Reference in New Issue
Block a user