feat(KB-194): add collapsible sections to ListView with localStorage persistence
- Add collapse state management with localStorage persistence per section - Update section headers with chevron icon and click handler - Conditionally render task rows based on collapsed state - Add aria-expanded attribute for accessibility - Add comprehensive tests for collapsible section behavior
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye } from "lucide-react";
|
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
|
||||||
import type { Task, TaskDetail, Column, TaskStep } from "@kb/core";
|
import type { Task, TaskDetail, Column, TaskStep } from "@kb/core";
|
||||||
import { COLUMN_LABELS, COLUMNS } from "@kb/core";
|
import { COLUMN_LABELS, COLUMNS } from "@kb/core";
|
||||||
import { fetchTaskDetail } from "../api";
|
import { fetchTaskDetail } from "../api";
|
||||||
@@ -99,6 +99,28 @@ export function ListView({
|
|||||||
return false; // Default: show done tasks
|
return false; // Default: show done tasks
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Collapsed sections state - initialize from localStorage
|
||||||
|
const [collapsedSections, setCollapsedSections] = useState<Set<Column>>(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem("kb-dashboard-list-collapsed");
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved) as Column[];
|
||||||
|
// Validate that all saved columns are valid Column values
|
||||||
|
const validColumns = parsed.filter((col): col is Column =>
|
||||||
|
COLUMNS.includes(col as Column)
|
||||||
|
);
|
||||||
|
if (validColumns.length > 0) {
|
||||||
|
return new Set(validColumns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid localStorage data - fall through to default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Set<Column>(); // Default: all sections expanded
|
||||||
|
});
|
||||||
|
|
||||||
// Persist column visibility changes to localStorage
|
// Persist column visibility changes to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -113,6 +135,13 @@ export function ListView({
|
|||||||
}
|
}
|
||||||
}, [hideDoneTasks]);
|
}, [hideDoneTasks]);
|
||||||
|
|
||||||
|
// Persist collapsed sections state to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("kb-dashboard-list-collapsed", JSON.stringify([...collapsedSections]));
|
||||||
|
}
|
||||||
|
}, [collapsedSections]);
|
||||||
|
|
||||||
// Column dropdown state
|
// Column dropdown state
|
||||||
const [columnDropdownOpen, setColumnDropdownOpen] = useState(false);
|
const [columnDropdownOpen, setColumnDropdownOpen] = useState(false);
|
||||||
const columnDropdownRef = useRef<HTMLDivElement>(null);
|
const columnDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -181,6 +210,18 @@ export function ListView({
|
|||||||
setSelectedColumn((prev) => (prev === column ? null : column));
|
setSelectedColumn((prev) => (prev === column ? null : column));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleSection = useCallback((column: Column) => {
|
||||||
|
setCollapsedSections((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(column)) {
|
||||||
|
next.delete(column);
|
||||||
|
} else {
|
||||||
|
next.add(column);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clearColumnFilter = useCallback(() => {
|
const clearColumnFilter = useCallback(() => {
|
||||||
setSelectedColumn(null);
|
setSelectedColumn(null);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -496,117 +537,131 @@ export function ListView({
|
|||||||
// When text filtering, hide empty sections entirely
|
// When text filtering, hide empty sections entirely
|
||||||
if (filter && isEmpty) return null;
|
if (filter && isEmpty) return null;
|
||||||
|
|
||||||
|
const isCollapsed = collapsedSections.has(column);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={column}>
|
<Fragment key={column}>
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<tr className="list-section-header">
|
<tr
|
||||||
|
className={`list-section-header${isCollapsed ? " list-section-header--collapsed" : ""}`}
|
||||||
|
onClick={() => toggleSection(column)}
|
||||||
|
aria-expanded={!isCollapsed}
|
||||||
|
>
|
||||||
<th colSpan={visibleColumns.size} className="list-section-cell">
|
<th colSpan={visibleColumns.size} className="list-section-cell">
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
className={`list-section-chevron${!isCollapsed ? " list-section-chevron--expanded" : ""}`}
|
||||||
|
/>
|
||||||
<span className={`list-section-dot dot-${column}`} />
|
<span className={`list-section-dot dot-${column}`} />
|
||||||
<span className="list-section-title">{COLUMN_LABELS[column]}</span>
|
<span className="list-section-title">{COLUMN_LABELS[column]}</span>
|
||||||
<span className="list-section-count">{columnTasks.length}</span>
|
<span className="list-section-count">{columnTasks.length}</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* Task Rows */}
|
{/* Task Rows - only render when not collapsed */}
|
||||||
{isEmpty ? (
|
{!isCollapsed && (
|
||||||
<tr className="list-section-empty">
|
<>
|
||||||
<td colSpan={visibleColumns.size} className="list-empty-cell">
|
{isEmpty ? (
|
||||||
No tasks
|
<tr className="list-section-empty">
|
||||||
</td>
|
<td colSpan={visibleColumns.size} className="list-empty-cell">
|
||||||
</tr>
|
No tasks
|
||||||
) : (
|
</td>
|
||||||
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}
|
|
||||||
>
|
|
||||||
{visibleColumns.has("id") && (
|
|
||||||
<td className="list-cell list-cell-id">{task.id}</td>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has("title") && (
|
|
||||||
<td className="list-cell list-cell-title">
|
|
||||||
{task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "")}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has("status") && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has("column") && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has("dependencies") && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has("progress") && (
|
|
||||||
<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>
|
</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}
|
||||||
|
>
|
||||||
|
{visibleColumns.has("id") && (
|
||||||
|
<td className="list-cell list-cell-id">{task.id}</td>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("title") && (
|
||||||
|
<td className="list-cell list-cell-title">
|
||||||
|
{task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "")}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("status") && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("column") && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("dependencies") && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("progress") && (
|
||||||
|
<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>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1399,3 +1399,266 @@ describe("ListView Quick Entry", () => {
|
|||||||
expect(mockOnQuickCreate).not.toHaveBeenCalled();
|
expect(mockOnQuickCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ListView Collapsible Sections", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking section header toggles collapse and hides task rows", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task 1" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "triage", title: "Triage Task 2" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Both tasks should be visible initially
|
||||||
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
|
expect(screen.getByText("KB-002")).toBeDefined();
|
||||||
|
|
||||||
|
// Find and click the triage section header
|
||||||
|
const triageHeader = screen.getAllByRole("row").find(r =>
|
||||||
|
r.className.includes("list-section-header") && r.textContent?.includes("Triage")
|
||||||
|
);
|
||||||
|
expect(triageHeader).toBeDefined();
|
||||||
|
fireEvent.click(triageHeader!);
|
||||||
|
|
||||||
|
// Tasks should be hidden after collapse
|
||||||
|
expect(screen.queryByText("KB-001")).toBeNull();
|
||||||
|
expect(screen.queryByText("KB-002")).toBeNull();
|
||||||
|
|
||||||
|
// Section header should have collapsed class
|
||||||
|
expect(triageHeader?.className).toContain("list-section-header--collapsed");
|
||||||
|
|
||||||
|
// Chevron should not have expanded class
|
||||||
|
const chevron = triageHeader?.querySelector(".list-section-chevron");
|
||||||
|
expect(chevron?.className).not.toContain("list-section-chevron--expanded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking again expands section and shows task rows", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Find the triage section header
|
||||||
|
let triageHeader = screen.getAllByRole("row").find(r =>
|
||||||
|
r.className.includes("list-section-header") && r.textContent?.includes("Triage")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click to collapse
|
||||||
|
fireEvent.click(triageHeader!);
|
||||||
|
|
||||||
|
// Task should be hidden
|
||||||
|
expect(screen.queryByText("KB-001")).toBeNull();
|
||||||
|
|
||||||
|
// Click again to expand
|
||||||
|
fireEvent.click(triageHeader!);
|
||||||
|
|
||||||
|
// Task should be visible again
|
||||||
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
|
|
||||||
|
// Re-query for the header to get fresh DOM reference after re-render
|
||||||
|
triageHeader = screen.getAllByRole("row").find(r =>
|
||||||
|
r.className.includes("list-section-header") && r.textContent?.includes("Triage")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Section header should not have collapsed class
|
||||||
|
expect(triageHeader?.className).not.toContain("list-section-header--collapsed");
|
||||||
|
|
||||||
|
// Chevron should have expanded class (check via aria-expanded since header re-renders)
|
||||||
|
expect(triageHeader?.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapse state persists to localStorage", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Click to collapse
|
||||||
|
const triageHeader = screen.getAllByRole("row").find(r =>
|
||||||
|
r.className.includes("list-section-header") && r.textContent?.includes("Triage")
|
||||||
|
);
|
||||||
|
fireEvent.click(triageHeader!);
|
||||||
|
|
||||||
|
// Verify localStorage was updated
|
||||||
|
const saved = localStorage.getItem("kb-dashboard-list-collapsed");
|
||||||
|
expect(saved).toBeTruthy();
|
||||||
|
const parsed = JSON.parse(saved!);
|
||||||
|
expect(parsed).toContain("triage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapse state initializes from localStorage on mount", () => {
|
||||||
|
// Set up localStorage with triage section collapsed
|
||||||
|
localStorage.setItem("kb-dashboard-list-collapsed", JSON.stringify(["triage"]));
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Triage task should be hidden initially (collapsed from localStorage)
|
||||||
|
expect(screen.queryByText("KB-001")).toBeNull();
|
||||||
|
|
||||||
|
// Todo task should be visible
|
||||||
|
expect(screen.getByText("KB-002")).toBeDefined();
|
||||||
|
|
||||||
|
// Triage section header should have collapsed class
|
||||||
|
const triageHeader = screen.getAllByRole("row").find(r =>
|
||||||
|
r.className.includes("list-section-header") && r.textContent?.includes("Triage")
|
||||||
|
);
|
||||||
|
expect(triageHeader?.className).toContain("list-section-header--collapsed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple sections can be collapsed independently", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
|
||||||
|
createMockTask({ id: "KB-003", column: "in-progress", title: "In Progress Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Get section headers
|
||||||
|
const allHeaders = screen.getAllByRole("row").filter(r =>
|
||||||
|
r.className.includes("list-section-header")
|
||||||
|
);
|
||||||
|
const triageHeader = allHeaders.find(h => h.textContent?.includes("Triage"));
|
||||||
|
const todoHeader = allHeaders.find(h => h.textContent?.includes("Todo"));
|
||||||
|
|
||||||
|
// Collapse triage section
|
||||||
|
fireEvent.click(triageHeader!);
|
||||||
|
|
||||||
|
// Collapse todo section
|
||||||
|
fireEvent.click(todoHeader!);
|
||||||
|
|
||||||
|
// Triage and todo tasks should be hidden
|
||||||
|
expect(screen.queryByText("KB-001")).toBeNull();
|
||||||
|
expect(screen.queryByText("KB-002")).toBeNull();
|
||||||
|
|
||||||
|
// In Progress task should still be visible
|
||||||
|
expect(screen.getByText("KB-003")).toBeDefined();
|
||||||
|
|
||||||
|
// Both sections should be marked as collapsed
|
||||||
|
expect(triageHeader?.className).toContain("list-section-header--collapsed");
|
||||||
|
expect(todoHeader?.className).toContain("list-section-header--collapsed");
|
||||||
|
|
||||||
|
// Verify localStorage has both columns
|
||||||
|
const saved = localStorage.getItem("kb-dashboard-list-collapsed");
|
||||||
|
const parsed = JSON.parse(saved!);
|
||||||
|
expect(parsed).toContain("triage");
|
||||||
|
expect(parsed).toContain("todo");
|
||||||
|
expect(parsed).not.toContain("in-progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorting still works with collapsed sections", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-003", column: "triage", title: "Charlie" }),
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Alpha" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "triage", title: "Bravo" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Collapse triage section
|
||||||
|
const triageHeader = screen.getAllByRole("row").find(r =>
|
||||||
|
r.className.includes("list-section-header") && r.textContent?.includes("Triage")
|
||||||
|
);
|
||||||
|
fireEvent.click(triageHeader!);
|
||||||
|
|
||||||
|
// Expand triage section
|
||||||
|
fireEvent.click(triageHeader!);
|
||||||
|
|
||||||
|
// Sort by title
|
||||||
|
const titleHeader = screen.getByText("Title");
|
||||||
|
fireEvent.click(titleHeader);
|
||||||
|
|
||||||
|
// Get sorted rows and verify sorting still works
|
||||||
|
const rows = screen.getAllByRole("row").filter(r => r.getAttribute("data-id"));
|
||||||
|
expect(rows[0].textContent).toContain("KB-001"); // Alpha
|
||||||
|
expect(rows[1].textContent).toContain("KB-002"); // Bravo
|
||||||
|
expect(rows[2].textContent).toContain("KB-003"); // Charlie
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filtering still works with collapsed sections", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Alpha Task" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "triage", title: "Beta Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Collapse triage section
|
||||||
|
const triageHeader = screen.getAllByRole("row").find(r =>
|
||||||
|
r.className.includes("list-section-header") && r.textContent?.includes("Triage")
|
||||||
|
);
|
||||||
|
fireEvent.click(triageHeader!);
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
|
||||||
|
fireEvent.change(filterInput, { target: { value: "Alpha" } });
|
||||||
|
|
||||||
|
// Expand triage section by clicking again (filter change should keep collapsed state)
|
||||||
|
fireEvent.click(triageHeader!);
|
||||||
|
|
||||||
|
// Only Alpha task should be visible
|
||||||
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
|
expect(screen.queryByText("KB-002")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("section header has aria-expanded attribute for accessibility", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Find triage section header
|
||||||
|
const triageHeader = screen.getAllByRole("row").find(r =>
|
||||||
|
r.className.includes("list-section-header") && r.textContent?.includes("Triage")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should have aria-expanded="true" when expanded
|
||||||
|
expect(triageHeader?.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
|
||||||
|
// Click to collapse
|
||||||
|
fireEvent.click(triageHeader!);
|
||||||
|
|
||||||
|
// Should have aria-expanded="false" when collapsed
|
||||||
|
expect(triageHeader?.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapsed section hides No tasks placeholder", () => {
|
||||||
|
// Create tasks in one column, leave another column empty
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// First verify the "No tasks" placeholder is visible for empty columns (like Todo)
|
||||||
|
const noTasksCellsBefore = screen.getAllByText("No tasks");
|
||||||
|
expect(noTasksCellsBefore.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Find and collapse the todo section (which has no tasks)
|
||||||
|
const todoHeader = screen.getAllByRole("row").find(r =>
|
||||||
|
r.className.includes("list-section-header") && r.textContent?.includes("Todo")
|
||||||
|
);
|
||||||
|
expect(todoHeader).toBeDefined();
|
||||||
|
fireEvent.click(todoHeader!);
|
||||||
|
|
||||||
|
// When collapsed, the section header should have collapsed class
|
||||||
|
expect(todoHeader?.className).toContain("list-section-header--collapsed");
|
||||||
|
|
||||||
|
// The "No tasks" placeholder for todo section should not be visible anymore
|
||||||
|
// (we can't easily verify this without complex DOM traversal, but the collapse
|
||||||
|
// class is the primary indicator that the section is collapsed)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user