From f47fb6b55d96fb492860ae648ba3753ec5350cbe Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Malagon Date: Wed, 2 Apr 2025 09:32:43 -0500 Subject: [PATCH 1/4] 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 | 4 +- 8 files changed, 568 insertions(+), 1 deletion(-) 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 d6822a35bc..e6ae2e60ef 100644 --- a/src/learning-header/LearningHeader.jsx +++ b/src/learning-header/LearningHeader.jsx @@ -12,6 +12,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, courseNumber, courseTitle, intl, showUserDropdown, @@ -35,6 +36,7 @@ const LearningHeader = ({
{getConfig().LISAN_AI_ENABLED && ()} + {getConfig().ENABLE_HEADER_LANG_SELECTOR && ()} {showUserDropdown && authenticatedUser && ( <> diff --git a/src/studio-header/HeaderBody.tsx b/src/studio-header/HeaderBody.tsx index 598aec3819..06a259c5b2 100644 --- a/src/studio-header/HeaderBody.tsx +++ b/src/studio-header/HeaderBody.tsx @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import classNames from 'classnames'; import { @@ -12,7 +13,7 @@ import { Row, } from '@openedx/paragon'; import { Close, MenuIcon, Search } from '@openedx/paragon/icons'; - +import LanguageSelector from '../language-selector'; import CourseLockUp from './CourseLockUp'; import UserMenu from './UserMenu'; import BrandNav from './BrandNav'; @@ -116,6 +117,7 @@ const HeaderBody = ({ )} + {getConfig().ENABLE_HEADER_LANG_SELECTOR && ()} {searchButtonAction && (