From 57be0d88d37060aba2150401b6d487189b20bcc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Tue, 12 May 2026 10:49:53 +0000 Subject: [PATCH] feat(changelog): wire up History tab to /changelog endpoint + diff modal --- ordinis-admin-ui/src/api/client.ts | 72 ++++++ ordinis-admin-ui/src/api/queries.ts | 54 +++++ .../changelog/ChangelogDiffModal.tsx | 219 ++++++++++++++++++ .../src/components/changelog/kind-meta.tsx | 96 ++++++++ ordinis-admin-ui/src/i18n.ts | 49 ++++ .../src/routes/dictionaries.$name.tsx | 162 ++++++++++--- 6 files changed, 614 insertions(+), 38 deletions(-) create mode 100644 ordinis-admin-ui/src/components/changelog/ChangelogDiffModal.tsx create mode 100644 ordinis-admin-ui/src/components/changelog/kind-meta.tsx diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 5a3ecca..0071f8a 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -482,3 +482,75 @@ export type WebhookTestPingResult = { latencyMs?: number | null message?: string | null } + +// === Changelog (v2.4.0 + full timeline + diff) === + +/** + * Frontend-friendly event kind. Backend mapping см. + * ChangelogService.mapEventTypeToKind. Используем для icon/color/label + * выбора в History tab. Versioning events + * (definition_create / definition_update / draft_publish) показываются + * с version label; promotional draft events идут без version. + */ +export type ChangelogKind = + | 'definition_create' + | 'definition_update' + | 'draft_create' + | 'draft_review' + | 'draft_approve' + | 'draft_reject' + | 'draft_changes_requested' + | 'draft_publish' + | 'draft_withdraw' + +export type ChangelogPublisher = { + /** JWT sub claim (Keycloak user id). */ + sub: string + /** Display name. Backend пока эхает sub; готовим место под user-resolve. */ + name: string +} + +export type ChangelogDiffSummary = { + added: string[] + removed: string[] + changed: string[] +} + +export type ChangelogEntry = { + /** audit_log.id — deep-link на diff endpoint. */ + id: number + /** Semver. null для promotional draft events. */ + version: string | null + publishedAt: string + publishedBy: ChangelogPublisher + /** Header summary text для card UI. */ + headers: string + diff: ChangelogDiffSummary + recordsAffected: number + isCurrent: boolean + kind: ChangelogKind +} + +export type ChangelogResponse = { + dictionary: string + currentVersion: string + entries: ChangelogEntry[] +} + +/** + * Full before/after schemas для side-by-side render. before=null для + * definition_create. Для draft_* (не publish) — after=proposed_schema, + * before=live schema на момент event'а (то что предлагалось vs реальность). + */ +export type ChangelogDiff = { + id: number + dictionaryName: string + /** Raw audit event_type (SCHEMA_UPDATED / SCHEMA_DRAFT_PUBLISHED / ...). */ + eventType: string + kind: ChangelogKind + occurredAt: string + publishedBy: ChangelogPublisher + before: unknown | null + after: unknown | null + summary: ChangelogDiffSummary +} diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 1c29d69..b4286ed 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -4,6 +4,8 @@ import { type AuditFilters, type AuditPage, type CascadePlan, + type ChangelogDiff, + type ChangelogResponse, type DictionaryDefinition, type DictionaryDetail, type DlqPage, @@ -138,6 +140,58 @@ export const useRecordHistory = ( enabled: Boolean(businessKey), }) +// === Changelog (schema versions timeline) ============================ + +/** + * Per-dict changelog. Backend default limit=50; покрывает типичные ~5-30 + * schema versions без pagination. Cursor можно добавить позже без + * breaking change. + */ +export const changelogQuery = (dictionaryName: string, limit = 50) => + queryOptions({ + queryKey: ['changelog', dictionaryName, limit] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + `/dictionaries/${dictionaryName}/changelog`, + { params: { limit } }, + ) + return data + }, + }) + +export const useChangelog = (dictionaryName: string | undefined, limit = 50) => + useQuery({ + ...changelogQuery(dictionaryName ?? '', limit), + enabled: Boolean(dictionaryName), + }) + +/** + * Full before/after diff payload для одного changelog entry'я. Lazy — + * fetch только когда юзер раскрывает Compare modal. Кешируем по + * (dict, entryId) — diff immutable, можно держать stale infinitely + * (audit_log row не меняется). + */ +export const changelogDiffQuery = (dictionaryName: string, entryId: number) => + queryOptions({ + queryKey: ['changelog-diff', dictionaryName, entryId] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + `/dictionaries/${dictionaryName}/changelog/${entryId}/diff`, + ) + return data + }, + staleTime: Infinity, + }) + +export const useChangelogDiff = ( + dictionaryName: string | undefined, + entryId: number | undefined, +) => + useQuery({ + ...changelogDiffQuery(dictionaryName ?? '', entryId ?? 0), + enabled: Boolean(dictionaryName) && Boolean(entryId), + }) + export const recordRawQuery = (dictionaryName: string, businessKey: string) => queryOptions({ queryKey: ['record-raw', dictionaryName, businessKey] as const, diff --git a/ordinis-admin-ui/src/components/changelog/ChangelogDiffModal.tsx b/ordinis-admin-ui/src/components/changelog/ChangelogDiffModal.tsx new file mode 100644 index 0000000..8e53957 --- /dev/null +++ b/ordinis-admin-ui/src/components/changelog/ChangelogDiffModal.tsx @@ -0,0 +1,219 @@ +import { useTranslation } from 'react-i18next' +import { Alert, Badge, LoadingBlock, Modal } from '@/ui' +import { useChangelogDiff } from '@/api/queries' +import { kindBadgeVariant, kindLabel } from './kind-meta' + +/** + * Side-by-side schema diff для одного changelog event'а. Lazy fetch'ит + * полный before/after через `useChangelogDiff` когда modal открыт. + * + *

Layout: + *

    + *
  • Header — event metadata (kind badge, version, дата, автор).
  • + *
  • Summary stripe — counts added/removed/changed properties.
  • + *
  • Two-column JSON panes (before | after). Pre-monospace, scrollable.
  • + *
+ * + *

На narrow viewport панели stack'аются вертикально. + */ +export function ChangelogDiffModal({ + open, + onClose, + dictionaryName, + entryId, +}: { + open: boolean + onClose: () => void + dictionaryName: string + entryId: number | undefined +}) { + const { t } = useTranslation() + const { data, isLoading, error } = useChangelogDiff( + open ? dictionaryName : undefined, + open ? entryId : undefined, + ) + + return ( + + {isLoading && } + {error && ( + + {String(error)} + + )} + {data && ( +

+ {/* Header strip — kind + author */} +
+ {kindLabel(data.kind, t)} + + {t('changelog.diff.by', { defaultValue: 'автор' })}:{' '} + {data.publishedBy.name} + +
+ + {/* Summary counts */} + + + {/* Field-level lists (collapsed by default — info dense) */} + {(data.summary.added.length > 0 || + data.summary.removed.length > 0 || + data.summary.changed.length > 0) && ( +
+ {data.summary.added.length > 0 && ( + + )} + {data.summary.removed.length > 0 && ( + + )} + {data.summary.changed.length > 0 && ( + + )} +
+ )} + + {/* Side-by-side JSON */} +
+ + +
+
+ )} + + ) +} + +function ChangelogSummary({ + added, + removed, + changed, +}: { + added: number + removed: number + changed: number +}) { + return ( +
+ + + +
+ ) +} + +function SummaryChip({ + label, + count, + tone, +}: { + label: string + count: number + tone: 'success' | 'danger' | 'warn' +}) { + const cls = + tone === 'success' + ? 'text-aurora' + : tone === 'danger' + ? 'text-danger' + : 'text-warn' + return ( + + {label} + {count} + + ) +} + +function PropList({ + label, + items, + tone, +}: { + label: string + items: string[] + tone: 'success' | 'danger' | 'warn' +}) { + const dotCls = + tone === 'success' + ? 'bg-aurora' + : tone === 'danger' + ? 'bg-danger' + : 'bg-warn' + return ( +
+
+ {label} ({items.length}) +
+
    + {items.map((p) => ( +
  • + + {p} +
  • + ))} +
+
+ ) +} + +function JsonPane({ + title, + data, + empty, +}: { + title: string + data: unknown | null + empty: string +}) { + return ( +
+
+ {title} +
+
+        {data === null || data === undefined ? empty : JSON.stringify(data, null, 2)}
+      
+
+ ) +} diff --git a/ordinis-admin-ui/src/components/changelog/kind-meta.tsx b/ordinis-admin-ui/src/components/changelog/kind-meta.tsx new file mode 100644 index 0000000..f2f26e6 --- /dev/null +++ b/ordinis-admin-ui/src/components/changelog/kind-meta.tsx @@ -0,0 +1,96 @@ +import { + PencilSimpleIcon, + PlusCircleIcon, + CheckCircleIcon, + XCircleIcon, + ArrowsCounterClockwiseIcon, + RocketIcon, + TrashIcon, + ChatCircleTextIcon, + EyeIcon, + type Icon, +} from '@phosphor-icons/react' +import type { ChangelogKind } from '@/api/client' +import type { BadgeProps } from '@/ui' + +/** + * Metadata mapping для ChangelogKind: icon, badge variant, локализуемый + * label. Один источник для History tab card + Diff modal header. + * + * Versioning events (definition_*, draft_publish) и draft promotional + * events различаются tone'ом — promotional рендерятся neutral/info чтобы + * не конкурировать с major version bump'ом визуально. + */ + +type TFn = (key: string, opts?: { defaultValue?: string }) => string + +export function kindIcon(kind: ChangelogKind): Icon { + switch (kind) { + case 'definition_create': + return PlusCircleIcon + case 'definition_update': + return PencilSimpleIcon + case 'draft_create': + return ChatCircleTextIcon + case 'draft_review': + return EyeIcon + case 'draft_approve': + return CheckCircleIcon + case 'draft_reject': + return XCircleIcon + case 'draft_changes_requested': + return ArrowsCounterClockwiseIcon + case 'draft_publish': + return RocketIcon + case 'draft_withdraw': + return TrashIcon + } +} + +export function kindBadgeVariant(kind: ChangelogKind): BadgeProps['variant'] { + switch (kind) { + case 'definition_create': + case 'draft_publish': + return 'primary' + case 'definition_update': + return 'info' + case 'draft_approve': + return 'success' + case 'draft_reject': + return 'danger' + case 'draft_changes_requested': + return 'warning' + case 'draft_create': + case 'draft_review': + case 'draft_withdraw': + return 'neutral' + } +} + +export function kindLabel(kind: ChangelogKind, t: TFn): string { + const fallbackByKind: Record = { + definition_create: 'Создан словарь', + definition_update: 'Изменена схема', + draft_create: 'Черновик создан', + draft_review: 'Отправлен на ревью', + draft_approve: 'Одобрен', + draft_reject: 'Отклонён', + draft_changes_requested: 'Запрошены правки', + draft_publish: 'Опубликован', + draft_withdraw: 'Отозван', + } + return t(`changelog.kind.${kind}`, { defaultValue: fallbackByKind[kind] }) +} + +/** + * Versioning events меняют semver/HEAD словаря. Promotional draft events + * (review/approve/reject/changes_requested/withdraw) меняют только + * workflow state — без bump'а версии. + */ +export function isVersioningKind(kind: ChangelogKind): boolean { + return ( + kind === 'definition_create' || + kind === 'definition_update' || + kind === 'draft_publish' + ) +} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 19b0f2f..758109d 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -398,6 +398,31 @@ i18n 'history.revert': 'Откатить к v{{v}}', 'history.revertConfirm': 'Откатить к v{{v}}? Создастся новая активная версия.', 'history.singleVersionHint': 'Это единственная версия. Кнопки «Сравнить» и «Откатить» появятся, когда у записи будет более одной версии.', + // === Changelog (schema timeline + diff) === + 'changelog.empty.title': 'История пуста', + 'changelog.empty.description': 'Изменения схемы и события workflow появятся здесь.', + 'changelog.viewDiff': 'Сравнить', + 'changelog.recordsAffected_one': '{{count}} запись', + 'changelog.recordsAffected_few': '{{count}} записи', + 'changelog.recordsAffected_many': '{{count}} записей', + 'changelog.diff.title': 'Изменения схемы', + 'changelog.diff.before': 'До', + 'changelog.diff.after': 'После', + 'changelog.diff.beforeEmpty': '— (первоначальное создание словаря)', + 'changelog.diff.afterEmpty': '— (нет proposed schema для этого события)', + 'changelog.diff.added': 'Добавлены', + 'changelog.diff.removed': 'Удалены', + 'changelog.diff.changed': 'Изменены', + 'changelog.diff.by': 'автор', + 'changelog.kind.definition_create': 'Создан словарь', + 'changelog.kind.definition_update': 'Изменена схема', + 'changelog.kind.draft_create': 'Черновик создан', + 'changelog.kind.draft_review': 'Отправлен на ревью', + 'changelog.kind.draft_approve': 'Одобрен', + 'changelog.kind.draft_reject': 'Отклонён', + 'changelog.kind.draft_changes_requested': 'Запрошены правки', + 'changelog.kind.draft_publish': 'Опубликован', + 'changelog.kind.draft_withdraw': 'Отозван', // === Lineage (dict-relationships-v2 Phase 1) === 'lineage.usedBy.title': 'На этот словарь ссылаются', 'lineage.usedBy.count_one': '{{count}} связь', @@ -913,6 +938,30 @@ i18n 'history.revert': 'Revert to v{{v}}', 'history.revertConfirm': 'Revert to v{{v}}? A new active version will be created.', 'history.singleVersionHint': 'This is the only version. Compare and Revert actions will appear once the record has more than one version.', + // === Changelog (schema timeline + diff) === + 'changelog.empty.title': 'No history yet', + 'changelog.empty.description': 'Schema changes and workflow events will appear here.', + 'changelog.viewDiff': 'Compare', + 'changelog.recordsAffected_one': '{{count}} record', + 'changelog.recordsAffected_other': '{{count}} records', + 'changelog.diff.title': 'Schema changes', + 'changelog.diff.before': 'Before', + 'changelog.diff.after': 'After', + 'changelog.diff.beforeEmpty': '— (dictionary creation, no prior state)', + 'changelog.diff.afterEmpty': '— (no proposed schema for this event)', + 'changelog.diff.added': 'Added', + 'changelog.diff.removed': 'Removed', + 'changelog.diff.changed': 'Changed', + 'changelog.diff.by': 'by', + 'changelog.kind.definition_create': 'Dictionary created', + 'changelog.kind.definition_update': 'Schema updated', + 'changelog.kind.draft_create': 'Draft created', + 'changelog.kind.draft_review': 'Sent for review', + 'changelog.kind.draft_approve': 'Approved', + 'changelog.kind.draft_reject': 'Rejected', + 'changelog.kind.draft_changes_requested': 'Changes requested', + 'changelog.kind.draft_publish': 'Published', + 'changelog.kind.draft_withdraw': 'Withdrawn', // === Lineage (dict-relationships-v2 Phase 1) === 'lineage.usedBy.title': 'Used by', 'lineage.usedBy.count_one': '{{count}} reference', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 4908cc6..879ed71 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -28,6 +28,7 @@ import { import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon } from '@phosphor-icons/react' import { useAudit, + useChangelog, useDictionaryDetail, useDictPendingDrafts, useRecordRaw, @@ -50,6 +51,8 @@ import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel' import { ExportModal } from '@/components/editor/ExportModal' import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog' import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal' +import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal' +import { kindBadgeVariant, kindIcon, kindLabel, isVersioningKind } from '@/components/changelog/kind-meta' import { nowIsoLocal } from '@/lib/dates' import { recordDisplayName } from '@/lib/locales' import { SCOPE_BORDER_TOP } from '@/lib/scope-style' @@ -2073,61 +2076,144 @@ function EventsTabContent({ dictName }: { dictName: string }) { ) } -/** History tab — placeholder (Stage 3.x followup, требует backend changelog endpoint). */ +/** + * History tab — schema changelog timeline. Версионные events (создание + * словаря, schema update, draft publish) рендерятся с version badge; + * promotional draft events (review/approve/reject/changes_requested/ + * withdraw) — без version, neutral tone. Каждый entry имеет «Сравнить» + * который открывает {@link ChangelogDiffModal} c full before/after. + * + *

Backend: GET /api/v1/dictionaries/{name}/changelog → ChangelogResponse. + * Diff endpoint lazy fetch'ится в modal. + * + *

Empty state — для свежесозданных словарей без update'ов. + */ function HistoryTabContent({ detail }: { detail?: import('@/api/client').DictionaryDetail }) { const { t } = useTranslation() + const [diffEntryId, setDiffEntryId] = useState(undefined) + const { data, isLoading, error } = useChangelog(detail?.name) + if (!detail) return null - // Placeholder version timeline пока backend не выкатил - // `/api/v1/dictionaries/{name}/versions` endpoint. Single row для текущей - // schema version (HEAD). Когда backend появится — рендерим full timeline. - const propsCount = Object.keys(detail.schemaJson.properties ?? {}).length - const updatedAt = new Date(detail.updatedAt) + return ( +

+ {isLoading && } + {error && ( + + {String(error)} + + )} + {data && data.entries.length === 0 && ( + + )} + {data && data.entries.length > 0 && ( +
    + {data.entries.map((entry) => ( + setDiffEntryId(entry.id)} + /> + ))} +
+ )} + setDiffEntryId(undefined)} + dictionaryName={detail.name} + entryId={diffEntryId} + /> +
+ ) +} + +function ChangelogTimelineItem({ + entry, + onOpenDiff, +}: { + entry: import('@/api/client').ChangelogEntry + onOpenDiff: () => void +}) { + const { t } = useTranslation() + const KindIcon = kindIcon(entry.kind) + const occurredAt = new Date(entry.publishedAt) + const showVersion = isVersioningKind(entry.kind) && entry.version + const added = entry.diff.added.length + const changed = entry.diff.changed.length + const removed = entry.diff.removed.length return ( -
-
+
  • + {/* Timeline dot marker — kind-coloured */} + +
    - {/* HEAD dot marker — filled circle */} -
    +
    - - v{detail.schemaVersion} - - - HEAD - + {showVersion && ( + + v{entry.version} + + )} + + {kindLabel(entry.kind, t)} + + {entry.isCurrent && ( + + {t('history.current', { defaultValue: 'текущая' })} + + )} - +{propsCount} ~0 −0 + {added > 0 && +{added}} + {added > 0 && (changed > 0 || removed > 0) && ' '} + {changed > 0 && ~{changed}} + {changed > 0 && removed > 0 && ' '} + {removed > 0 && −{removed}}
    -
    - {detail.description ?? t('history.placeholder.title', { - defaultValue: 'Текущая версия схемы', - })} + {entry.headers && ( +
    {entry.headers}
    + )} +
    + {occurredAt.toLocaleString()} + {entry.publishedBy.name} + {entry.recordsAffected > 0 && ( + + {t('changelog.recordsAffected', { + count: entry.recordsAffected, + defaultValue: `${entry.recordsAffected} запис.`, + })} + + )}
    -
    - {updatedAt.toLocaleString(undefined, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - })} +
    +
    - - {/* Backend-pending notice */} -
    - {t('history.pending', { - defaultValue: - 'Полный timeline — backend `/api/v1/dictionaries/{name}/changelog` pending', - })} -
    -
    +
  • ) }