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