Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@ jobs:
- name: Run Coverage
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
fail_ci_if_error: true
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
34 changes: 34 additions & 0 deletions .github/workflows/create-jira-issue.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Trigger Jira Issue Creation

on:
pull_request:
types:
- closed

jobs:
prepare:
runs-on: ubuntu-latest
environment:
name: ${{ github.event.pull_request.base.ref }}
outputs:
story_summary: ${{ vars.MIGRATION_BACKLOG_JIRA_STORY_SUMMARY }}
story_description: ${{ vars.MIGRATION_BACKLOG_JIRA_STORY_DESCRIPTION }}
steps:
- run: echo "Preparing input vars..."
create-jira-issue:
needs: prepare
if: github.event.pull_request.merged == true
uses: nelc/actions-hub/.github/workflows/create-jira-issue.yml@main
with:
story_summary: ${{ needs.prepare.outputs.story_summary }}
story_description: ${{ needs.prepare.outputs.story_description }}
subtask_summary: "${{ github.event.pull_request.title }}"
subtask_description: "${{ github.event.pull_request.body }} 🔗 PR: ${{ github.event.pull_request.html_url }}"
environment: ${{ github.event.pull_request.base.ref }}
secrets:
MIGRATION_BACKLOG_JIRA_EMAIL: ${{ secrets.MIGRATION_BACKLOG_JIRA_EMAIL }}
MIGRATION_BACKLOG_JIRA_TOKEN: ${{ secrets.MIGRATION_BACKLOG_JIRA_TOKEN }}
MIGRATION_BACKLOG_JIRA_URL: ${{ secrets.MIGRATION_BACKLOG_JIRA_URL }}
MIGRATION_BACKLOG_JIRA_PROJECT: ${{ secrets.MIGRATION_BACKLOG_JIRA_PROJECT }}
MIGRATION_BACKLOG_JIRA_EPIC_KEY: ${{ secrets.MIGRATION_BACKLOG_JIRA_EPIC_KEY }}
MIGRATION_BACKLOG_JIRA_EPIC_LINK_FIELD: ${{ secrets.MIGRATION_BACKLOG_JIRA_EPIC_LINK_FIELD }}
5 changes: 5 additions & 0 deletions src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export {
getPrimaryLanguageSubtag,
getLocale,
getMessages,
getSupportedLocaleList,
isRtl,
handleRtl,
mergeMessages,
Expand All @@ -65,3 +66,7 @@ export {
getLanguageList,
getLanguageMessages,
} from './languages';

export {
changeUserSessionLanguage,
} from './languageManager';
59 changes: 59 additions & 0 deletions src/i18n/languageApi.js
Original file line number Diff line number Diff line change
@@ -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',
},
},
);
}
56 changes: 56 additions & 0 deletions src/i18n/languageApi.test.js
Original file line number Diff line number Diff line change
@@ -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) }),
);
});
});
});
42 changes: 42 additions & 0 deletions src/i18n/languageManager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { getConfig } from '../config';
import { getCookies, 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,
) {
const cookies = getCookies();
const cookieName = getConfig().LANGUAGE_PREFERENCE_COOKIE_NAME;
cookies.set(cookieName, languageCode);

try {
await updateAuthenticatedUserPreferences({ prefLang: languageCode });
await setSessionLanguage(languageCode);
handleRtl(languageCode);
publish(LOCALE_CHANGED, languageCode);
} catch (error) {
logError(error);
}

if (forceReload) {
window.location.reload();
}
}
77 changes: 77 additions & 0 deletions src/i18n/languageManager.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { changeUserSessionLanguage } from './languageManager';
import { getConfig } from '../config';
import { getCookies, handleRtl, LOCALE_CHANGED } from './lib';
import { logError } from '../logging';
import { publish } from '../pubSub';
import { updateAuthenticatedUserPreferences, setSessionLanguage } from './languageApi';

jest.mock('../config');
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 mockReload;

beforeEach(() => {
jest.clearAllMocks();
getConfig.mockReturnValue({
LMS_BASE_URL,
LANGUAGE_PREFERENCE_COOKIE_NAME,
});

mockCookies = { set: jest.fn() };
getCookies.mockReturnValue(mockCookies);

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(getCookies().set).toHaveBeenCalledWith(
LANGUAGE_PREFERENCE_COOKIE_NAME,
'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();
});
});
});
22 changes: 22 additions & 0 deletions src/i18n/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
27 changes: 27 additions & 0 deletions src/i18n/lib.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getPrimaryLanguageSubtag,
getLocale,
getMessages,
getSupportedLocaleList,
isRtl,
handleRtl,
getCookies,
Expand Down Expand Up @@ -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);
Expand Down
Loading