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,46 +362,117 @@ 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: d.name }}
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"
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'
}`}
>
{/* Scope strip 3px вертикальная полоса */}
<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}
</h3>
<span className="font-mono text-[11px] text-carbon/60 truncate">{d.name}</span>
{d.approvalRequired && (
<Badge variant="warning">{t('dict.list.approval')}</Badge>
)}
</div>
{d.description && (
<p className="text-[12px] text-carbon mt-1 leading-snug line-clamp-2">
{d.description}
</p>
)}
{/* TODO: FK chips row. Когда DictionaryDefinition получит fk[]/refBy[] —
раскомментить + заменить empty placeholder. */}
</div>
{/* Right rail: scope badge + version */}
<div className="px-3.5 py-3 flex flex-col items-end gap-1.5 shrink-0">
<Badge variant="info">{d.scope}</Badge>
<span className="font-mono text-[11px] text-carbon/60 whitespace-nowrap">
v{d.schemaVersion}
</span>
{typeof d.recordCount === 'number' && (
<span className="text-2xs uppercase tracking-label text-carbon/50 whitespace-nowrap">
{t('dict.list.recordCount', { count: d.recordCount })}
</span>
)}
</div>
{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 }}
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"
>
{/* Scope strip 3px вертикальная полоса */}
<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}
</h3>
<span className="font-mono text-[11px] text-carbon/60 truncate">{d.name}</span>
{d.approvalRequired && (
<Badge variant="warning">{t('dict.list.approval')}</Badge>
)}
</div>
{d.description && (
<p className="text-[12px] text-carbon mt-1 leading-snug line-clamp-2">
{d.description}
</p>
)}
{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 */}
<div className="px-3.5 py-3 flex flex-col items-end gap-1.5 shrink-0">
<Badge variant="info">{d.scope}</Badge>
<span className="font-mono text-[11px] text-carbon/60 whitespace-nowrap">
v{d.schemaVersion}
</span>
{typeof d.recordCount === 'number' && (
<span className="text-2xs uppercase tracking-label text-carbon/50 whitespace-nowrap">
{t('dict.list.recordCount', { count: d.recordCount })}
</span>
)}
</div>
</Link>
)
}