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] 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); +}