LinkDesk/.kiro/specs/task-browser-bulk-actions/design.md

564 lines
16 KiB
Markdown

# 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 positioning
- `showContextMenu`: boolean for menu visibility
- `isProcessingBulkAction`: boolean to prevent duplicate operations
## Components and Interfaces
### 1. Enhanced TaskBrowser.vue
**New Props:** None
**New State:**
```typescript
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:**
```typescript
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:**
```typescript
// 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:**
```typescript
interface Props {
open: boolean
position: { x: number, y: number }
selectedCount: number
projectMembers: Array<{ id: number; name: string }>
}
```
**Emits:**
```typescript
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:**
```typescript
{
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
```typescript
interface RowSelectionState {
[rowId: string]: boolean
}
```
### Context Menu Position
```typescript
interface MenuPosition {
x: number
y: number
}
```
### Bulk Action Request
```typescript
interface BulkStatusUpdate {
task_ids: number[]
status: TaskStatus
}
interface BulkAssignment {
task_ids: number[]
assigned_user_id: number
}
```
### Bulk Action Response
```typescript
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
```typescript
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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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**
```typescript
// 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:
```typescript
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:
```typescript
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:
```typescript
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`:
```python
@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`:
```typescript
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)