Skip to content
Open
5 changes: 5 additions & 0 deletions src/i18n/index.js → src/i18n/index.ts
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';
56 changes: 56 additions & 0 deletions src/i18n/languageApi.test.ts
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 as jest.Mock).mockReturnValue({ LMS_BASE_URL });
(getAuthenticatedUser as jest.Mock).mockReturnValue({ username: 'testuser', userId: '123' });
});

describe('updateAuthenticatedUserPreferences', () => {
it('should send a PATCH request with correct data', async () => {
const patchMock = jest.fn().mockResolvedValue({});
(getAuthenticatedHttpClient as jest.Mock).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 as jest.Mock).mockReturnValue({ patch: patchMock });
(getAuthenticatedUser as jest.Mock).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 as jest.Mock).mockReturnValue({ post: postMock });

await setSessionLanguage('ar');

expect(postMock).toHaveBeenCalledWith(
`${LMS_BASE_URL}/i18n/setlang/`,
expect.any(FormData),
expect.objectContaining({ headers: expect.any(Object) }),
);
});
});
});
65 changes: 65 additions & 0 deletions src/i18n/languageApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { getConfig } from '../config';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '../auth';
import { convertKeyNames, snakeCaseObject } from '../utils';

interface PreferenceData {
prefLang: string;
[key: string]: string;
}

/**
* 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 {PreferenceData} 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: PreferenceData): Promise<void> {
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 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.
*/
export async function setSessionLanguage(languageCode: string): Promise<void> {
const formData = new FormData();
formData.append('language', languageCode);

return getAuthenticatedHttpClient().post(
`${getConfig().LMS_BASE_URL}/i18n/setlang/`,
formData,
{
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
},
);
}
59 changes: 59 additions & 0 deletions src/i18n/languageManager.test.ts
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: jest.Mock;

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

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

(updateAuthenticatedUserPreferences as jest.Mock).mockResolvedValue({});
(setSessionLanguage as jest.Mock).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).toHaveBeenCalled();
expect(publish).toHaveBeenCalledWith(LOCALE_CHANGED, 'fr');
expect(mockReload).not.toHaveBeenCalled();
});

it('should handle errors gracefully', async () => {
(updateAuthenticatedUserPreferences as jest.Mock).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();
});
});
});
38 changes: 38 additions & 0 deletions src/i18n/languageManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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-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
* @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: string,
forceReload: boolean = false,
): Promise<void> {
try {
await updateAuthenticatedUserPreferences({ prefLang: languageCode });
await setSessionLanguage(languageCode);
handleRtl();
publish(LOCALE_CHANGED, languageCode);
} catch (error: any) {
logError(error);
}

if (forceReload) {
window.location.reload();
}
}
45 changes: 36 additions & 9 deletions src/i18n/lib.test.js → src/i18n/lib.test.ts
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 All @@ -14,15 +15,15 @@ jest.mock('universal-cookie');

describe('lib', () => {
describe('configure', () => {
let originalWarn = null;
let originalWarn: typeof console.warn | null = null;

beforeEach(() => {
originalWarn = console.warn;
console.warn = jest.fn();
});

afterEach(() => {
console.warn = originalWarn;
console.warn = originalWarn!;
});

it('should not call console.warn in production', () => {
Expand Down Expand Up @@ -176,11 +177,37 @@ describe('lib', () => {
});

it('should return the messages for the provided locale', () => {
expect(getMessages('en-us').message).toEqual('en-us-hah');
expect(getMessages('en-us')!.message).toEqual('en-us-hah');
});

it('should return the messages for the preferred locale if no argument is passed', () => {
expect(getMessages().message).toEqual('es-hah');
expect(getMessages()!.message).toEqual('es-hah');
});
});

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']);
});
});
});

Expand All @@ -204,15 +231,15 @@ describe('lib', () => {
});

describe('handleRtl', () => {
let setAttribute;
let setAttribute: jest.Mock;
beforeEach(() => {
setAttribute = jest.fn();

global.document.getElementsByTagName = jest.fn(() => [
{
setAttribute,
},
]);
]) as any;
});

it('should do the right thing for non-RTL languages', () => {
Expand Down Expand Up @@ -283,7 +310,7 @@ describe('mergeMessages', () => {
ar: { message: 'ar-hah' },
},
});
const result = mergeMessages([{ foo: 'bar' }, { buh: 'baz' }, { gah: 'wut' }]);
const result = mergeMessages([{ foo: 'bar' }, { buh: 'baz' }, { gah: 'wut' }] as any);
expect(result).toEqual({
ar: { message: 'ar-hah' },
foo: 'bar',
Expand All @@ -304,7 +331,7 @@ describe('mergeMessages', () => {
es: { init: 'inicial' },
},
});
const messages = [
const msgs: import('./lib').I18nMessages[] = [
{
en: { hello: 'hello' },
es: { hello: 'hola' },
Expand All @@ -315,7 +342,7 @@ describe('mergeMessages', () => {
},
];

const result = mergeMessages(messages);
const result = mergeMessages(msgs);
expect(result).toEqual({
en: {
init: 'initial',
Expand Down
Loading
Loading