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:
@@ -274,12 +274,32 @@ const plugin: FusionPlugin = {
|
|||||||
| `onTaskMoved` | `(task: Task, fromColumn: string, toColumn: string, ctx: PluginContext) => Promise<void> \| void` | Task moved between columns |
|
| `onTaskMoved` | `(task: Task, fromColumn: string, toColumn: string, ctx: PluginContext) => Promise<void> \| void` | Task moved between columns |
|
||||||
| `onTaskCompleted` | `(task: Task, ctx: PluginContext) => Promise<void> \| void` | Task reached "done" |
|
| `onTaskCompleted` | `(task: Task, ctx: PluginContext) => Promise<void> \| void` | Task reached "done" |
|
||||||
| `onError` | `(error: Error, ctx: PluginContext) => Promise<void> \| void` | Error occurred in plugin execution |
|
| `onError` | `(error: Error, ctx: PluginContext) => Promise<void> \| void` | Error occurred in plugin execution |
|
||||||
|
| `onSchemaInit` | `(db: Database) => Promise<void> \| void` | During DB schema initialization (before core migrations complete) |
|
||||||
|
|
||||||
### Hook Behavior
|
### Hook Behavior
|
||||||
|
|
||||||
- **Timeout**: 5 seconds per invocation (logged and skipped if exceeded)
|
- **Timeout**: 5 seconds per invocation (logged and skipped if exceeded)
|
||||||
- **Error Isolation**: Hook failures never block the host system
|
- **Error Isolation**: Hook failures never block the host system
|
||||||
- **Optional**: Only define the hooks you need
|
- **Optional**: Only define the hooks you need
|
||||||
|
- **Schema hook constraints**: `onSchemaInit` is intended for idempotent DDL only (`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`). Avoid data backfills or long-running logic.
|
||||||
|
|
||||||
|
### Example: Schema initialization hook
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
hooks: {
|
||||||
|
onSchemaInit: async (db) => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS plugin_roadmaps (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plugin_roadmaps_created_at
|
||||||
|
ON plugin_roadmaps(created_at);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
### Example: Notification on Task Completion
|
### Example: Notification on Task Completion
|
||||||
|
|
||||||
@@ -505,11 +525,24 @@ const dashboardViews: PluginDashboardViewDefinition[] = [
|
|||||||
componentPath: "./src/DependencyGraphView.tsx",
|
componentPath: "./src/DependencyGraphView.tsx",
|
||||||
icon: "Network",
|
icon: "Network",
|
||||||
order: 40,
|
order: 40,
|
||||||
placement: "more",
|
placement: "overflow",
|
||||||
|
description: "Explore task dependency links",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### PluginDashboardViewDefinition fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `viewId` | `string` | Yes | Unique slug-like ID within your plugin namespace |
|
||||||
|
| `label` | `string` | Yes | Human-readable nav label |
|
||||||
|
| `componentPath` | `string` | Yes | Module path for the view component, relative to plugin root |
|
||||||
|
| `icon` | `string` | No | Lucide icon name |
|
||||||
|
| `order` | `number` | No | Lower values appear earlier in nav |
|
||||||
|
| `placement` | `"primary" \| "overflow" \| "more"` | No | Navigation placement hint (default is host-defined overflow behavior) |
|
||||||
|
| `description` | `string` | No | Short summary for nav/help surfaces |
|
||||||
|
|
||||||
Current host constraints:
|
Current host constraints:
|
||||||
- Discovery API: `GET /api/plugins/dashboard-views`
|
- Discovery API: `GET /api/plugins/dashboard-views`
|
||||||
- The dashboard **does not eval or filesystem-load plugin code in-browser**
|
- The dashboard **does not eval or filesystem-load plugin code in-browser**
|
||||||
|
|||||||
@@ -1344,6 +1344,26 @@ describe("PluginLoader", () => {
|
|||||||
expect(loader.getPluginDashboardViews()).toEqual([]);
|
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 () => {
|
it("aggregates dashboard views from multiple plugins and keeps uiSlots separate", async () => {
|
||||||
await pluginStore.init();
|
await pluginStore.init();
|
||||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
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 ─────────────────────────────────────────────
|
// ── getPluginRuntimes ─────────────────────────────────────────────
|
||||||
|
|
||||||
describe("getPluginRuntimes", () => {
|
describe("getPluginRuntimes", () => {
|
||||||
|
|||||||
@@ -779,6 +779,24 @@ describe("PluginUiSlotDefinition", () => {
|
|||||||
|
|
||||||
// ── FusionPlugin with uiSlots ──────────────────────────────────────────
|
// ── 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", () => {
|
describe("FusionPlugin with uiSlots", () => {
|
||||||
it("accepts a FusionPlugin with uiSlots array", () => {
|
it("accepts a FusionPlugin with uiSlots array", () => {
|
||||||
const plugin = {
|
const plugin = {
|
||||||
@@ -818,6 +836,27 @@ describe("FusionPlugin with uiSlots", () => {
|
|||||||
|
|
||||||
expect((plugin as any).uiSlots).toBeUndefined();
|
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 ──────────────────────────────────────────
|
// ── FusionPlugin with runtime ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export type {
|
|||||||
PluginSettingType,
|
PluginSettingType,
|
||||||
PluginOnLoad,
|
PluginOnLoad,
|
||||||
PluginOnUnload,
|
PluginOnUnload,
|
||||||
|
PluginOnSchemaInit,
|
||||||
PluginOnTaskCreated,
|
PluginOnTaskCreated,
|
||||||
PluginOnTaskMoved,
|
PluginOnTaskMoved,
|
||||||
PluginOnTaskCompleted,
|
PluginOnTaskCompleted,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
PluginRouteDefinition,
|
PluginRouteDefinition,
|
||||||
PluginUiSlotDefinition,
|
PluginUiSlotDefinition,
|
||||||
PluginDashboardViewDefinition,
|
PluginDashboardViewDefinition,
|
||||||
|
PluginOnSchemaInit,
|
||||||
PluginRuntimeRegistration,
|
PluginRuntimeRegistration,
|
||||||
PluginInstallation,
|
PluginInstallation,
|
||||||
PluginSkillContribution,
|
PluginSkillContribution,
|
||||||
@@ -805,6 +806,19 @@ export class PluginLoader extends EventEmitter<{
|
|||||||
return views;
|
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.
|
* Get all runtime registrations from loaded plugins.
|
||||||
* Returns plugin ownership metadata along with the runtime registration.
|
* Returns plugin ownership metadata along with the runtime registration.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
* - PluginInstallation: persisted plugin record
|
* - PluginInstallation: persisted plugin record
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { Database } from "./db.js";
|
||||||
import type { TaskStore } from "./store.js";
|
import type { TaskStore } from "./store.js";
|
||||||
import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js";
|
import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js";
|
||||||
|
|
||||||
@@ -108,6 +109,8 @@ export interface PluginLogger {
|
|||||||
export type PluginOnLoad = (ctx: PluginContext) => Promise<void> | void;
|
export type PluginOnLoad = (ctx: PluginContext) => Promise<void> | void;
|
||||||
/** Lifecycle hook: called when plugin is unloaded */
|
/** Lifecycle hook: called when plugin is unloaded */
|
||||||
export type PluginOnUnload = () => Promise<void> | void;
|
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 */
|
/** Lifecycle hook: called when a task is created */
|
||||||
export type PluginOnTaskCreated = (task: Task, ctx: PluginContext) => Promise<void> | void;
|
export type PluginOnTaskCreated = (task: Task, ctx: PluginContext) => Promise<void> | void;
|
||||||
/** Lifecycle hook: called when a task moves between columns */
|
/** 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. */
|
/** Optional sort order for nav presentation. Lower numbers appear first. */
|
||||||
order?: number;
|
order?: number;
|
||||||
/** Preferred navigation placement for this top-level view. */
|
/** 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 ─────────────────────────────────────────────────
|
// ── Plugin Runtimes ─────────────────────────────────────────────────
|
||||||
@@ -398,6 +403,7 @@ export interface FusionPlugin {
|
|||||||
onTaskMoved?: PluginOnTaskMoved;
|
onTaskMoved?: PluginOnTaskMoved;
|
||||||
onTaskCompleted?: PluginOnTaskCompleted;
|
onTaskCompleted?: PluginOnTaskCompleted;
|
||||||
onError?: PluginOnError;
|
onError?: PluginOnError;
|
||||||
|
onSchemaInit?: PluginOnSchemaInit;
|
||||||
};
|
};
|
||||||
tools?: PluginToolDefinition[];
|
tools?: PluginToolDefinition[];
|
||||||
routes?: PluginRouteDefinition[];
|
routes?: PluginRouteDefinition[];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getTrailingPath } from "../utils/pathDisplay";
|
|||||||
import type { TaskView } from "../hooks/useViewState";
|
import type { TaskView } from "../hooks/useViewState";
|
||||||
import type { PluginDashboardViewEntry } from "../api";
|
import type { PluginDashboardViewEntry } from "../api";
|
||||||
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
||||||
|
import { getPluginNavIcon } from "./pluginNavIcon";
|
||||||
|
|
||||||
export { useViewportMode };
|
export { useViewportMode };
|
||||||
|
|
||||||
@@ -1119,6 +1120,26 @@ export function Header({
|
|||||||
>
|
>
|
||||||
<Mail size={16} />
|
<Mail size={16} />
|
||||||
</button>
|
</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 && (
|
{hasViewOverflowItems && (
|
||||||
<>
|
<>
|
||||||
<button
|
<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))
|
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER))
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
|
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
|
||||||
|
const PluginIcon = getPluginNavIcon(entry.view.icon);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${entry.pluginId}:${entry.view.viewId}`}
|
key={`${entry.pluginId}:${entry.view.viewId}`}
|
||||||
@@ -1255,7 +1277,7 @@ export function Header({
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid={`view-overflow-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
data-testid={`view-overflow-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
||||||
>
|
>
|
||||||
<Grid3X3 size={14} />
|
<PluginIcon size={14} />
|
||||||
<span>{entry.view.label}</span>
|
<span>{entry.view.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type { PluginDashboardViewEntry } from "../api";
|
|||||||
import { useViewportMode } from "./Header";
|
import { useViewportMode } from "./Header";
|
||||||
import type { TaskView } from "../hooks/useViewState";
|
import type { TaskView } from "../hooks/useViewState";
|
||||||
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
|
||||||
|
import { getPluginNavIcon } from "./pluginNavIcon";
|
||||||
|
|
||||||
export interface MobileNavBarProps {
|
export interface MobileNavBarProps {
|
||||||
/** Current task view mode */
|
/** Current task view mode */
|
||||||
@@ -196,6 +197,9 @@ export function MobileNavBar({
|
|||||||
const showRoadmapsTopLevel = roadmapEnabled && (!skillsEnabled || view === "roadmaps");
|
const showRoadmapsTopLevel = roadmapEnabled && (!skillsEnabled || view === "roadmaps");
|
||||||
const showSkillsTopLevel = skillsEnabled && (!roadmapEnabled || view !== "roadmaps");
|
const showSkillsTopLevel = skillsEnabled && (!roadmapEnabled || view !== "roadmaps");
|
||||||
const showSkillsInMore = skillsEnabled && !showSkillsTopLevel;
|
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 =
|
const isMoreActive =
|
||||||
view === "documents"
|
view === "documents"
|
||||||
@@ -207,7 +211,7 @@ export function MobileNavBar({
|
|||||||
|| (todosOpen && todoViewEnabled)
|
|| (todosOpen && todoViewEnabled)
|
||||||
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
||||||
|| (view === "skills" && !showSkillsTopLevel)
|
|| (view === "skills" && !showSkillsTopLevel)
|
||||||
|| view.startsWith("plugin:");
|
|| (view.startsWith("plugin:") && !primaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -315,6 +319,25 @@ export function MobileNavBar({
|
|||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`mobile-nav-tab${isMoreActive ? " mobile-nav-tab--active" : ""}`}
|
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))
|
.sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER))
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
|
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
|
||||||
|
const PluginIcon = getPluginNavIcon(entry.view.icon);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${entry.pluginId}:${entry.view.viewId}`}
|
key={`${entry.pluginId}:${entry.view.viewId}`}
|
||||||
@@ -649,7 +673,7 @@ export function MobileNavBar({
|
|||||||
data-testid={`mobile-more-item-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
data-testid={`mobile-more-item-plugin-${entry.pluginId}-${entry.view.viewId}`}
|
||||||
onClick={() => handleMoreAction(() => onChangeView(pluginTaskView))}
|
onClick={() => handleMoreAction(() => onChangeView(pluginTaskView))}
|
||||||
>
|
>
|
||||||
<Grid3X3 />
|
<PluginIcon />
|
||||||
<span>{entry.view.label}</span>
|
<span>{entry.view.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -157,23 +157,32 @@ describe("Header", () => {
|
|||||||
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
|
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();
|
const onChangeView = vi.fn();
|
||||||
renderHeader({
|
renderHeader({
|
||||||
onChangeView,
|
onChangeView,
|
||||||
pluginDashboardViews: [
|
pluginDashboardViews: [
|
||||||
{
|
{
|
||||||
pluginId: "fusion-plugin-dependency-graph",
|
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 graphPrimary = screen.getByTestId("view-toggle-plugin-fusion-plugin-dependency-graph-graph");
|
||||||
const graphItem = screen.getByTestId("view-overflow-plugin-fusion-plugin-dependency-graph-graph");
|
expect(graphPrimary.querySelector(".lucide-map")).toBeTruthy();
|
||||||
expect(graphItem).toBeInTheDocument();
|
fireEvent.click(graphPrimary);
|
||||||
fireEvent.click(graphItem);
|
|
||||||
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
|
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", () => {
|
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();
|
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();
|
const props = createDefaultProps();
|
||||||
render(
|
render(
|
||||||
<MobileNavBar
|
<MobileNavBar
|
||||||
@@ -112,16 +112,26 @@ describe("MobileNavBar", () => {
|
|||||||
pluginDashboardViews={[
|
pluginDashboardViews={[
|
||||||
{
|
{
|
||||||
pluginId: "fusion-plugin-dependency-graph",
|
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();
|
const primaryTab = screen.getByTestId("mobile-nav-tab-plugin-fusion-plugin-dependency-graph-graph");
|
||||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
expect(primaryTab.querySelector(".lucide-map")).toBeTruthy();
|
||||||
fireEvent.click(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-dependency-graph-graph"));
|
fireEvent.click(primaryTab);
|
||||||
expect(props.onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:graph");
|
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", () => {
|
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,
|
PluginSettingType,
|
||||||
PluginOnLoad,
|
PluginOnLoad,
|
||||||
PluginOnUnload,
|
PluginOnUnload,
|
||||||
|
PluginOnSchemaInit,
|
||||||
PluginOnTaskCreated,
|
PluginOnTaskCreated,
|
||||||
PluginOnTaskMoved,
|
PluginOnTaskMoved,
|
||||||
PluginOnTaskCompleted,
|
PluginOnTaskCompleted,
|
||||||
|
|||||||
Reference in New Issue
Block a user