feat(E): Unified Search — 24 Tasks complete
Check Cross-Plugin Imports / check (push) Has been cancelled

- SPIKE-E: FTS+Vector+Permission benchmark on 10k records (all <30ms)
- E-PROV: supports_fts/vector/rag/graph capability flags on all providers
- E-FTS/VEC: All 11 providers refactored to BaseSearchProvider with permission filtering
- E-PERM: Over-fetch strategy for vector+permission (15x faster than ANY() filter)
- E-FUSE: rrf_fusion_multi() for N-way RRF over FTS+Vector+RAG+Graph
- E-LLM: Query understanding cleaned up to use central llm_complete()
- E-CHUNK: Document chunking module + document_chunks table with HNSW index
- E-EMB: Chunk embedding ARQ jobs (index_file_chunks, reindex_chunks)
- E-RAG: RAG retrieval via FileSearchProvider.search_rag()
- E-GRAPH: GraphRAG BFS traversal via GraphRAGSearchProvider.search_graph()
- E-IX-EVT: Auto-indexing via outbox events + delete/cleanup handlers
- E-IX-RE: Batch reindex with progress tracking + reindex_all job
- E-DATA-LIFE: Lifecycle module (remove/rebuild/restore/correct) + API endpoints
- E-K-MEM: AgentMemorySearchProvider
- E-P-AI: AIChatSearchProvider
- E-P-WF: WorkflowSearchProvider
- E-P-COMM: ConversationSearchProvider verified (already on BaseSearchProvider)
- E-API: Filter params (date_from/to, tags, sort) + /facets endpoint
- E-TOOL: unified_search AI tool registered in ToolRegistry
- E-MCP: Search tool in MCP server with normal RBAC/tenant checks
- E-UI-CMD: CommandPalette (Cmd+K) with debounced search + recent searches
- E-UI-FAC: SearchFacets, SearchResultCard, SavedSearches components
- E-TEST: 40 new tests in test_unified_search_phase_e.py (105 total green)
- E-DOC: api-documentation.md, plugin-development-guide.md, test-strategy.md updated

105 tests passing, TypeScript clean.
This commit is contained in:
Agent Zero
2026-08-14 01:34:58 +02:00
parent 60f30d021b
commit 3d9b76cea4
45 changed files with 5378 additions and 402 deletions
@@ -13,6 +13,7 @@ import { WindowContainer } from '@/components/window/WindowContainer';
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
import { WelcomeDialog } from '@/components/onboarding/WelcomeDialog';
import { OnboardingTour } from '@/components/onboarding/OnboardingTour';
import { CommandPalette } from '@/components/search/CommandPalette';
import { useOnboardingStore } from '@/store/onboardingStore';
export function AppShell() {
@@ -57,6 +58,7 @@ export function AppShell() {
{showMessageSidebar && <MessageSidebar />}
<AIUIControlIndicator />
<WindowContainer />
<CommandPalette />
<ToastContainer />
<WelcomeDialog open={!completed && !skipped} onClose={skip} />
<OnboardingTour />
+12 -1
View File
@@ -8,11 +8,12 @@ import { useLogout } from '@/api/hooks';
import { Avatar } from '@/components/ui/Avatar';
import { SearchDropdown } from '@/components/shared/SearchDropdown';
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code } from 'lucide-react';
import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code, Search } from 'lucide-react';
import { NotificationBell } from '@/components/layout/NotificationBell';
import { WorkspaceSwitcher } from '@/components/layout/WorkspaceSwitcher';
import { useWindowStore } from '@/store/windowStore';
import { usePermission } from '@/hooks/usePermission';
import { useCommandPaletteStore } from '@/store/commandPaletteStore';
export function TopBar() {
const { t } = useTranslation();
@@ -86,6 +87,16 @@ export function TopBar() {
<div className="hidden md:block">
<SearchDropdown placeholder={t('topbar.search')} />
</div>
{/* Command palette shortcut */}
<button
onClick={() => useCommandPaletteStore.getState().open()}
className="p-2 rounded-md text-secondary-500 hover:bg-secondary-100 hover:text-secondary-700 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('topbar.commandPalette')}
title={t('topbar.commandPaletteHint')}
data-testid="command-palette-btn"
>
<Search className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
</button>
</div>
<div className="flex items-center gap-2">
@@ -0,0 +1,317 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import clsx from 'clsx';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, Loader2, Clock, X } from 'lucide-react';
import { useGlobalSearch, SearchResult } from '@/api/hooks';
import { useCommandPalette } from '@/hooks/useCommandPalette';
const RECENT_KEY = 'leocrm_recent_searches';
const MAX_RECENT = 5;
const TYPE_LABELS: Record<string, string> = {
company: 'search.companies',
contact: 'search.contacts',
mail: 'search.mails',
file: 'search.files',
event: 'search.events',
message: 'search.messages',
};
const TYPE_ICON_CLASSES: Record<string, string> = {
company: 'bg-primary-100 text-primary-700',
contact: 'bg-accent-100 text-accent-700',
mail: 'bg-success-100 text-success-700',
file: 'bg-warning-100 text-warning-700',
event: 'bg-secondary-100 text-secondary-700',
message: 'bg-secondary-100 text-secondary-700',
};
function typeIcon(type: string): string {
switch (type) {
case 'company': return 'F';
case 'contact': return 'K';
case 'mail': return '@';
case 'file': return '📄';
case 'event': return '📅';
case 'message': return '💬';
default: return '?';
}
}
function loadRecent(): string[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
function saveRecent(query: string) {
const trimmed = query.trim();
if (!trimmed) return;
const next = [trimmed, ...loadRecent().filter((q) => q !== trimmed)].slice(0, MAX_RECENT);
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
} catch {
// ignore storage errors
}
}
export function CommandPalette() {
const { t } = useTranslation();
const navigate = useNavigate();
const { isOpen, close } = useCommandPalette();
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const [recent, setRecent] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
const { data: results, isLoading, isError } = useGlobalSearch(debouncedQuery);
// Debounce query
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
// Load recent searches when opened
useEffect(() => {
if (isOpen) {
setRecent(loadRecent());
setQuery('');
setDebouncedQuery('');
setActiveIndex(-1);
}
}, [isOpen]);
// Focus management + focus trap
useEffect(() => {
if (!isOpen) return;
previouslyFocused.current = document.activeElement as HTMLElement;
const timer = setTimeout(() => inputRef.current?.focus(), 0);
return () => {
clearTimeout(timer);
previouslyFocused.current?.focus();
};
}, [isOpen]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!isOpen) return;
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key === 'Tab') {
// Simple focus trap: keep focus within the dialog
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}, [isOpen, close]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const flatResults = useMemo(() => results || [], [results]);
const groupedResults = useMemo(() => {
const groups: Record<string, SearchResult[]> = {};
for (const r of flatResults) {
if (!groups[r.type]) groups[r.type] = [];
groups[r.type].push(r);
}
return groups;
}, [flatResults]);
const totalCount = flatResults.length;
const handleResultClick = (result: SearchResult) => {
saveRecent(debouncedQuery);
close();
navigate(result.url);
};
const handleRecentClick = (q: string) => {
setQuery(q);
setDebouncedQuery(q);
setActiveIndex(-1);
};
const handleInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => Math.min(prev + 1, Math.max(totalCount - 1, 0)));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, -1));
} else if (e.key === 'Enter' && activeIndex >= 0 && flatResults[activeIndex]) {
e.preventDefault();
handleResultClick(flatResults[activeIndex]);
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh] px-4"
role="dialog"
aria-modal="true"
aria-label={t('commandPalette.title')}
data-testid="command-palette"
>
<div
className="absolute inset-0 bg-secondary-900/50"
onClick={close}
aria-hidden="true"
data-testid="command-palette-backdrop"
/>
<div
ref={dialogRef}
className="relative w-full max-w-xl bg-white rounded-lg shadow-xl border border-secondary-200 overflow-hidden"
>
<div className="flex items-center gap-3 px-4 border-b border-secondary-200">
<Search className="w-5 h-5 text-secondary-400 flex-shrink-0" aria-hidden="true" />
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => { setQuery(e.target.value); setActiveIndex(-1); }}
onKeyDown={handleInputKeyDown}
placeholder={t('commandPalette.placeholder')}
className="flex-1 py-3 text-base bg-transparent focus:outline-none text-secondary-900 placeholder-secondary-400"
aria-label={t('commandPalette.placeholder')}
role="combobox"
aria-expanded={isOpen}
aria-controls="command-palette-results"
aria-autocomplete="list"
data-testid="command-palette-input"
/>
<button
onClick={close}
className="p-2 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label={t('common.close')}
>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div
id="command-palette-results"
className="max-h-[50vh] overflow-y-auto"
role="listbox"
aria-label={t('commandPalette.results')}
>
{isLoading ? (
<div className="flex items-center justify-center gap-2 px-4 py-8 text-sm text-secondary-500" role="status">
<Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />
{t('common.loading')}
</div>
) : isError ? (
<div className="px-4 py-8 text-sm text-danger-600" role="alert">
{t('commandPalette.error')}
</div>
) : debouncedQuery.trim() ? (
totalCount === 0 ? (
<div className="px-4 py-8 text-sm text-secondary-500">
{t('commandPalette.noResults', { query: debouncedQuery })}
</div>
) : (
Object.entries(groupedResults).map(([type, items]) => (
<div key={type} className="py-1">
<div className="px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-secondary-400">
{t(TYPE_LABELS[type] || 'search.allTypes')}
</div>
<ul>
{items.map((result, idx) => {
const flatIdx = flatResults.indexOf(result);
return (
<li key={`${result.type}-${result.id}`}>
<button
onClick={() => handleResultClick(result)}
onMouseEnter={() => setActiveIndex(flatIdx)}
className={clsx(
'w-full text-left px-4 py-2 flex items-center gap-3 min-h-touch hover:bg-secondary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
activeIndex === flatIdx && 'bg-primary-50'
)}
role="option"
aria-selected={activeIndex === flatIdx}
>
<span
className={clsx(
'inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-semibold flex-shrink-0',
TYPE_ICON_CLASSES[type] || 'bg-secondary-100 text-secondary-700'
)}
aria-hidden="true"
>
{typeIcon(type)}
</span>
<div className="flex-1 min-w-0">
<p className="font-medium text-secondary-900 truncate">{result.name}</p>
{result.description && (
<p className="text-xs text-secondary-500 truncate">{result.description}</p>
)}
</div>
{typeof result.score === 'number' && result.score > 0 && (
<span className="text-xs text-secondary-400 flex-shrink-0">
{Math.round(result.score * 100)}%
</span>
)}
</button>
</li>
);
})}
</ul>
</div>
))
)
) : recent.length > 0 ? (
<div className="py-1">
<div className="px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-secondary-400">
{t('commandPalette.recent')}
</div>
<ul>
{recent.map((q) => (
<li key={q}>
<button
onClick={() => handleRecentClick(q)}
className="w-full text-left px-4 py-2 flex items-center gap-3 min-h-touch hover:bg-secondary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
>
<Clock className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" />
<span className="text-sm text-secondary-700 truncate">{q}</span>
</button>
</li>
))}
</ul>
</div>
) : (
<div className="px-4 py-8 text-sm text-secondary-500">
{t('commandPalette.hint')}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,144 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, Bookmark, BookmarkCheck } from 'lucide-react';
import { SearchFilters } from '@/api/hooks';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
const SAVED_KEY = 'leocrm_saved_searches';
const MAX_SAVED = 20;
export interface SavedSearch {
id: string;
name: string;
query: string;
filters: SearchFilters;
createdAt: string;
}
function loadSaved(): SavedSearch[] {
try {
const raw = localStorage.getItem(SAVED_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function persistSaved(saved: SavedSearch[]) {
try {
localStorage.setItem(SAVED_KEY, JSON.stringify(saved));
} catch {
// ignore storage errors
}
}
interface SavedSearchesProps {
currentQuery: string;
currentFilters: SearchFilters;
onRun: (query: string, filters: SearchFilters) => void;
}
export function SavedSearches({ currentQuery, currentFilters, onRun }: SavedSearchesProps) {
const { t } = useTranslation();
const [saved, setSaved] = useState<SavedSearch[]>([]);
const [showSaveForm, setShowSaveForm] = useState(false);
const [name, setName] = useState('');
useEffect(() => {
setSaved(loadSaved());
}, []);
const refresh = useCallback(() => setSaved(loadSaved()), []);
const handleSave = () => {
const trimmed = name.trim();
if (!trimmed || !currentQuery.trim()) return;
const entry: SavedSearch = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: trimmed,
query: currentQuery,
filters: currentFilters,
createdAt: new Date().toISOString(),
};
const next = [entry, ...loadSaved()].slice(0, MAX_SAVED);
persistSaved(next);
setName('');
setShowSaveForm(false);
refresh();
};
const handleDelete = (id: string) => {
persistSaved(loadSaved().filter((s) => s.id !== id));
refresh();
};
const canSave = currentQuery.trim().length > 0;
return (
<div className="space-y-3" data-testid="saved-searches">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-secondary-900">{t('search.savedSearches')}</h2>
{canSave && !showSaveForm && (
<Button variant="ghost" size="sm" onClick={() => setShowSaveForm(true)} data-testid="save-search-btn">
<Bookmark className="w-4 h-4 mr-1" aria-hidden="true" />
{t('search.saveSearch')}
</Button>
)}
</div>
{showSaveForm && (
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('search.saveSearchName')}
aria-label={t('search.saveSearchName')}
data-testid="save-search-name"
/>
</div>
<Button size="sm" onClick={handleSave} disabled={!name.trim()} data-testid="save-search-confirm">
<BookmarkCheck className="w-4 h-4 mr-1" aria-hidden="true" />
{t('common.save')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setShowSaveForm(false)}>
{t('common.cancel')}
</Button>
</div>
)}
{saved.length === 0 ? (
<p className="text-sm text-secondary-500">{t('search.noSavedSearches')}</p>
) : (
<ul className="space-y-2">
{saved.map((s) => (
<li
key={s.id}
className="flex items-center gap-2 bg-white rounded-md border border-secondary-200 px-3 py-2"
>
<button
onClick={() => onRun(s.query, s.filters)}
className="flex-1 text-left min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded-md"
data-testid={`saved-search-${s.id}`}
>
<span className="block text-sm font-medium text-secondary-900 truncate">{s.name}</span>
<span className="block text-xs text-secondary-500 truncate">{s.query}</span>
</button>
<button
onClick={() => handleDelete(s.id)}
className="p-2 rounded-md text-secondary-400 hover:text-danger-600 hover:bg-danger-50 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-danger-500"
aria-label={t('common.delete')}
data-testid={`delete-saved-search-${s.id}`}
>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,206 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SearchFilters } from '@/api/hooks';
import { fetchFacets, FacetsResponse } from '@/api/search';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Button } from '@/components/ui/Button';
import { Skeleton } from '@/components/ui/Skeleton';
const TYPE_LABELS: Record<string, string> = {
company: 'search.companies',
contact: 'search.contacts',
mail: 'search.mails',
file: 'search.files',
event: 'search.events',
message: 'search.messages',
};
interface SearchFacetsProps {
filters: SearchFilters;
onChange: (filters: SearchFilters) => void;
onClear: () => void;
entityCounts?: Record<string, number>;
tagCounts?: Record<string, number>;
}
export function SearchFacets({ filters, onChange, onClear, entityCounts, tagCounts }: SearchFacetsProps) {
const { t } = useTranslation();
const [facets, setFacets] = useState<FacetsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetchFacets()
.then((data) => {
if (!cancelled) setFacets(data);
})
.catch(() => {
// ignore — facets are optional; the page still works without them
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const entityTypes = facets?.entity_types || [];
const tags = facets?.tags || [];
const toggleEntityType = (type: string) => {
const current = filters.entityTypes || [];
const next = current.includes(type)
? current.filter((x) => x !== type)
: [...current, type];
onChange({ ...filters, entityTypes: next.length > 0 ? next : undefined });
};
const toggleTag = (tag: string) => {
const current = filters.tags || [];
const next = current.includes(tag)
? current.filter((x) => x !== tag)
: [...current, tag];
onChange({ ...filters, tags: next.length > 0 ? next : undefined });
};
const hasActiveFilters =
(filters.entityTypes?.length || 0) > 0 ||
(filters.tags?.length || 0) > 0 ||
!!filters.dateFrom ||
!!filters.dateTo ||
(filters.sort && filters.sort !== 'relevance');
return (
<div className="space-y-6" data-testid="search-facets">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-secondary-900">{t('search.filters')}</h2>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={onClear} data-testid="clear-filters-btn">
{t('common.reset')}
</Button>
)}
</div>
{/* Entity type filter */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-secondary-400 mb-2">
{t('search.entityType')}
</h3>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-5" />)}
</div>
) : entityTypes.length === 0 ? (
<p className="text-sm text-secondary-500">{t('common.none')}</p>
) : (
<ul className="space-y-1">
{entityTypes.map((type) => {
const checked = filters.entityTypes?.includes(type) || false;
const count = entityCounts?.[type] ?? 0;
return (
<li key={type}>
<label className="flex items-center gap-2 min-h-touch cursor-pointer hover:bg-secondary-50 rounded-md px-2">
<input
type="checkbox"
checked={checked}
onChange={() => toggleEntityType(type)}
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={t(TYPE_LABELS[type] || 'search.allTypes')}
/>
<span className="flex-1 text-sm text-secondary-700">
{t(TYPE_LABELS[type] || 'search.allTypes')}
</span>
{count > 0 && (
<span className="text-xs text-secondary-400">{count}</span>
)}
</label>
</li>
);
})}
</ul>
)}
</div>
{/* Tag filter */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-secondary-400 mb-2">
{t('search.tags')}
</h3>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-5" />)}
</div>
) : tags.length === 0 ? (
<p className="text-sm text-secondary-500">{t('common.none')}</p>
) : (
<ul className="space-y-1">
{tags.map((tag) => {
const checked = filters.tags?.includes(tag) || false;
const count = tagCounts?.[tag] ?? 0;
return (
<li key={tag}>
<label className="flex items-center gap-2 min-h-touch cursor-pointer hover:bg-secondary-50 rounded-md px-2">
<input
type="checkbox"
checked={checked}
onChange={() => toggleTag(tag)}
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={tag}
/>
<span className="flex-1 text-sm text-secondary-700 truncate">{tag}</span>
{count > 0 && (
<span className="text-xs text-secondary-400">{count}</span>
)}
</label>
</li>
);
})}
</ul>
)}
</div>
{/* Date range filter */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-secondary-400 mb-2">
{t('search.dateRange')}
</h3>
<div className="space-y-3">
<Input
type="date"
label={t('search.dateFrom')}
value={filters.dateFrom || ''}
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value || undefined })}
data-testid="search-date-from"
/>
<Input
type="date"
label={t('search.dateTo')}
value={filters.dateTo || ''}
onChange={(e) => onChange({ ...filters, dateTo: e.target.value || undefined })}
data-testid="search-date-to"
/>
</div>
</div>
{/* Sort selector */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-secondary-400 mb-2">
{t('common.sort')}
</h3>
<Select
value={filters.sort || 'relevance'}
onChange={(e) => onChange({ ...filters, sort: e.target.value as SearchFilters['sort'] })}
options={[
{ value: 'relevance', label: t('search.sortRelevance') },
{ value: 'date', label: t('search.sortDate') },
{ value: 'name', label: t('search.sortName') },
]}
aria-label={t('common.sort')}
data-testid="search-sort"
/>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import React from 'react';
import clsx from 'clsx';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { SearchResult } from '@/api/hooks';
import { Badge } from '@/components/ui/Badge';
const TYPE_LABELS: Record<string, string> = {
company: 'search.companies',
contact: 'search.contacts',
mail: 'search.mails',
file: 'search.files',
event: 'search.events',
message: 'search.messages',
};
const TYPE_ICON_CLASSES: Record<string, string> = {
company: 'bg-primary-100 text-primary-700',
contact: 'bg-accent-100 text-accent-700',
mail: 'bg-success-100 text-success-700',
file: 'bg-warning-100 text-warning-700',
event: 'bg-secondary-100 text-secondary-700',
message: 'bg-secondary-100 text-secondary-700',
};
const TYPE_BADGE_VARIANTS: Record<string, 'primary' | 'info' | 'success' | 'warning' | 'default'> = {
company: 'primary',
contact: 'info',
mail: 'success',
file: 'warning',
event: 'default',
message: 'default',
};
function typeIcon(type: string): string {
switch (type) {
case 'company': return 'F';
case 'contact': return 'K';
case 'mail': return '@';
case 'file': return '📄';
case 'event': return '📅';
case 'message': return '💬';
default: return '?';
}
}
interface SearchResultCardProps {
result: SearchResult;
query?: string;
}
function highlightMatch(text: string, query: string): React.ReactNode {
if (!query.trim()) return text;
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const idx = lowerText.indexOf(lowerQuery);
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<mark className="bg-warning-200 text-secondary-900 rounded px-0.5">{text.slice(idx, idx + query.length)}</mark>
{text.slice(idx + query.length)}
</>
);
}
export function SearchResultCard({ result, query }: SearchResultCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const score = typeof result.score === 'number' ? result.score : 0;
const scorePct = Math.round(score * 100);
return (
<button
onClick={() => navigate(result.url)}
className="w-full text-left bg-white rounded-lg shadow-sm border border-secondary-200 p-4 flex items-start gap-4 hover:shadow-md hover:border-primary-300 transition-shadow min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
data-testid={`search-result-${result.type}-${result.id}`}
>
<span
className={clsx(
'inline-flex items-center justify-center w-10 h-10 rounded-lg text-sm font-semibold flex-shrink-0',
TYPE_ICON_CLASSES[result.type] || 'bg-secondary-100 text-secondary-700'
)}
aria-hidden="true"
>
{typeIcon(result.type)}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-secondary-900 truncate">
{highlightMatch(result.name, query || '')}
</p>
<Badge variant={TYPE_BADGE_VARIANTS[result.type] || 'default'}>
{t(TYPE_LABELS[result.type] || 'search.allTypes')}
</Badge>
</div>
{result.description && (
<p className="text-sm text-secondary-500 mt-1 line-clamp-2">
{highlightMatch(result.description, query || '')}
</p>
)}
</div>
{score > 0 && (
<span
className="inline-flex items-center justify-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-700 flex-shrink-0"
title={t('search.score')}
>
{scorePct}%
</span>
)}
</button>
);
}