From 49ab6c93404c7eb128ac8997c3ed11aa797e34b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Sun, 10 May 2026 18:26:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin-ui):=20catalog=20FK=20chips=20=D0=BD?= =?UTF-8?q?=D0=B0=20cards=20(=E2=86=90=20N=20=D0=B8=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/dictionaries.index.test.ts | 41 +++++ .../src/routes/dictionaries.index.tsx | 151 +++++++++++++----- 2 files changed, 152 insertions(+), 40 deletions(-) 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 вертикальная полоса */} -