diff --git a/src/i18n/index.js b/src/i18n/index.ts similarity index 97% rename from src/i18n/index.js rename to src/i18n/index.ts index 7ff12e4f5..2e0d306fa 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.ts @@ -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.test.ts b/src/i18n/languageApi.test.ts new file mode 100644 index 000000000..dddc42d56 --- /dev/null +++ b/src/i18n/languageApi.test.ts @@ -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) }), + ); + }); + }); +}); diff --git a/src/i18n/languageApi.ts b/src/i18n/languageApi.ts new file mode 100644 index 000000000..747e9cc4e --- /dev/null +++ b/src/i18n/languageApi.ts @@ -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 { + 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 { + 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.test.ts b/src/i18n/languageManager.test.ts new file mode 100644 index 000000000..97d35343c --- /dev/null +++ b/src/i18n/languageManager.test.ts @@ -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(); + }); + }); +}); diff --git a/src/i18n/languageManager.ts b/src/i18n/languageManager.ts new file mode 100644 index 000000000..47e4e9a25 --- /dev/null +++ b/src/i18n/languageManager.ts @@ -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 { + try { + await updateAuthenticatedUserPreferences({ prefLang: languageCode }); + await setSessionLanguage(languageCode); + handleRtl(); + publish(LOCALE_CHANGED, languageCode); + } catch (error: any) { + logError(error); + } + + if (forceReload) { + window.location.reload(); + } +} diff --git a/src/i18n/lib.test.js b/src/i18n/lib.test.ts similarity index 89% rename from src/i18n/lib.test.js rename to src/i18n/lib.test.ts index 75b82ec9f..b9bbb92ac 100644 --- a/src/i18n/lib.test.js +++ b/src/i18n/lib.test.ts @@ -4,6 +4,7 @@ import { getPrimaryLanguageSubtag, getLocale, getMessages, + getSupportedLocaleList, isRtl, handleRtl, getCookies, @@ -14,7 +15,7 @@ jest.mock('universal-cookie'); describe('lib', () => { describe('configure', () => { - let originalWarn = null; + let originalWarn: typeof console.warn | null = null; beforeEach(() => { originalWarn = console.warn; @@ -22,7 +23,7 @@ describe('lib', () => { }); afterEach(() => { - console.warn = originalWarn; + console.warn = originalWarn!; }); it('should not call console.warn in production', () => { @@ -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']); + }); }); }); @@ -204,7 +231,7 @@ describe('lib', () => { }); describe('handleRtl', () => { - let setAttribute; + let setAttribute: jest.Mock; beforeEach(() => { setAttribute = jest.fn(); @@ -212,7 +239,7 @@ describe('lib', () => { { setAttribute, }, - ]); + ]) as any; }); it('should do the right thing for non-RTL languages', () => { @@ -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', @@ -304,7 +331,7 @@ describe('mergeMessages', () => { es: { init: 'inicial' }, }, }); - const messages = [ + const msgs: import('./lib').I18nMessages[] = [ { en: { hello: 'hello' }, es: { hello: 'hola' }, @@ -315,7 +342,7 @@ describe('mergeMessages', () => { }, ]; - const result = mergeMessages(messages); + const result = mergeMessages(msgs); expect(result).toEqual({ en: { init: 'initial', diff --git a/src/i18n/lib.js b/src/i18n/lib.ts similarity index 78% rename from src/i18n/lib.js rename to src/i18n/lib.ts index 84b3b00a7..483c57d23 100644 --- a/src/i18n/lib.js +++ b/src/i18n/lib.ts @@ -36,8 +36,29 @@ import '@formatjs/intl-relativetimeformat/locale-data/th'; import '@formatjs/intl-relativetimeformat/locale-data/uk'; import '@formatjs/intl-relativetimeformat/locale-data/vi'; +export interface LoggingService { + logError: (...args: any[]) => void; +} + +export interface I18nConfig { + ENVIRONMENT?: string; + LANGUAGE_PREFERENCE_COOKIE_NAME: string; + LMS_BASE_URL?: string; + [key: string]: any; +} + +export interface I18nMessages { + [locale: string]: Record; +} + +export interface ConfigureOptions { + config: I18nConfig; + loggingService: LoggingService; + messages: I18nMessages | I18nMessages[]; +} + const cookies = new Cookies(); -const supportedLocales = [ +const supportedLocales: string[] = [ 'ar', // Arabic // NOTE: 'en' is not included in this list intentionally, since it's the fallback. 'es-419', // Spanish, Latin American @@ -56,7 +77,7 @@ const supportedLocales = [ 'uk', // Ukrainian 'vi', // Vietnamese ]; -const rtlLocales = [ +const rtlLocales: string[] = [ 'ar', // Arabic 'he', // Hebrew 'fa', // Farsi (not currently supported) @@ -64,9 +85,9 @@ const rtlLocales = [ 'ur', // Urdu (not currently supported) ]; -let config = null; -let loggingService = null; -let messages = null; +let config: I18nConfig | null = null; +let loggingService: LoggingService | null = null; +let messages: I18nMessages | null = null; /** * @memberof module:Internationalization @@ -85,7 +106,7 @@ export const intlShape = PropTypes.object; * @ignore * @returns {LoggingService} */ -export const getLoggingService = () => loggingService; +export const getLoggingService = (): LoggingService | null => loggingService; /** * @memberof module:Internationalization @@ -102,7 +123,7 @@ export const LOCALE_CHANGED = `${LOCALE_TOPIC}.CHANGED`; * @memberof module:Internationalization * @returns {Cookies} */ -export function getCookies() { +export function getCookies(): Cookies { return cookies; } @@ -114,7 +135,7 @@ export function getCookies() { * @param {string} code * @memberof module:Internationalization */ -export function getPrimaryLanguageSubtag(code) { +export function getPrimaryLanguageSubtag(code: string): string { return code.split('-')[0]; } @@ -130,12 +151,12 @@ export function getPrimaryLanguageSubtag(code) { * @returns {string} * @memberof module:Internationalization */ -export function findSupportedLocale(locale) { - if (messages[locale] !== undefined) { +export function findSupportedLocale(locale: string): string { + if (messages![locale] !== undefined) { return locale; } - if (messages[getPrimaryLanguageSubtag(locale)] !== undefined) { + if (messages![getPrimaryLanguageSubtag(locale)] !== undefined) { return getPrimaryLanguageSubtag(locale); } @@ -152,7 +173,7 @@ export function findSupportedLocale(locale) { * @returns {string} * @memberof module:Internationalization */ -export function getLocale(locale) { +export function getLocale(locale?: string): string { if (messages === null) { throw new Error('getLocale called before configuring i18n. Call configure with messages first.'); } @@ -162,7 +183,7 @@ export function getLocale(locale) { } // 2. User setting in cookie const cookieLangPref = cookies - .get(config.LANGUAGE_PREFERENCE_COOKIE_NAME); + .get(config!.LANGUAGE_PREFERENCE_COOKIE_NAME); if (cookieLangPref) { return findSupportedLocale(cookieLangPref.toLowerCase()); } @@ -180,8 +201,30 @@ export function getLocale(locale) { * @param {string} [locale=getLocale()] * @memberof module:Internationalization */ -export function getMessages(locale = getLocale()) { - return messages[locale]; +export function getMessages(locale: string = getLocale()): Record | undefined { + 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(): string[] { + 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; } /** @@ -190,7 +233,7 @@ export function getMessages(locale = getLocale()) { * @param {string} locale * @memberof module:Internationalization */ -export function isRtl(locale) { +export function isRtl(locale: string): boolean { return rtlLocales.includes(locale); } @@ -200,7 +243,7 @@ export function isRtl(locale) { * * @memberof module:Internationalization */ -export function handleRtl() { +export function handleRtl(): void { if (isRtl(getLocale())) { globalThis.document.getElementsByTagName('html')[0].setAttribute('dir', 'rtl'); } else { @@ -244,11 +287,11 @@ const optionsShape = { * @returns {Object} * @memberof module:Internationalization */ -export function mergeMessages(newMessages) { +export function mergeMessages(newMessages: I18nMessages | I18nMessages[] | null | undefined): I18nMessages { const msgs = Array.isArray(newMessages) ? merge({}, ...newMessages) : newMessages; messages = merge(messages, msgs); - return messages; + return messages as I18nMessages; } /** @@ -257,13 +300,10 @@ export function mergeMessages(newMessages) { * Logs a warning if it detects a locale it doesn't expect (as defined by the supportedLocales list * above), or if an expected locale is not provided. * - * @param {Object} options - * @param {LoggingService} options.loggingService - * @param {Object} options.config - * @param {Object} options.messages + * @param {ConfigureOptions} options * @memberof module:Internationalization */ -export function configure(options) { +export function configure(options: ConfigureOptions): void { PropTypes.checkPropTypes(optionsShape, options, 'property', 'i18n'); // eslint-disable-next-line prefer-destructuring loggingService = options.loggingService; @@ -272,14 +312,14 @@ export function configure(options) { messages = Array.isArray(options.messages) ? merge({}, ...options.messages) : options.messages; if (config.ENVIRONMENT !== 'production') { - Object.keys(messages).forEach((key) => { + Object.keys(messages!).forEach((key) => { if (supportedLocales.indexOf(key) < 0) { console.warn(`Unexpected locale: ${key}`); // eslint-disable-line no-console } }); supportedLocales.forEach((key) => { - if (messages[key] === undefined) { + if (messages![key] === undefined) { console.warn(`Missing locale: ${key}`); // eslint-disable-line no-console } });