feat(FN-3223): add compact desktop sidebar controls for list view
The merge delivers three major feature clusters: hardened research lifecycle with idempotent cancel/retry and SSE support across the orchestrator and routes layer; unified header action clusters in AgentDetailView integrating planning mode disclosures; and a compact desktop sidebar for list view con Fusion-Task-Id: FN-3223
This commit is contained in:
@@ -16,10 +16,62 @@
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.list-stats {
|
||||
font-size: 12px;
|
||||
.list-sidebar-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.list-sidebar-controls__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.list-sidebar-summary-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.list-sidebar-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--border) 60%, transparent);
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
}
|
||||
|
||||
.list-view-options-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.list-toolbar-mobile-options {
|
||||
padding: 0 var(--space-md) var(--space-sm);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.list-view-options-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.list-stats {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Section expand/collapse controls */
|
||||
@@ -240,20 +292,21 @@
|
||||
.list-drop-zones {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.list-drop-zones--sidebar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.list-drop-zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
@@ -970,6 +1023,16 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.list-toolbar-mobile-options .list-drop-zones {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.list-toolbar-mobile-options .drop-zone-label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Toolbar stacking on mobile */
|
||||
.list-toolbar {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
|
||||
@@ -286,9 +286,7 @@ export function ListView({
|
||||
}
|
||||
}, [collapsedSections, projectId]);
|
||||
|
||||
// Column dropdown state
|
||||
const [columnDropdownOpen, setColumnDropdownOpen] = useState(false);
|
||||
const columnDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [viewOptionsOpen, setViewOptionsOpen] = useState(false);
|
||||
|
||||
// Selection state - initialize from localStorage
|
||||
const [bulkEditEnabled, setBulkEditEnabled] = useState(false);
|
||||
@@ -438,30 +436,6 @@ export function ListView({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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> = {
|
||||
@@ -959,148 +933,39 @@ export function ListView({
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="list-view">
|
||||
<div className="list-toolbar">
|
||||
<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>
|
||||
<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">
|
||||
{selectedColumn
|
||||
? `${filteredCount} of ${tasks.length} tasks in ${COLUMN_LABELS[selectedColumn]}`
|
||||
: `${filteredCount} of ${tasks.length} tasks`}
|
||||
{hiddenCompletedCount > 0 && !selectedColumn && (
|
||||
<span className="list-stats-hidden"> ({hiddenCompletedCount} hidden)</span>
|
||||
)}
|
||||
{selectedColumn && (
|
||||
<button
|
||||
className="btn btn-sm list-clear-column-filter-btn"
|
||||
onClick={clearColumnFilter}
|
||||
aria-label="Clear column filter"
|
||||
const renderViewOptionsPanel = (panelId: string) => (
|
||||
<div id={panelId} className="list-view-options-panel">
|
||||
<div className="list-view-options-columns">
|
||||
{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" : ""}`}
|
||||
title={isLastVisible ? "At least one column must be visible" : ""}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
|
||||
{bulkEditEnabled ? "Done Editing" : "Bulk Edit"}
|
||||
</button>
|
||||
{bulkEditEnabled && selectedTaskIds.size > 0 && (
|
||||
<div className="list-selection-stats">
|
||||
<span className="selection-count">{selectedTaskIds.size} selected</span>
|
||||
<button className="btn btn-sm btn-link" onClick={clearSelection}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Bulk Edit Toolbar */}
|
||||
{bulkEditEnabled && selectedTaskIds.size > 0 && availableModels && availableModels.length > 0 && (
|
||||
<div className="bulk-edit-toolbar">
|
||||
<span className="bulk-edit-label">Bulk Edit Models & Node:</span>
|
||||
<div className="bulk-edit-dropdown">
|
||||
<CustomModelDropdown
|
||||
models={availableModels}
|
||||
value={executorModel}
|
||||
onChange={setExecutorModel}
|
||||
label="Executor Model"
|
||||
noChangeValue="__no_change__"
|
||||
noChangeLabel="No change"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible}
|
||||
onChange={() => toggleColumn(column)}
|
||||
disabled={isLastVisible}
|
||||
/>
|
||||
</div>
|
||||
<div className="bulk-edit-dropdown">
|
||||
<CustomModelDropdown
|
||||
models={availableModels}
|
||||
value={validatorModel}
|
||||
onChange={setValidatorModel}
|
||||
label="Reviewer Model"
|
||||
noChangeValue="__no_change__"
|
||||
noChangeLabel="No change"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
<div className="bulk-edit-dropdown bulk-edit-node-wrap">
|
||||
<select
|
||||
className="select bulk-node-select"
|
||||
value={nodeOverride}
|
||||
onChange={(e) => setNodeOverride(e.target.value)}
|
||||
aria-label="Node Override"
|
||||
disabled={isLoadingNodes}
|
||||
>
|
||||
<option value="__no_change__">No change</option>
|
||||
<option value="">Use project default</option>
|
||||
{availableNodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{`${getNodeStatusSymbol(node.status)} ${node.name || node.id} (${getNodeStatusLabel(node.status)})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedOverrideNode ? <NodeHealthDot status={selectedOverrideNode.status} showLabel /> : null}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm bulk-edit-apply-btn"
|
||||
onClick={handleApplyBulkUpdate}
|
||||
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && nodeOverride === "__no_change__")}
|
||||
>
|
||||
{isApplying ? "Applying..." : "Apply"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{onNewTask ? (
|
||||
<button className="btn btn-task-create btn-sm" onClick={onNewTask}>
|
||||
+ New Task
|
||||
</button>
|
||||
) : null}
|
||||
<span>{COLUMN_LABELS_MAP[column]}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="list-drop-zones">
|
||||
<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-drop-zones list-drop-zones--sidebar">
|
||||
{COLUMNS.map((column) => {
|
||||
const totalCount = tasks.filter((t) => t.column === column).length;
|
||||
const isCompletedColumn = column === "done" || column === "archived";
|
||||
@@ -1117,7 +982,7 @@ export function ListView({
|
||||
onDrop={(e) => handleColumnDrop(e, column)}
|
||||
data-column={column}
|
||||
>
|
||||
<span className="drop-zone-dot" style={{ background: COLUMN_COLOR_MAP[column] }} />
|
||||
<span className={`list-section-dot dot-${column}`} />
|
||||
<span className="drop-zone-label">{COLUMN_LABELS[column]}</span>
|
||||
<span className="drop-zone-count">
|
||||
{showPartial ? `${visibleCount} of ${totalCount}` : totalCount}
|
||||
@@ -1126,6 +991,42 @@ export function ListView({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="list-view">
|
||||
{isMobile && (
|
||||
<>
|
||||
<div className="list-toolbar">
|
||||
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
|
||||
{bulkEditEnabled ? "Done Editing" : "Bulk Edit"}
|
||||
</button>
|
||||
{onNewTask ? (
|
||||
<button className="btn btn-task-create btn-sm" onClick={onNewTask}>
|
||||
+ New Task
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-sm list-view-options-toggle"
|
||||
onClick={() => setViewOptionsOpen((prev) => !prev)}
|
||||
aria-expanded={viewOptionsOpen}
|
||||
aria-controls="list-view-options-panel-mobile"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
View options
|
||||
</button>
|
||||
<div className="list-stats">
|
||||
{selectedColumn
|
||||
? `${filteredCount} of ${tasks.length} tasks in ${COLUMN_LABELS[selectedColumn]}`
|
||||
: `${filteredCount} of ${tasks.length} tasks`}
|
||||
</div>
|
||||
</div>
|
||||
{viewOptionsOpen ? (
|
||||
<div className="list-toolbar-mobile-options">{renderViewOptionsPanel("list-view-options-panel-mobile")}</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="list-table-container">
|
||||
<div className={isMobile ? "" : "list-split-layout"} data-testid={isMobile ? undefined : "list-split-layout"} ref={splitLayoutRef}>
|
||||
@@ -1135,6 +1036,114 @@ export function ListView({
|
||||
ref={splitSidebarRef}
|
||||
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
|
||||
>
|
||||
{!isMobile && (
|
||||
<aside className="list-sidebar-controls" aria-label="List controls">
|
||||
<div className="list-sidebar-controls__header">
|
||||
<p className="list-stats">
|
||||
{selectedColumn
|
||||
? `${filteredCount} of ${tasks.length} tasks in ${COLUMN_LABELS[selectedColumn]}`
|
||||
: `${filteredCount} of ${tasks.length} tasks`}
|
||||
{hiddenCompletedCount > 0 && !selectedColumn && (
|
||||
<span className="list-stats-hidden"> ({hiddenCompletedCount} hidden)</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="list-sidebar-controls__actions">
|
||||
{onNewTask ? (
|
||||
<button className="btn btn-task-create btn-sm" onClick={onNewTask}>
|
||||
+ New Task
|
||||
</button>
|
||||
) : null}
|
||||
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
|
||||
{bulkEditEnabled ? "Done Editing" : "Bulk Edit"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-sidebar-summary-chips">
|
||||
{selectedColumn ? (
|
||||
<button className="btn btn-sm" onClick={clearColumnFilter} aria-label="Clear column filter">
|
||||
{`Filter: ${COLUMN_LABELS[selectedColumn]}`}
|
||||
</button>
|
||||
) : null}
|
||||
{hideDoneTasks ? <span className="list-sidebar-chip">Done hidden</span> : null}
|
||||
{bulkEditEnabled ? (
|
||||
<span className="list-sidebar-chip">Bulk edit</span>
|
||||
) : null}
|
||||
{bulkEditEnabled && selectedTaskIds.size > 0 ? (
|
||||
<button className="btn btn-sm" onClick={clearSelection}>
|
||||
{`${selectedTaskIds.size} selected`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm list-view-options-toggle"
|
||||
onClick={() => setViewOptionsOpen((prev) => !prev)}
|
||||
aria-expanded={viewOptionsOpen}
|
||||
aria-controls="list-view-options-panel"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
View options
|
||||
</button>
|
||||
{viewOptionsOpen && renderViewOptionsPanel("list-view-options-panel")}
|
||||
{bulkEditEnabled && selectedTaskIds.size > 0 && availableModels && availableModels.length > 0 && (
|
||||
<div className="bulk-edit-toolbar">
|
||||
<span className="bulk-edit-label">Bulk Edit Models & Node:</span>
|
||||
<div className="bulk-edit-dropdown">
|
||||
<CustomModelDropdown
|
||||
models={availableModels}
|
||||
value={executorModel}
|
||||
onChange={setExecutorModel}
|
||||
label="Executor Model"
|
||||
noChangeValue="__no_change__"
|
||||
noChangeLabel="No change"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
<div className="bulk-edit-dropdown">
|
||||
<CustomModelDropdown
|
||||
models={availableModels}
|
||||
value={validatorModel}
|
||||
onChange={setValidatorModel}
|
||||
label="Reviewer Model"
|
||||
noChangeValue="__no_change__"
|
||||
noChangeLabel="No change"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
<div className="bulk-edit-dropdown bulk-edit-node-wrap">
|
||||
<select
|
||||
className="select bulk-node-select"
|
||||
value={nodeOverride}
|
||||
onChange={(e) => setNodeOverride(e.target.value)}
|
||||
aria-label="Node Override"
|
||||
disabled={isLoadingNodes}
|
||||
>
|
||||
<option value="__no_change__">No change</option>
|
||||
<option value="">Use project default</option>
|
||||
{availableNodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{`${getNodeStatusSymbol(node.status)} ${node.name || node.id} (${getNodeStatusLabel(node.status)})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedOverrideNode ? <NodeHealthDot status={selectedOverrideNode.status} showLabel /> : null}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm bulk-edit-apply-btn"
|
||||
onClick={handleApplyBulkUpdate}
|
||||
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && nodeOverride === "__no_change__")}
|
||||
>
|
||||
{isApplying ? "Applying..." : "Apply"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
<div className="list-quick-entry-above-table">
|
||||
<QuickEntryBox
|
||||
onCreate={onQuickCreate ?? (async () => addToast("Task creation not available", "error"))}
|
||||
|
||||
@@ -50,7 +50,10 @@ const createMockTask = (overrides: Partial<Task> = {}): Task => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderListView = (props: Partial<React.ComponentProps<typeof ListView>> = {}) => {
|
||||
const renderListView = (
|
||||
props: Partial<React.ComponentProps<typeof ListView>> = {},
|
||||
options: { openViewOptions?: boolean } = {},
|
||||
) => {
|
||||
const defaultProps = {
|
||||
tasks: [],
|
||||
onMoveTask: vi.fn(async () => createMockTask()),
|
||||
@@ -66,7 +69,14 @@ const renderListView = (props: Partial<React.ComponentProps<typeof ListView>> =
|
||||
projectId: TEST_PROJECT_ID,
|
||||
};
|
||||
|
||||
return render(<ListView {...defaultProps} {...props} />);
|
||||
const result = render(<ListView {...defaultProps} {...props} />);
|
||||
if (options.openViewOptions ?? true) {
|
||||
const viewOptionsToggle = screen.queryByRole("button", { name: /view options/i });
|
||||
if (viewOptionsToggle) {
|
||||
fireEvent.click(viewOptionsToggle);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const enterBulkEditMode = () => {
|
||||
@@ -144,7 +154,28 @@ describe("ListView", () => {
|
||||
it("renders without crashing", () => {
|
||||
renderListView();
|
||||
// The search/filter is now in the header, not in the list view toolbar
|
||||
expect(screen.getByText("Columns")).toBeDefined();
|
||||
expect(screen.getByText("View options")).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps view options collapsed by default on desktop", () => {
|
||||
renderListView({}, { openViewOptions: false });
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /view options/i });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(document.getElementById("list-view-options-panel")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows compact summary chips while view options stay collapsed", () => {
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-hide-done"), "true");
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "done" }),
|
||||
createMockTask({ id: "FN-002", column: "triage" }),
|
||||
];
|
||||
|
||||
renderListView({ tasks }, { openViewOptions: false });
|
||||
|
||||
expect(screen.getByText("Done hidden")).toBeDefined();
|
||||
expect(document.getElementById("list-view-options-panel")).toBeNull();
|
||||
});
|
||||
|
||||
it("displays tasks in table format", () => {
|
||||
@@ -285,6 +316,25 @@ describe("ListView", () => {
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("exposes view options controls on mobile", () => {
|
||||
const viewportSpy = mockMobileViewport();
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-hide-done"), "false");
|
||||
|
||||
renderListView({}, { openViewOptions: false });
|
||||
|
||||
const toggle = screen.getByRole("button", { name: /view options/i });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(document.getElementById("list-view-options-panel-mobile")).toBeNull();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(screen.getByLabelText("Title")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /hide done/i }));
|
||||
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-hide-done"))).toBe("true");
|
||||
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps embedded selection visible when filters hide the selected row", async () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const tasks = [
|
||||
@@ -473,7 +523,7 @@ describe("ListView", () => {
|
||||
renderListView({ tasks });
|
||||
|
||||
// First click - ascending
|
||||
const titleHeader = screen.getByText("Title");
|
||||
const titleHeader = screen.getByRole("columnheader", { name: /title/i });
|
||||
fireEvent.click(titleHeader);
|
||||
|
||||
// Get all data rows (excluding section headers by using data-id attribute)
|
||||
@@ -500,7 +550,7 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
const columnHeader = screen.getByText("Column");
|
||||
const columnHeader = screen.getByRole("columnheader", { name: /column/i });
|
||||
fireEvent.click(columnHeader);
|
||||
|
||||
// Rows are rendered in fixed column-section order.
|
||||
@@ -519,7 +569,7 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
const statusHeader = screen.getByText("Status");
|
||||
const statusHeader = screen.getByRole("columnheader", { name: /status/i });
|
||||
fireEvent.click(statusHeader);
|
||||
|
||||
// Get data rows - sorted by status alphabetically
|
||||
@@ -1088,7 +1138,7 @@ describe("ListView", () => {
|
||||
renderListView({ tasks });
|
||||
|
||||
// Sort by title
|
||||
const titleHeader = screen.getByText("Title");
|
||||
const titleHeader = screen.getByRole("columnheader", { name: /title/i });
|
||||
fireEvent.click(titleHeader);
|
||||
|
||||
// Get only data rows within the triage section
|
||||
@@ -1281,36 +1331,31 @@ describe("ListView Column Visibility", () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders column toggle button", () => {
|
||||
it("renders view options toggle button", () => {
|
||||
renderListView();
|
||||
|
||||
const columnsButton = screen.getByRole("button", { name: /columns/i });
|
||||
const columnsButton = screen.getByRole("button", { name: /view options/i });
|
||||
expect(columnsButton).toBeDefined();
|
||||
});
|
||||
|
||||
it("opens column dropdown when toggle clicked", () => {
|
||||
renderListView();
|
||||
renderListView({}, { openViewOptions: false });
|
||||
|
||||
const columnsButton = screen.getByRole("button", { name: /columns/i });
|
||||
const columnsButton = screen.getByRole("button", { name: /view options/i });
|
||||
fireEvent.click(columnsButton);
|
||||
|
||||
// Dropdown should be visible with checkboxes for each column
|
||||
expect(columnsButton).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.queryByText("ID")).toBeNull();
|
||||
expect(screen.getByText("Title")).toBeDefined();
|
||||
expect(screen.getByText("Status")).toBeDefined();
|
||||
expect(screen.getByText("Column")).toBeDefined();
|
||||
expect(screen.getByText("Dependencies")).toBeDefined();
|
||||
expect(screen.getByText("Progress")).toBeDefined();
|
||||
expect(screen.getByLabelText("Title")).toBeDefined();
|
||||
expect(screen.getByLabelText("Status")).toBeDefined();
|
||||
expect(screen.getByLabelText("Column")).toBeDefined();
|
||||
expect(screen.getByLabelText("Dependencies")).toBeDefined();
|
||||
expect(screen.getByLabelText("Progress")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides column when unchecked in dropdown", () => {
|
||||
const tasks = [createMockTask({ id: "FN-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(
|
||||
@@ -1328,10 +1373,6 @@ describe("ListView Column Visibility", () => {
|
||||
const tasks = [createMockTask({ id: "FN-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(
|
||||
@@ -1360,9 +1401,7 @@ describe("ListView Column Visibility", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })];
|
||||
renderListView({ tasks });
|
||||
|
||||
// Open dropdown and uncheck Title
|
||||
const columnsButton = screen.getByRole("button", { name: /columns/i });
|
||||
fireEvent.click(columnsButton);
|
||||
// Uncheck Title
|
||||
const titleCheckbox = screen.getByLabelText("Title");
|
||||
fireEvent.click(titleCheckbox);
|
||||
|
||||
@@ -1389,9 +1428,7 @@ describe("ListView Column Visibility", () => {
|
||||
it("prevents hiding all columns (at least one stays visible)", () => {
|
||||
renderListView();
|
||||
|
||||
// Open dropdown
|
||||
const columnsButton = screen.getByRole("button", { name: /columns/i });
|
||||
fireEvent.click(columnsButton);
|
||||
// View options panel already open
|
||||
|
||||
// Get all checkboxes and try to uncheck all except one
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
@@ -1419,8 +1456,6 @@ describe("ListView Column Visibility", () => {
|
||||
renderListView({ tasks });
|
||||
|
||||
// Hide some columns
|
||||
const columnsButton = screen.getByRole("button", { name: /columns/i });
|
||||
fireEvent.click(columnsButton);
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
const columnCheckbox = checkboxes.find(
|
||||
cb => cb.parentElement?.textContent?.includes("Column")
|
||||
@@ -1786,7 +1821,7 @@ describe("ListView Quick Entry", () => {
|
||||
const tableContainer = document.querySelector(".list-table-container");
|
||||
|
||||
// QuickEntryBox should not be inside toolbar
|
||||
expect(toolbar?.contains(quickEntry)).toBe(false);
|
||||
expect(toolbar?.contains(quickEntry)).not.toBe(true);
|
||||
// QuickEntryBox should be inside the new quick-entry area
|
||||
expect(quickEntryArea?.contains(quickEntry)).toBe(true);
|
||||
// QuickEntryBox should be inside the table container (parent of quick-entry area)
|
||||
@@ -2095,7 +2130,7 @@ describe("ListView Collapsible Sections", () => {
|
||||
fireEvent.click(triageHeader!);
|
||||
|
||||
// Sort by title
|
||||
const titleHeader = screen.getByText("Title");
|
||||
const titleHeader = screen.getByRole("columnheader", { name: /title/i });
|
||||
fireEvent.click(titleHeader);
|
||||
|
||||
// Get sorted rows and verify sorting still works
|
||||
@@ -2256,7 +2291,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
fireEvent.click(checkbox);
|
||||
expect(screen.getByText("1 selected")).toBeDefined();
|
||||
|
||||
const clearButton = screen.getByText("Clear");
|
||||
const clearButton = screen.getByRole("button", { name: /selected/i });
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(screen.queryByText("1 selected")).toBeNull();
|
||||
@@ -2273,7 +2308,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
const selectAllCheckbox = screen.getByLabelText("Select all visible tasks");
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
|
||||
expect(screen.getByText("2 selected")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: /selected/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts favoriteProviders and favoriteModels props", () => {
|
||||
@@ -2786,8 +2821,8 @@ describe("ListView - Bulk Selection", () => {
|
||||
enterBulkEditMode();
|
||||
fireEvent.click(screen.getByLabelText("Select FN-002"));
|
||||
|
||||
expect(screen.getByText("2 selected")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bulk Edit Models & Node:")).toBeInTheDocument();
|
||||
expect((screen.getByLabelText("Select FN-001") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Select FN-002") as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("applies agent-active class to mobile cards when task is in-progress and not paused/failed", () => {
|
||||
|
||||
Reference in New Issue
Block a user