feat(KB-629): complete Step 5 — add ListView bulk selection tests and fix dependency order
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"]');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user