From 987559a6ab1add14df3f7675d8ddbd64f9d2e051 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 4 May 2026 03:12:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin-ui):=20visual=20schema=20builder=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F/=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый функционал — заводить свои справочники через 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) --- ordinis-admin-ui/src/api/client.ts | 12 + ordinis-admin-ui/src/api/mutations.ts | 35 +++ .../schema/DictionaryEditorDialog.tsx | 224 ++++++++++++++++++ .../src/components/schema/PropertyEditor.tsx | 183 ++++++++++++++ .../src/components/schema/SchemaBuilder.tsx | 63 +++++ .../src/components/schema/types.ts | 179 ++++++++++++++ ordinis-admin-ui/src/i18n.ts | 82 +++++++ .../src/routes/dictionaries.$name.tsx | 39 ++- .../src/routes/dictionaries.index.tsx | 56 ++++- 9 files changed, 860 insertions(+), 13 deletions(-) create mode 100644 ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx create mode 100644 ordinis-admin-ui/src/components/schema/PropertyEditor.tsx create mode 100644 ordinis-admin-ui/src/components/schema/SchemaBuilder.tsx create mode 100644 ordinis-admin-ui/src/components/schema/types.ts diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index f597337..38d924c 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -88,6 +88,18 @@ export type RecordResponse = { version: number } +export type CreateDictionaryRequest = { + name: string + displayName?: string + description?: string + scope: DataScope + schemaJson: JsonSchema + schemaVersion?: string + bundle?: string + supportedLocales?: string[] + defaultLocale?: string +} + export type CreateRecordRequest = { businessKey: string data: Record diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index 2616386..76b493c 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -1,7 +1,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { apiClient, + type CreateDictionaryRequest, type CreateRecordRequest, + type DictionaryDetail, type RecordResponse, } from './client' @@ -10,6 +12,39 @@ const idempotencyKey = () => ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}` +export const useCreateDictionary = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (req: CreateDictionaryRequest): Promise => { + const { data } = await apiClient.post('/dictionaries', req) + return data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['dictionaries'] }) + }, + }) +} + +export const useUpdateDictionary = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (params: { + name: string + payload: CreateDictionaryRequest + }): Promise => { + const { data } = await apiClient.put( + `/dictionaries/${params.name}`, + params.payload, + ) + return data + }, + onSuccess: (_, params) => { + qc.invalidateQueries({ queryKey: ['dictionaries'] }) + qc.invalidateQueries({ queryKey: ['dictionary', params.name] }) + }, + }) +} + export const useCreateRecord = (dictionaryName: string) => { const qc = useQueryClient() return useMutation({ diff --git a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx new file mode 100644 index 0000000..8a444d6 --- /dev/null +++ b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx @@ -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('metadata') + const [name, setName] = useState(initial?.name ?? '') + const [displayName, setDisplayName] = useState(initial?.displayName ?? '') + const [description, setDescription] = useState(initial?.description ?? '') + const [scope, setScope] = useState(initial?.scope ?? 'PUBLIC') + const [bundle, setBundle] = useState(initial?.bundle ?? 'cuod') + const [schemaVersion, setSchemaVersion] = useState(initial?.schemaVersion ?? '1.0.0') + const [supportedLocales, setSupportedLocales] = useState( + initial?.supportedLocales ?? ['ru-RU'], + ) + const [defaultLocale, setDefaultLocale] = useState(initial?.defaultLocale ?? 'ru-RU') + const [properties, setProperties] = useState( + () => parseSchemaJson(initial?.schemaJson), + ) + const [nameError, setNameError] = useState(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 ( + +
+ setActiveTab(id as EditorTab)} /> + +
+
+ setName(e.target.value)} + /> + setDisplayName(e.target.value)} + /> +
+