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

This commit is contained in:
Александр Зимин
2026-06-04 15:24:55 +00:00
parent 48a1fd5f48
commit bfe5142bba
@@ -13,7 +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' import { useDictionaries, useDictionaryDetail } from '@/api/queries'
type Props = { type Props = {
prop: PropertyDef prop: PropertyDef
@@ -236,6 +236,13 @@ const ReferenceTargetPicker = ({ value, onChange }: ReferenceTargetPickerProps)
const { t } = useTranslation() const { t } = useTranslation()
const dictsQuery = useDictionaries() const dictsQuery = useDictionaries()
const [refDict = '', refField = ''] = (value ?? '').split('.', 2) 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 dictOptions: SelectOption[] = useMemo(() => {
const list = dictsQuery.data ?? [] const list = dictsQuery.data ?? []
@@ -247,10 +254,30 @@ const ReferenceTargetPicker = ({ value, onChange }: ReferenceTargetPickerProps)
.sort((a, b) => a.label.localeCompare(b.label, 'ru')) .sort((a, b) => a.label.localeCompare(b.label, 'ru'))
}, [dictsQuery.data]) }, [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) => { const handleDictChange = (next: string) => {
// При смене target dict сбрасываем field на 'code' — это default для // При смене target dict сбрасываем field на businessKey — universal
// 95% справочников (x-id-source: code). Admin может переписать. // identifier что всегда существует на любом dict. Юзер потом выберет
onChange(next ? `${next}.${refField || 'code'}` : '') // более специфичное поле (designator / code / etc) из загруженного
// списка fieldOptions.
onChange(next ? `${next}.businessKey` : '')
} }
const handleFieldChange = (next: string) => { const handleFieldChange = (next: string) => {
@@ -258,6 +285,11 @@ const ReferenceTargetPicker = ({ value, onChange }: ReferenceTargetPickerProps)
} }
const isValid = Boolean(refDict && refField) 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 ( return (
<div className="border border-accent/20 bg-accent/4 rounded-md p-3 space-y-3"> <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 ? '…' : '—'} placeholder={dictsQuery.isLoading ? '…' : '—'}
disabled={dictsQuery.isLoading} disabled={dictsQuery.isLoading}
/> />
<TextInput <SingleSelect
label={t('schema.reference.targetField')} label={t('schema.reference.targetField')}
required required
placeholder="code" options={fieldOptions}
value={refField} value={refField}
onChange={(e) => handleFieldChange(e.target.value)} onChange={handleFieldChange}
hint={t('schema.reference.targetFieldHint')} placeholder={targetDetail.isLoading ? '…' : '—'}
disabled={!refDict || targetDetail.isLoading}
/> />
</div> </div>
{!isValid && ( {!isValid && (
<div className="text-cell text-mars">{t('schema.reference.required')}</div> <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> </div>
) )
} }