f0dbc20f62
Реальные измерения через DevTools на staging выявили 3 бага после MR !47: Bug 1: text-title-md/lg/xl inherited Tektur от @nstart/ui Card parent. Handoff: 'Inter for all headings'. Computed: Tektur. Fix: добавил font-family: var(--font-sans) explicit на text-title-* и text-body/text-cell. После Stage 3.9 (nstart gone) можно убрать — body inherit будет достаточно. Bug 2: text-cap + font-mono cohabit (catalog scope chips, time-travel presets) → Tailwind v4 cascade order: font-mono идёт после text-cap в bundled CSS → JetBrains Mono перебивает Tektur. text-cap baked font-display, font-mono token redundant. Fix: codemod strip font-mono где есть text-cap (4 sites). Computed font-family теперь правильно Tektur. Bug 3: @nstart/ui PageHeader рендерил h1 'DICTIONARIES' в Tektur 36px/700 uppercase (font-primary text-4xl). Handoff page title: 17px/600 Inter normal case. Наш src/ui/components/page-header.tsx написан но не экспортирован через barrel — все routes резолвили PageHeader к nstart версии. Fix: добавил 'export { PageHeader }' override в src/ui/index.ts. Все 7 PageHeader usages (catalog/search/reviews/webhooks/outbox/ my-drafts/graph) теперь получат handoff-conformant heading. Bug 4 (small): catalog counter '37/37' использовал text-cap но это numeric value — handoff role text-mono (11px JetBrains Mono). Fix: text-cap → text-mono + tabular-nums для grid alignment. Verified via injected test elements + CDP computed styles: text-title-xl: 22/600 Inter text-title-lg: 17/600 Inter text-title-md: 15/600 Inter text-body: 13/400 Inter text-cell: 12.5/500 Inter text-mono: 11/500 JetBrains Mono text-cap: 10.5/600 Tektur Все 7 utilities точно matches handoff scale. Files: 4 modified. TS strict + 116 tests pass.
520 lines
19 KiB
TypeScript
520 lines
19 KiB
TypeScript
/**
|
||
* 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<DataScope>(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
|
||
|
||
export type CatalogSearch = {
|
||
q?: string
|
||
scope?: string // CSV "PUBLIC,INTERNAL"
|
||
bundle?: string
|
||
deps?: '1'
|
||
}
|
||
|
||
const validateSearch = (raw: Record<string, unknown>): 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<DataScope> => {
|
||
if (!csv) return new Set()
|
||
const out = new Set<DataScope>()
|
||
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<string, DictionaryDefinition[]> => {
|
||
const out = new Map<string, DictionaryDefinition[]>()
|
||
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<DataScope, string> = {
|
||
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<CatalogSearch>) => {
|
||
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<string, number>()
|
||
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<string, number>()
|
||
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<DataScope, number> = { 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 ? (
|
||
<Button
|
||
type="button"
|
||
variant="primary"
|
||
leftIcon={<PlusIcon weight="bold" size={16} />}
|
||
onClick={() => setCreateOpen(true)}
|
||
className="whitespace-nowrap"
|
||
>
|
||
{t('schema.action.create')}
|
||
</Button>
|
||
) : null
|
||
|
||
if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
|
||
if (error) {
|
||
return (
|
||
<Alert variant="error" title={t('error.failed')}>
|
||
{String(error)}
|
||
</Alert>
|
||
)
|
||
}
|
||
if (!data || data.length === 0) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<PageHeader
|
||
title={t('nav.dictionaries')}
|
||
description={t('dict.list.subtitle')}
|
||
actions={createButton}
|
||
/>
|
||
<EmptyState title={t('dict.empty')} />
|
||
<DictionaryEditorDialog
|
||
open={createOpen}
|
||
mode={{ kind: 'create' }}
|
||
onClose={() => setCreateOpen(false)}
|
||
onSuccess={(name) => {
|
||
setCreateOpen(false)
|
||
navigate({ to: '/dictionaries/$name', params: { name } })
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<section
|
||
aria-label={t('dict.list.heading')}
|
||
className="space-y-6"
|
||
>
|
||
<PageHeader
|
||
title={t('nav.dictionaries')}
|
||
description={t('dict.list.subtitle')}
|
||
actions={createButton}
|
||
/>
|
||
|
||
{/* Compact toolbar: всё в одной строке (wraps на narrow viewport).
|
||
Tradeoff: scope/bundle chips меньше padding'а + краткие labels.
|
||
UPPERCASE labels оставлены для consistency с brand voice (Tektur-like). */}
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||
<div className="flex-1 min-w-[240px] max-w-sm">
|
||
<SearchInput
|
||
value={search.q ?? ''}
|
||
onChange={(e) => setSearch({ q: e.target.value })}
|
||
placeholder={t('dict.list.search.placeholder')}
|
||
aria-label={t('dict.list.search.placeholder')}
|
||
/>
|
||
</div>
|
||
|
||
<span className="text-mono text-mute whitespace-nowrap tabular-nums">
|
||
{filtered.length}/{data.length}
|
||
</span>
|
||
|
||
{/* Scope chips: компактные, dot + count, full label по hover'у через title */}
|
||
<div
|
||
role="group"
|
||
aria-label={t('dict.list.filter.scope')}
|
||
className="flex items-center gap-1"
|
||
>
|
||
{SCOPE_ORDER.map((scope) => {
|
||
const selected = scopeFilter.has(scope)
|
||
const count = scopeCounts[scope]
|
||
return (
|
||
<button
|
||
key={scope}
|
||
type="button"
|
||
onClick={() => toggleScope(scope)}
|
||
aria-pressed={selected}
|
||
title={t(`dict.list.section.${scope}`)}
|
||
className={`text-cap px-2 py-1 rounded-full flex items-center gap-1 border transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||
selected
|
||
? 'border-accent bg-accent-bg text-accent'
|
||
: 'border-line hover:border-accent/60 text-ink'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`inline-block size-1.5 rounded-full ${SCOPE_DOT[scope]}`}
|
||
aria-hidden="true"
|
||
/>
|
||
{t(`dict.list.section.${scope}.short`)}
|
||
<span className="text-mute ml-0.5">{count}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Vertical divider */}
|
||
<span className="h-5 w-px bg-line" aria-hidden="true" />
|
||
|
||
{/* Bundle filter — chips inline */}
|
||
<div
|
||
role="group"
|
||
aria-label="Bundle"
|
||
className="flex items-center gap-1"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => setBundle(undefined)}
|
||
aria-pressed={!bundleFilter}
|
||
className={`text-cap px-2 py-1 rounded-sm border transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||
!bundleFilter
|
||
? 'border-accent bg-accent-bg text-accent'
|
||
: 'border-line hover:border-accent/60 text-ink'
|
||
}`}
|
||
>
|
||
{t('dict.list.bundle.all')}
|
||
</button>
|
||
{Array.from(bundleCounts.entries())
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([bundle, count]) => {
|
||
const selected = bundleFilter === bundle
|
||
return (
|
||
<button
|
||
key={bundle}
|
||
type="button"
|
||
onClick={() => setBundle(selected ? undefined : bundle)}
|
||
aria-pressed={selected}
|
||
className={`text-cap px-2 py-1 rounded-sm flex items-center gap-1 border transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||
selected
|
||
? 'border-accent bg-accent-bg text-accent'
|
||
: 'border-line hover:border-accent/60 text-ink'
|
||
}`}
|
||
>
|
||
{bundle}
|
||
<span className="text-mute">{count}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Vertical divider + deps toggle */}
|
||
<span className="h-5 w-px bg-line" aria-hidden="true" />
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearch({ deps: withDepsOnly ? undefined : '1' })}
|
||
aria-pressed={withDepsOnly}
|
||
title={t('dict.list.deps.only')}
|
||
className={`text-cap px-2 py-1 rounded-sm border transition focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||
withDepsOnly
|
||
? 'border-accent bg-accent-bg text-accent'
|
||
: 'border-line hover:border-accent/60 text-ink'
|
||
}`}
|
||
>
|
||
{t('dict.list.deps.short')}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Empty state */}
|
||
{filtered.length === 0 ? (
|
||
<div className="border border-line rounded-lg p-12 text-center bg-surface-2">
|
||
<p className="font-sans text-title-md text-accent mb-2">
|
||
{t('dict.list.search.empty')}
|
||
</p>
|
||
{filtersActive && (
|
||
<Button type="button" variant="ghost" onClick={resetFilters}>
|
||
{t('dict.list.search.reset')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
// Bundle-grouped 2-col card grid
|
||
Array.from(grouped.entries()).map(([bundle, items]) => (
|
||
<section key={bundle} className="space-y-3">
|
||
<h2 className="flex items-baseline gap-3 pb-2 border-b border-line">
|
||
<span className="text-body uppercase tracking-[0.10em] text-accent font-mono font-medium">
|
||
{bundle}
|
||
</span>
|
||
<span className="text-cap text-mute">
|
||
· {items.length} {t('dict.list.records.short')}
|
||
</span>
|
||
</h2>
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||
{items.map((d) => (
|
||
<DictCard key={d.id} d={d} t={t} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
))
|
||
)}
|
||
|
||
<DictionaryEditorDialog
|
||
open={createOpen}
|
||
mode={{ kind: 'create' }}
|
||
onClose={() => setCreateOpen(false)}
|
||
onSuccess={(name) => {
|
||
setCreateOpen(false)
|
||
navigate({ to: '/dictionaries/$name', params: { name } })
|
||
}}
|
||
/>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// ===== Card =====
|
||
|
||
type TFunc = ReturnType<typeof useTranslation>['t']
|
||
|
||
const MAX_REFBY_CHIPS = 3
|
||
|
||
/**
|
||
* Дедуплицирует SchemaDependent[] по `sourceDict` (одно название может
|
||
* указывать через несколько fields — на катаоге показываем как один chip).
|
||
* Сохраняет displayName из первого вхождения.
|
||
*/
|
||
export const uniqueRefBy = (deps: SchemaDependent[]): SchemaDependent[] => {
|
||
const seen = new Set<string>()
|
||
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
|
||
}) => (
|
||
<Link
|
||
to="/dictionaries/$name"
|
||
params={{ name: to }}
|
||
onClick={(e) => 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}
|
||
</Link>
|
||
)
|
||
|
||
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 (
|
||
<Link
|
||
to="/dictionaries/$name"
|
||
params={{ name: d.name }}
|
||
className="grid grid-cols-[3px_1fr_auto] bg-surface border border-line rounded-lg overflow-hidden transition-all hover:-translate-y-px hover:shadow-md hover:border-line-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||
>
|
||
{/* Scope strip 3px вертикальная полоса (handoff spec) */}
|
||
<span className={SCOPE_STRIP[d.scope]} aria-hidden="true" />
|
||
|
||
{/* Body */}
|
||
<div className="px-3.5 py-3 min-w-0">
|
||
<div className="flex items-baseline gap-2.5 flex-wrap">
|
||
<h3 className="text-title-md text-navy truncate">
|
||
{d.displayName ?? d.name}
|
||
</h3>
|
||
<span className="text-mono text-mute truncate">{d.name}</span>
|
||
{d.approvalRequired && (
|
||
<Badge variant="warning">{t('dict.list.approval')}</Badge>
|
||
)}
|
||
</div>
|
||
{d.description && (
|
||
<p className="text-[12px] text-ink-2 mt-1 leading-snug line-clamp-2">
|
||
{d.description}
|
||
</p>
|
||
)}
|
||
{hasFkRow && (
|
||
<div className="flex flex-wrap items-center gap-1.5 mt-2.5">
|
||
<span className="text-cap font-display text-mute mr-1">
|
||
{t('dict.list.fk.usedBy', { count: refBy.length })}
|
||
</span>
|
||
{refBy.slice(0, MAX_REFBY_CHIPS).map((dep) => (
|
||
<FkChip
|
||
key={dep.sourceDict}
|
||
to={dep.sourceDict}
|
||
label={dep.sourceDisplayName ?? dep.sourceDict}
|
||
dim
|
||
/>
|
||
))}
|
||
{refBy.length > MAX_REFBY_CHIPS && (
|
||
<span className="text-mono text-mute">
|
||
+{refBy.length - MAX_REFBY_CHIPS}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right rail: scope badge + version */}
|
||
<div className="px-3.5 py-3 flex flex-col items-end gap-1.5 shrink-0">
|
||
<Badge variant="info">{d.scope}</Badge>
|
||
<span className="text-mono text-mute whitespace-nowrap">
|
||
v{d.schemaVersion}
|
||
</span>
|
||
{typeof d.recordCount === 'number' && (
|
||
<span className="text-cap font-display text-mute/80 whitespace-nowrap">
|
||
{t('dict.list.recordCount', { count: d.recordCount })}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</Link>
|
||
)
|
||
}
|