Merge branch 'feat/catalog-v2' into 'main'
feat(admin-ui): Dictionary Catalog v2 (URL filters + sort + density) See merge request 2-6/2-6-4/terravault/ordinis!15
This commit is contained in:
@@ -225,6 +225,23 @@ i18n
|
|||||||
'dict.list.section.PUBLIC': 'Публичные',
|
'dict.list.section.PUBLIC': 'Публичные',
|
||||||
'dict.list.section.INTERNAL': 'Внутренние',
|
'dict.list.section.INTERNAL': 'Внутренние',
|
||||||
'dict.list.section.RESTRICTED': 'Ограниченные',
|
'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.empty': 'В этом справочнике пока нет записей',
|
||||||
'dict.col.businessKey': 'Бизнес-ключ',
|
'dict.col.businessKey': 'Бизнес-ключ',
|
||||||
'dict.col.scope': 'Scope',
|
'dict.col.scope': 'Scope',
|
||||||
@@ -669,6 +686,23 @@ i18n
|
|||||||
'dict.list.section.PUBLIC': 'Public',
|
'dict.list.section.PUBLIC': 'Public',
|
||||||
'dict.list.section.INTERNAL': 'Internal',
|
'dict.list.section.INTERNAL': 'Internal',
|
||||||
'dict.list.section.RESTRICTED': 'Restricted',
|
'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.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',
|
||||||
|
|||||||
@@ -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>): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 { 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'
|
||||||
@@ -16,11 +30,63 @@ 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_ACCENT, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
||||||
|
|
||||||
export const Route = createFileRoute('/dictionaries/')({
|
// ===== Search params types + parsing =====
|
||||||
component: DictionariesPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
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<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'])
|
||||||
|
|
||||||
|
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<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.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<DataScope>. Case-insensitive,
|
||||||
|
* invalid scopes отбрасываются (защита от bookmark с устаревшими values).
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match query против name + displayName + description (lowercase). */
|
||||||
|
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 ?? '']
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -28,29 +94,88 @@ const matchesQuery = (d: DictionaryDefinition, q: string): boolean => {
|
|||||||
return haystack.includes(q)
|
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() {
|
function DictionariesPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate({ from: '/dictionaries/' })
|
||||||
|
const search = Route.useSearch()
|
||||||
const { data, isLoading, error } = useDictionaries()
|
const { data, isLoading, error } = useDictionaries()
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!data) return []
|
if (!data) return []
|
||||||
const q = deferredQuery.trim().toLowerCase()
|
const byScope =
|
||||||
return data.filter((d) => matchesQuery(d, q))
|
scopeFilter.size === 0 ? data : data.filter((d) => scopeFilter.has(d.scope))
|
||||||
}, [data, deferredQuery])
|
const byQuery = byScope.filter((d) => matchesQuery(d, deferredQuery))
|
||||||
|
return sortDicts(byQuery, sortKey)
|
||||||
|
}, [data, deferredQuery, scopeFilter, sortKey])
|
||||||
|
|
||||||
const groupedByScope = useMemo(() => {
|
const scopeCounts = useMemo(() => {
|
||||||
const map: Record<DataScope, DictionaryDefinition[]> = {
|
const out: Record<DataScope, number> = { PUBLIC: 0, INTERNAL: 0, RESTRICTED: 0 }
|
||||||
PUBLIC: [],
|
if (data) for (const d of data) out[d.scope]++
|
||||||
INTERNAL: [],
|
return out
|
||||||
RESTRICTED: [],
|
}, [data])
|
||||||
}
|
|
||||||
for (const d of filtered) map[d.scope].push(d)
|
const setSearch = (next: Partial<CatalogSearch>) => {
|
||||||
return map
|
navigate({
|
||||||
}, [filtered])
|
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 = (
|
const createButton = (
|
||||||
<Button
|
<Button
|
||||||
@@ -58,6 +183,7 @@ function DictionariesPage() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
leftIcon={<PlusIcon weight="bold" size={16} />}
|
leftIcon={<PlusIcon weight="bold" size={16} />}
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{t('schema.action.create')}
|
{t('schema.action.create')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -98,88 +224,121 @@ function DictionariesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<section
|
||||||
|
aria-label={t('dict.list.heading')}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('nav.dictionaries')}
|
title={t('nav.dictionaries')}
|
||||||
description={t('dict.list.subtitle')}
|
description={t('dict.list.subtitle')}
|
||||||
actions={createButton}
|
actions={createButton}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
{/* Row 1: search + scope chips */}
|
||||||
<div className="flex-1 max-w-md">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-[280px] max-w-md">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
value={query}
|
value={search.q ?? ''}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setSearch({ q: e.target.value })}
|
||||||
placeholder={t('dict.list.search.placeholder')}
|
placeholder={t('dict.list.search.placeholder')}
|
||||||
aria-label={t('dict.list.search.placeholder')}
|
aria-label={t('dict.list.search.placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-carbon/70">
|
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t('dict.list.filter.scope')}
|
||||||
|
className="flex items-center gap-1.5 flex-wrap"
|
||||||
|
>
|
||||||
|
{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}
|
||||||
|
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
|
||||||
|
? 'border-ultramarain bg-orbit/40 text-ultramarain'
|
||||||
|
: 'border-regolith hover:border-ultramarain/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block size-2 rounded-full ${SCOPE_DOT[scope]}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t(`dict.list.section.${scope}`)} · {count}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</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
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch({ density: d })}
|
||||||
|
aria-pressed={d === density}
|
||||||
|
className={`px-3 py-1 text-2xs uppercase tracking-label transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
|
||||||
|
d === density
|
||||||
|
? 'bg-orbit/40 text-ultramarain'
|
||||||
|
: 'hover:bg-orbit/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`dict.list.density.${d}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-2xs uppercase tracking-label text-carbon/70">
|
||||||
{t('dict.list.found', { shown: filtered.length, total: data.length })}
|
{t('dict.list.found', { shown: filtered.length, total: data.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<EmptyState title={t('dict.list.search.empty')} />
|
<div className="border border-regolith rounded-lg p-12 text-center bg-orbit/10">
|
||||||
) : (
|
<p className="font-primary text-lg text-ultramarain mb-2">
|
||||||
<div className="space-y-8">
|
{t('dict.list.search.empty')}
|
||||||
{SCOPE_ORDER.map((scope) => {
|
</p>
|
||||||
const items = groupedByScope[scope]
|
{filtersActive && (
|
||||||
if (items.length === 0) return null
|
<Button type="button" variant="ghost" onClick={resetFilters}>
|
||||||
return (
|
{t('dict.list.search.reset')}
|
||||||
<section key={scope} className="space-y-3">
|
</Button>
|
||||||
<h2 className="flex items-center gap-2 text-2xs uppercase tracking-label text-carbon/70">
|
)}
|
||||||
<span
|
|
||||||
className={`inline-block size-2 rounded-full ${SCOPE_DOT[scope]}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="font-primary text-sm normal-case tracking-normal text-ultramarain">
|
|
||||||
{t(`dict.list.section.${scope}`)}
|
|
||||||
</span>
|
|
||||||
<span className="text-carbon/50">· {items.length}</span>
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{items.map((d) => (
|
|
||||||
<Link
|
|
||||||
key={d.id}
|
|
||||||
to="/dictionaries/$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 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1 before:rounded-l-lg ${SCOPE_ACCENT[d.scope]}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-2 gap-2">
|
|
||||||
<h3 className="font-primary text-base text-ultramarain">
|
|
||||||
{d.displayName ?? d.name}
|
|
||||||
</h3>
|
|
||||||
<Badge variant="info">{d.scope}</Badge>
|
|
||||||
</div>
|
|
||||||
{d.description && (
|
|
||||||
<p className="text-sm text-carbon line-clamp-2 mb-3">
|
|
||||||
{d.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between gap-2 text-2xs uppercase tracking-label text-carbon/60">
|
|
||||||
<div className="flex gap-2 min-w-0">
|
|
||||||
<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>
|
|
||||||
{typeof d.recordCount === 'number' && (
|
|
||||||
<Badge variant="neutral">
|
|
||||||
{t('dict.list.recordCount', { count: d.recordCount })}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : density === 'card' ? (
|
||||||
|
<CardGrid items={filtered} t={t} />
|
||||||
|
) : (
|
||||||
|
<DenseList items={filtered} t={t} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DictionaryEditorDialog
|
<DictionaryEditorDialog
|
||||||
@@ -191,6 +350,98 @@ function DictionariesPage() {
|
|||||||
navigate({ to: '/dictionaries/$name', params: { name } })
|
navigate({ to: '/dictionaries/$name', params: { name } })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Subcomponents =====
|
||||||
|
|
||||||
|
type TFunc = ReturnType<typeof useTranslation>['t']
|
||||||
|
|
||||||
|
const CardGrid = ({ items, t }: { items: 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
|
||||||
|
key={d.id}
|
||||||
|
to="/dictionaries/$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]}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2 gap-2">
|
||||||
|
<h3 className="font-primary text-base text-ultramarain">
|
||||||
|
{d.displayName ?? d.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
|
||||||
|
{d.approvalRequired && (
|
||||||
|
<Badge variant="warning">{t('dict.list.approval')}</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="info">{d.scope}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{d.description && (
|
||||||
|
<p className="text-sm text-carbon line-clamp-2 mb-3">{d.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between gap-2 text-2xs uppercase tracking-label text-carbon/60">
|
||||||
|
<div className="flex gap-2 min-w-0">
|
||||||
|
<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>
|
||||||
|
{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 }) => (
|
||||||
|
<div className="border border-regolith rounded-lg overflow-hidden">
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
role="row"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
<span>{t('dict.list.col.name')}</span>
|
||||||
|
<span>{t('dict.list.col.bundle')}</span>
|
||||||
|
<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 className="text-2xs text-carbon/60 truncate">{d.name}</span>
|
||||||
|
{d.approvalRequired && (
|
||||||
|
<Badge variant="warning">{t('dict.list.approval')}</Badge>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user