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

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 positioning
  • showContextMenu: boolean for menu visibility
  • isProcessingBulkAction: 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)