Skip to content

Commit 6d627d2

Browse files
andrey-canonAng-m4
andauthored
feat(i18n): add language preference management functionality (#14)
* feat(i18n): add language preference management functionality * fix: force page reload to ensure complete translation application * feat: conditional page reloading and module rename * refactor(i18n): move language preference functions to languageApi module * test(i18n): add unit tests for languageApi and languageManager functions * feat(i18n): add getSupportedLocales function * feat(i18n): update user preferences function * feat(i18n): update getSupportedLocaleList function --------- Co-authored-by: Andres Felipe Giraldo Malagon <[email protected]>
1 parent b35dc20 commit 6d627d2

File tree

7 files changed

+288
-0
lines changed

7 files changed

+288
-0
lines changed

src/i18n/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export {
102102
getPrimaryLanguageSubtag,
103103
getLocale,
104104
getMessages,
105+
getSupportedLocaleList,
105106
isRtl,
106107
handleRtl,
107108
mergeMessages,
@@ -122,3 +123,7 @@ export {
122123
getLanguageList,
123124
getLanguageMessages,
124125
} from './languages';
126+
127+
export {
128+
changeUserSessionLanguage,
129+
} from './languageManager';

src/i18n/languageApi.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { getConfig } from '../config';
2+
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '../auth';
3+
import { convertKeyNames, snakeCaseObject } from '../utils';
4+
5+
/**
6+
* Updates user language preferences via the preferences API.
7+
*
8+
* This function gets the authenticated user, converts preference data to snake_case
9+
* and formats specific keys according to backend requirements before sending the PATCH request.
10+
* If no user is authenticated, the function returns early without making the API call.
11+
*
12+
* @param {Object} preferenceData - The preference parameters to update (e.g., { prefLang: 'en' }).
13+
* @returns {Promise} - A promise that resolves when the API call completes successfully,
14+
* or rejects if there's an error with the request. Returns early if no user is authenticated.
15+
*/
16+
export async function updateAuthenticatedUserPreferences(preferenceData) {
17+
const user = getAuthenticatedUser();
18+
if (!user) {
19+
return Promise.resolve();
20+
}
21+
22+
const snakeCaseData = snakeCaseObject(preferenceData);
23+
const formattedData = convertKeyNames(snakeCaseData, {
24+
pref_lang: 'pref-lang',
25+
});
26+
27+
return getAuthenticatedHttpClient().patch(
28+
`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${user.username}`,
29+
formattedData,
30+
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
31+
);
32+
}
33+
34+
/**
35+
* Sets the language for the current session using the setlang endpoint.
36+
*
37+
* This function sends a POST request to the LMS setlang endpoint to change
38+
* the language for the current user session.
39+
*
40+
* @param {string} languageCode - The language code to set (e.g., 'en', 'es', 'ar').
41+
* Should be a valid ISO language code supported by the platform.
42+
* @returns {Promise} - A promise that resolves when the API call completes successfully,
43+
* or rejects if there's an error with the request.
44+
*/
45+
export async function setSessionLanguage(languageCode) {
46+
const formData = new FormData();
47+
formData.append('language', languageCode);
48+
49+
return getAuthenticatedHttpClient().post(
50+
`${getConfig().LMS_BASE_URL}/i18n/setlang/`,
51+
formData,
52+
{
53+
headers: {
54+
Accept: 'application/json',
55+
'X-Requested-With': 'XMLHttpRequest',
56+
},
57+
},
58+
);
59+
}

src/i18n/languageApi.test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi';
2+
import { getConfig } from '../config';
3+
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '../auth';
4+
5+
jest.mock('../config');
6+
jest.mock('../auth');
7+
8+
const LMS_BASE_URL = 'http://test.lms';
9+
10+
describe('languageApi', () => {
11+
beforeEach(() => {
12+
jest.clearAllMocks();
13+
getConfig.mockReturnValue({ LMS_BASE_URL });
14+
getAuthenticatedUser.mockReturnValue({ username: 'testuser', userId: '123' });
15+
});
16+
17+
describe('updateAuthenticatedUserPreferences', () => {
18+
it('should send a PATCH request with correct data', async () => {
19+
const patchMock = jest.fn().mockResolvedValue({});
20+
getAuthenticatedHttpClient.mockReturnValue({ patch: patchMock });
21+
22+
await updateAuthenticatedUserPreferences({ prefLang: 'es' });
23+
24+
expect(patchMock).toHaveBeenCalledWith(
25+
`${LMS_BASE_URL}/api/user/v1/preferences/testuser`,
26+
expect.any(Object),
27+
expect.objectContaining({ headers: expect.any(Object) }),
28+
);
29+
});
30+
31+
it('should return early if no authenticated user', async () => {
32+
const patchMock = jest.fn().mockResolvedValue({});
33+
getAuthenticatedHttpClient.mockReturnValue({ patch: patchMock });
34+
getAuthenticatedUser.mockReturnValue(null);
35+
36+
await updateAuthenticatedUserPreferences({ prefLang: 'es' });
37+
38+
expect(patchMock).not.toHaveBeenCalled();
39+
});
40+
});
41+
42+
describe('setSessionLanguage', () => {
43+
it('should send a POST request to setlang endpoint', async () => {
44+
const postMock = jest.fn().mockResolvedValue({});
45+
getAuthenticatedHttpClient.mockReturnValue({ post: postMock });
46+
47+
await setSessionLanguage('ar');
48+
49+
expect(postMock).toHaveBeenCalledWith(
50+
`${LMS_BASE_URL}/i18n/setlang/`,
51+
expect.any(FormData),
52+
expect.objectContaining({ headers: expect.any(Object) }),
53+
);
54+
});
55+
});
56+
});

src/i18n/languageManager.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { getConfig } from '../config';
2+
import { getCookies, handleRtl, LOCALE_CHANGED } from './lib';
3+
import { publish } from '../pubSub';
4+
import { logError } from '../logging';
5+
import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi';
6+
7+
/**
8+
* Changes the user's language preference and applies it to the current session.
9+
*
10+
* This comprehensive function handles the complete language change process:
11+
* 1. Sets the language cookie with the selected language code
12+
* 2. If a user is authenticated, updates their server-side preference in the backend
13+
* 3. Updates the session language through the setlang endpoint
14+
* 4. Publishes a locale change event to notify other parts of the application
15+
*
16+
* @param {string} languageCode - The selected language locale code (e.g., 'en', 'es', 'ar').
17+
* Should be a valid ISO language code supported by the platform.
18+
* @param {boolean} [forceReload=false] - Whether to force a page reload after changing the language.
19+
* @returns {Promise} - A promise that resolves when all operations complete.
20+
*
21+
*/
22+
export async function changeUserSessionLanguage(
23+
languageCode,
24+
forceReload = false,
25+
) {
26+
const cookies = getCookies();
27+
const cookieName = getConfig().LANGUAGE_PREFERENCE_COOKIE_NAME;
28+
cookies.set(cookieName, languageCode);
29+
30+
try {
31+
await updateAuthenticatedUserPreferences({ prefLang: languageCode });
32+
await setSessionLanguage(languageCode);
33+
handleRtl(languageCode);
34+
publish(LOCALE_CHANGED, languageCode);
35+
} catch (error) {
36+
logError(error);
37+
}
38+
39+
if (forceReload) {
40+
window.location.reload();
41+
}
42+
}

src/i18n/languageManager.test.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { changeUserSessionLanguage } from './languageManager';
2+
import { getConfig } from '../config';
3+
import { getCookies, handleRtl, LOCALE_CHANGED } from './lib';
4+
import { logError } from '../logging';
5+
import { publish } from '../pubSub';
6+
import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi';
7+
8+
jest.mock('../config');
9+
jest.mock('./lib');
10+
jest.mock('../logging');
11+
jest.mock('../pubSub');
12+
jest.mock('./languageApi');
13+
14+
const LMS_BASE_URL = 'http://test.lms';
15+
const LANGUAGE_PREFERENCE_COOKIE_NAME = 'lang';
16+
17+
describe('languageManager', () => {
18+
let mockCookies;
19+
let mockReload;
20+
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
getConfig.mockReturnValue({
24+
LMS_BASE_URL,
25+
LANGUAGE_PREFERENCE_COOKIE_NAME,
26+
});
27+
28+
mockCookies = { set: jest.fn() };
29+
getCookies.mockReturnValue(mockCookies);
30+
31+
mockReload = jest.fn();
32+
Object.defineProperty(window, 'location', {
33+
configurable: true,
34+
writable: true,
35+
value: { reload: mockReload },
36+
});
37+
38+
updateAuthenticatedUserPreferences.mockResolvedValue({});
39+
setSessionLanguage.mockResolvedValue({});
40+
});
41+
42+
describe('changeUserSessionLanguage', () => {
43+
it('should perform complete language change process', async () => {
44+
await changeUserSessionLanguage('fr');
45+
46+
expect(getCookies().set).toHaveBeenCalledWith(
47+
LANGUAGE_PREFERENCE_COOKIE_NAME,
48+
'fr',
49+
);
50+
expect(updateAuthenticatedUserPreferences).toHaveBeenCalledWith({
51+
prefLang: 'fr',
52+
});
53+
expect(setSessionLanguage).toHaveBeenCalledWith('fr');
54+
expect(handleRtl).toHaveBeenCalledWith('fr');
55+
expect(publish).toHaveBeenCalledWith(LOCALE_CHANGED, 'fr');
56+
expect(mockReload).not.toHaveBeenCalled();
57+
});
58+
59+
it('should handle errors gracefully', async () => {
60+
updateAuthenticatedUserPreferences.mockRejectedValue(new Error('fail'));
61+
await changeUserSessionLanguage('es', true);
62+
expect(logError).toHaveBeenCalled();
63+
});
64+
65+
it('should call updateAuthenticatedUserPreferences even when user is not authenticated', async () => {
66+
await changeUserSessionLanguage('en', true);
67+
expect(updateAuthenticatedUserPreferences).toHaveBeenCalledWith({
68+
prefLang: 'en',
69+
});
70+
});
71+
72+
it('should reload if forceReload is true', async () => {
73+
await changeUserSessionLanguage('de', true);
74+
expect(mockReload).toHaveBeenCalled();
75+
});
76+
});
77+
});

src/i18n/lib.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,28 @@ export function getMessages(locale = getLocale()) {
181181
return messages[locale];
182182
}
183183

184+
/**
185+
* Returns the list of supported locales based on the configured messages.
186+
* This list is dynamically generated from the translation messages that were
187+
* provided during i18n configuration. Always includes the current locale.
188+
*
189+
* @throws An error if i18n has not yet been configured.
190+
* @returns {string[]} Array of supported locale codes
191+
* @memberof module:Internationalization
192+
*/
193+
export function getSupportedLocaleList() {
194+
if (messages === null) {
195+
throw new Error('getSupportedLocaleList called before configuring i18n. Call configure with messages first.');
196+
}
197+
198+
const locales = Object.keys(messages);
199+
if (!locales.includes('en')) {
200+
locales.push('en');
201+
}
202+
203+
return locales;
204+
}
205+
184206
/**
185207
* Determines if the provided locale is a right-to-left language.
186208
*

src/i18n/lib.test.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getPrimaryLanguageSubtag,
55
getLocale,
66
getMessages,
7+
getSupportedLocaleList,
78
isRtl,
89
handleRtl,
910
getCookies,
@@ -183,6 +184,32 @@ describe('lib', () => {
183184
});
184185
});
185186

187+
describe('getSupportedLocales', () => {
188+
describe('when configured', () => {
189+
beforeEach(() => {
190+
configure({
191+
loggingService: { logError: jest.fn() },
192+
config: {
193+
ENVIRONMENT: 'production',
194+
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
195+
},
196+
messages: {
197+
'es-419': { message: 'es-hah' },
198+
de: { message: 'de-hah' },
199+
'en-us': { message: 'en-us-hah' },
200+
fr: { message: 'fr-hah' },
201+
},
202+
});
203+
});
204+
205+
it('should return an array of supported locale codes', () => {
206+
const supportedLocales = getSupportedLocaleList();
207+
expect(Array.isArray(supportedLocales)).toBe(true);
208+
expect(supportedLocales).toEqual(['es-419', 'de', 'en-us', 'fr', 'en']);
209+
});
210+
});
211+
});
212+
186213
describe('isRtl', () => {
187214
it('should be true for RTL languages', () => {
188215
expect(isRtl('ar')).toBe(true);

0 commit comments

Comments
 (0)