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:
parent
6b544808de
commit
864b684d32
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue