feat(E): Unified Search — 24 Tasks complete
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -1,33 +1,16 @@
|
||||
/**
|
||||
* Global search results page with tabs for companies/contacts/mails/files/events.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGlobalSearch, SearchResult } from '@/api/hooks';
|
||||
import { useGlobalSearch, SearchResult, SearchFilters } from '@/api/hooks';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { Tabs } from '@/components/shared/Tabs';
|
||||
|
||||
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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { SearchFacets } from '@/components/search/SearchFacets';
|
||||
import { SearchResultCard } from '@/components/search/SearchResultCard';
|
||||
import { SavedSearches } from '@/components/search/SavedSearches';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
company: 'search.companies',
|
||||
@@ -35,36 +18,34 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
mail: 'search.mails',
|
||||
file: 'search.files',
|
||||
event: 'search.events',
|
||||
message: 'search.messages',
|
||||
};
|
||||
|
||||
function renderResultIcon(type: string): React.ReactNode {
|
||||
const iconClass = 'w-10 h-10 rounded-lg flex items-center justify-center font-semibold flex-shrink-0';
|
||||
switch (type) {
|
||||
case 'company':
|
||||
return <div className={`${iconClass} bg-primary-100 text-primary-700`} aria-hidden="true">F</div>;
|
||||
case 'contact':
|
||||
return <div className={`${iconClass} bg-accent-100 text-accent-700`} aria-hidden="true">K</div>;
|
||||
case 'mail':
|
||||
return <div className={`${iconClass} bg-success-100 text-success-700`} aria-hidden="true">@</div>;
|
||||
case 'file':
|
||||
return <div className={`${iconClass} bg-warning-100 text-warning-700`} aria-hidden="true">📄</div>;
|
||||
case 'event':
|
||||
return <div className={`${iconClass} bg-secondary-100 text-secondary-700`} aria-hidden="true">📅</div>;
|
||||
default:
|
||||
return <div className={`${iconClass} bg-secondary-100 text-secondary-700`} aria-hidden="true">?</div>;
|
||||
}
|
||||
const EMPTY_FILTERS: SearchFilters = {};
|
||||
|
||||
function filtersToParams(filters: SearchFilters): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
if (filters.entityTypes?.length) params.entity_types = filters.entityTypes.join(',');
|
||||
if (filters.tags?.length) params.tags = filters.tags.join(',');
|
||||
if (filters.dateFrom) params.date_from = filters.dateFrom;
|
||||
if (filters.dateTo) params.date_to = filters.dateTo;
|
||||
if (filters.sort && filters.sort !== 'relevance') params.sort = filters.sort;
|
||||
return params;
|
||||
}
|
||||
|
||||
function renderResultBadge(type: string, t: (s: string) => string): React.ReactNode {
|
||||
const labelKey = TYPE_LABELS[type] || 'search.allTypes';
|
||||
const variantMap: Record<string, 'primary' | 'info' | 'success' | 'warning' | 'default'> = {
|
||||
company: 'primary',
|
||||
contact: 'info',
|
||||
mail: 'success',
|
||||
file: 'warning',
|
||||
event: 'default',
|
||||
};
|
||||
return <Badge variant={variantMap[type] || 'default'}>{t(labelKey)}</Badge>;
|
||||
function paramsToFilters(params: URLSearchParams): SearchFilters {
|
||||
const filters: SearchFilters = {};
|
||||
const entityTypes = params.get('entity_types');
|
||||
const tags = params.get('tags');
|
||||
const dateFrom = params.get('date_from');
|
||||
const dateTo = params.get('date_to');
|
||||
const sort = params.get('sort');
|
||||
if (entityTypes) filters.entityTypes = entityTypes.split(',').filter(Boolean);
|
||||
if (tags) filters.tags = tags.split(',').filter(Boolean);
|
||||
if (dateFrom) filters.dateFrom = dateFrom;
|
||||
if (dateTo) filters.dateTo = dateTo;
|
||||
if (sort === 'date' || sort === 'name') filters.sort = sort;
|
||||
return filters;
|
||||
}
|
||||
|
||||
export function GlobalSearchResultsPage() {
|
||||
@@ -75,18 +56,39 @@ export function GlobalSearchResultsPage() {
|
||||
const query = searchParams.get('q') || '';
|
||||
const [searchInput, setSearchInput] = useState(query);
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [filters, setFilters] = useState<SearchFilters>(() => paramsToFilters(searchParams));
|
||||
|
||||
const { data: results, isLoading } = useGlobalSearch(query);
|
||||
const { data: results, isLoading } = useGlobalSearch(query, filters);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const params: Record<string, string> = {};
|
||||
if (searchInput) params.q = searchInput;
|
||||
setSearchParams(params);
|
||||
setSearchParams({ ...params, ...filtersToParams(filters) });
|
||||
};
|
||||
|
||||
const handleFiltersChange = useCallback((next: SearchFilters) => {
|
||||
setFilters(next);
|
||||
const params: Record<string, string> = {};
|
||||
if (searchInput) params.q = searchInput;
|
||||
setSearchParams({ ...params, ...filtersToParams(next) });
|
||||
}, [searchInput, setSearchParams]);
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters(EMPTY_FILTERS);
|
||||
const params: Record<string, string> = {};
|
||||
if (searchInput) params.q = searchInput;
|
||||
setSearchParams(params);
|
||||
}, [searchInput, setSearchParams]);
|
||||
|
||||
const handleRunSaved = useCallback((savedQuery: string, savedFilters: SearchFilters) => {
|
||||
setSearchInput(savedQuery);
|
||||
setFilters(savedFilters);
|
||||
setSearchParams({ q: savedQuery, ...filtersToParams(savedFilters) });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const groupedResults = useMemo(() => {
|
||||
const groups: Record<string, SearchResult[]> = { company: [], contact: [], mail: [], file: [], event: [] };
|
||||
const groups: Record<string, SearchResult[]> = { company: [], contact: [], mail: [], file: [], event: [], message: [] };
|
||||
if (results) {
|
||||
for (const r of results) {
|
||||
if (groups[r.type]) {
|
||||
@@ -97,9 +99,16 @@ export function GlobalSearchResultsPage() {
|
||||
return groups;
|
||||
}, [results]);
|
||||
|
||||
const totalResults = useMemo(() => {
|
||||
if (!results) return 0;
|
||||
return results.length;
|
||||
const totalResults = useMemo(() => (results ? results.length : 0), [results]);
|
||||
|
||||
const entityCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
if (results) {
|
||||
for (const r of results) {
|
||||
counts[r.type] = (counts[r.type] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [results]);
|
||||
|
||||
const renderResults = (items: SearchResult[]) => {
|
||||
@@ -109,29 +118,7 @@ export function GlobalSearchResultsPage() {
|
||||
return (
|
||||
<div className="space-y-3" data-testid="search-results-list">
|
||||
{items.map((result) => (
|
||||
<Card key={`${result.type}-${result.id}`}>
|
||||
<button
|
||||
onClick={() => navigate(result.url)}
|
||||
className="w-full flex items-center gap-4 text-left hover:bg-secondary-50 p-2 rounded-md min-h-touch"
|
||||
data-testid={`search-result-${result.type}-${result.id}`}
|
||||
>
|
||||
{renderResultIcon(result.type)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-secondary-900">
|
||||
{highlightMatch(result.name, query)}
|
||||
</p>
|
||||
{renderResultBadge(result.type, t)}
|
||||
</div>
|
||||
{result.description && (
|
||||
<p className="text-sm text-secondary-500 truncate">
|
||||
{highlightMatch(result.description, query)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-secondary-400 text-sm" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</Card>
|
||||
<SearchResultCard key={`${result.type}-${result.id}`} result={result} query={query} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -213,17 +200,37 @@ export function GlobalSearchResultsPage() {
|
||||
|
||||
{!query ? (
|
||||
<EmptyState title={t('search.enterQuery')} />
|
||||
) : isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div data-testid="search-tabs">
|
||||
<Tabs tabs={tabs} defaultKey={activeTab} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6">
|
||||
<aside className="space-y-6" aria-label={t('search.filters')}>
|
||||
<SearchFacets
|
||||
filters={filters}
|
||||
onChange={handleFiltersChange}
|
||||
onClear={handleClearFilters}
|
||||
entityCounts={entityCounts}
|
||||
/>
|
||||
<SavedSearches
|
||||
currentQuery={query}
|
||||
currentFilters={filters}
|
||||
onRun={handleRunSaved}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div data-testid="search-tabs">
|
||||
<Tabs tabs={tabs} defaultKey={activeTab} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user