feat(FN-1673): add editable AI suggestion drafts before acceptance
- Add editable drafts for AI-generated milestone and feature suggestions before acceptance - Users can edit suggestion title and description before creating the actual items - Draft edits persist when accepting individual or all suggestions - Add empty/whitespace title validation to prevent accepting blank suggestions - Add comprehensive tests for suggestion generation, editing, and acceptance - Fix prop type mismatch between RoadmapsView and MilestoneCard components - Add missing clearMilestoneSuggestions function to useRoadmaps hook - Update refs to synchronize immediately with state for proper test behavior - Add generateMilestoneSuggestions to API mock in component tests - Add documentation for the AI suggestion feature in dashboard-guide.md
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles } from "lucide-react";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useRoadmaps, type FeatureSuggestion } from "../hooks/useRoadmaps";
|
||||
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "../hooks/useRoadmaps";
|
||||
import type {
|
||||
Roadmap,
|
||||
RoadmapMilestone,
|
||||
@@ -184,6 +184,7 @@ function MilestoneCard({
|
||||
onGenerateFeatureSuggestions,
|
||||
onAcceptFeatureSuggestion,
|
||||
onAcceptAllFeatureSuggestions,
|
||||
onUpdateFeatureSuggestionDraft,
|
||||
onClearFeatureSuggestions,
|
||||
}: {
|
||||
milestone: RoadmapMilestone;
|
||||
@@ -226,7 +227,8 @@ function MilestoneCard({
|
||||
featureSuggestions?: FeatureSuggestion[];
|
||||
isGeneratingFeatureSuggestions?: boolean;
|
||||
onGenerateFeatureSuggestions?: () => void;
|
||||
onAcceptFeatureSuggestion?: (index: number) => void;
|
||||
onUpdateFeatureSuggestionDraft?: (milestoneId: string, draftId: string, patch: SuggestionDraftPatch) => void;
|
||||
onAcceptFeatureSuggestion?: (milestoneId: string, draftId: string) => void;
|
||||
onAcceptAllFeatureSuggestions?: () => void;
|
||||
onClearFeatureSuggestions?: () => void;
|
||||
}) {
|
||||
@@ -623,28 +625,16 @@ function MilestoneCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="roadmap-suggestion-list">
|
||||
{featureSuggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={`suggestion-${index}`}
|
||||
className="roadmap-suggestion-card"
|
||||
data-testid={`feature-suggestion-${milestone.id}-${index}`}
|
||||
>
|
||||
<div className="roadmap-suggestion-content">
|
||||
<span className="roadmap-suggestion-card-title">{suggestion.title}</span>
|
||||
{suggestion.description && (
|
||||
<p className="roadmap-suggestion-card-desc">{suggestion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="roadmap-suggestion-accept-btn"
|
||||
onClick={() => onAcceptFeatureSuggestion?.(index)}
|
||||
title="Accept this suggestion"
|
||||
aria-label="Accept"
|
||||
data-testid={`accept-feature-${milestone.id}-${index}`}
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{featureSuggestions.map((suggestion) => (
|
||||
<FeatureSuggestionCard
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
onUpdateDraft={(patch) => onUpdateFeatureSuggestionDraft?.(milestone.id, suggestion.id, patch as SuggestionDraftPatch)}
|
||||
onAccept={() => {
|
||||
onAcceptFeatureSuggestion?.(milestone.id, suggestion.id);
|
||||
}}
|
||||
testIdPrefix={`feature-suggestion-${milestone.id}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -654,6 +644,264 @@ function MilestoneCard({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Feature Suggestion Card ───────────────────────────────────────────
|
||||
|
||||
interface FeatureSuggestionCardProps {
|
||||
suggestion: FeatureSuggestion;
|
||||
onUpdateDraft: (patch: SuggestionDraftPatch) => void;
|
||||
onAccept: () => void;
|
||||
testIdPrefix: string;
|
||||
}
|
||||
|
||||
function FeatureSuggestionCard({
|
||||
suggestion,
|
||||
onUpdateDraft,
|
||||
onAccept,
|
||||
testIdPrefix,
|
||||
}: FeatureSuggestionCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(suggestion.title);
|
||||
const [editDescription, setEditDescription] = useState(suggestion.description || "");
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditTitle(suggestion.title);
|
||||
setEditDescription(suggestion.description || "");
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
onUpdateDraft({
|
||||
title: editTitle.trim(),
|
||||
description: editDescription.trim() || undefined,
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditTitle(suggestion.title);
|
||||
setEditDescription(suggestion.description || "");
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleAccept = () => {
|
||||
if (!suggestion.title.trim()) {
|
||||
return; // Don't accept empty titles
|
||||
}
|
||||
onAccept();
|
||||
};
|
||||
|
||||
const isValid = suggestion.title.trim().length > 0;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="roadmap-suggestion-card roadmap-suggestion-card--editing">
|
||||
<div className="roadmap-suggestion-edit-form">
|
||||
<input
|
||||
type="text"
|
||||
className="roadmap-suggestion-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="Feature title"
|
||||
autoFocus
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}-title-input`}
|
||||
/>
|
||||
<textarea
|
||||
className="roadmap-suggestion-textarea"
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
rows={2}
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}-desc-input`}
|
||||
/>
|
||||
<div className="roadmap-suggestion-edit-actions">
|
||||
<button
|
||||
className="roadmap-suggestion-save-btn"
|
||||
onClick={handleSaveEdit}
|
||||
disabled={!editTitle.trim()}
|
||||
title="Save"
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}-save`}
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="roadmap-suggestion-cancel-btn"
|
||||
onClick={handleCancelEdit}
|
||||
title="Cancel"
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}-cancel`}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="roadmap-suggestion-card"
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}`}
|
||||
>
|
||||
<div className="roadmap-suggestion-content">
|
||||
<span className="roadmap-suggestion-card-title">{suggestion.title}</span>
|
||||
{suggestion.description && (
|
||||
<p className="roadmap-suggestion-card-desc">{suggestion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="roadmap-suggestion-card-actions">
|
||||
<button
|
||||
className="roadmap-suggestion-edit-btn"
|
||||
onClick={handleStartEdit}
|
||||
title="Edit suggestion"
|
||||
aria-label="Edit"
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}-edit`}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="roadmap-suggestion-accept-btn"
|
||||
onClick={handleAccept}
|
||||
disabled={!isValid}
|
||||
title="Accept this suggestion"
|
||||
aria-label="Accept"
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}-accept`}
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Milestone Suggestion Card ────────────────────────────────────────
|
||||
|
||||
interface MilestoneSuggestionCardProps {
|
||||
suggestion: MilestoneSuggestion;
|
||||
onUpdateDraft: (patch: SuggestionDraftPatch) => void;
|
||||
onAccept: () => void;
|
||||
testIdPrefix: string;
|
||||
}
|
||||
|
||||
function MilestoneSuggestionCard({
|
||||
suggestion,
|
||||
onUpdateDraft,
|
||||
onAccept,
|
||||
testIdPrefix,
|
||||
}: MilestoneSuggestionCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(suggestion.title);
|
||||
const [editDescription, setEditDescription] = useState(suggestion.description || "");
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditTitle(suggestion.title);
|
||||
setEditDescription(suggestion.description || "");
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
onUpdateDraft({
|
||||
title: editTitle.trim(),
|
||||
description: editDescription.trim() || undefined,
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditTitle(suggestion.title);
|
||||
setEditDescription(suggestion.description || "");
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleAccept = () => {
|
||||
if (!suggestion.title.trim()) {
|
||||
return; // Don't accept empty titles
|
||||
}
|
||||
onAccept();
|
||||
};
|
||||
|
||||
const isValid = suggestion.title.trim().length > 0;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="roadmap-suggestion-card roadmap-suggestion-card--editing">
|
||||
<div className="roadmap-suggestion-edit-form">
|
||||
<input
|
||||
type="text"
|
||||
className="roadmap-suggestion-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="Milestone title"
|
||||
autoFocus
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}-title-input`}
|
||||
/>
|
||||
<textarea
|
||||
className="roadmap-suggestion-textarea"
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
rows={2}
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}-desc-input`}
|
||||
/>
|
||||
<div className="roadmap-suggestion-edit-actions">
|
||||
<button
|
||||
className="roadmap-suggestion-save-btn"
|
||||
onClick={handleSaveEdit}
|
||||
disabled={!editTitle.trim()}
|
||||
title="Save"
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}-save`}
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="roadmap-suggestion-cancel-btn"
|
||||
onClick={handleCancelEdit}
|
||||
title="Cancel"
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}-cancel`}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="roadmap-suggestion-card"
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}`}
|
||||
>
|
||||
<div className="roadmap-suggestion-content">
|
||||
<span className="roadmap-suggestion-card-title">{suggestion.title}</span>
|
||||
{suggestion.description && (
|
||||
<p className="roadmap-suggestion-card-desc">{suggestion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="roadmap-suggestion-card-actions">
|
||||
<button
|
||||
className="roadmap-suggestion-edit-btn"
|
||||
onClick={handleStartEdit}
|
||||
title="Edit suggestion"
|
||||
aria-label="Edit"
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}-edit`}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="roadmap-suggestion-accept-btn"
|
||||
onClick={handleAccept}
|
||||
disabled={!isValid}
|
||||
title="Accept this suggestion"
|
||||
aria-label="Accept"
|
||||
data-testid={`${testIdPrefix}-${suggestion.id}-accept`}
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Create Form ───────────────────────────────────────────────────────
|
||||
|
||||
function CreateRoadmapForm({
|
||||
@@ -868,12 +1116,14 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
milestoneSuggestions,
|
||||
isGeneratingSuggestions,
|
||||
generateMilestoneSuggestions,
|
||||
updateMilestoneSuggestionDraft,
|
||||
acceptMilestoneSuggestion,
|
||||
acceptAllMilestoneSuggestions,
|
||||
clearMilestoneSuggestions,
|
||||
featureSuggestionsByMilestoneId,
|
||||
isGeneratingFeatureSuggestions,
|
||||
generateFeatureSuggestions,
|
||||
updateFeatureSuggestionDraft,
|
||||
acceptFeatureSuggestion,
|
||||
acceptAllFeatureSuggestions,
|
||||
clearFeatureSuggestions,
|
||||
@@ -1345,9 +1595,9 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
);
|
||||
|
||||
const handleAcceptSuggestion = useCallback(
|
||||
async (index: number) => {
|
||||
async (draftId: string) => {
|
||||
try {
|
||||
await acceptMilestoneSuggestion(index, {
|
||||
await acceptMilestoneSuggestion(draftId, {
|
||||
onError: (err) => addToast(err.message, "error"),
|
||||
});
|
||||
addToast("Milestone added", "success");
|
||||
@@ -1393,9 +1643,9 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
);
|
||||
|
||||
const handleAcceptFeatureSuggestion = useCallback(
|
||||
async (milestoneId: string, index: number) => {
|
||||
async (milestoneId: string, draftId: string) => {
|
||||
try {
|
||||
await acceptFeatureSuggestion(milestoneId, index, {
|
||||
await acceptFeatureSuggestion(milestoneId, draftId, {
|
||||
onError: (err) => addToast(err.message, "error"),
|
||||
});
|
||||
addToast("Feature added", "success");
|
||||
@@ -1406,6 +1656,13 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
[acceptFeatureSuggestion, addToast]
|
||||
);
|
||||
|
||||
const handleUpdateFeatureSuggestionDraft = useCallback(
|
||||
(milestoneId: string, draftId: string, patch: SuggestionDraftPatch) => {
|
||||
updateFeatureSuggestionDraft(milestoneId, draftId, patch);
|
||||
},
|
||||
[updateFeatureSuggestionDraft]
|
||||
);
|
||||
|
||||
const handleAcceptAllFeatureSuggestions = useCallback(
|
||||
async (milestoneId: string) => {
|
||||
const suggestions = featureSuggestionsByMilestoneId[milestoneId] || [];
|
||||
@@ -1642,26 +1899,14 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
{/* Suggestion Cards */}
|
||||
{milestoneSuggestions.length > 0 && (
|
||||
<div className="roadmap-suggestion-list">
|
||||
{milestoneSuggestions.map((suggestion, index) => (
|
||||
<div key={index} className="roadmap-suggestion-card" data-testid={`suggestion-card-${index}`}>
|
||||
<div className="roadmap-suggestion-card-content">
|
||||
<span className="roadmap-suggestion-card-title">{suggestion.title}</span>
|
||||
{suggestion.description && (
|
||||
<span className="roadmap-suggestion-card-desc">{suggestion.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="roadmap-suggestion-card-actions">
|
||||
<button
|
||||
className="roadmap-suggestion-accept-btn"
|
||||
onClick={() => handleAcceptSuggestion(index)}
|
||||
title="Accept this milestone"
|
||||
aria-label="Accept this milestone"
|
||||
data-testid={`accept-suggestion-${index}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{milestoneSuggestions.map((suggestion) => (
|
||||
<MilestoneSuggestionCard
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
onUpdateDraft={(patch) => updateMilestoneSuggestionDraft(suggestion.id, patch)}
|
||||
onAccept={() => handleAcceptSuggestion(suggestion.id)}
|
||||
testIdPrefix="suggestion"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1748,8 +1993,9 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
featureSuggestions={featureSuggestionsByMilestoneId[milestone.id]}
|
||||
isGeneratingFeatureSuggestions={isGeneratingFeatureSuggestions(milestone.id)}
|
||||
onGenerateFeatureSuggestions={() => handleGenerateFeatureSuggestions(milestone.id)}
|
||||
onAcceptFeatureSuggestion={(index) => handleAcceptFeatureSuggestion(milestone.id, index)}
|
||||
onAcceptFeatureSuggestion={(draftId) => handleAcceptFeatureSuggestion(milestone.id, draftId)}
|
||||
onAcceptAllFeatureSuggestions={() => handleAcceptAllFeatureSuggestions(milestone.id)}
|
||||
onUpdateFeatureSuggestionDraft={(milestoneId, draftId, patch) => handleUpdateFeatureSuggestionDraft(milestoneId, draftId, patch)}
|
||||
onClearFeatureSuggestions={() => handleClearFeatureSuggestions(milestone.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user