diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 2912aba..3727ee2 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -242,6 +242,15 @@ i18n 'dict.list.col.version': 'Версия', 'dict.list.col.locales': 'Локали', '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.col.businessKey': 'Бизнес-ключ', 'dict.col.scope': 'Scope', @@ -703,6 +712,13 @@ i18n 'dict.list.col.version': 'Version', 'dict.list.col.locales': 'Locales', '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.col.businessKey': 'Business key', 'dict.col.scope': 'Scope', diff --git a/ordinis-admin-ui/src/routes/dictionaries.index.test.ts b/ordinis-admin-ui/src/routes/dictionaries.index.test.ts index 1ea5759..d7a6e42 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.index.test.ts +++ b/ordinis-admin-ui/src/routes/dictionaries.index.test.ts @@ -1,13 +1,16 @@ /** - * Unit tests для catalog v2 pure helpers (`parseScopeFilter` + `sortDicts`). - * Component-level interactions tested через RTL отдельным test file (followup). + * Unit tests для catalog list-view pure helpers (parseScopeFilter + + * matchesQuery + groupByBundle). * - * См. design_handoff_dictionary_catalog/review.md "Test plan" для полного - * matrix'а сценариев. + * См. design_handoff_dictionary_catalog/ для полной spec'и (List view D). */ import { describe, it, expect } from 'vitest' 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 => ({ 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', () => { const dict = mkDict({ name: 'spacecraft', @@ -142,3 +97,37 @@ describe('matchesQuery', () => { 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) + }) +}) diff --git a/ordinis-admin-ui/src/routes/dictionaries.index.tsx b/ordinis-admin-ui/src/routes/dictionaries.index.tsx index 89d9f7f..5e1e183 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.index.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.index.tsx @@ -1,91 +1,60 @@ /** - * Dictionary Catalog v2. + * Dictionary Catalog — список со связями (View D из handoff). * - * Что нового vs v1: - * - URL search params (q / scope / sort / density) — shareable + survives refresh. - * - Multi-scope filter (chips) вместо forced grouping by scope. - * - Sort dropdown (name / recordCount / updated). - * - Density toggle (card / dense list). - * - Approval-required badge на карточках. - * - xl:4-cols grid (was 3-col max). + * 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`). * - * Spec / design: `design_handoff_dictionary_catalog/README.md` + `review.md`. - * URL pattern: `/dictionaries/?q=spacecraft&scope=PUBLIC,INTERNAL&sort=recordCount&density=dense` + * 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 '@nstart/ui' +import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@nstart/ui' import { PlusIcon } from '@phosphor-icons/react' import { useDictionaries } from '@/api/queries' import type { DataScope, DictionaryDefinition } from '@/api/client' 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 ===== - -const SORT_OPTIONS = ['name', 'recordCount', 'updated'] as const -export type SortKey = (typeof SORT_OPTIONS)[number] -const SORT_VALUES = new Set(SORT_OPTIONS) - -const DENSITY_OPTIONS = ['card', 'dense'] as const -export type Density = (typeof DENSITY_OPTIONS)[number] -const DENSITY_VALUES = new Set(DENSITY_OPTIONS) +// ===== Search params ===== const SCOPE_VALUES = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED']) export type CatalogSearch = { q?: string scope?: string // CSV "PUBLIC,INTERNAL" - sort?: SortKey - density?: Density + bundle?: string + 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): 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.sort === 'string' && SORT_VALUES.has(raw.sort as SortKey)) { - out.sort = raw.sort as SortKey - } - if (typeof raw.density === 'string' && DENSITY_VALUES.has(raw.density as Density)) { - out.density = raw.density as Density - } + 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) ===== -/** - * Parse CSV "PUBLIC,INTERNAL" → Set. Case-insensitive, - * invalid scopes отбрасываются (защита от bookmark с устаревшими values). - */ 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) - } + if (SCOPE_VALUES.has(candidate as DataScope)) out.add(candidate as DataScope) } return out } -/** Match query против name + displayName + description (lowercase). */ export const matchesQuery = (d: DictionaryDefinition, q: string): boolean => { if (!q) return true const haystack = [d.name, d.displayName ?? '', d.description ?? ''] @@ -94,27 +63,29 @@ export const matchesQuery = (d: DictionaryDefinition, q: string): boolean => { return haystack.includes(q) } -/** - * 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 = ( +export const groupByBundle = ( list: DictionaryDefinition[], - key: SortKey, -): DictionaryDefinition[] => { - const copy = [...list] - switch (key) { - case 'name': - return copy.sort((a, b) => - (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)) +): 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 на левом крае карточки. +const SCOPE_STRIP: Record = { + PUBLIC: 'bg-emerald-500', + INTERNAL: 'bg-amber-500', + RESTRICTED: 'bg-rose-500', } // ===== Route ===== @@ -134,32 +105,17 @@ function DictionariesPage() { const q = (search.q ?? '').trim().toLowerCase() const deferredQuery = useDeferredValue(q) const scopeFilter = useMemo(() => parseScopeFilter(search.scope), [search.scope]) - const sortKey: SortKey = search.sort ?? 'name' - const density: Density = search.density ?? 'card' - - 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 = { PUBLIC: 0, INTERNAL: 0, RESTRICTED: 0 } - if (data) for (const d of data) out[d.scope]++ - return out - }, [data]) + const bundleFilter = search.bundle + const withDepsOnly = search.deps === '1' const setSearch = (next: Partial) => { navigate({ search: (prev) => { - const merged = { ...prev, ...next } as CatalogSearch - // Cleanup defaults — URL не несёт лишних "default" params. + const merged: CatalogSearch = { ...prev, ...next } if (!merged.q) delete merged.q if (!merged.scope) delete merged.scope - if (merged.sort === 'name') delete merged.sort - if (merged.density === 'card') delete merged.density + if (!merged.bundle) delete merged.bundle + if (!merged.deps) delete merged.deps return merged }, }) @@ -169,13 +125,40 @@ function DictionariesPage() { 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(','), - }) + setSearch({ 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() + 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: {} }) - const filtersActive = Boolean(q) || scopeFilter.size > 0 const createButton = ( - {/* Row 2: sort + density + counts */} -
-
- - -
- {DENSITY_OPTIONS.map((d) => ( + {/* Bundle filter row */} +
+ + BUNDLE + + + {Array.from(bundleCounts.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([bundle, count]) => { + const selected = bundleFilter === bundle + return ( - ))} -
-
- - - {t('dict.list.found', { shown: filtered.length, total: data.length })} - + ) + })}
- {/* Results */} + {/* Empty state */} {filtered.length === 0 ? (

@@ -335,10 +324,25 @@ function DictionariesPage() { )}

- ) : density === 'card' ? ( - ) : ( - + // Bundle-grouped 2-col card grid + Array.from(grouped.entries()).map(([bundle, items]) => ( +
+

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

+
+ {items.map((d) => ( + + ))} +
+
+ )) )} ['t'] -const CardGrid = ({ items, t }: { items: DictionaryDefinition[]; t: TFunc }) => ( -
- {items.map((d) => ( - -
-

- {d.displayName ?? d.name} -

-
- {d.approvalRequired && ( - {t('dict.list.approval')} - )} - {d.scope} -
-
- {d.description && ( -

{d.description}

- )} -
-
- v{d.schemaVersion} - · - {d.bundle} - · - {d.supportedLocales.join(', ')} -
- {typeof d.recordCount === 'number' && ( - - {t('dict.list.recordCount', { count: d.recordCount })} - - )} -
- - ))} -
-) +const DictCard = ({ d, t }: { d: DictionaryDefinition; t: TFunc }) => ( + + {/* Scope strip 3px вертикальная полоса */} +