feat(FN-3066): extend plugin UI slot metadata and fix settings token fallba
This release (v0.15.0) introduces plugin dashboard views and a new dependency graph plugin, extends the plugin slot system with richer metadata surfaces and UX improvements, adds SQLite WAL tuning with integrity checks, and stabilizes agent run log streaming with enriched session labels. It also fix Fusion-Task-Id: FN-3066
This commit is contained in:
@@ -1177,6 +1177,7 @@ describe("PluginLoader", () => {
|
|||||||
expect(slots).toHaveLength(1);
|
expect(slots).toHaveLength(1);
|
||||||
expect(slots[0].pluginId).toBe("slots-a");
|
expect(slots[0].pluginId).toBe("slots-a");
|
||||||
expect(slots[0].slot.slotId).toBe("task-detail-tab");
|
expect(slots[0].slot.slotId).toBe("task-detail-tab");
|
||||||
|
expect(slots[0].slot.surface).toBe("task-detail-tab");
|
||||||
expect(slots[0].slot.label).toBe("Task Details");
|
expect(slots[0].slot.label).toBe("Task Details");
|
||||||
expect(slots[0].slot.componentPath).toBe("./components/TaskDetailTab.js");
|
expect(slots[0].slot.componentPath).toBe("./components/TaskDetailTab.js");
|
||||||
});
|
});
|
||||||
@@ -1235,6 +1236,52 @@ describe("PluginLoader", () => {
|
|||||||
"header-action",
|
"header-action",
|
||||||
);
|
);
|
||||||
expect(slots.filter((s) => s.pluginId === "slots-b")).toHaveLength(2);
|
expect(slots.filter((s) => s.pluginId === "slots-b")).toHaveLength(2);
|
||||||
|
expect(slots.map((slot) => slot.pluginId)).toEqual(["slots-a", "slots-b", "slots-b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts slots by order and then pluginId/slotId", async () => {
|
||||||
|
await pluginStore.init();
|
||||||
|
|
||||||
|
const loader = new PluginLoader({
|
||||||
|
pluginStore,
|
||||||
|
taskStore: mockTaskStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
(loader as any).plugins.set("plugin-b", {
|
||||||
|
manifest: makeManifest({ id: "plugin-b" }),
|
||||||
|
state: "started",
|
||||||
|
hooks: {},
|
||||||
|
uiSlots: [
|
||||||
|
{
|
||||||
|
slotId: "onboarding-provider-card",
|
||||||
|
label: "B",
|
||||||
|
componentPath: "./B.js",
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as FusionPlugin);
|
||||||
|
|
||||||
|
(loader as any).plugins.set("plugin-a", {
|
||||||
|
manifest: makeManifest({ id: "plugin-a" }),
|
||||||
|
state: "started",
|
||||||
|
hooks: {},
|
||||||
|
uiSlots: [
|
||||||
|
{
|
||||||
|
slotId: "onboarding-provider-card",
|
||||||
|
label: "A-first",
|
||||||
|
componentPath: "./A.js",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slotId: "settings-section",
|
||||||
|
label: "A-second",
|
||||||
|
componentPath: "./A2.js",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as FusionPlugin);
|
||||||
|
|
||||||
|
const slots = loader.getPluginUiSlots();
|
||||||
|
expect(slots.map((slot) => slot.slot.label)).toEqual(["A-first", "B", "A-second"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("each slot includes correct pluginId", async () => {
|
it("each slot includes correct pluginId", async () => {
|
||||||
|
|||||||
@@ -708,12 +708,30 @@ describe("PluginUiSlotDefinition", () => {
|
|||||||
label: "Task Details",
|
label: "Task Details",
|
||||||
icon: "FileText",
|
icon: "FileText",
|
||||||
componentPath: "./components/TaskDetailTab.js",
|
componentPath: "./components/TaskDetailTab.js",
|
||||||
|
surface: "task-detail-tab",
|
||||||
|
order: 5,
|
||||||
|
placement: "after-default",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(slot.slotId).toBe("task-detail-tab");
|
expect(slot.slotId).toBe("task-detail-tab");
|
||||||
expect(slot.label).toBe("Task Details");
|
expect(slot.label).toBe("Task Details");
|
||||||
expect(slot.icon).toBe("FileText");
|
expect(slot.icon).toBe("FileText");
|
||||||
expect(slot.componentPath).toBe("./components/TaskDetailTab.js");
|
expect(slot.componentPath).toBe("./components/TaskDetailTab.js");
|
||||||
|
expect(slot.surface).toBe("task-detail-tab");
|
||||||
|
expect(slot.order).toBe(5);
|
||||||
|
expect(slot.placement).toBe("after-default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts new host-owned onboarding/settings surfaces", () => {
|
||||||
|
const slot = {
|
||||||
|
slotId: "onboarding-setup-help",
|
||||||
|
label: "Setup help",
|
||||||
|
componentPath: "./components/SetupHelp.js",
|
||||||
|
surface: "onboarding-setup-help",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(slot.slotId).toBe("onboarding-setup-help");
|
||||||
|
expect(slot.surface).toBe("onboarding-setup-help");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a valid PluginUiSlotDefinition without optional icon field", () => {
|
it("accepts a valid PluginUiSlotDefinition without optional icon field", () => {
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export type {
|
|||||||
PluginToolResult,
|
PluginToolResult,
|
||||||
PluginRouteDefinition,
|
PluginRouteDefinition,
|
||||||
PluginRouteMethod,
|
PluginRouteMethod,
|
||||||
|
PluginUiSurface,
|
||||||
PluginUiSlotDefinition,
|
PluginUiSlotDefinition,
|
||||||
PluginDashboardViewDefinition,
|
PluginDashboardViewDefinition,
|
||||||
PluginRuntimeManifestMetadata,
|
PluginRuntimeManifestMetadata,
|
||||||
|
|||||||
@@ -769,11 +769,24 @@ export class PluginLoader extends EventEmitter<{
|
|||||||
for (const [pluginId, plugin] of this.plugins) {
|
for (const [pluginId, plugin] of this.plugins) {
|
||||||
if (plugin.uiSlots) {
|
if (plugin.uiSlots) {
|
||||||
for (const slot of plugin.uiSlots) {
|
for (const slot of plugin.uiSlots) {
|
||||||
slots.push({ pluginId, slot });
|
slots.push({
|
||||||
|
pluginId,
|
||||||
|
slot: {
|
||||||
|
...slot,
|
||||||
|
surface: slot.surface ?? (typeof slot.slotId === "string" ? slot.slotId as PluginUiSlotDefinition["surface"] : undefined),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return slots;
|
|
||||||
|
return slots.sort((a, b) => {
|
||||||
|
const orderA = a.slot.order ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const orderB = b.slot.order ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
if (a.pluginId !== b.pluginId) return a.pluginId.localeCompare(b.pluginId);
|
||||||
|
return String(a.slot.slotId).localeCompare(String(b.slot.slotId));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -159,24 +159,48 @@ export interface PluginRouteDefinition {
|
|||||||
|
|
||||||
// ── Plugin UI Slots ─────────────────────────────────────────────────
|
// ── Plugin UI Slots ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-defined dashboard UI surfaces that plugins can contribute to.
|
||||||
|
* Existing generic surfaces remain supported for backward compatibility.
|
||||||
|
*/
|
||||||
|
export type PluginUiSurface =
|
||||||
|
| "header-action"
|
||||||
|
| "task-detail-tab"
|
||||||
|
| "task-card-badge"
|
||||||
|
| "board-column-footer"
|
||||||
|
| "settings-section"
|
||||||
|
| "settings-provider-card"
|
||||||
|
| "settings-integration-card"
|
||||||
|
| "onboarding-provider-card"
|
||||||
|
| "onboarding-recommendation-card"
|
||||||
|
| "onboarding-setup-help"
|
||||||
|
| "post-onboarding-recommendation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI slot definition for plugin-provided dashboard components.
|
* UI slot definition for plugin-provided dashboard components.
|
||||||
* Each slot represents a mount point where a plugin can render UI.
|
* Each slot represents a host-owned mount point where a plugin can render UI.
|
||||||
*/
|
*/
|
||||||
export interface PluginUiSlotDefinition {
|
export interface PluginUiSlotDefinition {
|
||||||
/** Unique slot identifier (e.g., "task-detail-tab", "header-action", "settings-section") */
|
/**
|
||||||
slotId: string;
|
* Unique slot identifier. Should match one of the known host surfaces above,
|
||||||
|
* but string is retained for compatibility with legacy plugins.
|
||||||
|
*/
|
||||||
|
slotId: PluginUiSurface | string;
|
||||||
/** Human-readable label for the UI slot */
|
/** Human-readable label for the UI slot */
|
||||||
label: string;
|
label: string;
|
||||||
/** Optional icon name (lucide-react icon name or custom icon identifier) */
|
/** Optional icon name (lucide-react icon name or custom icon identifier) */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
/**
|
/**
|
||||||
* Path to the JS module that exports the component.
|
* 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.
|
* Path is relative to the plugin's root directory.
|
||||||
*/
|
*/
|
||||||
componentPath: string;
|
componentPath: string;
|
||||||
|
/** Optional explicit surface metadata (defaults to slotId). */
|
||||||
|
surface?: PluginUiSurface;
|
||||||
|
/** Optional deterministic render order; lower values render first. */
|
||||||
|
order?: number;
|
||||||
|
/** Optional host placement hint for the surface. */
|
||||||
|
placement?: "before-default" | "after-default" | "replace-default";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,20 +7,19 @@ interface PluginSlotProps {
|
|||||||
slotId: string;
|
slotId: string;
|
||||||
/** Optional project ID for multi-project slot scoping */
|
/** Optional project ID for multi-project slot scoping */
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
/** Optional plugin IDs to restrict rendering to a subset of matching entries */
|
||||||
|
pluginIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders placeholder divs for all plugin UI slots matching the given slotId.
|
* Renders plugin slot registrations for a host surface.
|
||||||
*
|
*
|
||||||
* This component is non-critical: it silently fails (returns null) for loading
|
* Dynamic plugin component loading is not yet available, so this renders a
|
||||||
* errors, or when no plugins are registered for the slot. Each rendered slot
|
* meaningful fallback shell with plugin metadata instead of empty placeholders.
|
||||||
* is wrapped in an ErrorBoundary to isolate plugin rendering failures from the
|
* Each rendered slot is wrapped in an ErrorBoundary to isolate failures from
|
||||||
* parent dashboard UI.
|
* the parent dashboard UI.
|
||||||
*
|
|
||||||
* Future iterations will replace placeholder divs with dynamically loaded
|
|
||||||
* components via the plugin's componentPath.
|
|
||||||
*/
|
*/
|
||||||
export function PluginSlot({ slotId, projectId }: PluginSlotProps): ReactNode {
|
export function PluginSlot({ slotId, projectId, pluginIds }: PluginSlotProps): ReactNode {
|
||||||
const { getSlotsForId, loading, error } = usePluginUiSlots(projectId);
|
const { getSlotsForId, loading, error } = usePluginUiSlots(projectId);
|
||||||
|
|
||||||
// Non-critical failure — no visible UI when loading, errored, or no matching slots
|
// Non-critical failure — no visible UI when loading, errored, or no matching slots
|
||||||
@@ -28,7 +27,9 @@ export function PluginSlot({ slotId, projectId }: PluginSlotProps): ReactNode {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchingEntries = getSlotsForId(slotId);
|
const matchingEntries = getSlotsForId(slotId).filter((entry) =>
|
||||||
|
pluginIds && pluginIds.length > 0 ? pluginIds.includes(entry.pluginId) : true,
|
||||||
|
);
|
||||||
|
|
||||||
if (matchingEntries.length === 0) {
|
if (matchingEntries.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -37,15 +38,29 @@ export function PluginSlot({ slotId, projectId }: PluginSlotProps): ReactNode {
|
|||||||
return (
|
return (
|
||||||
<ErrorBoundary level="page">
|
<ErrorBoundary level="page">
|
||||||
<>
|
<>
|
||||||
{matchingEntries.map((entry) => (
|
{matchingEntries.map((entry, index) => (
|
||||||
<div
|
<section
|
||||||
key={`${entry.pluginId}-${entry.slot.slotId}`}
|
key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}
|
||||||
|
className="card"
|
||||||
data-plugin-slot
|
data-plugin-slot
|
||||||
data-slot-id={entry.slot.slotId}
|
data-slot-id={entry.slot.slotId}
|
||||||
data-plugin-id={entry.pluginId}
|
data-plugin-id={entry.pluginId}
|
||||||
data-component-path={entry.slot.componentPath}
|
data-component-path={entry.slot.componentPath}
|
||||||
aria-label={entry.slot.label}
|
aria-label={entry.slot.label}
|
||||||
/>
|
>
|
||||||
|
<div className="card-header">
|
||||||
|
<span className="card-title">{entry.slot.label}</span>
|
||||||
|
<span className="card-id">{entry.pluginId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-meta">
|
||||||
|
<span className="detail-metadata-label">Surface</span>
|
||||||
|
<code className="detail-source-number">{entry.slot.slotId}</code>
|
||||||
|
</div>
|
||||||
|
<div className="detail-source-summary">
|
||||||
|
<span className="detail-source-label">Component</span>
|
||||||
|
<code className="detail-source-number">{entry.slot.componentPath}</code>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -407,7 +407,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-plugins-subsection-btn:hover {
|
.settings-plugins-subsection-btn:hover {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover, color-mix(in srgb, var(--text) 6%, transparent));
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -444,6 +444,14 @@ export function TaskDetailModal({
|
|||||||
// Plugin UI slots for task-detail-tab
|
// Plugin UI slots for task-detail-tab
|
||||||
const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId);
|
const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId);
|
||||||
const pluginTabSlots = getPluginSlots("task-detail-tab");
|
const pluginTabSlots = getPluginSlots("task-detail-tab");
|
||||||
|
const pluginTabs = pluginTabSlots.map((entry, index) => ({
|
||||||
|
entry,
|
||||||
|
tabId: `plugin-${entry.pluginId}-${index}` as TabId,
|
||||||
|
}));
|
||||||
|
const activePluginTab =
|
||||||
|
typeof activeTab === "string" && activeTab.startsWith("plugin-")
|
||||||
|
? pluginTabs.find((tab) => tab.tabId === activeTab) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
// Track mount state to avoid setting state on unmounted component
|
// Track mount state to avoid setting state on unmounted component
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1712,13 +1720,12 @@ export function TaskDetailModal({
|
|||||||
Routing
|
Routing
|
||||||
</button>
|
</button>
|
||||||
{/* Plugin tabs */}
|
{/* Plugin tabs */}
|
||||||
{pluginTabSlots.map((entry, index) => {
|
{pluginTabs.map(({ entry, tabId }) => {
|
||||||
const pluginTabId = `plugin-${index}` as TabId;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`plugin-tab-${entry.pluginId}`}
|
key={`plugin-tab-${entry.pluginId}-${tabId}`}
|
||||||
className={`detail-tab${activeTab === pluginTabId ? " detail-tab-active" : ""}`}
|
className={`detail-tab${activeTab === tabId ? " detail-tab-active" : ""}`}
|
||||||
onClick={() => setActiveTab(pluginTabId)}
|
onClick={() => setActiveTab(tabId)}
|
||||||
>
|
>
|
||||||
{entry.slot.label}
|
{entry.slot.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -1812,9 +1819,13 @@ export function TaskDetailModal({
|
|||||||
onTaskUpdated={onTaskUpdated}
|
onTaskUpdated={onTaskUpdated}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
/>
|
/>
|
||||||
) : typeof activeTab === "string" && activeTab.startsWith("plugin-") ? (
|
) : activePluginTab ? (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<PluginSlot slotId="task-detail-tab" projectId={projectId} />
|
<PluginSlot
|
||||||
|
slotId="task-detail-tab"
|
||||||
|
projectId={projectId}
|
||||||
|
pluginIds={[activePluginTab.entry.pluginId]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === "stats" ? (
|
) : activeTab === "stats" ? (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe("PluginSlot", () => {
|
|||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders placeholder div for single matching slot", () => {
|
it("renders fallback shell for single matching slot", () => {
|
||||||
const entry = createSlotEntry("task-detail-tab", "plugin-a");
|
const entry = createSlotEntry("task-detail-tab", "plugin-a");
|
||||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||||
slots: [entry],
|
slots: [entry],
|
||||||
@@ -49,16 +49,18 @@ describe("PluginSlot", () => {
|
|||||||
|
|
||||||
const { container } = render(<PluginSlot slotId="task-detail-tab" />);
|
const { container } = render(<PluginSlot slotId="task-detail-tab" />);
|
||||||
|
|
||||||
const divs = container.querySelectorAll("[data-plugin-slot]");
|
const shells = container.querySelectorAll("[data-plugin-slot]");
|
||||||
expect(divs).toHaveLength(1);
|
expect(shells).toHaveLength(1);
|
||||||
const div = divs[0];
|
const shell = shells[0];
|
||||||
expect(div).toHaveAttribute("data-slot-id", "task-detail-tab");
|
expect(shell).toHaveAttribute("data-slot-id", "task-detail-tab");
|
||||||
expect(div).toHaveAttribute("data-plugin-id", "plugin-a");
|
expect(shell).toHaveAttribute("data-plugin-id", "plugin-a");
|
||||||
expect(div).toHaveAttribute("data-component-path", "./components/task-detail-tab.js");
|
expect(shell).toHaveAttribute("data-component-path", "./components/task-detail-tab.js");
|
||||||
expect(div).toHaveAttribute("aria-label", "Test slot task-detail-tab");
|
expect(shell).toHaveAttribute("aria-label", "Test slot task-detail-tab");
|
||||||
|
expect(shell.textContent).toContain("Test slot task-detail-tab");
|
||||||
|
expect(shell.textContent).toContain("plugin-a");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders multiple placeholders for multiple plugins registered for same slotId", () => {
|
it("renders multiple fallback shells for multiple plugins registered for same slotId", () => {
|
||||||
const entryA = createSlotEntry("board-column-footer", "plugin-x");
|
const entryA = createSlotEntry("board-column-footer", "plugin-x");
|
||||||
const entryB = createSlotEntry("board-column-footer", "plugin-y");
|
const entryB = createSlotEntry("board-column-footer", "plugin-y");
|
||||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||||
@@ -70,14 +72,14 @@ describe("PluginSlot", () => {
|
|||||||
|
|
||||||
const { container } = render(<PluginSlot slotId="board-column-footer" />);
|
const { container } = render(<PluginSlot slotId="board-column-footer" />);
|
||||||
|
|
||||||
const divs = container.querySelectorAll("[data-plugin-slot]");
|
const shells = container.querySelectorAll("[data-plugin-slot]");
|
||||||
expect(divs).toHaveLength(2);
|
expect(shells).toHaveLength(2);
|
||||||
|
|
||||||
// Verify both divs have correct attributes
|
// Verify both shells have correct attributes
|
||||||
expect(divs[0]).toHaveAttribute("data-plugin-id", "plugin-x");
|
expect(shells[0]).toHaveAttribute("data-plugin-id", "plugin-x");
|
||||||
expect(divs[0]).toHaveAttribute("data-slot-id", "board-column-footer");
|
expect(shells[0]).toHaveAttribute("data-slot-id", "board-column-footer");
|
||||||
expect(divs[1]).toHaveAttribute("data-plugin-id", "plugin-y");
|
expect(shells[1]).toHaveAttribute("data-plugin-id", "plugin-y");
|
||||||
expect(divs[1]).toHaveAttribute("data-slot-id", "board-column-footer");
|
expect(shells[1]).toHaveAttribute("data-slot-id", "board-column-footer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when loading", () => {
|
it("returns null when loading", () => {
|
||||||
@@ -132,7 +134,20 @@ describe("PluginSlot", () => {
|
|||||||
expect(getSlotsForId).not.toHaveBeenCalled();
|
expect(getSlotsForId).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: Error boundary testing should be added when dynamic component loading
|
it("filters rendered slots by pluginIds when provided", () => {
|
||||||
// is implemented. The ErrorBoundary wraps the rendered divs and catches any
|
const entryA = createSlotEntry("task-detail-tab", "plugin-a");
|
||||||
// rendering errors from future plugin components.
|
const entryB = createSlotEntry("task-detail-tab", "plugin-b");
|
||||||
|
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||||
|
slots: [entryA, entryB],
|
||||||
|
getSlotsForId: vi.fn(() => [entryA, entryB]),
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<PluginSlot slotId="task-detail-tab" pluginIds={["plugin-b"]} />);
|
||||||
|
|
||||||
|
const shells = container.querySelectorAll("[data-plugin-slot]");
|
||||||
|
expect(shells).toHaveLength(1);
|
||||||
|
expect(shells[0]).toHaveAttribute("data-plugin-id", "plugin-b");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6191,13 +6191,15 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(screen.getByText("Plugin B Tab")).toBeDefined();
|
expect(screen.getByText("Plugin B Tab")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows plugin tab content when plugin tab is clicked", async () => {
|
it("shows only the selected plugin tab content when plugin tab is clicked", async () => {
|
||||||
mockUsePluginUiSlots.mockReturnValue({
|
mockUsePluginUiSlots.mockReturnValue({
|
||||||
slots: [
|
slots: [
|
||||||
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
|
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
|
||||||
|
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
|
||||||
],
|
],
|
||||||
getSlotsForId: (id: string) => id === "task-detail-tab" ? [
|
getSlotsForId: (id: string) => id === "task-detail-tab" ? [
|
||||||
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
|
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
|
||||||
|
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
|
||||||
] : [],
|
] : [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -6215,13 +6217,12 @@ describe("TaskDetailModal", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Click the plugin tab
|
await userEvent.click(screen.getByText("Plugin B Tab"));
|
||||||
await userEvent.click(screen.getByText("Plugin A Tab"));
|
|
||||||
|
|
||||||
// Verify plugin slot renders with task-detail-tab slotId
|
const slots = container.querySelectorAll('[data-slot-id="task-detail-tab"]');
|
||||||
const slot = container.querySelector('[data-slot-id="task-detail-tab"]');
|
expect(slots).toHaveLength(1);
|
||||||
expect(slot).not.toBeNull();
|
expect(slots[0]).toHaveAttribute("data-plugin-id", "plugin-b");
|
||||||
expect(slot).toHaveAttribute("data-plugin-id", "plugin-a");
|
expect(container.querySelector('[data-plugin-id="plugin-a"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders no extra tabs when no plugins register", () => {
|
it("renders no extra tabs when no plugins register", () => {
|
||||||
|
|||||||
@@ -769,6 +769,7 @@ describe("GET /api/plugins/ui-slots", () => {
|
|||||||
slotId: "task-detail-tab",
|
slotId: "task-detail-tab",
|
||||||
label: "Task Details",
|
label: "Task Details",
|
||||||
componentPath: "./components/TaskDetailTab.js",
|
componentPath: "./components/TaskDetailTab.js",
|
||||||
|
order: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -778,6 +779,7 @@ describe("GET /api/plugins/ui-slots", () => {
|
|||||||
label: "Header Action",
|
label: "Header Action",
|
||||||
icon: "Plus",
|
icon: "Plus",
|
||||||
componentPath: "./components/HeaderAction.js",
|
componentPath: "./components/HeaderAction.js",
|
||||||
|
order: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -786,11 +788,13 @@ describe("GET /api/plugins/ui-slots", () => {
|
|||||||
const res = await performGet(buildApp(), "/api/plugins/ui-slots");
|
const res = await performGet(buildApp(), "/api/plugins/ui-slots");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toEqual(mockSlots);
|
|
||||||
expect(res.body).toHaveLength(2);
|
expect(res.body).toHaveLength(2);
|
||||||
expect(res.body[0].pluginId).toBe("test-plugin");
|
expect(res.body[0].pluginId).toBe("test-plugin");
|
||||||
expect(res.body[0].slot.slotId).toBe("task-detail-tab");
|
expect(res.body[0].slot.slotId).toBe("header-action");
|
||||||
expect(res.body[1].slot.icon).toBe("Plus");
|
expect(res.body[0].slot.surface).toBe("header-action");
|
||||||
|
expect(res.body[1].slot.slotId).toBe("task-detail-tab");
|
||||||
|
expect(res.body[1].slot.surface).toBe("task-detail-tab");
|
||||||
|
expect(res.body[1].slot.order).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("response shape is Array<{ pluginId: string; slot: PluginUiSlotDefinition }>", async () => {
|
it("response shape is Array<{ pluginId: string; slot: PluginUiSlotDefinition }>", async () => {
|
||||||
@@ -817,6 +821,8 @@ describe("GET /api/plugins/ui-slots", () => {
|
|||||||
expect(res.body[0].slot).toHaveProperty("slotId");
|
expect(res.body[0].slot).toHaveProperty("slotId");
|
||||||
expect(res.body[0].slot).toHaveProperty("label");
|
expect(res.body[0].slot).toHaveProperty("label");
|
||||||
expect(res.body[0].slot).toHaveProperty("componentPath");
|
expect(res.body[0].slot).toHaveProperty("componentPath");
|
||||||
|
expect(res.body[0].slot).toHaveProperty("surface");
|
||||||
|
expect(res.body[0].slot).toHaveProperty("order");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty array when pluginLoader is not available", async () => {
|
it("returns empty array when pluginLoader is not available", async () => {
|
||||||
|
|||||||
@@ -3028,7 +3028,23 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
*/
|
*/
|
||||||
router.get("/plugins/ui-slots", async (_req: Request, res: Response) => {
|
router.get("/plugins/ui-slots", async (_req: Request, res: Response) => {
|
||||||
const slots = options?.pluginLoader?.getPluginUiSlots() ?? [];
|
const slots = options?.pluginLoader?.getPluginUiSlots() ?? [];
|
||||||
res.json(slots);
|
const normalizedSlots = slots
|
||||||
|
.map((entry) => ({
|
||||||
|
pluginId: entry.pluginId,
|
||||||
|
slot: {
|
||||||
|
...entry.slot,
|
||||||
|
surface: entry.slot.surface ?? (typeof entry.slot.slotId === "string" ? entry.slot.slotId : undefined),
|
||||||
|
order: entry.slot.order ?? null,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const orderA = typeof a.slot.order === "number" ? a.slot.order : Number.MAX_SAFE_INTEGER;
|
||||||
|
const orderB = typeof b.slot.order === "number" ? b.slot.order : Number.MAX_SAFE_INTEGER;
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
if (a.pluginId !== b.pluginId) return a.pluginId.localeCompare(b.pluginId);
|
||||||
|
return String(a.slot.slotId).localeCompare(String(b.slot.slotId));
|
||||||
|
});
|
||||||
|
res.json(normalizedSlots);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export type {
|
|||||||
PluginToolResult,
|
PluginToolResult,
|
||||||
PluginRouteDefinition,
|
PluginRouteDefinition,
|
||||||
PluginRouteMethod,
|
PluginRouteMethod,
|
||||||
|
PluginUiSurface,
|
||||||
PluginUiSlotDefinition,
|
PluginUiSlotDefinition,
|
||||||
PluginDashboardViewDefinition,
|
PluginDashboardViewDefinition,
|
||||||
PluginRuntimeManifestMetadata,
|
PluginRuntimeManifestMetadata,
|
||||||
|
|||||||
Reference in New Issue
Block a user