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 { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react' import { ClockCounterClockwiseIcon, CaretLeftIcon, CaretRightIcon, XIcon } from '@phosphor-icons/react'
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui' import { Badge, Dialog, DialogContent, DialogTitle, DialogDescription } from '@/ui'
import type { DictionaryDetail } from '@/api/client' 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' 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 * <li><b>Slider</b>: HTML range + overlay version markers (top) + date
* markers (bottom). Cursor — dark circle.</li> * markers (bottom). Cursor — dark circle.</li>
* <li><b>Tabs</b>: Что изменилось / Записи на момент / Структура и поля. * <li><b>Tabs</b>: Что изменилось / Записи на момент / Структура и поля.
* Backend diff endpoints отсутствуют — показываем empty/placeholder * «Что изменилось» — wired через {@code useChangelog} + filter
* состояния с подсказкой backend pending.</li> * events с {@code publishedAt >= picked}. Diff button открывает
* {@link ChangelogDiffModal} с full before/after JSON.</li>
* <li><b>Footer</b>: disclaimer + Закрыть / "Открыть v{X} как readonly" CTA</li> * <li><b>Footer</b>: disclaimer + Закрыть / "Открыть v{X} как readonly" CTA</li>
* </ul> * </ul>
* *
@@ -70,6 +74,19 @@ export function TimeTravelModal({
const [picked, setPicked] = useState<number>(valueMs) const [picked, setPicked] = useState<number>(valueMs)
const [step, setStep] = useState<StepMode>('version') const [step, setStep] = useState<StepMode>('version')
const [tab, setTab] = useState<'changes' | 'records' | 'schema'>('changes') 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. // Range: от min(marks, createdAt) до now+padding.
const createdMs = Date.parse(detail.createdAt) const createdMs = Date.parse(detail.createdAt)
@@ -248,7 +265,7 @@ export function TimeTravelModal({
active={tab === 'changes'} active={tab === 'changes'}
onClick={() => setTab('changes')} onClick={() => setTab('changes')}
label={t('timeTravel.tabs.changes', { defaultValue: 'Что изменилось' })} label={t('timeTravel.tabs.changes', { defaultValue: 'Что изменилось' })}
count={0} count={eventsSincePicked.length}
/> />
<TabBtn <TabBtn
active={tab === 'records'} active={tab === 'records'}
@@ -265,39 +282,47 @@ export function TimeTravelModal({
</div> </div>
{/* Tab content */} {/* 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' && ( {tab === 'changes' && (
<span> <ChangesTabContent
{isAtNow isAtNow={isAtNow}
? t('timeTravel.empty.same', { loading={changelog.isLoading}
defaultValue: 'Между этими версиями нет изменений.', events={eventsSincePicked}
}) onOpenDiff={(id) => setDiffEntryId(id)}
: t('timeTravel.placeholder.changes', { />
defaultValue:
'Diff endpoint /api/v1/dictionaries/{name}/changelog · backend pending.',
})}
</span>
)} )}
{tab === 'records' && ( {tab === 'records' && (
<span> <div className="flex items-center justify-center text-mute text-body">
{pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '} <span>
{totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')} {pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '}
</span> {totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')}
</span>
</div>
)} )}
{tab === 'schema' && ( {tab === 'schema' && (
<span> <div className="flex items-center justify-center text-mute text-body">
v{detail.schemaVersion} ·{' '} <span>
{Object.keys(detail.schemaJson.properties ?? {}).length}{' '} v{detail.schemaVersion} ·{' '}
{pluralRu( {Object.keys(detail.schemaJson.properties ?? {}).length}{' '}
Object.keys(detail.schemaJson.properties ?? {}).length, {pluralRu(
'поле', Object.keys(detail.schemaJson.properties ?? {}).length,
'поля', 'поле',
'полей', 'поля',
)} 'полей',
</span> )}
</span>
</div>
)} )}
</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 */} {/* Footer */}
<div className="px-6 py-3 border-t border-line flex items-center justify-between gap-3 mt-auto"> <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"> <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 if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few
return many 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.client.body': 'Сервер отклонил запрос.',
'queryError.generic.title': 'Не удалось загрузить данные', 'queryError.generic.title': 'Не удалось загрузить данные',
'queryError.generic.body': 'Что-то пошло не так. Попробуйте ещё раз.', 'queryError.generic.body': 'Что-то пошло не так. Попробуйте ещё раз.',
'timeTravel.empty.noEvents': 'Между этими моментами ничего не менялось.',
'timeTravel.action.diff': 'Diff',
'webhooks.deliveries.col.eventId': 'Event ID', 'webhooks.deliveries.col.eventId': 'Event ID',
'webhooks.deliveries.col.attempts': 'Попыток', 'webhooks.deliveries.col.attempts': 'Попыток',
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка', 'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
@@ -866,6 +868,8 @@ i18n
'queryError.client.body': 'The server rejected the request.', 'queryError.client.body': 'The server rejected the request.',
'queryError.generic.title': 'Failed to load data', 'queryError.generic.title': 'Failed to load data',
'queryError.generic.body': 'Something went wrong. Please try again.', '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.eventId': 'Event ID',
'webhooks.deliveries.col.attempts': 'Attempts', 'webhooks.deliveries.col.attempts': 'Attempts',
'webhooks.deliveries.col.lastAttempt': 'Last attempt', 'webhooks.deliveries.col.lastAttempt': 'Last attempt',