- 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
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|