diff --git a/docs/how_tos/i18n.rst b/docs/how_tos/i18n.rst index efeada31..1e211845 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 Open edX 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 ``@openedx/frontend-base`` as a dependency in your ``package.json`` (for a consumable component, add it as both a dev dependency and a peer dependency instead). It re-exports everything from ``react-intl`` plus additional helpers; import ``react-intl`` members from it rather than from ``react-intl`` directly. -#. 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`` rather than adding an ``IntlProvider`` yourself: ``SiteProvider`` renders one internally, with the resolved locale and messages. See `Load up your translation files`_. (Consumable components: skip this step, except possibly in tests — the consuming application does 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,7 +42,7 @@ 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: #. Use a hook to access the ``intl`` object within your component: @@ -50,7 +50,8 @@ These steps will allow your application to accept translation strings. See `fron #. write ``const intl = useIntl();`` near the beginning of 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 ``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:: + #. 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 { defineMessages } from '@openedx/frontend-base'; @@ -64,11 +65,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 messages from './SomeComponent.messages'; - // ... - intl.formatMessage(messages.cartPayNow) + import { useIntl } from '@openedx/frontend-base'; + import messages from './messages'; + + 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``. @@ -86,27 +92,95 @@ 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 messages through the ``site.i18n`` webpack alias, so exporting them from ``src/i18n/index.ts`` is all the wiring you need. + +#. ``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. + + To verify, switch languages from the language menu, or set your browser language to one 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 ``@openedx/frontend-base``: + +- ``defaultLanguage``: The fallback locale when nothing in the resolution order above matches. 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 it is offered even when no translations are loaded for it. Unlike ``en`` below, it is still subject to the next step's filter. +#. If ``supportedLanguages`` is configured, keep only the locales that appear in it. +#. Add ``en`` if it isn't already present. This runs after the filter, so English is offered even when ``supportedLanguages`` omits 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 f44507b8..0dbd4cfa 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.ts b/runtime/i18n/index.ts index 32228608..8e6d4b7e 100644 --- a/runtime/i18n/index.ts +++ b/runtime/i18n/index.ts @@ -2,17 +2,14 @@ * #### Import members from **@openedx/frontend-base** * The i18n module relies on react-intl and re-exports all of that package's exports. * - * For each locale we want to support, react-intl needs 1) the locale-data, which includes - * information about how to format numbers, handle plurals, etc., and 2) the translations, as an - * object holding message id / translated string pairs. A locale string and the messages object are - * passed into the IntlProvider element that wraps your element hierarchy. + * For each locale we want to support, react-intl needs the translations as an object holding + * message id / translated string pairs. A locale string and the messages object are passed into + * the IntlProvider element that wraps your element hierarchy. The locale data used to format + * numbers, dates, and plurals comes from the runtime's built-in Intl APIs. * * Note that react-intl has no way of checking if the translations you give it actually have * anything to do with the locale you pass it; it will happily use whatever messages object you pass - * in. However, if the locale data for the locale you passed into the IntlProvider was not - * correctly installed with addLocaleData, all of your translations will fall back to the default - * (in our case English), *even if you gave IntlProvider the correct messages object for that - * locale*. + * in. * * Messages are provided to this module via the configureI18n() function below. * @@ -53,3 +50,5 @@ export { mergeMessages, updateLocale, } from './lib'; + +export { updateSiteLanguage } from './updateSiteLanguage'; diff --git a/runtime/i18n/lib.test.js b/runtime/i18n/lib.test.js index ae73b3ca..c62d259c 100644 --- a/runtime/i18n/lib.test.js +++ b/runtime/i18n/lib.test.js @@ -1,17 +1,30 @@ +import cloneDeep from 'lodash/cloneDeep'; + import { configureI18n, getCookies, getLocale, getMessages, getPrimaryLanguageSubtag, + getSupportedLanguageList, handleRtl, isRtl, mergeMessages, + updateLocale, } from './lib'; +import { getSiteConfig, mergeSiteConfig, setSiteConfig } from '../config'; + jest.mock('universal-cookie'); describe('lib', () => { + const defaultSiteConfig = cloneDeep(getSiteConfig()); + + // Site config is module-level state, so restore the defaults between tests. + afterEach(() => { + setSiteConfig(cloneDeep(defaultSiteConfig)); + }); + describe('getPrimaryLanguageSubtag', () => { it('should work for primary language subtags', () => { expect(getPrimaryLanguageSubtag('en')).toEqual('en'); @@ -64,6 +77,76 @@ describe('lib', () => { getCookies().get = jest.fn(() => null); expect(getLocale()).toEqual(global.navigator.language.toLowerCase()); }); + + it('should return en even though it has no entry in messages', () => { + mergeSiteConfig({ defaultLanguage: 'es-419' }); + expect(getLocale('en')).toEqual('en'); + expect(getLocale('en-gb')).toEqual('en'); + }); + + it('should return en even if supportedLanguages excludes it', () => { + mergeSiteConfig({ defaultLanguage: 'es-419', supportedLanguages: ['es-419', 'de'] }); + expect(getLocale('en')).toEqual('en'); + }); + }); + + 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 include en when it is not the default language', () => { + mergeSiteConfig({ defaultLanguage: 'es-419' }); + configureI18n({ + messages: { + 'es-419': {}, + de: {}, + }, + }); + const codes = getSupportedLanguageList().map((l) => l.code); + expect(codes).toContain('en'); + expect(codes).toContain('es-419'); + }); + + it('should include en even when supportedLanguages excludes it', () => { + mergeSiteConfig({ defaultLanguage: 'es-419', supportedLanguages: ['es-419', 'de'] }); + configureI18n({ + messages: { + 'es-419': {}, + de: {}, + fr: {}, + }, + }); + const codes = getSupportedLanguageList().map((l) => l.code); + expect(codes).toEqual(['de', 'en', 'es-419']); + }); + + 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', () => { @@ -106,11 +189,55 @@ describe('lib', () => { }); }); - describe('handleRtl', () => { + 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(() => { + // handleRtl reads the locale via getLocale(), which needs loaded messages. + configureI18n({ + messages: { + 'es-419': { message: 'es-hah' }, + ar: { message: 'ar-hah' }, + }, + }); + // Spy after configureI18n, which calls handleRtl itself. + setAttribute = jest.fn(); global.document.getElementsByTagName = jest.fn(() => [ { setAttribute, @@ -120,25 +247,19 @@ describe('lib', () => { it('should do the right thing for non-RTL languages', () => { getCookies().get = jest.fn(() => 'es-419'); - configureI18n({ - messages: { - 'es-419': { message: 'es-hah' }, - }, - }); handleRtl(); + + expect(setAttribute).toHaveBeenCalledWith('lang', 'es-419'); expect(setAttribute).toHaveBeenCalledWith('dir', 'ltr'); }); it('should do the right thing for RTL languages', () => { getCookies().get = jest.fn(() => 'ar'); - configureI18n({ - messages: { - ar: { message: 'ar-hah' }, - }, - }); handleRtl(); + + expect(setAttribute).toHaveBeenCalledWith('lang', 'ar'); expect(setAttribute).toHaveBeenCalledWith('dir', 'rtl'); }); }); diff --git a/runtime/i18n/lib.ts b/runtime/i18n/lib.ts index a61521e6..50342a17 100644 --- a/runtime/i18n/lib.ts +++ b/runtime/i18n/lib.ts @@ -48,8 +48,21 @@ const rtlLocales = [ 'yi-us', // Yiddish (United States) ]; +/** + * The language the source strings are written in. It never has an entry in `messages`, + * since its strings come from each message's `defaultMessage`. + */ +const SOURCE_LANGUAGE = 'en'; + 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 */ @@ -82,12 +95,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 it is the source language, 'en', or 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} @@ -98,20 +112,35 @@ export function findSupportedLocale(locale) { throw new Error('findSupportedLocale called before configuring i18n. Call configureI18n with messages first.'); } - if (messages[locale] !== undefined) { + const { defaultLanguage = 'en', supportedLanguages = [] } = getSiteConfig(); + + const isLocaleSupported = (code) => { + // The source language is always available, regardless of supportedLanguages. + if (code === SOURCE_LANGUAGE) { + return true; + } + + 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. @@ -128,7 +157,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) { @@ -138,13 +171,21 @@ 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 return findSupportedLocale(globalThis.navigator.language.toLowerCase()); } +/** + * Returns a language's name in that language, capitalized (e.g. 'Deutsch' for 'de'). + * + * @param {string} locale + * @returns {string} + * @throws {Error} If the runtime has no display name for the locale. + * @memberof module:Internationalization + */ export function getLocalizedLanguageName(locale) { const localizedName = (new Intl.DisplayNames([locale], { type: 'language' })).of(locale); @@ -155,9 +196,30 @@ export function getLocalizedLanguageName(locale) { return `${localizedName.charAt(0).toLocaleUpperCase(locale)}${localizedName.slice(1)}`; } +/** + * Returns the languages the language menu offers, as { code, name } pairs sorted by code. + * + * The list is the locales with loaded messages plus the site's defaultLanguage, filtered by + * supportedLanguages when that is configured. 'en' is added after the filter, so English is + * always offered. + * + * @returns {{ code: string, name: string }[]} + * @memberof module:Internationalization + */ 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 = Array.from(new Set([...Object.keys(messages), defaultLanguage])); + + if (supportedLanguages.length > 0) { + locales = locales.filter((locale) => supportedLanguages.includes(locale)); + } + + // The source language is always available, regardless of supportedLanguages. + if (!locales.includes(SOURCE_LANGUAGE)) { + locales.push(SOURCE_LANGUAGE); + } + locales.sort(); return locales.map((locale) => ({ @@ -166,7 +228,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); } @@ -197,17 +273,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'); } /** @@ -240,6 +315,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..7d06041f --- /dev/null +++ b/runtime/i18n/updateSiteLanguage.test.ts @@ -0,0 +1,102 @@ +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() }; + + beforeEach(() => { + jest.clearAllMocks(); + mockAuthHttpClient.patch.mockReset(); + 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 still set the session language if the user preference patch fails', async () => { + mockGetAuthenticatedUser.mockReturnValue({ username: 'testuser' } as any); + mockAuthHttpClient.patch.mockImplementation((url: string) => ( + url.includes('/api/user/v1/preferences/') + ? Promise.reject(new Error('Network error')) + : Promise.resolve({}) + )); + + await expect(updateSiteLanguage('es-419')).rejects.toThrow(AggregateError); + expect(mockUpdateLocale).toHaveBeenCalledWith('es-419'); + expect(mockAuthHttpClient.patch).toHaveBeenCalledWith( + 'http://localhost:18000/lang_pref/update_language', + { 'pref-lang': 'es-419' }, + { isPublic: true }, + ); + }); + + it('should still patch user preferences if the update_language call fails', async () => { + mockGetAuthenticatedUser.mockReturnValue({ username: 'testuser' } as any); + mockAuthHttpClient.patch.mockImplementation((url: string) => ( + url.includes('/lang_pref/update_language') + ? Promise.reject(new Error('update_language failed')) + : Promise.resolve({}) + )); + + await expect(updateSiteLanguage('es-419')).rejects.toThrow(AggregateError); + expect(mockUpdateLocale).toHaveBeenCalledWith('es-419'); + expect(mockAuthHttpClient.patch).toHaveBeenCalledWith( + 'http://localhost:18000/api/user/v1/preferences/testuser', + { 'pref-lang': 'es-419' }, + { headers: { 'Content-Type': 'application/merge-patch+json' } }, + ); + }); + + it('should aggregate the failures when both requests fail', async () => { + mockGetAuthenticatedUser.mockReturnValue({ username: 'testuser' } as any); + mockAuthHttpClient.patch.mockRejectedValue(new Error('Network error')); + + await expect(updateSiteLanguage('es-419')).rejects.toMatchObject({ + errors: [new Error('Network error'), new Error('Network error')], + }); + expect(mockUpdateLocale).toHaveBeenCalledWith('es-419'); + }); +}); diff --git a/runtime/i18n/updateSiteLanguage.ts b/runtime/i18n/updateSiteLanguage.ts new file mode 100644 index 00000000..f2f89f86 --- /dev/null +++ b/runtime/i18n/updateSiteLanguage.ts @@ -0,0 +1,95 @@ +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. + * + * Both requests are attempted regardless of whether the other one fails, so a failed + * preference save doesn't prevent the session cookie from being set, and vice versa. + * + * @param {string} locale The locale code to switch to (e.g. 'es-419', 'ar'). + * @returns {Promise} Resolves when the switch is complete. Rejects with an + * AggregateError of the failures if any request fails. + * @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); + + const requests = [setSessionLanguage(locale)]; + + // Save the preference for authenticated users. + if (user !== null) { + requests.push(patchUserPreferences(user.username, locale)); + } + + const results = await Promise.allSettled(requests); + const failures = results + .filter((result) => result.status === 'rejected') + .map((result) => (result as PromiseRejectedResult).reason); + + if (failures.length > 0) { + throw new AggregateError(failures, `Failed to persist the site language '${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. + */ +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(); + + // Use the authenticated HTTP client to ensure that the request includes the CSRF token. + // Works for both authenticated and anonymous users, since the 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 c21c801d..d7b7db6f 100644 --- a/runtime/index.ts +++ b/runtime/index.ts @@ -74,6 +74,7 @@ export { mergeMessages, updateLocale, useIntl, + updateSiteLanguage, type IntlConfig, type ResolvedIntlConfig, type IntlShape, 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 ( - - - {currentLanguageName} - - - {languages.map((language) => ( - - ))} - - + <> + + + {toggleLabel} + + + {languages.map((language) => ( + + ))} + + + {errorMessage && ( + setErrorMessage(null)} + > + {errorMessage} + + )} + ); } diff --git a/shell/footer/LanguageMenuItem.tsx b/shell/footer/LanguageMenuItem.tsx index 51a83b69..7dc07fb9 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 7fd84f61..55b245e4 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;