fix(FN-3161): migrate roadmap dashboard surface to bundled plugin

- Move RoadmapsView UI, hooks, and tests from dashboard app into fusion-plugin-roadmap
- Replace the built-in roadmaps route/nav wiring with plugin view registration and host rendering
- Add bundled plugin view registration bootstrap in dashboard startup
- Update roadmap plugin build/test config and add FN-3161 removal changeset

Fusion-Task-Id: FN-3161
This commit is contained in:
Fusion
2026-05-08 16:51:04 -07:00
committed by gsxdsm
parent 0da7aa8c0f
commit d20d45ea58
33 changed files with 295 additions and 149 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Remove the dashboard-owned `RoadmapsView`, `useRoadmaps` hook, and related CSS/tests from `@fusion/dashboard`. Roadmap planning now routes exclusively through the bundled `roadmap-planner` plugin dashboard view (`plugin:roadmap-planner:roadmaps`).

View File

@@ -607,9 +607,9 @@ The test config (`vitest.config.ts`) includes `test.css: { include: [/.+/] }` so
### Lazy-Loaded Heavy Views
These 16 views are lazy-loaded via `React.lazy()` to manage bundle size:
These 15 views are lazy-loaded via `React.lazy()` to manage bundle size:
- `AgentsView`, `RoadmapsView`, `NodesView`, `ChatView`, `MemoryView`
- `AgentsView`, `NodesView`, `ChatView`, `MemoryView`
- `DevServerView`, `InsightsView`, `DocumentsView`, `SkillsView`, `ResearchView`, `EvalsView`, `TodoView`
- `SetupWizardModal`, `PluginManager`, `PiExtensionsManager`, `AgentDetailView`

View File

@@ -50,6 +50,7 @@ import { useNavigationHistory } from "./hooks/useNavigationHistory";
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
import { isPluginViewId } from "./plugins/pluginViewRegistry";
import { registerBundledPluginViews } from "./plugins/registerBundledPluginViews";
import { useProjectActions } from "./hooks/useProjectActions";
import { useTaskHandlers } from "./hooks/useTaskHandlers";
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
@@ -85,7 +86,7 @@ const ResearchView = lazy(() => import("./components/ResearchView").then((m) =>
const EvalsView = lazy(() => import("./components/EvalsView").then((m) => ({ default: m.EvalsView })));
const NodesView = lazy(() => import("./components/NodesView").then((m) => ({ default: m.NodesView })));
const ChatView = lazy(() => import("./components/ChatView").then((m) => ({ default: m.ChatView })));
const RoadmapsView = lazy(() => import("./components/RoadmapsView").then((m) => ({ default: m.RoadmapsView })));
const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ default: m.SkillsView })));
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
@@ -110,7 +111,7 @@ function prefetchLazyViews() {
void import("./components/EvalsView");
void import("./components/NodesView");
void import("./components/ChatView");
void import("./components/RoadmapsView");
void import("./components/SkillsView");
void import("./components/MemoryView");
void import("./components/DevServerView");
@@ -118,6 +119,8 @@ function prefetchLazyViews() {
});
}
registerBundledPluginViews();
const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed";
const ACTIVE_CHAT_SESSION_STORAGE_KEY = "kb-chat-active-session";
const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter";
@@ -574,9 +577,6 @@ function AppInner() {
if (taskView === "insights" && !insightsEnabled) {
handleChangeTaskView("board");
}
if (taskView === "roadmaps" && !roadmapEnabled) {
handleChangeTaskView("board");
}
if (taskView === "agents" && !agentsEnabled) {
handleChangeTaskView("board");
}
@@ -592,7 +592,7 @@ function AppInner() {
if (taskView === "evals" && !evalsEnabled) {
handleChangeTaskView("board");
}
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, graphPluginTaskView]);
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, graphPluginTaskView]);
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
useEffect(() => {
@@ -1047,6 +1047,7 @@ function AppInner() {
disableDrag={true}
/>
),
addToast,
}}
/>
</PageErrorBoundary>
@@ -1092,18 +1093,6 @@ function AppInner() {
);
}
if (taskView === "roadmaps") {
if (!settingsLoaded || !roadmapEnabled) {
return null;
}
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<RoadmapsView addToast={addToast} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "missions") {
return (

View File

@@ -4,7 +4,6 @@ import { resolve } from "node:path";
const EXPECTED_DOCUMENTED_VIEWS = new Set([
"AgentsView",
"RoadmapsView",
"NodesView",
"ChatView",
"MemoryView",
@@ -29,7 +28,6 @@ const EXPECTED_APP_LEVEL_VIEWS = new Set([
"EvalsView",
"NodesView",
"ChatView",
"RoadmapsView",
"SkillsView",
"MemoryView",
"DevServerView",
@@ -64,11 +62,11 @@ describe("AGENTS lazy-loaded views inventory", () => {
const section = extractLazyLoadedSection(agentsDoc);
const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/);
expect(countMatch).toBeTruthy();
expect(Number(countMatch?.[1])).toBe(16);
expect(Number(countMatch?.[1])).toBe(15);
const documentedViews = extractBacktickedNamesFromBullets(section);
expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS);
expect(documentedViews).toHaveLength(16);
expect(documentedViews).toHaveLength(15);
expect(section).toContain("`ResearchView`");
expect(section).toContain("`TodoView`");

View File

@@ -105,11 +105,10 @@ describe("tablet header controls", () => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
});
it("opens overflow menu with Insights, Roadmaps, Skills on tablet when trigger is clicked", () => {
it("opens overflow menu with Insights and Skills on tablet when trigger is clicked", () => {
renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true, roadmap: true } });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.getByTestId("view-overflow-insights")).toBeDefined();
expect(screen.getByTestId("view-overflow-roadmaps")).toBeDefined();
expect(screen.getByTestId("view-overflow-skills")).toBeDefined();
});

View File

@@ -359,7 +359,7 @@ export function Header({
}, [overflowScripts]);
const hasRoadmapsPluginView = useMemo(
() => pluginDashboardViews.some((entry) => entry.pluginId === "fusion-plugin-roadmap"),
() => pluginDashboardViews.some((entry) => entry.pluginId === "roadmap-planner"),
[pluginDashboardViews],
);
@@ -369,7 +369,7 @@ export function Header({
experimentalFeatures?.researchView ||
todosEnabled ||
experimentalFeatures?.insights ||
(experimentalFeatures?.roadmap && !hasRoadmapsPluginView) ||
showSkillsTab ||
experimentalFeatures?.memoryView ||
experimentalFeatures?.devServerView ||
@@ -1178,7 +1178,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver", "graph"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
className={`view-toggle-btn${["research", "skills", "insights", "memory", "dev-server", "devserver", "graph"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title="More views"
aria-label="More views"
@@ -1237,19 +1237,7 @@ export function Header({
<span>Insights</span>
</button>
)}
{experimentalFeatures?.roadmap && !hasRoadmapsPluginView && (
<button
className={`view-toggle-overflow-item${view === "roadmaps" ? " active" : ""}`}
onClick={() => {
onChangeView("roadmaps");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-overflow-roadmaps"
>
<span>Roadmaps</span>
</button>
)}
{showSkillsTab && (
<button
className={`view-toggle-overflow-item${view === "skills" ? " active" : ""}`}

View File

@@ -27,7 +27,6 @@ import {
Target,
Terminal,
Workflow,
Map,
Zap,
} from "lucide-react";
import { fetchScripts } from "../api";
@@ -205,15 +204,12 @@ export function MobileNavBar({
const planningHandler = activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning;
const hasRoadmapsPluginView = pluginDashboardViews.some((entry) => entry.pluginId === "fusion-plugin-roadmap");
const roadmapEnabled = Boolean(experimentalFeatures?.roadmap) && !hasRoadmapsPluginView;
const skillsEnabled = Boolean(showSkillsTab);
const todoViewEnabled = Boolean(experimentalFeatures?.todoView);
// Keep a maximum of one optional primary tab visible at once to preserve touch-target width.
// Overflowed destinations remain available in the More sheet.
const showRoadmapsTopLevel = roadmapEnabled && (!skillsEnabled || view === "roadmaps");
const showSkillsTopLevel = skillsEnabled && (!roadmapEnabled || view !== "roadmaps");
const showSkillsTopLevel = skillsEnabled;
const showSkillsInMore = skillsEnabled && !showSkillsTopLevel;
const sortedPrimaryPluginViews = pluginDashboardViews
.filter((entry) => entry.view.placement === "primary")
@@ -236,7 +232,6 @@ export function MobileNavBar({
|| view === "devserver"
|| view === "dev-server"
|| (todosOpen && todoViewEnabled)
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|| (view === "skills" && !showSkillsTopLevel)
|| view === "graph"
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
@@ -338,19 +333,6 @@ export function MobileNavBar({
</button>
)}
{showRoadmapsTopLevel && (
<button
type="button"
className={`mobile-nav-tab${view === "roadmaps" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-roadmaps"
role="tab"
aria-selected={view === "roadmaps"}
onClick={() => onChangeView("roadmaps")}
>
<Map />
<span className="mobile-nav-tab-label">Roadmaps</span>
</button>
)}
{topLevelPrimaryPluginViews.map((entry) => {
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
@@ -635,17 +617,7 @@ export function MobileNavBar({
</button>
)}
{roadmapEnabled && (
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-roadmaps"
onClick={() => handleMoreAction(() => onChangeView("roadmaps"))}
>
<Map />
<span>Roadmaps</span>
</button>
)}
{experimentalFeatures?.researchView && (
<button

View File

@@ -70,29 +70,28 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
});
it("renders roadmaps tab when experimentalFeatures.roadmap is true", () => {
it("does not render legacy roadmaps tab when experimentalFeatures.roadmap is true", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ roadmap: true }} />);
expect(screen.getByTestId("mobile-nav-tab-roadmaps")).toBeDefined();
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
});
it("keeps optional tabs within mobile top-level capacity by overflowing roadmaps into More", () => {
it("keeps skills available without rendering legacy roadmaps destinations", () => {
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} experimentalFeatures={{ roadmap: true }} />);
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-roadmaps")).toBeDefined();
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
});
it("moves skills into More when roadmaps is the active optional top-level tab", () => {
render(<MobileNavBar {...createDefaultProps()} view="roadmaps" showSkillsTab={true} experimentalFeatures={{ roadmap: true }} />);
it("keeps skills top-level regardless of legacy roadmaps view value", () => {
render(<MobileNavBar {...createDefaultProps()} view="board" showSkillsTab={true} experimentalFeatures={{ roadmap: true }} />);
expect(screen.getByTestId("mobile-nav-tab-roadmaps")).toBeDefined();
expect(screen.queryByTestId("mobile-nav-tab-skills")).toBeNull();
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-skills")).toBeDefined();
expect(screen.queryByTestId("mobile-more-item-skills")).toBeNull();
});
it("does not render skills tab when showSkillsTab is false", () => {
@@ -388,10 +387,10 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
});
it("shows roadmaps in more sheet when experimentalFeatures.roadmap is true", () => {
it("does not show legacy roadmaps in more sheet when experimentalFeatures.roadmap is true", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ roadmap: true }} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-roadmaps")).toBeDefined();
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
});
it("suppresses legacy roadmaps entries when roadmap plugin view is registered", () => {
@@ -401,7 +400,7 @@ describe("MobileNavBar", () => {
experimentalFeatures={{ roadmap: true }}
pluginDashboardViews={[
{
pluginId: "fusion-plugin-roadmap",
pluginId: "roadmap-planner",
view: { viewId: "roadmaps", label: "Roadmaps", componentPath: "./RoadmapsView", icon: "Map", placement: "primary" },
},
]}

View File

@@ -1,6 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useViewState } from "../useViewState";
import * as pluginViewRegistry from "../../plugins/pluginViewRegistry";
import type { ProjectInfo } from "../../api";
import type { ThemeMode } from "@fusion/core";
@@ -33,6 +34,7 @@ describe("useViewState", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockImplementation(() => false);
});
it("returns default viewMode and taskView when no localStorage exists", async () => {
@@ -64,6 +66,28 @@ describe("useViewState", () => {
});
});
it("migrates legacy roadmaps state to plugin view when registered", async () => {
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockReturnValue(true);
localStorage.setItem("kb-dashboard-task-view", "roadmaps");
const { result } = renderHook(() => useViewState(createOptions()));
await waitFor(() => {
expect(result.current.taskView).toBe("plugin:roadmap-planner:roadmaps");
});
});
it("falls back to board for legacy roadmaps state when plugin is unavailable", async () => {
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockReturnValue(false);
localStorage.setItem("kb-dashboard-task-view", "roadmaps");
const { result } = renderHook(() => useViewState(createOptions()));
await waitFor(() => {
expect(result.current.taskView).toBe("board");
});
});
it("persists viewMode changes to localStorage", async () => {
const { result } = renderHook(() => useViewState(createOptions()));

View File

@@ -2,10 +2,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { ThemeMode } from "@fusion/core";
import type { ProjectInfo } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { isPluginViewId } from "../plugins/pluginViewRegistry";
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
export type ViewMode = "overview" | "project";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView;
@@ -19,7 +19,7 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
"documents",
"research",
"evals",
"roadmaps",
"skills",
"mailbox",
"insights",
@@ -36,10 +36,19 @@ function isTaskView(value: string | null): value is TaskView {
return value !== null && (isBuiltInTaskView(value) || isPluginViewId(value));
}
const LEGACY_ROADMAPS_PLUGIN_VIEW = getPluginViewId("roadmap-planner", "roadmaps");
function normalizeTaskView(value: TaskView): TaskView {
return value === "devserver" ? "dev-server" : value;
}
function migrateLegacyRoadmapsView(value: string): TaskView {
if (value !== "roadmaps") {
return "board";
}
return isPluginViewRegistered("roadmap-planner", "roadmaps") ? LEGACY_ROADMAPS_PLUGIN_VIEW : "board";
}
interface UseViewStateOptions {
projectsLoading: boolean;
projectsError: string | null;
@@ -84,6 +93,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
const [taskView, setTaskView] = useState<TaskView>(() => {
const saved = getScopedItem("kb-dashboard-task-view");
if (saved === "roadmaps") return migrateLegacyRoadmapsView(saved);
if (isTaskView(saved)) return saved;
return "board";
});
@@ -95,7 +105,9 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
useEffect(() => {
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
if (isTaskView(saved)) {
if (saved === "roadmaps") {
setTaskView(migrateLegacyRoadmapsView(saved));
} else if (isTaskView(saved)) {
const preserveLegacyOnFirstScopedHydration =
!hasHydratedScopedTaskViewRef.current && saved === "devserver";

View File

@@ -6,6 +6,7 @@ import { installAuthFetch } from "./auth";
import { installVersionCheck } from "./versionCheck";
import { installSwUpdate } from "./swUpdate";
import { bootstrapShellHostContext } from "./shell-host";
import { registerBundledPluginViews } from "./plugins/registerBundledPluginViews";
import "./styles.css";
// Install the bearer-token fetch wrapper before React mounts so every API
@@ -15,6 +16,7 @@ import "./styles.css";
installAuthFetch();
installVersionCheck();
bootstrapShellHostContext();
registerBundledPluginViews();
createRoot(document.getElementById("root")!).render(
<StrictMode>

View File

@@ -1,5 +1,6 @@
import { PluginDashboardViewHost as RegistryPluginDashboardViewHost } from "./pluginViewRegistry";
import type { PluginDashboardViewContext, PluginTaskView } from "./pluginViewRegistry";
import type { PluginTaskView } from "./pluginViewRegistry";
import type { PluginDashboardViewContext } from "./types";
export function PluginDashboardViewHost({ taskView, context }: { taskView: PluginTaskView; context?: PluginDashboardViewContext }) {
return <RegistryPluginDashboardViewHost viewId={taskView} context={context} />;

View File

@@ -1,20 +1,11 @@
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import type { PluginDashboardViewContext } from "./types";
import { AlertTriangle } from "lucide-react";
import { lazy, Suspense, type LazyExoticComponent, type ReactElement, type ReactNode } from "react";
import { ErrorBoundary } from "../components/ErrorBoundary";
import type { DetailTaskTab } from "../hooks/useModalManager";
import "./pluginViewRegistry.css";
export type PluginTaskView = `plugin:${string}:${string}`;
export interface PluginDashboardViewContext {
projectId?: string;
tasks: Task[];
workflowSteps: WorkflowStep[];
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
}
type PluginViewComponent = LazyExoticComponent<({ context }: { context?: PluginDashboardViewContext }) => ReactElement>;
const registry = new Map<string, PluginViewComponent>();
@@ -46,25 +37,15 @@ export function getPluginViewComponent(pluginId: string, viewId: string): Plugin
return registry.get(getPluginViewId(pluginId, viewId)) ?? null;
}
export function isPluginViewRegistered(pluginId: string, viewId: string): boolean {
return registry.has(getPluginViewId(pluginId, viewId));
}
/** Test helper for clearing global registry state. */
export function __test_clearPluginViewRegistry(): void {
registry.clear();
registerBundledPluginViews();
}
function registerBundledPluginViews(): void {
registerPluginView(
"fusion-plugin-dependency-graph",
"graph",
lazy(async () => {
const mod = await import("@fusion-plugin-examples/dependency-graph/dashboard-view");
return { default: mod.DependencyGraphDashboardView };
}),
);
}
registerBundledPluginViews();
function PluginViewUnavailable({ viewId }: { viewId: string }): ReactNode {
return (
<section className="card plugin-dashboard-view-missing" data-testid="plugin-view-unavailable">

View File

@@ -0,0 +1,27 @@
import { lazy } from "react";
import { registerPluginView } from "./pluginViewRegistry";
let registered = false;
export function registerBundledPluginViews(): void {
if (registered) return;
registered = true;
registerPluginView(
"fusion-plugin-dependency-graph",
"graph",
lazy(async () => {
const mod = await import("@fusion-plugin-examples/dependency-graph/dashboard-view");
return { default: mod.DependencyGraphDashboardView };
}),
);
registerPluginView(
"roadmap-planner",
"roadmaps",
lazy(async () => {
const mod = await import("@fusion-plugin-examples/roadmap/dashboard-view");
return { default: mod.RoadmapDashboardView };
}),
);
}

View File

@@ -14,6 +14,8 @@ import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
/** Tab identifiers for the task detail modal. Mirrors the dashboard's local enum. */
export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "model" | "workflow";
export type PluginToastType = "success" | "error" | "warning" | "info";
/** Runtime context passed to a plugin dashboard view component. */
export interface PluginDashboardViewContext {
projectId?: string;
@@ -21,6 +23,7 @@ export interface PluginDashboardViewContext {
workflowSteps: WorkflowStep[];
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
addToast?: (message: string, type?: PluginToastType) => void;
}
/** Composite view ID format: `plugin:{pluginId}:{viewId}`. */

View File

@@ -4,17 +4,21 @@
## Plugin identity
- Manifest id: `fusion-plugin-roadmap`
- Route namespace: `/api/plugins/fusion-plugin-roadmap/*`
- Dashboard view id: `plugin:fusion-plugin-roadmap:roadmaps`
- Manifest id: `roadmap-planner`
- Route namespace: `/api/plugins/roadmap-planner/*`
- Dashboard view id: `plugin:roadmap-planner:roadmaps`
## Package layout
- `manifest.json` — plugin metadata and dashboard view declaration
- `src/index.ts` — plugin definition (`onSchemaInit`, routes, dashboard view metadata)
- `src/server/index.ts` — backend server exports
- `src/dashboard-view.tsx` — dashboard view entry export
- `src/roadmap-types.ts` + `src/store/*` — roadmap domain ownership target (migrated in follow-up steps)
- `src/dashboard-view.tsx` — dashboard view entry export for host registration
- `src/dashboard/RoadmapsView.tsx` — plugin-owned roadmap planner page
- `src/dashboard/useRoadmaps.ts` — plugin-owned roadmap CRUD/reorder/suggestions/handoff hook
- `src/dashboard/RoadmapsView.css` — plugin-owned roadmap styles
- `src/dashboard/api.ts` — plugin-local client for `/api/plugins/roadmap-planner/*`
- `src/roadmap-types.ts` + `src/store/*` — roadmap domain types/store
## Exported surfaces
@@ -24,4 +28,4 @@
## Notes
The plugin keeps a single canonical ID/path (`fusion-plugin-roadmap`). Do not introduce alternate route namespaces or plugin IDs for this feature.
The plugin keeps a single canonical dashboard entrypoint (`./dashboard-view`) and accepts host-supplied dashboard context (`projectId`, optional `addToast`). Do not deep-import dashboard internals from this plugin.

View File

@@ -14,8 +14,8 @@
"import": "./src/server/index.ts"
},
"./dashboard-view": {
"types": "./src/dashboard-view.ts",
"import": "./src/dashboard-view.ts"
"types": "./src/dashboard-view.tsx",
"import": "./src/dashboard-view.tsx"
},
"./roadmap-suggestions": {
"types": "./src/roadmap-suggestions.d.ts",
@@ -30,11 +30,18 @@
"@fusion/core": "workspace:*",
"@fusion/dashboard": "workspace:*",
"@fusion/plugin-sdk": "workspace:*",
"express": "^5.1.0"
"express": "^5.1.0",
"lucide-react": "^0.542.0",
"react": "^19.0.0",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/express": "^5.0.5",
"@types/node": "^25.5.2",
"@types/react": "^19.0.0",
"typescript": "^5.7.0",
"vitest": "^3.2.4"
}

View File

@@ -1,3 +0,0 @@
export function RoadmapsView() {
return null;
}

View File

@@ -1,3 +0,0 @@
import { RoadmapsView } from "./RoadmapsViewBridge.js";
export { RoadmapsView as RoadmapDashboardView };

View File

@@ -0,0 +1,6 @@
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
import { RoadmapsView } from "./dashboard/RoadmapsView.js";
export function RoadmapDashboardView({ context }: { context?: PluginDashboardViewContext }) {
return <RoadmapsView projectId={context?.projectId} addToast={context?.addToast ?? (() => undefined)} />;
}

View File

@@ -1,10 +1,10 @@
import { useState, useCallback, useEffect, useRef } from "react";
import React, { useState, useCallback, useEffect, useRef } from "react";
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader, ArrowLeft, ChevronUp } from "lucide-react";
import "./RoadmapsView.css";
import type { ToastType } from "../hooks/useToast";
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "../hooks/useRoadmaps";
import { useViewportMode } from "../hooks/useViewportMode";
import { useConfirm } from "../hooks/useConfirm";
import type { ToastType } from "./types.js";
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "./useRoadmaps.js";
import { useViewportMode } from "./useViewportMode.js";
import { useConfirm } from "./useConfirm.js";
import type {
Roadmap,
RoadmapMilestone,
@@ -17,7 +17,7 @@ import type {
RoadmapFeatureUpdateInput,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
} from "@fusion-plugin-examples/roadmap";
} from "../roadmap-types.js";
export interface RoadmapsViewProps {
projectId?: string;

View File

@@ -1,17 +1,19 @@
/* @vitest-environment jsdom */
import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { RoadmapsView } from "../RoadmapsView";
import * as api from "../../api";
import * as api from "../api";
import type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapWithHierarchy,
} from "@fusion/core";
} from "../../roadmap-types";
// Mock the API module
vi.mock("../../api", () => ({
vi.mock("../api", () => ({
fetchRoadmaps: vi.fn(),
fetchRoadmap: vi.fn(),
createRoadmap: vi.fn(),
@@ -33,7 +35,7 @@ vi.mock("../../api", () => ({
// Mock lucide-react icons
const mockConfirm = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({
vi.mock("../useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));

View File

@@ -1,10 +1,11 @@
/* @vitest-environment jsdom */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useRoadmaps } from "../useRoadmaps";
import * as api from "../../api";
import * as api from "../api";
// Mock the API module
vi.mock("../../api", () => ({
vi.mock("../api", () => ({
fetchRoadmaps: vi.fn(),
fetchRoadmap: vi.fn(),
createRoadmap: vi.fn(),
@@ -41,7 +42,7 @@ const mockRoadmaps = [
},
];
const mockRoadmapHierarchy: import("@fusion/core").RoadmapWithHierarchy = {
const mockRoadmapHierarchy: import("../../roadmap-types").RoadmapWithHierarchy = {
id: "RM-001",
title: "Q2 Roadmap",
description: "Q2 product roadmap",
@@ -559,7 +560,7 @@ describe("useRoadmaps", () => {
it("reorders features within a milestone with optimistic update", async () => {
// This test requires multiple features to meaningfully test reordering
// We'll create a custom hierarchy with multiple features
const multiFeatureHierarchy: import("@fusion/core").RoadmapWithHierarchy = {
const multiFeatureHierarchy: import("../../roadmap-types").RoadmapWithHierarchy = {
...mockRoadmapHierarchy,
milestones: [
{
@@ -600,7 +601,7 @@ describe("useRoadmaps", () => {
it("rolls back on failure", async () => {
// This test requires multiple features
const multiFeatureHierarchy: import("@fusion/core").RoadmapWithHierarchy = {
const multiFeatureHierarchy: import("../../roadmap-types").RoadmapWithHierarchy = {
...mockRoadmapHierarchy,
milestones: [
{

View File

@@ -0,0 +1,70 @@
import type {
Roadmap,
RoadmapCreateInput,
RoadmapUpdateInput,
RoadmapMilestone,
RoadmapMilestoneCreateInput,
RoadmapMilestoneUpdateInput,
RoadmapFeature,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapWithHierarchy,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
} from "../roadmap-types.js";
const BASE = "/api/plugins/roadmap-planner";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const body = (await response.json()) as { error?: string };
if (body.error) message = body.error;
} catch {
// ignore
}
throw new Error(message);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
function qp(projectId?: string): string {
return projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
}
export function fetchRoadmaps(projectId?: string): Promise<Roadmap[]> { return request(`/roadmaps${qp(projectId)}`); }
export function fetchRoadmap(roadmapId: string, projectId?: string): Promise<RoadmapWithHierarchy> { return request(`/roadmaps/${roadmapId}${qp(projectId)}`); }
export function createRoadmap(input: RoadmapCreateInput, projectId?: string): Promise<Roadmap> { return request(`/roadmaps${qp(projectId)}`, { method: "POST", body: JSON.stringify({ ...input, projectId }) }); }
export function updateRoadmap(roadmapId: string, updates: RoadmapUpdateInput, projectId?: string): Promise<Roadmap> { return request(`/roadmaps/${roadmapId}${qp(projectId)}`, { method: "PATCH", body: JSON.stringify({ ...updates, projectId }) }); }
export function deleteRoadmap(roadmapId: string, projectId?: string): Promise<void> { return request(`/roadmaps/${roadmapId}${qp(projectId)}`, { method: "DELETE" }); }
export function createRoadmapMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput, projectId?: string): Promise<RoadmapMilestone> { return request(`/roadmaps/${roadmapId}/milestones${qp(projectId)}`, { method: "POST", body: JSON.stringify({ ...input, projectId }) }); }
export function updateRoadmapMilestone(milestoneId: string, updates: RoadmapMilestoneUpdateInput, projectId?: string): Promise<RoadmapMilestone> { return request(`/roadmaps/milestones/${milestoneId}${qp(projectId)}`, { method: "PATCH", body: JSON.stringify({ ...updates, projectId }) }); }
export function deleteRoadmapMilestone(milestoneId: string, projectId?: string): Promise<void> { return request(`/roadmaps/milestones/${milestoneId}${qp(projectId)}`, { method: "DELETE" }); }
export function reorderRoadmapMilestones(roadmapId: string, orderedMilestoneIds: string[], projectId?: string): Promise<void> { return request(`/roadmaps/${roadmapId}/milestones/reorder${qp(projectId)}`, { method: "POST", body: JSON.stringify({ orderedMilestoneIds, projectId }) }); }
export function createRoadmapFeature(milestoneId: string, input: RoadmapFeatureCreateInput, projectId?: string): Promise<RoadmapFeature> { return request(`/roadmaps/milestones/${milestoneId}/features${qp(projectId)}`, { method: "POST", body: JSON.stringify({ ...input, projectId }) }); }
export function updateRoadmapFeature(featureId: string, updates: RoadmapFeatureUpdateInput, projectId?: string): Promise<RoadmapFeature> { return request(`/roadmaps/features/${featureId}${qp(projectId)}`, { method: "PATCH", body: JSON.stringify({ ...updates, projectId }) }); }
export function deleteRoadmapFeature(featureId: string, projectId?: string): Promise<void> { return request(`/roadmaps/features/${featureId}${qp(projectId)}`, { method: "DELETE" }); }
export function reorderRoadmapFeatures(milestoneId: string, orderedFeatureIds: string[], projectId?: string): Promise<void> { return request(`/roadmaps/milestones/${milestoneId}/features/reorder${qp(projectId)}`, { method: "POST", body: JSON.stringify({ orderedFeatureIds, projectId }) }); }
export function moveRoadmapFeature(featureId: string, targetMilestoneId: string, targetIndex: number, projectId?: string): Promise<void> { return request(`/roadmaps/features/${featureId}/move${qp(projectId)}`, { method: "POST", body: JSON.stringify({ targetMilestoneId, targetIndex, projectId }) }); }
export function generateMilestoneSuggestions(roadmapId: string, goalPrompt: string, count = 5, projectId?: string): Promise<{ suggestions: Array<{ title: string; description?: string }> }> {
return request(`/roadmaps/${roadmapId}/suggestions/milestones${qp(projectId)}`, { method: "POST", body: JSON.stringify({ goalPrompt, count, projectId }) });
}
export function generateFeatureSuggestions(milestoneId: string, input?: { prompt?: string; count?: number }, projectId?: string): Promise<{ suggestions: Array<{ title: string; description?: string }> }> {
return request(`/roadmaps/milestones/${milestoneId}/suggestions/features${qp(projectId)}`, { method: "POST", body: JSON.stringify({ ...input, projectId }) });
}
export function fetchRoadmapHandoff(roadmapId: string, projectId?: string): Promise<{ mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] }> {
return request(`/roadmaps/${roadmapId}/handoff${qp(projectId)}`);
}

View File

@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});

View File

@@ -0,0 +1 @@
export type ToastType = "success" | "error" | "warning" | "info";

View File

@@ -0,0 +1,8 @@
export function useConfirm() {
return {
confirm: async (input: string | { title?: string; message?: string; danger?: boolean }): Promise<boolean> => {
const message = typeof input === "string" ? input : [input.title, input.message].filter(Boolean).join("\n\n");
return window.confirm(message || "Are you sure?");
},
};
}

View File

@@ -12,8 +12,8 @@ import type {
RoadmapWithHierarchy,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
} from "@fusion-plugin-examples/roadmap";
import * as api from "../api";
} from "../roadmap-types.js";
import * as api from "./api.js";
/**
* A suggested milestone from AI generation with a stable local draft ID.

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
export type ViewportMode = "mobile" | "tablet" | "desktop";
function getViewportMode(): ViewportMode {
if (typeof window === "undefined") return "desktop";
if (window.matchMedia("(max-width: 768px)").matches) return "mobile";
if (window.matchMedia("(min-width: 769px) and (max-width: 1024px)").matches) return "tablet";
return "desktop";
}
export function useViewportMode(): ViewportMode {
const [mode, setMode] = useState<ViewportMode>(() => getViewportMode());
useEffect(() => {
const onResize = () => setMode(getViewportMode());
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return mode;
}

View File

@@ -2,8 +2,9 @@
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"jsx": "react-jsx"
},
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]
}

View File

@@ -12,8 +12,12 @@ export default defineConfig({
},
},
test: {
include: ["src/**/*.test.ts"],
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
include: ["src/**/__tests__/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
environmentMatchGlobs: [["src/dashboard/__tests__/**", "jsdom"]],
setupFiles: [
fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url)),
fileURLToPath(new URL("./src/dashboard/test-setup.ts", import.meta.url)),
],
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
pool: "threads",
maxWorkers,

21
pnpm-lock.yaml generated
View File

@@ -808,13 +808,34 @@ importers:
express:
specifier: ^5.1.0
version: 5.2.1
lucide-react:
specifier: ^0.542.0
version: 0.542.0(react@19.2.4)
react:
specifier: ^19.0.0
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
devDependencies:
'@testing-library/jest-dom':
specifier: ^6.6.3
version: 6.9.1
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@types/express':
specifier: ^5.0.5
version: 5.0.6
'@types/node':
specifier: ^25.5.2
version: 25.5.2
'@types/react':
specifier: ^19.0.0
version: 19.2.14
typescript:
specifier: ^5.7.0
version: 5.9.3