feat(KB-049): add hide done tasks toggle to list view
- Add toggle UI for hiding completed tasks in ListView component - Implement filtering logic to conditionally render done tasks - Add comprehensive tests for hide done tasks feature - Include changeset for the new feature - Update vitest configs and clean up gitignore coverage entries
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3 } from "lucide-react";
|
||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, TaskStep } from "@kb/core";
|
||||
import { COLUMN_LABELS, COLUMNS } from "@kb/core";
|
||||
import { fetchTaskDetail } from "../api";
|
||||
@@ -87,6 +87,21 @@ export function ListView({
|
||||
return new Set(ALL_LIST_COLUMNS);
|
||||
});
|
||||
|
||||
// Hide done tasks state - initialize from localStorage
|
||||
const [hideDoneTasks, setHideDoneTasks] = useState<boolean>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const saved = localStorage.getItem("kb-dashboard-hide-done");
|
||||
if (saved !== null) {
|
||||
return saved === "true";
|
||||
}
|
||||
} catch {
|
||||
// Invalid localStorage data - fall through to default
|
||||
}
|
||||
}
|
||||
return false; // Default: show done tasks
|
||||
});
|
||||
|
||||
// Persist column visibility changes to localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -94,6 +109,13 @@ export function ListView({
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
// Persist hide done tasks state to localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("kb-dashboard-hide-done", hideDoneTasks.toString());
|
||||
}
|
||||
}, [hideDoneTasks]);
|
||||
|
||||
// Column dropdown state
|
||||
const [columnDropdownOpen, setColumnDropdownOpen] = useState(false);
|
||||
const columnDropdownRef = useRef<HTMLDivElement>(null);
|
||||
@@ -161,14 +183,20 @@ export function ListView({
|
||||
}, [sortField]);
|
||||
|
||||
const groupedTasks = useMemo(() => {
|
||||
const filtered = filter
|
||||
// First filter by search filter
|
||||
let filtered = filter
|
||||
? tasks.filter(
|
||||
(t) =>
|
||||
t.id.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(t.title && t.title.toLowerCase().includes(filter.toLowerCase())) ||
|
||||
t.description.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
: tasks;
|
||||
: [...tasks];
|
||||
|
||||
// Then filter out done tasks if hideDoneTasks is enabled
|
||||
if (hideDoneTasks) {
|
||||
filtered = filtered.filter((t) => t.column !== "done");
|
||||
}
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
@@ -205,12 +233,23 @@ export function ListView({
|
||||
};
|
||||
sorted.forEach(task => groups[task.column].push(task));
|
||||
return groups;
|
||||
}, [tasks, filter, sortField, sortDirection]);
|
||||
}, [tasks, filter, sortField, sortDirection, hideDoneTasks]);
|
||||
|
||||
// Calculate total filtered count from groups
|
||||
const filteredCount = useMemo(() => {
|
||||
return Object.values(groupedTasks).reduce((sum, group) => sum + group.length, 0);
|
||||
}, [groupedTasks]);
|
||||
|
||||
// Calculate done task counts for stats display
|
||||
const doneTaskCount = useMemo(() => {
|
||||
return tasks.filter((t) => t.column === "done").length;
|
||||
}, [tasks]);
|
||||
|
||||
// Calculate hidden done tasks count
|
||||
const hiddenDoneCount = useMemo(() => {
|
||||
if (!hideDoneTasks) return 0;
|
||||
return doneTaskCount;
|
||||
}, [hideDoneTasks, doneTaskCount]);
|
||||
const handleRowClick = useCallback(
|
||||
async (task: Task) => {
|
||||
try {
|
||||
@@ -332,8 +371,20 @@ export function ListView({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm list-hide-done-toggle"
|
||||
onClick={() => setHideDoneTasks((prev) => !prev)}
|
||||
aria-pressed={hideDoneTasks}
|
||||
title={hideDoneTasks ? "Show done tasks" : "Hide done tasks"}
|
||||
>
|
||||
{hideDoneTasks ? <Eye size={14} /> : <EyeOff size={14} />}
|
||||
{hideDoneTasks ? "Show Done" : "Hide Done"}
|
||||
</button>
|
||||
<div className="list-stats">
|
||||
{filteredCount} of {tasks.length} tasks
|
||||
{hiddenDoneCount > 0 && (
|
||||
<span className="list-stats-hidden"> ({hiddenDoneCount} done hidden)</span>
|
||||
)}
|
||||
</div>
|
||||
{onNewTask && (
|
||||
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
|
||||
@@ -343,22 +394,28 @@ export function ListView({
|
||||
</div>
|
||||
|
||||
<div className="list-drop-zones">
|
||||
{COLUMNS.map((column) => (
|
||||
<div
|
||||
key={column}
|
||||
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}`}
|
||||
onDragOver={(e) => handleColumnDragOver(e, column)}
|
||||
onDragLeave={handleColumnDragLeave}
|
||||
onDrop={(e) => handleColumnDrop(e, column)}
|
||||
data-column={column}
|
||||
>
|
||||
<span className="drop-zone-dot" style={{ background: COLUMN_COLOR_MAP[column] }} />
|
||||
<span className="drop-zone-label">{COLUMN_LABELS[column]}</span>
|
||||
<span className="drop-zone-count">
|
||||
{tasks.filter((t) => t.column === column).length}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{COLUMNS.map((column) => {
|
||||
const totalCount = tasks.filter((t) => t.column === column).length;
|
||||
const visibleCount = hideDoneTasks && column === "done" ? 0 : totalCount;
|
||||
const showPartial = hideDoneTasks && column === "done" && totalCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column}
|
||||
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}`}
|
||||
onDragOver={(e) => handleColumnDragOver(e, column)}
|
||||
onDragLeave={handleColumnDragLeave}
|
||||
onDrop={(e) => handleColumnDrop(e, column)}
|
||||
data-column={column}
|
||||
>
|
||||
<span className="drop-zone-dot" style={{ background: COLUMN_COLOR_MAP[column] }} />
|
||||
<span className="drop-zone-label">{COLUMN_LABELS[column]}</span>
|
||||
<span className="drop-zone-count">
|
||||
{showPartial ? `${visibleCount} of ${totalCount}` : totalCount}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="list-table-container">
|
||||
@@ -410,6 +467,9 @@ export function ListView({
|
||||
</thead>
|
||||
<tbody>
|
||||
{COLUMNS.map((column) => {
|
||||
// Skip done column section when hideDoneTasks is enabled
|
||||
if (hideDoneTasks && column === "done") return null;
|
||||
|
||||
const columnTasks = groupedTasks[column];
|
||||
const isEmpty = columnTasks.length === 0;
|
||||
|
||||
|
||||
@@ -853,3 +853,175 @@ describe("ListView Column Visibility", () => {
|
||||
expect(columnBadge?.textContent).toContain("Triage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ListView Hide Done Tasks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders hide done tasks toggle button", () => {
|
||||
renderListView();
|
||||
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
expect(hideDoneButton).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides done tasks when toggle is activated", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "KB-001", column: "done" }),
|
||||
createMockTask({ id: "KB-002", column: "triage" }),
|
||||
];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Both tasks should be visible initially
|
||||
expect(screen.getByText("KB-001")).toBeDefined();
|
||||
expect(screen.getByText("KB-002")).toBeDefined();
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done task should be hidden, triage task should still be visible
|
||||
expect(screen.queryByText("KB-001")).toBeNull();
|
||||
expect(screen.getByText("KB-002")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows done tasks when toggle is deactivated", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "KB-001", column: "done" }),
|
||||
createMockTask({ id: "KB-002", column: "triage" }),
|
||||
];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click hide done button to hide done tasks
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done task should be hidden
|
||||
expect(screen.queryByText("KB-001")).toBeNull();
|
||||
|
||||
// Click again to show done tasks
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Both tasks should be visible again
|
||||
expect(screen.getByText("KB-001")).toBeDefined();
|
||||
expect(screen.getByText("KB-002")).toBeDefined();
|
||||
});
|
||||
|
||||
it("persists hide done preference to localStorage", () => {
|
||||
const tasks = [createMockTask({ id: "KB-001", column: "done" })];
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Verify localStorage was updated
|
||||
expect(localStorage.getItem("kb-dashboard-hide-done")).toBe("true");
|
||||
});
|
||||
|
||||
it("initializes hide done state from localStorage", () => {
|
||||
// Set up localStorage with hide done enabled
|
||||
localStorage.setItem("kb-dashboard-hide-done", "true");
|
||||
|
||||
const tasks = [
|
||||
createMockTask({ id: "KB-001", column: "done" }),
|
||||
createMockTask({ id: "KB-002", column: "triage" }),
|
||||
];
|
||||
renderListView({ tasks });
|
||||
|
||||
// Button should show "Show Done" text since done tasks are hidden
|
||||
expect(screen.getByRole("button", { name: /show done/i })).toBeDefined();
|
||||
|
||||
// Done task should be hidden initially
|
||||
expect(screen.queryByText("KB-001")).toBeNull();
|
||||
expect(screen.getByText("KB-002")).toBeDefined();
|
||||
});
|
||||
|
||||
it("updates stats text when done tasks are hidden", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "KB-001", column: "done" }),
|
||||
createMockTask({ id: "KB-002", column: "triage" }),
|
||||
createMockTask({ id: "KB-003", column: "done" }),
|
||||
];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Initial stats should show all tasks
|
||||
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Stats should show filtered count with hidden indicator
|
||||
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
|
||||
expect(screen.getByText(/2 done hidden/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides done column section header when hide done is active", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "KB-001", column: "done" }),
|
||||
createMockTask({ id: "KB-002", column: "triage" }),
|
||||
];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// All section headers should be visible initially
|
||||
const sectionHeadersBefore = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeadersBefore.length).toBe(5); // All 5 columns
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done section should be hidden - find section headers and verify done is not present
|
||||
const doneSection = screen.getAllByRole("row").find(r =>
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Done")
|
||||
);
|
||||
expect(doneSection).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows done drop zone with count when hide done is active", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "KB-001", column: "done" }),
|
||||
createMockTask({ id: "KB-002", column: "done" }),
|
||||
];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done drop zone should still be visible with "X of Y" format
|
||||
const doneZone = document.querySelector('[data-column="done"].list-drop-zone');
|
||||
expect(doneZone).toBeDefined();
|
||||
expect(doneZone?.textContent).toContain("0 of 2");
|
||||
});
|
||||
|
||||
it("preserves hide done state through filter changes", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "KB-001", column: "done", title: "Alpha" }),
|
||||
createMockTask({ id: "KB-002", column: "triage", title: "Beta" }),
|
||||
];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Hide done tasks
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Apply filter
|
||||
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
|
||||
fireEvent.change(filterInput, { target: { value: "Beta" } });
|
||||
|
||||
// Done task should remain hidden
|
||||
expect(screen.queryByText("KB-001")).toBeNull();
|
||||
// Filtered task should be visible
|
||||
expect(screen.getByText("KB-002")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2295,6 +2295,11 @@ body {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.list-stats-hidden {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Column toggle dropdown */
|
||||
.list-column-toggle {
|
||||
position: relative;
|
||||
@@ -2367,6 +2372,13 @@ body {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Hide done tasks toggle */
|
||||
.list-hide-done-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Drop zones for drag and drop */
|
||||
.list-drop-zones {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user