diff --git a/__tests__/components/solo/solo-board-batch-enqueue.test.tsx b/__tests__/components/solo/solo-board-batch-enqueue.test.tsx new file mode 100644 index 0000000..392bf6e --- /dev/null +++ b/__tests__/components/solo/solo-board-batch-enqueue.test.tsx @@ -0,0 +1,204 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +const { mockPreviewEnqueueAllAction, mockEnqueueClaudeJobsBatchAction } = vi.hoisted(() => ({ + mockPreviewEnqueueAllAction: vi.fn(), + mockEnqueueClaudeJobsBatchAction: vi.fn(), +})) + +vi.mock('@/actions/claude-jobs', () => ({ + previewEnqueueAllAction: mockPreviewEnqueueAllAction, + enqueueClaudeJobsBatchAction: mockEnqueueClaudeJobsBatchAction, + cancelClaudeJobAction: vi.fn(), + enqueueClaudeJobAction: vi.fn(), +})) +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })) +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, + DragOverlay: () => null, + PointerSensor: class {}, + useSensor: vi.fn(() => ({})), + useSensors: vi.fn(() => []), + closestCorners: vi.fn(), +})) +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children?: React.ReactNode + onClick?: () => void + disabled?: boolean + }) => ( + + ), +})) +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})) +vi.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) => + r ? <>{r} : <>{children}, + TooltipContent: () => null, +})) +vi.mock('@/components/shared/demo-tooltip', () => ({ + DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})) +vi.mock('@/components/split-pane/split-pane', () => ({ + SplitPane: ({ panes }: { panes: React.ReactNode[] }) => <>{panes}, +})) +vi.mock('@/components/solo/solo-column', () => ({ + SoloColumn: () =>
, +})) +vi.mock('@/components/solo/solo-task-card', () => ({ + SoloTaskCardOverlay: () => null, +})) +vi.mock('@/components/solo/task-detail-dialog', () => ({ + TaskDetailDialog: () => null, +})) +vi.mock('@/components/solo/unassigned-stories-sheet', () => ({ + UnassignedStoriesSheet: () => null, +})) +vi.mock('@/lib/task-status', () => ({ + taskStatusToApi: (s: string) => s.toLowerCase(), +})) + +import { useSoloStore } from '@/stores/solo-store' +import { SoloBoard } from '@/components/solo/solo-board' +import { toast } from 'sonner' + +const PRODUCT_ID = 'prod-1' +const TODO_TASK = { + id: 't1', + title: 'Task 1', + description: null, + implementation_plan: null, + priority: 1, + sort_order: 1, + status: 'TO_DO' as const, + verify_only: false, + verify_required: 'ALIGNED_OR_PARTIAL' as const, + story_id: 'story-1', + story_code: 'ST-1', + story_title: 'Story 1', + task_code: 'ST-1.1', +} + +const DEFAULT_PROPS = { + productId: PRODUCT_ID, + sprintGoal: 'Sprint goal', + tasks: [TODO_TASK], + unassignedStories: [], + isDemo: false, + currentUserId: 'user-1', +} + +const PREVIEW_NO_BLOCKER = { + tasks: [{ id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }], + blockerIndex: null, + blockerReason: null, +} + +const PREVIEW_WITH_BLOCKER = { + tasks: [ + { id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }, + { id: 't2', title: 'Task 2', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }, + { id: 't3', title: 'Task Review', status: 'REVIEW', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }, + ], + blockerIndex: 2, + blockerReason: 'task-review' as const, +} + +beforeEach(() => { + vi.clearAllMocks() + useSoloStore.setState({ tasks: {}, claudeJobsByTaskId: {}, connectedWorkers: 1 }) +}) + +describe('SoloBoard — batch-enqueue flow', () => { + it('no blocker: calls enqueueClaudeJobsBatchAction with TO_DO task IDs directly', async () => { + mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_NO_BLOCKER) + mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 1 }) + + render() + + fireEvent.click(screen.getByText(/Start agents/)) + + await waitFor(() => { + expect(mockPreviewEnqueueAllAction).toHaveBeenCalledWith(PRODUCT_ID) + expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1']) + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('1 agent')) + }) + }) + + it('blocker: shows dialog when preview returns blockerIndex', async () => { + mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER) + + render() + + fireEvent.click(screen.getByText(/Start agents/)) + + await waitFor(() => { + expect(screen.getByTestId('dialog')).toBeInTheDocument() + expect(screen.getByText(/Blokkade gedetecteerd/)).toBeInTheDocument() + }) + expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled() + }) + + it('blocker dialog confirm: enqueues prefix tasks and closes', async () => { + mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER) + mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 2 }) + + render() + fireEvent.click(screen.getByText(/Start agents/)) + + await waitFor(() => screen.getByTestId('dialog')) + + fireEvent.click(screen.getByText(/Stuur 2 taken tot aan blokkade/)) + + await waitFor(() => { + expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1', 't2']) + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('2 agents')) + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + }) + }) + + it('blocker dialog cancel: closes dialog without enqueuing', async () => { + mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER) + + render() + fireEvent.click(screen.getByText(/Start agents/)) + + await waitFor(() => screen.getByTestId('dialog')) + + fireEvent.click(screen.getByText('Annuleer')) + + await waitFor(() => { + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + }) + expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled() + }) + + it('preview error: shows toast without opening dialog', async () => { + mockPreviewEnqueueAllAction.mockResolvedValue({ error: 'Geen toegang' }) + + render() + fireEvent.click(screen.getByText(/Start agents/)) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Geen toegang') + }) + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 54ee48e..0dd6fa2 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -8,7 +8,8 @@ import { import { toast } from 'sonner' import { useSoloStore } from '@/stores/solo-store' import { taskStatusToApi } from '@/lib/task-status' -import { enqueueAllTodoJobsAction } from '@/actions/claude-jobs' +import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs' +import { BatchEnqueueBlockerDialog } from './batch-enqueue-blocker-dialog' import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { SplitPane } from '@/components/split-pane/split-pane' @@ -61,6 +62,15 @@ export function SoloBoard({ const [unassignedStories, setUnassignedStories] = useState(initialUnassigned) const [, startTransition] = useTransition() const [batchPending, startBatchTransition] = useTransition() + const [confirmPending, startConfirmTransition] = useTransition() + + type BlockerDialogState = { + prefixCount: number + blockerReason: 'task-review' | 'pbi-blocked' + blockerLabel: string + prefixIds: string[] + } + const [blockerDialog, setBlockerDialog] = useState(null) const taskKey = initialTasks.map(t => t.id).join(',') useEffect(() => { @@ -140,7 +150,42 @@ export function SoloBoard({ function handleStartAll() { if (queueableCount === 0) return startBatchTransition(async () => { - const result = await enqueueAllTodoJobsAction(productId) + const preview = await previewEnqueueAllAction(productId) + if ('error' in preview) { + toast.error(preview.error) + return + } + if (preview.blockerIndex === null) { + const todoIds = preview.tasks.filter(t => t.status === 'TO_DO').map(t => t.id) + const result = await enqueueClaudeJobsBatchAction(productId, todoIds) + if ('error' in result) { + toast.error(result.error) + } else if (result.count === 0) { + toast.info('Geen taken om te starten') + } else { + toast.success(`${result.count} ${result.count === 1 ? 'agent' : 'agents'} ingeschakeld`) + } + } else { + const blockerTask = preview.tasks[preview.blockerIndex] + const blockerLabel = preview.blockerReason === 'task-review' + ? `${blockerTask.story_title} — ${blockerTask.title}` + : blockerTask.story_title + setBlockerDialog({ + prefixCount: preview.blockerIndex, + blockerReason: preview.blockerReason!, + blockerLabel, + prefixIds: preview.tasks.slice(0, preview.blockerIndex).map(t => t.id), + }) + } + }) + } + + function handleBlockerConfirm() { + if (!blockerDialog) return + const { prefixIds } = blockerDialog + setBlockerDialog(null) + startConfirmTransition(async () => { + const result = await enqueueClaudeJobsBatchAction(productId, prefixIds) if ('error' in result) { toast.error(result.error) } else if (result.count === 0) { @@ -159,9 +204,9 @@ export function SoloBoard({ {sprintGoal && ( @@ -234,6 +279,18 @@ export function SoloBoard({ onOpenChange={setSheetOpen} onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))} /> + + {blockerDialog && ( + { if (!v) setBlockerDialog(null) }} + prefixCount={blockerDialog.prefixCount} + blockerReason={blockerDialog.blockerReason} + blockerLabel={blockerDialog.blockerLabel} + onConfirm={handleBlockerConfirm} + onCancel={() => setBlockerDialog(null)} + /> + )}
) }