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:
@@ -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<string, unknown>
|
||||
|
||||
@@ -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<DictionaryDetail> => {
|
||||
const { data } = await apiClient.post<DictionaryDetail>('/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<DictionaryDetail> => {
|
||||
const { data } = await apiClient.put<DictionaryDetail>(
|
||||
`/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({
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -43,6 +43,47 @@ i18n
|
||||
'form.save': 'Сохранить',
|
||||
'form.saving': 'Сохранение...',
|
||||
'form.error.required': 'Обязательное поле',
|
||||
'scope.PUBLIC': 'PUBLIC — публичный',
|
||||
'scope.INTERNAL': 'INTERNAL — внутренний',
|
||||
'scope.RESTRICTED': 'RESTRICTED — ограниченный',
|
||||
'schema.action.create': 'Создать справочник',
|
||||
'schema.action.edit': 'Изменить справочник',
|
||||
'schema.action.editSchema': 'Схема',
|
||||
'schema.tabs.metadata': 'Метаданные',
|
||||
'schema.tabs.schema': 'Поля',
|
||||
'schema.tabs.preview': 'JSON',
|
||||
'schema.name': 'Имя',
|
||||
'schema.nameHint': 'snake_case, начинается с буквы (например my_dictionary)',
|
||||
'schema.nameInvalid': 'Только snake_case: a-z, 0-9, _; начинается с буквы',
|
||||
'schema.displayName': 'Отображаемое имя',
|
||||
'schema.description': 'Описание',
|
||||
'schema.scope': 'Scope',
|
||||
'schema.bundle': 'Bundle',
|
||||
'schema.bundleHint': 'Обычно cuod',
|
||||
'schema.version': 'Версия схемы',
|
||||
'schema.supportedLocales': 'Поддерживаемые локали',
|
||||
'schema.defaultLocale': 'Локаль по умолчанию',
|
||||
'schema.property': 'Поле',
|
||||
'schema.required': 'Обязательное',
|
||||
'schema.pattern': 'Pattern (regex)',
|
||||
'schema.enumValues': 'Значения enum (через запятую)',
|
||||
'schema.enumValuesHint': 'Например: ACTIVE, INACTIVE, RETIRED',
|
||||
'schema.arrayEnumOptional': 'Допустимые значения (опц., через запятую)',
|
||||
'schema.empty': 'Полей нет — добавь первое',
|
||||
'schema.addProperty': 'Добавить поле',
|
||||
'schema.delete': 'Удалить',
|
||||
'schema.moveUp': 'Выше',
|
||||
'schema.moveDown': 'Ниже',
|
||||
'schema.kind.label': 'Тип',
|
||||
'schema.kind.string': 'Строка',
|
||||
'schema.kind.integer': 'Целое',
|
||||
'schema.kind.number': 'Число',
|
||||
'schema.kind.boolean': 'Логическое',
|
||||
'schema.kind.enum': 'Enum (выбор из списка)',
|
||||
'schema.kind.localized': 'I18N строка (ru-RU/en-US/...)',
|
||||
'schema.kind.date': 'Дата',
|
||||
'schema.kind.datetime': 'Дата+время',
|
||||
'schema.kind.array_string': 'Массив строк',
|
||||
'field.name': 'Наименование',
|
||||
'field.designator': 'Designator (COSPAR)',
|
||||
'field.norad_id': 'NORAD ID',
|
||||
@@ -98,6 +139,47 @@ i18n
|
||||
'form.save': 'Save',
|
||||
'form.saving': 'Saving...',
|
||||
'form.error.required': 'Required field',
|
||||
'scope.PUBLIC': 'PUBLIC',
|
||||
'scope.INTERNAL': 'INTERNAL',
|
||||
'scope.RESTRICTED': 'RESTRICTED',
|
||||
'schema.action.create': 'Create dictionary',
|
||||
'schema.action.edit': 'Edit dictionary',
|
||||
'schema.action.editSchema': 'Schema',
|
||||
'schema.tabs.metadata': 'Metadata',
|
||||
'schema.tabs.schema': 'Fields',
|
||||
'schema.tabs.preview': 'JSON',
|
||||
'schema.name': 'Name',
|
||||
'schema.nameHint': 'snake_case, starts with letter (e.g. my_dictionary)',
|
||||
'schema.nameInvalid': 'Only snake_case: a-z, 0-9, _; must start with letter',
|
||||
'schema.displayName': 'Display name',
|
||||
'schema.description': 'Description',
|
||||
'schema.scope': 'Scope',
|
||||
'schema.bundle': 'Bundle',
|
||||
'schema.bundleHint': 'Usually cuod',
|
||||
'schema.version': 'Schema version',
|
||||
'schema.supportedLocales': 'Supported locales',
|
||||
'schema.defaultLocale': 'Default locale',
|
||||
'schema.property': 'Field',
|
||||
'schema.required': 'Required',
|
||||
'schema.pattern': 'Pattern (regex)',
|
||||
'schema.enumValues': 'Enum values (comma-separated)',
|
||||
'schema.enumValuesHint': 'E.g.: ACTIVE, INACTIVE, RETIRED',
|
||||
'schema.arrayEnumOptional': 'Allowed values (optional, comma-separated)',
|
||||
'schema.empty': 'No fields yet — add the first one',
|
||||
'schema.addProperty': 'Add field',
|
||||
'schema.delete': 'Delete',
|
||||
'schema.moveUp': 'Move up',
|
||||
'schema.moveDown': 'Move down',
|
||||
'schema.kind.label': 'Type',
|
||||
'schema.kind.string': 'String',
|
||||
'schema.kind.integer': 'Integer',
|
||||
'schema.kind.number': 'Number',
|
||||
'schema.kind.boolean': 'Boolean',
|
||||
'schema.kind.enum': 'Enum (pick from list)',
|
||||
'schema.kind.localized': 'I18N string (ru-RU/en-US/...)',
|
||||
'schema.kind.date': 'Date',
|
||||
'schema.kind.datetime': 'Date+time',
|
||||
'schema.kind.array_string': 'Array of strings',
|
||||
'field.name': 'Name',
|
||||
'field.designator': 'Designator (COSPAR)',
|
||||
'field.norad_id': 'NORAD ID',
|
||||
|
||||
@@ -20,11 +20,12 @@ import {
|
||||
TableRow,
|
||||
TextInput,
|
||||
} from '@nstart/ui'
|
||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon } from '@phosphor-icons/react'
|
||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon } from '@phosphor-icons/react'
|
||||
import { useDictionaryDetail, useRecordRaw, useRecords } from '@/api/queries'
|
||||
import { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
||||
import type { CreateRecordRequest, FlattenedRecord } from '@/api/client'
|
||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||
|
||||
export const Route = createFileRoute('/dictionaries/$name')({
|
||||
component: DictionaryDetail,
|
||||
@@ -47,6 +48,7 @@ function DictionaryDetail() {
|
||||
|
||||
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
|
||||
const [closeReason, setCloseReason] = useState('')
|
||||
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
||||
|
||||
const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
|
||||
const rawRecordQuery = useRecordRaw(name, editingKey)
|
||||
@@ -98,14 +100,24 @@ function DictionaryDetail() {
|
||||
: `${totalRecords} ${t('dict.list.records')}`
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<PlusIcon weight="bold" size={16} />}
|
||||
disabled={!detailQuery.data}
|
||||
onClick={() => setEdit({ kind: 'create' })}
|
||||
>
|
||||
{t('dict.action.create')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={<GearIcon weight="bold" size={16} />}
|
||||
disabled={!detailQuery.data}
|
||||
onClick={() => setSchemaEditOpen(true)}
|
||||
>
|
||||
{t('schema.action.editSchema')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<PlusIcon weight="bold" size={16} />}
|
||||
disabled={!detailQuery.data}
|
||||
onClick={() => setEdit({ kind: 'create' })}
|
||||
>
|
||||
{t('dict.action.create')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -278,6 +290,15 @@ function DictionaryDetail() {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{detailQuery.data && (
|
||||
<DictionaryEditorDialog
|
||||
open={schemaEditOpen}
|
||||
mode={{ kind: 'edit', existing: detailQuery.data }}
|
||||
onClose={() => setSchemaEditOpen(false)}
|
||||
onSuccess={() => setSchemaEditOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, Badge, EmptyState, LoadingBlock, PageHeader } from '@nstart/ui'
|
||||
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader } from '@nstart/ui'
|
||||
import { PlusIcon } from '@phosphor-icons/react'
|
||||
import { useDictionaries } from '@/api/queries'
|
||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||
|
||||
export const Route = createFileRoute('/dictionaries/')({
|
||||
component: DictionariesPage,
|
||||
@@ -9,7 +12,20 @@ export const Route = createFileRoute('/dictionaries/')({
|
||||
|
||||
function DictionariesPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { data, isLoading, error } = useDictionaries()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const createButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
leftIcon={<PlusIcon weight="bold" size={16} />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
{t('schema.action.create')}
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingBlock size="md" label={t('loading')} />
|
||||
@@ -24,12 +40,34 @@ function DictionariesPage() {
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <EmptyState title={t('dict.empty')} />
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t('nav.dictionaries')}
|
||||
description={t('dict.list.subtitle')}
|
||||
actions={createButton}
|
||||
/>
|
||||
<EmptyState title={t('dict.empty')} />
|
||||
<DictionaryEditorDialog
|
||||
open={createOpen}
|
||||
mode={{ kind: 'create' }}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSuccess={(name) => {
|
||||
setCreateOpen(false)
|
||||
navigate({ to: '/dictionaries/$name', params: { name } })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={t('nav.dictionaries')} description={t('dict.list.subtitle')} />
|
||||
<PageHeader
|
||||
title={t('nav.dictionaries')}
|
||||
description={t('dict.list.subtitle')}
|
||||
actions={createButton}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.map((d) => (
|
||||
@@ -58,6 +96,16 @@ function DictionariesPage() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DictionaryEditorDialog
|
||||
open={createOpen}
|
||||
mode={{ kind: 'create' }}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSuccess={(name) => {
|
||||
setCreateOpen(false)
|
||||
navigate({ to: '/dictionaries/$name', params: { name } })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user