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 {
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' && (
<ReferenceTargetPicker
value={prop.referenceTarget}
onChange={(next) => update({ referenceTarget: next })}
/>
)}
{prop.kind === 'array_string' && (
<div className="space-y-3">
<TextInput
@@ -208,3 +219,74 @@ export const PropertyEditor = ({
</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>
)
}