feat(FN-1670): add roadmap drag-and-drop with milestone and feature reordering

- Implement RoadmapsView component with drag-and-drop support for milestones and features
- Add milestone reorder via drag-and-drop with optimistic UI updates and API persistence
- Add feature reorder within milestones and cross-milestone move support
- Add useRoadmaps hook with full CRUD API for roadmaps, milestones, and features
- Add /roadmaps route with sidebar navigation to roadmap views
- Add comprehensive tests for API, useRoadmaps hook, and RoadmapsView component
- Update dashboard guide with roadmap management documentation
- Harden API with race-condition safety and proper error handling
This commit is contained in:
Fusion
2026-04-15 06:55:34 -07:00
committed by gsxdsm
parent 8eb68b091b
commit 940494272d
9 changed files with 1408 additions and 5 deletions

View File

@@ -4191,4 +4191,117 @@ describe("Settings API wrappers", () => {
await expect(fetchGlobalSettings()).rejects.toThrow("Settings file corrupted");
});
});
describe("roadmap reorder APIs", () => {
it("reorderRoadmapMilestones sends POST with orderedMilestoneIds", async () => {
const { reorderRoadmapMilestones } = await import("./api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await reorderRoadmapMilestones("RM-001", ["RMS-002", "RMS-001", "RMS-003"]);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/roadmaps/RM-001/milestones/reorder");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body as string)).toEqual({
orderedMilestoneIds: ["RMS-002", "RMS-001", "RMS-003"],
});
});
it("reorderRoadmapMilestones includes projectId when provided", async () => {
const { reorderRoadmapMilestones } = await import("./api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await reorderRoadmapMilestones("RM-001", ["RMS-001", "RMS-002"], "proj_abc");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/roadmaps/RM-001/milestones/reorder?projectId=proj_abc");
});
it("reorderRoadmapFeatures sends POST with orderedFeatureIds", async () => {
const { reorderRoadmapFeatures } = await import("./api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await reorderRoadmapFeatures("RMS-001", ["RF-002", "RF-001"]);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/roadmaps/milestones/RMS-001/features/reorder");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body as string)).toEqual({
orderedFeatureIds: ["RF-002", "RF-001"],
});
});
it("moveRoadmapFeature sends POST with targetMilestoneId and targetIndex", async () => {
const { moveRoadmapFeature } = await import("./api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await moveRoadmapFeature("RF-001", "RMS-002", 2);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/roadmaps/features/RF-001/move");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body as string)).toEqual({
targetMilestoneId: "RMS-002",
targetIndex: 2,
});
});
it("moveRoadmapFeature includes projectId when provided", async () => {
const { moveRoadmapFeature } = await import("./api");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
headers: {
get: () => null,
},
text: () => Promise.resolve(""),
} as unknown as Response);
await moveRoadmapFeature("RF-001", "RMS-002", 0, "proj_xyz");
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe("/api/roadmaps/features/RF-001/move?projectId=proj_xyz");
});
});
});