Files
mdm-ordinis/ordinis-admin-ui/src/routes/dictionaries.index.tsx
T
Zimin A.N. f43ce5563a feat(catalog): row-based table per handoff prototype design/compact.html
Refactor каталога с card grid → compact row table. Handoff README line
46 явно говорит 'clicking any dictionary row in the catalog' — наша cards
implementation diverged от прототипа.

What's new:
- DictionaryListTable — single flat table (bundle стал column, не группа)
- DictRow — compact tr с scope dot prefix + title + truncated desc
- 8 columns per prototype: name / id / bundle / scope / records / → / ← / updated
- Responsive column hiding via hideBelow-style breakpoints:
  - id: hidden <md
  - bundle: hidden <lg
  - scope: hidden <sm
  - records: hidden <md
  - → ←: hidden <lg
  - updated: hidden <xl
- На mobile <640px показано только name column (с inline scope dot и desc)
- Row click → navigate к editor; Enter/Space keyboard accessible (role=link)

Removed:
- DictCard component (card layout)
- FkChip component (FK chips на cards — теперь just counts)
- SCOPE_STRIP (vertical 3px strip — заменён на inline scope dot)
- groupByBundle usage (bundle стал column, не group section)
- MAX_REFBY_CHIPS const

Note: outgoing FK count (→) пока показывает '—' — требует schemaJson per-dict
fetch (40+ requests). Batch endpoint GET /dictionaries/graph (handoff open
question #2) разблокирует это. Текущий incoming refBy через useDictionary-
Dependents работает per row.

Tests: 116 pass. TS strict clean.
2026-05-11 16:30:31 +03:00

538 lines
20 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, 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
}
// ===== 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 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>
) : (
// Row-based table per handoff prototype design/compact.html.
// Single flat table (bundle is a column, не group header).
// Hover/keyboard клик на строку → navigate к editor.
<DictionaryListTable rows={filtered} />
)}
<DictionaryEditorDialog
open={createOpen}
mode={{ kind: 'create' }}
onClose={() => setCreateOpen(false)}
onSuccess={(name) => {
setCreateOpen(false)
navigate({ to: '/dictionaries/$name', params: { name } })
}}
/>
</section>
)
}
// ===== Helpers =====
type TFunc = ReturnType<typeof useTranslation>['t']
/**
* Дедуплицирует 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
}
// ===== Row table per handoff prototype design/compact.html =====
/**
* Catalog list — compact row table per handoff Screen 1 (line 173+).
* Columns: name+subtitle / id / bundle / scope / records / → / ← / updated.
* Click row → navigate к editor.
*/
function DictionaryListTable({ rows }: { rows: DictionaryDefinition[] }) {
const { t } = useTranslation()
return (
<div className="rounded-lg border border-line overflow-hidden">
<table className="w-full">
<thead className="bg-surface-2 border-b border-line">
<tr>
<th scope="col" className="text-cap text-mute text-left px-3 py-2">
{t('dict.col.title', { defaultValue: 'Название' })}
</th>
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden md:table-cell">
{t('dict.col.id', { defaultValue: 'id' })}
</th>
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden lg:table-cell">
{t('dict.col.bundle', { defaultValue: 'bundle' })}
</th>
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden sm:table-cell">
{t('dict.col.scope', { defaultValue: 'scope' })}
</th>
<th scope="col" className="text-cap text-mute text-right px-3 py-2 hidden md:table-cell">
{t('dict.col.records', { defaultValue: 'записей' })}
</th>
<th
scope="col"
className="text-cap text-mute text-right px-3 py-2 hidden lg:table-cell"
title={t('dict.col.outgoingFk', { defaultValue: 'Ссылается (outgoing FK)' })}
>
</th>
<th
scope="col"
className="text-cap text-mute text-right px-3 py-2 hidden lg:table-cell"
title={t('dict.col.incomingFk', { defaultValue: 'Используют (incoming FK)' })}
>
</th>
<th scope="col" className="text-cap text-mute text-left px-3 py-2 hidden xl:table-cell">
{t('dict.col.updated', { defaultValue: 'изменён' })}
</th>
</tr>
</thead>
<tbody>
{rows.map((d) => (
<DictRow key={d.id} d={d} t={t} />
))}
</tbody>
</table>
</div>
)
}
function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) {
const navigate = useNavigate({ from: '/dictionaries/' })
const { data: refByRaw } = useDictionaryDependents(d.name)
const refBy = useMemo(() => uniqueRefBy(refByRaw ?? []), [refByRaw])
const incomingCount = refBy.length
const updatedLabel = useMemo(() => {
const date = new Date(d.updatedAt)
return isNaN(date.getTime())
? '—'
: date.toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}, [d.updatedAt])
const handleClick = () => {
void navigate({ to: '/dictionaries/$name', params: { name: d.name } })
}
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleClick()
}
}
return (
<tr
role="link"
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKey}
className="border-b border-line-2 last:border-b-0 cursor-pointer hover:bg-surface-2/40 focus-visible:outline-none focus-visible:bg-accent-bg/30 transition-colors"
>
{/* name + subtitle (description short) */}
<td className="px-3 py-2 align-top min-w-0">
<div className="flex items-center gap-2">
<span className={`inline-block size-1.5 rounded-full shrink-0 ${SCOPE_DOT[d.scope]}`} aria-hidden />
<span className="text-body font-medium text-ink truncate">
{d.displayName ?? d.name}
</span>
{d.approvalRequired && (
<Badge variant="warning">{t('dict.list.approval')}</Badge>
)}
</div>
{d.description && (
<p className="text-cell text-mute mt-0.5 truncate max-w-md">
{d.description}
</p>
)}
</td>
{/* id mono */}
<td className="px-3 py-2 hidden md:table-cell">
<span className="text-mono text-ink-2">{d.name}</span>
</td>
{/* bundle */}
<td className="px-3 py-2 hidden lg:table-cell">
<span className="text-cap text-mute">{d.bundle}</span>
</td>
{/* scope badge */}
<td className="px-3 py-2 hidden sm:table-cell">
<Badge variant="info">{d.scope}</Badge>
</td>
{/* records count */}
<td className="px-3 py-2 text-right tabular-nums text-mono text-ink-2 hidden md:table-cell">
{typeof d.recordCount === 'number' ? d.recordCount : '—'}
</td>
{/* outgoing FK count (proxy via schemaJson требует detail fetch; пока — placeholder) */}
<td className="px-3 py-2 text-right text-mono text-mute hidden lg:table-cell"></td>
{/* incoming FK count */}
<td className="px-3 py-2 text-right text-mono text-mute hidden lg:table-cell">
{incomingCount > 0 ? incomingCount : '—'}
</td>
{/* updated */}
<td className="px-3 py-2 text-mono text-mute tabular-nums hidden xl:table-cell whitespace-nowrap">
{updatedLabel}
</td>
</tr>
)
}