Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: modify footer lang selector behavior #21

Merged
merged 3 commits into from
Dec 11, 2024
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
17 changes: 3 additions & 14 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ Optionally, use the following variables to configure the Terms of Service Modal
* ``PRIVACY_POLICY_URL`` - The URL for the privacy policy.
* ``TERMS_OF_SERVICE_URL`` - The URL for the terms of service.
* ``TOS_AND_HONOR_CODE`` - The URL for the honor code.

* ``ENABLE_FOOTER_LANG_SELECTOR`` - A boolean to enable the lnaguage selector in the footer component.
* ``SITE_SUPPORTED_LANGUAGES`` - A list with all the languages to display in the selector.

Installation
============
Expand Down Expand Up @@ -94,12 +95,6 @@ This library has the following exports:
* ``messages``: Internationalization messages suitable for use with `@edx/frontend-platform/i18n <https://edx.github.io/frontend-platform/module-Internationalization.html>`_
* ``dist/footer.scss``: A SASS file which contains style information for the component. It should be imported into the micro-frontend's own SCSS file.

<Footer /> component props
==========================

* onLanguageSelected: Provides the footer with an event handler for when the user selects a
language from its dropdown.
* supportedLanguages: An array of objects representing available languages. See example below for object shape.

Terms of Service Modal
=======================
Expand Down Expand Up @@ -156,13 +151,7 @@ Component Usage Example::

...

<Footer
onLanguageSelected={(languageCode) => {/* set language */}}
supportedLanguages={[
{ label: 'English', value: 'en'},
{ label: 'Español', value: 'es' },
]}
/>
<Footer />

* `An example of minimal component and messages usage. <https://github.com/openedx/frontend-template-application/blob/3355bb3a96232390e9056f35b06ffa8f105ed7ca/src/index.jsx#L23>`_
* `An example of SCSS file usage. <https://github.com/openedx/frontend-template-application/blob/3cd5485bf387b8c479baf6b02bf59e3061dc3465/src/index.scss#L9>`_
Expand Down
9 changes: 9 additions & 0 deletions src/_footer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ $footer-links-navigation-color: $footer-text;
background-color: $footer-background-color;
padding-top: 2rem;
padding-bottom: 2rem;

.language-selector {
button {
color: $footer-text;
padding: 0;
border: unset;
background-color: unset;
}
}
}

.footer-copyright {
Expand Down
30 changes: 11 additions & 19 deletions src/components/Footer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,10 @@ class SiteFooter extends React.Component {

render() {
const {
supportedLanguages,
onLanguageSelected,
logo,
intl,
} = this.props;
const showLanguageSelector = supportedLanguages.length > 0 && onLanguageSelected;
const { config } = this.context;
const { config, authenticatedUser } = this.context;

return (
<footer
Expand All @@ -140,23 +137,25 @@ class SiteFooter extends React.Component {
/>
</a>
<FooterCopyrightSection intl={intl} />
{config.ENABLE_FOOTER_LANG_SELECTOR && (
<div className="mb-2">
<LanguageSelector
options={parseEnvSettings(config.SITE_SUPPORTED_LANGUAGES)}
authenticatedUser={authenticatedUser}
/>
</div>
)}
<FooterSocial intl={intl} />
<FooterPoweredBy intl={intl} />
</div>
<FooterLinks intl={intl} />

{showLanguageSelector && (
<LanguageSelector
options={supportedLanguages}
onSubmit={onLanguageSelected}
/>
)}
</div>
</section>
<AdditionalLogosSection />
{
config.MODAL_UPDATE_TERMS_OF_SERVICE && <ModalToS />
}
config.MODAL_UPDATE_TERMS_OF_SERVICE && <ModalToS />
}
</footer>
);
}
Expand All @@ -167,17 +166,10 @@ SiteFooter.contextType = AppContext;
SiteFooter.propTypes = {
intl: intlShape.isRequired,
logo: PropTypes.string,
onLanguageSelected: PropTypes.func,
supportedLanguages: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
})),
};

SiteFooter.defaultProps = {
logo: undefined,
onLanguageSelected: undefined,
supportedLanguages: [],
};

export default injectIntl(SiteFooter);
Expand Down
60 changes: 38 additions & 22 deletions src/components/Footer.test.jsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
/* eslint-disable react/prop-types */
import React, { useMemo } from 'react';
import renderer from 'react-test-renderer';
import { render, fireEvent, screen } from '@testing-library/react';
import {
render, waitFor,
} from '@testing-library/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import '@testing-library/jest-dom';

import Footer from './Footer';

import { patchPreferences, postSetLang } from './data/api';

jest.mock('./data/api', () => ({
patchPreferences: jest.fn(),
postSetLang: jest.fn(),
}));

const FooterWithContext = ({ locale = 'pt-pt' }) => {
const contextValue = useMemo(() => ({
authenticatedUser: null,
config: {
LOGO_TRADEMARK_URL: process.env.LOGO_TRADEMARK_URL,
LMS_BASE_URL: process.env.LMS_BASE_URL,
SITE_NAME: process.env.SITE_NAME,
},
}), []);

Expand All @@ -27,12 +39,18 @@ const FooterWithContext = ({ locale = 'pt-pt' }) => {
);
};

const FooterWithLanguageSelector = ({ languageSelected = () => {} }) => {
const FooterWithLanguageSelector = () => {
const contextValue = useMemo(() => ({
authenticatedUser: null,
authenticatedUser: { username: 'user123' },
config: {
LOGO_TRADEMARK_URL: process.env.LOGO_TRADEMARK_URL,
LMS_BASE_URL: process.env.LMS_BASE_URL,
SITE_NAME: process.env.SITE_NAME,
ENABLE_FOOTER_LANG_SELECTOR: true,
SITE_SUPPORTED_LANGUAGES: [
{ label: 'English', value: 'en' },
{ label: 'Português', value: 'pt-pt' },
],
},
}), []);

Expand All @@ -41,13 +59,7 @@ const FooterWithLanguageSelector = ({ languageSelected = () => {} }) => {
<AppContext.Provider
value={contextValue}
>
<Footer
onLanguageSelected={languageSelected}
supportedLanguages={[
{ label: 'English', value: 'en' },
{ label: 'Português', value: 'pt-pt' },
]}
/>
<Footer />
</AppContext.Provider>
</IntlProvider>
);
Expand Down Expand Up @@ -76,21 +88,25 @@ describe('<Footer />', () => {
});

describe('handles language switching', () => {
it('calls onLanguageSelected prop when a language is changed', () => {
const mockHandleLanguageSelected = jest.fn();
render(<FooterWithLanguageSelector languageSelected={mockHandleLanguageSelected} />);
it('calls patchPreferences and postSetLang when a language is changed', async () => {
initializeMockApp();
render(<FooterWithLanguageSelector />);

fireEvent.submit(screen.getByTestId('site-footer-submit-btn'), {
target: {
elements: {
'site-footer-language-select': {
value: 'pt-pt',
},
},
},
await waitFor(() => {
document.querySelector('.language-selector');
});
expect(document.querySelectorAll('.language-selector').length).toBe(1);

expect(mockHandleLanguageSelected).toHaveBeenCalledWith('pt-pt');
document.querySelector('.language-selector>button').click();

Array.from(document.querySelectorAll('.dropdown-menu.show a')).filter((e) => e.innerHTML === 'Português')[0].click();

await waitFor(() => {
expect(patchPreferences).toHaveBeenCalledWith('user123', { prefLang: 'pt-pt' });
});
await waitFor(() => {
expect(postSetLang).toHaveBeenCalledWith('pt-pt');
});
});
});
});
116 changes: 78 additions & 38 deletions src/components/LanguageSelector.jsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,98 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGlobe } from '@fortawesome/free-solid-svg-icons';
import { publish } from '@edx/frontend-platform';
import {
getLocale, injectIntl, intlShape, FormattedMessage, LOCALE_CHANGED, handleRtl,
} from '@edx/frontend-platform/i18n';
import { Dropdown } from '@openedx/paragon';
import { logError } from '@edx/frontend-platform/logging';

import { patchPreferences, postSetLang } from './data/api';

const onLanguageSelected = async (username, selectedLanguageCode) => {
try {
if (username) {
await patchPreferences(username, { prefLang: selectedLanguageCode });
await postSetLang(selectedLanguageCode);
}
publish(LOCALE_CHANGED, getLocale());
handleRtl();
} catch (error) {
logError(error);
}
};

const LanguageSelector = ({
intl, options, onSubmit, ...props
intl, options, authenticatedUser, compact,
}) => {
const handleSubmit = (e) => {
e.preventDefault();
const languageCode = e.target.elements['site-footer-language-select'].value;
onSubmit(languageCode);
const languageLabel = (languageCode) => {
const option = options.find(({ value }) => value === languageCode);
return option ? option.label : null;
};

const handleChange = (languageCode, event) => {
const previousSiteLanguage = getLocale();
/* eslint-disable no-console */
console.debug(previousSiteLanguage, languageCode, authenticatedUser);

if (previousSiteLanguage !== languageCode) {
onLanguageSelected(authenticatedUser?.username, languageCode);
}

const languageLabelElement = event.target.parentElement.parentElement.querySelector('.languageLabel');
languageLabelElement.innerHTML = languageLabel(languageCode);
};

const currentLangLabel = languageLabel(intl.locale);
const showLabel = !(compact || false);

return (
<form
className="form-inline"
onSubmit={handleSubmit}
{...props}
>
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label htmlFor="site-footer-language-select" className="d-inline-block m-0">
<FormattedMessage
id="footer.languageForm.select.label"
defaultMessage="Choose Language"
description="The label for the laguage select part of the language selection form."
/>
</label>
<select
id="site-footer-language-select"
className="form-control-sm mx-2"
name="site-footer-language-select"
defaultValue={intl.locale}
>
{options.map(({ value, label }) => <option key={value} value={value}>{label}</option>)}
</select>
<button data-testid="site-footer-submit-btn" className="btn btn-outline-primary btn-sm" type="submit">
<FormattedMessage
id="footer.languageForm.submit.label"
defaultMessage="Apply"
description="The label for button to submit the language selection form."
/>
</button>
</div>
</form>
<Dropdown className="language-selector">
<Dropdown.Toggle variant="outline-primary">
<FontAwesomeIcon icon={faGlobe} />
{showLabel && (
currentLangLabel ? (
<span className="pl-1 languageLabel">
{currentLangLabel}
</span>
) : (
<span className="pl-1">
<FormattedMessage
id="footer.languageForm.select.label"
defaultMessage="Choose Language"
description="The label for the laguage select part of the language selection form."
/>
</span>
)
)}
</Dropdown.Toggle>
<Dropdown.Menu>
{options.map(({ value, label }) => (
<Dropdown.Item key={value} eventKey={value} onSelect={handleChange}>
{label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};

LanguageSelector.propTypes = {
authenticatedUser: PropTypes.shape({
username: PropTypes.string,
}).isRequired,
intl: intlShape.isRequired,
onSubmit: PropTypes.func.isRequired,
compact: PropTypes.bool,
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
label: PropTypes.string,
})).isRequired,
};

LanguageSelector.defaultProps = {
compact: false,
};

export default injectIntl(LanguageSelector);
Loading
Loading