Skip to content

Commit

Permalink
feat: internationalization support
Browse files Browse the repository at this point in the history
- react-i18next and i18next libaries
- jest mock for consistent test translations
- updated Home component with examples

Contributes to: strimzi#19

Signed-off-by: Nic Townsend <[email protected]>
  • Loading branch information
nictownsend committed Nov 9, 2020
1 parent 85761b6 commit 0cca303
Show file tree
Hide file tree
Showing 15 changed files with 421 additions and 5 deletions.
7 changes: 7 additions & 0 deletions __mocks__/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## Contents

Manual mocks for node modules in Jest.

| module | behaviour |
| ------------- | ------------------------------------------------------------------------------------------------------- |
| react-i18next | stubs out `useTranslation` hook and `<Trans>` to provide stringified representations of the translation |
27 changes: 27 additions & 0 deletions __mocks__/react-i18next.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright Strimzi authors.
* License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html).
*/
import { translate, translateWithFormatting } from 'utils/test/i18n';
import { TFunction } from 'i18next';
import React, { FunctionComponent } from 'react';
import { UseTranslationResponse } from 'react-i18next';

const i18next: jest.Mock = jest.createMockFromModule('react-i18next');

const useTranslation = () => {
const trans = translate as TFunction;

const hookResult = [translate, null, true];
// eslint-disable-next-line id-length
(hookResult as UseTranslationResponse).t = trans;

return hookResult;
};

const Trans: FunctionComponent<{ i18nKey: string }> = ({
i18nKey,
children,
}) => <div>{translateWithFormatting(i18nKey, children)}</div>;

module.exports = { ...i18next, useTranslation, Trans };
2 changes: 2 additions & 0 deletions client/Bootstrap/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
*/

import './style.scss';
import { init } from 'i18n';
import ReactDOM from 'react-dom';
import React from 'react';
import { Home } from 'Panels/Home';

init(); //Bootstrap i18next support
ReactDOM.render(<Home />, document.getElementById('root'));
1 change: 0 additions & 1 deletion client/Panels/Home/Home.feature
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

# Copyright Strimzi authors.
# License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html).
Feature: Home component
Expand Down
5 changes: 5 additions & 0 deletions client/Panels/Home/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/*
* Copyright Strimzi authors.
* License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html).
*/
export { Home } from './Home';
140 changes: 140 additions & 0 deletions client/i18n/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Internationalisation (i18n)

This project uses `i18next` with `react-i18next` to provide internationalization of content. Human readable content should not be used directly in React components. Instead, they should be looked up using `react-i18next` - which will ensure the content is localized. The library also supports interpolation of dynamic values and formatting of the output. `eslint-plugin-i18next` is used to ensure that JSX cannot be written that contains text without a translation.

## Usage

The `useTranslation` hook provides a `t(key, options)` function - that takes a translation key and (optional) options and returns the translated content. See [https://www.i18next.com/translation-function/essentials](https://www.i18next.com/translation-function/essentials) for more details. An object hash containing values for interpolation can be supplied as the second parameter to `t` instead of a full options object.

For readability purposes, `t` should be renamed to `translate` or equivalent.

```tsx
import {useTranslation} from 'react-i18next';
...
export const myComponent = () => {
const {t: translate} = useTranslation();
return (
<>
<div>{translate('my.key')}</div>
<div>{translate('with.inserts', {valueToInsert: "test"})}</div>
</>);
}
```

### Providing translation values

`Bootstrap/i18n.ts` configures the `i18next` library to load translations from `./index.ts`.

`index.ts` exports an Object containing translations from `<language>.json`.

`<language>.json` contains the following shape - where `{{key}}` is substituted using a matching key in the inserts passed to `translate()`:

```json
{
"my": {
"key": "value"
},
"with": {
"inserts": "This has a {{valueToInsert}}"
}
}
```

#### Fallback

If you're using the `t` function - the key will be rendered if the translation is missing in the desired language or any fallbacks. Inserts are ignored.

If you're using the `Trans` component, any children will be rendered but not the `i18nkey` prop.

## Basic formatting in translations

The `<Trans>` component can be used to render translated messages that contain basic formatting tags - `<br>, <strong>, <i>, <p>` can all be embedded directly into the translation string. `<Trans>` takes `translate` as a property `t` to allow it to re-render when language changes. `i18nKey` is passed in to point to the right translation. Inserts are passed as singleton key:value objects.

```tsx
import { Trans, useTranslation } from 'react-i18next';
export const myComponent = () => {
const { translate } = useTranslation();
return (
<>
<div>
<Trans t={translate} i18nKey='simple.format'></Trans>
</div>
<div>
<Trans t={translate} i18nKey='with.inserts'>
{{ insert: 'value1' }}
{{ another: 'value2' }}
</Trans>
</div>
</>
);
};
```

### Providing translation values

```json
{
"simple": {
"format": "This is a <strong>value</strong>"
},
"with": {
"inserts": "This has an <i>{{insert}}</i> and {{another}}"
}
}
```

## Advanced formatting

The `<Trans>` component also supports interpolation of JSX elements into the translated string to provide additional formatting (e.g class names, headers, links) around the string content. These elements must be passed as children to `<Trans>`. Dynamic content should be supplied after elements as singleton children as before.

```tsx
import { Trans, useTranslation } from 'react-i18next';
export const myComponent = () => {
const { t: translate } = useTranslation();
return (
<>
<div>
<Trans t={translate} i18nKey='with.link'>
<a href='.' />
</Trans>
</div>
<div>
<Trans t={translate} i18nKey='with.classname'>
<div className='bold' />
{{ topic: 'mytopic' }}
</Trans>
</div>
<div>
<Trans t={translate} i18nKey='with.custom'>
<MyComp prop='value' />
<SecondComp />
</Trans>
</div>
</>
);
};
```

### Providing translation values

Each element is referenced in the translation file by its array index (children in React ultimately are passed as an array). Elements can be used standalone (no children) or they can wrap content (content passed as single child to the component. _The content will replace any children in the element._)

```json
{
"with": {
"link": "This is a <0>link</0>",
"classname": "You have created topic <0>{{topic}}</0>",
"custom": "This is a custom component: <0/> with <1>another custom component</1>"
}
}
```

### Notes/Findings with advanced formatting

`eslint-plugin-i18next` will not flag content in `<Trans>` - this is because it will only be written to the page if you include it in your interpolated tranlsation string.

For example with translation file `{key: "Some unrelated text"}` - `<Trans t={t} i18nKey="key">This is a <MyComp>custom component</MyComp></Trans>` would render `Some unrelated text` because the children are not being used in the translation string with key `key`.

However with translation file `{key: "Insert <0/> here and <0>also here</0>"}` - `<Trans t={t} i18nKey="key">This is text with a <a href="">link</a></Trans>` would render `Insert This is text with a here and This is text with a`. This is because string `This is text with a ` is child `0`.

To avoid any confusion, `<Trans>` components must only have self closing elements `<MyComp/>` and `{key:value}` tuple objects as children.
35 changes: 35 additions & 0 deletions client/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright Strimzi authors.
* License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html).
*/
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { resources } from './locale';
// don't want to use this?
// have a look at the Quick start guide
// for passing in lng and translations on init

const init = (): void => {
i18n
// pass the i18n instance to react-i18next.
.use(initReactI18next)
.use(LanguageDetector)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
detection: {
order: ['htmlTag', 'navigator'],
caches: [],
},
fallbackLng: 'en',
debug: true,

interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
resources,
});
};

export { init };
10 changes: 10 additions & 0 deletions client/i18n/locale/de.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"home": {
"basic": "DE Welcome to the Strimzi UI",
"insert": "DE This includes an {{insert}}",
"formatted": "DE This includes <strong>formatting</strong>",
"formattedInsert": "DE This includes a formatted <strong>{{insert}}</strong> and {{another}}",
"customInserts": "DE This paragraph contains multiple inserts. First {{insert}} is a <0>div with a classname</0>. Second is <1>bold</1>. Third is <2>italic</2>. Finally there is a <3>link</3>.",
"customContent": "DE This is <0>Something</0>"
}
}
10 changes: 10 additions & 0 deletions client/i18n/locale/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"home": {
"basic": "Welcome to the Strimzi UI",
"insert": "This includes an {{insert}}",
"formatted": "This includes <strong>formatting</strong>",
"formattedInsert": "This includes a formatted <strong>{{insert}}</strong> and {{another}}",
"customInserts": "This paragraph contains multiple inserts. First {{insert}} is a <0>div with a classname</0>. Second is <1>bold</1>. Third is <2>italic</2>. Finally there is a <3>link</3>.",
"customContent": "This is <0>Something</0>"
}
}
15 changes: 15 additions & 0 deletions client/i18n/locale/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright Strimzi authors.
* License: Apache License 2.0 (see the file LICENSE or http://apache.org/licenses/LICENSE-2.0.html).
*/
import en from './en.json';
import de from './de.json';

export const resources = {
en: {
translation: en,
},
de: {
translation: de,
},
};
57 changes: 54 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 0cca303

Please sign in to comment.