feat(client/server): implement a much better search

This commit is contained in:
perf3ct 2025-06-17 02:41:16 +00:00
parent 98a4b7479b
commit 76529f83be
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
18 changed files with 3692 additions and 136 deletions

View File

@ -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<AdvancedSearchSettings>) => 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<AdvancedSearchPanelProps> = ({
settings,
onSettingsChange,
onSavePreset,
onLoadPreset,
availablePresets = [],
expanded = false,
onExpandedChange,
}) => {
const [activeSection, setActiveSection] = useState<string>('search-behavior');
const [showPresetSave, setShowPresetSave] = useState(false);
const [presetName, setPresetName] = useState('');
const sections = [
{
id: 'search-behavior',
label: 'Search Behavior',
icon: <SearchIcon />,
description: 'How search queries are processed and matched',
},
{
id: 'results-display',
label: 'Results Display',
icon: <VisibilityIcon />,
description: 'How search results are shown and formatted',
},
{
id: 'performance',
label: 'Performance',
icon: <SpeedIcon />,
description: 'Speed and resource optimization settings',
},
{
id: 'content-sources',
label: 'Content Sources',
icon: <PsychologyIcon />,
description: 'Which parts of documents to search',
},
];
const handleSettingChange = <K extends keyof AdvancedSearchSettings>(
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 (
<Paper
elevation={2}
sx={{
mb: 3,
overflow: 'hidden',
border: '1px solid',
borderColor: expanded ? 'primary.main' : 'divider',
transition: 'all 0.3s ease',
}}
>
<Box
sx={{
p: 2,
backgroundColor: expanded ? 'primary.50' : 'background.paper',
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
backgroundColor: expanded ? 'primary.100' : 'action.hover',
},
}}
onClick={() => onExpandedChange?.(!expanded)}
>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={2}>
<Badge
color="primary"
variant="dot"
invisible={!settings.useEnhancedSearch}
>
<TuneIcon color={expanded ? 'primary' : 'action'} />
</Badge>
<Box>
<Typography variant="h6" color={expanded ? 'primary.main' : 'text.primary'}>
Advanced Search Options
</Typography>
<Typography variant="caption" color="text.secondary">
Customize search behavior and result display
</Typography>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Chip
label={settings.searchMode.toUpperCase()}
size="small"
color={settings.useEnhancedSearch ? 'primary' : 'default'}
variant={expanded ? 'filled' : 'outlined'}
/>
<IconButton size="small">
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
</Box>
</Box>
<Collapse in={expanded}>
<Box sx={{ p: 0 }}>
{/* Section Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}>
<Box display="flex" gap={1} sx={{ overflowX: 'auto', py: 1 }}>
{sections.map((section) => (
<Button
key={section.id}
variant={activeSection === section.id ? 'contained' : 'text'}
size="small"
startIcon={section.icon}
onClick={() => setActiveSection(section.id)}
sx={{ minWidth: 'fit-content', whiteSpace: 'nowrap' }}
>
{section.label}
</Button>
))}
</Box>
</Box>
<Box sx={{ p: 3 }}>
{/* Search Behavior Section */}
{activeSection === 'search-behavior' && (
<Box>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
These settings control how your search queries are interpreted and matched against documents.
</Typography>
</Alert>
<Box display="flex" flexDirection={{ xs: 'column', md: 'row' }} gap={3} mb={3}>
<Box flex={1}>
<FormControl fullWidth>
<InputLabel>Search Mode</InputLabel>
<Select
value={settings.searchMode}
onChange={(e) => handleSettingChange('searchMode', e.target.value as SearchMode)}
label="Search Mode"
>
<MenuItem value="simple">
<Box>
<Typography variant="body2">Simple Search</Typography>
<Typography variant="caption" color="text.secondary">
Basic keyword matching
</Typography>
</Box>
</MenuItem>
<MenuItem value="phrase">
<Box>
<Typography variant="body2">Phrase Search</Typography>
<Typography variant="caption" color="text.secondary">
Exact phrase matching
</Typography>
</Box>
</MenuItem>
<MenuItem value="fuzzy">
<Box>
<Typography variant="body2">Fuzzy Search</Typography>
<Typography variant="caption" color="text.secondary">
Typo-tolerant matching
</Typography>
</Box>
</MenuItem>
<MenuItem value="boolean">
<Box>
<Typography variant="body2">Boolean Search</Typography>
<Typography variant="caption" color="text.secondary">
AND, OR, NOT operators
</Typography>
</Box>
</MenuItem>
</Select>
</FormControl>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{getSearchModeDescription(settings.searchMode)}
</Typography>
</Box>
<Box flex={1}>
<Typography variant="body2" gutterBottom>
Fuzzy Match Threshold: {settings.fuzzyThreshold}
</Typography>
<Slider
value={settings.fuzzyThreshold}
onChange={(_, value) => 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)}%`}
/>
<Typography variant="caption" color="text.secondary">
Higher values = stricter matching
</Typography>
</Box>
</Box>
<Box>
<Box display="flex" flexWrap="wrap" gap={2}>
<FormControlLabel
control={
<Switch
checked={settings.useEnhancedSearch}
onChange={(e) => handleSettingChange('useEnhancedSearch', e.target.checked)}
/>
}
label="Enhanced Search Engine"
/>
<FormControlLabel
control={
<Switch
checked={settings.enableAutoCorrect}
onChange={(e) => handleSettingChange('enableAutoCorrect', e.target.checked)}
/>
}
label="Auto-correct Spelling"
/>
<FormControlLabel
control={
<Switch
checked={settings.boostRecentDocs}
onChange={(e) => handleSettingChange('boostRecentDocs', e.target.checked)}
/>
}
label="Boost Recent Documents"
/>
</Box>
</Grid>
</Grid>
)}
{/* Results Display Section */}
{activeSection === 'results-display' && (
<Grid container spacing={3}>
<Grid size={12}>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
Control how search results are presented and what information is shown.
</Typography>
</Alert>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControlLabel
control={
<Switch
checked={settings.includeSnippets}
onChange={(e) => handleSettingChange('includeSnippets', e.target.checked)}
/>
}
label="Show Text Snippets"
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth disabled={!settings.includeSnippets}>
<InputLabel>Snippet Length</InputLabel>
<Select
value={settings.snippetLength}
onChange={(e) => handleSettingChange('snippetLength', e.target.value as number)}
label="Snippet Length"
>
<MenuItem value={100}>Short (100 chars)</MenuItem>
<MenuItem value={200}>Medium (200 chars)</MenuItem>
<MenuItem value={400}>Long (400 chars)</MenuItem>
<MenuItem value={600}>Extra Long (600 chars)</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth>
<InputLabel>Results Per Page</InputLabel>
<Select
value={settings.resultLimit}
onChange={(e) => handleSettingChange('resultLimit', e.target.value as number)}
label="Results Per Page"
>
<MenuItem value={25}>25 results</MenuItem>
<MenuItem value={50}>50 results</MenuItem>
<MenuItem value={100}>100 results</MenuItem>
<MenuItem value={200}>200 results</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
)}
{/* Content Sources Section */}
{activeSection === 'content-sources' && (
<Grid container spacing={3}>
<Grid size={12}>
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
Choose which parts of your documents to include in search.
</Typography>
</Alert>
</Grid>
<Grid size={12}>
<Typography variant="subtitle2" gutterBottom>
Search In:
</Typography>
<Box display="flex" flexDirection="column" gap={1}>
<FormControlLabel
control={
<Switch
checked={settings.includeFileContent}
onChange={(e) => handleSettingChange('includeFileContent', e.target.checked)}
/>
}
label="Document Content"
/>
<FormControlLabel
control={
<Switch
checked={settings.includeOcrText}
onChange={(e) => handleSettingChange('includeOcrText', e.target.checked)}
/>
}
label="OCR Extracted Text"
/>
<FormControlLabel
control={
<Switch
checked={settings.includeFilenames}
onChange={(e) => handleSettingChange('includeFilenames', e.target.checked)}
/>
}
label="Filenames"
/>
</Box>
</Grid>
</Grid>
)}
{/* Performance Section */}
{activeSection === 'performance' && (
<Grid container spacing={3}>
<Grid size={12}>
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
These settings can affect search speed. Use with caution for large document collections.
</Typography>
</Alert>
</Grid>
<Grid size={12}>
<Typography variant="body2" gutterBottom>
Maximum Results: {settings.resultLimit}
</Typography>
<Slider
value={settings.resultLimit}
onChange={(_, value) => 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"
/>
<Typography variant="caption" color="text.secondary">
Higher values may slow down search for large collections
</Typography>
</Grid>
</Grid>
)}
{/* Action Buttons */}
<Divider sx={{ my: 3 }} />
<Box display="flex" alignItems="center" justifyContent="between" gap={2}>
<Box display="flex" gap={1}>
<Button
variant="outlined"
size="small"
startIcon={<ResetIcon />}
onClick={handleResetToDefaults}
>
Reset to Defaults
</Button>
<Button
variant="outlined"
size="small"
startIcon={<SaveIcon />}
onClick={() => setShowPresetSave(!showPresetSave)}
>
Save Preset
</Button>
</Box>
{availablePresets.length > 0 && (
<FormControl size="small" sx={{ minWidth: 150 }}>
<InputLabel>Load Preset</InputLabel>
<Select
label="Load Preset"
onChange={(e) => {
const preset = availablePresets.find(p => p.name === e.target.value);
if (preset && onLoadPreset) {
onLoadPreset(preset.settings);
}
}}
value=""
>
{availablePresets.map((preset) => (
<MenuItem key={preset.name} value={preset.name}>
{preset.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
</Box>
{/* Save Preset Dialog */}
<Collapse in={showPresetSave}>
<Card variant="outlined" sx={{ mt: 2, p: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Save Current Settings as Preset
</Typography>
<Box display="flex" gap={1} alignItems="end">
<TextField
size="small"
label="Preset Name"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
fullWidth
/>
<Button
variant="contained"
size="small"
onClick={handleSavePreset}
disabled={!presetName.trim()}
>
Save
</Button>
<Button
size="small"
onClick={() => {
setShowPresetSave(false);
setPresetName('');
}}
>
Cancel
</Button>
</Box>
</Card>
</Collapse>
</Box>
</Box>
</Collapse>
</Paper>
);
};
export default AdvancedSearchPanel;

View File

@ -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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={false}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={false}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
// 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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={fuzzySettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={settingsWithoutSnippets}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
onSavePreset={mockOnSavePreset}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
onSavePreset={mockOnSavePreset}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
availablePresets={mockPresets}
onLoadPreset={mockOnLoadPreset}
/>
);
expect(screen.getByLabelText('Load Preset')).toBeInTheDocument();
});
test('loads preset when selected', async () => {
const user = userEvent.setup();
render(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
availablePresets={mockPresets}
onLoadPreset={mockOnLoadPreset}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={false}
onExpandedChange={mockOnExpandedChange}
/>
);
// 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(
<AdvancedSearchPanel
settings={settingsWithoutEnhanced}
onSettingsChange={mockOnSettingsChange}
expanded={false}
onExpandedChange={mockOnExpandedChange}
/>
);
// 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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
onSavePreset={mockOnSavePreset}
/>
);
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(
<AdvancedSearchPanel
settings={mockSettings}
onSettingsChange={mockOnSettingsChange}
expanded={true}
onExpandedChange={mockOnExpandedChange}
/>
);
expect(screen.getByText('Basic keyword matching with stemming')).toBeInTheDocument();
});
});

View File

@ -0,0 +1 @@
export { default } from './AdvancedSearchPanel';

View File

@ -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<EnhancedSearchGuideProps> = ({ onExampleClick, compact = false }) => {
const [expanded, setExpanded] = useState(!compact);
const [activeTab, setActiveTab] = useState(0);
const [copiedExample, setCopiedExample] = useState<string | null>(null);
const searchExamples: SearchExample[] = [
// Basic searches
{
query: 'invoice',
description: 'Simple keyword search',
category: 'basic',
icon: <SearchIcon />,
},
{
query: '"project proposal"',
description: 'Exact phrase search',
category: 'basic',
icon: <TextIcon />,
},
{
query: 'report*',
description: 'Wildcard search (finds report, reports, reporting)',
category: 'basic',
icon: <FunctionIcon />,
},
// Advanced searches
{
query: 'invoice AND payment',
description: 'Both terms must appear',
category: 'advanced',
icon: <CodeIcon />,
},
{
query: 'budget OR forecast',
description: 'Either term can appear',
category: 'advanced',
icon: <CodeIcon />,
},
{
query: 'contract NOT draft',
description: 'Exclude documents with "draft"',
category: 'advanced',
icon: <CodeIcon />,
},
{
query: '(invoice OR receipt) AND 2024',
description: 'Complex boolean search with grouping',
category: 'advanced',
icon: <CodeIcon />,
},
// Filter searches
{
query: 'tag:important',
description: 'Search by tag',
category: 'filters',
icon: <LabelIcon />,
},
{
query: 'tag:invoice tag:paid',
description: 'Multiple tags (must have both)',
category: 'filters',
icon: <LabelIcon />,
},
{
query: 'type:pdf invoice',
description: 'Search only PDF files',
category: 'filters',
icon: <TextIcon />,
},
{
query: 'size:>5MB presentation',
description: 'Files larger than 5MB',
category: 'filters',
icon: <SizeIcon />,
},
{
query: 'date:2024 quarterly report',
description: 'Documents from 2024',
category: 'filters',
icon: <DateIcon />,
},
{
query: 'ocr:yes scan',
description: 'Only documents with OCR text',
category: 'filters',
icon: <TextIcon />,
},
// Power user operators
{
query: 'invoice NEAR payment',
description: 'Terms appear close together',
category: 'operators',
icon: <FunctionIcon />,
},
{
query: '"annual report" ~5 2024',
description: 'Terms within 5 words of each other',
category: 'operators',
icon: <FunctionIcon />,
},
{
query: 'proj* AND (budget OR cost*) tag:active',
description: 'Complex query combining wildcards, boolean, and filters',
category: 'operators',
icon: <FunctionIcon />,
},
];
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) => (
<Card
key={example.query}
variant="outlined"
sx={{
mb: 1.5,
transition: 'all 0.2s',
'&:hover': {
boxShadow: 2,
transform: 'translateY(-2px)',
},
}}
>
<CardContent sx={{ py: 1.5, px: 2 }}>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={1} flex={1}>
{example.icon && (
<Box sx={{ color: 'primary.main' }}>
{example.icon}
</Box>
)}
<Box flex={1}>
<Typography
variant="body2"
fontFamily="monospace"
sx={{
backgroundColor: 'grey.100',
px: 1,
py: 0.5,
borderRadius: 1,
display: 'inline-block',
mb: 0.5,
}}
>
{example.query}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
{example.description}
</Typography>
</Box>
</Box>
<Box display="flex" gap={0.5}>
<Tooltip title="Copy to clipboard">
<IconButton
size="small"
onClick={() => handleCopyExample(example.query)}
sx={{
color: copiedExample === example.query ? 'success.main' : 'text.secondary'
}}
>
<CopyIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Try this search">
<IconButton
size="small"
color="primary"
onClick={() => handleExampleClick(example.query)}
>
<PlayIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</CardContent>
</Card>
);
const renderQuickTips = () => (
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
<TipIcon sx={{ verticalAlign: 'middle', mr: 1 }} />
Quick Tips
</Typography>
<List dense sx={{ mt: 1 }}>
<ListItem>
<ListItemText
primary="Use quotes for exact phrases"
secondary='"annual report" finds the exact phrase'
/>
</ListItem>
<ListItem>
<ListItemText
primary="Combine filters for precision"
secondary='type:pdf tag:important date:2024'
/>
</ListItem>
<ListItem>
<ListItemText
primary="Use wildcards for variations"
secondary='doc* matches document, documentation, docs'
/>
</ListItem>
</List>
</Alert>
);
if (compact && !expanded) {
return (
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={1}>
<TipIcon color="primary" />
<Typography variant="body2">
Need help with search? View examples and syntax guide
</Typography>
</Box>
<Button
size="small"
endIcon={<ExpandMoreIcon />}
onClick={() => setExpanded(true)}
>
Show Guide
</Button>
</Box>
</Paper>
);
}
return (
<Paper elevation={0} sx={{ p: 3, mb: 3, backgroundColor: 'grey.50' }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h6" display="flex" alignItems="center" gap={1}>
<TipIcon color="primary" />
Search Guide
</Typography>
{compact && (
<IconButton onClick={() => setExpanded(false)} size="small">
<ExpandMoreIcon sx={{ transform: 'rotate(180deg)' }} />
</IconButton>
)}
</Box>
{renderQuickTips()}
<Tabs
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
sx={{ mb: 2 }}
>
<Tab label={`Basic (${categorizedExamples.basic.length})`} />
<Tab label={`Advanced (${categorizedExamples.advanced.length})`} />
<Tab label={`Filters (${categorizedExamples.filters.length})`} />
<Tab label={`Power User (${categorizedExamples.operators.length})`} />
</Tabs>
<Box role="tabpanel" hidden={activeTab !== 0}>
<Grid container spacing={2}>
{categorizedExamples.basic.map(example => (
<Grid item xs={12} md={6} key={example.query}>
{renderExampleCard(example)}
</Grid>
))}
</Grid>
</Box>
<Box role="tabpanel" hidden={activeTab !== 1}>
<Grid container spacing={2}>
{categorizedExamples.advanced.map(example => (
<Grid item xs={12} md={6} key={example.query}>
{renderExampleCard(example)}
</Grid>
))}
</Grid>
</Box>
<Box role="tabpanel" hidden={activeTab !== 2}>
<Grid container spacing={2}>
{categorizedExamples.filters.map(example => (
<Grid item xs={12} md={6} key={example.query}>
{renderExampleCard(example)}
</Grid>
))}
</Grid>
</Box>
<Box role="tabpanel" hidden={activeTab !== 3}>
<Grid container spacing={2}>
{categorizedExamples.operators.map(example => (
<Grid item xs={12} key={example.query}>
{renderExampleCard(example)}
</Grid>
))}
</Grid>
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary" display="block" textAlign="center">
Click <PlayIcon sx={{ fontSize: 14, verticalAlign: 'middle' }} /> to try an example,
or <CopyIcon sx={{ fontSize: 14, verticalAlign: 'middle' }} /> to copy it
</Typography>
</Paper>
);
};
export default EnhancedSearchGuide;

View File

@ -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(<EnhancedSearchGuide onExampleClick={mockOnExampleClick} compact />);
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(<EnhancedSearchGuide onExampleClick={mockOnExampleClick} compact />);
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(<EnhancedSearchGuide onExampleClick={mockOnExampleClick} />);
// 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(<EnhancedSearchGuide onExampleClick={mockOnExampleClick} />);
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(<EnhancedSearchGuide onExampleClick={mockOnExampleClick} />);
// 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(<EnhancedSearchGuide onExampleClick={mockOnExampleClick} />);
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(<EnhancedSearchGuide onExampleClick={mockOnExampleClick} />);
const copyButtons = screen.getAllByLabelText('Copy to clipboard');
await user.click(copyButtons[0]);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('invoice');
});
test('shows quick tips', () => {
render(<EnhancedSearchGuide onExampleClick={mockOnExampleClick} />);
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(<EnhancedSearchGuide onExampleClick={mockOnExampleClick} compact={false} />);
// 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(<EnhancedSearchGuide onExampleClick={mockOnExampleClick} />);
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(<EnhancedSearchGuide onExampleClick={mockOnExampleClick} />);
// 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(<EnhancedSearchGuide />);
const playButtons = screen.getAllByLabelText('Try this search');
expect(() => fireEvent.click(playButtons[0])).not.toThrow();
});
});

View File

@ -0,0 +1 @@
export { default } from './EnhancedSearchGuide';

View File

@ -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<EnhancedSnippetViewerProps> = ({
snippets,
searchQuery,
maxSnippetsToShow = 3,
onSnippetClick,
}) => {
const [expanded, setExpanded] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('detailed');
const [highlightStyle, setHighlightStyle] = useState<HighlightStyle>('background');
const [fontSize, setFontSize] = useState<number>(14);
const [contextLength, setContextLength] = useState<number>(50);
const [settingsAnchor, setSettingsAnchor] = useState<null | HTMLElement>(null);
const [copiedIndex, setCopiedIndex] = useState<number | null>(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(
<span key={`text-${index}`}>
{text.substring(lastIndex, range.start)}
</span>
);
}
// Add highlighted text
const highlightedText = text.substring(range.start, range.end);
parts.push(
<Box
key={`highlight-${index}`}
component="mark"
sx={{
backgroundColor: highlightStyle === 'background' ? 'primary.light' : 'transparent',
color: highlightStyle === 'background' ? 'primary.contrastText' : 'primary.main',
textDecoration: highlightStyle === 'underline' ? 'underline' : 'none',
textDecorationColor: 'primary.main',
textDecorationThickness: '2px',
fontWeight: highlightStyle === 'bold' ? 700 : 600,
padding: highlightStyle === 'background' ? '0 2px' : 0,
borderRadius: highlightStyle === 'background' ? '2px' : 0,
}}
>
{highlightedText}
</Box>
);
lastIndex = range.end;
});
// Add remaining text
if (lastIndex < text.length) {
parts.push(
<span key={`text-end`}>
{text.substring(lastIndex)}
</span>
);
}
return parts;
};
const getSourceIcon = (source?: string) => {
switch (source) {
case 'ocr_text':
return <SearchIcon fontSize="small" />;
case 'filename':
return <QuoteIcon fontSize="small" />;
default:
return <CodeIcon fontSize="small" />;
}
};
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 (
<Paper
key={index}
variant="outlined"
sx={{
p: isCompact ? 1 : 2,
mb: 1.5,
backgroundColor: (theme) => 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)}
>
<Box display="flex" alignItems="flex-start" justifyContent="space-between">
<Box flex={1}>
{!isCompact && (
<Box display="flex" alignItems="center" gap={1} mb={1}>
<Chip
icon={getSourceIcon(snippet.source)}
label={getSourceLabel(snippet.source)}
size="small"
variant="outlined"
/>
{snippet.page_number && (
<Chip
label={`Page ${snippet.page_number}`}
size="small"
variant="outlined"
/>
)}
{snippet.confidence && snippet.confidence < 0.8 && (
<Chip
label={`${(snippet.confidence * 100).toFixed(0)}% confidence`}
size="small"
color="warning"
variant="outlined"
/>
)}
</Box>
)}
<Typography
variant="body2"
sx={{
fontSize: `${fontSize}px`,
lineHeight: 1.6,
color: 'text.primary',
wordWrap: 'break-word',
fontFamily: viewMode === 'context' ? 'monospace' : 'inherit',
}}
>
{renderHighlightedText(snippet.text, snippet.highlight_ranges)}
</Typography>
</Box>
<Box display="flex" gap={0.5} ml={2}>
<Tooltip title="Copy snippet">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleCopySnippet(snippet.text, index);
}}
sx={{
color: copiedIndex === index ? 'success.main' : 'text.secondary'
}}
>
<CopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Paper>
);
};
return (
<Box>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="subtitle2" fontWeight="bold">
Search Results
</Typography>
{snippets.length > 0 && (
<Chip
label={`${snippets.length} matches`}
size="small"
color="primary"
variant="outlined"
/>
)}
</Box>
<Box display="flex" alignItems="center" gap={1}>
<Tooltip title="Snippet settings">
<IconButton
size="small"
onClick={(e) => setSettingsAnchor(e.currentTarget)}
>
<SettingsIcon fontSize="small" />
</IconButton>
</Tooltip>
{snippets.length > maxSnippetsToShow && (
<Button
size="small"
onClick={() => setExpanded(!expanded)}
endIcon={expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
>
{expanded ? 'Show Less' : `Show All (${snippets.length})`}
</Button>
)}
</Box>
</Box>
{searchQuery && (
<Box mb={2}>
<Typography variant="caption" color="text.secondary">
Showing matches for: <strong>{searchQuery}</strong>
</Typography>
</Box>
)}
{visibleSnippets.map((snippet, index) => renderSnippet(snippet, index))}
{snippets.length === 0 && (
<Paper variant="outlined" sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No text snippets available for this search result
</Typography>
</Paper>
)}
{/* Settings Menu */}
<Menu
anchorEl={settingsAnchor}
open={Boolean(settingsAnchor)}
onClose={() => setSettingsAnchor(null)}
PaperProps={{ sx: { width: 320, p: 2 } }}
>
<Typography variant="subtitle2" sx={{ mb: 2 }}>
Snippet Display Settings
</Typography>
<Box mb={2}>
<Typography variant="caption" color="text.secondary" gutterBottom>
View Mode
</Typography>
<RadioGroup
value={viewMode}
onChange={(e) => setViewMode(e.target.value as ViewMode)}
>
<FormControlLabel
value="compact"
control={<Radio size="small" />}
label="Compact"
/>
<FormControlLabel
value="detailed"
control={<Radio size="small" />}
label="Detailed (with metadata)"
/>
<FormControlLabel
value="context"
control={<Radio size="small" />}
label="Context Focus"
/>
</RadioGroup>
</Box>
<Divider sx={{ my: 2 }} />
<Box mb={2}>
<Typography variant="caption" color="text.secondary" gutterBottom>
Highlight Style
</Typography>
<RadioGroup
value={highlightStyle}
onChange={(e) => setHighlightStyle(e.target.value as HighlightStyle)}
>
<FormControlLabel
value="background"
control={<Radio size="small" />}
label="Background Color"
/>
<FormControlLabel
value="underline"
control={<Radio size="small" />}
label="Underline"
/>
<FormControlLabel
value="bold"
control={<Radio size="small" />}
label="Bold Text"
/>
</RadioGroup>
</Box>
<Divider sx={{ my: 2 }} />
<Box mb={2}>
<Typography variant="caption" color="text.secondary" gutterBottom>
Font Size: {fontSize}px
</Typography>
<Slider
value={fontSize}
onChange={(_, value) => setFontSize(value as number)}
min={12}
max={20}
marks
valueLabelDisplay="auto"
/>
</Box>
{viewMode === 'context' && (
<>
<Divider sx={{ my: 2 }} />
<Box>
<Typography variant="caption" color="text.secondary" gutterBottom>
Context Length: {contextLength} characters
</Typography>
<Slider
value={contextLength}
onChange={(_, value) => setContextLength(value as number)}
min={20}
max={200}
step={10}
marks
valueLabelDisplay="auto"
/>
</Box>
</>
)}
</Menu>
</Box>
);
};
export default EnhancedSnippetViewer;

View File

@ -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(
<EnhancedSnippetViewer
snippets={mockSnippets}
searchQuery="invoice payment"
onSnippetClick={mockOnSnippetClick}
/>
);
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(
<EnhancedSnippetViewer
snippets={mockSnippets}
searchQuery="invoice payment"
onSnippetClick={mockOnSnippetClick}
/>
);
expect(screen.getByText('Showing matches for:')).toBeInTheDocument();
expect(screen.getByText('invoice payment')).toBeInTheDocument();
});
test('shows correct source badges', () => {
render(
<EnhancedSnippetViewer
snippets={mockSnippets}
onSnippetClick={mockOnSnippetClick}
/>
);
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(
<EnhancedSnippetViewer
snippets={mockSnippets}
onSnippetClick={mockOnSnippetClick}
/>
);
expect(screen.getByText('Page 1')).toBeInTheDocument();
expect(screen.getByText('75% confidence')).toBeInTheDocument();
});
test('limits snippets display based on maxSnippetsToShow', () => {
render(
<EnhancedSnippetViewer
snippets={mockSnippets}
maxSnippetsToShow={2}
onSnippetClick={mockOnSnippetClick}
/>
);
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(
<EnhancedSnippetViewer
snippets={mockSnippets}
maxSnippetsToShow={2}
onSnippetClick={mockOnSnippetClick}
/>
);
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(
<EnhancedSnippetViewer
snippets={mockSnippets}
onSnippetClick={mockOnSnippetClick}
/>
);
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(
<EnhancedSnippetViewer
snippets={mockSnippets}
onSnippetClick={mockOnSnippetClick}
/>
);
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(
<EnhancedSnippetViewer
snippets={mockSnippets}
onSnippetClick={mockOnSnippetClick}
/>
);
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(
<EnhancedSnippetViewer
snippets={mockSnippets}
onSnippetClick={mockOnSnippetClick}
/>
);
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(
<EnhancedSnippetViewer
snippets={mockSnippets}
onSnippetClick={mockOnSnippetClick}
/>
);
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(
<EnhancedSnippetViewer
snippets={mockSnippets}
onSnippetClick={mockOnSnippetClick}
/>
);
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(
<EnhancedSnippetViewer
snippets={[mockSnippets[0]]} // First snippet has multiple highlights
onSnippetClick={mockOnSnippetClick}
/>
);
// 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(
<EnhancedSnippetViewer
snippets={snippetsWithoutHighlights}
onSnippetClick={mockOnSnippetClick}
/>
);
expect(screen.getByText('Plain text without any highlights')).toBeInTheDocument();
});
test('displays empty state when no snippets provided', () => {
render(
<EnhancedSnippetViewer
snippets={[]}
onSnippetClick={mockOnSnippetClick}
/>
);
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(
<EnhancedSnippetViewer
snippets={lowConfidenceSnippet}
onSnippetClick={mockOnSnippetClick}
/>
);
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(
<EnhancedSnippetViewer
snippets={highConfidenceSnippet}
onSnippetClick={mockOnSnippetClick}
/>
);
expect(screen.queryByText('90% confidence')).not.toBeInTheDocument();
});
test('handles click events without onSnippetClick prop', async () => {
const user = userEvent.setup();
render(
<EnhancedSnippetViewer
snippets={mockSnippets}
/>
);
const firstSnippet = screen.getByText(/This is a sample document/).closest('div');
expect(() => user.click(firstSnippet!)).not.toThrow();
});
});

View File

@ -0,0 +1 @@
export { default } from './EnhancedSnippetViewer';

View File

@ -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: <PdfIcon />,
patterns: ['application/pdf'],
color: '#d32f2f',
},
{
label: 'Images',
icon: <ImageIcon />,
patterns: ['image/'],
color: '#1976d2',
},
{
label: 'Documents',
icon: <DocIcon />,
patterns: ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml', 'application/rtf'],
color: '#388e3c',
},
{
label: 'Spreadsheets',
icon: <SpreadsheetIcon />,
patterns: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml', 'text/csv'],
color: '#f57c00',
},
{
label: 'Text Files',
icon: <TextIcon />,
patterns: ['text/plain', 'text/markdown', 'text/x-'],
color: '#7b1fa2',
},
{
label: 'Code',
icon: <CodeIcon />,
patterns: ['application/javascript', 'application/json', 'application/xml', 'text/html', 'text/css'],
color: '#00796b',
},
];
const MimeTypeFacetFilter: React.FC<MimeTypeFacetFilterProps> = ({
selectedMimeTypes,
onMimeTypeChange,
maxItemsToShow = 10,
}) => {
const [facets, setFacets] = useState<FacetItem[]>([]);
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 : <FolderIcon />;
};
const getMimeTypeLabel = (mimeType: string): string => {
const labels: Record<string, string> = {
'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<string, FacetItem[]> = 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 (
<Box key={group.label} sx={{ mb: 2 }}>
<Box
display="flex"
alignItems="center"
sx={{
cursor: 'pointer',
'&:hover': { backgroundColor: 'action.hover' },
p: 1,
borderRadius: 1,
}}
onClick={() => handleSelectGroup(group)}
>
<Checkbox
checked={allSelected}
indeterminate={someSelected}
sx={{ p: 0, mr: 1 }}
/>
<Box display="flex" alignItems="center" gap={1} flex={1}>
<Box sx={{ color: group.color }}>{group.icon}</Box>
<Typography variant="subtitle2" fontWeight="bold">
{group.label}
</Typography>
<Chip
label={totalCount}
size="small"
variant={selectedCount > 0 ? "filled" : "outlined"}
color={selectedCount > 0 ? "primary" : "default"}
/>
</Box>
</Box>
<Box sx={{ pl: 4 }}>
{groupFacets.map(facet => (
<FormControlLabel
key={facet.value}
control={
<Checkbox
size="small"
checked={selectedMimeTypes.includes(facet.value)}
onChange={() => handleToggleMimeType(facet.value)}
/>
}
label={
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="body2">
{getMimeTypeLabel(facet.value)}
</Typography>
<Chip label={facet.count} size="small" variant="outlined" />
</Box>
}
sx={{ display: 'flex', width: '100%', mb: 0.5 }}
/>
))}
</Box>
</Box>
);
})}
{ungroupedFacets.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" fontWeight="bold" sx={{ mb: 1 }}>
Other Types
</Typography>
{ungroupedFacets.map(facet => (
<FormControlLabel
key={facet.value}
control={
<Checkbox
size="small"
checked={selectedMimeTypes.includes(facet.value)}
onChange={() => handleToggleMimeType(facet.value)}
/>
}
label={
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="body2">
{getMimeTypeLabel(facet.value)}
</Typography>
<Chip label={facet.count} size="small" variant="outlined" />
</Box>
}
sx={{ display: 'flex', width: '100%', mb: 0.5 }}
/>
))}
</Box>
)}
</>
);
};
return (
<Paper variant="outlined" sx={{ p: 2 }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
<Typography variant="subtitle1" fontWeight="bold">
File Types
</Typography>
<Box display="flex" alignItems="center" gap={1}>
{selectedMimeTypes.length > 0 && (
<Chip
label={`${selectedMimeTypes.length} selected`}
size="small"
onDelete={() => onMimeTypeChange([])}
deleteIcon={<ClearIcon />}
/>
)}
<IconButton size="small" onClick={() => setExpanded(!expanded)}>
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
</Box>
<Collapse in={expanded}>
{loading ? (
<Box display="flex" justifyContent="center" p={2}>
<CircularProgress size={24} />
</Box>
) : (
<>
{facets.length > maxItemsToShow && (
<TextField
size="small"
fullWidth
placeholder="Search file types..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
sx={{ mb: 2 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
endAdornment: searchTerm && (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setSearchTerm('')}>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
}}
/>
)}
<FormGroup>
{renderGroupedFacets()}
</FormGroup>
{filteredFacets.length > maxItemsToShow && (
<Box mt={2} display="flex" justifyContent="center">
<Button
size="small"
onClick={() => setShowAll(!showAll)}
endIcon={showAll ? <ExpandLessIcon /> : <ExpandMoreIcon />}
>
{showAll ? 'Show Less' : `Show All (${filteredFacets.length})`}
</Button>
</Box>
)}
{filteredFacets.length === 0 && searchTerm && (
<Typography variant="body2" color="text.secondary" textAlign="center" py={2}>
No file types match "{searchTerm}"
</Typography>
)}
</>
)}
</Collapse>
</Paper>
);
};
export default MimeTypeFacetFilter;

View File

@ -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(
<MimeTypeFacetFilter
selectedMimeTypes={[]}
onMimeTypeChange={mockOnMimeTypeChange}
/>
);
expect(screen.getByText('File Types')).toBeInTheDocument();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
test('loads and displays MIME type facets', async () => {
render(
<MimeTypeFacetFilter
selectedMimeTypes={[]}
onMimeTypeChange={mockOnMimeTypeChange}
/>
);
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(
<MimeTypeFacetFilter
selectedMimeTypes={[]}
onMimeTypeChange={mockOnMimeTypeChange}
/>
);
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(
<MimeTypeFacetFilter
selectedMimeTypes={[]}
onMimeTypeChange={mockOnMimeTypeChange}
/>
);
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(
<MimeTypeFacetFilter
selectedMimeTypes={[]}
onMimeTypeChange={mockOnMimeTypeChange}
/>
);
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(
<MimeTypeFacetFilter
selectedMimeTypes={['application/pdf', 'image/jpeg']}
onMimeTypeChange={mockOnMimeTypeChange}
/>
);
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(
<MimeTypeFacetFilter
selectedMimeTypes={['application/pdf', 'image/jpeg']}
onMimeTypeChange={mockOnMimeTypeChange}
/>
);
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(
<MimeTypeFacetFilter
selectedMimeTypes={[]}
onMimeTypeChange={mockOnMimeTypeChange}
maxItemsToShow={3} // Trigger search box
/>
);
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(
<MimeTypeFacetFilter
selectedMimeTypes={[]}
onMimeTypeChange={mockOnMimeTypeChange}
maxItemsToShow={2}
/>
);
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(
<MimeTypeFacetFilter
selectedMimeTypes={[]}
onMimeTypeChange={mockOnMimeTypeChange}
/>
);
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(
<MimeTypeFacetFilter
selectedMimeTypes={[]}
onMimeTypeChange={mockOnMimeTypeChange}
/>
);
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(
<MimeTypeFacetFilter
selectedMimeTypes={[]}
onMimeTypeChange={mockOnMimeTypeChange}
/>
);
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(
<MimeTypeFacetFilter
selectedMimeTypes={[]}
onMimeTypeChange={mockOnMimeTypeChange}
/>
);
await waitFor(() => {
expect(screen.getByText('Other Types')).toBeInTheDocument();
});
});
test('shows indeterminate state for partial group selection', async () => {
render(
<MimeTypeFacetFilter
selectedMimeTypes={['image/jpeg']} // Only one image type selected
onMimeTypeChange={mockOnMimeTypeChange}
/>
);
await waitFor(() => {
const imageGroupCheckbox = screen.getByText('Images').closest('div')?.querySelector('input[type="checkbox"]');
expect(imageGroupCheckbox).toHaveProperty('indeterminate', true);
});
});
});

View File

@ -0,0 +1 @@
export { default } from './MimeTypeFacetFilter';

View File

@ -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<boolean>(true);
const [searchMode, setSearchMode] = useState<SearchMode>('simple');
const [includeSnippets, setIncludeSnippets] = useState<boolean>(true);
const [snippetLength, setSnippetLength] = useState<number>(200);
// Search settings - consolidated into advanced settings
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
const [advancedSettings, setAdvancedSettings] = useState<AdvancedSearchSettings>({
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<string[]>([]);
@ -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<number>): void => {
setSnippetLength(event.target.value as number);
};
return (
<Box sx={{ p: 3 }}>
@ -673,68 +697,28 @@ const SearchPage: React.FC = () => {
</Box>
)}
{/* Advanced Search Options */}
{showAdvanced && (
<Box sx={{ mt: 3, pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
<Grid container spacing={2}>
<Grid item xs={12} md={8}>
<Typography variant="subtitle2" gutterBottom>
Search Options
</Typography>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} sm={6} md={4}>
<FormControlLabel
control={
<Switch
checked={useEnhancedSearch}
onChange={(e) => setUseEnhancedSearch(e.target.checked)}
color="primary"
/>
}
label="Enhanced Search"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<FormControlLabel
control={
<Switch
checked={includeSnippets}
onChange={(e) => setIncludeSnippets(e.target.checked)}
color="primary"
/>
}
label="Show Snippets"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<FormControl size="small" fullWidth>
<InputLabel>Snippet Length</InputLabel>
<Select
value={snippetLength}
onChange={handleSnippetLengthChange}
label="Snippet Length"
>
<MenuItem value={100}>Short (100)</MenuItem>
<MenuItem value={200}>Medium (200)</MenuItem>
<MenuItem value={400}>Long (400)</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</Grid>
<Grid item xs={12} md={4}>
<SearchGuidance
compact
onExampleClick={setSearchQuery}
sx={{ position: 'relative' }}
/>
</Grid>
</Grid>
{/* Enhanced Search Guide when not in advanced mode */}
{!showAdvanced && (
<Box sx={{ mt: 2 }}>
<EnhancedSearchGuide
compact
onExampleClick={setSearchQuery}
/>
</Box>
)}
</Paper>
</Box>
{/* Advanced Search Panel */}
<AdvancedSearchPanel
settings={advancedSettings}
onSettingsChange={(newSettings) =>
setAdvancedSettings(prev => ({ ...prev, ...newSettings }))
}
expanded={showAdvanced}
onExpandedChange={setShowAdvanced}
/>
<Grid container spacing={3}>
{/* Mobile Filters Drawer */}
{showFilters && (
@ -806,40 +790,14 @@ const SearchPage: React.FC = () => {
</AccordionDetails>
</Accordion>
{/* File Type Filter */}
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle2">File Types</Typography>
</AccordionSummary>
<AccordionDetails>
<FormControl fullWidth size="small">
<InputLabel>Select Types</InputLabel>
<Select
multiple
value={selectedMimeTypes}
onChange={handleMimeTypesChange}
input={<OutlinedInput label="Select Types" />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => {
const option = mimeTypeOptions.find(opt => opt.value === value);
return (
<Chip key={value} label={option?.label || value} size="small" />
);
})}
</Box>
)}
>
{mimeTypeOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
<Checkbox checked={selectedMimeTypes.indexOf(option.value) > -1} />
<ListItemText primary={option.label} />
</MenuItem>
))}
</Select>
</FormControl>
</AccordionDetails>
</Accordion>
{/* File Type Filter with Facets */}
<Box sx={{ mb: 2 }}>
<MimeTypeFacetFilter
selectedMimeTypes={selectedMimeTypes}
onMimeTypeChange={setSelectedMimeTypes}
maxItemsToShow={8}
/>
</Box>
{/* OCR Filter */}
<Accordion>
@ -1176,38 +1134,18 @@ const SearchPage: React.FC = () => {
</Stack>
)}
{/* Search Snippets */}
{/* Enhanced Search Snippets */}
{doc.snippets && doc.snippets.length > 0 && (
<Box sx={{ mt: 2, mb: 1 }}>
{doc.snippets.slice(0, 2).map((snippet, index) => (
<Paper
key={index}
variant="outlined"
sx={{
p: 1.5,
mb: 1,
backgroundColor: (theme) => theme.palette.mode === 'light' ? 'grey.50' : 'grey.800',
borderLeft: '3px solid',
borderLeftColor: 'primary.main',
}}
>
<Typography
variant="body2"
sx={{
fontSize: '0.8rem',
lineHeight: 1.4,
color: 'text.primary',
}}
>
...{renderHighlightedText(snippet.text, snippet.highlight_ranges)}...
</Typography>
</Paper>
))}
{doc.snippets.length > 2 && (
<Typography variant="caption" color="text.secondary">
+{doc.snippets.length - 2} more matches
</Typography>
)}
<EnhancedSnippetViewer
snippets={doc.snippets}
searchQuery={searchQuery}
maxSnippetsToShow={2}
onSnippetClick={(snippet, index) => {
// Could navigate to document with snippet highlighted
console.log('Snippet clicked:', snippet, index);
}}
/>
</Box>
)}

View File

@ -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(
<BrowserRouter>
<SearchPage />
</BrowserRouter>
);
};
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();
});
});

View File

@ -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<T> {
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<SearchFacetsResponse>('/search/facets')
},
}
export interface OcrStatusResponse {

View File

@ -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<Vec<(String, i64)>> {
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<Vec<(String, i64)>> {
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)
}
}

View File

@ -234,6 +234,22 @@ pub struct SearchResponse {
pub suggestions: Vec<String>,
}
#[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<FacetItem>,
/// Tag facets with counts
pub tags: Vec<FacetItem>,
}
impl From<Document> for DocumentResponse {
fn from(doc: Document) -> Self {
Self {

View File

@ -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<Arc<AppState>> {
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<String> {
}
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<Arc<AppState>>,
auth_user: AuthUser,
) -> Result<Json<SearchFacetsResponse>, 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))
}