feat(changelog): wire up History tab to /changelog endpoint + diff modal
This commit is contained in:
@@ -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