Skip to content

Commit 5c79c4e

Browse files
committed
refactor: implement typescript
1 parent 729b314 commit 5c79c4e

File tree

7 files changed

+1064
-0
lines changed

7 files changed

+1064
-0
lines changed

src/i18n/index.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* #### Import members from **@edx/frontend-platform/i18n**
3+
* The i18n module relies on react-intl and re-exports all of that package's exports.
4+
*
5+
* For each locale we want to support, react-intl needs 1) the locale-data, which includes
6+
* information about how to format numbers, handle plurals, etc., and 2) the translations, as an
7+
* object holding message id / translated string pairs. A locale string and the messages object are
8+
* passed into the IntlProvider element that wraps your element hierarchy.
9+
*
10+
* Note that react-intl has no way of checking if the translations you give it actually have
11+
* anything to do with the locale you pass it; it will happily use whatever messages object you pass
12+
* in. However, if the locale data for the locale you passed into the IntlProvider was not
13+
* correctly installed with addLocaleData, all of your translations will fall back to the default
14+
* (in our case English), *even if you gave IntlProvider the correct messages object for that
15+
* locale*.
16+
*
17+
* Messages are provided to this module via the configure() function below.
18+
*
19+
*
20+
* @module Internationalization
21+
* @see {@link https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst}
22+
* @see {@link https://formatjs.io/docs/react-intl/components/ Intl} for components exported from this module.
23+
*
24+
*/
25+
26+
/**
27+
* @name createIntl
28+
* @kind function
29+
* @see {@link https://formatjs.io/docs/react-intl/api#createIntl Intl}
30+
*/
31+
32+
/**
33+
* @name FormattedDate
34+
* @kind class
35+
* @see {@link https://formatjs.io/docs/react-intl/components/#formatteddate Intl}
36+
*/
37+
38+
/**
39+
* @name FormattedTime
40+
* @kind class
41+
* @see {@link https://formatjs.io/docs/react-intl/components/#formattedtime Intl}
42+
*/
43+
44+
/**
45+
* @name FormattedRelativeTime
46+
* @kind class
47+
* @see {@link https://formatjs.io/docs/react-intl/components/#formattedrelativetime Intl}
48+
*/
49+
50+
/**
51+
* @name FormattedNumber
52+
* @kind class
53+
* @see {@link https://formatjs.io/docs/react-intl/components/#formattednumber Intl}
54+
*/
55+
56+
/**
57+
* @name FormattedPlural
58+
* @kind class
59+
* @see {@link https://formatjs.io/docs/react-intl/components/#formattedplural Intl}
60+
*/
61+
62+
/**
63+
* @name FormattedMessage
64+
* @kind class
65+
* @see {@link https://formatjs.io/docs/react-intl/components/#formattedmessage Intl}
66+
*/
67+
68+
/**
69+
* @name IntlProvider
70+
* @kind class
71+
* @see {@link https://formatjs.io/docs/react-intl/components/#intlprovider Intl}
72+
*/
73+
74+
/**
75+
* @name defineMessages
76+
* @kind function
77+
* @see {@link https://formatjs.io/docs/react-intl/api#definemessagesdefinemessage Intl}
78+
*/
79+
80+
/**
81+
* @name useIntl
82+
* @kind function
83+
* @see {@link https://formatjs.io/docs/react-intl/api#useIntl Intl}
84+
*/
85+
86+
export {
87+
createIntl,
88+
FormattedDate,
89+
FormattedTime,
90+
FormattedRelativeTime,
91+
FormattedNumber,
92+
FormattedPlural,
93+
FormattedMessage,
94+
defineMessages,
95+
IntlProvider,
96+
useIntl,
97+
} from 'react-intl';
98+
99+
export {
100+
intlShape,
101+
configure,
102+
getPrimaryLanguageSubtag,
103+
getLocale,
104+
getMessages,
105+
getSupportedLocaleList,
106+
isRtl,
107+
handleRtl,
108+
mergeMessages,
109+
LOCALE_CHANGED,
110+
LOCALE_TOPIC,
111+
} from './lib';
112+
113+
export {
114+
default as injectIntl,
115+
} from './injectIntlWithShim';
116+
117+
export {
118+
getCountryList,
119+
getCountryMessages,
120+
} from './countries';
121+
122+
export {
123+
getLanguageList,
124+
getLanguageMessages,
125+
} from './languages';
126+
127+
export {
128+
changeUserSessionLanguage,
129+
} from './languageManager';

src/i18n/languageApi.test.ts

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 as jest.Mock).mockReturnValue({ LMS_BASE_URL });
14+
(getAuthenticatedUser as jest.Mock).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 as jest.Mock).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 as jest.Mock).mockReturnValue({ patch: patchMock });
34+
(getAuthenticatedUser as jest.Mock).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 as jest.Mock).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/languageApi.ts

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

src/i18n/languageManager.test.ts

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

src/i18n/languageManager.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { handleRtl, LOCALE_CHANGED } from './lib';
2+
import { publish } from '../pubSub';
3+
import { logError } from '../logging';
4+
import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi';
5+
6+
/**
7+
* Changes the user's language preference and applies it to the current session.
8+
*
9+
* This comprehensive function handles the complete language change process:
10+
* 1. Sets the language cookie with the selected language code
11+
* 2. If a user is authenticated, updates their server-side preference in the backend
12+
* 3. Updates the session language through the setlang endpoint
13+
* 4. Publishes a locale change event to notify other parts of the application
14+
*
15+
* @param {string} languageCode - The selected language locale code (e.g., 'en', 'es-419', 'ar', 'de-de').
16+
* Should be a valid ISO language code supported by the platform. For reference:
17+
* https://github.com/openedx/openedx-platform/blob/master/openedx/envs/common.py#L231
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: string,
24+
forceReload: boolean = false,
25+
): Promise<void> {
26+
try {
27+
await updateAuthenticatedUserPreferences({ prefLang: languageCode });
28+
await setSessionLanguage(languageCode);
29+
handleRtl();
30+
publish(LOCALE_CHANGED, languageCode);
31+
} catch (error: any) {
32+
logError(error);
33+
}
34+
35+
if (forceReload) {
36+
window.location.reload();
37+
}
38+
}

0 commit comments

Comments
 (0)