diff --git a/frontend/src/app/workspace/[id]/page.tsx b/frontend/src/app/workspace/[id]/page.tsx index fd5827b..c60196b 100644 --- a/frontend/src/app/workspace/[id]/page.tsx +++ b/frontend/src/app/workspace/[id]/page.tsx @@ -18,9 +18,9 @@ import { ChatPanel } from '@/components/ChatPanel'; import { DocumentViewer } from '@/components/DocumentViewer'; import { DeleteDocumentDialog } from '@/components/DeleteDocumentDialog'; import { ProviderSelectionProvider } from '@/contexts/ProviderSelectionContext'; -import type { Upload } from '@/components/UploadProgress'; +import type { Upload, ProcessingPhase } from '@/components/UploadProgress'; import { getWorkspace } from '@/lib/api'; -import { uploadDocuments, getDocumentStatus, deleteDocument, type Document } from '@/lib/documents'; +import { uploadDocuments, getDocumentStatus, deleteDocument, uploadUrl, detectUrlType, type Document } from '@/lib/documents'; import { toast } from 'sonner'; import type { Workspace } from '@/lib/types'; import type { CitationData } from '@/types/citation'; @@ -81,12 +81,14 @@ export default function WorkspacePage() { upload.fileName === fileName ? { ...upload, - status: (status.status === 'processing' || status.status === 'embedding') ? 'uploading' : + status: (status.status === 'processing' || status.status === 'embedding' || status.status === 'fetching') ? 'uploading' : status.status === 'ready' ? 'success' : status.status === 'error' ? 'error' : upload.status, - progress: (status.status === 'processing' || status.status === 'embedding') ? 95 : + progress: (status.status === 'processing' || status.status === 'embedding' || status.status === 'fetching') ? 95 : status.status === 'ready' ? 100 : upload.progress, - phase: status.phase ?? undefined, // Pass phase for detailed progress indicator + phase: status.status === 'fetching' + ? 'fetching' as ProcessingPhase + : status.phase ?? undefined, } : upload ) @@ -203,6 +205,53 @@ export default function WorkspacePage() { } }; + // Handle URL submission for ingestion + const handleUrlSubmit = useCallback(async (url: string, sourceType: 'url' | 'youtube' | 'pdf') => { + if (!workspace) return; + + const displayName = url.length > 50 ? url.substring(0, 47) + '...' : url; + + // Add to uploads state for progress display + const newUpload: Upload = { + fileName: displayName, + progress: 10, + status: 'uploading', + }; + setUploads(prev => [...prev, newUpload]); + + try { + const response = await uploadUrl(workspace.id, url, sourceType); + + // Update upload progress + setUploads(prev => + prev.map(u => u.fileName === displayName + ? { ...u, progress: 50, status: 'uploading' as const } + : u + ) + ); + + toast.success(`URL submitted - ${response.message}`); + setDocumentListRefresh(prev => prev + 1); + + // Start polling (reuse existing pollDocumentStatus) + const interval = setInterval(() => { + pollDocumentStatus(response.id, displayName); + }, 2000); + pollingIntervalsRef.current.set(response.id, interval); + pollDocumentStatus(response.id, displayName); + + } catch (err) { + const error = err instanceof Error ? err : new Error('URL upload failed'); + setUploads(prev => + prev.map(u => u.fileName === displayName + ? { ...u, status: 'error' as const, error: error.message } + : u + ) + ); + toast.error(error.message); + } + }, [workspace, pollDocumentStatus]); + // Handle document selection for viewing const handleSelectDocument = (doc: Document) => { setSelectedDocumentId(doc.id); @@ -346,6 +395,7 @@ export default function WorkspacePage() { onUploadError={handleUploadError} onCancelUpload={handleCancel} onRetryUpload={handleRetry} + onUrlSubmit={handleUrlSubmit} /> diff --git a/frontend/src/components/DocumentList.tsx b/frontend/src/components/DocumentList.tsx index bdecad4..b6101df 100644 --- a/frontend/src/components/DocumentList.tsx +++ b/frontend/src/components/DocumentList.tsx @@ -12,7 +12,7 @@ import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'; import { getDocuments, type Document } from '@/lib/documents'; -import { FileText, Trash2, Loader2 } from 'lucide-react'; +import { FileText, Trash2, Loader2, Globe, Play, FileType } from 'lucide-react'; import { cn } from '@/lib/utils'; interface DocumentListProps { @@ -55,6 +55,32 @@ function getStatusStyles(status: string) { } } +/** + * Get the appropriate icon component for a document based on its source type. + * URL documents have null fileType, so we detect type from fileName pattern. + */ +function getDocumentIcon(doc: Document, className: string) { + const name = doc.fileName.toLowerCase(); + + // YouTube URLs + if (name.includes('youtube.com/watch') || name.includes('youtu.be/')) { + return ; + } + + // PDF URLs (null fileType + .pdf in URL) + if (doc.fileType === null && (name.endsWith('.pdf') || name.includes('.pdf?'))) { + return ; + } + + // Web URLs (null fileType, not YouTube/PDF) + if (doc.fileType === null) { + return ; + } + + // Default: regular file upload + return ; +} + export function DocumentList({ workspaceId, onSelectDocument, @@ -135,7 +161,7 @@ export function DocumentList({ onClick={() => onSelectDocument(doc)} data-testid={`collapsed-doc-${doc.id}`} > - + {getDocumentIcon(doc, cn("h-4 w-4", styles.icon))} @@ -167,7 +193,7 @@ export function DocumentList({ onClick={() => onSelectDocument(doc)} > {/* Icon - color indicates status */} - + {getDocumentIcon(doc, cn("h-4 w-4 flex-shrink-0", styles.icon))} {/* File name - takes full available space */}

diff --git a/frontend/src/components/DocumentSidebar.tsx b/frontend/src/components/DocumentSidebar.tsx index 21ecf32..7788295 100644 --- a/frontend/src/components/DocumentSidebar.tsx +++ b/frontend/src/components/DocumentSidebar.tsx @@ -38,6 +38,8 @@ export interface DocumentSidebarProps { onCancelUpload: (fileName: string) => void; /** Callback to retry a failed upload (kept for interface compatibility) */ onRetryUpload: (fileName: string) => void; + /** Callback when a URL is submitted for ingestion */ + onUrlSubmit?: (url: string, sourceType: 'url' | 'youtube' | 'pdf') => void; } /** @@ -60,6 +62,7 @@ export function DocumentSidebar({ onUploadError, onCancelUpload: _onCancelUpload = () => { }, // eslint-disable-line @typescript-eslint/no-unused-vars -- Kept for interface compatibility onRetryUpload: _onRetryUpload = () => { }, // eslint-disable-line @typescript-eslint/no-unused-vars -- Kept for interface compatibility + onUrlSubmit, }: DocumentSidebarProps) { const [isCollapsed, setIsCollapsed] = useLocalStorage('sidebar-collapsed', false); const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); @@ -139,6 +142,7 @@ export function DocumentSidebar({ onFilesSelected={onFilesSelected} onUploadComplete={onUploadComplete} onUploadError={onUploadError} + onUrlSubmit={onUrlSubmit} /> ); diff --git a/frontend/src/components/UploadModal.tsx b/frontend/src/components/UploadModal.tsx index 66a23e6..adf5f6a 100644 --- a/frontend/src/components/UploadModal.tsx +++ b/frontend/src/components/UploadModal.tsx @@ -2,11 +2,13 @@ * UploadModal component * * Modal dialog wrapping the IngestionDropzone for document uploads. + * Also includes URL input for web page, YouTube, and PDF ingestion. * Triggered from the sidebar "Add Source" button per UX spec. */ 'use client'; +import { useState } from 'react'; import { Dialog, DialogContent, @@ -14,7 +16,12 @@ import { DialogTitle, DialogDescription, } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { IngestionDropzone } from '@/components/IngestionDropzone'; +import { Globe, Play, FileType } from 'lucide-react'; +import { detectUrlType } from '@/lib/documents'; import type { Document } from '@/lib/documents'; export interface UploadModalProps { @@ -30,11 +37,27 @@ export interface UploadModalProps { onUploadComplete: (documents: Document[]) => void; /** Callback when upload fails */ onUploadError: (error: Error) => void; + /** Callback when URL is submitted for ingestion */ + onUrlSubmit?: (url: string, sourceType: 'url' | 'youtube' | 'pdf') => void; +} + +/** + * Get the URL type icon component based on detected type + */ +function UrlTypeIcon({ urlType }: { urlType: 'youtube' | 'pdf' | 'url' }) { + switch (urlType) { + case 'youtube': + return ; + case 'pdf': + return ; + default: + return ; + } } /** * Modal dialog for document upload. - * Wraps IngestionDropzone in a Dialog component. + * Wraps IngestionDropzone in a Dialog component with URL input above. */ export function UploadModal({ open, @@ -43,13 +66,26 @@ export function UploadModal({ onFilesSelected, onUploadComplete, onUploadError, + onUrlSubmit, }: UploadModalProps) { + const [urlValue, setUrlValue] = useState(''); + const handleFilesSelected = (files: File[]) => { onFilesSelected(files); // Close modal after files are selected (upload will continue in background) onOpenChange(false); }; + const handleUrlSubmit = () => { + const trimmed = urlValue.trim(); + if (!trimmed || !onUrlSubmit) return; + const sourceType = detectUrlType(trimmed); + onUrlSubmit(trimmed, sourceType); + setUrlValue(''); + }; + + const detectedType = urlValue.trim() ? detectUrlType(urlValue) : null; + return (

Add Documents - Upload documents to your workspace for RAG-powered conversations. + Upload documents or add URLs to your workspace for RAG-powered conversations. -
+
+ {/* URL Input Section */} +
+ +
+ {detectedType && } + setUrlValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleUrlSubmit(); + } + }} + /> +
+ +
+ + {/* Visual separator */} +
+
+ or +
+
+ + {/* File Dropzone */} = 95 && (

- {upload.phase === 'embedding' - ? 'Generating Embeddings...' - : 'Parsing document...'} + {upload.phase === 'fetching' + ? 'Fetching content...' + : upload.phase === 'embedding' + ? 'Generating Embeddings...' + : 'Parsing document...'}

)} {upload.error && ( diff --git a/frontend/src/components/__tests__/DocumentList.test.tsx b/frontend/src/components/__tests__/DocumentList.test.tsx index 8d8f218..5a352a5 100644 --- a/frontend/src/components/__tests__/DocumentList.test.tsx +++ b/frontend/src/components/__tests__/DocumentList.test.tsx @@ -189,4 +189,115 @@ describe('DocumentList', () => { expect(getDocuments).toHaveBeenCalledWith('ws-2'); }); }); + + it('renders Globe icon for web URL documents', async () => { + const { getDocuments } = await import('@/lib/documents'); + vi.mocked(getDocuments).mockResolvedValue({ + documents: [ + { id: '1', fileName: 'https://example.com/article', fileType: null, status: 'ready', createdAt: '2025-12-01T10:00:00Z' }, + ], + total: 1, + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId('doc-icon-globe')).toBeInTheDocument(); + }); + }); + + it('renders YouTube icon for YouTube URL documents', async () => { + const { getDocuments } = await import('@/lib/documents'); + vi.mocked(getDocuments).mockResolvedValue({ + documents: [ + { id: '1', fileName: 'https://youtube.com/watch?v=abc', fileType: null, status: 'ready', createdAt: '2025-12-01T10:00:00Z' }, + ], + total: 1, + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId('doc-icon-youtube')).toBeInTheDocument(); + }); + }); + + it('renders PDF icon for PDF URL documents', async () => { + const { getDocuments } = await import('@/lib/documents'); + vi.mocked(getDocuments).mockResolvedValue({ + documents: [ + { id: '1', fileName: 'https://example.com/doc.pdf', fileType: null, status: 'ready', createdAt: '2025-12-01T10:00:00Z' }, + ], + total: 1, + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId('doc-icon-pdf')).toBeInTheDocument(); + }); + }); + + it('renders FileText icon for regular file documents', async () => { + const { getDocuments } = await import('@/lib/documents'); + vi.mocked(getDocuments).mockResolvedValue({ + documents: [ + { id: '1', fileName: 'report.docx', fileType: 'docx', status: 'ready', createdAt: '2025-12-01T10:00:00Z' }, + ], + total: 1, + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId('doc-icon-file')).toBeInTheDocument(); + }); + }); + + it('renders correct icon in collapsed mode for URL documents', async () => { + const { getDocuments } = await import('@/lib/documents'); + vi.mocked(getDocuments).mockResolvedValue({ + documents: [ + { id: '1', fileName: 'https://youtube.com/watch?v=abc', fileType: null, status: 'ready', createdAt: '2025-12-01T10:00:00Z' }, + ], + total: 1, + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId('doc-icon-youtube')).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/__tests__/UploadModal.test.tsx b/frontend/src/components/__tests__/UploadModal.test.tsx index a08ecc9..d75fe67 100644 --- a/frontend/src/components/__tests__/UploadModal.test.tsx +++ b/frontend/src/components/__tests__/UploadModal.test.tsx @@ -3,7 +3,8 @@ */ import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { UploadModal } from '../UploadModal'; // Mock the IngestionDropzone component @@ -23,14 +24,19 @@ describe('UploadModal', () => { onFilesSelected: vi.fn(), onUploadComplete: vi.fn(), onUploadError: vi.fn(), + onUrlSubmit: vi.fn(), }; + beforeEach(() => { + vi.clearAllMocks(); + }); + it('renders the modal when open is true', () => { render(); expect(screen.getByTestId('upload-modal')).toBeInTheDocument(); expect(screen.getByText('Add Documents')).toBeInTheDocument(); - expect(screen.getByText(/Upload documents to your workspace/)).toBeInTheDocument(); + expect(screen.getByText(/Upload documents or add URLs/)).toBeInTheDocument(); }); it('does not render modal content when open is false', () => { @@ -61,4 +67,105 @@ describe('UploadModal', () => { expect(screen.getByRole('heading', { name: 'Add Documents' })).toBeInTheDocument(); }); + + // AC1: URL Input Field + it('renders URL input field with correct label and placeholder', () => { + render(); + + const urlInput = screen.getByTestId('url-input'); + expect(urlInput).toBeInTheDocument(); + expect(urlInput).toHaveAttribute('placeholder', 'https://example.com/article'); + expect(screen.getByText('Enter URL (web page, YouTube, PDF)')).toBeInTheDocument(); + }); + + it('renders URL input above the file dropzone', () => { + render(); + + // URL input should come before the dropzone in the DOM + const modal = screen.getByTestId('upload-modal'); + const allElements = modal.querySelectorAll('[data-testid]'); + const urlIndex = Array.from(allElements).findIndex(el => el.getAttribute('data-testid') === 'url-input'); + const dropzoneIndex = Array.from(allElements).findIndex(el => el.getAttribute('data-testid') === 'ingestion-dropzone'); + expect(urlIndex).toBeGreaterThanOrEqual(0); + expect(dropzoneIndex).toBeGreaterThanOrEqual(0); + expect(urlIndex).toBeLessThan(dropzoneIndex); + }); + + // AC2: URL Type Detection Visual Badge + it('shows Globe icon for regular web URLs', async () => { + const user = userEvent.setup(); + render(); + + const urlInput = screen.getByTestId('url-input'); + await user.type(urlInput, 'https://example.com/article'); + + expect(screen.getByTestId('url-type-icon-web')).toBeInTheDocument(); + }); + + it('shows YouTube icon for YouTube URLs', async () => { + const user = userEvent.setup(); + render(); + + const urlInput = screen.getByTestId('url-input'); + await user.type(urlInput, 'https://youtube.com/watch?v=abc'); + + expect(screen.getByTestId('url-type-icon-youtube')).toBeInTheDocument(); + }); + + it('shows PDF icon for PDF URLs', async () => { + const user = userEvent.setup(); + render(); + + const urlInput = screen.getByTestId('url-input'); + await user.type(urlInput, 'https://example.com/doc.pdf'); + + expect(screen.getByTestId('url-type-icon-pdf')).toBeInTheDocument(); + }); + + // AC3: Submit URL Button + it('renders Add URL button', () => { + render(); + + expect(screen.getByTestId('add-url-button')).toBeInTheDocument(); + expect(screen.getByTestId('add-url-button')).toHaveTextContent('Add URL'); + }); + + it('calls onUrlSubmit with url and sourceType when Add URL clicked', async () => { + const user = userEvent.setup(); + render(); + + const urlInput = screen.getByTestId('url-input'); + await user.type(urlInput, 'https://example.com/article'); + await user.click(screen.getByTestId('add-url-button')); + + expect(defaultProps.onUrlSubmit).toHaveBeenCalledWith('https://example.com/article', 'url'); + }); + + it('clears URL input after successful submit', async () => { + const user = userEvent.setup(); + render(); + + const urlInput = screen.getByTestId('url-input'); + await user.type(urlInput, 'https://example.com/article'); + await user.click(screen.getByTestId('add-url-button')); + + expect(urlInput).toHaveValue(''); + }); + + it('does not call onUrlSubmit when URL input is empty', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('add-url-button')); + + expect(defaultProps.onUrlSubmit).not.toHaveBeenCalled(); + }); + + // Visual separator + it('renders visual separator between URL input and dropzone', () => { + render(); + + expect(screen.getByTestId('url-file-separator')).toBeInTheDocument(); + expect(screen.getByText('or')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/__tests__/UploadProgress.test.tsx b/frontend/src/components/__tests__/UploadProgress.test.tsx index 3afb5fe..8137307 100644 --- a/frontend/src/components/__tests__/UploadProgress.test.tsx +++ b/frontend/src/components/__tests__/UploadProgress.test.tsx @@ -165,4 +165,19 @@ describe('UploadProgress', () => { expect(screen.getByText('Parsing document...')).toBeInTheDocument(); }); + + it('shows "Fetching content..." when in fetching phase', () => { + const uploads = [ + { + fileName: 'https://example.com/page', + progress: 95, + status: 'uploading' as const, + phase: 'fetching' as const, + }, + ]; + + render(); + + expect(screen.getByText('Fetching content...')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/lib/__tests__/documents.test.ts b/frontend/src/lib/__tests__/documents.test.ts index cc465a9..8e66d5a 100644 --- a/frontend/src/lib/__tests__/documents.test.ts +++ b/frontend/src/lib/__tests__/documents.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { uploadDocuments } from '../documents'; +import { uploadDocuments, uploadUrl, detectUrlType } from '../documents'; describe('documents API', () => { const originalFetch = global.fetch; @@ -123,4 +123,146 @@ describe('documents API', () => { expect(uploadedFiles).toHaveLength(3); }); }); + + describe('uploadUrl', () => { + it('sends POST request with JSON body', async () => { + const workspaceId = 'test-workspace-id'; + const url = 'https://example.com/article'; + const sourceType = 'url' as const; + + const mockResponse = { + id: 'doc-1', + sourceType: 'url', + sourceUrl: url, + status: 'backlog', + message: 'URL submitted for processing', + }; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockResponse, + }); + + const result = await uploadUrl(workspaceId, url, sourceType); + + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [fetchUrl, options] = mockFetch.mock.calls[0]; + expect(fetchUrl).toContain(`/api/v1/workspaces/${workspaceId}/documents/upload-url`); + expect(options.method).toBe('POST'); + expect(options.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(JSON.parse(options.body)).toEqual({ sourceType: 'url', url }); + + expect(result).toEqual(mockResponse); + }); + + it('sends correct sourceType for YouTube URLs', async () => { + const mockResponse = { + id: 'doc-2', + sourceType: 'youtube', + sourceUrl: 'https://youtube.com/watch?v=abc', + status: 'backlog', + message: 'URL submitted for processing', + }; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockResponse, + }); + + await uploadUrl('ws-1', 'https://youtube.com/watch?v=abc', 'youtube'); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.sourceType).toBe('youtube'); + }); + + it('throws error on 400 invalid URL', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ detail: 'Only HTTP and HTTPS URLs are allowed' }), + }); + + await expect(uploadUrl('ws-1', 'ftp://example.com', 'url')).rejects.toThrow( + 'Only HTTP and HTTPS URLs are allowed' + ); + }); + + it('throws error on 409 duplicate URL', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 409, + json: async () => ({ detail: 'URL already submitted for this workspace' }), + }); + + await expect(uploadUrl('ws-1', 'https://example.com', 'url')).rejects.toThrow( + 'URL already submitted for this workspace' + ); + }); + + it('handles network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect(uploadUrl('ws-1', 'https://example.com', 'url')).rejects.toThrow( + 'Network error' + ); + }); + + it('handles non-JSON error responses', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + json: async () => { throw new Error('not json'); }, + }); + + await expect(uploadUrl('ws-1', 'https://example.com', 'url')).rejects.toThrow( + 'URL upload failed with status 500' + ); + }); + }); + + describe('detectUrlType', () => { + it('detects YouTube URLs with youtube.com/watch', () => { + expect(detectUrlType('https://youtube.com/watch?v=abc123')).toBe('youtube'); + expect(detectUrlType('https://www.youtube.com/watch?v=abc123')).toBe('youtube'); + }); + + it('detects YouTube URLs with youtu.be/', () => { + expect(detectUrlType('https://youtu.be/abc123')).toBe('youtube'); + }); + + it('detects YouTube URLs case-insensitively', () => { + expect(detectUrlType('https://YOUTUBE.COM/watch?v=abc')).toBe('youtube'); + expect(detectUrlType('https://YouTu.Be/abc')).toBe('youtube'); + }); + + it('detects PDF URLs by extension', () => { + expect(detectUrlType('https://example.com/document.pdf')).toBe('pdf'); + }); + + it('detects PDF URLs with query parameters', () => { + expect(detectUrlType('https://example.com/doc.pdf?token=abc')).toBe('pdf'); + }); + + it('detects PDF URLs case-insensitively', () => { + expect(detectUrlType('https://example.com/Document.PDF')).toBe('pdf'); + }); + + it('returns url for regular web URLs', () => { + expect(detectUrlType('https://example.com/article')).toBe('url'); + expect(detectUrlType('https://blog.example.com/post/123')).toBe('url'); + }); + + it('returns url for empty or whitespace input', () => { + expect(detectUrlType('')).toBe('url'); + expect(detectUrlType(' ')).toBe('url'); + }); + + it('handles URLs with trailing whitespace', () => { + expect(detectUrlType('https://youtube.com/watch?v=abc ')).toBe('youtube'); + expect(detectUrlType('https://example.com/doc.pdf ')).toBe('pdf'); + }); + }); }); diff --git a/frontend/src/lib/documents.ts b/frontend/src/lib/documents.ts index b3fecbc..5d7fa87 100644 --- a/frontend/src/lib/documents.ts +++ b/frontend/src/lib/documents.ts @@ -43,6 +43,37 @@ export interface DocumentStatus { phase: 'parsing' | 'embedding' | null; // Current processing phase } +export interface URLUploadResponse { + id: string; + sourceType: 'url' | 'youtube' | 'pdf'; + sourceUrl: string; + status: string; + message: string; +} + +/** + * Detect URL type based on URL pattern + * + * @param url - URL string to analyze + * @returns 'youtube' for YouTube URLs, 'pdf' for PDF URLs, 'url' for other web URLs + */ +export function detectUrlType(url: string): 'youtube' | 'pdf' | 'url' { + const lower = url.toLowerCase().trim(); + + // YouTube detection + if (lower.includes('youtube.com/watch') || lower.includes('youtu.be/')) { + return 'youtube'; + } + + // PDF detection (by extension) + if (lower.endsWith('.pdf') || lower.includes('.pdf?')) { + return 'pdf'; + } + + // Default: web URL + return 'url'; +} + /** * Upload multiple documents to a workspace * @@ -176,3 +207,35 @@ export async function deleteDocument(documentId: string): Promise { throw new Error(error.detail); } } + +/** + * Upload a URL for document ingestion + * + * @param workspaceId - Target workspace ID + * @param url - URL to ingest (web page, YouTube, or PDF) + * @param sourceType - Detected URL type ('url', 'youtube', or 'pdf') + * @returns Promise with URL upload response containing document metadata + * @throws Error if upload fails + */ +export async function uploadUrl( + workspaceId: string, + url: string, + sourceType: 'url' | 'youtube' | 'pdf', +): Promise { + const apiUrl = `${API_BASE}/api/v1/workspaces/${workspaceId}/documents/upload-url`; + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceType, url }), + }); + + if (!response.ok) { + const error: ApiError = await response.json().catch(() => ({ + detail: `URL upload failed with status ${response.status}`, + })); + throw new Error(error.detail); + } + + return response.json(); +} diff --git a/frontend/tests/e2e/url-upload.spec.ts b/frontend/tests/e2e/url-upload.spec.ts new file mode 100644 index 0000000..26a0a73 --- /dev/null +++ b/frontend/tests/e2e/url-upload.spec.ts @@ -0,0 +1,269 @@ +/** + * E2E Tests for URL Upload Flow + * + * Tests URL submission via modal with mocked API responses, + * document list integration, and error scenarios. + */ + +import { test, expect } from '@playwright/test'; + +/** + * Helper to set up API mocks for workspace and document operations + */ +async function setupWorkspaceMocks(page: import('@playwright/test').Page, workspaceId: string) { + // Mock workspace detail + await page.route(`**/api/v1/workspaces/${workspaceId}`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: workspaceId, + name: 'Test Workspace', + description: 'A test workspace', + createdAt: '2025-12-01T00:00:00Z', + updatedAt: '2025-12-01T00:00:00Z', + }), + }); + } else { + await route.continue(); + } + }); + + // Mock document list + await page.route(`**/api/v1/workspaces/${workspaceId}/documents`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ documents: [], total: 0 }), + }); + } else { + await route.continue(); + } + }); + + // Mock chat providers + await page.route('**/api/v1/chat/providers', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ providers: [] }), + }); + }); +} + +test.describe('URL Upload', () => { + const workspaceId = 'test-ws-url'; + + test('should submit a web URL via modal and show in progress', async ({ page }) => { + await setupWorkspaceMocks(page, workspaceId); + + // Mock URL upload endpoint + await page.route(`**/api/v1/workspaces/${workspaceId}/documents/upload-url`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'doc-url-1', + sourceType: 'url', + sourceUrl: 'https://example.com/article', + status: 'backlog', + message: 'URL submitted for processing', + }), + }); + }); + + // Mock status polling — first call returns fetching, second returns ready + let pollCount = 0; + await page.route('**/api/v1/workspaces/documents/doc-url-1/status', async (route) => { + pollCount++; + if (pollCount <= 1) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'doc-url-1', + status: 'fetching', + fileName: 'https://example.com/article', + phase: null, + }), + }); + } else { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 'doc-url-1', + status: 'ready', + fileName: 'https://example.com/article', + phase: null, + }), + }); + } + }); + + await page.goto(`/workspace/${workspaceId}`); + await expect(page.getByText('Test Workspace')).toBeVisible(); + + // Open the upload modal + await page.getByTestId('add-source-button').click(); + await expect(page.getByTestId('upload-modal')).toBeVisible(); + + // Enter URL + const urlInput = page.getByTestId('url-input'); + await urlInput.fill('https://example.com/article'); + + // Should show web icon + await expect(page.getByTestId('url-type-icon-web')).toBeVisible(); + + // Click Add URL + await page.getByTestId('add-url-button').click(); + + // URL input should be cleared + await expect(urlInput).toHaveValue(''); + + // Toast should show + await expect(page.getByText(/URL submitted/i)).toBeVisible(); + }); + + test('should show YouTube icon for YouTube URLs in modal', async ({ page }) => { + await setupWorkspaceMocks(page, workspaceId); + + await page.goto(`/workspace/${workspaceId}`); + await page.getByTestId('add-source-button').click(); + await expect(page.getByTestId('upload-modal')).toBeVisible(); + + await page.getByTestId('url-input').fill('https://youtube.com/watch?v=abc123'); + await expect(page.getByTestId('url-type-icon-youtube')).toBeVisible(); + }); + + test('should show PDF icon for PDF URLs in modal', async ({ page }) => { + await setupWorkspaceMocks(page, workspaceId); + + await page.goto(`/workspace/${workspaceId}`); + await page.getByTestId('add-source-button').click(); + await expect(page.getByTestId('upload-modal')).toBeVisible(); + + await page.getByTestId('url-input').fill('https://example.com/document.pdf'); + await expect(page.getByTestId('url-type-icon-pdf')).toBeVisible(); + }); + + test('should display error for invalid URL (400)', async ({ page }) => { + await setupWorkspaceMocks(page, workspaceId); + + // Mock 400 error + await page.route(`**/api/v1/workspaces/${workspaceId}/documents/upload-url`, async (route) => { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + detail: 'Only HTTP and HTTPS URLs are allowed', + }), + }); + }); + + await page.goto(`/workspace/${workspaceId}`); + await page.getByTestId('add-source-button').click(); + await page.getByTestId('url-input').fill('ftp://example.com/file'); + await page.getByTestId('add-url-button').click(); + + // Error toast should appear + await expect(page.getByText(/Only HTTP and HTTPS URLs are allowed/i)).toBeVisible(); + }); + + test('should display error for SSRF-blocked URL', async ({ page }) => { + await setupWorkspaceMocks(page, workspaceId); + + await page.route(`**/api/v1/workspaces/${workspaceId}/documents/upload-url`, async (route) => { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + detail: 'Private and localhost URLs are not allowed', + }), + }); + }); + + await page.goto(`/workspace/${workspaceId}`); + await page.getByTestId('add-source-button').click(); + await page.getByTestId('url-input').fill('http://localhost:8080/secret'); + await page.getByTestId('add-url-button').click(); + + await expect(page.getByText(/Private and localhost URLs are not allowed/i)).toBeVisible(); + }); + + test('should display error for duplicate URL (409)', async ({ page }) => { + await setupWorkspaceMocks(page, workspaceId); + + await page.route(`**/api/v1/workspaces/${workspaceId}/documents/upload-url`, async (route) => { + await route.fulfill({ + status: 409, + contentType: 'application/json', + body: JSON.stringify({ + detail: 'URL already submitted for this workspace', + }), + }); + }); + + await page.goto(`/workspace/${workspaceId}`); + await page.getByTestId('add-source-button').click(); + await page.getByTestId('url-input').fill('https://example.com/article'); + await page.getByTestId('add-url-button').click(); + + await expect(page.getByText(/URL already submitted for this workspace/i)).toBeVisible(); + }); + + test('should show document with correct source-type icon after ingestion', async ({ page }) => { + const wsId = 'test-ws-icons'; + + // Mock workspace + await page.route(`**/api/v1/workspaces/${wsId}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: wsId, + name: 'Icon Test Workspace', + description: null, + createdAt: '2025-12-01T00:00:00Z', + updatedAt: '2025-12-01T00:00:00Z', + }), + }); + }); + + // Mock document list with URL-based documents + await page.route(`**/api/v1/workspaces/${wsId}/documents`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + documents: [ + { id: 'd1', fileName: 'https://youtube.com/watch?v=abc', fileType: null, status: 'ready', createdAt: '2025-12-01T00:00:00Z' }, + { id: 'd2', fileName: 'https://example.com/paper.pdf', fileType: null, status: 'ready', createdAt: '2025-12-01T00:00:00Z' }, + { id: 'd3', fileName: 'https://blog.example.com/post', fileType: null, status: 'ready', createdAt: '2025-12-01T00:00:00Z' }, + { id: 'd4', fileName: 'report.docx', fileType: 'docx', status: 'ready', createdAt: '2025-12-01T00:00:00Z' }, + ], + total: 4, + }), + }); + }); + + // Mock chat providers + await page.route('**/api/v1/chat/providers', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ providers: [] }), + }); + }); + + await page.goto(`/workspace/${wsId}`); + + // Verify icons render for each document type + await expect(page.getByTestId('doc-icon-youtube')).toBeVisible(); + await expect(page.getByTestId('doc-icon-pdf')).toBeVisible(); + await expect(page.getByTestId('doc-icon-globe')).toBeVisible(); + await expect(page.getByTestId('doc-icon-file')).toBeVisible(); + }); +});