feat(FN-1914): merge fusion/fn-1914
This commit is contained in:
@@ -107,6 +107,7 @@ export type {
|
||||
PluginToolResult,
|
||||
PluginRouteDefinition,
|
||||
PluginRouteMethod,
|
||||
PluginUiSlotDefinition,
|
||||
PluginContext,
|
||||
PluginLogger,
|
||||
FusionPlugin,
|
||||
|
||||
@@ -813,6 +813,182 @@ describe("PluginLoader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── getPluginUiSlots ───────────────────────────────────────────────
|
||||
|
||||
describe("getPluginUiSlots", () => {
|
||||
it("returns empty array when no plugins loaded", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
const slots = loader.getPluginUiSlots();
|
||||
expect(slots).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when plugins have no uiSlots", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugin without uiSlots
|
||||
(loader as any).plugins.set("no-ui-slots", {
|
||||
manifest: makeManifest({ id: "no-ui-slots" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
} as FusionPlugin);
|
||||
|
||||
const slots = loader.getPluginUiSlots();
|
||||
expect(slots).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns aggregated slots from single plugin", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugin with uiSlots
|
||||
(loader as any).plugins.set("slots-a", {
|
||||
manifest: makeManifest({ id: "slots-a" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "task-detail-tab",
|
||||
label: "Task Details",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
|
||||
const slots = loader.getPluginUiSlots();
|
||||
|
||||
expect(slots).toHaveLength(1);
|
||||
expect(slots[0].pluginId).toBe("slots-a");
|
||||
expect(slots[0].slot.slotId).toBe("task-detail-tab");
|
||||
expect(slots[0].slot.label).toBe("Task Details");
|
||||
expect(slots[0].slot.componentPath).toBe("./components/TaskDetailTab.js");
|
||||
});
|
||||
|
||||
it("returns aggregated slots from multiple plugins", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugins with uiSlots
|
||||
(loader as any).plugins.set("slots-a", {
|
||||
manifest: makeManifest({ id: "slots-a" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "task-detail-tab",
|
||||
label: "Task Details",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("slots-b", {
|
||||
manifest: makeManifest({ id: "slots-b" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "header-action",
|
||||
label: "Header Action",
|
||||
icon: "Plus",
|
||||
componentPath: "./components/HeaderAction.js",
|
||||
},
|
||||
{
|
||||
slotId: "settings-section",
|
||||
label: "Settings",
|
||||
componentPath: "./components/SettingsSection.js",
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
|
||||
const slots = loader.getPluginUiSlots();
|
||||
|
||||
expect(slots).toHaveLength(3);
|
||||
expect(slots.find((s) => s.pluginId === "slots-a")?.slot.slotId).toBe(
|
||||
"task-detail-tab",
|
||||
);
|
||||
expect(slots.find((s) => s.pluginId === "slots-b")?.slot.slotId).toBe(
|
||||
"header-action",
|
||||
);
|
||||
expect(slots.filter((s) => s.pluginId === "slots-b")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("each slot includes correct pluginId", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugins with overlapping slotIds (different plugins)
|
||||
(loader as any).plugins.set("plugin-x", {
|
||||
manifest: makeManifest({ id: "plugin-x" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "custom-tab",
|
||||
label: "Custom Tab",
|
||||
componentPath: "./components/CustomTab.js",
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("plugin-y", {
|
||||
manifest: makeManifest({ id: "plugin-y" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "custom-tab",
|
||||
label: "Custom Tab Y",
|
||||
componentPath: "./components/CustomTabY.js",
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
|
||||
const slots = loader.getPluginUiSlots();
|
||||
|
||||
// Both plugins can have slots with the same slotId
|
||||
const pluginXSlot = slots.find((s) => s.pluginId === "plugin-x");
|
||||
const pluginYSlot = slots.find((s) => s.pluginId === "plugin-y");
|
||||
|
||||
expect(pluginXSlot?.slot.slotId).toBe("custom-tab");
|
||||
expect(pluginXSlot?.slot.label).toBe("Custom Tab");
|
||||
expect(pluginYSlot?.slot.slotId).toBe("custom-tab");
|
||||
expect(pluginYSlot?.slot.label).toBe("Custom Tab Y");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLoadedPlugins ───────────────────────────────────────────────
|
||||
|
||||
describe("getLoadedPlugins", () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
PluginLogger,
|
||||
PluginToolDefinition,
|
||||
PluginRouteDefinition,
|
||||
PluginUiSlotDefinition,
|
||||
PluginState,
|
||||
PluginInstallation,
|
||||
} from "./plugin-types.js";
|
||||
@@ -729,6 +730,21 @@ export class PluginLoader extends EventEmitter<{
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all UI slot definitions from loaded plugins.
|
||||
*/
|
||||
getPluginUiSlots(): Array<{ pluginId: string; slot: PluginUiSlotDefinition }> {
|
||||
const slots: Array<{ pluginId: string; slot: PluginUiSlotDefinition }> = [];
|
||||
for (const [pluginId, plugin] of this.plugins) {
|
||||
if (plugin.uiSlots) {
|
||||
for (const slot of plugin.uiSlots) {
|
||||
slots.push({ pluginId, slot });
|
||||
}
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded plugin instances.
|
||||
*/
|
||||
|
||||
@@ -463,3 +463,106 @@ describe("validatePluginManifest", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── PluginUiSlotDefinition ─────────────────────────────────────────────
|
||||
|
||||
describe("PluginUiSlotDefinition", () => {
|
||||
it("accepts a valid PluginUiSlotDefinition with all fields", () => {
|
||||
const slot = {
|
||||
slotId: "task-detail-tab",
|
||||
label: "Task Details",
|
||||
icon: "FileText",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
};
|
||||
|
||||
expect(slot.slotId).toBe("task-detail-tab");
|
||||
expect(slot.label).toBe("Task Details");
|
||||
expect(slot.icon).toBe("FileText");
|
||||
expect(slot.componentPath).toBe("./components/TaskDetailTab.js");
|
||||
});
|
||||
|
||||
it("accepts a valid PluginUiSlotDefinition without optional icon field", () => {
|
||||
const slot = {
|
||||
slotId: "header-action",
|
||||
label: "Header Action",
|
||||
componentPath: "./components/HeaderAction.js",
|
||||
};
|
||||
|
||||
expect(slot.slotId).toBe("header-action");
|
||||
expect(slot.label).toBe("Header Action");
|
||||
expect(slot.componentPath).toBe("./components/HeaderAction.js");
|
||||
// icon is optional, so it should be undefined
|
||||
expect((slot as any).icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires slotId field", () => {
|
||||
const slot = {
|
||||
label: "Some Label",
|
||||
componentPath: "./components/Test.js",
|
||||
};
|
||||
|
||||
// TypeScript would catch this at compile time, but at runtime we verify the structure
|
||||
expect((slot as any).slotId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires label field", () => {
|
||||
const slot = {
|
||||
slotId: "some-slot",
|
||||
componentPath: "./components/Test.js",
|
||||
};
|
||||
|
||||
expect((slot as any).label).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires componentPath field", () => {
|
||||
const slot = {
|
||||
slotId: "some-slot",
|
||||
label: "Some Label",
|
||||
};
|
||||
|
||||
expect((slot as any).componentPath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── FusionPlugin with uiSlots ──────────────────────────────────────────
|
||||
|
||||
describe("FusionPlugin with uiSlots", () => {
|
||||
it("accepts a FusionPlugin with uiSlots array", () => {
|
||||
const plugin = {
|
||||
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
|
||||
state: "started" as const,
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "task-detail-tab",
|
||||
label: "Task Details",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
},
|
||||
{
|
||||
slotId: "header-action",
|
||||
label: "Header Action",
|
||||
icon: "Plus",
|
||||
componentPath: "./components/HeaderAction.js",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(plugin.uiSlots).toHaveLength(2);
|
||||
expect(plugin.uiSlots![0].slotId).toBe("task-detail-tab");
|
||||
expect(plugin.uiSlots![1].icon).toBe("Plus");
|
||||
});
|
||||
|
||||
it("accepts a FusionPlugin without uiSlots field", () => {
|
||||
const plugin = {
|
||||
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
|
||||
state: "started" as const,
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
};
|
||||
|
||||
expect((plugin as any).uiSlots).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,6 +142,28 @@ export interface PluginRouteDefinition {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Plugin UI Slots ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* UI slot definition for plugin-provided dashboard components.
|
||||
* Each slot represents a mount point where a plugin can render UI.
|
||||
*/
|
||||
export interface PluginUiSlotDefinition {
|
||||
/** Unique slot identifier (e.g., "task-detail-tab", "header-action", "settings-section") */
|
||||
slotId: string;
|
||||
/** Human-readable label for the UI slot */
|
||||
label: string;
|
||||
/** Optional icon name (lucide-react icon name or custom icon identifier) */
|
||||
icon?: string;
|
||||
/**
|
||||
* Path to the JS module that exports the component.
|
||||
* This should be a web component or a function component descriptor
|
||||
* that the dashboard can render in the slot.
|
||||
* Path is relative to the plugin's root directory.
|
||||
*/
|
||||
componentPath: string;
|
||||
}
|
||||
|
||||
// ── Fusion Plugin ────────────────────────────────────────────────────
|
||||
|
||||
export type PluginState = "installed" | "started" | "stopped" | "error";
|
||||
@@ -162,6 +184,7 @@ export interface FusionPlugin {
|
||||
};
|
||||
tools?: PluginToolDefinition[];
|
||||
routes?: PluginRouteDefinition[];
|
||||
uiSlots?: PluginUiSlotDefinition[];
|
||||
}
|
||||
|
||||
// ── Plugin Installation ───────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user