Skip to content
Merged
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
27 changes: 27 additions & 0 deletions docs/Translate.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,33 @@ const messages = {

{% endraw %}

### React Element Interpolation

Unlike `useTranslate`, `<Translate>` supports React elements as interpolation values. This is useful when you need to include styled or interactive content within a translated message.

{% raw %}

```tsx
const messages = {
custom: {
welcome: 'Hello, %{name}! Welcome to %{app}.',
},
};

<Translate
i18nKey="custom.welcome"
options={{
name: <strong>John</strong>,
app: <a href="https://marmelab.com/react-admin">react-admin</a>,
}}
/>
// Hello, <strong>John</strong>! Welcome to <a href="...">react-admin</a>.
```

{% endraw %}

**Tip:** This feature is only available in the `<Translate>` component, not in the `useTranslate` hook.

One particular option is `smart_count`, which is used for pluralization.

{% raw %}
Expand Down
23 changes: 23 additions & 0 deletions docs_headless/src/content/docs/Translate.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ const messages = {
// Hello, John!
```

### React Element Interpolation

Unlike `useTranslate`, `<Translate>` supports React elements as interpolation values. This is useful when you need to include styled or interactive content within a translated message.

```tsx
const messages = {
custom: {
welcome: 'Hello, %{name}! Welcome to %{app}.',
},
};

<Translate
i18nKey="custom.welcome"
options={{
name: <strong>John</strong>,
app: <a href="https://marmelab.com/react-admin">react-admin</a>,
}}
/>
// Hello, <strong>John</strong>! Welcome to <a href="...">react-admin</a>.
```

**Tip:** This feature is only available in the `<Translate>` component, not in the `useTranslate` hook.

One particular option is `smart_count`, which is used for pluralization.

```tsx
Expand Down
8 changes: 8 additions & 0 deletions packages/ra-core/src/i18n/Translate.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
NoTranslation,
NoTranslationWithChildrenAsNode,
NoTranslationWithChildrenAsString,
ReactElementInterpolation,
} from './Translate.stories';

describe('<Translate />', () => {
Expand Down Expand Up @@ -45,4 +46,11 @@ describe('<Translate />', () => {
const { container } = render(<Options />);
expect(container.innerHTML).toBe('It cost 6.00 $');
});

it('should render the translation with React element interpolation', () => {
const { container } = render(<ReactElementInterpolation />);
expect(container.innerHTML).toBe(
'Hello <strong>John</strong>, welcome to <em>react-admin</em>!'
);
});
});
19 changes: 19 additions & 0 deletions packages/ra-core/src/i18n/Translate.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,22 @@ export const Options = () => (
<Translate i18nKey="custom.myKey" options={{ price: '6' }} />
</TestTranslationProvider>
);

export const ReactElementInterpolation = () => (
<TestTranslationProvider
messages={{
custom: {
myKey: ({ name, place }) =>
`Hello ${name}, welcome to ${place}!`,
},
}}
>
<Translate
i18nKey="custom.myKey"
options={{
name: <strong>John</strong>,
place: <em>react-admin</em>,
}}
/>
</TestTranslationProvider>
);
60 changes: 52 additions & 8 deletions packages/ra-core/src/i18n/Translate.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,65 @@
import React, { ReactNode } from 'react';
import React, { isValidElement, ReactElement, ReactNode } from 'react';
import { useTranslate } from './useTranslate';

const SPLIT_MARKER = '#@RA_CORE_INTERNAL_SPLIT@#';

export const Translate = ({ i18nKey, options, children }: TranslateProps) => {
const translate = useTranslate();
const translatedMessage = translate(
i18nKey,
typeof children === 'string' ? { _: children, ...options } : options
);

if (translatedMessage) {
// Separate React element values from plain values
const elementMap: Record<string, ReactElement> = {};
const sanitizedOptions: Record<string, any> = {};
let placeholderIndex = 0;

if (options) {
for (const [key, value] of Object.entries(options)) {
if (isValidElement(value)) {
const placeholder = `TRANSLATION_PLACEHOLDER_${placeholderIndex++}`;
elementMap[placeholder] = value;
sanitizedOptions[key] =
`${SPLIT_MARKER}${placeholder}${SPLIT_MARKER}`;
} else {
sanitizedOptions[key] = value;
}
}
}

const translateOptions =
typeof children === 'string'
? { _: children, ...sanitizedOptions }
: sanitizedOptions;

const translatedMessage = translate(i18nKey, translateOptions);

if (!translatedMessage) {
return children;
}

// If no elements were extracted, return plain string
if (placeholderIndex === 0) {
return <>{translatedMessage}</>;
}
return children;

// Split the translated string and replace placeholders with React elements
const parts = translatedMessage.split(SPLIT_MARKER);
Comment on lines +10 to +44
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about adding useMemo around all those computations.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When calling <Translate key="foo" options={{ name: <NameField /> }} />, the options prop receives a new object on every render, so useMemo will always recompute. The memo is effectively useless for the most common usage pattern.

Besides, we don't know if this computation is costly. Premature optimization?

return (
<>
{/* After splitting by SPLIT_MARKER, even indices are text and odd indices are placeholders */}
{parts.map((part, index) =>
index % 2 === 1 ? (
<React.Fragment key={index}>
{elementMap[part]}
</React.Fragment>
) : (
<React.Fragment key={index}>{part}</React.Fragment>
)
)}
</>
);
};

export interface TranslateProps {
i18nKey: string;
children?: ReactNode;
options?: Object;
options?: Record<string, any>;
}
10 changes: 10 additions & 0 deletions packages/ra-i18n-i18next/src/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
WithCustomOptions,
WithLazyLoadedLanguages,
TranslateComponent,
TranslateWithReactElement,
} from './index.stories';

describe('i18next i18nProvider', () => {
Expand Down Expand Up @@ -79,4 +80,13 @@ describe('i18next i18nProvider', () => {
);
});
});

test('should support React elements in <Translate> options', async () => {
const { container } = render(<TranslateWithReactElement />);
await waitFor(() => {
expect(container.innerHTML).toContain(
'Hello, <strong>John</strong>! Welcome to <em>react-admin</em>.'
);
});
});
});
71 changes: 70 additions & 1 deletion packages/ra-i18n-i18next/src/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import {
import i18n from 'i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import englishMessages from 'ra-language-english';
import frenchMessages from 'ra-language-french';
import fakeRestDataProvider from 'ra-data-fakerest';
import { Translate, I18nContextProvider } from 'ra-core';
import { Translate, I18nContextProvider, useI18nProvider } from 'ra-core';
import { useI18nextProvider, convertRaTranslationsToI18next } from './index';

export default {
Expand Down Expand Up @@ -173,6 +174,74 @@ export const WithCustomOptions = () => {
);
};

export const TranslateWithReactElement = () => {
const i18nProvider = useI18nextProvider({
options: {
resources: {
en: {
translation: convertRaTranslationsToI18next({
...englishMessages,
custom: {
welcome: 'Hello, %{name}! Welcome to %{place}.',
},
}),
},
fr: {
translation: convertRaTranslationsToI18next({
...frenchMessages,
custom: {
welcome: 'Bonjour, %{name}! Bienvenue à %{place}.',
},
}),
},
},
},
availableLocales: [
{ locale: 'en', name: 'English' },
{ locale: 'fr', name: 'Français' },
],
});

if (!i18nProvider) return null;

return (
<I18nContextProvider value={i18nProvider}>
<TranslateWithReactElementContent />
</I18nContextProvider>
);
};

const TranslateWithReactElementContent = () => {
const i18nProvider = useI18nProvider();
const [, forceUpdate] = React.useReducer(x => x + 1, 0);

const handleLocaleChange = async (locale: string) => {
await i18nProvider.changeLocale(locale);
forceUpdate();
};

return (
<div>
<div>
<button onClick={() => handleLocaleChange('en')}>
English
</button>
<button onClick={() => handleLocaleChange('fr')}>
Français
</button>
</div>
<br />
<Translate
i18nKey="custom.welcome"
options={{
name: <strong>John</strong>,
place: <em>react-admin</em>,
}}
/>
</div>
);
};

export const TranslateComponent = () => {
const i18nProvider = useI18nextProvider({
options: {
Expand Down
13 changes: 11 additions & 2 deletions packages/ra-i18n-polyglot/src/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from 'react';
import { render, waitFor } from '@testing-library/react';
import { TranslateComponent } from './index.stories';
import { TranslateComponent, TranslateWithReactElement } from './index.stories';

describe('i18next i18nProvider', () => {
describe('polyglot i18nProvider', () => {
test('should be compatible with the <Translate> component', async () => {
const { container } = render(<TranslateComponent />);
await waitFor(() => {
Expand All @@ -11,4 +11,13 @@ describe('i18next i18nProvider', () => {
);
});
});

test('should support React elements in <Translate> options', async () => {
const { container } = render(<TranslateWithReactElement />);
await waitFor(() => {
expect(container.innerHTML).toEqual(
'Hello, <strong>John</strong>! Welcome to <em>react-admin</em>.'
);
});
});
});
50 changes: 48 additions & 2 deletions packages/ra-i18n-polyglot/src/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default { title: 'ra-i18n-polyglot' };

export const Basic = () => {
const i18nProvider = polyglotI18nProvider(
locale => messages[locale],
locale => (locale === 'fr' ? messages['fr'] : messages['en']),
'en',
[
{ locale: 'en', name: 'English' },
Expand Down Expand Up @@ -102,7 +102,7 @@ export const TranslateComponent = () => {
};

const i18nProvider = polyglotI18nProvider(
locale => messages[locale],
locale => (locale === 'fr' ? messages['fr'] : messages['en']),
'en',
[
{ locale: 'en', name: 'English' },
Expand All @@ -128,3 +128,49 @@ export const TranslateComponent = () => {
</I18nContextProvider>
);
};

export const TranslateWithReactElement = ({ locale = 'en' }) => {
const messages = {
fr: {
...frenchMessages,
custom: {
welcome: 'Bonjour, %{name}! Bienvenue à %{place}.',
},
},
en: {
...englishMessages,
custom: {
welcome: 'Hello, %{name}! Welcome to %{place}.',
},
},
};
const i18nProvider = polyglotI18nProvider(
locale => (locale === 'fr' ? messages['fr'] : messages['en']),
locale,
[
{ locale: 'en', name: 'English' },
{ locale: 'fr', name: 'Français' },
]
);

return (
<I18nContextProvider value={i18nProvider}>
<Translate
i18nKey="custom.welcome"
options={{
name: <strong>John</strong>,
place: <em>react-admin</em>,
}}
/>
</I18nContextProvider>
);
};
TranslateWithReactElement.args = {
locale: 'en',
};
TranslateWithReactElement.argTypes = {
locale: {
control: 'select',
options: ['en', 'fr'],
},
};
Loading