From a58e9038fcf7d0248b523bdce8df43b0ee887c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrey=20Ca=C3=B1on?= <36200299+andrey-canon@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:52:05 -0500 Subject: [PATCH 1/3] feat: trigger Jira issue automation on PR merge event --- .github/workflows/create-jira-issue.yml | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/create-jira-issue.yml diff --git a/.github/workflows/create-jira-issue.yml b/.github/workflows/create-jira-issue.yml new file mode 100644 index 000000000..20eeb6ba8 --- /dev/null +++ b/.github/workflows/create-jira-issue.yml @@ -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 }} From 52d2b8a0c61578365f7e872e0a0e567ff9b04994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrey=20Ca=C3=B1on?= <36200299+andrey-canon@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:58:19 -0500 Subject: [PATCH 2/3] feat: disable action fail on coverage error --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10a71d442..da2102da1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} From b1c7daa7a720df105e573cf54f2aee2cc0ec778c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrey=20Ca=C3=B1on?= <36200299+andrey-canon@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:33:00 -0500 Subject: [PATCH 3/3] feat(i18n): add language preference management functionality * 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 --- src/i18n/index.ts | 5 +++ src/i18n/languageApi.js | 59 ++++++++++++++++++++++++ src/i18n/languageApi.test.js | 56 +++++++++++++++++++++++ src/i18n/languageManager.js | 42 +++++++++++++++++ src/i18n/languageManager.test.js | 77 ++++++++++++++++++++++++++++++++ src/i18n/lib.js | 22 +++++++++ src/i18n/lib.test.js | 27 +++++++++++ 7 files changed, 288 insertions(+) create mode 100644 src/i18n/languageApi.js create mode 100644 src/i18n/languageApi.test.js create mode 100644 src/i18n/languageManager.js create mode 100644 src/i18n/languageManager.test.js diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 47487966c..8207117b6 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -45,6 +45,7 @@ export { getPrimaryLanguageSubtag, getLocale, getMessages, + getSupportedLocaleList, isRtl, handleRtl, mergeMessages, @@ -65,3 +66,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..ea1c43438 --- /dev/null +++ b/src/i18n/languageApi.js @@ -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', + }, + }, + ); +} diff --git a/src/i18n/languageApi.test.js b/src/i18n/languageApi.test.js new file mode 100644 index 000000000..3e64c0e83 --- /dev/null +++ b/src/i18n/languageApi.test.js @@ -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) }), + ); + }); + }); +}); diff --git a/src/i18n/languageManager.js b/src/i18n/languageManager.js new file mode 100644 index 000000000..374b978bb --- /dev/null +++ b/src/i18n/languageManager.js @@ -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(); + } +} diff --git a/src/i18n/languageManager.test.js b/src/i18n/languageManager.test.js new file mode 100644 index 000000000..ee297d8fd --- /dev/null +++ b/src/i18n/languageManager.test.js @@ -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(); + }); + }); +}); diff --git a/src/i18n/lib.js b/src/i18n/lib.js index eb0f1dfe8..111c2c619 100644 --- a/src/i18n/lib.js +++ b/src/i18n/lib.js @@ -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. * diff --git a/src/i18n/lib.test.js b/src/i18n/lib.test.js index 75b82ec9f..07c1ce216 100644 --- a/src/i18n/lib.test.js +++ b/src/i18n/lib.test.js @@ -4,6 +4,7 @@ import { getPrimaryLanguageSubtag, getLocale, getMessages, + getSupportedLocaleList, isRtl, handleRtl, getCookies, @@ -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);