feat(FN-1880): add mobile roadmap controls - list, create, navigate
- Add mobile-friendly roadmap view with list, create, and navigation controls - Implement RoadmapCard and inline creation form for quick roadmap entry - Add mobile-optimized navigation between roadmap views - Include mobile touch targets and responsive layout using design tokens - Add comprehensive tests for RoadmapsView mobile components
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader } from "lucide-react";
|
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader, ChevronLeft, ArrowLeft } from "lucide-react";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "../hooks/useRoadmaps";
|
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "../hooks/useRoadmaps";
|
||||||
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
import type {
|
import type {
|
||||||
Roadmap,
|
Roadmap,
|
||||||
RoadmapMilestone,
|
RoadmapMilestone,
|
||||||
@@ -266,6 +267,190 @@ function RoadmapItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mobile Roadmap List ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MobileRoadmapList({
|
||||||
|
roadmaps,
|
||||||
|
selectedRoadmapId,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onExport,
|
||||||
|
showCreateForm,
|
||||||
|
onCancelCreate,
|
||||||
|
onSaveCreate,
|
||||||
|
}: {
|
||||||
|
roadmaps: Roadmap[];
|
||||||
|
selectedRoadmapId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onEdit: (roadmap: Roadmap) => void;
|
||||||
|
onDelete: (roadmapId: string) => void;
|
||||||
|
onExport: (roadmap: Roadmap) => void;
|
||||||
|
showCreateForm: boolean;
|
||||||
|
onCancelCreate: () => void;
|
||||||
|
onSaveCreate: (input: RoadmapCreateInput) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="roadmaps-view__mobile-list" data-testid="roadmaps-view__mobile-list">
|
||||||
|
<div className="roadmaps-view__mobile-list-header">
|
||||||
|
<h2 className="roadmaps-view__mobile-list-title">Roadmaps</h2>
|
||||||
|
{!showCreateForm && (
|
||||||
|
<button
|
||||||
|
className="roadmaps-view__mobile-add-btn"
|
||||||
|
onClick={onCreate}
|
||||||
|
title="Create roadmap"
|
||||||
|
aria-label="Create roadmap"
|
||||||
|
data-testid="mobile-create-roadmap-btn"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="roadmaps-view__mobile-create-form">
|
||||||
|
<CreateRoadmapForm onSave={onSaveCreate} onCancel={onCancelCreate} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{roadmaps.length === 0 && !showCreateForm ? (
|
||||||
|
<div className="roadmaps-view__mobile-empty">
|
||||||
|
<p>No roadmaps yet.</p>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={onCreate}>
|
||||||
|
<Plus size={14} />
|
||||||
|
<span>Create Roadmap</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="roadmaps-view__mobile-list-items">
|
||||||
|
{roadmaps.map((roadmap) => (
|
||||||
|
<div
|
||||||
|
key={roadmap.id}
|
||||||
|
className={`roadmaps-view__mobile-item${roadmap.id === selectedRoadmapId ? " roadmaps-view__mobile-item--active" : ""}`}
|
||||||
|
onClick={() => onSelect(roadmap.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
onSelect(roadmap.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-testid={`mobile-roadmap-item-${roadmap.id}`}
|
||||||
|
>
|
||||||
|
<div className="roadmaps-view__mobile-item-content">
|
||||||
|
<span className="roadmaps-view__mobile-item-title">{roadmap.title}</span>
|
||||||
|
{roadmap.description && (
|
||||||
|
<span className="roadmaps-view__mobile-item-desc">{roadmap.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="roadmaps-view__mobile-item-actions">
|
||||||
|
<button
|
||||||
|
className="roadmaps-view__mobile-action-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onExport(roadmap);
|
||||||
|
}}
|
||||||
|
title="Export roadmap"
|
||||||
|
aria-label="Export roadmap"
|
||||||
|
data-testid={`mobile-roadmap-export-${roadmap.id}`}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="roadmaps-view__mobile-action-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(roadmap);
|
||||||
|
}}
|
||||||
|
title="Edit roadmap"
|
||||||
|
aria-label="Edit roadmap"
|
||||||
|
data-testid={`mobile-roadmap-edit-${roadmap.id}`}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="roadmaps-view__mobile-action-btn roadmaps-view__mobile-action-btn--danger"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(roadmap.id);
|
||||||
|
}}
|
||||||
|
title="Delete roadmap"
|
||||||
|
aria-label="Delete roadmap"
|
||||||
|
data-testid={`mobile-roadmap-delete-${roadmap.id}`}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mobile Roadmap Header (shown when roadmap is selected) ────────────
|
||||||
|
|
||||||
|
function MobileRoadmapHeader({
|
||||||
|
roadmapTitle,
|
||||||
|
onBack,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
roadmapTitle: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="roadmaps-view__mobile-header" data-testid="roadmaps-view__mobile-header">
|
||||||
|
<button
|
||||||
|
className="roadmaps-view__mobile-back-btn"
|
||||||
|
onClick={onBack}
|
||||||
|
title="Back to roadmap list"
|
||||||
|
aria-label="Back to roadmap list"
|
||||||
|
data-testid="mobile-back-btn"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<h2 className="roadmaps-view__mobile-header-title">{roadmapTitle}</h2>
|
||||||
|
<div className="roadmaps-view__mobile-header-actions">
|
||||||
|
<button
|
||||||
|
className="roadmaps-view__mobile-action-btn"
|
||||||
|
onClick={onCreate}
|
||||||
|
title="Create roadmap"
|
||||||
|
aria-label="Create roadmap"
|
||||||
|
data-testid="mobile-header-create-btn"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="roadmaps-view__mobile-action-btn"
|
||||||
|
onClick={onEdit}
|
||||||
|
title="Edit roadmap"
|
||||||
|
aria-label="Edit roadmap"
|
||||||
|
data-testid="mobile-header-edit-btn"
|
||||||
|
>
|
||||||
|
<Pencil size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="roadmaps-view__mobile-action-btn roadmaps-view__mobile-action-btn--danger"
|
||||||
|
onClick={onDelete}
|
||||||
|
title="Delete roadmap"
|
||||||
|
aria-label="Delete roadmap"
|
||||||
|
data-testid="mobile-header-delete-btn"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Milestone Card ───────────────────────────────────────────────────
|
// ── Milestone Card ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function MilestoneCard({
|
function MilestoneCard({
|
||||||
@@ -1219,6 +1404,8 @@ function CreateFeatureForm({
|
|||||||
// ── Main Component ────────────────────────────────────────────────────
|
// ── Main Component ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||||
|
const isMobile = useViewportMode() === "mobile";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
roadmaps,
|
roadmaps,
|
||||||
selectedRoadmapId,
|
selectedRoadmapId,
|
||||||
@@ -1294,8 +1481,8 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
|||||||
description: "",
|
description: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mobile sidebar state
|
// Mobile roadmap list create form state
|
||||||
const [mobileSelectedRoadmapId, setMobileSelectedRoadmapId] = useState<string | null>(null);
|
const [mobileShowCreateForm, setMobileShowCreateForm] = useState(false);
|
||||||
|
|
||||||
// Milestone drag-and-drop state
|
// Milestone drag-and-drop state
|
||||||
const [milestoneDrag, setMilestoneDrag] = useState<MilestoneDragState>({
|
const [milestoneDrag, setMilestoneDrag] = useState<MilestoneDragState>({
|
||||||
@@ -1896,49 +2083,83 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="roadmaps-view">
|
<div className="roadmaps-view">
|
||||||
{/* Desktop sidebar */}
|
{/* Mobile Roadmap List (shown when mobile and no roadmap selected) */}
|
||||||
<aside className="roadmaps-view__sidebar" aria-label="Roadmaps">
|
{isMobile && !effectiveSelectedRoadmapId && (
|
||||||
<div className="roadmaps-view__sidebar-header">
|
<MobileRoadmapList
|
||||||
<h2 className="roadmaps-view__sidebar-title">Roadmaps</h2>
|
roadmaps={roadmaps}
|
||||||
<button
|
selectedRoadmapId={effectiveSelectedRoadmapId}
|
||||||
className="roadmaps-view__add-btn"
|
onSelect={(id) => selectRoadmap(id)}
|
||||||
onClick={() => setCreateForm({ type: "roadmap", title: "", description: "" })}
|
onCreate={() => setMobileShowCreateForm(true)}
|
||||||
title="Create roadmap"
|
onEdit={handleStartRoadmapEdit}
|
||||||
aria-label="Create roadmap"
|
onDelete={handleDeleteRoadmap}
|
||||||
data-testid="create-roadmap-btn"
|
onExport={(roadmap) => handleOpenHandoffModal(roadmap.id, roadmap.title)}
|
||||||
>
|
showCreateForm={mobileShowCreateForm}
|
||||||
<Plus size={16} />
|
onCancelCreate={() => setMobileShowCreateForm(false)}
|
||||||
</button>
|
onSaveCreate={async (input) => {
|
||||||
</div>
|
await handleCreateRoadmap(input);
|
||||||
|
setMobileShowCreateForm(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{createForm.type === "roadmap" && (
|
{/* Desktop sidebar (hidden on mobile) */}
|
||||||
<CreateRoadmapForm
|
{!isMobile && (
|
||||||
onSave={handleCreateRoadmap}
|
<aside className="roadmaps-view__sidebar" aria-label="Roadmaps">
|
||||||
onCancel={() => setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
|
<div className="roadmaps-view__sidebar-header">
|
||||||
/>
|
<h2 className="roadmaps-view__sidebar-title">Roadmaps</h2>
|
||||||
)}
|
<button
|
||||||
|
className="roadmaps-view__add-btn"
|
||||||
|
onClick={() => setCreateForm({ type: "roadmap", title: "", description: "" })}
|
||||||
|
title="Create roadmap"
|
||||||
|
aria-label="Create roadmap"
|
||||||
|
data-testid="create-roadmap-btn"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="roadmaps-view__sidebar-list">
|
{createForm.type === "roadmap" && (
|
||||||
{roadmaps.length === 0 ? (
|
<CreateRoadmapForm
|
||||||
<p className="roadmaps-view__empty-sidebar">No roadmaps yet. Click + to create one.</p>
|
onSave={handleCreateRoadmap}
|
||||||
) : (
|
onCancel={() => setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
|
||||||
roadmaps.map((roadmap) => (
|
/>
|
||||||
<RoadmapItem
|
|
||||||
key={roadmap.id}
|
|
||||||
roadmap={roadmap}
|
|
||||||
isSelected={roadmap.id === effectiveSelectedRoadmapId}
|
|
||||||
onSelect={() => selectRoadmap(roadmap.id)}
|
|
||||||
onEdit={() => handleStartRoadmapEdit(roadmap)}
|
|
||||||
onDelete={() => handleDeleteRoadmap(roadmap.id)}
|
|
||||||
onExport={() => handleOpenHandoffModal(roadmap.id, roadmap.title)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</aside>
|
<div className="roadmaps-view__sidebar-list">
|
||||||
|
{roadmaps.length === 0 ? (
|
||||||
|
<p className="roadmaps-view__empty-sidebar">No roadmaps yet. Click + to create one.</p>
|
||||||
|
) : (
|
||||||
|
roadmaps.map((roadmap) => (
|
||||||
|
<RoadmapItem
|
||||||
|
key={roadmap.id}
|
||||||
|
roadmap={roadmap}
|
||||||
|
isSelected={roadmap.id === effectiveSelectedRoadmapId}
|
||||||
|
onSelect={() => selectRoadmap(roadmap.id)}
|
||||||
|
onEdit={() => handleStartRoadmapEdit(roadmap)}
|
||||||
|
onDelete={() => handleDeleteRoadmap(roadmap.id)}
|
||||||
|
onExport={() => handleOpenHandoffModal(roadmap.id, roadmap.title)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="roadmaps-view__main" aria-label="Roadmap content">
|
<main className="roadmaps-view__main" aria-label="Roadmap content">
|
||||||
|
{/* Mobile header when roadmap is selected */}
|
||||||
|
{isMobile && effectiveSelectedRoadmapId && (
|
||||||
|
<MobileRoadmapHeader
|
||||||
|
roadmapTitle={selectedRoadmap?.title || "Untitled Roadmap"}
|
||||||
|
onBack={() => selectRoadmap(null)}
|
||||||
|
onEdit={() => {
|
||||||
|
if (selectedRoadmap) handleStartRoadmapEdit(selectedRoadmap);
|
||||||
|
}}
|
||||||
|
onDelete={() => handleDeleteRoadmap(effectiveSelectedRoadmapId)}
|
||||||
|
onCreate={() => setMobileShowCreateForm(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!effectiveSelectedRoadmapId ? (
|
{!effectiveSelectedRoadmapId ? (
|
||||||
<div className="roadmaps-view__empty-main">
|
<div className="roadmaps-view__empty-main">
|
||||||
<p>Select a roadmap from the sidebar to view its milestones.</p>
|
<p>Select a roadmap from the sidebar to view its milestones.</p>
|
||||||
|
|||||||
@@ -44,8 +44,28 @@ vi.mock("lucide-react", () => ({
|
|||||||
Download: (props: unknown) => <span data-testid="download-icon" {...props}>Download</span>,
|
Download: (props: unknown) => <span data-testid="download-icon" {...props}>Download</span>,
|
||||||
Copy: (props: unknown) => <span data-testid="copy-icon" {...props}>Copy</span>,
|
Copy: (props: unknown) => <span data-testid="copy-icon" {...props}>Copy</span>,
|
||||||
Loader: (props: unknown) => <span data-testid="loader-icon" {...props}>Loader</span>,
|
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>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Viewport mode mock helper
|
||||||
|
function mockViewport(mode: "mobile" | "desktop") {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => {
|
||||||
|
const isMobileQuery = query === "(max-width: 768px)";
|
||||||
|
const isTabletQuery = query === "(min-width: 769px) and (max-width: 1024px)";
|
||||||
|
return {
|
||||||
|
matches: mode === "mobile" ? isMobileQuery : false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const mockRoadmaps: Roadmap[] = [
|
const mockRoadmaps: Roadmap[] = [
|
||||||
{
|
{
|
||||||
id: "RM-001",
|
id: "RM-001",
|
||||||
@@ -108,6 +128,7 @@ const mockAddToast = vi.fn();
|
|||||||
describe("RoadmapsView", () => {
|
describe("RoadmapsView", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockViewport("desktop");
|
||||||
(api.fetchRoadmaps as ReturnType<typeof vi.fn>).mockResolvedValue(mockRoadmaps);
|
(api.fetchRoadmaps as ReturnType<typeof vi.fn>).mockResolvedValue(mockRoadmaps);
|
||||||
(api.fetchRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(mockRoadmapHierarchy);
|
(api.fetchRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(mockRoadmapHierarchy);
|
||||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
@@ -646,4 +667,181 @@ describe("RoadmapsView", () => {
|
|||||||
expect(editButtons.length).toBeGreaterThan(0);
|
expect(editButtons.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Mobile roadmap controls", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockViewport("mobile");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockViewport("desktop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows mobile roadmap list when no roadmap selected on mobile", async () => {
|
||||||
|
render(<RoadmapsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("roadmaps-view__mobile-list")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Q2 Roadmap")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Q3 Roadmap")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("mobile-create-roadmap-btn")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows mobile roadmap items when roadmaps exist on mobile", async () => {
|
||||||
|
render(<RoadmapsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("mobile-roadmap-item-RM-002")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can select a roadmap from mobile list", async () => {
|
||||||
|
render(<RoadmapsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click on a roadmap item
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
|
||||||
|
|
||||||
|
// Should show the mobile header with the roadmap title
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("roadmaps-view__mobile-header")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Q2 Roadmap", { selector: ".roadmaps-view__mobile-header-title" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mobile back button deselects roadmap", async () => {
|
||||||
|
render(<RoadmapsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select a roadmap
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("roadmaps-view__mobile-header")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click back button
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-back-btn"));
|
||||||
|
|
||||||
|
// Should show mobile list again
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("roadmaps-view__mobile-list")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mobile create button shows create form", async () => {
|
||||||
|
render(<RoadmapsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mobile-create-roadmap-btn")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click create button
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-create-roadmap-btn"));
|
||||||
|
|
||||||
|
// Should show create form
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("create-roadmap-form")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("create-roadmap-title")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mobile can create roadmap via form", async () => {
|
||||||
|
const newRoadmap = {
|
||||||
|
id: "RM-003",
|
||||||
|
title: "New Roadmap",
|
||||||
|
createdAt: "2026-01-03T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-03T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
(api.createRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(newRoadmap);
|
||||||
|
|
||||||
|
render(<RoadmapsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mobile-create-roadmap-btn")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click create button
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-create-roadmap-btn"));
|
||||||
|
|
||||||
|
// Fill in the form using userEvent for better React integration
|
||||||
|
await waitFor(() => {
|
||||||
|
const titleInput = screen.getByTestId("create-roadmap-title");
|
||||||
|
expect(titleInput).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleInput = screen.getByTestId("create-roadmap-title");
|
||||||
|
await userEvent.type(titleInput, "New Roadmap");
|
||||||
|
|
||||||
|
// Submit the form using fireEvent.submit
|
||||||
|
const form = screen.getByTestId("create-roadmap-form").querySelector("form");
|
||||||
|
expect(form).toBeTruthy();
|
||||||
|
fireEvent.submit(form!);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.createRoadmap).toHaveBeenCalledWith(
|
||||||
|
{ title: "New Roadmap" },
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
expect(mockAddToast).toHaveBeenCalledWith("Roadmap created", "success");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mobile edit and delete buttons are visible on roadmap items", async () => {
|
||||||
|
render(<RoadmapsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit and delete buttons should be visible (not hidden behind hover on mobile)
|
||||||
|
expect(screen.getByTestId("mobile-roadmap-edit-RM-001")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("mobile-roadmap-delete-RM-001")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("mobile-roadmap-export-RM-001")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows empty state on mobile when no roadmaps", async () => {
|
||||||
|
(api.fetchRoadmaps as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
||||||
|
render(<RoadmapsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("roadmaps-view__mobile-list")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("No roadmaps yet.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("roadmaps-view__mobile-list").textContent).toContain("Create Roadmap");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mobile header shows action buttons when roadmap selected", async () => {
|
||||||
|
render(<RoadmapsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mobile-roadmap-item-RM-001")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select a roadmap
|
||||||
|
fireEvent.click(screen.getByTestId("mobile-roadmap-item-RM-001"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("roadmaps-view__mobile-header")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Action buttons should be visible in header
|
||||||
|
expect(screen.getByTestId("mobile-header-create-btn")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("mobile-header-edit-btn")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("mobile-header-delete-btn")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("mobile-back-btn")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32134,10 +32134,217 @@ html .column.drag-over * {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile Roadmap List */
|
||||||
|
.roadmaps-view__mobile-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-list-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-add-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-add-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-add-btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-create-form {
|
||||||
|
padding: var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-list-items {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
min-height: 44px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-item:hover {
|
||||||
|
background: var(--surface-hover, rgba(0, 0, 0, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-item--active {
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-item-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-item-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-item-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-action-btn:hover {
|
||||||
|
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-action-btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-action-btn--danger:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Roadmap Header (shown when roadmap selected) */
|
||||||
|
.roadmaps-view__mobile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-back-btn:hover {
|
||||||
|
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-back-btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-header-title {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roadmaps-view__mobile-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile milestone lanes */
|
||||||
.roadmaps-view__milestone-lanes {
|
.roadmaps-view__milestone-lanes {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.roadmaps-view__milestone {
|
.roadmaps-view__milestone {
|
||||||
@@ -32166,6 +32373,16 @@ html .column.drag-over * {
|
|||||||
.roadmap-suggestion-card {
|
.roadmap-suggestion-card {
|
||||||
padding: var(--space-sm);
|
padding: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile roadmap header section */
|
||||||
|
.roadmaps-view__roadmap-header {
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile create form on desktop sidebar for roadmaps - ensure proper sizing */
|
||||||
|
.roadmaps-view__create-form {
|
||||||
|
margin: var(--space-sm);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user