Merge branch 'feat/catalog-fk-chips' into 'main'

feat(admin-ui): catalog FK chips на cards (← N использ.)

See merge request 2-6/2-6-4/terravault/ordinis!18
This commit is contained in:
Александр Зимин
2026-05-10 18:26:30 +00:00
2 changed files with 152 additions and 40 deletions
@@ -10,7 +10,9 @@ import {
parseScopeFilter,
matchesQuery,
groupByBundle,
uniqueRefBy,
} from './dictionaries.index'
import type { SchemaDependent } from '@/api/client'
const mkDict = (overrides: Partial<DictionaryDefinition>): DictionaryDefinition => ({
id: 'id-' + (overrides.name ?? 'x'),
@@ -131,3 +133,42 @@ describe('groupByBundle', () => {
expect(out.has('default')).toBe(true)
})
})
describe('uniqueRefBy', () => {
const mkDep = (sourceDict: string, sourceField = 'fk'): SchemaDependent => ({
sourceDict,
sourceDisplayName: `${sourceDict}-display`,
sourceField,
targetField: 'id',
onClose: 'BLOCK',
activeRecordsInSourceDict: 0,
})
it('empty input returns empty array', () => {
expect(uniqueRefBy([])).toEqual([])
})
it('preserves single entry as-is', () => {
const out = uniqueRefBy([mkDep('missions')])
expect(out).toHaveLength(1)
expect(out[0].sourceDict).toBe('missions')
})
it('dedups by sourceDict — multiple fields → один chip', () => {
const out = uniqueRefBy([
mkDep('satellites', 'type_id'),
mkDep('satellites', 'mission_id'),
mkDep('missions', 'fk'),
])
expect(out.map((d) => d.sourceDict)).toEqual(['satellites', 'missions'])
})
it('preserves first-occurrence displayName', () => {
const out = uniqueRefBy([
{ ...mkDep('x'), sourceDisplayName: 'First' },
{ ...mkDep('x'), sourceDisplayName: 'Second' },
])
expect(out).toHaveLength(1)
expect(out[0].sourceDisplayName).toBe('First')
})
})
@@ -18,8 +18,8 @@ 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 { PlusIcon } from '@phosphor-icons/react'
import { useDictionaries } from '@/api/queries'
import type { DataScope, DictionaryDefinition } from '@/api/client'
import { useDictionaries, useDictionaryDependents } from '@/api/queries'
import type { DataScope, DictionaryDefinition, SchemaDependent } from '@/api/client'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
@@ -362,7 +362,59 @@ function DictionariesPage() {
type TFunc = ReturnType<typeof useTranslation>['t']
const DictCard = ({ d, t }: { d: DictionaryDefinition; t: TFunc }) => (
const MAX_REFBY_CHIPS = 3
/**
* Дедуплицирует SchemaDependent[] по `sourceDict` (одно название может
* указывать через несколько fields — на катаоге показываем как один chip).
* Сохраняет displayName из первого вхождения.
*/
export const uniqueRefBy = (deps: SchemaDependent[]): SchemaDependent[] => {
const seen = new Set<string>()
const out: SchemaDependent[] = []
for (const dep of deps) {
if (!seen.has(dep.sourceDict)) {
seen.add(dep.sourceDict)
out.push(dep)
}
}
return out
}
const FkChip = ({
to,
label,
dim,
}: {
to: string
label: string
dim?: boolean
}) => (
<Link
to="/dictionaries/$name"
params={{ name: to }}
onClick={(e) => e.stopPropagation()}
className={`inline-flex items-center px-1.5 py-0.5 rounded font-mono text-[11px] transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
dim
? 'border border-dashed border-regolith text-carbon/60 hover:border-ultramarain/60 hover:text-ultramarain'
: 'border border-regolith bg-orbit/30 text-ultramarain hover:bg-orbit/60'
}`}
>
{label}
</Link>
)
const DictCard = ({ d, t }: { d: DictionaryDefinition; t: TFunc }) => {
// refBy — backend возвращает SchemaDependent[] (cached 5min, parallel
// queries dedup'ятся TanStack Query'ем). На каталоге фетчим per dict, что на
// 40 dicts = 40 параллельных GET — bursty первый раз, кешировано далее.
// outgoing fk → ссылается потребует schemaJson per dict (отдельный followup,
// backend batch endpoint желателен).
const { data: refByRaw } = useDictionaryDependents(d.name)
const refBy = useMemo(() => uniqueRefBy(refByRaw ?? []), [refByRaw])
const hasFkRow = refBy.length > 0
return (
<Link
to="/dictionaries/$name"
params={{ name: d.name }}
@@ -387,8 +439,26 @@ const DictCard = ({ d, t }: { d: DictionaryDefinition; t: TFunc }) => (
{d.description}
</p>
)}
{/* TODO: FK chips row. Когда DictionaryDefinition получит fk[]/refBy[] —
раскомментить + заменить empty placeholder. */}
{hasFkRow && (
<div className="flex flex-wrap items-center gap-1.5 mt-2.5">
<span className="text-2xs uppercase tracking-label text-carbon/60 mr-1">
{t('dict.list.fk.usedBy', { count: refBy.length })}
</span>
{refBy.slice(0, MAX_REFBY_CHIPS).map((dep) => (
<FkChip
key={dep.sourceDict}
to={dep.sourceDict}
label={dep.sourceDisplayName ?? dep.sourceDict}
dim
/>
))}
{refBy.length > MAX_REFBY_CHIPS && (
<span className="font-mono text-[11px] text-carbon/60">
+{refBy.length - MAX_REFBY_CHIPS}
</span>
)}
</div>
)}
</div>
{/* Right rail: scope badge + version */}
@@ -404,4 +474,5 @@ const DictCard = ({ d, t }: { d: DictionaryDefinition; t: TFunc }) => (
)}
</div>
</Link>
)
)
}