From bfe5142bbae19f2d537eb2f2c62e5c29e666d173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Thu, 4 Jun 2026 15:24:55 +0000 Subject: [PATCH] =?UTF-8?q?fix(schema):=20FK=20target=20field=20=E2=80=94?= =?UTF-8?q?=20select=20=D0=B8=D0=B7=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=81=D1=85=D0=B5=D0=BC=D1=8B=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20free-form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/schema/PropertyEditor.tsx | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) 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 (
@@ -275,18 +307,29 @@ const ReferenceTargetPicker = ({ value, onChange }: ReferenceTargetPickerProps) placeholder={dictsQuery.isLoading ? '…' : '—'} disabled={dictsQuery.isLoading} /> - handleFieldChange(e.target.value)} - hint={t('schema.reference.targetFieldHint')} + onChange={handleFieldChange} + placeholder={targetDetail.isLoading ? '…' : '—'} + disabled={!refDict || targetDetail.isLoading} />
{!isValid && (
{t('schema.reference.required')}
)} + {isValid && !fieldExistsInTarget && ( +
+ {t('schema.reference.targetFieldMissing', { + defaultValue: + 'Поле «{{field}}» отсутствует в схеме «{{dict}}». Выберите существующее поле — иначе записи ссылающиеся на этот FK не пройдут валидацию.', + field: refField, + dict: refDict, + })} +
+ )} ) }