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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user