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 8c0d16a283
commit 6d853036ff
9 changed files with 1408 additions and 5 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import { Plus, Pencil, Trash2, Check, X } from "lucide-react";
import { Plus, Pencil, Trash2, Check, X, GripVertical } from "lucide-react";
import type { ToastType } from "../hooks/useToast";
import { useRoadmaps } from "../hooks/useRoadmaps";
import type {
@@ -19,6 +19,22 @@ export interface RoadmapsViewProps {
addToast: (message: string, type?: ToastType) => void;
}
// ── Drag State Types ────────────────────────────────────────────────
interface MilestoneDragState {
draggingId: string | null;
dropTargetId: string | null;
dropPosition: "before" | "after" | null;
}
interface FeatureDragState {
draggingId: string | null;
draggingMilestoneId: string | null;
dropTargetMilestoneId: string | null;
dropTargetIndex: number | null;
dropPosition: "before" | "after" | null;
}
// ── Inline Edit State Types ─────────────────────────────────────────
interface InlineEditState {
@@ -143,6 +159,25 @@ function MilestoneCard({
onSaveFeatureEdit,
projectId,
addToast,
// Milestone drag-and-drop props
isMilestoneDragging,
isMilestoneDropTarget,
milestoneDropPosition,
onMilestoneDragStart,
onMilestoneDragEnd,
onMilestoneDragOver,
onMilestoneDrop,
onMilestoneDragLeave,
// Feature drag-and-drop props
isFeatureDragging,
isFeatureDropTarget,
featureDropIndex,
onFeatureDragStart,
onFeatureDragEnd,
onFeatureDragOver,
onFeatureDrop,
onFeatureDragLeave,
onFeatureDropOnMilestone,
}: {
milestone: RoadmapMilestone;
features: RoadmapFeature[];
@@ -161,6 +196,25 @@ function MilestoneCard({
onSaveFeatureEdit: (updates: RoadmapFeatureUpdateInput) => void;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
// Milestone drag-and-drop props
isMilestoneDragging: boolean;
isMilestoneDropTarget: boolean;
milestoneDropPosition: "before" | "after" | null;
onMilestoneDragStart: (milestoneId: string) => void;
onMilestoneDragEnd: () => void;
onMilestoneDragOver: (milestoneId: string) => void;
onMilestoneDrop: (milestoneId: string) => void;
onMilestoneDragLeave: (e: React.DragEvent) => void;
// Feature drag-and-drop props
isFeatureDragging: (featureId: string) => boolean;
isFeatureDropTarget: boolean;
featureDropIndex: number | null;
onFeatureDragStart: (featureId: string, milestoneId: string) => void;
onFeatureDragEnd: () => void;
onFeatureDragOver: (featureId: string, position: "before" | "after") => void;
onFeatureDrop: (featureId: string, targetIndex: number) => void;
onFeatureDragLeave: (e: React.DragEvent) => void;
onFeatureDropOnMilestone: () => void;
}) {
const isEditingMilestone = milestoneEdit?.milestoneId === milestone.id;
@@ -181,12 +235,70 @@ function MilestoneCard({
}
};
// Build class names for drag states
const milestoneClasses = [
"roadmaps-view__milestone",
isMilestoneDragging ? "roadmaps-view__milestone--dragging" : "",
isMilestoneDropTarget ? "roadmaps-view__milestone--drop-target" : "",
isMilestoneDropTarget && milestoneDropPosition === "before" ? "roadmaps-view__milestone--drop-before" : "",
isMilestoneDropTarget && milestoneDropPosition === "after" ? "roadmaps-view__milestone--drop-after" : "",
].filter(Boolean).join(" ");
// Build class names for feature list drop state
const featureListClasses = [
"roadmaps-view__feature-list",
isFeatureDropTarget ? "roadmaps-view__feature-list--drop-target" : "",
].filter(Boolean).join(" ");
return (
<div className="roadmaps-view__milestone">
<div
className={milestoneClasses}
draggable={!isEditingMilestone}
onDragStart={(e) => {
if (!isEditingMilestone) {
onMilestoneDragStart(milestone.id);
e.dataTransfer.setData("text/plain", `milestone:${milestone.id}`);
e.dataTransfer.effectAllowed = "move";
}
}}
onDragEnd={onMilestoneDragEnd}
onDragOver={(e) => {
// Only prevent default for milestone drops, not feature drops
if (e.dataTransfer.types.includes("text/plain")) {
const data = e.dataTransfer.types.includes("text/plain");
if (data) {
// This is a milestone drag
e.preventDefault();
e.dataTransfer.dropEffect = "move";
onMilestoneDragOver(milestone.id);
}
}
}}
onDrop={(e) => {
e.preventDefault();
// Check if this is a feature drop or milestone drop
const data = e.dataTransfer.getData("text/plain");
if (data?.startsWith("feature:")) {
// Feature drop - handled by child element
} else {
onMilestoneDrop(milestone.id);
}
}}
onDragLeave={onMilestoneDragLeave}
data-testid={`milestone-card-${milestone.id}`}
>
<div className="roadmaps-view__milestone-header">
{isEditingMilestone ? (
<div className="roadmaps-view__inline-edit">
<div className="roadmaps-view__inline-edit-row">
<span
className="roadmaps-view__drag-handle"
title="Drag to reorder"
aria-label="Drag to reorder"
data-testid={`milestone-drag-handle-${milestone.id}`}
>
<GripVertical size={14} />
</span>
<input
type="text"
className="roadmaps-view__inline-input"
@@ -231,6 +343,14 @@ function MilestoneCard({
) : (
<>
<div className="roadmaps-view__milestone-title-row">
<span
className="roadmaps-view__drag-handle"
title="Drag to reorder"
aria-label="Drag to reorder"
data-testid={`milestone-drag-handle-${milestone.id}`}
>
<GripVertical size={14} />
</span>
<h3 className="roadmaps-view__milestone-title">{milestone.title}</h3>
<div className="roadmaps-view__milestone-actions">
<button
@@ -273,12 +393,33 @@ function MilestoneCard({
</button>
</div>
<div className="roadmaps-view__feature-list">
<div
className={featureListClasses}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// Check if this is a feature being dragged
const data = e.dataTransfer.getData("text/plain");
if (data?.startsWith("feature:")) {
onFeatureDropOnMilestone();
}
}}
onDrop={(e) => {
e.preventDefault();
const data = e.dataTransfer.getData("text/plain");
if (data?.startsWith("feature:")) {
// Drop on empty area of feature list - append to end
onFeatureDrop(data.split(":")[1], features.length);
}
}}
onDragLeave={onFeatureDragLeave}
>
{features.length === 0 ? (
<p className="roadmaps-view__empty-features">No features yet.</p>
) : (
features.map((feature) => {
features.map((feature, index) => {
const isEditingFeature = featureEdit?.featureId === feature.id;
const isFeatureDraggingThis = isFeatureDragging(feature.id);
const handleFeatureTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
@@ -291,11 +432,71 @@ function MilestoneCard({
}
};
// Build class names for feature drag states
const featureClasses = [
"roadmaps-view__feature-item",
isFeatureDraggingThis ? "roadmaps-view__feature-item--dragging" : "",
isFeatureDropTarget && featureDropIndex === index ? "roadmaps-view__feature-item--drop-before" : "",
isFeatureDropTarget && featureDropIndex === index + 1 ? "roadmaps-view__feature-item--drop-after" : "",
].filter(Boolean).join(" ");
return (
<div key={feature.id} className="roadmaps-view__feature-item">
<div
key={feature.id}
className={featureClasses}
draggable={!isEditingFeature}
onDragStart={(e) => {
if (!isEditingFeature) {
onFeatureDragStart(feature.id, milestone.id);
e.dataTransfer.setData("text/plain", `feature:${feature.id}`);
e.dataTransfer.effectAllowed = "move";
}
}}
onDragEnd={onFeatureDragEnd}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
const data = e.dataTransfer.getData("text/plain");
if (data?.startsWith("feature:")) {
// Calculate position (before or after)
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position: "before" | "after" = e.clientY < midY ? "before" : "after";
onFeatureDragOver(feature.id, position);
}
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const data = e.dataTransfer.getData("text/plain");
if (data?.startsWith("feature:")) {
const draggedFeatureId = data.split(":")[1];
// Calculate target index
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position: "before" | "after" = e.clientY < midY ? "before" : "after";
let targetIndex = index;
if (position === "after") {
targetIndex = index + 1;
}
onFeatureDrop(draggedFeatureId, targetIndex);
}
}}
onDragLeave={onFeatureDragLeave}
data-testid={`feature-item-${feature.id}`}
>
{isEditingFeature ? (
<div className="roadmaps-view__inline-edit roadmaps-view__inline-edit--compact">
<div className="roadmaps-view__inline-edit-row">
<span
className="roadmaps-view__drag-handle roadmaps-view__drag-handle--feature"
title="Drag to reorder"
aria-label="Drag to reorder"
data-testid={`feature-drag-handle-${feature.id}`}
>
<GripVertical size={12} />
</span>
<input
type="text"
className="roadmaps-view__inline-input"
@@ -326,6 +527,14 @@ function MilestoneCard({
</div>
) : (
<>
<span
className="roadmaps-view__drag-handle roadmaps-view__drag-handle--feature"
title="Drag to reorder"
aria-label="Drag to reorder"
data-testid={`feature-drag-handle-${feature.id}`}
>
<GripVertical size={12} />
</span>
<div className="roadmaps-view__feature-content">
<span className="roadmaps-view__feature-title">{feature.title}</span>
{feature.description && (
@@ -571,6 +780,9 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
createFeature,
updateFeature,
deleteFeature,
reorderMilestones,
reorderFeatures,
moveFeature,
} = useRoadmaps({ projectId });
// Inline edit states
@@ -601,6 +813,263 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
// Mobile sidebar state
const [mobileSelectedRoadmapId, setMobileSelectedRoadmapId] = useState<string | null>(null);
// Milestone drag-and-drop state
const [milestoneDrag, setMilestoneDrag] = useState<MilestoneDragState>({
draggingId: null,
dropTargetId: null,
dropPosition: null,
});
// Milestone drag handlers
const handleMilestoneDragStart = useCallback((milestoneId: string) => {
setMilestoneDrag((prev) => ({
...prev,
draggingId: milestoneId,
}));
}, []);
const handleMilestoneDragEnd = useCallback(() => {
setMilestoneDrag({
draggingId: null,
dropTargetId: null,
dropPosition: null,
});
}, []);
const handleMilestoneDragOver = useCallback((targetMilestoneId: string) => {
setMilestoneDrag((prev) => {
// Don't update if dragging over self
if (prev.draggingId === targetMilestoneId) {
return prev;
}
// Calculate drop position based on mouse position relative to target
// The position will be computed based on where the drop will happen
// For now, we just track the target
return {
...prev,
dropTargetId: targetMilestoneId,
dropPosition: null, // Will be set in handleMilestoneDrop
};
});
}, []);
// Feature drag-and-drop state
const [featureDrag, setFeatureDrag] = useState<FeatureDragState>({
draggingId: null,
draggingMilestoneId: null,
dropTargetMilestoneId: null,
dropTargetIndex: null,
dropPosition: null,
});
// Feature drag handlers
const handleFeatureDragStart = useCallback((featureId: string, milestoneId: string) => {
setFeatureDrag((prev) => ({
...prev,
draggingId: featureId,
draggingMilestoneId: milestoneId,
}));
}, []);
const handleFeatureDragEnd = useCallback(() => {
setFeatureDrag({
draggingId: null,
draggingMilestoneId: null,
dropTargetMilestoneId: null,
dropTargetIndex: null,
dropPosition: null,
});
}, []);
const handleFeatureDragOver = useCallback((targetFeatureId: string, position: "before" | "after") => {
setFeatureDrag((prev) => {
// Don't update if dragging over self
if (prev.draggingId === targetFeatureId) {
return prev;
}
// Find the target feature's index in its milestone
const targetFeatures = featuresByMilestoneId[prev.draggingMilestoneId || ""] || [];
const targetIndex = targetFeatures.findIndex((f) => f.id === targetFeatureId);
let dropTargetIndex: number;
if (position === "before") {
dropTargetIndex = targetIndex;
} else {
dropTargetIndex = targetIndex + 1;
}
return {
...prev,
dropTargetMilestoneId: prev.draggingMilestoneId,
dropTargetIndex,
dropPosition: position,
};
});
}, [featuresByMilestoneId]);
const handleFeatureDropOnMilestone = useCallback(() => {
setFeatureDrag((prev) => ({
...prev,
dropTargetMilestoneId: prev.draggingMilestoneId,
// Append to end of feature list
dropTargetIndex: (featuresByMilestoneId[prev.draggingMilestoneId || ""] || []).length,
}));
}, [featuresByMilestoneId]);
const handleFeatureDrop = useCallback(async (featureId: string, targetIndex: number) => {
const { draggingMilestoneId, dropTargetMilestoneId } = featureDrag;
if (!draggingMilestoneId) {
handleFeatureDragEnd();
return;
}
// Determine the target milestone - use the drop target if available, otherwise the dragging milestone
const targetMilestoneId = dropTargetMilestoneId || draggingMilestoneId;
// Get the source features
const sourceFeatures = featuresByMilestoneId[draggingMilestoneId] || [];
// Find the feature being dragged
const featureBeingDragged = sourceFeatures.find((f) => f.id === featureId);
if (!featureBeingDragged) {
handleFeatureDragEnd();
return;
}
// Check if this is a cross-milestone move
const isCrossMilestone = draggingMilestoneId !== targetMilestoneId;
if (isCrossMilestone) {
// Cross-milestone move
const targetFeatures = featuresByMilestoneId[targetMilestoneId] || [];
// No-op check: if moving to same position in same milestone (shouldn't happen but safety check)
if (draggingMilestoneId === targetMilestoneId) {
handleFeatureDragEnd();
return;
}
// Perform the move
try {
await moveFeature(featureId, targetMilestoneId, targetIndex, {
onError: (err) => {
addToast(`Failed to move feature: ${err.message}`, "error");
},
});
} catch {
// Error handled in callback
}
} else {
// Same-milestone reorder
const targetFeatures = [...sourceFeatures];
const fromIndex = targetFeatures.findIndex((f) => f.id === featureId);
// Remove from current position and insert at target
targetFeatures.splice(fromIndex, 1);
targetFeatures.splice(targetIndex, 0, featureBeingDragged);
// Compute new order of feature IDs
const orderedIds = targetFeatures.map((f) => f.id);
// No-op check: if order is unchanged
const currentIds = sourceFeatures.map((f) => f.id);
if (orderedIds.join(",") === currentIds.join(",")) {
handleFeatureDragEnd();
return;
}
// Perform the reorder
try {
await reorderFeatures(draggingMilestoneId, orderedIds, {
onError: (err) => {
addToast(`Failed to reorder features: ${err.message}`, "error");
},
});
} catch {
// Error handled in callback
}
}
handleFeatureDragEnd();
}, [featureDrag, featuresByMilestoneId, reorderFeatures, moveFeature, addToast, handleFeatureDragEnd]);
const handleFeatureDragLeave = useCallback((e: React.DragEvent) => {
// Only clear if leaving the element entirely
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setFeatureDrag((prev) => ({
...prev,
dropTargetMilestoneId: null,
dropTargetIndex: null,
dropPosition: null,
}));
}
}, []);
// Check if a feature is being dragged
const isFeatureDragging = useCallback((featureId: string) => {
return featureDrag.draggingId === featureId;
}, [featureDrag.draggingId]);
const handleMilestoneDrop = useCallback(async (targetMilestoneId: string) => {
const { draggingId } = milestoneDrag;
if (!draggingId || draggingId === targetMilestoneId) {
handleMilestoneDragEnd();
return;
}
// Compute the new order
const currentOrder = milestones.map((m) => m.id);
const fromIndex = currentOrder.indexOf(draggingId);
const toIndex = currentOrder.indexOf(targetMilestoneId);
if (fromIndex === -1 || toIndex === -1) {
handleMilestoneDragEnd();
return;
}
// Compute the new order based on drop position
// The drop indicator shows where the item will be inserted
const newOrder = [...currentOrder];
newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, draggingId);
// No-op check: if the order is unchanged
if (newOrder.join(",") === currentOrder.join(",")) {
handleMilestoneDragEnd();
return;
}
// Perform the reorder
try {
await reorderMilestones(selectedRoadmapId!, newOrder, {
onError: (err) => {
addToast(`Failed to reorder milestones: ${err.message}`, "error");
},
});
} catch {
// Error handled in callback
}
handleMilestoneDragEnd();
}, [milestoneDrag, milestones, selectedRoadmapId, reorderMilestones, addToast, handleMilestoneDragEnd]);
const handleMilestoneDragLeave = useCallback((e: React.DragEvent) => {
// Only clear if leaving the element entirely
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setMilestoneDrag((prev) => ({
...prev,
dropTargetId: null,
dropPosition: null,
}));
}
}, []);
// Roadmap handlers
const handleStartRoadmapEdit = useCallback((roadmap: Roadmap) => {
setRoadmapEdit({
@@ -985,6 +1454,25 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
onSaveFeatureEdit={handleSaveFeatureEdit}
projectId={projectId}
addToast={addToast}
// Milestone drag-and-drop props
isMilestoneDragging={milestoneDrag.draggingId === milestone.id}
isMilestoneDropTarget={milestoneDrag.dropTargetId === milestone.id}
milestoneDropPosition={milestoneDrag.dropTargetId === milestone.id ? milestoneDrag.dropPosition : null}
onMilestoneDragStart={handleMilestoneDragStart}
onMilestoneDragEnd={handleMilestoneDragEnd}
onMilestoneDragOver={handleMilestoneDragOver}
onMilestoneDrop={handleMilestoneDrop}
onMilestoneDragLeave={handleMilestoneDragLeave}
// Feature drag-and-drop props
isFeatureDragging={isFeatureDragging}
isFeatureDropTarget={featureDrag.dropTargetMilestoneId === milestone.id}
featureDropIndex={featureDrag.dropTargetMilestoneId === milestone.id ? featureDrag.dropTargetIndex : null}
onFeatureDragStart={handleFeatureDragStart}
onFeatureDragEnd={handleFeatureDragEnd}
onFeatureDragOver={handleFeatureDragOver}
onFeatureDrop={handleFeatureDrop}
onFeatureDragLeave={handleFeatureDragLeave}
onFeatureDropOnMilestone={handleFeatureDropOnMilestone}
/>
))}
</>