Skip to content
5 changes: 5 additions & 0 deletions src/i18n/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export {
getPrimaryLanguageSubtag,
getLocale,
getMessages,
getSupportedLocaleList,
isRtl,
handleRtl,
mergeMessages,
Expand All @@ -122,3 +123,7 @@ export {
getLanguageList,
getLanguageMessages,
} from './languages';

export {
changeUserSessionLanguage,
} from './languageManager';
59 changes: 59 additions & 0 deletions src/i18n/languageApi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { getConfig } from '../config';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '../auth';
import { convertKeyNames, snakeCaseObject } from '../utils';

/**
* Updates user language preferences via the preferences API.
*
* This function gets the authenticated user, converts preference data to snake_case
* and formats specific keys according to backend requirements before sending the PATCH request.
* If no user is authenticated, the function returns early without making the API call.
*
* @param {Object} preferenceData - The preference parameters to update (e.g., { prefLang: 'en' }).
* @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.
*/
export async function updateAuthenticatedUserPreferences(preferenceData) {
const user = getAuthenticatedUser();
if (!user) {
return Promise.resolve();
}

const snakeCaseData = snakeCaseObject(preferenceData);
const formattedData = convertKeyNames(snakeCaseData, {
pref_lang: 'pref-lang',
});

return getAuthenticatedHttpClient().patch(
`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${user.username}`,
formattedData,
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
);
}

/**
* Sets the language for the current session using the setlang endpoint.
*
* This function sends a POST request to the LMS setlang endpoint to change
* the language for the current user session.
*
* @param {string} languageCode - The language code to set (e.g., 'en', 'es', 'ar').
* Should be a valid ISO language code supported by the platform.
* @returns {Promise} - A promise that resolves when the API call completes successfully,
* or rejects if there's an error with the request.
*/
export async function setSessionLanguage(languageCode) {
const formData = new FormData();
formData.append('language', languageCode);

return getAuthenticatedHttpClient().post(
`${getConfig().LMS_BASE_URL}/i18n/setlang/`,
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm adding a "pending" note here regarding @dcoa's findings on this other conversation. If the update_language endpoint works, we should probably use it.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, we should use the update_language API indeed. It touches the database.

Copy link
Contributor

Choose a reason for hiding this comment

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

@Ang-m4, any objections to implementing this?

Copy link
Member

Choose a reason for hiding this comment

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

sadly @Ang-m4 has recently left our team. @dcoa, do you think this work is something you could continue?

formData,
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
},
);
}
56 changes: 56 additions & 0 deletions src/i18n/languageApi.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi';
import { getConfig } from '../config';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '../auth';

jest.mock('../config');
jest.mock('../auth');

const LMS_BASE_URL = 'http://test.lms';

describe('languageApi', () => {
beforeEach(() => {
jest.clearAllMocks();
getConfig.mockReturnValue({ LMS_BASE_URL });
getAuthenticatedUser.mockReturnValue({ username: 'testuser', userId: '123' });
});

describe('updateAuthenticatedUserPreferences', () => {
it('should send a PATCH request with correct data', async () => {
const patchMock = jest.fn().mockResolvedValue({});
getAuthenticatedHttpClient.mockReturnValue({ patch: patchMock });

await updateAuthenticatedUserPreferences({ prefLang: 'es' });

expect(patchMock).toHaveBeenCalledWith(
`${LMS_BASE_URL}/api/user/v1/preferences/testuser`,
expect.any(Object),
expect.objectContaining({ headers: expect.any(Object) }),
);
});

it('should return early if no authenticated user', async () => {
const patchMock = jest.fn().mockResolvedValue({});
getAuthenticatedHttpClient.mockReturnValue({ patch: patchMock });
getAuthenticatedUser.mockReturnValue(null);

await updateAuthenticatedUserPreferences({ prefLang: 'es' });

expect(patchMock).not.toHaveBeenCalled();
});
});

describe('setSessionLanguage', () => {
it('should send a POST request to setlang endpoint', async () => {
const postMock = jest.fn().mockResolvedValue({});
getAuthenticatedHttpClient.mockReturnValue({ post: postMock });

await setSessionLanguage('ar');

expect(postMock).toHaveBeenCalledWith(
`${LMS_BASE_URL}/i18n/setlang/`,
expect.any(FormData),
expect.objectContaining({ headers: expect.any(Object) }),
);
});
});
});
37 changes: 37 additions & 0 deletions src/i18n/languageManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { handleRtl, LOCALE_CHANGED } from './lib';
import { publish } from '../pubSub';
import { logError } from '../logging';
import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi';

/**
* Changes the user's language preference and applies it to the current session.
*
* This comprehensive function handles the complete language change process:
* 1. Sets the language cookie with the selected language code
* 2. If a user is authenticated, updates their server-side preference in the backend
* 3. Updates the session language through the setlang endpoint
* 4. Publishes a locale change event to notify other parts of the application
*
* @param {string} languageCode - The selected language locale code (e.g., 'en', 'es', 'ar').
* Should be a valid ISO language code supported by the platform.
Comment on lines +15 to +16
Copy link
Contributor

@bradenmacdonald bradenmacdonald Feb 24, 2026

Choose a reason for hiding this comment

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

Please give more specific examples of what these locale codes are like (e.g. fr-ca ?) because the platform has at least two separate lists of locale codes, with different formats, and browsers/react-intl tend to use yet a third format (fr-CA part lowercase, part uppercase) that's different than either of the platform's two backend formats.

Also, since this is a brand new file, can you not use TypeScript?

Copy link
Contributor

@dcoa dcoa Mar 2, 2026

Choose a reason for hiding this comment

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

Thanks for pointing out the different formats available in the platform, I check in Django docs (https://docs.djangoproject.com/en/6.0/topics/i18n/translation/) to understand which format apply in this case, it says should work with LANGUAGES, so I will update the example taking that in consideration.

since this is a brand new file, can you not use TypeScript?

I'm not sure what do you refer here, could you help me with a more context?. I understand the comment is JSDoc format.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure what do you refer here, could you help me with a more context?. I understand the comment is JSDoc format.

I mean rename this file to languageManager.ts (not .js) and change it like this:

export async function changeUserSessionLanguage(
-   languageCode,
-   forceReload = false,
- ) {
+   languageCode: string,
+   forceReload = false,
+ ) -> Promise<void> {

etc.

* @param {boolean} [forceReload=false] - Whether to force a page reload after changing the language.
* @returns {Promise} - A promise that resolves when all operations complete.
*
*/
export async function changeUserSessionLanguage(
languageCode,
forceReload = false,
) {
try {
await updateAuthenticatedUserPreferences({ prefLang: languageCode });
await setSessionLanguage(languageCode);
handleRtl(languageCode);
publish(LOCALE_CHANGED, languageCode);
} catch (error) {
logError(error);
}

if (forceReload) {
window.location.reload();
}
}
59 changes: 59 additions & 0 deletions src/i18n/languageManager.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { changeUserSessionLanguage } from './languageManager';
import { handleRtl, LOCALE_CHANGED } from './lib';
import { logError } from '../logging';
import { publish } from '../pubSub';
import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi';

jest.mock('./lib');
jest.mock('../logging');
jest.mock('../pubSub');
jest.mock('./languageApi');

describe('languageManager', () => {
let mockReload;

beforeEach(() => {
jest.clearAllMocks();

mockReload = jest.fn();
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { reload: mockReload },
});

updateAuthenticatedUserPreferences.mockResolvedValue({});
setSessionLanguage.mockResolvedValue({});
});

describe('changeUserSessionLanguage', () => {
it('should perform complete language change process', async () => {
await changeUserSessionLanguage('fr');
expect(updateAuthenticatedUserPreferences).toHaveBeenCalledWith({
prefLang: 'fr',
});
expect(setSessionLanguage).toHaveBeenCalledWith('fr');
expect(handleRtl).toHaveBeenCalledWith('fr');
expect(publish).toHaveBeenCalledWith(LOCALE_CHANGED, 'fr');
expect(mockReload).not.toHaveBeenCalled();
});

it('should handle errors gracefully', async () => {
updateAuthenticatedUserPreferences.mockRejectedValue(new Error('fail'));
await changeUserSessionLanguage('es', true);
expect(logError).toHaveBeenCalled();
});

it('should call updateAuthenticatedUserPreferences even when user is not authenticated', async () => {
await changeUserSessionLanguage('en', true);
expect(updateAuthenticatedUserPreferences).toHaveBeenCalledWith({
prefLang: 'en',
});
});

it('should reload if forceReload is true', async () => {
await changeUserSessionLanguage('de', true);
expect(mockReload).toHaveBeenCalled();
});
});
});
22 changes: 22 additions & 0 deletions src/i18n/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,28 @@ export function getMessages(locale = getLocale()) {
return messages[locale];
}

/**
* Returns the list of supported locales based on the configured messages.
* This list is dynamically generated from the translation messages that were
* provided during i18n configuration. Always includes the current locale.
*
* @throws An error if i18n has not yet been configured.
* @returns {string[]} Array of supported locale codes
* @memberof module:Internationalization
*/
export function getSupportedLocaleList() {
Copy link
Contributor

Choose a reason for hiding this comment

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

@Asespinel poses a great question: shouldn't we allow the operator to manually define which languages are actually available for use or selection by the end user, independently of the locales derived from messages?

I believe we should. In the pre-MFE world this was possible via a call to released_languages(), which in turn consulted the DarkLangConfig model.

In the header PR that goes along with this one there's a reference to a SITE_SUPPORTED_LANGUAGES config item, but I don't see it implemented anywhere.

What do? 1) a call to released_languages on the backend, or 2) a new frontend-only configuration?

Personally, I think either would be sufficient, but probably 2) is easier to implement and has less potential for UX problems. I can also see a path where 2) is configurable at runtime via MFE_CONFIG.

(Tangent: if we don't go with 1) and don't foresee ever needing the features of DarkLang - such as the ability to test fake/beta languages by adding a ?langPref=english@pirate query to the URL - we should deprecate the DarkLang thing.)

@brian-smith-tcril, @dcoa, @felipemontoya: thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, I was just researching some of this recently. The whole thing seems a bit messy. Sounds like you might have some insight to share on openedx/openedx-platform#38036

Copy link
Contributor

@dcoa dcoa Mar 2, 2026

Choose a reason for hiding this comment

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

In the header PR that goes along with this one there's a reference to a SITE_SUPPORTED_LANGUAGES config item, but I don't see it implemented anywhere.

That was previously implement but we change it to pass the languages as props for the slot refactor.

Here is in the footer PR commit as reference openedx/frontend-component-footer@82bfc97#diff-618acf142786bf7414152d4982d3d647b5ca1f96edaed7385ba0f17b46741b65R63-R64 and the comment that mention the idea #786 (comment)

I think for now, just to keep consistency with the legacy code and the templates that allow to select the language in header and footer there, having a single source of true DarkLang is ideal (I ignore if we have a current endpoint to return the information, I think no, right?). However, looking ahead, once the UI becomes fully client-side rendered, I don’t see much value (at least from the frontend perspective) in keeping DarkLang feature. In that scenario, I would prefer to manage it through a configuration variable instead.

if (messages === null) {
throw new Error('getSupportedLocaleList called before configuring i18n. Call configure with messages first.');
}

const locales = Object.keys(messages);
if (!locales.includes('en')) {
locales.push('en');
}

return locales;
}

/**
* Determines if the provided locale is a right-to-left language.
*
Expand Down
27 changes: 27 additions & 0 deletions src/i18n/lib.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getPrimaryLanguageSubtag,
getLocale,
getMessages,
getSupportedLocaleList,
isRtl,
handleRtl,
getCookies,
Expand Down Expand Up @@ -184,6 +185,32 @@ describe('lib', () => {
});
});

describe('getSupportedLocales', () => {
describe('when configured', () => {
beforeEach(() => {
configure({
loggingService: { logError: jest.fn() },
config: {
ENVIRONMENT: 'production',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
},
messages: {
'es-419': { message: 'es-hah' },
de: { message: 'de-hah' },
'en-us': { message: 'en-us-hah' },
fr: { message: 'fr-hah' },
},
});
});

it('should return an array of supported locale codes', () => {
const supportedLocales = getSupportedLocaleList();
expect(Array.isArray(supportedLocales)).toBe(true);
expect(supportedLocales).toEqual(['es-419', 'de', 'en-us', 'fr', 'en']);
});
});
});

describe('isRtl', () => {
it('should be true for RTL languages', () => {
expect(isRtl('ar')).toBe(true);
Expand Down