feat(KB-020): add column visibility toggle to list view
- Add column visibility state management with localStorage persistence - Create dropdown UI for toggling column visibility with checkboxes - Implement conditional column rendering in ListView - Add CSS styling for column toggle dropdown component - Add comprehensive tests for column visibility toggle functionality - Include changeset for patch release
This commit is contained in:
5
.changeset/add-list-column-toggle.md
Normal file
5
.changeset/add-list-column-toggle.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add column visibility toggle to list view. Users can now show/hide columns via the Columns button in the toolbar, with preferences persisting to localStorage.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useMemo, Fragment } from "react";
|
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link } from "lucide-react";
|
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3 } 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";
|
||||||
@@ -18,6 +18,10 @@ const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finali
|
|||||||
type SortField = "id" | "title" | "status" | "column" | "createdAt" | "updatedAt";
|
type SortField = "id" | "title" | "status" | "column" | "createdAt" | "updatedAt";
|
||||||
type SortDirection = "asc" | "desc";
|
type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
// Column visibility types
|
||||||
|
const ALL_LIST_COLUMNS = ["id", "title", "status", "column", "createdAt", "updatedAt", "dependencies", "progress"] as const;
|
||||||
|
type ListColumn = typeof ALL_LIST_COLUMNS[number];
|
||||||
|
|
||||||
interface ListViewProps {
|
interface ListViewProps {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
||||||
@@ -61,6 +65,92 @@ export function ListView({
|
|||||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||||
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
|
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
|
||||||
|
|
||||||
|
// Column visibility state - initialize from localStorage or default to all columns
|
||||||
|
const [visibleColumns, setVisibleColumns] = useState<Set<ListColumn>>(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem("kb-dashboard-list-columns");
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved) as ListColumn[];
|
||||||
|
// Validate that all saved columns are valid ListColumn values
|
||||||
|
const validColumns = parsed.filter((col): col is ListColumn =>
|
||||||
|
ALL_LIST_COLUMNS.includes(col as ListColumn)
|
||||||
|
);
|
||||||
|
if (validColumns.length > 0) {
|
||||||
|
return new Set(validColumns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid localStorage data - fall through to default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Set(ALL_LIST_COLUMNS);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist column visibility changes to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("kb-dashboard-list-columns", JSON.stringify([...visibleColumns]));
|
||||||
|
}
|
||||||
|
}, [visibleColumns]);
|
||||||
|
|
||||||
|
// Column dropdown state
|
||||||
|
const [columnDropdownOpen, setColumnDropdownOpen] = useState(false);
|
||||||
|
const columnDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Toggle a column's visibility
|
||||||
|
const toggleColumn = useCallback((column: ListColumn) => {
|
||||||
|
setVisibleColumns((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(column)) {
|
||||||
|
// Prevent hiding the last visible column
|
||||||
|
if (next.size > 1) {
|
||||||
|
next.delete(column);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.add(column);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!columnDropdownOpen) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (columnDropdownRef.current && !columnDropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setColumnDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setColumnDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [columnDropdownOpen]);
|
||||||
|
|
||||||
|
// Column display labels
|
||||||
|
const COLUMN_LABELS_MAP: Record<ListColumn, string> = {
|
||||||
|
id: "ID",
|
||||||
|
title: "Title",
|
||||||
|
status: "Status",
|
||||||
|
column: "Column",
|
||||||
|
createdAt: "Created",
|
||||||
|
updatedAt: "Updated",
|
||||||
|
dependencies: "Dependencies",
|
||||||
|
progress: "Progress",
|
||||||
|
};
|
||||||
|
|
||||||
const handleSort = useCallback((field: SortField) => {
|
const handleSort = useCallback((field: SortField) => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||||
@@ -207,6 +297,41 @@ export function ListView({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="list-column-toggle" ref={columnDropdownRef}>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => setColumnDropdownOpen((prev) => !prev)}
|
||||||
|
aria-expanded={columnDropdownOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
<Columns3 size={14} />
|
||||||
|
Columns
|
||||||
|
</button>
|
||||||
|
{columnDropdownOpen && (
|
||||||
|
<div className="list-column-dropdown" role="menu">
|
||||||
|
{ALL_LIST_COLUMNS.map((column) => {
|
||||||
|
const isVisible = visibleColumns.has(column);
|
||||||
|
const isLastVisible = isVisible && visibleColumns.size === 1;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={column}
|
||||||
|
className={`list-column-dropdown-item${isLastVisible ? " disabled" : ""}`}
|
||||||
|
role="menuitem"
|
||||||
|
title={isLastVisible ? "At least one column must be visible" : ""}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isVisible}
|
||||||
|
onChange={() => toggleColumn(column)}
|
||||||
|
disabled={isLastVisible}
|
||||||
|
/>
|
||||||
|
<span>{COLUMN_LABELS_MAP[column]}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="list-stats">
|
<div className="list-stats">
|
||||||
{filteredCount} of {tasks.length} tasks
|
{filteredCount} of {tasks.length} tasks
|
||||||
</div>
|
</div>
|
||||||
@@ -245,26 +370,42 @@ export function ListView({
|
|||||||
<table className="list-table">
|
<table className="list-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="list-header-cell" onClick={() => handleSort("id")}>
|
{visibleColumns.has("id") && (
|
||||||
ID {getSortIcon("id")}
|
<th className="list-header-cell" onClick={() => handleSort("id")}>
|
||||||
</th>
|
ID {getSortIcon("id")}
|
||||||
<th className="list-header-cell" onClick={() => handleSort("title")}>
|
</th>
|
||||||
Title {getSortIcon("title")}
|
)}
|
||||||
</th>
|
{visibleColumns.has("title") && (
|
||||||
<th className="list-header-cell" onClick={() => handleSort("status")}>
|
<th className="list-header-cell" onClick={() => handleSort("title")}>
|
||||||
Status {getSortIcon("status")}
|
Title {getSortIcon("title")}
|
||||||
</th>
|
</th>
|
||||||
<th className="list-header-cell" onClick={() => handleSort("column")}>
|
)}
|
||||||
Column {getSortIcon("column")}
|
{visibleColumns.has("status") && (
|
||||||
</th>
|
<th className="list-header-cell" onClick={() => handleSort("status")}>
|
||||||
<th className="list-header-cell" onClick={() => handleSort("createdAt")}>
|
Status {getSortIcon("status")}
|
||||||
Created {getSortIcon("createdAt")}
|
</th>
|
||||||
</th>
|
)}
|
||||||
<th className="list-header-cell" onClick={() => handleSort("updatedAt")}>
|
{visibleColumns.has("column") && (
|
||||||
Updated {getSortIcon("updatedAt")}
|
<th className="list-header-cell" onClick={() => handleSort("column")}>
|
||||||
</th>
|
Column {getSortIcon("column")}
|
||||||
<th className="list-header-cell">Dependencies</th>
|
</th>
|
||||||
<th className="list-header-cell">Progress</th>
|
)}
|
||||||
|
{visibleColumns.has("createdAt") && (
|
||||||
|
<th className="list-header-cell" onClick={() => handleSort("createdAt")}>
|
||||||
|
Created {getSortIcon("createdAt")}
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("updatedAt") && (
|
||||||
|
<th className="list-header-cell" onClick={() => handleSort("updatedAt")}>
|
||||||
|
Updated {getSortIcon("updatedAt")}
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("dependencies") && (
|
||||||
|
<th className="list-header-cell">Dependencies</th>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has("progress") && (
|
||||||
|
<th className="list-header-cell">Progress</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -279,7 +420,7 @@ export function ListView({
|
|||||||
<Fragment key={column}>
|
<Fragment key={column}>
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<tr className="list-section-header">
|
<tr className="list-section-header">
|
||||||
<th colSpan={8} className="list-section-cell">
|
<th colSpan={visibleColumns.size} className="list-section-cell">
|
||||||
<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>
|
||||||
@@ -289,7 +430,7 @@ export function ListView({
|
|||||||
{/* Task Rows */}
|
{/* Task Rows */}
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<tr className="list-section-empty">
|
<tr className="list-section-empty">
|
||||||
<td colSpan={8} className="list-empty-cell">
|
<td colSpan={visibleColumns.size} className="list-empty-cell">
|
||||||
No tasks
|
No tasks
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -316,63 +457,79 @@ export function ListView({
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
data-id={task.id}
|
data-id={task.id}
|
||||||
>
|
>
|
||||||
<td className="list-cell list-cell-id">{task.id}</td>
|
{visibleColumns.has("id") && (
|
||||||
<td className="list-cell list-cell-title">
|
<td className="list-cell list-cell-id">{task.id}</td>
|
||||||
{task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "")}
|
)}
|
||||||
</td>
|
{visibleColumns.has("title") && (
|
||||||
<td className="list-cell">
|
<td className="list-cell list-cell-title">
|
||||||
{task.status ? (
|
{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
|
<span
|
||||||
className={`list-status-badge${isFailed ? " failed" : ""}${
|
className="list-column-badge"
|
||||||
isAgentActive ? " pulsing" : ""
|
style={{
|
||||||
}`}
|
background: `${COLUMN_COLOR_MAP[task.column]}20`,
|
||||||
|
color: COLUMN_COLOR_MAP[task.column],
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{task.status}
|
{COLUMN_LABELS[task.column]}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
</td>
|
||||||
<span className="list-status-badge">-</span>
|
)}
|
||||||
)}
|
{visibleColumns.has("createdAt") && (
|
||||||
</td>
|
<td className="list-cell list-cell-date">{formatDate(task.createdAt)}</td>
|
||||||
<td className="list-cell">
|
)}
|
||||||
<span
|
{visibleColumns.has("updatedAt") && (
|
||||||
className="list-column-badge"
|
<td className="list-cell list-cell-date">{formatDate(task.updatedAt)}</td>
|
||||||
style={{
|
)}
|
||||||
background: `${COLUMN_COLOR_MAP[task.column]}20`,
|
{visibleColumns.has("dependencies") && (
|
||||||
color: COLUMN_COLOR_MAP[task.column],
|
<td className="list-cell list-cell-deps">
|
||||||
}}
|
{task.dependencies && task.dependencies.length > 0 ? (
|
||||||
>
|
<span className="list-dep-badge" title={task.dependencies.join(", ")}>
|
||||||
{COLUMN_LABELS[task.column]}
|
<Link size={12} /> {task.dependencies.length}
|
||||||
</span>
|
</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">
|
</td>
|
||||||
{task.dependencies && task.dependencies.length > 0 ? (
|
)}
|
||||||
<span className="list-dep-badge" title={task.dependencies.join(", ")}>
|
{visibleColumns.has("progress") && (
|
||||||
<Link size={12} /> {task.dependencies.length}
|
<td className="list-cell list-cell-progress">
|
||||||
</span>
|
{task.steps.length > 0 ? (
|
||||||
) : (
|
<div className="list-progress">
|
||||||
"-"
|
<div className="list-progress-bar">
|
||||||
)}
|
<div
|
||||||
</td>
|
className="list-progress-fill"
|
||||||
<td className="list-cell list-cell-progress">
|
style={{
|
||||||
{task.steps.length > 0 ? (
|
width: `${getStepProgressPercent(task.steps)}%`,
|
||||||
<div className="list-progress">
|
backgroundColor: COLUMN_COLOR_MAP[task.column],
|
||||||
<div className="list-progress-bar">
|
}}
|
||||||
<div
|
/>
|
||||||
className="list-progress-fill"
|
</div>
|
||||||
style={{
|
<span className="list-progress-label">{getStepProgress(task.steps)}</span>
|
||||||
width: `${getStepProgressPercent(task.steps)}%`,
|
|
||||||
backgroundColor: COLUMN_COLOR_MAP[task.column],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="list-progress-label">{getStepProgress(task.steps)}</span>
|
) : (
|
||||||
</div>
|
"-"
|
||||||
) : (
|
)}
|
||||||
"-"
|
</td>
|
||||||
)}
|
)}
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -666,3 +666,190 @@ describe("ListView", () => {
|
|||||||
expect(dataRows[2].textContent).toContain("KB-003"); // Charlie
|
expect(dataRows[2].textContent).toContain("KB-003"); // Charlie
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ListView Column Visibility", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Clear localStorage before each test
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders column toggle button", () => {
|
||||||
|
renderListView();
|
||||||
|
|
||||||
|
const columnsButton = screen.getByRole("button", { name: /columns/i });
|
||||||
|
expect(columnsButton).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens column dropdown when toggle clicked", () => {
|
||||||
|
renderListView();
|
||||||
|
|
||||||
|
const columnsButton = screen.getByRole("button", { name: /columns/i });
|
||||||
|
fireEvent.click(columnsButton);
|
||||||
|
|
||||||
|
// Dropdown should be visible with checkboxes for each column
|
||||||
|
expect(screen.getByText("ID")).toBeDefined();
|
||||||
|
expect(screen.getByText("Title")).toBeDefined();
|
||||||
|
expect(screen.getByText("Status")).toBeDefined();
|
||||||
|
expect(screen.getByText("Column")).toBeDefined();
|
||||||
|
expect(screen.getByText("Created")).toBeDefined();
|
||||||
|
expect(screen.getByText("Updated")).toBeDefined();
|
||||||
|
expect(screen.getByText("Dependencies")).toBeDefined();
|
||||||
|
expect(screen.getByText("Progress")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides column when unchecked in dropdown", () => {
|
||||||
|
const tasks = [createMockTask({ id: "KB-001", title: "Test Task" })];
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
const columnsButton = screen.getByRole("button", { name: /columns/i });
|
||||||
|
fireEvent.click(columnsButton);
|
||||||
|
|
||||||
|
// Uncheck the Title column
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
const titleCheckbox = checkboxes.find(
|
||||||
|
cb => cb.parentElement?.textContent?.includes("Title")
|
||||||
|
);
|
||||||
|
expect(titleCheckbox).toBeDefined();
|
||||||
|
fireEvent.click(titleCheckbox!);
|
||||||
|
|
||||||
|
// Title column should no longer be visible in the table
|
||||||
|
const table = document.querySelector(".list-table");
|
||||||
|
expect(table?.textContent).not.toContain("Test Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows column when checked in dropdown", () => {
|
||||||
|
const tasks = [createMockTask({ id: "KB-001", title: "Test Task" })];
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
const columnsButton = screen.getByRole("button", { name: /columns/i });
|
||||||
|
fireEvent.click(columnsButton);
|
||||||
|
|
||||||
|
// Find and uncheck the Title column
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
const titleCheckbox = checkboxes.find(
|
||||||
|
cb => cb.parentElement?.textContent?.includes("Title")
|
||||||
|
);
|
||||||
|
expect(titleCheckbox).toBeDefined();
|
||||||
|
fireEvent.click(titleCheckbox!);
|
||||||
|
|
||||||
|
// Verify Title is hidden
|
||||||
|
const table = document.querySelector(".list-table");
|
||||||
|
expect(table?.textContent).not.toContain("Test Task");
|
||||||
|
|
||||||
|
// Re-check the Title column (still in the same dropdown session)
|
||||||
|
const titleCheckbox2 = screen.getAllByRole("checkbox").find(
|
||||||
|
cb => cb.parentElement?.textContent?.includes("Title")
|
||||||
|
);
|
||||||
|
expect(titleCheckbox2).toBeDefined();
|
||||||
|
fireEvent.click(titleCheckbox2!);
|
||||||
|
|
||||||
|
// Title column should be visible again
|
||||||
|
const tableAfter = document.querySelector(".list-table");
|
||||||
|
expect(tableAfter?.textContent).toContain("Test Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists column visibility to localStorage", () => {
|
||||||
|
const tasks = [createMockTask({ id: "KB-001", title: "Test Task" })];
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Open dropdown and uncheck Title
|
||||||
|
const columnsButton = screen.getByRole("button", { name: /columns/i });
|
||||||
|
fireEvent.click(columnsButton);
|
||||||
|
const titleCheckbox = screen.getByLabelText("Title");
|
||||||
|
fireEvent.click(titleCheckbox);
|
||||||
|
|
||||||
|
// Verify localStorage was updated
|
||||||
|
const saved = localStorage.getItem("kb-dashboard-list-columns");
|
||||||
|
expect(saved).toBeTruthy();
|
||||||
|
const parsed = JSON.parse(saved!);
|
||||||
|
expect(parsed).not.toContain("title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes column visibility from localStorage", () => {
|
||||||
|
// Set up localStorage with only ID and Status visible
|
||||||
|
localStorage.setItem("kb-dashboard-list-columns", JSON.stringify(["id", "status"]));
|
||||||
|
|
||||||
|
const tasks = [createMockTask({ id: "KB-001", title: "Test Task", status: "pending" })];
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// ID should be visible
|
||||||
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
|
|
||||||
|
// Title should NOT be visible (hidden by localStorage)
|
||||||
|
const table = document.querySelector(".list-table");
|
||||||
|
expect(table?.textContent).not.toContain("Test Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents hiding all columns (at least one stays visible)", () => {
|
||||||
|
renderListView();
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
const columnsButton = screen.getByRole("button", { name: /columns/i });
|
||||||
|
fireEvent.click(columnsButton);
|
||||||
|
|
||||||
|
// Get all checkboxes and try to uncheck all except one
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
|
||||||
|
// Uncheck all but one
|
||||||
|
for (let i = 0; i < checkboxes.length - 1; i++) {
|
||||||
|
if (checkboxes[i].checked) {
|
||||||
|
fireEvent.click(checkboxes[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last checkbox should be disabled (check the disabled property)
|
||||||
|
const lastCheckbox = checkboxes[checkboxes.length - 1];
|
||||||
|
if (lastCheckbox.checked) {
|
||||||
|
expect(lastCheckbox.disabled).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorting still works when some columns are hidden", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-003", column: "triage" }),
|
||||||
|
createMockTask({ id: "KB-001", column: "triage" }),
|
||||||
|
createMockTask({ id: "KB-002", column: "triage" }),
|
||||||
|
];
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// Hide some columns
|
||||||
|
const columnsButton = screen.getByRole("button", { name: /columns/i });
|
||||||
|
fireEvent.click(columnsButton);
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
const titleCheckbox = checkboxes.find(
|
||||||
|
cb => cb.parentElement?.textContent?.includes("Title")
|
||||||
|
);
|
||||||
|
expect(titleCheckbox).toBeDefined();
|
||||||
|
fireEvent.click(titleCheckbox!);
|
||||||
|
|
||||||
|
// Find and click ID header to sort (use getAllByText and find the header cell)
|
||||||
|
const idHeaders = screen.getAllByText("ID");
|
||||||
|
const idHeader = idHeaders.find(el => el.tagName === "TH" || el.closest("th"));
|
||||||
|
expect(idHeader).toBeDefined();
|
||||||
|
fireEvent.click(idHeader!);
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
expect(rows[1].textContent).toContain("KB-002");
|
||||||
|
expect(rows[2].textContent).toContain("KB-003");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all columns visible by default when no localStorage", () => {
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "KB-001", title: "Test Task", status: "pending", column: "triage" }),
|
||||||
|
];
|
||||||
|
renderListView({ tasks });
|
||||||
|
|
||||||
|
// All columns should be visible by default
|
||||||
|
expect(screen.getByText("KB-001")).toBeDefined();
|
||||||
|
expect(screen.getByText("Test Task")).toBeDefined();
|
||||||
|
expect(screen.getByText("pending")).toBeDefined();
|
||||||
|
// Check for column badge specifically using the class
|
||||||
|
const columnBadge = document.querySelector(".list-column-badge");
|
||||||
|
expect(columnBadge?.textContent).toContain("Triage");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2027,6 +2027,78 @@ body {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Column toggle dropdown */
|
||||||
|
.list-column-toggle {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-column-toggle .btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-column-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 6px 0;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: dropdown-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropdown-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-column-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-column-dropdown-item:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-column-dropdown-item.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-column-dropdown-item.disabled:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-column-dropdown-item input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: var(--todo);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-column-dropdown-item.disabled input[type="checkbox"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Drop zones for drag and drop */
|
/* Drop zones for drag and drop */
|
||||||
.list-drop-zones {
|
.list-drop-zones {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2376,6 +2448,11 @@ body {
|
|||||||
order: 1;
|
order: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-column-toggle {
|
||||||
|
order: 3;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.list-drop-zones {
|
.list-drop-zones {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
Reference in New Issue
Block a user