feat: per-status selection with toolbar buttons and column checkboxes (fixes #24)

- Add toggleGroup() to useBulkActions for toggling groups of IDs
- Add status filter buttons (Todo/In Progress/Blocked/Done) to BulkActionsBar
- Buttons are toggleable: click Done then Blocked to select both groups
- Visual states: filled (all selected), dimmed (partial), outline (none)
- Add column header checkboxes with tri-state (checked/indeterminate/unchecked)
- Empty columns hide their checkbox
- Status buttons hide when column has 0 tasks
This commit is contained in:
Brad Groux 2026-01-31 00:22:58 -06:00
parent 287ac80b3f
commit 762f7fa23a
6 changed files with 167 additions and 54 deletions

View file

@ -73,7 +73,9 @@ vi.mock('@/hooks/useBulkActions', () => ({
useBulkActions: () => ({
isSelecting: false,
toggleSelect: vi.fn(),
toggleGroup: vi.fn(),
isSelected: () => false,
selectedIds: new Set(),
}),
}));

View file

@ -31,7 +31,9 @@ vi.mock('@/hooks/useBulkActions', () => ({
useBulkActions: () => ({
isSelecting: false,
toggleSelect: vi.fn(),
toggleGroup: vi.fn(),
isSelected: () => false,
selectedIds: new Set(),
}),
}));

View file

@ -1,12 +1,7 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { X, Trash2, Archive, ArrowRight, CheckSquare } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
@ -19,28 +14,68 @@ import {
} from '@/components/ui/alert-dialog';
import { useBulkActions } from '@/hooks/useBulkActions';
import { useUpdateTask, useDeleteTask, useArchiveTask } from '@/hooks/useTasks';
import type { TaskStatus } from '@veritas-kanban/shared';
import { cn } from '@/lib/utils';
import type { Task, TaskStatus } from '@veritas-kanban/shared';
const STATUS_BUTTONS: { id: TaskStatus; label: string; color: string; activeColor: string }[] = [
{
id: 'todo',
label: 'Todo',
color: 'border-slate-400 text-slate-600',
activeColor: 'bg-slate-500 text-white border-slate-500',
},
{
id: 'in-progress',
label: 'In Progress',
color: 'border-blue-400 text-blue-600',
activeColor: 'bg-blue-500 text-white border-blue-500',
},
{
id: 'blocked',
label: 'Blocked',
color: 'border-red-400 text-red-600',
activeColor: 'bg-red-500 text-white border-red-500',
},
{
id: 'done',
label: 'Done',
color: 'border-green-400 text-green-600',
activeColor: 'bg-green-500 text-white border-green-500',
},
];
interface BulkActionsBarProps {
allTaskIds: string[];
tasks: Task[];
}
export function BulkActionsBar({ allTaskIds }: BulkActionsBarProps) {
const {
selectedIds,
isSelecting,
toggleSelecting,
selectAll,
clearSelection,
} = useBulkActions();
export function BulkActionsBar({ tasks }: BulkActionsBarProps) {
const { selectedIds, isSelecting, toggleSelecting, selectAll, toggleGroup, clearSelection } =
useBulkActions();
const updateTask = useUpdateTask();
const deleteTask = useDeleteTask();
const archiveTask = useArchiveTask();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// Group task IDs by status
const taskIdsByStatus = useMemo(() => {
const map: Record<TaskStatus, string[]> = {
todo: [],
'in-progress': [],
blocked: [],
done: [],
};
for (const task of tasks) {
if (map[task.status]) {
map[task.status].push(task.id);
}
}
return map;
}, [tasks]);
const allTaskIds = useMemo(() => tasks.map((t) => t.id), [tasks]);
const selectedCount = selectedIds.size;
const allSelected = selectedCount === allTaskIds.length && allTaskIds.length > 0;
@ -52,13 +87,26 @@ export function BulkActionsBar({ allTaskIds }: BulkActionsBarProps) {
}
};
/** Check if all tasks of a given status are selected */
const isStatusFullySelected = (status: TaskStatus): boolean => {
const ids = taskIdsByStatus[status];
return ids.length > 0 && ids.every((id) => selectedIds.has(id));
};
/** Check if some (but not all) tasks of a given status are selected */
const isStatusPartiallySelected = (status: TaskStatus): boolean => {
const ids = taskIdsByStatus[status];
if (ids.length === 0) return false;
const someSelected = ids.some((id) => selectedIds.has(id));
const allSelectedInGroup = ids.every((id) => selectedIds.has(id));
return someSelected && !allSelectedInGroup;
};
const handleMoveToStatus = async (status: TaskStatus) => {
setIsProcessing(true);
try {
await Promise.all(
Array.from(selectedIds).map(id =>
updateTask.mutateAsync({ id, input: { status } })
)
Array.from(selectedIds).map((id) => updateTask.mutateAsync({ id, input: { status } }))
);
clearSelection();
} finally {
@ -69,9 +117,7 @@ export function BulkActionsBar({ allTaskIds }: BulkActionsBarProps) {
const handleArchiveSelected = async () => {
setIsProcessing(true);
try {
await Promise.all(
Array.from(selectedIds).map(id => archiveTask.mutateAsync(id))
);
await Promise.all(Array.from(selectedIds).map((id) => archiveTask.mutateAsync(id)));
clearSelection();
} finally {
setIsProcessing(false);
@ -81,9 +127,7 @@ export function BulkActionsBar({ allTaskIds }: BulkActionsBarProps) {
const handleDeleteSelected = async () => {
setIsProcessing(true);
try {
await Promise.all(
Array.from(selectedIds).map(id => deleteTask.mutateAsync(id))
);
await Promise.all(Array.from(selectedIds).map((id) => deleteTask.mutateAsync(id)));
clearSelection();
} finally {
setIsProcessing(false);
@ -109,16 +153,16 @@ export function BulkActionsBar({ allTaskIds }: BulkActionsBarProps) {
return (
<>
<div className="flex items-center justify-between gap-4 mb-4 p-3 rounded-lg bg-muted/50 border" role="toolbar" aria-label="Bulk actions">
<div
className="flex items-center justify-between gap-4 mb-4 p-3 rounded-lg bg-muted/50 border"
role="toolbar"
aria-label="Bulk actions"
>
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={toggleSelecting}
>
<Button variant="ghost" size="sm" onClick={toggleSelecting}>
<X className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
@ -127,10 +171,34 @@ export function BulkActionsBar({ allTaskIds }: BulkActionsBarProps) {
>
{allSelected ? 'Deselect All' : 'Select All'}
</Button>
<span className="text-sm text-muted-foreground">
{selectedCount} selected
</span>
{/* Status filter buttons */}
<div className="flex items-center gap-1.5 ml-1">
{STATUS_BUTTONS.map(({ id, label, color, activeColor }) => {
const count = taskIdsByStatus[id].length;
if (count === 0) return null;
const fullySelected = isStatusFullySelected(id);
const partiallySelected = isStatusPartiallySelected(id);
return (
<Button
key={id}
variant="outline"
size="sm"
onClick={() => toggleGroup(taskIdsByStatus[id])}
className={cn(
'text-xs h-7 px-2 border transition-colors',
fullySelected ? activeColor : partiallySelected ? `${color} opacity-70` : color
)}
aria-label={`Select all ${label} tasks (${count})`}
aria-pressed={fullySelected}
>
{label} ({count})
</Button>
);
})}
</div>
<span className="text-sm text-muted-foreground ml-1">{selectedCount} selected</span>
</div>
{selectedCount > 0 && (
@ -184,7 +252,9 @@ export function BulkActionsBar({ allTaskIds }: BulkActionsBarProps) {
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedCount} task{selectedCount !== 1 ? 's' : ''}?</AlertDialogTitle>
<AlertDialogTitle>
Delete {selectedCount} task{selectedCount !== 1 ? 's' : ''}?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The selected tasks will be permanently deleted.
</AlertDialogDescription>

View file

@ -182,7 +182,7 @@ export function KanbanBoard() {
<>
<FilterBar tasks={tasks || []} filters={filters} onFiltersChange={setFilters} />
<BulkActionsBar allTaskIds={filteredTasks.map((t) => t.id)} />
<BulkActionsBar tasks={filteredTasks} />
{featureSettings.board.showArchiveSuggestions && <ArchiveSuggestionBanner />}

View file

@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { TaskCard } from '@/components/task/TaskCard';
import { isTaskBlocked, getTaskBlockers } from '@/hooks/useTasks';
import { useBulkTaskMetrics } from '@/hooks/useBulkTaskMetrics';
import { useBulkActions } from '@/hooks/useBulkActions';
import { useFeatureSettings } from '@/hooks/useFeatureSettings';
import { ErrorBoundary } from '@/components/shared/ErrorBoundary';
import type { Task, TaskStatus } from '@veritas-kanban/shared';
@ -37,6 +38,7 @@ export function KanbanColumn({
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
const { settings: featureSettings } = useFeatureSettings();
const { isSelecting, selectedIds, toggleGroup } = useBulkActions();
const showDoneMetrics = featureSettings.board.showDoneMetrics;
// Get task IDs for done column to fetch bulk metrics
@ -48,6 +50,13 @@ export function KanbanColumn({
// Fetch bulk metrics only for done column
const { data: metricsMap } = useBulkTaskMetrics(doneTaskIds, id === 'done' && showDoneMetrics);
// Column selection state
const columnTaskIds = useMemo(() => tasks.map((t) => t.id), [tasks]);
const allColumnSelected =
columnTaskIds.length > 0 && columnTaskIds.every((tid) => selectedIds.has(tid));
const someColumnSelected =
!allColumnSelected && columnTaskIds.some((tid) => selectedIds.has(tid));
return (
<div
ref={setNodeRef}
@ -61,9 +70,23 @@ export function KanbanColumn({
)}
>
<div className="flex items-center justify-between px-3 py-2">
<h2 id={`column-heading-${id}`} className="text-sm font-medium text-muted-foreground">
{title}
</h2>
<div className="flex items-center gap-2">
{isSelecting && tasks.length > 0 && (
<input
type="checkbox"
checked={allColumnSelected}
ref={(el) => {
if (el) el.indeterminate = someColumnSelected;
}}
onChange={() => toggleGroup(columnTaskIds)}
className="h-3.5 w-3.5 rounded border-muted-foreground/50 cursor-pointer accent-primary"
aria-label={`Select all ${title} tasks`}
/>
)}
<h2 id={`column-heading-${id}`} className="text-sm font-medium text-muted-foreground">
{title}
</h2>
</div>
<span
className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
aria-live="polite"

View file

@ -6,6 +6,7 @@ interface BulkActionsContextValue {
toggleSelecting: () => void;
toggleSelect: (id: string) => void;
selectAll: (ids: string[]) => void;
toggleGroup: (ids: string[]) => void;
clearSelection: () => void;
isSelected: (id: string) => boolean;
}
@ -17,7 +18,7 @@ export function BulkActionsProvider({ children }: { children: ReactNode }) {
const [isSelecting, setIsSelecting] = useState(false);
const toggleSelecting = useCallback(() => {
setIsSelecting(prev => {
setIsSelecting((prev) => {
if (prev) {
// Clear selection when exiting selection mode
setSelectedIds(new Set());
@ -27,7 +28,7 @@ export function BulkActionsProvider({ children }: { children: ReactNode }) {
}, []);
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
@ -42,14 +43,31 @@ export function BulkActionsProvider({ children }: { children: ReactNode }) {
setSelectedIds(new Set(ids));
}, []);
/** Toggle a group of IDs: if all are selected, remove them; otherwise add them. */
const toggleGroup = useCallback((ids: string[]) => {
setSelectedIds((prev) => {
const next = new Set(prev);
const allInGroup = ids.length > 0 && ids.every((id) => next.has(id));
if (allInGroup) {
ids.forEach((id) => next.delete(id));
} else {
ids.forEach((id) => next.add(id));
}
return next;
});
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
setIsSelecting(false);
}, []);
const isSelected = useCallback((id: string) => {
return selectedIds.has(id);
}, [selectedIds]);
const isSelected = useCallback(
(id: string) => {
return selectedIds.has(id);
},
[selectedIds]
);
const value: BulkActionsContextValue = {
selectedIds,
@ -57,15 +75,12 @@ export function BulkActionsProvider({ children }: { children: ReactNode }) {
toggleSelecting,
toggleSelect,
selectAll,
toggleGroup,
clearSelection,
isSelected,
};
return (
<BulkActionsContext.Provider value={value}>
{children}
</BulkActionsContext.Provider>
);
return <BulkActionsContext.Provider value={value}>{children}</BulkActionsContext.Provider>;
}
// Default values for when hook is used outside provider (e.g., DragOverlay)
@ -75,6 +90,7 @@ const defaultContext: BulkActionsContextValue = {
toggleSelecting: () => {},
toggleSelect: () => {},
selectAll: () => {},
toggleGroup: () => {},
clearSelection: () => {},
isSelected: () => false,
};