feat(client/server): implement a much better search
This commit is contained in:
parent
98a4b7479b
commit
76529f83be
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './AdvancedSearchPanel';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './EnhancedSearchGuide';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './EnhancedSnippetViewer';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './MimeTypeFacetFilter';
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
Loading…
Reference in New Issue