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