Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export {
getPrimaryLanguageSubtag,
getLocale,
getMessages,
getSupportedLocaleList,
isRtl,
handleRtl,
mergeMessages,
Expand All @@ -65,3 +66,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 PATCH request to update_language endpoint', async () => {
const patchMock = jest.fn().mockResolvedValue({});
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ patch: patchMock });

await setSessionLanguage('ar');

expect(patchMock).toHaveBeenCalledWith(
`${LMS_BASE_URL}/lang_pref/update_language`,
{ 'pref-lang': 'ar' },
expect.objectContaining({ isPublic: true }),
);
});
});
});
60 changes: 60 additions & 0 deletions src/i18n/languageApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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().patch(
`${getConfig().LMS_BASE_URL}/lang_pref/update_language`,
{ 'pref-lang': languageCode },
{ isPublic: true },
);
}
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