Files
mdm-ordinis/ordinis-admin-ui/src/routes/dictionaries.index.tsx
T
Zimin A.N. f0dbc20f62 fix(fonts): explicit font-family on title utilities + cascade conflicts
Реальные измерения через 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.
2026-05-11 14:58:32 +03:00

520 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
)
}