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
@@ -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>
)
}