From 76529f83be470adadbc3833808471cfe687f3759 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Tue, 17 Jun 2025 02:41:16 +0000 Subject: [PATCH] feat(client/server): implement a much better search --- .../AdvancedSearchPanel.tsx | 582 ++++++++++++++++++ .../__tests__/AdvancedSearchPanel.test.tsx | 443 +++++++++++++ .../components/AdvancedSearchPanel/index.ts | 1 + .../EnhancedSearchGuide.tsx | 373 +++++++++++ .../__tests__/EnhancedSearchGuide.test.tsx | 140 +++++ .../components/EnhancedSearchGuide/index.ts | 1 + .../EnhancedSnippetViewer.tsx | 429 +++++++++++++ .../__tests__/EnhancedSnippetViewer.test.tsx | 341 ++++++++++ .../components/EnhancedSnippetViewer/index.ts | 1 + .../MimeTypeFacetFilter.tsx | 380 ++++++++++++ .../__tests__/MimeTypeFacetFilter.test.tsx | 291 +++++++++ .../components/MimeTypeFacetFilter/index.ts | 1 + frontend/src/pages/SearchPage.tsx | 208 +++---- .../__tests__/SearchPage.integration.test.tsx | 469 ++++++++++++++ frontend/src/services/api.ts | 24 + src/db/documents.rs | 78 +++ src/models.rs | 16 + src/routes/search.rs | 50 +- 18 files changed, 3692 insertions(+), 136 deletions(-) create mode 100644 frontend/src/components/AdvancedSearchPanel/AdvancedSearchPanel.tsx create mode 100644 frontend/src/components/AdvancedSearchPanel/__tests__/AdvancedSearchPanel.test.tsx create mode 100644 frontend/src/components/AdvancedSearchPanel/index.ts create mode 100644 frontend/src/components/EnhancedSearchGuide/EnhancedSearchGuide.tsx create mode 100644 frontend/src/components/EnhancedSearchGuide/__tests__/EnhancedSearchGuide.test.tsx create mode 100644 frontend/src/components/EnhancedSearchGuide/index.ts create mode 100644 frontend/src/components/EnhancedSnippetViewer/EnhancedSnippetViewer.tsx create mode 100644 frontend/src/components/EnhancedSnippetViewer/__tests__/EnhancedSnippetViewer.test.tsx create mode 100644 frontend/src/components/EnhancedSnippetViewer/index.ts create mode 100644 frontend/src/components/MimeTypeFacetFilter/MimeTypeFacetFilter.tsx create mode 100644 frontend/src/components/MimeTypeFacetFilter/__tests__/MimeTypeFacetFilter.test.tsx create mode 100644 frontend/src/components/MimeTypeFacetFilter/index.ts create mode 100644 frontend/src/pages/__tests__/SearchPage.integration.test.tsx diff --git a/frontend/src/components/AdvancedSearchPanel/AdvancedSearchPanel.tsx b/frontend/src/components/AdvancedSearchPanel/AdvancedSearchPanel.tsx new file mode 100644 index 0000000..db7c47c --- /dev/null +++ b/frontend/src/components/AdvancedSearchPanel/AdvancedSearchPanel.tsx @@ -0,0 +1,582 @@ +import React, { useState } from 'react'; +import { + Box, + Paper, + Typography, + FormControl, + FormControlLabel, + Switch, + Select, + MenuItem, + InputLabel, + Accordion, + AccordionSummary, + AccordionDetails, + Chip, + Slider, + TextField, + Button, + Divider, + IconButton, + Tooltip, + Alert, + List, + ListItem, + ListItemIcon, + ListItemText, + ToggleButtonGroup, + ToggleButton, + Card, + CardContent, + Collapse, + Badge, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + ExpandLess as ExpandLessIcon, + Settings as SettingsIcon, + Speed as SpeedIcon, + Visibility as VisibilityIcon, + TextSnippet as SnippetIcon, + Search as SearchIcon, + Tune as TuneIcon, + Psychology as PsychologyIcon, + FormatQuote as QuoteIcon, + Code as CodeIcon, + BlurOn as BlurIcon, + Help as HelpIcon, + RestoreFromTrash as ResetIcon, + BookmarkBorder as SaveIcon, + Lightbulb as TipIcon, +} from '@mui/icons-material'; + +type SearchMode = 'simple' | 'phrase' | 'fuzzy' | 'boolean'; + +interface AdvancedSearchSettings { + useEnhancedSearch: boolean; + searchMode: SearchMode; + includeSnippets: boolean; + snippetLength: number; + fuzzyThreshold: number; + resultLimit: number; + includeOcrText: boolean; + includeFileContent: boolean; + includeFilenames: boolean; + boostRecentDocs: boolean; + enableAutoCorrect: boolean; +} + +interface AdvancedSearchPanelProps { + settings: AdvancedSearchSettings; + onSettingsChange: (settings: Partial) => void; + onSavePreset?: (name: string, settings: AdvancedSearchSettings) => void; + onLoadPreset?: (preset: AdvancedSearchSettings) => void; + availablePresets?: { name: string; settings: AdvancedSearchSettings }[]; + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; +} + +const AdvancedSearchPanel: React.FC = ({ + settings, + onSettingsChange, + onSavePreset, + onLoadPreset, + availablePresets = [], + expanded = false, + onExpandedChange, +}) => { + const [activeSection, setActiveSection] = useState('search-behavior'); + const [showPresetSave, setShowPresetSave] = useState(false); + const [presetName, setPresetName] = useState(''); + + const sections = [ + { + id: 'search-behavior', + label: 'Search Behavior', + icon: , + description: 'How search queries are processed and matched', + }, + { + id: 'results-display', + label: 'Results Display', + icon: , + description: 'How search results are shown and formatted', + }, + { + id: 'performance', + label: 'Performance', + icon: , + description: 'Speed and resource optimization settings', + }, + { + id: 'content-sources', + label: 'Content Sources', + icon: , + description: 'Which parts of documents to search', + }, + ]; + + const handleSettingChange = ( + key: K, + value: AdvancedSearchSettings[K] + ) => { + onSettingsChange({ [key]: value }); + }; + + const handleResetToDefaults = () => { + const defaults: AdvancedSearchSettings = { + useEnhancedSearch: true, + searchMode: 'simple', + includeSnippets: true, + snippetLength: 200, + fuzzyThreshold: 0.8, + resultLimit: 100, + includeOcrText: true, + includeFileContent: true, + includeFilenames: true, + boostRecentDocs: false, + enableAutoCorrect: true, + }; + onSettingsChange(defaults); + }; + + const handleSavePreset = () => { + if (presetName.trim() && onSavePreset) { + onSavePreset(presetName.trim(), settings); + setPresetName(''); + setShowPresetSave(false); + } + }; + + const getSearchModeDescription = (mode: SearchMode) => { + switch (mode) { + case 'simple': + return 'Basic keyword matching with stemming'; + case 'phrase': + return 'Exact phrase matching in order'; + case 'fuzzy': + return 'Flexible matching with typo tolerance'; + case 'boolean': + return 'Advanced operators (AND, OR, NOT)'; + default: + return ''; + } + }; + + return ( + + onExpandedChange?.(!expanded)} + > + + + + + + + + Advanced Search Options + + + Customize search behavior and result display + + + + + + + {expanded ? : } + + + + + + + + {/* Section Tabs */} + + + {sections.map((section) => ( + + ))} + + + + + {/* Search Behavior Section */} + {activeSection === 'search-behavior' && ( + + + + These settings control how your search queries are interpreted and matched against documents. + + + + + + + Search Mode + + + + {getSearchModeDescription(settings.searchMode)} + + + + + + Fuzzy Match Threshold: {settings.fuzzyThreshold} + + handleSettingChange('fuzzyThreshold', value as number)} + min={0.1} + max={1.0} + step={0.1} + marks + disabled={settings.searchMode !== 'fuzzy'} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${(value * 100).toFixed(0)}%`} + /> + + Higher values = stricter matching + + + + + + + handleSettingChange('useEnhancedSearch', e.target.checked)} + /> + } + label="Enhanced Search Engine" + /> + handleSettingChange('enableAutoCorrect', e.target.checked)} + /> + } + label="Auto-correct Spelling" + /> + handleSettingChange('boostRecentDocs', e.target.checked)} + /> + } + label="Boost Recent Documents" + /> + + + + )} + + {/* Results Display Section */} + {activeSection === 'results-display' && ( + + + + + Control how search results are presented and what information is shown. + + + + + + handleSettingChange('includeSnippets', e.target.checked)} + /> + } + label="Show Text Snippets" + /> + + + + + Snippet Length + + + + + + + Results Per Page + + + + + )} + + {/* Content Sources Section */} + {activeSection === 'content-sources' && ( + + + + + Choose which parts of your documents to include in search. + + + + + + + Search In: + + + handleSettingChange('includeFileContent', e.target.checked)} + /> + } + label="Document Content" + /> + handleSettingChange('includeOcrText', e.target.checked)} + /> + } + label="OCR Extracted Text" + /> + handleSettingChange('includeFilenames', e.target.checked)} + /> + } + label="Filenames" + /> + + + + )} + + {/* Performance Section */} + {activeSection === 'performance' && ( + + + + + These settings can affect search speed. Use with caution for large document collections. + + + + + + + Maximum Results: {settings.resultLimit} + + handleSettingChange('resultLimit', value as number)} + min={10} + max={500} + step={10} + marks={[ + { value: 25, label: '25' }, + { value: 100, label: '100' }, + { value: 250, label: '250' }, + { value: 500, label: '500' }, + ]} + valueLabelDisplay="auto" + /> + + Higher values may slow down search for large collections + + + + )} + + {/* Action Buttons */} + + + + + + + + + {availablePresets.length > 0 && ( + + Load Preset + + + )} + + + {/* Save Preset Dialog */} + + + + Save Current Settings as Preset + + + setPresetName(e.target.value)} + fullWidth + /> + + + + + + + + + + ); +}; + +export default AdvancedSearchPanel; \ No newline at end of file diff --git a/frontend/src/components/AdvancedSearchPanel/__tests__/AdvancedSearchPanel.test.tsx b/frontend/src/components/AdvancedSearchPanel/__tests__/AdvancedSearchPanel.test.tsx new file mode 100644 index 0000000..ea59ac7 --- /dev/null +++ b/frontend/src/components/AdvancedSearchPanel/__tests__/AdvancedSearchPanel.test.tsx @@ -0,0 +1,443 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import AdvancedSearchPanel from '../AdvancedSearchPanel'; + +const mockSettings = { + useEnhancedSearch: true, + searchMode: 'simple' as const, + includeSnippets: true, + snippetLength: 200, + fuzzyThreshold: 0.8, + resultLimit: 100, + includeOcrText: true, + includeFileContent: true, + includeFilenames: true, + boostRecentDocs: false, + enableAutoCorrect: true, +}; + +const mockPresets = [ + { + name: 'Fast Search', + settings: { + ...mockSettings, + includeSnippets: false, + resultLimit: 50, + }, + }, + { + name: 'Detailed Search', + settings: { + ...mockSettings, + snippetLength: 400, + resultLimit: 200, + }, + }, +]; + +describe('AdvancedSearchPanel', () => { + const mockOnSettingsChange = vi.fn(); + const mockOnExpandedChange = vi.fn(); + const mockOnSavePreset = vi.fn(); + const mockOnLoadPreset = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('renders collapsed state by default', () => { + render( + + ); + + expect(screen.getByText('Advanced Search Options')).toBeInTheDocument(); + expect(screen.getByText('Customize search behavior and result display')).toBeInTheDocument(); + expect(screen.getByText('SIMPLE')).toBeInTheDocument(); + expect(screen.queryByText('Search Behavior')).not.toBeInTheDocument(); + }); + + test('expands when expanded prop is true', () => { + render( + + ); + + expect(screen.getByText('Search Behavior')).toBeInTheDocument(); + expect(screen.getByText('Results Display')).toBeInTheDocument(); + expect(screen.getByText('Performance')).toBeInTheDocument(); + expect(screen.getByText('Content Sources')).toBeInTheDocument(); + }); + + test('calls onExpandedChange when header is clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + const header = screen.getByText('Advanced Search Options').closest('div'); + await user.click(header!); + + expect(mockOnExpandedChange).toHaveBeenCalledWith(true); + }); + + test('displays search behavior section by default when expanded', () => { + render( + + ); + + expect(screen.getByText('These settings control how your search queries are interpreted and matched against documents.')).toBeInTheDocument(); + expect(screen.getByDisplayValue('simple')).toBeInTheDocument(); + expect(screen.getByLabelText('Enhanced Search Engine')).toBeInTheDocument(); + }); + + test('switches between sections correctly', async () => { + const user = userEvent.setup(); + render( + + ); + + // Click on Results Display section + await user.click(screen.getByText('Results Display')); + + expect(screen.getByText('Control how search results are presented and what information is shown.')).toBeInTheDocument(); + expect(screen.getByLabelText('Show Text Snippets')).toBeInTheDocument(); + }); + + test('changes search mode setting', async () => { + const user = userEvent.setup(); + render( + + ); + + const searchModeSelect = screen.getByDisplayValue('simple'); + await user.click(searchModeSelect); + + const fuzzyOption = screen.getByText('Fuzzy Search'); + await user.click(fuzzyOption); + + expect(mockOnSettingsChange).toHaveBeenCalledWith({ searchMode: 'fuzzy' }); + }); + + test('toggles enhanced search setting', async () => { + const user = userEvent.setup(); + render( + + ); + + const enhancedSearchSwitch = screen.getByLabelText('Enhanced Search Engine'); + await user.click(enhancedSearchSwitch); + + expect(mockOnSettingsChange).toHaveBeenCalledWith({ useEnhancedSearch: false }); + }); + + test('adjusts fuzzy threshold when in fuzzy mode', async () => { + const user = userEvent.setup(); + const fuzzySettings = { ...mockSettings, searchMode: 'fuzzy' as const }; + + render( + + ); + + const fuzzySlider = screen.getByRole('slider', { name: /fuzzy match threshold/i }); + expect(fuzzySlider).not.toHaveAttribute('disabled'); + }); + + test('disables fuzzy threshold when not in fuzzy mode', () => { + render( + + ); + + const fuzzySlider = screen.getByRole('slider', { name: /fuzzy match threshold/i }); + expect(fuzzySlider).toHaveAttribute('disabled'); + }); + + test('shows results display settings correctly', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByText('Results Display')); + + expect(screen.getByLabelText('Show Text Snippets')).toBeChecked(); + expect(screen.getByDisplayValue('200')).toBeInTheDocument(); // Snippet length + expect(screen.getByDisplayValue('100')).toBeInTheDocument(); // Results per page + }); + + test('disables snippet length when snippets are disabled', async () => { + const user = userEvent.setup(); + const settingsWithoutSnippets = { ...mockSettings, includeSnippets: false }; + + render( + + ); + + await user.click(screen.getByText('Results Display')); + + const snippetLengthSelect = screen.getByLabelText('Snippet Length'); + expect(snippetLengthSelect.closest('div')).toHaveClass('Mui-disabled'); + }); + + test('shows content sources settings', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByText('Content Sources')); + + expect(screen.getByLabelText('Document Content')).toBeChecked(); + expect(screen.getByLabelText('OCR Extracted Text')).toBeChecked(); + expect(screen.getByLabelText('Filenames')).toBeChecked(); + }); + + test('shows performance settings with warning', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByText('Performance')); + + expect(screen.getByText('These settings can affect search speed. Use with caution for large document collections.')).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: /maximum results/i })).toBeInTheDocument(); + }); + + test('resets to defaults when reset button is clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + const resetButton = screen.getByText('Reset to Defaults'); + await user.click(resetButton); + + expect(mockOnSettingsChange).toHaveBeenCalledWith({ + useEnhancedSearch: true, + searchMode: 'simple', + includeSnippets: true, + snippetLength: 200, + fuzzyThreshold: 0.8, + resultLimit: 100, + includeOcrText: true, + includeFileContent: true, + includeFilenames: true, + boostRecentDocs: false, + enableAutoCorrect: true, + }); + }); + + test('shows save preset dialog when save preset is clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + const saveButton = screen.getByText('Save Preset'); + await user.click(saveButton); + + expect(screen.getByText('Save Current Settings as Preset')).toBeInTheDocument(); + expect(screen.getByLabelText('Preset Name')).toBeInTheDocument(); + }); + + test('saves preset with valid name', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByText('Save Preset')); + + const nameInput = screen.getByLabelText('Preset Name'); + await user.type(nameInput, 'My Custom Preset'); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + await user.click(saveButton); + + expect(mockOnSavePreset).toHaveBeenCalledWith('My Custom Preset', mockSettings); + }); + + test('shows preset selector when presets are available', () => { + render( + + ); + + expect(screen.getByLabelText('Load Preset')).toBeInTheDocument(); + }); + + test('loads preset when selected', async () => { + const user = userEvent.setup(); + render( + + ); + + const presetSelect = screen.getByLabelText('Load Preset'); + await user.click(presetSelect); + + const fastSearchOption = screen.getByText('Fast Search'); + await user.click(fastSearchOption); + + expect(mockOnLoadPreset).toHaveBeenCalledWith(mockPresets[0].settings); + }); + + test('shows enhanced search badge when enabled', () => { + render( + + ); + + // Badge should be visible (not invisible) when enhanced search is enabled + const badge = screen.getByText('Advanced Search Options').closest('div')?.querySelector('[class*="MuiBadge"]'); + expect(badge).toBeInTheDocument(); + }); + + test('hides badge when enhanced search is disabled', () => { + const settingsWithoutEnhanced = { ...mockSettings, useEnhancedSearch: false }; + + render( + + ); + + // Badge should be invisible when enhanced search is disabled + const badge = screen.getByText('Advanced Search Options').closest('div')?.querySelector('[class*="MuiBadge"]'); + expect(badge).toBeInTheDocument(); // Badge element exists but should be invisible + }); + + test('cancels preset save when cancel is clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByText('Save Preset')); + + const cancelButton = screen.getByText('Cancel'); + await user.click(cancelButton); + + expect(screen.queryByText('Save Current Settings as Preset')).not.toBeInTheDocument(); + }); + + test('shows correct search mode descriptions', () => { + render( + + ); + + expect(screen.getByText('Basic keyword matching with stemming')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/AdvancedSearchPanel/index.ts b/frontend/src/components/AdvancedSearchPanel/index.ts new file mode 100644 index 0000000..e75e50b --- /dev/null +++ b/frontend/src/components/AdvancedSearchPanel/index.ts @@ -0,0 +1 @@ +export { default } from './AdvancedSearchPanel'; \ No newline at end of file diff --git a/frontend/src/components/EnhancedSearchGuide/EnhancedSearchGuide.tsx b/frontend/src/components/EnhancedSearchGuide/EnhancedSearchGuide.tsx new file mode 100644 index 0000000..b061a93 --- /dev/null +++ b/frontend/src/components/EnhancedSearchGuide/EnhancedSearchGuide.tsx @@ -0,0 +1,373 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Card, + CardContent, + Chip, + IconButton, + Collapse, + Grid, + Button, + Tabs, + Tab, + Paper, + Tooltip, + List, + ListItem, + ListItemIcon, + ListItemText, + Divider, + Alert, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + ContentCopy as CopyIcon, + Search as SearchIcon, + Label as LabelIcon, + TextFormat as TextIcon, + Functions as FunctionIcon, + DateRange as DateIcon, + Storage as SizeIcon, + Code as CodeIcon, + Lightbulb as TipIcon, + PlayArrow as PlayIcon, +} from '@mui/icons-material'; + +interface SearchExample { + query: string; + description: string; + category: 'basic' | 'advanced' | 'filters' | 'operators'; + icon?: React.ReactElement; +} + +interface EnhancedSearchGuideProps { + onExampleClick?: (query: string) => void; + compact?: boolean; +} + +const EnhancedSearchGuide: React.FC = ({ onExampleClick, compact = false }) => { + const [expanded, setExpanded] = useState(!compact); + const [activeTab, setActiveTab] = useState(0); + const [copiedExample, setCopiedExample] = useState(null); + + const searchExamples: SearchExample[] = [ + // Basic searches + { + query: 'invoice', + description: 'Simple keyword search', + category: 'basic', + icon: , + }, + { + query: '"project proposal"', + description: 'Exact phrase search', + category: 'basic', + icon: , + }, + { + query: 'report*', + description: 'Wildcard search (finds report, reports, reporting)', + category: 'basic', + icon: , + }, + + // Advanced searches + { + query: 'invoice AND payment', + description: 'Both terms must appear', + category: 'advanced', + icon: , + }, + { + query: 'budget OR forecast', + description: 'Either term can appear', + category: 'advanced', + icon: , + }, + { + query: 'contract NOT draft', + description: 'Exclude documents with "draft"', + category: 'advanced', + icon: , + }, + { + query: '(invoice OR receipt) AND 2024', + description: 'Complex boolean search with grouping', + category: 'advanced', + icon: , + }, + + // Filter searches + { + query: 'tag:important', + description: 'Search by tag', + category: 'filters', + icon: , + }, + { + query: 'tag:invoice tag:paid', + description: 'Multiple tags (must have both)', + category: 'filters', + icon: , + }, + { + query: 'type:pdf invoice', + description: 'Search only PDF files', + category: 'filters', + icon: , + }, + { + query: 'size:>5MB presentation', + description: 'Files larger than 5MB', + category: 'filters', + icon: , + }, + { + query: 'date:2024 quarterly report', + description: 'Documents from 2024', + category: 'filters', + icon: , + }, + { + query: 'ocr:yes scan', + description: 'Only documents with OCR text', + category: 'filters', + icon: , + }, + + // Power user operators + { + query: 'invoice NEAR payment', + description: 'Terms appear close together', + category: 'operators', + icon: , + }, + { + query: '"annual report" ~5 2024', + description: 'Terms within 5 words of each other', + category: 'operators', + icon: , + }, + { + query: 'proj* AND (budget OR cost*) tag:active', + description: 'Complex query combining wildcards, boolean, and filters', + category: 'operators', + icon: , + }, + ]; + + const categorizedExamples = { + basic: searchExamples.filter(e => e.category === 'basic'), + advanced: searchExamples.filter(e => e.category === 'advanced'), + filters: searchExamples.filter(e => e.category === 'filters'), + operators: searchExamples.filter(e => e.category === 'operators'), + }; + + const handleCopyExample = (query: string) => { + navigator.clipboard.writeText(query); + setCopiedExample(query); + setTimeout(() => setCopiedExample(null), 2000); + }; + + const handleExampleClick = (query: string) => { + onExampleClick?.(query); + }; + + const renderExampleCard = (example: SearchExample) => ( + + + + + {example.icon && ( + + {example.icon} + + )} + + + {example.query} + + + {example.description} + + + + + + handleCopyExample(example.query)} + sx={{ + color: copiedExample === example.query ? 'success.main' : 'text.secondary' + }} + > + + + + + handleExampleClick(example.query)} + > + + + + + + + + ); + + const renderQuickTips = () => ( + + + + Quick Tips + + + + + + + + + + + + + + ); + + if (compact && !expanded) { + return ( + + + + + + Need help with search? View examples and syntax guide + + + + + + ); + } + + return ( + + + + + Search Guide + + {compact && ( + setExpanded(false)} size="small"> + + + )} + + + {renderQuickTips()} + + setActiveTab(newValue)} + sx={{ mb: 2 }} + > + + + + + + + + + + + + + + + + + + Click to try an example, + or to copy it + + + ); +}; + +export default EnhancedSearchGuide; \ No newline at end of file diff --git a/frontend/src/components/EnhancedSearchGuide/__tests__/EnhancedSearchGuide.test.tsx b/frontend/src/components/EnhancedSearchGuide/__tests__/EnhancedSearchGuide.test.tsx new file mode 100644 index 0000000..e88321f --- /dev/null +++ b/frontend/src/components/EnhancedSearchGuide/__tests__/EnhancedSearchGuide.test.tsx @@ -0,0 +1,140 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import EnhancedSearchGuide from '../EnhancedSearchGuide'; + +describe('EnhancedSearchGuide', () => { + const mockOnExampleClick = vi.fn(); + + beforeEach(() => { + mockOnExampleClick.mockClear(); + }); + + test('renders in compact mode by default', () => { + render(); + + expect(screen.getByText('Need help with search? View examples and syntax guide')).toBeInTheDocument(); + expect(screen.getByText('Show Guide')).toBeInTheDocument(); + }); + + test('expands when show guide button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const showGuideButton = screen.getByText('Show Guide'); + await user.click(showGuideButton); + + expect(screen.getByText('Search Guide')).toBeInTheDocument(); + expect(screen.getByText('Basic (3)')).toBeInTheDocument(); + }); + + test('displays search examples in different categories', () => { + render(); + + // Check for tab labels + expect(screen.getByText('Basic (3)')).toBeInTheDocument(); + expect(screen.getByText('Advanced (4)')).toBeInTheDocument(); + expect(screen.getByText('Filters (6)')).toBeInTheDocument(); + expect(screen.getByText('Power User (3)')).toBeInTheDocument(); + }); + + test('displays basic search examples by default', () => { + render(); + + expect(screen.getByText('invoice')).toBeInTheDocument(); + expect(screen.getByText('"project proposal"')).toBeInTheDocument(); + expect(screen.getByText('report*')).toBeInTheDocument(); + }); + + test('switches between tabs correctly', async () => { + const user = userEvent.setup(); + render(); + + // Click on Advanced tab + await user.click(screen.getByText('Advanced (4)')); + expect(screen.getByText('invoice AND payment')).toBeInTheDocument(); + expect(screen.getByText('budget OR forecast')).toBeInTheDocument(); + + // Click on Filters tab + await user.click(screen.getByText('Filters (6)')); + expect(screen.getByText('tag:important')).toBeInTheDocument(); + expect(screen.getByText('type:pdf invoice')).toBeInTheDocument(); + }); + + test('calls onExampleClick when play button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const playButtons = screen.getAllByLabelText('Try this search'); + await user.click(playButtons[0]); + + expect(mockOnExampleClick).toHaveBeenCalledWith('invoice'); + }); + + test('copies example to clipboard when copy button is clicked', async () => { + const user = userEvent.setup(); + + // Mock clipboard API + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockImplementation(() => Promise.resolve()), + }, + }); + + render(); + + const copyButtons = screen.getAllByLabelText('Copy to clipboard'); + await user.click(copyButtons[0]); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('invoice'); + }); + + test('shows quick tips', () => { + render(); + + expect(screen.getByText('Quick Tips')).toBeInTheDocument(); + expect(screen.getByText('Use quotes for exact phrases')).toBeInTheDocument(); + expect(screen.getByText('Combine filters for precision')).toBeInTheDocument(); + expect(screen.getByText('Use wildcards for variations')).toBeInTheDocument(); + }); + + test('collapses when compact mode is toggled', async () => { + const user = userEvent.setup(); + render(); + + // Should be expanded initially + expect(screen.getByText('Search Guide')).toBeInTheDocument(); + + // Find and click collapse button (it's an IconButton with ExpandMoreIcon rotated) + const collapseButton = screen.getByRole('button', { name: '' }); // IconButton without explicit aria-label + await user.click(collapseButton); + + // Should show compact view + await waitFor(() => { + expect(screen.getByText('Need help with search? View examples and syntax guide')).toBeInTheDocument(); + }); + }); + + test('renders example descriptions correctly', () => { + render(); + + expect(screen.getByText('Simple keyword search')).toBeInTheDocument(); + expect(screen.getByText('Exact phrase search')).toBeInTheDocument(); + expect(screen.getByText('Wildcard search (finds report, reports, reporting)')).toBeInTheDocument(); + }); + + test('shows correct number of examples per category', () => { + render(); + + // Basic tab should have 3 examples + const basicExamples = screen.getAllByText(/Simple keyword search|Exact phrase search|Wildcard search/); + expect(basicExamples).toHaveLength(3); + }); + + test('handles missing onExampleClick gracefully', () => { + render(); + + const playButtons = screen.getAllByLabelText('Try this search'); + expect(() => fireEvent.click(playButtons[0])).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/EnhancedSearchGuide/index.ts b/frontend/src/components/EnhancedSearchGuide/index.ts new file mode 100644 index 0000000..9d46adc --- /dev/null +++ b/frontend/src/components/EnhancedSearchGuide/index.ts @@ -0,0 +1 @@ +export { default } from './EnhancedSearchGuide'; \ No newline at end of file diff --git a/frontend/src/components/EnhancedSnippetViewer/EnhancedSnippetViewer.tsx b/frontend/src/components/EnhancedSnippetViewer/EnhancedSnippetViewer.tsx new file mode 100644 index 0000000..7a47136 --- /dev/null +++ b/frontend/src/components/EnhancedSnippetViewer/EnhancedSnippetViewer.tsx @@ -0,0 +1,429 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + Paper, + IconButton, + Collapse, + Chip, + Button, + Menu, + MenuItem, + ListItemIcon, + ListItemText, + Tooltip, + RadioGroup, + FormControlLabel, + Radio, + Slider, + Divider, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + ExpandLess as ExpandLessIcon, + FormatSize as FontSizeIcon, + ViewModule as ViewModeIcon, + ContentCopy as CopyIcon, + FormatQuote as QuoteIcon, + Code as CodeIcon, + WrapText as WrapIcon, + Search as SearchIcon, + Settings as SettingsIcon, +} from '@mui/icons-material'; + +interface HighlightRange { + start: number; + end: number; +} + +interface Snippet { + text: string; + highlight_ranges?: HighlightRange[]; + source?: 'content' | 'ocr_text' | 'filename'; + page_number?: number; + confidence?: number; +} + +interface EnhancedSnippetViewerProps { + snippets: Snippet[]; + searchQuery?: string; + maxSnippetsToShow?: number; + onSnippetClick?: (snippet: Snippet, index: number) => void; +} + +type ViewMode = 'compact' | 'detailed' | 'context'; +type HighlightStyle = 'background' | 'underline' | 'bold'; + +const EnhancedSnippetViewer: React.FC = ({ + snippets, + searchQuery, + maxSnippetsToShow = 3, + onSnippetClick, +}) => { + const [expanded, setExpanded] = useState(false); + const [viewMode, setViewMode] = useState('detailed'); + const [highlightStyle, setHighlightStyle] = useState('background'); + const [fontSize, setFontSize] = useState(14); + const [contextLength, setContextLength] = useState(50); + const [settingsAnchor, setSettingsAnchor] = useState(null); + const [copiedIndex, setCopiedIndex] = useState(null); + + const visibleSnippets = expanded ? snippets : snippets.slice(0, maxSnippetsToShow); + + const handleCopySnippet = (text: string, index: number) => { + navigator.clipboard.writeText(text); + setCopiedIndex(index); + setTimeout(() => setCopiedIndex(null), 2000); + }; + + const renderHighlightedText = (text: string, highlightRanges?: HighlightRange[]): React.ReactNode => { + if (!highlightRanges || highlightRanges.length === 0) { + return text; + } + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + highlightRanges.forEach((range, index) => { + // Add text before highlight + if (range.start > lastIndex) { + parts.push( + + {text.substring(lastIndex, range.start)} + + ); + } + + // Add highlighted text + const highlightedText = text.substring(range.start, range.end); + parts.push( + + {highlightedText} + + ); + + lastIndex = range.end; + }); + + // Add remaining text + if (lastIndex < text.length) { + parts.push( + + {text.substring(lastIndex)} + + ); + } + + return parts; + }; + + const getSourceIcon = (source?: string) => { + switch (source) { + case 'ocr_text': + return ; + case 'filename': + return ; + default: + return ; + } + }; + + const getSourceLabel = (source?: string) => { + switch (source) { + case 'ocr_text': + return 'OCR Text'; + case 'filename': + return 'Filename'; + default: + return 'Document Content'; + } + }; + + const renderSnippet = (snippet: Snippet, index: number) => { + const isCompact = viewMode === 'compact'; + const showContext = viewMode === 'context'; + + // Extract context around highlights if in context mode + let displayText = snippet.text; + if (showContext && snippet.highlight_ranges && snippet.highlight_ranges.length > 0) { + const firstHighlight = snippet.highlight_ranges[0]; + const lastHighlight = snippet.highlight_ranges[snippet.highlight_ranges.length - 1]; + + const contextStart = Math.max(0, firstHighlight.start - contextLength); + const contextEnd = Math.min(snippet.text.length, lastHighlight.end + contextLength); + + displayText = (contextStart > 0 ? '...' : '') + + snippet.text.substring(contextStart, contextEnd) + + (contextEnd < snippet.text.length ? '...' : ''); + + // Adjust highlight ranges for the new substring + if (snippet.highlight_ranges) { + snippet = { + ...snippet, + text: displayText, + highlight_ranges: snippet.highlight_ranges.map(range => ({ + start: range.start - contextStart + (contextStart > 0 ? 3 : 0), + end: range.end - contextStart + (contextStart > 0 ? 3 : 0), + })), + }; + } + } + + return ( + theme.palette.mode === 'light' ? 'grey.50' : 'grey.900', + borderLeft: '3px solid', + borderLeftColor: snippet.source === 'ocr_text' ? 'warning.main' : 'primary.main', + cursor: onSnippetClick ? 'pointer' : 'default', + transition: 'all 0.2s', + '&:hover': onSnippetClick ? { + backgroundColor: (theme) => theme.palette.mode === 'light' ? 'grey.100' : 'grey.800', + transform: 'translateX(4px)', + } : {}, + }} + onClick={() => onSnippetClick?.(snippet, index)} + > + + + {!isCompact && ( + + + {snippet.page_number && ( + + )} + {snippet.confidence && snippet.confidence < 0.8 && ( + + )} + + )} + + + {renderHighlightedText(snippet.text, snippet.highlight_ranges)} + + + + + + { + e.stopPropagation(); + handleCopySnippet(snippet.text, index); + }} + sx={{ + color: copiedIndex === index ? 'success.main' : 'text.secondary' + }} + > + + + + + + + ); + }; + + return ( + + + + + Search Results + + {snippets.length > 0 && ( + + )} + + + + + setSettingsAnchor(e.currentTarget)} + > + + + + + {snippets.length > maxSnippetsToShow && ( + + )} + + + + {searchQuery && ( + + + Showing matches for: {searchQuery} + + + )} + + {visibleSnippets.map((snippet, index) => renderSnippet(snippet, index))} + + {snippets.length === 0 && ( + + + No text snippets available for this search result + + + )} + + {/* Settings Menu */} + setSettingsAnchor(null)} + PaperProps={{ sx: { width: 320, p: 2 } }} + > + + Snippet Display Settings + + + + + View Mode + + setViewMode(e.target.value as ViewMode)} + > + } + label="Compact" + /> + } + label="Detailed (with metadata)" + /> + } + label="Context Focus" + /> + + + + + + + + Highlight Style + + setHighlightStyle(e.target.value as HighlightStyle)} + > + } + label="Background Color" + /> + } + label="Underline" + /> + } + label="Bold Text" + /> + + + + + + + + Font Size: {fontSize}px + + setFontSize(value as number)} + min={12} + max={20} + marks + valueLabelDisplay="auto" + /> + + + {viewMode === 'context' && ( + <> + + + + Context Length: {contextLength} characters + + setContextLength(value as number)} + min={20} + max={200} + step={10} + marks + valueLabelDisplay="auto" + /> + + + )} + + + ); +}; + +export default EnhancedSnippetViewer; \ No newline at end of file diff --git a/frontend/src/components/EnhancedSnippetViewer/__tests__/EnhancedSnippetViewer.test.tsx b/frontend/src/components/EnhancedSnippetViewer/__tests__/EnhancedSnippetViewer.test.tsx new file mode 100644 index 0000000..7d87941 --- /dev/null +++ b/frontend/src/components/EnhancedSnippetViewer/__tests__/EnhancedSnippetViewer.test.tsx @@ -0,0 +1,341 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import EnhancedSnippetViewer from '../EnhancedSnippetViewer'; + +const mockSnippets = [ + { + text: 'This is a sample document about invoice processing and payment systems.', + highlight_ranges: [ + { start: 38, end: 45 }, // "invoice" + { start: 59, end: 66 }, // "payment" + ], + source: 'content' as const, + page_number: 1, + confidence: 0.95, + }, + { + text: 'OCR extracted text from scanned document with lower confidence.', + highlight_ranges: [ + { start: 0, end: 3 }, // "OCR" + ], + source: 'ocr_text' as const, + confidence: 0.75, + }, + { + text: 'filename_with_keywords.pdf', + highlight_ranges: [ + { start: 14, end: 22 }, // "keywords" + ], + source: 'filename' as const, + }, +]; + +describe('EnhancedSnippetViewer', () => { + const mockOnSnippetClick = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + // Mock clipboard API + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockImplementation(() => Promise.resolve()), + }, + }); + }); + + test('renders snippets with correct content', () => { + render( + + ); + + expect(screen.getByText('Search Results')).toBeInTheDocument(); + expect(screen.getByText('3 matches')).toBeInTheDocument(); + expect(screen.getByText(/This is a sample document about/)).toBeInTheDocument(); + expect(screen.getByText(/OCR extracted text/)).toBeInTheDocument(); + }); + + test('displays search query context', () => { + render( + + ); + + expect(screen.getByText('Showing matches for:')).toBeInTheDocument(); + expect(screen.getByText('invoice payment')).toBeInTheDocument(); + }); + + test('shows correct source badges', () => { + render( + + ); + + expect(screen.getByText('Document Content')).toBeInTheDocument(); + expect(screen.getByText('OCR Text')).toBeInTheDocument(); + expect(screen.getByText('Filename')).toBeInTheDocument(); + }); + + test('displays page numbers and confidence scores', () => { + render( + + ); + + expect(screen.getByText('Page 1')).toBeInTheDocument(); + expect(screen.getByText('75% confidence')).toBeInTheDocument(); + }); + + test('limits snippets display based on maxSnippetsToShow', () => { + render( + + ); + + expect(screen.getByText('Show All (3)')).toBeInTheDocument(); + + // Should only show first 2 snippets + expect(screen.getByText(/This is a sample document/)).toBeInTheDocument(); + expect(screen.getByText(/OCR extracted text/)).toBeInTheDocument(); + expect(screen.queryByText(/filename_with_keywords/)).not.toBeInTheDocument(); + }); + + test('expands to show all snippets when clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + const showAllButton = screen.getByText('Show All (3)'); + await user.click(showAllButton); + + expect(screen.getByText('Show Less')).toBeInTheDocument(); + expect(screen.getByText(/filename_with_keywords/)).toBeInTheDocument(); + }); + + test('calls onSnippetClick when snippet is clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + const firstSnippet = screen.getByText(/This is a sample document/).closest('div'); + await user.click(firstSnippet!); + + expect(mockOnSnippetClick).toHaveBeenCalledWith(mockSnippets[0], 0); + }); + + test('copies snippet text to clipboard', async () => { + const user = userEvent.setup(); + render( + + ); + + const copyButtons = screen.getAllByLabelText('Copy snippet'); + await user.click(copyButtons[0]); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockSnippets[0].text); + }); + + test('opens settings menu and changes view mode', async () => { + const user = userEvent.setup(); + render( + + ); + + const settingsButton = screen.getByLabelText('Snippet settings'); + await user.click(settingsButton); + + expect(screen.getByText('Snippet Display Settings')).toBeInTheDocument(); + expect(screen.getByText('View Mode')).toBeInTheDocument(); + + const compactOption = screen.getByLabelText('Compact'); + await user.click(compactOption); + + // Settings menu should close and compact mode should be applied + await waitFor(() => { + expect(screen.queryByText('Snippet Display Settings')).not.toBeInTheDocument(); + }); + }); + + test('changes highlight style through settings', async () => { + const user = userEvent.setup(); + render( + + ); + + const settingsButton = screen.getByLabelText('Snippet settings'); + await user.click(settingsButton); + + const underlineOption = screen.getByLabelText('Underline'); + await user.click(underlineOption); + + await waitFor(() => { + expect(screen.queryByText('Snippet Display Settings')).not.toBeInTheDocument(); + }); + + // Check if highlight style has changed (this would require checking computed styles) + const highlightedText = screen.getByText('invoice'); + expect(highlightedText).toBeInTheDocument(); + }); + + test('adjusts font size through settings', async () => { + const user = userEvent.setup(); + render( + + ); + + const settingsButton = screen.getByLabelText('Snippet settings'); + await user.click(settingsButton); + + const fontSizeSlider = screen.getByRole('slider', { name: /font size/i }); + await user.click(fontSizeSlider); + + // Font size should be adjustable + expect(fontSizeSlider).toBeInTheDocument(); + }); + + test('handles context mode settings', async () => { + const user = userEvent.setup(); + render( + + ); + + const settingsButton = screen.getByLabelText('Snippet settings'); + await user.click(settingsButton); + + const contextOption = screen.getByLabelText('Context Focus'); + await user.click(contextOption); + + // Context length slider should appear + expect(screen.getByText(/Context Length:/)).toBeInTheDocument(); + }); + + test('renders highlighted text with multiple ranges correctly', () => { + render( + + ); + + // Both "invoice" and "payment" should be highlighted + expect(screen.getByText('invoice')).toBeInTheDocument(); + expect(screen.getByText('payment')).toBeInTheDocument(); + }); + + test('handles snippets without highlight ranges', () => { + const snippetsWithoutHighlights = [ + { + text: 'Plain text without any highlights', + source: 'content' as const, + }, + ]; + + render( + + ); + + expect(screen.getByText('Plain text without any highlights')).toBeInTheDocument(); + }); + + test('displays empty state when no snippets provided', () => { + render( + + ); + + expect(screen.getByText('No text snippets available for this search result')).toBeInTheDocument(); + }); + + test('shows confidence warning for low confidence OCR', () => { + const lowConfidenceSnippet = [ + { + text: 'Low confidence OCR text', + source: 'ocr_text' as const, + confidence: 0.6, + }, + ]; + + render( + + ); + + expect(screen.getByText('60% confidence')).toBeInTheDocument(); + }); + + test('does not show confidence for high confidence OCR', () => { + const highConfidenceSnippet = [ + { + text: 'High confidence OCR text', + source: 'ocr_text' as const, + confidence: 0.9, + }, + ]; + + render( + + ); + + expect(screen.queryByText('90% confidence')).not.toBeInTheDocument(); + }); + + test('handles click events without onSnippetClick prop', async () => { + const user = userEvent.setup(); + render( + + ); + + const firstSnippet = screen.getByText(/This is a sample document/).closest('div'); + expect(() => user.click(firstSnippet!)).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/EnhancedSnippetViewer/index.ts b/frontend/src/components/EnhancedSnippetViewer/index.ts new file mode 100644 index 0000000..e8785b0 --- /dev/null +++ b/frontend/src/components/EnhancedSnippetViewer/index.ts @@ -0,0 +1 @@ +export { default } from './EnhancedSnippetViewer'; \ No newline at end of file diff --git a/frontend/src/components/MimeTypeFacetFilter/MimeTypeFacetFilter.tsx b/frontend/src/components/MimeTypeFacetFilter/MimeTypeFacetFilter.tsx new file mode 100644 index 0000000..c0c7f10 --- /dev/null +++ b/frontend/src/components/MimeTypeFacetFilter/MimeTypeFacetFilter.tsx @@ -0,0 +1,380 @@ +import React, { useEffect, useState } from 'react'; +import { + Box, + Typography, + FormControl, + FormGroup, + FormControlLabel, + Checkbox, + Chip, + CircularProgress, + Collapse, + IconButton, + Badge, + Paper, + Divider, + Button, + TextField, + InputAdornment, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + ExpandLess as ExpandLessIcon, + PictureAsPdf as PdfIcon, + Image as ImageIcon, + Description as DocIcon, + TextSnippet as TextIcon, + Article as ArticleIcon, + TableChart as SpreadsheetIcon, + Code as CodeIcon, + Folder as FolderIcon, + Search as SearchIcon, + Clear as ClearIcon, +} from '@mui/icons-material'; +import { documentService, FacetItem } from '../../services/api'; + +interface MimeTypeFacetFilterProps { + selectedMimeTypes: string[]; + onMimeTypeChange: (mimeTypes: string[]) => void; + maxItemsToShow?: number; +} + +interface MimeTypeGroup { + label: string; + icon: React.ReactElement; + patterns: string[]; + color: string; +} + +const MIME_TYPE_GROUPS: MimeTypeGroup[] = [ + { + label: 'PDFs', + icon: , + patterns: ['application/pdf'], + color: '#d32f2f', + }, + { + label: 'Images', + icon: , + patterns: ['image/'], + color: '#1976d2', + }, + { + label: 'Documents', + icon: , + patterns: ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml', 'application/rtf'], + color: '#388e3c', + }, + { + label: 'Spreadsheets', + icon: , + patterns: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml', 'text/csv'], + color: '#f57c00', + }, + { + label: 'Text Files', + icon: , + patterns: ['text/plain', 'text/markdown', 'text/x-'], + color: '#7b1fa2', + }, + { + label: 'Code', + icon: , + patterns: ['application/javascript', 'application/json', 'application/xml', 'text/html', 'text/css'], + color: '#00796b', + }, +]; + +const MimeTypeFacetFilter: React.FC = ({ + selectedMimeTypes, + onMimeTypeChange, + maxItemsToShow = 10, +}) => { + const [facets, setFacets] = useState([]); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(true); + const [showAll, setShowAll] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + loadFacets(); + }, []); + + const loadFacets = async () => { + try { + setLoading(true); + const response = await documentService.getFacets(); + setFacets(response.data.mime_types); + } catch (error) { + console.error('Failed to load facets:', error); + } finally { + setLoading(false); + } + }; + + const getGroupForMimeType = (mimeType: string): MimeTypeGroup | undefined => { + return MIME_TYPE_GROUPS.find(group => + group.patterns.some(pattern => mimeType.startsWith(pattern)) + ); + }; + + const getMimeTypeIcon = (mimeType: string): React.ReactElement => { + const group = getGroupForMimeType(mimeType); + return group ? group.icon : ; + }; + + const getMimeTypeLabel = (mimeType: string): string => { + const labels: Record = { + 'application/pdf': 'PDF Documents', + 'image/jpeg': 'JPEG Images', + 'image/png': 'PNG Images', + 'image/gif': 'GIF Images', + 'image/webp': 'WebP Images', + 'text/plain': 'Plain Text', + 'text/html': 'HTML', + 'text/css': 'CSS', + 'text/csv': 'CSV Files', + 'text/markdown': 'Markdown', + 'application/json': 'JSON', + 'application/xml': 'XML', + 'application/msword': 'Word Documents', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word Documents (DOCX)', + 'application/vnd.ms-excel': 'Excel Spreadsheets', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel Spreadsheets (XLSX)', + 'application/rtf': 'Rich Text Format', + }; + + return labels[mimeType] || mimeType.split('/').pop()?.toUpperCase() || mimeType; + }; + + const handleToggleMimeType = (mimeType: string) => { + const newSelection = selectedMimeTypes.includes(mimeType) + ? selectedMimeTypes.filter(mt => mt !== mimeType) + : [...selectedMimeTypes, mimeType]; + onMimeTypeChange(newSelection); + }; + + const handleSelectGroup = (group: MimeTypeGroup) => { + const groupMimeTypes = facets + .filter(facet => group.patterns.some(pattern => facet.value.startsWith(pattern))) + .map(facet => facet.value); + + const allSelected = groupMimeTypes.every(mt => selectedMimeTypes.includes(mt)); + + if (allSelected) { + // Deselect all in group + onMimeTypeChange(selectedMimeTypes.filter(mt => !groupMimeTypes.includes(mt))); + } else { + // Select all in group + onMimeTypeChange([...new Set([...selectedMimeTypes, ...groupMimeTypes])]); + } + }; + + const filteredFacets = facets.filter(facet => + searchTerm === '' || + facet.value.toLowerCase().includes(searchTerm.toLowerCase()) || + getMimeTypeLabel(facet.value).toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const visibleFacets = showAll ? filteredFacets : filteredFacets.slice(0, maxItemsToShow); + + const renderGroupedFacets = () => { + const groupedFacets: Map = new Map(); + const ungroupedFacets: FacetItem[] = []; + + filteredFacets.forEach(facet => { + const group = getGroupForMimeType(facet.value); + if (group) { + if (!groupedFacets.has(group.label)) { + groupedFacets.set(group.label, []); + } + groupedFacets.get(group.label)!.push(facet); + } else { + ungroupedFacets.push(facet); + } + }); + + return ( + <> + {MIME_TYPE_GROUPS.map(group => { + const groupFacets = groupedFacets.get(group.label) || []; + if (groupFacets.length === 0) return null; + + const totalCount = groupFacets.reduce((sum, facet) => sum + facet.count, 0); + const selectedCount = groupFacets.filter(facet => selectedMimeTypes.includes(facet.value)).length; + const allSelected = selectedCount === groupFacets.length; + const someSelected = selectedCount > 0 && selectedCount < groupFacets.length; + + return ( + + handleSelectGroup(group)} + > + + + {group.icon} + + {group.label} + + 0 ? "filled" : "outlined"} + color={selectedCount > 0 ? "primary" : "default"} + /> + + + + + {groupFacets.map(facet => ( + handleToggleMimeType(facet.value)} + /> + } + label={ + + + {getMimeTypeLabel(facet.value)} + + + + } + sx={{ display: 'flex', width: '100%', mb: 0.5 }} + /> + ))} + + + ); + })} + + {ungroupedFacets.length > 0 && ( + + + Other Types + + {ungroupedFacets.map(facet => ( + handleToggleMimeType(facet.value)} + /> + } + label={ + + + {getMimeTypeLabel(facet.value)} + + + + } + sx={{ display: 'flex', width: '100%', mb: 0.5 }} + /> + ))} + + )} + + ); + }; + + return ( + + + + File Types + + + {selectedMimeTypes.length > 0 && ( + onMimeTypeChange([])} + deleteIcon={} + /> + )} + setExpanded(!expanded)}> + {expanded ? : } + + + + + + {loading ? ( + + + + ) : ( + <> + {facets.length > maxItemsToShow && ( + setSearchTerm(e.target.value)} + sx={{ mb: 2 }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm && ( + + setSearchTerm('')}> + + + + ), + }} + /> + )} + + + {renderGroupedFacets()} + + + {filteredFacets.length > maxItemsToShow && ( + + + + )} + + {filteredFacets.length === 0 && searchTerm && ( + + No file types match "{searchTerm}" + + )} + + )} + + + ); +}; + +export default MimeTypeFacetFilter; \ No newline at end of file diff --git a/frontend/src/components/MimeTypeFacetFilter/__tests__/MimeTypeFacetFilter.test.tsx b/frontend/src/components/MimeTypeFacetFilter/__tests__/MimeTypeFacetFilter.test.tsx new file mode 100644 index 0000000..5d2c8ae --- /dev/null +++ b/frontend/src/components/MimeTypeFacetFilter/__tests__/MimeTypeFacetFilter.test.tsx @@ -0,0 +1,291 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import MimeTypeFacetFilter from '../MimeTypeFacetFilter'; +import { documentService } from '../../../services/api'; + +// Mock the document service +vi.mock('../../../services/api', () => ({ + documentService: { + getFacets: vi.fn(), + }, +})); + +const mockFacetsResponse = { + data: { + mime_types: [ + { value: 'application/pdf', count: 25 }, + { value: 'image/jpeg', count: 15 }, + { value: 'image/png', count: 10 }, + { value: 'text/plain', count: 8 }, + { value: 'application/msword', count: 5 }, + { value: 'text/csv', count: 3 }, + ], + tags: [], + }, +}; + +describe('MimeTypeFacetFilter', () => { + const mockOnMimeTypeChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (documentService.getFacets as any).mockResolvedValue(mockFacetsResponse); + }); + + test('renders loading state initially', () => { + render( + + ); + + expect(screen.getByText('File Types')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + test('loads and displays MIME type facets', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('PDFs')).toBeInTheDocument(); + expect(screen.getByText('Images')).toBeInTheDocument(); + expect(screen.getByText('Text Files')).toBeInTheDocument(); + }); + + expect(documentService.getFacets).toHaveBeenCalledTimes(1); + }); + + test('displays correct counts for each MIME type group', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('25')).toBeInTheDocument(); // PDF count + expect(screen.getByText('25')).toBeInTheDocument(); // Images total (15+10) + expect(screen.getByText('11')).toBeInTheDocument(); // Text files total (8+3) + }); + }); + + test('allows individual MIME type selection', async () => { + const user = userEvent.setup(); + render( + + ); + + await waitFor(() => { + expect(screen.getByText('PDF Documents')).toBeInTheDocument(); + }); + + const pdfCheckbox = screen.getByLabelText(/PDF Documents/); + await user.click(pdfCheckbox); + + expect(mockOnMimeTypeChange).toHaveBeenCalledWith(['application/pdf']); + }); + + test('allows group selection', async () => { + const user = userEvent.setup(); + render( + + ); + + await waitFor(() => { + expect(screen.getByText('PDFs')).toBeInTheDocument(); + }); + + const pdfGroupCheckbox = screen.getByText('PDFs').closest('div')?.querySelector('input[type="checkbox"]'); + expect(pdfGroupCheckbox).toBeInTheDocument(); + + await user.click(pdfGroupCheckbox!); + + expect(mockOnMimeTypeChange).toHaveBeenCalledWith(['application/pdf']); + }); + + test('shows selected state correctly', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('2 selected')).toBeInTheDocument(); + }); + + const clearButton = screen.getByRole('button', { name: /clear/i }); + expect(clearButton).toBeInTheDocument(); + }); + + test('allows clearing selections', async () => { + const user = userEvent.setup(); + render( + + ); + + await waitFor(() => { + expect(screen.getByText('2 selected')).toBeInTheDocument(); + }); + + const clearButton = screen.getByRole('button', { name: /clear/i }); + await user.click(clearButton); + + expect(mockOnMimeTypeChange).toHaveBeenCalledWith([]); + }); + + test('supports search functionality', async () => { + const user = userEvent.setup(); + render( + + ); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Search file types...')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Search file types...'); + await user.type(searchInput, 'pdf'); + + // Should filter to show only PDF-related items + expect(screen.getByText('PDF Documents')).toBeInTheDocument(); + expect(screen.queryByText('JPEG Images')).not.toBeInTheDocument(); + }); + + test('shows/hides all items based on maxItemsToShow', async () => { + const user = userEvent.setup(); + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Show All (6)')).toBeInTheDocument(); + }); + + const showAllButton = screen.getByText('Show All (6)'); + await user.click(showAllButton); + + expect(screen.getByText('Show Less')).toBeInTheDocument(); + }); + + test('can be collapsed and expanded', async () => { + const user = userEvent.setup(); + render( + + ); + + await waitFor(() => { + expect(screen.getByText('File Types')).toBeInTheDocument(); + }); + + const collapseButton = screen.getByLabelText(/expand/i); + await user.click(collapseButton); + + // Content should be hidden + expect(screen.queryByText('PDFs')).not.toBeInTheDocument(); + }); + + test('handles API errors gracefully', async () => { + (documentService.getFacets as any).mockRejectedValue(new Error('API Error')); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to load facets:', expect.any(Error)); + consoleSpy.mockRestore(); + }); + + test('displays proper icons for different MIME types', async () => { + render( + + ); + + await waitFor(() => { + // Check that icons are rendered (they have specific test IDs or classes) + expect(screen.getByText('PDFs')).toBeInTheDocument(); + expect(screen.getByText('Images')).toBeInTheDocument(); + expect(screen.getByText('Text Files')).toBeInTheDocument(); + }); + }); + + test('groups unknown MIME types under "Other Types"', async () => { + const customResponse = { + data: { + mime_types: [ + { value: 'application/unknown', count: 5 }, + { value: 'weird/type', count: 2 }, + ], + tags: [], + }, + }; + + (documentService.getFacets as any).mockResolvedValue(customResponse); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Other Types')).toBeInTheDocument(); + }); + }); + + test('shows indeterminate state for partial group selection', async () => { + render( + + ); + + await waitFor(() => { + const imageGroupCheckbox = screen.getByText('Images').closest('div')?.querySelector('input[type="checkbox"]'); + expect(imageGroupCheckbox).toHaveProperty('indeterminate', true); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/MimeTypeFacetFilter/index.ts b/frontend/src/components/MimeTypeFacetFilter/index.ts new file mode 100644 index 0000000..731f755 --- /dev/null +++ b/frontend/src/components/MimeTypeFacetFilter/index.ts @@ -0,0 +1 @@ +export { default } from './MimeTypeFacetFilter'; \ No newline at end of file diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index 5eace73..f2ba237 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -60,6 +60,10 @@ import { } from '@mui/icons-material'; import { documentService, SearchRequest } from '../services/api'; import SearchGuidance from '../components/SearchGuidance'; +import EnhancedSearchGuide from '../components/EnhancedSearchGuide'; +import MimeTypeFacetFilter from '../components/MimeTypeFacetFilter'; +import EnhancedSnippetViewer from '../components/EnhancedSnippetViewer'; +import AdvancedSearchPanel from '../components/AdvancedSearchPanel'; interface Document { id: string; @@ -108,6 +112,20 @@ type ViewMode = 'grid' | 'list'; type SearchMode = 'simple' | 'phrase' | 'fuzzy' | 'boolean'; type OcrStatus = 'all' | 'yes' | 'no'; +interface AdvancedSearchSettings { + useEnhancedSearch: boolean; + searchMode: SearchMode; + includeSnippets: boolean; + snippetLength: number; + fuzzyThreshold: number; + resultLimit: number; + includeOcrText: boolean; + includeFileContent: boolean; + includeFilenames: boolean; + boostRecentDocs: boolean; + enableAutoCorrect: boolean; +} + const SearchPage: React.FC = () => { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -130,12 +148,21 @@ const SearchPage: React.FC = () => { 'Use wildcards: proj* for project, projects, etc.' ]); - // Search settings - const [useEnhancedSearch, setUseEnhancedSearch] = useState(true); - const [searchMode, setSearchMode] = useState('simple'); - const [includeSnippets, setIncludeSnippets] = useState(true); - const [snippetLength, setSnippetLength] = useState(200); + // Search settings - consolidated into advanced settings const [showAdvanced, setShowAdvanced] = useState(false); + const [advancedSettings, setAdvancedSettings] = useState({ + useEnhancedSearch: true, + searchMode: 'simple', + includeSnippets: true, + snippetLength: 200, + fuzzyThreshold: 0.8, + resultLimit: 100, + includeOcrText: true, + includeFileContent: true, + includeFilenames: true, + boostRecentDocs: false, + enableAutoCorrect: true, + }); // Filter states const [selectedTags, setSelectedTags] = useState([]); @@ -218,14 +245,14 @@ const SearchPage: React.FC = () => { query: query.trim(), tags: filters.tags?.length ? filters.tags : undefined, mime_types: filters.mimeTypes?.length ? filters.mimeTypes : undefined, - limit: 100, + limit: advancedSettings.resultLimit, offset: 0, - include_snippets: includeSnippets, - snippet_length: snippetLength, - search_mode: searchMode, + include_snippets: advancedSettings.includeSnippets, + snippet_length: advancedSettings.snippetLength, + search_mode: advancedSettings.searchMode, }; - const response = useEnhancedSearch + const response = advancedSettings.useEnhancedSearch ? await documentService.enhancedSearch(searchRequest) : await documentService.search(searchRequest); @@ -282,7 +309,7 @@ const SearchPage: React.FC = () => { } finally { setLoading(false); } - }, [useEnhancedSearch, includeSnippets, snippetLength, searchMode]); + }, [advancedSettings]); const debouncedSearch = useCallback( debounce((query: string, filters: SearchFilters) => performSearch(query, filters), 300), @@ -447,9 +474,6 @@ const SearchPage: React.FC = () => { setHasOcr(event.target.value as OcrStatus); }; - const handleSnippetLengthChange = (event: SelectChangeEvent): void => { - setSnippetLength(event.target.value as number); - }; return ( @@ -673,68 +697,28 @@ const SearchPage: React.FC = () => { )} - {/* Advanced Search Options */} - {showAdvanced && ( - - - - - Search Options - - - - setUseEnhancedSearch(e.target.checked)} - color="primary" - /> - } - label="Enhanced Search" - /> - - - setIncludeSnippets(e.target.checked)} - color="primary" - /> - } - label="Show Snippets" - /> - - - - Snippet Length - - - - - - - - - + {/* Enhanced Search Guide when not in advanced mode */} + {!showAdvanced && ( + + )} + {/* Advanced Search Panel */} + + setAdvancedSettings(prev => ({ ...prev, ...newSettings })) + } + expanded={showAdvanced} + onExpandedChange={setShowAdvanced} + /> + {/* Mobile Filters Drawer */} {showFilters && ( @@ -806,40 +790,14 @@ const SearchPage: React.FC = () => { - {/* File Type Filter */} - - }> - File Types - - - - Select Types - - - - + {/* File Type Filter with Facets */} + + + {/* OCR Filter */} @@ -1176,38 +1134,18 @@ const SearchPage: React.FC = () => { )} - {/* Search Snippets */} + {/* Enhanced Search Snippets */} {doc.snippets && doc.snippets.length > 0 && ( - {doc.snippets.slice(0, 2).map((snippet, index) => ( - theme.palette.mode === 'light' ? 'grey.50' : 'grey.800', - borderLeft: '3px solid', - borderLeftColor: 'primary.main', - }} - > - - ...{renderHighlightedText(snippet.text, snippet.highlight_ranges)}... - - - ))} - {doc.snippets.length > 2 && ( - - +{doc.snippets.length - 2} more matches - - )} + { + // Could navigate to document with snippet highlighted + console.log('Snippet clicked:', snippet, index); + }} + /> )} diff --git a/frontend/src/pages/__tests__/SearchPage.integration.test.tsx b/frontend/src/pages/__tests__/SearchPage.integration.test.tsx new file mode 100644 index 0000000..24b19c5 --- /dev/null +++ b/frontend/src/pages/__tests__/SearchPage.integration.test.tsx @@ -0,0 +1,469 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BrowserRouter } from 'react-router-dom'; +import SearchPage from '../SearchPage'; +import { documentService } from '../../services/api'; + +// Mock the document service +vi.mock('../../services/api', () => ({ + documentService: { + search: vi.fn(), + enhancedSearch: vi.fn(), + getFacets: vi.fn(), + download: vi.fn(), + }, +})); + +const mockSearchResponse = { + data: { + documents: [ + { + id: '1', + original_filename: 'invoice_2024.pdf', + filename: 'invoice_2024.pdf', + file_size: 1024000, + mime_type: 'application/pdf', + created_at: '2024-01-01T10:00:00Z', + has_ocr_text: true, + tags: ['invoice', '2024'], + snippets: [ + { + text: 'This is an invoice for services rendered in January 2024.', + highlight_ranges: [{ start: 10, end: 17 }, { start: 50, end: 57 }], + }, + ], + search_rank: 0.95, + }, + { + id: '2', + original_filename: 'contract_agreement.docx', + filename: 'contract_agreement.docx', + file_size: 512000, + mime_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + created_at: '2024-01-15T14:30:00Z', + has_ocr_text: false, + tags: ['contract', 'legal'], + snippets: [ + { + text: 'Contract agreement between parties for invoice processing.', + highlight_ranges: [{ start: 0, end: 8 }, { start: 40, end: 47 }], + }, + ], + search_rank: 0.87, + }, + ], + total: 2, + query_time_ms: 45, + suggestions: ['invoice processing', 'invoice payment'], + }, +}; + +const mockFacetsResponse = { + data: { + mime_types: [ + { value: 'application/pdf', count: 15 }, + { value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', count: 8 }, + { value: 'image/jpeg', count: 5 }, + { value: 'text/plain', count: 3 }, + ], + tags: [ + { value: 'invoice', count: 12 }, + { value: 'contract', count: 6 }, + { value: 'legal', count: 4 }, + { value: '2024', count: 20 }, + ], + }, +}; + +const renderSearchPage = () => { + return render( + + + + ); +}; + +describe('SearchPage Integration Tests', () => { + beforeEach(() => { + vi.clearAllMocks(); + (documentService.enhancedSearch as any).mockResolvedValue(mockSearchResponse); + (documentService.search as any).mockResolvedValue(mockSearchResponse); + (documentService.getFacets as any).mockResolvedValue(mockFacetsResponse); + }); + + test('performs complete search workflow', async () => { + const user = userEvent.setup(); + renderSearchPage(); + + // Wait for facets to load + await waitFor(() => { + expect(screen.getByText('File Types')).toBeInTheDocument(); + }); + + // Enter search query + const searchInput = screen.getByPlaceholderText(/search your documents/i); + await user.type(searchInput, 'invoice'); + + // Wait for search results + await waitFor(() => { + expect(screen.getByText('invoice_2024.pdf')).toBeInTheDocument(); + expect(screen.getByText('contract_agreement.docx')).toBeInTheDocument(); + }); + + // Verify search was called + expect(documentService.enhancedSearch).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'invoice', + limit: 100, + include_snippets: true, + snippet_length: 200, + search_mode: 'simple', + }) + ); + + // Verify results are displayed + expect(screen.getByText('2 documents found')).toBeInTheDocument(); + expect(screen.getByText('Search completed in 45ms')).toBeInTheDocument(); + }); + + test('filters results using MIME type facets', async () => { + const user = userEvent.setup(); + renderSearchPage(); + + // Wait for facets to load + await waitFor(() => { + expect(screen.getByText('PDFs')).toBeInTheDocument(); + }); + + // Enter search query first + const searchInput = screen.getByPlaceholderText(/search your documents/i); + await user.type(searchInput, 'invoice'); + + // Wait for initial results + await waitFor(() => { + expect(screen.getByText('invoice_2024.pdf')).toBeInTheDocument(); + }); + + // Apply PDF filter + const pdfCheckbox = screen.getByText('PDF Documents').closest('label')?.querySelector('input'); + await user.click(pdfCheckbox!); + + // Verify search is called again with MIME type filter + await waitFor(() => { + expect(documentService.enhancedSearch).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'invoice', + mime_types: ['application/pdf'], + }) + ); + }); + }); + + test('uses advanced search options', async () => { + const user = userEvent.setup(); + renderSearchPage(); + + // Open advanced search panel + const advancedButton = screen.getByText('Advanced Search Options'); + await user.click(advancedButton); + + // Wait for panel to expand + await waitFor(() => { + expect(screen.getByText('Search Behavior')).toBeInTheDocument(); + }); + + // Change search mode to fuzzy + const searchModeSelect = screen.getByDisplayValue('simple'); + await user.click(searchModeSelect); + await user.click(screen.getByText('Fuzzy Search')); + + // Go to Results Display section + await user.click(screen.getByText('Results Display')); + + // Change snippet length + const snippetLengthSelect = screen.getByDisplayValue('200'); + await user.click(snippetLengthSelect); + await user.click(screen.getByText('Long (400 chars)')); + + // Perform search + const searchInput = screen.getByPlaceholderText(/search your documents/i); + await user.type(searchInput, 'invoice'); + + // Verify advanced settings are applied + await waitFor(() => { + expect(documentService.enhancedSearch).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'invoice', + search_mode: 'fuzzy', + snippet_length: 400, + }) + ); + }); + }); + + test('displays enhanced snippets with customization', async () => { + const user = userEvent.setup(); + renderSearchPage(); + + // Perform search + const searchInput = screen.getByPlaceholderText(/search your documents/i); + await user.type(searchInput, 'invoice'); + + // Wait for results with snippets + await waitFor(() => { + expect(screen.getByText(/This is an invoice for services/)).toBeInTheDocument(); + }); + + // Find snippet viewer settings + const settingsButton = screen.getAllByLabelText('Snippet settings')[0]; + await user.click(settingsButton); + + // Change to compact view + const compactOption = screen.getByLabelText('Compact'); + await user.click(compactOption); + + // Verify compact view is applied (content should still be visible but styled differently) + expect(screen.getByText(/This is an invoice for services/)).toBeInTheDocument(); + }); + + test('suggests search examples and allows interaction', async () => { + const user = userEvent.setup(); + renderSearchPage(); + + // Open search guide + const showGuideButton = screen.getByText('Show Guide'); + await user.click(showGuideButton); + + // Wait for guide to expand + await waitFor(() => { + expect(screen.getByText('Search Guide')).toBeInTheDocument(); + }); + + // Click on an example + const exampleButtons = screen.getAllByLabelText('Try this search'); + await user.click(exampleButtons[0]); + + // Verify search input is populated + const searchInput = screen.getByPlaceholderText(/search your documents/i); + expect(searchInput).toHaveValue('invoice'); + + // Verify search is triggered + await waitFor(() => { + expect(documentService.enhancedSearch).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'invoice', + }) + ); + }); + }); + + test('handles search errors gracefully', async () => { + const user = userEvent.setup(); + (documentService.enhancedSearch as any).mockRejectedValue(new Error('Search failed')); + + renderSearchPage(); + + const searchInput = screen.getByPlaceholderText(/search your documents/i); + await user.type(searchInput, 'invoice'); + + // Should show error message + await waitFor(() => { + expect(screen.getByText('Search failed. Please try again.')).toBeInTheDocument(); + }); + }); + + test('switches between view modes', async () => { + const user = userEvent.setup(); + renderSearchPage(); + + // Perform search first + const searchInput = screen.getByPlaceholderText(/search your documents/i); + await user.type(searchInput, 'invoice'); + + // Wait for results + await waitFor(() => { + expect(screen.getByText('invoice_2024.pdf')).toBeInTheDocument(); + }); + + // Switch to list view + const listViewButton = screen.getByLabelText('List view'); + await user.click(listViewButton); + + // Results should still be visible but in list format + expect(screen.getByText('invoice_2024.pdf')).toBeInTheDocument(); + expect(screen.getByText('contract_agreement.docx')).toBeInTheDocument(); + }); + + test('shows search suggestions', async () => { + const user = userEvent.setup(); + renderSearchPage(); + + const searchInput = screen.getByPlaceholderText(/search your documents/i); + await user.type(searchInput, 'invoice'); + + // Wait for suggestions to appear + await waitFor(() => { + expect(screen.getByText('Suggestions:')).toBeInTheDocument(); + expect(screen.getByText('invoice processing')).toBeInTheDocument(); + expect(screen.getByText('invoice payment')).toBeInTheDocument(); + }); + + // Click on a suggestion + const suggestionChip = screen.getByText('invoice processing'); + await user.click(suggestionChip); + + // Verify search input is updated + expect(searchInput).toHaveValue('invoice processing'); + }); + + test('applies multiple filters simultaneously', async () => { + const user = userEvent.setup(); + renderSearchPage(); + + // Wait for facets to load + await waitFor(() => { + expect(screen.getByText('File Types')).toBeInTheDocument(); + }); + + // Enter search query + const searchInput = screen.getByPlaceholderText(/search your documents/i); + await user.type(searchInput, 'invoice'); + + // Apply PDF filter + const pdfCheckbox = screen.getByText('PDF Documents').closest('label')?.querySelector('input'); + await user.click(pdfCheckbox!); + + // Apply date range filter (if visible) + const dateRangeSlider = screen.queryByRole('slider', { name: /date range/i }); + if (dateRangeSlider) { + await user.click(dateRangeSlider); + } + + // Apply OCR filter + const ocrSelect = screen.getByDisplayValue('All Documents'); + await user.click(ocrSelect); + await user.click(screen.getByText('Has OCR Text')); + + // Verify search is called with all filters + await waitFor(() => { + expect(documentService.enhancedSearch).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'invoice', + mime_types: ['application/pdf'], + }) + ); + }); + }); + + test('clears all filters when clear button is clicked', async () => { + const user = userEvent.setup(); + renderSearchPage(); + + // Wait for facets to load + await waitFor(() => { + expect(screen.getByText('File Types')).toBeInTheDocument(); + }); + + // Apply some filters first + const pdfCheckbox = screen.getByText('PDF Documents').closest('label')?.querySelector('input'); + await user.click(pdfCheckbox!); + + // Click clear filters button + const clearButton = screen.getByText('Clear'); + await user.click(clearButton); + + // Verify filters are cleared + expect(pdfCheckbox).not.toBeChecked(); + }); + + test('handles empty search results', async () => { + const user = userEvent.setup(); + const emptyResponse = { + data: { + documents: [], + total: 0, + query_time_ms: 10, + suggestions: [], + }, + }; + + (documentService.enhancedSearch as any).mockResolvedValue(emptyResponse); + renderSearchPage(); + + const searchInput = screen.getByPlaceholderText(/search your documents/i); + await user.type(searchInput, 'nonexistent'); + + await waitFor(() => { + expect(screen.getByText('No documents found')).toBeInTheDocument(); + }); + }); + + test('preserves search state in URL', async () => { + const user = userEvent.setup(); + renderSearchPage(); + + const searchInput = screen.getByPlaceholderText(/search your documents/i); + await user.type(searchInput, 'invoice'); + + // Verify URL is updated (this would require checking window.location or using a memory router) + await waitFor(() => { + expect(searchInput).toHaveValue('invoice'); + }); + }); +}); + +describe('SearchPage Performance Tests', () => { + beforeEach(() => { + vi.clearAllMocks(); + (documentService.enhancedSearch as any).mockResolvedValue(mockSearchResponse); + (documentService.getFacets as any).mockResolvedValue(mockFacetsResponse); + }); + + test('debounces search input to avoid excessive API calls', async () => { + const user = userEvent.setup(); + renderSearchPage(); + + const searchInput = screen.getByPlaceholderText(/search your documents/i); + + // Type quickly + await user.type(searchInput, 'invoice', { delay: 50 }); + + // Wait for debounce + await waitFor(() => { + expect(documentService.enhancedSearch).toHaveBeenCalledTimes(1); + }); + + // Should only be called once due to debouncing + expect(documentService.enhancedSearch).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'invoice', + }) + ); + }); + + test('shows loading states during search', async () => { + const user = userEvent.setup(); + + // Make the API call take longer to see loading state + (documentService.enhancedSearch as any).mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve(mockSearchResponse), 1000)) + ); + + renderSearchPage(); + + const searchInput = screen.getByPlaceholderText(/search your documents/i); + await user.type(searchInput, 'invoice'); + + // Should show loading indicator + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + + // Wait for search to complete + await waitFor(() => { + expect(screen.getByText('invoice_2024.pdf')).toBeInTheDocument(); + }, { timeout: 2000 }); + + // Loading indicator should be gone + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2785526..e45d142 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -72,6 +72,26 @@ export interface SearchResponse { suggestions: string[] } +export interface FacetItem { + value: string + count: number +} + +export interface SearchFacetsResponse { + mime_types: FacetItem[] + tags: FacetItem[] +} + +export interface PaginatedResponse { + documents: T[] + pagination: { + total: number + limit: number + offset: number + has_more: boolean + } +} + export interface QueueStats { pending_count: number processing_count: number @@ -182,6 +202,10 @@ export const documentService = { }, }) }, + + getFacets: () => { + return api.get('/search/facets') + }, } export interface OcrStatusResponse { diff --git a/src/db/documents.rs b/src/db/documents.rs index 3672609..f906493 100644 --- a/src/db/documents.rs +++ b/src/db/documents.rs @@ -989,4 +989,82 @@ impl Database { Ok(documents) } + + pub async fn get_mime_type_facets(&self, user_id: Uuid, user_role: crate::models::UserRole) -> Result> { + let query = if user_role == crate::models::UserRole::Admin { + // Admins see facets for all documents + r#" + SELECT mime_type, COUNT(*) as count + FROM documents + GROUP BY mime_type + ORDER BY count DESC + "# + } else { + // Regular users see facets for their own documents + r#" + SELECT mime_type, COUNT(*) as count + FROM documents + WHERE user_id = $1 + GROUP BY mime_type + ORDER BY count DESC + "# + }; + + let rows = if user_role == crate::models::UserRole::Admin { + sqlx::query(query) + .fetch_all(&self.pool) + .await? + } else { + sqlx::query(query) + .bind(user_id) + .fetch_all(&self.pool) + .await? + }; + + let facets = rows + .into_iter() + .map(|row| (row.get("mime_type"), row.get("count"))) + .collect(); + + Ok(facets) + } + + pub async fn get_tag_facets(&self, user_id: Uuid, user_role: crate::models::UserRole) -> Result> { + let query = if user_role == crate::models::UserRole::Admin { + // Admins see facets for all documents + r#" + SELECT UNNEST(tags) as tag, COUNT(*) as count + FROM documents + GROUP BY tag + ORDER BY count DESC + "# + } else { + // Regular users see facets for their own documents + r#" + SELECT UNNEST(tags) as tag, COUNT(*) as count + FROM documents + WHERE user_id = $1 + GROUP BY tag + ORDER BY count DESC + "# + }; + + let rows = if user_role == crate::models::UserRole::Admin { + sqlx::query(query) + .fetch_all(&self.pool) + .await? + } else { + sqlx::query(query) + .bind(user_id) + .fetch_all(&self.pool) + .await? + }; + + let facets = rows + .into_iter() + .map(|row| (row.get("tag"), row.get("count"))) + .collect(); + + Ok(facets) + } } \ No newline at end of file diff --git a/src/models.rs b/src/models.rs index ef9c32e..cd4b7f7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -234,6 +234,22 @@ pub struct SearchResponse { pub suggestions: Vec, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct FacetItem { + /// The facet value (e.g., mime type or tag) + pub value: String, + /// Number of documents with this value + pub count: i64, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct SearchFacetsResponse { + /// MIME type facets with counts + pub mime_types: Vec, + /// Tag facets with counts + pub tags: Vec, +} + impl From for DocumentResponse { fn from(doc: Document) -> Self { Self { diff --git a/src/routes/search.rs b/src/routes/search.rs index de27ff3..d153dbc 100644 --- a/src/routes/search.rs +++ b/src/routes/search.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use crate::{ auth::AuthUser, - models::{SearchRequest, SearchResponse, EnhancedDocumentResponse}, + models::{SearchRequest, SearchResponse, EnhancedDocumentResponse, SearchFacetsResponse, FacetItem}, AppState, }; @@ -17,6 +17,7 @@ pub fn router() -> Router> { Router::new() .route("/", get(search_documents)) .route("/enhanced", get(enhanced_search_documents)) + .route("/facets", get(get_search_facets)) } #[utoipa::path( @@ -131,4 +132,51 @@ fn generate_search_suggestions(query: &str) -> Vec { } suggestions.into_iter().take(3).collect() +} + +#[utoipa::path( + get, + path = "/api/search/facets", + tag = "search", + security( + ("bearer_auth" = []) + ), + responses( + (status = 200, description = "Search facets with counts", body = SearchFacetsResponse), + (status = 401, description = "Unauthorized") + ) +)] +async fn get_search_facets( + State(state): State>, + auth_user: AuthUser, +) -> Result, StatusCode> { + let user_id = auth_user.user.id; + let user_role = auth_user.user.role; + + // Get MIME type facets + let mime_type_facets = state + .db + .get_mime_type_facets(user_id, user_role.clone()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Get tag facets + let tag_facets = state + .db + .get_tag_facets(user_id, user_role) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let response = SearchFacetsResponse { + mime_types: mime_type_facets + .into_iter() + .map(|(value, count)| FacetItem { value, count }) + .collect(), + tags: tag_facets + .into_iter() + .map(|(value, count)| FacetItem { value, count }) + .collect(), + }; + + Ok(Json(response)) } \ No newline at end of file