diff --git a/ordinis-admin-ui/src/routes/dictionaries.index.test.ts b/ordinis-admin-ui/src/routes/dictionaries.index.test.ts index d7a6e42..6a16157 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.index.test.ts +++ b/ordinis-admin-ui/src/routes/dictionaries.index.test.ts @@ -10,7 +10,9 @@ import { parseScopeFilter, matchesQuery, groupByBundle, + uniqueRefBy, } from './dictionaries.index' +import type { SchemaDependent } from '@/api/client' const mkDict = (overrides: Partial): 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') + }) +}) diff --git a/ordinis-admin-ui/src/routes/dictionaries.index.tsx b/ordinis-admin-ui/src/routes/dictionaries.index.tsx index 5e1e183..660e79e 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.index.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.index.tsx @@ -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['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() + 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 +}) => ( 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 вертикальная полоса */} -