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
|
latencyMs?: number | null
|
||||||
message?: string | 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 AuditFilters,
|
||||||
type AuditPage,
|
type AuditPage,
|
||||||
type CascadePlan,
|
type CascadePlan,
|
||||||
|
type ChangelogDiff,
|
||||||
|
type ChangelogResponse,
|
||||||
type DictionaryDefinition,
|
type DictionaryDefinition,
|
||||||
type DictionaryDetail,
|
type DictionaryDetail,
|
||||||
type DlqPage,
|
type DlqPage,
|
||||||
@@ -138,6 +140,58 @@ export const useRecordHistory = (
|
|||||||
enabled: Boolean(businessKey),
|
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) =>
|
export const recordRawQuery = (dictionaryName: string, businessKey: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['record-raw', dictionaryName, businessKey] as const,
|
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.revert': 'Откатить к v{{v}}',
|
||||||
'history.revertConfirm': 'Откатить к v{{v}}? Создастся новая активная версия.',
|
'history.revertConfirm': 'Откатить к v{{v}}? Создастся новая активная версия.',
|
||||||
'history.singleVersionHint': 'Это единственная версия. Кнопки «Сравнить» и «Откатить» появятся, когда у записи будет более одной версии.',
|
'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 (dict-relationships-v2 Phase 1) ===
|
||||||
'lineage.usedBy.title': 'На этот словарь ссылаются',
|
'lineage.usedBy.title': 'На этот словарь ссылаются',
|
||||||
'lineage.usedBy.count_one': '{{count}} связь',
|
'lineage.usedBy.count_one': '{{count}} связь',
|
||||||
@@ -913,6 +938,30 @@ i18n
|
|||||||
'history.revert': 'Revert to v{{v}}',
|
'history.revert': 'Revert to v{{v}}',
|
||||||
'history.revertConfirm': 'Revert to v{{v}}? A new active version will be created.',
|
'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.',
|
'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 (dict-relationships-v2 Phase 1) ===
|
||||||
'lineage.usedBy.title': 'Used by',
|
'lineage.usedBy.title': 'Used by',
|
||||||
'lineage.usedBy.count_one': '{{count}} reference',
|
'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 { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon } from '@phosphor-icons/react'
|
||||||
import {
|
import {
|
||||||
useAudit,
|
useAudit,
|
||||||
|
useChangelog,
|
||||||
useDictionaryDetail,
|
useDictionaryDetail,
|
||||||
useDictPendingDrafts,
|
useDictPendingDrafts,
|
||||||
useRecordRaw,
|
useRecordRaw,
|
||||||
@@ -50,6 +51,8 @@ import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel'
|
|||||||
import { ExportModal } from '@/components/editor/ExportModal'
|
import { ExportModal } from '@/components/editor/ExportModal'
|
||||||
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
||||||
import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal'
|
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 { nowIsoLocal } from '@/lib/dates'
|
||||||
import { recordDisplayName } from '@/lib/locales'
|
import { recordDisplayName } from '@/lib/locales'
|
||||||
import { SCOPE_BORDER_TOP } from '@/lib/scope-style'
|
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 }) {
|
function HistoryTabContent({ detail }: { detail?: import('@/api/client').DictionaryDetail }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [diffEntryId, setDiffEntryId] = useState<number | undefined>(undefined)
|
||||||
|
const { data, isLoading, error } = useChangelog(detail?.name)
|
||||||
|
|
||||||
if (!detail) return null
|
if (!detail) return null
|
||||||
|
|
||||||
// Placeholder version timeline пока backend не выкатил
|
return (
|
||||||
// `/api/v1/dictionaries/{name}/versions` endpoint. Single row для текущей
|
<div className="space-y-3">
|
||||||
// schema version (HEAD). Когда backend появится — рендерим full timeline.
|
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||||
const propsCount = Object.keys(detail.schemaJson.properties ?? {}).length
|
{error && (
|
||||||
const updatedAt = new Date(detail.updatedAt)
|
<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 (
|
return (
|
||||||
<div className="space-y-2">
|
<li className="relative">
|
||||||
<div className="rounded-lg border border-line bg-surface p-4">
|
{/* 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">
|
<div className="flex items-start gap-3">
|
||||||
{/* HEAD dot marker — filled circle */}
|
<KindIcon weight="duotone" size={20} className="text-mute shrink-0 mt-0.5" />
|
||||||
<div className="shrink-0 size-3 rounded-full bg-ink mt-1.5" aria-hidden />
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-baseline gap-2 flex-wrap">
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
<span className="text-mono text-title-md text-ink font-semibold">
|
{showVersion && (
|
||||||
v{detail.schemaVersion}
|
<span className="text-mono text-title-sm text-ink font-semibold tabular-nums">
|
||||||
</span>
|
v{entry.version}
|
||||||
<span className="text-cap font-semibold tracking-wider px-1.5 py-0.5 rounded-sm bg-ink text-on-accent uppercase">
|
</span>
|
||||||
HEAD
|
)}
|
||||||
</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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-body text-ink mt-1">
|
{entry.headers && (
|
||||||
{detail.description ?? t('history.placeholder.title', {
|
<div className="text-body text-ink mt-1 truncate">{entry.headers}</div>
|
||||||
defaultValue: 'Текущая версия схемы',
|
)}
|
||||||
})}
|
<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>
|
||||||
<div className="text-cell text-mute mt-0.5 tabular-nums">
|
<div className="mt-2">
|
||||||
{updatedAt.toLocaleString(undefined, {
|
<Button
|
||||||
year: 'numeric',
|
type="button"
|
||||||
month: '2-digit',
|
variant="ghost"
|
||||||
day: '2-digit',
|
size="sm"
|
||||||
hour: '2-digit',
|
onClick={onOpenDiff}
|
||||||
minute: '2-digit',
|
>
|
||||||
})}
|
{t('changelog.viewDiff', { defaultValue: 'Сравнить' })}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
{/* 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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user