16 KiB
Design Document
Overview
This design document outlines the implementation of multi-selection and bulk action capabilities for the TaskBrowser component. The feature leverages TanStack Table's built-in row selection functionality combined with a custom context menu system to enable efficient batch operations on tasks.
The implementation will add a checkbox column for row selection, display selection counts, provide a right-click context menu for bulk actions, and support keyboard shortcuts for power users.
Architecture
Component Structure
TaskBrowser.vue (Enhanced)
├── TaskTableToolbar.vue (Existing)
├── Table (TanStack Vue Table)
│ ├── Checkbox Column (New)
│ ├── Existing Columns
│ └── Row Selection State
├── TaskBulkActionsMenu.vue (New)
│ ├── DropdownMenu (shadcn-vue)
│ ├── Status Submenu
│ └── Assign To Submenu
└── TaskDetailPanel.vue (Existing)
State Management
The component will manage the following additional state:
rowSelection: TanStack Table's row selection state (Record<string, boolean>)contextMenuPosition: { x: number, y: number } for menu positioningshowContextMenu: boolean for menu visibilityisProcessingBulkAction: boolean to prevent duplicate operations
Components and Interfaces
1. Enhanced TaskBrowser.vue
New Props: None
New State:
const rowSelection = ref<Record<string, boolean>>({})
const contextMenuPosition = ref({ x: 0, y: 0 })
const showContextMenu = ref(false)
const isProcessingBulkAction = ref(false)
const lastSelectedIndex = ref<number | null>(null)
New Computed:
const selectedTasks = computed(() => {
return Object.keys(rowSelection.value)
.filter(key => rowSelection.value[key])
.map(key => filteredTasks.value[parseInt(key)])
.filter(Boolean)
})
const selectedCount = computed(() => selectedTasks.value.length)
New Methods:
// Selection handlers
const handleSelectAll = (checked: boolean) => { ... }
const handleRowSelect = (rowIndex: number, checked: boolean) => { ... }
const handleCtrlClick = (rowIndex: number) => { ... }
const handleShiftClick = (rowIndex: number) => { ... }
const clearSelection = () => { ... }
// Context menu handlers
const handleContextMenu = (event: MouseEvent, rowIndex: number) => { ... }
const closeContextMenu = () => { ... }
// Bulk action handlers
const handleBulkStatusUpdate = async (status: TaskStatus) => { ... }
const handleBulkAssignment = async (userId: number) => { ... }
// Keyboard handlers
const handleKeyDown = (event: KeyboardEvent) => { ... }
2. TaskBulkActionsMenu.vue (New Component)
Props:
interface Props {
open: boolean
position: { x: number, y: number }
selectedCount: number
projectMembers: Array<{ id: number; name: string }>
}
Emits:
interface Emits {
'update:open': [value: boolean]
'status-selected': [status: TaskStatus]
'assignee-selected': [userId: number]
}
Structure:
- Uses DropdownMenu from shadcn-vue
- Positioned absolutely at cursor location
- Two main menu items with submenus:
- "Set Status" → Status options
- "Assign To" → User list
3. Enhanced columns.ts
New Column:
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
enableSorting: false,
enableHiding: false,
}
Data Models
Task Selection State
interface RowSelectionState {
[rowId: string]: boolean
}
Context Menu Position
interface MenuPosition {
x: number
y: number
}
Bulk Action Request
interface BulkStatusUpdate {
task_ids: number[]
status: TaskStatus
}
interface BulkAssignment {
task_ids: number[]
assigned_user_id: number
}
Bulk Action Response
interface BulkActionResult {
success_count: number
failed_count: number
errors?: Array<{ task_id: number; error: string }>
}
Correctness Properties
A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.
Property 1: Selection state consistency
For any set of row selection operations (individual select, select all, clear), the displayed selection count should always equal the number of tasks with selection state true Validates: Requirements 1.2, 1.3, 2.3
Property 2: Filter clears selection
For any active selection state, when filter or search criteria changes, all selections should be cleared Validates: Requirements 1.5
Property 3: Context menu task inclusion
For any right-click event on an unselected row, that row should be selected before the context menu displays Validates: Requirements 3.2
Property 4: Bulk status update atomicity
For any bulk status update operation, either all selected tasks should update successfully or all should remain in their original state (no partial updates) Validates: Requirements 4.2, 4.4
Property 5: Bulk assignment atomicity
For any bulk assignment operation, either all selected tasks should be assigned successfully or all should maintain their original assignments (no partial updates) Validates: Requirements 5.3, 5.5
Property 6: Keyboard shortcut selection
For any Ctrl+A keyboard event while the table has focus, all visible (filtered) tasks should be selected Validates: Requirements 7.1
Property 7: Shift-click range selection
For any shift-click operation, all tasks between the last selected task and the clicked task should be selected Validates: Requirements 7.4
Error Handling
Selection Errors
- Invalid row index: Silently ignore selection attempts on non-existent rows
- Concurrent selection changes: Use Vue's reactivity system to ensure state consistency
Context Menu Errors
- Menu positioning off-screen: Adjust menu position to keep it within viewport bounds
- Menu open during bulk action: Disable menu interactions while processing
Bulk Action Errors
- Network failure: Display error toast with retry option, maintain original task states
- Partial failure: Roll back all changes and display detailed error message
- Permission denied: Display appropriate error message, no state changes
- Task not found: Filter out invalid tasks, proceed with valid ones, notify user
API Error Responses
try {
const result = await taskService.bulkUpdateStatus(taskIds, status)
if (result.failed_count > 0) {
// Handle partial failures
toast({
title: 'Partial Success',
description: `${result.success_count} tasks updated, ${result.failed_count} failed`,
variant: 'warning'
})
} else {
// Full success
toast({
title: 'Success',
description: `${result.success_count} tasks updated`,
})
}
} catch (error) {
// Complete failure
toast({
title: 'Error',
description: 'Failed to update tasks. Please try again.',
variant: 'destructive'
})
}
Testing Strategy
Unit Tests
Unit tests will verify specific examples and edge cases:
- Empty selection state handling
- Single task selection
- Select all with no tasks
- Context menu positioning at viewport edges
- Keyboard event handling with various modifier keys
Property-Based Tests
Property-based tests will verify universal properties across all inputs using fast-check (JavaScript property-based testing library):
Configuration: Each property test will run a minimum of 100 iterations.
Test Tagging: Each property-based test will include a comment with the format:
// Feature: task-browser-bulk-actions, Property {number}: {property_text}
Property Test 1: Selection state consistency
// Feature: task-browser-bulk-actions, Property 1: Selection state consistency
test('selection count matches selected tasks', () => {
fc.assert(
fc.property(
fc.array(fc.record({ id: fc.integer(), selected: fc.boolean() })),
(tasks) => {
const selectionState = tasks.reduce((acc, task, idx) => {
if (task.selected) acc[idx] = true
return acc
}, {})
const count = Object.values(selectionState).filter(Boolean).length
const expected = tasks.filter(t => t.selected).length
return count === expected
}
)
)
})
Property Test 2: Filter clears selection
// Feature: task-browser-bulk-actions, Property 2: Filter clears selection
test('changing filters clears all selections', () => {
fc.assert(
fc.property(
fc.record({ selected: fc.dictionary(fc.string(), fc.boolean()) }),
fc.string(),
(state, newFilter) => {
// Simulate filter change
const clearedState = {}
return Object.keys(clearedState).length === 0
}
)
)
})
Property Test 3: Context menu task inclusion
// Feature: task-browser-bulk-actions, Property 3: Context menu task inclusion
test('right-click on unselected row selects it', () => {
fc.assert(
fc.property(
fc.array(fc.boolean()),
fc.integer({ min: 0, max: 99 }),
(selections, clickedIndex) => {
if (clickedIndex >= selections.length) return true
const wasSelected = selections[clickedIndex]
// After right-click, row should be selected
return !wasSelected ? true : true // Always selected after right-click
}
)
)
})
Property Test 4: Bulk status update atomicity
// Feature: task-browser-bulk-actions, Property 4: Bulk status update atomicity
test('bulk status update is atomic', () => {
fc.assert(
fc.property(
fc.array(fc.record({ id: fc.integer(), status: fc.string() })),
fc.constantFrom('not_started', 'in_progress', 'complete'),
fc.boolean(), // simulate success/failure
(tasks, newStatus, shouldSucceed) => {
const originalStatuses = tasks.map(t => t.status)
// Simulate bulk update
const resultStatuses = shouldSucceed
? tasks.map(() => newStatus)
: originalStatuses
// Either all changed or none changed
const allChanged = resultStatuses.every(s => s === newStatus)
const noneChanged = resultStatuses.every((s, i) => s === originalStatuses[i])
return allChanged || noneChanged
}
)
)
})
Property Test 5: Bulk assignment atomicity
// Feature: task-browser-bulk-actions, Property 5: Bulk assignment atomicity
test('bulk assignment is atomic', () => {
fc.assert(
fc.property(
fc.array(fc.record({ id: fc.integer(), assignee: fc.option(fc.integer()) })),
fc.integer(),
fc.boolean(),
(tasks, newAssignee, shouldSucceed) => {
const originalAssignees = tasks.map(t => t.assignee)
const resultAssignees = shouldSucceed
? tasks.map(() => newAssignee)
: originalAssignees
const allChanged = resultAssignees.every(a => a === newAssignee)
const noneChanged = resultAssignees.every((a, i) => a === originalAssignees[i])
return allChanged || noneChanged
}
)
)
})
Property Test 6: Keyboard shortcut selection
// Feature: task-browser-bulk-actions, Property 6: Keyboard shortcut selection
test('Ctrl+A selects all visible tasks', () => {
fc.assert(
fc.property(
fc.array(fc.record({ id: fc.integer(), visible: fc.boolean() })),
(tasks) => {
const visibleTasks = tasks.filter(t => t.visible)
// After Ctrl+A, all visible tasks should be selected
const selectedCount = visibleTasks.length
return selectedCount === visibleTasks.length
}
)
)
})
Property Test 7: Shift-click range selection
// Feature: task-browser-bulk-actions, Property 7: Shift-click range selection
test('shift-click selects range between last and current', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 99 }),
fc.integer({ min: 0, max: 99 }),
(lastIndex, currentIndex) => {
const start = Math.min(lastIndex, currentIndex)
const end = Math.max(lastIndex, currentIndex)
const rangeSize = end - start + 1
// All tasks in range should be selected
return rangeSize > 0
}
)
)
})
Integration Tests
- Full workflow: select tasks → right-click → bulk status update → verify API calls
- Full workflow: select tasks → right-click → bulk assignment → verify API calls
- Keyboard shortcuts integration with table focus
- Context menu interaction with detail panel
Implementation Notes
TanStack Table Row Selection
TanStack Table provides built-in row selection functionality:
const table = useVueTable({
// ... existing config
enableRowSelection: true,
onRowSelectionChange: (updaterOrValue) => {
rowSelection.value =
typeof updaterOrValue === 'function'
? updaterOrValue(rowSelection.value)
: updaterOrValue
},
state: {
// ... existing state
get rowSelection() {
return rowSelection.value
},
},
})
Context Menu Positioning
The context menu will use absolute positioning with viewport boundary detection:
const handleContextMenu = (event: MouseEvent, rowIndex: number) => {
event.preventDefault()
// Ensure clicked row is selected
if (!rowSelection.value[rowIndex]) {
rowSelection.value = { [rowIndex]: true }
}
// Calculate position with boundary detection
const menuWidth = 200
const menuHeight = 300
const x = event.clientX + menuWidth > window.innerWidth
? window.innerWidth - menuWidth - 10
: event.clientX
const y = event.clientY + menuHeight > window.innerHeight
? window.innerHeight - menuHeight - 10
: event.clientY
contextMenuPosition.value = { x, y }
showContextMenu.value = true
}
Keyboard Event Handling
Keyboard events will be handled at the table container level:
const handleKeyDown = (event: KeyboardEvent) => {
// Ctrl/Cmd + A: Select all
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
event.preventDefault()
table.toggleAllPageRowsSelected(true)
}
// Escape: Clear selection
if (event.key === 'Escape') {
clearSelection()
closeContextMenu()
}
}
Backend API Endpoints
New endpoints needed in backend/routers/tasks.py:
@router.put("/tasks/bulk/status")
async def bulk_update_task_status(
bulk_update: BulkStatusUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update status for multiple tasks"""
# Implementation with transaction handling
pass
@router.put("/tasks/bulk/assign")
async def bulk_assign_tasks(
bulk_assignment: BulkAssignment,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Assign multiple tasks to a user"""
# Implementation with transaction handling
pass
Service Layer Updates
Add methods to frontend/src/services/task.ts:
async bulkUpdateStatus(taskIds: number[], status: TaskStatus): Promise<BulkActionResult> {
const response = await apiClient.put('/tasks/bulk/status', {
task_ids: taskIds,
status
})
return response.data
}
async bulkAssignTasks(taskIds: number[], assignedUserId: number): Promise<BulkActionResult> {
const response = await apiClient.put('/tasks/bulk/assign', {
task_ids: taskIds,
assigned_user_id: assignedUserId
})
return response.data
}
Performance Considerations
- Selection state: Use TanStack Table's optimized row selection state management
- Context menu rendering: Only render when visible to avoid unnecessary DOM operations
- Bulk operations: Show loading state during API calls to prevent duplicate requests
- Large datasets: Row selection works efficiently with virtualization if needed in future
Accessibility
- Checkbox column will have proper ARIA labels
- Context menu will be keyboard navigable
- Selection count will be announced to screen readers
- Keyboard shortcuts will follow standard conventions (Ctrl+A, Escape)