From a2d5df24309a7a83cd52d06b60175b18ebca83c9 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 20:49:39 +0300 Subject: [PATCH] =?UTF-8?q?feat(schema):=20FK=20=D1=82=D0=B8=D0=BF=20?= =?UTF-8?q?=D0=B2=20schema-builder=20=E2=80=94=20picker=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20x-references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше для добавления FK поля admin должен был лезть в JSON-tab и руками писать 'x-references': 'dict.field' — рискованно (typo в имени словаря не диагностится до save), и неочевидно для не-дев пользователей. Теперь: - В Type dropdown появилось 'Ссылка на словарь (FK)' рядом с Enum - При выборе reference показывается двухпольный picker: - SingleSelect списка доступных словарей (через useDictionaries запрос — admin видит full list с displayName) - TextInput поля в target (default 'code', соответствует x-id-source большинства словарей) - Highlight border-ultramarain/20 + bg-ultramarain/4 — визуально отличается от обычного scalar-конфига types.ts: - 'reference' в PropertyKind union + PROPERTY_KINDS list - referenceTarget?: string в PropertyDef ('dict.field') - propertyToSchema → {type:'string','x-references':'...'} - inferKind распознаёт x-references → reference kind (не opaque fallback) — round-trip preserved - kindOf включает 'reference' для diff: смена reference↔string ловится как breaking change Tests: +1 (76 total) — reference roundtrip build→parse. После CI rebuild создатели справочников делают FK без JSON tab. --- .../src/components/schema/PropertyEditor.tsx | 82 +++++++++++++++++++ .../src/components/schema/types.test.ts | 19 +++++ .../src/components/schema/types.ts | 25 ++++++ ordinis-admin-ui/src/i18n.ts | 14 ++++ 4 files changed, 140 insertions(+) diff --git a/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx b/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx index 93cde57..9d4d6f2 100644 --- a/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx +++ b/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx @@ -1,6 +1,9 @@ +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Checkbox, + FieldHint, + FieldLabel, IconButton, SingleSelect, TextInput, @@ -10,6 +13,7 @@ import { import { TrashIcon, ArrowUpIcon, ArrowDownIcon } from '@phosphor-icons/react' import type { PropertyDef, PropertyKind } from './types' import { PROPERTY_KINDS } from './types' +import { useDictionaries } from '@/api/queries' type Props = { prop: PropertyDef @@ -183,6 +187,13 @@ export const PropertyEditor = ({ /> )} + {prop.kind === 'reference' && ( + update({ referenceTarget: next })} + /> + )} + {prop.kind === 'array_string' && (
) } + +/** + * Picker для x-references = "dict.field". Левый select — список accessible + * dictionaries, правый input — имя поля в target dict (обычно 'code'). + * + * value/onChange работают с CSV формой "dict.field" чтобы родителю не пришлось + * разносить state на 2 поля. Парсинг split('.', 2) — backend regex такой же. + */ +type ReferenceTargetPickerProps = { + value: string | undefined + onChange: (next: string) => void +} + +const ReferenceTargetPicker = ({ value, onChange }: ReferenceTargetPickerProps) => { + const { t } = useTranslation() + const dictsQuery = useDictionaries() + const [refDict = '', refField = ''] = (value ?? '').split('.', 2) + + const dictOptions: SelectOption[] = useMemo(() => { + const list = dictsQuery.data ?? [] + return list + .map((d) => ({ + id: d.name, + label: d.displayName ? `${d.name} — ${d.displayName}` : d.name, + })) + .sort((a, b) => a.label.localeCompare(b.label, 'ru')) + }, [dictsQuery.data]) + + const handleDictChange = (next: string) => { + // При смене target dict сбрасываем field на 'code' — это default для + // 95% справочников (x-id-source: code). Admin может переписать. + onChange(next ? `${next}.${refField || 'code'}` : '') + } + + const handleFieldChange = (next: string) => { + onChange(refDict ? `${refDict}.${next}` : '') + } + + const isValid = Boolean(refDict && refField) + + return ( +
+
+ {t('schema.reference.title')} + {t('schema.reference.hint')} +
+
+ + handleFieldChange(e.target.value)} + hint={t('schema.reference.targetFieldHint')} + /> +
+ {!isValid && ( +
{t('schema.reference.required')}
+ )} +
+ ) +} diff --git a/ordinis-admin-ui/src/components/schema/types.test.ts b/ordinis-admin-ui/src/components/schema/types.test.ts index ee07459..b8a9108 100644 --- a/ordinis-admin-ui/src/components/schema/types.test.ts +++ b/ordinis-admin-ui/src/components/schema/types.test.ts @@ -136,6 +136,25 @@ describe('parseSchemaJson roundtrip', () => { expect(parsed.properties[0].unique).toBe(true) }) + test('reference kind roundtrip — emit x-references + parse обратно', () => { + const json = buildSchemaJson([ + prop({ + name: 'status', + kind: 'reference', + referenceTarget: 'spacecraft_status.code', + required: true, + }), + ]) + // build emit'ит x-references + expect((json.properties!.status as Record)['x-references']).toBe( + 'spacecraft_status.code', + ) + // parse → recovers reference kind, не fallback на opaque/string + const parsed = parseSchemaJson(json) + expect(parsed.properties[0].kind).toBe('reference') + expect(parsed.properties[0].referenceTarget).toBe('spacecraft_status.code') + }) + test('пустой schema → пустой результат', () => { expect(parseSchemaJson(undefined)).toEqual({ properties: [], idSource: undefined }) }) diff --git a/ordinis-admin-ui/src/components/schema/types.ts b/ordinis-admin-ui/src/components/schema/types.ts index ed010dc..c9592a1 100644 --- a/ordinis-admin-ui/src/components/schema/types.ts +++ b/ordinis-admin-ui/src/components/schema/types.ts @@ -6,6 +6,7 @@ export type PropertyKind = | 'number' | 'boolean' | 'enum' + | 'reference' // FK на запись другого справочника через x-references | 'localized' | 'date' | 'datetime' @@ -26,6 +27,10 @@ export type PropertyDef = { minimum?: number maximum?: number uniqueItems?: boolean + /** Для kind: 'reference' — "dict_name.field_name" (например "spacecraft_status.code"). + * Сериализуется как `x-references` в JSON Schema; ReferenceValidator на backend + * проверяет FK на CRUD. */ + referenceTarget?: string /** Для kind: 'opaque' — оригинальный JSON Schema fragment, передаётся * без изменений на save. Защита от lossy round-trip при nested objects. */ rawSchema?: JsonSchema @@ -37,6 +42,7 @@ export const PROPERTY_KINDS: PropertyKind[] = [ 'number', 'boolean', 'enum', + 'reference', 'localized', 'date', 'datetime', @@ -106,6 +112,17 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => { enum: p.enumValues ?? [], ...uniqueExt, } + case 'reference': + // x-references = "dict.field". Backend ReferenceValidator парсит и + // проверяет FK на CRUD. Если referenceTarget пуст — emit пустую + // string ссылку (validate ловит на save), но это invalid bundle — + // UI должна disable Save кнопку при empty referenceTarget. + return { + type: 'string', + description: desc, + ['x-references']: p.referenceTarget ?? '', + ...uniqueExt, + } as JsonSchema case 'localized': return { type: 'object', @@ -175,6 +192,11 @@ const inferKind = ( if (raw['x-localized']) return { ...base, kind: 'localized' } if (raw.enum) return { ...base, kind: 'enum', enumValues: raw.enum.map(String) } + // FK через x-references — приоритет над plain string fallback. После + // round-trip останется reference kind, не теряется breaking change detection. + if (typeof raw['x-references'] === 'string' && raw['x-references']) { + return { ...base, kind: 'reference', referenceTarget: raw['x-references'] } + } if (raw.type === 'string' && raw.format === 'date') return { ...base, kind: 'date' } if (raw.type === 'string' && raw.format === 'date-time') return { ...base, kind: 'datetime' } @@ -222,6 +244,9 @@ export type SchemaChange = { const kindOf = (s: JsonSchema): string => { if (s['x-localized']) return 'localized' if (s.enum) return 'enum' + // reference — отдельный kind для diff: смена FK target → breaking change, + // не должен путаться со swap'ом обычной string ↔ reference. + if (typeof s['x-references'] === 'string' && s['x-references']) return 'reference' if (s.type === 'string' && s.format === 'date') return 'date' if (s.type === 'string' && s.format === 'date-time') return 'datetime' return s.type ?? 'unknown' diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index c16811e..1101b2b 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -208,11 +208,18 @@ i18n 'schema.kind.number': 'Число', 'schema.kind.boolean': 'Логическое', 'schema.kind.enum': 'Enum (выбор из списка)', + 'schema.kind.reference': 'Ссылка на словарь (FK)', 'schema.kind.localized': 'I18N строка (ru-RU/en-US/...)', 'schema.kind.date': 'Дата', 'schema.kind.datetime': 'Дата+время', 'schema.kind.array_string': 'Массив строк', 'schema.kind.opaque': 'Сложный (JSON)', + 'schema.reference.title': 'FK на запись справочника', + 'schema.reference.hint': 'Значение поля будет проверяться при сохранении: оно должно совпадать с business-key или указанным полем существующей записи целевого словаря.', + 'schema.reference.targetDict': 'Целевой словарь', + 'schema.reference.targetField': 'Поле', + 'schema.reference.targetFieldHint': 'Обычно code или business_key', + 'schema.reference.required': 'Заполните оба: словарь и поле — иначе FK не сработает.', 'schema.opaque.title': 'Сложное поле — редактируется только через JSON-таб', 'schema.opaque.hint': 'Nested object / array of objects. Поля-табе нет редактора, raw schema показан ниже. Меняй через вкладку "JSON".', 'field.name': 'Наименование', @@ -450,6 +457,7 @@ i18n 'schema.kind.number': 'Number', 'schema.kind.boolean': 'Boolean', 'schema.kind.enum': 'Enum (pick from list)', + 'schema.kind.reference': 'Reference to dictionary (FK)', 'schema.kind.localized': 'I18N string (ru-RU/en-US/...)', 'schema.kind.date': 'Date', 'schema.kind.datetime': 'Date+time', @@ -457,6 +465,12 @@ i18n 'schema.kind.opaque': 'Complex (JSON)', 'schema.opaque.title': 'Complex field — edit only via JSON tab', 'schema.opaque.hint': 'Nested object / array of objects. No editor in Fields tab, raw schema shown below. Edit via JSON tab.', + 'schema.reference.title': 'FK to dictionary record', + 'schema.reference.hint': 'Value is validated on save: must match the business-key or specified field of an existing record in the target dictionary.', + 'schema.reference.targetDict': 'Target dictionary', + 'schema.reference.targetField': 'Field', + 'schema.reference.targetFieldHint': 'Usually code or business_key', + 'schema.reference.required': 'Fill both target dictionary and field — otherwise FK will not work.', 'field.name': 'Name', 'field.designator': 'Designator (COSPAR)', 'field.norad_id': 'NORAD ID',