feat(frontend): add PipelineProgress component with 4-stage stepper and tests

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Woody 2026-04-23 11:46:23 +08:00
parent 6b544808de
commit 864b684d32
2 changed files with 168 additions and 0 deletions

View File

@ -0,0 +1,63 @@
import React from 'react'
import { CheckCircle, Loader2, Circle } from 'lucide-react'
export interface PipelineProgressProps {
currentStep: number
}
const STAGES = [
{ label: 'Extracting keywords...', index: 1 },
{ label: 'Retrieving documents...', index: 2 },
{ label: 'Filtering relevance...', index: 3 },
{ label: 'Generating answer...', index: 4 },
]
export const PipelineProgress: React.FC<PipelineProgressProps> = ({ currentStep }) => {
if (currentStep === 0) {
return null
}
return (
<div className="w-full py-4" data-testid="pipeline-progress">
<div className="flex items-center justify-between">
{STAGES.map((stage, index) => {
const stageNumber = index + 1
const isCompleted = currentStep >= stageNumber && (currentStep === STAGES.length || currentStep > stageNumber)
const isActive = currentStep === stageNumber && currentStep !== STAGES.length
const isPending = currentStep < stageNumber && currentStep !== STAGES.length
return (
<div key={stage.index} className="flex flex-col items-center flex-1 relative">
{index < STAGES.length - 1 && (
<div className="absolute top-4 left-1/2 w-full h-0.5 bg-gray-200 -z-10" />
)}
<div className="relative z-10">
{isCompleted && (
<CheckCircle className="w-8 h-8 text-green-500" data-testid={`stage-${stageNumber}-completed`} />
)}
{isActive && (
<Loader2
className="w-8 h-8 text-blue-500 animate-spin"
data-testid={`stage-${stageNumber}-active`}
/>
)}
{isPending && (
<Circle className="w-8 h-8 text-gray-300" data-testid={`stage-${stageNumber}-pending`} />
)}
</div>
<span
className={`mt-2 text-xs font-medium text-center max-w-[120px] ${
isCompleted ? 'text-gray-600' : isActive ? 'text-blue-600' : 'text-gray-400'
}`}
>
{stage.label}
</span>
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,105 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { PipelineProgress } from '../../components/PipelineProgress'
describe('PipelineProgress', () => {
it('renders nothing when currentStep is 0', () => {
const { container } = render(<PipelineProgress currentStep={0} />)
expect(container).toBeEmptyDOMElement()
})
it('renders all 4 stage labels when currentStep >= 1', () => {
render(<PipelineProgress currentStep={1} />)
expect(screen.getByText('Extracting keywords...')).toBeInTheDocument()
expect(screen.getByText('Retrieving documents...')).toBeInTheDocument()
expect(screen.getByText('Filtering relevance...')).toBeInTheDocument()
expect(screen.getByText('Generating answer...')).toBeInTheDocument()
})
it('marks stages as completed when currentStep > their index', () => {
render(<PipelineProgress currentStep={3} />)
expect(screen.getByTestId('stage-1-completed')).toBeInTheDocument()
expect(screen.getByTestId('stage-2-completed')).toBeInTheDocument()
expect(screen.getByTestId('stage-3-active')).toBeInTheDocument()
expect(screen.getByTestId('stage-4-pending')).toBeInTheDocument()
})
it('shows active stage with animated indicator when currentStep matches', () => {
render(<PipelineProgress currentStep={2} />)
const activeIndicator = screen.getByTestId('stage-2-active')
expect(activeIndicator).toBeInTheDocument()
expect(activeIndicator).toHaveClass('animate-spin')
})
it('shows all stages as completed when currentStep is 4', () => {
render(<PipelineProgress currentStep={4} />)
expect(screen.getByTestId('stage-1-completed')).toBeInTheDocument()
expect(screen.getByTestId('stage-2-completed')).toBeInTheDocument()
expect(screen.getByTestId('stage-3-completed')).toBeInTheDocument()
expect(screen.getByTestId('stage-4-completed')).toBeInTheDocument()
expect(screen.queryByTestId(/stage-\d+-active/)).not.toBeInTheDocument()
expect(screen.queryByTestId(/stage-\d+-pending/)).not.toBeInTheDocument()
})
it('uses correct stage label text', () => {
render(<PipelineProgress currentStep={1} />)
expect(screen.getByText('Extracting keywords...')).toBeInTheDocument()
expect(screen.getByText('Retrieving documents...')).toBeInTheDocument()
expect(screen.getByText('Filtering relevance...')).toBeInTheDocument()
expect(screen.getByText('Generating answer...')).toBeInTheDocument()
})
it('shows stage 1 as active when currentStep is 1', () => {
render(<PipelineProgress currentStep={1} />)
expect(screen.getByTestId('stage-1-active')).toBeInTheDocument()
expect(screen.getByTestId('stage-1-active')).toHaveClass('animate-spin')
expect(screen.getByTestId('stage-2-pending')).toBeInTheDocument()
expect(screen.getByTestId('stage-3-pending')).toBeInTheDocument()
expect(screen.getByTestId('stage-4-pending')).toBeInTheDocument()
})
it('applies correct text colors for each stage state', () => {
render(<PipelineProgress currentStep={2} />)
const stage1Label = screen.getByText('Extracting keywords...')
expect(stage1Label).toHaveClass('text-gray-600')
const stage2Label = screen.getByText('Retrieving documents...')
expect(stage2Label).toHaveClass('text-blue-600')
const stage3Label = screen.getByText('Filtering relevance...')
expect(stage3Label).toHaveClass('text-gray-400')
const stage4Label = screen.getByText('Generating answer...')
expect(stage4Label).toHaveClass('text-gray-400')
})
it('shows completed stages with CheckCircle icon', () => {
render(<PipelineProgress currentStep={4} />)
const completedIcons = screen.getAllByTestId(/stage-\d+-completed/)
completedIcons.forEach((icon) => {
expect(icon).toBeInTheDocument()
})
})
it('shows pending stages with Circle icon', () => {
render(<PipelineProgress currentStep={1} />)
const pendingIcons = screen.getAllByTestId(/stage-\d+-pending/)
expect(pendingIcons).toHaveLength(3)
})
it('renders main container with correct testid', () => {
render(<PipelineProgress currentStep={1} />)
expect(screen.getByTestId('pipeline-progress')).toBeInTheDocument()
})
})