From 7f6a18d9d4b87d1e069ef76de3023a1c3d71ba83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Pl=C3=BCtzer?= Date: Thu, 11 Jun 2026 15:26:47 +0200 Subject: [PATCH 1/2] Add `@@translate` route for creating and editing content translations --- docs/how-to-guides/index.md | 1 + docs/how-to-guides/translate-content.md | 65 ++++ .../components/ContentForm/ContentForm.tsx | 129 ++++--- packages/cmsui/config/routes.ts | 11 + packages/cmsui/locales/de/common.json | 5 + packages/cmsui/locales/en/common.json | 5 + packages/cmsui/locales/it/common.json | 5 + packages/cmsui/news/+translate-route.feature | 1 + packages/cmsui/routes/translate.test.tsx | 290 +++++++++++++++ packages/cmsui/routes/translate.tsx | 337 ++++++++++++++++++ 10 files changed, 800 insertions(+), 49 deletions(-) create mode 100644 docs/how-to-guides/translate-content.md create mode 100644 packages/cmsui/news/+translate-route.feature create mode 100644 packages/cmsui/routes/translate.test.tsx create mode 100644 packages/cmsui/routes/translate.tsx diff --git a/docs/how-to-guides/index.md b/docs/how-to-guides/index.md index b42b76e4c..0cce268c6 100644 --- a/docs/how-to-guides/index.md +++ b/docs/how-to-guides/index.md @@ -30,4 +30,5 @@ icons configure-plate-code-block-languages bind-metadata-fields-to-plate-text-blocks custom-content-types +translate-content ``` diff --git a/docs/how-to-guides/translate-content.md b/docs/how-to-guides/translate-content.md new file mode 100644 index 000000000..f621195ad --- /dev/null +++ b/docs/how-to-guides/translate-content.md @@ -0,0 +1,65 @@ +--- +myst: + html_meta: + "description": "How to create a translation of a content item in Plone Aurora while reading the original side by side" + "property=og:description": "How to create a translation of a content item in Plone Aurora while reading the original side by side" + "property=og:title": "Translate content" + "keywords": "Plone Aurora, translate, translation, multilingual, babel" +--- + +# Translate content + +This guide shows you how to create a translation of a content item while reading the original side by side. + +## Requirements + +Your site must be multilingual. +Install the `plone.app.multilingual` add-on, and configure at least two site languages in the {guilabel}`Language` control panel. +The backend then provides one root folder per language, and Aurora places every translation in the folder of its language. + +## Open the translation view + +Open the translation view directly by URL. +Append the path of the item to translate to `@@translate`, and pass the target language as a query parameter. + +```text +https://example.com/@@translate/?language= +``` + +For example, to translate the English page at `/en/welcome` to German, open the following URL. + +```text +https://example.com/@@translate/en/welcome?language=de +``` + +If a translation in the target language already exists, the view redirects to the edit view of that translation. + +```{todo} +Describe the user interface entry point for the translation view, once the toolbar provides a {guilabel}`Translate` action. +See [issue 21](https://github.com/plone/aurora/issues/21) and [issue 31](https://github.com/plone/aurora/issues/31). +``` + +## Translate the content + +The view shows the original on the left, and the new translation on the right. +The {guilabel}`Blocks` and {guilabel}`Content` tabs switch both columns at the same time. + +In the {guilabel}`Blocks` tab, the left column shows the rendered original, and the right column shows the block editor for the translation. +The translation starts with the same block structure as the original. + +- Text blocks contain the original text. + Overwrite it with your translation. +- All other blocks, such as a teaser, appear empty at their original position. + Fill them as you would fill a manually added block. + +In the {guilabel}`Content` tab, the left column shows the field values of the original, and the right column shows the fields of the translation. +The fields start empty and show the values of the original as placeholders. +Enter the translated values, starting with the {guilabel}`Title`. + +## Save the translation + +Select the {guilabel}`Save` button in the toolbar. +Aurora creates the translation in the root folder of the target language, links it to the original, and opens the edit view of the new translation. + +Fields that you leave empty stay empty on the translation. +The link between the original and the translation lets visitors switch between the two language versions of the item. diff --git a/packages/cmsui/components/ContentForm/ContentForm.tsx b/packages/cmsui/components/ContentForm/ContentForm.tsx index 7de5f8a3c..cbf307bd7 100644 --- a/packages/cmsui/components/ContentForm/ContentForm.tsx +++ b/packages/cmsui/components/ContentForm/ContentForm.tsx @@ -40,6 +40,10 @@ interface ContentFormProps { schema: Schema; heading: ReactNode; submitMethod: 'post' | 'patch'; + /** Per-tab sticky panels rendered left of the form (eg. translation source). */ + asidePanels?: { header?: ReactNode; blocks?: ReactNode; content?: ReactNode }; + /** Placeholder text per field name (eg. the source values of a translation). */ + fieldPlaceholders?: Record; } export default function ContentForm({ @@ -47,6 +51,8 @@ export default function ContentForm({ schema, heading, submitMethod, + asidePanels, + fieldPlaceholders, }: ContentFormProps) { const { t } = useTranslation(); const fetcher = useFetcher(); @@ -64,6 +70,68 @@ export default function ContentForm({ }, }); + const fieldsetsForm = ( +
+ {schema.fieldsets.map((fieldset) => ( + + + {fieldset.title} + + {(fieldset.fields as DeepKeys[]).map( + (schemaField, index) => ( + ( + + )} + /> + ), + )} + + + + ))} +
+ ); + + // The shared header row keeps both columns starting level; px-6 mirrors the + // blocks editor's own gutters, padBody pads bodies that lack them. + const withAside = (panel: ReactNode, body: ReactNode, padBody = false) => + asidePanels ? ( +
+
{asidePanels.header}
+

{heading}

+ +
+ {body} +
+
+ ) : ( + body + ); + return ( @@ -86,59 +154,22 @@ export default function ContentForm({ { id: 'blocks', title: t('cmsui.blocksEditor.blocksTab'), - content: , + content: withAside(asidePanels?.blocks, ), }, { id: 'content', title: t('cmsui.blocksEditor.contentTab'), - content: ( -
-

{heading}

-
- {schema.fieldsets.map((fieldset) => ( - - - - {fieldset.title} - - - {(fieldset.fields as DeepKeys[]).map( - (schemaField, index) => ( - ( - - )} - /> - ), - )} - - - - ))} -
-
+ content: withAside( + asidePanels?.content, + asidePanels ? ( + fieldsetsForm + ) : ( +
+

{heading}

+ {fieldsetsForm} +
+ ), + true, ), }, ]} diff --git a/packages/cmsui/config/routes.ts b/packages/cmsui/config/routes.ts index cccac5bec..cb708daf2 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: '@@translate', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/translate.tsx', + }, + ], + }, { type: 'prefix', path: 'controlpanel', diff --git a/packages/cmsui/locales/de/common.json b/packages/cmsui/locales/de/common.json index fcd14d912..23f85d184 100644 --- a/packages/cmsui/locales/de/common.json +++ b/packages/cmsui/locales/de/common.json @@ -56,6 +56,11 @@ "groupMembership": "Gruppenmitgliedschaft", "groups": "Gruppen" }, + "translate": { + "source": "Quelle", + "view_source": "Quellinhalt ansehen", + "heading_new": "\"{{title}}\" nach {{language}} übersetzen" + }, "blocksEditor": { "blocksTab": "Blöcke", "contentTab": "Inhalt" diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json index 46fd15f42..6d5606ffb 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -104,6 +104,11 @@ "groupMembership": "Group Membership", "groups": "Groups" }, + "translate": { + "source": "Source", + "view_source": "View source content", + "heading_new": "Translate \"{{title}}\" to {{language}}" + }, "blocksEditor": { "blocksTab": "Blocks", "contentTab": "Content" diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json index 7cd7fb030..eeb7db260 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -94,6 +94,11 @@ "groupMembership": "Appartenenza ai gruppi", "groups": "Gruppi" }, + "translate": { + "source": "Origine", + "view_source": "Visualizza il contenuto di origine", + "heading_new": "Traduci \"{{title}}\" in {{language}}" + }, "blocksEditor": { "blocksTab": "Blocchi", "contentTab": "Contenuto" diff --git a/packages/cmsui/news/+translate-route.feature b/packages/cmsui/news/+translate-route.feature new file mode 100644 index 000000000..209c4891a --- /dev/null +++ b/packages/cmsui/news/+translate-route.feature @@ -0,0 +1 @@ +Add `@@translate` route to create and edit translations of a content item. The form is pre-filled with the source content (or the existing translation), and saving creates the translation in the target language root folder and links it to the source. @nils-pzr diff --git a/packages/cmsui/routes/translate.test.tsx b/packages/cmsui/routes/translate.test.tsx new file mode 100644 index 000000000..0115c80fb --- /dev/null +++ b/packages/cmsui/routes/translate.test.tsx @@ -0,0 +1,290 @@ +import { expect, describe, it, vi, afterEach } from 'vitest'; +import config from '@plone/registry'; +import { loader, action } from './translate'; +import { RouterContextProvider } from 'react-router'; +import { + ploneClientContext, + ploneContentContext, +} from '@plone/aurora/app/middleware.server'; + +vi.mock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('fake-token'), +})); + +const mockSchema = { + title: 'Page', + fieldsets: [ + { id: 'default', title: 'Default', fields: ['title', 'description'] }, + ], + properties: { + title: { title: 'Title', type: 'string' }, + description: { title: 'Description', type: 'string' }, + }, + required: ['title'], +}; + +const mockSource = { + '@id': 'http://example.com/en/my-page', + '@type': 'Document', + title: 'My Page', + description: 'A test page', + language: { title: 'English', token: 'en' }, + blocks: { + __somersault__: { + '@type': '__somersault__', + value: [ + { type: 'title', children: [{ text: 'My Page' }] }, + { type: 'p', children: [{ text: 'Hello world' }] }, + { + type: 'unknown', + '@type': 'teaser', + id: 'abc123', + href: [{ '@id': 'http://example.com/en/other' }], + children: [{ text: '' }], + }, + ], + }, + }, + blocks_layout: { items: [] }, +}; + +const mockTranslations = { + '@id': 'http://example.com/en/my-page/@translations', + items: [], + root: { en: 'http://example.com/en', de: 'http://example.com/de' }, +}; + +function makeContext(client: Record) { + const context = new RouterContextProvider(); + context.set(ploneClientContext, client as any); + context.set(ploneContentContext, mockSource as any); + return context; +} + +function loaderArgs(context: RouterContextProvider, url: string) { + const request = new Request(url); + return { + request, + params: { '*': 'en/my-page' }, + context, + unstable_pattern: '/@@translate/*', + unstable_url: new URL(request.url), + }; +} + +async function caughtStatus(promise: Promise) { + try { + await promise; + return null; + } catch (error: any) { + return error?.init?.status ?? error?.status ?? 'unknown'; + } +} + +describe('Translate route', () => { + afterEach(() => { + vi.restoreAllMocks(); + config.settings = {}; + }); + + describe('loader', () => { + it('returns the source and an initial with kept text and emptied data blocks', async () => { + config.settings.apiPath = 'http://example.com'; + const context = makeContext({ + getType: vi.fn().mockResolvedValue({ data: mockSchema }), + getTranslation: vi.fn().mockResolvedValue({ data: mockTranslations }), + }); + + const result = await loader( + loaderArgs( + context, + 'http://example.com/@@translate/en/my-page?language=de', + ) as any, + ); + + const { source, initial, targetLanguage } = (result as any).data; + expect(targetLanguage).toBe('de'); + expect(source.title).toBe('My Page'); + expect(initial.title).toBe(''); + expect(initial['@type']).toBe('Document'); + + const value = initial.blocks.__somersault__.value; + expect(value[0].children[0].text).toBe('My Page'); + expect(value[1].children[0].text).toBe('Hello world'); + expect(value[2]).toEqual({ + type: 'unknown', + '@type': 'teaser', + id: 'abc123', + children: [{ text: '' }], + }); + }); + + it('redirects to the edit route when the translation already exists', async () => { + config.settings.apiPath = 'http://example.com'; + const context = makeContext({ + getType: vi.fn().mockResolvedValue({ data: mockSchema }), + getTranslation: vi.fn().mockResolvedValue({ + data: { + ...mockTranslations, + items: [ + { '@id': 'http://example.com/de/meine-seite', language: 'de' }, + ], + }, + }), + }); + + try { + await loader( + loaderArgs( + context, + 'http://example.com/@@translate/en/my-page?language=de', + ) as any, + ); + expect.unreachable('loader should redirect'); + } catch (response: any) { + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('/@@edit/de/meine-seite'); + } + }); + + it('rejects a missing target language', async () => { + config.settings.apiPath = 'http://example.com'; + const context = makeContext({ + getType: vi.fn().mockResolvedValue({ data: mockSchema }), + getTranslation: vi.fn().mockResolvedValue({ data: mockTranslations }), + }); + + const status = await caughtStatus( + loader( + loaderArgs( + context, + 'http://example.com/@@translate/en/my-page', + ) as any, + ) as Promise, + ); + expect(status).toBe(400); + }); + + it('rejects a target language without a language root', async () => { + config.settings.apiPath = 'http://example.com'; + const context = makeContext({ + getType: vi.fn().mockResolvedValue({ data: mockSchema }), + getTranslation: vi.fn().mockResolvedValue({ data: mockTranslations }), + }); + + const status = await caughtStatus( + loader( + loaderArgs( + context, + 'http://example.com/@@translate/en/my-page?language=fr', + ) as any, + ) as Promise, + ); + expect(status).toBe(400); + }); + + it('rejects untranslatable content', async () => { + config.settings.apiPath = 'http://example.com'; + const context = makeContext({ + getType: vi.fn().mockResolvedValue({ data: mockSchema }), + getTranslation: vi.fn().mockRejectedValue({ status: 404 }), + }); + + const status = await caughtStatus( + loader( + loaderArgs( + context, + 'http://example.com/@@translate/en/my-page?language=de', + ) as any, + ) as Promise, + ); + expect(status).toBe(400); + }); + }); + + describe('action', () => { + function actionArgs(context: RouterContextProvider, body: unknown) { + const request = new Request( + 'http://example.com/@@translate/en/my-page?language=de', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); + return { + request, + params: { '*': 'en/my-page' }, + context, + unstable_pattern: '/@@translate/*', + unstable_url: new URL(request.url), + }; + } + + it('creates the translation in the language root, links it, and redirects', async () => { + config.settings.apiPath = 'http://example.com'; + const createContent = vi + .fn() + .mockResolvedValue({ data: { '@id': 'http://example.com/de/titel' } }); + const linkTranslation = vi.fn().mockResolvedValue({}); + const context = makeContext({ + getTranslation: vi.fn().mockResolvedValue({ data: mockTranslations }), + createContent, + linkTranslation, + }); + + const result = await action( + actionArgs(context, { + title: 'Titel', + '@id': '/should-be-stripped', + }) as any, + ); + + expect(createContent).toHaveBeenCalledWith({ + path: '/de', + data: expect.objectContaining({ + title: 'Titel', + '@type': 'Document', + language: 'de', + }), + }); + expect(createContent.mock.calls[0][0].data['@id']).toBeUndefined(); + expect(linkTranslation).toHaveBeenCalledWith({ + path: '/en/my-page', + data: { id: '/de/titel' }, + }); + expect((result as Response).status).toBe(302); + expect((result as Response).headers.get('Location')).toBe( + '/@@edit/de/titel', + ); + }); + + it('updates the translation instead when it already exists', async () => { + config.settings.apiPath = 'http://example.com'; + const updateContent = vi.fn().mockResolvedValue({}); + const context = makeContext({ + getTranslation: vi.fn().mockResolvedValue({ + data: { + ...mockTranslations, + items: [ + { '@id': 'http://example.com/de/meine-seite', language: 'de' }, + ], + }, + }), + updateContent, + }); + + const result = await action( + actionArgs(context, { title: 'Titel' }) as any, + ); + + expect(updateContent).toHaveBeenCalledWith({ + path: '/de/meine-seite', + data: expect.objectContaining({ title: 'Titel' }), + }); + expect((result as Response).headers.get('Location')).toBe( + '/@@edit/de/meine-seite', + ); + }); + }); +}); diff --git a/packages/cmsui/routes/translate.tsx b/packages/cmsui/routes/translate.tsx new file mode 100644 index 000000000..e11a4300c --- /dev/null +++ b/packages/cmsui/routes/translate.tsx @@ -0,0 +1,337 @@ +import { + data, + redirect, + RouterContextProvider, + useLoaderData, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { flattenToAppURL, hasBlocksData } from '@plone/helpers'; +import { requireAuthCookie } from '@plone/react-router'; +import type { Content } from '@plone/types'; +import { + ploneClientContext, + ploneContentContext, +} from '@plone/aurora/app/middleware.server'; +import RenderBlocks from '@plone/layout/blocks/RenderBlocks'; +import config from '@plone/registry'; +import ContentForm from '../components/ContentForm/ContentForm'; + +const SERVER_MANAGED_KEYS = [ + '@id', + '@components', + 'UID', + 'id', + 'review_state', + 'is_folderish', + 'parent', +]; + +function stripServerKeys(content: Record) { + const clean: Record = {}; + for (const [key, value] of Object.entries(content)) { + if (!SERVER_MANAGED_KEYS.includes(key)) clean[key] = value; + } + return clean; +} + +const TEXT_NODE_TYPES = [ + 'title', + 'description', + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'ul', + 'ol', + 'li', + 'lic', + 'a', + 'code', + 'pre', +]; + +const TEXT_BLOCK_TYPES = ['slate', 'text', 'title', 'description']; + +type PlateNode = { type?: string; children?: unknown } & Record< + string, + unknown +>; + +function placeholderValueNodes(nodes: unknown): unknown { + if (!Array.isArray(nodes)) return nodes; + return nodes.map((node) => { + if (!node || typeof node !== 'object' || Array.isArray(node)) return node; + const plateNode = node as PlateNode; + const type = plateNode.type; + if (type && !TEXT_NODE_TYPES.includes(type) && !('text' in plateNode)) { + // Without the adapted block's '@type', the block adapter renders nothing. + return { + type, + ...(typeof plateNode.id === 'string' ? { id: plateNode.id } : {}), + ...(typeof plateNode['@type'] === 'string' + ? { '@type': plateNode['@type'] } + : {}), + children: [{ text: '' }], + }; + } + return { + ...plateNode, + ...(Array.isArray(plateNode.children) + ? { children: placeholderValueNodes(plateNode.children) } + : {}), + }; + }); +} + +function hasVisibleNodes(nodes: unknown): boolean { + if (!Array.isArray(nodes)) return false; + return nodes.some((node) => { + if (!node || typeof node !== 'object') return false; + const plateNode = node as PlateNode; + if (typeof plateNode.text === 'string' && plateNode.text.trim()) + return true; + if (typeof plateNode['@type'] === 'string') return true; + return hasVisibleNodes(plateNode.children); + }); +} + +// The middleware migrates all content to a somersault block, even empty one, +// so block presence alone proves nothing. +function hasVisibleSourceBlocks(source: Content): boolean { + const blocks = (source.blocks ?? {}) as Record< + string, + { value?: unknown } | undefined + >; + const somersault = blocks.__somersault__; + if (somersault) return hasVisibleNodes(somersault.value); + return hasBlocksData(source) && Object.keys(blocks).length > 0; +} + +function placeholderBlocks(blocks: Record) { + const clean: Record = {}; + for (const [id, block] of Object.entries(blocks)) { + const blockData = (block ?? {}) as Record; + const type = blockData['@type'] as string | undefined; + if (type === '__somersault__') { + clean[id] = { + ...blockData, + value: placeholderValueNodes(blockData.value), + }; + } else if (type && TEXT_BLOCK_TYPES.includes(type)) { + clean[id] = blockData; + } else { + clean[id] = { '@type': type }; + } + } + return clean; +} + +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + const source = context.get(ploneContentContext); + const sourcePath = flattenToAppURL(source['@id']); + + const targetLanguage = + new URL(request.url).searchParams.get('language') || ''; + + let schema; + let translations; + try { + [{ data: schema }, { data: translations }] = await Promise.all([ + cli.getType({ type: source['@type'] }), + cli.getTranslation({ path: sourcePath }), + ]); + } catch { + // eg. content outside a language root + throw data('Content is not translatable', { status: 400 }); + } + + if (!targetLanguage || !(translations.root ?? {})[targetLanguage]) { + throw data('Unknown target language', { status: 400 }); + } + + const existing = (translations.items ?? []).find( + (item) => item.language === targetLanguage, + ); + if (existing) { + throw redirect(`/@@edit${flattenToAppURL(existing['@id'])}`); + } + + const initial = { + '@type': source['@type'], + title: '', + blocks: placeholderBlocks((source.blocks ?? {}) as Record), + blocks_layout: source.blocks_layout ?? { items: [] }, + } as unknown as Content; + + return data( + flattenToAppURL({ + source, + schema, + initial, + targetLanguage, + }), + ); +} + +export async function action({ + request, + params, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + const source = context.get(ploneContentContext); + const sourcePath = `/${params['*'] || ''}`; + const targetLanguage = + new URL(request.url).searchParams.get('language') || ''; + + const formData = (await request.json()) as Record; + + const { data: translations } = await cli.getTranslation({ + path: sourcePath, + }); + const existing = (translations.items ?? []).find( + (item) => item.language === targetLanguage, + ); + + if (existing) { + const translationPath = flattenToAppURL(existing['@id']); + await cli.updateContent({ + path: translationPath, + data: stripServerKeys(formData), + }); + return redirect(`/@@edit${translationPath}`); + } + + const targetRoot = flattenToAppURL( + (translations.root ?? {})[targetLanguage] ?? '', + ); + if (!targetLanguage || !targetRoot) { + throw data('Unknown target language', { status: 400 }); + } + const created = await cli.createContent({ + path: targetRoot, + data: { + ...stripServerKeys(formData), + '@type': source['@type'], + language: targetLanguage, + } as any, + }); + const translationPath = flattenToAppURL(created.data['@id']); + await cli.linkTranslation({ + path: sourcePath, + data: { id: translationPath }, + }); + + return redirect(`/@@edit${translationPath}`); +} + +export default function Translate() { + const { source, schema, initial, targetLanguage } = + useLoaderData(); + const { t } = useTranslation(); + + const sourceLanguage = + typeof source.language === 'string' + ? source.language + : ((source.language as { token?: string } | undefined)?.token ?? '—'); + + const fieldPlaceholders: Record = {}; + for (const [field, value] of Object.entries(source)) { + if (typeof value === 'string' && value && field in schema.properties) { + fieldPlaceholders[field] = value; + } + } + + return ( + + {t('cmsui.translate.source')} ({sourceLanguage}) —{' '} + + {t('cmsui.translate.view_source')} + +

+ ), + blocks: hasVisibleSourceBlocks(source) ? ( + + ) : ( + <> +

{source.title}

+ {source.description && ( +

{source.description}

+ )} + + ), + content: ( +
+ {schema.fieldsets.flatMap((fieldset) => + fieldset.fields + .filter( + (field) => + !['blocks', 'blocks_layout', 'changeNote'].includes( + field, + ) && formatValue(source[field as keyof Content]), + ) + .map((field) => ( +
+
+ {schema.properties[field]?.title ?? field} +
+
{formatValue(source[field as keyof Content])}
+
+ )), + )} +
+ ), + }} + heading={t('cmsui.translate.heading_new', { + title: source.title, + language: targetLanguage, + })} + submitMethod="post" + /> + ); +} + +function formatValue(value: unknown): string { + if (value == null || value === '') return ''; + if (typeof value === 'boolean') return value ? '✓' : ''; + if (Array.isArray(value)) { + return value + .map((item) => formatValue(item)) + .filter(Boolean) + .join(', '); + } + if (typeof value === 'object') { + const record = value as Record; + return String(record.title ?? record.token ?? ''); + } + return String(value); +} From 4eea35c30ae3b665746601c19646e20a2952493b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Pl=C3=BCtzer?= Date: Fri, 12 Jun 2026 13:30:56 +0200 Subject: [PATCH 2/2] Add `@@manage-translations` route for managing content translations --- docs/how-to-guides/index.md | 1 + docs/how-to-guides/manage-translations.md | 59 +++ .../ObjectBrowserModal.test.tsx | 44 ++- .../ObjectBrowserModal.tsx | 122 +++--- packages/cmsui/config/routes.ts | 11 + packages/cmsui/locales/de/common.json | 14 + packages/cmsui/locales/en/common.json | 14 + packages/cmsui/locales/it/common.json | 14 + packages/cmsui/routes/layout.tsx | 5 + .../cmsui/routes/manageTranslations.test.tsx | 363 ++++++++++++++++++ packages/cmsui/routes/manageTranslations.tsx | 362 +++++++++++++++++ packages/cmsui/styles/cmsui.css | 1 + 12 files changed, 941 insertions(+), 69 deletions(-) create mode 100644 docs/how-to-guides/manage-translations.md create mode 100644 packages/cmsui/routes/manageTranslations.test.tsx create mode 100644 packages/cmsui/routes/manageTranslations.tsx diff --git a/docs/how-to-guides/index.md b/docs/how-to-guides/index.md index 0cce268c6..8087489e7 100644 --- a/docs/how-to-guides/index.md +++ b/docs/how-to-guides/index.md @@ -31,4 +31,5 @@ configure-plate-code-block-languages bind-metadata-fields-to-plate-text-blocks custom-content-types translate-content +manage-translations ``` diff --git a/docs/how-to-guides/manage-translations.md b/docs/how-to-guides/manage-translations.md new file mode 100644 index 000000000..2edc84b70 --- /dev/null +++ b/docs/how-to-guides/manage-translations.md @@ -0,0 +1,59 @@ +--- +myst: + html_meta: + "description": "How to link, unlink, and create translations of a content item in the Plone Aurora manage translations view" + "property=og:description": "How to link, unlink, and create translations of a content item in the Plone Aurora manage translations view" + "property=og:title": "Manage translations" + "keywords": "Plone Aurora, translations, multilingual, language, link, unlink" +--- + +# Manage translations + +This guide shows you how to link content items in different languages together as translations of each other, how to unlink them, and how to create a missing translation. +Translations work on a multilingual site, where each language has its own root folder, such as `/en` and `/de`. +Refer to {doc}`/how-to-guides/translate-content` for how to configure a multilingual site. + +## Open the manage translations view + +Log in to your site, then navigate to `https:///@@manage-translations/`, where `` is the path of the item whose translations you want to manage. +For example, to manage the translations of the page at `/en/my-page`, navigate to `https:///@@manage-translations/en/my-page`. + +The page heading {guilabel}`Manage translations for ""` confirms which item you're working on. +Below it, a table with the columns {guilabel}`Language`, {guilabel}`Path`, and {guilabel}`Tools` lists one row for each language of the site. +The row of the item's own language appears in bold and offers no tools. +Every other row shows either the path of the linked translation or {guilabel}`No translation`. +Select a path in the {guilabel}`Path` column to open that item. +To leave the view, select the {guilabel}`Back` button in the toolbar. + +```{note} +The view works only for content inside a language folder. +For content outside a language folder, such as the site root, the view responds with an {guilabel}`Error 400` page instead. +``` + +## Link an existing translation + +If the translation already exists as an item on the site, link it. + +1. In the row of the language, select the {guilabel}`+` button, labeled {guilabel}`Link an existing <language> translation`. +2. In the object browser, choose the item to link as the translation. + +The object browser starts at the root folder of that language. +To find an item in another folder, select the {guilabel}`Search content` button, and search the whole site. +A toast {guilabel}`Translation linked` confirms the link, and the row now shows the path of the linked item. + +If the server rejects the link, for example when the chosen item has the same language as the current item, an error toast {guilabel}`Translation update failed` shows the reason. + +## Create a translation + +If the translation doesn't exist yet, create it. + +In the row of the language, select the language icon, labeled {guilabel}`Create the <language> translation`. +This opens the translation view for that language, with the original next to your translation. +Refer to {doc}`/how-to-guides/translate-content` for how to work in the translation view. + +## Unlink a translation + +To unlink a translation, select the chain icon in its row, labeled {guilabel}`Unlink the <language> translation`. +Aurora removes the link between the two items immediately and confirms it with a toast {guilabel}`Translation unlinked`. +Unlinking doesn't delete any content. +Both items stay on the site, but they're no longer connected as translations of each other. diff --git a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.test.tsx b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.test.tsx index 9ab9449b9..11fe83945 100644 --- a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.test.tsx +++ b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.test.tsx @@ -43,14 +43,9 @@ vi.mock('react-i18next', () => ({ })); // Mock dei componenti -vi.mock('@plone/components', () => ({ - Modal: ({ children, isOpen, onOpenChange, className, ...props }: any) => ( - <div - data-testid="modal" - data-open={isOpen} - className={className} - {...props} - > +vi.mock('react-aria-components', () => ({ + ModalOverlay: ({ children, isOpen, onOpenChange, className }: any) => ( + <div data-testid="modal-overlay" data-open={isOpen} className={className}> {children} {onOpenChange && ( <button @@ -62,9 +57,11 @@ vi.mock('@plone/components', () => ({ )} </div> ), -})); - -vi.mock('react-aria-components', () => ({ + Modal: ({ children, className }: any) => ( + <div data-testid="modal" className={className}> + {children} + </div> + ), Dialog: ({ children, className, ...props }: any) => ( <div data-testid="dialog" className={className} {...props}> {children} @@ -145,7 +142,10 @@ describe('ObjectBrowserModal', () => { renderWithContext(defaultContextValue); expect(screen.getByTestId('modal')).toBeInTheDocument(); - expect(screen.getByTestId('modal')).toHaveAttribute('data-open', 'true'); + expect(screen.getByTestId('modal-overlay')).toHaveAttribute( + 'data-open', + 'true', + ); }); it('should render modal when closed', () => { @@ -154,7 +154,10 @@ describe('ObjectBrowserModal', () => { open: false, }); - expect(screen.getByTestId('modal')).toHaveAttribute('data-open', 'false'); + expect(screen.getByTestId('modal-overlay')).toHaveAttribute( + 'data-open', + 'false', + ); }); it('should prefer controlled isOpen when provided', () => { @@ -168,7 +171,10 @@ describe('ObjectBrowserModal', () => { }, ); - expect(screen.getByTestId('modal')).toHaveAttribute('data-open', 'true'); + expect(screen.getByTestId('modal-overlay')).toHaveAttribute( + 'data-open', + 'true', + ); }); it('should render dialog and widget body', () => { @@ -340,12 +346,16 @@ describe('ObjectBrowserModal', () => { }); describe('CSS Classes', () => { - it('should apply correct CSS classes to modal', () => { + it('should apply correct CSS classes to modal and overlay', () => { renderWithContext(defaultContextValue); + const overlay = screen.getByTestId('modal-overlay'); + expect(overlay).toHaveClass('fixed'); + expect(overlay).toHaveClass('inset-0'); + expect(overlay).toHaveClass('z-50'); + expect(overlay).toHaveClass('bg-black/50'); + const modal = screen.getByTestId('modal'); - expect(modal).toHaveClass('data-[entering]:animate-slide-in'); - expect(modal).toHaveClass('data-[exiting]:animate-slide-out'); expect(modal).toHaveClass('border-quanta-azure'); expect(modal).toHaveClass('bg-quanta-air'); }); diff --git a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.tsx b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.tsx index c2d4a01f9..daffb33b8 100644 --- a/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.tsx +++ b/packages/cmsui/components/ObjectBrowserWidget/ObjectBrowserModal.tsx @@ -1,5 +1,9 @@ -import { Dialog, Heading } from 'react-aria-components'; -import { Modal } from '@plone/components'; +import { + Dialog, + Heading, + Modal as RACModal, + ModalOverlay, +} from 'react-aria-components'; import { Button, Input } from '@plone/components/quanta'; import { SearchIcon, CloseIcon } from '@plone/components/Icons'; import { ObjectBrowserWidgetBody } from './ObjectBrowserWidgetBody'; @@ -33,71 +37,85 @@ export const ObjectBrowserModal = ({ }; return ( - <Modal + <ModalOverlay isDismissable className={` - data-[entering]:animate-slide-in - data-[exiting]:animate-slide-out - fixed top-0 right-0 bottom-0 w-[360px] border-l border-quanta-azure bg-quanta-air px-6 py-8 - text-black shadow-[rgba(0,0,0,0.1)_-8px_0px_20px] outline-none + fixed inset-0 z-50 bg-black/50 + data-[entering]:duration-200 data-[entering]:animate-in data-[entering]:fade-in + data-[exiting]:duration-150 data-[exiting]:animate-out data-[exiting]:fade-out `} isOpen={isOpen ?? open} onOpenChange={handleOpenChange} > - <Dialog className="flex h-full flex-col overflow-hidden p-1"> - {!searchMode ? ( - <div className="flex items-center justify-between"> - <Heading slot="title" className="!mb-0 !text-2xl"> - {title || t('cmsui.objectbrowserwidget.dialogTitle')} - </Heading> - <div className="flex items-center gap-0.5"> - <Button - variant="icon" - onPress={() => { - setSearchMode(true); - setSearchableText(''); - }} - type="button" - aria-label={t('cmsui.objectbrowserwidget.openSearch')} + <RACModal + className={` + fixed top-0 right-0 bottom-0 w-[360px] border-l border-quanta-azure bg-quanta-air px-6 + py-8 text-black shadow-2xl outline-none + data-[entering]:duration-300 data-[entering]:animate-in + data-[entering]:slide-in-from-right-full + data-[exiting]:duration-200 data-[exiting]:animate-out + data-[exiting]:slide-out-to-right-full + `} + > + <Dialog className="flex h-full flex-col overflow-hidden p-1"> + {!searchMode ? ( + <div className="flex items-center justify-between gap-2"> + <Heading + slot="title" + className="!mb-0 min-w-0 flex-1 truncate !text-xl" + title={title || t('cmsui.objectbrowserwidget.dialogTitle')} > - <SearchIcon /> - </Button> + {title || t('cmsui.objectbrowserwidget.dialogTitle')} + </Heading> + <div className="flex shrink-0 items-center gap-0.5"> + <Button + variant="icon" + onPress={() => { + setSearchMode(true); + setSearchableText(''); + }} + type="button" + aria-label={t('cmsui.objectbrowserwidget.openSearch')} + > + <SearchIcon /> + </Button> + <Button + slot="close" + variant="icon" + type="button" + aria-label={t('cmsui.objectbrowserwidget.closeDialog')} + onPress={() => handleOpenChange(false)} + > + <CloseIcon /> + </Button> + </div> + </div> + ) : ( + <div className="flex items-center gap-2"> + <Input + onChange={handleSearchInputChange} + className={'border-quanta rounded-md'} + aria-controls={ariaControlsId} + placeholder={t('cmsui.objectbrowserwidget.searchPlaceholder')} + /> <Button - slot="close" variant="icon" type="button" - aria-label={t('cmsui.objectbrowserwidget.closeDialog')} - onPress={() => handleOpenChange(false)} + aria-label={t('cmsui.objectbrowserwidget.closeSearch')} + onPress={() => { + setSearchMode(false); + setSearchableText(''); + }} > <CloseIcon /> </Button> </div> - </div> - ) : ( - <div className="flex items-center gap-2"> - <Input - onChange={handleSearchInputChange} - className={'border-quanta rounded-md'} - aria-controls={ariaControlsId} - placeholder={t('cmsui.objectbrowserwidget.searchPlaceholder')} - /> - <Button - variant="icon" - type="button" - aria-label={t('cmsui.objectbrowserwidget.closeSearch')} - onPress={() => { - setSearchMode(false); - setSearchableText(''); - }} - > - <CloseIcon /> - </Button> - </div> - )} - <ObjectBrowserWidgetBody /> - </Dialog> - </Modal> + )} + <ObjectBrowserWidgetBody /> + </Dialog> + </RACModal> + </ModalOverlay> ); }; diff --git a/packages/cmsui/config/routes.ts b/packages/cmsui/config/routes.ts index cb708daf2..937f6d568 100644 --- a/packages/cmsui/config/routes.ts +++ b/packages/cmsui/config/routes.ts @@ -60,6 +60,17 @@ export default function install(config: ConfigType) { }, ], }, + { + type: 'prefix', + path: '@@manage-translations', + children: [ + { + type: 'route', + path: '*', + file: '@plone/cmsui/routes/manageTranslations.tsx', + }, + ], + }, { type: 'prefix', path: 'controlpanel', diff --git a/packages/cmsui/locales/de/common.json b/packages/cmsui/locales/de/common.json index 23f85d184..a4eb63431 100644 --- a/packages/cmsui/locales/de/common.json +++ b/packages/cmsui/locales/de/common.json @@ -2,6 +2,20 @@ "cmsui": { "add": "Hinzufügen", "edit": "Bearbeiten", + "manage_translations": { + "title": "Übersetzungen verwalten für „{{title}}“", + "back": "Zurück", + "language": "Sprache", + "path": "Pfad", + "tools": "Werkzeuge", + "no_translation": "Keine Übersetzung", + "link": "Vorhandene Übersetzung ({{language}}) verknüpfen", + "unlink": "Übersetzung ({{language}}) trennen", + "create": "Übersetzung ({{language}}) erstellen", + "linked": "Übersetzung verknüpft", + "unlinked": "Übersetzung getrennt", + "failed": "Aktualisierung der Übersetzung fehlgeschlagen" + }, "save": "Speichern", "controlpanel": "Kontrollzentrum", "objectbrowserwidget": { diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json index 6d5606ffb..33b58a241 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -2,6 +2,20 @@ "cmsui": { "add": "Add", "edit": "Edit", + "manage_translations": { + "title": "Manage translations for \"{{title}}\"", + "back": "Back", + "language": "Language", + "path": "Path", + "tools": "Tools", + "no_translation": "No translation", + "link": "Link an existing {{language}} translation", + "unlink": "Unlink the {{language}} translation", + "create": "Create the {{language}} translation", + "linked": "Translation linked", + "unlinked": "Translation unlinked", + "failed": "Translation update failed" + }, "save": "Save", "recurrence": { "editRecurrence": "Edit recurrence", diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json index eeb7db260..40a70379a 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -2,6 +2,20 @@ "cmsui": { "add": "Aggiungi", "edit": "Modifica", + "manage_translations": { + "title": "Gestisci le traduzioni di \"{{title}}\"", + "back": "Indietro", + "language": "Lingua", + "path": "Percorso", + "tools": "Strumenti", + "no_translation": "Nessuna traduzione", + "link": "Collega una traduzione esistente ({{language}})", + "unlink": "Scollega la traduzione ({{language}})", + "create": "Crea la traduzione ({{language}})", + "linked": "Traduzione collegata", + "unlinked": "Traduzione scollegata", + "failed": "Aggiornamento della traduzione non riuscito" + }, "save": "Salva", "recurrence": { "editRecurrence": "Cambia la ricorrenza", diff --git a/packages/cmsui/routes/layout.tsx b/packages/cmsui/routes/layout.tsx index 49e3791d5..dc5370ce4 100644 --- a/packages/cmsui/routes/layout.tsx +++ b/packages/cmsui/routes/layout.tsx @@ -17,8 +17,10 @@ import { clsx } from 'clsx'; import i18next from '@plone/aurora/app/i18next.server'; import type { RootLoader } from '@plone/aurora/app/root'; import { PluggablesProvider } from '@plone/layout/components/Pluggable'; +import Toast from '@plone/layout/components/Toast/Toast'; import Toolbar from '@plone/layout/components/Toolbar/Toolbar'; import { shouldShowToolbar } from '@plone/layout/helpers'; +import config from '@plone/registry'; import stylesheet from '@plone/aurora/.plone/cmsui.css?url'; import { ploneContentContext } from '@plone/aurora/app/middleware.server'; @@ -105,6 +107,9 @@ export default function Index() { </div> </PluggablesProvider> </RACRouterProvider> + <Toast + queue={config.getUtility({ name: 'queue', type: 'toast' }).method()} + /> <ScrollRestoration /> <Scripts /> </body> diff --git a/packages/cmsui/routes/manageTranslations.test.tsx b/packages/cmsui/routes/manageTranslations.test.tsx new file mode 100644 index 000000000..1c0f3704c --- /dev/null +++ b/packages/cmsui/routes/manageTranslations.test.tsx @@ -0,0 +1,363 @@ +import { expect, describe, it, vi, afterEach } from 'vitest'; +import config from '@plone/registry'; +import { loader, action } from './manageTranslations'; +import { RouterContextProvider } from 'react-router'; +import { + ploneClientContext, + ploneContentContext, +} from '@plone/aurora/app/middleware.server'; + +vi.mock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('fake-token'), +})); + +const mockContent = { + '@id': 'http://example.com/de/my-page', + '@type': 'Document', + title: 'My Page', + language: { token: 'de', title: 'Deutsch' }, +}; + +const mockTranslations = { + '@id': 'http://example.com/de/my-page/@translations', + items: [{ '@id': 'http://example.com/en/my-page', language: 'en' }], + root: { + it: 'http://example.com/it', + fr: 'http://example.com/fr', + de: 'http://example.com/de', + en: 'http://example.com/en', + }, +}; + +describe('Manage translations route', () => { + afterEach(() => { + vi.restoreAllMocks(); + config.settings = {}; + }); + + describe('loader', () => { + it('should call getTranslation with the flattened content path', async () => { + const getTranslationMock = vi + .fn() + .mockResolvedValue({ data: mockTranslations }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + getTranslation: getTranslationMock, + } as any); + context.set(ploneContentContext, mockContent as any); + + const request = new Request( + 'http://example.com/@@manage-translations/de/my-page', + ); + + await loader({ + request, + params: { '*': 'de/my-page' }, + context, + unstable_pattern: '/@@manage-translations/*', + unstable_url: new URL(request.url), + }); + + expect(getTranslationMock).toHaveBeenCalledWith({ path: '/de/my-page' }); + }); + + it('should put the current language first and sort the rest alphabetically', async () => { + const getTranslationMock = vi + .fn() + .mockResolvedValue({ data: mockTranslations }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + getTranslation: getTranslationMock, + } as any); + context.set(ploneContentContext, mockContent as any); + + const request = new Request( + 'http://example.com/@@manage-translations/de/my-page', + ); + + const result = await loader({ + request, + params: { '*': 'de/my-page' }, + context, + unstable_pattern: '/@@manage-translations/*', + unstable_url: new URL(request.url), + }); + + expect(result.rows.map((row) => row.language)).toEqual([ + 'de', + 'en', + 'fr', + 'it', + ]); + expect(result.rows[0]).toEqual({ + language: 'de', + isCurrent: true, + translationPath: '/de/my-page', + rootPath: '/de', + }); + expect(result.rows.slice(1).every((row) => !row.isCurrent)).toBe(true); + }); + + it('should resolve linked translations and leave missing ones empty', async () => { + const getTranslationMock = vi + .fn() + .mockResolvedValue({ data: mockTranslations }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + getTranslation: getTranslationMock, + } as any); + context.set(ploneContentContext, mockContent as any); + + const request = new Request( + 'http://example.com/@@manage-translations/de/my-page', + ); + + const result = await loader({ + request, + params: { '*': 'de/my-page' }, + context, + unstable_pattern: '/@@manage-translations/*', + unstable_url: new URL(request.url), + }); + + expect(result.rows.find((row) => row.language === 'en')).toEqual({ + language: 'en', + isCurrent: false, + translationPath: '/en/my-page', + rootPath: '/en', + }); + expect(result.rows.find((row) => row.language === 'fr')).toEqual({ + language: 'fr', + isCurrent: false, + translationPath: null, + rootPath: '/fr', + }); + }); + + it('should return the content title and contentPath', async () => { + const getTranslationMock = vi + .fn() + .mockResolvedValue({ data: mockTranslations }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + getTranslation: getTranslationMock, + } as any); + context.set(ploneContentContext, mockContent as any); + + const request = new Request( + 'http://example.com/@@manage-translations/de/my-page', + ); + + const result = await loader({ + request, + params: { '*': 'de/my-page' }, + context, + unstable_pattern: '/@@manage-translations/*', + unstable_url: new URL(request.url), + }); + + expect(result.title).toBe('My Page'); + expect(result.contentPath).toBe('/de/my-page'); + }); + + it('should support a plain string language field', async () => { + const getTranslationMock = vi + .fn() + .mockResolvedValue({ data: mockTranslations }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + getTranslation: getTranslationMock, + } as any); + context.set(ploneContentContext, { + ...mockContent, + language: 'de', + } as any); + + const request = new Request( + 'http://example.com/@@manage-translations/de/my-page', + ); + + const result = await loader({ + request, + params: { '*': 'de/my-page' }, + context, + unstable_pattern: '/@@manage-translations/*', + unstable_url: new URL(request.url), + }); + + expect(result.rows[0].language).toBe('de'); + expect(result.rows[0].isCurrent).toBe(true); + }); + + it('should throw a 400 when the content is not translatable', async () => { + const getTranslationMock = vi + .fn() + .mockRejectedValue(new Error('Not Found')); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + getTranslation: getTranslationMock, + } as any); + context.set(ploneContentContext, mockContent as any); + + const request = new Request( + 'http://example.com/@@manage-translations/de/my-page', + ); + + await expect( + loader({ + request, + params: { '*': 'de/my-page' }, + context, + unstable_pattern: '/@@manage-translations/*', + unstable_url: new URL(request.url), + }), + ).rejects.toEqual( + expect.objectContaining({ + data: 'Content is not translatable', + init: expect.objectContaining({ status: 400 }), + }), + ); + }); + }); + + describe('action', () => { + it('should call linkTranslation and report success', async () => { + const linkTranslationMock = vi.fn().mockResolvedValue({}); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + linkTranslation: linkTranslationMock, + } as any); + context.set(ploneContentContext, mockContent as any); + + const request = new Request( + 'http://example.com/@@manage-translations/de/my-page', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ intent: 'link', target: '/en/my-page' }), + }, + ); + + const result = await action({ + request, + params: { '*': 'de/my-page' }, + context, + unstable_pattern: '/@@manage-translations/*', + unstable_url: new URL(request.url), + }); + + expect(linkTranslationMock).toHaveBeenCalledWith({ + path: '/de/my-page', + data: { id: '/en/my-page' }, + }); + expect(result).toEqual({ ok: true, intent: 'link' }); + }); + + it('should call unlinkTranslation and report success', async () => { + const unlinkTranslationMock = vi.fn().mockResolvedValue({}); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + unlinkTranslation: unlinkTranslationMock, + } as any); + context.set(ploneContentContext, mockContent as any); + + const request = new Request( + 'http://example.com/@@manage-translations/de/my-page', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ intent: 'unlink', language: 'en' }), + }, + ); + + const result = await action({ + request, + params: { '*': 'de/my-page' }, + context, + unstable_pattern: '/@@manage-translations/*', + unstable_url: new URL(request.url), + }); + + expect(unlinkTranslationMock).toHaveBeenCalledWith({ + path: '/de/my-page', + data: { language: 'en' }, + }); + expect(result).toEqual({ ok: true, intent: 'unlink' }); + }); + + it('should return the error message and status when the client rejects', async () => { + const linkTranslationMock = vi.fn().mockRejectedValue({ + status: 400, + data: { error: { message: 'Boom' } }, + }); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + linkTranslation: linkTranslationMock, + } as any); + context.set(ploneContentContext, mockContent as any); + + const request = new Request( + 'http://example.com/@@manage-translations/de/my-page', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ intent: 'link', target: '/en/my-page' }), + }, + ); + + const result = (await action({ + request, + params: { '*': 'de/my-page' }, + context, + unstable_pattern: '/@@manage-translations/*', + unstable_url: new URL(request.url), + })) as any; + + expect(result.data).toEqual({ ok: false, error: 'Boom' }); + expect(result.init?.status).toBe(400); + }); + + it('should return a 400 for an unknown intent', async () => { + const linkTranslationMock = vi.fn(); + const unlinkTranslationMock = vi.fn(); + config.settings.apiPath = 'http://example.com'; + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + linkTranslation: linkTranslationMock, + unlinkTranslation: unlinkTranslationMock, + } as any); + context.set(ploneContentContext, mockContent as any); + + const request = new Request( + 'http://example.com/@@manage-translations/de/my-page', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ intent: 'copy' }), + }, + ); + + const result = (await action({ + request, + params: { '*': 'de/my-page' }, + context, + unstable_pattern: '/@@manage-translations/*', + unstable_url: new URL(request.url), + })) as any; + + expect(linkTranslationMock).not.toHaveBeenCalled(); + expect(unlinkTranslationMock).not.toHaveBeenCalled(); + expect(result.data).toEqual({ ok: false, error: 'Unknown intent' }); + expect(result.init?.status).toBe(400); + }); + }); +}); diff --git a/packages/cmsui/routes/manageTranslations.tsx b/packages/cmsui/routes/manageTranslations.tsx new file mode 100644 index 000000000..21762935a --- /dev/null +++ b/packages/cmsui/routes/manageTranslations.tsx @@ -0,0 +1,362 @@ +import { useEffect, useState } from 'react'; +import { + data, + Link, + RouterContextProvider, + useFetcher, + useLoaderData, + useNavigate, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { flattenToAppURL, langmap } from '@plone/helpers'; +import { requireAuthCookie } from '@plone/react-router'; +import type { Brain } from '@plone/types'; +import config from '@plone/registry'; +import { + ploneClientContext, + ploneContentContext, +} from '@plone/aurora/app/middleware.server'; +import { Plug } from '@plone/layout/components/Pluggable'; +import { type ToastItem } from '@plone/layout/config/toast'; +import { + Button, + Cell, + Column, + Container, + Row, + Table, + TableBody, + TableHeader, +} from '@plone/components/quanta'; +import { AddIcon, LanguageIcon, LinkIcon } from '@plone/components/Icons'; +import Back from '@plone/components/icons/arrow-left.svg?react'; +import { ObjectBrowserProvider } from '../components/ObjectBrowserWidget/ObjectBrowserContext'; +import { ObjectBrowserModal } from '../components/ObjectBrowserWidget/ObjectBrowserModal'; + +export async function loader({ + request, + context, +}: LoaderFunctionArgs<RouterContextProvider>) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + const content = context.get(ploneContentContext); + const contentPath = flattenToAppURL(content['@id']); + + let translations; + try { + ({ data: translations } = await cli.getTranslation({ path: contentPath })); + } catch { + throw data('Content is not translatable', { status: 400 }); + } + + const language = content.language as { token?: string } | string | undefined; + const contentLanguage = + typeof language === 'string' ? language : (language?.token ?? ''); + + const linked = new Map( + (translations.items ?? []).map((item) => [ + item.language, + flattenToAppURL(item['@id']), + ]), + ); + + const rows = Object.keys(translations.root ?? {}) + .sort( + (a, b) => + Number(b === contentLanguage) - Number(a === contentLanguage) || + a.localeCompare(b), + ) + .map((lang) => ({ + language: lang, + isCurrent: lang === contentLanguage, + translationPath: + lang === contentLanguage ? contentPath : (linked.get(lang) ?? null), + rootPath: flattenToAppURL(translations.root[lang]), + })); + + return { title: content.title, contentPath, rows }; +} + +type ActionBody = + | { intent: 'link'; target: string } + | { intent: 'unlink'; language: string }; + +export async function action({ + request, + context, +}: ActionFunctionArgs<RouterContextProvider>) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + const content = context.get(ploneContentContext); + const contentPath = flattenToAppURL(content['@id']); + const body = (await request.json()) as ActionBody; + + try { + if (body.intent === 'link') { + await cli.linkTranslation({ + path: contentPath, + data: { id: body.target }, + }); + } else if (body.intent === 'unlink') { + await cli.unlinkTranslation({ + path: contentPath, + data: { language: body.language }, + }); + } else { + return data( + { ok: false as const, error: 'Unknown intent' }, + { status: 400 }, + ); + } + } catch (e) { + const err = e as { + status?: number; + data?: { error?: { message?: string }; message?: string }; + }; + return data( + { + ok: false as const, + error: + err?.data?.error?.message ?? err?.data?.message ?? 'Request failed', + }, + { status: typeof err?.status === 'number' ? err.status : 400 }, + ); + } + + return { ok: true as const, intent: body.intent }; +} + +const showToast = (item: ToastItem) => + config.getUtility({ name: 'show', type: 'toast' }).method(item); + +export default function ManageTranslations() { + const { title, contentPath, rows } = useLoaderData<typeof loader>(); + const fetcher = useFetcher<typeof action>(); + const navigate = useNavigate(); + const { t } = useTranslation(); + + useEffect(() => { + if (fetcher.state === 'idle' && fetcher.data) { + if (fetcher.data.ok) { + showToast({ + title: + fetcher.data.intent === 'link' + ? t('cmsui.manage_translations.linked') + : t('cmsui.manage_translations.unlinked'), + icon: <LanguageIcon />, + }); + } else { + showToast({ + title: t('cmsui.manage_translations.failed'), + description: fetcher.data.error, + icon: <LanguageIcon />, + className: 'error', + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher.state, fetcher.data]); + + const busy = fetcher.state !== 'idle'; + + const link = (target: string) => + fetcher.submit( + { intent: 'link', target }, + { method: 'POST', encType: 'application/json' }, + ); + + const unlink = (language: string) => + fetcher.submit( + { intent: 'unlink', language }, + { method: 'POST', encType: 'application/json' }, + ); + + return ( + <> + <Plug pluggable="toolbar-top" id="button-back"> + <Button + aria-label={t('cmsui.manage_translations.back')} + size="L" + onPress={() => navigate(contentPath || '/')} + > + <Back /> + </Button> + </Plug> + <main> + <Container width="default" className="route-manage-translations py-8"> + <h1 className="documentFirstHeading mb-6 text-2xl font-bold"> + {t('cmsui.manage_translations.title', { title })} + </h1> + <Table + aria-label={t('cmsui.manage_translations.title', { title })} + className="max-h-none rounded-none border-0 border-b border-b-neutral-200" + > + <TableHeader + className={` + border-b-quanta-silver bg-transparent backdrop-blur-none + supports-[-moz-appearance:none]:bg-transparent + `} + > + <Column + isRowHeader + width="1fr" + minWidth={160} + className="border-b border-b-neutral-200 font-medium text-quanta-sapphire" + > + {t('cmsui.manage_translations.language')} + </Column> + <Column + width="2fr" + minWidth={240} + className="border-b border-b-neutral-200 font-medium text-quanta-sapphire" + > + {t('cmsui.manage_translations.path')} + </Column> + <Column + width={112} + className="border-b border-b-neutral-200 font-medium text-quanta-sapphire" + > + {t('cmsui.manage_translations.tools')} + </Column> + </TableHeader> + <TableBody> + {rows.map((row) => { + const languageName = + langmap[row.language]?.nativeName ?? row.language; + return ( + <Row key={row.language}> + <Cell> + <div + className={` + flex min-h-10 items-center text-sm text-quanta-space + ${row.isCurrent ? 'font-bold' : ''} + `} + > + {languageName} + </div> + </Cell> + <Cell> + <div className="flex min-h-10 items-center text-sm"> + {row.translationPath ? ( + <Link + to={row.translationPath} + className={` + text-quanta-sapphire + hover:underline + `} + > + {row.translationPath} + </Link> + ) : ( + <span className="text-quanta-pigeon"> + {t('cmsui.manage_translations.no_translation')} + </span> + )} + </div> + </Cell> + <Cell> + <div className="flex min-h-10 items-center gap-1"> + {!row.isCurrent && + (row.translationPath ? ( + <Button + variant="icon" + type="button" + aria-label={t( + 'cmsui.manage_translations.unlink', + { language: languageName }, + )} + onPress={() => unlink(row.language)} + isDisabled={busy} + > + <LinkIcon size="sm" /> + </Button> + ) : ( + <> + <LinkTranslationPicker + label={t('cmsui.manage_translations.link', { + language: languageName, + })} + rootPath={row.rootPath} + isDisabled={busy} + onPick={(item) => + item['@id'] && + link(flattenToAppURL(item['@id'])) + } + /> + <Link + to={`/@@translate${contentPath}?language=${row.language}`} + aria-label={t( + 'cmsui.manage_translations.create', + { language: languageName }, + )} + className={` + flex size-8 items-center justify-center rounded-full + text-quanta-iron + hover:bg-quanta-snow + active:bg-quanta-silver + `} + > + <LanguageIcon size="sm" /> + </Link> + </> + ))} + </div> + </Cell> + </Row> + ); + })} + </TableBody> + </Table> + </Container> + </main> + </> + ); +} + +function LinkTranslationPicker({ + label, + rootPath, + isDisabled, + onPick, +}: { + label: string; + rootPath: string; + isDisabled?: boolean; + onPick: (item: Partial<Brain>) => void; +}) { + const [open, setOpen] = useState(false); + return ( + <> + <Button + variant="icon" + type="button" + aria-label={label} + onPress={() => setOpen(true)} + isDisabled={isDisabled} + > + <AddIcon size="sm" /> + </Button> + {open && ( + <ObjectBrowserProvider + config={{ + mode: 'single', + title: label, + initialPath: rootPath, + onChange: (selected) => { + if (selected.length > 0) { + setOpen(false); + onPick(selected[0]); + } + }, + }} + > + <ObjectBrowserModal isOpen={open} onOpenChange={setOpen} /> + </ObjectBrowserProvider> + )} + </> + ); +} diff --git a/packages/cmsui/styles/cmsui.css b/packages/cmsui/styles/cmsui.css index 0eb7b8acc..1007024bc 100644 --- a/packages/cmsui/styles/cmsui.css +++ b/packages/cmsui/styles/cmsui.css @@ -7,6 +7,7 @@ @layer cmsui { @import '@plone/components/src/styles/basic/theme.css'; + @import '@plone/components/src/styles/basic/Toast.css'; @import 'tailwindcss/theme.css'; @import 'tailwindcss/preflight.css'; @import 'tailwindcss/utilities.css';