feat(KB-047): add column filter to list view

- Add selectedColumn state for filtering tasks by column
- Make drop zones clickable to filter by column
- Add visual active state styling for selected column filter
- Add 'Clear' button to reset column filter
- Integrate column filter with existing hideDoneTasks feature
- Add comprehensive tests for column filter functionality
This commit is contained in:
gsxdsm
2026-03-29 19:49:28 -07:00
parent 435d261a58
commit 6ef6de3ba9
3 changed files with 224 additions and 9 deletions

View File

@@ -68,6 +68,7 @@ export function ListView({
const [filter, setFilter] = useState("");
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
const [selectedColumn, setSelectedColumn] = useState<Column | null>(null);
// Column visibility state - initialize from localStorage or default to all columns
const [visibleColumns, setVisibleColumns] = useState<Set<ListColumn>>(() => {
@@ -186,8 +187,16 @@ export function ListView({
}
}, [sortField]);
const handleColumnFilter = useCallback((column: Column) => {
setSelectedColumn((prev) => (prev === column ? null : column));
}, []);
const clearColumnFilter = useCallback(() => {
setSelectedColumn(null);
}, []);
const groupedTasks = useMemo(() => {
// First filter by search filter
// First apply text filter
let filtered = filter
? tasks.filter(
(t) =>
@@ -202,7 +211,12 @@ export function ListView({
filtered = filtered.filter((t) => t.column !== "done");
}
const sorted = [...filtered].sort((a, b) => {
// Then apply column filter if selected
const columnFiltered = selectedColumn
? filtered.filter((t) => t.column === selectedColumn)
: filtered;
const sorted = [...columnFiltered].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case "id":
@@ -237,7 +251,7 @@ export function ListView({
};
sorted.forEach(task => groups[task.column].push(task));
return groups;
}, [tasks, filter, sortField, sortDirection, hideDoneTasks]);
}, [tasks, filter, sortField, sortDirection, hideDoneTasks, selectedColumn]);
// Calculate total filtered count from groups
const filteredCount = useMemo(() => {
@@ -385,10 +399,22 @@ export function ListView({
{hideDoneTasks ? "Show Done" : "Hide Done"}
</button>
<div className="list-stats">
{filteredCount} of {tasks.length} tasks
{hiddenDoneCount > 0 && (
{selectedColumn
? `${filteredCount} of ${tasks.length} tasks in ${COLUMN_LABELS[selectedColumn]}`
: `${filteredCount} of ${tasks.length} tasks`}
{hiddenDoneCount > 0 && !selectedColumn && (
<span className="list-stats-hidden"> ({hiddenDoneCount} done hidden)</span>
)}
{selectedColumn && (
<button
className="btn btn-sm"
onClick={clearColumnFilter}
aria-label="Clear column filter"
style={{ marginLeft: "8px" }}
>
Clear
</button>
)}
</div>
{onNewTask && (
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
@@ -406,7 +432,8 @@ export function ListView({
return (
<div
key={column}
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}`}
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}${selectedColumn === column ? " active" : ""}`}
onClick={() => handleColumnFilter(column)}
onDragOver={(e) => handleColumnDragOver(e, column)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, column)}
@@ -471,13 +498,16 @@ export function ListView({
</thead>
<tbody>
{COLUMNS.map((column) => {
// Skip done column section when hideDoneTasks is enabled
if (hideDoneTasks && column === "done") return null;
// When column filter is active, only show the selected column
if (selectedColumn && column !== selectedColumn) return null;
// Skip done column section when hideDoneTasks is enabled (unless it's the selected column)
if (hideDoneTasks && column === "done" && !selectedColumn) return null;
const columnTasks = groupedTasks[column];
const isEmpty = columnTasks.length === 0;
// When filtering, hide empty sections entirely (except triage when creating)
// When text filtering, hide empty sections entirely (except triage when creating)
if (filter && isEmpty && !(column === "triage" && isCreating)) return null;
return (

View File

@@ -667,6 +667,185 @@ describe("ListView", () => {
});
});
describe("ListView Column Filtering", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("filters tasks by column when drop zone is clicked", () => {
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 });
// Click on the triage drop zone
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Only triage task should be visible
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.queryByText("KB-002")).toBeNull();
expect(screen.queryByText("KB-003")).toBeNull();
// Only triage section header should be visible
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(1);
expect(sectionHeaders[0].textContent).toContain("Triage");
});
it("clears column filter when same drop zone is clicked again", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone to filter
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Verify filter is active - only triage task visible
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.queryByText("KB-002")).toBeNull();
// Click the same drop zone again to clear filter
fireEvent.click(triageZone);
// All tasks should be visible again
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
// All 5 section headers should be visible
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(5);
});
it("switches column filter when different drop zone is clicked", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone to filter
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Verify only triage task visible
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.queryByText("KB-002")).toBeNull();
// Click on the todo drop zone to switch filter
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
fireEvent.click(todoZone);
// Only todo task should be visible now
expect(screen.queryByText("KB-001")).toBeNull();
expect(screen.getByText("KB-002")).toBeDefined();
// Only todo section header should be visible
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(1);
expect(sectionHeaders[0].textContent).toContain("Todo");
});
it("clears column filter when clear button is clicked", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone to filter
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Verify filter is active
expect(screen.queryByText("KB-002")).toBeNull();
// Click the clear button
const clearButton = screen.getByRole("button", { name: /clear column filter/i });
fireEvent.click(clearButton);
// All tasks should be visible again
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
});
it("shows correct filtered stats when column filter is active", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
createMockTask({ id: "KB-002", column: "triage", title: "Triage Task 2" }),
createMockTask({ id: "KB-003", column: "todo", title: "Todo Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone to filter
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Stats should show filtered count with column name
expect(screen.getByText("2 of 3 tasks in Triage")).toBeDefined();
});
it("applies text filter within column filter", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Alpha Triage Task" }),
createMockTask({ id: "KB-002", column: "triage", title: "Beta Triage Task" }),
createMockTask({ id: "KB-003", column: "todo", title: "Alpha Todo Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone to filter by column
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Both triage tasks should be visible
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
expect(screen.queryByText("KB-003")).toBeNull();
// Apply text filter within the triage column
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Alpha" } });
// Only Alpha triage task should be visible
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.queryByText("KB-002")).toBeNull();
expect(screen.queryByText("KB-003")).toBeNull();
// Stats should reflect combined filtering
expect(screen.getByText("1 of 3 tasks in Triage")).toBeDefined();
});
it("applies active class to selected column drop zone", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Should have active class
expect(triageZone.classList.contains("active")).toBe(true);
// Other drop zones should not have active class
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
expect(todoZone.classList.contains("active")).toBe(false);
});
});
describe("ListView Column Visibility", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -2407,6 +2407,12 @@ body {
border-color: var(--text-muted);
}
.list-drop-zone.active {
border-color: var(--todo);
background: rgba(88, 166, 255, 0.15);
box-shadow: 0 0 0 1px var(--todo);
}
.list-drop-zone.drag-over {
border-color: var(--todo);
box-shadow: 0 0 0 1px var(--todo);