feat(FN-3154): add plugin schema hooks, nav placement, and progress preserv
This merge introduces three major features: task reset now preserves existing progress with explicit user confirmation (FN-3185), the engine's soft-pause/unpause behavior is restored and documented (FN-3201), and the plugin system gains schema hook aggregation with new navigation placement and icons Fusion-Task-Id: FN-3154
This commit is contained in:
@@ -1344,6 +1344,26 @@ describe("PluginLoader", () => {
|
||||
expect(loader.getPluginDashboardViews()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns aggregated views from a single plugin", 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: {},
|
||||
dashboardViews: [
|
||||
{ viewId: "graph", label: "Graph", componentPath: "./graph.js", placement: "more" },
|
||||
{ viewId: "timeline", label: "Timeline", componentPath: "./timeline.js", placement: "overflow" },
|
||||
],
|
||||
} as FusionPlugin);
|
||||
|
||||
const views = loader.getPluginDashboardViews();
|
||||
expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([
|
||||
"views-a:graph",
|
||||
"views-a:timeline",
|
||||
]);
|
||||
});
|
||||
|
||||
it("aggregates dashboard views from multiple plugins and keeps uiSlots separate", async () => {
|
||||
await pluginStore.init();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
@@ -1371,6 +1391,48 @@ describe("PluginLoader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPluginSchemaInitHooks", () => {
|
||||
it("returns empty array when no plugins define onSchemaInit", async () => {
|
||||
await pluginStore.init();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
(loader as any).plugins.set("no-hook", {
|
||||
manifest: makeManifest({ id: "no-hook" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
} as FusionPlugin);
|
||||
|
||||
expect(loader.getPluginSchemaInitHooks()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns hooks only from plugins that define onSchemaInit", async () => {
|
||||
await pluginStore.init();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const hookA = async () => {};
|
||||
const hookB = () => {};
|
||||
|
||||
(loader as any).plugins.set("schema-a", {
|
||||
manifest: makeManifest({ id: "schema-a" }),
|
||||
state: "started",
|
||||
hooks: { onSchemaInit: hookA },
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("schema-b", {
|
||||
manifest: makeManifest({ id: "schema-b" }),
|
||||
state: "started",
|
||||
hooks: { onLoad: async () => {} },
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("schema-c", {
|
||||
manifest: makeManifest({ id: "schema-c" }),
|
||||
state: "started",
|
||||
hooks: { onSchemaInit: hookB },
|
||||
} as FusionPlugin);
|
||||
|
||||
const hooks = loader.getPluginSchemaInitHooks();
|
||||
expect(hooks.map((entry) => entry.pluginId)).toEqual(["schema-a", "schema-c"]);
|
||||
expect(hooks[0]?.hook).toBe(hookA);
|
||||
expect(hooks[1]?.hook).toBe(hookB);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getPluginRuntimes ─────────────────────────────────────────────
|
||||
|
||||
describe("getPluginRuntimes", () => {
|
||||
|
||||
@@ -779,6 +779,24 @@ describe("PluginUiSlotDefinition", () => {
|
||||
|
||||
// ── FusionPlugin with uiSlots ──────────────────────────────────────────
|
||||
|
||||
describe("PluginDashboardViewDefinition", () => {
|
||||
it("accepts a valid PluginDashboardViewDefinition with optional fields", () => {
|
||||
const view = {
|
||||
viewId: "roadmap-planner",
|
||||
label: "Roadmap Planner",
|
||||
componentPath: "./views/RoadmapPlanner.js",
|
||||
icon: "Map",
|
||||
order: 10,
|
||||
placement: "overflow",
|
||||
description: "Plan milestones and slices",
|
||||
};
|
||||
|
||||
expect(view.viewId).toBe("roadmap-planner");
|
||||
expect(view.placement).toBe("overflow");
|
||||
expect(view.description).toContain("milestones");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FusionPlugin with uiSlots", () => {
|
||||
it("accepts a FusionPlugin with uiSlots array", () => {
|
||||
const plugin = {
|
||||
@@ -818,6 +836,27 @@ describe("FusionPlugin with uiSlots", () => {
|
||||
|
||||
expect((plugin as any).uiSlots).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a FusionPlugin with dashboardViews and onSchemaInit hook", () => {
|
||||
const plugin: FusionPlugin = {
|
||||
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
|
||||
state: "started",
|
||||
hooks: {
|
||||
onSchemaInit: async () => {},
|
||||
},
|
||||
dashboardViews: [
|
||||
{
|
||||
viewId: "dependencies",
|
||||
label: "Dependencies",
|
||||
componentPath: "./views/Dependencies.js",
|
||||
placement: "primary",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(plugin.hooks.onSchemaInit).toBeTypeOf("function");
|
||||
expect(plugin.dashboardViews?.[0]?.viewId).toBe("dependencies");
|
||||
});
|
||||
});
|
||||
|
||||
// ── FusionPlugin with runtime ──────────────────────────────────────────
|
||||
|
||||
@@ -131,6 +131,7 @@ export type {
|
||||
PluginSettingType,
|
||||
PluginOnLoad,
|
||||
PluginOnUnload,
|
||||
PluginOnSchemaInit,
|
||||
PluginOnTaskCreated,
|
||||
PluginOnTaskMoved,
|
||||
PluginOnTaskCompleted,
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
PluginRouteDefinition,
|
||||
PluginUiSlotDefinition,
|
||||
PluginDashboardViewDefinition,
|
||||
PluginOnSchemaInit,
|
||||
PluginRuntimeRegistration,
|
||||
PluginInstallation,
|
||||
PluginSkillContribution,
|
||||
@@ -805,6 +806,19 @@ export class PluginLoader extends EventEmitter<{
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all schema initialization hooks from loaded plugins.
|
||||
*/
|
||||
getPluginSchemaInitHooks(): Array<{ pluginId: string; hook: PluginOnSchemaInit }> {
|
||||
const hooks: Array<{ pluginId: string; hook: PluginOnSchemaInit }> = [];
|
||||
for (const [pluginId, plugin] of this.plugins) {
|
||||
if (plugin.hooks.onSchemaInit) {
|
||||
hooks.push({ pluginId, hook: plugin.hooks.onSchemaInit });
|
||||
}
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all runtime registrations from loaded plugins.
|
||||
* Returns plugin ownership metadata along with the runtime registration.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* - PluginInstallation: persisted plugin record
|
||||
*/
|
||||
|
||||
import type { Database } from "./db.js";
|
||||
import type { TaskStore } from "./store.js";
|
||||
import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js";
|
||||
|
||||
@@ -108,6 +109,8 @@ export interface PluginLogger {
|
||||
export type PluginOnLoad = (ctx: PluginContext) => Promise<void> | void;
|
||||
/** Lifecycle hook: called when plugin is unloaded */
|
||||
export type PluginOnUnload = () => Promise<void> | void;
|
||||
/** Lifecycle hook: called during database schema initialization */
|
||||
export type PluginOnSchemaInit = (db: Database) => Promise<void> | void;
|
||||
/** Lifecycle hook: called when a task is created */
|
||||
export type PluginOnTaskCreated = (task: Task, ctx: PluginContext) => Promise<void> | void;
|
||||
/** Lifecycle hook: called when a task moves between columns */
|
||||
@@ -222,7 +225,9 @@ export interface PluginDashboardViewDefinition {
|
||||
/** Optional sort order for nav presentation. Lower numbers appear first. */
|
||||
order?: number;
|
||||
/** Preferred navigation placement for this top-level view. */
|
||||
placement?: "primary" | "more";
|
||||
placement?: "primary" | "overflow" | "more";
|
||||
/** Optional short description used by navigation/help UI. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Plugin Runtimes ─────────────────────────────────────────────────
|
||||
@@ -398,6 +403,7 @@ export interface FusionPlugin {
|
||||
onTaskMoved?: PluginOnTaskMoved;
|
||||
onTaskCompleted?: PluginOnTaskCompleted;
|
||||
onError?: PluginOnError;
|
||||
onSchemaInit?: PluginOnSchemaInit;
|
||||
};
|
||||
tools?: PluginToolDefinition[];
|
||||
routes?: PluginRouteDefinition[];
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getTrailingPath } from "../utils/pathDisplay";
|
||||
import type { TaskView } from "../hooks/useViewState";
|
||||
import type { PluginDashboardViewEntry } from "../api";
|
||||
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
||||
import { getPluginNavIcon } from "./pluginNavIcon";
|
||||
|
||||
export { useViewportMode };
|
||||
|
||||
@@ -1119,6 +1120,26 @@ export function Header({
|
||||
>
|
||||
<Mail size={16} />
|
||||
</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);
|
||||
const PluginIcon = getPluginNavIcon(entry.view.icon);
|
||||
return (
|
||||
<button
|
||||
key={`${entry.pluginId}:${entry.view.viewId}`}
|
||||
className={`view-toggle-btn${view === pluginTaskView ? " active" : ""}`}
|
||||
onClick={() => onChangeView(pluginTaskView)}
|
||||
title={`${entry.view.label} view`}
|
||||
aria-label={`${entry.view.label} view`}
|
||||
aria-pressed={view === pluginTaskView}
|
||||
data-testid={`view-toggle-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
||||
>
|
||||
<PluginIcon size={16} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{hasViewOverflowItems && (
|
||||
<>
|
||||
<button
|
||||
@@ -1244,6 +1265,7 @@ export function Header({
|
||||
.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);
|
||||
const PluginIcon = getPluginNavIcon(entry.view.icon);
|
||||
return (
|
||||
<button
|
||||
key={`${entry.pluginId}:${entry.view.viewId}`}
|
||||
@@ -1255,7 +1277,7 @@ export function Header({
|
||||
role="menuitem"
|
||||
data-testid={`view-overflow-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
||||
>
|
||||
<Grid3X3 size={14} />
|
||||
<PluginIcon size={14} />
|
||||
<span>{entry.view.label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,7 @@ import type { PluginDashboardViewEntry } from "../api";
|
||||
import { useViewportMode } from "./Header";
|
||||
import type { TaskView } from "../hooks/useViewState";
|
||||
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
||||
import { getPluginNavIcon } from "./pluginNavIcon";
|
||||
|
||||
export interface MobileNavBarProps {
|
||||
/** Current task view mode */
|
||||
@@ -196,6 +197,9 @@ export function MobileNavBar({
|
||||
const showRoadmapsTopLevel = roadmapEnabled && (!skillsEnabled || view === "roadmaps");
|
||||
const showSkillsTopLevel = skillsEnabled && (!roadmapEnabled || view !== "roadmaps");
|
||||
const showSkillsInMore = skillsEnabled && !showSkillsTopLevel;
|
||||
const primaryPluginViews = pluginDashboardViews
|
||||
.filter((entry) => entry.view.placement === "primary")
|
||||
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER));
|
||||
|
||||
const isMoreActive =
|
||||
view === "documents"
|
||||
@@ -207,7 +211,7 @@ export function MobileNavBar({
|
||||
|| (todosOpen && todoViewEnabled)
|
||||
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
||||
|| (view === "skills" && !showSkillsTopLevel)
|
||||
|| view.startsWith("plugin:");
|
||||
|| (view.startsWith("plugin:") && !primaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -315,6 +319,25 @@ export function MobileNavBar({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{primaryPluginViews.map((entry) => {
|
||||
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
|
||||
const PluginIcon = getPluginNavIcon(entry.view.icon);
|
||||
return (
|
||||
<button
|
||||
key={`${entry.pluginId}:${entry.view.viewId}`}
|
||||
type="button"
|
||||
className={`mobile-nav-tab${view === pluginTaskView ? " mobile-nav-tab--active" : ""}`}
|
||||
data-testid={`mobile-nav-tab-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
||||
role="tab"
|
||||
aria-selected={view === pluginTaskView}
|
||||
onClick={() => onChangeView(pluginTaskView)}
|
||||
>
|
||||
<PluginIcon />
|
||||
<span className="mobile-nav-tab-label">{entry.view.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`mobile-nav-tab${isMoreActive ? " mobile-nav-tab--active" : ""}`}
|
||||
@@ -641,6 +664,7 @@ export function MobileNavBar({
|
||||
.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);
|
||||
const PluginIcon = getPluginNavIcon(entry.view.icon);
|
||||
return (
|
||||
<button
|
||||
key={`${entry.pluginId}:${entry.view.viewId}`}
|
||||
@@ -649,7 +673,7 @@ export function MobileNavBar({
|
||||
data-testid={`mobile-more-item-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
||||
onClick={() => handleMoreAction(() => onChangeView(pluginTaskView))}
|
||||
>
|
||||
<Grid3X3 />
|
||||
<PluginIcon />
|
||||
<span>{entry.view.label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -157,23 +157,32 @@ describe("Header", () => {
|
||||
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plugin dashboard views in desktop view overflow only", () => {
|
||||
it("renders plugin dashboard views by placement and uses manifest icon metadata", () => {
|
||||
const onChangeView = vi.fn();
|
||||
renderHeader({
|
||||
onChangeView,
|
||||
pluginDashboardViews: [
|
||||
{
|
||||
pluginId: "fusion-plugin-dependency-graph",
|
||||
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView" },
|
||||
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", icon: "Map", placement: "primary" },
|
||||
},
|
||||
{
|
||||
pluginId: "fusion-plugin-dependency-graph",
|
||||
view: { viewId: "queue", label: "Queue", componentPath: "./QueueView", icon: "Workflow" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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);
|
||||
const graphPrimary = screen.getByTestId("view-toggle-plugin-fusion-plugin-dependency-graph-graph");
|
||||
expect(graphPrimary.querySelector(".lucide-map")).toBeTruthy();
|
||||
fireEvent.click(graphPrimary);
|
||||
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
|
||||
|
||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||
const queueItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-queue");
|
||||
expect(queueItem.querySelector(".lucide-workflow")).toBeTruthy();
|
||||
fireEvent.click(queueItem);
|
||||
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:queue");
|
||||
});
|
||||
|
||||
it("renders view overflow trigger when an experimental overflow feature is enabled", () => {
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("MobileNavBar", () => {
|
||||
expect(screen.queryByTestId("mobile-nav-tab-skills")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders plugin dashboard views in More sheet and not top-level tabs", () => {
|
||||
it("renders primary plugin dashboard views as top-level tabs and keeps overflow views in More", () => {
|
||||
const props = createDefaultProps();
|
||||
render(
|
||||
<MobileNavBar
|
||||
@@ -112,16 +112,26 @@ describe("MobileNavBar", () => {
|
||||
pluginDashboardViews={[
|
||||
{
|
||||
pluginId: "fusion-plugin-dependency-graph",
|
||||
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView" },
|
||||
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", icon: "Map", placement: "primary" },
|
||||
},
|
||||
{
|
||||
pluginId: "fusion-plugin-dependency-graph",
|
||||
view: { viewId: "queue", label: "Queue", componentPath: "./QueueView", icon: "Workflow" },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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"));
|
||||
const primaryTab = screen.getByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph");
|
||||
expect(primaryTab.querySelector(".lucide-map")).toBeTruthy();
|
||||
fireEvent.click(primaryTab);
|
||||
expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
|
||||
|
||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||
const overflowItem = screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-queue");
|
||||
expect(overflowItem.querySelector(".lucide-workflow")).toBeTruthy();
|
||||
fireEvent.click(overflowItem);
|
||||
expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:queue");
|
||||
});
|
||||
|
||||
it("active tab is highlighted for mailbox", () => {
|
||||
|
||||
32
packages/dashboard/app/components/pluginNavIcon.tsx
Normal file
32
packages/dashboard/app/components/pluginNavIcon.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Activity, Bot, Brain, CheckSquare, Clock, FileText, Folder, GitBranch, Grid3X3, LayoutGrid, Mail, Map, MessageSquare, Monitor, Search, Sparkles, Target, Workflow, Zap } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
const PLUGIN_NAV_ICON_MAP: Record<string, LucideIcon> = {
|
||||
activity: Activity,
|
||||
bot: Bot,
|
||||
brain: Brain,
|
||||
checksquare: CheckSquare,
|
||||
clock: Clock,
|
||||
filetext: FileText,
|
||||
folder: Folder,
|
||||
gitbranch: GitBranch,
|
||||
grid3x3: Grid3X3,
|
||||
layoutgrid: LayoutGrid,
|
||||
mail: Mail,
|
||||
map: Map,
|
||||
messagesquare: MessageSquare,
|
||||
monitor: Monitor,
|
||||
search: Search,
|
||||
sparkles: Sparkles,
|
||||
target: Target,
|
||||
workflow: Workflow,
|
||||
zap: Zap,
|
||||
};
|
||||
|
||||
function normalizeIconName(iconName?: string): string {
|
||||
return (iconName ?? "").trim().toLowerCase().replace(/[-_\s]/g, "");
|
||||
}
|
||||
|
||||
export function getPluginNavIcon(iconName?: string): LucideIcon {
|
||||
return PLUGIN_NAV_ICON_MAP[normalizeIconName(iconName)] ?? Grid3X3;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export type {
|
||||
PluginSettingType,
|
||||
PluginOnLoad,
|
||||
PluginOnUnload,
|
||||
PluginOnSchemaInit,
|
||||
PluginOnTaskCreated,
|
||||
PluginOnTaskMoved,
|
||||
PluginOnTaskCompleted,
|
||||
|
||||
Reference in New Issue
Block a user