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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user