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:
@@ -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