/** * Dictionary Catalog — список со связями (View D из handoff). * * Bundle-grouped cards с scope-color strip, search/scope/bundle filters * и "только со связями" toggle. См. design_handoff_dictionary_catalog/ * (`design/proto/view-list.jsx` + `screenshots/D-list-view.png`). * * URL state preserved для shareable links: `?q=&scope=PUBLIC,INTERNAL&bundle=cuod&deps=1`. * * FK chips: outgoing fk[] парсится из schemaJson properties (x-references), но для * каталога fetch'ить полный detail per dict expensive (N+1). На каталоге показываем * лишь refBy via `useDictionaryDependents` per card (TanStack Query кеширует * параллельные запросы). Outgoing fk будет добавлен через batch backend endpoint * (followup), сейчас рендерится только если backend начнёт возвращать в DictionaryDefinition. */ import { useDeferredValue, useMemo, useState } from 'react' import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@/ui' import { PlusIcon } from '@phosphor-icons/react' import { useQueries } from '@tanstack/react-query' import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents } from '@/api/queries' import type { DataScope, DictionaryDefinition, SchemaDependent } from '@/api/client' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' import { useCanMutate } from '@/auth/useCanMutate' import { SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style' // ===== Search params ===== const SCOPE_VALUES = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED']) export type CatalogSearch = { q?: string scope?: string // CSV "PUBLIC,INTERNAL" bundle?: string deps?: '1' } const validateSearch = (raw: Record): CatalogSearch => { const out: CatalogSearch = {} if (typeof raw.q === 'string' && raw.q.length > 0) out.q = raw.q if (typeof raw.scope === 'string' && raw.scope.length > 0) out.scope = raw.scope if (typeof raw.bundle === 'string' && raw.bundle.length > 0) out.bundle = raw.bundle if (raw.deps === '1' || raw.deps === 1) out.deps = '1' return out } // ===== Pure helpers (exported для unit tests) ===== export const parseScopeFilter = (csv: string | undefined): Set => { if (!csv) return new Set() const out = new Set() for (const raw of csv.split(',')) { const candidate = raw.trim().toUpperCase() if (SCOPE_VALUES.has(candidate as DataScope)) out.add(candidate as DataScope) } return out } export const matchesQuery = (d: DictionaryDefinition, q: string): boolean => { if (!q) return true const haystack = [d.name, d.displayName ?? '', d.description ?? ''] .join(' ') .toLowerCase() return haystack.includes(q) } export const groupByBundle = ( list: DictionaryDefinition[], ): Map => { const out = new Map() for (const d of list) { const key = d.bundle || 'default' if (!out.has(key)) out.set(key, []) out.get(key)!.push(d) } // Sort dicts within each bundle by displayName/name for (const [, items] of out) { items.sort((a, b) => (a.displayName ?? a.name).localeCompare(b.displayName ?? b.name), ) } return out } // Scope strip color — vertical 3px на левом крае карточки. // Использует design handoff tokens — auto-switch в dark mode. const SCOPE_STRIP: Record = { PUBLIC: 'bg-accent', INTERNAL: 'bg-warn', RESTRICTED: 'bg-pink', } // ===== Route ===== export const Route = createFileRoute('/dictionaries/')({ validateSearch, component: DictionariesPage, }) function DictionariesPage() { const { t } = useTranslation() const navigate = useNavigate({ from: '/dictionaries/' }) const search = Route.useSearch() const { data, isLoading, error } = useDictionaries() const [createOpen, setCreateOpen] = useState(false) const canMutate = useCanMutate() const q = (search.q ?? '').trim().toLowerCase() const deferredQuery = useDeferredValue(q) const scopeFilter = useMemo(() => parseScopeFilter(search.scope), [search.scope]) const bundleFilter = search.bundle const withDepsOnly = search.deps === '1' const setSearch = (next: Partial) => { navigate({ search: (prev) => { const merged: CatalogSearch = { ...prev, ...next } if (!merged.q) delete merged.q if (!merged.scope) delete merged.scope if (!merged.bundle) delete merged.bundle if (!merged.deps) delete merged.deps return merged }, }) } const toggleScope = (scope: DataScope) => { const next = new Set(scopeFilter) if (next.has(scope)) next.delete(scope) else next.add(scope) setSearch({ scope: next.size === 0 ? undefined : Array.from(next).join(',') }) } const setBundle = (bundle: string | undefined) => setSearch({ bundle }) // Батчевая загрузка refBy для всех словарей — для активации withDeps filter. // useQueries dedup'ит c per-card useDictionaryDependents (тот же queryKey), // так что N=37 запросов выполнятся один раз и cached. Запускается ТОЛЬКО // когда deps filter активен — для anonymous browsing N+1 burst не нужен. const dependentsResults = useQueries({ queries: withDepsOnly && data ? data.map((d) => ({ ...dictionaryDependentsQuery(d.name) })) : [], }) const dependentsMap = useMemo(() => { const m = new Map() if (!withDepsOnly || !data) return m data.forEach((d, i) => { const r = dependentsResults[i] // Пока loading или error — считаем 0 (не показываем). После загрузки — // refBy.length. Уникализация не нужна для бинарного hasDeps теста. m.set(d.name, r?.data?.length ?? 0) }) return m }, [data, dependentsResults, withDepsOnly]) const filtered = useMemo(() => { if (!data) return [] return data.filter((d) => { if (scopeFilter.size > 0 && !scopeFilter.has(d.scope)) return false if (bundleFilter && d.bundle !== bundleFilter) return false if (!matchesQuery(d, deferredQuery)) return false // «Со связями» — справочник используется в других справочниках (refBy > 0). // Outgoing FK потребовал бы загрузки полной schema per dict — not worth // the bandwidth для catalog filter. refBy достаточно для semantic. if (withDepsOnly && (dependentsMap.get(d.name) ?? 0) === 0) return false return true }) }, [data, deferredQuery, scopeFilter, bundleFilter, withDepsOnly, dependentsMap]) const bundleCounts = useMemo(() => { const out = new Map() if (data) for (const d of data) out.set(d.bundle, (out.get(d.bundle) ?? 0) + 1) return out }, [data]) const scopeCounts = useMemo(() => { const out: Record = { PUBLIC: 0, INTERNAL: 0, RESTRICTED: 0 } if (data) for (const d of data) out[d.scope]++ return out }, [data]) const grouped = useMemo(() => groupByBundle(filtered), [filtered]) const filtersActive = Boolean(q) || scopeFilter.size > 0 || bundleFilter || withDepsOnly const resetFilters = () => navigate({ search: {} }) // Guest mode (anonymous) — кнопка «Создать справочник» скрыта. Backend всё // равно вернёт 401 на POST, но UI hide избегает confusing UX. const createButton = canMutate ? ( ) : null if (isLoading) return if (error) { return ( {String(error)} ) } if (!data || data.length === 0) { return (
setCreateOpen(false)} onSuccess={(name) => { setCreateOpen(false) navigate({ to: '/dictionaries/$name', params: { name } }) }} />
) } return (
{/* Compact toolbar: всё в одной строке (wraps на narrow viewport). Tradeoff: scope/bundle chips меньше padding'а + краткие labels. UPPERCASE labels оставлены для consistency с brand voice (Tektur-like). */}
setSearch({ q: e.target.value })} placeholder={t('dict.list.search.placeholder')} aria-label={t('dict.list.search.placeholder')} />
{filtered.length}/{data.length} {/* Scope chips: компактные, dot + count, full label по hover'у через title */}
{SCOPE_ORDER.map((scope) => { const selected = scopeFilter.has(scope) const count = scopeCounts[scope] return ( ) })}
{/* Vertical divider */}
{/* Empty state */} {filtered.length === 0 ? (

{t('dict.list.search.empty')}

{filtersActive && ( )}
) : ( // Bundle-grouped 2-col card grid Array.from(grouped.entries()).map(([bundle, items]) => (

{bundle} · {items.length} {t('dict.list.records.short')}

{items.map((d) => ( ))}
)) )} setCreateOpen(false)} onSuccess={(name) => { setCreateOpen(false) navigate({ to: '/dictionaries/$name', params: { name } }) }} />
) } // ===== Card ===== type TFunc = ReturnType['t'] const MAX_REFBY_CHIPS = 3 /** * Дедуплицирует SchemaDependent[] по `sourceDict` (одно название может * указывать через несколько fields — на катаоге показываем как один chip). * Сохраняет displayName из первого вхождения. */ export const uniqueRefBy = (deps: SchemaDependent[]): SchemaDependent[] => { const seen = new Set() const out: SchemaDependent[] = [] for (const dep of deps) { if (!seen.has(dep.sourceDict)) { seen.add(dep.sourceDict) out.push(dep) } } return out } const FkChip = ({ to, label, dim, }: { to: string label: string dim?: boolean }) => ( e.stopPropagation()} title={to} className={`inline-flex items-center px-[7px] py-[2px] rounded-[4px] font-mono text-[11px] transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${ dim ? 'border border-dashed border-line text-mute hover:border-accent/60 hover:text-accent' : 'bg-accent-bg text-accent hover:bg-accent hover:text-on-accent' }`} > {label} ) const DictCard = ({ d, t }: { d: DictionaryDefinition; t: TFunc }) => { // refBy — backend возвращает SchemaDependent[] (cached 5min, parallel // queries dedup'ятся TanStack Query'ем). На каталоге фетчим per dict, что на // 40 dicts = 40 параллельных GET — bursty первый раз, кешировано далее. // outgoing fk → ссылается потребует schemaJson per dict (отдельный followup, // backend batch endpoint желателен). const { data: refByRaw } = useDictionaryDependents(d.name) const refBy = useMemo(() => uniqueRefBy(refByRaw ?? []), [refByRaw]) const hasFkRow = refBy.length > 0 return ( {/* Scope strip 3px вертикальная полоса (handoff spec) */}