336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|