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.

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.
* @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() {
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