feat(FN-1963): merge fusion/fn-1963

This commit is contained in:
Fusion
2026-04-16 14:55:23 -07:00
committed by gsxdsm
parent b1fcb54eda
commit 78a7c0a829
6 changed files with 668 additions and 65 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader, ChevronLeft, ArrowLeft } from "lucide-react";
import { useState, useCallback, useEffect, useRef } from "react";
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader, ChevronLeft, ArrowLeft, ChevronUp } from "lucide-react";
import type { ToastType } from "../hooks/useToast";
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "../hooks/useRoadmaps";
import { useViewportMode } from "../hooks/useViewportMode";
@@ -1456,6 +1456,18 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
// Goal prompt state for milestone suggestion generation
const [goalPrompt, setGoalPrompt] = useState("");
// Mobile suggestion panel collapse state
const [showSuggestionPanel, setShowSuggestionPanel] = useState(false);
// Reset suggestion panel when roadmap changes on mobile
const prevRoadmapIdRef = useRef<string | null>(null);
useEffect(() => {
if (prevRoadmapIdRef.current !== null && prevRoadmapIdRef.current !== selectedRoadmapId) {
setShowSuggestionPanel(false);
}
prevRoadmapIdRef.current = selectedRoadmapId;
}, [selectedRoadmapId]);
// Inline edit states
const [roadmapEdit, setRoadmapEdit] = useState<InlineEditState>({
roadmapId: null,
@@ -2245,67 +2257,154 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
</div>
{/* Milestone Suggestions Section */}
<div className="roadmap-suggestion-section">
<div className="roadmap-suggestion-header">
<h3 className="roadmap-suggestion-title">Generate Milestone Ideas</h3>
</div>
<div className="roadmap-suggestion-form">
<textarea
className="roadmap-suggestion-input"
value={goalPrompt}
onChange={(e) => setGoalPrompt(e.target.value)}
placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
rows={2}
disabled={isGeneratingSuggestions || !selectedRoadmapId}
data-testid="goal-prompt-input"
/>
<div className="roadmap-suggestion-actions">
<button
className="roadmap-suggestion-generate-btn"
onClick={handleGenerateSuggestions}
disabled={!goalPrompt.trim() || isGeneratingSuggestions || !selectedRoadmapId}
data-testid="generate-suggestions-btn"
>
{isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
</button>
{isMobile ? (
showSuggestionPanel ? (
<div className="roadmap-suggestion-section">
<div className="roadmap-suggestion-header">
<h3 className="roadmap-suggestion-title">Generate Milestone Ideas</h3>
<button
className="roadmap-suggestion-collapse-btn"
onClick={() => setShowSuggestionPanel(false)}
aria-label="Collapse suggestion panel"
data-testid="collapse-suggestion-panel-btn"
>
<ChevronUp size={16} />
</button>
</div>
<div className="roadmap-suggestion-form">
<textarea
className="roadmap-suggestion-input"
value={goalPrompt}
onChange={(e) => setGoalPrompt(e.target.value)}
placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
rows={2}
disabled={isGeneratingSuggestions || !selectedRoadmapId}
data-testid="goal-prompt-input"
autoFocus
/>
<div className="roadmap-suggestion-actions">
<button
className="roadmap-suggestion-generate-btn"
onClick={handleGenerateSuggestions}
disabled={!goalPrompt.trim() || isGeneratingSuggestions || !selectedRoadmapId}
data-testid="generate-suggestions-btn"
>
{isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
</button>
{milestoneSuggestions.length > 0 && (
<>
<button
className="roadmap-suggestion-accept-all-btn"
onClick={handleAcceptAllSuggestions}
data-testid="accept-all-suggestions-btn"
>
Accept All ({milestoneSuggestions.length})
</button>
<button
className="roadmap-suggestion-clear-btn"
onClick={handleClearSuggestions}
title="Clear suggestions"
aria-label="Clear suggestions"
data-testid="clear-suggestions-btn"
>
<X size={14} />
</button>
</>
)}
</div>
</div>
{/* Suggestion Cards */}
{milestoneSuggestions.length > 0 && (
<>
<button
className="roadmap-suggestion-accept-all-btn"
onClick={handleAcceptAllSuggestions}
data-testid="accept-all-suggestions-btn"
>
Accept All ({milestoneSuggestions.length})
</button>
<button
className="roadmap-suggestion-clear-btn"
onClick={handleClearSuggestions}
title="Clear suggestions"
aria-label="Clear suggestions"
data-testid="clear-suggestions-btn"
>
<X size={14} />
</button>
</>
<div className="roadmap-suggestion-list">
{milestoneSuggestions.map((suggestion) => (
<MilestoneSuggestionCard
key={suggestion.id}
suggestion={suggestion}
onUpdateDraft={(patch) => updateMilestoneSuggestionDraft(suggestion.id, patch)}
onAccept={() => handleAcceptSuggestion(suggestion.id)}
testIdPrefix="suggestion"
/>
))}
</div>
)}
</div>
</div>
{/* Suggestion Cards */}
{milestoneSuggestions.length > 0 && (
<div className="roadmap-suggestion-list">
{milestoneSuggestions.map((suggestion) => (
<MilestoneSuggestionCard
key={suggestion.id}
suggestion={suggestion}
onUpdateDraft={(patch) => updateMilestoneSuggestionDraft(suggestion.id, patch)}
onAccept={() => handleAcceptSuggestion(suggestion.id)}
testIdPrefix="suggestion"
/>
))}
) : (
<div className="roadmap-suggestion-section">
<button
className="roadmap-suggestion-expand-btn"
onClick={() => setShowSuggestionPanel(true)}
disabled={!selectedRoadmapId}
data-testid="expand-suggestion-panel-btn"
>
<Sparkles size={16} />
Generate Milestone Ideas
</button>
</div>
)}
</div>
)
) : (
<div className="roadmap-suggestion-section">
<div className="roadmap-suggestion-header">
<h3 className="roadmap-suggestion-title">Generate Milestone Ideas</h3>
</div>
<div className="roadmap-suggestion-form">
<textarea
className="roadmap-suggestion-input"
value={goalPrompt}
onChange={(e) => setGoalPrompt(e.target.value)}
placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
rows={2}
disabled={isGeneratingSuggestions || !selectedRoadmapId}
data-testid="goal-prompt-input"
/>
<div className="roadmap-suggestion-actions">
<button
className="roadmap-suggestion-generate-btn"
onClick={handleGenerateSuggestions}
disabled={!goalPrompt.trim() || isGeneratingSuggestions || !selectedRoadmapId}
data-testid="generate-suggestions-btn"
>
{isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
</button>
{milestoneSuggestions.length > 0 && (
<>
<button
className="roadmap-suggestion-accept-all-btn"
onClick={handleAcceptAllSuggestions}
data-testid="accept-all-suggestions-btn"
>
Accept All ({milestoneSuggestions.length})
</button>
<button
className="roadmap-suggestion-clear-btn"
onClick={handleClearSuggestions}
title="Clear suggestions"
aria-label="Clear suggestions"
data-testid="clear-suggestions-btn"
>
<X size={14} />
</button>
</>
)}
</div>
</div>
{/* Suggestion Cards */}
{milestoneSuggestions.length > 0 && (
<div className="roadmap-suggestion-list">
{milestoneSuggestions.map((suggestion) => (
<MilestoneSuggestionCard
key={suggestion.id}
suggestion={suggestion}
onUpdateDraft={(patch) => updateMilestoneSuggestionDraft(suggestion.id, patch)}
onAccept={() => handleAcceptSuggestion(suggestion.id)}
testIdPrefix="suggestion"
/>
))}
</div>
)}
</div>
)}
{/* Milestone lanes */}
<div className="roadmaps-view__milestone-lanes">

View File

@@ -46,6 +46,7 @@ vi.mock("lucide-react", () => ({
Loader: (props: unknown) => <span data-testid="loader-icon" {...props}>Loader</span>,
ArrowLeft: (props: unknown) => <span data-testid="arrow-left-icon" {...props}>ArrowLeft</span>,
ChevronLeft: (props: unknown) => <span data-testid="chevron-left-icon" {...props}>ChevronLeft</span>,
ChevronUp: (props: unknown) => <span data-testid="chevron-up-icon" {...props}>ChevronUp</span>,
}));
// Viewport mode mock helper
@@ -919,4 +920,180 @@ describe("RoadmapsView", () => {
expect(screen.getByText("Milestone 2")).toBeInTheDocument();
});
});
describe("Mobile suggestion panel collapse", () => {
beforeEach(() => {
mockViewport("mobile");
});
afterEach(() => {
vi.restoreAllMocks();
});
it("shows expand button instead of suggestion section on mobile", async () => {
render(<RoadmapsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
});
// Select roadmap
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
// On mobile, should show the expand button instead of the goal prompt input
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
expect(screen.queryByTestId("goal-prompt-input")).not.toBeInTheDocument();
});
it("expands suggestion panel on mobile when button is clicked", async () => {
render(<RoadmapsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
});
// Select roadmap
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
// Expand the panel
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("expand-suggestion-panel-btn"));
// Panel should now be visible
await waitFor(() => {
expect(screen.getByTestId("goal-prompt-input")).toBeInTheDocument();
});
expect(screen.queryByTestId("expand-suggestion-panel-btn")).not.toBeInTheDocument();
});
it("can collapse suggestion panel on mobile", async () => {
render(<RoadmapsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
});
// Select roadmap
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
// Expand the panel
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("expand-suggestion-panel-btn"));
// Wait for panel to expand
await waitFor(() => {
expect(screen.getByTestId("goal-prompt-input")).toBeInTheDocument();
});
// Collapse the panel
fireEvent.click(screen.getByTestId("collapse-suggestion-panel-btn"));
// Panel should be hidden, expand button should be back
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
expect(screen.queryByTestId("goal-prompt-input")).not.toBeInTheDocument();
});
it("persists goal prompt and suggestions across collapse/expand on mobile", async () => {
// Mock milestone suggestion generation
(api.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
suggestions: [
{ title: "Persisted Milestone", description: "Persisted description" },
],
});
render(<RoadmapsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
});
// Select roadmap
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
// Expand the panel
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("expand-suggestion-panel-btn"));
// Type into goal prompt
await waitFor(() => {
expect(screen.getByTestId("goal-prompt-input")).toBeInTheDocument();
});
const goalInput = screen.getByTestId("goal-prompt-input");
await userEvent.type(goalInput, "Build an app");
// Generate suggestions
fireEvent.click(screen.getByTestId("generate-suggestions-btn"));
// Wait for suggestions to appear
await waitFor(() => {
expect(screen.getByText("Persisted Milestone")).toBeInTheDocument();
});
// Collapse the panel
fireEvent.click(screen.getByTestId("collapse-suggestion-panel-btn"));
// Wait for expand button to appear
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
// Re-expand the panel
fireEvent.click(screen.getByTestId("expand-suggestion-panel-btn"));
// Goal prompt and suggestions should persist
await waitFor(() => {
expect(screen.getByTestId("goal-prompt-input")).toBeInTheDocument();
});
expect(screen.getByTestId("goal-prompt-input")).toHaveValue("Build an app");
expect(screen.getByText("Persisted Milestone")).toBeInTheDocument();
});
it("resets suggestion panel when switching roadmaps on mobile", async () => {
render(<RoadmapsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
});
// Select RM-001
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
// Expand the panel
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("expand-suggestion-panel-btn"));
await waitFor(() => {
expect(screen.getByTestId("goal-prompt-input")).toBeInTheDocument();
});
// Go back to roadmap list
fireEvent.click(screen.getByTestId("mobile-back-btn"));
// Wait for list to appear
await waitFor(() => {
expect(screen.getByTestId("mobile-roadmap-item-RM-002")).toBeInTheDocument();
});
// Switch to RM-002
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-002"));
// Panel should be collapsed (expand button visible)
await waitFor(() => {
expect(screen.getByTestId("expand-suggestion-panel-btn")).toBeInTheDocument();
});
expect(screen.queryByTestId("goal-prompt-input")).not.toBeInTheDocument();
});
});
});