feat(changelog): wire up History tab to /changelog endpoint + diff modal
This commit is contained in:
@@ -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'
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user