Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/how-to-guides/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@ icons
configure-plate-code-block-languages
bind-metadata-fields-to-plate-text-blocks
custom-content-types
translate-content
manage-translations
```
59 changes: 59 additions & 0 deletions docs/how-to-guides/manage-translations.md
Original file line number Diff line number Diff line change
@@ -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://<site>/@@manage-translations/<path-of-the-content>`, where `<path-of-the-content>` 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://<site>/@@manage-translations/en/my-page`.

The page heading {guilabel}`Manage translations for "<title>"` 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.
65 changes: 65 additions & 0 deletions docs/how-to-guides/translate-content.md
Original file line number Diff line number Diff line change
@@ -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/<path-to-item>?language=<language-code>
```

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.
129 changes: 80 additions & 49 deletions packages/cmsui/components/ContentForm/ContentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,19 @@ 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<string, string>;
}

export default function ContentForm({
content,
schema,
heading,
submitMethod,
asidePanels,
fieldPlaceholders,
}: ContentFormProps) {
const { t } = useTranslation();
const fetcher = useFetcher();
Expand All @@ -64,6 +70,68 @@ export default function ContentForm({
},
});

const fieldsetsForm = (
<form>
{schema.fieldsets.map((fieldset) => (
<Accordion defaultExpandedKeys={['default']} key={fieldset.id}>
<AccordionItem id={fieldset.id} key={fieldset.id}>
<AccordionItemTrigger>{fieldset.title}</AccordionItemTrigger>
<AccordionPanel>
{(fieldset.fields as DeepKeys<Content>[]).map(
(schemaField, index) => (
<form.AppField
name={schemaField}
key={index}
// eslint-disable-next-line react/no-children-prop
children={(field) => (
<field.Quanta
{...schema.properties[schemaField]}
className="mb-4"
label={schema.properties[field.name].title}
name={field.name}
defaultValue={field.state.value}
required={schema.required.indexOf(schemaField) !== -1}
error={field.state.meta.errors}
formAtom={formAtom}
value={field.state.value}
{...(fieldPlaceholders?.[field.name]
? { placeholder: fieldPlaceholders[field.name] }
: {})}
/>
)}
/>
),
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
))}
</form>
);

// 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 ? (
<div
className={`
grid grid-cols-1 gap-x-8 pt-4
lg:grid-cols-2 lg:grid-rows-[auto_1fr]
`}
>
<div className="mb-4 self-end px-6">{asidePanels.header}</div>
<h1 className="mb-4 self-end px-6 text-2xl font-bold">{heading}</h1>
<aside className="sticky top-0 max-h-screen self-start overflow-y-auto px-6">
{panel}
</aside>
<div className={padBody ? 'flex flex-col px-6' : 'flex flex-col'}>
{body}
</div>
</div>
) : (
body
);

return (
<Provider store={store}>
<InitAtoms atomValues={[[formAtom, content]]}>
Expand All @@ -86,59 +154,22 @@ export default function ContentForm({
{
id: 'blocks',
title: t('cmsui.blocksEditor.blocksTab'),
content: <BlocksEditor />,
content: withAside(asidePanels?.blocks, <BlocksEditor />),
},
{
id: 'content',
title: t('cmsui.blocksEditor.contentTab'),
content: (
<div className="flex flex-col">
<h1 className="mb-4 text-2xl font-bold">{heading}</h1>
<form>
{schema.fieldsets.map((fieldset) => (
<Accordion
defaultExpandedKeys={['default']}
key={fieldset.id}
>
<AccordionItem id={fieldset.id} key={fieldset.id}>
<AccordionItemTrigger>
{fieldset.title}
</AccordionItemTrigger>
<AccordionPanel>
{(fieldset.fields as DeepKeys<Content>[]).map(
(schemaField, index) => (
<form.AppField
name={schemaField}
key={index}
// eslint-disable-next-line react/no-children-prop
children={(field) => (
<field.Quanta
{...schema.properties[schemaField]}
className="mb-4"
label={
schema.properties[field.name].title
}
name={field.name}
defaultValue={field.state.value}
required={
schema.required.indexOf(
schemaField,
) !== -1
}
error={field.state.meta.errors}
formAtom={formAtom}
value={field.state.value}
/>
)}
/>
),
)}
</AccordionPanel>
</AccordionItem>
</Accordion>
))}
</form>
</div>
content: withAside(
asidePanels?.content,
asidePanels ? (
fieldsetsForm
) : (
<div className="flex flex-col">
<h1 className="mb-4 text-2xl font-bold">{heading}</h1>
{fieldsetsForm}
</div>
),
true,
),
},
]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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');
});
Expand Down
Loading
Loading