diff --git a/src/i18n/index.js b/src/i18n/index.js index 7ff12e4f5..64d2966da 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -122,3 +122,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..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/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.js b/src/i18n/languageManager.js new file mode 100644 index 000000000..35d9893d9 --- /dev/null +++ b/src/i18n/languageManager.js @@ -0,0 +1,47 @@ +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'; + +/** + * 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, +) { + 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); + } + + 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..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(); + }); + }); +});