diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 0639da9..2912aba 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -225,6 +225,23 @@ i18n 'dict.list.section.PUBLIC': 'Публичные', 'dict.list.section.INTERNAL': 'Внутренние', 'dict.list.section.RESTRICTED': 'Ограниченные', + // Catalog v2 (URL-driven filters + sort + density) + 'dict.list.heading': 'Каталог справочников', + 'dict.list.filter.scope': 'Фильтр по области', + 'dict.list.sort.label': 'Сортировка', + 'dict.list.sort.name': 'По названию', + 'dict.list.sort.recordCount': 'По количеству записей', + 'dict.list.sort.updated': 'По обновлению', + 'dict.list.density.label': 'Плотность', + 'dict.list.density.card': 'Карточки', + 'dict.list.density.dense': 'Список', + 'dict.list.search.reset': 'Сбросить фильтры', + 'dict.list.approval': 'approval', + 'dict.list.col.name': 'Название', + 'dict.list.col.bundle': 'Bundle', + 'dict.list.col.version': 'Версия', + 'dict.list.col.locales': 'Локали', + 'dict.list.col.records': 'Записи', 'dict.empty': 'В этом справочнике пока нет записей', 'dict.col.businessKey': 'Бизнес-ключ', 'dict.col.scope': 'Scope', @@ -669,6 +686,23 @@ i18n 'dict.list.section.PUBLIC': 'Public', 'dict.list.section.INTERNAL': 'Internal', 'dict.list.section.RESTRICTED': 'Restricted', + // Catalog v2 + 'dict.list.heading': 'Dictionary catalog', + 'dict.list.filter.scope': 'Scope filter', + 'dict.list.sort.label': 'Sort', + 'dict.list.sort.name': 'By name', + 'dict.list.sort.recordCount': 'By record count', + 'dict.list.sort.updated': 'By updated', + 'dict.list.density.label': 'Density', + 'dict.list.density.card': 'Cards', + 'dict.list.density.dense': 'List', + 'dict.list.search.reset': 'Reset filters', + 'dict.list.approval': 'approval', + 'dict.list.col.name': 'Name', + 'dict.list.col.bundle': 'Bundle', + 'dict.list.col.version': 'Version', + 'dict.list.col.locales': 'Locales', + 'dict.list.col.records': 'Records', '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 new file mode 100644 index 0000000..1ea5759 --- /dev/null +++ b/ordinis-admin-ui/src/routes/dictionaries.index.test.ts @@ -0,0 +1,144 @@ +/** + * Unit tests для catalog v2 pure helpers (`parseScopeFilter` + `sortDicts`). + * Component-level interactions tested через RTL отдельным test file (followup). + * + * См. design_handoff_dictionary_catalog/review.md "Test plan" для полного + * matrix'а сценариев. + */ +import { describe, it, expect } from 'vitest' +import type { DictionaryDefinition } from '@/api/client' +import { parseScopeFilter, sortDicts, matchesQuery } from './dictionaries.index' + +const mkDict = (overrides: Partial): DictionaryDefinition => ({ + id: 'id-' + (overrides.name ?? 'x'), + name: overrides.name ?? 'test', + displayName: overrides.displayName, + description: overrides.description, + scope: overrides.scope ?? 'PUBLIC', + schemaVersion: overrides.schemaVersion ?? '1.0.0', + bundle: overrides.bundle ?? 'cuod', + supportedLocales: overrides.supportedLocales ?? ['ru-RU'], + defaultLocale: overrides.defaultLocale ?? 'ru-RU', + redisProjectionEnabled: overrides.redisProjectionEnabled ?? false, + approvalRequired: overrides.approvalRequired ?? false, + approvalMinRole: overrides.approvalMinRole ?? null, + recordCount: overrides.recordCount, + createdAt: overrides.createdAt ?? '2026-01-01T00:00:00Z', + updatedAt: overrides.updatedAt ?? '2026-01-01T00:00:00Z', +}) + +describe('parseScopeFilter', () => { + it('returns empty Set for undefined / empty input', () => { + expect(parseScopeFilter(undefined).size).toBe(0) + expect(parseScopeFilter('').size).toBe(0) + }) + + it('parses single valid scope', () => { + const out = parseScopeFilter('PUBLIC') + expect(out.size).toBe(1) + expect(out.has('PUBLIC')).toBe(true) + }) + + it('parses CSV with multiple scopes', () => { + const out = parseScopeFilter('PUBLIC,INTERNAL,RESTRICTED') + expect(out.size).toBe(3) + }) + + it('case-insensitive — lowercase + mixed case normalized to UPPER', () => { + const out = parseScopeFilter('public,Internal') + expect(out.has('PUBLIC')).toBe(true) + expect(out.has('INTERNAL')).toBe(true) + }) + + it('skips invalid scopes (bookmark protection)', () => { + const out = parseScopeFilter('FOO,PUBLIC,BAR') + expect(out.size).toBe(1) + expect(out.has('PUBLIC')).toBe(true) + }) + + it('deduplicates repeated scopes via Set', () => { + const out = parseScopeFilter('PUBLIC,PUBLIC,public') + expect(out.size).toBe(1) + }) + + it('handles whitespace', () => { + const out = parseScopeFilter(' PUBLIC , INTERNAL ') + expect(out.size).toBe(2) + }) +}) + +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', + displayName: 'Космические аппараты', + description: 'Реестр спутников', + }) + + it('empty query matches anything', () => { + expect(matchesQuery(dict, '')).toBe(true) + }) + + it('matches name substring', () => { + expect(matchesQuery(dict, 'space')).toBe(true) + }) + + it('matches displayName (RU)', () => { + expect(matchesQuery(dict, 'космич')).toBe(true) + }) + + it('matches description', () => { + expect(matchesQuery(dict, 'спутник')).toBe(true) + }) + + it('returns false on miss', () => { + expect(matchesQuery(dict, 'абракадабра')).toBe(false) + }) +}) diff --git a/ordinis-admin-ui/src/routes/dictionaries.index.tsx b/ordinis-admin-ui/src/routes/dictionaries.index.tsx index 7e8ed8a..89d9f7f 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.index.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.index.tsx @@ -1,3 +1,17 @@ +/** + * Dictionary Catalog v2. + * + * Что нового 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). + * + * Spec / design: `design_handoff_dictionary_catalog/README.md` + `review.md`. + * URL pattern: `/dictionaries/?q=spacecraft&scope=PUBLIC,INTERNAL&sort=recordCount&density=dense` + */ import { useDeferredValue, useMemo, useState } from 'react' import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' @@ -16,11 +30,63 @@ import type { DataScope, DictionaryDefinition } from '@/api/client' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' import { SCOPE_ACCENT, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style' -export const Route = createFileRoute('/dictionaries/')({ - component: DictionariesPage, -}) +// ===== Search params types + parsing ===== -const matchesQuery = (d: DictionaryDefinition, q: string): boolean => { +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) + +const SCOPE_VALUES = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED']) + +export type CatalogSearch = { + q?: string + scope?: string // CSV "PUBLIC,INTERNAL" + sort?: SortKey + density?: Density +} + +/** + * 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 + } + 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) + } + } + 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 ?? ''] .join(' ') @@ -28,29 +94,88 @@ 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 = ( + 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)) + } +} + +// ===== Route ===== + +export const Route = createFileRoute('/dictionaries/')({ + validateSearch, + component: DictionariesPage, +}) + function DictionariesPage() { const { t } = useTranslation() - const navigate = useNavigate() + const navigate = useNavigate({ from: '/dictionaries/' }) + const search = Route.useSearch() const { data, isLoading, error } = useDictionaries() const [createOpen, setCreateOpen] = useState(false) - const [query, setQuery] = useState('') - const deferredQuery = useDeferredValue(query) + + 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 q = deferredQuery.trim().toLowerCase() - return data.filter((d) => matchesQuery(d, q)) - }, [data, deferredQuery]) + 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 groupedByScope = useMemo(() => { - const map: Record = { - PUBLIC: [], - INTERNAL: [], - RESTRICTED: [], - } - for (const d of filtered) map[d.scope].push(d) - return map - }, [filtered]) + 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 setSearch = (next: Partial) => { + navigate({ + search: (prev) => { + const merged = { ...prev, ...next } as CatalogSearch + // Cleanup defaults — URL не несёт лишних "default" params. + 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 + 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 resetFilters = () => navigate({ search: {} }) + const filtersActive = Boolean(q) || scopeFilter.size > 0 const createButton = ( @@ -98,88 +224,121 @@ function DictionariesPage() { } return ( -
+
-
-
+ {/* Row 1: search + scope chips */} +
+
setQuery(e.target.value)} + value={search.q ?? ''} + onChange={(e) => setSearch({ q: e.target.value })} placeholder={t('dict.list.search.placeholder')} aria-label={t('dict.list.search.placeholder')} />
- + +
+ {SCOPE_ORDER.map((scope) => { + const selected = scopeFilter.has(scope) + const count = scopeCounts[scope] + return ( + + ) + })} +
+
+ + {/* Row 2: sort + density + counts */} +
+
+ + +
+ {DENSITY_OPTIONS.map((d) => ( + + ))} +
+
+ + {t('dict.list.found', { shown: filtered.length, total: data.length })}
+ {/* Results */} {filtered.length === 0 ? ( - - ) : ( -
- {SCOPE_ORDER.map((scope) => { - const items = groupedByScope[scope] - if (items.length === 0) return null - return ( -
-

-

-
- {items.map((d) => ( - -
-

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

- {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 })} - - )} -
- - ))} -
-
- ) - })} +
+

+ {t('dict.list.search.empty')} +

+ {filtersActive && ( + + )}
+ ) : density === 'card' ? ( + + ) : ( + )} -
+
) } + +// ===== Subcomponents ===== + +type TFunc = ReturnType['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 DenseList = ({ items, t }: { items: DictionaryDefinition[]; t: TFunc }) => ( +
+
+
+ {items.map((d) => ( + + +
+ + {d.displayName ?? d.name} + + {d.name} + {d.approvalRequired && ( + {t('dict.list.approval')} + )} +
+ {d.bundle} + v{d.schemaVersion} + + {d.supportedLocales.join(', ')} + + + {d.recordCount ?? '—'} + + + ))} +
+)