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('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 [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(parsed.properties) const [idSource, setIdSource] = useState(parsed.idSource ?? '') const [nameError, setNameError] = useState(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 (
setActiveTab(id as EditorTab)} />
{!isEdit && (
{ void handleApplyTemplate(id) }} placeholder={ dictsQuery.isLoading || loadingTemplate ? '…' : t('schema.template.placeholder') } disabled={dictsQuery.isLoading || loadingTemplate} />
)}
setName(e.target.value)} /> setDisplayName(e.target.value)} />