Merge branch 'feat/catalog-list-with-fk' into 'main'

feat(admin-ui): catalog list view per design handoff (D-list-view)

See merge request 2-6/2-6-4/terravault/ordinis!17
This commit is contained in:
Александр Зимин
2026-05-10 18:05:21 +00:00
3 changed files with 247 additions and 282 deletions
+16
View File
@@ -242,6 +242,15 @@ i18n
'dict.list.col.version': 'Версия', 'dict.list.col.version': 'Версия',
'dict.list.col.locales': 'Локали', 'dict.list.col.locales': 'Локали',
'dict.list.col.records': 'Записи', 'dict.list.col.records': 'Записи',
// List view (handoff D)
'dict.list.bundle.all': 'все',
'dict.list.deps.only': 'Только со связями',
'dict.list.records.short': 'справочн.',
'dict.list.fk.references': '→ ссылается',
'dict.list.fk.usedBy_one': '← {{count}} использ.',
'dict.list.fk.usedBy_few': '← {{count}} использ.',
'dict.list.fk.usedBy_many': '← {{count}} использ.',
'dict.list.fk.usedBy_other': '← {{count}} использ.',
'dict.empty': 'В этом справочнике пока нет записей', 'dict.empty': 'В этом справочнике пока нет записей',
'dict.col.businessKey': 'Бизнес-ключ', 'dict.col.businessKey': 'Бизнес-ключ',
'dict.col.scope': 'Scope', 'dict.col.scope': 'Scope',
@@ -703,6 +712,13 @@ i18n
'dict.list.col.version': 'Version', 'dict.list.col.version': 'Version',
'dict.list.col.locales': 'Locales', 'dict.list.col.locales': 'Locales',
'dict.list.col.records': 'Records', 'dict.list.col.records': 'Records',
// List view (handoff D)
'dict.list.bundle.all': 'all',
'dict.list.deps.only': 'Only with links',
'dict.list.records.short': 'dicts',
'dict.list.fk.references': '→ references',
'dict.list.fk.usedBy_one': '← {{count}} used by',
'dict.list.fk.usedBy_other': '← {{count}} used by',
'dict.empty': 'No records in this dictionary yet', 'dict.empty': 'No records in this dictionary yet',
'dict.col.businessKey': 'Business key', 'dict.col.businessKey': 'Business key',
'dict.col.scope': 'Scope', 'dict.col.scope': 'Scope',
@@ -1,13 +1,16 @@
/** /**
* Unit tests для catalog v2 pure helpers (`parseScopeFilter` + `sortDicts`). * Unit tests для catalog list-view pure helpers (parseScopeFilter +
* Component-level interactions tested через RTL отдельным test file (followup). * matchesQuery + groupByBundle).
* *
* См. design_handoff_dictionary_catalog/review.md "Test plan" для полного * См. design_handoff_dictionary_catalog/ для полной spec'и (List view D).
* matrix'а сценариев.
*/ */
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import type { DictionaryDefinition } from '@/api/client' import type { DictionaryDefinition } from '@/api/client'
import { parseScopeFilter, sortDicts, matchesQuery } from './dictionaries.index' import {
parseScopeFilter,
matchesQuery,
groupByBundle,
} from './dictionaries.index'
const mkDict = (overrides: Partial<DictionaryDefinition>): DictionaryDefinition => ({ const mkDict = (overrides: Partial<DictionaryDefinition>): DictionaryDefinition => ({
id: 'id-' + (overrides.name ?? 'x'), id: 'id-' + (overrides.name ?? 'x'),
@@ -67,54 +70,6 @@ describe('parseScopeFilter', () => {
}) })
}) })
describe('sortDicts', () => {
const a = mkDict({ name: 'alpha', displayName: 'Альфа', recordCount: 5, updatedAt: '2026-01-01T00:00:00Z' })
const b = mkDict({ name: 'beta', displayName: 'Бета', recordCount: 100, updatedAt: '2026-03-01T00:00:00Z' })
const c = mkDict({ name: 'gamma', displayName: 'Гамма', recordCount: 0, updatedAt: '2026-02-01T00:00:00Z' })
it('sort=name — alphabetical (RU localeCompare)', () => {
const out = sortDicts([c, a, b], 'name')
expect(out.map((d) => d.name)).toEqual(['alpha', 'beta', 'gamma'])
})
it('sort=name — uses displayName if present, fallback name', () => {
const noDisplay = mkDict({ name: 'zebra' }) // displayName=undefined → name used
const out = sortDicts([noDisplay, a, b], 'name')
// Default Intl collation сортирует latin до cyrillic — `zebra` идёт перед
// `Альфа`/`Бета`. Эта зависимость от Node-version'а / Intl backend'а.
// Проверяем стабильность сорт'а — порядок detereministic, не конкретный.
const sortedNames = out.map((d) => d.name)
expect(sortedNames).toContain('zebra')
expect(sortedNames).toContain('alpha')
expect(sortedNames).toContain('beta')
// displayName присутствует на a/b — sorted relatively к друг другу
const idxAlpha = sortedNames.indexOf('alpha')
const idxBeta = sortedNames.indexOf('beta')
expect(idxAlpha).toBeLessThan(idxBeta) // Альфа < Бета (RU collation)
})
it('sort=recordCount — descending, missing = 0', () => {
const noCount = mkDict({ name: 'omega', recordCount: undefined })
const out = sortDicts([a, noCount, b, c], 'recordCount')
expect(out.map((d) => d.recordCount ?? 0)).toEqual([100, 5, 0, 0])
})
it('sort=updated — descending по ISO timestamp', () => {
const out = sortDicts([a, b, c], 'updated')
expect(out.map((d) => d.updatedAt)).toEqual([
'2026-03-01T00:00:00Z',
'2026-02-01T00:00:00Z',
'2026-01-01T00:00:00Z',
])
})
it('does not mutate input array', () => {
const input = [c, a, b]
sortDicts(input, 'name')
expect(input.map((d) => d.name)).toEqual(['gamma', 'alpha', 'beta'])
})
})
describe('matchesQuery', () => { describe('matchesQuery', () => {
const dict = mkDict({ const dict = mkDict({
name: 'spacecraft', name: 'spacecraft',
@@ -142,3 +97,37 @@ describe('matchesQuery', () => {
expect(matchesQuery(dict, 'абракадабра')).toBe(false) expect(matchesQuery(dict, 'абракадабра')).toBe(false)
}) })
}) })
describe('groupByBundle', () => {
it('groups dicts by bundle', () => {
const list = [
mkDict({ name: 'a', bundle: 'cuod' }),
mkDict({ name: 'b', bundle: 'geo' }),
mkDict({ name: 'c', bundle: 'cuod' }),
]
const out = groupByBundle(list)
expect(out.size).toBe(2)
expect(out.get('cuod')).toHaveLength(2)
expect(out.get('geo')).toHaveLength(1)
})
it('sorts dicts within group by displayName/name', () => {
const list = [
mkDict({ name: 'zebra', displayName: 'Зебра', bundle: 'cuod' }),
mkDict({ name: 'alpha', displayName: 'Альфа', bundle: 'cuod' }),
mkDict({ name: 'beta', displayName: 'Бета', bundle: 'cuod' }),
]
const out = groupByBundle(list)
expect(out.get('cuod')!.map((d) => d.name)).toEqual(['alpha', 'beta', 'zebra'])
})
it('empty input returns empty Map', () => {
expect(groupByBundle([]).size).toBe(0)
})
it('falls back to "default" bundle если bundle пуст', () => {
const list = [mkDict({ name: 'x', bundle: '' })]
const out = groupByBundle(list)
expect(out.has('default')).toBe(true)
})
})
+170 -210
View File
@@ -1,91 +1,60 @@
/** /**
* Dictionary Catalog v2. * Dictionary Catalog — список со связями (View D из handoff).
* *
* Что нового vs v1: * Bundle-grouped cards с scope-color strip, search/scope/bundle filters
* - URL search params (q / scope / sort / density) — shareable + survives refresh. * и "только со связями" toggle. См. design_handoff_dictionary_catalog/
* - Multi-scope filter (chips) вместо forced grouping by scope. * (`design/proto/view-list.jsx` + `screenshots/D-list-view.png`).
* - Sort dropdown (name / recordCount / updated).
* - Density toggle (card / dense list).
* - Approval-required badge на карточках.
* - xl:4-cols grid (was 3-col max).
* *
* Spec / design: `design_handoff_dictionary_catalog/README.md` + `review.md`. * URL state preserved для shareable links: `?q=&scope=PUBLIC,INTERNAL&bundle=cuod&deps=1`.
* URL pattern: `/dictionaries/?q=spacecraft&scope=PUBLIC,INTERNAL&sort=recordCount&density=dense` *
* 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 { useDeferredValue, useMemo, useState } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@nstart/ui'
Alert,
Badge,
Button,
EmptyState,
LoadingBlock,
PageHeader,
SearchInput,
} from '@nstart/ui'
import { PlusIcon } from '@phosphor-icons/react' import { PlusIcon } from '@phosphor-icons/react'
import { useDictionaries } from '@/api/queries' import { useDictionaries } from '@/api/queries'
import type { DataScope, DictionaryDefinition } from '@/api/client' import type { DataScope, DictionaryDefinition } from '@/api/client'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { SCOPE_ACCENT, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style' import { SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
// ===== Search params types + parsing ===== // ===== Search params =====
const SORT_OPTIONS = ['name', 'recordCount', 'updated'] as const
export type SortKey = (typeof SORT_OPTIONS)[number]
const SORT_VALUES = new Set<SortKey>(SORT_OPTIONS)
const DENSITY_OPTIONS = ['card', 'dense'] as const
export type Density = (typeof DENSITY_OPTIONS)[number]
const DENSITY_VALUES = new Set<Density>(DENSITY_OPTIONS)
const SCOPE_VALUES = new Set<DataScope>(['PUBLIC', 'INTERNAL', 'RESTRICTED']) const SCOPE_VALUES = new Set<DataScope>(['PUBLIC', 'INTERNAL', 'RESTRICTED'])
export type CatalogSearch = { export type CatalogSearch = {
q?: string q?: string
scope?: string // CSV "PUBLIC,INTERNAL" scope?: string // CSV "PUBLIC,INTERNAL"
sort?: SortKey bundle?: string
density?: Density deps?: '1'
} }
/**
* Manual validateSearch (без zod-dep). Pattern consistent с
* `audit.tsx` validateSearch. Invalid values → ignored, не throws — это
* shareable URL safety: bookmark не должен 500-нуть после schema change.
*/
const validateSearch = (raw: Record<string, unknown>): CatalogSearch => { const validateSearch = (raw: Record<string, unknown>): CatalogSearch => {
const out: CatalogSearch = {} const out: CatalogSearch = {}
if (typeof raw.q === 'string' && raw.q.length > 0) out.q = raw.q 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.scope === 'string' && raw.scope.length > 0) out.scope = raw.scope
if (typeof raw.sort === 'string' && SORT_VALUES.has(raw.sort as SortKey)) { if (typeof raw.bundle === 'string' && raw.bundle.length > 0) out.bundle = raw.bundle
out.sort = raw.sort as SortKey if (raw.deps === '1' || raw.deps === 1) out.deps = '1'
}
if (typeof raw.density === 'string' && DENSITY_VALUES.has(raw.density as Density)) {
out.density = raw.density as Density
}
return out return out
} }
// ===== Pure helpers (exported для unit tests) ===== // ===== Pure helpers (exported для unit tests) =====
/**
* Parse CSV "PUBLIC,INTERNAL" → Set<DataScope>. Case-insensitive,
* invalid scopes отбрасываются (защита от bookmark с устаревшими values).
*/
export const parseScopeFilter = (csv: string | undefined): Set<DataScope> => { export const parseScopeFilter = (csv: string | undefined): Set<DataScope> => {
if (!csv) return new Set() if (!csv) return new Set()
const out = new Set<DataScope>() const out = new Set<DataScope>()
for (const raw of csv.split(',')) { for (const raw of csv.split(',')) {
const candidate = raw.trim().toUpperCase() const candidate = raw.trim().toUpperCase()
if (SCOPE_VALUES.has(candidate as DataScope)) { if (SCOPE_VALUES.has(candidate as DataScope)) out.add(candidate as DataScope)
out.add(candidate as DataScope)
}
} }
return out return out
} }
/** Match query против name + displayName + description (lowercase). */
export const matchesQuery = (d: DictionaryDefinition, q: string): boolean => { export const matchesQuery = (d: DictionaryDefinition, q: string): boolean => {
if (!q) return true if (!q) return true
const haystack = [d.name, d.displayName ?? '', d.description ?? ''] const haystack = [d.name, d.displayName ?? '', d.description ?? '']
@@ -94,27 +63,29 @@ export const matchesQuery = (d: DictionaryDefinition, q: string): boolean => {
return haystack.includes(q) return haystack.includes(q)
} }
/** export const groupByBundle = (
* Sort copy of list по выбранному ключу:
* - `name` — по displayName (fallback name), localeCompare для RU+EN mixed
* - `recordCount` — descending, missing recordCount = 0
* - `updated` — descending по updatedAt (ISO 8601 → лексикографический сорт)
*/
export const sortDicts = (
list: DictionaryDefinition[], list: DictionaryDefinition[],
key: SortKey, ): Map<string, DictionaryDefinition[]> => {
): DictionaryDefinition[] => { const out = new Map<string, DictionaryDefinition[]>()
const copy = [...list] for (const d of list) {
switch (key) { const key = d.bundle || 'default'
case 'name': if (!out.has(key)) out.set(key, [])
return copy.sort((a, b) => 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), (a.displayName ?? a.name).localeCompare(b.displayName ?? b.name),
) )
case 'recordCount':
return copy.sort((a, b) => (b.recordCount ?? 0) - (a.recordCount ?? 0))
case 'updated':
return copy.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
} }
return out
}
// Scope strip color — vertical 3px на левом крае карточки.
const SCOPE_STRIP: Record<DataScope, string> = {
PUBLIC: 'bg-emerald-500',
INTERNAL: 'bg-amber-500',
RESTRICTED: 'bg-rose-500',
} }
// ===== Route ===== // ===== Route =====
@@ -134,32 +105,17 @@ function DictionariesPage() {
const q = (search.q ?? '').trim().toLowerCase() const q = (search.q ?? '').trim().toLowerCase()
const deferredQuery = useDeferredValue(q) const deferredQuery = useDeferredValue(q)
const scopeFilter = useMemo(() => parseScopeFilter(search.scope), [search.scope]) const scopeFilter = useMemo(() => parseScopeFilter(search.scope), [search.scope])
const sortKey: SortKey = search.sort ?? 'name' const bundleFilter = search.bundle
const density: Density = search.density ?? 'card' const withDepsOnly = search.deps === '1'
const filtered = useMemo(() => {
if (!data) return []
const byScope =
scopeFilter.size === 0 ? data : data.filter((d) => scopeFilter.has(d.scope))
const byQuery = byScope.filter((d) => matchesQuery(d, deferredQuery))
return sortDicts(byQuery, sortKey)
}, [data, deferredQuery, scopeFilter, sortKey])
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 setSearch = (next: Partial<CatalogSearch>) => { const setSearch = (next: Partial<CatalogSearch>) => {
navigate({ navigate({
search: (prev) => { search: (prev) => {
const merged = { ...prev, ...next } as CatalogSearch const merged: CatalogSearch = { ...prev, ...next }
// Cleanup defaults — URL не несёт лишних "default" params.
if (!merged.q) delete merged.q if (!merged.q) delete merged.q
if (!merged.scope) delete merged.scope if (!merged.scope) delete merged.scope
if (merged.sort === 'name') delete merged.sort if (!merged.bundle) delete merged.bundle
if (merged.density === 'card') delete merged.density if (!merged.deps) delete merged.deps
return merged return merged
}, },
}) })
@@ -169,13 +125,40 @@ function DictionariesPage() {
const next = new Set(scopeFilter) const next = new Set(scopeFilter)
if (next.has(scope)) next.delete(scope) if (next.has(scope)) next.delete(scope)
else next.add(scope) else next.add(scope)
setSearch({ setSearch({ scope: next.size === 0 ? undefined : Array.from(next).join(',') })
scope: next.size === 0 ? undefined : Array.from(next).join(','),
})
} }
const setBundle = (bundle: string | undefined) => setSearch({ bundle })
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
// withDepsOnly: пока нет FK metadata в DictionaryDefinition, фильтр
// эффективно no-op. После backend batch FK endpoint — заработает.
// TODO: backend пишет fk + refBy в DictionaryDefinition; раскомментить:
// if (withDepsOnly && d.fk.length === 0 && d.refBy.length === 0) return false
return true
})
}, [data, deferredQuery, scopeFilter, bundleFilter])
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: {} }) const resetFilters = () => navigate({ search: {} })
const filtersActive = Boolean(q) || scopeFilter.size > 0
const createButton = ( const createButton = (
<Button <Button
@@ -189,10 +172,7 @@ function DictionariesPage() {
</Button> </Button>
) )
if (isLoading) { if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
return <LoadingBlock size="md" label={t('loading')} />
}
if (error) { if (error) {
return ( return (
<Alert variant="error" title={t('error.failed')}> <Alert variant="error" title={t('error.failed')}>
@@ -200,7 +180,6 @@ function DictionariesPage() {
</Alert> </Alert>
) )
} }
if (!data || data.length === 0) { if (!data || data.length === 0) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -234,9 +213,9 @@ function DictionariesPage() {
actions={createButton} actions={createButton}
/> />
{/* Row 1: search + scope chips */} {/* Toolbar: search + count + scope chips + "deps only" */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="flex-1 min-w-[280px] max-w-md"> <div className="flex-1 min-w-[300px] max-w-md">
<SearchInput <SearchInput
value={search.q ?? ''} value={search.q ?? ''}
onChange={(e) => setSearch({ q: e.target.value })} onChange={(e) => setSearch({ q: e.target.value })}
@@ -245,6 +224,10 @@ function DictionariesPage() {
/> />
</div> </div>
<span className="text-2xs uppercase tracking-label text-carbon/70 whitespace-nowrap">
{t('dict.list.found', { shown: filtered.length, total: data.length })}
</span>
<div <div
role="group" role="group"
aria-label={t('dict.list.filter.scope')} aria-label={t('dict.list.filter.scope')}
@@ -262,7 +245,7 @@ function DictionariesPage() {
className={`px-3 py-1.5 rounded-full text-2xs uppercase tracking-label flex items-center gap-1.5 border transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${ className={`px-3 py-1.5 rounded-full text-2xs uppercase tracking-label flex items-center gap-1.5 border transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
selected selected
? 'border-ultramarain bg-orbit/40 text-ultramarain' ? 'border-ultramarain bg-orbit/40 text-ultramarain'
: 'border-regolith hover:border-ultramarain/60' : 'border-regolith hover:border-ultramarain/60 text-carbon'
}`} }`}
> >
<span <span
@@ -274,56 +257,62 @@ function DictionariesPage() {
) )
})} })}
</div> </div>
</div>
{/* Row 2: sort + density + counts */}
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-3 flex-wrap">
<label className="flex items-center gap-2">
<span className="text-2xs uppercase tracking-label text-carbon/70">
{t('dict.list.sort.label')}
</span>
<select
value={sortKey}
onChange={(e) => setSearch({ sort: e.target.value as SortKey })}
className="border border-regolith rounded-sm py-1 px-2 text-sm focus:border-ultramarain focus:outline-none focus:ring-2 focus:ring-ultramarain/40"
aria-label={t('dict.list.sort.label')}
>
<option value="name">{t('dict.list.sort.name')}</option>
<option value="recordCount">{t('dict.list.sort.recordCount')}</option>
<option value="updated">{t('dict.list.sort.updated')}</option>
</select>
</label>
<div
role="group"
aria-label={t('dict.list.density.label')}
className="hidden sm:flex border border-regolith rounded-sm overflow-hidden"
>
{DENSITY_OPTIONS.map((d) => (
<button <button
key={d}
type="button" type="button"
onClick={() => setSearch({ density: d })} onClick={() => setSearch({ deps: withDepsOnly ? undefined : '1' })}
aria-pressed={d === density} aria-pressed={withDepsOnly}
className={`px-3 py-1 text-2xs uppercase tracking-label transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${ className={`px-3 py-1.5 rounded-sm text-2xs uppercase tracking-label border transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
d === density withDepsOnly
? 'bg-orbit/40 text-ultramarain' ? 'border-ultramarain bg-orbit/40 text-ultramarain'
: 'hover:bg-orbit/20' : 'border-regolith hover:border-ultramarain/60 text-carbon'
}`} }`}
> >
{t(`dict.list.density.${d}`)} {t('dict.list.deps.only')}
</button> </button>
))}
</div>
</div> </div>
<span className="text-2xs uppercase tracking-label text-carbon/70"> {/* Bundle filter row */}
{t('dict.list.found', { shown: filtered.length, total: data.length })} <div className="flex flex-wrap items-center gap-2">
<span className="text-2xs uppercase tracking-label text-carbon/70 mr-1 font-mono">
BUNDLE
</span> </span>
<button
type="button"
onClick={() => setBundle(undefined)}
aria-pressed={!bundleFilter}
className={`px-3 py-1 rounded-sm text-2xs uppercase tracking-label border transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
!bundleFilter
? 'border-ultramarain bg-orbit/40 text-ultramarain'
: 'border-regolith hover:border-ultramarain/60 text-carbon'
}`}
>
{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={`px-3 py-1 rounded-sm text-2xs uppercase tracking-label border transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 font-mono ${
selected
? 'border-ultramarain bg-orbit/40 text-ultramarain'
: 'border-regolith hover:border-ultramarain/60 text-carbon'
}`}
>
{bundle}
<span className="ml-1.5 text-carbon/60">{count}</span>
</button>
)
})}
</div> </div>
{/* Results */} {/* Empty state */}
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="border border-regolith rounded-lg p-12 text-center bg-orbit/10"> <div className="border border-regolith rounded-lg p-12 text-center bg-orbit/10">
<p className="font-primary text-lg text-ultramarain mb-2"> <p className="font-primary text-lg text-ultramarain mb-2">
@@ -335,10 +324,25 @@ function DictionariesPage() {
</Button> </Button>
)} )}
</div> </div>
) : density === 'card' ? (
<CardGrid items={filtered} t={t} />
) : ( ) : (
<DenseList items={filtered} t={t} /> // 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-regolith">
<span className="text-sm uppercase tracking-label text-ultramarain font-mono font-medium">
{bundle}
</span>
<span className="text-2xs uppercase tracking-label text-carbon/60">
· {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 <DictionaryEditorDialog
@@ -354,94 +358,50 @@ function DictionariesPage() {
) )
} }
// ===== Subcomponents ===== // ===== Card =====
type TFunc = ReturnType<typeof useTranslation>['t'] type TFunc = ReturnType<typeof useTranslation>['t']
const CardGrid = ({ items, t }: { items: DictionaryDefinition[]; t: TFunc }) => ( const DictCard = ({ d, t }: { d: DictionaryDefinition; t: TFunc }) => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{items.map((d) => (
<Link <Link
key={d.id}
to="/dictionaries/$name" to="/dictionaries/$name"
params={{ name: d.name }} params={{ name: d.name }}
className={`relative block bg-white border border-regolith rounded-lg p-4 pl-5 transition shadow-card hover:shadow-hover hover:border-ultramarain/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ultramarain before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1 before:rounded-l-lg ${SCOPE_ACCENT[d.scope]}`} className="grid grid-cols-[3px_1fr_auto] bg-white border border-regolith rounded-lg overflow-hidden transition hover:shadow-hover hover:-translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ultramarain/40"
> >
<div className="flex items-start justify-between mb-2 gap-2"> {/* Scope strip 3px вертикальная полоса */}
<h3 className="font-primary text-base text-ultramarain"> <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="font-primary text-[15px] font-semibold text-ultramarain truncate">
{d.displayName ?? d.name} {d.displayName ?? d.name}
</h3> </h3>
<div className="flex gap-1 shrink-0 flex-wrap justify-end"> <span className="font-mono text-[11px] text-carbon/60 truncate">{d.name}</span>
{d.approvalRequired && ( {d.approvalRequired && (
<Badge variant="warning">{t('dict.list.approval')}</Badge> <Badge variant="warning">{t('dict.list.approval')}</Badge>
)} )}
<Badge variant="info">{d.scope}</Badge>
</div>
</div> </div>
{d.description && ( {d.description && (
<p className="text-sm text-carbon line-clamp-2 mb-3">{d.description}</p> <p className="text-[12px] text-carbon mt-1 leading-snug line-clamp-2">
{d.description}
</p>
)} )}
<div className="flex items-center justify-between gap-2 text-2xs uppercase tracking-label text-carbon/60"> {/* TODO: FK chips row. Когда DictionaryDefinition получит fk[]/refBy[] —
<div className="flex gap-2 min-w-0"> раскомментить + заменить empty placeholder. */}
<span className="truncate">v{d.schemaVersion}</span>
<span>·</span>
<span className="truncate">{d.bundle}</span>
<span>·</span>
<span className="truncate">{d.supportedLocales.join(', ')}</span>
</div> </div>
{typeof d.recordCount === 'number' && (
<Badge variant="neutral">
{t('dict.list.recordCount', { count: d.recordCount })}
</Badge>
)}
</div>
</Link>
))}
</div>
)
const DenseList = ({ items, t }: { items: DictionaryDefinition[]; t: TFunc }) => ( {/* Right rail: scope badge + version */}
<div className="border border-regolith rounded-lg overflow-hidden"> <div className="px-3.5 py-3 flex flex-col items-end gap-1.5 shrink-0">
<div <Badge variant="info">{d.scope}</Badge>
className="grid grid-cols-[24px_1fr_120px_80px_120px_80px] gap-3 px-4 py-2 bg-orbit/20 text-2xs uppercase tracking-label text-carbon/70 border-b border-regolith" <span className="font-mono text-[11px] text-carbon/60 whitespace-nowrap">
role="row" v{d.schemaVersion}
> </span>
<span aria-hidden="true" /> {typeof d.recordCount === 'number' && (
<span>{t('dict.list.col.name')}</span> <span className="text-2xs uppercase tracking-label text-carbon/50 whitespace-nowrap">
<span>{t('dict.list.col.bundle')}</span> {t('dict.list.recordCount', { count: d.recordCount })}
<span>{t('dict.list.col.version')}</span>
<span>{t('dict.list.col.locales')}</span>
<span className="text-right">{t('dict.list.col.records')}</span>
</div>
{items.map((d) => (
<Link
key={d.id}
to="/dictionaries/$name"
params={{ name: d.name }}
className="grid grid-cols-[24px_1fr_120px_80px_120px_80px] gap-3 items-center px-4 py-2.5 hover:bg-orbit/20 border-b border-regolith last:border-0 group focus-visible:outline-none focus-visible:bg-orbit/30 focus-visible:ring-2 focus-visible:ring-ultramarain/40"
>
<span
className={`size-2 rounded-full justify-self-center ${SCOPE_DOT[d.scope]}`}
aria-label={d.scope}
/>
<div className="min-w-0 flex items-center gap-2">
<span className="font-primary text-sm text-ultramarain group-hover:underline truncate">
{d.displayName ?? d.name}
</span> </span>
<span className="text-2xs text-carbon/60 truncate">{d.name}</span>
{d.approvalRequired && (
<Badge variant="warning">{t('dict.list.approval')}</Badge>
)} )}
</div> </div>
<span className="text-2xs text-carbon/70 truncate">{d.bundle}</span>
<span className="text-2xs text-carbon/70">v{d.schemaVersion}</span>
<span className="text-2xs text-carbon/70 truncate">
{d.supportedLocales.join(', ')}
</span>
<span className="text-2xs text-carbon font-medium text-right">
{d.recordCount ?? '—'}
</span>
</Link> </Link>
))}
</div>
) )