feat(FN-3104): switch todos to modal-only navigation
- Add TodoModal component and styling, and mount it through AppModals - Remove dedicated Todos view routing and drive todos access through modal state/actions - Update Header and MobileNavBar interactions plus modal manager/view-state hooks for modal flow - Expand dashboard tests to cover modal rendering, open/close behavior, and updated app/header expectations Fusion-Task-Id: FN-3104
This commit is contained in:
@@ -78,7 +78,7 @@ const RoadmapsView = lazy(() => import("./components/RoadmapsView").then((m) =>
|
||||
const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ default: m.SkillsView })));
|
||||
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
|
||||
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
|
||||
const TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
|
||||
const _TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
|
||||
|
||||
// Warm lazy chunks during browser idle so first navigation to each view is
|
||||
// instant. Each chunk is ~10–80 kB; total prefetch finishes well under a
|
||||
@@ -405,13 +405,10 @@ function AppInner() {
|
||||
if ((taskView === "devserver" || taskView === "dev-server") && !devServerEnabled) {
|
||||
handleChangeTaskView("board");
|
||||
}
|
||||
if (taskView === "todos" && !todosEnabled) {
|
||||
handleChangeTaskView("board");
|
||||
}
|
||||
if (taskView === "research" && !researchEnabled) {
|
||||
handleChangeTaskView("board");
|
||||
}
|
||||
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, todosEnabled, researchEnabled]);
|
||||
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled]);
|
||||
|
||||
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
|
||||
useEffect(() => {
|
||||
@@ -822,24 +819,6 @@ function AppInner() {
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "todos") {
|
||||
if (!settingsLoaded || !todosEnabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<TodoView
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
onPlanningMode={modalManager.openPlanningWithInitialPlan}
|
||||
onTaskCreated={(task) => ingestCreatedTasks([task])}
|
||||
/>
|
||||
</Suspense>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "devserver" || taskView === "dev-server") {
|
||||
if (!settingsLoaded || !devServerEnabled) {
|
||||
return null;
|
||||
@@ -961,6 +940,9 @@ function AppInner() {
|
||||
onToggleTerminal={modalManager.toggleTerminal}
|
||||
onOpenFiles={modalManager.openFiles}
|
||||
filesOpen={modalManager.filesOpen}
|
||||
onOpenTodos={modalManager.openTodos}
|
||||
todosOpen={modalManager.todosOpen}
|
||||
todosEnabled={todosEnabled}
|
||||
globalPaused={globalPaused}
|
||||
enginePaused={enginePaused}
|
||||
onToggleGlobalPause={toggleGlobalPause}
|
||||
@@ -994,7 +976,6 @@ function AppInner() {
|
||||
memoryView: memoryEnabled,
|
||||
devServer: devServerEnabled,
|
||||
devServerView: devServerEnabled,
|
||||
todoView: todosEnabled,
|
||||
researchView: researchEnabled,
|
||||
}}
|
||||
pluginDashboardViews={pluginDashboardViews}
|
||||
@@ -1072,6 +1053,8 @@ function AppInner() {
|
||||
onOpenScripts={modalManager.openScripts}
|
||||
onToggleTerminal={modalManager.toggleTerminal}
|
||||
onOpenFiles={modalManager.openFiles}
|
||||
onOpenTodos={modalManager.openTodos}
|
||||
todosOpen={modalManager.todosOpen}
|
||||
onOpenGitHubImport={modalManager.openGitHubImport}
|
||||
onOpenPlanning={modalManager.openPlanning}
|
||||
onResumePlanning={modalManager.resumePlanning}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
|
||||
import { TerminalModal } from "./TerminalModal";
|
||||
import { ScriptsModal } from "./ScriptsModal";
|
||||
import { FileBrowserModal } from "./FileBrowserModal";
|
||||
import { TodoModal } from "./TodoModal";
|
||||
import { UsageIndicator } from "./UsageIndicator";
|
||||
import { ScheduledTasksModal } from "./ScheduledTasksModal";
|
||||
import { NewTaskModal } from "./NewTaskModal";
|
||||
@@ -254,6 +255,16 @@ export function AppModals({
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalManager.todosOpen && (
|
||||
<TodoModal
|
||||
isOpen={true}
|
||||
onClose={modalManager.closeTodos}
|
||||
addToast={addToast}
|
||||
projectId={projectId}
|
||||
onPlanningMode={modalManager.openPlanningWithInitialPlan}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UsageIndicator
|
||||
isOpen={modalManager.usageOpen}
|
||||
onClose={modalManager.closeUsage}
|
||||
|
||||
@@ -192,6 +192,9 @@ export interface HeaderProps {
|
||||
/** Opens the top-level workspace-aware file browser modal. */
|
||||
onOpenFiles?: () => void;
|
||||
filesOpen?: boolean;
|
||||
onOpenTodos?: () => void;
|
||||
todosOpen?: boolean;
|
||||
todosEnabled?: boolean;
|
||||
globalPaused?: boolean;
|
||||
enginePaused?: boolean;
|
||||
onToggleGlobalPause?: () => void;
|
||||
@@ -222,7 +225,7 @@ export interface HeaderProps {
|
||||
/** Whether the current view is a remote node */
|
||||
isRemote?: boolean;
|
||||
/** Experimental feature flags controlling visibility of nav items. */
|
||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; todoView?: boolean; researchView?: boolean };
|
||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean };
|
||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||
}
|
||||
|
||||
@@ -247,6 +250,9 @@ export function Header({
|
||||
onToggleTerminal,
|
||||
onOpenFiles,
|
||||
filesOpen,
|
||||
onOpenTodos,
|
||||
todosOpen,
|
||||
todosEnabled,
|
||||
globalPaused,
|
||||
enginePaused,
|
||||
onToggleGlobalPause,
|
||||
@@ -334,7 +340,7 @@ export function Header({
|
||||
const hasViewOverflowItems = useMemo(() => {
|
||||
return !!(
|
||||
experimentalFeatures?.researchView ||
|
||||
experimentalFeatures?.todoView ||
|
||||
todosEnabled ||
|
||||
experimentalFeatures?.insights ||
|
||||
experimentalFeatures?.roadmap ||
|
||||
showSkillsTab ||
|
||||
@@ -343,7 +349,7 @@ export function Header({
|
||||
!hideFullNav ||
|
||||
pluginDashboardViews.some((entry) => entry.view.placement !== "primary")
|
||||
);
|
||||
}, [experimentalFeatures, showSkillsTab, hideFullNav, pluginDashboardViews]);
|
||||
}, [experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, pluginDashboardViews]);
|
||||
|
||||
const getEffectiveViewport = useCallback(() => {
|
||||
const vv = window.visualViewport;
|
||||
@@ -1117,7 +1123,7 @@ export function Header({
|
||||
<>
|
||||
<button
|
||||
ref={viewOverflowTriggerRef}
|
||||
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (experimentalFeatures?.todoView && view === "todos") || view.startsWith("plugin:") ? " active" : ""}`}
|
||||
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || view.startsWith("plugin:") ? " active" : ""}`}
|
||||
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
|
||||
title="More views"
|
||||
aria-label="More views"
|
||||
@@ -1219,11 +1225,11 @@ export function Header({
|
||||
<span className="visually-hidden" data-testid="view-toggle-dev-server" />
|
||||
</button>
|
||||
)}
|
||||
{experimentalFeatures?.todoView && (
|
||||
{todosEnabled && onOpenTodos && (
|
||||
<button
|
||||
className={`view-toggle-overflow-item${view === "todos" ? " active" : ""}`}
|
||||
className={`view-toggle-overflow-item${todosOpen ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
onChangeView("todos");
|
||||
onOpenTodos();
|
||||
setIsViewOverflowOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
@@ -1438,6 +1444,17 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isCompact && todosEnabled && onOpenTodos && (
|
||||
<button
|
||||
className={`btn-icon${todosOpen ? " btn-icon--active" : ""}`}
|
||||
onClick={onOpenTodos}
|
||||
title="Open todos"
|
||||
data-testid="todos-toggle-btn"
|
||||
>
|
||||
<CheckSquare size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Git Manager button - desktop only (moved to overflow on mobile/tablet) */}
|
||||
{!isCompact && onOpenGitManager && (
|
||||
<button
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface MobileNavBarProps {
|
||||
onOpenScripts?: () => void;
|
||||
onToggleTerminal?: () => void;
|
||||
onOpenFiles?: () => void;
|
||||
onOpenTodos?: () => void;
|
||||
todosOpen?: boolean;
|
||||
onOpenGitHubImport?: () => void;
|
||||
onOpenPlanning?: () => void;
|
||||
onResumePlanning?: () => void;
|
||||
@@ -108,6 +110,8 @@ export function MobileNavBar({
|
||||
onOpenScripts,
|
||||
onToggleTerminal,
|
||||
onOpenFiles,
|
||||
onOpenTodos,
|
||||
todosOpen = false,
|
||||
onOpenGitHubImport,
|
||||
onOpenPlanning,
|
||||
onResumePlanning,
|
||||
@@ -200,7 +204,7 @@ export function MobileNavBar({
|
||||
|| view === "memory"
|
||||
|| view === "devserver"
|
||||
|| view === "dev-server"
|
||||
|| (view === "todos" && todoViewEnabled)
|
||||
|| (todosOpen && todoViewEnabled)
|
||||
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|
||||
|| (view === "skills" && !showSkillsTopLevel)
|
||||
|| view.startsWith("plugin:");
|
||||
@@ -625,7 +629,7 @@ export function MobileNavBar({
|
||||
type="button"
|
||||
className="mobile-more-item"
|
||||
data-testid="mobile-more-item-todos"
|
||||
onClick={() => handleMoreAction(() => onChangeView("todos"))}
|
||||
onClick={() => handleMoreAction(() => onOpenTodos?.())}
|
||||
>
|
||||
<CheckSquare />
|
||||
<span>Todos</span>
|
||||
|
||||
64
packages/dashboard/app/components/TodoModal.css
Normal file
64
packages/dashboard/app/components/TodoModal.css
Normal file
@@ -0,0 +1,64 @@
|
||||
.modal.todo-modal {
|
||||
width: 80vw;
|
||||
max-width: calc(var(--space-xl) * 37.5);
|
||||
height: 75vh;
|
||||
min-height: calc(var(--space-xs) * 100);
|
||||
max-height: calc(100dvh - var(--overlay-padding-top, 10vh) - var(--space-lg));
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.todo-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.todo-modal-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.todo-modal-header-title h2 {
|
||||
margin: 0;
|
||||
font-size: calc(var(--space-md) + var(--space-xs) * 0.75);
|
||||
}
|
||||
|
||||
.todo-modal-header-title p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-md) - var(--space-xs) * 0.25);
|
||||
}
|
||||
|
||||
.todo-modal-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal-overlay:has(.todo-modal) {
|
||||
padding-top: 0;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.modal.todo-modal {
|
||||
width: 100vw;
|
||||
min-width: 0;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
max-width: 100vw;
|
||||
max-height: 100dvh;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
56
packages/dashboard/app/components/TodoModal.tsx
Normal file
56
packages/dashboard/app/components/TodoModal.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import "./TodoModal.css";
|
||||
import { useEffect } from "react";
|
||||
import { ListChecks, X } from "lucide-react";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import { TodoView } from "./TodoView";
|
||||
|
||||
interface TodoModalProps {
|
||||
isOpen?: boolean;
|
||||
onClose: () => void;
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: "success" | "error" | "info") => void;
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
}
|
||||
|
||||
export function TodoModal({ onClose, projectId, addToast, onPlanningMode }: TodoModalProps) {
|
||||
const overlayDismissProps = useOverlayDismiss(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
|
||||
<div className="modal todo-modal">
|
||||
<div className="modal-header todo-modal-header">
|
||||
<div className="todo-modal-header-title">
|
||||
<ListChecks size={18} />
|
||||
<div>
|
||||
<h2>Todos</h2>
|
||||
<p>Manage reusable todo lists for your project.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="todo-modal-body">
|
||||
<TodoView
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,34 +6,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.todo-view-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
gap: var(--space-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-view-header h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.todo-view-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
|
||||
.todo-view-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -25,6 +25,7 @@ interface TodoViewProps {
|
||||
addToast: (message: string, type?: "success" | "error" | "info") => void;
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
onTaskCreated?: (task: Task) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function sortItems(items: TodoItem[]): TodoItem[] {
|
||||
@@ -307,13 +308,6 @@ export function TodoView({ projectId, addToast, onPlanningMode, onTaskCreated }:
|
||||
|
||||
return (
|
||||
<div className="todo-view">
|
||||
<div className="todo-view-header">
|
||||
<div>
|
||||
<h2>Todos</h2>
|
||||
<p className="todo-view-description">Manage reusable todo lists for your project.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="todo-view-layout">
|
||||
<aside className="todo-view-sidebar" aria-label="Todo lists sidebar">
|
||||
<div className="todo-sidebar-header">
|
||||
|
||||
@@ -1504,7 +1504,6 @@ describe("App view switching", () => {
|
||||
|
||||
it("opens planning mode when TodoView triggers planning from todo item", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
localStorage.setItem(taskViewStorageKey(), "todos");
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: {
|
||||
@@ -1515,6 +1514,12 @@ describe("App view switching", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("todos-toggle-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("todos-toggle-btn"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("todo-view")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -45,6 +45,14 @@ vi.mock("../FileBrowserModal", () => ({
|
||||
FileBrowserModal: () => null,
|
||||
}));
|
||||
|
||||
const mockTodoModalProps = vi.fn();
|
||||
vi.mock("../TodoModal", () => ({
|
||||
TodoModal: (props: any) => {
|
||||
mockTodoModalProps(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../UsageIndicator", () => ({
|
||||
UsageIndicator: () => null,
|
||||
}));
|
||||
@@ -145,6 +153,7 @@ describe("AppModals", () => {
|
||||
terminalInitialCommand: undefined,
|
||||
scriptsOpen: false,
|
||||
filesOpen: false,
|
||||
todosOpen: false,
|
||||
fileBrowserWorkspace: "project",
|
||||
usageOpen: false,
|
||||
usageAnchorRect: null,
|
||||
@@ -182,6 +191,8 @@ describe("AppModals", () => {
|
||||
runScript: vi.fn(),
|
||||
openFiles: vi.fn(),
|
||||
closeFiles: vi.fn(),
|
||||
openTodos: vi.fn(),
|
||||
closeTodos: vi.fn(),
|
||||
setFileWorkspace: vi.fn(),
|
||||
openUsage: vi.fn(),
|
||||
closeUsage: vi.fn(),
|
||||
@@ -224,6 +235,7 @@ describe("AppModals", () => {
|
||||
mockModelOnboardingModalProps.mockClear();
|
||||
mockSettingsModalProps.mockClear();
|
||||
mockSystemStatsModalProps.mockClear();
|
||||
mockTodoModalProps.mockClear();
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
@@ -247,6 +259,50 @@ describe("AppModals", () => {
|
||||
expect(document.body).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders TodoModal when todosOpen is true", () => {
|
||||
render(
|
||||
<AppModals
|
||||
projectId="proj-1"
|
||||
tasks={[]}
|
||||
projects={[]}
|
||||
currentProject={null}
|
||||
addToast={vi.fn()}
|
||||
toasts={mockToasts}
|
||||
removeToast={vi.fn()}
|
||||
modalManager={{ ...mockModalManager, todosOpen: true }}
|
||||
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
||||
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
||||
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
||||
deepLink={{ handleDetailClose: vi.fn() }}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(mockTodoModalProps).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not render TodoModal when todosOpen is false", () => {
|
||||
render(
|
||||
<AppModals
|
||||
projectId="proj-1"
|
||||
tasks={[]}
|
||||
projects={[]}
|
||||
currentProject={null}
|
||||
addToast={vi.fn()}
|
||||
toasts={mockToasts}
|
||||
removeToast={vi.fn()}
|
||||
modalManager={{ ...mockModalManager, todosOpen: false }}
|
||||
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
||||
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
||||
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
||||
deepLink={{ handleDetailClose: vi.fn() }}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(mockTodoModalProps).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the live board task snapshot into the open detail modal while preserving prompt data", async () => {
|
||||
const manager = {
|
||||
...mockModalManager,
|
||||
|
||||
@@ -146,13 +146,13 @@ describe("Header", () => {
|
||||
expect(listBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders view overflow trigger when todoView experimental flag is enabled", () => {
|
||||
renderHeader({ onChangeView: noop, experimentalFeatures: { todoView: true } });
|
||||
it("renders view overflow trigger when todos are enabled", () => {
|
||||
renderHeader({ onChangeView: noop, todosEnabled: true });
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows the Todos entry in view overflow when todoView is enabled", () => {
|
||||
renderHeader({ onChangeView: noop, experimentalFeatures: { todoView: true } });
|
||||
it("shows the Todos entry in view overflow when todos are enabled", () => {
|
||||
renderHeader({ onChangeView: noop, onOpenTodos: vi.fn(), todosEnabled: true });
|
||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||
expect(screen.getByTestId("view-overflow-todos")).toBeInTheDocument();
|
||||
});
|
||||
@@ -190,7 +190,7 @@ describe("Header", () => {
|
||||
renderHeader({
|
||||
onChangeView: noop,
|
||||
showSkillsTab: false,
|
||||
experimentalFeatures: { insights: false, roadmap: false, memoryView: false, devServerView: false, todoView: false },
|
||||
experimentalFeatures: { insights: false, roadmap: false, memoryView: false, devServerView: false },
|
||||
});
|
||||
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
|
||||
});
|
||||
@@ -340,6 +340,25 @@ describe("Header", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("todos button", () => {
|
||||
it("renders todos button on desktop when enabled", () => {
|
||||
renderHeader({ onOpenTodos: vi.fn(), todosEnabled: true }, "desktop");
|
||||
expect(screen.getByTitle("Open todos")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render todos button when disabled", () => {
|
||||
renderHeader({ onOpenTodos: vi.fn(), todosEnabled: false }, "desktop");
|
||||
expect(screen.queryByTitle("Open todos")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onOpenTodos when clicked", () => {
|
||||
const onOpenTodos = vi.fn();
|
||||
renderHeader({ onOpenTodos, todosEnabled: true }, "desktop");
|
||||
fireEvent.click(screen.getByTitle("Open todos"));
|
||||
expect(onOpenTodos).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pause controls", () => {
|
||||
it("renders engine control split-button", () => {
|
||||
renderHeader();
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { TodoModal } from "../TodoModal";
|
||||
|
||||
const mockTodoView = vi.fn();
|
||||
|
||||
vi.mock("../TodoView", () => ({
|
||||
TodoView: (props: unknown) => {
|
||||
mockTodoView(props);
|
||||
return <div data-testid="todo-view-content">Todo content</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("TodoModal", () => {
|
||||
const onClose = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders modal dialog semantics and header content", () => {
|
||||
render(<TodoModal onClose={onClose} addToast={addToast} />);
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true");
|
||||
expect(screen.getByRole("heading", { name: "Todos" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Manage reusable todo lists for your project.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on Escape", () => {
|
||||
render(<TodoModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes on overlay backdrop click", () => {
|
||||
render(<TodoModal onClose={onClose} addToast={addToast} />);
|
||||
const overlay = screen.getByRole("dialog");
|
||||
fireEvent.mouseDown(overlay);
|
||||
fireEvent.mouseUp(overlay);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes from close button", () => {
|
||||
render(<TodoModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes projectId and addToast through to TodoView", () => {
|
||||
render(<TodoModal onClose={onClose} addToast={addToast} projectId="proj-1" />);
|
||||
|
||||
expect(screen.getByTestId("todo-view-content")).toBeInTheDocument();
|
||||
expect(mockTodoView).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectId: "proj-1", addToast }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -78,11 +78,6 @@ describe("TodoView", () => {
|
||||
mockUseTodoLists.mockReturnValue(createMockTodoLists());
|
||||
});
|
||||
|
||||
it("renders header with Todos heading", () => {
|
||||
render(<TodoView addToast={addToast} />);
|
||||
expect(screen.getByRole("heading", { name: "Todos" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders sidebar with list names", () => {
|
||||
render(<TodoView addToast={addToast} />);
|
||||
expect(screen.getByTestId("todo-list-list-1")).toHaveTextContent("My List");
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface ModalManager {
|
||||
terminalOpen: boolean;
|
||||
terminalInitialCommand: string | undefined;
|
||||
filesOpen: boolean;
|
||||
todosOpen: boolean;
|
||||
fileBrowserWorkspace: string;
|
||||
activityLogOpen: boolean;
|
||||
gitManagerOpen: boolean;
|
||||
@@ -90,6 +91,8 @@ export interface ModalManager {
|
||||
|
||||
openFiles: () => void;
|
||||
closeFiles: () => void;
|
||||
openTodos: () => void;
|
||||
closeTodos: () => void;
|
||||
setFileWorkspace: (workspace: string) => void;
|
||||
|
||||
openActivityLog: () => void;
|
||||
@@ -148,6 +151,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [todosOpen, setTodosOpen] = useState(false);
|
||||
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
|
||||
const [activityLogOpen, setActivityLogOpen] = useState(false);
|
||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||
@@ -165,6 +169,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
isSubtaskOpen ||
|
||||
terminalOpen ||
|
||||
filesOpen ||
|
||||
todosOpen ||
|
||||
activityLogOpen ||
|
||||
gitManagerOpen ||
|
||||
workflowStepsOpen ||
|
||||
@@ -268,6 +273,8 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
|
||||
const openFiles = useCallback(() => setFilesOpen(true), []);
|
||||
const closeFiles = useCallback(() => setFilesOpen(false), []);
|
||||
const openTodos = useCallback(() => setTodosOpen(true), []);
|
||||
const closeTodos = useCallback(() => setTodosOpen(false), []);
|
||||
const setFileWorkspace = useCallback((workspace: string) => {
|
||||
setFileBrowserWorkspace(workspace);
|
||||
}, []);
|
||||
@@ -338,6 +345,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
terminalOpen,
|
||||
terminalInitialCommand,
|
||||
filesOpen,
|
||||
todosOpen,
|
||||
fileBrowserWorkspace,
|
||||
activityLogOpen,
|
||||
gitManagerOpen,
|
||||
@@ -375,6 +383,8 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
closeTerminal,
|
||||
openFiles,
|
||||
closeFiles,
|
||||
openTodos,
|
||||
closeTodos,
|
||||
setFileWorkspace,
|
||||
openActivityLog,
|
||||
closeActivityLog,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ProjectInfo } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
export type ViewMode = "overview" | "project";
|
||||
export type BuiltInTaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "todos";
|
||||
export type BuiltInTaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
|
||||
export type PluginTaskView = `plugin:${string}:${string}`;
|
||||
export type TaskView = BuiltInTaskView | PluginTaskView;
|
||||
|
||||
@@ -23,7 +23,6 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
|
||||
"memory",
|
||||
"devserver",
|
||||
"dev-server",
|
||||
"todos",
|
||||
];
|
||||
|
||||
function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {
|
||||
|
||||
Reference in New Issue
Block a user