Merge branch 'feat/timetravel-changes-tab-wireup' into 'main'
feat(timetravel): «Что изменилось» tab — закрывает «backend pending» placeholder See merge request 2-6/2-6-4/terravault/ordinis!139
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user