FN-8398: show enabled Compound Engineering navigation
Expose installed, enabled Compound Engineering views consistently across dashboard navigation. - Refresh plugin dashboard views when plugin lifecycle events arrive. - Scope dashboard-view discovery to the active project loader. - Cover desktop, header, mobile, hook, and route behavior. Files changed: .changeset/fn-8398-compound-engineering-nav.md | 7 +++ .../app/components/__tests__/Header.test.tsx | 29 +++++++++ .../components/__tests__/LeftSidebarNav.test.tsx | 27 ++++++++- .../app/components/__tests__/MobileNavBar.test.tsx | 21 +++++++ .../__tests__/usePluginDashboardViews.test.ts | 69 ++++++++++++++++++++++ .../dashboard/app/hooks/usePluginDashboardViews.ts | 20 +++++++ .../dashboard/src/__tests__/routes-system.test.ts | 44 ++++++++++++++ .../src/routes/register-plugins-automation.ts | 13 +++- 8 files changed, 225 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8398 Fusion-Task-Lineage: ffcfe801-6fcf-47a9-857a-78668ecc7246 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8398-compound-engineering-nav.md
Normal file
7
.changeset/fn-8398-compound-engineering-nav.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Show Compound Engineering in navigation when the enabled plugin starts.
|
||||||
|
category: fix
|
||||||
|
dev: Refresh project-scoped dashboard views after plugin lifecycle events and prevent project view leakage.
|
||||||
@@ -375,6 +375,35 @@ describe("Header", () => {
|
|||||||
expect(screen.getByTestId("view-toggle-plugin-fusion-plugin-roadmap-roadmaps")).toBeInTheDocument();
|
expect(screen.getByTestId("view-toggle-plugin-fusion-plugin-roadmap-roadmaps")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows Compound Engineering in sidebar-off header navigation and removes it after disable and uninstall", () => {
|
||||||
|
const compoundEngineeringView = [{
|
||||||
|
pluginId: "fusion-plugin-compound-engineering",
|
||||||
|
view: {
|
||||||
|
viewId: "compound-engineering",
|
||||||
|
label: "Compound Engineering",
|
||||||
|
componentPath: "./CompoundEngineeringView",
|
||||||
|
icon: "Boxes",
|
||||||
|
placement: "primary" as const,
|
||||||
|
order: 36,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
const rendered = renderHeader({
|
||||||
|
onChangeView: noop,
|
||||||
|
leftSidebarNavActive: false,
|
||||||
|
pluginDashboardViews: compoundEngineeringView,
|
||||||
|
});
|
||||||
|
const testId = "view-toggle-plugin-fusion-plugin-compound-engineering-compound-engineering";
|
||||||
|
|
||||||
|
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||||
|
rendered.rerender(<Header onOpenSettings={noop} onOpenGitHubImport={noop} onChangeView={noop} leftSidebarNavActive={false} pluginDashboardViews={[]} />);
|
||||||
|
expect(screen.queryByTestId(testId)).toBeNull();
|
||||||
|
|
||||||
|
rendered.rerender(<Header onOpenSettings={noop} onOpenGitHubImport={noop} onChangeView={noop} leftSidebarNavActive={false} pluginDashboardViews={compoundEngineeringView} />);
|
||||||
|
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||||
|
rendered.rerender(<Header onOpenSettings={noop} onOpenGitHubImport={noop} onChangeView={noop} leftSidebarNavActive={false} pluginDashboardViews={[]} />);
|
||||||
|
expect(screen.queryByTestId(testId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders view overflow trigger when an experimental overflow feature is enabled", () => {
|
it("renders view overflow trigger when an experimental overflow feature is enabled", () => {
|
||||||
renderHeader({ onChangeView: noop, experimentalFeatures: { insights: true } });
|
renderHeader({ onChangeView: noop, experimentalFeatures: { insights: true } });
|
||||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||||
|
|||||||
@@ -426,13 +426,13 @@ describe("LeftSidebarNav", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders plugin labels without view suffix and pins Compound Engineering to the Boxes sidebar icon", () => {
|
it("renders plugin labels without view suffix and pins Compound Engineering to the Boxes sidebar icon", () => {
|
||||||
renderSidebar({
|
const rendered = renderSidebar({
|
||||||
pluginDashboardViews: [
|
pluginDashboardViews: [
|
||||||
...pluginViews,
|
...pluginViews,
|
||||||
{
|
{
|
||||||
pluginId: "fusion-plugin-compound-engineering",
|
pluginId: "fusion-plugin-compound-engineering",
|
||||||
view: {
|
view: {
|
||||||
viewId: "compound",
|
viewId: "compound-engineering",
|
||||||
label: "Compound Engineering",
|
label: "Compound Engineering",
|
||||||
componentPath: "./CompoundEngineering",
|
componentPath: "./CompoundEngineering",
|
||||||
icon: "Sparkles",
|
icon: "Sparkles",
|
||||||
@@ -444,7 +444,7 @@ describe("LeftSidebarNav", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const primaryPlugin = screen.getByTestId("sidebar-nav-plugin-fusion-plugin-primary-primary-view");
|
const primaryPlugin = screen.getByTestId("sidebar-nav-plugin-fusion-plugin-primary-primary-view");
|
||||||
const compoundPlugin = screen.getByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound");
|
const compoundPlugin = screen.getByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound-engineering");
|
||||||
expect(primaryPlugin).toHaveAccessibleName("Primary Plugin");
|
expect(primaryPlugin).toHaveAccessibleName("Primary Plugin");
|
||||||
expect(primaryPlugin).toHaveAttribute("title", "Primary Plugin");
|
expect(primaryPlugin).toHaveAttribute("title", "Primary Plugin");
|
||||||
expect(primaryPlugin).toHaveTextContent("Primary Plugin");
|
expect(primaryPlugin).toHaveTextContent("Primary Plugin");
|
||||||
@@ -456,6 +456,27 @@ describe("LeftSidebarNav", () => {
|
|||||||
expect(compoundPlugin.querySelector(".lucide-boxes")).not.toBeNull();
|
expect(compoundPlugin.querySelector(".lucide-boxes")).not.toBeNull();
|
||||||
expect(compoundPlugin.querySelector(".lucide-sparkles")).toBeNull();
|
expect(compoundPlugin.querySelector(".lucide-sparkles")).toBeNull();
|
||||||
expect(compoundPlugin.querySelector(".lucide-grid-3x3")).toBeNull();
|
expect(compoundPlugin.querySelector(".lucide-grid-3x3")).toBeNull();
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CompoundEngineeringNav 2026-07-19-17:27:
|
||||||
|
Disable and uninstall both remove the shared view entry; neither may leave a dead sidebar shell.
|
||||||
|
*/
|
||||||
|
rendered.rerender(<LeftSidebarNav {...rendered.props} pluginDashboardViews={[]} />);
|
||||||
|
expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeNull();
|
||||||
|
rendered.rerender(<LeftSidebarNav {...rendered.props} pluginDashboardViews={[...pluginViews, {
|
||||||
|
pluginId: "fusion-plugin-compound-engineering",
|
||||||
|
view: {
|
||||||
|
viewId: "compound-engineering",
|
||||||
|
label: "Compound Engineering",
|
||||||
|
componentPath: "./CompoundEngineering",
|
||||||
|
icon: "Sparkles",
|
||||||
|
placement: "primary",
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
}]} />);
|
||||||
|
expect(screen.getByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeInTheDocument();
|
||||||
|
rendered.rerender(<LeftSidebarNav {...rendered.props} pluginDashboardViews={[]} />);
|
||||||
|
expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each<[TaskView, string]>([
|
it.each<[TaskView, string]>([
|
||||||
|
|||||||
@@ -782,6 +782,27 @@ describe("MobileNavBar", () => {
|
|||||||
expect(screen.queryByTestId("mobile-more-item-command-center")).toBeNull();
|
expect(screen.queryByTestId("mobile-more-item-command-center")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes Compound Engineering from More after disable and uninstall without creating a primary tab", () => {
|
||||||
|
const props = createDefaultProps();
|
||||||
|
const compoundEngineeringView = [{
|
||||||
|
pluginId: "fusion-plugin-compound-engineering",
|
||||||
|
view: { viewId: "compound-engineering", label: "Compound Engineering", componentPath: "./CompoundEngineeringView", icon: "Boxes", placement: "primary" as const, order: 36 },
|
||||||
|
}];
|
||||||
|
const testId = "mobile-more-item-plugin-fusion-plugin-compound-engineering-compound-engineering";
|
||||||
|
const rendered = render(<MobileNavBar {...props} pluginDashboardViews={compoundEngineeringView} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||||
|
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("mobile-nav-tab-plugin-fusion-plugin-compound-engineering-compound-engineering")).toBeNull();
|
||||||
|
|
||||||
|
rendered.rerender(<MobileNavBar {...props} pluginDashboardViews={[]} />);
|
||||||
|
expect(screen.queryByTestId(testId)).toBeNull();
|
||||||
|
rendered.rerender(<MobileNavBar {...props} pluginDashboardViews={compoundEngineeringView} />);
|
||||||
|
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||||
|
rendered.rerender(<MobileNavBar {...props} pluginDashboardViews={[]} />);
|
||||||
|
expect(screen.queryByTestId(testId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the hosted roadmaps plugin entry when roadmap plugin view is registered", () => {
|
it("renders the hosted roadmaps plugin entry when roadmap plugin view is registered", () => {
|
||||||
render(
|
render(
|
||||||
<MobileNavBar
|
<MobileNavBar
|
||||||
|
|||||||
@@ -2,17 +2,43 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { usePluginDashboardViews, __test_clearDashboardViewsCache } from "../usePluginDashboardViews";
|
import { usePluginDashboardViews, __test_clearDashboardViewsCache } from "../usePluginDashboardViews";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
|
import { subscribeSse } from "../../sse-bus";
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchPluginDashboardViews: vi.fn(),
|
fetchPluginDashboardViews: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../sse-bus", () => ({
|
||||||
|
subscribeSse: vi.fn(() => vi.fn()),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockFetch = vi.mocked(api.fetchPluginDashboardViews);
|
const mockFetch = vi.mocked(api.fetchPluginDashboardViews);
|
||||||
|
const mockSubscribeSse = vi.mocked(subscribeSse);
|
||||||
|
|
||||||
|
const compoundEngineeringView = {
|
||||||
|
pluginId: "fusion-plugin-compound-engineering",
|
||||||
|
view: {
|
||||||
|
viewId: "compound-engineering",
|
||||||
|
label: "Compound Engineering",
|
||||||
|
componentPath: "./dashboard-view",
|
||||||
|
icon: "Boxes",
|
||||||
|
placement: "primary" as const,
|
||||||
|
order: 36,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function emitPluginLifecycle(transition: string): void {
|
||||||
|
const subscription = mockSubscribeSse.mock.calls.at(-1)?.[1];
|
||||||
|
const handler = subscription?.events?.["plugin:lifecycle"];
|
||||||
|
if (!handler) throw new Error("expected plugin:lifecycle subscription");
|
||||||
|
handler(new MessageEvent("plugin:lifecycle", { data: JSON.stringify({ transition }) }));
|
||||||
|
}
|
||||||
|
|
||||||
describe("usePluginDashboardViews", () => {
|
describe("usePluginDashboardViews", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
__test_clearDashboardViewsCache();
|
__test_clearDashboardViewsCache();
|
||||||
mockFetch.mockReset();
|
mockFetch.mockReset();
|
||||||
|
mockSubscribeSse.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty array when no dashboard views are registered", async () => {
|
it("returns empty array when no dashboard views are registered", async () => {
|
||||||
@@ -102,6 +128,49 @@ describe("usePluginDashboardViews", () => {
|
|||||||
expect(mockFetch).toHaveBeenCalledWith("project-b");
|
expect(mockFetch).toHaveBeenCalledWith("project-b");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refreshes the current project's cached views across install, disable, and uninstall lifecycle transitions", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([compoundEngineeringView])
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([compoundEngineeringView])
|
||||||
|
.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePluginDashboardViews("project-a"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.views).toEqual([]);
|
||||||
|
|
||||||
|
emitPluginLifecycle("registered");
|
||||||
|
await waitFor(() => expect(result.current.views).toEqual([compoundEngineeringView]));
|
||||||
|
|
||||||
|
emitPluginLifecycle("disabled");
|
||||||
|
await waitFor(() => expect(result.current.views).toEqual([]));
|
||||||
|
|
||||||
|
emitPluginLifecycle("enabled");
|
||||||
|
await waitFor(() => expect(result.current.views).toEqual([compoundEngineeringView]));
|
||||||
|
|
||||||
|
emitPluginLifecycle("uninstalled");
|
||||||
|
await waitFor(() => expect(result.current.views).toEqual([]));
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never exposes project A's Compound Engineering view after switching to project B", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce([compoundEngineeringView])
|
||||||
|
.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ projectId }: { projectId: string }) => usePluginDashboardViews(projectId),
|
||||||
|
{ initialProps: { projectId: "project-a" } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.views).toEqual([compoundEngineeringView]));
|
||||||
|
|
||||||
|
rerender({ projectId: "project-b" });
|
||||||
|
await waitFor(() => expect(result.current.views).toEqual([]));
|
||||||
|
expect(mockFetch).toHaveBeenNthCalledWith(1, "project-a");
|
||||||
|
expect(mockFetch).toHaveBeenNthCalledWith(2, "project-b");
|
||||||
|
});
|
||||||
|
|
||||||
it("supports filtering view entries by pluginId in consumers", async () => {
|
it("supports filtering view entries by pluginId in consumers", async () => {
|
||||||
mockFetch.mockResolvedValueOnce([
|
mockFetch.mockResolvedValueOnce([
|
||||||
{ pluginId: "fusion-plugin-dependency-graph", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
|
{ pluginId: "fusion-plugin-dependency-graph", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { fetchPluginDashboardViews } from "../api";
|
import { fetchPluginDashboardViews } from "../api";
|
||||||
import type { PluginDashboardViewEntry } from "../api";
|
import type { PluginDashboardViewEntry } from "../api";
|
||||||
|
import { subscribeSse } from "../sse-bus";
|
||||||
|
|
||||||
const dashboardViewsCache = new Map<string, { views: PluginDashboardViewEntry[]; expiresAt: number }>();
|
const dashboardViewsCache = new Map<string, { views: PluginDashboardViewEntry[]; expiresAt: number }>();
|
||||||
const CACHE_TTL_MS = 60_000;
|
const CACHE_TTL_MS = 60_000;
|
||||||
@@ -32,6 +33,25 @@ export function usePluginDashboardViews(projectId?: string): {
|
|||||||
setReloadKey((key) => key + 1);
|
setReloadKey((key) => key + 1);
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CompoundEngineeringNav 2026-07-19-17:01:
|
||||||
|
An enabled plugin becomes a navigation destination only after its project-scoped loader starts it.
|
||||||
|
Subscribe at the shared dashboard-views seam so install, enable, disable, and uninstall lifecycle
|
||||||
|
changes invalidate every nav renderer together instead of leaving a 60-second stale empty sidebar.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (projectId) params.set("projectId", projectId);
|
||||||
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
|
|
||||||
|
const refreshViews = () => refetch();
|
||||||
|
return subscribeSse(`/api/events${query}`, {
|
||||||
|
events: { "plugin:lifecycle": refreshViews },
|
||||||
|
// A reconnect may have missed a lifecycle event, so re-read the loaded set.
|
||||||
|
onReconnect: refreshViews,
|
||||||
|
});
|
||||||
|
}, [projectId, refetch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cacheKey = projectId ?? "default";
|
const cacheKey = projectId ?? "default";
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|||||||
@@ -819,6 +819,50 @@ describe("POST /api/kill-vitest", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/plugins/dashboard-views", () => {
|
||||||
|
it("uses the request project's loaded plugin loader rather than leaking launch-project views", async () => {
|
||||||
|
const compoundEngineeringView = {
|
||||||
|
pluginId: "fusion-plugin-compound-engineering",
|
||||||
|
view: {
|
||||||
|
viewId: "compound-engineering",
|
||||||
|
label: "Compound Engineering",
|
||||||
|
componentPath: "./dashboard-view",
|
||||||
|
icon: "Boxes",
|
||||||
|
placement: "primary",
|
||||||
|
order: 36,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const projectLoaders = {
|
||||||
|
"project-a": { getPluginDashboardViews: vi.fn().mockResolvedValue([compoundEngineeringView]) },
|
||||||
|
"project-b": { getPluginDashboardViews: vi.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
const engines = Object.fromEntries(
|
||||||
|
Object.entries(projectLoaders).map(([projectId, loader]) => [projectId, {
|
||||||
|
getTaskStore: () => createMockStore(),
|
||||||
|
getPluginRunner: () => ({ getLoader: () => loader }),
|
||||||
|
}]),
|
||||||
|
);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(createMockStore(), {
|
||||||
|
// The launch loader intentionally has CE to reproduce the prior project-A leak.
|
||||||
|
pluginLoader: projectLoaders["project-a"],
|
||||||
|
engineManager: {
|
||||||
|
getEngine: (projectId: string) => engines[projectId as keyof typeof engines],
|
||||||
|
onProjectAccessed: vi.fn(),
|
||||||
|
} as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [projectA, projectB] = await Promise.all([
|
||||||
|
GET(app, "/api/plugins/dashboard-views?projectId=project-a"),
|
||||||
|
GET(app, "/api/plugins/dashboard-views?projectId=project-b"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(projectA.body).toEqual([compoundEngineeringView]);
|
||||||
|
expect(projectB.body).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("GET /api/plugins/runtimes", () => {
|
describe("GET /api/plugins/runtimes", () => {
|
||||||
function buildApp(pluginLoader?: { getPluginRuntimes?: () => Array<{ pluginId: string; runtime: { metadata: { runtimeId: string; name: string; description?: string; version?: string }; factory: () => unknown } }> }) {
|
function buildApp(pluginLoader?: { getPluginRuntimes?: () => Array<{ pluginId: string; runtime: { metadata: { runtimeId: string; name: string; description?: string; version?: string }; factory: () => unknown } }> }) {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|||||||
@@ -908,8 +908,17 @@ export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: Plu
|
|||||||
* Get all plugin top-level dashboard view definitions from active plugins.
|
* Get all plugin top-level dashboard view definitions from active plugins.
|
||||||
* Returns aggregated array of { pluginId, view } objects.
|
* Returns aggregated array of { pluginId, view } objects.
|
||||||
*/
|
*/
|
||||||
router.get("/plugins/dashboard-views", async (_req: Request, res: Response) => {
|
/*
|
||||||
const views = await options?.pluginLoader?.getPluginDashboardViews() ?? [];
|
FNXC:PluginNavigation 2026-07-19-17:01:
|
||||||
|
Dashboard views are a loaded-plugin contract scoped to the requested project. Never use the
|
||||||
|
launch project's fallback loader for an explicit project id: doing so leaks enabled Compound
|
||||||
|
Engineering navigation from project A into project B while B's loader is still absent or empty.
|
||||||
|
*/
|
||||||
|
router.get("/plugins/dashboard-views", async (req: Request, res: Response) => {
|
||||||
|
const { engine, projectId } = await getProjectContext(req);
|
||||||
|
const pluginLoader = engine?.getPluginRunner?.()?.getLoader()
|
||||||
|
?? (projectId === undefined ? options?.pluginLoader : undefined);
|
||||||
|
const views = await pluginLoader?.getPluginDashboardViews() ?? [];
|
||||||
res.json(views);
|
res.json(views);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user