feat(admin-ui): visual schema builder для создания/редактирования справочников
Новый функционал — заводить свои справочники через UI без code deploy:
API:
- CreateDictionaryRequest type в client.ts
- useCreateDictionary / useUpdateDictionary mutations с invalidate
['dictionaries'] и ['dictionary', name]
Компоненты (src/components/schema/):
- types.ts: PropertyDef + buildSchemaJson() (Draft-07) + parseSchemaJson()
для двусторонней конвертации между UI state и schema_json
- PropertyEditor: name + kind select + required + description + type-specific
опции (minLength/maxLength/pattern для string; minimum/maximum для number;
enum values; uniqueItems для array). Up/Down/Trash icon buttons
- SchemaBuilder: список property cards + кнопка «добавить» + EmptyState
- DictionaryEditorDialog: модалка с 3 табами:
* Метаданные (name/displayName/description/scope/bundle/version/locales)
* Поля (SchemaBuilder)
* JSON (readonly preview сгенерированной schema_json)
Поддерживаемые типы полей: string, integer, number, boolean, enum,
localized (i18n object с patternProperties), date (format=date),
datetime (format=date-time), array_string (с опц. enum + uniqueItems).
Wiring:
- /dictionaries: PageHeader actions = «+ Создать справочник» → Dialog в режиме create
→ onSuccess navigate на /dictionaries/{name}
- /dictionaries/$name: PageHeader actions расширен «Схема» (secondary) +
«Создать запись» (primary). Dialog в edit-режиме prefilled из
detailQuery.data + parseSchemaJson()
i18n: 30+ новых ключей в schema.* / scope.* (RU/EN)
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Checkbox,
|
||||
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'
|
||||
|
||||
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()
|
||||
|
||||
const kindOptions: SelectOption[] = PROPERTY_KINDS.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>
|
||||
|
||||
<Checkbox
|
||||
label={t('schema.required')}
|
||||
checked={prop.required}
|
||||
onChange={(e) => update({ required: e.target.checked })}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label={t('schema.description')}
|
||||
rows={2}
|
||||
value={prop.description ?? ''}
|
||||
onChange={(e) => update({ description: e.target.value })}
|
||||
/>
|
||||
|
||||
{(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 === '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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user