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 { dictionaryDetailQuery, useDictionaries } from '@/api/queries'
|
||||||
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
|
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
|
||||||
import { SchemaBuilder } from './SchemaBuilder'
|
import { SchemaBuilder } from './SchemaBuilder'
|
||||||
|
import { EventsPreviewTab } from './EventsPreviewTab'
|
||||||
import {
|
import {
|
||||||
buildSchemaJson,
|
buildSchemaJson,
|
||||||
diffSchemas,
|
diffSchemas,
|
||||||
@@ -48,7 +49,7 @@ const SCOPE_OPTIONS = (t: (k: string) => string): SelectOption[] => [
|
|||||||
{ id: 'RESTRICTED', label: t('scope.RESTRICTED') },
|
{ 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) => {
|
export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -172,6 +173,7 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
|||||||
{ id: 'metadata', label: t('schema.tabs.metadata') },
|
{ id: 'metadata', label: t('schema.tabs.metadata') },
|
||||||
{ id: 'schema', label: t('schema.tabs.schema') },
|
{ id: 'schema', label: t('schema.tabs.schema') },
|
||||||
{ id: 'preview', label: t('schema.tabs.preview') },
|
{ id: 'preview', label: t('schema.tabs.preview') },
|
||||||
|
{ id: 'events', label: t('schema.tabs.events') },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -297,6 +299,18 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</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 && (
|
{isEdit && breaking && (
|
||||||
<Alert variant="error" title={t('schema.diff.blockedTitle')}>
|
<Alert variant="error" title={t('schema.diff.blockedTitle')}>
|
||||||
<div className="space-y-2">
|
<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',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -191,6 +191,10 @@ i18n
|
|||||||
'schema.tabs.metadata': 'Метаданные',
|
'schema.tabs.metadata': 'Метаданные',
|
||||||
'schema.tabs.schema': 'Поля',
|
'schema.tabs.schema': 'Поля',
|
||||||
'schema.tabs.preview': 'JSON',
|
'schema.tabs.preview': 'JSON',
|
||||||
|
'schema.tabs.events': 'События',
|
||||||
|
'schema.events.intro': 'Превью JSON событий, которые будут опубликованы в Kafka после save справочника. Данные подставлены на основе типов полей schema. Полезно интеграторам (Альтум, Геопортал) для понимания shape\'а до live-выкатки.',
|
||||||
|
'schema.events.topic': 'Topic',
|
||||||
|
'schema.events.disclaimer': 'Это client-side preview. Реальные события содержат actual data + traceId/spanId из request scope. Schema events ordinis-events-api/src/main/java/.../events/ — единственный source of truth.',
|
||||||
'schema.name': 'Имя',
|
'schema.name': 'Имя',
|
||||||
'schema.nameHint': 'snake_case, начинается с буквы (например my_dictionary)',
|
'schema.nameHint': 'snake_case, начинается с буквы (например my_dictionary)',
|
||||||
'schema.nameInvalid': 'Только snake_case: a-z, 0-9, _; начинается с буквы',
|
'schema.nameInvalid': 'Только snake_case: a-z, 0-9, _; начинается с буквы',
|
||||||
@@ -536,6 +540,10 @@ i18n
|
|||||||
'schema.tabs.metadata': 'Metadata',
|
'schema.tabs.metadata': 'Metadata',
|
||||||
'schema.tabs.schema': 'Fields',
|
'schema.tabs.schema': 'Fields',
|
||||||
'schema.tabs.preview': 'JSON',
|
'schema.tabs.preview': 'JSON',
|
||||||
|
'schema.tabs.events': 'Events',
|
||||||
|
'schema.events.intro': 'Preview of JSON events that will be published to Kafka after the dictionary is saved. Data is filled in based on schema field types. Useful for integrators (Altum, Geoportal) to understand the shape before going live.',
|
||||||
|
'schema.events.topic': 'Topic',
|
||||||
|
'schema.events.disclaimer': 'This is a client-side preview. Real events contain actual data + traceId/spanId from request scope. The events ordinis-events-api/src/main/java/.../events/ is the single source of truth.',
|
||||||
'schema.name': 'Name',
|
'schema.name': 'Name',
|
||||||
'schema.nameHint': 'snake_case, starts with letter (e.g. my_dictionary)',
|
'schema.nameHint': 'snake_case, starts with letter (e.g. my_dictionary)',
|
||||||
'schema.nameInvalid': 'Only snake_case: a-z, 0-9, _; must start with letter',
|
'schema.nameInvalid': 'Only snake_case: a-z, 0-9, _; must start with letter',
|
||||||
|
|||||||
Reference in New Issue
Block a user