feat(KB-629): complete Step 5 — add ListView bulk selection tests and fix dependency order

This commit is contained in:
gsxdsm
2026-03-31 18:19:05 -07:00
parent e2ed25d54b
commit 173939e182
2 changed files with 283 additions and 132 deletions

View File

@@ -202,141 +202,11 @@ export function ListView({
});
}, []);
// Toggle all visible tasks
const toggleSelectAll = useCallback(() => {
const visibleTaskIds = Object.values(groupedTasks)
.flat()
.filter((t) => t.column !== "archived") // Can't bulk edit archived
.map((t) => t.id);
setSelectedTaskIds((prev) => {
const allSelected = visibleTaskIds.every((id) => prev.has(id));
if (allSelected) {
// Deselect all visible
const next = new Set(prev);
visibleTaskIds.forEach((id) => next.delete(id));
return next;
} else {
// Select all visible
return new Set([...prev, ...visibleTaskIds]);
}
});
}, [groupedTasks]);
// Clear selection
const clearSelection = useCallback(() => {
setSelectedTaskIds(new Set());
}, []);
// Check if all visible tasks are selected
const isSelectAll = useMemo(() => {
const visibleTaskIds = Object.values(groupedTasks)
.flat()
.filter((t) => t.column !== "archived");
if (visibleTaskIds.length === 0) return false;
return visibleTaskIds.every((t) => selectedTaskIds.has(t.id));
}, [groupedTasks, selectedTaskIds]);
// Check if some (but not all) visible tasks are selected
const isSelectIndeterminate = useMemo(() => {
const visibleTaskIds = Object.values(groupedTasks)
.flat()
.filter((t) => t.column !== "archived");
if (visibleTaskIds.length === 0) return false;
const selectedCount = visibleTaskIds.filter((t) => selectedTaskIds.has(t.id)).length;
return selectedCount > 0 && selectedCount < visibleTaskIds.length;
}, [groupedTasks, selectedTaskIds]);
// Bulk edit state
const [executorModel, setExecutorModel] = useState<string>("__no_change__");
const [validatorModel, setValidatorModel] = useState<string>("__no_change__");
const [isApplying, setIsApplying] = useState(false);
// Handle apply bulk model update
const handleApplyBulkUpdate = useCallback(async () => {
if (selectedTaskIds.size === 0) return;
const taskIds = Array.from(selectedTaskIds).filter((id) => {
const task = tasks.find((t) => t.id === id);
return task && task.column !== "archived";
});
if (taskIds.length === 0) {
addToast("No valid tasks to update (archived tasks cannot be modified)", "error");
return;
}
// Build payload - only include fields that changed from "__no_change__"
const payload: {
taskIds: string[];
modelProvider?: string | null;
modelId?: string | null;
validatorModelProvider?: string | null;
validatorModelId?: string | null;
} = { taskIds };
if (executorModel !== "__no_change__") {
if (executorModel === "") {
// "Use default" - clear override
payload.modelProvider = null;
payload.modelId = null;
} else {
const slashIdx = executorModel.indexOf("/");
if (slashIdx !== -1) {
payload.modelProvider = executorModel.slice(0, slashIdx);
payload.modelId = executorModel.slice(slashIdx + 1);
}
}
}
if (validatorModel !== "__no_change__") {
if (validatorModel === "") {
// "Use default" - clear override
payload.validatorModelProvider = null;
payload.validatorModelId = null;
} else {
const slashIdx = validatorModel.indexOf("/");
if (slashIdx !== -1) {
payload.validatorModelProvider = validatorModel.slice(0, slashIdx);
payload.validatorModelId = validatorModel.slice(slashIdx + 1);
}
}
}
// Check if any changes were made
if (Object.keys(payload).length === 1) {
addToast("No changes to apply", "info");
return;
}
setIsApplying(true);
try {
const result = await batchUpdateTaskModels(
payload.taskIds,
payload.modelProvider,
payload.modelId,
payload.validatorModelProvider,
payload.validatorModelId,
);
// Optimistically update parent with returned tasks
if (onTasksUpdated && result.updated.length > 0) {
onTasksUpdated(result.updated);
}
addToast(`Updated ${result.count} task${result.count === 1 ? "" : "s"}`, "success");
// Reset state
clearSelection();
setExecutorModel("__no_change__");
setValidatorModel("__no_change__");
} catch (err: any) {
addToast(err.message || "Failed to update models", "error");
} finally {
setIsApplying(false);
}
}, [selectedTaskIds, tasks, executorModel, validatorModel, addToast, clearSelection, onTasksUpdated]);
// Toggle a column's visibility
const toggleColumn = useCallback((column: ListColumn) => {
setVisibleColumns((prev) => {
@@ -486,6 +356,138 @@ export function ListView({
if (!hideDoneTasks) return 0;
return completedTaskCount;
}, [hideDoneTasks, completedTaskCount]);
// Selection logic that depends on groupedTasks (must be after groupedTasks definition)
// Toggle all visible tasks
const toggleSelectAll = useCallback(() => {
const visibleTaskIds = Object.values(groupedTasks)
.flat()
.filter((t) => t.column !== "archived") // Can't bulk edit archived
.map((t) => t.id);
setSelectedTaskIds((prev) => {
const allSelected = visibleTaskIds.every((id) => prev.has(id));
if (allSelected) {
// Deselect all visible
const next = new Set(prev);
visibleTaskIds.forEach((id) => next.delete(id));
return next;
} else {
// Select all visible
return new Set([...prev, ...visibleTaskIds]);
}
});
}, [groupedTasks]);
// Check if all visible tasks are selected
const isSelectAll = useMemo(() => {
const visibleTaskIds = Object.values(groupedTasks)
.flat()
.filter((t) => t.column !== "archived");
if (visibleTaskIds.length === 0) return false;
return visibleTaskIds.every((t) => selectedTaskIds.has(t.id));
}, [groupedTasks, selectedTaskIds]);
// Check if some (but not all) visible tasks are selected
const isSelectIndeterminate = useMemo(() => {
const visibleTaskIds = Object.values(groupedTasks)
.flat()
.filter((t) => t.column !== "archived");
if (visibleTaskIds.length === 0) return false;
const selectedCount = visibleTaskIds.filter((t) => selectedTaskIds.has(t.id)).length;
return selectedCount > 0 && selectedCount < visibleTaskIds.length;
}, [groupedTasks, selectedTaskIds]);
// Bulk edit state and handlers (must be after groupedTasks and clearSelection definition)
const [executorModel, setExecutorModel] = useState<string>("__no_change__");
const [validatorModel, setValidatorModel] = useState<string>("__no_change__");
const [isApplying, setIsApplying] = useState(false);
// Handle apply bulk model update
const handleApplyBulkUpdate = useCallback(async () => {
if (selectedTaskIds.size === 0) return;
const taskIds = Array.from(selectedTaskIds).filter((id) => {
const task = tasks.find((t) => t.id === id);
return task && task.column !== "archived";
});
if (taskIds.length === 0) {
addToast("No valid tasks to update (archived tasks cannot be modified)", "error");
return;
}
// Build payload - only include fields that changed from "__no_change__"
const payload: {
taskIds: string[];
modelProvider?: string | null;
modelId?: string | null;
validatorModelProvider?: string | null;
validatorModelId?: string | null;
} = { taskIds };
if (executorModel !== "__no_change__") {
if (executorModel === "") {
// "Use default" - clear override
payload.modelProvider = null;
payload.modelId = null;
} else {
const slashIdx = executorModel.indexOf("/");
if (slashIdx !== -1) {
payload.modelProvider = executorModel.slice(0, slashIdx);
payload.modelId = executorModel.slice(slashIdx + 1);
}
}
}
if (validatorModel !== "__no_change__") {
if (validatorModel === "") {
// "Use default" - clear override
payload.validatorModelProvider = null;
payload.validatorModelId = null;
} else {
const slashIdx = validatorModel.indexOf("/");
if (slashIdx !== -1) {
payload.validatorModelProvider = validatorModel.slice(0, slashIdx);
payload.validatorModelId = validatorModel.slice(slashIdx + 1);
}
}
}
// Check if any changes were made
if (Object.keys(payload).length === 1) {
addToast("No changes to apply", "info");
return;
}
setIsApplying(true);
try {
const result = await batchUpdateTaskModels(
payload.taskIds,
payload.modelProvider,
payload.modelId,
payload.validatorModelProvider,
payload.validatorModelId,
);
// Optimistically update parent with returned tasks
if (onTasksUpdated && result.updated.length > 0) {
onTasksUpdated(result.updated);
}
addToast(`Updated ${result.count} task${result.count === 1 ? "" : "s"}`, "success");
// Reset state
clearSelection();
setExecutorModel("__no_change__");
setValidatorModel("__no_change__");
} catch (err: any) {
addToast(err.message || "Failed to update models", "error");
} finally {
setIsApplying(false);
}
}, [selectedTaskIds, tasks, executorModel, validatorModel, addToast, clearSelection, onTasksUpdated]);
const handleRowClick = useCallback(
async (task: Task) => {
try {

View File

@@ -378,8 +378,8 @@ describe("ListView", () => {
renderListView({ tasks });
const depCells = screen.getAllByRole("cell");
// Find the cell that should contain deps (5th column, index 4)
const depCell = depCells[4];
// Find the cell that should contain deps (6th column, index 5 - after checkbox column)
const depCell = depCells[5];
expect(depCell.textContent).toBe("-");
});
@@ -1716,3 +1716,152 @@ describe("ListView Collapsible Sections", () => {
// class is the primary indicator that the section is collapsed)
});
});
describe("ListView - Bulk Selection", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
const createMockTask = (overrides: Partial<Task> = {}): Task => ({
id: "KB-001",
description: "Test task description",
title: "Test Task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
status: "pending",
paused: false,
log: [],
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
...overrides,
});
it("shows selection checkbox in header", () => {
const tasks = [createMockTask({ id: "KB-001" })];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
const headerCheckbox = screen.getByLabelText("Select all visible tasks");
expect(headerCheckbox).toBeDefined();
});
it("shows selection checkbox for each task row", () => {
const tasks = [
createMockTask({ id: "KB-001" }),
createMockTask({ id: "KB-002" }),
];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
const checkboxes = screen.getAllByLabelText(/Select KB-/);
expect(checkboxes).toHaveLength(2);
});
it("disables checkbox for archived tasks", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "archived" }),
];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
const checkbox = screen.getByLabelText("Select KB-001");
expect(checkbox).toBeDisabled();
});
it("shows selection count when tasks are selected", () => {
const tasks = [
createMockTask({ id: "KB-001" }),
createMockTask({ id: "KB-002" }),
];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
const checkbox = screen.getByLabelText("Select KB-001");
fireEvent.click(checkbox);
expect(screen.getByText("1 selected")).toBeDefined();
});
it("clears selection when clear button clicked", () => {
const tasks = [
createMockTask({ id: "KB-001" }),
];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
const checkbox = screen.getByLabelText("Select KB-001");
fireEvent.click(checkbox);
expect(screen.getByText("1 selected")).toBeDefined();
const clearButton = screen.getByText("Clear");
fireEvent.click(clearButton);
expect(screen.queryByText("1 selected")).toBeNull();
});
it("toggles all visible tasks with select all checkbox", () => {
const tasks = [
createMockTask({ id: "KB-001" }),
createMockTask({ id: "KB-002" }),
];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
const selectAllCheckbox = screen.getByLabelText("Select all visible tasks");
fireEvent.click(selectAllCheckbox);
expect(screen.getByText("2 selected")).toBeDefined();
});
it("shows bulk edit toolbar when tasks are selected", () => {
const availableModels = [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
const tasks = [createMockTask({ id: "KB-001" })];
render(
<ListView
tasks={tasks}
onMoveTask={vi.fn()}
onOpenDetail={vi.fn()}
addToast={mockAddToast}
availableModels={availableModels}
/>
);
const checkbox = screen.getByLabelText("Select KB-001");
fireEvent.click(checkbox);
expect(screen.getByText("Bulk Edit Models:")).toBeDefined();
});
it("disables apply button when no model changes selected", () => {
const availableModels = [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
const tasks = [createMockTask({ id: "KB-001" })];
render(
<ListView
tasks={tasks}
onMoveTask={vi.fn()}
onOpenDetail={vi.fn()}
addToast={mockAddToast}
availableModels={availableModels}
/>
);
const checkbox = screen.getByLabelText("Select KB-001");
fireEvent.click(checkbox);
const applyButton = screen.getByText("Apply");
expect(applyButton).toBeDisabled();
});
it("persists selection to localStorage", () => {
const tasks = [createMockTask({ id: "KB-001" })];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
const checkbox = screen.getByLabelText("Select KB-001");
fireEvent.click(checkbox);
expect(localStorage.getItem("kb-dashboard-selected-tasks")).toBe('["KB-001"]');
});
});