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:
@@ -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 (
|
||||
|
||||
@@ -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`");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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" : ""}`}
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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" },
|
||||
},
|
||||
]}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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()));
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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">
|
||||
|
||||
27
packages/dashboard/app/plugins/registerBundledPluginViews.ts
Normal file
27
packages/dashboard/app/plugins/registerBundledPluginViews.ts
Normal 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 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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}`. */
|
||||
|
||||
Reference in New Issue
Block a user