feat(KB-005): add ListView component with board/list view toggle

- Create ListView component with sorting, filtering, and drag-and-drop support
- Add view toggle buttons to Header for switching between board and list views
- Integrate view switching in App with localStorage persistence
- Add comprehensive tests for ListView, Header view toggle, and App integration
- Add CSS styles for list view layout and toggle button states
This commit is contained in:
gsxdsm
2026-03-29 17:53:03 -07:00
parent 52f28c2658
commit 478e7ffac3
8 changed files with 1714 additions and 15 deletions

View File

@@ -3,6 +3,7 @@ import type { TaskDetail, TaskCreateInput, Task } from "@kb/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings } from "./api";
import { Header } from "./components/Header";
import { Board } from "./components/Board";
import { ListView } from "./components/ListView";
import { TaskDetailModal } from "./components/TaskDetailModal";
import { SettingsModal } from "./components/SettingsModal";
import type { SectionId } from "./components/SettingsModal";
@@ -21,6 +22,16 @@ function AppInner() {
const [autoMerge, setAutoMerge] = useState(true);
const [globalPaused, setGlobalPaused] = useState(false);
const [enginePaused, setEnginePaused] = useState(false);
const [view, setView] = useState<"board" | "list">(() => {
// Initialize from localStorage if available
if (typeof window !== "undefined") {
const saved = localStorage.getItem("kb-dashboard-view");
if (saved === "list" || saved === "board") {
return saved;
}
}
return "board";
});
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask } = useTasks();
useEffect(() => {
@@ -45,6 +56,15 @@ function AppInner() {
}, []);
const { toasts, addToast, removeToast } = useToast();
// Persist view preference to localStorage
useEffect(() => {
localStorage.setItem("kb-dashboard-view", view);
}, [view]);
const handleChangeView = useCallback((newView: "board" | "list") => {
setView(newView);
}, []);
const handleCreateOpen = useCallback(() => setIsCreating(true), []);
const handleCancelCreate = useCallback(() => setIsCreating(false), []);
@@ -106,21 +126,37 @@ function AppInner() {
enginePaused={enginePaused}
onToggleGlobalPause={handleToggleGlobalPause}
onToggleEnginePause={handleToggleEnginePause}
view={view}
onChangeView={handleChangeView}
/>
<Board
tasks={tasks}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
addToast={addToast}
isCreating={isCreating}
onCancelCreate={handleCancelCreate}
onCreateTask={handleCreateTask}
onNewTask={handleCreateOpen}
autoMerge={autoMerge}
onToggleAutoMerge={handleToggleAutoMerge}
globalPaused={globalPaused}
/>
{view === "board" ? (
<Board
tasks={tasks}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
addToast={addToast}
isCreating={isCreating}
onCancelCreate={handleCancelCreate}
onCreateTask={handleCreateTask}
onNewTask={handleCreateOpen}
autoMerge={autoMerge}
onToggleAutoMerge={handleToggleAutoMerge}
globalPaused={globalPaused}
/>
) : (
<ListView
tasks={tasks}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
addToast={addToast}
globalPaused={globalPaused}
isCreating={isCreating}
onCancelCreate={handleCancelCreate}
onCreateTask={handleCreateTask}
onNewTask={handleCreateOpen}
/>
)}
{detailTask && (
<TaskDetailModal
task={detailTask}

View File

@@ -0,0 +1,135 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Header } from "./Header";
const noop = () => {};
function renderHeader(props = {}) {
return render(
<Header
onOpenSettings={noop}
onOpenGitHubImport={noop}
globalPaused={false}
enginePaused={false}
onToggleGlobalPause={noop}
onToggleEnginePause={noop}
{...props}
/>
);
}
describe("Header", () => {
it("renders the logo and brand", () => {
renderHeader();
expect(screen.getByText("kb")).toBeDefined();
expect(screen.getByText("board")).toBeDefined();
});
it("renders action buttons", () => {
renderHeader();
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
expect(screen.getByTitle("Settings")).toBeDefined();
});
it("calls onOpenSettings when settings button is clicked", () => {
const onOpenSettings = vi.fn();
renderHeader({ onOpenSettings });
fireEvent.click(screen.getByTitle("Settings"));
expect(onOpenSettings).toHaveBeenCalled();
});
it("calls onOpenGitHubImport when import button is clicked", () => {
const onOpenGitHubImport = vi.fn();
renderHeader({ onOpenGitHubImport });
fireEvent.click(screen.getByTitle("Import from GitHub"));
expect(onOpenGitHubImport).toHaveBeenCalled();
});
describe("view toggle", () => {
it("does not render view toggle when onChangeView is not provided", () => {
renderHeader();
expect(screen.queryByTitle("Board view")).toBeNull();
expect(screen.queryByTitle("List view")).toBeNull();
});
it("renders view toggle when onChangeView is provided", () => {
renderHeader({ onChangeView: noop });
expect(screen.getByTitle("Board view")).toBeDefined();
expect(screen.getByTitle("List view")).toBeDefined();
});
it("shows board view as active by default", () => {
renderHeader({ onChangeView: noop });
const boardBtn = screen.getByTitle("Board view");
const listBtn = screen.getByTitle("List view");
expect(boardBtn.className).toContain("active");
expect(listBtn.className).not.toContain("active");
});
it("shows list view as active when view is 'list'", () => {
renderHeader({ onChangeView: noop, view: "list" });
const boardBtn = screen.getByTitle("Board view");
const listBtn = screen.getByTitle("List view");
expect(boardBtn.className).not.toContain("active");
expect(listBtn.className).toContain("active");
});
it("calls onChangeView with 'board' when clicking board view button", () => {
const onChangeView = vi.fn();
renderHeader({ onChangeView, view: "list" });
fireEvent.click(screen.getByTitle("Board view"));
expect(onChangeView).toHaveBeenCalledWith("board");
});
it("calls onChangeView with 'list' when clicking list view button", () => {
const onChangeView = vi.fn();
renderHeader({ onChangeView, view: "board" });
fireEvent.click(screen.getByTitle("List view"));
expect(onChangeView).toHaveBeenCalledWith("list");
});
it("has correct aria attributes for accessibility", () => {
renderHeader({ onChangeView: noop, view: "board" });
const boardBtn = screen.getByTitle("Board view");
const listBtn = screen.getByTitle("List view");
expect(boardBtn.getAttribute("aria-pressed")).toBe("true");
expect(listBtn.getAttribute("aria-pressed")).toBe("false");
});
});
describe("pause controls", () => {
it("renders pause button for engine pause", () => {
renderHeader();
expect(screen.getByTitle("Pause scheduling")).toBeDefined();
});
it("renders stop button for global pause", () => {
renderHeader();
expect(screen.getByTitle("Stop AI engine")).toBeDefined();
});
it("calls onToggleEnginePause when pause button is clicked", () => {
const onToggleEnginePause = vi.fn();
renderHeader({ onToggleEnginePause });
fireEvent.click(screen.getByTitle("Pause scheduling"));
expect(onToggleEnginePause).toHaveBeenCalled();
});
it("calls onToggleGlobalPause when stop button is clicked", () => {
const onToggleGlobalPause = vi.fn();
renderHeader({ onToggleGlobalPause });
fireEvent.click(screen.getByTitle("Stop AI engine"));
expect(onToggleGlobalPause).toHaveBeenCalled();
});
it("shows resume text when engine is paused", () => {
renderHeader({ enginePaused: true });
expect(screen.getByTitle("Resume scheduling")).toBeDefined();
});
it("shows start text when global is paused", () => {
renderHeader({ globalPaused: true });
expect(screen.getByTitle("Start AI engine")).toBeDefined();
});
});
});

View File

@@ -1,4 +1,4 @@
import { Settings, Pause, Play, Square, Download } from "lucide-react";
import { Settings, Pause, Play, Square, Download, LayoutGrid, List } from "lucide-react";
interface HeaderProps {
onOpenSettings?: () => void;
@@ -7,6 +7,8 @@ interface HeaderProps {
enginePaused?: boolean;
onToggleGlobalPause?: () => void;
onToggleEnginePause?: () => void;
view?: "board" | "list";
onChangeView?: (view: "board" | "list") => void;
}
export function Header({
@@ -16,6 +18,8 @@ export function Header({
enginePaused,
onToggleGlobalPause,
onToggleEnginePause,
view = "board",
onChangeView,
}: HeaderProps) {
return (
<header className="header">
@@ -25,6 +29,29 @@ export function Header({
<span className="logo-sub">board</span>
</div>
<div className="header-actions">
{/* View Toggle */}
{onChangeView && (
<div className="view-toggle">
<button
className={`view-toggle-btn${view === "board" ? " active" : ""}`}
onClick={() => onChangeView("board")}
title="Board view"
aria-label="Board view"
aria-pressed={view === "board"}
>
<LayoutGrid size={16} />
</button>
<button
className={`view-toggle-btn${view === "list" ? " active" : ""}`}
onClick={() => onChangeView("list")}
title="List view"
aria-label="List view"
aria-pressed={view === "list"}
>
<List size={16} />
</button>
</div>
)}
{/* Import from GitHub */}
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
<Download size={16} />

View File

@@ -0,0 +1,344 @@
import { useState, useCallback, useMemo } from "react";
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link } from "lucide-react";
import type { Task, TaskDetail, Column, TaskStep } from "@kb/core";
import { COLUMN_LABELS, COLUMNS } from "@kb/core";
import { fetchTaskDetail } from "../api";
import type { ToastType } from "../hooks/useToast";
const COLUMN_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)",
todo: "var(--todo)",
"in-progress": "var(--in-progress)",
"in-review": "var(--in-review)",
done: "var(--done)",
};
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
type SortField = "id" | "title" | "status" | "column" | "createdAt" | "updatedAt";
type SortDirection = "asc" | "desc";
interface ListViewProps {
tasks: Task[];
onMoveTask: (id: string, column: Column) => Promise<Task>;
onOpenDetail: (task: TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
globalPaused?: boolean;
isCreating?: boolean;
onCancelCreate?: () => void;
onCreateTask?: (input: { description: string; column: Column; dependencies?: string[] }) => Promise<Task>;
onNewTask?: () => void;
}
function formatDate(iso: string): string {
const date = new Date(iso);
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function getStepProgress(steps: TaskStep[]): string {
if (steps.length === 0) return "-";
const done = steps.filter((s) => s.status === "done").length;
return `${done}/${steps.length}`;
}
function getStepProgressPercent(steps: TaskStep[]): number {
if (steps.length === 0) return 0;
const done = steps.filter((s) => s.status === "done").length;
return (done / steps.length) * 100;
}
export function ListView({
tasks,
onMoveTask,
onOpenDetail,
addToast,
globalPaused,
onNewTask,
}: ListViewProps) {
const [sortField, setSortField] = useState<SortField>("createdAt");
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
const [filter, setFilter] = useState("");
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortDirection("asc");
}
}, [sortField]);
const filteredAndSortedTasks = useMemo(() => {
const filtered = filter
? tasks.filter(
(t) =>
t.id.toLowerCase().includes(filter.toLowerCase()) ||
(t.title && t.title.toLowerCase().includes(filter.toLowerCase())) ||
t.description.toLowerCase().includes(filter.toLowerCase())
)
: tasks;
return [...filtered].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case "id":
comparison = a.id.localeCompare(b.id);
break;
case "title":
comparison = (a.title || a.description).localeCompare(b.title || b.description);
break;
case "status":
comparison = (a.status || "").localeCompare(b.status || "");
break;
case "column":
comparison = a.column.localeCompare(b.column);
break;
case "createdAt":
comparison = a.createdAt.localeCompare(b.createdAt);
break;
case "updatedAt":
comparison = a.updatedAt.localeCompare(b.updatedAt);
break;
}
return sortDirection === "asc" ? comparison : -comparison;
});
}, [tasks, filter, sortField, sortDirection]);
const handleRowClick = useCallback(
async (task: Task) => {
try {
const detail = await fetchTaskDetail(task.id);
onOpenDetail(detail);
} catch (err: any) {
addToast("Failed to load task details", "error");
}
},
[onOpenDetail, addToast]
);
const handleDragStart = useCallback(
(e: React.DragEvent, task: Task) => {
if (task.paused) {
e.preventDefault();
return;
}
e.dataTransfer.setData("text/plain", task.id);
e.dataTransfer.effectAllowed = "move";
setDraggingTaskId(task.id);
},
[]
);
const handleDragEnd = useCallback(() => {
setDraggingTaskId(null);
setDragOverColumn(null);
}, []);
const handleColumnDragOver = useCallback(
(e: React.DragEvent, column: Column) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverColumn(column);
},
[]
);
const handleColumnDragLeave = useCallback(() => {
setDragOverColumn(null);
}, []);
const handleColumnDrop = useCallback(
async (e: React.DragEvent, column: Column) => {
e.preventDefault();
setDragOverColumn(null);
const taskId = e.dataTransfer.getData("text/plain");
if (!taskId) return;
try {
await onMoveTask(taskId, column);
} catch (err: any) {
addToast(err.message, "error");
}
},
[onMoveTask, addToast]
);
const getSortIcon = (field: SortField) => {
if (sortField !== field) return <ArrowUpDown size={14} className="sort-icon" />;
return sortDirection === "asc" ? (
<ArrowUp size={14} className="sort-icon active" />
) : (
<ArrowDown size={14} className="sort-icon active" />
);
};
return (
<div className="list-view">
<div className="list-toolbar">
<div className="list-filter">
<Search size={14} className="filter-icon" />
<input
type="text"
placeholder="Filter by ID or title..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="filter-input"
/>
{filter && (
<button className="filter-clear" onClick={() => setFilter("")}>
×
</button>
)}
</div>
<div className="list-stats">
{filteredAndSortedTasks.length} of {tasks.length} tasks
</div>
{onNewTask && (
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
+ New Task
</button>
)}
</div>
<div className="list-drop-zones">
{COLUMNS.map((column) => (
<div
key={column}
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}`}
onDragOver={(e) => handleColumnDragOver(e, column)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, column)}
data-column={column}
>
<span className="drop-zone-dot" style={{ background: COLUMN_COLOR_MAP[column] }} />
<span className="drop-zone-label">{COLUMN_LABELS[column]}</span>
<span className="drop-zone-count">
{tasks.filter((t) => t.column === column).length}
</span>
</div>
))}
</div>
<div className="list-table-container">
{filteredAndSortedTasks.length === 0 ? (
<div className="list-empty">
{filter ? "No tasks match your filter" : "No tasks yet"}
</div>
) : (
<table className="list-table">
<thead>
<tr>
<th className="list-header-cell" onClick={() => handleSort("id")}>
ID {getSortIcon("id")}
</th>
<th className="list-header-cell" onClick={() => handleSort("title")}>
Title {getSortIcon("title")}
</th>
<th className="list-header-cell" onClick={() => handleSort("status")}>
Status {getSortIcon("status")}
</th>
<th className="list-header-cell" onClick={() => handleSort("column")}>
Column {getSortIcon("column")}
</th>
<th className="list-header-cell" onClick={() => handleSort("createdAt")}>
Created {getSortIcon("createdAt")}
</th>
<th className="list-header-cell" onClick={() => handleSort("updatedAt")}>
Updated {getSortIcon("updatedAt")}
</th>
<th className="list-header-cell">Dependencies</th>
<th className="list-header-cell">Progress</th>
</tr>
</thead>
<tbody>
{filteredAndSortedTasks.map((task) => {
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isAgentActive =
!globalPaused &&
!isFailed &&
!isPaused &&
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const isDragging = draggingTaskId === task.id;
return (
<tr
key={task.id}
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
isAgentActive ? " agent-active" : ""
}${isDragging ? " dragging" : ""}`}
onClick={() => handleRowClick(task)}
draggable={!isPaused}
onDragStart={(e) => handleDragStart(e, task)}
onDragEnd={handleDragEnd}
data-id={task.id}
>
<td className="list-cell list-cell-id">{task.id}</td>
<td className="list-cell list-cell-title">
{task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "")}
</td>
<td className="list-cell">
{task.status ? (
<span
className={`list-status-badge${isFailed ? " failed" : ""}${
isAgentActive ? " pulsing" : ""
}`}
>
{task.status}
</span>
) : (
<span className="list-status-badge">-</span>
)}
</td>
<td className="list-cell">
<span
className="list-column-badge"
style={{
background: `${COLUMN_COLOR_MAP[task.column]}20`,
color: COLUMN_COLOR_MAP[task.column],
}}
>
{COLUMN_LABELS[task.column]}
</span>
</td>
<td className="list-cell list-cell-date">{formatDate(task.createdAt)}</td>
<td className="list-cell list-cell-date">{formatDate(task.updatedAt)}</td>
<td className="list-cell list-cell-deps">
{task.dependencies && task.dependencies.length > 0 ? (
<span className="list-dep-badge" title={task.dependencies.join(", ")}>
<Link size={12} /> {task.dependencies.length}
</span>
) : (
"-"
)}
</td>
<td className="list-cell list-cell-progress">
{task.steps.length > 0 ? (
<div className="list-progress">
<div className="list-progress-bar">
<div
className="list-progress-fill"
style={{
width: `${getStepProgressPercent(task.steps)}%`,
backgroundColor: COLUMN_COLOR_MAP[task.column],
}}
/>
</div>
<span className="list-progress-label">{getStepProgress(task.steps)}</span>
</div>
) : (
"-"
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -262,6 +262,106 @@ describe("App engine pause (soft pause)", () => {
});
});
describe("App view switching", () => {
it("renders Board view by default", async () => {
render(<App />);
// Wait for the app to render and check that the board is visible
await waitFor(() => {
expect(screen.getByRole("main")).toBeTruthy();
});
// Board should be rendered
expect(screen.getByRole("main").className).toContain("board");
});
it("renders ListView when view is switched to list", async () => {
render(<App />);
// Wait for the header to render with view toggle
await waitFor(() => {
expect(screen.getByTitle("List view")).toBeTruthy();
});
// Click to switch to list view
fireEvent.click(screen.getByTitle("List view"));
// List view should be rendered (it has a different structure)
await waitFor(() => {
expect(document.querySelector(".list-view")).toBeTruthy();
});
});
it("switches back to Board view from list view", async () => {
render(<App />);
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTitle("List view")).toBeTruthy();
});
// Switch to list view
fireEvent.click(screen.getByTitle("List view"));
await waitFor(() => {
expect(document.querySelector(".list-view")).toBeTruthy();
});
// Switch back to board view
fireEvent.click(screen.getByTitle("Board view"));
await waitFor(() => {
expect(document.querySelector(".board")).toBeTruthy();
});
});
it("persists view preference to localStorage", async () => {
// Clear any previous value
localStorage.removeItem("kb-dashboard-view");
render(<App />);
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTitle("List view")).toBeTruthy();
});
// Switch to list view
fireEvent.click(screen.getByTitle("List view"));
// Should have saved to localStorage
await waitFor(() => {
expect(localStorage.getItem("kb-dashboard-view")).toBe("list");
});
});
it("initializes view from localStorage if available", async () => {
// Set localStorage to list view
localStorage.setItem("kb-dashboard-view", "list");
render(<App />);
// Wait for the app to render
await waitFor(() => {
expect(document.querySelector(".list-view")).toBeTruthy();
});
// List view should be active
expect(screen.getByTitle("List view").className).toContain("active");
// Cleanup
localStorage.removeItem("kb-dashboard-view");
});
it("shows view toggle buttons in header", async () => {
render(<App />);
// Wait for the header to render with view toggle
await waitFor(() => {
expect(screen.getByTitle("Board view")).toBeTruthy();
expect(screen.getByTitle("List view")).toBeTruthy();
});
});
});
describe("App GitHub import", () => {
it("opens GitHub import modal when import button is clicked", async () => {
render(<App />);

View File

@@ -128,4 +128,64 @@ describe("Header", () => {
const svg = btn.querySelector("svg");
expect(svg).toBeDefined();
});
// ── View Toggle ────────────────────────────────────────────────────
it("renders view toggle when onChangeView is provided", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const boardBtn = screen.getByTitle("Board view");
const listBtn = screen.getByTitle("List view");
expect(boardBtn).toBeDefined();
expect(listBtn).toBeDefined();
});
it("does not render view toggle when onChangeView is not provided", () => {
render(<Header />);
const boardBtn = screen.queryByTitle("Board view");
const listBtn = screen.queryByTitle("List view");
expect(boardBtn).toBeNull();
expect(listBtn).toBeNull();
});
it("calls onChangeView with 'board' when board view button is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="list" onChangeView={onChangeView} />);
const boardBtn = screen.getByTitle("Board view");
fireEvent.click(boardBtn);
expect(onChangeView).toHaveBeenCalledWith("board");
});
it("calls onChangeView with 'list' when list view button is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const listBtn = screen.getByTitle("List view");
fireEvent.click(listBtn);
expect(onChangeView).toHaveBeenCalledWith("list");
});
it("marks board view button as active when view is 'board'", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const boardBtn = screen.getByTitle("Board view");
expect(boardBtn.className).toContain("active");
expect(boardBtn.getAttribute("aria-pressed")).toBe("true");
});
it("marks list view button as active when view is 'list'", () => {
const onChangeView = vi.fn();
render(<Header view="list" onChangeView={onChangeView} />);
const listBtn = screen.getByTitle("List view");
expect(listBtn.className).toContain("active");
expect(listBtn.getAttribute("aria-pressed")).toBe("true");
});
it("does not mark board view button as active when view is 'list'", () => {
const onChangeView = vi.fn();
render(<Header view="list" onChangeView={onChangeView} />);
const boardBtn = screen.getByTitle("Board view");
expect(boardBtn.className).not.toContain("active");
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
});
});

View File

@@ -0,0 +1,567 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { ListView } from "../ListView";
import type { Task, TaskDetail } from "@kb/core";
// Mock the API
vi.mock("../../api", () => ({
fetchTaskDetail: vi.fn(),
}));
import { fetchTaskDetail } from "../../api";
const mockAddToast = vi.fn();
const createMockTask = (overrides: Partial<Task> = {}): Task => ({
id: "KB-001",
description: "Test task description",
title: "Test Task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
status: "pending",
paused: false,
log: [],
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
...overrides,
});
const renderListView = (props: Partial<React.ComponentProps<typeof ListView>> = {}) => {
const defaultProps = {
tasks: [],
onMoveTask: vi.fn(),
onOpenDetail: vi.fn(),
addToast: mockAddToast,
globalPaused: false,
onNewTask: vi.fn(),
};
return render(<ListView {...defaultProps} {...props} />);
};
describe("ListView", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders without crashing", () => {
renderListView();
expect(screen.getByPlaceholderText("Filter by ID or title...")).toBeDefined();
});
it("displays tasks in table format", () => {
const tasks = [
createMockTask({ id: "KB-001", title: "First Task" }),
createMockTask({ id: "KB-002", title: "Second Task" }),
];
renderListView({ tasks });
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("First Task")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
expect(screen.getByText("Second Task")).toBeDefined();
});
it("shows empty state when no tasks", () => {
renderListView({ tasks: [] });
expect(screen.getByText("No tasks yet")).toBeDefined();
});
it("shows empty state when filter matches nothing", () => {
const tasks = [createMockTask({ id: "KB-001", title: "Test Task" })];
renderListView({ tasks });
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "nonexistent" } });
expect(screen.getByText("No tasks match your filter")).toBeDefined();
});
it("filters tasks by ID", () => {
const tasks = [
createMockTask({ id: "KB-001", title: "First Task" }),
createMockTask({ id: "KB-002", title: "Second Task" }),
];
renderListView({ tasks });
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "KB-001" } });
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.queryByText("KB-002")).toBeNull();
});
it("filters tasks by title", () => {
const tasks = [
createMockTask({ id: "KB-001", title: "First Task" }),
createMockTask({ id: "KB-002", title: "Second Task" }),
];
renderListView({ tasks });
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Second" } });
expect(screen.queryByText("KB-001")).toBeNull();
expect(screen.getByText("KB-002")).toBeDefined();
});
it("filters tasks by description when no title", () => {
const tasks = [
createMockTask({ id: "KB-001", title: undefined, description: "Alpha description" }),
createMockTask({ id: "KB-002", title: undefined, description: "Beta description" }),
];
renderListView({ tasks });
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Alpha" } });
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.queryByText("KB-002")).toBeNull();
});
it("clears filter when clear button is clicked", () => {
const tasks = [
createMockTask({ id: "KB-001", title: "First Task" }),
createMockTask({ id: "KB-002", title: "Second Task" }),
];
renderListView({ tasks });
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "KB-001" } });
// Wait for filter to apply
expect(screen.queryByText("KB-002")).toBeNull();
// Click clear button (×)
const clearButton = screen.getByText("×");
fireEvent.click(clearButton);
// Both tasks should be visible again
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
});
it("calls onOpenDetail when row is clicked", async () => {
const tasks = [createMockTask({ id: "KB-001", title: "Test Task" })];
const mockOnOpenDetail = vi.fn();
const mockDetail: TaskDetail = {
...tasks[0],
prompt: "Test prompt",
};
(fetchTaskDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockDetail);
renderListView({ tasks, onOpenDetail: mockOnOpenDetail });
const row = screen.getByText("KB-001").closest("tr");
fireEvent.click(row!);
await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("KB-001");
});
expect(mockOnOpenDetail).toHaveBeenCalledWith(mockDetail);
});
it("shows error toast when fetchTaskDetail fails", async () => {
const tasks = [createMockTask({ id: "KB-001", title: "Test Task" })];
const mockOnOpenDetail = vi.fn();
(fetchTaskDetail as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
renderListView({ tasks, onOpenDetail: mockOnOpenDetail });
const row = screen.getByText("KB-001").closest("tr");
fireEvent.click(row!);
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Failed to load task details", "error");
});
});
it("sorts tasks by ID when ID header is clicked", () => {
const tasks = [
createMockTask({ id: "KB-003", title: "Third" }),
createMockTask({ id: "KB-001", title: "First" }),
createMockTask({ id: "KB-002", title: "Second" }),
];
renderListView({ tasks });
// First click - ascending
const idHeader = screen.getByText("ID");
fireEvent.click(idHeader);
const rows = screen.getAllByRole("row").slice(1); // Skip header row
expect(rows[0].textContent).toContain("KB-001");
expect(rows[1].textContent).toContain("KB-002");
expect(rows[2].textContent).toContain("KB-003");
// Second click - descending
fireEvent.click(idHeader);
const rowsDesc = screen.getAllByRole("row").slice(1);
expect(rowsDesc[0].textContent).toContain("KB-003");
expect(rowsDesc[1].textContent).toContain("KB-002");
expect(rowsDesc[2].textContent).toContain("KB-001");
});
it("sorts tasks by column when Column header is clicked", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "done" }),
createMockTask({ id: "KB-002", column: "triage" }),
createMockTask({ id: "KB-003", column: "in-progress" }),
];
renderListView({ tasks });
const columnHeader = screen.getByText("Column");
fireEvent.click(columnHeader);
const rows = screen.getAllByRole("row").slice(1);
// Should be sorted alphabetically: done, in-progress, triage
expect(rows[0].textContent).toContain("Done");
expect(rows[2].textContent).toContain("Triage");
});
it("sorts tasks by status when Status header is clicked", () => {
const tasks = [
createMockTask({ id: "KB-001", status: "executing" }),
createMockTask({ id: "KB-002", status: "pending" }),
createMockTask({ id: "KB-003", status: "failed" }),
];
renderListView({ tasks });
const statusHeader = screen.getByText("Status");
fireEvent.click(statusHeader);
const rows = screen.getAllByRole("row").slice(1);
// Should be sorted alphabetically: executing, failed, pending
expect(rows[0].textContent).toContain("executing");
expect(rows[2].textContent).toContain("pending");
});
it("renders failed status with correct styling", () => {
const tasks = [createMockTask({ id: "KB-001", status: "failed" })];
renderListView({ tasks });
const row = screen.getByText("KB-001").closest("tr");
expect(row?.className).toContain("failed");
const statusBadge = screen.getByText("failed");
expect(statusBadge.className).toContain("failed");
});
it("renders paused tasks with dimmed styling", () => {
const tasks = [createMockTask({ id: "KB-001", paused: true })];
renderListView({ tasks });
const row = screen.getByText("KB-001").closest("tr");
expect(row?.className).toContain("paused");
});
it("renders agent-active tasks with glow styling", () => {
const tasks = [
createMockTask({
id: "KB-001",
status: "executing",
column: "in-progress",
}),
];
renderListView({ tasks, globalPaused: false });
const row = screen.getByText("KB-001").closest("tr");
expect(row?.className).toContain("agent-active");
});
it("does not render agent-active when globalPaused is true", () => {
const tasks = [
createMockTask({
id: "KB-001",
status: "executing",
column: "in-progress",
}),
];
renderListView({ tasks, globalPaused: true });
const row = screen.getByText("KB-001").closest("tr");
expect(row?.className).not.toContain("agent-active");
});
it("renders column badges with correct colors", () => {
const columns = ["triage", "todo", "in-progress", "in-review", "done"] as const;
const tasks = columns.map((col, i) =>
createMockTask({ id: `KB-00${i + 1}`, column: col })
);
renderListView({ tasks });
// Check that all column badges are rendered in the table
// Use getAllByText and check length since column names appear in both drop zones and badges
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1);
// Check that badges have the correct styling by querying within the table
const table = document.querySelector(".list-table");
expect(table?.textContent).toContain("Triage");
expect(table?.textContent).toContain("Todo");
expect(table?.textContent).toContain("In Progress");
expect(table?.textContent).toContain("In Review");
expect(table?.textContent).toContain("Done");
});
it("renders step progress bar", () => {
const tasks = [
createMockTask({
id: "KB-001",
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "done" },
{ name: "Step 3", status: "pending" },
],
}),
];
renderListView({ tasks });
expect(screen.getByText("2/3")).toBeDefined();
});
it("shows - for tasks with no steps", () => {
const tasks = [createMockTask({ id: "KB-001", steps: [] })];
renderListView({ tasks });
const progressCells = screen.getAllByRole("cell");
const lastCell = progressCells[progressCells.length - 1];
expect(lastCell.textContent).toBe("-");
});
it("renders dependency count with icon", () => {
const tasks = [
createMockTask({
id: "KB-001",
dependencies: ["KB-002", "KB-003"],
}),
];
renderListView({ tasks });
expect(screen.getByText("2")).toBeDefined();
});
it("shows - for tasks with no dependencies", () => {
const tasks = [createMockTask({ id: "KB-001", dependencies: [] })];
renderListView({ tasks });
const depCells = screen.getAllByRole("cell");
// Find the cell that should contain deps (7th column)
const depCell = depCells[6];
expect(depCell.textContent).toBe("-");
});
it("displays correct task count in stats", () => {
const tasks = [
createMockTask({ id: "KB-001" }),
createMockTask({ id: "KB-002" }),
createMockTask({ id: "KB-003" }),
];
renderListView({ tasks });
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
});
it("displays filtered task count in stats", () => {
const tasks = [
createMockTask({ id: "KB-001", title: "Alpha" }),
createMockTask({ id: "KB-002", title: "Beta" }),
createMockTask({ id: "KB-003", title: "Gamma" }),
];
renderListView({ tasks });
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Alpha" } });
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
});
it("calls onNewTask when + New Task button is clicked", () => {
const mockOnNewTask = vi.fn();
renderListView({ onNewTask: mockOnNewTask });
const newTaskButton = screen.getByText("+ New Task");
fireEvent.click(newTaskButton);
expect(mockOnNewTask).toHaveBeenCalled();
});
it("does not render + New Task button when onNewTask is not provided", () => {
renderListView({ onNewTask: undefined });
expect(screen.queryByText("+ New Task")).toBeNull();
});
it("renders drop zones for each column", () => {
renderListView();
expect(screen.getByText("Triage")).toBeDefined();
expect(screen.getByText("Todo")).toBeDefined();
expect(screen.getByText("In Progress")).toBeDefined();
expect(screen.getByText("In Review")).toBeDefined();
expect(screen.getByText("Done")).toBeDefined();
});
it("displays correct task counts in drop zones", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage" }),
createMockTask({ id: "KB-002", column: "triage" }),
createMockTask({ id: "KB-003", column: "todo" }),
];
renderListView({ tasks });
// Use querySelector to find drop zones by data-column attribute
const triageZone = document.querySelector('[data-column="triage"]');
expect(triageZone?.textContent).toContain("2");
const todoZone = document.querySelector('[data-column="todo"]');
expect(todoZone?.textContent).toContain("1");
});
it("handles drag and drop to move tasks between columns", async () => {
const tasks = [createMockTask({ id: "KB-001", column: "triage" })];
const mockOnMoveTask = vi.fn(() => Promise.resolve(tasks[0]));
renderListView({ tasks, onMoveTask: mockOnMoveTask });
const row = screen.getByText("KB-001").closest("tr")!;
// Simulate drag start
fireEvent.dragStart(row, {
dataTransfer: {
setData: vi.fn(),
effectAllowed: "move",
},
});
// Simulate drop on todo column
const todoZone = screen.getByText("Todo").closest("[data-column]")!;
fireEvent.dragOver(todoZone, {
preventDefault: vi.fn(),
dataTransfer: { dropEffect: "move" },
});
fireEvent.drop(todoZone, {
preventDefault: vi.fn(),
dataTransfer: {
getData: vi.fn(() => "KB-001"),
},
});
await waitFor(() => {
expect(mockOnMoveTask).toHaveBeenCalledWith("KB-001", "todo");
});
});
it("does not set draggable for paused tasks", () => {
const tasks = [createMockTask({ id: "KB-001", paused: true })];
renderListView({ tasks });
const row = screen.getByText("KB-001").closest("tr")!;
// Paused tasks should have draggable="false"
expect(row.getAttribute("draggable")).toBe("false");
});
it("sets draggable for non-paused tasks", () => {
const tasks = [createMockTask({ id: "KB-001", paused: false })];
renderListView({ tasks });
const row = screen.getByText("KB-001").closest("tr")!;
// Non-paused tasks should have draggable="true"
expect(row.getAttribute("draggable")).toBe("true");
});
it("shows error toast when onMoveTask fails during drag and drop", async () => {
const tasks = [createMockTask({ id: "KB-001", column: "triage" })];
const mockOnMoveTask = vi.fn(() => Promise.reject(new Error("Move failed")));
renderListView({ tasks, onMoveTask: mockOnMoveTask });
const row = screen.getByText("KB-001").closest("tr")!;
fireEvent.dragStart(row, {
dataTransfer: {
setData: vi.fn(),
effectAllowed: "move",
},
});
const todoZone = screen.getByText("Todo").closest("[data-column]")!;
fireEvent.drop(todoZone, {
preventDefault: vi.fn(),
dataTransfer: {
getData: vi.fn(() => "KB-001"),
},
});
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Move failed", "error");
});
});
it("formats dates correctly", () => {
const tasks = [
createMockTask({
id: "KB-001",
createdAt: "2024-03-15T10:30:00Z",
updatedAt: "2024-03-16T14:45:00Z",
}),
];
renderListView({ tasks });
// Check that dates are formatted and displayed
const cells = screen.getAllByRole("cell");
// Created and Updated are columns 5 and 6 (0-indexed: 4 and 5)
const createdCell = cells[4];
const updatedCell = cells[5];
// Should contain formatted dates with time
expect(createdCell.textContent).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/);
expect(updatedCell.textContent).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/);
});
it("truncates long descriptions in title cell", () => {
const longDescription = "A".repeat(100);
const tasks = [createMockTask({ id: "KB-001", title: undefined, description: longDescription })];
renderListView({ tasks });
const titleCell = screen.getByText(/A{60}/).closest("td")!;
expect(titleCell.textContent).toContain("…");
expect(titleCell.textContent?.length).toBeLessThan(longDescription.length);
});
});

View File

@@ -98,6 +98,47 @@ body {
.btn-icon--stopped:hover {
color: var(--color-error);
}
/* View Toggle */
.view-toggle {
display: flex;
align-items: center;
gap: 2px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2px;
}
.view-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 24px;
background: none;
border: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.view-toggle-btn:hover {
color: var(--text);
background: var(--card-hover);
}
.view-toggle-btn.active {
background: var(--todo);
color: var(--bg);
}
.view-toggle-btn.active:hover {
background: var(--todo);
color: var(--bg);
}
.logo {
font-size: 20px;
font-weight: 700;
@@ -1789,3 +1830,392 @@ body {
.spin {
animation: spin 1s linear infinite;
}
/* === List View === */
.list-view {
display: flex;
flex-direction: column;
height: calc(100vh - 57px);
overflow: hidden;
background: var(--bg);
}
.list-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.list-filter {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
max-width: 320px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 12px;
transition: border-color var(--transition-fast);
}
.list-filter:focus-within {
border-color: var(--todo);
}
.filter-icon {
color: var(--text-muted);
flex-shrink: 0;
}
.filter-input {
flex: 1;
background: none;
border: none;
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
}
.filter-input::placeholder {
color: var(--text-dim);
}
.filter-clear {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 2px;
transition: color var(--transition-fast);
}
.filter-clear:hover {
color: var(--text);
}
.list-stats {
font-size: 12px;
color: var(--text-muted);
margin-left: auto;
}
/* Drop zones for drag and drop */
.list-drop-zones {
display: flex;
gap: 8px;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
overflow-x: auto;
}
.list-drop-zone {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.list-drop-zone:hover {
background: var(--card-hover);
border-color: var(--text-muted);
}
.list-drop-zone.drag-over {
border-color: var(--todo);
box-shadow: 0 0 0 1px var(--todo);
background: rgba(88, 166, 255, 0.1);
}
.drop-zone-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.drop-zone-label {
font-size: 12px;
font-weight: 500;
color: var(--text);
white-space: nowrap;
}
.drop-zone-count {
font-size: 11px;
color: var(--text-muted);
background: var(--bg);
padding: 2px 6px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
/* Table container */
.list-table-container {
flex: 1;
overflow: auto;
padding: 0;
}
.list-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.list-table thead {
position: sticky;
top: 0;
z-index: 10;
}
.list-header-cell {
text-align: left;
padding: 12px 16px;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
background: var(--surface);
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
transition: color var(--transition-fast);
white-space: nowrap;
}
.list-header-cell:hover {
color: var(--text);
}
.sort-icon {
vertical-align: middle;
margin-left: 4px;
opacity: 0.5;
transition: opacity var(--transition-fast);
}
.sort-icon.active {
opacity: 1;
color: var(--todo);
}
/* Table rows */
.list-row {
cursor: pointer;
transition: background var(--transition-fast);
}
.list-row:hover {
background: var(--card-hover);
}
.list-row.dragging {
opacity: 0.4;
}
.list-row.failed {
border-left: 3px solid #da3633;
}
.list-row.paused {
opacity: 0.55;
}
.list-row.agent-active {
border-left: 3px solid var(--in-progress);
animation: list-agent-glow 2.5s ease-in-out infinite;
}
@keyframes list-agent-glow {
0%,
100% {
background: rgba(188, 140, 255, 0.05);
}
50% {
background: rgba(188, 140, 255, 0.12);
}
}
/* Table cells */
.list-cell {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.list-cell-id {
font-family: "SF Mono", Monaco, Consolas, monospace;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.list-cell-title {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-cell-date {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
.list-cell-deps {
text-align: center;
}
/* Badges */
.list-status-badge {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 3px 10px;
border-radius: 10px;
background: var(--card);
color: var(--text-muted);
}
.list-status-badge.failed {
background: rgba(218, 54, 51, 0.15);
color: #da3633;
}
.list-status-badge.pulsing {
animation: pulse 1.5s ease-in-out infinite;
}
.list-column-badge {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 3px 10px;
border-radius: 10px;
}
.list-dep-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--triage);
}
/* Progress bar */
.list-cell-progress {
min-width: 100px;
}
.list-progress {
display: flex;
align-items: center;
gap: 8px;
}
.list-progress-bar {
flex: 1;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.list-progress-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s;
}
.list-progress-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
font-family: "SF Mono", Monaco, Consolas, monospace;
flex-shrink: 0;
min-width: 30px;
text-align: right;
}
/* Empty state */
.list-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
font-size: 14px;
color: var(--text-muted);
}
/* === List View Mobile Responsive === */
@media (max-width: 768px) {
.list-toolbar {
padding: 12px;
flex-wrap: wrap;
}
.list-filter {
max-width: none;
width: 100%;
order: 2;
margin-top: 8px;
}
.list-stats {
order: 1;
}
.list-drop-zones {
padding: 8px 12px;
gap: 6px;
}
.drop-zone-label {
display: none;
}
.list-table-container {
overflow-x: auto;
}
.list-table {
min-width: 800px;
}
.list-cell-title {
max-width: 150px;
}
.list-cell-date {
display: none;
}
.list-header-cell:nth-child(5),
.list-header-cell:nth-child(6) {
display: none;
}
}