feat(solo): preview-then-confirm flow in SoloBoard Voer-alle-uit
Vervang directe enqueueAllTodoJobsAction door previewEnqueueAllAction + BatchEnqueueBlockerDialog. Geen blocker → enqueueClaudeJobsBatchAction direct. Wel blocker → dialog met prefix-enqueue of annuleer. Loading-state op knop tijdens preview en confirm. 5 integratie-tests.
This commit is contained in:
parent
80a7d793b6
commit
94b99198ed
2 changed files with 265 additions and 4 deletions
204
__tests__/components/solo/solo-board-batch-enqueue.test.tsx
Normal file
204
__tests__/components/solo/solo-board-batch-enqueue.test.tsx
Normal file
|
|
@ -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
|
||||
}) => (
|
||||
<button onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="dialog">{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||
}))
|
||||
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: () => <div data-testid="solo-column" />,
|
||||
}))
|
||||
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(<SoloBoard {...DEFAULT_PROPS} />)
|
||||
|
||||
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(<SoloBoard {...DEFAULT_PROPS} />)
|
||||
|
||||
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(<SoloBoard {...DEFAULT_PROPS} />)
|
||||
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(<SoloBoard {...DEFAULT_PROPS} />)
|
||||
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(<SoloBoard {...DEFAULT_PROPS} />)
|
||||
fireEvent.click(screen.getByText(/Start agents/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Geen toegang')
|
||||
})
|
||||
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<BlockerDialogState | null>(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({
|
|||
<Button
|
||||
size="sm"
|
||||
onClick={handleStartAll}
|
||||
disabled={isDemo || batchPending || queueableCount === 0}
|
||||
disabled={isDemo || batchPending || confirmPending || queueableCount === 0}
|
||||
>
|
||||
{batchPending ? 'Starten…' : `Start agents (${queueableCount})`}
|
||||
{batchPending || confirmPending ? 'Starten…' : `Start agents (${queueableCount})`}
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
{sprintGoal && (
|
||||
|
|
@ -234,6 +279,18 @@ export function SoloBoard({
|
|||
onOpenChange={setSheetOpen}
|
||||
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))}
|
||||
/>
|
||||
|
||||
{blockerDialog && (
|
||||
<BatchEnqueueBlockerDialog
|
||||
open
|
||||
onOpenChange={(v) => { if (!v) setBlockerDialog(null) }}
|
||||
prefixCount={blockerDialog.prefixCount}
|
||||
blockerReason={blockerDialog.blockerReason}
|
||||
blockerLabel={blockerDialog.blockerLabel}
|
||||
onConfirm={handleBlockerConfirm}
|
||||
onCancel={() => setBlockerDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue