feat(timetravel): «Что изменилось» tab — закрывает «backend pending» placeholder

This commit is contained in:
Александр Зимин
2026-05-12 12:45:55 +00:00
parent 735cc955fc
commit 3dbad1e16c
2 changed files with 165 additions and 30 deletions
@@ -1,8 +1,11 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react'
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui'
import type { DictionaryDetail } from '@/api/client'
import { Badge, Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui'
import type { ChangelogEntry, DictionaryDetail } from '@/api/client'
import { useChangelog } from '@/api/queries'
import { kindBadgeVariant, kindIcon, kindLabel } from '@/components/changelog/kind-meta'
import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal'
import { cn } from '@/lib/utils'
/**
@@ -18,8 +21,9 @@ import { cn } from '@/lib/utils'
* <li><b>Slider</b>: HTML range + overlay version markers (top) + date
* markers (bottom). Cursor — dark circle.</li>
* <li><b>Tabs</b>: Что изменилось / Записи на момент / Структура и поля.
* Backend diff endpoints отсутствуют — показываем empty/placeholder
* состояния с подсказкой backend pending.</li>
* «Что изменилось» — wired через {@code useChangelog} + filter
* events с {@code publishedAt >= picked}. Diff button открывает
* {@link ChangelogDiffModal} с full before/after JSON.</li>
* <li><b>Footer</b>: disclaimer + Закрыть / "Открыть v{X} как readonly" CTA</li>
* </ul>
*
@@ -70,6 +74,19 @@ export function TimeTravelModal({
const [picked, setPicked] = useState<number>(valueMs)
const [step, setStep] = useState<StepMode>('version')
const [tab, setTab] = useState<'changes' | 'records' | 'schema'>('changes')
/** Open ChangelogDiffModal для конкретной entry — full before/after JSON. */
const [diffEntryId, setDiffEntryId] = useState<number | undefined>(undefined)
// Changelog для compare-since-picked: показываем events после picked moment.
// Размер 200 покрывает типичные dict'ы (5-50 events); cursor pagination
// можно добавить когда что-то будет с этим больше.
const changelog = useChangelog(detail.name, 200)
const eventsSincePicked = useMemo<ChangelogEntry[]>(() => {
if (!changelog.data) return []
return changelog.data.entries.filter(
(e) => new Date(e.publishedAt).getTime() >= picked,
)
}, [changelog.data, picked])
// Range: от min(marks, createdAt) до now+padding.
const createdMs = Date.parse(detail.createdAt)
@@ -248,7 +265,7 @@ export function TimeTravelModal({
active={tab === 'changes'}
onClick={() => setTab('changes')}
label={t('timeTravel.tabs.changes', { defaultValue: 'Что изменилось' })}
count={0}
count={eventsSincePicked.length}
/>
<TabBtn
active={tab === 'records'}
@@ -265,39 +282,47 @@ export function TimeTravelModal({
</div>
{/* Tab content */}
<div className="px-6 py-10 min-h-[180px] flex items-center justify-center text-mute text-body">
<div className="px-6 py-6 min-h-[180px]">
{tab === 'changes' && (
<span>
{isAtNow
? t('timeTravel.empty.same', {
defaultValue: 'Между этими версиями нет изменений.',
})
: t('timeTravel.placeholder.changes', {
defaultValue:
'Diff endpoint /api/v1/dictionaries/{name}/changelog · backend pending.',
})}
</span>
<ChangesTabContent
isAtNow={isAtNow}
loading={changelog.isLoading}
events={eventsSincePicked}
onOpenDiff={(id) => setDiffEntryId(id)}
/>
)}
{tab === 'records' && (
<span>
{pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '}
{totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')}
</span>
<div className="flex items-center justify-center text-mute text-body">
<span>
{pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '}
{totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')}
</span>
</div>
)}
{tab === 'schema' && (
<span>
v{detail.schemaVersion} ·{' '}
{Object.keys(detail.schemaJson.properties ?? {}).length}{' '}
{pluralRu(
Object.keys(detail.schemaJson.properties ?? {}).length,
'поле',
'поля',
'полей',
)}
</span>
<div className="flex items-center justify-center text-mute text-body">
<span>
v{detail.schemaVersion} ·{' '}
{Object.keys(detail.schemaJson.properties ?? {}).length}{' '}
{pluralRu(
Object.keys(detail.schemaJson.properties ?? {}).length,
'поле',
'поля',
'полей',
)}
</span>
</div>
)}
</div>
{/* Per-event diff modal — fires lazy fetch /changelog/{id}/diff. */}
<ChangelogDiffModal
open={diffEntryId !== undefined}
onClose={() => setDiffEntryId(undefined)}
dictionaryName={detail.name}
entryId={diffEntryId}
/>
{/* Footer */}
<div className="px-6 py-3 border-t border-line flex items-center justify-between gap-3 mt-auto">
<div className="text-cap text-mute tracking-[0.14em] uppercase">
@@ -555,3 +580,109 @@ function pluralRu(n: number, one: string, few: string, many: string): string {
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few
return many
}
/**
* «Что изменилось» tab — список changelog events которые произошли
* после picked moment. Каждый event с kind-aware icon/badge + summary
* counts + кнопка «Diff» открывает {@link ChangelogDiffModal} с full
* before/after JSON.
*
* <p>isAtNow={true} → empty state «нет изменений». isLoading → skeleton.
* events=[] → «между этими версиями нет изменений».
*/
function ChangesTabContent({
isAtNow,
loading,
events,
onOpenDiff,
}: {
isAtNow: boolean
loading: boolean
events: ChangelogEntry[]
onOpenDiff: (id: number) => void
}) {
const { t } = useTranslation()
if (isAtNow) {
return (
<div className="flex items-center justify-center text-mute text-body">
{t('timeTravel.empty.same', {
defaultValue: 'Между этими версиями нет изменений.',
})}
</div>
)
}
if (loading) {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-12 rounded-lg border border-line bg-surface-2/40 animate-pulse"
/>
))}
</div>
)
}
if (events.length === 0) {
return (
<div className="flex items-center justify-center text-mute text-body">
{t('timeTravel.empty.noEvents', {
defaultValue: 'Между этими моментами ничего не менялось.',
})}
</div>
)
}
return (
<ul className="space-y-2">
{events.map((e) => {
const Icon = kindIcon(e.kind)
const added = e.diff.added.length
const changed = e.diff.changed.length
const removed = e.diff.removed.length
return (
<li
key={e.id}
className="rounded-lg border border-line bg-surface p-3 flex items-start gap-3"
>
<Icon 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">
{e.version && (
<span className="text-mono text-cell text-ink font-semibold tabular-nums">
v{e.version}
</span>
)}
<Badge variant={kindBadgeVariant(e.kind)}>{kindLabel(e.kind, t)}</Badge>
<span className="text-mono text-cap text-mute tabular-nums ml-auto">
{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>
{e.headers && (
<div className="text-body text-ink mt-0.5 truncate">{e.headers}</div>
)}
<div className="flex items-center gap-3 mt-1 text-cell text-mute">
<span className="tabular-nums">
{new Date(e.publishedAt).toLocaleString()}
</span>
<span className="font-mono">{e.publishedBy.name}</span>
<button
type="button"
onClick={() => onOpenDiff(e.id)}
className="ml-auto text-accent hover:underline"
>
{t('timeTravel.action.diff', { defaultValue: 'Diff' })}
</button>
</div>
</div>
</li>
)
})}
</ul>
)
}
+4
View File
@@ -201,6 +201,8 @@ i18n
'queryError.client.body': 'Сервер отклонил запрос.',
'queryError.generic.title': 'Не удалось загрузить данные',
'queryError.generic.body': 'Что-то пошло не так. Попробуйте ещё раз.',
'timeTravel.empty.noEvents': 'Между этими моментами ничего не менялось.',
'timeTravel.action.diff': 'Diff',
'webhooks.deliveries.col.eventId': 'Event ID',
'webhooks.deliveries.col.attempts': 'Попыток',
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
@@ -866,6 +868,8 @@ i18n
'queryError.client.body': 'The server rejected the request.',
'queryError.generic.title': 'Failed to load data',
'queryError.generic.body': 'Something went wrong. Please try again.',
'timeTravel.empty.noEvents': 'Nothing changed between these moments.',
'timeTravel.action.diff': 'Diff',
'webhooks.deliveries.col.eventId': 'Event ID',
'webhooks.deliveries.col.attempts': 'Attempts',
'webhooks.deliveries.col.lastAttempt': 'Last attempt',