feat(admin-ui): catalog FK chips на cards (← N использ.)
This commit is contained in:
@@ -10,7 +10,9 @@ import {
|
|||||||
parseScopeFilter,
|
parseScopeFilter,
|
||||||
matchesQuery,
|
matchesQuery,
|
||||||
groupByBundle,
|
groupByBundle,
|
||||||
|
uniqueRefBy,
|
||||||
} from './dictionaries.index'
|
} from './dictionaries.index'
|
||||||
|
import type { SchemaDependent } from '@/api/client'
|
||||||
|
|
||||||
const mkDict = (overrides: Partial<DictionaryDefinition>): DictionaryDefinition => ({
|
const mkDict = (overrides: Partial<DictionaryDefinition>): DictionaryDefinition => ({
|
||||||
id: 'id-' + (overrides.name ?? 'x'),
|
id: 'id-' + (overrides.name ?? 'x'),
|
||||||
@@ -131,3 +133,42 @@ describe('groupByBundle', () => {
|
|||||||
expect(out.has('default')).toBe(true)
|
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 { useTranslation } from 'react-i18next'
|
||||||
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@nstart/ui'
|
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader, SearchInput } from '@nstart/ui'
|
||||||
import { PlusIcon } from '@phosphor-icons/react'
|
import { PlusIcon } from '@phosphor-icons/react'
|
||||||
import { useDictionaries } from '@/api/queries'
|
import { useDictionaries, useDictionaryDependents } from '@/api/queries'
|
||||||
import type { DataScope, DictionaryDefinition } from '@/api/client'
|
import type { DataScope, DictionaryDefinition, SchemaDependent } from '@/api/client'
|
||||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||||
import { SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
import { SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
||||||
|
|
||||||
@@ -362,7 +362,59 @@ function DictionariesPage() {
|
|||||||
|
|
||||||
type TFunc = ReturnType<typeof useTranslation>['t']
|
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
|
<Link
|
||||||
to="/dictionaries/$name"
|
to="/dictionaries/$name"
|
||||||
params={{ name: d.name }}
|
params={{ name: d.name }}
|
||||||
@@ -387,8 +439,26 @@ const DictCard = ({ d, t }: { d: DictionaryDefinition; t: TFunc }) => (
|
|||||||
{d.description}
|
{d.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{/* TODO: FK chips row. Когда DictionaryDefinition получит fk[]/refBy[] —
|
{hasFkRow && (
|
||||||
раскомментить + заменить empty placeholder. */}
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Right rail: scope badge + version */}
|
{/* Right rail: scope badge + version */}
|
||||||
@@ -405,3 +475,4 @@ const DictCard = ({ d, t }: { d: DictionaryDefinition; t: TFunc }) => (
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user