feat(changelog): wire up History tab to /changelog endpoint + diff modal
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<ChangelogResponse> => {
|
||||
const { data } = await apiClient.get<ChangelogResponse>(
|
||||
`/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<ChangelogDiff> => {
|
||||
const { data } = await apiClient.get<ChangelogDiff>(
|
||||
`/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,
|
||||
|
||||
@@ -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 открыт.
|
||||
*
|
||||
* <p>Layout:
|
||||
* <ul>
|
||||
* <li>Header — event metadata (kind badge, version, дата, автор).</li>
|
||||
* <li>Summary stripe — counts added/removed/changed properties.</li>
|
||||
* <li>Two-column JSON panes (before | after). Pre-monospace, scrollable.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>На 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 (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
size="full"
|
||||
title={t('changelog.diff.title', { defaultValue: 'Изменения схемы' })}
|
||||
description={
|
||||
data
|
||||
? `${data.dictionaryName} · ${new Date(data.occurredAt).toLocaleString()}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||
{error && (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(error)}
|
||||
</Alert>
|
||||
)}
|
||||
{data && (
|
||||
<div className="space-y-4">
|
||||
{/* Header strip — kind + author */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={kindBadgeVariant(data.kind)}>{kindLabel(data.kind, t)}</Badge>
|
||||
<span className="text-cell text-mute">
|
||||
{t('changelog.diff.by', { defaultValue: 'автор' })}:{' '}
|
||||
<span className="text-ink font-mono">{data.publishedBy.name}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary counts */}
|
||||
<ChangelogSummary
|
||||
added={data.summary.added.length}
|
||||
removed={data.summary.removed.length}
|
||||
changed={data.summary.changed.length}
|
||||
/>
|
||||
|
||||
{/* Field-level lists (collapsed by default — info dense) */}
|
||||
{(data.summary.added.length > 0 ||
|
||||
data.summary.removed.length > 0 ||
|
||||
data.summary.changed.length > 0) && (
|
||||
<div className="rounded-lg border border-line bg-surface p-3 space-y-2 text-cell">
|
||||
{data.summary.added.length > 0 && (
|
||||
<PropList
|
||||
label={t('changelog.diff.added', { defaultValue: 'Добавлены' })}
|
||||
items={data.summary.added}
|
||||
tone="success"
|
||||
/>
|
||||
)}
|
||||
{data.summary.removed.length > 0 && (
|
||||
<PropList
|
||||
label={t('changelog.diff.removed', { defaultValue: 'Удалены' })}
|
||||
items={data.summary.removed}
|
||||
tone="danger"
|
||||
/>
|
||||
)}
|
||||
{data.summary.changed.length > 0 && (
|
||||
<PropList
|
||||
label={t('changelog.diff.changed', { defaultValue: 'Изменены' })}
|
||||
items={data.summary.changed}
|
||||
tone="warn"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Side-by-side JSON */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<JsonPane
|
||||
title={t('changelog.diff.before', { defaultValue: 'До' })}
|
||||
data={data.before}
|
||||
empty={t('changelog.diff.beforeEmpty', {
|
||||
defaultValue: '— (первоначальное создание словаря)',
|
||||
})}
|
||||
/>
|
||||
<JsonPane
|
||||
title={t('changelog.diff.after', { defaultValue: 'После' })}
|
||||
data={data.after}
|
||||
empty={t('changelog.diff.afterEmpty', {
|
||||
defaultValue: '— (нет proposed schema для этого события)',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function ChangelogSummary({
|
||||
added,
|
||||
removed,
|
||||
changed,
|
||||
}: {
|
||||
added: number
|
||||
removed: number
|
||||
changed: number
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 text-mono text-cap tabular-nums">
|
||||
<SummaryChip label="+" count={added} tone="success" />
|
||||
<SummaryChip label="−" count={removed} tone="danger" />
|
||||
<SummaryChip label="~" count={changed} tone="warn" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={`inline-flex items-center gap-1 ${cls}`}>
|
||||
<span className="font-bold">{label}</span>
|
||||
<span>{count}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="text-cap font-semibold uppercase tracking-wider text-mute mb-1">
|
||||
{label} ({items.length})
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{items.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-sm border border-line bg-surface-2/40"
|
||||
>
|
||||
<span className={`size-1.5 rounded-full ${dotCls}`} aria-hidden />
|
||||
<code className="font-mono text-cell">{p}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function JsonPane({
|
||||
title,
|
||||
data,
|
||||
empty,
|
||||
}: {
|
||||
title: string
|
||||
data: unknown | null
|
||||
empty: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-surface overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-line text-cap font-semibold uppercase tracking-wider text-mute">
|
||||
{title}
|
||||
</div>
|
||||
<pre className="text-mono text-cell p-3 overflow-auto max-h-[60vh] bg-[#0c0a23] text-[#f0eaff]">
|
||||
{data === null || data === undefined ? empty : JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<ChangelogKind, string> = {
|
||||
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'
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Backend: GET /api/v1/dictionaries/{name}/changelog → ChangelogResponse.
|
||||
* Diff endpoint lazy fetch'ится в modal.
|
||||
*
|
||||
* <p>Empty state — для свежесозданных словарей без update'ов.
|
||||
*/
|
||||
function HistoryTabContent({ detail }: { detail?: import('@/api/client').DictionaryDetail }) {
|
||||
const { t } = useTranslation()
|
||||
const [diffEntryId, setDiffEntryId] = useState<number | undefined>(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 (
|
||||
<div className="space-y-3">
|
||||
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||
{error && (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(error)}
|
||||
</Alert>
|
||||
)}
|
||||
{data && data.entries.length === 0 && (
|
||||
<EmptyState
|
||||
title={t('changelog.empty.title', {
|
||||
defaultValue: 'История пуста',
|
||||
})}
|
||||
description={t('changelog.empty.description', {
|
||||
defaultValue: 'Изменения схемы и события workflow появятся здесь.',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{data && data.entries.length > 0 && (
|
||||
<ol className="relative border-l-2 border-line pl-5 space-y-3">
|
||||
{data.entries.map((entry) => (
|
||||
<ChangelogTimelineItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onOpenDiff={() => setDiffEntryId(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
<ChangelogDiffModal
|
||||
open={diffEntryId !== undefined}
|
||||
onClose={() => setDiffEntryId(undefined)}
|
||||
dictionaryName={detail.name}
|
||||
entryId={diffEntryId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-line bg-surface p-4">
|
||||
<li className="relative">
|
||||
{/* Timeline dot marker — kind-coloured */}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -left-[27px] top-1.5 size-3 rounded-full border-2 bg-surface',
|
||||
entry.isCurrent ? 'border-accent' : 'border-line-2',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="rounded-lg border border-line bg-surface p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* HEAD dot marker — filled circle */}
|
||||
<div className="shrink-0 size-3 rounded-full bg-ink mt-1.5" aria-hidden />
|
||||
<KindIcon weight="duotone" size={20} className="text-mute shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<span className="text-mono text-title-md text-ink font-semibold">
|
||||
v{detail.schemaVersion}
|
||||
</span>
|
||||
<span className="text-cap font-semibold tracking-wider px-1.5 py-0.5 rounded-sm bg-ink text-on-accent uppercase">
|
||||
HEAD
|
||||
</span>
|
||||
{showVersion && (
|
||||
<span className="text-mono text-title-sm text-ink font-semibold tabular-nums">
|
||||
v{entry.version}
|
||||
</span>
|
||||
)}
|
||||
<Badge variant={kindBadgeVariant(entry.kind)}>
|
||||
{kindLabel(entry.kind, t)}
|
||||
</Badge>
|
||||
{entry.isCurrent && (
|
||||
<Badge variant="primary">
|
||||
{t('history.current', { defaultValue: 'текущая' })}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-mono text-cell text-mute tabular-nums ml-auto">
|
||||
+{propsCount} ~0 −0
|
||||
{added > 0 && <span className="text-aurora">+{added}</span>}
|
||||
{added > 0 && (changed > 0 || removed > 0) && ' '}
|
||||
{changed > 0 && <span className="text-warn">~{changed}</span>}
|
||||
{changed > 0 && removed > 0 && ' '}
|
||||
{removed > 0 && <span className="text-danger">−{removed}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-body text-ink mt-1">
|
||||
{detail.description ?? t('history.placeholder.title', {
|
||||
defaultValue: 'Текущая версия схемы',
|
||||
})}
|
||||
{entry.headers && (
|
||||
<div className="text-body text-ink mt-1 truncate">{entry.headers}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1 text-cell text-mute">
|
||||
<span className="tabular-nums">{occurredAt.toLocaleString()}</span>
|
||||
<span className="font-mono">{entry.publishedBy.name}</span>
|
||||
{entry.recordsAffected > 0 && (
|
||||
<span>
|
||||
{t('changelog.recordsAffected', {
|
||||
count: entry.recordsAffected,
|
||||
defaultValue: `${entry.recordsAffected} запис.`,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-cell text-mute mt-0.5 tabular-nums">
|
||||
{updatedAt.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenDiff}
|
||||
>
|
||||
{t('changelog.viewDiff', { defaultValue: 'Сравнить' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backend-pending notice */}
|
||||
<div className="rounded-lg border border-dashed border-line-2 bg-surface-2/30 p-4 text-cap text-mute tracking-wider uppercase text-center">
|
||||
{t('history.pending', {
|
||||
defaultValue:
|
||||
'Полный timeline — backend `/api/v1/dictionaries/{name}/changelog` pending',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user