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
133 changes: 100 additions & 33 deletions docs/how_tos/i18n.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ React App i18n HOWTO
Introduction
************

This is a step by step guide to making your React app ready to accept translations. The instructions here are very specific to the edX setup.
This is a step by step guide to making your React app ready to accept translations. The instructions here are very specific to the Openedx setup.

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.

"Openedx" should be "Open edX".


.. contents:: Table of Contents

Expand All @@ -15,11 +15,11 @@ This is a step by step guide to making your React app ready to accept translatio
Internationalize your application with react-intl
*************************************************

These steps will allow your application to accept translation strings. See `frontend-app-account <https://github.com/openedx/frontend-app-account/>`_ for an example app to follow.
These steps will allow your application to accept translation strings.

#. Add ``@edx/frontend-platform`` as a dependency to your ``package.json`` . (If you are actually writing a consumable component, add ``@edx/frontend-platform`` as both a dev dependency and peer dependency instead.) ``@edx/frontend-platform/i18n`` is a wrapper around ``react-intl`` that adds some shims. You should only access the ``react-intl`` functions and elements exposed by ``@edx/frontend-platform/i18n``. (They have the same names as in ``react-intl``.)
#. Add ``@edx/frontend-base`` as a dependency to your ``package.json`` . (If you are actually writing a consumable component, add ``@edx/frontend-base`` as both a dev dependency and peer dependency instead.) ``@edx/frontend-base/i18n`` re-exports everything from ``react-intl`` plus additional helpers. You should only access the ``react-intl`` functions and elements exposed by ``@edx/frontend-base/i18n``.

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.

Replace @edx/frontend-base with @openedx/frontend-base, and drop the /i18n subpath. Same on :50, :64 and :150.


#. In ``App.js``, wrap your entire app in an ``IntlProvider`` element. See `Load up your translation files`_ for details. (Consumable components: Don't do this step, except possibly in tests. Your consuming application will do it for you. Instead, update your `README like this example <https://github.com/openedx/frontend-component-footer/blame/master/README.rst#L23-L27>`__.)
#. In your application entry point, wrap your app in ``SiteProvider`` instead of manually adding an ``IntlProvider``. ``SiteProvider`` renders ``IntlProvider`` with the correct locale and messages internally. See `Load up your translation files`_ for details. (Consumable components: Don't do this step, except possibly in tests. Your consuming application will do it for you.)

#. For places in your code where you need a display string, and it's okay if it's a React element (generally, most messages): use a ``FormattedMessage``.

Expand All @@ -42,19 +42,12 @@ These steps will allow your application to accept translation strings. See `fron

For additional help, including adding interprolated variables, see the `FormattedMessage documentation <https://formatjs.io/docs/react-intl/components#formattedmessage>`__. It can also handle plurals.

#. For places in your code where you need a display string, and it has to be a plain JavaScript string (e.g., a button label), you will need to do the following:
#. For places in your code where you need a display string, and it has to be a plain JavaScript string (e.g., a button label), use the ``useIntl`` hook to access the ``intl`` object:

#. Inject the ``intl`` object into your component:
#. Define your messages using ``defineMessages``. This function doesn't actually do anything; it's just a hook for the translation pipeline to be able to find your translation strings. You can call ``defineMessages`` wherever you want, but if you have a lot of them you might want to move them to a separate file. Either ``messages.ts`` (if your entire app has only a few strings)
or ``SomeComponent/messages.ts`` will work. Your file should look like the example below. For your own sanity, using a short camel-case string for the property name is fine as long as ``id`` is globally unique in the MFE. Example::

#. ``import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';``;

#. add ``intl: intlShape.isRequired`` to your component's ``propTypes``.

#. instead of ``export Foo``, ``export injectIntl(Foo)`` .

#. Define your messages using ``defineMessages``. This function doesn't actually do anything; it's just a hook for the translation pipeline to be able to find your translation strings. You can call ``defineMessages`` wherever you want, but if you have a lot of them you might want to move them to a separate file. Either ``MyAppName.messages.js`` (if your entire app has only a few strings) or ``SomeComponent.messages.js`` will work. Your file should look like the example below. For your own sanity, using a short camel-case string for the property name is fine as long as ``id`` is globally unique in the MFE. Example::

import { defineMessages } from '@edx/frontend-platform/i18n';
import { defineMessages } from '@edx/frontend-base';

const messages = defineMessages({
'cartPayNow': {
Expand All @@ -66,11 +59,16 @@ These steps will allow your application to accept translation strings. See `fron

export default messages;

#. Use the ``intl.formatMessage`` function to get your translated string::
#. Use the ``useIntl`` hook and ``intl.formatMessage`` to get your translated string::

import { useIntl } from '@edx/frontend-base';
import messages from './messages';

import messages from './SomeComponent.messages';
// ...
intl.formatMessage(messages.cartPayNow)
function MyComponent() {
const { formatMessage } = useIntl();
const payNowLabel = formatMessage(messages.cartPayNow);
// ...
}

#. If you want to use ``FormattedMessage`` but your display string is repeated several times, it's probably better to pull it out into a messages file. In this case the messages file will have the ``defaultMessage`` and the ``description``, and you can just give ``FormattedMessage`` the ``id``.

Expand All @@ -88,27 +86,96 @@ Load up your translation files

.. note:: This step is for applications only. You can skip this for consumable components.

You can actually do this step even before you have Transifex and Jenkins set up, by providing your own translation files in ``src/i18n/messages/LANG_CODE.json``.
Translations are pulled and prepared using the ``openedx translations:pull`` CLI command. Add an ``atlasTranslations`` field to your ``package.json`` so the command knows where to find your app's translations and which dependencies to resolve transitively:

.. code-block:: json

"atlasTranslations": {
"path": "translations/frontend-app-[YOUR_APP]/src/i18n/messages",
"dependencies": ["@openedx/frontend-base"]
}

Also add a ``translations:pull`` script to your ``package.json``:

.. code-block:: json

"scripts": {
"translations:pull": "openedx translations:pull"
}

And update your ``pull_translations`` Makefile target to use it:

.. code-block:: Makefile

pull_translations: | requirements
npm run translations:pull -- --atlas-options="$(ATLAS_OPTIONS)"

Running ``npm run translations:pull`` will pull translations from ``openedx-translations`` and generate ``src/i18n/messages.ts``.

#. Add a ``src/i18n/index.ts`` file that re-exports the generated messages:

.. code-block:: ts

export { default } from './messages';

#. Also add a ``src/i18n/messages.d.ts`` type declaration file so TypeScript knows the shape of the generated module even before ``translations:pull`` has been run:

.. code-block:: ts

import type { SiteMessages } from '@openedx/frontend-base';

declare const messages: SiteMessages;
export default messages;

#. The shell's entry point imports your translation messages via the ``site.i18n`` webpack alias and passes them internally, just make sure your ``src/i18n/index.ts`` exports the messages correctly.

``SiteProvider`` wraps your app in ``IntlProvider`` with the correct locale and messages. The locale is resolved internally from the ``SiteConfig`` values read via ``getSiteConfig()``, as described in the next step.

#. ``frontend-base`` resolves the active locale in the following order:

1. An explicit locale passed to ``getLocale(locale)`` or ``getMessages(locale)``.
2. The locale selected during the current session via ``updateLocale(locale)`` (for example, when the user switches language from the language menu).
3. The user's language preference cookie, named by the ``languagePreferenceCookieName`` site config value.
4. The browser's language setting.

Each candidate is checked against the messages provided to ``configureI18n`` and, when configured, against the site's ``supportedLanguages`` list. If a candidate locale isn't supported exactly, its primary language subtag is tried (e.g. ``es`` for ``es-419``); if neither matches, the site's ``defaultLanguage`` (``en`` by default) is used. Once resolved, ``frontend-base`` sets the ``lang`` and ``dir`` attributes on the ``<html>`` element so that right-to-left languages are handled automatically.

You can verify everything is working by changing your language preference using the displayed language menu. You can also change your browser language to one of the languages you have translations for.


*********************************************
Supported languages and switching languages
*********************************************

``frontend-base`` ships a language menu (in the footer shell) that lets users switch the site language at runtime. It is built on two optional ``SiteConfig`` values and a couple of i18n helpers exported from ``@edx/frontend-base``:

- ``defaultLanguage``: The locale used as a last-resort fallback. Defaults to ``en``.
- ``supportedLanguages``: An optional list of locale codes. When set, only locales in this list are considered supported; ``findSupportedLocale`` and ``getSupportedLanguageList`` filter by it. When empty (the default), every locale with loaded messages is considered supported.

Where the list of languages comes from
--------------------------------------

The language menu's list is produced by ``getSupportedLanguageList()``. It is derived as follows:

#. Your pipeline job should have updated several translation files in ``src/i18n/messages/LANG_CODE.json`` .
#. Start with the keys of the ``messages`` map passed to ``configureI18n`` — i.e. the ``src/i18n/messages/LANG_CODE.json`` files your translation pipeline produced.
#. Add the site's ``defaultLanguage`` if it isn't already present, so the default is always offered even when no translations are loaded for it.
#. If ``supportedLanguages`` is configured, keep only the locales that appear in it.
#. Sort the remaining codes alphabetically.

#. Create ``src/i18n/index.js`` using `frontend-app-account's index.js <https://github.com/openedx/frontend-app-account/blob/master/src/i18n/index.js>`_ as a model.
The ``name`` shown for each language is the localized name obtained from the browser's native ``Intl.DisplayNames`` API, so each language is displayed in its own language (e.g. ``Deutsch`` for ``de``).

#. In ``App.jsx``, make the following changes::
Switching languages
-------------------

import { IntlProvider, getMessages, configureI18n } from '@edx/frontend-base';
import messages from './i18n/index'; // A map of all messages by locale
The supported way to change the site language at runtime is ``updateSiteLanguage(locale)``:

configureI18n({
messages,
config: getSiteConfig(), // environment and languagePreferenceCookieName are required
loggingService: getLoggingService(), // An object with logError and logInfo methods
});
- It optimistically updates the UI locale and RTL direction immediately, via ``updateLocale(locale)``, without waiting for the network.
- For authenticated users, it persists the preference to the LMS preferences API (``pref-lang``).
- For all users, it sets the session language through the LMS language preference endpoint.

// ...inside ReactDOM.render...
<IntlProvider locale={this.props.locale} messages={}>
If persisting the preference fails, the UI keeps the newly selected language and the caller is responsible for surfacing the error; the built-in language menu shows an error toast.

#. As of this writing, ``frontend-base`` reads the locale from the user language preference cookie, or, if none is found, from the browser's language setting. You can verify everything is working by changing your language preference in your account settings. If you are not logged in, you can change your browser language to one of the languages you have translations for.
``updateLocale(locale)`` is the lower-level helper that switches the active locale (and RTL handling) for the current session without persisting anything. ``SiteProvider`` subscribes to the ``LOCALE_CHANGED`` event it publishes and re-renders ``IntlProvider`` with the new locale and messages.


*************************
Expand Down
2 changes: 2 additions & 0 deletions runtime/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ let siteConfig: SiteConfig = {
externalLinkUrlOverrides: [],
runtimeConfigJsonUrl: null,
theme: {},
defaultLanguage: 'en',
supportedLanguages: [],
accessTokenCookieName: 'edx-jwt-cookie-header-payload',
csrfTokenApiPath: '/csrf/api/v1/token',
ignoredErrorRegex: null,
Expand Down
2 changes: 2 additions & 0 deletions runtime/i18n/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export {
updateLocale
} from './lib';

export { updateSiteLanguage } from './updateSiteLanguage';

export {
default as injectIntl
} from './injectIntlWithShim';
79 changes: 76 additions & 3 deletions runtime/i18n/lib.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import {
getLocale,
getMessages,
getPrimaryLanguageSubtag,
handleRtl,
getSupportedLanguageList,
isRtl,
mergeMessages,
updateLocale,
} from './lib';

import { getSiteConfig, mergeSiteConfig } from '../config';

jest.mock('universal-cookie');

describe('lib', () => {
Expand Down Expand Up @@ -66,6 +69,39 @@ describe('lib', () => {
});
});

describe('getSupportedLanguageList', () => {
it('should return all loaded locales plus the default language', () => {
configureI18n({
messages: {
'es-419': {},
de: {},
},
});
const languages = getSupportedLanguageList();
const codes = languages.map((l) => l.code);
expect(codes).toContain('de');
expect(codes).toContain('es-419');
expect(codes).toContain('en');
});

it('should filter by supportedLanguages when configured', () => {
mergeSiteConfig({ supportedLanguages: ['en', 'es-419'] });

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.

Site config is leaking between test blocks. Restore the default in an afterEach so :184 doesn't have to mutate getSiteConfig().supportedLanguages directly.

This mergeSiteConfig in particular is never undone, which also weakens the updateLocale block: the leaked allowlist excludes ar, so "should take precedence over the language preference cookie" isn't testing against a locale the cookie path could actually have returned.

configureI18n({
messages: {
'es-419': {},
de: {},
fr: {},
},
});
const languages = getSupportedLanguageList();
const codes = languages.map((l) => l.code);
expect(codes).toContain('en');
expect(codes).toContain('es-419');
expect(codes).not.toContain('de');
expect(codes).not.toContain('fr');
});
});

describe('getMessages', () => {
beforeEach(() => {
configureI18n({
Expand Down Expand Up @@ -106,9 +142,46 @@ describe('lib', () => {
});
});

describe('updateLocale', () => {
let setAttribute;
beforeEach(() => {
configureI18n({
messages: {
'es-419': {},
ar: {},
},
});
setAttribute = jest.fn();
global.document.getElementsByTagName = jest.fn(() => [
{ setAttribute },
]);
});

it('should update the UI locale immediately without relying on the cookie', () => {
getCookies().get = jest.fn(() => null);

updateLocale('es-419');

expect(getLocale()).toEqual('es-419');
expect(setAttribute).toHaveBeenCalledWith('lang', 'es-419');
expect(setAttribute).toHaveBeenCalledWith('dir', 'ltr');
});

it('should take precedence over the language preference cookie', () => {
getCookies().get = jest.fn(() => 'ar');

updateLocale('es-419');

expect(getLocale()).toEqual('es-419');
expect(setAttribute).toHaveBeenCalledWith('lang', 'es-419');
expect(setAttribute).toHaveBeenCalledWith('dir', 'ltr');
});
});

describe('handleRtl', () => {
let setAttribute;
beforeEach(() => {
getSiteConfig().supportedLanguages = [];
setAttribute = jest.fn();

global.document.getElementsByTagName = jest.fn(() => [
Expand All @@ -126,7 +199,7 @@ describe('lib', () => {
},
});

handleRtl();
expect(setAttribute).toHaveBeenCalledWith('lang', 'es-419');

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.

This test block is called handleRtl, but here it's being removed. These assertions now fire on setAttribute calls that only configureI18n triggers, so the function under test is exercised only incidentally.

expect(setAttribute).toHaveBeenCalledWith('dir', 'ltr');
});

Expand All @@ -138,7 +211,7 @@ describe('lib', () => {
},
});

handleRtl();
expect(setAttribute).toHaveBeenCalledWith('lang', 'ar');
expect(setAttribute).toHaveBeenCalledWith('dir', 'rtl');
});
});
Expand Down
Loading