feat(KB-019): add grouped list view with section headers

- Implement task grouping logic by columns in ListView component
- Add CSS styling for section headers and grouped layout
- Fix React Fragment keys for proper list rendering
- Update tests for grouped list view functionality
- Add changeset for release notes
This commit is contained in:
gsxdsm
2026-03-29 18:43:52 -07:00
parent e79e28e717
commit 95fe32560d
4 changed files with 323 additions and 103 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, Fragment } 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";
@@ -70,7 +70,7 @@ export function ListView({
}
}, [sortField]);
const filteredAndSortedTasks = useMemo(() => {
const groupedTasks = useMemo(() => {
const filtered = filter
? tasks.filter(
(t) =>
@@ -80,7 +80,7 @@ export function ListView({
)
: tasks;
return [...filtered].sort((a, b) => {
const sorted = [...filtered].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case "id":
@@ -104,8 +104,23 @@ export function ListView({
}
return sortDirection === "asc" ? comparison : -comparison;
});
// Group by column while preserving sort order within each group
const groups: Record<Column, Task[]> = {
triage: [],
todo: [],
"in-progress": [],
"in-review": [],
done: []
};
sorted.forEach(task => groups[task.column].push(task));
return groups;
}, [tasks, filter, sortField, sortDirection]);
// Calculate total filtered count from groups
const filteredCount = useMemo(() => {
return Object.values(groupedTasks).reduce((sum, group) => sum + group.length, 0);
}, [groupedTasks]);
const handleRowClick = useCallback(
async (task: Task) => {
try {
@@ -193,7 +208,7 @@ export function ListView({
)}
</div>
<div className="list-stats">
{filteredAndSortedTasks.length} of {tasks.length} tasks
{filteredCount} of {tasks.length} tasks
</div>
{onNewTask && (
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
@@ -222,7 +237,7 @@ export function ListView({
</div>
<div className="list-table-container">
{filteredAndSortedTasks.length === 0 ? (
{filteredCount === 0 ? (
<div className="list-empty">
{filter ? "No tasks match your filter" : "No tasks yet"}
</div>
@@ -253,86 +268,116 @@ export function ListView({
</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;
{COLUMNS.map((column) => {
const columnTasks = groupedTasks[column];
const isEmpty = columnTasks.length === 0;
// When filtering, hide empty sections entirely
if (filter && isEmpty) return null;
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>
<Fragment key={column}>
{/* Section Header */}
<tr className="list-section-header">
<th colSpan={8} className="list-section-cell">
<span className={`list-section-dot dot-${column}`} />
<span className="list-section-title">{COLUMN_LABELS[column]}</span>
<span className="list-section-count">{columnTasks.length}</span>
</th>
</tr>
{/* Task Rows */}
{isEmpty ? (
<tr className="list-section-empty">
<td colSpan={8} className="list-empty-cell">
No tasks
</td>
</tr>
) : (
columnTasks.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>
);
})
)}
</Fragment>
);
})}
</tbody>

View File

@@ -189,9 +189,9 @@ describe("ListView", () => {
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" }),
createMockTask({ id: "KB-003", title: "Third", column: "triage" }),
createMockTask({ id: "KB-001", title: "First", column: "triage" }),
createMockTask({ id: "KB-002", title: "Second", column: "triage" }),
];
renderListView({ tasks });
@@ -200,7 +200,8 @@ describe("ListView", () => {
const idHeader = screen.getByText("ID");
fireEvent.click(idHeader);
const rows = screen.getAllByRole("row").slice(1); // Skip header row
// Get all data rows (excluding section headers by using data-id attribute)
const rows = screen.getAllByRole("row").filter(r => r.getAttribute("data-id"));
expect(rows[0].textContent).toContain("KB-001");
expect(rows[1].textContent).toContain("KB-002");
expect(rows[2].textContent).toContain("KB-003");
@@ -208,7 +209,7 @@ describe("ListView", () => {
// Second click - descending
fireEvent.click(idHeader);
const rowsDesc = screen.getAllByRole("row").slice(1);
const rowsDesc = screen.getAllByRole("row").filter(r => r.getAttribute("data-id"));
expect(rowsDesc[0].textContent).toContain("KB-003");
expect(rowsDesc[1].textContent).toContain("KB-002");
expect(rowsDesc[2].textContent).toContain("KB-001");
@@ -226,17 +227,18 @@ describe("ListView", () => {
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");
// Get data rows - sorted by column alphabetically: done, in-progress, triage
const rows = screen.getAllByRole("row").filter(r => r.getAttribute("data-id"));
expect(rows[0].textContent).toContain("KB-002"); // triage (sorted first alphabetically)
expect(rows[1].textContent).toContain("KB-003"); // in-progress
expect(rows[2].textContent).toContain("KB-001"); // done
});
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" }),
createMockTask({ id: "KB-001", status: "executing", column: "triage" }),
createMockTask({ id: "KB-002", status: "pending", column: "triage" }),
createMockTask({ id: "KB-003", status: "failed", column: "triage" }),
];
renderListView({ tasks });
@@ -244,8 +246,9 @@ describe("ListView", () => {
const statusHeader = screen.getByText("Status");
fireEvent.click(statusHeader);
const rows = screen.getAllByRole("row").slice(1);
// Should be sorted alphabetically: executing, failed, pending
// Get data rows - sorted by status alphabetically
const rows = screen.getAllByRole("row").filter(r => r.getAttribute("data-id"));
// Should be sorted alphabetically by status: executing, failed, pending
expect(rows[0].textContent).toContain("executing");
expect(rows[2].textContent).toContain("pending");
});
@@ -349,9 +352,10 @@ describe("ListView", () => {
renderListView({ tasks });
const progressCells = screen.getAllByRole("cell");
const lastCell = progressCells[progressCells.length - 1];
expect(lastCell.textContent).toBe("-");
// Find the task row and check its progress cell
const row = screen.getByText("KB-001").closest("tr")!;
const progressCell = row.querySelector(".list-cell-progress");
expect(progressCell?.textContent).toBe("-");
});
it("renders dependency count with icon", () => {
@@ -465,8 +469,8 @@ describe("ListView", () => {
},
});
// Simulate drop on todo column
const todoZone = screen.getByText("Todo").closest("[data-column]")!;
// Simulate drop on todo column drop zone (use querySelector for specificity)
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
fireEvent.dragOver(todoZone, {
preventDefault: vi.fn(),
dataTransfer: { dropEffect: "move" },
@@ -519,7 +523,8 @@ describe("ListView", () => {
},
});
const todoZone = screen.getByText("Todo").closest("[data-column]")!;
// Use querySelector to find the specific drop zone
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
fireEvent.drop(todoZone, {
preventDefault: vi.fn(),
dataTransfer: {
@@ -564,4 +569,100 @@ describe("ListView", () => {
expect(titleCell.textContent).toContain("…");
expect(titleCell.textContent?.length).toBeLessThan(longDescription.length);
});
// Grouped view tests
it("renders section headers for each column", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage" }),
createMockTask({ id: "KB-002", column: "todo" }),
];
renderListView({ tasks });
// Check that section headers are rendered with column names
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);
});
it("displays correct task count in section headers", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage" }),
createMockTask({ id: "KB-002", column: "triage" }),
createMockTask({ id: "KB-003", column: "todo" }),
];
renderListView({ tasks });
// Find section headers by their structure
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(5); // One for each column
// Check that triage section shows count of 2
const triageHeader = sectionHeaders.find(h => h.textContent?.includes("Triage"));
expect(triageHeader?.textContent).toContain("2");
// Check that todo section shows count of 1
const todoHeader = sectionHeaders.find(h => h.textContent?.includes("Todo"));
expect(todoHeader?.textContent).toContain("1");
});
it("shows No tasks placeholder for empty columns", () => {
const tasks = [createMockTask({ id: "KB-001", column: "triage" })];
renderListView({ tasks });
// Should show "No tasks" for empty columns
const noTasksCells = screen.getAllByText("No tasks");
expect(noTasksCells.length).toBeGreaterThanOrEqual(1);
});
it("hides empty sections when filter is active", () => {
const tasks = [
createMockTask({ id: "KB-001", title: "Alpha Task", column: "triage" }),
createMockTask({ id: "KB-002", title: "Beta Task", column: "todo" }),
];
renderListView({ tasks });
// Apply filter that only matches triage task
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Alpha" } });
// Only triage section should be visible (todo section should be hidden)
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(1);
expect(sectionHeaders[0].textContent).toContain("Triage");
// Verify the filtered task is visible
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.queryByText("KB-002")).toBeNull();
});
it("maintains sort order within each section", () => {
const tasks = [
createMockTask({ id: "KB-003", title: "Charlie", column: "triage" }),
createMockTask({ id: "KB-001", title: "Alpha", column: "triage" }),
createMockTask({ id: "KB-002", title: "Bravo", column: "triage" }),
];
renderListView({ tasks });
// Sort by title
const titleHeader = screen.getByText("Title");
fireEvent.click(titleHeader);
// Get only data rows within the triage section
const allRows = screen.getAllByRole("row");
const triageSectionStart = allRows.findIndex(r => r.className.includes("list-section-header") && r.textContent?.includes("Triage"));
// The next 3 rows after the section header should be the sorted tasks
const dataRows = allRows.slice(triageSectionStart + 1, triageSectionStart + 4).filter(r => r.getAttribute("data-id"));
expect(dataRows[0].textContent).toContain("KB-001"); // Alpha
expect(dataRows[1].textContent).toContain("KB-002"); // Bravo
expect(dataRows[2].textContent).toContain("KB-003"); // Charlie
});
});

View File

@@ -2291,6 +2291,73 @@ body {
color: var(--text-muted);
}
/* Section headers for grouped list view */
.list-section-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.list-section-header:hover {
background: var(--surface); /* Prevent hover effect on section headers */
}
.list-section-cell {
padding: 10px 16px;
text-align: left;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text);
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.list-section-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
}
.list-section-title {
display: inline-block;
vertical-align: middle;
margin-right: 8px;
}
.list-section-count {
display: inline-block;
font-size: 11px;
color: var(--text-muted);
background: var(--card);
padding: 2px 8px;
border-radius: 10px;
min-width: 24px;
text-align: center;
vertical-align: middle;
}
/* Empty section row */
.list-section-empty {
background: var(--bg);
}
.list-section-empty:hover {
background: var(--bg); /* Prevent hover effect on empty section rows */
}
.list-empty-cell {
padding: 24px 16px;
text-align: center;
font-size: 13px;
color: var(--text-dim);
font-style: italic;
border-bottom: 1px solid var(--border);
}
/* === List View Mobile Responsive === */
@media (max-width: 768px) {
.list-toolbar {