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:
@@ -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)} />;
|
||||
}
|
||||
1299
plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.css
Normal file
1299
plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.css
Normal file
File diff suppressed because it is too large
Load Diff
2559
plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx
Normal file
2559
plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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?");
|
||||
},
|
||||
};
|
||||
}
|
||||
1188
plugins/fusion-plugin-roadmap/src/dashboard/useRoadmaps.ts
Normal file
1188
plugins/fusion-plugin-roadmap/src/dashboard/useRoadmaps.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user