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:
@@ -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',
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user