feat(schema): FK тип в schema-builder — picker для x-references

Раньше для добавления 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.
This commit is contained in:
Zimin A.N.
2026-05-06 20:49:39 +03:00
parent 4ae6dc3863
commit a2d5df2430
4 changed files with 140 additions and 0 deletions
@@ -1,6 +1,9 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
Checkbox, Checkbox,
FieldHint,
FieldLabel,
IconButton, IconButton,
SingleSelect, SingleSelect,
TextInput, TextInput,
@@ -10,6 +13,7 @@ import {
import { TrashIcon, ArrowUpIcon, ArrowDownIcon } from '@phosphor-icons/react' import { TrashIcon, ArrowUpIcon, ArrowDownIcon } from '@phosphor-icons/react'
import type { PropertyDef, PropertyKind } from './types' import type { PropertyDef, PropertyKind } from './types'
import { PROPERTY_KINDS } from './types' import { PROPERTY_KINDS } from './types'
import { useDictionaries } from '@/api/queries'
type Props = { type Props = {
prop: PropertyDef prop: PropertyDef
@@ -183,6 +187,13 @@ export const PropertyEditor = ({
/> />
)} )}
{prop.kind === 'reference' && (
<ReferenceTargetPicker
value={prop.referenceTarget}
onChange={(next) => update({ referenceTarget: next })}
/>
)}
{prop.kind === 'array_string' && ( {prop.kind === 'array_string' && (
<div className="space-y-3"> <div className="space-y-3">
<TextInput <TextInput
@@ -208,3 +219,74 @@ export const PropertyEditor = ({
</div> </div>
) )
} }
/**
* 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 (
<div className="border border-ultramarain/20 bg-ultramarain/4 rounded-md p-3 space-y-3">
<div>
<FieldLabel required>{t('schema.reference.title')}</FieldLabel>
<FieldHint>{t('schema.reference.hint')}</FieldHint>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<SingleSelect
label={t('schema.reference.targetDict')}
required
options={dictOptions}
value={refDict}
onChange={handleDictChange}
placeholder={dictsQuery.isLoading ? '…' : '—'}
disabled={dictsQuery.isLoading}
/>
<TextInput
label={t('schema.reference.targetField')}
required
placeholder="code"
value={refField}
onChange={(e) => handleFieldChange(e.target.value)}
hint={t('schema.reference.targetFieldHint')}
/>
</div>
{!isValid && (
<div className="text-2xs text-mars">{t('schema.reference.required')}</div>
)}
</div>
)
}
@@ -136,6 +136,25 @@ describe('parseSchemaJson roundtrip', () => {
expect(parsed.properties[0].unique).toBe(true) 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<string, unknown>)['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 → пустой результат', () => { test('пустой schema → пустой результат', () => {
expect(parseSchemaJson(undefined)).toEqual({ properties: [], idSource: undefined }) expect(parseSchemaJson(undefined)).toEqual({ properties: [], idSource: undefined })
}) })
@@ -6,6 +6,7 @@ export type PropertyKind =
| 'number' | 'number'
| 'boolean' | 'boolean'
| 'enum' | 'enum'
| 'reference' // FK на запись другого справочника через x-references
| 'localized' | 'localized'
| 'date' | 'date'
| 'datetime' | 'datetime'
@@ -26,6 +27,10 @@ export type PropertyDef = {
minimum?: number minimum?: number
maximum?: number maximum?: number
uniqueItems?: boolean 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, передаётся /** Для kind: 'opaque' — оригинальный JSON Schema fragment, передаётся
* без изменений на save. Защита от lossy round-trip при nested objects. */ * без изменений на save. Защита от lossy round-trip при nested objects. */
rawSchema?: JsonSchema rawSchema?: JsonSchema
@@ -37,6 +42,7 @@ export const PROPERTY_KINDS: PropertyKind[] = [
'number', 'number',
'boolean', 'boolean',
'enum', 'enum',
'reference',
'localized', 'localized',
'date', 'date',
'datetime', 'datetime',
@@ -106,6 +112,17 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => {
enum: p.enumValues ?? [], enum: p.enumValues ?? [],
...uniqueExt, ...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': case 'localized':
return { return {
type: 'object', type: 'object',
@@ -175,6 +192,11 @@ const inferKind = (
if (raw['x-localized']) return { ...base, kind: 'localized' } if (raw['x-localized']) return { ...base, kind: 'localized' }
if (raw.enum) return { ...base, kind: 'enum', enumValues: raw.enum.map(String) } 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') return { ...base, kind: 'date' }
if (raw.type === 'string' && raw.format === 'date-time') return { ...base, kind: 'datetime' } if (raw.type === 'string' && raw.format === 'date-time') return { ...base, kind: 'datetime' }
@@ -222,6 +244,9 @@ export type SchemaChange = {
const kindOf = (s: JsonSchema): string => { const kindOf = (s: JsonSchema): string => {
if (s['x-localized']) return 'localized' if (s['x-localized']) return 'localized'
if (s.enum) return 'enum' 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') return 'date'
if (s.type === 'string' && s.format === 'date-time') return 'datetime' if (s.type === 'string' && s.format === 'date-time') return 'datetime'
return s.type ?? 'unknown' return s.type ?? 'unknown'
+14
View File
@@ -208,11 +208,18 @@ i18n
'schema.kind.number': 'Число', 'schema.kind.number': 'Число',
'schema.kind.boolean': 'Логическое', 'schema.kind.boolean': 'Логическое',
'schema.kind.enum': 'Enum (выбор из списка)', 'schema.kind.enum': 'Enum (выбор из списка)',
'schema.kind.reference': 'Ссылка на словарь (FK)',
'schema.kind.localized': 'I18N строка (ru-RU/en-US/...)', 'schema.kind.localized': 'I18N строка (ru-RU/en-US/...)',
'schema.kind.date': 'Дата', 'schema.kind.date': 'Дата',
'schema.kind.datetime': 'Дата+время', 'schema.kind.datetime': 'Дата+время',
'schema.kind.array_string': 'Массив строк', 'schema.kind.array_string': 'Массив строк',
'schema.kind.opaque': 'Сложный (JSON)', '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.title': 'Сложное поле — редактируется только через JSON-таб',
'schema.opaque.hint': 'Nested object / array of objects. Поля-табе нет редактора, raw schema показан ниже. Меняй через вкладку "JSON".', 'schema.opaque.hint': 'Nested object / array of objects. Поля-табе нет редактора, raw schema показан ниже. Меняй через вкладку "JSON".',
'field.name': 'Наименование', 'field.name': 'Наименование',
@@ -450,6 +457,7 @@ i18n
'schema.kind.number': 'Number', 'schema.kind.number': 'Number',
'schema.kind.boolean': 'Boolean', 'schema.kind.boolean': 'Boolean',
'schema.kind.enum': 'Enum (pick from list)', '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.localized': 'I18N string (ru-RU/en-US/...)',
'schema.kind.date': 'Date', 'schema.kind.date': 'Date',
'schema.kind.datetime': 'Date+time', 'schema.kind.datetime': 'Date+time',
@@ -457,6 +465,12 @@ i18n
'schema.kind.opaque': 'Complex (JSON)', 'schema.kind.opaque': 'Complex (JSON)',
'schema.opaque.title': 'Complex field — edit only via JSON tab', '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.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.name': 'Name',
'field.designator': 'Designator (COSPAR)', 'field.designator': 'Designator (COSPAR)',
'field.norad_id': 'NORAD ID', 'field.norad_id': 'NORAD ID',