From 42fca2fd3acec9aa3c54efc1eab5295a9d31c918 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Malagon Date: Tue, 1 Apr 2025 14:24:54 -0500 Subject: [PATCH 1/9] feat(i18n): add language preference management functionality --- src/i18n/LanguageManager.js | 93 +++++++++++++++++++++++++++++++++++++ src/i18n/index.js | 4 ++ 2 files changed, 97 insertions(+) create mode 100644 src/i18n/LanguageManager.js diff --git a/src/i18n/LanguageManager.js b/src/i18n/LanguageManager.js new file mode 100644 index 000000000..78b9538d9 --- /dev/null +++ b/src/i18n/LanguageManager.js @@ -0,0 +1,93 @@ +/** + * LanguageManager.js + * + * Provides utility functions for updating the session language preferences for users. + */ + +import { getConfig } from '../config'; +import { getAuthenticatedHttpClient, getAuthenticatedUser } from '../auth'; +import { convertKeyNames, snakeCaseObject } from '../utils'; +import { getCookies, handleRtl, LOCALE_CHANGED } from './lib'; +import { logError } from '../logging'; +import { publish } from '../pubSub'; + +/** + * Updates user language preferences via the preferences API. + * + * This function converts preference data to snake_case and formats specific keys + * according to backend requirements before sending the PATCH request. + * + * @param {string} username - The username of the user whose preferences to update. + * @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. + */ +export async function updateUserPreferences(username, preferenceData) { + const snakeCaseData = snakeCaseObject(preferenceData); + const formattedData = convertKeyNames(snakeCaseData, { + pref_lang: 'pref-lang', + }); + + return getAuthenticatedHttpClient().patch( + `${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${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); + + const url = `${getConfig().LMS_BASE_URL}/i18n/setlang/`; + return getAuthenticatedHttpClient().post(url, formData, { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); +} + +/** + * 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. + * @returns {Promise} - A promise that resolves when all operations complete. + * + */ +export async function changeUserSessionLanguage(languageCode) { + const cookies = getCookies(); + const cookieName = getConfig().LANGUAGE_PREFERENCE_COOKIE_NAME; + cookies.set(cookieName, languageCode); + + try { + const user = getAuthenticatedUser(); + if (user) { + await updateUserPreferences(user.username, { prefLang: languageCode }); + } + + await setSessionLanguage(languageCode); + handleRtl(languageCode); + publish(LOCALE_CHANGED, languageCode); + } catch (error) { + logError(error); + } +} diff --git a/src/i18n/index.js b/src/i18n/index.js index 7ff12e4f5..28a2b4d09 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -122,3 +122,7 @@ export { getLanguageList, getLanguageMessages, } from './languages'; + +export { + changeUserSessionLanguage, +} from './LanguageManager'; From f0814f74b6f0f61e12dc37a6261f0fec512c1164 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Malagon Date: Tue, 8 Apr 2025 13:56:37 -0500 Subject: [PATCH 2/9] fix: force page reload to ensure complete translation application --- src/i18n/LanguageManager.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/i18n/LanguageManager.js b/src/i18n/LanguageManager.js index 78b9538d9..03942a771 100644 --- a/src/i18n/LanguageManager.js +++ b/src/i18n/LanguageManager.js @@ -90,4 +90,10 @@ export async function changeUserSessionLanguage(languageCode) { } catch (error) { logError(error); } + + // Force page reload to ensure complete translation application. + // While some translations update via the publish event, many sections + // of the platform are not configured to receive these events or + // handle translations dynamically, requiring a full reload for consistency. + window.location.reload(); } From a5c70f577b935e4b2f6ae5ef0e39cea746363ffa Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Malagon Date: Wed, 18 Jun 2025 14:21:50 -0500 Subject: [PATCH 3/9] feat: conditional page reloading and module rename --- src/i18n/index.js | 2 +- .../{LanguageManager.js => languageManager.js} | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) rename src/i18n/{LanguageManager.js => languageManager.js} (90%) diff --git a/src/i18n/index.js b/src/i18n/index.js index 28a2b4d09..64d2966da 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -125,4 +125,4 @@ export { export { changeUserSessionLanguage, -} from './LanguageManager'; +} from './languageManager'; diff --git a/src/i18n/LanguageManager.js b/src/i18n/languageManager.js similarity index 90% rename from src/i18n/LanguageManager.js rename to src/i18n/languageManager.js index 03942a771..be57828e5 100644 --- a/src/i18n/LanguageManager.js +++ b/src/i18n/languageManager.js @@ -70,10 +70,14 @@ export async function setSessionLanguage(languageCode) { * * @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) { +export async function changeUserSessionLanguage( + languageCode, + forceReload = false, +) { const cookies = getCookies(); const cookieName = getConfig().LANGUAGE_PREFERENCE_COOKIE_NAME; cookies.set(cookieName, languageCode); @@ -91,9 +95,7 @@ export async function changeUserSessionLanguage(languageCode) { logError(error); } - // Force page reload to ensure complete translation application. - // While some translations update via the publish event, many sections - // of the platform are not configured to receive these events or - // handle translations dynamically, requiring a full reload for consistency. - window.location.reload(); + if (forceReload) { + window.location.reload(); + } } From 2a3be051f9edeecf4292eb2ed78c1d0b24bf1b95 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Malagon Date: Thu, 19 Jun 2025 20:56:37 -0500 Subject: [PATCH 4/9] refactor(i18n): move language preference functions to languageApi module --- src/i18n/languageApi.js | 54 +++++++++++++++++++++++++++++++++ src/i18n/languageManager.js | 60 ++----------------------------------- 2 files changed, 57 insertions(+), 57 deletions(-) create mode 100644 src/i18n/languageApi.js diff --git a/src/i18n/languageApi.js b/src/i18n/languageApi.js new file mode 100644 index 000000000..458794d33 --- /dev/null +++ b/src/i18n/languageApi.js @@ -0,0 +1,54 @@ +import { getConfig } from '../config'; +import { getAuthenticatedHttpClient } from '../auth'; +import { convertKeyNames, snakeCaseObject } from '../utils'; + +/** + * Updates user language preferences via the preferences API. + * + * This function converts preference data to snake_case and formats specific keys + * according to backend requirements before sending the PATCH request. + * + * @param {string} username - The username of the user whose preferences to update. + * @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. + */ +export async function updateUserPreferences(username, preferenceData) { + const snakeCaseData = snakeCaseObject(preferenceData); + const formattedData = convertKeyNames(snakeCaseData, { + pref_lang: 'pref-lang', + }); + + return getAuthenticatedHttpClient().patch( + `${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${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/`, + formData, + { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }, + ); +} diff --git a/src/i18n/languageManager.js b/src/i18n/languageManager.js index be57828e5..35d9893d9 100644 --- a/src/i18n/languageManager.js +++ b/src/i18n/languageManager.js @@ -1,63 +1,9 @@ -/** - * LanguageManager.js - * - * Provides utility functions for updating the session language preferences for users. - */ - import { getConfig } from '../config'; -import { getAuthenticatedHttpClient, getAuthenticatedUser } from '../auth'; -import { convertKeyNames, snakeCaseObject } from '../utils'; +import { getAuthenticatedUser } from '../auth'; import { getCookies, handleRtl, LOCALE_CHANGED } from './lib'; -import { logError } from '../logging'; import { publish } from '../pubSub'; - -/** - * Updates user language preferences via the preferences API. - * - * This function converts preference data to snake_case and formats specific keys - * according to backend requirements before sending the PATCH request. - * - * @param {string} username - The username of the user whose preferences to update. - * @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. - */ -export async function updateUserPreferences(username, preferenceData) { - const snakeCaseData = snakeCaseObject(preferenceData); - const formattedData = convertKeyNames(snakeCaseData, { - pref_lang: 'pref-lang', - }); - - return getAuthenticatedHttpClient().patch( - `${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${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); - - const url = `${getConfig().LMS_BASE_URL}/i18n/setlang/`; - return getAuthenticatedHttpClient().post(url, formData, { - headers: { - Accept: 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - }); -} +import { logError } from '../logging'; +import { updateUserPreferences, setSessionLanguage } from './languageApi'; /** * Changes the user's language preference and applies it to the current session. From b63d4ee474ee3da5385a7306e620b331c2c7cb97 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Malagon Date: Thu, 19 Jun 2025 20:57:36 -0500 Subject: [PATCH 5/9] test(i18n): add unit tests for languageApi and languageManager functions --- src/i18n/languageApi.test.js | 45 ++++++++++++++++++ src/i18n/languageManager.test.js | 82 ++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/i18n/languageApi.test.js create mode 100644 src/i18n/languageManager.test.js diff --git a/src/i18n/languageApi.test.js b/src/i18n/languageApi.test.js new file mode 100644 index 000000000..ab0f36c6d --- /dev/null +++ b/src/i18n/languageApi.test.js @@ -0,0 +1,45 @@ +import { updateUserPreferences, setSessionLanguage } from './languageApi'; +import { getConfig } from '../config'; +import { getAuthenticatedHttpClient } 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 }); + }); + + describe('updateUserPreferences', () => { + it('should send a PATCH request with correct data', async () => { + const patchMock = jest.fn().mockResolvedValue({}); + getAuthenticatedHttpClient.mockReturnValue({ patch: patchMock }); + + await updateUserPreferences('user1', { prefLang: 'es' }); + + expect(patchMock).toHaveBeenCalledWith( + `${LMS_BASE_URL}/api/user/v1/preferences/user1`, + expect.any(Object), + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); + }); + + 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) }), + ); + }); + }); +}); diff --git a/src/i18n/languageManager.test.js b/src/i18n/languageManager.test.js new file mode 100644 index 000000000..693746177 --- /dev/null +++ b/src/i18n/languageManager.test.js @@ -0,0 +1,82 @@ +import { changeUserSessionLanguage } from './languageManager'; +import { getConfig } from '../config'; +import { getAuthenticatedUser } from '../auth'; +import { getCookies, handleRtl, LOCALE_CHANGED } from './lib'; +import { logError } from '../logging'; +import { publish } from '../pubSub'; +import { updateUserPreferences, setSessionLanguage } from './languageApi'; + +jest.mock('../config'); +jest.mock('../auth'); +jest.mock('./lib'); +jest.mock('../logging'); +jest.mock('../pubSub'); +jest.mock('./languageApi'); + +const LMS_BASE_URL = 'http://test.lms'; +const LANGUAGE_PREFERENCE_COOKIE_NAME = 'lang'; + +describe('languageManager', () => { + let mockCookies; + let mockUser; + let mockReload; + + beforeEach(() => { + jest.clearAllMocks(); + getConfig.mockReturnValue({ + LMS_BASE_URL, + LANGUAGE_PREFERENCE_COOKIE_NAME, + }); + + mockCookies = { set: jest.fn() }; + getCookies.mockReturnValue(mockCookies); + + mockUser = { username: 'testuser', userId: '123' }; + getAuthenticatedUser.mockReturnValue(mockUser); + + mockReload = jest.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { reload: mockReload }, + }); + + updateUserPreferences.mockResolvedValue({}); + setSessionLanguage.mockResolvedValue({}); + }); + + describe('changeUserSessionLanguage', () => { + it('should perform complete language change process', async () => { + await changeUserSessionLanguage('fr'); + + expect(getCookies().set).toHaveBeenCalledWith( + LANGUAGE_PREFERENCE_COOKIE_NAME, + 'fr', + ); + expect(updateUserPreferences).toHaveBeenCalledWith('testuser', { + 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 () => { + updateUserPreferences.mockRejectedValue(new Error('fail')); + await changeUserSessionLanguage('es', true); + expect(logError).toHaveBeenCalled(); + }); + + it('should skip updateUserPreferences if user is not authenticated', async () => { + getAuthenticatedUser.mockReturnValue(null); + await changeUserSessionLanguage('en', true); + expect(updateUserPreferences).not.toHaveBeenCalled(); + }); + + it('should reload if forceReload is true', async () => { + await changeUserSessionLanguage('de', true); + expect(mockReload).toHaveBeenCalled(); + }); + }); +}); From 837ef56bc8d5dbdcccbda41706c6dc7b9bb67539 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Date: Sun, 5 Oct 2025 15:09:32 -0500 Subject: [PATCH 6/9] feat(i18n): add getSupportedLocales function --- src/i18n/index.js | 1 + src/i18n/lib.js | 16 ++++++++++++++++ src/i18n/lib.test.js | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/src/i18n/index.js b/src/i18n/index.js index 64d2966da..340d809bb 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -102,6 +102,7 @@ export { getPrimaryLanguageSubtag, getLocale, getMessages, + getSupportedLocales, isRtl, handleRtl, mergeMessages, diff --git a/src/i18n/lib.js b/src/i18n/lib.js index 84b3b00a7..01d74eb05 100644 --- a/src/i18n/lib.js +++ b/src/i18n/lib.js @@ -184,6 +184,22 @@ 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. + * + * @throws An error if i18n has not yet been configured. + * @returns {string[]} Array of supported locale codes + * @memberof module:Internationalization + */ +export function getSupportedLocales() { + if (messages === null) { + throw new Error('getSupportedLocales called before configuring i18n. Call configure with messages first.'); + } + return Object.keys(messages); +} + /** * Determines if the provided locale is a right-to-left language. * diff --git a/src/i18n/lib.test.js b/src/i18n/lib.test.js index 75b82ec9f..d446595e3 100644 --- a/src/i18n/lib.test.js +++ b/src/i18n/lib.test.js @@ -4,6 +4,7 @@ import { getPrimaryLanguageSubtag, getLocale, getMessages, + getSupportedLocales, isRtl, handleRtl, getCookies, @@ -184,6 +185,40 @@ describe('lib', () => { }); }); + describe('getSupportedLocales', () => { + 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 = getSupportedLocales(); + expect(Array.isArray(supportedLocales)).toBe(true); + expect(supportedLocales).toEqual(['es-419', 'de', 'en-us', 'fr']); + }); + + it('should throw an error if i18n is not configured', () => { + // Reset the configuration to null + jest.resetModules(); + const { getSupportedLocales: freshGetSupportedLocales } = require('./lib'); + + expect(() => freshGetSupportedLocales()).toThrow( + 'getSupportedLocales called before configuring i18n. Call configure with messages first.' + ); + }); + }); + describe('isRtl', () => { it('should be true for RTL languages', () => { expect(isRtl('ar')).toBe(true); From d6d1cd7954864203930845f3e3125dedcc778a03 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Date: Sun, 5 Oct 2025 17:30:39 -0500 Subject: [PATCH 7/9] feat(i18n): update user preferences function --- src/i18n/languageApi.js | 19 ++++++++++++------- src/i18n/languageApi.test.js | 21 ++++++++++++++++----- src/i18n/languageManager.js | 9 ++------- src/i18n/languageManager.test.js | 21 ++++++++------------- 4 files changed, 38 insertions(+), 32 deletions(-) diff --git a/src/i18n/languageApi.js b/src/i18n/languageApi.js index 458794d33..07d6acae0 100644 --- a/src/i18n/languageApi.js +++ b/src/i18n/languageApi.js @@ -1,26 +1,31 @@ import { getConfig } from '../config'; -import { getAuthenticatedHttpClient } from '../auth'; +import { getAuthenticatedHttpClient, getAuthenticatedUser } from '../auth'; import { convertKeyNames, snakeCaseObject } from '../utils'; /** * Updates user language preferences via the preferences API. * - * This function converts preference data to snake_case and formats specific keys - * according to backend requirements before sending the PATCH request. + * 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 {string} username - The username of the user whose preferences to update. * @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. + * or rejects if there's an error with the request. Returns early if no user is authenticated. */ -export async function updateUserPreferences(username, preferenceData) { +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/${username}`, + `${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${user.username}`, formattedData, { headers: { 'Content-Type': 'application/merge-patch+json' } }, ); diff --git a/src/i18n/languageApi.test.js b/src/i18n/languageApi.test.js index ab0f36c6d..3e64c0e83 100644 --- a/src/i18n/languageApi.test.js +++ b/src/i18n/languageApi.test.js @@ -1,6 +1,6 @@ -import { updateUserPreferences, setSessionLanguage } from './languageApi'; +import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi'; import { getConfig } from '../config'; -import { getAuthenticatedHttpClient } from '../auth'; +import { getAuthenticatedHttpClient, getAuthenticatedUser } from '../auth'; jest.mock('../config'); jest.mock('../auth'); @@ -11,21 +11,32 @@ describe('languageApi', () => { beforeEach(() => { jest.clearAllMocks(); getConfig.mockReturnValue({ LMS_BASE_URL }); + getAuthenticatedUser.mockReturnValue({ username: 'testuser', userId: '123' }); }); - describe('updateUserPreferences', () => { + describe('updateAuthenticatedUserPreferences', () => { it('should send a PATCH request with correct data', async () => { const patchMock = jest.fn().mockResolvedValue({}); getAuthenticatedHttpClient.mockReturnValue({ patch: patchMock }); - await updateUserPreferences('user1', { prefLang: 'es' }); + await updateAuthenticatedUserPreferences({ prefLang: 'es' }); expect(patchMock).toHaveBeenCalledWith( - `${LMS_BASE_URL}/api/user/v1/preferences/user1`, + `${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', () => { diff --git a/src/i18n/languageManager.js b/src/i18n/languageManager.js index 35d9893d9..374b978bb 100644 --- a/src/i18n/languageManager.js +++ b/src/i18n/languageManager.js @@ -1,9 +1,8 @@ import { getConfig } from '../config'; -import { getAuthenticatedUser } from '../auth'; import { getCookies, handleRtl, LOCALE_CHANGED } from './lib'; import { publish } from '../pubSub'; import { logError } from '../logging'; -import { updateUserPreferences, setSessionLanguage } from './languageApi'; +import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi'; /** * Changes the user's language preference and applies it to the current session. @@ -29,11 +28,7 @@ export async function changeUserSessionLanguage( cookies.set(cookieName, languageCode); try { - const user = getAuthenticatedUser(); - if (user) { - await updateUserPreferences(user.username, { prefLang: languageCode }); - } - + await updateAuthenticatedUserPreferences({ prefLang: languageCode }); await setSessionLanguage(languageCode); handleRtl(languageCode); publish(LOCALE_CHANGED, languageCode); diff --git a/src/i18n/languageManager.test.js b/src/i18n/languageManager.test.js index 693746177..ee297d8fd 100644 --- a/src/i18n/languageManager.test.js +++ b/src/i18n/languageManager.test.js @@ -1,13 +1,11 @@ import { changeUserSessionLanguage } from './languageManager'; import { getConfig } from '../config'; -import { getAuthenticatedUser } from '../auth'; import { getCookies, handleRtl, LOCALE_CHANGED } from './lib'; import { logError } from '../logging'; import { publish } from '../pubSub'; -import { updateUserPreferences, setSessionLanguage } from './languageApi'; +import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi'; jest.mock('../config'); -jest.mock('../auth'); jest.mock('./lib'); jest.mock('../logging'); jest.mock('../pubSub'); @@ -18,7 +16,6 @@ const LANGUAGE_PREFERENCE_COOKIE_NAME = 'lang'; describe('languageManager', () => { let mockCookies; - let mockUser; let mockReload; beforeEach(() => { @@ -31,9 +28,6 @@ describe('languageManager', () => { mockCookies = { set: jest.fn() }; getCookies.mockReturnValue(mockCookies); - mockUser = { username: 'testuser', userId: '123' }; - getAuthenticatedUser.mockReturnValue(mockUser); - mockReload = jest.fn(); Object.defineProperty(window, 'location', { configurable: true, @@ -41,7 +35,7 @@ describe('languageManager', () => { value: { reload: mockReload }, }); - updateUserPreferences.mockResolvedValue({}); + updateAuthenticatedUserPreferences.mockResolvedValue({}); setSessionLanguage.mockResolvedValue({}); }); @@ -53,7 +47,7 @@ describe('languageManager', () => { LANGUAGE_PREFERENCE_COOKIE_NAME, 'fr', ); - expect(updateUserPreferences).toHaveBeenCalledWith('testuser', { + expect(updateAuthenticatedUserPreferences).toHaveBeenCalledWith({ prefLang: 'fr', }); expect(setSessionLanguage).toHaveBeenCalledWith('fr'); @@ -63,15 +57,16 @@ describe('languageManager', () => { }); it('should handle errors gracefully', async () => { - updateUserPreferences.mockRejectedValue(new Error('fail')); + updateAuthenticatedUserPreferences.mockRejectedValue(new Error('fail')); await changeUserSessionLanguage('es', true); expect(logError).toHaveBeenCalled(); }); - it('should skip updateUserPreferences if user is not authenticated', async () => { - getAuthenticatedUser.mockReturnValue(null); + it('should call updateAuthenticatedUserPreferences even when user is not authenticated', async () => { await changeUserSessionLanguage('en', true); - expect(updateUserPreferences).not.toHaveBeenCalled(); + expect(updateAuthenticatedUserPreferences).toHaveBeenCalledWith({ + prefLang: 'en', + }); }); it('should reload if forceReload is true', async () => { From b0b7b341f7c45838c56fcf877495b27166d14fa3 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Date: Sun, 5 Oct 2025 20:53:43 -0500 Subject: [PATCH 8/9] feat(i18n): update getSupportedLocaleList function --- src/i18n/index.js | 2 +- src/i18n/languageApi.js | 2 +- src/i18n/lib.js | 14 ++++++++---- src/i18n/lib.test.js | 50 +++++++++++++++++------------------------ 4 files changed, 33 insertions(+), 35 deletions(-) diff --git a/src/i18n/index.js b/src/i18n/index.js index 340d809bb..2e0d306fa 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -102,7 +102,7 @@ export { getPrimaryLanguageSubtag, getLocale, getMessages, - getSupportedLocales, + getSupportedLocaleList, isRtl, handleRtl, mergeMessages, diff --git a/src/i18n/languageApi.js b/src/i18n/languageApi.js index 07d6acae0..ea1c43438 100644 --- a/src/i18n/languageApi.js +++ b/src/i18n/languageApi.js @@ -5,7 +5,7 @@ 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 + * 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. * diff --git a/src/i18n/lib.js b/src/i18n/lib.js index 01d74eb05..4f6f5dcc1 100644 --- a/src/i18n/lib.js +++ b/src/i18n/lib.js @@ -187,17 +187,23 @@ export function getMessages(locale = getLocale()) { /** * 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. + * 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 getSupportedLocales() { +export function getSupportedLocaleList() { if (messages === null) { - throw new Error('getSupportedLocales called before configuring i18n. Call configure with messages first.'); + throw new Error('getSupportedLocaleList called before configuring i18n. Call configure with messages first.'); } - return Object.keys(messages); + + const locales = Object.keys(messages); + if (!locales.includes('en')) { + locales.push('en'); + } + + return locales; } /** diff --git a/src/i18n/lib.test.js b/src/i18n/lib.test.js index d446595e3..07c1ce216 100644 --- a/src/i18n/lib.test.js +++ b/src/i18n/lib.test.js @@ -4,7 +4,7 @@ import { getPrimaryLanguageSubtag, getLocale, getMessages, - getSupportedLocales, + getSupportedLocaleList, isRtl, handleRtl, getCookies, @@ -186,36 +186,28 @@ describe('lib', () => { }); describe('getSupportedLocales', () => { - 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' }, - }, + 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 = getSupportedLocales(); - expect(Array.isArray(supportedLocales)).toBe(true); - expect(supportedLocales).toEqual(['es-419', 'de', 'en-us', 'fr']); - }); - it('should throw an error if i18n is not configured', () => { - // Reset the configuration to null - jest.resetModules(); - const { getSupportedLocales: freshGetSupportedLocales } = require('./lib'); - - expect(() => freshGetSupportedLocales()).toThrow( - 'getSupportedLocales called before configuring i18n. Call configure with messages first.' - ); + 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']); + }); }); }); From 729b314a765e3672424a57fb9e998ae453140063 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Date: Tue, 7 Oct 2025 13:40:06 -0500 Subject: [PATCH 9/9] fix: remove client-side cookie setting to prevent duplication --- src/i18n/languageManager.js | 7 +------ src/i18n/languageManager.test.js | 20 +------------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/src/i18n/languageManager.js b/src/i18n/languageManager.js index 374b978bb..d5e200826 100644 --- a/src/i18n/languageManager.js +++ b/src/i18n/languageManager.js @@ -1,5 +1,4 @@ -import { getConfig } from '../config'; -import { getCookies, handleRtl, LOCALE_CHANGED } from './lib'; +import { handleRtl, LOCALE_CHANGED } from './lib'; import { publish } from '../pubSub'; import { logError } from '../logging'; import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi'; @@ -23,10 +22,6 @@ export async function changeUserSessionLanguage( languageCode, forceReload = false, ) { - const cookies = getCookies(); - const cookieName = getConfig().LANGUAGE_PREFERENCE_COOKIE_NAME; - cookies.set(cookieName, languageCode); - try { await updateAuthenticatedUserPreferences({ prefLang: languageCode }); await setSessionLanguage(languageCode); diff --git a/src/i18n/languageManager.test.js b/src/i18n/languageManager.test.js index ee297d8fd..9008c7086 100644 --- a/src/i18n/languageManager.test.js +++ b/src/i18n/languageManager.test.js @@ -1,32 +1,19 @@ import { changeUserSessionLanguage } from './languageManager'; -import { getConfig } from '../config'; -import { getCookies, handleRtl, LOCALE_CHANGED } from './lib'; +import { handleRtl, LOCALE_CHANGED } from './lib'; import { logError } from '../logging'; import { publish } from '../pubSub'; import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi'; -jest.mock('../config'); jest.mock('./lib'); jest.mock('../logging'); jest.mock('../pubSub'); jest.mock('./languageApi'); -const LMS_BASE_URL = 'http://test.lms'; -const LANGUAGE_PREFERENCE_COOKIE_NAME = 'lang'; - describe('languageManager', () => { - let mockCookies; let mockReload; beforeEach(() => { jest.clearAllMocks(); - getConfig.mockReturnValue({ - LMS_BASE_URL, - LANGUAGE_PREFERENCE_COOKIE_NAME, - }); - - mockCookies = { set: jest.fn() }; - getCookies.mockReturnValue(mockCookies); mockReload = jest.fn(); Object.defineProperty(window, 'location', { @@ -42,11 +29,6 @@ describe('languageManager', () => { describe('changeUserSessionLanguage', () => { it('should perform complete language change process', async () => { await changeUserSessionLanguage('fr'); - - expect(getCookies().set).toHaveBeenCalledWith( - LANGUAGE_PREFERENCE_COOKIE_NAME, - 'fr', - ); expect(updateAuthenticatedUserPreferences).toHaveBeenCalledWith({ prefLang: 'fr', });