Files
mdm-ordinis/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx
T

552 lines
21 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 { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
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'
/**
* Decide whether the editor's Save button should be blocked because the
* change has to go through the schema-draft workflow instead of inline PUT.
* Mirrors the three backend gate clauses in DictionaryDefinitionService
* exactly so the UX matches the server contract.
*
* Returns false (don't block) for create-mode, for non-approval-required
* dicts, and for pure metadata edits (display name / description / locales
* etc.) that leave schema + approval config alone.
*
* Exported for unit testability — the three-clause logic is the load-bearing
* piece, easier to verify in isolation than mounting the full editor with
* its query and mutation deps.
*/
export function computeRequiresDraftFlow({
isEdit,
initialApprovalRequired,
initialMinRole,
currentApprovalRequired,
currentMinRole,
schemaChangeCount,
}: {
isEdit: boolean
initialApprovalRequired: boolean
initialMinRole: string | null
currentApprovalRequired: boolean
currentMinRole: string | null
schemaChangeCount: number
}): boolean {
if (!isEdit || !initialApprovalRequired) return false
const schemaChanged = schemaChangeCount > 0
const approvalToggledOff = !currentApprovalRequired
const minRoleChanged = initialMinRole !== currentMinRole
return schemaChanged || approvalToggledOff || minRoleChanged
}
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)
/** Open state for the "Создать черновик с моими изменениями" CTA. The CTA
* is the inline handoff from the disabled-Save path (requiresDraftFlow)
* into the schema-draft modal with the maker's in-progress schemaJson
* pre-loaded — no retyping. */
const [draftHandoffOpen, setDraftHandoffOpen] = useState(false)
// 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],
)
// Approval-aware Save: when editing an approval-required dict, any schema
// change OR toggling approvalRequired off OR changing approvalMinRole has
// to route through the schema-draft workflow. The backend will reject with
// 422 approval_required, but it's much better UX to disable Save up front
// and tell the user to open a draft instead. Mirror the three backend
// gate clauses exactly so the UX matches the server contract.
const requiresDraftFlow = computeRequiresDraftFlow({
isEdit,
initialApprovalRequired: Boolean(
(initial as { approvalRequired?: boolean } | null)?.approvalRequired,
),
initialMinRole:
((initial as { approvalMinRole?: string } | null)?.approvalMinRole ?? '') || null,
currentApprovalRequired: approvalRequired,
currentMinRole: approvalMinRole.trim() === '' ? null : approvalMinRole.trim(),
schemaChangeCount: changes.length,
})
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-body font-sans text-ink">
{t('schema.redisProjection.label')}
</span>
<span className="block text-cell 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-body font-sans text-ink">
{t('schema.approvalRequired.label')}
</span>
<span className="block text-cell 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-cap 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-cell 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="text-mono bg-line/30 rounded p-3 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-body">{t('schema.diff.blockedBody')}</p>
<ul className="list-disc pl-5 text-body 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-body 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>}
{requiresDraftFlow && (
<Alert
variant="warning"
title={t('schema.approvalRequired.editorBlockTitle', {
defaultValue: 'Изменения требуют ревью',
})}
action={
<Button
type="button"
variant="primary"
size="sm"
onClick={() => setDraftHandoffOpen(true)}
>
{t('schema.approvalRequired.openDraftCta', {
defaultValue: 'Создать черновик с моими изменениями',
})}
</Button>
}
>
{t('schema.approvalRequired.editorBlock', {
defaultValue:
'Этот словарь требует maker-checker review. Откройте черновик — ' +
'ваши текущие изменения будут предзаполнены, вам нужно только ' +
'указать причину и отправить на согласование.',
})}
</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) || requiresDraftFlow}
onClick={handleSubmit}
>
{activeMutation.isPending ? t('form.saving') : t('form.save')}
</Button>
</FormActions>
</div>
{/* Inline handoff: CreateSchemaDraftModal mounted with the in-progress
schemaJson pre-loaded. Maker hits "Создать черновик с моими изменениями"
→ modal opens with the same edits they had → they add a reason and
submit. No retyping, no hunt for a button on the dict page. */}
{isEdit && initial && (
<CreateSchemaDraftModal
open={draftHandoffOpen}
onClose={() => setDraftHandoffOpen(false)}
detail={initial}
initialProposedSchema={schemaJson}
onCreated={() => {
// Cascade close: dismiss the draft modal AND the editor. Once
// the draft is submitted, the in-memory edit in the editor is
// stale by design — the draft is now the source of truth until
// a reviewer decides. Leaving the editor open would let the
// maker silently mutate further state that can't go anywhere
// (Save still disabled until the draft resolves).
setDraftHandoffOpen(false)
onClose()
}}
/>
)}
</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'
}