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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,6 +136,25 @@ describe('parseSchemaJson roundtrip', () => {
|
||||
expect(parsed.properties[0].unique).toBe(true)
|
||||
})
|
||||
|
||||
test('reference kind roundtrip — emit x-references + parse обратно', () => {
|
||||
const json = buildSchemaJson([
|
||||
prop({
|
||||
name: 'status',
|
||||
kind: 'reference',
|
||||
referenceTarget: 'spacecraft_status.code',
|
||||
required: true,
|
||||
}),
|
||||
])
|
||||
// build emit'ит x-references
|
||||
expect((json.properties!.status as Record<string, unknown>)['x-references']).toBe(
|
||||
'spacecraft_status.code',
|
||||
)
|
||||
// parse → recovers reference kind, не fallback на opaque/string
|
||||
const parsed = parseSchemaJson(json)
|
||||
expect(parsed.properties[0].kind).toBe('reference')
|
||||
expect(parsed.properties[0].referenceTarget).toBe('spacecraft_status.code')
|
||||
})
|
||||
|
||||
test('пустой schema → пустой результат', () => {
|
||||
expect(parseSchemaJson(undefined)).toEqual({ properties: [], idSource: undefined })
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ export type PropertyKind =
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'enum'
|
||||
| 'reference' // FK на запись другого справочника через x-references
|
||||
| 'localized'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
@@ -26,6 +27,10 @@ export type PropertyDef = {
|
||||
minimum?: number
|
||||
maximum?: number
|
||||
uniqueItems?: boolean
|
||||
/** Для kind: 'reference' — "dict_name.field_name" (например "spacecraft_status.code").
|
||||
* Сериализуется как `x-references` в JSON Schema; ReferenceValidator на backend
|
||||
* проверяет FK на CRUD. */
|
||||
referenceTarget?: string
|
||||
/** Для kind: 'opaque' — оригинальный JSON Schema fragment, передаётся
|
||||
* без изменений на save. Защита от lossy round-trip при nested objects. */
|
||||
rawSchema?: JsonSchema
|
||||
@@ -37,6 +42,7 @@ export const PROPERTY_KINDS: PropertyKind[] = [
|
||||
'number',
|
||||
'boolean',
|
||||
'enum',
|
||||
'reference',
|
||||
'localized',
|
||||
'date',
|
||||
'datetime',
|
||||
@@ -106,6 +112,17 @@ const propertyToSchema = (p: PropertyDef): JsonSchema => {
|
||||
enum: p.enumValues ?? [],
|
||||
...uniqueExt,
|
||||
}
|
||||
case 'reference':
|
||||
// x-references = "dict.field". Backend ReferenceValidator парсит и
|
||||
// проверяет FK на CRUD. Если referenceTarget пуст — emit пустую
|
||||
// string ссылку (validate ловит на save), но это invalid bundle —
|
||||
// UI должна disable Save кнопку при empty referenceTarget.
|
||||
return {
|
||||
type: 'string',
|
||||
description: desc,
|
||||
['x-references']: p.referenceTarget ?? '',
|
||||
...uniqueExt,
|
||||
} as JsonSchema
|
||||
case 'localized':
|
||||
return {
|
||||
type: 'object',
|
||||
@@ -175,6 +192,11 @@ const inferKind = (
|
||||
|
||||
if (raw['x-localized']) return { ...base, kind: 'localized' }
|
||||
if (raw.enum) return { ...base, kind: 'enum', enumValues: raw.enum.map(String) }
|
||||
// FK через x-references — приоритет над plain string fallback. После
|
||||
// round-trip останется reference kind, не теряется breaking change detection.
|
||||
if (typeof raw['x-references'] === 'string' && raw['x-references']) {
|
||||
return { ...base, kind: 'reference', referenceTarget: raw['x-references'] }
|
||||
}
|
||||
if (raw.type === 'string' && raw.format === 'date') return { ...base, kind: 'date' }
|
||||
if (raw.type === 'string' && raw.format === 'date-time') return { ...base, kind: 'datetime' }
|
||||
|
||||
@@ -222,6 +244,9 @@ export type SchemaChange = {
|
||||
const kindOf = (s: JsonSchema): string => {
|
||||
if (s['x-localized']) return 'localized'
|
||||
if (s.enum) return 'enum'
|
||||
// reference — отдельный kind для diff: смена FK target → breaking change,
|
||||
// не должен путаться со swap'ом обычной string ↔ reference.
|
||||
if (typeof s['x-references'] === 'string' && s['x-references']) return 'reference'
|
||||
if (s.type === 'string' && s.format === 'date') return 'date'
|
||||
if (s.type === 'string' && s.format === 'date-time') return 'datetime'
|
||||
return s.type ?? 'unknown'
|
||||
|
||||
Reference in New Issue
Block a user