feat(frontend): add IngestPanel and ErrorBoundary components with tests

IngestPanel: file upload for PDF/DOCX/TXT with progress and success/error feedback.

ErrorBoundary: React error boundary with fallback UI and reload button.

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:24:29 +08:00
parent 3d76b894cb
commit a7d5dc610a
4 changed files with 230 additions and 0 deletions

View File

@ -0,0 +1,52 @@
import React from 'react'
import { AlertCircle } from 'lucide-react'
interface Props {
children: React.ReactNode
fallback?: React.ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
render(): React.ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
<div className="flex items-center space-x-2 mb-4">
<AlertCircle className="w-6 h-6 text-red-600" />
<h2 className="text-lg font-semibold text-red-800">Something went wrong</h2>
</div>
{this.state.error && (
<p className="text-sm text-red-600 mb-4">{this.state.error.message}</p>
)}
<button
type="button"
onClick={() => window.location.reload()}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Reload
</button>
</div>
)
}
return this.props.children
}
}

View File

@ -0,0 +1,71 @@
import React from 'react'
import { Upload, Loader2, CheckCircle, AlertCircle } from 'lucide-react'
interface IngestPanelProps {
onUpload: (file: File) => void
isLoading: boolean
success: string | null
error: string | null
}
export const IngestPanel: React.FC<IngestPanelProps> = ({
onUpload,
isLoading,
success,
error,
}) => {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
onUpload(file)
}
}
return (
<div className="p-4 space-y-4">
<input
type="file"
accept=".pdf,.docx,.txt"
onChange={handleFileChange}
disabled={isLoading}
className="hidden"
id="file-upload"
data-testid="file-input"
/>
<label
htmlFor="file-upload"
className={`inline-flex items-center space-x-2 px-4 py-2 rounded cursor-pointer ${
isLoading
? 'bg-gray-300 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Uploading...</span>
</>
) : (
<>
<Upload className="w-4 h-4" />
<span>Upload Document</span>
</>
)}
</label>
{success && (
<div className="flex items-center space-x-2 text-green-700">
<CheckCircle className="w-5 h-5" />
<span>Uploaded {success}</span>
</div>
)}
{error && (
<div className="flex items-center space-x-2 text-red-700">
<AlertCircle className="w-5 h-5" />
<span>{error}</span>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,56 @@
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import { ErrorBoundary } from '../../components/ErrorBoundary'
const ThrowingComponent = (): JSX.Element => {
throw new Error('Test error')
}
describe('ErrorBoundary', () => {
it('renders children when no error', () => {
render(
<ErrorBoundary>
<div>Normal content</div>
</ErrorBoundary>
)
expect(screen.getByText('Normal content')).toBeInTheDocument()
})
it('shows fallback UI when child component throws', () => {
render(
<ErrorBoundary>
<ThrowingComponent />
</ErrorBoundary>
)
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
})
it('shows error message in fallback', () => {
render(
<ErrorBoundary>
<ThrowingComponent />
</ErrorBoundary>
)
expect(screen.getByText(/Test error/i)).toBeInTheDocument()
})
it('renders custom fallback prop when provided', () => {
render(
<ErrorBoundary fallback={<div>Custom fallback</div>}>
<ThrowingComponent />
</ErrorBoundary>
)
expect(screen.getByText('Custom fallback')).toBeInTheDocument()
})
it('has a reload button', () => {
render(
<ErrorBoundary>
<ThrowingComponent />
</ErrorBoundary>
)
const reloadButton = screen.getByRole('button', { name: /reload/i })
expect(reloadButton).toBeInTheDocument()
expect(reloadButton).toHaveAttribute('type', 'button')
})
})

View File

@ -0,0 +1,51 @@
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import { IngestPanel } from '../../components/IngestPanel'
describe('IngestPanel', () => {
const mockOnUpload = vi.fn()
beforeEach(() => {
mockOnUpload.mockClear()
})
it('renders upload button', () => {
render(<IngestPanel onUpload={mockOnUpload} isLoading={false} success={null} error={null} />)
expect(screen.getByText(/upload document/i)).toBeInTheDocument()
})
it('button is disabled when isLoading is true', () => {
render(<IngestPanel onUpload={mockOnUpload} isLoading={true} success={null} error={null} />)
expect(screen.getByText(/uploading\.\.\./i).closest('label')).toHaveClass('cursor-not-allowed')
})
it('shows uploading state when isLoading', () => {
render(<IngestPanel onUpload={mockOnUpload} isLoading={true} success={null} error={null} />)
expect(screen.getByText(/uploading\.\.\./i)).toBeInTheDocument()
})
it('shows success message when success prop is set', () => {
render(
<IngestPanel onUpload={mockOnUpload} isLoading={false} success="test.pdf" error={null} />
)
expect(screen.getByText(/uploaded test\.pdf/i)).toBeInTheDocument()
})
it('shows error message when error prop is set', () => {
render(
<IngestPanel onUpload={mockOnUpload} isLoading={false} success={null} error="Upload failed" />
)
expect(screen.getByText(/upload failed/i)).toBeInTheDocument()
})
it('calls onUpload when file is selected', () => {
render(<IngestPanel onUpload={mockOnUpload} isLoading={false} success={null} error={null} />)
const fileInput = screen.getByTestId('file-input')
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' })
fireEvent.change(fileInput, { target: { files: [file] } })
expect(mockOnUpload).toHaveBeenCalledTimes(1)
expect(mockOnUpload).toHaveBeenCalledWith(file)
})
})