feat(changelog): wire up History tab to /changelog endpoint + diff modal

This commit is contained in:
Александр Зимин
2026-05-12 10:49:53 +00:00
parent ba04c2199e
commit 57be0d88d3
6 changed files with 614 additions and 38 deletions
@@ -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'
)
}