feat(admin-ui): webhook event preview tab в DictionaryEditorDialog

CEO plan v1 polish — закрывает gap "Webhook preview при определении схемы".

Что добавлено:
- New tab "События" (4-й после Метаданные / Поля / JSON) в schema editor.
- Per-event sample JSON envelope для всех 5 event types которые fire'ят
  на dict + records: RecordCreated, RecordUpdated, RecordDeleted,
  DefinitionCreated, DefinitionUpdated.
- Sample data автогенерируется из schema property types:
  string → "sample", integer → 42, number → 3.14, boolean → true,
  array → [<sample>], object → recursive, x-localized → {ru-RU, en-US},
  enum → first value, x-references → "REF-001", date/datetime → ISO.
- Topic name pattern shown: ordinis.{dict}.{scope.lower} для record events,
  ordinis.definitions для definition events.
- Disclaimer что это client-side preview — реальный shape определяется
  ordinis-events-api DTOs.

Helps integrators (Альтум, Геопортал, BI consumers) понять shape события
до того как dict published — без ожидания live event'а.

Files:
- src/components/schema/eventPreview.ts (new, ~155 LOC) — pure helper
- src/components/schema/EventsPreviewTab.tsx (new, ~70 LOC) — UI
- src/components/schema/DictionaryEditorDialog.tsx — +16 LOC, mount tab
- src/i18n.ts — schema.tabs.events + schema.events.{intro,topic,disclaimer}
  RU + EN

Verify:
- pnpm tsc --noEmit: clean
- pnpm test (vitest): 89/89 PASS (4.34s)
- pnpm build: clean
This commit is contained in:
Zimin A.N.
2026-05-08 12:14:35 +03:00
parent 7d2199a3f3
commit 5bc82d2951
4 changed files with 301 additions and 1 deletions
@@ -19,6 +19,7 @@ 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,
@@ -48,7 +49,7 @@ const SCOPE_OPTIONS = (t: (k: string) => string): SelectOption[] => [
{ id: 'RESTRICTED', label: t('scope.RESTRICTED') },
]
type EditorTab = 'metadata' | 'schema' | 'preview'
type EditorTab = 'metadata' | 'schema' | 'preview' | 'events'
export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props) => {
const { t } = useTranslation()
@@ -172,6 +173,7 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
{ 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 (
@@ -297,6 +299,18 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
</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">
@@ -0,0 +1,78 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge, Tabs, type TabItem } from '@nstart/ui'
import { generateEventPreviews } from './eventPreview'
import type { DataScope, JsonSchema } from '@/api/client'
type Props = {
dictionaryName: string
dataScope: DataScope
bundle: string
schema: JsonSchema
businessKeyField?: string
}
/**
* Preview tab inside DictionaryEditorDialog. Показывает sample JSON envelope
* для каждого event type, который Kafka получит после save'а dict / records.
*
* <p>Помогает integrators (Альтум, Геопортал, etc) понять shape события до
* того как dict published. Без этого они вынуждены ждать live event'а
* чтобы посмотреть payload.
*
* <p>Pure client-side preview — не отправляет ничего в Kafka. Sample data
* генерится из schema properties (string → "sample", integer → 42, и т.д.).
*/
export const EventsPreviewTab = ({
dictionaryName,
dataScope,
bundle,
schema,
businessKeyField,
}: Props) => {
const { t } = useTranslation()
const previews = generateEventPreviews(
dictionaryName || 'sample_dict',
dataScope,
bundle || 'cuod',
schema,
businessKeyField,
)
const [active, setActive] = useState(previews[0]?.eventType ?? 'RecordCreated')
const current = previews.find((p) => p.eventType === active) ?? previews[0]
const tabs: TabItem[] = previews.map((p) => ({
id: p.eventType,
label: p.label,
}))
return (
<div className="space-y-3">
<p className="text-sm text-carbon/70">
{t('schema.events.intro')}
</p>
<Tabs items={tabs} value={active} onValueChange={setActive} />
{current && (
<div className="space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="info">{current.eventType}</Badge>
<span className="text-2xs text-carbon/70">
{t('schema.events.topic')}:
</span>
<code className="text-2xs font-mono text-ultramarain">
{current.topic}
</code>
</div>
<p className="text-2xs text-carbon/60">{current.description}</p>
<pre className="bg-regolith/30 rounded p-3 text-2xs font-mono overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(current.envelope, null, 2)}
</pre>
<p className="text-2xs text-carbon/50">
{t('schema.events.disclaimer')}
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,200 @@
/**
* Webhook event preview generator (Phase 4.5 / CEO plan v1 polish).
*
* <p>При schema definition'е admin видит какой shape JSON event получит
* downstream consumer. Это важно для integrators (Альтум, Геопортал, etc) —
* перед published'ом нового справочника они хотят посмотреть payload.
*
* <p>Функции генерируют sample envelope + payload для трёх record event
* types + двух definition event types. Sample data заполняется placeholder'ами
* на основе schema property types (string → "sample-string", number → 42, etc).
*
* <p>Output не отправляется в Kafka — это pure client-side preview. Backend
* shapes (см. ordinis-events-api/src/main/java/.../events/) — единственный
* source of truth; preview обновляется вместе с DTOs если schema меняется.
*/
import type { DataScope, JsonSchema } from '@/api/client'
const SAMPLE_UUID = '00000000-0000-0000-0000-000000000000'
const SAMPLE_TIMESTAMP = '2026-05-08T12:00:00+03:00'
/** Generate a placeholder value for one schema property based on type. */
const sampleValueFor = (prop: JsonSchema | undefined): unknown => {
if (!prop) return null
if (prop.enum && prop.enum.length > 0) return prop.enum[0]
if (prop['x-localized']) return { 'ru-RU': 'Пример', 'en-US': 'Sample' }
if (prop.type === 'integer') return 42
if (prop.type === 'number') return 3.14
if (prop.type === 'boolean') return true
if (prop.type === 'array') {
return [sampleValueFor(prop.items)]
}
if (prop.type === 'object' && prop.properties) {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(prop.properties)) {
out[k] = sampleValueFor(v)
}
return out
}
// string fallback
if (prop.format === 'date') return '2026-01-01'
if (prop.format === 'date-time') return SAMPLE_TIMESTAMP
if (prop['x-references']) return 'REF-001'
return 'sample'
}
/** Build sample data record matching the schema's properties. */
export const sampleDataFor = (schema: JsonSchema): Record<string, unknown> => {
const out: Record<string, unknown> = {}
if (!schema.properties) return out
for (const [key, prop] of Object.entries(schema.properties)) {
out[key] = sampleValueFor(prop)
}
return out
}
type EnvelopeOptions = {
dictionaryName: string
dataScope: DataScope
bundle: string
eventType: string
}
const envelope = <T>(opts: EnvelopeOptions, payload: T) => ({
eventId: SAMPLE_UUID,
occurredAt: SAMPLE_TIMESTAMP,
schemaVersion: '1.0.0',
bundle: opts.bundle,
dictionaryName: opts.dictionaryName,
dataScope: opts.dataScope,
traceId: 'sample-trace-id',
spanId: 'sample-span-id',
payload: { type: opts.eventType, ...payload },
})
export type EventPreview = {
/** "RecordCreated" / "RecordUpdated" / "RecordDeleted" / "DefinitionCreated" / "DefinitionUpdated". */
eventType: string
/** Прозрачное название для UI tab. */
label: string
/** Краткое описание когда это событие fires. */
description: string
/** Topic name pattern в Kafka. */
topic: string
/** Полный JSON envelope + payload. */
envelope: unknown
}
/** Генерирует все 5 sample events для текущей schema. */
export const generateEventPreviews = (
dictionaryName: string,
dataScope: DataScope,
bundle: string,
schema: JsonSchema,
businessKeyField?: string,
): EventPreview[] => {
const sampleData = sampleDataFor(schema)
const businessKey =
businessKeyField && typeof sampleData[businessKeyField] === 'string'
? (sampleData[businessKeyField] as string)
: 'BK-001'
const opts = (eventType: string): EnvelopeOptions => ({
dictionaryName,
dataScope,
bundle,
eventType,
})
const topic = `ordinis.${dictionaryName}.${dataScope.toLowerCase()}`
return [
{
eventType: 'RecordCreated',
label: 'Record Created',
description: 'Новая запись добавлена в справочник',
topic,
envelope: envelope(opts('RecordCreated'), {
recordId: SAMPLE_UUID,
businessKey,
data: sampleData,
geometryWkt: null,
validFrom: SAMPLE_TIMESTAMP,
validTo: '9999-12-31T23:59:59Z',
createdAt: SAMPLE_TIMESTAMP,
createdBy: 'admin@example.org',
}),
},
{
eventType: 'RecordUpdated',
label: 'Record Updated',
description: 'Создана новая версия записи (старая закрыта validTo=now)',
topic,
envelope: envelope(opts('RecordUpdated'), {
recordId: SAMPLE_UUID,
previousRecordId: '11111111-1111-1111-1111-111111111111',
businessKey,
data: sampleData,
previousData: { ...sampleData, _previous: true },
geometryWkt: null,
validFrom: SAMPLE_TIMESTAMP,
validTo: '9999-12-31T23:59:59Z',
updatedAt: SAMPLE_TIMESTAMP,
updatedBy: 'admin@example.org',
}),
},
{
eventType: 'RecordDeleted',
label: 'Record Deleted (closed)',
description: 'Запись закрыта (valid_to = now). Реального DELETE нет — для audit',
topic,
envelope: envelope(opts('RecordDeleted'), {
recordId: SAMPLE_UUID,
businessKey,
closedAt: SAMPLE_TIMESTAMP,
deletedAt: SAMPLE_TIMESTAMP,
deletedBy: 'admin@example.org',
reason: 'manual close — example reason',
}),
},
{
eventType: 'DefinitionCreated',
label: 'Definition Created',
description: 'Справочник создан / реrgистрирован в каталоге',
topic: `ordinis.definitions`,
envelope: envelope(opts('DefinitionCreated'), {
dictionaryId: SAMPLE_UUID,
name: dictionaryName,
displayName: dictionaryName,
description: 'Sample dictionary description',
scope: dataScope,
schemaJson: schema,
schemaVersion: '1.0.0',
bundle,
supportedLocales: ['ru-RU', 'en-US'],
defaultLocale: 'ru-RU',
createdAt: SAMPLE_TIMESTAMP,
createdBy: 'admin@example.org',
}),
},
{
eventType: 'DefinitionUpdated',
label: 'Definition Updated',
description: 'Schema справочника изменена (новый schemaVersion)',
topic: `ordinis.definitions`,
envelope: envelope(opts('DefinitionUpdated'), {
dictionaryId: SAMPLE_UUID,
name: dictionaryName,
displayName: dictionaryName,
description: 'Updated dictionary description',
scope: dataScope,
schemaJson: schema,
previousSchemaJson: { ...schema, _previous: true },
schemaVersion: '1.1.0',
bundle,
supportedLocales: ['ru-RU', 'en-US'],
defaultLocale: 'ru-RU',
updatedAt: SAMPLE_TIMESTAMP,
updatedBy: 'admin@example.org',
}),
},
]
}