abbe7a18fc
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner - P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var - P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup - P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns - P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs) - P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import - P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default - P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed - P2: 28 frontend TODOs (hardcoded constants, deprecated notification API) - P3: dead code, duplicates, deprecated imports, private attr, __import__ inline - P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n) - ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix) - F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String) - Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
238 lines
8.2 KiB
TypeScript
238 lines
8.2 KiB
TypeScript
import React, { useState, useMemo, useCallback } from 'react';
|
|
// TODO: P2-F5 — Replace hardcoded TYPE_LABELS with shared constant
|
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
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 { Skeleton } from '@/components/ui/Skeleton';
|
|
import { Tabs } from '@/components/shared/Tabs';
|
|
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',
|
|
contact: 'search.contacts',
|
|
mail: 'search.mails',
|
|
file: 'search.files',
|
|
event: 'search.events',
|
|
message: 'search.messages',
|
|
};
|
|
|
|
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 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() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
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, filters);
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const params: Record<string, string> = {};
|
|
if (searchInput) params.q = searchInput;
|
|
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: [], message: [] };
|
|
if (results) {
|
|
for (const r of results) {
|
|
if (groups[r.type]) {
|
|
groups[r.type].push(r);
|
|
}
|
|
}
|
|
}
|
|
return groups;
|
|
}, [results]);
|
|
|
|
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[]) => {
|
|
if (items.length === 0) {
|
|
return <EmptyState title={t('search.noResults', { query })} />;
|
|
}
|
|
return (
|
|
<div className="space-y-3" data-testid="search-results-list">
|
|
{items.map((result) => (
|
|
<SearchResultCard key={`${result.type}-${result.id}`} result={result} query={query} />
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const tabs = useMemo(() => [
|
|
{
|
|
key: 'all',
|
|
label: t('search.allTypes'),
|
|
badge: totalResults,
|
|
content: (
|
|
<div className="space-y-3" data-testid="search-results-all">
|
|
{totalResults === 0 && !isLoading ? (
|
|
<EmptyState title={t('search.noResults', { query })} />
|
|
) : (
|
|
renderResults(results || [])
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'company',
|
|
label: t('search.companies'),
|
|
badge: groupedResults.company.length,
|
|
content: <div data-testid="search-results-company">{renderResults(groupedResults.company)}</div>,
|
|
},
|
|
{
|
|
key: 'contact',
|
|
label: t('search.contacts'),
|
|
badge: groupedResults.contact.length,
|
|
content: <div data-testid="search-results-contact">{renderResults(groupedResults.contact)}</div>,
|
|
},
|
|
{
|
|
key: 'mail',
|
|
label: t('search.mails'),
|
|
badge: groupedResults.mail.length,
|
|
content: <div data-testid="search-results-mail">{renderResults(groupedResults.mail)}</div>,
|
|
},
|
|
{
|
|
key: 'file',
|
|
label: t('search.files'),
|
|
badge: groupedResults.file.length,
|
|
content: <div data-testid="search-results-file">{renderResults(groupedResults.file)}</div>,
|
|
},
|
|
{
|
|
key: 'event',
|
|
label: t('search.events'),
|
|
badge: groupedResults.event.length,
|
|
content: <div data-testid="search-results-event">{renderResults(groupedResults.event)}</div>,
|
|
},
|
|
], [t, totalResults, groupedResults, results, isLoading, query, navigate]);
|
|
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto" data-testid="global-search-page">
|
|
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('search.title')}</h1>
|
|
|
|
<Card title={t('search.filters')} className="mb-6">
|
|
<form onSubmit={handleSearch} className="flex gap-3">
|
|
<div className="flex-1">
|
|
<Input
|
|
label={t('common.search')}
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
placeholder={t('common.search')}
|
|
data-testid="search-input"
|
|
/>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<Button type="submit" data-testid="search-submit-btn">{t('common.search')}</Button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
|
|
{query && (
|
|
<p className="text-sm text-secondary-600 mb-4" data-testid="search-query-display">
|
|
{t('search.resultsFor', { query })}:
|
|
</p>
|
|
)}
|
|
|
|
{!query ? (
|
|
<EmptyState title={t('search.enterQuery')} />
|
|
) : (
|
|
<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>
|
|
);
|
|
}
|