feat(search): smart JSONB search across all dictionaries
CEO plan v1 stretch — закрывает gap "Smart JSONB search across all dictionaries".
Last gap из v1 list shipped.
Migration 0018:
- CREATE EXTENSION pg_trgm (CNPG initdb обычно не enable'ит, добавили
явно с MARK_RAN preCondition если уже есть).
- CREATE INDEX idx_dict_records_data_trgm
ON dictionary_records USING GIN ((data::text) gin_trgm_ops)
- Trigram-based ILIKE с минимум 3 символа в query — index используется.
ILIKE '%pat%' на data::text возвращает любые matches в JSONB serialized.
Backend:
- New RecordSearchQuery (ordinis-domain): native SQL c ROW_NUMBER()
per-dict cap. SQL injection защищён PreparedStatement param + extension
whitelist (allowed_scopes — text[]).
- New SearchController (ordinis-rest-api): GET /api/v1/search?q=&size&perDict.
Default size=100, perDict=10. Min q length=3 (silently empty if shorter).
Scope filtering through ScopeContext — каждый caller видит только свои
scope levels. Results grouped per dict с display name + count + items.
Admin UI:
- New /search route с SearchInput + grouped Panel results.
- URL state ?q=… — share-friendly link "/search?q=SAR-X".
- Per-result link → /dictionaries/{dict}?q={businessKey} для drill-down.
- Min-3 hint, empty state, loading + error.
- New "Поиск" / "Search" tab в navigation header.
- i18n RU (с правильными plurals search.totalHits) + EN.
Behavior:
- Active records only (valid_from <= now() < valid_to).
- Per-dict cap 10 — защита от dominant-dict skew (один dict с 1000 matches
не забьёт результат).
- Total cap 100 — admin "find this code" use case достаточно. Browser-style
search-as-you-type (10k+ matches, instant) — отложен на v2 с dedicated
index server (Elastic / Meili).
Verify:
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
После migration apply'я index size на dictionary_records будет ~30-40%
data column. На текущих 5k records ~3-5 MB, acceptable.
This commit is contained in:
@@ -51,6 +51,13 @@ function RootLayout() {
|
||||
>
|
||||
{t('nav.webhooks')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/search"
|
||||
className="text-carbon hover:text-ultramarain"
|
||||
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||
>
|
||||
{t('nav.search')}
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<LanguageSwitch
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
EmptyState,
|
||||
LoadingBlock,
|
||||
PageHeader,
|
||||
Panel,
|
||||
SearchInput,
|
||||
} from '@nstart/ui'
|
||||
import { useSearch } from '@/api/queries'
|
||||
|
||||
type SearchSearch = { q?: string }
|
||||
|
||||
const validateSearch = (raw: Record<string, unknown>): SearchSearch => {
|
||||
if (typeof raw.q === 'string' && raw.q.length > 0) return { q: raw.q }
|
||||
return {}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/search')({
|
||||
component: SearchPage,
|
||||
validateSearch,
|
||||
})
|
||||
|
||||
/**
|
||||
* Smart JSONB search across all dictionaries (CEO plan v1 stretch).
|
||||
*
|
||||
* <p>Backend: GET /api/v1/search?q=&size&perDict — ILIKE с trigram-индексом
|
||||
* на dictionary_records.data::text. Min query length = 3 (trigram threshold).
|
||||
*
|
||||
* <p>UI: search input → grouped results per dict с per-row link на full
|
||||
* record. URL state ?q=… — share-friendly.
|
||||
*/
|
||||
function SearchPage() {
|
||||
const { t } = useTranslation()
|
||||
const urlSearch = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const [input, setInput] = useState(urlSearch.q ?? '')
|
||||
|
||||
const result = useSearch(urlSearch.q)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = input.trim()
|
||||
void navigate({ search: trimmed ? { q: trimmed } : {}, replace: true })
|
||||
}
|
||||
|
||||
const tooShort = input.length > 0 && input.length < 3
|
||||
const hasQuery = Boolean(urlSearch.q && urlSearch.q.length >= 3)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={t('search.title')}
|
||||
description={t('search.description')}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<div className="flex-1 max-w-2xl">
|
||||
<SearchInput
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t('search.placeholder')}
|
||||
aria-label={t('search.placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{tooShort && (
|
||||
<p className="text-2xs text-carbon/60">{t('search.tooShort')}</p>
|
||||
)}
|
||||
|
||||
{hasQuery && result.isLoading && (
|
||||
<LoadingBlock size="md" label={t('loading')} />
|
||||
)}
|
||||
|
||||
{hasQuery && result.error && (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(result.error)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasQuery && result.data && result.data.total === 0 && (
|
||||
<EmptyState title={t('search.empty')} />
|
||||
)}
|
||||
|
||||
{hasQuery && result.data && result.data.total > 0 && (
|
||||
<>
|
||||
<p className="text-2xs text-carbon/60">
|
||||
{t('search.totalHits', { count: result.data.total })}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{result.data.groups.map((g) => (
|
||||
<Panel key={g.dictName}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Link
|
||||
to="/dictionaries/$name"
|
||||
params={{ name: g.dictName }}
|
||||
search={{ q: urlSearch.q }}
|
||||
className="text-sm font-primary text-ultramarain hover:underline"
|
||||
>
|
||||
{g.dictDisplayName ?? g.dictName}
|
||||
</Link>
|
||||
<Badge variant="neutral">{g.count}</Badge>
|
||||
</div>
|
||||
<ul className="divide-y divide-regolith">
|
||||
{g.items.map((it) => (
|
||||
<li
|
||||
key={`${g.dictName}-${it.businessKey}`}
|
||||
className="flex items-center justify-between gap-3 py-2 text-2xs"
|
||||
>
|
||||
<Link
|
||||
to="/dictionaries/$name"
|
||||
params={{ name: g.dictName }}
|
||||
search={{ q: it.businessKey }}
|
||||
className="font-mono text-ultramarain hover:underline truncate"
|
||||
>
|
||||
{it.businessKey}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant="neutral">{it.dataScope}</Badge>
|
||||
<span className="text-carbon/60">
|
||||
{new Date(it.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasQuery && !tooShort && (
|
||||
<p className="text-2xs text-carbon/60">{t('search.intro')}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user