From 762f7fa23af895911c4ef861139fa2c849f67d6a Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Sat, 31 Jan 2026 00:22:58 -0600 Subject: [PATCH] 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 --- web/src/__tests__/KanbanBoard.test.tsx | 2 + web/src/__tests__/TaskCard.test.tsx | 2 + web/src/components/board/BulkActionsBar.tsx | 150 ++++++++++++++------ web/src/components/board/KanbanBoard.tsx | 2 +- web/src/components/board/KanbanColumn.tsx | 29 +++- web/src/hooks/useBulkActions.tsx | 36 +++-- 6 files changed, 167 insertions(+), 54 deletions(-) diff --git a/web/src/__tests__/KanbanBoard.test.tsx b/web/src/__tests__/KanbanBoard.test.tsx index af706b02..c54f1416 100644 --- a/web/src/__tests__/KanbanBoard.test.tsx +++ b/web/src/__tests__/KanbanBoard.test.tsx @@ -73,7 +73,9 @@ vi.mock('@/hooks/useBulkActions', () => ({ useBulkActions: () => ({ isSelecting: false, toggleSelect: vi.fn(), + toggleGroup: vi.fn(), isSelected: () => false, + selectedIds: new Set(), }), })); diff --git a/web/src/__tests__/TaskCard.test.tsx b/web/src/__tests__/TaskCard.test.tsx index 27419bc2..617cf6b3 100644 --- a/web/src/__tests__/TaskCard.test.tsx +++ b/web/src/__tests__/TaskCard.test.tsx @@ -31,7 +31,9 @@ vi.mock('@/hooks/useBulkActions', () => ({ useBulkActions: () => ({ isSelecting: false, toggleSelect: vi.fn(), + toggleGroup: vi.fn(), isSelected: () => false, + selectedIds: new Set(), }), })); diff --git a/web/src/components/board/BulkActionsBar.tsx b/web/src/components/board/BulkActionsBar.tsx index d73fe5fc..987345a4 100644 --- a/web/src/components/board/BulkActionsBar.tsx +++ b/web/src/components/board/BulkActionsBar.tsx @@ -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 = { + 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 ( <> -
+
- - + - - - {selectedCount} selected - + + {/* Status filter buttons */} +
+ {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 ( + + ); + })} +
+ + {selectedCount} selected
{selectedCount > 0 && ( @@ -184,7 +252,9 @@ export function BulkActionsBar({ allTaskIds }: BulkActionsBarProps) { - Delete {selectedCount} task{selectedCount !== 1 ? 's' : ''}? + + Delete {selectedCount} task{selectedCount !== 1 ? 's' : ''}? + This action cannot be undone. The selected tasks will be permanently deleted. diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx index 0e18577b..7db5adb3 100644 --- a/web/src/components/board/KanbanBoard.tsx +++ b/web/src/components/board/KanbanBoard.tsx @@ -182,7 +182,7 @@ export function KanbanBoard() { <> - t.id)} /> + {featureSettings.board.showArchiveSuggestions && } diff --git a/web/src/components/board/KanbanColumn.tsx b/web/src/components/board/KanbanColumn.tsx index 63c15067..788cd63a 100644 --- a/web/src/components/board/KanbanColumn.tsx +++ b/web/src/components/board/KanbanColumn.tsx @@ -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 (
-

- {title} -

+
+ {isSelecting && tasks.length > 0 && ( + { + 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`} + /> + )} +

+ {title} +

+
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 ( - - {children} - - ); + return {children}; } // 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, };