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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4537,6 +4537,35 @@ export function deleteRoadmapFeature(featureId: string, projectId?: string): Pro
|
||||
});
|
||||
}
|
||||
|
||||
/** Reorder milestones within a roadmap */
|
||||
export function reorderRoadmapMilestones(roadmapId: string, orderedMilestoneIds: string[], projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/milestones/reorder`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ orderedMilestoneIds }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Reorder features within a milestone */
|
||||
export function reorderRoadmapFeatures(milestoneId: string, orderedFeatureIds: string[], projectId?: string): Promise<void> {
|
||||
return api<void>(withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features/reorder`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ orderedFeatureIds }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Move a feature to a different milestone or position */
|
||||
export function moveRoadmapFeature(
|
||||
featureId: string,
|
||||
targetMilestoneId: string,
|
||||
targetIndex: number,
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
return api<void>(withProjectId(`/roadmaps/features/${encodeURIComponent(featureId)}/move`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ targetMilestoneId, targetIndex }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
|
||||
|
||||
export interface AiSessionSummary {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -23,6 +23,9 @@ vi.mock("../../api", () => ({
|
||||
createRoadmapFeature: vi.fn(),
|
||||
updateRoadmapFeature: vi.fn(),
|
||||
deleteRoadmapFeature: vi.fn(),
|
||||
reorderRoadmapMilestones: vi.fn(),
|
||||
reorderRoadmapFeatures: vi.fn(),
|
||||
moveRoadmapFeature: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
@@ -34,6 +37,7 @@ vi.mock("lucide-react", () => ({
|
||||
Trash2: (props: unknown) => <span data-testid="trash-icon" {...props}>Delete</span>,
|
||||
Check: (props: unknown) => <span data-testid="check-icon" {...props}>Check</span>,
|
||||
X: (props: unknown) => <span data-testid="x-icon" {...props}>X</span>,
|
||||
GripVertical: (props: unknown) => <span data-testid="grip-icon" {...props}>Grip</span>,
|
||||
}));
|
||||
|
||||
const mockRoadmaps: Roadmap[] = [
|
||||
|
||||
@@ -16,6 +16,9 @@ vi.mock("../../api", () => ({
|
||||
createRoadmapFeature: vi.fn(),
|
||||
updateRoadmapFeature: vi.fn(),
|
||||
deleteRoadmapFeature: vi.fn(),
|
||||
reorderRoadmapMilestones: vi.fn(),
|
||||
reorderRoadmapFeatures: vi.fn(),
|
||||
moveRoadmapFeature: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockRoadmaps = [
|
||||
@@ -467,4 +470,214 @@ describe("useRoadmaps", () => {
|
||||
expect(api.fetchRoadmaps).toHaveBeenCalled();
|
||||
expect(api.fetchRoadmap).toHaveBeenCalledWith("RM-001", undefined);
|
||||
});
|
||||
|
||||
describe("reorderMilestones", () => {
|
||||
it("reorders milestones and refreshes", async () => {
|
||||
(api.reorderRoadmapMilestones as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
// Reorder milestones: swap RMS-001 and RMS-002
|
||||
await result.current.reorderMilestones("RM-001", ["RMS-002", "RMS-001"]);
|
||||
|
||||
expect(api.reorderRoadmapMilestones).toHaveBeenCalledWith(
|
||||
"RM-001",
|
||||
["RMS-002", "RMS-001"],
|
||||
undefined
|
||||
);
|
||||
// Should refresh to get server state
|
||||
expect(api.fetchRoadmap).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back on failure and calls onError", async () => {
|
||||
const reorderError = new Error("Reorder failed");
|
||||
(api.reorderRoadmapMilestones as ReturnType<typeof vi.fn>).mockRejectedValue(reorderError);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
const initialMilestones = result.current.milestones;
|
||||
const onError = vi.fn();
|
||||
|
||||
try {
|
||||
await result.current.reorderMilestones("RM-001", ["RMS-002", "RMS-001"], { onError });
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(reorderError);
|
||||
// State should be rolled back
|
||||
expect(result.current.milestones).toEqual(initialMilestones);
|
||||
});
|
||||
|
||||
it("sends correct payload shape for reorder", async () => {
|
||||
(api.reorderRoadmapMilestones as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
await result.current.reorderMilestones("RM-001", ["RMS-001", "RMS-002"]);
|
||||
|
||||
// Verify the payload shape
|
||||
expect(api.reorderRoadmapMilestones).toHaveBeenCalledTimes(1);
|
||||
const call = (api.reorderRoadmapMilestones as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(call[0]).toBe("RM-001");
|
||||
expect(Array.isArray(call[1])).toBe(true);
|
||||
expect(call[1]).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderFeatures", () => {
|
||||
it("reorders features within a milestone", async () => {
|
||||
(api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
// Reorder features in RMS-001: already has RF-001 at orderIndex 0
|
||||
await result.current.reorderFeatures("RMS-001", ["RF-001"]);
|
||||
|
||||
expect(api.reorderRoadmapFeatures).toHaveBeenCalledWith(
|
||||
"RMS-001",
|
||||
["RF-001"],
|
||||
undefined
|
||||
);
|
||||
expect(api.fetchRoadmap).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back on failure", async () => {
|
||||
const reorderError = new Error("Feature reorder failed");
|
||||
(api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockRejectedValue(reorderError);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
const initialFeatures = result.current.featuresByMilestoneId["RMS-001"];
|
||||
const onError = vi.fn();
|
||||
|
||||
try {
|
||||
await result.current.reorderFeatures("RMS-001", ["RF-001"], { onError });
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(reorderError);
|
||||
expect(result.current.featuresByMilestoneId["RMS-001"]).toEqual(initialFeatures);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveFeature", () => {
|
||||
it("moves a feature to a different milestone", async () => {
|
||||
(api.moveRoadmapFeature as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
// Move RF-001 from RMS-001 to RMS-002 at index 0
|
||||
await result.current.moveFeature("RF-001", "RMS-002", 0);
|
||||
|
||||
expect(api.moveRoadmapFeature).toHaveBeenCalledWith(
|
||||
"RF-001",
|
||||
"RMS-002",
|
||||
0,
|
||||
undefined
|
||||
);
|
||||
expect(api.fetchRoadmap).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back on failure", async () => {
|
||||
const moveError = new Error("Move failed");
|
||||
(api.moveRoadmapFeature as ReturnType<typeof vi.fn>).mockRejectedValue(moveError);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
const initialFeaturesByMilestoneId = result.current.featuresByMilestoneId;
|
||||
const onError = vi.fn();
|
||||
|
||||
try {
|
||||
await result.current.moveFeature("RF-001", "RMS-002", 0, { onError });
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(moveError);
|
||||
expect(result.current.featuresByMilestoneId).toEqual(initialFeaturesByMilestoneId);
|
||||
});
|
||||
|
||||
it("throws when feature not found", async () => {
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
await expect(
|
||||
result.current.moveFeature("NONEXISTENT", "RMS-002", 0, { onError })
|
||||
).rejects.toThrow("Feature not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,10 @@ export interface UseRoadmapsResult {
|
||||
/** 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>;
|
||||
@@ -60,6 +64,12 @@ export interface UseRoadmapsResult {
|
||||
/** 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>;
|
||||
}
|
||||
@@ -349,6 +359,154 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
}
|
||||
}, [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) {
|
||||
@@ -371,9 +529,12 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
createMilestone,
|
||||
updateMilestone,
|
||||
deleteMilestone,
|
||||
reorderMilestones,
|
||||
createFeature,
|
||||
updateFeature,
|
||||
deleteFeature,
|
||||
reorderFeatures,
|
||||
moveFeature,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30552,6 +30552,44 @@ html .column.drag-over * {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
transition: opacity 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.roadmaps-view__milestone:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.roadmaps-view__milestone--dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.roadmaps-view__milestone--drop-target {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.roadmaps-view__milestone--drop-before {
|
||||
border-top: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.roadmaps-view__milestone--drop-after {
|
||||
border-bottom: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.roadmaps-view__drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
cursor: grab;
|
||||
padding: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.roadmaps-view__drag-handle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.roadmaps-view__milestone-header {
|
||||
@@ -30614,6 +30652,11 @@ html .column.drag-over * {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-sm);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.roadmaps-view__feature-list--drop-target {
|
||||
background: rgba(63, 131, 245, 0.05);
|
||||
}
|
||||
|
||||
.roadmaps-view__empty-features {
|
||||
@@ -30630,12 +30673,34 @@ html .column.drag-over * {
|
||||
padding: var(--space-sm);
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: var(--space-sm);
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.roadmaps-view__feature-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.roadmaps-view__feature-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.roadmaps-view__feature-item--dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.roadmaps-view__feature-item--drop-before {
|
||||
border-top: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.roadmaps-view__feature-item--drop-after {
|
||||
border-bottom: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.roadmaps-view__drag-handle--feature {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.roadmaps-view__feature-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
Reference in New Issue
Block a user