From 3dbad1e16ce63cdd1a26e12bcedfcf67b54891cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Tue, 12 May 2026 12:45:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(timetravel):=20=C2=AB=D0=A7=D1=82=D0=BE=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8=D0=BB=D0=BE=D1=81=D1=8C?= =?UTF-8?q?=C2=BB=20tab=20=E2=80=94=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=C2=ABbackend=20pending=C2=BB=20placeholde?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/timetravel/TimeTravelModal.tsx | 191 +++++++++++++++--- ordinis-admin-ui/src/i18n.ts | 4 + 2 files changed, 165 insertions(+), 30 deletions(-) diff --git a/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx b/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx index d6c6e9a..031171f 100644 --- a/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx +++ b/ordinis-admin-ui/src/components/timetravel/TimeTravelModal.tsx @@ -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' *
  • Slider: HTML range + overlay version markers (top) + date * markers (bottom). Cursor — dark circle.
  • *
  • Tabs: Что изменилось / Записи на момент / Структура и поля. - * Backend diff endpoints отсутствуют — показываем empty/placeholder - * состояния с подсказкой backend pending.
  • + * «Что изменилось» — wired через {@code useChangelog} + filter + * events с {@code publishedAt >= picked}. Diff button открывает + * {@link ChangelogDiffModal} с full before/after JSON. *
  • Footer: disclaimer + Закрыть / "Открыть v{X} как readonly" CTA
  • * * @@ -70,6 +74,19 @@ export function TimeTravelModal({ const [picked, setPicked] = useState(valueMs) const [step, setStep] = useState('version') const [tab, setTab] = useState<'changes' | 'records' | 'schema'>('changes') + /** Open ChangelogDiffModal для конкретной entry — full before/after JSON. */ + const [diffEntryId, setDiffEntryId] = useState(undefined) + + // Changelog для compare-since-picked: показываем events после picked moment. + // Размер 200 покрывает типичные dict'ы (5-50 events); cursor pagination + // можно добавить когда что-то будет с этим больше. + const changelog = useChangelog(detail.name, 200) + const eventsSincePicked = useMemo(() => { + 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} /> {/* Tab content */} -
    +
    {tab === 'changes' && ( - - {isAtNow - ? t('timeTravel.empty.same', { - defaultValue: 'Между этими версиями нет изменений.', - }) - : t('timeTravel.placeholder.changes', { - defaultValue: - 'Diff endpoint /api/v1/dictionaries/{name}/changelog · backend pending.', - })} - + setDiffEntryId(id)} + /> )} {tab === 'records' && ( - - {pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '} - {totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')} - +
    + + {pickedDate.toLocaleDateString(undefined, { dateStyle: 'long' })} ·{' '} + {totalRecords} {pluralRu(totalRecords, 'запись', 'записи', 'записей')} + +
    )} {tab === 'schema' && ( - - v{detail.schemaVersion} ·{' '} - {Object.keys(detail.schemaJson.properties ?? {}).length}{' '} - {pluralRu( - Object.keys(detail.schemaJson.properties ?? {}).length, - 'поле', - 'поля', - 'полей', - )} - +
    + + v{detail.schemaVersion} ·{' '} + {Object.keys(detail.schemaJson.properties ?? {}).length}{' '} + {pluralRu( + Object.keys(detail.schemaJson.properties ?? {}).length, + 'поле', + 'поля', + 'полей', + )} + +
    )}
    + {/* Per-event diff modal — fires lazy fetch /changelog/{id}/diff. */} + setDiffEntryId(undefined)} + dictionaryName={detail.name} + entryId={diffEntryId} + /> + {/* Footer */}
    @@ -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. + * + *

    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 ( +

    + {t('timeTravel.empty.same', { + defaultValue: 'Между этими версиями нет изменений.', + })} +
    + ) + } + if (loading) { + return ( +
    + {[1, 2, 3].map((i) => ( +
    + ))} +
    + ) + } + if (events.length === 0) { + return ( +
    + {t('timeTravel.empty.noEvents', { + defaultValue: 'Между этими моментами ничего не менялось.', + })} +
    + ) + } + + return ( +
      + {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 ( +
    • + +
      +
      + {e.version && ( + + v{e.version} + + )} + {kindLabel(e.kind, t)} + + {added > 0 && +{added}} + {added > 0 && (changed > 0 || removed > 0) && ' '} + {changed > 0 && ~{changed}} + {changed > 0 && removed > 0 && ' '} + {removed > 0 && −{removed}} + +
      + {e.headers && ( +
      {e.headers}
      + )} +
      + + {new Date(e.publishedAt).toLocaleString()} + + {e.publishedBy.name} + +
      +
      +
    • + ) + })} +
    + ) +} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 73713a9..3489ea5 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -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',