FN-7201: refresh plugin dashboard-view metadata from manifests
Refresh plugin dashboard navigation metadata from the current on-disk manifest so rebuilt plugins do not serve stale view details. - Resolve the authoritative manifest path from plugin entry directories and package roots. - Re-read and validate dashboardViews metadata before returning dashboard navigation entries, falling back only when the manifest cannot be used. - Await refreshed dashboard-view metadata in the dashboard route and cover rebuild/removal cases with tests. - Add a patch changeset for the published CLI package. Files changed: .../FN-7201-plugin-dashboard-view-refresh.md | 7 ++ packages/core/src/__tests__/plugin-loader.test.ts | 114 ++++++++++++++++++++- packages/core/src/plugin-loader.ts | 94 ++++++++++++++++- .../dashboard/src/__tests__/plugin-routes.test.ts | 42 +++++++- packages/dashboard/src/routes.ts | 2 +- 5 files changed, 244 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-7201 Fusion-Task-Lineage: dbdd5575-1999-4c9c-93a4-25eb8f213e07 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/FN-7201-plugin-dashboard-view-refresh.md
Normal file
7
.changeset/FN-7201-plugin-dashboard-view-refresh.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Plugin sidebar icons now refresh after a plugin rebuild instead of showing stale glyphs.
|
||||||
|
category: fix
|
||||||
|
dev: Dashboard-view metadata is re-derived from the authoritative on-disk manifest so rebuilt plugins do not serve stale dashboardViews icon, label, or placement values to navigation while the in-view bundle is current.
|
||||||
@@ -1969,7 +1969,7 @@ export default plugin;
|
|||||||
it("returns empty array when no plugins loaded", async () => {
|
it("returns empty array when no plugins loaded", async () => {
|
||||||
await pluginStore.init();
|
await pluginStore.init();
|
||||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||||
expect(loader.getPluginDashboardViews()).toEqual([]);
|
await expect(loader.getPluginDashboardViews()).resolves.toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns aggregated views from a single plugin", async () => {
|
it("returns aggregated views from a single plugin", async () => {
|
||||||
@@ -1985,7 +1985,7 @@ export default plugin;
|
|||||||
],
|
],
|
||||||
} as FusionPlugin);
|
} as FusionPlugin);
|
||||||
|
|
||||||
const views = loader.getPluginDashboardViews();
|
const views = await loader.getPluginDashboardViews();
|
||||||
expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([
|
expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([
|
||||||
"views-a:graph",
|
"views-a:graph",
|
||||||
"views-a:timeline",
|
"views-a:timeline",
|
||||||
@@ -2009,7 +2009,7 @@ export default plugin;
|
|||||||
dashboardViews: [{ viewId: "timeline", label: "Timeline", componentPath: "./timeline.js" }],
|
dashboardViews: [{ viewId: "timeline", label: "Timeline", componentPath: "./timeline.js" }],
|
||||||
} as FusionPlugin);
|
} as FusionPlugin);
|
||||||
|
|
||||||
const views = loader.getPluginDashboardViews();
|
const views = await loader.getPluginDashboardViews();
|
||||||
expect(views).toHaveLength(2);
|
expect(views).toHaveLength(2);
|
||||||
expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([
|
expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([
|
||||||
"views-a:graph",
|
"views-a:graph",
|
||||||
@@ -2040,7 +2040,7 @@ export default plugin;
|
|||||||
],
|
],
|
||||||
} as FusionPlugin);
|
} as FusionPlugin);
|
||||||
|
|
||||||
expect(loader.getPluginDashboardViews()).toEqual([
|
await expect(loader.getPluginDashboardViews()).resolves.toEqual([
|
||||||
{
|
{
|
||||||
pluginId: "views-shape",
|
pluginId: "views-shape",
|
||||||
view: {
|
view: {
|
||||||
@@ -2055,6 +2055,112 @@ export default plugin;
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serves current on-disk manifest dashboard-view metadata when the loaded module is stale", async () => {
|
||||||
|
await pluginStore.init();
|
||||||
|
const pluginDir = join(rootDir, "generic-nav-plugin");
|
||||||
|
await mkdir(pluginDir, { recursive: true });
|
||||||
|
const entryPath = join(pluginDir, "bundled.js");
|
||||||
|
await writeFile(entryPath, "export default {};\n");
|
||||||
|
const currentDashboardViews = [
|
||||||
|
{
|
||||||
|
viewId: "overview",
|
||||||
|
label: "Current Overview",
|
||||||
|
componentPath: "./dashboard/overview.js",
|
||||||
|
icon: "Boxes",
|
||||||
|
placement: "primary" as const,
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
viewId: "details",
|
||||||
|
label: "Current Details",
|
||||||
|
componentPath: "./dashboard/details.js",
|
||||||
|
icon: "Network",
|
||||||
|
placement: "more" as const,
|
||||||
|
description: "Fresh manifest metadata",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const currentManifest = {
|
||||||
|
...makeManifest({ id: "generic-nav-plugin", name: "Generic Nav Plugin" }),
|
||||||
|
dashboardViews: currentDashboardViews,
|
||||||
|
};
|
||||||
|
await writeFile(join(pluginDir, "manifest.json"), JSON.stringify(currentManifest));
|
||||||
|
await pluginStore.registerPlugin({ manifest: currentManifest as PluginManifest, path: entryPath });
|
||||||
|
|
||||||
|
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||||
|
(loader as any).plugins.set("generic-nav-plugin", {
|
||||||
|
manifest: makeManifest({ id: "generic-nav-plugin", name: "Generic Nav Plugin" }),
|
||||||
|
state: "started",
|
||||||
|
hooks: {},
|
||||||
|
dashboardViews: [
|
||||||
|
{
|
||||||
|
viewId: "overview",
|
||||||
|
label: "Stale Overview",
|
||||||
|
componentPath: "./dashboard/overview.js",
|
||||||
|
icon: "Sparkles",
|
||||||
|
placement: "overflow",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as FusionPlugin);
|
||||||
|
|
||||||
|
await expect(loader.getPluginDashboardViews()).resolves.toEqual([
|
||||||
|
{ pluginId: "generic-nav-plugin", view: currentDashboardViews[0] },
|
||||||
|
{ pluginId: "generic-nav-plugin", view: currentDashboardViews[1] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an empty on-disk dashboardViews array as current metadata", async () => {
|
||||||
|
await pluginStore.init();
|
||||||
|
const pluginDir = join(rootDir, "generic-empty-plugin");
|
||||||
|
await mkdir(pluginDir, { recursive: true });
|
||||||
|
const entryPath = join(pluginDir, "dist", "index.js");
|
||||||
|
await mkdir(join(pluginDir, "dist"), { recursive: true });
|
||||||
|
await writeFile(entryPath, "export default {};\n");
|
||||||
|
const currentManifest = {
|
||||||
|
...makeManifest({ id: "generic-empty-plugin", name: "Generic Empty Plugin" }),
|
||||||
|
dashboardViews: [],
|
||||||
|
};
|
||||||
|
await writeFile(join(pluginDir, "manifest.json"), JSON.stringify(currentManifest));
|
||||||
|
await pluginStore.registerPlugin({ manifest: currentManifest as PluginManifest, path: entryPath });
|
||||||
|
|
||||||
|
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||||
|
(loader as any).plugins.set("generic-empty-plugin", {
|
||||||
|
manifest: makeManifest({ id: "generic-empty-plugin", name: "Generic Empty Plugin" }),
|
||||||
|
state: "started",
|
||||||
|
hooks: {},
|
||||||
|
dashboardViews: [{ viewId: "old", label: "Old", componentPath: "./old.js", icon: "Sparkles" }],
|
||||||
|
} as FusionPlugin);
|
||||||
|
|
||||||
|
await expect(loader.getPluginDashboardViews()).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a valid on-disk manifest without dashboardViews as no current nav entries", async () => {
|
||||||
|
await pluginStore.init();
|
||||||
|
const pluginDir = join(rootDir, "generic-removed-views-plugin");
|
||||||
|
await mkdir(pluginDir, { recursive: true });
|
||||||
|
const entryPath = join(pluginDir, "dist", "index.js");
|
||||||
|
await mkdir(join(pluginDir, "dist"), { recursive: true });
|
||||||
|
await writeFile(entryPath, "export default {};\n");
|
||||||
|
const currentManifest = makeManifest({
|
||||||
|
id: "generic-removed-views-plugin",
|
||||||
|
name: "Generic Removed Views Plugin",
|
||||||
|
});
|
||||||
|
await writeFile(join(pluginDir, "manifest.json"), JSON.stringify(currentManifest));
|
||||||
|
await pluginStore.registerPlugin({ manifest: currentManifest as PluginManifest, path: entryPath });
|
||||||
|
|
||||||
|
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||||
|
(loader as any).plugins.set("generic-removed-views-plugin", {
|
||||||
|
manifest: {
|
||||||
|
...makeManifest({ id: "generic-removed-views-plugin", name: "Generic Removed Views Plugin" }),
|
||||||
|
dashboardViews: [{ viewId: "old", label: "Old", componentPath: "./old.js", icon: "Sparkles" }],
|
||||||
|
},
|
||||||
|
state: "started",
|
||||||
|
hooks: {},
|
||||||
|
dashboardViews: [{ viewId: "old", label: "Old", componentPath: "./old.js", icon: "Sparkles" }],
|
||||||
|
} as FusionPlugin);
|
||||||
|
|
||||||
|
await expect(loader.getPluginDashboardViews()).resolves.toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getPluginSchemaInitHooks", () => {
|
describe("getPluginSchemaInitHooks", () => {
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
|
|
||||||
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
||||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||||
import { stat } from "node:fs/promises";
|
import { copyFile, readFile, stat } from "node:fs/promises";
|
||||||
import { copyFile } from "node:fs/promises";
|
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { TaskStore } from "./store.js";
|
import type { TaskStore } from "./store.js";
|
||||||
@@ -49,6 +48,10 @@ import { scanPluginSecurity } from "./plugin-security-scan.js";
|
|||||||
// Minimum Fusion version for plugin compatibility checks (can be expanded later)
|
// Minimum Fusion version for plugin compatibility checks (can be expanded later)
|
||||||
const MINIMUM_FUSION_VERSION = "0.1.0";
|
const MINIMUM_FUSION_VERSION = "0.1.0";
|
||||||
let moduleImportVersion = 0;
|
let moduleImportVersion = 0;
|
||||||
|
const PLUGIN_MANIFEST_PARENT_DIR_NAMES = new Set(["dist", "build", "lib", "src"]);
|
||||||
|
type CurrentManifestDashboardViewsResult =
|
||||||
|
| { found: true; dashboardViews: PluginDashboardViewDefinition[] }
|
||||||
|
| { found: false };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
|
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
|
||||||
@@ -1074,14 +1077,95 @@ export class PluginLoader extends EventEmitter<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveCurrentManifestPath(pluginEntryPath: string): Promise<string | null> {
|
||||||
|
const candidates = new Set<string>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entryStats = await stat(pluginEntryPath);
|
||||||
|
if (entryStats.isDirectory()) {
|
||||||
|
candidates.add(join(pluginEntryPath, "manifest.json"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The entry file can be temporarily absent during rebuilds; still try the
|
||||||
|
// package-root candidates derived from the persisted loadable path.
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryDir = dirname(pluginEntryPath);
|
||||||
|
candidates.add(join(entryDir, "manifest.json"));
|
||||||
|
|
||||||
|
if (PLUGIN_MANIFEST_PARENT_DIR_NAMES.has(basename(entryDir))) {
|
||||||
|
candidates.add(join(dirname(entryDir), "manifest.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const candidateStats = await stat(candidate);
|
||||||
|
if (candidateStats.isFile()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next candidate so unusual installs keep falling back safely.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCurrentManifestDashboardViews(pluginId: string): Promise<CurrentManifestDashboardViewsResult> {
|
||||||
|
let installation: PluginInstallation;
|
||||||
|
try {
|
||||||
|
installation = await this.options.pluginStore.getPlugin(pluginId);
|
||||||
|
} catch (err) {
|
||||||
|
this.log.warn(`Could not refresh dashboard views for ${pluginId}:`, err);
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginPath = this.resolvePluginPath(installation.path);
|
||||||
|
const manifestPath = await this.resolveCurrentManifestPath(pluginPath);
|
||||||
|
if (!manifestPath) {
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: unknown;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||||
|
} catch (err) {
|
||||||
|
this.log.warn(`Could not read dashboard-view manifest metadata for ${pluginId}:`, err);
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validatePluginManifest(manifest);
|
||||||
|
if (!validation.valid) {
|
||||||
|
this.log.warn(`Could not refresh dashboard views for ${pluginId}: ${validation.errors.join(", ")}`);
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardViews = (manifest as { dashboardViews?: unknown }).dashboardViews;
|
||||||
|
if (dashboardViews === undefined) {
|
||||||
|
return { found: true, dashboardViews: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { found: true, dashboardViews: dashboardViews as PluginDashboardViewDefinition[] };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all top-level dashboard view definitions from loaded plugins.
|
* Get all top-level dashboard view definitions from loaded plugins.
|
||||||
|
*
|
||||||
|
* FNXC:Plugins 2026-06-28-12:30:
|
||||||
|
* Navigation metadata must come from the current on-disk manifest when present because dashboard component bundles can update immediately after a rebuild while the loaded plugin module instance remains cached. Reading manifest dashboardViews here keeps desktop and mobile nav icon/label/placement in sync with the served in-view bundle for every plugin, without per-plugin pins.
|
||||||
|
*
|
||||||
|
* FNXC:Plugins 2026-06-28-19:58:
|
||||||
|
* A valid manifest that omits dashboardViews is authoritative and means the rebuilt plugin now exposes no top-level views. Do not fall back to stale module dashboardViews after a successful manifest read; fallback is only for missing/unreadable/invalid manifests.
|
||||||
*/
|
*/
|
||||||
getPluginDashboardViews(): Array<{ pluginId: string; view: PluginDashboardViewDefinition }> {
|
async getPluginDashboardViews(): Promise<Array<{ pluginId: string; view: PluginDashboardViewDefinition }>> {
|
||||||
const views: Array<{ pluginId: string; view: PluginDashboardViewDefinition }> = [];
|
const views: Array<{ pluginId: string; view: PluginDashboardViewDefinition }> = [];
|
||||||
for (const [pluginId, plugin] of this.plugins) {
|
for (const [pluginId, plugin] of this.plugins) {
|
||||||
if (plugin.dashboardViews) {
|
const currentManifestDashboardViews = await this.getCurrentManifestDashboardViews(pluginId);
|
||||||
for (const view of plugin.dashboardViews) {
|
const dashboardViews = currentManifestDashboardViews.found
|
||||||
|
? currentManifestDashboardViews.dashboardViews
|
||||||
|
: plugin.dashboardViews;
|
||||||
|
if (dashboardViews) {
|
||||||
|
for (const view of dashboardViews) {
|
||||||
views.push({ pluginId, view });
|
views.push({ pluginId, view });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
|
|||||||
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
||||||
getPluginUiContributions: vi.fn().mockReturnValue([]),
|
getPluginUiContributions: vi.fn().mockReturnValue([]),
|
||||||
getPluginRuntimes: vi.fn().mockReturnValue([]),
|
getPluginRuntimes: vi.fn().mockReturnValue([]),
|
||||||
getPluginDashboardViews: vi.fn().mockReturnValue([]),
|
getPluginDashboardViews: vi.fn().mockResolvedValue([]),
|
||||||
createRouteContext: vi.fn(async (pluginId: string, overrides?: { taskStore?: TaskStore; settings?: Record<string, unknown>; resolveProjectTaskStore?: (projectId: string) => Promise<TaskStore> }) => ({
|
createRouteContext: vi.fn(async (pluginId: string, overrides?: { taskStore?: TaskStore; settings?: Record<string, unknown>; resolveProjectTaskStore?: (projectId: string) => Promise<TaskStore> }) => ({
|
||||||
pluginId,
|
pluginId,
|
||||||
taskStore: overrides?.taskStore ?? createMockTaskStore(),
|
taskStore: overrides?.taskStore ?? createMockTaskStore(),
|
||||||
@@ -1243,7 +1243,7 @@ describe("GET /api/plugins/dashboard-views", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 200 with empty array when no plugins have dashboard views", async () => {
|
it("returns 200 with empty array when no plugins have dashboard views", async () => {
|
||||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
|
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toEqual([]);
|
expect(res.body).toEqual([]);
|
||||||
@@ -1264,7 +1264,7 @@ describe("GET /api/plugins/dashboard-views", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue(mockViews);
|
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue(mockViews);
|
||||||
|
|
||||||
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
|
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
|
||||||
|
|
||||||
@@ -1272,8 +1272,40 @@ describe("GET /api/plugins/dashboard-views", () => {
|
|||||||
expect(res.body).toEqual(mockViews);
|
expect(res.body).toEqual(mockViews);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("awaits refreshed loader dashboard-view metadata before responding", async () => {
|
||||||
|
const refreshedViews = [
|
||||||
|
{
|
||||||
|
pluginId: "generic-nav-plugin",
|
||||||
|
view: {
|
||||||
|
viewId: "overview",
|
||||||
|
label: "Current Overview",
|
||||||
|
componentPath: "./dashboard/overview.js",
|
||||||
|
icon: "Boxes",
|
||||||
|
placement: "primary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "generic-reports-plugin",
|
||||||
|
view: {
|
||||||
|
viewId: "reports",
|
||||||
|
label: "Current Reports",
|
||||||
|
componentPath: "./dashboard/reports.js",
|
||||||
|
icon: "FileText",
|
||||||
|
placement: "more",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue(refreshedViews);
|
||||||
|
|
||||||
|
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(pluginLoader.getPluginDashboardViews).toHaveBeenCalledTimes(1);
|
||||||
|
expect(res.body).toEqual(refreshedViews);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns exactly pluginLoader dashboard-view entries (no synthesized plugin rows)", async () => {
|
it("returns exactly pluginLoader dashboard-view entries (no synthesized plugin rows)", async () => {
|
||||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([
|
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
{
|
{
|
||||||
pluginId: "with-view",
|
pluginId: "with-view",
|
||||||
view: {
|
view: {
|
||||||
@@ -1295,7 +1327,7 @@ describe("GET /api/plugins/dashboard-views", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps dashboard-views payload separate from ui-slots payload", async () => {
|
it("keeps dashboard-views payload separate from ui-slots payload", async () => {
|
||||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([
|
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
{
|
{
|
||||||
pluginId: "fusion-plugin-roadmap",
|
pluginId: "fusion-plugin-roadmap",
|
||||||
view: {
|
view: {
|
||||||
|
|||||||
@@ -3432,7 +3432,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
* Returns aggregated array of { pluginId, view } objects.
|
* Returns aggregated array of { pluginId, view } objects.
|
||||||
*/
|
*/
|
||||||
router.get("/plugins/dashboard-views", async (_req: Request, res: Response) => {
|
router.get("/plugins/dashboard-views", async (_req: Request, res: Response) => {
|
||||||
const views = options?.pluginLoader?.getPluginDashboardViews() ?? [];
|
const views = await options?.pluginLoader?.getPluginDashboardViews() ?? [];
|
||||||
res.json(views);
|
res.json(views);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user