mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-05 19:27:50 +00:00
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:
parent
287ac80b3f
commit
762f7fa23a
6 changed files with 167 additions and 54 deletions
|
|
@ -73,7 +73,9 @@ vi.mock('@/hooks/useBulkActions', () => ({
|
|||
useBulkActions: () => ({
|
||||
isSelecting: false,
|
||||
toggleSelect: vi.fn(),
|
||||
toggleGroup: vi.fn(),
|
||||
isSelected: () => false,
|
||||
selectedIds: new Set(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ vi.mock('@/hooks/useBulkActions', () => ({
|
|||
useBulkActions: () => ({
|
||||
isSelecting: false,
|
||||
toggleSelect: vi.fn(),
|
||||
toggleGroup: vi.fn(),
|
||||
isSelected: () => false,
|
||||
selectedIds: new Set(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue