feat(timetravel): «Что изменилось» tab — закрывает «backend pending» placeholder
This commit is contained in:
@@ -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,26 +282,25 @@ 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' && (
|
||||||
|
<div className="flex items-center justify-center text-mute text-body">
|
||||||
<span>
|
<span>
|
||||||
{pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '}
|
{pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '}
|
||||||
{totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')}
|
{totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{tab === 'schema' && (
|
{tab === 'schema' && (
|
||||||
|
<div className="flex items-center justify-center text-mute text-body">
|
||||||
<span>
|
<span>
|
||||||
v{detail.schemaVersion} ·{' '}
|
v{detail.schemaVersion} ·{' '}
|
||||||
{Object.keys(detail.schemaJson.properties ?? {}).length}{' '}
|
{Object.keys(detail.schemaJson.properties ?? {}).length}{' '}
|
||||||
@@ -295,9 +311,18 @@ export function TimeTravelModal({
|
|||||||
'полей',
|
'полей',
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user