564 lines
16 KiB
Markdown
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)
|