Files
mdm-ordinis/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx
T
2026-05-11 09:08:38 +00:00

442 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQueryClient } from '@tanstack/react-query'
import axios from 'axios'
import {
Alert,
Button,
FormActions,
Modal,
MultiSelect,
SingleSelect,
Tabs,
TextArea,
TextInput,
type SelectOption,
type TabItem,
} from '@/ui'
import { useCreateDictionary, useUpdateDictionary } from '@/api/mutations'
import { dictionaryDetailQuery, useDictionaries } from '@/api/queries'
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
import { SchemaBuilder } from './SchemaBuilder'
import { EventsPreviewTab } from './EventsPreviewTab'
import {
buildSchemaJson,
diffSchemas,
hasBreakingChanges,
parseSchemaJson,
suggestVersionBump,
type PropertyDef,
type SchemaChange,
} 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' | 'events'
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 parsed = useMemo(() => parseSchemaJson(initial?.schemaJson), [initial])
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 [redisProjection, setRedisProjection] = useState(
initial?.redisProjectionEnabled ?? false,
)
const [approvalRequired, setApprovalRequired] = useState(
(initial as { approvalRequired?: boolean } | null)?.approvalRequired ?? false,
)
const [approvalMinRole, setApprovalMinRole] = useState(
(initial as { approvalMinRole?: string } | null)?.approvalMinRole ?? '',
)
const [properties, setProperties] = useState<PropertyDef[]>(parsed.properties)
const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '')
const [nameError, setNameError] = useState<string | null>(null)
// Template (только в create-mode): admin выбирает существующий dict,
// его schema + locale + scope копируются. Имя остаётся пустым (admin
// вводит уникальное), version reset на 1.0.0.
const queryClient = useQueryClient()
const dictsQuery = useDictionaries()
const [loadingTemplate, setLoadingTemplate] = useState(false)
const templateOptions: SelectOption[] = useMemo(() => {
if (isEdit) return []
const list = dictsQuery.data ?? []
return list
.map((d) => ({
id: d.name,
label: d.displayName ? `${d.name}${d.displayName}` : d.name,
}))
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
}, [dictsQuery.data, isEdit])
const handleApplyTemplate = async (templateName: string) => {
if (!templateName) return
setLoadingTemplate(true)
try {
const detail = await queryClient.fetchQuery(dictionaryDetailQuery(templateName))
const tplParsed = parseSchemaJson(detail.schemaJson)
// Не трогаем name — admin должен ввести уникальное.
setDisplayName('')
setDescription(detail.description ?? '')
setScope(detail.scope)
setBundle(detail.bundle ?? 'cuod')
setSchemaVersion('1.0.0')
setSupportedLocales(detail.supportedLocales ?? ['ru-RU'])
setDefaultLocale(detail.defaultLocale ?? 'ru-RU')
setProperties(tplParsed.properties)
setIdSource(tplParsed.idSource ?? '')
} finally {
setLoadingTemplate(false)
}
}
const schemaJson = useMemo(
() => buildSchemaJson(properties, idSource || undefined),
[properties, idSource],
)
const changes: SchemaChange[] = useMemo(
() => (isEdit ? diffSchemas(initial?.schemaJson, schemaJson) : []),
[isEdit, initial, schemaJson],
)
const breaking = hasBreakingChanges(changes)
const suggestedVersion = useMemo(
() => (isEdit ? suggestVersionBump(initial?.schemaVersion, changes) : schemaVersion),
[isEdit, initial, changes, schemaVersion],
)
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)
if (isEdit && breaking) {
setActiveTab('metadata')
return
}
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,
redisProjectionEnabled: redisProjection,
approvalRequired,
approvalMinRole: approvalMinRole.trim() || 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') },
{ id: 'events', label: t('schema.tabs.events') },
]
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'}>
{!isEdit && (
<div className="mb-3 p-3 rounded-md border border-accent/20 bg-accent/4">
<SingleSelect
label={t('schema.template.label')}
hint={t('schema.template.hint')}
options={templateOptions}
value=""
onChange={(id) => {
void handleApplyTemplate(id)
}}
placeholder={
dictsQuery.isLoading || loadingTemplate
? '…'
: t('schema.template.placeholder')
}
disabled={dictsQuery.isLoading || loadingTemplate}
/>
</div>
)}
<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"
hint={
isEdit && suggestedVersion !== schemaVersion
? t('schema.versionSuggest', { v: suggestedVersion })
: undefined
}
value={schemaVersion}
onChange={(e) => setSchemaVersion(e.target.value)}
/>
<SingleSelect
label={t('schema.idSource')}
hint={t('schema.idSourceHint')}
options={[
{ id: '', label: '— ' + t('schema.idSourceManual') + ' —' },
...properties
.filter((p) => p.name && (p.kind === 'string' || p.kind === 'integer' || p.kind === 'number'))
.map((p) => ({ id: p.name, label: p.name })),
]}
value={idSource}
onChange={setIdSource}
/>
<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}
/>
{/* CEO plan E2: per-dict Redis projection opt-in. Default false.
Включается когда dict хочет 10k+ RPS на read-api (вместо PG). */}
<label className="md:col-span-2 flex items-start gap-3 px-3 py-2 rounded-sm border border-line bg-line/20 cursor-pointer">
<input
type="checkbox"
checked={redisProjection}
onChange={(e) => setRedisProjection(e.target.checked)}
className="mt-0.5 size-4 accent-ultramarain"
/>
<span className="flex-1">
<span className="block text-sm font-sans text-ink">
{t('schema.redisProjection.label')}
</span>
<span className="block text-2xs text-mute mt-0.5">
{t('schema.redisProjection.hint')}
</span>
</span>
</label>
{/* Approval Workflow v2: per-dict opt-in. Default false. Включается
для критичных dicts (ground_station, spacecraft и т.д.) где
один admin не должен иметь power публиковать без review. */}
<label className="md:col-span-2 flex items-start gap-3 px-3 py-2 rounded-sm border border-warn bg-warn-bg cursor-pointer">
<input
type="checkbox"
checked={approvalRequired}
onChange={(e) => setApprovalRequired(e.target.checked)}
className="mt-0.5 size-4 accent-orbit"
/>
<span className="flex-1">
<span className="block text-sm font-sans text-ink">
{t('schema.approvalRequired.label')}
</span>
<span className="block text-2xs text-mute mt-0.5">
{t('schema.approvalRequired.hint')}
</span>
</span>
</label>
{approvalRequired && (
<div className="md:col-span-2">
<label
htmlFor="approval-min-role"
className="text-2xs uppercase tracking-[0.10em] text-ink-2"
>
{t('schema.approvalMinRole.label')}
</label>
<TextInput
id="approval-min-role"
value={approvalMinRole}
onChange={(e) => setApprovalMinRole(e.target.value)}
placeholder="ordinis:internal"
/>
<p className="text-2xs text-mute mt-0.5">
{t('schema.approvalMinRole.hint')}
</p>
</div>
)}
</div>
</div>
<div className={activeTab === 'schema' ? 'block' : 'hidden'}>
<SchemaBuilder properties={properties} onChange={setProperties} />
</div>
<div className={activeTab === 'preview' ? 'block' : 'hidden'}>
<pre className="bg-line/30 rounded p-3 text-2xs font-mono overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(schemaJson, null, 2)}
</pre>
</div>
<div className={activeTab === 'events' ? 'block' : 'hidden'}>
{/* Phase 4.5 / CEO plan v1: integrators видят shape Kafka events
до published'а dictionary. Sample data на основе schema fields. */}
<EventsPreviewTab
dictionaryName={name}
dataScope={scope}
bundle={bundle}
schema={schemaJson}
businessKeyField={idSource || undefined}
/>
</div>
{isEdit && breaking && (
<Alert variant="error" title={t('schema.diff.blockedTitle')}>
<div className="space-y-2">
<p className="text-sm">{t('schema.diff.blockedBody')}</p>
<ul className="list-disc pl-5 text-sm space-y-0.5">
{changes
.filter((c) => c.severity === 'breaking')
.map((c, i) => (
<li key={i}>
<span className="font-mono">{c.field}</span> {c.reason}
</li>
))}
</ul>
</div>
</Alert>
)}
{isEdit && !breaking && changes.length > 0 && (
<Alert variant="info" title={t('schema.diff.compatibleTitle')}>
<ul className="list-disc pl-5 text-sm space-y-0.5">
{changes.map((c, i) => (
<li key={i}>
<span className="font-mono">{c.field}</span> {c.reason}
</li>
))}
</ul>
</Alert>
)}
{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}
disabled={isEdit && breaking}
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'
}