From e87b97c940e1eddc413a610e5703c1b424dce6bd Mon Sep 17 00:00:00 2001 From: Marcel Liebischer Date: Tue, 9 Jun 2026 14:42:14 +0200 Subject: [PATCH 01/12] Add CMSUI History route scaffold (#30) Register an @@history route in cmsui that lists content revisions via the plone.restapi @history endpoint and supports reverting to a previous version. Add a History toolbar button in publicui linking to the route, plus en/de/it locale strings and towncrier news fragments. --- packages/cmsui/config/routes.ts | 11 ++++ packages/cmsui/locales/de/common.json | 4 ++ packages/cmsui/locales/en/common.json | 4 ++ packages/cmsui/locales/it/common.json | 4 ++ packages/cmsui/news/30.feature | 1 + packages/cmsui/routes/history.tsx | 78 +++++++++++++++++++++++++++ packages/publicui/news/30.feature | 1 + packages/publicui/routes/index.tsx | 16 +++++- 8 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 packages/cmsui/news/30.feature create mode 100644 packages/cmsui/routes/history.tsx create mode 100644 packages/publicui/news/30.feature diff --git a/packages/cmsui/config/routes.ts b/packages/cmsui/config/routes.ts index cccac5bec..3785bf10a 100644 --- a/packages/cmsui/config/routes.ts +++ b/packages/cmsui/config/routes.ts @@ -49,6 +49,17 @@ export default function install(config: ConfigType) { }, ], }, + { + type: 'prefix', + path: '@@history', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/history.tsx', + }, + ], + }, { type: 'prefix', path: 'controlpanel', diff --git a/packages/cmsui/locales/de/common.json b/packages/cmsui/locales/de/common.json index fcd14d912..bd815c957 100644 --- a/packages/cmsui/locales/de/common.json +++ b/packages/cmsui/locales/de/common.json @@ -71,6 +71,10 @@ "signInTo": "Bei {{site}} anmelden", "forgotPassword": "Passwort vergessen? Neues Passwort anfordern", "returnToHome": "Zur Startseite zurückkehren" + }, + "history": { + "label": "Verlauf", + "revert": "Zurücksetzen" } } } diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json index 46fd15f42..54b274823 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -119,6 +119,10 @@ "signInTo": "Sign in to {{site}}", "forgotPassword": "Forgot password? Request a new password", "returnToHome": "Return to home page" + }, + "history": { + "label": "History", + "revert": "Revert" } } } diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json index 7cd7fb030..34d79cdd6 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -109,6 +109,10 @@ "signInTo": "Accedi a {{site}}", "forgotPassword": "Password dimenticata? Richiedi una nuova password", "returnToHome": "Torna alla home page" + }, + "history": { + "label": "Cronologia", + "revert": "Ripristina" } } } diff --git a/packages/cmsui/news/30.feature b/packages/cmsui/news/30.feature new file mode 100644 index 000000000..96a498d28 --- /dev/null +++ b/packages/cmsui/news/30.feature @@ -0,0 +1 @@ +Added a History route (`@@history`) that lists content revisions and supports reverting to a previous version. diff --git a/packages/cmsui/routes/history.tsx b/packages/cmsui/routes/history.tsx new file mode 100644 index 000000000..b643513a9 --- /dev/null +++ b/packages/cmsui/routes/history.tsx @@ -0,0 +1,78 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; +import { + data, + RouterContextProvider, + useFetcher, + useLoaderData, +} from 'react-router'; +import { requireAuthCookie } from '@plone/react-router'; +import { + ploneClientContext, + ploneContentContext, +} from '@plone/aurora/app/middleware.server'; +import { flattenToAppURL } from '@plone/helpers'; +import { useTranslation } from 'react-i18next'; +import type { GetHistoryResponse } from '@plone/types'; + +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + const content = context.get(ploneContentContext); + const contentPath = content?.['@id'] ?? '/'; + + const { data: history } = await cli.getHistory({ path: contentPath }); + + return data(flattenToAppURL({ content, history })); +} + +export async function action({ + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + const content = context.get(ploneContentContext); + const contentPath = content?.['@id'] ?? '/'; + + const formData = await request.formData(); + const version = Number(formData.get('version')); + + await cli.revertHistory({ path: contentPath, data: { version } }); + + return data({ ok: true }); +} + +export default function History() { + const { content, history } = useLoaderData(); + const { t } = useTranslation(); + const fetcher = useFetcher(); + + // TODO: replace this list with the quanta Table component and format the + // timestamp with @internationalized/date. See Volto's History.jsx for the + // full UX (version comparison/diff, state transitions). + return ( +
+

{t('cmsui.history.label')}

+

{content['@id']}

+
    + {(history as GetHistoryResponse).map((entry, index) => ( +
  • + {entry.actor?.fullname} {entry.transition_title} {entry.time} + {entry.comments ? ({entry.comments}) : null} + {'version' in entry && entry.may_revert ? ( + + + + + ) : null} +
  • + ))} +
+
+ ); +} diff --git a/packages/publicui/news/30.feature b/packages/publicui/news/30.feature new file mode 100644 index 000000000..1535d0314 --- /dev/null +++ b/packages/publicui/news/30.feature @@ -0,0 +1 @@ +Added a History button to the toolbar linking to the `@@history` route. \ No newline at end of file diff --git a/packages/publicui/routes/index.tsx b/packages/publicui/routes/index.tsx index 48b10de27..fea4da981 100644 --- a/packages/publicui/routes/index.tsx +++ b/packages/publicui/routes/index.tsx @@ -25,7 +25,7 @@ import { import i18next from '@plone/aurora/app/i18next.server'; import { ploneContentContext } from '@plone/aurora/app/middleware.server'; import type { RootLoader } from '@plone/aurora/app/root'; -import { FolderIcon } from '@plone/components/Icons'; +import { FolderIcon, HistoryIcon } from '@plone/components/Icons'; import Pencil from '@plone/components/icons/pencil.svg?react'; import SlotRenderer from '@plone/layout/slots/SlotRenderer'; import Toolbar from '@plone/layout/components/Toolbar/Toolbar'; @@ -150,6 +150,20 @@ export default function Index() { + + + + + {showToolbar && }
From c17b489332bd2c62a46a8de87b96338df6ab881d Mon Sep 17 00:00:00 2001 From: Marcel Liebischer Date: Wed, 10 Jun 2026 15:57:27 +0200 Subject: [PATCH 02/12] Refine CMSUI History view with Quanta styling (#30) Extract the route's inline list into a prop-driven HistoryView component styled like the @@contents view (Quanta table, breadcrumb with home icon, status dots, relative timestamps). Replace the immediate revert submit with a confirmation dialog and style the revert menu action as destructive (red). Keep the dialog text stable during the close animation, and hide the History toolbar button on the Plone Site root, which has no history, matching Volto's behavior. --- .../cmsui/components/History/HistoryView.tsx | 297 ++++++++++++++++++ packages/cmsui/locales/de/common.json | 20 +- packages/cmsui/locales/en/common.json | 20 +- packages/cmsui/locales/it/common.json | 20 +- packages/cmsui/routes/history.tsx | 62 ++-- packages/publicui/routes/index.tsx | 19 +- 6 files changed, 396 insertions(+), 42 deletions(-) create mode 100644 packages/cmsui/components/History/HistoryView.tsx diff --git a/packages/cmsui/components/History/HistoryView.tsx b/packages/cmsui/components/History/HistoryView.tsx new file mode 100644 index 000000000..3286db957 --- /dev/null +++ b/packages/cmsui/components/History/HistoryView.tsx @@ -0,0 +1,297 @@ +import { useState } from 'react'; +import { useFetcher } from 'react-router'; +import { useDateFormatter } from 'react-aria'; +import { Heading } from 'react-aria-components'; +import { useTranslation } from 'react-i18next'; +import type { Content, GetHistoryResponse } from '@plone/types'; +import { + Container, + Breadcrumbs, + Breadcrumb, + Table, + TableHeader, + TableBody, + Column, + Row, + Cell, + Menu, + MenuItem, + MenuTrigger, + Button, + Dialog, + Modal, +} from '@plone/components/quanta'; +import { + MoreoptionsIcon, + EyeIcon, + HistoryIcon, + ReviewIcon, + HomeIcon, +} from '@plone/components/Icons'; +import CloseSVG from '@plone/components/icons/close.svg?react'; +import UndoSVG from '@plone/components/icons/undo.svg?react'; + +type HistoryEntry = GetHistoryResponse[number]; + +// The colored status dot, using Quanta theme tokens (mirrors the approach of +// @plone/contents' ReviewState). Versioning (edits) are green; workflow entries +// are colored by their resulting review state (published = blue, private = red). +// TODO: replace with a global, configurable review-state -> color mapping +// shared with @plone/contents' ReviewState (see #30). +function statusDotClass(entry: HistoryEntry): string { + if ('version' in entry) return 'bg-quanta-neon'; + const state = 'review_state' in entry ? entry.review_state : undefined; + if (state === 'published') return 'bg-quanta-cobalt'; + if (state === 'private') return 'bg-quanta-rose'; + return 'bg-quanta-pigeon'; +} + +// Human-friendly relative time ("2 minutes ago") in the active locale, using +// the built-in Intl API so we don't pull in a date library. +// TODO: extract into a shared helper in @plone/helpers (see #30). +function formatRelativeTime(iso: string, locale: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return iso; + const diffSeconds = Math.round((then - Date.now()) / 1000); + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + const units: [Intl.RelativeTimeFormatUnit, number][] = [ + ['year', 31536000], + ['month', 2592000], + ['week', 604800], + ['day', 86400], + ['hour', 3600], + ['minute', 60], + ['second', 1], + ]; + for (const [unit, secondsInUnit] of units) { + if (Math.abs(diffSeconds) >= secondsInUnit || unit === 'second') { + return rtf.format(Math.round(diffSeconds / secondsInUnit), unit); + } + } + return iso; +} + +interface HistoryViewProps { + content: Content; + history: GetHistoryResponse; +} + +export default function HistoryView({ content, history }: HistoryViewProps) { + const { t, i18n } = useTranslation(); + const fetcher = useFetcher(); + const fullDateFormatter = useDateFormatter({ + dateStyle: 'full', + timeStyle: 'short', + }); + + // The version selected for revert (mirrors @plone/contents' DeleteModal: + // controlled state, submit on confirm). The open flag is separate from the + // target data: the modal content must stay stable while the close animation + // is still playing, so the target is never cleared on close. + const [revertTarget, setRevertTarget] = useState<{ + version: number; + time: string; + } | null>(null); + const [isRevertOpen, setIsRevertOpen] = useState(false); + + const submitRevert = () => { + if (!revertTarget) return; + fetcher.submit( + { version: String(revertTarget.version) }, + { method: 'post' }, + ); + setIsRevertOpen(false); + }; + + // Entries arrive newest-first, so the first versioning entry is the current + // revision. The current revision cannot be reverted to itself. + const currentVersion = history.find((entry) => 'version' in entry)?.version; + + // Breadcrumbs from the content's @components.breadcrumbs, mirroring the + // inline Quanta breadcrumb of @plone/contents (ContentsTable) for a + // consistent look. The root item carries the HomeIcon. + // TODO: this duplicates @plone/contents' inline breadcrumb; extract it into a + // shared component so both routes stay visually in sync (see #30). + const breadcrumbs = [ + { + '@id': content['@components']?.breadcrumbs?.root || '/', + title: t('cmsui.history.home'), + icon: , + }, + ...(content['@components']?.breadcrumbs?.items ?? []).map((item) => ({ + '@id': item['@id'], + title: item.title, + })), + ]; + + return ( + +
+
+ + {(item) => ( + + {item.title} + + )} + +

+ {t('cmsui.history.changesTo', { title: content.title })} +

+
+ + + + + {t('cmsui.history.column.action')} + + {t('cmsui.history.column.by')} + {t('cmsui.history.column.time')} + {t('cmsui.history.column.changeNote')} + + {''} + + + + {history.map((entry, index) => { + const versioned = 'version' in entry; + const isCurrent = versioned && entry.version === currentVersion; + return ( + + + + + {entry.transition_title} + + + {entry.actor?.fullname} + + + + {entry.comments} + + {versioned ? ( + + + + + + {t('cmsui.history.reviewChanges')} + + + + {t('cmsui.history.viewRevision')} + + {entry.may_revert && !isCurrent ? ( + { + setRevertTarget({ + version: entry.version, + time: entry.time, + }); + setIsRevertOpen(true); + }} + > + + {t('cmsui.history.revert')} + + ) : null} + + + ) : null} + + + ); + })} + +
+ + + + + {t('cmsui.history.modalRevert.title')} + +

+ {t('cmsui.history.modalRevert.description', { + title: content.title, + time: revertTarget + ? fullDateFormatter.format(new Date(revertTarget.time)) + : '', + })} +

+
+ + +
+
+
+
+
+ ); +} diff --git a/packages/cmsui/locales/de/common.json b/packages/cmsui/locales/de/common.json index bd815c957..6ace5fa13 100644 --- a/packages/cmsui/locales/de/common.json +++ b/packages/cmsui/locales/de/common.json @@ -74,7 +74,25 @@ }, "history": { "label": "Verlauf", - "revert": "Zurücksetzen" + "home": "Startseite", + "back": "Zurück", + "changesTo": "Änderungen an \"{{title}}\"", + "actions": "Aktionen", + "column": { + "action": "Aktion", + "by": "Von", + "time": "Zeit", + "changeNote": "Änderungsnotiz" + }, + "reviewChanges": "Änderungen ansehen", + "viewRevision": "Diese Version ansehen", + "revert": "Auf diese Version zurücksetzen", + "modalRevert": { + "title": "Auf diese Version zurücksetzen?", + "description": "„{{title}}\" wird auf den Stand vom {{time}} zurückgesetzt.", + "cancel": "Abbrechen", + "confirm": "Zurücksetzen" + } } } } diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json index 54b274823..83da2647f 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -122,7 +122,25 @@ }, "history": { "label": "History", - "revert": "Revert" + "home": "Home", + "back": "Back", + "changesTo": "Changes to \"{{title}}\"", + "actions": "Actions", + "column": { + "action": "Action done", + "by": "By", + "time": "Time", + "changeNote": "Change note" + }, + "reviewChanges": "Review changes", + "viewRevision": "View this revision", + "revert": "Revert to this version", + "modalRevert": { + "title": "Revert to this version?", + "description": "\"{{title}}\" will be reset to the state from {{time}}.", + "cancel": "Cancel", + "confirm": "Revert" + } } } } diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json index 34d79cdd6..de667c840 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -112,7 +112,25 @@ }, "history": { "label": "Cronologia", - "revert": "Ripristina" + "home": "Home", + "back": "Indietro", + "changesTo": "Modifiche a \"{{title}}\"", + "actions": "Azioni", + "column": { + "action": "Azione", + "by": "Di", + "time": "Quando", + "changeNote": "Nota di modifica" + }, + "reviewChanges": "Rivedi le modifiche", + "viewRevision": "Vedi questa revisione", + "revert": "Ripristina questa versione", + "modalRevert": { + "title": "Ripristinare questa versione?", + "description": "\"{{title}}\" sarà riportato allo stato del {{time}}.", + "cancel": "Annulla", + "confirm": "Ripristina" + } } } } diff --git a/packages/cmsui/routes/history.tsx b/packages/cmsui/routes/history.tsx index b643513a9..df7ba93d6 100644 --- a/packages/cmsui/routes/history.tsx +++ b/packages/cmsui/routes/history.tsx @@ -1,18 +1,17 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; -import { - data, - RouterContextProvider, - useFetcher, - useLoaderData, -} from 'react-router'; +import { data, RouterContextProvider, useLoaderData } from 'react-router'; +import { Link } from 'react-aria-components'; +import { useTranslation } from 'react-i18next'; import { requireAuthCookie } from '@plone/react-router'; +import { Plug } from '@plone/layout/components/Pluggable'; +import Back from '@plone/components/icons/arrow-left.svg?react'; import { ploneClientContext, ploneContentContext, } from '@plone/aurora/app/middleware.server'; import { flattenToAppURL } from '@plone/helpers'; -import { useTranslation } from 'react-i18next'; -import type { GetHistoryResponse } from '@plone/types'; +import type { Content, GetHistoryResponse } from '@plone/types'; +import HistoryView from '../components/History/HistoryView'; export async function loader({ request, @@ -48,31 +47,32 @@ export async function action({ } export default function History() { - const { content, history } = useLoaderData(); const { t } = useTranslation(); - const fetcher = useFetcher(); + const { content, history } = useLoaderData(); + const typedContent = content as unknown as Content; - // TODO: replace this list with the quanta Table component and format the - // timestamp with @internationalized/date. See Volto's History.jsx for the - // full UX (version comparison/diff, state transitions). return ( -
-

{t('cmsui.history.label')}

-

{content['@id']}

-
    - {(history as GetHistoryResponse).map((entry, index) => ( -
  • - {entry.actor?.fullname} {entry.transition_title} {entry.time} - {entry.comments ? ({entry.comments}) : null} - {'version' in entry && entry.may_revert ? ( - - - - - ) : null} -
  • - ))} -
-
+ <> + + + + + +
+ +
+ ); } diff --git a/packages/publicui/routes/index.tsx b/packages/publicui/routes/index.tsx index fea4da981..a5078b460 100644 --- a/packages/publicui/routes/index.tsx +++ b/packages/publicui/routes/index.tsx @@ -154,15 +154,18 @@ export default function Index() { pluggable="toolbar-top" id="button-history" // @ts-expect-error this is currently typed as never[] - dependencies={[location.pathname]} + dependencies={[location.pathname, content['@type']]} > - - - + {content['@type'] !== 'Plone Site' ? ( + // Plone Site does not have history (yet), same guard as Volto + + + + ) : null} {showToolbar && }
From b7ccf4b35370b8d51d6eaf2f6181fa76f8351439 Mon Sep 17 00:00:00 2001 From: Marcel Liebischer Date: Thu, 11 Jun 2026 10:35:47 +0200 Subject: [PATCH 03/12] Add tests and an a11y fix for the History view (#30) Cover the view with vitest unit tests (status dot and relative-time helpers, rendering, revert confirmation flow, axe accessibility check) and Playwright acceptance tests (listing, revert with confirmation, History toolbar button hidden on the site root). The axe check surfaced an empty actions column header, now labelled via VisuallyHidden text. --- .../cmsui/acceptance/tests/history.test.ts | 110 ++++++++++ .../components/History/HistoryView.test.tsx | 195 ++++++++++++++++++ .../cmsui/components/History/HistoryView.tsx | 10 +- 3 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 packages/cmsui/acceptance/tests/history.test.ts create mode 100644 packages/cmsui/components/History/HistoryView.test.tsx diff --git a/packages/cmsui/acceptance/tests/history.test.ts b/packages/cmsui/acceptance/tests/history.test.ts new file mode 100644 index 000000000..5bcb3aa1a --- /dev/null +++ b/packages/cmsui/acceptance/tests/history.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from '../../../tooling/playwright/test'; +import { login } from '../../../tooling/playwright/login'; +import { createContent } from '../../../tooling/playwright/content'; + +const apiURL = + process.env.API_PATH || + `http://${process.env.BACKEND_HOST || '127.0.0.1'}:55001/${process.env.SITE_ID || 'plone'}`; + +const authHeader = `Basic ${Buffer.from('admin:secret', 'utf8').toString('base64')}`; + +/** + * Opens the history page and waits for hydration: the toolbar back button is + * rendered client-side only (Pluggable), so once it is visible the react-aria + * widgets are interactive. + */ +async function openHistory(page: Parameters[0]) { + await page.goto('/@@history/my-page'); + await expect(page.getByRole('link', { name: 'Back' })).toBeVisible(); +} + +/** Edits the document via the API so a new version is recorded. */ +async function editTitle(page: Parameters[0], title: string) { + const response = await page.request.patch(`${apiURL}/my-page`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: authHeader, + }, + data: { title }, + }); + expect(response.ok()).toBeTruthy(); +} + +test.describe('History route', () => { + test.beforeEach(async ({ page }) => { + await createContent(page, { + contentType: 'Document', + contentId: 'my-page', + contentTitle: 'My Page', + }); + await login(page); + }); + + test('lists the revision history of a document', async ({ page }) => { + await page.goto('/@@history/my-page'); + + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Changes to "My Page"', + ); + // the creation already yields at least one entry + await expect(page.locator('tbody tr').first()).toBeVisible(); + }); + + test('asks for confirmation before reverting', async ({ page }) => { + // two edits, so an older, revertable version exists + await editTitle(page, 'My Page (v2)'); + await editTitle(page, 'My Page (v3)'); + + await openHistory(page); + + // the oldest versioning row is revertable; its menu is the last one + await page.getByRole('button', { name: 'Actions' }).last().click(); + await page + .getByRole('menuitem', { name: 'Revert to this version' }) + .click(); + + // the menu popover is itself a (closing) dialog, so match by name + const dialog = page.getByRole('dialog', { + name: 'Revert to this version?', + }); + await expect(dialog).toBeVisible(); + + // cancelling closes the dialog without changing the content + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog).toBeHidden(); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Changes to "My Page (v3)"', + ); + }); + + test('reverts to a previous version after confirmation', async ({ page }) => { + await editTitle(page, 'My Page (v2)'); + await editTitle(page, 'My Page (v3)'); + + await openHistory(page); + + await page.getByRole('button', { name: 'Actions' }).last().click(); + await page + .getByRole('menuitem', { name: 'Revert to this version' }) + .click(); + const dialog = page.getByRole('dialog', { + name: 'Revert to this version?', + }); + await dialog.getByRole('button', { name: 'Revert' }).click(); + + // the revert adds a new history entry for the restored state + await expect(dialog).toBeHidden(); + await expect(page.locator('tbody tr').first()).toBeVisible(); + }); + + test('hides the History toolbar button on the site root', async ({ + page, + }) => { + await page.goto('/my-page'); + await expect(page.getByRole('link', { name: 'History' })).toBeVisible(); + + await page.goto('/'); + await expect(page.getByRole('link', { name: 'History' })).toBeHidden(); + }); +}); diff --git a/packages/cmsui/components/History/HistoryView.test.tsx b/packages/cmsui/components/History/HistoryView.test.tsx new file mode 100644 index 000000000..d3cdc27ab --- /dev/null +++ b/packages/cmsui/components/History/HistoryView.test.tsx @@ -0,0 +1,195 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { vi, describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { Content, GetHistoryResponse } from '@plone/types'; +import HistoryView, { statusDotClass, formatRelativeTime } from './HistoryView'; + +const submitMock = vi.fn(); + +vi.mock('react-router', () => ({ + useFetcher: vi.fn(() => ({ + submit: (...args: unknown[]) => submitMock(...args), + state: 'idle', + data: undefined, + })), +})); + +// Like the sibling cmsui tests, translations are not initialized; t() returns +// the raw key, so assertions reference the cmsui.history.* keys. +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), +})); + +// vitest has no svgr transform for `?react` imports; replace the two raw +// icons of the revert dialog with plain placeholders. +vi.mock('@plone/components/icons/close.svg?react', () => ({ + default: () => , +})); +vi.mock('@plone/components/icons/undo.svg?react', () => ({ + default: () => , +})); + +const versionedEntry = ( + overrides: Partial = {}, +) => ({ + '@id': 'http://localhost/history-test/@history/1', + action: 'Edited', + actor: { + '@id': 'http://localhost/@users/admin', + fullname: 'Jane Editor', + id: 'admin', + username: 'admin', + }, + comments: 'Fixed a typo', + may_revert: true, + time: '2026-06-10T12:00:00+00:00', + transition_title: 'Edited', + type: 'versioning', + version: 1, + ...overrides, +}); + +const workflowEntry = (overrides: Record = {}) => ({ + action: 'publish', + actor: { + '@id': 'http://localhost/@users/admin', + fullname: 'Jane Editor', + id: 'admin', + username: 'admin', + }, + comments: '', + review_state: 'published', + state_title: 'Published', + time: '2026-06-10T11:00:00+00:00', + transition_title: 'Publish', + type: 'workflow', + ...overrides, +}); + +const content = { + '@id': '/history-test', + title: 'History Test Page', + '@components': { + breadcrumbs: { + root: '/', + items: [{ '@id': '/history-test', title: 'History Test Page' }], + }, + }, +} as unknown as Content; + +describe('statusDotClass', () => { + it('marks versioning entries green', () => { + expect(statusDotClass(versionedEntry() as never)).toBe('bg-quanta-neon'); + }); + + it('colors workflow entries by review state', () => { + expect( + statusDotClass(workflowEntry({ review_state: 'published' }) as never), + ).toBe('bg-quanta-cobalt'); + expect( + statusDotClass(workflowEntry({ review_state: 'private' }) as never), + ).toBe('bg-quanta-rose'); + expect( + statusDotClass(workflowEntry({ review_state: 'pending' }) as never), + ).toBe('bg-quanta-pigeon'); + }); +}); + +describe('formatRelativeTime', () => { + beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-10T12:00:00+00:00')); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('formats past times in the given locale', () => { + expect(formatRelativeTime('2026-06-10T11:58:00+00:00', 'en')).toBe( + '2 minutes ago', + ); + expect(formatRelativeTime('2026-06-10T07:00:00+00:00', 'en')).toBe( + '5 hours ago', + ); + expect(formatRelativeTime('2026-06-05T12:00:00+00:00', 'de')).toBe( + 'vor 5 Tagen', + ); + }); + + it('returns the input for unparsable dates', () => { + expect(formatRelativeTime('not-a-date', 'en')).toBe('not-a-date'); + }); +}); + +describe('HistoryView', () => { + const history = [ + versionedEntry({ version: 2, comments: 'Latest edit' }), + versionedEntry(), + workflowEntry(), + ] as GetHistoryResponse; + + it('renders the title, breadcrumbs, and one row per entry', async () => { + const { container } = render( + , + ); + + expect( + screen.getByRole('heading', { + level: 1, + name: 'cmsui.history.changesTo', + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /cmsui\.history\.home/ }), + ).toHaveAttribute('href', '/'); + // one row per history entry plus the header row + expect(screen.getAllByRole('row')).toHaveLength(history.length + 1); + // actions menu only on versioning rows + expect( + screen.getAllByRole('button', { name: 'cmsui.history.actions' }), + ).toHaveLength(2); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it('asks for confirmation before submitting a revert', async () => { + render(); + + // the newest versioning entry is current: no revert item in its menu + const [currentMenu, oldMenu] = screen.getAllByRole('button', { + name: 'cmsui.history.actions', + }); + fireEvent.click(currentMenu); + expect(await screen.findByRole('menu')).toBeInTheDocument(); + expect( + screen.queryByRole('menuitem', { name: /cmsui\.history\.revert/ }), + ).not.toBeInTheDocument(); + fireEvent.keyDown(document.activeElement ?? document.body, { + key: 'Escape', + }); + + // an older revertable version opens the confirmation dialog + fireEvent.click(oldMenu); + fireEvent.click( + await screen.findByRole('menuitem', { + name: /cmsui\.history\.revert/, + }), + ); + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent('cmsui.history.modalRevert.title'); + expect(submitMock).not.toHaveBeenCalled(); + + // confirming submits the version to the route action + fireEvent.click( + screen.getByRole('button', { name: 'cmsui.history.modalRevert.confirm' }), + ); + expect(submitMock).toHaveBeenCalledWith( + { version: '1' }, + { method: 'post' }, + ); + }); +}); diff --git a/packages/cmsui/components/History/HistoryView.tsx b/packages/cmsui/components/History/HistoryView.tsx index 3286db957..c62a5bcc9 100644 --- a/packages/cmsui/components/History/HistoryView.tsx +++ b/packages/cmsui/components/History/HistoryView.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useFetcher } from 'react-router'; -import { useDateFormatter } from 'react-aria'; +import { useDateFormatter, VisuallyHidden } from 'react-aria'; import { Heading } from 'react-aria-components'; import { useTranslation } from 'react-i18next'; import type { Content, GetHistoryResponse } from '@plone/types'; @@ -38,7 +38,7 @@ type HistoryEntry = GetHistoryResponse[number]; // are colored by their resulting review state (published = blue, private = red). // TODO: replace with a global, configurable review-state -> color mapping // shared with @plone/contents' ReviewState (see #30). -function statusDotClass(entry: HistoryEntry): string { +export function statusDotClass(entry: HistoryEntry): string { if ('version' in entry) return 'bg-quanta-neon'; const state = 'review_state' in entry ? entry.review_state : undefined; if (state === 'published') return 'bg-quanta-cobalt'; @@ -49,7 +49,7 @@ function statusDotClass(entry: HistoryEntry): string { // Human-friendly relative time ("2 minutes ago") in the active locale, using // the built-in Intl API so we don't pull in a date library. // TODO: extract into a shared helper in @plone/helpers (see #30). -function formatRelativeTime(iso: string, locale: string): string { +export function formatRelativeTime(iso: string, locale: string): string { const then = new Date(iso).getTime(); if (Number.isNaN(then)) return iso; const diffSeconds = Math.round((then - Date.now()) / 1000); @@ -167,8 +167,8 @@ export default function HistoryView({ content, history }: HistoryViewProps) { {t('cmsui.history.column.by')} {t('cmsui.history.column.time')} {t('cmsui.history.column.changeNote')} - - {''} + + {t('cmsui.history.actions')} From 80da3268fd746f64998d73696a7032ec2aee4fd2 Mon Sep 17 00:00:00 2001 From: Marcel Liebischer Date: Thu, 11 Jun 2026 13:46:46 +0200 Subject: [PATCH 04/12] Fix 'View this revision' showing the current content (#30) Two bugs hid each other: the content middleware never read the ?version query parameter, and getContent dropped the expand parameter whenever a version was requested. The middleware now passes the version through (including the anonymous retry), and getContent applies expand to the @history endpoint as well. Covered by client integration tests, a middleware test, and acceptance tests including that anonymous visitors cannot access old revisions. --- apps/aurora/app/middleware.server.test.ts | 32 ++++++++++ apps/aurora/app/middleware.server.ts | 6 +- .../client/src/restapi/content/get.test.ts | 59 +++++++++++++++---- packages/client/src/restapi/content/get.ts | 12 ++-- .../cmsui/acceptance/tests/history.test.ts | 45 ++++++++++++++ 5 files changed, 136 insertions(+), 18 deletions(-) diff --git a/apps/aurora/app/middleware.server.test.ts b/apps/aurora/app/middleware.server.test.ts index 981e50ded..2878c329e 100644 --- a/apps/aurora/app/middleware.server.test.ts +++ b/apps/aurora/app/middleware.server.test.ts @@ -640,6 +640,38 @@ describe('middleware', () => { }); }); + it('passes a ?version query parameter through to getContent', async () => { + const getContentMock = vi.fn().mockResolvedValue({ data: {} }); + const getSiteMock = vi.fn().mockResolvedValue({ data: {} }); + config.settings.apiPath = 'http://example.com'; + registerPloneClientFactory({ + getContent: getContentMock, + getSite: getSiteMock, + }); + const request = new Request('http://example.com/test-content?version=2'); + const context = new RouterContextProvider(); + const nextMock = vi.fn(); + + await initializePloneClientContext(request, context); + + await fetchPloneContent( + { + request, + params: { '*': 'test-content' }, + context, + unstable_pattern: '/test-content', + unstable_url: new URL(request.url), + }, + nextMock, + ); + + expect(getContentMock).toHaveBeenCalledWith({ + path: '/test-content', + version: '2', + expand: ['navroot', 'breadcrumbs', 'navigation', 'actions'], + }); + }); + it('throws when content is not found', async () => { const getContentMock = vi .fn() diff --git a/apps/aurora/app/middleware.server.ts b/apps/aurora/app/middleware.server.ts index 6c679c84a..19b0034e3 100644 --- a/apps/aurora/app/middleware.server.ts +++ b/apps/aurora/app/middleware.server.ts @@ -143,6 +143,9 @@ export const fetchPloneContent: Route.MiddlewareFunction = async ( let cli = context.get(ploneClientContext); const path = `/${params['*'] || ''}`; + // A `?version=N` query (e.g. History's "View this revision") fetches that + // revision via the @history endpoint instead of the current content. + const version = new URL(request.url).searchParams.get('version') ?? undefined; let userId = ''; if (token) { @@ -172,7 +175,7 @@ export const fetchPloneContent: Route.MiddlewareFunction = async ( try { const [content, site, user] = await Promise.all([ - cli.getContent({ path, expand }), + cli.getContent({ path, version, expand }), cli.getSite(), userId ? cli.getUser({ id: userId }).catch(() => null) : null, ]); @@ -200,6 +203,7 @@ export const fetchPloneContent: Route.MiddlewareFunction = async ( const [content, site] = await Promise.all([ cli.getContent({ path, + version, expand: expand.filter((item) => item !== 'types'), }), cli.getSite(), diff --git a/packages/client/src/restapi/content/get.test.ts b/packages/client/src/restapi/content/get.test.ts index a47d74e8f..fb662185a 100644 --- a/packages/client/src/restapi/content/get.test.ts +++ b/packages/client/src/restapi/content/get.test.ts @@ -7,6 +7,13 @@ const cli = ploneClient.initialize({ apiPath: 'http://localhost:55001/plone', }); +// Versions are permission-protected, so the version tests need a logged-in +// client (same pattern as update.test.ts). +const authCli = ploneClient.initialize({ + apiPath: 'http://localhost:55001/plone', +}); +await authCli.login({ data: { login: 'admin', password: 'secret' } }); + beforeEach(async () => { await setup(); }); @@ -54,18 +61,48 @@ describe('getContent', () => { ); }); - test.skip('Version', async () => { - const path = '/'; - const version = 'abcd'; - const result = await cli.getContent({ path, version }); - expect(result.data.title).toBe('Welcome to Plone'); + test('Version', async () => { + await authCli.createContent({ + path: '/', + data: { '@type': 'Document', title: 'Versioned Page' }, + }); + await authCli.updateContent({ + path: '/versioned-page', + data: { title: 'Versioned Page updated' }, + }); + + const result = await authCli.getContent({ + path: '/versioned-page', + version: '0', + }); + + expect(result.data.title).toBe('Versioned Page'); }); - test.skip('FullObjects & version', async () => { - const path = '/'; - const fullObjects = true; - const version = 'abcd'; - const result = await cli.getContent({ path, fullObjects, version }); - expect(result.data.title).toBe('Welcome to Plone'); + test('Version & expand', async () => { + await authCli.createContent({ + path: '/', + data: { '@type': 'Document', title: 'Versioned Page' }, + }); + await authCli.updateContent({ + path: '/versioned-page', + data: { title: 'Versioned Page updated' }, + }); + + // expand must reach the @history endpoint too (it used to be dropped + // whenever a version was requested) + const result = await authCli.getContent({ + path: '/versioned-page', + version: '0', + expand: ['breadcrumbs', 'navigation'], + }); + + expect(result.data.title).toBe('Versioned Page'); + expect(result.data['@components'].breadcrumbs.root).toBe( + 'http://localhost:55001/plone', + ); + expect(result.data['@components'].navigation.items.length).toBeGreaterThan( + 0, + ); }); }); diff --git a/packages/client/src/restapi/content/get.ts b/packages/client/src/restapi/content/get.ts index 9c7125ae9..6d1f44e3d 100644 --- a/packages/client/src/restapi/content/get.ts +++ b/packages/client/src/restapi/content/get.ts @@ -37,6 +37,12 @@ export async function getContent( }), }, }; + if (validatedArgs.expand) { + options.params = { + ...options.params, + expand, + }; + } if (validatedArgs.version) { return apiRequest( 'get', @@ -44,11 +50,5 @@ export async function getContent( options, ); } - if (validatedArgs.expand) { - options.params = { - ...options.params, - expand, - }; - } return apiRequest('get', path, options); } diff --git a/packages/cmsui/acceptance/tests/history.test.ts b/packages/cmsui/acceptance/tests/history.test.ts index 5bcb3aa1a..204536fb1 100644 --- a/packages/cmsui/acceptance/tests/history.test.ts +++ b/packages/cmsui/acceptance/tests/history.test.ts @@ -98,6 +98,51 @@ test.describe('History route', () => { await expect(page.locator('tbody tr').first()).toBeVisible(); }); + test('shows an older revision via "View this revision"', async ({ page }) => { + await editTitle(page, 'My Page (v2)'); + await editTitle(page, 'My Page (v3)'); + + await openHistory(page); + + // the last menu belongs to the oldest version (v0, title "My Page") + await page.getByRole('button', { name: 'Actions' }).last().click(); + await page.getByRole('menuitem', { name: 'View this revision' }).click(); + + await expect(page).toHaveURL(/\?version=0$/); + await expect( + page.getByRole('heading', { name: 'My Page', exact: true }), + ).toBeVisible(); + }); + + test('does not leak old revisions to anonymous visitors', async ({ + page, + }) => { + // a published page whose original title differs from the current one + await createContent(page, { + contentType: 'Document', + contentId: 'public-page', + contentTitle: 'Old secret title', + transition: 'publish', + }); + const patch = await page.request.patch(`${apiURL}/public-page`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: authHeader, + }, + data: { title: 'Public title' }, + }); + expect(patch.ok()).toBeTruthy(); + + // drop the auth cookie: the backend protects the @history endpoint, so + // the version request must fail instead of serving the old revision + await page.context().clearCookies(); + const response = await page.goto('/public-page?version=0'); + + expect(response?.ok()).toBeFalsy(); + await expect(page.getByText('Old secret title')).toBeHidden(); + }); + test('hides the History toolbar button on the site root', async ({ page, }) => { From 0d526d621c1268b8373ac28e730a6373df118726 Mon Sep 17 00:00:00 2001 From: Marcel Liebischer Date: Thu, 11 Jun 2026 13:47:07 +0200 Subject: [PATCH 05/12] Validate the revert action input and return failures as data (#30) Number(formData.get('version')) turned a missing field into 0 and garbage into NaN, both passed unchecked to revertHistory, and a failing backend call threw into the error boundary. The action now rejects invalid versions with a 400 data response and reports backend failures as a 502 data response so the UI can react. --- packages/cmsui/routes/history.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/cmsui/routes/history.tsx b/packages/cmsui/routes/history.tsx index df7ba93d6..3c4802575 100644 --- a/packages/cmsui/routes/history.tsx +++ b/packages/cmsui/routes/history.tsx @@ -39,11 +39,22 @@ export async function action({ const contentPath = content?.['@id'] ?? '/'; const formData = await request.formData(); - const version = Number(formData.get('version')); + const rawVersion = formData.get('version'); + const version = Number(rawVersion); - await cli.revertHistory({ path: contentPath, data: { version } }); + if (rawVersion === null || !Number.isInteger(version) || version < 0) { + return data({ ok: false, error: 'invalidVersion' as const }, 400); + } - return data({ ok: true }); + try { + await cli.revertHistory({ path: contentPath, data: { version } }); + } catch { + // Surface the failure as data so the dialog can show feedback instead of + // the route's error boundary taking over. + return data({ ok: false, error: 'revertFailed' as const }, 502); + } + + return data({ ok: true as const }); } export default function History() { From e5ebe2894708df1dd711f60bdf2f2558dddc7e7c Mon Sep 17 00:00:00 2001 From: Marcel Liebischer Date: Thu, 11 Jun 2026 13:49:09 +0200 Subject: [PATCH 06/12] Show pending and error feedback in the revert dialog (#30) The dialog used to close right after submitting, so a failing revert went unnoticed. It now stays open while the request is in flight with the confirm button disabled, closes itself only on success, and shows an inline error message otherwise (new locale strings in en/de/it). The acceptance test now also asserts that the restored title is shown after a successful revert. --- .../cmsui/acceptance/tests/history.test.ts | 7 +- .../components/History/HistoryView.test.tsx | 64 ++++++++++++++++++- .../cmsui/components/History/HistoryView.tsx | 36 ++++++++++- packages/cmsui/locales/de/common.json | 3 +- packages/cmsui/locales/en/common.json | 3 +- packages/cmsui/locales/it/common.json | 3 +- 6 files changed, 106 insertions(+), 10 deletions(-) diff --git a/packages/cmsui/acceptance/tests/history.test.ts b/packages/cmsui/acceptance/tests/history.test.ts index 204536fb1..aaf47945a 100644 --- a/packages/cmsui/acceptance/tests/history.test.ts +++ b/packages/cmsui/acceptance/tests/history.test.ts @@ -93,9 +93,12 @@ test.describe('History route', () => { }); await dialog.getByRole('button', { name: 'Revert' }).click(); - // the revert adds a new history entry for the restored state + // the dialog closes on success and the loader revalidates: the title + // shows the restored (oldest) state again await expect(dialog).toBeHidden(); - await expect(page.locator('tbody tr').first()).toBeVisible(); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Changes to "My Page"', + ); }); test('shows an older revision via "View this revision"', async ({ page }) => { diff --git a/packages/cmsui/components/History/HistoryView.test.tsx b/packages/cmsui/components/History/HistoryView.test.tsx index d3cdc27ab..335bff356 100644 --- a/packages/cmsui/components/History/HistoryView.test.tsx +++ b/packages/cmsui/components/History/HistoryView.test.tsx @@ -1,16 +1,30 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { axe } from 'jest-axe'; -import { vi, describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + vi, + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, +} from 'vitest'; import type { Content, GetHistoryResponse } from '@plone/types'; import HistoryView, { statusDotClass, formatRelativeTime } from './HistoryView'; const submitMock = vi.fn(); +// Mutable so tests can simulate the fetcher lifecycle (submitting → result). +const fetcherState: { state: string; data: unknown } = { + state: 'idle', + data: undefined, +}; + vi.mock('react-router', () => ({ useFetcher: vi.fn(() => ({ submit: (...args: unknown[]) => submitMock(...args), - state: 'idle', - data: undefined, + state: fetcherState.state, + data: fetcherState.data, })), })); @@ -132,6 +146,12 @@ describe('HistoryView', () => { workflowEntry(), ] as GetHistoryResponse; + beforeEach(() => { + submitMock.mockClear(); + fetcherState.state = 'idle'; + fetcherState.data = undefined; + }); + it('renders the title, breadcrumbs, and one row per entry', async () => { const { container } = render( , @@ -192,4 +212,42 @@ describe('HistoryView', () => { { method: 'post' }, ); }); + + it('keeps the dialog open during submit and shows a message on failure', async () => { + const view = render(); + + const [, oldMenu] = screen.getAllByRole('button', { + name: 'cmsui.history.actions', + }); + fireEvent.click(oldMenu); + fireEvent.click( + await screen.findByRole('menuitem', { name: /cmsui\.history\.revert/ }), + ); + fireEvent.click( + screen.getByRole('button', { name: 'cmsui.history.modalRevert.confirm' }), + ); + + // while the revert is in flight, the dialog stays open and confirm is + // disabled + fetcherState.state = 'submitting'; + view.rerender(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'cmsui.history.modalRevert.confirm' }), + ).toBeDisabled(); + + // a failed action keeps it open and shows the error message + fetcherState.state = 'idle'; + fetcherState.data = { ok: false, error: 'revertFailed' }; + view.rerender(); + expect(screen.getByRole('alert')).toHaveTextContent( + 'cmsui.history.modalRevert.error', + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // a successful action closes the dialog + fetcherState.data = { ok: true }; + view.rerender(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); }); diff --git a/packages/cmsui/components/History/HistoryView.tsx b/packages/cmsui/components/History/HistoryView.tsx index c62a5bcc9..f38c87fc3 100644 --- a/packages/cmsui/components/History/HistoryView.tsx +++ b/packages/cmsui/components/History/HistoryView.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useFetcher } from 'react-router'; import { useDateFormatter, VisuallyHidden } from 'react-aria'; import { Heading } from 'react-aria-components'; @@ -93,16 +93,38 @@ export default function HistoryView({ content, history }: HistoryViewProps) { time: string; } | null>(null); const [isRevertOpen, setIsRevertOpen] = useState(false); + // Tracks whether THIS dialog instance submitted, so stale fetcher.data from + // an earlier revert cannot close or decorate a freshly opened dialog. + const [hasSubmitted, setHasSubmitted] = useState(false); + + const revertResult = fetcher.data as + | { ok: boolean; error?: string } + | undefined; + const isSubmitting = fetcher.state !== 'idle'; + const revertFailed = + hasSubmitted && !isSubmitting && revertResult !== undefined + ? !revertResult.ok + : false; const submitRevert = () => { if (!revertTarget) return; + setHasSubmitted(true); fetcher.submit( { version: String(revertTarget.version) }, { method: 'post' }, ); - setIsRevertOpen(false); }; + // The dialog stays open while the revert is in flight; it only closes + // itself once the action reports success. Failures keep it open and show + // the error message instead. + useEffect(() => { + if (hasSubmitted && !isSubmitting && revertResult?.ok) { + setIsRevertOpen(false); + setHasSubmitted(false); + } + }, [hasSubmitted, isSubmitting, revertResult]); + // Entries arrive newest-first, so the first versioning entry is the current // revision. The current revision cannot be reverted to itself. const currentVersion = history.find((entry) => 'version' in entry)?.version; @@ -231,6 +253,7 @@ export default function HistoryView({ content, history }: HistoryViewProps) { version: entry.version, time: entry.time, }); + setHasSubmitted(false); setIsRevertOpen(true); }} > @@ -268,6 +291,14 @@ export default function HistoryView({ content, history }: HistoryViewProps) { : '', })}

+ {revertFailed ? ( +

+ {t('cmsui.history.modalRevert.error')} +

+ ) : null}