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>
|
||||
|
||||
Reference in New Issue
Block a user