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:
parent
3d76b894cb
commit
a7d5dc610a
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue