diff --git a/src/i18n/index.js b/src/i18n/index.js index 7ff12e4f5..2e0d306fa 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -102,6 +102,7 @@ export { getPrimaryLanguageSubtag, getLocale, getMessages, + getSupportedLocaleList, isRtl, handleRtl, mergeMessages, @@ -122,3 +123,7 @@ export { getLanguageList, getLanguageMessages, } from './languages'; + +export { + changeUserSessionLanguage, +} from './languageManager'; diff --git a/src/i18n/languageApi.js b/src/i18n/languageApi.js new file mode 100644 index 000000000..ea1c43438 --- /dev/null +++ b/src/i18n/languageApi.js @@ -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/`, + formData, + { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }, + ); +} diff --git a/src/i18n/languageApi.test.js b/src/i18n/languageApi.test.js new file mode 100644 index 000000000..3e64c0e83 --- /dev/null +++ b/src/i18n/languageApi.test.js @@ -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) }), + ); + }); + }); +}); diff --git a/src/i18n/languageManager.js b/src/i18n/languageManager.js new file mode 100644 index 000000000..d5e200826 --- /dev/null +++ b/src/i18n/languageManager.js @@ -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(); + } +} diff --git a/src/i18n/languageManager.test.js b/src/i18n/languageManager.test.js new file mode 100644 index 000000000..9008c7086 --- /dev/null +++ b/src/i18n/languageManager.test.js @@ -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(); + }); + }); +}); diff --git a/src/i18n/lib.js b/src/i18n/lib.js index 84b3b00a7..4f6f5dcc1 100644 --- a/src/i18n/lib.js +++ b/src/i18n/lib.js @@ -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. * diff --git a/src/i18n/lib.test.js b/src/i18n/lib.test.js index 75b82ec9f..07c1ce216 100644 --- a/src/i18n/lib.test.js +++ b/src/i18n/lib.test.js @@ -4,6 +4,7 @@ import { getPrimaryLanguageSubtag, getLocale, getMessages, + getSupportedLocaleList, isRtl, handleRtl, getCookies, @@ -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);