@runfusion/fusion 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +26610 -20597
- package/dist/client/assets/AgentDetailView-BwJaLqZh.css +1 -0
- package/dist/client/assets/AgentDetailView-gy_5SUj2.js +18 -0
- package/dist/client/assets/AgentsView-BkB9FiMT.js +29 -0
- package/dist/client/assets/{AgentsView-DSGQWObq.css → AgentsView-CV3vm7Qk.css} +1 -1
- package/dist/client/assets/ChatView-B_-B8fqu.js +1 -0
- package/dist/client/assets/ChatView-DwJAd5G1.css +1 -0
- package/dist/client/assets/{DevServerView-C9lzHrcT.js → DevServerView-BkvtjZBa.js} +1 -1
- package/dist/client/assets/{DirectoryPicker-aVdFaV37.js → DirectoryPicker-BK-KbnhP.js} +1 -1
- package/dist/client/assets/{DocumentsView-DIpg3NSP.js → DocumentsView-BEg1CQAk.js} +1 -1
- package/dist/client/assets/{DocumentsView-BrhyOdeE.css → DocumentsView-gv4zG3aT.css} +1 -1
- package/dist/client/assets/EvalsView-Berf9bQm.js +1 -0
- package/dist/client/assets/EvalsView-CUNJ1TLc.css +1 -0
- package/dist/client/assets/{agentSkills-DDHJnrkn.css → ExperimentalAgentOnboardingModal-B-APN_lM.css} +1 -1
- package/dist/client/assets/ExperimentalAgentOnboardingModal-jcInE50G.js +499 -0
- package/dist/client/assets/InsightsView-B0J4mhzV.css +1 -0
- package/dist/client/assets/InsightsView-BX5bSF1J.js +11 -0
- package/dist/client/assets/{MemoryView-nXlTqebk.js → MemoryView-CKElJY_3.js} +2 -2
- package/dist/client/assets/NodesView-DLUOBLf6.js +14 -0
- package/dist/client/assets/NodesView-DT4pXowv.css +1 -0
- package/dist/client/assets/{PiExtensionsManager-Buopv-jb.js → PiExtensionsManager-COlJf0Kx.js} +2 -2
- package/dist/client/assets/PluginManager-CfW55BF4.js +1 -0
- package/dist/client/assets/PluginManager-DtRQXia5.css +1 -0
- package/dist/client/assets/{ResearchView-_BHXUv2j.js → ResearchView-B256Lr8I.js} +1 -1
- package/dist/client/assets/SettingsModal-BeA_nQtW.js +31 -0
- package/dist/client/assets/SettingsModal-DzsLquBu.css +1 -0
- package/dist/client/assets/{SettingsModal-C89Ikhfm.js → SettingsModal-yRqM4DV8.js} +1 -1
- package/dist/client/assets/SetupWizardModal-uUZk3TKT.js +1 -0
- package/dist/client/assets/{SkillsView-hDpTBdFT.js → SkillsView-CP8JX0P_.js} +1 -1
- package/dist/client/assets/TodoView-Cx9cVhq7.css +1 -0
- package/dist/client/assets/TodoView-DCRIkDZ-.js +6 -0
- package/dist/client/assets/createLucideIcon-BazL2hk5.js +21 -0
- package/dist/client/assets/dashboard-view-BkTMSZYn.css +1 -0
- package/dist/client/assets/dashboard-view-CyWN-d02.js +63 -0
- package/dist/client/assets/dashboard-view-lR7YYmSC.js +21 -0
- package/dist/client/assets/{folder-open-usZkXdq2.js → folder-open-DHjELt8-.js} +1 -1
- package/dist/client/assets/index-CQyVRLOb.js +692 -0
- package/dist/client/assets/index-CxA2Nn0_.css +1 -0
- package/dist/client/assets/projectDetection-G3XuxD2X.js +1 -0
- package/dist/client/assets/{star-BAT_ObKE.js → star-DYesq1AV.js} +1 -1
- package/dist/client/assets/{upload-BC2YKNEV.js → upload-DTWF3Db5.js} +1 -1
- package/dist/client/assets/{users-Dkd4rtrN.js → users--syrel4l.js} +1 -1
- package/dist/client/index.html +12 -20
- package/dist/client/theme-data.css +106 -0
- package/dist/client/version.json +1 -1
- package/dist/droid-cli/package.json +1 -1
- package/dist/extension.js +14287 -9568
- package/dist/pi-claude-cli/package.json +1 -1
- package/dist/plugins/fusion-plugin-cursor-runtime/bundled.js +218 -0
- package/dist/plugins/fusion-plugin-cursor-runtime/manifest.json +6 -0
- package/dist/plugins/fusion-plugin-cursor-runtime/package.json +11 -0
- package/dist/plugins/fusion-plugin-dependency-graph/manifest.json +1 -1
- package/dist/plugins/fusion-plugin-dependency-graph/package.json +6 -4
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.css +58 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx +301 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphHighlight.css +27 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.css +157 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphTaskNode.tsx +126 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphToolbar.css +35 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/GraphToolbar.tsx +36 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.highlighting.test.tsx +112 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.persistence.test.tsx +115 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.test.tsx +128 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.drag.test.tsx +82 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx +307 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphToolbar.test.tsx +60 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/edges.test.tsx +75 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/filtering.test.tsx +62 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/filters.test.ts +78 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/graphPositionStorage.test.ts +95 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/host-integration.test.ts +74 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/index.test.ts +58 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/interactions.test.tsx +121 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/layout.test.ts +70 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/persistence.test.tsx +89 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphData.test.ts +86 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphInteraction.test.ts +167 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useGraphPositions.test.ts +66 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/useNodeDrag.test.ts +81 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts +35 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx +19 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/edges.tsx +70 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/filters.ts +8 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/__tests__/useDependencyChain.test.ts +53 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useDependencyChain.ts +60 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useGraphPositions.ts +45 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/hooks/useNodeDrag.ts +114 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/index.ts +1 -2
- package/dist/plugins/fusion-plugin-dependency-graph/src/layout.ts +91 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/styles/drag.css +15 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/types.ts +21 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/useGraphData.ts +17 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/useGraphInteraction.ts +292 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/utils/graphPositionStorage.ts +65 -0
- package/dist/plugins/fusion-plugin-droid-runtime/bundled.js +136680 -0
- package/dist/plugins/fusion-plugin-droid-runtime/manifest.json +13 -0
- package/dist/plugins/fusion-plugin-droid-runtime/mcp-schema-server.cjs +49 -0
- package/dist/plugins/fusion-plugin-droid-runtime/package.json +11 -0
- package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-openclaw-runtime/bundled.js +93 -6
- package/dist/plugins/fusion-plugin-openclaw-runtime/mcp-schema-server.cjs +59 -0
- package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
- package/dist/plugins/fusion-plugin-reports/manifest.json +33 -0
- package/dist/plugins/fusion-plugin-reports/package.json +26 -0
- package/dist/plugins/fusion-plugin-reports/src/__tests__/manifest.test.ts +51 -0
- package/dist/plugins/fusion-plugin-reports/src/__tests__/review-panel.test.ts +166 -0
- package/dist/plugins/fusion-plugin-reports/src/__tests__/settings.test.ts +157 -0
- package/dist/plugins/fusion-plugin-reports/src/index.ts +41 -0
- package/dist/plugins/fusion-plugin-reports/src/review-panel.ts +294 -0
- package/dist/plugins/fusion-plugin-reports/src/review-types.ts +75 -0
- package/dist/plugins/fusion-plugin-reports/src/settings.ts +105 -0
- package/dist/plugins/fusion-plugin-roadmap/manifest.json +16 -0
- package/dist/plugins/fusion-plugin-roadmap/package.json +48 -0
- package/dist/plugins/fusion-plugin-roadmap/src/__tests__/api-client.test.ts +101 -0
- package/dist/plugins/fusion-plugin-roadmap/src/__tests__/index.test.ts +92 -0
- package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-routes.test.ts +48 -0
- package/dist/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-suggestions.test.ts +31 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.css +1299 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/RoadmapsView.tsx +2559 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/RoadmapsView.test.tsx +1144 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/__tests__/useRoadmaps.test.ts +1756 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/api.ts +70 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/test-setup.ts +7 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/types.ts +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useConfirm.ts +8 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useRoadmaps.ts +1188 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard/useViewportMode.ts +20 -0
- package/dist/plugins/fusion-plugin-roadmap/src/dashboard-view.tsx +6 -0
- package/dist/plugins/fusion-plugin-roadmap/src/index.ts +74 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-routes.ts +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-schema.ts +41 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.d.ts +15 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-suggestions.ts +15 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts +283 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.d.ts.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js +21 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.js.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/roadmap-types.ts +310 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts +5 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.d.ts.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js +361 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.js.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts +408 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts +68 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.d.ts.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js +300 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.js.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/routes/roadmap-suggestions.ts +381 -0
- package/dist/plugins/fusion-plugin-roadmap/src/server/index.d.ts +3 -0
- package/dist/plugins/fusion-plugin-roadmap/src/server/index.ts +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-handoff.test.ts +445 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-ordering.test.ts +334 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +1318 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-handoff.ts +163 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts +37 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.d.ts.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js +188 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.js.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-ordering.ts +311 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts +299 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.d.ts.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js +765 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.js.map +1 -0
- package/dist/plugins/fusion-plugin-roadmap/src/store/roadmap-store.ts +1001 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/manifest.json +8 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +34 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/auth-state.test.ts +99 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts +145 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts +216 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/reply.test.ts +52 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts +89 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/connection.ts +253 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/index.ts +262 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/qrcode.d.ts +1 -0
- package/dist/plugins/fusion-plugin-whatsapp-chat/src/reply.ts +37 -0
- package/package.json +2 -2
- package/skill/fusion/SKILL.md +2 -2
- package/skill/fusion/references/engine-tools.md +3 -0
- package/skill/fusion/references/extension-tools.md +39 -0
- package/skill/fusion/references/fusion-capabilities.md +3 -0
- package/dist/client/assets/AgentDetailView-C1XceMgi.js +0 -18
- package/dist/client/assets/AgentDetailView-CeO_1MK7.css +0 -1
- package/dist/client/assets/AgentsView-Deh125ss.js +0 -527
- package/dist/client/assets/ChatView-7D_RQDqT.js +0 -1
- package/dist/client/assets/InsightsView-AWo5o_81.css +0 -1
- package/dist/client/assets/InsightsView-jKjEFAx_.js +0 -11
- package/dist/client/assets/NodesView-Di2SvOhg.js +0 -14
- package/dist/client/assets/NodesView-fXqDk9ur.css +0 -1
- package/dist/client/assets/PluginManager-B9-NbQ8f.js +0 -1
- package/dist/client/assets/PluginManager-C1DbPaar.css +0 -1
- package/dist/client/assets/RoadmapsView-DHWjUoc8.js +0 -6
- package/dist/client/assets/SettingsModal-DHitIpsa.css +0 -1
- package/dist/client/assets/SettingsModal-DR_yirvK.js +0 -31
- package/dist/client/assets/SetupWizardModal-BtDMY9pa.js +0 -1
- package/dist/client/assets/agentSkills-B-w5wFHh.js +0 -1
- package/dist/client/assets/index-Bc6ZdGMz.css +0 -1
- package/dist/client/assets/index-D__RMku8.js +0 -694
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.css +0 -141
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.tsx +0 -428
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraphView.test.tsx +0 -261
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/storage.test.ts +0 -41
- package/dist/plugins/fusion-plugin-dependency-graph/src/storage.ts +0 -22
- /package/dist/client/assets/{RoadmapsView-DdGlfuu-.css → dashboard-view-DdGlfuu-.css} +0 -0
|
@@ -0,0 +1,2559 @@
|
|
|
1
|
+
import React, { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader, ArrowLeft, ChevronUp } from "lucide-react";
|
|
3
|
+
import "./RoadmapsView.css";
|
|
4
|
+
import type { ToastType } from "./types.js";
|
|
5
|
+
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "./useRoadmaps.js";
|
|
6
|
+
import { useViewportMode } from "./useViewportMode.js";
|
|
7
|
+
import { useConfirm } from "./useConfirm.js";
|
|
8
|
+
import type {
|
|
9
|
+
Roadmap,
|
|
10
|
+
RoadmapMilestone,
|
|
11
|
+
RoadmapFeature,
|
|
12
|
+
RoadmapCreateInput,
|
|
13
|
+
RoadmapUpdateInput,
|
|
14
|
+
RoadmapMilestoneCreateInput,
|
|
15
|
+
RoadmapMilestoneUpdateInput,
|
|
16
|
+
RoadmapFeatureCreateInput,
|
|
17
|
+
RoadmapFeatureUpdateInput,
|
|
18
|
+
RoadmapMissionPlanningHandoff,
|
|
19
|
+
RoadmapFeatureTaskPlanningHandoff,
|
|
20
|
+
} from "../roadmap-types.js";
|
|
21
|
+
|
|
22
|
+
export interface RoadmapsViewProps {
|
|
23
|
+
projectId?: string;
|
|
24
|
+
addToast: (message: string, type?: ToastType) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Drag State Types ────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
interface MilestoneDragState {
|
|
30
|
+
draggingId: string | null;
|
|
31
|
+
dropTargetId: string | null;
|
|
32
|
+
dropPosition: "before" | "after" | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface FeatureDragState {
|
|
36
|
+
draggingId: string | null;
|
|
37
|
+
draggingMilestoneId: string | null;
|
|
38
|
+
dropTargetMilestoneId: string | null;
|
|
39
|
+
dropTargetIndex: number | null;
|
|
40
|
+
dropPosition: "before" | "after" | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Inline Edit State Types ─────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
interface InlineEditState {
|
|
46
|
+
roadmapId: string | null;
|
|
47
|
+
field: "title" | "description" | null;
|
|
48
|
+
value: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface MilestoneInlineEditState {
|
|
52
|
+
milestoneId: string | null;
|
|
53
|
+
field: "title" | "description" | null;
|
|
54
|
+
value: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface FeatureInlineEditState {
|
|
58
|
+
featureId: string | null;
|
|
59
|
+
field: "title" | "description" | null;
|
|
60
|
+
value: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Create Form State ───────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
interface CreateFormState {
|
|
66
|
+
type: "roadmap" | "milestone" | "feature" | null;
|
|
67
|
+
parentId?: string;
|
|
68
|
+
title: string;
|
|
69
|
+
description: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Handoff Modal Types ─────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
interface HandoffModalProps {
|
|
75
|
+
isOpen: boolean;
|
|
76
|
+
onClose: () => void;
|
|
77
|
+
roadmapId: string;
|
|
78
|
+
roadmapTitle: string;
|
|
79
|
+
handoffPayload: { mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null;
|
|
80
|
+
isLoading: boolean;
|
|
81
|
+
error: Error | null;
|
|
82
|
+
onFetchHandoff: () => void;
|
|
83
|
+
onCopyToClipboard: () => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Handoff Modal Component ─────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function HandoffModal({
|
|
89
|
+
isOpen,
|
|
90
|
+
onClose,
|
|
91
|
+
roadmapTitle,
|
|
92
|
+
handoffPayload,
|
|
93
|
+
isLoading,
|
|
94
|
+
error,
|
|
95
|
+
onFetchHandoff,
|
|
96
|
+
onCopyToClipboard,
|
|
97
|
+
}: HandoffModalProps) {
|
|
98
|
+
if (!isOpen) return null;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="modal-overlay open" onClick={onClose} role="presentation">
|
|
102
|
+
<div className="modal modal-lg" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="handoff-modal-title">
|
|
103
|
+
<div className="modal-header">
|
|
104
|
+
<h2 id="handoff-modal-title">Export Roadmap: {roadmapTitle}</h2>
|
|
105
|
+
<button className="modal-close" onClick={onClose} aria-label="Close modal">
|
|
106
|
+
<X size={18} />
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="modal-body">
|
|
110
|
+
<p className="text-muted roadmaps-view__handoff-intro">
|
|
111
|
+
Export roadmap data for use in mission and task planning flows.
|
|
112
|
+
This is a read-only export — no missions or tasks will be created.
|
|
113
|
+
</p>
|
|
114
|
+
|
|
115
|
+
{error && (
|
|
116
|
+
<div className="form-error roadmaps-view__handoff-error">
|
|
117
|
+
Error loading handoff data: {error.message}
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{!handoffPayload && !isLoading && (
|
|
122
|
+
<div className="roadmaps-view__handoff-empty-state">
|
|
123
|
+
<button className="btn btn-primary" onClick={onFetchHandoff}>
|
|
124
|
+
<Download size={16} className="roadmaps-view__handoff-button-icon" />
|
|
125
|
+
Load Handoff Data
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{isLoading && (
|
|
131
|
+
<div className="roadmaps-view__handoff-loading-state">
|
|
132
|
+
<Loader size={24} className="spin" />
|
|
133
|
+
<p className="roadmaps-view__handoff-loading-text">Loading handoff data...</p>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{handoffPayload && (
|
|
138
|
+
<>
|
|
139
|
+
<div className="roadmaps-view__handoff-section">
|
|
140
|
+
<h3 className="roadmaps-view__handoff-section-title">Mission Planning Handoff</h3>
|
|
141
|
+
<div className="card roadmaps-view__handoff-card">
|
|
142
|
+
<pre className="roadmaps-view__handoff-pre roadmaps-view__handoff-pre--mission">
|
|
143
|
+
{JSON.stringify(handoffPayload.mission, null, 2)}
|
|
144
|
+
</pre>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div className="roadmaps-view__handoff-section">
|
|
149
|
+
<h3 className="roadmaps-view__handoff-section-title">
|
|
150
|
+
Feature Task Planning Handoffs ({handoffPayload.features.length})
|
|
151
|
+
</h3>
|
|
152
|
+
<div className="card roadmaps-view__handoff-card">
|
|
153
|
+
<pre className="roadmaps-view__handoff-pre roadmaps-view__handoff-pre--features">
|
|
154
|
+
{JSON.stringify(handoffPayload.features, null, 2)}
|
|
155
|
+
</pre>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
<div className="modal-actions">
|
|
162
|
+
<div className="modal-actions-left">
|
|
163
|
+
{handoffPayload && (
|
|
164
|
+
<button className="btn btn-sm" onClick={onCopyToClipboard}>
|
|
165
|
+
<Copy size={14} className="roadmaps-view__handoff-copy-icon" />
|
|
166
|
+
Copy to Clipboard
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
<div className="modal-actions-right">
|
|
171
|
+
<button className="btn" onClick={onClose}>Close</button>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Roadmap Item ─────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
function RoadmapItem({
|
|
182
|
+
roadmap,
|
|
183
|
+
isSelected,
|
|
184
|
+
onSelect,
|
|
185
|
+
onEdit,
|
|
186
|
+
onDelete,
|
|
187
|
+
onExport,
|
|
188
|
+
}: {
|
|
189
|
+
roadmap: Roadmap;
|
|
190
|
+
isSelected: boolean;
|
|
191
|
+
onSelect: () => void;
|
|
192
|
+
onEdit: () => void;
|
|
193
|
+
onDelete: () => void;
|
|
194
|
+
onExport: () => void;
|
|
195
|
+
}) {
|
|
196
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
197
|
+
if (e.key === "Enter") {
|
|
198
|
+
onSelect();
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const handleEditClick = (e: React.MouseEvent) => {
|
|
203
|
+
e.stopPropagation();
|
|
204
|
+
onEdit();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const handleDeleteClick = (e: React.MouseEvent) => {
|
|
208
|
+
e.stopPropagation();
|
|
209
|
+
onDelete();
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const handleExportClick = (e: React.MouseEvent) => {
|
|
213
|
+
e.stopPropagation();
|
|
214
|
+
onExport();
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
className={`roadmaps-view__sidebar-item${isSelected ? " roadmaps-view__sidebar-item--active" : ""}`}
|
|
220
|
+
onClick={onSelect}
|
|
221
|
+
onKeyDown={handleKeyDown}
|
|
222
|
+
role="button"
|
|
223
|
+
tabIndex={0}
|
|
224
|
+
aria-selected={isSelected}
|
|
225
|
+
data-testid={`roadmap-item-${roadmap.id}`}
|
|
226
|
+
>
|
|
227
|
+
<div className="roadmaps-view__sidebar-item-content">
|
|
228
|
+
<div className="roadmaps-view__sidebar-item-title">{roadmap.title}</div>
|
|
229
|
+
{roadmap.description && (
|
|
230
|
+
<div className="roadmaps-view__sidebar-item-desc">{roadmap.description}</div>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
<div className="roadmaps-view__sidebar-item-actions" onClick={handleEditClick} role="presentation">
|
|
234
|
+
<button
|
|
235
|
+
className="roadmaps-view__icon-btn"
|
|
236
|
+
onClick={handleExportClick}
|
|
237
|
+
title="Export roadmap"
|
|
238
|
+
aria-label="Export roadmap"
|
|
239
|
+
data-testid={`roadmap-export-${roadmap.id}`}
|
|
240
|
+
type="button"
|
|
241
|
+
>
|
|
242
|
+
<Download size={14} />
|
|
243
|
+
</button>
|
|
244
|
+
<button
|
|
245
|
+
className="roadmaps-view__icon-btn"
|
|
246
|
+
onClick={handleEditClick}
|
|
247
|
+
title="Edit roadmap"
|
|
248
|
+
aria-label="Edit roadmap"
|
|
249
|
+
data-testid={`roadmap-edit-${roadmap.id}`}
|
|
250
|
+
type="button"
|
|
251
|
+
>
|
|
252
|
+
<Pencil size={14} />
|
|
253
|
+
</button>
|
|
254
|
+
<button
|
|
255
|
+
className="roadmaps-view__icon-btn roadmaps-view__icon-btn--danger"
|
|
256
|
+
onClick={handleDeleteClick}
|
|
257
|
+
title="Delete roadmap"
|
|
258
|
+
aria-label="Delete roadmap"
|
|
259
|
+
data-testid={`roadmap-delete-${roadmap.id}`}
|
|
260
|
+
type="button"
|
|
261
|
+
>
|
|
262
|
+
<Trash2 size={14} />
|
|
263
|
+
</button>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Mobile Roadmap List ──────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
function MobileRoadmapList({
|
|
272
|
+
roadmaps,
|
|
273
|
+
selectedRoadmapId,
|
|
274
|
+
onSelect,
|
|
275
|
+
onCreate,
|
|
276
|
+
onEdit,
|
|
277
|
+
onDelete,
|
|
278
|
+
onExport,
|
|
279
|
+
showCreateForm,
|
|
280
|
+
onCancelCreate,
|
|
281
|
+
onSaveCreate,
|
|
282
|
+
}: {
|
|
283
|
+
roadmaps: Roadmap[];
|
|
284
|
+
selectedRoadmapId: string | null;
|
|
285
|
+
onSelect: (id: string) => void;
|
|
286
|
+
onCreate: () => void;
|
|
287
|
+
onEdit: (roadmap: Roadmap) => void;
|
|
288
|
+
onDelete: (roadmapId: string) => void;
|
|
289
|
+
onExport: (roadmap: Roadmap) => void;
|
|
290
|
+
showCreateForm: boolean;
|
|
291
|
+
onCancelCreate: () => void;
|
|
292
|
+
onSaveCreate: (input: RoadmapCreateInput) => void;
|
|
293
|
+
}) {
|
|
294
|
+
return (
|
|
295
|
+
<div className="roadmaps-view__mobile-list" data-testid="roadmaps-view__mobile-list">
|
|
296
|
+
<div className="roadmaps-view__mobile-list-header">
|
|
297
|
+
<h2 className="roadmaps-view__mobile-list-title">Roadmaps</h2>
|
|
298
|
+
{!showCreateForm && (
|
|
299
|
+
<button
|
|
300
|
+
className="roadmaps-view__mobile-add-btn"
|
|
301
|
+
onClick={onCreate}
|
|
302
|
+
title="Create roadmap"
|
|
303
|
+
aria-label="Create roadmap"
|
|
304
|
+
data-testid="mobile-create-roadmap-btn"
|
|
305
|
+
>
|
|
306
|
+
<Plus size={18} />
|
|
307
|
+
</button>
|
|
308
|
+
)}
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
{showCreateForm && (
|
|
312
|
+
<div className="roadmaps-view__mobile-create-form">
|
|
313
|
+
<CreateRoadmapForm onSave={onSaveCreate} onCancel={onCancelCreate} />
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
|
|
317
|
+
{roadmaps.length === 0 && !showCreateForm ? (
|
|
318
|
+
<div className="roadmaps-view__mobile-empty">
|
|
319
|
+
<p>No roadmaps yet.</p>
|
|
320
|
+
<button className="btn btn-primary btn-sm" onClick={onCreate}>
|
|
321
|
+
<Plus size={14} />
|
|
322
|
+
<span>Create Roadmap</span>
|
|
323
|
+
</button>
|
|
324
|
+
</div>
|
|
325
|
+
) : (
|
|
326
|
+
<div className="roadmaps-view__mobile-list-items">
|
|
327
|
+
{roadmaps.map((roadmap) => (
|
|
328
|
+
<div
|
|
329
|
+
key={roadmap.id}
|
|
330
|
+
className={`roadmaps-view__mobile-item${roadmap.id === selectedRoadmapId ? " roadmaps-view__mobile-item--active" : ""}`}
|
|
331
|
+
onClick={() => onSelect(roadmap.id)}
|
|
332
|
+
role="button"
|
|
333
|
+
tabIndex={0}
|
|
334
|
+
onKeyDown={(e) => {
|
|
335
|
+
if (e.key === "Enter") {
|
|
336
|
+
onSelect(roadmap.id);
|
|
337
|
+
}
|
|
338
|
+
}}
|
|
339
|
+
data-testid={`mobile-roadmap-item-${roadmap.id}`}
|
|
340
|
+
>
|
|
341
|
+
<div className="roadmaps-view__mobile-item-content">
|
|
342
|
+
<span className="roadmaps-view__mobile-item-title">{roadmap.title}</span>
|
|
343
|
+
{roadmap.description && (
|
|
344
|
+
<span className="roadmaps-view__mobile-item-desc">{roadmap.description}</span>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
<div className="roadmaps-view__mobile-item-actions">
|
|
348
|
+
<button
|
|
349
|
+
className="roadmaps-view__mobile-action-btn"
|
|
350
|
+
onClick={(e) => {
|
|
351
|
+
e.stopPropagation();
|
|
352
|
+
onExport(roadmap);
|
|
353
|
+
}}
|
|
354
|
+
title="Export roadmap"
|
|
355
|
+
aria-label="Export roadmap"
|
|
356
|
+
data-testid={`mobile-roadmap-export-${roadmap.id}`}
|
|
357
|
+
>
|
|
358
|
+
<Download size={16} />
|
|
359
|
+
</button>
|
|
360
|
+
<button
|
|
361
|
+
className="roadmaps-view__mobile-action-btn"
|
|
362
|
+
onClick={(e) => {
|
|
363
|
+
e.stopPropagation();
|
|
364
|
+
onEdit(roadmap);
|
|
365
|
+
}}
|
|
366
|
+
title="Edit roadmap"
|
|
367
|
+
aria-label="Edit roadmap"
|
|
368
|
+
data-testid={`mobile-roadmap-edit-${roadmap.id}`}
|
|
369
|
+
>
|
|
370
|
+
<Pencil size={16} />
|
|
371
|
+
</button>
|
|
372
|
+
<button
|
|
373
|
+
className="roadmaps-view__mobile-action-btn roadmaps-view__mobile-action-btn--danger"
|
|
374
|
+
onClick={(e) => {
|
|
375
|
+
e.stopPropagation();
|
|
376
|
+
onDelete(roadmap.id);
|
|
377
|
+
}}
|
|
378
|
+
title="Delete roadmap"
|
|
379
|
+
aria-label="Delete roadmap"
|
|
380
|
+
data-testid={`mobile-roadmap-delete-${roadmap.id}`}
|
|
381
|
+
>
|
|
382
|
+
<Trash2 size={16} />
|
|
383
|
+
</button>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
))}
|
|
387
|
+
</div>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Mobile Roadmap Header (shown when roadmap is selected) ────────────
|
|
394
|
+
|
|
395
|
+
function MobileRoadmapHeader({
|
|
396
|
+
roadmapTitle,
|
|
397
|
+
onBack,
|
|
398
|
+
onEdit,
|
|
399
|
+
onDelete,
|
|
400
|
+
onCreate,
|
|
401
|
+
}: {
|
|
402
|
+
roadmapTitle: string;
|
|
403
|
+
onBack: () => void;
|
|
404
|
+
onEdit: () => void;
|
|
405
|
+
onDelete: () => void;
|
|
406
|
+
onCreate: () => void;
|
|
407
|
+
}) {
|
|
408
|
+
return (
|
|
409
|
+
<div className="roadmaps-view__mobile-header" data-testid="roadmaps-view__mobile-header">
|
|
410
|
+
<button
|
|
411
|
+
className="roadmaps-view__mobile-back-btn"
|
|
412
|
+
onClick={onBack}
|
|
413
|
+
title="Back to roadmap list"
|
|
414
|
+
aria-label="Back to roadmap list"
|
|
415
|
+
data-testid="mobile-back-btn"
|
|
416
|
+
>
|
|
417
|
+
<ArrowLeft size={20} />
|
|
418
|
+
</button>
|
|
419
|
+
<h2 className="roadmaps-view__mobile-header-title">{roadmapTitle}</h2>
|
|
420
|
+
<div className="roadmaps-view__mobile-header-actions">
|
|
421
|
+
<button
|
|
422
|
+
className="roadmaps-view__mobile-action-btn"
|
|
423
|
+
onClick={onCreate}
|
|
424
|
+
title="Create roadmap"
|
|
425
|
+
aria-label="Create roadmap"
|
|
426
|
+
data-testid="mobile-header-create-btn"
|
|
427
|
+
>
|
|
428
|
+
<Plus size={18} />
|
|
429
|
+
</button>
|
|
430
|
+
<button
|
|
431
|
+
className="roadmaps-view__mobile-action-btn"
|
|
432
|
+
onClick={onEdit}
|
|
433
|
+
title="Edit roadmap"
|
|
434
|
+
aria-label="Edit roadmap"
|
|
435
|
+
data-testid="mobile-header-edit-btn"
|
|
436
|
+
>
|
|
437
|
+
<Pencil size={18} />
|
|
438
|
+
</button>
|
|
439
|
+
<button
|
|
440
|
+
className="roadmaps-view__mobile-action-btn roadmaps-view__mobile-action-btn--danger"
|
|
441
|
+
onClick={onDelete}
|
|
442
|
+
title="Delete roadmap"
|
|
443
|
+
aria-label="Delete roadmap"
|
|
444
|
+
data-testid="mobile-header-delete-btn"
|
|
445
|
+
>
|
|
446
|
+
<Trash2 size={18} />
|
|
447
|
+
</button>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── Milestone Card ───────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
function MilestoneCard({
|
|
456
|
+
milestone,
|
|
457
|
+
features,
|
|
458
|
+
onEditMilestone,
|
|
459
|
+
onDeleteMilestone,
|
|
460
|
+
onAddFeature,
|
|
461
|
+
onEditFeature,
|
|
462
|
+
onDeleteFeature,
|
|
463
|
+
milestoneEdit,
|
|
464
|
+
onMilestoneEditChange,
|
|
465
|
+
onMilestoneEditFieldChange,
|
|
466
|
+
onCancelMilestoneEdit,
|
|
467
|
+
onSaveMilestoneEdit,
|
|
468
|
+
featureEdit,
|
|
469
|
+
onFeatureEditChange,
|
|
470
|
+
onStartFeatureEdit: _onStartFeatureEdit,
|
|
471
|
+
onCancelFeatureEdit,
|
|
472
|
+
onSaveFeatureEdit,
|
|
473
|
+
projectId: _projectId,
|
|
474
|
+
addToast: _addToast,
|
|
475
|
+
// Milestone drag-and-drop props
|
|
476
|
+
isMilestoneDragging,
|
|
477
|
+
isMilestoneDropTarget,
|
|
478
|
+
milestoneDropPosition,
|
|
479
|
+
onMilestoneDragStart,
|
|
480
|
+
onMilestoneDragEnd,
|
|
481
|
+
onMilestoneDragOver,
|
|
482
|
+
onMilestoneDrop,
|
|
483
|
+
onMilestoneDragLeave,
|
|
484
|
+
// Feature drag-and-drop props
|
|
485
|
+
isFeatureDragging,
|
|
486
|
+
isFeatureDropTarget,
|
|
487
|
+
featureDropIndex,
|
|
488
|
+
onFeatureDragStart,
|
|
489
|
+
onFeatureDragEnd,
|
|
490
|
+
onFeatureDragOver,
|
|
491
|
+
onFeatureDrop,
|
|
492
|
+
onFeatureDragLeave,
|
|
493
|
+
onFeatureDropOnMilestone,
|
|
494
|
+
// Feature suggestion props
|
|
495
|
+
featureSuggestions,
|
|
496
|
+
isGeneratingFeatureSuggestions,
|
|
497
|
+
onGenerateFeatureSuggestions,
|
|
498
|
+
onAcceptFeatureSuggestion,
|
|
499
|
+
onAcceptAllFeatureSuggestions,
|
|
500
|
+
onUpdateFeatureSuggestionDraft,
|
|
501
|
+
onClearFeatureSuggestions,
|
|
502
|
+
}: {
|
|
503
|
+
milestone: RoadmapMilestone;
|
|
504
|
+
features: RoadmapFeature[];
|
|
505
|
+
onEditMilestone: () => void;
|
|
506
|
+
onDeleteMilestone: () => void;
|
|
507
|
+
onAddFeature: () => void;
|
|
508
|
+
onEditFeature: (featureId: string) => void;
|
|
509
|
+
onDeleteFeature: (featureId: string) => void;
|
|
510
|
+
milestoneEdit: MilestoneInlineEditState | null;
|
|
511
|
+
onMilestoneEditChange: (value: string) => void;
|
|
512
|
+
onMilestoneEditFieldChange: (field: "title" | "description") => void;
|
|
513
|
+
onCancelMilestoneEdit: () => void;
|
|
514
|
+
onSaveMilestoneEdit: (updates: RoadmapMilestoneUpdateInput) => void;
|
|
515
|
+
featureEdit: FeatureInlineEditState | null;
|
|
516
|
+
onFeatureEditChange: (value: string) => void;
|
|
517
|
+
onStartFeatureEdit: (featureId: string, currentTitle: string, currentDescription?: string) => void;
|
|
518
|
+
onCancelFeatureEdit: () => void;
|
|
519
|
+
onSaveFeatureEdit: (updates: RoadmapFeatureUpdateInput) => void;
|
|
520
|
+
projectId?: string;
|
|
521
|
+
addToast: (message: string, type?: ToastType) => void;
|
|
522
|
+
// Milestone drag-and-drop props
|
|
523
|
+
isMilestoneDragging: boolean;
|
|
524
|
+
isMilestoneDropTarget: boolean;
|
|
525
|
+
milestoneDropPosition: "before" | "after" | null;
|
|
526
|
+
onMilestoneDragStart: (milestoneId: string) => void;
|
|
527
|
+
onMilestoneDragEnd: () => void;
|
|
528
|
+
onMilestoneDragOver: (milestoneId: string) => void;
|
|
529
|
+
onMilestoneDrop: (milestoneId: string) => void;
|
|
530
|
+
onMilestoneDragLeave: (e: React.DragEvent) => void;
|
|
531
|
+
// Feature drag-and-drop props
|
|
532
|
+
isFeatureDragging: (featureId: string) => boolean;
|
|
533
|
+
isFeatureDropTarget: boolean;
|
|
534
|
+
featureDropIndex: number | null;
|
|
535
|
+
onFeatureDragStart: (featureId: string, milestoneId: string) => void;
|
|
536
|
+
onFeatureDragEnd: () => void;
|
|
537
|
+
onFeatureDragOver: (featureId: string, position: "before" | "after") => void;
|
|
538
|
+
onFeatureDrop: (featureId: string, targetIndex: number) => void;
|
|
539
|
+
onFeatureDragLeave: (e: React.DragEvent) => void;
|
|
540
|
+
onFeatureDropOnMilestone: () => void;
|
|
541
|
+
// Feature suggestion props
|
|
542
|
+
featureSuggestions?: FeatureSuggestion[];
|
|
543
|
+
isGeneratingFeatureSuggestions?: boolean;
|
|
544
|
+
onGenerateFeatureSuggestions?: () => void;
|
|
545
|
+
onUpdateFeatureSuggestionDraft?: (milestoneId: string, draftId: string, patch: SuggestionDraftPatch) => void;
|
|
546
|
+
onAcceptFeatureSuggestion?: (milestoneId: string, draftId: string) => void;
|
|
547
|
+
onAcceptAllFeatureSuggestions?: () => void;
|
|
548
|
+
onClearFeatureSuggestions?: () => void;
|
|
549
|
+
}) {
|
|
550
|
+
const isEditingMilestone = milestoneEdit?.milestoneId === milestone.id;
|
|
551
|
+
|
|
552
|
+
const handleMilestoneTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
553
|
+
if (e.key === "Enter") {
|
|
554
|
+
e.preventDefault();
|
|
555
|
+
if (milestoneEdit) {
|
|
556
|
+
onSaveMilestoneEdit({ title: milestoneEdit.value });
|
|
557
|
+
}
|
|
558
|
+
} else if (e.key === "Escape") {
|
|
559
|
+
onCancelMilestoneEdit();
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const handleMilestoneDescKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
564
|
+
if (e.key === "Escape") {
|
|
565
|
+
onCancelMilestoneEdit();
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
// Build class names for drag states
|
|
570
|
+
const milestoneClasses = [
|
|
571
|
+
"roadmaps-view__milestone",
|
|
572
|
+
isMilestoneDragging ? "roadmaps-view__milestone--dragging" : "",
|
|
573
|
+
isMilestoneDropTarget ? "roadmaps-view__milestone--drop-target" : "",
|
|
574
|
+
isMilestoneDropTarget && milestoneDropPosition === "before" ? "roadmaps-view__milestone--drop-before" : "",
|
|
575
|
+
isMilestoneDropTarget && milestoneDropPosition === "after" ? "roadmaps-view__milestone--drop-after" : "",
|
|
576
|
+
].filter(Boolean).join(" ");
|
|
577
|
+
|
|
578
|
+
// Build class names for feature list drop state
|
|
579
|
+
const featureListClasses = [
|
|
580
|
+
"roadmaps-view__feature-list",
|
|
581
|
+
isFeatureDropTarget ? "roadmaps-view__feature-list--drop-target" : "",
|
|
582
|
+
].filter(Boolean).join(" ");
|
|
583
|
+
|
|
584
|
+
return (
|
|
585
|
+
<div
|
|
586
|
+
className={milestoneClasses}
|
|
587
|
+
draggable={!isEditingMilestone}
|
|
588
|
+
onDragStart={(e) => {
|
|
589
|
+
if (!isEditingMilestone) {
|
|
590
|
+
onMilestoneDragStart(milestone.id);
|
|
591
|
+
e.dataTransfer.setData("text/plain", `milestone:${milestone.id}`);
|
|
592
|
+
e.dataTransfer.effectAllowed = "move";
|
|
593
|
+
}
|
|
594
|
+
}}
|
|
595
|
+
onDragEnd={onMilestoneDragEnd}
|
|
596
|
+
onDragOver={(e) => {
|
|
597
|
+
// Only prevent default for milestone drops, not feature drops
|
|
598
|
+
if (e.dataTransfer.types.includes("text/plain")) {
|
|
599
|
+
const data = e.dataTransfer.types.includes("text/plain");
|
|
600
|
+
if (data) {
|
|
601
|
+
// This is a milestone drag
|
|
602
|
+
e.preventDefault();
|
|
603
|
+
e.dataTransfer.dropEffect = "move";
|
|
604
|
+
onMilestoneDragOver(milestone.id);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}}
|
|
608
|
+
onDrop={(e) => {
|
|
609
|
+
e.preventDefault();
|
|
610
|
+
// Check if this is a feature drop or milestone drop
|
|
611
|
+
const data = e.dataTransfer.getData("text/plain");
|
|
612
|
+
if (data?.startsWith("feature:")) {
|
|
613
|
+
// Feature drop - handled by child element
|
|
614
|
+
} else {
|
|
615
|
+
onMilestoneDrop(milestone.id);
|
|
616
|
+
}
|
|
617
|
+
}}
|
|
618
|
+
onDragLeave={onMilestoneDragLeave}
|
|
619
|
+
data-testid={`milestone-card-${milestone.id}`}
|
|
620
|
+
>
|
|
621
|
+
<div className="roadmaps-view__milestone-header">
|
|
622
|
+
{isEditingMilestone && milestoneEdit ? (
|
|
623
|
+
<div className="roadmaps-view__inline-edit">
|
|
624
|
+
<div className="roadmaps-view__inline-edit-row">
|
|
625
|
+
<span
|
|
626
|
+
className="roadmaps-view__drag-handle"
|
|
627
|
+
title="Drag to reorder"
|
|
628
|
+
aria-label="Drag to reorder"
|
|
629
|
+
data-testid={`milestone-drag-handle-${milestone.id}`}
|
|
630
|
+
>
|
|
631
|
+
<GripVertical size={14} />
|
|
632
|
+
</span>
|
|
633
|
+
<input
|
|
634
|
+
type="text"
|
|
635
|
+
className="roadmaps-view__inline-input"
|
|
636
|
+
value={milestoneEdit.value}
|
|
637
|
+
onChange={(e) => {
|
|
638
|
+
onMilestoneEditFieldChange("title");
|
|
639
|
+
onMilestoneEditChange(e.target.value);
|
|
640
|
+
}}
|
|
641
|
+
onKeyDown={handleMilestoneTitleKeyDown}
|
|
642
|
+
placeholder="Milestone title"
|
|
643
|
+
autoFocus
|
|
644
|
+
data-testid={`milestone-title-input-${milestone.id}`}
|
|
645
|
+
/>
|
|
646
|
+
<button
|
|
647
|
+
className="roadmaps-view__icon-btn roadmaps-view__icon-btn--success"
|
|
648
|
+
onClick={() => onSaveMilestoneEdit({ title: milestoneEdit.value })}
|
|
649
|
+
aria-label="Save milestone title"
|
|
650
|
+
title="Save"
|
|
651
|
+
>
|
|
652
|
+
<Check size={14} />
|
|
653
|
+
</button>
|
|
654
|
+
<button
|
|
655
|
+
className="roadmaps-view__icon-btn"
|
|
656
|
+
onClick={onCancelMilestoneEdit}
|
|
657
|
+
aria-label="Cancel editing"
|
|
658
|
+
title="Cancel"
|
|
659
|
+
>
|
|
660
|
+
<X size={14} />
|
|
661
|
+
</button>
|
|
662
|
+
</div>
|
|
663
|
+
<textarea
|
|
664
|
+
className="roadmaps-view__inline-textarea"
|
|
665
|
+
value={milestoneEdit.field === "description" ? milestoneEdit.value : milestone.description || ""}
|
|
666
|
+
onChange={(e) => {
|
|
667
|
+
onMilestoneEditFieldChange("description");
|
|
668
|
+
onMilestoneEditChange(e.target.value);
|
|
669
|
+
}}
|
|
670
|
+
onKeyDown={handleMilestoneDescKeyDown}
|
|
671
|
+
placeholder="Milestone description (optional)"
|
|
672
|
+
rows={2}
|
|
673
|
+
data-testid={`milestone-desc-input-${milestone.id}`}
|
|
674
|
+
/>
|
|
675
|
+
</div>
|
|
676
|
+
) : (
|
|
677
|
+
<>
|
|
678
|
+
<div className="roadmaps-view__milestone-title-row">
|
|
679
|
+
<span
|
|
680
|
+
className="roadmaps-view__drag-handle"
|
|
681
|
+
title="Drag to reorder"
|
|
682
|
+
aria-label="Drag to reorder"
|
|
683
|
+
data-testid={`milestone-drag-handle-${milestone.id}`}
|
|
684
|
+
>
|
|
685
|
+
<GripVertical size={14} />
|
|
686
|
+
</span>
|
|
687
|
+
<h3 className="roadmaps-view__milestone-title">{milestone.title}</h3>
|
|
688
|
+
<div className="roadmaps-view__milestone-actions">
|
|
689
|
+
<button
|
|
690
|
+
className="roadmaps-view__icon-btn"
|
|
691
|
+
onClick={onEditMilestone}
|
|
692
|
+
title="Edit milestone"
|
|
693
|
+
aria-label="Edit milestone"
|
|
694
|
+
data-testid={`milestone-edit-${milestone.id}`}
|
|
695
|
+
>
|
|
696
|
+
<Pencil size={14} />
|
|
697
|
+
</button>
|
|
698
|
+
<button
|
|
699
|
+
className="roadmaps-view__icon-btn roadmaps-view__icon-btn--danger"
|
|
700
|
+
onClick={onDeleteMilestone}
|
|
701
|
+
title="Delete milestone"
|
|
702
|
+
aria-label="Delete milestone"
|
|
703
|
+
data-testid={`milestone-delete-${milestone.id}`}
|
|
704
|
+
>
|
|
705
|
+
<Trash2 size={14} />
|
|
706
|
+
</button>
|
|
707
|
+
</div>
|
|
708
|
+
</div>
|
|
709
|
+
{milestone.description && (
|
|
710
|
+
<p className="roadmaps-view__milestone-desc">{milestone.description}</p>
|
|
711
|
+
)}
|
|
712
|
+
</>
|
|
713
|
+
)}
|
|
714
|
+
</div>
|
|
715
|
+
|
|
716
|
+
<div className="roadmaps-view__milestone-actions-bar">
|
|
717
|
+
<button
|
|
718
|
+
className="roadmaps-view__add-feature-btn"
|
|
719
|
+
onClick={onAddFeature}
|
|
720
|
+
title="Add feature"
|
|
721
|
+
aria-label="Add feature"
|
|
722
|
+
data-testid={`add-feature-${milestone.id}`}
|
|
723
|
+
>
|
|
724
|
+
<Plus size={12} />
|
|
725
|
+
<span>Add Feature</span>
|
|
726
|
+
</button>
|
|
727
|
+
<button
|
|
728
|
+
className="roadmaps-view__suggest-btn"
|
|
729
|
+
onClick={() => {
|
|
730
|
+
// Generate feature suggestions for this milestone
|
|
731
|
+
onGenerateFeatureSuggestions?.();
|
|
732
|
+
}}
|
|
733
|
+
disabled={isGeneratingFeatureSuggestions ?? false}
|
|
734
|
+
title="Generate feature suggestions with AI"
|
|
735
|
+
aria-label="Generate feature suggestions"
|
|
736
|
+
data-testid={`generate-features-${milestone.id}`}
|
|
737
|
+
>
|
|
738
|
+
<Sparkles size={12} />
|
|
739
|
+
<span>{isGeneratingFeatureSuggestions ? "Generating..." : "AI Suggestions"}</span>
|
|
740
|
+
</button>
|
|
741
|
+
</div>
|
|
742
|
+
|
|
743
|
+
<div
|
|
744
|
+
className={featureListClasses}
|
|
745
|
+
onDragOver={(e) => {
|
|
746
|
+
e.preventDefault();
|
|
747
|
+
e.dataTransfer.dropEffect = "move";
|
|
748
|
+
// Check if this is a feature being dragged
|
|
749
|
+
const data = e.dataTransfer.getData("text/plain");
|
|
750
|
+
if (data?.startsWith("feature:")) {
|
|
751
|
+
onFeatureDropOnMilestone();
|
|
752
|
+
}
|
|
753
|
+
}}
|
|
754
|
+
onDrop={(e) => {
|
|
755
|
+
e.preventDefault();
|
|
756
|
+
const data = e.dataTransfer.getData("text/plain");
|
|
757
|
+
if (data?.startsWith("feature:")) {
|
|
758
|
+
// Drop on empty area of feature list - append to end
|
|
759
|
+
onFeatureDrop(data.split(":")[1], features.length);
|
|
760
|
+
}
|
|
761
|
+
}}
|
|
762
|
+
onDragLeave={onFeatureDragLeave}
|
|
763
|
+
>
|
|
764
|
+
{features.length === 0 ? (
|
|
765
|
+
<p className="roadmaps-view__empty-features">No features yet.</p>
|
|
766
|
+
) : (
|
|
767
|
+
features.map((feature, index) => {
|
|
768
|
+
const isEditingFeature = featureEdit?.featureId === feature.id;
|
|
769
|
+
const isFeatureDraggingThis = isFeatureDragging(feature.id);
|
|
770
|
+
|
|
771
|
+
const handleFeatureTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
772
|
+
if (e.key === "Enter") {
|
|
773
|
+
e.preventDefault();
|
|
774
|
+
if (featureEdit) {
|
|
775
|
+
onSaveFeatureEdit({ title: featureEdit.value });
|
|
776
|
+
}
|
|
777
|
+
} else if (e.key === "Escape") {
|
|
778
|
+
onCancelFeatureEdit();
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
// Build class names for feature drag states
|
|
783
|
+
const featureClasses = [
|
|
784
|
+
"roadmaps-view__feature-item",
|
|
785
|
+
isFeatureDraggingThis ? "roadmaps-view__feature-item--dragging" : "",
|
|
786
|
+
isFeatureDropTarget && featureDropIndex === index ? "roadmaps-view__feature-item--drop-before" : "",
|
|
787
|
+
isFeatureDropTarget && featureDropIndex === index + 1 ? "roadmaps-view__feature-item--drop-after" : "",
|
|
788
|
+
].filter(Boolean).join(" ");
|
|
789
|
+
|
|
790
|
+
return (
|
|
791
|
+
<div
|
|
792
|
+
key={feature.id}
|
|
793
|
+
className={featureClasses}
|
|
794
|
+
draggable={!isEditingFeature}
|
|
795
|
+
onDragStart={(e) => {
|
|
796
|
+
if (!isEditingFeature) {
|
|
797
|
+
onFeatureDragStart(feature.id, milestone.id);
|
|
798
|
+
e.dataTransfer.setData("text/plain", `feature:${feature.id}`);
|
|
799
|
+
e.dataTransfer.effectAllowed = "move";
|
|
800
|
+
}
|
|
801
|
+
}}
|
|
802
|
+
onDragEnd={onFeatureDragEnd}
|
|
803
|
+
onDragOver={(e) => {
|
|
804
|
+
e.preventDefault();
|
|
805
|
+
e.stopPropagation();
|
|
806
|
+
e.dataTransfer.dropEffect = "move";
|
|
807
|
+
const data = e.dataTransfer.getData("text/plain");
|
|
808
|
+
if (data?.startsWith("feature:")) {
|
|
809
|
+
// Calculate position (before or after)
|
|
810
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
811
|
+
const midY = rect.top + rect.height / 2;
|
|
812
|
+
const position: "before" | "after" = e.clientY < midY ? "before" : "after";
|
|
813
|
+
onFeatureDragOver(feature.id, position);
|
|
814
|
+
}
|
|
815
|
+
}}
|
|
816
|
+
onDrop={(e) => {
|
|
817
|
+
e.preventDefault();
|
|
818
|
+
e.stopPropagation();
|
|
819
|
+
const data = e.dataTransfer.getData("text/plain");
|
|
820
|
+
if (data?.startsWith("feature:")) {
|
|
821
|
+
const draggedFeatureId = data.split(":")[1];
|
|
822
|
+
// Calculate target index
|
|
823
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
824
|
+
const midY = rect.top + rect.height / 2;
|
|
825
|
+
const position: "before" | "after" = e.clientY < midY ? "before" : "after";
|
|
826
|
+
let targetIndex = index;
|
|
827
|
+
if (position === "after") {
|
|
828
|
+
targetIndex = index + 1;
|
|
829
|
+
}
|
|
830
|
+
onFeatureDrop(draggedFeatureId, targetIndex);
|
|
831
|
+
}
|
|
832
|
+
}}
|
|
833
|
+
onDragLeave={onFeatureDragLeave}
|
|
834
|
+
data-testid={`feature-item-${feature.id}`}
|
|
835
|
+
>
|
|
836
|
+
{isEditingFeature && featureEdit ? (
|
|
837
|
+
<div className="roadmaps-view__inline-edit roadmaps-view__inline-edit--compact">
|
|
838
|
+
<div className="roadmaps-view__inline-edit-row">
|
|
839
|
+
<span
|
|
840
|
+
className="roadmaps-view__drag-handle roadmaps-view__drag-handle--feature"
|
|
841
|
+
title="Drag to reorder"
|
|
842
|
+
aria-label="Drag to reorder"
|
|
843
|
+
data-testid={`feature-drag-handle-${feature.id}`}
|
|
844
|
+
>
|
|
845
|
+
<GripVertical size={12} />
|
|
846
|
+
</span>
|
|
847
|
+
<input
|
|
848
|
+
type="text"
|
|
849
|
+
className="roadmaps-view__inline-input"
|
|
850
|
+
value={featureEdit.value}
|
|
851
|
+
onChange={(e) => onFeatureEditChange(e.target.value)}
|
|
852
|
+
onKeyDown={handleFeatureTitleKeyDown}
|
|
853
|
+
placeholder="Feature title"
|
|
854
|
+
autoFocus
|
|
855
|
+
data-testid={`feature-title-input-${feature.id}`}
|
|
856
|
+
/>
|
|
857
|
+
<button
|
|
858
|
+
className="roadmaps-view__icon-btn roadmaps-view__icon-btn--success"
|
|
859
|
+
onClick={() => onSaveFeatureEdit({ title: featureEdit.value })}
|
|
860
|
+
aria-label="Save feature title"
|
|
861
|
+
title="Save"
|
|
862
|
+
>
|
|
863
|
+
<Check size={14} />
|
|
864
|
+
</button>
|
|
865
|
+
<button
|
|
866
|
+
className="roadmaps-view__icon-btn"
|
|
867
|
+
onClick={onCancelFeatureEdit}
|
|
868
|
+
aria-label="Cancel editing"
|
|
869
|
+
title="Cancel"
|
|
870
|
+
>
|
|
871
|
+
<X size={14} />
|
|
872
|
+
</button>
|
|
873
|
+
</div>
|
|
874
|
+
</div>
|
|
875
|
+
) : (
|
|
876
|
+
<>
|
|
877
|
+
<span
|
|
878
|
+
className="roadmaps-view__drag-handle roadmaps-view__drag-handle--feature"
|
|
879
|
+
title="Drag to reorder"
|
|
880
|
+
aria-label="Drag to reorder"
|
|
881
|
+
data-testid={`feature-drag-handle-${feature.id}`}
|
|
882
|
+
>
|
|
883
|
+
<GripVertical size={12} />
|
|
884
|
+
</span>
|
|
885
|
+
<div className="roadmaps-view__feature-content">
|
|
886
|
+
<span className="roadmaps-view__feature-title">{feature.title}</span>
|
|
887
|
+
{feature.description && (
|
|
888
|
+
<p className="roadmaps-view__feature-desc">{feature.description}</p>
|
|
889
|
+
)}
|
|
890
|
+
</div>
|
|
891
|
+
<div className="roadmaps-view__feature-actions">
|
|
892
|
+
<button
|
|
893
|
+
className="roadmaps-view__icon-btn"
|
|
894
|
+
onClick={() => onEditFeature(feature.id)}
|
|
895
|
+
title="Edit feature"
|
|
896
|
+
aria-label="Edit feature"
|
|
897
|
+
data-testid={`feature-edit-${feature.id}`}
|
|
898
|
+
>
|
|
899
|
+
<Pencil size={12} />
|
|
900
|
+
</button>
|
|
901
|
+
<button
|
|
902
|
+
className="roadmaps-view__icon-btn roadmaps-view__icon-btn--danger"
|
|
903
|
+
onClick={() => onDeleteFeature(feature.id)}
|
|
904
|
+
title="Delete feature"
|
|
905
|
+
aria-label="Delete feature"
|
|
906
|
+
data-testid={`feature-delete-${feature.id}`}
|
|
907
|
+
>
|
|
908
|
+
<Trash2 size={12} />
|
|
909
|
+
</button>
|
|
910
|
+
</div>
|
|
911
|
+
</>
|
|
912
|
+
)}
|
|
913
|
+
</div>
|
|
914
|
+
);
|
|
915
|
+
})
|
|
916
|
+
)}
|
|
917
|
+
|
|
918
|
+
{/* Feature Suggestions Section */}
|
|
919
|
+
{featureSuggestions && featureSuggestions.length > 0 && (
|
|
920
|
+
<div className="roadmap-suggestion-section">
|
|
921
|
+
<div className="roadmap-suggestion-header">
|
|
922
|
+
<h4 className="roadmap-suggestion-title">AI Feature Suggestions</h4>
|
|
923
|
+
<div className="roadmap-suggestion-actions">
|
|
924
|
+
<button
|
|
925
|
+
className="roadmap-suggestion-accept-all-btn"
|
|
926
|
+
onClick={() => onAcceptAllFeatureSuggestions?.()}
|
|
927
|
+
title="Accept all suggestions"
|
|
928
|
+
aria-label="Accept all"
|
|
929
|
+
data-testid={`accept-all-features-${milestone.id}`}
|
|
930
|
+
>
|
|
931
|
+
Accept All
|
|
932
|
+
</button>
|
|
933
|
+
<button
|
|
934
|
+
className="roadmap-suggestion-clear-btn"
|
|
935
|
+
onClick={() => onClearFeatureSuggestions?.()}
|
|
936
|
+
title="Clear suggestions"
|
|
937
|
+
aria-label="Clear"
|
|
938
|
+
data-testid={`clear-features-${milestone.id}`}
|
|
939
|
+
>
|
|
940
|
+
Clear
|
|
941
|
+
</button>
|
|
942
|
+
</div>
|
|
943
|
+
</div>
|
|
944
|
+
<div className="roadmap-suggestion-list">
|
|
945
|
+
{featureSuggestions.map((suggestion) => (
|
|
946
|
+
<FeatureSuggestionCard
|
|
947
|
+
key={suggestion.id}
|
|
948
|
+
suggestion={suggestion}
|
|
949
|
+
onUpdateDraft={(patch) => onUpdateFeatureSuggestionDraft?.(milestone.id, suggestion.id, patch)}
|
|
950
|
+
onAccept={() => {
|
|
951
|
+
onAcceptFeatureSuggestion?.(milestone.id, suggestion.id);
|
|
952
|
+
}}
|
|
953
|
+
testIdPrefix={`feature-suggestion-${milestone.id}`}
|
|
954
|
+
/>
|
|
955
|
+
))}
|
|
956
|
+
</div>
|
|
957
|
+
</div>
|
|
958
|
+
)}
|
|
959
|
+
</div>
|
|
960
|
+
</div>
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// ── Feature Suggestion Card ───────────────────────────────────────────
|
|
965
|
+
|
|
966
|
+
interface FeatureSuggestionCardProps {
|
|
967
|
+
suggestion: FeatureSuggestion;
|
|
968
|
+
onUpdateDraft: (patch: SuggestionDraftPatch) => void;
|
|
969
|
+
onAccept: () => void;
|
|
970
|
+
testIdPrefix: string;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function FeatureSuggestionCard({
|
|
974
|
+
suggestion,
|
|
975
|
+
onUpdateDraft,
|
|
976
|
+
onAccept,
|
|
977
|
+
testIdPrefix,
|
|
978
|
+
}: FeatureSuggestionCardProps) {
|
|
979
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
980
|
+
const [editTitle, setEditTitle] = useState(suggestion.title);
|
|
981
|
+
const [editDescription, setEditDescription] = useState(suggestion.description || "");
|
|
982
|
+
|
|
983
|
+
const handleStartEdit = () => {
|
|
984
|
+
setEditTitle(suggestion.title);
|
|
985
|
+
setEditDescription(suggestion.description || "");
|
|
986
|
+
setIsEditing(true);
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
const handleSaveEdit = () => {
|
|
990
|
+
onUpdateDraft({
|
|
991
|
+
title: editTitle.trim(),
|
|
992
|
+
description: editDescription.trim() || undefined,
|
|
993
|
+
});
|
|
994
|
+
setIsEditing(false);
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
const handleCancelEdit = () => {
|
|
998
|
+
setEditTitle(suggestion.title);
|
|
999
|
+
setEditDescription(suggestion.description || "");
|
|
1000
|
+
setIsEditing(false);
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
const handleAccept = () => {
|
|
1004
|
+
if (!suggestion.title.trim()) {
|
|
1005
|
+
return; // Don't accept empty titles
|
|
1006
|
+
}
|
|
1007
|
+
onAccept();
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
const isValid = suggestion.title.trim().length > 0;
|
|
1011
|
+
|
|
1012
|
+
if (isEditing) {
|
|
1013
|
+
return (
|
|
1014
|
+
<div className="roadmap-suggestion-card roadmap-suggestion-card--editing">
|
|
1015
|
+
<div className="roadmap-suggestion-edit-form">
|
|
1016
|
+
<input
|
|
1017
|
+
type="text"
|
|
1018
|
+
className="roadmap-suggestion-input"
|
|
1019
|
+
value={editTitle}
|
|
1020
|
+
onChange={(e) => setEditTitle(e.target.value)}
|
|
1021
|
+
placeholder="Feature title"
|
|
1022
|
+
autoFocus
|
|
1023
|
+
data-testid={`${testIdPrefix}-${suggestion.id}-title-input`}
|
|
1024
|
+
/>
|
|
1025
|
+
<textarea
|
|
1026
|
+
className="roadmap-suggestion-textarea"
|
|
1027
|
+
value={editDescription}
|
|
1028
|
+
onChange={(e) => setEditDescription(e.target.value)}
|
|
1029
|
+
placeholder="Description (optional)"
|
|
1030
|
+
rows={2}
|
|
1031
|
+
data-testid={`${testIdPrefix}-${suggestion.id}-desc-input`}
|
|
1032
|
+
/>
|
|
1033
|
+
<div className="roadmap-suggestion-edit-actions">
|
|
1034
|
+
<button
|
|
1035
|
+
className="roadmap-suggestion-save-btn"
|
|
1036
|
+
onClick={handleSaveEdit}
|
|
1037
|
+
disabled={!editTitle.trim()}
|
|
1038
|
+
title="Save"
|
|
1039
|
+
data-testid={`${testIdPrefix}-${suggestion.id}-save`}
|
|
1040
|
+
>
|
|
1041
|
+
<Check size={12} />
|
|
1042
|
+
</button>
|
|
1043
|
+
<button
|
|
1044
|
+
className="roadmap-suggestion-cancel-btn"
|
|
1045
|
+
onClick={handleCancelEdit}
|
|
1046
|
+
title="Cancel"
|
|
1047
|
+
data-testid={`${testIdPrefix}-${suggestion.id}-cancel`}
|
|
1048
|
+
>
|
|
1049
|
+
<X size={12} />
|
|
1050
|
+
</button>
|
|
1051
|
+
</div>
|
|
1052
|
+
</div>
|
|
1053
|
+
</div>
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
return (
|
|
1058
|
+
<div
|
|
1059
|
+
className="roadmap-suggestion-card"
|
|
1060
|
+
data-testid={`${testIdPrefix}-${suggestion.id}`}
|
|
1061
|
+
>
|
|
1062
|
+
<div className="roadmap-suggestion-content">
|
|
1063
|
+
<span className="roadmap-suggestion-card-title">{suggestion.title}</span>
|
|
1064
|
+
{suggestion.description && (
|
|
1065
|
+
<p className="roadmap-suggestion-card-desc">{suggestion.description}</p>
|
|
1066
|
+
)}
|
|
1067
|
+
</div>
|
|
1068
|
+
<div className="roadmap-suggestion-card-actions">
|
|
1069
|
+
<button
|
|
1070
|
+
className="roadmap-suggestion-edit-btn"
|
|
1071
|
+
onClick={handleStartEdit}
|
|
1072
|
+
title="Edit suggestion"
|
|
1073
|
+
aria-label="Edit"
|
|
1074
|
+
data-testid={`${testIdPrefix}-${suggestion.id}-edit`}
|
|
1075
|
+
>
|
|
1076
|
+
<Pencil size={12} />
|
|
1077
|
+
</button>
|
|
1078
|
+
<button
|
|
1079
|
+
className="roadmap-suggestion-accept-btn"
|
|
1080
|
+
onClick={handleAccept}
|
|
1081
|
+
disabled={!isValid}
|
|
1082
|
+
title="Accept this suggestion"
|
|
1083
|
+
aria-label="Accept"
|
|
1084
|
+
data-testid={`${testIdPrefix}-${suggestion.id}-accept`}
|
|
1085
|
+
>
|
|
1086
|
+
<Check size={12} />
|
|
1087
|
+
</button>
|
|
1088
|
+
</div>
|
|
1089
|
+
</div>
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ── Milestone Suggestion Card ────────────────────────────────────────
|
|
1094
|
+
|
|
1095
|
+
interface MilestoneSuggestionCardProps {
|
|
1096
|
+
suggestion: MilestoneSuggestion;
|
|
1097
|
+
onUpdateDraft: (patch: SuggestionDraftPatch) => void;
|
|
1098
|
+
onAccept: () => void;
|
|
1099
|
+
testIdPrefix: string;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function MilestoneSuggestionCard({
|
|
1103
|
+
suggestion,
|
|
1104
|
+
onUpdateDraft,
|
|
1105
|
+
onAccept,
|
|
1106
|
+
testIdPrefix,
|
|
1107
|
+
}: MilestoneSuggestionCardProps) {
|
|
1108
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
1109
|
+
const [editTitle, setEditTitle] = useState(suggestion.title);
|
|
1110
|
+
const [editDescription, setEditDescription] = useState(suggestion.description || "");
|
|
1111
|
+
|
|
1112
|
+
const handleStartEdit = () => {
|
|
1113
|
+
setEditTitle(suggestion.title);
|
|
1114
|
+
setEditDescription(suggestion.description || "");
|
|
1115
|
+
setIsEditing(true);
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
const handleSaveEdit = () => {
|
|
1119
|
+
onUpdateDraft({
|
|
1120
|
+
title: editTitle.trim(),
|
|
1121
|
+
description: editDescription.trim() || undefined,
|
|
1122
|
+
});
|
|
1123
|
+
setIsEditing(false);
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
const handleCancelEdit = () => {
|
|
1127
|
+
setEditTitle(suggestion.title);
|
|
1128
|
+
setEditDescription(suggestion.description || "");
|
|
1129
|
+
setIsEditing(false);
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
const handleAccept = () => {
|
|
1133
|
+
if (!suggestion.title.trim()) {
|
|
1134
|
+
return; // Don't accept empty titles
|
|
1135
|
+
}
|
|
1136
|
+
onAccept();
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
const isValid = suggestion.title.trim().length > 0;
|
|
1140
|
+
|
|
1141
|
+
if (isEditing) {
|
|
1142
|
+
return (
|
|
1143
|
+
<div className="roadmap-suggestion-card roadmap-suggestion-card--editing">
|
|
1144
|
+
<div className="roadmap-suggestion-edit-form">
|
|
1145
|
+
<input
|
|
1146
|
+
type="text"
|
|
1147
|
+
className="roadmap-suggestion-input"
|
|
1148
|
+
value={editTitle}
|
|
1149
|
+
onChange={(e) => setEditTitle(e.target.value)}
|
|
1150
|
+
placeholder="Milestone title"
|
|
1151
|
+
autoFocus
|
|
1152
|
+
data-testid={`${testIdPrefix}-${suggestion.id}-title-input`}
|
|
1153
|
+
/>
|
|
1154
|
+
<textarea
|
|
1155
|
+
className="roadmap-suggestion-textarea"
|
|
1156
|
+
value={editDescription}
|
|
1157
|
+
onChange={(e) => setEditDescription(e.target.value)}
|
|
1158
|
+
placeholder="Description (optional)"
|
|
1159
|
+
rows={2}
|
|
1160
|
+
data-testid={`${testIdPrefix}-${suggestion.id}-desc-input`}
|
|
1161
|
+
/>
|
|
1162
|
+
<div className="roadmap-suggestion-edit-actions">
|
|
1163
|
+
<button
|
|
1164
|
+
className="roadmap-suggestion-save-btn"
|
|
1165
|
+
onClick={handleSaveEdit}
|
|
1166
|
+
disabled={!editTitle.trim()}
|
|
1167
|
+
title="Save"
|
|
1168
|
+
data-testid={`${testIdPrefix}-${suggestion.id}-save`}
|
|
1169
|
+
>
|
|
1170
|
+
<Check size={12} />
|
|
1171
|
+
</button>
|
|
1172
|
+
<button
|
|
1173
|
+
className="roadmap-suggestion-cancel-btn"
|
|
1174
|
+
onClick={handleCancelEdit}
|
|
1175
|
+
title="Cancel"
|
|
1176
|
+
data-testid={`${testIdPrefix}-${suggestion.id}-cancel`}
|
|
1177
|
+
>
|
|
1178
|
+
<X size={12} />
|
|
1179
|
+
</button>
|
|
1180
|
+
</div>
|
|
1181
|
+
</div>
|
|
1182
|
+
</div>
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
return (
|
|
1187
|
+
<div
|
|
1188
|
+
className="roadmap-suggestion-card"
|
|
1189
|
+
data-testid={`${testIdPrefix}-${suggestion.id}`}
|
|
1190
|
+
>
|
|
1191
|
+
<div className="roadmap-suggestion-content">
|
|
1192
|
+
<span className="roadmap-suggestion-card-title">{suggestion.title}</span>
|
|
1193
|
+
{suggestion.description && (
|
|
1194
|
+
<p className="roadmap-suggestion-card-desc">{suggestion.description}</p>
|
|
1195
|
+
)}
|
|
1196
|
+
</div>
|
|
1197
|
+
<div className="roadmap-suggestion-card-actions">
|
|
1198
|
+
<button
|
|
1199
|
+
className="roadmap-suggestion-edit-btn"
|
|
1200
|
+
onClick={handleStartEdit}
|
|
1201
|
+
title="Edit suggestion"
|
|
1202
|
+
aria-label="Edit"
|
|
1203
|
+
data-testid={`${testIdPrefix}-${suggestion.id}-edit`}
|
|
1204
|
+
>
|
|
1205
|
+
<Pencil size={12} />
|
|
1206
|
+
</button>
|
|
1207
|
+
<button
|
|
1208
|
+
className="roadmap-suggestion-accept-btn"
|
|
1209
|
+
onClick={handleAccept}
|
|
1210
|
+
disabled={!isValid}
|
|
1211
|
+
title="Accept this suggestion"
|
|
1212
|
+
aria-label="Accept"
|
|
1213
|
+
data-testid={`${testIdPrefix}-${suggestion.id}-accept`}
|
|
1214
|
+
>
|
|
1215
|
+
<Check size={12} />
|
|
1216
|
+
</button>
|
|
1217
|
+
</div>
|
|
1218
|
+
</div>
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// ── Create Form ───────────────────────────────────────────────────────
|
|
1223
|
+
|
|
1224
|
+
function CreateRoadmapForm({
|
|
1225
|
+
onSave,
|
|
1226
|
+
onCancel,
|
|
1227
|
+
}: {
|
|
1228
|
+
onSave: (input: RoadmapCreateInput) => void;
|
|
1229
|
+
onCancel: () => void;
|
|
1230
|
+
}) {
|
|
1231
|
+
const [title, setTitle] = useState("");
|
|
1232
|
+
const [description, setDescription] = useState("");
|
|
1233
|
+
|
|
1234
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
1235
|
+
e.preventDefault();
|
|
1236
|
+
if (!title.trim()) return;
|
|
1237
|
+
onSave({ title: title.trim(), description: description.trim() || undefined });
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
return (
|
|
1241
|
+
<div className="roadmaps-view__create-form" data-testid="create-roadmap-form">
|
|
1242
|
+
<form onSubmit={handleSubmit}>
|
|
1243
|
+
<input
|
|
1244
|
+
type="text"
|
|
1245
|
+
className="roadmaps-view__inline-input"
|
|
1246
|
+
value={title}
|
|
1247
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
1248
|
+
placeholder="Roadmap title"
|
|
1249
|
+
autoFocus
|
|
1250
|
+
data-testid="create-roadmap-title"
|
|
1251
|
+
/>
|
|
1252
|
+
<textarea
|
|
1253
|
+
className="roadmaps-view__inline-textarea"
|
|
1254
|
+
value={description}
|
|
1255
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
1256
|
+
placeholder="Roadmap description (optional)"
|
|
1257
|
+
rows={2}
|
|
1258
|
+
data-testid="create-roadmap-description"
|
|
1259
|
+
/>
|
|
1260
|
+
<div className="roadmaps-view__create-form-actions">
|
|
1261
|
+
<button
|
|
1262
|
+
type="submit"
|
|
1263
|
+
className="roadmaps-view__btn roadmaps-view__btn--primary"
|
|
1264
|
+
disabled={!title.trim()}
|
|
1265
|
+
data-testid="create-roadmap-submit"
|
|
1266
|
+
>
|
|
1267
|
+
Create
|
|
1268
|
+
</button>
|
|
1269
|
+
<button
|
|
1270
|
+
type="button"
|
|
1271
|
+
className="roadmaps-view__btn"
|
|
1272
|
+
onClick={onCancel}
|
|
1273
|
+
data-testid="create-roadmap-cancel"
|
|
1274
|
+
>
|
|
1275
|
+
Cancel
|
|
1276
|
+
</button>
|
|
1277
|
+
</div>
|
|
1278
|
+
</form>
|
|
1279
|
+
</div>
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function CreateMilestoneForm({
|
|
1284
|
+
onSave,
|
|
1285
|
+
onCancel,
|
|
1286
|
+
}: {
|
|
1287
|
+
onSave: (input: RoadmapMilestoneCreateInput) => void;
|
|
1288
|
+
onCancel: () => void;
|
|
1289
|
+
}) {
|
|
1290
|
+
const [title, setTitle] = useState("");
|
|
1291
|
+
const [description, setDescription] = useState("");
|
|
1292
|
+
|
|
1293
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
1294
|
+
e.preventDefault();
|
|
1295
|
+
if (!title.trim()) return;
|
|
1296
|
+
onSave({ title: title.trim(), description: description.trim() || undefined });
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
return (
|
|
1300
|
+
<div className="roadmaps-view__create-form roadmaps-view__create-form--inline" data-testid="create-milestone-form">
|
|
1301
|
+
<form onSubmit={handleSubmit} className="roadmaps-view__inline-form">
|
|
1302
|
+
<input
|
|
1303
|
+
type="text"
|
|
1304
|
+
className="roadmaps-view__inline-input"
|
|
1305
|
+
value={title}
|
|
1306
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
1307
|
+
placeholder="Milestone title"
|
|
1308
|
+
autoFocus
|
|
1309
|
+
data-testid="create-milestone-title"
|
|
1310
|
+
/>
|
|
1311
|
+
<textarea
|
|
1312
|
+
className="roadmaps-view__inline-textarea"
|
|
1313
|
+
value={description}
|
|
1314
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
1315
|
+
placeholder="Description (optional)"
|
|
1316
|
+
rows={1}
|
|
1317
|
+
data-testid="create-milestone-description"
|
|
1318
|
+
/>
|
|
1319
|
+
<div className="roadmaps-view__inline-form-actions">
|
|
1320
|
+
<button
|
|
1321
|
+
type="submit"
|
|
1322
|
+
className="roadmaps-view__icon-btn roadmaps-view__icon-btn--success"
|
|
1323
|
+
disabled={!title.trim()}
|
|
1324
|
+
aria-label="Save milestone"
|
|
1325
|
+
title="Save"
|
|
1326
|
+
data-testid="create-milestone-submit"
|
|
1327
|
+
>
|
|
1328
|
+
<Check size={14} />
|
|
1329
|
+
</button>
|
|
1330
|
+
<button
|
|
1331
|
+
type="button"
|
|
1332
|
+
className="roadmaps-view__icon-btn"
|
|
1333
|
+
onClick={onCancel}
|
|
1334
|
+
aria-label="Cancel"
|
|
1335
|
+
title="Cancel"
|
|
1336
|
+
data-testid="create-milestone-cancel"
|
|
1337
|
+
>
|
|
1338
|
+
<X size={14} />
|
|
1339
|
+
</button>
|
|
1340
|
+
</div>
|
|
1341
|
+
</form>
|
|
1342
|
+
</div>
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function CreateFeatureForm({
|
|
1347
|
+
onSave,
|
|
1348
|
+
onCancel,
|
|
1349
|
+
}: {
|
|
1350
|
+
onSave: (input: RoadmapFeatureCreateInput) => void;
|
|
1351
|
+
onCancel: () => void;
|
|
1352
|
+
}) {
|
|
1353
|
+
const [title, setTitle] = useState("");
|
|
1354
|
+
const [description, setDescription] = useState("");
|
|
1355
|
+
|
|
1356
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
1357
|
+
e.preventDefault();
|
|
1358
|
+
if (!title.trim()) return;
|
|
1359
|
+
onSave({ title: title.trim(), description: description.trim() || undefined });
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1362
|
+
return (
|
|
1363
|
+
<div className="roadmaps-view__create-form roadmaps-view__create-form--inline" data-testid="create-feature-form">
|
|
1364
|
+
<form onSubmit={handleSubmit} className="roadmaps-view__inline-form">
|
|
1365
|
+
<input
|
|
1366
|
+
type="text"
|
|
1367
|
+
className="roadmaps-view__inline-input"
|
|
1368
|
+
value={title}
|
|
1369
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
1370
|
+
placeholder="Feature title"
|
|
1371
|
+
autoFocus
|
|
1372
|
+
data-testid="create-feature-title"
|
|
1373
|
+
/>
|
|
1374
|
+
<textarea
|
|
1375
|
+
className="roadmaps-view__inline-textarea"
|
|
1376
|
+
value={description}
|
|
1377
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
1378
|
+
placeholder="Description (optional)"
|
|
1379
|
+
rows={1}
|
|
1380
|
+
data-testid="create-feature-description"
|
|
1381
|
+
/>
|
|
1382
|
+
<div className="roadmaps-view__inline-form-actions">
|
|
1383
|
+
<button
|
|
1384
|
+
type="submit"
|
|
1385
|
+
className="roadmaps-view__icon-btn roadmaps-view__icon-btn--success"
|
|
1386
|
+
disabled={!title.trim()}
|
|
1387
|
+
aria-label="Save feature"
|
|
1388
|
+
title="Save"
|
|
1389
|
+
data-testid="create-feature-submit"
|
|
1390
|
+
>
|
|
1391
|
+
<Check size={14} />
|
|
1392
|
+
</button>
|
|
1393
|
+
<button
|
|
1394
|
+
type="button"
|
|
1395
|
+
className="roadmaps-view__icon-btn"
|
|
1396
|
+
onClick={onCancel}
|
|
1397
|
+
aria-label="Cancel"
|
|
1398
|
+
title="Cancel"
|
|
1399
|
+
data-testid="create-feature-cancel"
|
|
1400
|
+
>
|
|
1401
|
+
<X size={14} />
|
|
1402
|
+
</button>
|
|
1403
|
+
</div>
|
|
1404
|
+
</form>
|
|
1405
|
+
</div>
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// ── Main Component ────────────────────────────────────────────────────
|
|
1410
|
+
|
|
1411
|
+
export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
|
1412
|
+
const { confirm } = useConfirm();
|
|
1413
|
+
const isMobile = useViewportMode() === "mobile";
|
|
1414
|
+
|
|
1415
|
+
const {
|
|
1416
|
+
roadmaps,
|
|
1417
|
+
selectedRoadmapId,
|
|
1418
|
+
selectedRoadmap,
|
|
1419
|
+
milestones,
|
|
1420
|
+
featuresByMilestoneId,
|
|
1421
|
+
loading,
|
|
1422
|
+
error,
|
|
1423
|
+
createRoadmap,
|
|
1424
|
+
updateRoadmap,
|
|
1425
|
+
deleteRoadmap,
|
|
1426
|
+
selectRoadmap,
|
|
1427
|
+
createMilestone,
|
|
1428
|
+
updateMilestone,
|
|
1429
|
+
deleteMilestone,
|
|
1430
|
+
createFeature,
|
|
1431
|
+
updateFeature,
|
|
1432
|
+
deleteFeature,
|
|
1433
|
+
reorderMilestones,
|
|
1434
|
+
reorderFeatures,
|
|
1435
|
+
moveFeature,
|
|
1436
|
+
milestoneSuggestions,
|
|
1437
|
+
isGeneratingSuggestions,
|
|
1438
|
+
generateMilestoneSuggestions,
|
|
1439
|
+
updateMilestoneSuggestionDraft,
|
|
1440
|
+
acceptMilestoneSuggestion,
|
|
1441
|
+
acceptAllMilestoneSuggestions,
|
|
1442
|
+
clearMilestoneSuggestions,
|
|
1443
|
+
featureSuggestionsByMilestoneId,
|
|
1444
|
+
isGeneratingFeatureSuggestions,
|
|
1445
|
+
generateFeatureSuggestions,
|
|
1446
|
+
updateFeatureSuggestionDraft,
|
|
1447
|
+
acceptFeatureSuggestion,
|
|
1448
|
+
acceptAllFeatureSuggestions,
|
|
1449
|
+
clearFeatureSuggestions,
|
|
1450
|
+
handoffPayload,
|
|
1451
|
+
isFetchingHandoff,
|
|
1452
|
+
handoffError,
|
|
1453
|
+
fetchHandoff,
|
|
1454
|
+
clearHandoff,
|
|
1455
|
+
} = useRoadmaps({ projectId });
|
|
1456
|
+
|
|
1457
|
+
// Handoff modal state
|
|
1458
|
+
const [handoffModalOpen, setHandoffModalOpen] = useState(false);
|
|
1459
|
+
const [handoffRoadmapId, setHandoffRoadmapId] = useState<string | null>(null);
|
|
1460
|
+
const [handoffRoadmapTitle, setHandoffRoadmapTitle] = useState<string>("");
|
|
1461
|
+
|
|
1462
|
+
// Goal prompt state for milestone suggestion generation
|
|
1463
|
+
const [goalPrompt, setGoalPrompt] = useState("");
|
|
1464
|
+
|
|
1465
|
+
// Mobile suggestion panel collapse state
|
|
1466
|
+
const [showSuggestionPanel, setShowSuggestionPanel] = useState(false);
|
|
1467
|
+
|
|
1468
|
+
// Reset suggestion panel when roadmap changes on mobile
|
|
1469
|
+
const prevRoadmapIdRef = useRef<string | null>(null);
|
|
1470
|
+
useEffect(() => {
|
|
1471
|
+
if (prevRoadmapIdRef.current !== null && prevRoadmapIdRef.current !== selectedRoadmapId) {
|
|
1472
|
+
setShowSuggestionPanel(false);
|
|
1473
|
+
}
|
|
1474
|
+
prevRoadmapIdRef.current = selectedRoadmapId;
|
|
1475
|
+
}, [selectedRoadmapId]);
|
|
1476
|
+
|
|
1477
|
+
// Inline edit states
|
|
1478
|
+
const [roadmapEdit, setRoadmapEdit] = useState<InlineEditState>({
|
|
1479
|
+
roadmapId: null,
|
|
1480
|
+
field: null,
|
|
1481
|
+
value: "",
|
|
1482
|
+
});
|
|
1483
|
+
const [milestoneEdit, setMilestoneEdit] = useState<MilestoneInlineEditState>({
|
|
1484
|
+
milestoneId: null,
|
|
1485
|
+
field: null,
|
|
1486
|
+
value: "",
|
|
1487
|
+
});
|
|
1488
|
+
const [featureEdit, setFeatureEdit] = useState<FeatureInlineEditState>({
|
|
1489
|
+
featureId: null,
|
|
1490
|
+
field: null,
|
|
1491
|
+
value: "",
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
// Create form state
|
|
1495
|
+
const [createForm, setCreateForm] = useState<CreateFormState>({
|
|
1496
|
+
type: null,
|
|
1497
|
+
parentId: undefined,
|
|
1498
|
+
title: "",
|
|
1499
|
+
description: "",
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
// Mobile roadmap list create form state
|
|
1503
|
+
const [mobileShowCreateForm, setMobileShowCreateForm] = useState(false);
|
|
1504
|
+
|
|
1505
|
+
// Milestone drag-and-drop state
|
|
1506
|
+
const [milestoneDrag, setMilestoneDrag] = useState<MilestoneDragState>({
|
|
1507
|
+
draggingId: null,
|
|
1508
|
+
dropTargetId: null,
|
|
1509
|
+
dropPosition: null,
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
// Milestone drag handlers
|
|
1513
|
+
const handleMilestoneDragStart = useCallback((milestoneId: string) => {
|
|
1514
|
+
setMilestoneDrag((prev) => ({
|
|
1515
|
+
...prev,
|
|
1516
|
+
draggingId: milestoneId,
|
|
1517
|
+
}));
|
|
1518
|
+
}, []);
|
|
1519
|
+
|
|
1520
|
+
const handleMilestoneDragEnd = useCallback(() => {
|
|
1521
|
+
setMilestoneDrag({
|
|
1522
|
+
draggingId: null,
|
|
1523
|
+
dropTargetId: null,
|
|
1524
|
+
dropPosition: null,
|
|
1525
|
+
});
|
|
1526
|
+
}, []);
|
|
1527
|
+
|
|
1528
|
+
const handleMilestoneDragOver = useCallback((targetMilestoneId: string) => {
|
|
1529
|
+
setMilestoneDrag((prev) => {
|
|
1530
|
+
// Don't update if dragging over self
|
|
1531
|
+
if (prev.draggingId === targetMilestoneId) {
|
|
1532
|
+
return prev;
|
|
1533
|
+
}
|
|
1534
|
+
// Calculate drop position based on mouse position relative to target
|
|
1535
|
+
// The position will be computed based on where the drop will happen
|
|
1536
|
+
// For now, we just track the target
|
|
1537
|
+
return {
|
|
1538
|
+
...prev,
|
|
1539
|
+
dropTargetId: targetMilestoneId,
|
|
1540
|
+
dropPosition: null, // Will be set in handleMilestoneDrop
|
|
1541
|
+
};
|
|
1542
|
+
});
|
|
1543
|
+
}, []);
|
|
1544
|
+
|
|
1545
|
+
// Feature drag-and-drop state
|
|
1546
|
+
const [featureDrag, setFeatureDrag] = useState<FeatureDragState>({
|
|
1547
|
+
draggingId: null,
|
|
1548
|
+
draggingMilestoneId: null,
|
|
1549
|
+
dropTargetMilestoneId: null,
|
|
1550
|
+
dropTargetIndex: null,
|
|
1551
|
+
dropPosition: null,
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
// Feature drag handlers
|
|
1555
|
+
const handleFeatureDragStart = useCallback((featureId: string, milestoneId: string) => {
|
|
1556
|
+
setFeatureDrag((prev) => ({
|
|
1557
|
+
...prev,
|
|
1558
|
+
draggingId: featureId,
|
|
1559
|
+
draggingMilestoneId: milestoneId,
|
|
1560
|
+
}));
|
|
1561
|
+
}, []);
|
|
1562
|
+
|
|
1563
|
+
const handleFeatureDragEnd = useCallback(() => {
|
|
1564
|
+
setFeatureDrag({
|
|
1565
|
+
draggingId: null,
|
|
1566
|
+
draggingMilestoneId: null,
|
|
1567
|
+
dropTargetMilestoneId: null,
|
|
1568
|
+
dropTargetIndex: null,
|
|
1569
|
+
dropPosition: null,
|
|
1570
|
+
});
|
|
1571
|
+
}, []);
|
|
1572
|
+
|
|
1573
|
+
const handleFeatureDragOver = useCallback((targetFeatureId: string, position: "before" | "after") => {
|
|
1574
|
+
setFeatureDrag((prev) => {
|
|
1575
|
+
// Don't update if dragging over self
|
|
1576
|
+
if (prev.draggingId === targetFeatureId) {
|
|
1577
|
+
return prev;
|
|
1578
|
+
}
|
|
1579
|
+
// Find the target feature's index in its milestone
|
|
1580
|
+
const targetFeatures = featuresByMilestoneId[prev.draggingMilestoneId || ""] || [];
|
|
1581
|
+
const targetIndex = targetFeatures.findIndex((f) => f.id === targetFeatureId);
|
|
1582
|
+
|
|
1583
|
+
let dropTargetIndex: number;
|
|
1584
|
+
if (position === "before") {
|
|
1585
|
+
dropTargetIndex = targetIndex;
|
|
1586
|
+
} else {
|
|
1587
|
+
dropTargetIndex = targetIndex + 1;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
return {
|
|
1591
|
+
...prev,
|
|
1592
|
+
dropTargetMilestoneId: prev.draggingMilestoneId,
|
|
1593
|
+
dropTargetIndex,
|
|
1594
|
+
dropPosition: position,
|
|
1595
|
+
};
|
|
1596
|
+
});
|
|
1597
|
+
}, [featuresByMilestoneId]);
|
|
1598
|
+
|
|
1599
|
+
const handleFeatureDropOnMilestone = useCallback(() => {
|
|
1600
|
+
setFeatureDrag((prev) => ({
|
|
1601
|
+
...prev,
|
|
1602
|
+
dropTargetMilestoneId: prev.draggingMilestoneId,
|
|
1603
|
+
// Append to end of feature list
|
|
1604
|
+
dropTargetIndex: (featuresByMilestoneId[prev.draggingMilestoneId || ""] || []).length,
|
|
1605
|
+
}));
|
|
1606
|
+
}, [featuresByMilestoneId]);
|
|
1607
|
+
|
|
1608
|
+
const handleFeatureDrop = useCallback(async (featureId: string, targetIndex: number) => {
|
|
1609
|
+
const { draggingMilestoneId, dropTargetMilestoneId } = featureDrag;
|
|
1610
|
+
if (!draggingMilestoneId) {
|
|
1611
|
+
handleFeatureDragEnd();
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// Determine the target milestone - use the drop target if available, otherwise the dragging milestone
|
|
1616
|
+
const targetMilestoneId = dropTargetMilestoneId || draggingMilestoneId;
|
|
1617
|
+
|
|
1618
|
+
// Get the source features
|
|
1619
|
+
const sourceFeatures = featuresByMilestoneId[draggingMilestoneId] || [];
|
|
1620
|
+
|
|
1621
|
+
// Find the feature being dragged
|
|
1622
|
+
const featureBeingDragged = sourceFeatures.find((f) => f.id === featureId);
|
|
1623
|
+
if (!featureBeingDragged) {
|
|
1624
|
+
handleFeatureDragEnd();
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// Check if this is a cross-milestone move
|
|
1629
|
+
const isCrossMilestone = draggingMilestoneId !== targetMilestoneId;
|
|
1630
|
+
|
|
1631
|
+
if (isCrossMilestone) {
|
|
1632
|
+
// No-op check: if moving to same position in same milestone (shouldn't happen but safety check)
|
|
1633
|
+
if (draggingMilestoneId === targetMilestoneId) {
|
|
1634
|
+
handleFeatureDragEnd();
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// Perform the move
|
|
1639
|
+
try {
|
|
1640
|
+
await moveFeature(featureId, targetMilestoneId, targetIndex, {
|
|
1641
|
+
onError: (err) => {
|
|
1642
|
+
addToast(`Failed to move feature: ${err.message}`, "error");
|
|
1643
|
+
},
|
|
1644
|
+
});
|
|
1645
|
+
} catch {
|
|
1646
|
+
// Error handled in callback
|
|
1647
|
+
}
|
|
1648
|
+
} else {
|
|
1649
|
+
// Same-milestone reorder
|
|
1650
|
+
const targetFeatures = [...sourceFeatures];
|
|
1651
|
+
const fromIndex = targetFeatures.findIndex((f) => f.id === featureId);
|
|
1652
|
+
|
|
1653
|
+
// Remove from current position and insert at target
|
|
1654
|
+
targetFeatures.splice(fromIndex, 1);
|
|
1655
|
+
targetFeatures.splice(targetIndex, 0, featureBeingDragged);
|
|
1656
|
+
|
|
1657
|
+
// Compute new order of feature IDs
|
|
1658
|
+
const orderedIds = targetFeatures.map((f) => f.id);
|
|
1659
|
+
|
|
1660
|
+
// No-op check: if order is unchanged
|
|
1661
|
+
const currentIds = sourceFeatures.map((f) => f.id);
|
|
1662
|
+
if (orderedIds.join(",") === currentIds.join(",")) {
|
|
1663
|
+
handleFeatureDragEnd();
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// Perform the reorder
|
|
1668
|
+
try {
|
|
1669
|
+
await reorderFeatures(draggingMilestoneId, orderedIds, {
|
|
1670
|
+
onError: (err) => {
|
|
1671
|
+
addToast(`Failed to reorder features: ${err.message}`, "error");
|
|
1672
|
+
},
|
|
1673
|
+
});
|
|
1674
|
+
} catch {
|
|
1675
|
+
// Error handled in callback
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
handleFeatureDragEnd();
|
|
1680
|
+
}, [featureDrag, featuresByMilestoneId, reorderFeatures, moveFeature, addToast, handleFeatureDragEnd]);
|
|
1681
|
+
|
|
1682
|
+
const handleFeatureDragLeave = useCallback((e: React.DragEvent) => {
|
|
1683
|
+
// Only clear if leaving the element entirely
|
|
1684
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
1685
|
+
const x = e.clientX;
|
|
1686
|
+
const y = e.clientY;
|
|
1687
|
+
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
|
1688
|
+
setFeatureDrag((prev) => ({
|
|
1689
|
+
...prev,
|
|
1690
|
+
dropTargetMilestoneId: null,
|
|
1691
|
+
dropTargetIndex: null,
|
|
1692
|
+
dropPosition: null,
|
|
1693
|
+
}));
|
|
1694
|
+
}
|
|
1695
|
+
}, []);
|
|
1696
|
+
|
|
1697
|
+
// Check if a feature is being dragged
|
|
1698
|
+
const isFeatureDragging = useCallback((featureId: string) => {
|
|
1699
|
+
return featureDrag.draggingId === featureId;
|
|
1700
|
+
}, [featureDrag.draggingId]);
|
|
1701
|
+
|
|
1702
|
+
const handleMilestoneDrop = useCallback(async (targetMilestoneId: string) => {
|
|
1703
|
+
const { draggingId } = milestoneDrag;
|
|
1704
|
+
if (!draggingId || draggingId === targetMilestoneId) {
|
|
1705
|
+
handleMilestoneDragEnd();
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// Compute the new order
|
|
1710
|
+
const currentOrder = milestones.map((m) => m.id);
|
|
1711
|
+
const fromIndex = currentOrder.indexOf(draggingId);
|
|
1712
|
+
const toIndex = currentOrder.indexOf(targetMilestoneId);
|
|
1713
|
+
|
|
1714
|
+
if (fromIndex === -1 || toIndex === -1) {
|
|
1715
|
+
handleMilestoneDragEnd();
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// Compute the new order based on drop position
|
|
1720
|
+
// The drop indicator shows where the item will be inserted
|
|
1721
|
+
const newOrder = [...currentOrder];
|
|
1722
|
+
newOrder.splice(fromIndex, 1);
|
|
1723
|
+
newOrder.splice(toIndex, 0, draggingId);
|
|
1724
|
+
|
|
1725
|
+
// No-op check: if the order is unchanged
|
|
1726
|
+
if (newOrder.join(",") === currentOrder.join(",")) {
|
|
1727
|
+
handleMilestoneDragEnd();
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Perform the reorder
|
|
1732
|
+
try {
|
|
1733
|
+
await reorderMilestones(selectedRoadmapId!, newOrder, {
|
|
1734
|
+
onError: (err) => {
|
|
1735
|
+
addToast(`Failed to reorder milestones: ${err.message}`, "error");
|
|
1736
|
+
},
|
|
1737
|
+
});
|
|
1738
|
+
} catch {
|
|
1739
|
+
// Error handled in callback
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
handleMilestoneDragEnd();
|
|
1743
|
+
}, [milestoneDrag, milestones, selectedRoadmapId, reorderMilestones, addToast, handleMilestoneDragEnd]);
|
|
1744
|
+
|
|
1745
|
+
const handleMilestoneDragLeave = useCallback((e: React.DragEvent) => {
|
|
1746
|
+
// Only clear if leaving the element entirely
|
|
1747
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
1748
|
+
const x = e.clientX;
|
|
1749
|
+
const y = e.clientY;
|
|
1750
|
+
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
|
1751
|
+
setMilestoneDrag((prev) => ({
|
|
1752
|
+
...prev,
|
|
1753
|
+
dropTargetId: null,
|
|
1754
|
+
dropPosition: null,
|
|
1755
|
+
}));
|
|
1756
|
+
}
|
|
1757
|
+
}, []);
|
|
1758
|
+
|
|
1759
|
+
// Roadmap handlers
|
|
1760
|
+
const handleStartRoadmapEdit = useCallback((roadmap: Roadmap) => {
|
|
1761
|
+
selectRoadmap(roadmap.id);
|
|
1762
|
+
setRoadmapEdit({
|
|
1763
|
+
roadmapId: roadmap.id,
|
|
1764
|
+
field: "title",
|
|
1765
|
+
value: roadmap.title,
|
|
1766
|
+
});
|
|
1767
|
+
}, [selectRoadmap]);
|
|
1768
|
+
|
|
1769
|
+
const handleCancelRoadmapEdit = useCallback(() => {
|
|
1770
|
+
setRoadmapEdit({ roadmapId: null, field: null, value: "" });
|
|
1771
|
+
}, []);
|
|
1772
|
+
|
|
1773
|
+
const handleSaveRoadmapEdit = useCallback(
|
|
1774
|
+
async (updates: RoadmapUpdateInput) => {
|
|
1775
|
+
if (!roadmapEdit.roadmapId) return;
|
|
1776
|
+
try {
|
|
1777
|
+
await updateRoadmap(roadmapEdit.roadmapId, updates, {
|
|
1778
|
+
onError: (err) => addToast(err.message, "error"),
|
|
1779
|
+
});
|
|
1780
|
+
handleCancelRoadmapEdit();
|
|
1781
|
+
} catch {
|
|
1782
|
+
// Error handled in callback
|
|
1783
|
+
}
|
|
1784
|
+
},
|
|
1785
|
+
[roadmapEdit.roadmapId, updateRoadmap, handleCancelRoadmapEdit, addToast]
|
|
1786
|
+
);
|
|
1787
|
+
|
|
1788
|
+
const handleDeleteRoadmap = useCallback(
|
|
1789
|
+
async (roadmapId: string) => {
|
|
1790
|
+
const shouldDelete = await confirm({
|
|
1791
|
+
title: "Delete Roadmap",
|
|
1792
|
+
message: "Delete this roadmap? This cannot be undone.",
|
|
1793
|
+
danger: true,
|
|
1794
|
+
});
|
|
1795
|
+
if (!shouldDelete) return;
|
|
1796
|
+
try {
|
|
1797
|
+
await deleteRoadmap(roadmapId, {
|
|
1798
|
+
onError: (err) => addToast(err.message, "error"),
|
|
1799
|
+
});
|
|
1800
|
+
addToast("Roadmap deleted", "success");
|
|
1801
|
+
} catch {
|
|
1802
|
+
// Error handled in callback
|
|
1803
|
+
}
|
|
1804
|
+
},
|
|
1805
|
+
[deleteRoadmap, addToast, confirm]
|
|
1806
|
+
);
|
|
1807
|
+
|
|
1808
|
+
// Handoff handlers
|
|
1809
|
+
const handleOpenHandoffModal = useCallback((roadmapId: string, roadmapTitle: string) => {
|
|
1810
|
+
setHandoffRoadmapId(roadmapId);
|
|
1811
|
+
setHandoffRoadmapTitle(roadmapTitle);
|
|
1812
|
+
setHandoffModalOpen(true);
|
|
1813
|
+
// Clear any previous handoff data
|
|
1814
|
+
clearHandoff();
|
|
1815
|
+
}, [clearHandoff]);
|
|
1816
|
+
|
|
1817
|
+
const handleCloseHandoffModal = useCallback(() => {
|
|
1818
|
+
setHandoffModalOpen(false);
|
|
1819
|
+
setHandoffRoadmapId(null);
|
|
1820
|
+
setHandoffRoadmapTitle("");
|
|
1821
|
+
clearHandoff();
|
|
1822
|
+
}, [clearHandoff]);
|
|
1823
|
+
|
|
1824
|
+
const handleFetchHandoff = useCallback(() => {
|
|
1825
|
+
if (handoffRoadmapId) {
|
|
1826
|
+
fetchHandoff(handoffRoadmapId, {
|
|
1827
|
+
onError: (err) => addToast(`Failed to load handoff: ${err.message}`, "error"),
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
}, [handoffRoadmapId, fetchHandoff, addToast]);
|
|
1831
|
+
|
|
1832
|
+
const handleCopyHandoffToClipboard = useCallback(() => {
|
|
1833
|
+
if (handoffPayload) {
|
|
1834
|
+
const data = JSON.stringify(handoffPayload, null, 2);
|
|
1835
|
+
navigator.clipboard.writeText(data).then(() => {
|
|
1836
|
+
addToast("Handoff data copied to clipboard", "success");
|
|
1837
|
+
}).catch(() => {
|
|
1838
|
+
addToast("Failed to copy to clipboard", "error");
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
}, [handoffPayload, addToast]);
|
|
1842
|
+
|
|
1843
|
+
const handleCreateRoadmap = useCallback(
|
|
1844
|
+
async (input: RoadmapCreateInput) => {
|
|
1845
|
+
try {
|
|
1846
|
+
await createRoadmap(input, {
|
|
1847
|
+
onError: (err) => addToast(err.message, "error"),
|
|
1848
|
+
});
|
|
1849
|
+
setCreateForm({ type: null, parentId: undefined, title: "", description: "" });
|
|
1850
|
+
addToast("Roadmap created", "success");
|
|
1851
|
+
} catch {
|
|
1852
|
+
// Error handled in callback
|
|
1853
|
+
}
|
|
1854
|
+
},
|
|
1855
|
+
[createRoadmap, addToast]
|
|
1856
|
+
);
|
|
1857
|
+
|
|
1858
|
+
// Milestone handlers
|
|
1859
|
+
const handleStartMilestoneEdit = useCallback((milestone: RoadmapMilestone) => {
|
|
1860
|
+
setMilestoneEdit({
|
|
1861
|
+
milestoneId: milestone.id,
|
|
1862
|
+
field: "title",
|
|
1863
|
+
value: milestone.title,
|
|
1864
|
+
});
|
|
1865
|
+
}, []);
|
|
1866
|
+
|
|
1867
|
+
const handleMilestoneEditChange = useCallback((value: string) => {
|
|
1868
|
+
setMilestoneEdit((previous) => ({ ...previous, value }));
|
|
1869
|
+
}, []);
|
|
1870
|
+
|
|
1871
|
+
const handleMilestoneEditFieldChange = useCallback((field: "title" | "description") => {
|
|
1872
|
+
setMilestoneEdit((previous) => ({ ...previous, field }));
|
|
1873
|
+
}, []);
|
|
1874
|
+
|
|
1875
|
+
const handleCancelMilestoneEdit = useCallback(() => {
|
|
1876
|
+
setMilestoneEdit({ milestoneId: null, field: null, value: "" });
|
|
1877
|
+
}, []);
|
|
1878
|
+
|
|
1879
|
+
const handleSaveMilestoneEdit = useCallback(
|
|
1880
|
+
async (updates: RoadmapMilestoneUpdateInput) => {
|
|
1881
|
+
if (!milestoneEdit.milestoneId) return;
|
|
1882
|
+
try {
|
|
1883
|
+
await updateMilestone(milestoneEdit.milestoneId, updates, {
|
|
1884
|
+
onError: (err) => addToast(err.message, "error"),
|
|
1885
|
+
});
|
|
1886
|
+
handleCancelMilestoneEdit();
|
|
1887
|
+
} catch {
|
|
1888
|
+
// Error handled in callback
|
|
1889
|
+
}
|
|
1890
|
+
},
|
|
1891
|
+
[milestoneEdit.milestoneId, updateMilestone, handleCancelMilestoneEdit, addToast]
|
|
1892
|
+
);
|
|
1893
|
+
|
|
1894
|
+
const handleDeleteMilestone = useCallback(
|
|
1895
|
+
async (milestoneId: string) => {
|
|
1896
|
+
const shouldDelete = await confirm({
|
|
1897
|
+
title: "Delete Milestone",
|
|
1898
|
+
message: "Delete this milestone and all its features?",
|
|
1899
|
+
danger: true,
|
|
1900
|
+
});
|
|
1901
|
+
if (!shouldDelete) return;
|
|
1902
|
+
try {
|
|
1903
|
+
await deleteMilestone(milestoneId, {
|
|
1904
|
+
onError: (err) => addToast(err.message, "error"),
|
|
1905
|
+
});
|
|
1906
|
+
addToast("Milestone deleted", "success");
|
|
1907
|
+
} catch {
|
|
1908
|
+
// Error handled in callback
|
|
1909
|
+
}
|
|
1910
|
+
},
|
|
1911
|
+
[deleteMilestone, addToast, confirm]
|
|
1912
|
+
);
|
|
1913
|
+
|
|
1914
|
+
const handleCreateMilestone = useCallback(
|
|
1915
|
+
async (input: RoadmapMilestoneCreateInput) => {
|
|
1916
|
+
try {
|
|
1917
|
+
await createMilestone(input, {
|
|
1918
|
+
onError: (err) => addToast(err.message, "error"),
|
|
1919
|
+
});
|
|
1920
|
+
setCreateForm({ type: null, parentId: undefined, title: "", description: "" });
|
|
1921
|
+
addToast("Milestone created", "success");
|
|
1922
|
+
} catch {
|
|
1923
|
+
// Error handled in callback
|
|
1924
|
+
}
|
|
1925
|
+
},
|
|
1926
|
+
[createMilestone, addToast]
|
|
1927
|
+
);
|
|
1928
|
+
|
|
1929
|
+
// Feature handlers
|
|
1930
|
+
const handleStartFeatureEdit = useCallback(
|
|
1931
|
+
(featureId: string, currentTitle: string, _currentDescription?: string) => {
|
|
1932
|
+
setFeatureEdit({
|
|
1933
|
+
featureId,
|
|
1934
|
+
field: "title",
|
|
1935
|
+
value: currentTitle,
|
|
1936
|
+
});
|
|
1937
|
+
},
|
|
1938
|
+
[]
|
|
1939
|
+
);
|
|
1940
|
+
|
|
1941
|
+
const handleFeatureEditChange = useCallback((value: string) => {
|
|
1942
|
+
setFeatureEdit((previous) => ({ ...previous, value }));
|
|
1943
|
+
}, []);
|
|
1944
|
+
|
|
1945
|
+
const handleCancelFeatureEdit = useCallback(() => {
|
|
1946
|
+
setFeatureEdit({ featureId: null, field: null, value: "" });
|
|
1947
|
+
}, []);
|
|
1948
|
+
|
|
1949
|
+
const handleSaveFeatureEdit = useCallback(
|
|
1950
|
+
async (updates: RoadmapFeatureUpdateInput) => {
|
|
1951
|
+
if (!featureEdit.featureId) return;
|
|
1952
|
+
try {
|
|
1953
|
+
await updateFeature(featureEdit.featureId, updates, {
|
|
1954
|
+
onError: (err) => addToast(err.message, "error"),
|
|
1955
|
+
});
|
|
1956
|
+
handleCancelFeatureEdit();
|
|
1957
|
+
} catch {
|
|
1958
|
+
// Error handled in callback
|
|
1959
|
+
}
|
|
1960
|
+
},
|
|
1961
|
+
[featureEdit.featureId, updateFeature, handleCancelFeatureEdit, addToast]
|
|
1962
|
+
);
|
|
1963
|
+
|
|
1964
|
+
const handleDeleteFeature = useCallback(
|
|
1965
|
+
async (featureId: string) => {
|
|
1966
|
+
const shouldDelete = await confirm({
|
|
1967
|
+
title: "Delete Feature",
|
|
1968
|
+
message: "Delete this feature?",
|
|
1969
|
+
danger: true,
|
|
1970
|
+
});
|
|
1971
|
+
if (!shouldDelete) return;
|
|
1972
|
+
try {
|
|
1973
|
+
await deleteFeature(featureId, {
|
|
1974
|
+
onError: (err) => addToast(err.message, "error"),
|
|
1975
|
+
});
|
|
1976
|
+
addToast("Feature deleted", "success");
|
|
1977
|
+
} catch {
|
|
1978
|
+
// Error handled in callback
|
|
1979
|
+
}
|
|
1980
|
+
},
|
|
1981
|
+
[deleteFeature, addToast, confirm]
|
|
1982
|
+
);
|
|
1983
|
+
|
|
1984
|
+
// Milestone suggestion handlers
|
|
1985
|
+
const handleGenerateSuggestions = useCallback(
|
|
1986
|
+
async () => {
|
|
1987
|
+
if (!goalPrompt.trim()) return;
|
|
1988
|
+
try {
|
|
1989
|
+
await generateMilestoneSuggestions(goalPrompt, 5, {
|
|
1990
|
+
onError: (err) => addToast(err.message, "error"),
|
|
1991
|
+
});
|
|
1992
|
+
} catch {
|
|
1993
|
+
// Error handled in callback
|
|
1994
|
+
}
|
|
1995
|
+
},
|
|
1996
|
+
[goalPrompt, generateMilestoneSuggestions, addToast]
|
|
1997
|
+
);
|
|
1998
|
+
|
|
1999
|
+
const handleAcceptSuggestion = useCallback(
|
|
2000
|
+
async (draftId: string) => {
|
|
2001
|
+
try {
|
|
2002
|
+
await acceptMilestoneSuggestion(draftId, {
|
|
2003
|
+
onError: (err) => addToast(err.message, "error"),
|
|
2004
|
+
});
|
|
2005
|
+
addToast("Milestone added", "success");
|
|
2006
|
+
} catch {
|
|
2007
|
+
// Error handled in callback
|
|
2008
|
+
}
|
|
2009
|
+
},
|
|
2010
|
+
[acceptMilestoneSuggestion, addToast]
|
|
2011
|
+
);
|
|
2012
|
+
|
|
2013
|
+
const handleAcceptAllSuggestions = useCallback(
|
|
2014
|
+
async () => {
|
|
2015
|
+
try {
|
|
2016
|
+
await acceptAllMilestoneSuggestions({
|
|
2017
|
+
onError: (err) => addToast(err.message, "error"),
|
|
2018
|
+
});
|
|
2019
|
+
addToast(`${milestoneSuggestions.length} milestones added`, "success");
|
|
2020
|
+
setGoalPrompt("");
|
|
2021
|
+
} catch {
|
|
2022
|
+
// Error handled in callback
|
|
2023
|
+
}
|
|
2024
|
+
},
|
|
2025
|
+
[acceptAllMilestoneSuggestions, milestoneSuggestions.length, addToast]
|
|
2026
|
+
);
|
|
2027
|
+
|
|
2028
|
+
const handleClearSuggestions = useCallback(() => {
|
|
2029
|
+
clearMilestoneSuggestions();
|
|
2030
|
+
setGoalPrompt("");
|
|
2031
|
+
}, [clearMilestoneSuggestions]);
|
|
2032
|
+
|
|
2033
|
+
// Feature suggestion handlers
|
|
2034
|
+
const handleGenerateFeatureSuggestions = useCallback(
|
|
2035
|
+
async (milestoneId: string) => {
|
|
2036
|
+
try {
|
|
2037
|
+
await generateFeatureSuggestions(milestoneId, { count: 5 }, {
|
|
2038
|
+
onError: (err) => addToast(err.message, "error"),
|
|
2039
|
+
});
|
|
2040
|
+
} catch {
|
|
2041
|
+
// Error handled in callback
|
|
2042
|
+
}
|
|
2043
|
+
},
|
|
2044
|
+
[generateFeatureSuggestions, addToast]
|
|
2045
|
+
);
|
|
2046
|
+
|
|
2047
|
+
const handleAcceptFeatureSuggestion = useCallback(
|
|
2048
|
+
async (milestoneId: string, draftId: string) => {
|
|
2049
|
+
try {
|
|
2050
|
+
await acceptFeatureSuggestion(milestoneId, draftId, {
|
|
2051
|
+
onError: (err) => addToast(err.message, "error"),
|
|
2052
|
+
});
|
|
2053
|
+
addToast("Feature added", "success");
|
|
2054
|
+
} catch {
|
|
2055
|
+
// Error handled in callback
|
|
2056
|
+
}
|
|
2057
|
+
},
|
|
2058
|
+
[acceptFeatureSuggestion, addToast]
|
|
2059
|
+
);
|
|
2060
|
+
|
|
2061
|
+
const handleUpdateFeatureSuggestionDraft = useCallback(
|
|
2062
|
+
(milestoneId: string, draftId: string, patch: SuggestionDraftPatch) => {
|
|
2063
|
+
updateFeatureSuggestionDraft(milestoneId, draftId, patch);
|
|
2064
|
+
},
|
|
2065
|
+
[updateFeatureSuggestionDraft]
|
|
2066
|
+
);
|
|
2067
|
+
|
|
2068
|
+
const handleAcceptAllFeatureSuggestions = useCallback(
|
|
2069
|
+
async (milestoneId: string) => {
|
|
2070
|
+
const suggestions = featureSuggestionsByMilestoneId[milestoneId] || [];
|
|
2071
|
+
try {
|
|
2072
|
+
await acceptAllFeatureSuggestions(milestoneId, {
|
|
2073
|
+
onError: (err) => addToast(err.message, "error"),
|
|
2074
|
+
});
|
|
2075
|
+
addToast(`${suggestions.length} features added`, "success");
|
|
2076
|
+
} catch {
|
|
2077
|
+
// Error handled in callback
|
|
2078
|
+
}
|
|
2079
|
+
},
|
|
2080
|
+
[acceptAllFeatureSuggestions, featureSuggestionsByMilestoneId, addToast]
|
|
2081
|
+
);
|
|
2082
|
+
|
|
2083
|
+
const handleClearFeatureSuggestions = useCallback(
|
|
2084
|
+
(milestoneId: string) => {
|
|
2085
|
+
clearFeatureSuggestions(milestoneId);
|
|
2086
|
+
},
|
|
2087
|
+
[clearFeatureSuggestions]
|
|
2088
|
+
);
|
|
2089
|
+
|
|
2090
|
+
const handleCreateFeature = useCallback(
|
|
2091
|
+
async (milestoneId: string, input: RoadmapFeatureCreateInput) => {
|
|
2092
|
+
try {
|
|
2093
|
+
await createFeature(milestoneId, input, {
|
|
2094
|
+
onError: (err) => addToast(err.message, "error"),
|
|
2095
|
+
});
|
|
2096
|
+
setCreateForm({ type: null, parentId: undefined, title: "", description: "" });
|
|
2097
|
+
addToast("Feature created", "success");
|
|
2098
|
+
} catch {
|
|
2099
|
+
// Error handled in callback
|
|
2100
|
+
}
|
|
2101
|
+
},
|
|
2102
|
+
[createFeature, addToast]
|
|
2103
|
+
);
|
|
2104
|
+
|
|
2105
|
+
// Get the currently selected roadmap ID (handles both desktop and mobile)
|
|
2106
|
+
const effectiveSelectedRoadmapId = selectedRoadmapId;
|
|
2107
|
+
|
|
2108
|
+
if (loading && roadmaps.length === 0) {
|
|
2109
|
+
return (
|
|
2110
|
+
<div className="roadmaps-view roadmaps-view--loading">
|
|
2111
|
+
<div className="roadmaps-view__loading-state">Loading roadmaps...</div>
|
|
2112
|
+
</div>
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
if (error && roadmaps.length === 0) {
|
|
2117
|
+
return (
|
|
2118
|
+
<div className="roadmaps-view roadmaps-view--error">
|
|
2119
|
+
<div className="roadmaps-view__error-state">
|
|
2120
|
+
<p>Failed to load roadmaps</p>
|
|
2121
|
+
<p className="roadmaps-view__error-msg">{error.message}</p>
|
|
2122
|
+
</div>
|
|
2123
|
+
</div>
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
return (
|
|
2128
|
+
<div className="roadmaps-view">
|
|
2129
|
+
{/* Mobile Roadmap List (shown when mobile and no roadmap selected) */}
|
|
2130
|
+
{isMobile && !effectiveSelectedRoadmapId && (
|
|
2131
|
+
<MobileRoadmapList
|
|
2132
|
+
roadmaps={roadmaps}
|
|
2133
|
+
selectedRoadmapId={effectiveSelectedRoadmapId}
|
|
2134
|
+
onSelect={(id) => selectRoadmap(id)}
|
|
2135
|
+
onCreate={() => setMobileShowCreateForm(true)}
|
|
2136
|
+
onEdit={handleStartRoadmapEdit}
|
|
2137
|
+
onDelete={handleDeleteRoadmap}
|
|
2138
|
+
onExport={(roadmap) => handleOpenHandoffModal(roadmap.id, roadmap.title)}
|
|
2139
|
+
showCreateForm={mobileShowCreateForm}
|
|
2140
|
+
onCancelCreate={() => setMobileShowCreateForm(false)}
|
|
2141
|
+
onSaveCreate={async (input) => {
|
|
2142
|
+
await handleCreateRoadmap(input);
|
|
2143
|
+
setMobileShowCreateForm(false);
|
|
2144
|
+
}}
|
|
2145
|
+
/>
|
|
2146
|
+
)}
|
|
2147
|
+
|
|
2148
|
+
{/* Desktop sidebar (hidden on mobile) */}
|
|
2149
|
+
{!isMobile && (
|
|
2150
|
+
<aside className="roadmaps-view__sidebar" aria-label="Roadmaps">
|
|
2151
|
+
<div className="roadmaps-view__sidebar-header">
|
|
2152
|
+
<h2 className="roadmaps-view__sidebar-title">Roadmaps</h2>
|
|
2153
|
+
<button
|
|
2154
|
+
className="roadmaps-view__add-btn"
|
|
2155
|
+
onClick={() => setCreateForm({ type: "roadmap", title: "", description: "" })}
|
|
2156
|
+
title="Create roadmap"
|
|
2157
|
+
aria-label="Create roadmap"
|
|
2158
|
+
data-testid="create-roadmap-btn"
|
|
2159
|
+
>
|
|
2160
|
+
<Plus size={16} />
|
|
2161
|
+
</button>
|
|
2162
|
+
</div>
|
|
2163
|
+
|
|
2164
|
+
{createForm.type === "roadmap" && (
|
|
2165
|
+
<CreateRoadmapForm
|
|
2166
|
+
onSave={handleCreateRoadmap}
|
|
2167
|
+
onCancel={() => setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
|
|
2168
|
+
/>
|
|
2169
|
+
)}
|
|
2170
|
+
|
|
2171
|
+
<div className="roadmaps-view__sidebar-list">
|
|
2172
|
+
{roadmaps.length === 0 ? (
|
|
2173
|
+
<p className="roadmaps-view__empty-sidebar">No roadmaps yet. Click + to create one.</p>
|
|
2174
|
+
) : (
|
|
2175
|
+
roadmaps.map((roadmap) => (
|
|
2176
|
+
<RoadmapItem
|
|
2177
|
+
key={roadmap.id}
|
|
2178
|
+
roadmap={roadmap}
|
|
2179
|
+
isSelected={roadmap.id === effectiveSelectedRoadmapId}
|
|
2180
|
+
onSelect={() => selectRoadmap(roadmap.id)}
|
|
2181
|
+
onEdit={() => handleStartRoadmapEdit(roadmap)}
|
|
2182
|
+
onDelete={() => handleDeleteRoadmap(roadmap.id)}
|
|
2183
|
+
onExport={() => handleOpenHandoffModal(roadmap.id, roadmap.title)}
|
|
2184
|
+
/>
|
|
2185
|
+
))
|
|
2186
|
+
)}
|
|
2187
|
+
</div>
|
|
2188
|
+
</aside>
|
|
2189
|
+
)}
|
|
2190
|
+
|
|
2191
|
+
{/* Main content */}
|
|
2192
|
+
<main className="roadmaps-view__main" aria-label="Roadmap content">
|
|
2193
|
+
{/* Mobile header when roadmap is selected */}
|
|
2194
|
+
{isMobile && effectiveSelectedRoadmapId && (
|
|
2195
|
+
<MobileRoadmapHeader
|
|
2196
|
+
roadmapTitle={selectedRoadmap?.title || "Untitled Roadmap"}
|
|
2197
|
+
onBack={() => selectRoadmap(null)}
|
|
2198
|
+
onEdit={() => {
|
|
2199
|
+
if (selectedRoadmap) handleStartRoadmapEdit(selectedRoadmap);
|
|
2200
|
+
}}
|
|
2201
|
+
onDelete={() => handleDeleteRoadmap(effectiveSelectedRoadmapId)}
|
|
2202
|
+
onCreate={() => setMobileShowCreateForm(true)}
|
|
2203
|
+
/>
|
|
2204
|
+
)}
|
|
2205
|
+
|
|
2206
|
+
{!effectiveSelectedRoadmapId ? (
|
|
2207
|
+
<div className="roadmaps-view__empty-main">
|
|
2208
|
+
<p>Select a roadmap from the sidebar to view its milestones.</p>
|
|
2209
|
+
</div>
|
|
2210
|
+
) : (
|
|
2211
|
+
<>
|
|
2212
|
+
{/* Roadmap header */}
|
|
2213
|
+
<div className="roadmaps-view__roadmap-header">
|
|
2214
|
+
{roadmapEdit.roadmapId === effectiveSelectedRoadmapId ? (
|
|
2215
|
+
<div className="roadmaps-view__inline-edit">
|
|
2216
|
+
<div className="roadmaps-view__inline-edit-row">
|
|
2217
|
+
<input
|
|
2218
|
+
type="text"
|
|
2219
|
+
className="roadmaps-view__inline-input roadmaps-view__inline-input--large"
|
|
2220
|
+
value={roadmapEdit.value}
|
|
2221
|
+
onChange={(e) =>
|
|
2222
|
+
setRoadmapEdit((prev) => ({ ...prev, value: e.target.value }))
|
|
2223
|
+
}
|
|
2224
|
+
onKeyDown={(e) => {
|
|
2225
|
+
if (e.key === "Enter") {
|
|
2226
|
+
handleSaveRoadmapEdit({ title: roadmapEdit.value });
|
|
2227
|
+
} else if (e.key === "Escape") {
|
|
2228
|
+
handleCancelRoadmapEdit();
|
|
2229
|
+
}
|
|
2230
|
+
}}
|
|
2231
|
+
placeholder="Roadmap title"
|
|
2232
|
+
autoFocus
|
|
2233
|
+
data-testid="roadmap-title-input"
|
|
2234
|
+
/>
|
|
2235
|
+
<button
|
|
2236
|
+
className="roadmaps-view__icon-btn roadmaps-view__icon-btn--success"
|
|
2237
|
+
onClick={() => handleSaveRoadmapEdit({ title: roadmapEdit.value })}
|
|
2238
|
+
aria-label="Save"
|
|
2239
|
+
title="Save"
|
|
2240
|
+
>
|
|
2241
|
+
<Check size={16} />
|
|
2242
|
+
</button>
|
|
2243
|
+
<button
|
|
2244
|
+
className="roadmaps-view__icon-btn"
|
|
2245
|
+
onClick={handleCancelRoadmapEdit}
|
|
2246
|
+
aria-label="Cancel"
|
|
2247
|
+
title="Cancel"
|
|
2248
|
+
>
|
|
2249
|
+
<X size={16} />
|
|
2250
|
+
</button>
|
|
2251
|
+
</div>
|
|
2252
|
+
</div>
|
|
2253
|
+
) : (
|
|
2254
|
+
<>
|
|
2255
|
+
<div className="roadmaps-view__roadmap-title-row">
|
|
2256
|
+
<h1 className="roadmaps-view__roadmap-title">
|
|
2257
|
+
{selectedRoadmap?.title || "Untitled Roadmap"}
|
|
2258
|
+
</h1>
|
|
2259
|
+
<div className="roadmaps-view__roadmap-actions">
|
|
2260
|
+
<button
|
|
2261
|
+
className="roadmaps-view__icon-btn"
|
|
2262
|
+
onClick={() => {
|
|
2263
|
+
if (selectedRoadmap) handleStartRoadmapEdit(selectedRoadmap);
|
|
2264
|
+
}}
|
|
2265
|
+
title="Edit roadmap"
|
|
2266
|
+
aria-label="Edit roadmap"
|
|
2267
|
+
data-testid="edit-roadmap-btn"
|
|
2268
|
+
>
|
|
2269
|
+
<Pencil size={16} />
|
|
2270
|
+
</button>
|
|
2271
|
+
<button
|
|
2272
|
+
className="roadmaps-view__icon-btn roadmaps-view__icon-btn--danger"
|
|
2273
|
+
onClick={() => handleDeleteRoadmap(effectiveSelectedRoadmapId)}
|
|
2274
|
+
title="Delete roadmap"
|
|
2275
|
+
aria-label="Delete roadmap"
|
|
2276
|
+
data-testid="delete-roadmap-btn"
|
|
2277
|
+
>
|
|
2278
|
+
<Trash2 size={16} />
|
|
2279
|
+
</button>
|
|
2280
|
+
</div>
|
|
2281
|
+
</div>
|
|
2282
|
+
{selectedRoadmap?.description && (
|
|
2283
|
+
<p className="roadmaps-view__roadmap-desc">{selectedRoadmap.description}</p>
|
|
2284
|
+
)}
|
|
2285
|
+
</>
|
|
2286
|
+
)}
|
|
2287
|
+
</div>
|
|
2288
|
+
|
|
2289
|
+
{/* Milestone Suggestions Section */}
|
|
2290
|
+
{isMobile ? (
|
|
2291
|
+
showSuggestionPanel ? (
|
|
2292
|
+
<div className="roadmap-suggestion-section">
|
|
2293
|
+
<div className="roadmap-suggestion-header">
|
|
2294
|
+
<h3 className="roadmap-suggestion-title">Generate Milestone Ideas</h3>
|
|
2295
|
+
<button
|
|
2296
|
+
className="roadmap-suggestion-collapse-btn"
|
|
2297
|
+
onClick={() => setShowSuggestionPanel(false)}
|
|
2298
|
+
aria-label="Collapse suggestion panel"
|
|
2299
|
+
data-testid="collapse-suggestion-panel-btn"
|
|
2300
|
+
>
|
|
2301
|
+
<ChevronUp size={16} />
|
|
2302
|
+
</button>
|
|
2303
|
+
</div>
|
|
2304
|
+
<div className="roadmap-suggestion-form">
|
|
2305
|
+
<textarea
|
|
2306
|
+
className="roadmap-suggestion-input"
|
|
2307
|
+
value={goalPrompt}
|
|
2308
|
+
onChange={(e) => setGoalPrompt(e.target.value)}
|
|
2309
|
+
placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
|
|
2310
|
+
rows={2}
|
|
2311
|
+
disabled={isGeneratingSuggestions || !selectedRoadmapId}
|
|
2312
|
+
data-testid="goal-prompt-input"
|
|
2313
|
+
autoFocus
|
|
2314
|
+
/>
|
|
2315
|
+
<div className="roadmap-suggestion-actions">
|
|
2316
|
+
<button
|
|
2317
|
+
className="roadmap-suggestion-generate-btn"
|
|
2318
|
+
onClick={handleGenerateSuggestions}
|
|
2319
|
+
disabled={!goalPrompt.trim() || isGeneratingSuggestions || !selectedRoadmapId}
|
|
2320
|
+
data-testid="generate-suggestions-btn"
|
|
2321
|
+
>
|
|
2322
|
+
{isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
|
|
2323
|
+
</button>
|
|
2324
|
+
{milestoneSuggestions.length > 0 && (
|
|
2325
|
+
<>
|
|
2326
|
+
<button
|
|
2327
|
+
className="roadmap-suggestion-accept-all-btn"
|
|
2328
|
+
onClick={handleAcceptAllSuggestions}
|
|
2329
|
+
data-testid="accept-all-suggestions-btn"
|
|
2330
|
+
>
|
|
2331
|
+
Accept All ({milestoneSuggestions.length})
|
|
2332
|
+
</button>
|
|
2333
|
+
<button
|
|
2334
|
+
className="roadmap-suggestion-clear-btn"
|
|
2335
|
+
onClick={handleClearSuggestions}
|
|
2336
|
+
title="Clear suggestions"
|
|
2337
|
+
aria-label="Clear suggestions"
|
|
2338
|
+
data-testid="clear-suggestions-btn"
|
|
2339
|
+
>
|
|
2340
|
+
<X size={14} />
|
|
2341
|
+
</button>
|
|
2342
|
+
</>
|
|
2343
|
+
)}
|
|
2344
|
+
</div>
|
|
2345
|
+
</div>
|
|
2346
|
+
|
|
2347
|
+
{/* Suggestion Cards */}
|
|
2348
|
+
{milestoneSuggestions.length > 0 && (
|
|
2349
|
+
<div className="roadmap-suggestion-list">
|
|
2350
|
+
{milestoneSuggestions.map((suggestion) => (
|
|
2351
|
+
<MilestoneSuggestionCard
|
|
2352
|
+
key={suggestion.id}
|
|
2353
|
+
suggestion={suggestion}
|
|
2354
|
+
onUpdateDraft={(patch) => updateMilestoneSuggestionDraft(suggestion.id, patch)}
|
|
2355
|
+
onAccept={() => handleAcceptSuggestion(suggestion.id)}
|
|
2356
|
+
testIdPrefix="suggestion"
|
|
2357
|
+
/>
|
|
2358
|
+
))}
|
|
2359
|
+
</div>
|
|
2360
|
+
)}
|
|
2361
|
+
</div>
|
|
2362
|
+
) : (
|
|
2363
|
+
<div className="roadmap-suggestion-section">
|
|
2364
|
+
<button
|
|
2365
|
+
className="roadmap-suggestion-expand-btn"
|
|
2366
|
+
onClick={() => setShowSuggestionPanel(true)}
|
|
2367
|
+
disabled={!selectedRoadmapId}
|
|
2368
|
+
data-testid="expand-suggestion-panel-btn"
|
|
2369
|
+
>
|
|
2370
|
+
<Sparkles size={16} />
|
|
2371
|
+
Generate Milestone Ideas
|
|
2372
|
+
</button>
|
|
2373
|
+
</div>
|
|
2374
|
+
)
|
|
2375
|
+
) : (
|
|
2376
|
+
<div className="roadmap-suggestion-section">
|
|
2377
|
+
<div className="roadmap-suggestion-header">
|
|
2378
|
+
<h3 className="roadmap-suggestion-title">Generate Milestone Ideas</h3>
|
|
2379
|
+
</div>
|
|
2380
|
+
<div className="roadmap-suggestion-form">
|
|
2381
|
+
<textarea
|
|
2382
|
+
className="roadmap-suggestion-input"
|
|
2383
|
+
value={goalPrompt}
|
|
2384
|
+
onChange={(e) => setGoalPrompt(e.target.value)}
|
|
2385
|
+
placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
|
|
2386
|
+
rows={2}
|
|
2387
|
+
disabled={isGeneratingSuggestions || !selectedRoadmapId}
|
|
2388
|
+
data-testid="goal-prompt-input"
|
|
2389
|
+
/>
|
|
2390
|
+
<div className="roadmap-suggestion-actions">
|
|
2391
|
+
<button
|
|
2392
|
+
className="roadmap-suggestion-generate-btn"
|
|
2393
|
+
onClick={handleGenerateSuggestions}
|
|
2394
|
+
disabled={!goalPrompt.trim() || isGeneratingSuggestions || !selectedRoadmapId}
|
|
2395
|
+
data-testid="generate-suggestions-btn"
|
|
2396
|
+
>
|
|
2397
|
+
{isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
|
|
2398
|
+
</button>
|
|
2399
|
+
{milestoneSuggestions.length > 0 && (
|
|
2400
|
+
<>
|
|
2401
|
+
<button
|
|
2402
|
+
className="roadmap-suggestion-accept-all-btn"
|
|
2403
|
+
onClick={handleAcceptAllSuggestions}
|
|
2404
|
+
data-testid="accept-all-suggestions-btn"
|
|
2405
|
+
>
|
|
2406
|
+
Accept All ({milestoneSuggestions.length})
|
|
2407
|
+
</button>
|
|
2408
|
+
<button
|
|
2409
|
+
className="roadmap-suggestion-clear-btn"
|
|
2410
|
+
onClick={handleClearSuggestions}
|
|
2411
|
+
title="Clear suggestions"
|
|
2412
|
+
aria-label="Clear suggestions"
|
|
2413
|
+
data-testid="clear-suggestions-btn"
|
|
2414
|
+
>
|
|
2415
|
+
<X size={14} />
|
|
2416
|
+
</button>
|
|
2417
|
+
</>
|
|
2418
|
+
)}
|
|
2419
|
+
</div>
|
|
2420
|
+
</div>
|
|
2421
|
+
|
|
2422
|
+
{/* Suggestion Cards */}
|
|
2423
|
+
{milestoneSuggestions.length > 0 && (
|
|
2424
|
+
<div className="roadmap-suggestion-list">
|
|
2425
|
+
{milestoneSuggestions.map((suggestion) => (
|
|
2426
|
+
<MilestoneSuggestionCard
|
|
2427
|
+
key={suggestion.id}
|
|
2428
|
+
suggestion={suggestion}
|
|
2429
|
+
onUpdateDraft={(patch) => updateMilestoneSuggestionDraft(suggestion.id, patch)}
|
|
2430
|
+
onAccept={() => handleAcceptSuggestion(suggestion.id)}
|
|
2431
|
+
testIdPrefix="suggestion"
|
|
2432
|
+
/>
|
|
2433
|
+
))}
|
|
2434
|
+
</div>
|
|
2435
|
+
)}
|
|
2436
|
+
</div>
|
|
2437
|
+
)}
|
|
2438
|
+
|
|
2439
|
+
{/* Milestone lanes */}
|
|
2440
|
+
<div className="roadmaps-view__milestone-lanes">
|
|
2441
|
+
{createForm.type === "milestone" && (
|
|
2442
|
+
<CreateMilestoneForm
|
|
2443
|
+
onSave={handleCreateMilestone}
|
|
2444
|
+
onCancel={() => setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
|
|
2445
|
+
/>
|
|
2446
|
+
)}
|
|
2447
|
+
|
|
2448
|
+
{milestones.length === 0 && createForm.type !== "milestone" ? (
|
|
2449
|
+
<div className="roadmaps-view__empty-milestones">
|
|
2450
|
+
<p>This roadmap has no milestones.</p>
|
|
2451
|
+
<button
|
|
2452
|
+
className="roadmaps-view__add-milestone-btn"
|
|
2453
|
+
onClick={() => setCreateForm({ type: "milestone", title: "", description: "" })}
|
|
2454
|
+
data-testid="add-milestone-btn-empty"
|
|
2455
|
+
>
|
|
2456
|
+
<Plus size={14} />
|
|
2457
|
+
<span>Add Milestone</span>
|
|
2458
|
+
</button>
|
|
2459
|
+
</div>
|
|
2460
|
+
) : (
|
|
2461
|
+
<>
|
|
2462
|
+
{createForm.type !== "milestone" && (
|
|
2463
|
+
<button
|
|
2464
|
+
className="roadmaps-view__add-milestone-fab"
|
|
2465
|
+
onClick={() => setCreateForm({ type: "milestone", title: "", description: "" })}
|
|
2466
|
+
data-testid="add-milestone-btn"
|
|
2467
|
+
>
|
|
2468
|
+
<Plus size={14} />
|
|
2469
|
+
<span>Add Milestone</span>
|
|
2470
|
+
</button>
|
|
2471
|
+
)}
|
|
2472
|
+
{milestones.map((milestone) => (
|
|
2473
|
+
<MilestoneCard
|
|
2474
|
+
key={milestone.id}
|
|
2475
|
+
milestone={milestone}
|
|
2476
|
+
features={featuresByMilestoneId[milestone.id] || []}
|
|
2477
|
+
onEditMilestone={() => handleStartMilestoneEdit(milestone)}
|
|
2478
|
+
onDeleteMilestone={() => handleDeleteMilestone(milestone.id)}
|
|
2479
|
+
onAddFeature={() => setCreateForm({ type: "feature", parentId: milestone.id, title: "", description: "" })}
|
|
2480
|
+
onEditFeature={(featureId) => {
|
|
2481
|
+
const feature = featuresByMilestoneId[milestone.id]?.find((f) => f.id === featureId);
|
|
2482
|
+
if (feature) {
|
|
2483
|
+
handleStartFeatureEdit(featureId, feature.title, feature.description);
|
|
2484
|
+
}
|
|
2485
|
+
}}
|
|
2486
|
+
onDeleteFeature={handleDeleteFeature}
|
|
2487
|
+
milestoneEdit={milestoneEdit}
|
|
2488
|
+
onMilestoneEditChange={handleMilestoneEditChange}
|
|
2489
|
+
onMilestoneEditFieldChange={handleMilestoneEditFieldChange}
|
|
2490
|
+
onCancelMilestoneEdit={handleCancelMilestoneEdit}
|
|
2491
|
+
onSaveMilestoneEdit={handleSaveMilestoneEdit}
|
|
2492
|
+
featureEdit={featureEdit}
|
|
2493
|
+
onFeatureEditChange={handleFeatureEditChange}
|
|
2494
|
+
onStartFeatureEdit={handleStartFeatureEdit}
|
|
2495
|
+
onCancelFeatureEdit={handleCancelFeatureEdit}
|
|
2496
|
+
onSaveFeatureEdit={handleSaveFeatureEdit}
|
|
2497
|
+
projectId={projectId}
|
|
2498
|
+
addToast={addToast}
|
|
2499
|
+
// Milestone drag-and-drop props
|
|
2500
|
+
isMilestoneDragging={milestoneDrag.draggingId === milestone.id}
|
|
2501
|
+
isMilestoneDropTarget={milestoneDrag.dropTargetId === milestone.id}
|
|
2502
|
+
milestoneDropPosition={milestoneDrag.dropTargetId === milestone.id ? milestoneDrag.dropPosition : null}
|
|
2503
|
+
onMilestoneDragStart={handleMilestoneDragStart}
|
|
2504
|
+
onMilestoneDragEnd={handleMilestoneDragEnd}
|
|
2505
|
+
onMilestoneDragOver={handleMilestoneDragOver}
|
|
2506
|
+
onMilestoneDrop={handleMilestoneDrop}
|
|
2507
|
+
onMilestoneDragLeave={handleMilestoneDragLeave}
|
|
2508
|
+
// Feature drag-and-drop props
|
|
2509
|
+
isFeatureDragging={isFeatureDragging}
|
|
2510
|
+
isFeatureDropTarget={featureDrag.dropTargetMilestoneId === milestone.id}
|
|
2511
|
+
featureDropIndex={featureDrag.dropTargetMilestoneId === milestone.id ? featureDrag.dropTargetIndex : null}
|
|
2512
|
+
onFeatureDragStart={handleFeatureDragStart}
|
|
2513
|
+
onFeatureDragEnd={handleFeatureDragEnd}
|
|
2514
|
+
onFeatureDragOver={handleFeatureDragOver}
|
|
2515
|
+
onFeatureDrop={handleFeatureDrop}
|
|
2516
|
+
onFeatureDragLeave={handleFeatureDragLeave}
|
|
2517
|
+
onFeatureDropOnMilestone={handleFeatureDropOnMilestone}
|
|
2518
|
+
// Feature suggestion props
|
|
2519
|
+
featureSuggestions={featureSuggestionsByMilestoneId[milestone.id]}
|
|
2520
|
+
isGeneratingFeatureSuggestions={isGeneratingFeatureSuggestions(milestone.id)}
|
|
2521
|
+
onGenerateFeatureSuggestions={() => handleGenerateFeatureSuggestions(milestone.id)}
|
|
2522
|
+
onAcceptFeatureSuggestion={(index) => handleAcceptFeatureSuggestion(milestone.id, index)}
|
|
2523
|
+
onAcceptAllFeatureSuggestions={() => handleAcceptAllFeatureSuggestions(milestone.id)}
|
|
2524
|
+
onUpdateFeatureSuggestionDraft={(milestoneId, draftId, patch) => handleUpdateFeatureSuggestionDraft(milestoneId, draftId, patch)}
|
|
2525
|
+
onClearFeatureSuggestions={() => handleClearFeatureSuggestions(milestone.id)}
|
|
2526
|
+
/>
|
|
2527
|
+
))}
|
|
2528
|
+
</>
|
|
2529
|
+
)}
|
|
2530
|
+
</div>
|
|
2531
|
+
</>
|
|
2532
|
+
)}
|
|
2533
|
+
</main>
|
|
2534
|
+
|
|
2535
|
+
{/* Feature create form overlay */}
|
|
2536
|
+
{createForm.type === "feature" && createForm.parentId && (
|
|
2537
|
+
<div className="roadmaps-view__feature-create-overlay">
|
|
2538
|
+
<CreateFeatureForm
|
|
2539
|
+
onSave={(input) => handleCreateFeature(createForm.parentId!, input)}
|
|
2540
|
+
onCancel={() => setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
|
|
2541
|
+
/>
|
|
2542
|
+
</div>
|
|
2543
|
+
)}
|
|
2544
|
+
|
|
2545
|
+
{/* Handoff export modal */}
|
|
2546
|
+
<HandoffModal
|
|
2547
|
+
isOpen={handoffModalOpen}
|
|
2548
|
+
onClose={handleCloseHandoffModal}
|
|
2549
|
+
roadmapId={handoffRoadmapId || ""}
|
|
2550
|
+
roadmapTitle={handoffRoadmapTitle}
|
|
2551
|
+
handoffPayload={handoffPayload}
|
|
2552
|
+
isLoading={isFetchingHandoff}
|
|
2553
|
+
error={handoffError}
|
|
2554
|
+
onFetchHandoff={handleFetchHandoff}
|
|
2555
|
+
onCopyToClipboard={handleCopyHandoffToClipboard}
|
|
2556
|
+
/>
|
|
2557
|
+
</div>
|
|
2558
|
+
);
|
|
2559
|
+
}
|