feat(FN-1675): merge fusion/fn-1675
This commit is contained in:
@@ -21,6 +21,7 @@ vi.mock("../../api", () => ({
|
||||
moveRoadmapFeature: vi.fn(),
|
||||
generateMilestoneSuggestions: vi.fn(),
|
||||
generateFeatureSuggestions: vi.fn(),
|
||||
fetchRoadmapHandoff: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockRoadmaps = [
|
||||
@@ -555,7 +556,24 @@ describe("useRoadmaps", () => {
|
||||
});
|
||||
|
||||
describe("reorderFeatures", () => {
|
||||
it("reorders features within a milestone", async () => {
|
||||
it("reorders features within a milestone with optimistic update", async () => {
|
||||
// This test requires multiple features to meaningfully test reordering
|
||||
// We'll create a custom hierarchy with multiple features
|
||||
const multiFeatureHierarchy: import("@fusion/core").RoadmapWithHierarchy = {
|
||||
...mockRoadmapHierarchy,
|
||||
milestones: [
|
||||
{
|
||||
...mockRoadmapHierarchy.milestones[0],
|
||||
features: [
|
||||
{ id: "RF-001", milestoneId: "RMS-001", title: "Feature 1", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||
{ id: "RF-002", milestoneId: "RMS-001", title: "Feature 2", orderIndex: 1, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||
],
|
||||
},
|
||||
mockRoadmapHierarchy.milestones[1],
|
||||
],
|
||||
};
|
||||
|
||||
(api.fetchRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(multiFeatureHierarchy);
|
||||
(api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
@@ -569,18 +587,34 @@ describe("useRoadmaps", () => {
|
||||
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"]);
|
||||
// Reorder features in RMS-001: swap RF-001 and RF-002
|
||||
await result.current.reorderFeatures("RMS-001", ["RF-002", "RF-001"]);
|
||||
|
||||
expect(api.reorderRoadmapFeatures).toHaveBeenCalledWith(
|
||||
"RMS-001",
|
||||
["RF-001"],
|
||||
["RF-002", "RF-001"],
|
||||
undefined
|
||||
);
|
||||
expect(api.fetchRoadmap).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back on failure", async () => {
|
||||
// This test requires multiple features
|
||||
const multiFeatureHierarchy: import("@fusion/core").RoadmapWithHierarchy = {
|
||||
...mockRoadmapHierarchy,
|
||||
milestones: [
|
||||
{
|
||||
...mockRoadmapHierarchy.milestones[0],
|
||||
features: [
|
||||
{ id: "RF-001", milestoneId: "RMS-001", title: "Feature 1", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||
{ id: "RF-002", milestoneId: "RMS-001", title: "Feature 2", orderIndex: 1, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||
],
|
||||
},
|
||||
mockRoadmapHierarchy.milestones[1],
|
||||
],
|
||||
};
|
||||
|
||||
(api.fetchRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(multiFeatureHierarchy);
|
||||
const reorderError = new Error("Feature reorder failed");
|
||||
(api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockRejectedValue(reorderError);
|
||||
|
||||
@@ -599,7 +633,7 @@ describe("useRoadmaps", () => {
|
||||
const onError = vi.fn();
|
||||
|
||||
try {
|
||||
await result.current.reorderFeatures("RMS-001", ["RF-001"], { onError });
|
||||
await result.current.reorderFeatures("RMS-001", ["RF-002", "RF-001"], { onError });
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
@@ -1461,4 +1495,239 @@ describe("useRoadmaps", () => {
|
||||
expect(result.current.milestoneSuggestions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Handoff / Export", () => {
|
||||
const mockHandoffPayload = {
|
||||
mission: {
|
||||
sourceRoadmapId: "RM-001",
|
||||
title: "Q2 Roadmap",
|
||||
description: "Q2 product roadmap",
|
||||
milestones: [
|
||||
{
|
||||
sourceMilestoneId: "RMS-001",
|
||||
title: "Milestone 1",
|
||||
description: "First milestone",
|
||||
orderIndex: 0,
|
||||
features: [
|
||||
{ sourceFeatureId: "RF-001", title: "Feature 1", description: "First feature", orderIndex: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
features: [
|
||||
{
|
||||
source: { roadmapId: "RM-001", milestoneId: "RMS-001", featureId: "RF-001", roadmapTitle: "Q2 Roadmap", milestoneTitle: "Milestone 1", milestoneOrderIndex: 0, featureOrderIndex: 0 },
|
||||
title: "Feature 1",
|
||||
description: "First feature",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset fetchHandoff mock for each test
|
||||
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockReset();
|
||||
});
|
||||
|
||||
it("fetches handoff payload for a roadmap", async () => {
|
||||
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockResolvedValue(mockHandoffPayload);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Clear any stale handoff state from previous tests
|
||||
result.current.clearHandoff();
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
await result.current.fetchHandoff("RM-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.handoffPayload).toEqual(mockHandoffPayload);
|
||||
expect(result.current.isFetchingHandoff).toBe(false);
|
||||
expect(result.current.handoffError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles fetchHandoff error", async () => {
|
||||
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Failed to fetch handoff")
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Clear any stale handoff state
|
||||
result.current.clearHandoff();
|
||||
|
||||
await result.current.fetchHandoff("RM-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.handoffError).toBeDefined();
|
||||
expect(result.current.handoffPayload).toBeNull();
|
||||
expect(result.current.isFetchingHandoff).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears handoff state with clearHandoff", async () => {
|
||||
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockResolvedValue(mockHandoffPayload);
|
||||
|
||||
const { result, rerender } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Clear any stale handoff state
|
||||
result.current.clearHandoff();
|
||||
|
||||
await result.current.fetchHandoff("RM-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.handoffPayload).toEqual(mockHandoffPayload);
|
||||
});
|
||||
|
||||
// Call clearHandoff and rerender to get fresh state
|
||||
result.current.clearHandoff();
|
||||
rerender();
|
||||
|
||||
expect(result.current.handoffPayload).toBeNull();
|
||||
expect(result.current.handoffError).toBeNull();
|
||||
expect(result.current.isFetchingHandoff).toBe(false);
|
||||
});
|
||||
|
||||
it("clears handoff payload when project changes", async () => {
|
||||
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockResolvedValue(mockHandoffPayload);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
|
||||
{ initialProps: { projectId: "proj-1" } }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Clear any stale handoff state
|
||||
result.current.clearHandoff();
|
||||
|
||||
await result.current.fetchHandoff("RM-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.handoffPayload).toEqual(mockHandoffPayload);
|
||||
});
|
||||
|
||||
// Change project
|
||||
rerender({ projectId: "proj-2" });
|
||||
|
||||
// Handoff should be cleared
|
||||
expect(result.current.handoffPayload).toBeNull();
|
||||
expect(result.current.handoffError).toBeNull();
|
||||
});
|
||||
|
||||
it("sends correct projectId when fetching handoff", async () => {
|
||||
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockResolvedValue(mockHandoffPayload);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps({ projectId: "proj-test" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Clear any stale handoff state
|
||||
result.current.clearHandoff();
|
||||
|
||||
await result.current.fetchHandoff("RM-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.fetchRoadmapHandoff).toHaveBeenCalledWith("RM-001", "proj-test");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set stale handoff response after project change", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
|
||||
{ initialProps: { projectId: "proj-1" } }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Clear any stale handoff state
|
||||
result.current.clearHandoff();
|
||||
|
||||
// Start fetch but don't resolve yet
|
||||
let resolveHandoff: ((value: typeof mockHandoffPayload) => void) | null = null;
|
||||
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
|
||||
return new Promise((resolve) => {
|
||||
resolveHandoff = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const fetchPromise = result.current.fetchHandoff("RM-001");
|
||||
|
||||
// Change project before promise resolves
|
||||
rerender({ projectId: "proj-2" });
|
||||
|
||||
// Resolve the promise
|
||||
if (resolveHandoff) {
|
||||
resolveHandoff(mockHandoffPayload);
|
||||
}
|
||||
await fetchPromise;
|
||||
|
||||
// Handoff should NOT be set because we're in a different project now
|
||||
expect(result.current.handoffPayload).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("No-op suppression", () => {
|
||||
it("skips API call when reordering features to same order", 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");
|
||||
});
|
||||
|
||||
// Try to reorder with same order as current
|
||||
const currentFeatureIds = result.current.featuresByMilestoneId["RMS-001"]?.map((f) => f.id) || [];
|
||||
|
||||
await result.current.reorderFeatures("RMS-001", currentFeatureIds);
|
||||
|
||||
// API should NOT have been called
|
||||
expect(api.reorderRoadmapFeatures).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips API call when moving feature to same position in same milestone", 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");
|
||||
});
|
||||
|
||||
// The feature RF-001 is already at index 0, try to move it to index 0
|
||||
await result.current.moveFeature("RF-001", "RMS-001", 0);
|
||||
|
||||
// API should NOT have been called
|
||||
expect(api.moveRoadmapFeature).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,7 +277,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
const newRoadmap = await api.createRoadmap(input, projectId);
|
||||
const newRoadmap = await api.createRoadmap(input, projectIdRef.current);
|
||||
setRoadmaps((prev) => [...prev, newRoadmap]);
|
||||
opts?.onSuccess?.();
|
||||
} catch (err) {
|
||||
@@ -285,7 +285,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [projectId]);
|
||||
}, []); // No dependencies needed - uses refs
|
||||
|
||||
const updateRoadmap = useCallback(async (
|
||||
roadmapId: string,
|
||||
@@ -293,7 +293,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
const updated = await api.updateRoadmap(roadmapId, updates, projectId);
|
||||
const updated = await api.updateRoadmap(roadmapId, updates, projectIdRef.current);
|
||||
setRoadmaps((prev) => prev.map((r) => (r.id === roadmapId ? updated : r)));
|
||||
if (selectedRoadmapIdRef.current === roadmapId) {
|
||||
setSelectedRoadmap((prev) => prev ? { ...prev, ...updated } : null);
|
||||
@@ -304,14 +304,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [projectId]);
|
||||
}, []); // No dependencies needed - uses refs
|
||||
|
||||
const deleteRoadmap = useCallback(async (
|
||||
roadmapId: string,
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
await api.deleteRoadmap(roadmapId, projectId);
|
||||
await api.deleteRoadmap(roadmapId, projectIdRef.current);
|
||||
setRoadmaps((prev) => prev.filter((r) => r.id !== roadmapId));
|
||||
if (selectedRoadmapIdRef.current === roadmapId) {
|
||||
setSelectedRoadmapId(null);
|
||||
@@ -325,7 +325,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [projectId]);
|
||||
}, []); // No dependencies needed - uses refs
|
||||
|
||||
const selectRoadmap = useCallback((roadmapId: string | null) => {
|
||||
setSelectedRoadmapId(roadmapId);
|
||||
@@ -364,7 +364,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
const updated = await api.updateRoadmapMilestone(milestoneId, updates, projectId);
|
||||
const updated = await api.updateRoadmapMilestone(milestoneId, updates, projectIdRef.current);
|
||||
setMilestones((prev) => prev.map((m) => (m.id === milestoneId ? updated : m)));
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
||||
@@ -375,14 +375,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
const deleteMilestone = useCallback(async (
|
||||
milestoneId: string,
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
await api.deleteRoadmapMilestone(milestoneId, projectId);
|
||||
await api.deleteRoadmapMilestone(milestoneId, projectIdRef.current);
|
||||
setMilestones((prev) => prev.filter((m) => m.id !== milestoneId));
|
||||
setFeaturesByMilestoneId((prev) => {
|
||||
const updated = { ...prev };
|
||||
@@ -398,7 +398,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
// Feature CRUD
|
||||
const createFeature = useCallback(async (
|
||||
@@ -407,7 +407,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
const newFeature = await api.createRoadmapFeature(milestoneId, input, projectId);
|
||||
const newFeature = await api.createRoadmapFeature(milestoneId, input, projectIdRef.current);
|
||||
setFeaturesByMilestoneId((prev) => ({
|
||||
...prev,
|
||||
[milestoneId]: [...(prev[milestoneId] || []), newFeature],
|
||||
@@ -421,7 +421,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
const updateFeature = useCallback(async (
|
||||
featureId: string,
|
||||
@@ -429,7 +429,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
const updated = await api.updateRoadmapFeature(featureId, updates, projectId);
|
||||
const updated = await api.updateRoadmapFeature(featureId, updates, projectIdRef.current);
|
||||
setFeaturesByMilestoneId((prev) => {
|
||||
const updatedMap: Record<string, RoadmapFeature[]> = {};
|
||||
for (const [milestoneId, features] of Object.entries(prev)) {
|
||||
@@ -446,14 +446,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
const deleteFeature = useCallback(async (
|
||||
featureId: string,
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
await api.deleteRoadmapFeature(featureId, projectId);
|
||||
await api.deleteRoadmapFeature(featureId, projectIdRef.current);
|
||||
setFeaturesByMilestoneId((prev) => {
|
||||
const updatedMap: Record<string, RoadmapFeature[]> = {};
|
||||
for (const [milestoneId, features] of Object.entries(prev)) {
|
||||
@@ -470,7 +470,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
// Milestone ordering
|
||||
const reorderMilestones = useCallback(async (
|
||||
@@ -490,7 +490,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
setMilestones(reordered);
|
||||
|
||||
try {
|
||||
await api.reorderRoadmapMilestones(roadmapId, orderedMilestoneIds, projectId);
|
||||
await api.reorderRoadmapMilestones(roadmapId, orderedMilestoneIds, projectIdRef.current);
|
||||
// Refresh to get server state
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
||||
@@ -503,7 +503,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
// Feature ordering
|
||||
const reorderFeatures = useCallback(async (
|
||||
@@ -511,9 +511,16 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
orderedFeatureIds: string[],
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
// No-op suppression: if IDs are already in the same order, skip API call
|
||||
const currentFeatures = featuresByMilestoneIdRef.current[milestoneId] || [];
|
||||
const currentIds = currentFeatures.map((f) => f.id);
|
||||
if (JSON.stringify(currentIds) === JSON.stringify(orderedFeatureIds)) {
|
||||
opts?.onSuccess?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Save snapshot for rollback
|
||||
const snapshot = featuresByMilestoneIdRef.current;
|
||||
const currentFeatures = snapshot[milestoneId] || [];
|
||||
|
||||
// Optimistic update
|
||||
const reordered = orderedFeatureIds
|
||||
@@ -527,7 +534,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
}));
|
||||
|
||||
try {
|
||||
await api.reorderRoadmapFeatures(milestoneId, orderedFeatureIds, projectId);
|
||||
await api.reorderRoadmapFeatures(milestoneId, orderedFeatureIds, projectIdRef.current);
|
||||
// Refresh to get server state
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
||||
@@ -540,7 +547,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
const moveFeature = useCallback(async (
|
||||
featureId: string,
|
||||
@@ -566,6 +573,17 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// No-op suppression: if already at target position in same milestone, skip
|
||||
if (sourceMilestoneId === targetMilestoneId) {
|
||||
const currentFeatures = snapshot[sourceMilestoneId] || [];
|
||||
const clampedIndex = Math.max(0, Math.min(targetIndex, currentFeatures.length - 1));
|
||||
const currentIndex = currentFeatures.findIndex((f) => f.id === featureId);
|
||||
if (currentIndex === clampedIndex) {
|
||||
opts?.onSuccess?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const sourceFeatures = snapshot[sourceMilestoneId] || [];
|
||||
const targetFeatures = snapshot[targetMilestoneId] || [];
|
||||
|
||||
Reference in New Issue
Block a user