Merge branch 'fix/schema-fk-target-field-select' into 'main'

fix(schema): FK target field — select из реальной схемы вместо free-form

See merge request 2-6/2-6-4/terravault/ordinis!291
This commit is contained in:
Александр Зимин
2026-06-04 15:24:55 +00:00
@@ -13,7 +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'
import { useDictionaries, useDictionaryDetail } from '@/api/queries'
type Props = {
prop: PropertyDef
@@ -236,6 +236,13 @@ const ReferenceTargetPicker = ({ value, onChange }: ReferenceTargetPickerProps)
const { t } = useTranslation()
const dictsQuery = useDictionaries()
const [refDict = '', refField = ''] = (value ?? '').split('.', 2)
// Fetch schema target dict чтобы показать реальные scalar поля как
// options. Без этого юзер вписывал free-form имя (e.g. 'code') и
// submit'ил schema с x-references на несуществующее поле → backend
// при сохранении record искал `data.code` в target → 422
// x_references_target_not_found. Реальный кейс: gs_sat_test03.sat =
// spacecraft.code, но в spacecraft нет поля code, есть designator.
const targetDetail = useDictionaryDetail(refDict || undefined)
const dictOptions: SelectOption[] = useMemo(() => {
const list = dictsQuery.data ?? []
@@ -247,10 +254,30 @@ const ReferenceTargetPicker = ({ value, onChange }: ReferenceTargetPickerProps)
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
}, [dictsQuery.data])
// Eligible target fields: scalar non-localized, non-array/object.
// FK имеет смысл только на простые ключевые поля (designator,
// businessKey, code etc.). x-localized object и array не подходят
// как identifier. businessKey включён как synthetic option — это
// всегда unique identifier любого record, backend resolves его.
const fieldOptions: SelectOption[] = useMemo(() => {
const props = targetDetail.data?.schemaJson?.properties
if (!props) return [{ id: 'businessKey', label: 'businessKey' }]
const eligible: SelectOption[] = [{ id: 'businessKey', label: 'businessKey' }]
for (const [key, prop] of Object.entries(props)) {
if (Boolean(prop['x-localized'])) continue
const type = prop.type
if (type === 'array' || type === 'object') continue
eligible.push({ id: key, label: key })
}
return eligible
}, [targetDetail.data?.schemaJson])
const handleDictChange = (next: string) => {
// При смене target dict сбрасываем field на 'code' — это default для
// 95% справочников (x-id-source: code). Admin может переписать.
onChange(next ? `${next}.${refField || 'code'}` : '')
// При смене target dict сбрасываем field на businessKey — universal
// identifier что всегда существует на любом dict. Юзер потом выберет
// более специфичное поле (designator / code / etc) из загруженного
// списка fieldOptions.
onChange(next ? `${next}.businessKey` : '')
}
const handleFieldChange = (next: string) => {
@@ -258,6 +285,11 @@ const ReferenceTargetPicker = ({ value, onChange }: ReferenceTargetPickerProps)
}
const isValid = Boolean(refDict && refField)
// Warn если выбранный field не существует в target schema (legacy
// schemas с broken references, до этого fix'а можно было сохранить
// такое значение). target dict ещё loading → не warn.
const fieldExistsInTarget =
!targetDetail.data || fieldOptions.some((opt) => opt.id === refField)
return (
<div className="border border-accent/20 bg-accent/4 rounded-md p-3 space-y-3">
@@ -275,18 +307,29 @@ const ReferenceTargetPicker = ({ value, onChange }: ReferenceTargetPickerProps)
placeholder={dictsQuery.isLoading ? '…' : '—'}
disabled={dictsQuery.isLoading}
/>
<TextInput
<SingleSelect
label={t('schema.reference.targetField')}
required
placeholder="code"
options={fieldOptions}
value={refField}
onChange={(e) => handleFieldChange(e.target.value)}
hint={t('schema.reference.targetFieldHint')}
onChange={handleFieldChange}
placeholder={targetDetail.isLoading ? '…' : '—'}
disabled={!refDict || targetDetail.isLoading}
/>
</div>
{!isValid && (
<div className="text-cell text-mars">{t('schema.reference.required')}</div>
)}
{isValid && !fieldExistsInTarget && (
<div className="text-cell text-mars">
{t('schema.reference.targetFieldMissing', {
defaultValue:
'Поле «{{field}}» отсутствует в схеме «{{dict}}». Выберите существующее поле — иначе записи ссылающиеся на этот FK не пройдут валидацию.',
field: refField,
dict: refDict,
})}
</div>
)}
</div>
)
}