Files
mdm-ordinis/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx
T
Zimin A.N. a2d5df2430 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.
2026-05-06 20:49:39 +03:00

293 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
Checkbox,
FieldHint,
FieldLabel,
IconButton,
SingleSelect,
TextInput,
TextArea,
type SelectOption,
} from '@nstart/ui'
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
index: number
total: number
onChange: (next: PropertyDef) => void
onRemove: () => void
onMoveUp: () => void
onMoveDown: () => void
}
export const PropertyEditor = ({
prop,
index,
total,
onChange,
onRemove,
onMoveUp,
onMoveDown,
}: Props) => {
const { t } = useTranslation()
// 'opaque' исключён из dropdown — admin не может вручную выбрать сложный
// тип. Поле получает opaque только через parseSchemaJson (когда уже было
// в исходной schema). Для NEW поля admin использует обычные kinds.
const kindOptions: SelectOption[] = PROPERTY_KINDS
.filter((k) => k !== 'opaque')
.map((k) => ({
id: k,
label: t(`schema.kind.${k}`),
}))
const update = (patch: Partial<PropertyDef>) => onChange({ ...prop, ...patch })
return (
<div className="border border-regolith rounded-lg p-3 space-y-3 bg-white">
<div className="flex items-start justify-between gap-2">
<span className="text-2xs uppercase tracking-label text-carbon/60">
{t('schema.property')} #{index + 1}
</span>
<div className="flex items-center gap-1">
<IconButton
label={t('schema.moveUp')}
variant="default"
disabled={index === 0}
icon={<ArrowUpIcon />}
onClick={onMoveUp}
/>
<IconButton
label={t('schema.moveDown')}
variant="default"
disabled={index === total - 1}
icon={<ArrowDownIcon />}
onClick={onMoveDown}
/>
<IconButton
label={t('schema.delete')}
variant="danger"
icon={<TrashIcon />}
onClick={onRemove}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<TextInput
label={t('schema.name')}
required
placeholder="snake_case_key"
pattern="^[a-z][a-z0-9_]*$"
value={prop.name}
onChange={(e) => update({ name: e.target.value })}
/>
<SingleSelect
label={t('schema.kind.label')}
required
options={kindOptions}
value={prop.kind}
onChange={(id) => update({ kind: id as PropertyKind })}
/>
</div>
<div className="flex flex-wrap gap-4">
<Checkbox
label={t('schema.required')}
checked={prop.required}
onChange={(e) => update({ required: e.target.checked })}
/>
<Checkbox
label={t('schema.unique')}
description={t('schema.uniqueHint')}
checked={prop.unique}
onChange={(e) => update({ unique: e.target.checked })}
/>
</div>
<TextArea
label={t('schema.description')}
rows={2}
value={prop.description ?? ''}
onChange={(e) => update({ description: e.target.value })}
/>
{prop.kind === 'opaque' && (
<div className="bg-amber-50 border border-amber-200 rounded-md p-3 text-2xs space-y-2">
<div className="font-medium text-carbon">
{t('schema.opaque.title')}
</div>
<div className="text-carbon/70">
{t('schema.opaque.hint')}
</div>
<pre className="font-mono text-2xs bg-white border border-regolith rounded p-2 overflow-auto max-h-40">
{JSON.stringify(prop.rawSchema ?? {}, null, 2)}
</pre>
</div>
)}
{(prop.kind === 'string') && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<TextInput
type="number"
label="minLength"
value={prop.minLength ?? ''}
onChange={(e) => update({ minLength: e.target.value === '' ? undefined : Number(e.target.value) })}
/>
<TextInput
type="number"
label="maxLength"
value={prop.maxLength ?? ''}
onChange={(e) => update({ maxLength: e.target.value === '' ? undefined : Number(e.target.value) })}
/>
<TextInput
label={t('schema.pattern')}
placeholder="^[A-Z]{2}$"
value={prop.pattern ?? ''}
onChange={(e) => update({ pattern: e.target.value || undefined })}
/>
</div>
)}
{(prop.kind === 'integer' || prop.kind === 'number') && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<TextInput
type="number"
label="minimum"
value={prop.minimum ?? ''}
onChange={(e) => update({ minimum: e.target.value === '' ? undefined : Number(e.target.value) })}
/>
<TextInput
type="number"
label="maximum"
value={prop.maximum ?? ''}
onChange={(e) => update({ maximum: e.target.value === '' ? undefined : Number(e.target.value) })}
/>
</div>
)}
{prop.kind === 'enum' && (
<TextInput
label={t('schema.enumValues')}
hint={t('schema.enumValuesHint')}
value={(prop.enumValues ?? []).join(', ')}
onChange={(e) =>
update({
enumValues: e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
)}
{prop.kind === 'reference' && (
<ReferenceTargetPicker
value={prop.referenceTarget}
onChange={(next) => update({ referenceTarget: next })}
/>
)}
{prop.kind === 'array_string' && (
<div className="space-y-3">
<TextInput
label={t('schema.arrayEnumOptional')}
hint={t('schema.enumValuesHint')}
value={(prop.enumValues ?? []).join(', ')}
onChange={(e) =>
update({
enumValues: e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
<Checkbox
label="uniqueItems"
checked={Boolean(prop.uniqueItems)}
onChange={(e) => update({ uniqueItems: e.target.checked })}
/>
</div>
)}
</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>
)
}