diff --git a/docs/how_tos/i18n.rst b/docs/how_tos/i18n.rst
index ec301ac2..b02cf4c2 100644
--- a/docs/how_tos/i18n.rst
+++ b/docs/how_tos/i18n.rst
@@ -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.
.. contents:: Table of Contents
@@ -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 `_ 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``.
-#. 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 `__.)
+#. 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``.
@@ -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 `__. 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': {
@@ -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``.
@@ -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 ```` 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 `_ 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...
-
+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.
*************************
diff --git a/runtime/config/index.ts b/runtime/config/index.ts
index 25212b6c..b73efaa9 100644
--- a/runtime/config/index.ts
+++ b/runtime/config/index.ts
@@ -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,
diff --git a/runtime/i18n/index.js b/runtime/i18n/index.js
index 0aa71cc2..4b4847fe 100644
--- a/runtime/i18n/index.js
+++ b/runtime/i18n/index.js
@@ -112,6 +112,8 @@ export {
updateLocale
} from './lib';
+export { updateSiteLanguage } from './updateSiteLanguage';
+
export {
default as injectIntl
} from './injectIntlWithShim';
diff --git a/runtime/i18n/lib.test.js b/runtime/i18n/lib.test.js
index ae73b3ca..5714093a 100644
--- a/runtime/i18n/lib.test.js
+++ b/runtime/i18n/lib.test.js
@@ -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', () => {
@@ -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'] });
+ 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({
@@ -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(() => [
@@ -126,7 +199,7 @@ describe('lib', () => {
},
});
- handleRtl();
+ expect(setAttribute).toHaveBeenCalledWith('lang', 'es-419');
expect(setAttribute).toHaveBeenCalledWith('dir', 'ltr');
});
@@ -138,7 +211,7 @@ describe('lib', () => {
},
});
- handleRtl();
+ expect(setAttribute).toHaveBeenCalledWith('lang', 'ar');
expect(setAttribute).toHaveBeenCalledWith('dir', 'rtl');
});
});
diff --git a/runtime/i18n/lib.ts b/runtime/i18n/lib.ts
index 41f39653..76131a2d 100644
--- a/runtime/i18n/lib.ts
+++ b/runtime/i18n/lib.ts
@@ -51,6 +51,13 @@ const rtlLocales = [
let messages: Record | Record | undefined>;
+/**
+ * The locale selected during this session via updateLocale(), used to update the UI
+ * immediately without waiting for the language preference cookie to be persisted.
+ * Cleared on page load (via configureI18n) so the cookie/browser setting takes effect.
+ */
+let currentLocale: string | undefined;
+
/**
* @memberof module:Internationalization
*
@@ -95,12 +102,13 @@ export function getPrimaryLanguageSubtag(code) {
}
/**
- * Finds the closest supported locale to the one provided. This is done in three steps:
+ * Finds the closest supported locale to the one provided. This is done in three steps:
*
- * 1. Returning the locale itself if its exact language code is supported.
- * 2. Returning the primary language subtag of the language code if it is supported (ar for ar-eg,
+ * 1. Returning the locale itself if its exact language code is in the loaded messages
+ * AND is in the site's supportedLanguages list.
+ * 2. Returning the primary language subtag if it meets the same criteria (ar for ar-eg,
* for instance).
- * 3. Returning 'en' if neither of the above produce a supported locale.
+ * 3. Returning the site's defaultLanguage if neither of the above match.
*
* @param {string} locale
* @returns {string}
@@ -111,20 +119,30 @@ export function findSupportedLocale(locale) {
throw new Error('findSupportedLocale called before configuring i18n. Call configureI18n with messages first.');
}
- if (messages[locale] !== undefined) {
+ const { defaultLanguage, supportedLanguages = [] } = getSiteConfig();
+
+ const isLocaleSupported = (code) => {
+ if (supportedLanguages.length > 0) {
+ return supportedLanguages.includes(code) && messages[code] !== undefined;
+ }
+ return messages[code] !== undefined;
+ };
+
+ if (isLocaleSupported(locale)) {
return locale;
}
- if (messages[getPrimaryLanguageSubtag(locale)] !== undefined) {
- return getPrimaryLanguageSubtag(locale);
+ const primarySubtag = getPrimaryLanguageSubtag(locale);
+ if (isLocaleSupported(primarySubtag)) {
+ return primarySubtag;
}
- return 'en';
+ return defaultLanguage;
}
/**
* Get the locale from the cookie or, failing that, the browser setting.
- * Gracefully fall back to a more general primary language subtag or to English (en)
+ * Gracefully fall back to a more general primary language subtag or to default language
* if we don't support that language.
*
* @param {string|undefined} locale If a locale is provided, returns the closest supported locale. Optional.
@@ -141,7 +159,11 @@ export function getLocale(locale?: string) {
if (locale !== undefined) {
return findSupportedLocale(locale);
}
- // 2. User setting in cookie
+ // 2. Locale selected in-session via updateLocale()
+ if (currentLocale !== undefined) {
+ return currentLocale;
+ }
+ // 3. User setting in cookie
const { languagePreferenceCookieName } = getSiteConfig();
if (languagePreferenceCookieName) {
@@ -151,7 +173,7 @@ export function getLocale(locale?: string) {
}
}
- // 3. Browser language (default)
+ // 4. Browser language (default)
// Note that some browers prefer upper case for the region part of the locale, while others don't.
// Thus the toLowerCase, for consistency.
// https://developer.mozilla.org/en-US/docs/Web/API/NavigatorLanguage/language
@@ -169,8 +191,18 @@ export function getLocalizedLanguageName(locale) {
}
export function getSupportedLanguageList() {
- const locales = Object.keys(messages);
- locales.push('en'); // 'en' is not in the messages object because it's the default.
+ const { defaultLanguage = 'en', supportedLanguages = [] } = getSiteConfig();
+
+ let locales = Object.keys(messages);
+
+ if (!locales.includes(defaultLanguage)) {
+ locales.push(defaultLanguage);
+ }
+
+ if (supportedLanguages.length > 0) {
+ locales = locales.filter((locale) => supportedLanguages.includes(locale));
+ }
+
locales.sort();
return locales.map((locale) => ({
@@ -179,7 +211,21 @@ export function getSupportedLanguageList() {
}));
}
-export function updateLocale() {
+/**
+ * Updates the active UI locale and RTL direction.
+ *
+ * If a locale is provided, the UI is updated to that locale immediately, without
+ * waiting for the language preference cookie to be persisted (that is handled
+ * separately, e.g. by updateSiteLanguage()). If no locale is provided, the current
+ * locale is read from the language preference cookie or browser setting.
+ *
+ * @param {string} [locale] The locale code to switch to (e.g. 'es-419', 'ar').
+ * @memberof module:Internationalization
+ */
+export function updateLocale(locale?: string) {
+ if (locale !== undefined) {
+ currentLocale = findSupportedLocale(locale);
+ }
handleRtl();
publish(LOCALE_CHANGED);
}
@@ -210,17 +256,16 @@ export function isRtl(locale) {
}
/**
- * Handles applying the RTL stylesheet and "dir=rtl" attribute to the html tag if the current locale
- * is a RTL language.
+ * Handles applying the RTL stylesheet, "dir" and "lang" attributes to the html tag
+ * based on the current locale.
*
* @memberof module:Internationalization
*/
export function handleRtl() {
- if (isRtl(getLocale())) {
- globalThis.document.getElementsByTagName('html')[0].setAttribute('dir', 'rtl');
- } else {
- globalThis.document.getElementsByTagName('html')[0].setAttribute('dir', 'ltr');
- }
+ const locale = getLocale();
+ const htmlElement = globalThis.document.getElementsByTagName('html')[0];
+ htmlElement.setAttribute('lang', locale);
+ htmlElement.setAttribute('dir', isRtl(locale) ? 'rtl' : 'ltr');
}
/**
@@ -253,6 +298,7 @@ interface ConfigureI18nOptions {
*/
export function configureI18n(options: ConfigureI18nOptions) {
messages = Array.isArray(options.messages) ? merge({}, ...options.messages) : options.messages;
+ currentLocale = undefined;
handleRtl();
}
diff --git a/runtime/i18n/updateSiteLanguage.test.ts b/runtime/i18n/updateSiteLanguage.test.ts
new file mode 100644
index 00000000..779519f1
--- /dev/null
+++ b/runtime/i18n/updateSiteLanguage.test.ts
@@ -0,0 +1,73 @@
+import { updateSiteLanguage } from './updateSiteLanguage';
+import { getAuthenticatedUser, getAuthenticatedHttpClient } from '../auth';
+import { getSiteConfig } from '../config';
+import { updateLocale } from './lib';
+
+jest.mock('../auth');
+jest.mock('../config');
+jest.mock('./lib');
+
+const mockGetAuthenticatedUser = getAuthenticatedUser as jest.MockedFunction;
+const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as jest.MockedFunction;
+const mockGetSiteConfig = getSiteConfig as jest.MockedFunction;
+const mockUpdateLocale = updateLocale as jest.MockedFunction;
+
+describe('updateSiteLanguage', () => {
+ const mockAuthHttpClient = { patch: jest.fn(), post: jest.fn() };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetAuthenticatedHttpClient.mockReturnValue(mockAuthHttpClient as any);
+ mockGetSiteConfig.mockReturnValue({
+ lmsBaseUrl: 'http://localhost:18000',
+ } as any);
+ });
+
+ it('should update the UI before persisting for anonymous users', async () => {
+ mockGetAuthenticatedUser.mockReturnValue(null);
+ mockAuthHttpClient.patch.mockResolvedValue({});
+
+ await updateSiteLanguage('es-419');
+
+ expect(mockUpdateLocale).toHaveBeenCalledWith('es-419');
+ expect(mockUpdateLocale.mock.invocationCallOrder[0])
+ .toBeLessThan((mockAuthHttpClient.patch as jest.Mock).mock.invocationCallOrder[0]);
+ expect(mockAuthHttpClient.patch).toHaveBeenCalledWith(
+ 'http://localhost:18000/lang_pref/update_language',
+ { 'pref-lang': 'es-419' },
+ { isPublic: true },
+ );
+ });
+
+ it('should patch user preferences for authenticated users after updating the UI', async () => {
+ mockGetAuthenticatedUser.mockReturnValue({ username: 'testuser' } as any);
+ mockAuthHttpClient.patch.mockResolvedValue({});
+
+ await updateSiteLanguage('ar');
+
+ expect(mockUpdateLocale).toHaveBeenCalledWith('ar');
+ expect(mockUpdateLocale.mock.invocationCallOrder[0])
+ .toBeLessThan((mockAuthHttpClient.patch as jest.Mock).mock.invocationCallOrder[0]);
+ expect(mockAuthHttpClient.patch).toHaveBeenCalledWith(
+ 'http://localhost:18000/api/user/v1/preferences/testuser',
+ { 'pref-lang': 'ar' },
+ { headers: { 'Content-Type': 'application/merge-patch+json' } },
+ );
+ });
+
+ it('should update the UI even if the user preference patch fails', async () => {
+ mockGetAuthenticatedUser.mockReturnValue({ username: 'testuser' } as any);
+ mockAuthHttpClient.patch.mockRejectedValueOnce(new Error('Network error'));
+
+ await expect(updateSiteLanguage('es-419')).rejects.toThrow('Network error');
+ expect(mockUpdateLocale).toHaveBeenCalledWith('es-419');
+ });
+
+ it('should update the UI even if the setlang call fails', async () => {
+ mockGetAuthenticatedUser.mockReturnValue(null);
+ mockAuthHttpClient.patch.mockRejectedValue(new Error('Setlang failed'));
+
+ await expect(updateSiteLanguage('es-419')).rejects.toThrow('Setlang failed');
+ expect(mockUpdateLocale).toHaveBeenCalledWith('es-419');
+ });
+});
diff --git a/runtime/i18n/updateSiteLanguage.ts b/runtime/i18n/updateSiteLanguage.ts
new file mode 100644
index 00000000..a94e28dc
--- /dev/null
+++ b/runtime/i18n/updateSiteLanguage.ts
@@ -0,0 +1,85 @@
+import {
+ getAuthenticatedHttpClient,
+ getAuthenticatedUser,
+} from '../auth';
+import { getSiteConfig } from '../config';
+import { updateLocale } from './lib';
+
+/**
+ * Changes the user's site language. This is the supported way to switch languages.
+ *
+ * - Updates the UI locale immediately via updateLocale(), so the change is reflected
+ * without waiting for the network requests to complete.
+ * - For authenticated users, persists the preference to the LMS API.
+ * - For all users (authenticated and anonymous), sets the language cookie via the LMS language preference endpoint.
+ *
+ * @param {string} locale The locale code to switch to (e.g. 'es-419', 'ar').
+ * @returns {Promise} Resolves when the switch is complete. Rejects on network failure.
+ * @memberof module:Internationalization
+ */
+export async function updateSiteLanguage(locale: string): Promise {
+ const user = getAuthenticatedUser();
+
+ // Update the UI locale and RTL direction immediately, before waiting on any
+ // network requests. This ensures that the UI reflects the change without delay.
+ updateLocale(locale);
+
+ // Save the preference for authenticated users.
+ if (user !== null) {
+ await patchUserPreferences(user.username, locale);
+ }
+
+ await setSessionLanguage(locale);
+}
+
+/**
+ * Updates user language preferences via the preferences API.
+ *
+ * @param {string} username - The username of the authenticated user.
+ * @param {string} locale - The selected language locale code (e.g., 'en', 'es-419', 'ar', 'de-de').
+ * Should be a valid ISO language code supported by the platform. For reference:
+ * https://github.com/openedx/openedx-platform/blob/master/openedx/envs/common.py#L231
+ * @returns {Promise} - A promise that resolves when the API call completes successfully,
+ * or rejects if there's an error with the request. Returns early if no user is authenticated.
+ */
+async function patchUserPreferences(username: string, locale: string) {
+ const { lmsBaseUrl } = getSiteConfig();
+ await getAuthenticatedHttpClient().patch(
+ `${lmsBaseUrl}/api/user/v1/preferences/${username}`,
+ {
+ 'pref-lang': locale,
+ },
+ {
+ headers: {
+ 'Content-Type': 'application/merge-patch+json',
+ },
+ },
+ );
+}
+
+/**
+ * Sets the language for the current session using the lang preference endpoint.
+ *
+ * This function sends a PATCH request to the LMS update_language endpoint to change
+ * the language for the current user session.
+ *
+ * @param {string} locale - The selected language locale code (e.g., 'en', 'es-419', 'ar', 'de-de').
+ * Should be a valid ISO language code supported by the platform. For reference:
+ * https://github.com/openedx/openedx-platform/blob/master/openedx/envs/common.py#L231
+ * @returns {Promise} - A promise that resolves when the API call completes successfully,
+ * or rejects if there's an error with the request.
+ */
+async function setSessionLanguage(locale: string) {
+ const { lmsBaseUrl } = getSiteConfig();
+ const formData = new FormData();
+ formData.append('language', locale);
+
+ // Post to the LMS setlang endpoint for server-side persistence.
+ // Use the authenticated HTTP client to ensure that the request includes the CSRF token.
+ // Works for both authenticated and anonymous users, since the LMS setlang endpoint is public.
+ await getAuthenticatedHttpClient().patch(
+ `${lmsBaseUrl}/lang_pref/update_language`,
+ { 'pref-lang': locale },
+ { isPublic: true },
+ );
+}
diff --git a/runtime/index.ts b/runtime/index.ts
index 2cc94a7d..e54eb665 100644
--- a/runtime/index.ts
+++ b/runtime/index.ts
@@ -75,6 +75,7 @@ export {
LOCALE_TOPIC,
mergeMessages,
updateLocale,
+ updateSiteLanguage,
useIntl
} from './i18n';
diff --git a/shell/footer/LanguageMenu.test.tsx b/shell/footer/LanguageMenu.test.tsx
new file mode 100644
index 00000000..2c062399
--- /dev/null
+++ b/shell/footer/LanguageMenu.test.tsx
@@ -0,0 +1,74 @@
+import '@testing-library/jest-dom';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { IntlProvider } from 'react-intl';
+
+import { SiteContext, configureI18n } from '../../runtime';
+
+import LanguageMenu from './LanguageMenu';
+
+jest.mock('../../runtime', () => ({
+ ...jest.requireActual('../../runtime'),
+ updateSiteLanguage: jest.fn(),
+ updateLocale: jest.fn(),
+}));
+
+const mockUpdateSiteLanguage = jest.requireMock('../../runtime').updateSiteLanguage as jest.Mock;
+const mockUpdateLocale = jest.requireMock('../../runtime').updateLocale as jest.Mock;
+
+function renderLanguageMenu(locale = 'en') {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe('LanguageMenu', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ configureI18n({
+ messages: {
+ 'es-419': {},
+ ar: {},
+ },
+ });
+ });
+
+ it('switches to the selected language', async () => {
+ const user = userEvent.setup();
+ mockUpdateSiteLanguage.mockResolvedValue(undefined);
+ renderLanguageMenu();
+
+ await user.click(screen.getByRole('button', { name: 'English' }));
+ await user.click(screen.getByText(/español/i));
+
+ await waitFor(() => expect(mockUpdateSiteLanguage).toHaveBeenCalledWith('es-419'));
+ });
+
+ it('shows the selected language on the toggle while the change is pending', async () => {
+ const user = userEvent.setup();
+ mockUpdateSiteLanguage.mockImplementation(() => new Promise(() => {}));
+ renderLanguageMenu();
+
+ await user.click(screen.getByRole('button', { name: 'English' }));
+ await user.click(screen.getByText(/español/i));
+
+ expect(screen.getByRole('button', { expanded: false })).toHaveTextContent(/español/i);
+ });
+
+ it('keeps the optimistic change and shows a toast when the preference save fails', async () => {
+ const user = userEvent.setup();
+ mockUpdateSiteLanguage.mockRejectedValue(new Error('Network Error'));
+ renderLanguageMenu();
+
+ await user.click(screen.getByRole('button', { name: 'English' }));
+ await user.click(screen.getByText(/español/i));
+
+ const toast = await screen.findByRole('alert');
+ expect(toast).toHaveTextContent(/could not save your language preference/i);
+ expect(mockUpdateLocale).not.toHaveBeenCalled();
+ });
+});
diff --git a/shell/footer/LanguageMenu.tsx b/shell/footer/LanguageMenu.tsx
index 1c1b12b7..c8abc288 100644
--- a/shell/footer/LanguageMenu.tsx
+++ b/shell/footer/LanguageMenu.tsx
@@ -1,35 +1,75 @@
-import { Dropdown } from '@openedx/paragon';
-import { useContext } from 'react';
+import { Dropdown, Toast } from '@openedx/paragon';
+import { useCallback, useContext, useState } from 'react';
import {
SiteContext,
getLocalizedLanguageName,
- getSupportedLanguageList
+ getSupportedLanguageList,
+ updateSiteLanguage,
+ useIntl,
} from '../../runtime';
import LanguageMenuItem from './LanguageMenuItem';
+import messages from './messages';
export default function LanguageMenu() {
+ const { formatMessage } = useIntl();
const { locale } = useContext(SiteContext);
+ const [pendingLanguage, setPendingLanguage] = useState(null);
+ const [errorMessage, setErrorMessage] = useState(null);
+
const languages = getSupportedLanguageList();
- const currentLanguageName = getLocalizedLanguageName(locale);
+
+ const handleSelect = useCallback(async (languageCode: string) => {
+ setPendingLanguage(languageCode);
+ setErrorMessage(null);
+ try {
+ await updateSiteLanguage(languageCode);
+ } catch {
+ // The UI switch is optimistic and stays in the picked language; only the
+ // preference save failed, so surface that without reverting.
+ setErrorMessage(formatMessage(messages.languageSaveError));
+ } finally {
+ setPendingLanguage(null);
+ }
+ }, [formatMessage]);
// Hide the menu if there's only one language.
if (languages.length === 1) {
return null;
}
+ const toggleLabel = pendingLanguage
+ ? getLocalizedLanguageName(pendingLanguage)
+ : getLocalizedLanguageName(locale);
+
return (
-
-
-
- {languages.map((language) => (
-
- ))}
-
-
+ <>
+
+
+
+ {languages.map((language) => (
+
+ ))}
+
+
+ {errorMessage && (
+ setErrorMessage(null)}
+ >
+ {errorMessage}
+
+ )}
+ >
);
}
diff --git a/shell/footer/LanguageMenuItem.tsx b/shell/footer/LanguageMenuItem.tsx
index 601a0344..2babea1c 100644
--- a/shell/footer/LanguageMenuItem.tsx
+++ b/shell/footer/LanguageMenuItem.tsx
@@ -1,22 +1,33 @@
import { Dropdown } from '@openedx/paragon';
import { useCallback } from 'react';
-import { updateSiteLanguage } from './data/api';
-
interface LanguageMenuItemProps {
language: {
code: string,
name: string,
},
+ disabled?: boolean,
+ isActive?: boolean,
+ onSelect: (code: string) => void,
}
-export default function LanguageMenuItem({ language }: LanguageMenuItemProps) {
+export default function LanguageMenuItem({
+ language,
+ disabled,
+ isActive,
+ onSelect,
+}: LanguageMenuItemProps) {
const handleClick = useCallback(() => {
- updateSiteLanguage(language.code);
- }, [language.code]);
+ onSelect(language.code);
+ }, [language.code, onSelect]);
return (
-
+
{language.name}
);
diff --git a/shell/footer/data/api.ts b/shell/footer/data/api.ts
deleted file mode 100644
index f1d084fa..00000000
--- a/shell/footer/data/api.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import {
- getAuthenticatedHttpClient,
- getAuthenticatedUser,
- getSiteConfig,
- updateLocale
-} from '../../../runtime';
-
-export async function updateSiteLanguage(locale: string) {
- const user = getAuthenticatedUser();
-
- if (user !== null) {
- const { username } = getAuthenticatedUser();
- await patchUserPreferences(username, locale);
- }
- await postSetlang(locale);
-
- updateLocale();
-}
-
-async function patchUserPreferences(username: string, locale: string) {
- await getAuthenticatedHttpClient().patch(
- `${getSiteConfig().lmsBaseUrl}/api/user/v1/preferences/${username}`,
- {
- 'pref-lang': locale
- },
- {
- headers: {
- 'Content-Type': 'application/merge-patch+json'
- },
- }
- );
-}
-
-async function postSetlang(locale: string) {
- const formData = new FormData();
- formData.append('language', locale);
-
- await getAuthenticatedHttpClient().post(
- `${getSiteConfig().lmsBaseUrl}/i18n/setlang/`,
- formData,
- {
- headers: {
- Accept: 'application/json',
- 'X-Requested-With': 'XMLHttpRequest',
- },
- }
- );
-}
diff --git a/shell/footer/messages.ts b/shell/footer/messages.ts
new file mode 100644
index 00000000..3e064133
--- /dev/null
+++ b/shell/footer/messages.ts
@@ -0,0 +1,11 @@
+import { defineMessages } from '../../runtime';
+
+const messages = defineMessages({
+ languageSaveError: {
+ id: 'footer.languageMenu.error.languageSave',
+ defaultMessage: 'We could not save your language preference.',
+ description: 'Error shown when saving the site language preference fails.',
+ },
+});
+
+export default messages;
diff --git a/types.ts b/types.ts
index 62113472..7363fa46 100644
--- a/types.ts
+++ b/types.ts
@@ -78,6 +78,10 @@ export interface OptionalSiteConfig {
// Theme
theme: Theme,
+ // i18n
+ defaultLanguage: string,
+ supportedLanguages: string[],
+
// Cookies
accessTokenCookieName: string,
languagePreferenceCookieName: string,