diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..5842b97 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -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 { + 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 ( +
+
+ +

Something went wrong

+
+ {this.state.error && ( +

{this.state.error.message}

+ )} + +
+ ) + } + + return this.props.children + } +} diff --git a/frontend/src/components/IngestPanel.tsx b/frontend/src/components/IngestPanel.tsx new file mode 100644 index 0000000..321db07 --- /dev/null +++ b/frontend/src/components/IngestPanel.tsx @@ -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 = ({ + onUpload, + isLoading, + success, + error, +}) => { + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (file) { + onUpload(file) + } + } + + return ( +
+ + + + {success && ( +
+ + Uploaded {success} +
+ )} + + {error && ( +
+ + {error} +
+ )} +
+ ) +} diff --git a/frontend/src/test/components/ErrorBoundary.test.tsx b/frontend/src/test/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..60fe799 --- /dev/null +++ b/frontend/src/test/components/ErrorBoundary.test.tsx @@ -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( + +
Normal content
+
+ ) + expect(screen.getByText('Normal content')).toBeInTheDocument() + }) + + it('shows fallback UI when child component throws', () => { + render( + + + + ) + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument() + }) + + it('shows error message in fallback', () => { + render( + + + + ) + expect(screen.getByText(/Test error/i)).toBeInTheDocument() + }) + + it('renders custom fallback prop when provided', () => { + render( + Custom fallback}> + + + ) + expect(screen.getByText('Custom fallback')).toBeInTheDocument() + }) + + it('has a reload button', () => { + render( + + + + ) + const reloadButton = screen.getByRole('button', { name: /reload/i }) + expect(reloadButton).toBeInTheDocument() + expect(reloadButton).toHaveAttribute('type', 'button') + }) +}) diff --git a/frontend/src/test/components/IngestPanel.test.tsx b/frontend/src/test/components/IngestPanel.test.tsx new file mode 100644 index 0000000..c05dbad --- /dev/null +++ b/frontend/src/test/components/IngestPanel.test.tsx @@ -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() + expect(screen.getByText(/upload document/i)).toBeInTheDocument() + }) + + it('button is disabled when isLoading is true', () => { + render() + expect(screen.getByText(/uploading\.\.\./i).closest('label')).toHaveClass('cursor-not-allowed') + }) + + it('shows uploading state when isLoading', () => { + render() + expect(screen.getByText(/uploading\.\.\./i)).toBeInTheDocument() + }) + + it('shows success message when success prop is set', () => { + render( + + ) + expect(screen.getByText(/uploaded test\.pdf/i)).toBeInTheDocument() + }) + + it('shows error message when error prop is set', () => { + render( + + ) + expect(screen.getByText(/upload failed/i)).toBeInTheDocument() + }) + + it('calls onUpload when file is selected', () => { + render() + 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) + }) +})