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:
Zimin A.N.
2026-05-04 03:12:56 +03:00
parent ef31499bd9
commit 987559a6ab
9 changed files with 860 additions and 13 deletions
@@ -0,0 +1,224 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import {
Alert,
Button,
FormActions,
Modal,
MultiSelect,
SingleSelect,
Tabs,
TextArea,
TextInput,
type SelectOption,
type TabItem,
} from '@nstart/ui'
import { useCreateDictionary, useUpdateDictionary } from '@/api/mutations'
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
import { SchemaBuilder } from './SchemaBuilder'
import { buildSchemaJson, parseSchemaJson, type PropertyDef } from './types'
type Mode =
| { kind: 'create' }
| { kind: 'edit'; existing: DictionaryDetail }
type Props = {
open: boolean
mode: Mode
onClose: () => void
onSuccess: (name: string) => void
}
const DEFAULT_LOCALES = ['ru-RU', 'en-US', 'zh-CN']
const SCOPE_OPTIONS = (t: (k: string) => string): SelectOption[] => [
{ id: 'PUBLIC', label: t('scope.PUBLIC') },
{ id: 'INTERNAL', label: t('scope.INTERNAL') },
{ id: 'RESTRICTED', label: t('scope.RESTRICTED') },
]
type EditorTab = 'metadata' | 'schema' | 'preview'
export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props) => {
const { t } = useTranslation()
const createMut = useCreateDictionary()
const updateMut = useUpdateDictionary()
const isEdit = mode.kind === 'edit'
const initial = mode.kind === 'edit' ? mode.existing : null
const [activeTab, setActiveTab] = useState<EditorTab>('metadata')
const [name, setName] = useState(initial?.name ?? '')
const [displayName, setDisplayName] = useState(initial?.displayName ?? '')
const [description, setDescription] = useState(initial?.description ?? '')
const [scope, setScope] = useState<DataScope>(initial?.scope ?? 'PUBLIC')
const [bundle, setBundle] = useState(initial?.bundle ?? 'cuod')
const [schemaVersion, setSchemaVersion] = useState(initial?.schemaVersion ?? '1.0.0')
const [supportedLocales, setSupportedLocales] = useState<string[]>(
initial?.supportedLocales ?? ['ru-RU'],
)
const [defaultLocale, setDefaultLocale] = useState(initial?.defaultLocale ?? 'ru-RU')
const [properties, setProperties] = useState<PropertyDef[]>(
() => parseSchemaJson(initial?.schemaJson),
)
const [nameError, setNameError] = useState<string | null>(null)
const schemaJson = useMemo(() => buildSchemaJson(properties), [properties])
const activeMutation = isEdit ? updateMut : createMut
const serverError = serverErrorMessage(activeMutation.error)
const localeOptions: SelectOption[] = DEFAULT_LOCALES.map((l) => ({ id: l, label: l }))
const defaultLocaleOptions: SelectOption[] = supportedLocales.map((l) => ({ id: l, label: l }))
const handleSubmit = () => {
if (!isEdit && !/^[a-z][a-z0-9_]{1,127}$/.test(name)) {
setNameError(t('schema.nameInvalid'))
setActiveTab('metadata')
return
}
setNameError(null)
const payload: CreateDictionaryRequest = {
name,
displayName: displayName || undefined,
description: description || undefined,
scope,
schemaJson,
schemaVersion: schemaVersion || undefined,
bundle: bundle || undefined,
supportedLocales: supportedLocales.length > 0 ? supportedLocales : undefined,
defaultLocale: defaultLocale || undefined,
}
if (isEdit) {
updateMut.mutate(
{ name: initial!.name, payload },
{ onSuccess: () => onSuccess(initial!.name) },
)
} else {
createMut.mutate(payload, { onSuccess: (resp) => onSuccess(resp.name) })
}
}
const tabs: TabItem[] = [
{ id: 'metadata', label: t('schema.tabs.metadata') },
{ id: 'schema', label: t('schema.tabs.schema') },
{ id: 'preview', label: t('schema.tabs.preview') },
]
return (
<Modal
isOpen={open}
onClose={onClose}
title={isEdit ? `${t('schema.action.edit')}: ${initial?.name}` : t('schema.action.create')}
maxWidth="max-w-5xl"
panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col"
bodyClassName="overflow-y-auto flex-1"
>
<div className="space-y-4">
<Tabs items={tabs} value={activeTab} onValueChange={(id) => setActiveTab(id as EditorTab)} />
<div className={activeTab === 'metadata' ? 'block' : 'hidden'}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<TextInput
label={t('schema.name')}
required
disabled={isEdit}
placeholder="my_dictionary"
hint={t('schema.nameHint')}
error={nameError ?? undefined}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<TextInput
label={t('schema.displayName')}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<div className="sm:col-span-2">
<TextArea
label={t('schema.description')}
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<SingleSelect
label={t('schema.scope')}
required
options={SCOPE_OPTIONS(t)}
value={scope}
onChange={(id) => setScope(id as DataScope)}
/>
<TextInput
label={t('schema.bundle')}
hint={t('schema.bundleHint')}
value={bundle}
onChange={(e) => setBundle(e.target.value)}
/>
<TextInput
label={t('schema.version')}
placeholder="1.0.0"
value={schemaVersion}
onChange={(e) => setSchemaVersion(e.target.value)}
/>
<MultiSelect
label={t('schema.supportedLocales')}
options={localeOptions}
value={supportedLocales}
onChange={(ids) => {
setSupportedLocales(ids)
if (!ids.includes(defaultLocale) && ids.length > 0) {
setDefaultLocale(ids[0])
}
}}
/>
<SingleSelect
label={t('schema.defaultLocale')}
options={defaultLocaleOptions}
value={defaultLocale}
onChange={setDefaultLocale}
/>
</div>
</div>
<div className={activeTab === 'schema' ? 'block' : 'hidden'}>
<SchemaBuilder properties={properties} onChange={setProperties} />
</div>
<div className={activeTab === 'preview' ? 'block' : 'hidden'}>
<pre className="bg-regolith/30 rounded p-3 text-2xs font-mono overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(schemaJson, null, 2)}
</pre>
</div>
{serverError && <Alert variant="error">{serverError}</Alert>}
<FormActions align="end">
<Button type="button" variant="ghost" onClick={onClose} disabled={activeMutation.isPending}>
{t('form.cancel')}
</Button>
<Button
type="button"
variant="primary"
loading={activeMutation.isPending}
onClick={handleSubmit}
>
{activeMutation.isPending ? t('form.saving') : t('form.save')}
</Button>
</FormActions>
</div>
</Modal>
)
}
const serverErrorMessage = (err: unknown): string | null => {
if (!err) return null
if (axios.isAxiosError(err)) {
const data = err.response?.data as { message?: string; error?: string } | undefined
return data?.message ?? data?.error ?? err.message
}
return err instanceof Error ? err.message : 'Unknown error'
}
@@ -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>
)
}
@@ -0,0 +1,63 @@
import { useTranslation } from 'react-i18next'
import { Button, EmptyState } from '@nstart/ui'
import { PlusIcon } from '@phosphor-icons/react'
import { PropertyEditor } from './PropertyEditor'
import { newPropertyDef, type PropertyDef } from './types'
type Props = {
properties: PropertyDef[]
onChange: (next: PropertyDef[]) => void
}
export const SchemaBuilder = ({ properties, onChange }: Props) => {
const { t } = useTranslation()
const updateAt = (index: number, next: PropertyDef) => {
const copy = properties.slice()
copy[index] = next
onChange(copy)
}
const removeAt = (index: number) => {
onChange(properties.filter((_, i) => i !== index))
}
const moveBy = (index: number, delta: number) => {
const target = index + delta
if (target < 0 || target >= properties.length) return
const copy = properties.slice()
const [item] = copy.splice(index, 1)
copy.splice(target, 0, item)
onChange(copy)
}
const add = () => onChange([...properties, newPropertyDef()])
return (
<div className="space-y-3">
{properties.length === 0 && (
<EmptyState title={t('schema.empty')} />
)}
{properties.map((p, i) => (
<PropertyEditor
key={p.id}
prop={p}
index={i}
total={properties.length}
onChange={(next) => updateAt(i, next)}
onRemove={() => removeAt(i)}
onMoveUp={() => moveBy(i, -1)}
onMoveDown={() => moveBy(i, 1)}
/>
))}
<Button
type="button"
variant="secondary"
leftIcon={<PlusIcon weight="bold" size={16} />}
onClick={add}
>
{t('schema.addProperty')}
</Button>
</div>
)
}
@@ -0,0 +1,179 @@
import type { JsonSchema } from '@/api/client'
export type PropertyKind =
| 'string'
| 'integer'
| 'number'
| 'boolean'
| 'enum'
| 'localized'
| 'date'
| 'datetime'
| 'array_string'
export type PropertyDef = {
id: string
name: string
kind: PropertyKind
required: boolean
description?: string
enumValues?: string[]
minLength?: number
maxLength?: number
pattern?: string
minimum?: number
maximum?: number
uniqueItems?: boolean
}
export const PROPERTY_KINDS: PropertyKind[] = [
'string',
'integer',
'number',
'boolean',
'enum',
'localized',
'date',
'datetime',
'array_string',
]
export const newPropertyDef = (): PropertyDef => ({
id: typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: '',
kind: 'string',
required: false,
})
export const buildSchemaJson = (props: PropertyDef[]): JsonSchema => {
const properties: Record<string, JsonSchema> = {}
const required: string[] = []
for (const p of props) {
if (!p.name) continue
properties[p.name] = propertyToSchema(p)
if (p.required) required.push(p.name)
}
return {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
additionalProperties: false,
required: required.length > 0 ? required : undefined,
properties,
} as JsonSchema
}
const propertyToSchema = (p: PropertyDef): JsonSchema => {
const desc = p.description?.trim() || undefined
switch (p.kind) {
case 'string': {
const s: JsonSchema = { type: 'string', description: desc }
if (p.minLength != null) s.minLength = p.minLength
if (p.maxLength != null) s.maxLength = p.maxLength
if (p.pattern) s.pattern = p.pattern
return s
}
case 'integer':
case 'number': {
const s: JsonSchema = { type: p.kind, description: desc }
if (p.minimum != null) s.minimum = p.minimum
if (p.maximum != null) s.maximum = p.maximum
return s
}
case 'boolean':
return { type: 'boolean', description: desc }
case 'enum':
return {
type: 'string',
description: desc,
enum: p.enumValues ?? [],
}
case 'localized':
return {
type: 'object',
description: desc,
['x-localized']: true,
// patternProperties not modeled in our JsonSchema TS yet — passes through as raw
...({
patternProperties: {
'^[a-z]{2}-[A-Z]{2}$': { type: 'string' },
},
} as object),
}
case 'date':
return { type: 'string', format: 'date', description: desc }
case 'datetime':
return { type: 'string', format: 'date-time', description: desc }
case 'array_string':
return {
type: 'array',
description: desc,
items: p.enumValues && p.enumValues.length > 0
? ({ type: 'string', enum: p.enumValues } as JsonSchema)
: { type: 'string' },
...(p.uniqueItems ? { uniqueItems: true } : {}),
} as JsonSchema
}
}
export const parseSchemaJson = (schema: JsonSchema | undefined): PropertyDef[] => {
if (!schema || !schema.properties) return []
const required = new Set(schema.required ?? [])
const list: PropertyDef[] = []
for (const [name, raw] of Object.entries(schema.properties)) {
const def = inferKind(name, raw, required.has(name))
if (def) list.push(def)
}
return list
}
const inferKind = (
name: string,
raw: JsonSchema,
isRequired: boolean,
): PropertyDef | null => {
const base: Pick<PropertyDef, 'id' | 'name' | 'required' | 'description'> = {
id: typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
name,
required: isRequired,
description: raw.description,
}
if (raw['x-localized']) return { ...base, kind: 'localized' }
if (raw.enum) return { ...base, kind: 'enum', enumValues: raw.enum.map(String) }
if (raw.type === 'string' && raw.format === 'date') return { ...base, kind: 'date' }
if (raw.type === 'string' && raw.format === 'date-time') return { ...base, kind: 'datetime' }
if (raw.type === 'string') {
return {
...base,
kind: 'string',
minLength: raw.minLength,
maxLength: raw.maxLength,
pattern: raw.pattern,
}
}
if (raw.type === 'integer' || raw.type === 'number') {
return {
...base,
kind: raw.type,
minimum: raw.minimum,
maximum: raw.maximum,
}
}
if (raw.type === 'boolean') return { ...base, kind: 'boolean' }
if (raw.type === 'array' && raw.items?.type === 'string') {
const enumValues = (raw.items.enum as unknown[] | undefined)?.map(String)
return {
...base,
kind: 'array_string',
enumValues,
uniqueItems: Boolean((raw as { uniqueItems?: boolean }).uniqueItems),
}
}
return { ...base, kind: 'string' }
}