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,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'
|
||||
}
|
||||
Reference in New Issue
Block a user