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/apps/aurora/news/30.feature b/apps/aurora/news/30.feature new file mode 100644 index 000000000..71c83349f --- /dev/null +++ b/apps/aurora/news/30.feature @@ -0,0 +1 @@ +Resolve the `?version` query parameter when fetching content, so "View this revision" in the History view shows the requested revision. diff --git a/packages/client/news/30.bugfix b/packages/client/news/30.bugfix new file mode 100644 index 000000000..fb9d09f60 --- /dev/null +++ b/packages/client/news/30.bugfix @@ -0,0 +1 @@ +`getContent` no longer drops the `expand` parameter when a specific version is requested. 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 new file mode 100644 index 000000000..41d021465 --- /dev/null +++ b/packages/cmsui/acceptance/tests/history.test.ts @@ -0,0 +1,188 @@ +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('redirects anonymous visitors to the login', async ({ page }) => { + // a published page: on private content the middleware already fails the + // anonymous content fetch (error boundary) before the loader's auth + // guard can redirect + await createContent(page, { + contentType: 'Document', + contentId: 'public-page', + contentTitle: 'Public Page', + transition: 'publish', + }); + await page.context().clearCookies(); + + await page.goto('/@@history/public-page'); + + await expect(page).toHaveURL(/\/login/); + }); + + test('navigates back to the content via the toolbar back button', async ({ + page, + }) => { + await openHistory(page); + + await page.getByRole('link', { name: 'Back' }).click(); + + await expect(page).toHaveURL(/\/my-page$/); + await expect( + page.getByRole('heading', { name: 'My Page', exact: true }), + ).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 dialog closes on success and the loader revalidates: the title + // shows the restored (oldest) state again + await expect(dialog).toBeHidden(); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Changes to "My Page"', + ); + }); + + 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, + }) => { + 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..5afa2033d --- /dev/null +++ b/packages/cmsui/components/History/HistoryView.test.tsx @@ -0,0 +1,318 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { axe } from 'jest-axe'; +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: fetcherState.state, + data: fetcherState.data, + })), +})); + +// 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; + + beforeEach(() => { + submitMock.mockClear(); + fetcherState.state = 'idle'; + fetcherState.data = undefined; + }); + + 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' }, + ); + }); + + it('shows the absolute date as tooltip on the relative time', () => { + render(); + + const times = document.querySelectorAll('time'); + expect(times.length).toBe(history.length); + for (const time of times) { + expect(time).toHaveAttribute('dateTime'); + // the full-date tooltip (browser locale, so only check presence) + expect(time.getAttribute('title')).toBeTruthy(); + } + }); + + it('renders an empty history without errors', () => { + render( + , + ); + + expect( + screen.getByRole('heading', { + level: 1, + name: 'cmsui.history.changesTo', + }), + ).toBeInTheDocument(); + // only the header row remains + expect(screen.getAllByRole('row')).toHaveLength(1); + }); + + it('shows the workflow state transition like Volto', () => { + const entries = [ + versionedEntry({ version: 2 }), + workflowEntry(), + workflowEntry({ + action: null, + transition_title: 'Create', + review_state: 'private', + state_title: 'Private', + time: '2026-06-10T10:00:00+00:00', + }), + ] as GetHistoryResponse; + render(); + + // transition with a known previous state + expect( + screen.getByText('Publish (Private → Published)'), + ).toBeInTheDocument(); + // creation: no previous state, no Volto-style literal "undefined" + expect(screen.getByText('Create (Private)')).toBeInTheDocument(); + }); + + it('survives entries with an unparsable time', () => { + render( + , + ); + + // the raw value is shown instead of crashing on Invalid Date + expect(screen.getByText('not-a-date')).toBeInTheDocument(); + }); + + 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 new file mode 100644 index 000000000..483b2ed7f --- /dev/null +++ b/packages/cmsui/components/History/HistoryView.tsx @@ -0,0 +1,388 @@ +import { useEffect, useState } from 'react'; +import { useFetcher } from 'react-router'; +import { useDateFormatter, VisuallyHidden } 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). +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'; + if (state === 'private') return 'bg-quanta-rose'; + return 'bg-quanta-pigeon'; +} + +// Constructing Intl formatters is expensive and this runs once per table row, +// so instances are cached per locale. +const relativeTimeFormatters = new Map(); +function getRelativeTimeFormatter(locale: string): Intl.RelativeTimeFormat { + let formatter = relativeTimeFormatters.get(locale); + if (!formatter) { + formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + relativeTimeFormatters.set(locale, formatter); + } + return formatter; +} + +// 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). +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); + const rtf = getRelativeTimeFormatter(locale); + 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; +} + +// Interim Volto-parity label for workflow rows: "Publish (Private → Published)", +// or just "(Private)" when no previous state is known (e.g. creation) — unlike +// Volto, which renders a literal "undefined" in that case. Uses the +// backend-translated state titles, so it stays neutral on the final wording +// ("Published from Private", Figma) that is still an open question in #30. +export function workflowStateSuffix( + entry: HistoryEntry, + prevStateTitle: string | undefined, +): string { + if (!('state_title' in entry) || !entry.state_title) return ''; + const from = entry.action && prevStateTitle ? `${prevStateTitle} → ` : ''; + return ` (${from}${entry.state_title})`; +} + +// Walks the (newest-first) entries and returns, per index, the workflow state +// title that was active BEFORE that entry (Volto's prev_state_title). +export function deriveWorkflowPrevStates( + history: GetHistoryResponse, +): (string | undefined)[] { + const prev: (string | undefined)[] = new Array(history.length); + let title: string | undefined; + for (let i = history.length - 1; i >= 0; i -= 1) { + const entry = history[i]; + if ('state_title' in entry && entry.state_title) { + prev[i] = title; + title = entry.state_title; + } + } + return prev; +} + +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); + // 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' }, + ); + }; + + // 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; + + const workflowPrevStates = deriveWorkflowPrevStates(history); + + // 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')} + + {t('cmsui.history.actions')} + + + + {history.map((entry, index) => { + const versioned = 'version' in entry; + const isCurrent = versioned && entry.version === currentVersion; + // Stable row identity: after a revert the loader prepends a new + // entry, so an array index would shift every row's state. + const rowId = versioned + ? `versioning-${entry.version}` + : `${entry.type}-${entry.time}-${entry.transition_title}`; + const time = new Date(entry.time); + const hasValidTime = !Number.isNaN(time.getTime()); + return ( + + + + + {entry.transition_title} + {workflowStateSuffix(entry, workflowPrevStates[index])} + + + {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, + }); + setHasSubmitted(false); + setIsRevertOpen(true); + }} + > + + {t('cmsui.history.revert')} + + ) : null} + + + ) : null} + + + ); + })} + +
+ + + + + {t('cmsui.history.modalRevert.title')} + +

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

+ {revertFailed ? ( +

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

+ ) : null} +
+ + +
+
+
+
+
+ ); +} 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 6d8e29cd7..fa2f7d320 100644 --- a/packages/cmsui/locales/de/common.json +++ b/packages/cmsui/locales/de/common.json @@ -73,6 +73,29 @@ "forgotPassword": "Passwort vergessen? Neues Passwort anfordern", "returnToHome": "Zur Startseite zurückkehren" }, + "history": { + "label": "Verlauf", + "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", + "error": "Die Version konnte nicht wiederhergestellt werden. Bitte erneut versuchen." + } + }, "toolbar": { "settings": "Einstellungen" } diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json index 20968727e..c096f1223 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -121,6 +121,29 @@ "forgotPassword": "Forgot password? Request a new password", "returnToHome": "Return to home page" }, + "history": { + "label": "History", + "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", + "error": "The version could not be restored. Please try again." + } + }, "toolbar": { "settings": "Settings" } diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json index 22f8a746d..630beb2b9 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -111,6 +111,29 @@ "forgotPassword": "Password dimenticata? Richiedi una nuova password", "returnToHome": "Torna alla home page" }, + "history": { + "label": "Cronologia", + "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", + "error": "Impossibile ripristinare la versione. Riprova." + } + }, "toolbar": { "settings": "Impostazioni" } 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.test.tsx b/packages/cmsui/routes/history.test.tsx new file mode 100644 index 000000000..8824760c4 --- /dev/null +++ b/packages/cmsui/routes/history.test.tsx @@ -0,0 +1,81 @@ +import { expect, describe, it, vi, afterEach } from 'vitest'; +import { RouterContextProvider } from 'react-router'; +import { + ploneClientContext, + ploneContentContext, +} from '@plone/aurora/app/middleware.server'; +import { action } from './history'; + +vi.mock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('token'), +})); + +function buildArgs(body: Record, revertMock: unknown) { + const context = new RouterContextProvider(); + context.set(ploneClientContext, { revertHistory: revertMock } as never); + context.set(ploneContentContext, { '@id': '/my-page' } as never); + + const formData = new FormData(); + for (const [key, value] of Object.entries(body)) { + formData.append(key, value); + } + const request = new Request('http://example.com/@@history/my-page', { + method: 'POST', + body: formData, + }); + + return { + request, + params: {}, + context, + unstable_pattern: '/@@history/*', + unstable_url: new URL(request.url), + }; +} + +describe('action', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('reverts to the requested version', async () => { + const revertMock = vi.fn().mockResolvedValue({}); + + const result = await action(buildArgs({ version: '2' }, revertMock)); + + expect(revertMock).toHaveBeenCalledWith({ + path: '/my-page', + data: { version: 2 }, + }); + expect(result.data).toEqual({ ok: true }); + }); + + it('rejects a missing version without calling the API', async () => { + const revertMock = vi.fn(); + + const result = await action(buildArgs({}, revertMock)); + + expect(revertMock).not.toHaveBeenCalled(); + expect(result.data).toEqual({ ok: false, error: 'invalidVersion' }); + expect(result.init).toEqual({ status: 400 }); + }); + + it('rejects a non-numeric version without calling the API', async () => { + const revertMock = vi.fn(); + + const result = await action(buildArgs({ version: 'abc' }, revertMock)); + + expect(revertMock).not.toHaveBeenCalled(); + expect(result.data).toEqual({ ok: false, error: 'invalidVersion' }); + expect(result.init).toEqual({ status: 400 }); + }); + + it('returns the failure as data when the revert call throws', async () => { + const revertMock = vi.fn().mockRejectedValue(new Error('forbidden')); + + const result = await action(buildArgs({ version: '1' }, revertMock)); + + expect(result.data).toEqual({ ok: false, error: 'revertFailed' }); + expect(result.init).toEqual({ status: 502 }); + }); +}); diff --git a/packages/cmsui/routes/history.tsx b/packages/cmsui/routes/history.tsx new file mode 100644 index 000000000..3c4802575 --- /dev/null +++ b/packages/cmsui/routes/history.tsx @@ -0,0 +1,89 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } 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 type { Content, GetHistoryResponse } from '@plone/types'; +import HistoryView from '../components/History/HistoryView'; + +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 rawVersion = formData.get('version'); + const version = Number(rawVersion); + + if (rawVersion === null || !Number.isInteger(version) || version < 0) { + return data({ ok: false, error: 'invalidVersion' as const }, 400); + } + + 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() { + const { t } = useTranslation(); + const { content, history } = useLoaderData(); + const typedContent = content as unknown as Content; + + return ( + <> + + + + + +
+ +
+ + ); +} 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 c39b4f8de..01720958f 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'; @@ -94,7 +94,7 @@ export async function loader({ export default function Index() { const location = useLocation(); const { content, locale } = useLoaderData(); - const { i18n } = useTranslation(); + const { t, i18n } = useTranslation(); const navigate = useNavigate(); const matches = useMatches() as UIMatch[]; const routesBodyClasses = matches @@ -158,6 +158,23 @@ export default function Index() { + + {content['@type'] !== 'Plone Site' ? ( + // Plone Site does not have history (yet), same guard as Volto + + + + ) : null} +