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:
5
.changeset/fn-3161-removal.md
Normal file
5
.changeset/fn-3161-removal.md
Normal 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`).
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
|
||||
@@ -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}`. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function RoadmapsView() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { RoadmapsView } from "./RoadmapsViewBridge.js";
|
||||
|
||||
export { RoadmapsView as RoadmapDashboardView };
|
||||
6
plugins/fusion-plugin-roadmap/src/dashboard-view.tsx
Normal file
6
plugins/fusion-plugin-roadmap/src/dashboard-view.tsx
Normal 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)} />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
70
plugins/fusion-plugin-roadmap/src/dashboard/api.ts
Normal file
70
plugins/fusion-plugin-roadmap/src/dashboard/api.ts
Normal 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)}`);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
1
plugins/fusion-plugin-roadmap/src/dashboard/types.ts
Normal file
1
plugins/fusion-plugin-roadmap/src/dashboard/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ToastType = "success" | "error" | "warning" | "info";
|
||||
@@ -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?");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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__/**"]
|
||||
}
|
||||
|
||||
@@ -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
21
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user