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:
@@ -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}
|
||||
|
||||
135
packages/dashboard/app/components/Header.test.tsx
Normal file
135
packages/dashboard/app/components/Header.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
344
packages/dashboard/app/components/ListView.tsx
Normal file
344
packages/dashboard/app/components/ListView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
567
packages/dashboard/app/components/__tests__/ListView.test.tsx
Normal file
567
packages/dashboard/app/components/__tests__/ListView.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user