Files
mdm-ordinis/ordinis-admin-ui/src/components/schema/PropertyEditor.tsx
T

336 lines
12 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 '@/ui'
import { TrashIcon, ArrowUpIcon, ArrowDownIcon } from '@phosphor-icons/react'
import type { PropertyDef, PropertyKind } from './types'
import { PROPERTY_KINDS } from './types'
import { useDictionaries, useDictionaryDetail } 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-line rounded-lg p-3 space-y-3 bg-surface">
<div className="flex items-start justify-between gap-2">
<span className="text-cap text-mute">
{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-cell space-y-2">
<div className="font-medium text-ink">
{t('schema.opaque.title')}
</div>
<div className="text-ink-2">
{t('schema.opaque.hint')}
</div>
<pre className="text-mono bg-surface border border-line 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)
// 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 ?? []
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])
// 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 на businessKey — universal
// identifier что всегда существует на любом dict. Юзер потом выберет
// более специфичное поле (designator / code / etc) из загруженного
// списка fieldOptions.
onChange(next ? `${next}.businessKey` : '')
}
const handleFieldChange = (next: string) => {
onChange(refDict ? `${refDict}.${next}` : '')
}
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 (
<div className="border border-accent/20 bg-accent/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}
/>
<SingleSelect
label={t('schema.reference.targetField')}
required
options={fieldOptions}
value={refField}
onChange={handleFieldChange}
placeholder={targetDetail.isLoading ? '…' : '—'}
disabled={!refDict || targetDetail.isLoading}
/>
</div>
{!isValid && (
<div className="text-cell text-mars">{t('schema.reference.required')}</div>
)}
{isValid && !fieldExistsInTarget && (
<div className="text-cell text-mars">
{t('schema.reference.targetFieldMissing', {
defaultValue:
'Поле «{{field}}» отсутствует в схеме «{{dict}}». Выберите существующее поле — иначе записи ссылающиеся на этот FK не пройдут валидацию.',
field: refField,
dict: refDict,
})}
</div>
)}
</div>
)
}