From 7587f09d3c941a2da9e2e8c6c26a4401b28e55b2 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Malagon Date: Wed, 2 Apr 2025 09:32:43 -0500 Subject: [PATCH 1/5] feat: add language selector component to header --- src/index.scss | 1 + src/language-selector/LanguageSelector.jsx | 100 ++++++ src/language-selector/LanguageSelector.scss | 22 ++ .../LanguageSelector.test.jsx | 126 +++++++ .../LanguageSelector.test.jsx.snap | 311 ++++++++++++++++++ src/language-selector/index.js | 3 + src/learning-header/LearningHeader.jsx | 2 + src/studio-header/HeaderBody.tsx | 3 + 8 files changed, 568 insertions(+) create mode 100644 src/language-selector/LanguageSelector.jsx create mode 100644 src/language-selector/LanguageSelector.scss create mode 100644 src/language-selector/LanguageSelector.test.jsx create mode 100644 src/language-selector/__snapshots__/LanguageSelector.test.jsx.snap create mode 100644 src/language-selector/index.js diff --git a/src/index.scss b/src/index.scss index 1094eb87cc..53edbe66a7 100644 --- a/src/index.scss +++ b/src/index.scss @@ -7,6 +7,7 @@ $rounded-pill: 50rem !default; @import './Menu/menu.scss'; @import './studio-header/StudioHeader.scss'; +@import './language-selector/LanguageSelector.scss'; .dropdown-item a { text-decoration: none; diff --git a/src/language-selector/LanguageSelector.jsx b/src/language-selector/LanguageSelector.jsx new file mode 100644 index 0000000000..84bfe672fb --- /dev/null +++ b/src/language-selector/LanguageSelector.jsx @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { useContext } from 'react'; + +import { changeUserSessionLanguage, getPrimaryLanguageSubtag, injectIntl } from '@edx/frontend-platform/i18n'; +import { getCookies } from '@edx/frontend-platform/i18n/lib'; +import { AppContext } from '@edx/frontend-platform/react'; +import { Dropdown } from '@openedx/paragon'; +import { Language } from '@openedx/paragon/icons'; + +/** + * Gets the localized display name of a language in its own language. + * + * @function getDisplayName + * @param {string} locale - The locale code (e.g., 'en', 'es', 'ar') + * @returns {string} The capitalized display name of the language in its native form + * @example + */ +const getDisplayName = (locale) => { + const langName = new Intl.DisplayNames([locale], { type: 'language', languageDisplay: 'standard' }).of(locale); + return langName.charAt(0).toUpperCase() + langName.slice(1); +}; + +/** + * Language Selector component that displays a dropdown allowing users to change the site language. + * + * The component is responsive and adapts to different screen sizes: + * - On large screens: Shows the full language name (e.g., "English") + * - On medium screens: Shows the language code (e.g., "EN") + * - On small screens: Shows only the language icon + * + * @component + * @param {Object} props - Component props + * @param {string} [props.className=''] - Additional CSS class names to apply to the component + * @returns {React.Element|null} The rendered component or null if disabled or no supported languages + * + * @requires config.SITE_SUPPORTED_LANGUAGES - Must be a non-empty array of locale codes + * @requires config.LANGUAGE_PREFERENCE_COOKIE_NAME - Cookie name for storing language preference + */ +const LanguageSelector = ({ className }) => { + const { config } = useContext(AppContext); + const cookies = getCookies(); + + const languageOptions = config.SITE_SUPPORTED_LANGUAGES; + const langCookieName = config.LANGUAGE_PREFERENCE_COOKIE_NAME; + const currentLocale = cookies.get(langCookieName) || 'en'; + + /** + * Handles the selection of a language from the dropdown. + * Only triggers language change if the selected language is different from the current one. + * + * @param {string} selectedLocale - The locale code selected by the user + */ + const handleSelect = (selectedLocale) => { + if (currentLocale !== selectedLocale) { + changeUserSessionLanguage(selectedLocale); + } + }; + + const currentLangCode = getPrimaryLanguageSubtag(currentLocale).toUpperCase(); + const currentlangDisplayName = getDisplayName(currentLocale); + + // Don't render the component if there are no language options + if (!Array.isArray(languageOptions) + || languageOptions.length === 0) { + return null; + } + + return ( +
+ + + {currentLangCode} + {currentlangDisplayName} + + + {languageOptions.map((locale) => ( + + {getDisplayName(locale)} + + ))} + + +
+ ); +}; + +LanguageSelector.propTypes = { + className: PropTypes.string, +}; + +LanguageSelector.defaultProps = { + className: '', +}; + +export default injectIntl(LanguageSelector); diff --git a/src/language-selector/LanguageSelector.scss b/src/language-selector/LanguageSelector.scss new file mode 100644 index 0000000000..fb7bb311a6 --- /dev/null +++ b/src/language-selector/LanguageSelector.scss @@ -0,0 +1,22 @@ +.language-selector { + padding: .75rem; + + .dropdown-toggle { + .lang-label-medium, + .lang-label-large { + display: none; + } + + @media (min-width: 576px) and (max-width: 767px) { + .lang-label-medium { + display: inline; + } + } + + @media (min-width: 768px) { + .lang-label-large { + display: inline; + } + } + } +} \ No newline at end of file diff --git a/src/language-selector/LanguageSelector.test.jsx b/src/language-selector/LanguageSelector.test.jsx new file mode 100644 index 0000000000..8f89f051db --- /dev/null +++ b/src/language-selector/LanguageSelector.test.jsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { mergeConfig } from '@edx/frontend-platform'; +import { getCookies } from '@edx/frontend-platform/i18n/lib'; +import { changeUserSessionLanguage } from '@edx/frontend-platform/i18n'; +import { + act, fireEvent, initializeMockApp, render, screen, +} from '../setupTest'; +import LanguageSelector from './LanguageSelector'; + +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + changeUserSessionLanguage: jest.fn().mockResolvedValue({}), +})); + +jest.mock('@openedx/paragon/icons', () => ({ + Language: () =>
LanguageIcon
, +})); + +jest.mock('@openedx/paragon', () => ({ + ...jest.requireActual('@openedx/paragon'), + useWindowSize: () => ({ width: global.innerWidth }), +})); + +const LANGUAGE_PREFERENCE_COOKIE_NAME = 'language-preference'; + +describe('LanguageSelector', () => { + let mockReload; + + beforeEach(() => { + jest.clearAllMocks(); + + mergeConfig({ + ENABLE_HEADER_LANG_SELECTOR: true, + LANGUAGE_PREFERENCE_COOKIE_NAME, + SITE_SUPPORTED_LANGUAGES: ['es', 'en'], + }); + + initializeMockApp(); + + mockReload = jest.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { reload: mockReload }, + }); + + global.innerWidth = 1200; + }); + + it('should not render when no supported languages are available', () => { + mergeConfig({ + SITE_SUPPORTED_LANGUAGES: [], + }); + + const { container } = render(); + expect(container).toMatchSnapshot('no-supported-languages'); + expect(container.querySelector('#language-selector')).toBeNull(); + }); + + it('should change the language when different language is selected', async () => { + jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en'); + + const { container } = render(); + expect(container).toMatchSnapshot('before-language-change'); + + const langDropdown = screen.getByRole('button', { id: 'lang-selector-dropdown' }); + fireEvent.click(langDropdown); + + const spanishOption = screen.getByRole('button', { name: 'Español' }); + + await act(async () => { + fireEvent.click(spanishOption); + }); + + expect(container).toMatchSnapshot('after-language-change'); + expect(changeUserSessionLanguage).toHaveBeenCalledWith('es'); + }); + + it('should not change language if the same language is selected', async () => { + jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en'); + + const { container } = render(); + expect(container).toMatchSnapshot('before-same-language-selection'); + + const langDropdown = screen.getByRole('button', { id: 'lang-selector-dropdown' }); + fireEvent.click(langDropdown); + + const englishOption = screen.getByRole('button', { name: 'English' }); + await act(async () => { + fireEvent.click(englishOption); + }); + + expect(container).toMatchSnapshot('after-same-language-selection'); + expect(changeUserSessionLanguage).not.toHaveBeenCalled(); + }); + + it('should display full language name on large screens', () => { + jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en'); + + global.innerWidth = 1200; + render(); + + const button = screen.getByRole('button', { id: 'lang-selector-dropdown' }); + expect(button).toMatchSnapshot('large-screen-button'); + }); + + it('should display language code on medium screens', () => { + jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en'); + + global.innerWidth = 700; + render(); + + const button = screen.getByRole('button', { id: 'lang-selector-dropdown' }); + expect(button).toMatchSnapshot('medium-screen-button'); + }); + + it('should display only icon on small screens', () => { + jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en'); + + global.innerWidth = 500; + render(); + + const button = screen.getByRole('button', { id: 'lang-selector-dropdown' }); + expect(button).toMatchSnapshot('small-screen-button'); + }); +}); diff --git a/src/language-selector/__snapshots__/LanguageSelector.test.jsx.snap b/src/language-selector/__snapshots__/LanguageSelector.test.jsx.snap new file mode 100644 index 0000000000..6deb2cf660 --- /dev/null +++ b/src/language-selector/__snapshots__/LanguageSelector.test.jsx.snap @@ -0,0 +1,311 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LanguageSelector should change the language when different language is selected: after-language-change 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`LanguageSelector should change the language when different language is selected: before-language-change 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`LanguageSelector should display full language name on large screens: large-screen-button 1`] = ` + +`; + +exports[`LanguageSelector should display language code on medium screens: medium-screen-button 1`] = ` + +`; + +exports[`LanguageSelector should display only icon on small screens: small-screen-button 1`] = ` + +`; + +exports[`LanguageSelector should not change language if the same language is selected: after-same-language-selection 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`LanguageSelector should not change language if the same language is selected: before-same-language-selection 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`LanguageSelector should not render when no supported languages are available: no-supported-languages 1`] = ` +
+
+
+`; diff --git a/src/language-selector/index.js b/src/language-selector/index.js new file mode 100644 index 0000000000..9e52505689 --- /dev/null +++ b/src/language-selector/index.js @@ -0,0 +1,3 @@ +import LanguageSelector from './LanguageSelector'; + +export default LanguageSelector; diff --git a/src/learning-header/LearningHeader.jsx b/src/learning-header/LearningHeader.jsx index 349690bb2e..b2efcdb7b1 100644 --- a/src/learning-header/LearningHeader.jsx +++ b/src/learning-header/LearningHeader.jsx @@ -11,6 +11,7 @@ import CourseInfoSlot from '../plugin-slots/CourseInfoSlot'; import { courseInfoDataShape } from './LearningHeaderCourseInfo'; import messages from './messages'; import LearningHelpSlot from '../plugin-slots/LearningHelpSlot'; +import LanguageSelector from '../language-selector'; const LearningHeader = ({ courseOrg, @@ -37,6 +38,7 @@ const LearningHeader = ({
+ {getConfig().ENABLE_HEADER_LANG_SELECTOR && ()} {showUserDropdown && authenticatedUser && ( <> diff --git a/src/studio-header/HeaderBody.tsx b/src/studio-header/HeaderBody.tsx index 4037240ac5..20793b6de6 100644 --- a/src/studio-header/HeaderBody.tsx +++ b/src/studio-header/HeaderBody.tsx @@ -1,4 +1,5 @@ import React, { type ReactNode, type ComponentProps } from 'react'; +import { getConfig } from '@edx/frontend-platform'; import classNames from 'classnames'; import { ActionRow, @@ -7,6 +8,7 @@ import { Nav, Row, } from '@openedx/paragon'; +import LanguageSelector from '../language-selector'; import { Close, MenuIcon } from '@openedx/paragon/icons'; import CourseLockUp from './CourseLockUp'; @@ -137,6 +139,7 @@ const HeaderBody = ({ )} + {getConfig().ENABLE_HEADER_LANG_SELECTOR && ()} From 263910e07132e335c83fa31c1a4f62917c0f2c9f Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Malagon Date: Fri, 4 Apr 2025 09:45:07 -0500 Subject: [PATCH 2/5] fix: update language selector to maintain state after switch --- src/language-selector/LanguageSelector.jsx | 9 ++++----- .../LanguageSelector.test.jsx | 19 ++++++++++++------- .../LanguageSelector.test.jsx.snap | 12 ++---------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/language-selector/LanguageSelector.jsx b/src/language-selector/LanguageSelector.jsx index 84bfe672fb..451ab8a215 100644 --- a/src/language-selector/LanguageSelector.jsx +++ b/src/language-selector/LanguageSelector.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import { changeUserSessionLanguage, getPrimaryLanguageSubtag, injectIntl } from '@edx/frontend-platform/i18n'; -import { getCookies } from '@edx/frontend-platform/i18n/lib'; +import { getLocale } from '@edx/frontend-platform/i18n/lib'; import { AppContext } from '@edx/frontend-platform/react'; import { Dropdown } from '@openedx/paragon'; import { Language } from '@openedx/paragon/icons'; @@ -38,11 +38,9 @@ const getDisplayName = (locale) => { */ const LanguageSelector = ({ className }) => { const { config } = useContext(AppContext); - const cookies = getCookies(); const languageOptions = config.SITE_SUPPORTED_LANGUAGES; - const langCookieName = config.LANGUAGE_PREFERENCE_COOKIE_NAME; - const currentLocale = cookies.get(langCookieName) || 'en'; + const [currentLocale, setCurrentLocale] = useState(getLocale()); /** * Handles the selection of a language from the dropdown. @@ -53,6 +51,7 @@ const LanguageSelector = ({ className }) => { const handleSelect = (selectedLocale) => { if (currentLocale !== selectedLocale) { changeUserSessionLanguage(selectedLocale); + setCurrentLocale(selectedLocale); } }; diff --git a/src/language-selector/LanguageSelector.test.jsx b/src/language-selector/LanguageSelector.test.jsx index 8f89f051db..d6377263d8 100644 --- a/src/language-selector/LanguageSelector.test.jsx +++ b/src/language-selector/LanguageSelector.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { mergeConfig } from '@edx/frontend-platform'; -import { getCookies } from '@edx/frontend-platform/i18n/lib'; +import { getLocale } from '@edx/frontend-platform/i18n/lib'; import { changeUserSessionLanguage } from '@edx/frontend-platform/i18n'; import { act, fireEvent, initializeMockApp, render, screen, @@ -12,6 +12,11 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ changeUserSessionLanguage: jest.fn().mockResolvedValue({}), })); +jest.mock('@edx/frontend-platform/i18n/lib', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n/lib'), + getLocale: jest.fn(), +})); + jest.mock('@openedx/paragon/icons', () => ({ Language: () =>
LanguageIcon
, })); @@ -53,12 +58,12 @@ describe('LanguageSelector', () => { }); const { container } = render(); - expect(container).toMatchSnapshot('no-supported-languages'); + // expect(container).toMatchSnapshot('no-supported-languages'); expect(container.querySelector('#language-selector')).toBeNull(); }); it('should change the language when different language is selected', async () => { - jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en'); + getLocale.mockReturnValue('en'); const { container } = render(); expect(container).toMatchSnapshot('before-language-change'); @@ -77,7 +82,7 @@ describe('LanguageSelector', () => { }); it('should not change language if the same language is selected', async () => { - jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en'); + getLocale.mockReturnValue('en'); const { container } = render(); expect(container).toMatchSnapshot('before-same-language-selection'); @@ -95,7 +100,7 @@ describe('LanguageSelector', () => { }); it('should display full language name on large screens', () => { - jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en'); + getLocale.mockReturnValue('en'); global.innerWidth = 1200; render(); @@ -105,7 +110,7 @@ describe('LanguageSelector', () => { }); it('should display language code on medium screens', () => { - jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en'); + getLocale.mockReturnValue('en'); global.innerWidth = 700; render(); @@ -115,7 +120,7 @@ describe('LanguageSelector', () => { }); it('should display only icon on small screens', () => { - jest.spyOn(getCookies(), 'get').mockImplementation(() => 'en'); + getLocale.mockReturnValue('en'); global.innerWidth = 500; render(); diff --git a/src/language-selector/__snapshots__/LanguageSelector.test.jsx.snap b/src/language-selector/__snapshots__/LanguageSelector.test.jsx.snap index 6deb2cf660..a93c368a67 100644 --- a/src/language-selector/__snapshots__/LanguageSelector.test.jsx.snap +++ b/src/language-selector/__snapshots__/LanguageSelector.test.jsx.snap @@ -30,12 +30,12 @@ exports[`LanguageSelector should change the language when different language is - EN + ES - English + Español
`; - -exports[`LanguageSelector should not render when no supported languages are available: no-supported-languages 1`] = ` -
-
-
-`; From 4c639c38b94c2f29d354fd3ff874f602f351af78 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Malagon Date: Tue, 8 Apr 2025 14:02:21 -0500 Subject: [PATCH 3/5] style: add trailing newline --- src/language-selector/LanguageSelector.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/language-selector/LanguageSelector.scss b/src/language-selector/LanguageSelector.scss index fb7bb311a6..7c2b439a5b 100644 --- a/src/language-selector/LanguageSelector.scss +++ b/src/language-selector/LanguageSelector.scss @@ -19,4 +19,4 @@ } } } -} \ No newline at end of file +} From 8b9142eaf8ec6ac21cd763aef9d6819494b6a394 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Date: Thu, 20 Nov 2025 16:56:59 -0500 Subject: [PATCH 4/5] refactor: simplify imports and use getSupportedLocaleList --- src/language-selector/LanguageSelector.jsx | 14 ++++++++------ .../LanguageSelector.test.jsx | 19 ++++--------------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/language-selector/LanguageSelector.jsx b/src/language-selector/LanguageSelector.jsx index 451ab8a215..4013bd2fe9 100644 --- a/src/language-selector/LanguageSelector.jsx +++ b/src/language-selector/LanguageSelector.jsx @@ -1,9 +1,13 @@ import PropTypes from 'prop-types'; -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; -import { changeUserSessionLanguage, getPrimaryLanguageSubtag, injectIntl } from '@edx/frontend-platform/i18n'; +import { + changeUserSessionLanguage, + getSupportedLocaleList, + getPrimaryLanguageSubtag, + injectIntl, +} from '@edx/frontend-platform/i18n'; import { getLocale } from '@edx/frontend-platform/i18n/lib'; -import { AppContext } from '@edx/frontend-platform/react'; import { Dropdown } from '@openedx/paragon'; import { Language } from '@openedx/paragon/icons'; @@ -37,9 +41,7 @@ const getDisplayName = (locale) => { * @requires config.LANGUAGE_PREFERENCE_COOKIE_NAME - Cookie name for storing language preference */ const LanguageSelector = ({ className }) => { - const { config } = useContext(AppContext); - - const languageOptions = config.SITE_SUPPORTED_LANGUAGES; + const languageOptions = getSupportedLocaleList(); const [currentLocale, setCurrentLocale] = useState(getLocale()); /** diff --git a/src/language-selector/LanguageSelector.test.jsx b/src/language-selector/LanguageSelector.test.jsx index d6377263d8..943217dcb9 100644 --- a/src/language-selector/LanguageSelector.test.jsx +++ b/src/language-selector/LanguageSelector.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { mergeConfig } from '@edx/frontend-platform'; import { getLocale } from '@edx/frontend-platform/i18n/lib'; -import { changeUserSessionLanguage } from '@edx/frontend-platform/i18n'; +import { changeUserSessionLanguage, getSupportedLocaleList } from '@edx/frontend-platform/i18n'; import { act, fireEvent, initializeMockApp, render, screen, } from '../setupTest'; @@ -10,6 +10,7 @@ import LanguageSelector from './LanguageSelector'; jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), changeUserSessionLanguage: jest.fn().mockResolvedValue({}), + getSupportedLocaleList: jest.fn(), })); jest.mock('@edx/frontend-platform/i18n/lib', () => ({ @@ -29,33 +30,21 @@ jest.mock('@openedx/paragon', () => ({ const LANGUAGE_PREFERENCE_COOKIE_NAME = 'language-preference'; describe('LanguageSelector', () => { - let mockReload; - beforeEach(() => { jest.clearAllMocks(); mergeConfig({ ENABLE_HEADER_LANG_SELECTOR: true, LANGUAGE_PREFERENCE_COOKIE_NAME, - SITE_SUPPORTED_LANGUAGES: ['es', 'en'], }); - + getSupportedLocaleList.mockReturnValue(['es', 'en']); initializeMockApp(); - mockReload = jest.fn(); - Object.defineProperty(window, 'location', { - configurable: true, - writable: true, - value: { reload: mockReload }, - }); - global.innerWidth = 1200; }); it('should not render when no supported languages are available', () => { - mergeConfig({ - SITE_SUPPORTED_LANGUAGES: [], - }); + getSupportedLocaleList.mockReturnValue([]); const { container } = render(); // expect(container).toMatchSnapshot('no-supported-languages'); From c46b2483a984691cce611b0a8c2238bea985d92d Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Date: Fri, 21 Nov 2025 11:35:15 -0500 Subject: [PATCH 5/5] feat: add language selector to Desktop header --- src/desktop-header/DesktopHeader.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/desktop-header/DesktopHeader.jsx b/src/desktop-header/DesktopHeader.jsx index 5828ae6da3..aa94ee63d2 100644 --- a/src/desktop-header/DesktopHeader.jsx +++ b/src/desktop-header/DesktopHeader.jsx @@ -15,6 +15,7 @@ import { desktopHeaderMainOrSecondaryMenuDataShape } from './DesktopHeaderMainOr import DesktopSecondaryMenuSlot from '../plugin-slots/DesktopSecondaryMenuSlot'; import DesktopUserMenuSlot from '../plugin-slots/DesktopUserMenuSlot'; import { desktopUserMenuDataShape } from './DesktopHeaderUserMenu'; +import LanguageSelector from '../language-selector'; // i18n import messages from '../Header.messages'; @@ -75,6 +76,7 @@ const DesktopHeader = ({ aria-label={intl.formatMessage(messages['header.label.secondary.nav'])} className="nav secondary-menu-container align-items-center ml-auto" > + {getConfig().ENABLE_HEADER_LANG_SELECTOR && ()} {loggedIn ? ( <>