Files
fusion/packages/dashboard/app/hooks/useRoadmaps.ts
Fusion 6d853036ff 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
2026-04-15 06:55:34 -07:00

541 lines
19 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapCreateInput,
RoadmapUpdateInput,
RoadmapMilestoneCreateInput,
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapWithHierarchy,
} from "@fusion/core";
import * as api from "../api";
export interface UseRoadmapsOptions {
/** When provided, fetches roadmaps for this project */
projectId?: string;
}
export interface UseRoadmapsResult {
/** All roadmaps for the current project */
roadmaps: Roadmap[];
/** Currently selected roadmap ID */
selectedRoadmapId: string | null;
/** Selected roadmap with full hierarchy (milestones and features) */
selectedRoadmap: RoadmapWithHierarchy | null;
/** Milestones for the selected roadmap */
milestones: RoadmapMilestone[];
/** Features by milestone ID */
featuresByMilestoneId: Record<string, RoadmapFeature[]>;
/** Loading state */
loading: boolean;
/** Error state */
error: Error | null;
// Roadmap CRUD callbacks
/** Create a new roadmap */
createRoadmap: (input: RoadmapCreateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Update a roadmap */
updateRoadmap: (roadmapId: string, updates: RoadmapUpdateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Delete a roadmap */
deleteRoadmap: (roadmapId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Select a roadmap to view its details */
selectRoadmap: (roadmapId: string | null) => void;
// Milestone CRUD callbacks
/** Create a milestone in the selected roadmap */
createMilestone: (input: RoadmapMilestoneCreateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Update a milestone */
updateMilestone: (milestoneId: string, updates: RoadmapMilestoneUpdateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Delete a milestone */
deleteMilestone: (milestoneId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
// Milestone ordering callbacks
/** Reorder milestones within a roadmap */
reorderMilestones: (roadmapId: string, orderedMilestoneIds: string[], opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
// Feature CRUD callbacks
/** Create a feature in a milestone */
createFeature: (milestoneId: string, input: RoadmapFeatureCreateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Update a feature */
updateFeature: (featureId: string, updates: RoadmapFeatureUpdateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Delete a feature */
deleteFeature: (featureId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
// Feature ordering callbacks
/** Reorder features within a milestone */
reorderFeatures: (milestoneId: string, orderedFeatureIds: string[], opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Move a feature to a different milestone or position */
moveFeature: (featureId: string, targetMilestoneId: string, targetIndex: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Refresh all roadmaps */
refresh: () => Promise<void>;
}
export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
const projectId = options?.projectId;
const [roadmaps, setRoadmaps] = useState<Roadmap[]>([]);
const [selectedRoadmapId, setSelectedRoadmapId] = useState<string | null>(null);
const [selectedRoadmap, setSelectedRoadmap] = useState<RoadmapWithHierarchy | null>(null);
const [milestones, setMilestones] = useState<RoadmapMilestone[]>([]);
const [featuresByMilestoneId, setFeaturesByMilestoneId] = useState<Record<string, RoadmapFeature[]>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Track previous projectId to detect changes
const previousProjectIdRef = useRef<string | undefined>(projectId);
// Refs to access latest state in callbacks
const roadmapsRef = useRef(roadmaps);
const selectedRoadmapIdRef = useRef(selectedRoadmapId);
const milestonesRef = useRef(milestones);
const featuresByMilestoneIdRef = useRef(featuresByMilestoneId);
const projectIdRef = useRef(projectId);
roadmapsRef.current = roadmaps;
selectedRoadmapIdRef.current = selectedRoadmapId;
milestonesRef.current = milestones;
featuresByMilestoneIdRef.current = featuresByMilestoneId;
projectIdRef.current = projectId;
// Clear selection when project changes
useEffect(() => {
if (previousProjectIdRef.current !== projectId) {
previousProjectIdRef.current = projectId;
setSelectedRoadmapId(null);
setSelectedRoadmap(null);
setMilestones([]);
setFeaturesByMilestoneId({});
}
}, [projectId]);
// Fetch roadmaps on mount and when projectId changes
const fetchRoadmaps = useCallback(async () => {
setLoading(true);
setError(null);
try {
const fetchedRoadmaps = await api.fetchRoadmaps(projectId);
setRoadmaps(fetchedRoadmaps);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to fetch roadmaps"));
} finally {
setLoading(false);
}
}, [projectId]);
// Fetch selected roadmap with full hierarchy
const fetchSelectedRoadmap = useCallback(async (roadmapId: string) => {
try {
const roadmap = await api.fetchRoadmap(roadmapId, projectId);
setSelectedRoadmap(roadmap);
setMilestones(roadmap.milestones || []);
// Build features by milestone ID
const featuresMap: Record<string, RoadmapFeature[]> = {};
for (const milestone of roadmap.milestones || []) {
featuresMap[milestone.id] = milestone.features || [];
}
setFeaturesByMilestoneId(featuresMap);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to fetch roadmap"));
}
}, [projectId]);
// Initial fetch
useEffect(() => {
void fetchRoadmaps();
}, [fetchRoadmaps]);
// Fetch selected roadmap when selection changes
useEffect(() => {
if (selectedRoadmapId) {
void fetchSelectedRoadmap(selectedRoadmapId);
} else {
setSelectedRoadmap(null);
setMilestones([]);
setFeaturesByMilestoneId({});
}
}, [selectedRoadmapId, fetchSelectedRoadmap]);
// Roadmap CRUD
const createRoadmap = useCallback(async (
input: RoadmapCreateInput,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
const newRoadmap = await api.createRoadmap(input, projectId);
setRoadmaps((prev) => [...prev, newRoadmap]);
opts?.onSuccess?.();
} catch (err) {
const error = err instanceof Error ? err : new Error("Failed to create roadmap");
opts?.onError?.(error);
throw error;
}
}, [projectId]);
const updateRoadmap = useCallback(async (
roadmapId: string,
updates: RoadmapUpdateInput,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
const updated = await api.updateRoadmap(roadmapId, updates, projectId);
setRoadmaps((prev) => prev.map((r) => (r.id === roadmapId ? updated : r)));
if (selectedRoadmapIdRef.current === roadmapId) {
setSelectedRoadmap((prev) => prev ? { ...prev, ...updated } : null);
}
opts?.onSuccess?.();
} catch (err) {
const error = err instanceof Error ? err : new Error("Failed to update roadmap");
opts?.onError?.(error);
throw error;
}
}, [projectId]);
const deleteRoadmap = useCallback(async (
roadmapId: string,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
await api.deleteRoadmap(roadmapId, projectId);
setRoadmaps((prev) => prev.filter((r) => r.id !== roadmapId));
if (selectedRoadmapIdRef.current === roadmapId) {
setSelectedRoadmapId(null);
setSelectedRoadmap(null);
setMilestones([]);
setFeaturesByMilestoneId({});
}
opts?.onSuccess?.();
} catch (err) {
const error = err instanceof Error ? err : new Error("Failed to delete roadmap");
opts?.onError?.(error);
throw error;
}
}, [projectId]);
const selectRoadmap = useCallback((roadmapId: string | null) => {
setSelectedRoadmapId(roadmapId);
}, []);
// Milestone CRUD
const createMilestone = useCallback(async (
input: RoadmapMilestoneCreateInput,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
const currentRoadmapId = selectedRoadmapIdRef.current;
if (!currentRoadmapId) {
const error = new Error("No roadmap selected");
opts?.onError?.(error);
throw error;
}
try {
const newMilestone = await api.createRoadmapMilestone(currentRoadmapId, input, projectIdRef.current);
setMilestones((prev) => [...prev, newMilestone]);
setFeaturesByMilestoneId((prev) => ({ ...prev, [newMilestone.id]: [] }));
// Refresh the full roadmap to get updated hierarchy
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
} catch (err) {
const error = err instanceof Error ? err : new Error("Failed to create milestone");
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap]);
const updateMilestone = useCallback(async (
milestoneId: string,
updates: RoadmapMilestoneUpdateInput,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
const updated = await api.updateRoadmapMilestone(milestoneId, updates, projectId);
setMilestones((prev) => prev.map((m) => (m.id === milestoneId ? updated : m)));
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
} catch (err) {
const error = err instanceof Error ? err : new Error("Failed to update milestone");
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
const deleteMilestone = useCallback(async (
milestoneId: string,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
await api.deleteRoadmapMilestone(milestoneId, projectId);
setMilestones((prev) => prev.filter((m) => m.id !== milestoneId));
setFeaturesByMilestoneId((prev) => {
const updated = { ...prev };
delete updated[milestoneId];
return updated;
});
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
} catch (err) {
const error = err instanceof Error ? err : new Error("Failed to delete milestone");
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
// Feature CRUD
const createFeature = useCallback(async (
milestoneId: string,
input: RoadmapFeatureCreateInput,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
const newFeature = await api.createRoadmapFeature(milestoneId, input, projectId);
setFeaturesByMilestoneId((prev) => ({
...prev,
[milestoneId]: [...(prev[milestoneId] || []), newFeature],
}));
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
} catch (err) {
const error = err instanceof Error ? err : new Error("Failed to create feature");
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
const updateFeature = useCallback(async (
featureId: string,
updates: RoadmapFeatureUpdateInput,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
const updated = await api.updateRoadmapFeature(featureId, updates, projectId);
setFeaturesByMilestoneId((prev) => {
const updatedMap: Record<string, RoadmapFeature[]> = {};
for (const [milestoneId, features] of Object.entries(prev)) {
updatedMap[milestoneId] = features.map((f) => (f.id === featureId ? updated : f));
}
return updatedMap;
});
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
} catch (err) {
const error = err instanceof Error ? err : new Error("Failed to update feature");
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
const deleteFeature = useCallback(async (
featureId: string,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
try {
await api.deleteRoadmapFeature(featureId, projectId);
setFeaturesByMilestoneId((prev) => {
const updatedMap: Record<string, RoadmapFeature[]> = {};
for (const [milestoneId, features] of Object.entries(prev)) {
updatedMap[milestoneId] = features.filter((f) => f.id !== featureId);
}
return updatedMap;
});
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
} catch (err) {
const error = err instanceof Error ? err : new Error("Failed to delete feature");
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
// Milestone ordering
const reorderMilestones = useCallback(async (
roadmapId: string,
orderedMilestoneIds: string[],
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
// Save snapshot for rollback
const snapshot = milestonesRef.current;
// Optimistic update
const reordered = orderedMilestoneIds
.map((id) => snapshot.find((m) => m.id === id))
.filter((m): m is RoadmapMilestone => m !== undefined)
.map((m, index) => ({ ...m, orderIndex: index }));
setMilestones(reordered);
try {
await api.reorderRoadmapMilestones(roadmapId, orderedMilestoneIds, projectId);
// Refresh to get server state
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
} catch (err) {
// Rollback to snapshot
setMilestones(snapshot);
const error = err instanceof Error ? err : new Error("Failed to reorder milestones");
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
// Feature ordering
const reorderFeatures = useCallback(async (
milestoneId: string,
orderedFeatureIds: string[],
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
// Save snapshot for rollback
const snapshot = featuresByMilestoneIdRef.current;
const currentFeatures = snapshot[milestoneId] || [];
// Optimistic update
const reordered = orderedFeatureIds
.map((id) => currentFeatures.find((f) => f.id === id))
.filter((f): f is RoadmapFeature => f !== undefined)
.map((f, index) => ({ ...f, orderIndex: index }));
setFeaturesByMilestoneId((prev) => ({
...prev,
[milestoneId]: reordered,
}));
try {
await api.reorderRoadmapFeatures(milestoneId, orderedFeatureIds, projectId);
// Refresh to get server state
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
} catch (err) {
// Rollback to snapshot
setFeaturesByMilestoneId(snapshot);
const error = err instanceof Error ? err : new Error("Failed to reorder features");
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
const moveFeature = useCallback(async (
featureId: string,
targetMilestoneId: string,
targetIndex: number,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
// Save snapshot for rollback
const snapshot = featuresByMilestoneIdRef.current;
// Find which milestone the feature is currently in
let sourceMilestoneId: string | null = null;
for (const [milestoneId, features] of Object.entries(snapshot)) {
if (features.some((f) => f.id === featureId)) {
sourceMilestoneId = milestoneId;
break;
}
}
if (!sourceMilestoneId) {
const error = new Error("Feature not found");
opts?.onError?.(error);
throw error;
}
// Optimistic update
const sourceFeatures = snapshot[sourceMilestoneId] || [];
const targetFeatures = snapshot[targetMilestoneId] || [];
const feature = sourceFeatures.find((f) => f.id === featureId);
if (!feature) {
const error = new Error("Feature not found");
opts?.onError?.(error);
throw error;
}
// Remove from source
const newSourceFeatures = sourceFeatures
.filter((f) => f.id !== featureId)
.map((f, index) => ({ ...f, orderIndex: index }));
// Add to target at correct position
const updatedFeature = { ...feature, milestoneId: targetMilestoneId, orderIndex: targetIndex };
const newTargetFeatures = [...targetFeatures];
newTargetFeatures.splice(targetIndex, 0, updatedFeature);
// Renormalize target
const normalizedTargetFeatures = newTargetFeatures.map((f, index) => ({ ...f, orderIndex: index }));
// If moving within same milestone, update source with the new order
if (sourceMilestoneId === targetMilestoneId) {
setFeaturesByMilestoneId((prev) => ({
...prev,
[sourceMilestoneId]: normalizedTargetFeatures,
}));
} else {
// Renormalize source after removal
setFeaturesByMilestoneId((prev) => ({
...prev,
[sourceMilestoneId]: newSourceFeatures,
[targetMilestoneId]: normalizedTargetFeatures,
}));
}
try {
await api.moveRoadmapFeature(featureId, targetMilestoneId, targetIndex, projectId);
// Refresh to get server state
if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
opts?.onSuccess?.();
} catch (err) {
// Rollback to snapshot
setFeaturesByMilestoneId(snapshot);
const error = err instanceof Error ? err : new Error("Failed to move feature");
opts?.onError?.(error);
throw error;
}
}, [fetchSelectedRoadmap, projectId]);
const refresh = useCallback(async () => {
await fetchRoadmaps();
if (selectedRoadmapIdRef.current) {
await fetchSelectedRoadmap(selectedRoadmapIdRef.current);
}
}, [fetchRoadmaps, fetchSelectedRoadmap]);
return {
roadmaps,
selectedRoadmapId,
selectedRoadmap,
milestones,
featuresByMilestoneId,
loading,
error,
createRoadmap,
updateRoadmap,
deleteRoadmap,
selectRoadmap,
createMilestone,
updateMilestone,
deleteMilestone,
reorderMilestones,
createFeature,
updateFeature,
deleteFeature,
reorderFeatures,
moveFeature,
refresh,
};
}