a2d5df2430
Раньше для добавления 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.
293 lines
9.2 KiB
TypeScript
293 lines
9.2 KiB
TypeScript
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>
|
||
)
|
||
}
|