diff --git a/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx b/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx index c8c2187..a3bef13 100644 --- a/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx +++ b/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx @@ -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 (