Compare commits

...

31 Commits

Author SHA1 Message Date
Farhaan Bukhsh
c86165180f feat: Moves search button into a slot (#644)
Signed-off-by: Farhaan Bukhsh <farhaan@opencraft.com>
2025-10-27 10:51:45 -04:00
renovate[bot]
97063850c6 chore(deps): update dependency @openedx/paragon to v23.15.0 (#650)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 04:33:28 +00:00
renovate[bot]
af252d2d3f chore(deps): update dependency @edx/frontend-platform to v8.5.2 (#649)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 04:33:12 +00:00
renovate[bot]
9e850c3229 chore(deps): update dependency @openedx/paragon to v23.14.9 (#647)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 05:52:53 +00:00
Braden MacDonald
413454b6e6 refactor: update propTypes -> TypeScript in Studio Header (#643) 2025-10-14 09:43:33 -07:00
renovate[bot]
ed7cc8a36e chore(deps): update dependency ts-jest to v29.4.5 (#646)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 04:27:12 +00:00
renovate[bot]
a8a4ad59ed chore(deps): update dependency @openedx/paragon to v23.14.8 (#645)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 04:27:04 +00:00
Kyle McCormick
9f2b2a4026 feat: Remove Studio Maintenance link (empty commit for release CI)
This was already merged in
6d424edbd5,
but we are pushing this empty commit with BREAKING CHANGE message
in order to trigger the release of a new semantic version.

BREAKING CHANGE: The Studio "Maintenance" link has been removed
as part of https://github.com/openedx/edx-platform/issues/36263
2025-10-07 15:28:43 -04:00
Feanil Patel
21953ec96d Merge pull request #629 from openedx/renovate/actions-checkout-5.x
chore(deps): update actions/checkout action to v5
2025-10-07 14:06:33 -04:00
renovate[bot]
57671449b1 chore(deps): update actions/checkout action to v5 2025-10-07 17:59:08 +00:00
Feanil Patel
66012905b2 Merge pull request #641 from openedx/renovate/actions-setup-node-5.x
chore(deps): update actions/setup-node action to v5
2025-10-07 13:57:38 -04:00
renovate[bot]
209d52ed60 chore(deps): update actions/setup-node action to v5 2025-10-07 17:55:09 +00:00
Kyle McCormick
6d424edbd5 feat!: Remove Studio Maintenance link (#642)
This reverts commit 48c49fe0b2.

The original commit (a229c34535)
was merged prematurely, so it was reverted. However, now that
we have properly DEPR'd the underlying feature, we can revert
that revert.

Part of: https://github.com/openedx/edx-platform/issues/36263
2025-10-07 13:54:30 -04:00
renovate[bot]
5a4279a7f3 chore(deps): update dependency @openedx/paragon to v23.14.4 (#640)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-07 13:54:01 -04:00
Peter Kulko
ef0ea1378d docs: [FC-86] Restructure Modify Items section with Add/Replace examples (#598)
* docs: example of adding Marketing links via the plugin slots

* refactor: code refactoring

* feat: added logoDestination props for Header component

* refactor: after rebase

* refactor: updated tests

* refactor: after review

* refactor: removed logoDestination prop

* docs: Custom Marketing links mobile example

* refactor: after review
2025-09-29 12:44:28 -04:00
oleksandr.buhaienko
b8b39bffbc test: Remove support for Node 20 2025-09-29 12:26:17 -03:00
renovate[bot]
7f778adda9 chore(deps): update jest monorepo to v30.2.0 (#637)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 06:50:45 +00:00
renovate[bot]
4cf48d8ba8 chore(deps): update dependency @openedx/paragon to v23.14.3 (#636)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 06:50:27 +00:00
Feanil Patel
2e82ba910d build: remove unused @edx/reactifex package
Remove @edx/reactifex package from devDependencies as it is no longer
needed. Translation extraction functionality has been verified to work
correctly without these dependencies.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 09:58:55 -03:00
oleksandr.buhaienko
02533b0474 build: Upgrade to Node 24 2025-09-26 09:18:14 -03:00
Brian Smith
ecf7f1dfc1 feat!: release breaking change as breaking (#634)
Add README note about typescript

BREAKING CHANGE: release breaking change (typescript) as breaking
2025-09-24 13:51:11 -04:00
renovate[bot]
dbec796ceb chore(deps): update jest monorepo (#633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 06:06:34 +00:00
bydawen
4a797a59cc test: Add Node 24 to CI matrix (#611) 2025-09-16 10:35:41 -04:00
renovate[bot]
a8a7348605 chore(deps): update dependency @edx/frontend-platform to v8.5.1 (#632)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 04:56:33 +00:00
renovate[bot]
1ff9ecaf15 chore(deps): update dependency @openedx/frontend-build to v14.6.2 (#631)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 05:27:56 +00:00
Jacobo Dominguez
8f67fdba68 refactor: replacement of injectIntl (#625) 2025-08-29 13:05:16 -04:00
renovate[bot]
43684dce91 fix(deps): update dependency @fortawesome/react-fontawesome to v0.2.6 (#630)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 06:16:18 +00:00
renovate[bot]
3ddfbab1ef chore(deps): update dependency @openedx/paragon to v23.14.2 (#628)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 04:53:34 +00:00
renovate[bot]
030758e078 chore(deps): update dependency @edx/frontend-platform to v8.5.0 (#627)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 04:25:30 +00:00
renovate[bot]
c56b945f0d chore(deps): update dependency @openedx/paragon to v23.14.1 (#626)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 04:25:16 +00:00
Fox Piacenti
69750674c3 feat: User menu trigger plugin slots. (#618) 2025-08-01 09:58:35 -04:00
50 changed files with 8920 additions and 4688 deletions

View File

@@ -11,11 +11,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Nodejs
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
- name: Install dependencies

View File

@@ -10,13 +10,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VER }}
- name: Install dependencies

2
.nvmrc
View File

@@ -1 +1 @@
20
24

View File

@@ -35,6 +35,7 @@ Requirements
This component uses ``@edx/frontend-platform`` services such as i18n, analytics, configuration, and the ``AppContext`` React component, and expects that it has been loaded into a micro-frontend that has been properly initialized via ``@edx/frontend-platform``'s ``initialize`` function. `Please visit the frontend template application to see an example. <https://github.com/openedx/frontend-template-application/blob/master/src/index.jsx>`_
As of version 7.x, consuming applications must support typescript.
Environment Variables
====================
@@ -71,9 +72,9 @@ Cloning and Startup
``git clone https://github.com/openedx/frontend-component-header.git``
2. Use node v18.x.
2. Use node v24.x.
The current version of the micro-frontend build scripts support node 18.
The current version of the micro-frontend build scripts support node 24.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
@@ -190,4 +191,4 @@ Please do not report security issues in public. Please email security@openedx.or
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-component-header.svg
:target: @edx/frontend-component-header
.. |semantic-release| image:: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
:target: https://github.com/semantic-release/semantic-release
:target: https://github.com/semantic-release/semantic-release

12206
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage",
"test:dev": "fedx-scripts jest --watchAll",
"types": "tsc --noEmit"
},
"files": [
@@ -33,13 +34,12 @@
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/browserslist-config": "^1.1.1",
"@edx/frontend-platform": "^8.3.1",
"@edx/reactifex": "^2.1.1",
"@openedx/frontend-build": "^14.3.2",
"@openedx/paragon": "^23.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "^16.2.0",
"jest": "30.0.5",
"jest": "30.2.0",
"jest-environment-jsdom": "^30.0.0",
"prop-types": "15.8.1",
"react": "^18.3.1",
@@ -48,7 +48,8 @@
"react-router-dom": "6.30.1",
"react-test-renderer": "^18.3.1",
"redux": "4.2.1",
"redux-saga": "1.3.0"
"redux-saga": "1.3.0",
"ts-jest": "^29.4.4"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.7.2",

View File

@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import Responsive from 'react-responsive';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import {
APP_CONFIG_INITIALIZED,
@@ -47,9 +47,10 @@ subscribe(APP_CONFIG_INITIALIZED, () => {
* See the documentation for the structure of user menu item.
*/
const Header = ({
intl, mainMenuItems, secondaryMenuItems, userMenuItems,
mainMenuItems, secondaryMenuItems, userMenuItems,
}) => {
const { authenticatedUser, config } = useContext(AppContext);
const intl = useIntl();
const defaultMainMenu = [
{
@@ -139,7 +140,6 @@ Header.defaultProps = {
};
Header.propTypes = {
intl: intlShape.isRequired,
mainMenuItems: PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
@@ -159,4 +159,4 @@ Header.propTypes = {
})),
};
export default injectIntl(Header);
export default Header;

View File

@@ -61,11 +61,6 @@ const messages = defineMessages({
defaultMessage: 'Studio Home',
description: 'Link to the Studio Home',
},
'header.user.menu.studio.maintenance': {
id: 'header.user.menu.studio.maintenance',
defaultMessage: 'Maintenance',
description: 'Link to the Studio Maintenance',
},
'header.label.account.nav': {
id: 'header.label.account.nav',
defaultMessage: 'Account',

View File

@@ -1,11 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
// Local Components
import DesktopUserMenuToggleSlot
from '../plugin-slots/DesktopUserMenuToggleSlot';
import { Menu, MenuTrigger, MenuContent } from '../Menu';
import Avatar from '../Avatar';
import LogoSlot from '../plugin-slots/LogoSlot';
import DesktopLoggedOutItemsSlot from '../plugin-slots/DesktopLoggedOutItemsSlot';
import { desktopLoggedOutItemsDataShape } from './DesktopLoggedOutItems';
@@ -19,94 +20,74 @@ import { desktopUserMenuDataShape } from './DesktopHeaderUserMenu';
import messages from '../Header.messages';
// Assets
import { CaretIcon } from '../Icons';
class DesktopHeader extends React.Component {
constructor(props) { // eslint-disable-line @typescript-eslint/no-useless-constructor
super(props);
}
const DesktopHeader = ({
mainMenu,
secondaryMenu,
userMenu,
loggedOutItems,
logo,
logoAltText,
logoDestination,
avatar,
username,
loggedIn,
}) => {
const intl = useIntl();
renderMainMenu() {
const { mainMenu } = this.props;
return <DesktopMainMenuSlot menu={mainMenu} />;
}
const renderMainMenu = () => <DesktopMainMenuSlot menu={mainMenu} />;
renderSecondaryMenu() {
const { secondaryMenu } = this.props;
return <DesktopSecondaryMenuSlot menu={secondaryMenu} />;
}
const renderSecondaryMenu = () => <DesktopSecondaryMenuSlot menu={secondaryMenu} />;
renderUserMenu() {
const {
userMenu,
avatar,
username,
intl,
} = this.props;
const renderUserMenu = () => (
<Menu transitionClassName="menu-dropdown" transitionTimeout={250}>
<MenuTrigger
tag="button"
aria-label={intl.formatMessage(messages['header.label.account.menu.for'], { username })}
className="btn btn-outline-primary d-inline-flex align-items-center pl-2 pr-3"
>
<DesktopUserMenuToggleSlot avatar={avatar} label={username} />
</MenuTrigger>
<MenuContent className="mb-0 dropdown-menu show dropdown-menu-right pin-right shadow py-2">
<DesktopUserMenuSlot menu={userMenu} />
</MenuContent>
</Menu>
);
return (
<Menu transitionClassName="menu-dropdown" transitionTimeout={250}>
<MenuTrigger
tag="button"
aria-label={intl.formatMessage(messages['header.label.account.menu.for'], { username })}
className="btn btn-outline-primary d-inline-flex align-items-center pl-2 pr-3"
>
<Avatar size="1.5em" src={avatar} alt="" className="mr-2" />
{username} <CaretIcon role="img" aria-hidden focusable="false" />
</MenuTrigger>
<MenuContent className="mb-0 dropdown-menu show dropdown-menu-right pin-right shadow py-2">
<DesktopUserMenuSlot menu={userMenu} />
</MenuContent>
</Menu>
);
}
const renderLoggedOutItems = () => <DesktopLoggedOutItemsSlot items={loggedOutItems} />;
renderLoggedOutItems() {
const { loggedOutItems } = this.props;
return <DesktopLoggedOutItemsSlot items={loggedOutItems} />;
}
const logoProps = { src: logo, alt: logoAltText, href: logoDestination };
const logoClasses = getConfig().AUTHN_MINIMAL_HEADER ? 'mw-100' : null;
render() {
const {
logo,
logoAltText,
logoDestination,
loggedIn,
intl,
} = this.props;
const logoProps = { src: logo, alt: logoAltText, href: logoDestination };
const logoClasses = getConfig().AUTHN_MINIMAL_HEADER ? 'mw-100' : null;
return (
<header className="site-header-desktop">
<a className="nav-skip sr-only sr-only-focusable" href="#main">{intl.formatMessage(messages['header.label.skip.nav'])}</a>
<div className={`container-fluid ${logoClasses}`}>
<div className="nav-container position-relative d-flex align-items-center">
<LogoSlot {...logoProps} />
<nav
aria-label={intl.formatMessage(messages['header.label.main.nav'])}
className="nav main-nav"
>
{this.renderMainMenu()}
</nav>
<nav
aria-label={intl.formatMessage(messages['header.label.secondary.nav'])}
className="nav secondary-menu-container align-items-center ml-auto"
>
{loggedIn
? (
<>
{this.renderSecondaryMenu()}
{this.renderUserMenu()}
</>
) : this.renderLoggedOutItems()}
</nav>
</div>
return (
<header className="site-header-desktop">
<a className="nav-skip sr-only sr-only-focusable" href="#main">{intl.formatMessage(messages['header.label.skip.nav'])}</a>
<div className={`container-fluid ${logoClasses}`}>
<div className="nav-container position-relative d-flex align-items-center">
<LogoSlot {...logoProps} />
<nav
aria-label={intl.formatMessage(messages['header.label.main.nav'])}
className="nav main-nav"
>
{renderMainMenu()}
</nav>
<nav
aria-label={intl.formatMessage(messages['header.label.secondary.nav'])}
className="nav secondary-menu-container align-items-center ml-auto"
>
{loggedIn
? (
<>
{renderSecondaryMenu()}
{renderUserMenu()}
</>
) : renderLoggedOutItems()}
</nav>
</div>
</header>
);
}
}
</div>
</header>
);
};
export const desktopHeaderDataShape = {
mainMenu: desktopHeaderMainOrSecondaryMenuDataShape,
@@ -123,18 +104,15 @@ export const desktopHeaderDataShape = {
DesktopHeader.propTypes = {
mainMenu: desktopHeaderDataShape.mainMenu,
secondaryMenu: desktopHeaderDataShape.secondaryMenumainMenu,
userMenu: desktopHeaderDataShape.userMenumainMenu,
loggedOutItems: desktopHeaderDataShape.loggedOutItemsmainMenu,
logo: desktopHeaderDataShape.logomainMenu,
logoAltText: desktopHeaderDataShape.logoAltTextmainMenu,
logoDestination: desktopHeaderDataShape.logoDestinationmainMenu,
avatar: desktopHeaderDataShape.avatarmainMenu,
username: desktopHeaderDataShape.usernamemainMenu,
loggedIn: desktopHeaderDataShape.loggedInmainMenu,
// i18n
intl: intlShape.isRequired,
secondaryMenu: desktopHeaderDataShape.secondaryMenu,
userMenu: desktopHeaderDataShape.userMenu,
loggedOutItems: desktopHeaderDataShape.loggedOutItems,
logo: desktopHeaderDataShape.logo,
logoAltText: desktopHeaderDataShape.logoAltText,
logoDestination: desktopHeaderDataShape.logoDestination,
avatar: desktopHeaderDataShape.avatar,
username: desktopHeaderDataShape.username,
loggedIn: desktopHeaderDataShape.loggedIn,
};
DesktopHeader.defaultProps = {
@@ -150,4 +128,4 @@ DesktopHeader.defaultProps = {
loggedIn: false,
};
export default injectIntl(DesktopHeader);
export default DesktopHeader;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import { CaretIcon } from '../Icons';
import Avatar from '../Avatar';
const DesktopUserMenuToggle = ({ avatar, label }) => (
<>
<Avatar size="1.5em" src={avatar} alt="" className="mr-2" />
{label} <CaretIcon role="img" aria-hidden focusable="false" />
</>
);
export const DesktopUserMenuTogglePropTypes = {
avatar: PropTypes.string,
label: PropTypes.string,
};
DesktopUserMenuToggle.propTypes = DesktopUserMenuTogglePropTypes;
export default DesktopUserMenuToggle;

View File

@@ -2,12 +2,13 @@ import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import LearningLoggedOutItemsSlot from '../plugin-slots/LearningLoggedOutItemsSlot';
import genericMessages from '../generic/messages';
const AnonymousUserMenu = ({ intl }) => {
const AnonymousUserMenu = () => {
const intl = useIntl();
const buttonsInfo = [
{
message: intl.formatMessage(genericMessages.registerSentenceCase),
@@ -23,8 +24,4 @@ const AnonymousUserMenu = ({ intl }) => {
return <LearningLoggedOutItemsSlot buttonsInfo={buttonsInfo} />;
};
AnonymousUserMenu.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(AnonymousUserMenu);
export default AnonymousUserMenu;

View File

@@ -1,17 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@openedx/paragon';
import LearningUserMenuToggleSlot from '../plugin-slots/LearningUserMenuToggleSlot';
import LearningUserMenuSlot from '../plugin-slots/LearningUserMenuSlot';
import messages from './messages';
const AuthenticatedUserDropdown = ({ intl, username }) => {
const AuthenticatedUserDropdown = ({ username }) => {
const intl = useIntl();
const dropdownItems = [
{
message: intl.formatMessage(messages.dashboard),
@@ -38,10 +39,7 @@ const AuthenticatedUserDropdown = ({ intl, username }) => {
return (
<Dropdown className="user-dropdown ml-3">
<Dropdown.Toggle variant="outline-primary" aria-label={intl.formatMessage(messages.userOptionsDropdownLabel)}>
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
<span data-hj-suppress className="d-none d-md-inline">
{username}
</span>
<LearningUserMenuToggleSlot label={username} icon={faUserCircle} />
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
<LearningUserMenuSlot items={dropdownItems} />
@@ -51,8 +49,7 @@ const AuthenticatedUserDropdown = ({ intl, username }) => {
};
AuthenticatedUserDropdown.propTypes = {
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
};
export default injectIntl(AuthenticatedUserDropdown);
export default AuthenticatedUserDropdown;

View File

@@ -1,7 +1,7 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import AnonymousUserMenu from './AnonymousUserMenu';
@@ -13,8 +13,12 @@ import messages from './messages';
import LearningHelpSlot from '../plugin-slots/LearningHelpSlot';
const LearningHeader = ({
courseOrg, courseNumber, courseTitle, intl, showUserDropdown,
courseOrg,
courseNumber,
courseTitle,
showUserDropdown,
}) => {
const intl = useIntl();
const { authenticatedUser } = useContext(AppContext);
const headerLogo = (
@@ -53,7 +57,6 @@ LearningHeader.propTypes = {
courseOrg: courseInfoDataShape.courseOrg,
courseNumber: courseInfoDataShape.courseNumber,
courseTitle: courseInfoDataShape.courseTitle,
intl: intlShape.isRequired,
showUserDropdown: PropTypes.bool,
};
@@ -64,4 +67,4 @@ LearningHeader.defaultProps = {
showUserDropdown: true,
};
export default injectIntl(LearningHeader);
export default LearningHeader;

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
const LearningUserMenuToggle = ({
label,
icon,
}) => (
<>
<FontAwesomeIcon icon={icon} className="d-md-none" size="lg" />
<span data-hj-suppress className="d-none d-md-inline">
{label}
</span>
</>
);
export const LearningUserMenuTogglePropTypes = {
label: PropTypes.string.isRequired,
// Full shape available by examining @fortawesome/fontawesome-common-types/index.d.ts.
icon: PropTypes.shape({
prefix: PropTypes.string.isRequired,
iconName: PropTypes.string.isRequired,
}).isRequired,
};
LearningUserMenuToggle.propTypes = LearningUserMenuTogglePropTypes;
export default LearningUserMenuToggle;

View File

@@ -1,11 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
// Local Components
import MobileUserMenuToggleSlot from '../plugin-slots/MobileUserMenuToggleSlot';
import { Menu, MenuTrigger, MenuContent } from '../Menu';
import Avatar from '../Avatar';
import LogoSlot from '../plugin-slots/LogoSlot';
import MobileLoggedOutItemsSlot from '../plugin-slots/MobileLoggedOutItemsSlot';
import { mobileHeaderLoggedOutItemsDataShape } from './MobileLoggedOutItems';
@@ -20,96 +20,84 @@ import messages from '../Header.messages';
// Assets
import { MenuIcon } from '../Icons';
class MobileHeader extends React.Component {
constructor(props) { // eslint-disable-line @typescript-eslint/no-useless-constructor
super(props);
}
const MobileHeader = ({
mainMenu,
secondaryMenu,
userMenu,
loggedOutItems,
logo,
logoAltText,
logoDestination,
avatar,
username,
loggedIn,
stickyOnMobile,
}) => {
const intl = useIntl();
renderMainMenu() {
const { mainMenu, secondaryMenu } = this.props;
return <MobileMainMenuSlot menu={[...mainMenu, ...secondaryMenu]} />;
}
const renderMainMenu = () => <MobileMainMenuSlot menu={[...mainMenu, ...secondaryMenu]} />;
renderUserMenuItems() {
const { userMenu } = this.props;
return <MobileUserMenuSlot menu={userMenu} />;
}
const renderUserMenuItems = () => <MobileUserMenuSlot menu={userMenu} />;
renderLoggedOutItems() {
const { loggedOutItems } = this.props;
return <MobileLoggedOutItemsSlot items={loggedOutItems} />;
}
const renderLoggedOutItems = () => <MobileLoggedOutItemsSlot items={loggedOutItems} />;
render() {
const {
logo,
logoAltText,
logoDestination,
loggedIn,
avatar,
username,
stickyOnMobile,
intl,
mainMenu,
userMenu,
loggedOutItems,
} = this.props;
const logoProps = { src: logo, alt: logoAltText, href: logoDestination };
const stickyClassName = stickyOnMobile ? 'sticky-top' : '';
const logoClasses = getConfig().AUTHN_MINIMAL_HEADER ? 'justify-content-left pl-3' : 'justify-content-center';
const renderUserMenuToggle = () => <MobileUserMenuToggleSlot avatar={avatar} label={username} />;
return (
<header
aria-label={intl.formatMessage(messages['header.label.main.header'])}
className={`site-header-mobile d-flex justify-content-between align-items-center shadow ${stickyClassName}`}
>
<a className="nav-skip sr-only sr-only-focusable" href="#main">{intl.formatMessage(messages['header.label.skip.nav'])}</a>
{mainMenu.length > 0 ? (
<div className="w-100 d-flex justify-content-start">
const logoProps = { src: logo, alt: logoAltText, href: logoDestination };
const stickyClassName = stickyOnMobile ? 'sticky-top' : '';
const logoClasses = getConfig().AUTHN_MINIMAL_HEADER ? 'justify-content-left pl-3' : 'justify-content-center';
<Menu className="position-static">
<MenuTrigger
tag="button"
className="icon-button"
aria-label={intl.formatMessage(messages['header.label.main.menu'])}
title={intl.formatMessage(messages['header.label.main.menu'])}
>
<MenuIcon role="img" aria-hidden focusable="false" style={{ width: '1.5rem', height: '1.5rem' }} />
</MenuTrigger>
<MenuContent
tag="nav"
aria-label={intl.formatMessage(messages['header.label.main.nav'])}
className="nav flex-column pin-left pin-right border-top shadow py-2"
>
{this.renderMainMenu()}
</MenuContent>
</Menu>
</div>
) : null}
<div className={`w-100 d-flex ${logoClasses}`}>
<LogoSlot {...logoProps} itemType="http://schema.org/Organization" />
return (
<header
aria-label={intl.formatMessage(messages['header.label.main.header'])}
className={`site-header-mobile d-flex justify-content-between align-items-center shadow ${stickyClassName}`}
>
<a className="nav-skip sr-only sr-only-focusable" href="#main">{intl.formatMessage(messages['header.label.skip.nav'])}</a>
{mainMenu.length > 0 ? (
<div className="w-100 d-flex justify-content-start">
<Menu className="position-static">
<MenuTrigger
tag="button"
className="icon-button"
aria-label={intl.formatMessage(messages['header.label.main.menu'])}
title={intl.formatMessage(messages['header.label.main.menu'])}
>
<MenuIcon role="img" aria-hidden focusable="false" style={{ width: '1.5rem', height: '1.5rem' }} />
</MenuTrigger>
<MenuContent
tag="nav"
aria-label={intl.formatMessage(messages['header.label.main.nav'])}
className="nav flex-column pin-left pin-right border-top shadow py-2"
>
{renderMainMenu()}
</MenuContent>
</Menu>
</div>
{userMenu.length > 0 || loggedOutItems.length > 0 ? (
<div className="w-100 d-flex justify-content-end align-items-center">
<Menu tag="nav" aria-label={intl.formatMessage(messages['header.label.secondary.nav'])} className="position-static">
<MenuTrigger
tag="button"
className="icon-button"
aria-label={intl.formatMessage(messages['header.label.account.menu'])}
title={intl.formatMessage(messages['header.label.account.menu'])}
>
<Avatar size="1.5rem" src={avatar} alt={username} />
</MenuTrigger>
<MenuContent tag="ul" className="nav flex-column pin-left pin-right border-top shadow py-2">
{loggedIn ? this.renderUserMenuItems() : this.renderLoggedOutItems()}
</MenuContent>
</Menu>
</div>
) : null}
</header>
);
}
}
) : null}
<div className={`w-100 d-flex ${logoClasses}`}>
<LogoSlot {...logoProps} itemType="http://schema.org/Organization" />
</div>
{userMenu.length > 0 || loggedOutItems.length > 0 ? (
<div className="w-100 d-flex justify-content-end align-items-center">
<Menu tag="nav" aria-label={intl.formatMessage(messages['header.label.secondary.nav'])} className="position-static">
<MenuTrigger
tag="button"
className="icon-button"
aria-label={intl.formatMessage(messages['header.label.account.menu'])}
title={intl.formatMessage(messages['header.label.account.menu'])}
>
{renderUserMenuToggle()}
</MenuTrigger>
<MenuContent tag="ul" className="nav flex-column pin-left pin-right border-top shadow py-2">
{loggedIn ? renderUserMenuItems() : renderLoggedOutItems()}
</MenuContent>
</Menu>
</div>
) : null}
</header>
);
};
export const mobileHeaderDataShape = {
mainMenu: mobileHeaderMainMenuDataShape,
@@ -137,9 +125,6 @@ MobileHeader.propTypes = {
username: mobileHeaderDataShape.username,
loggedIn: mobileHeaderDataShape.loggedIn,
stickyOnMobile: mobileHeaderDataShape.stickyOnMobile,
// i18n
intl: intlShape.isRequired,
};
MobileHeader.defaultProps = {
@@ -157,4 +142,4 @@ MobileHeader.defaultProps = {
};
export default injectIntl(MobileHeader);
export default MobileHeader;

View File

@@ -0,0 +1,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import Avatar from '../Avatar';
const MobileUserMenuToggle = ({ avatar, username }) => <Avatar size="1.5rem" src={avatar} alt={username} />;
export const MobileUserMenuTogglePropTypes = {
avatar: PropTypes.string,
username: PropTypes.string,
};
MobileUserMenuToggle.propTypes = MobileUserMenuTogglePropTypes;
export default MobileUserMenuToggle;

View File

@@ -41,4 +41,4 @@ const config = {
}
export default config;
```
```

View File

@@ -13,9 +13,11 @@ This slot is used to replace/modify/hide the desktop main menu.
### Modify Items
The following `env.config.jsx` will modify the items in the desktop main menu.
#### Replace All Items
![Screenshot of modified items](./images/desktop_main_menu_modify_items.png)
The following `env.config.jsx` will replace all items in the desktop main menu.
![Screenshot of modified items](./images/desktop_main_menu_replace_all_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
@@ -59,6 +61,58 @@ const config = {
export default config;
```
#### Add Items
The following `env.config.jsx` will add items in the desktop main menu.
![Screenshot of custom marketing links](./images/desktop_main_menu_add_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifyMainMenu = (widget) => {
const existingMenu = widget.RenderWidget.props.menu || [];
const newMarketingLinks = [
{
type: 'item',
href: 'https://example.com/how-it-works',
content: 'How it works',
},
{
type: 'item',
href: 'https://example.com/courses',
content: 'Courses',
},
{
type: 'item',
href: 'https://example.com/schools',
content: 'Schools',
}
];
widget.content.menu = [...existingMenu, ...newMarketingLinks];
return widget;
};
const config = {
pluginSlots: {
'org.openedx.frontend.layout.header_desktop_main_menu.v1': {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyMainMenu,
},
]
},
},
}
export default config;
```
### Replace Menu with Custom Component
The following `env.config.jsx` will replace the desktop main menu entirely (in this case with a centered 🗺️ `h1`)
@@ -134,4 +188,3 @@ const config = {
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,74 @@
# Desktop User Menu Toggle Slot
### Slot ID: `org.openedx.frontend.layout.header_desktop_user_menu_toggle.v1`
## Description
This slot is used to replace/modify/hide the contents of the user menu toggle button on desktop sized screens.
## Examples
### Modify Label Text
The following `env.config.jsx` will modify the label text to be something more generic:
![Screenshot of modified label](./images/desktop_user_menu_modified_toggle.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
import { faHouse } from '@fortawesome/free-solid-svg-icons';
const modifyUserMenuToggle = ( widget ) => {
widget.content.label = "My Profile";
return widget;
};
const config = {
pluginSlots: {
'org.openedx.frontend.layout.header_desktop_user_menu_toggle.v1': {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyUserMenuToggle,
},
]
},
},
}
export default config;
```
### Replace Menu toggle contents with Custom Component
The following `env.config.jsx` will replace the contents of the learning user menu toggle button entirely (in this case with an emoji)
![Screenshot of replaced with custom component](./images/desktop_user_menu_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
'org.openedx.frontend.layout.header_desktop_user_menu_toggle.v1': {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_desktop_user_menu_toggle',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<span>🦊</span>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import DesktopUserMenuToggle, { DesktopUserMenuTogglePropTypes } from '../../desktop-header/DesktopUserMenuToggle';
const DesktopUserMenuToggleSlot = ({
avatar,
label,
}) => (
<PluginSlot
id="org.openedx.frontend.layout.header_desktop_user_menu_toggle.v1"
slotOptions={{
mergeProps: true,
}}
>
<DesktopUserMenuToggle avatar={avatar} label={label} />
</PluginSlot>
);
DesktopUserMenuToggleSlot.propTypes = DesktopUserMenuTogglePropTypes;
export default DesktopUserMenuToggleSlot;

View File

@@ -0,0 +1,74 @@
# Learning User Menu Toggle Slot
### Slot ID: `org.openedx.frontend.layout.header_learning_user_menu_toggle.v1`
## Description
This slot is used to replace/modify/hide the contents of the learning user menu toggle button.
## Examples
### Modify Icon
The following `env.config.jsx` will modify the icon for the learning user menu toggle button. **Note:** The icon is only shown on mobile screens.
![Screenshot of modified items](./images/learning_user_menu_toggle_modified_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
import { faHouse } from '@fortawesome/free-solid-svg-icons';
const modifyUserMenuToggle = ( widget ) => {
widget.content.icon = faHouse;
return widget;
};
const config = {
pluginSlots: {
'org.openedx.frontend.layout.header_learning_user_menu_toggle.v1': {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyUserMenuToggle,
},
]
},
},
}
export default config;
```
### Replace Menu toggle contents with Custom Component
The following `env.config.jsx` will replace the contents of the learning user menu toggle button's contents entirely (in this case with an emoji)
![Screenshot of replaced with custom component](./images/learning_user_menu_toggle_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
'org.openedx.frontend.layout.header_learning_user_menu_toggle.v1': {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_learning_user_menu_toggle',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<span>🦊</span>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import LearningUserMenuToggle, {
LearningUserMenuTogglePropTypes,
} from '../../learning-header/LearningUserMenuToggle';
const LearningUserMenuToggleSlot = ({
label, icon,
}) => (
<PluginSlot
id="org.openedx.frontend.layout.header_learning_user_menu_toggle.v1"
slotOptions={{
mergeProps: true,
}}
>
<LearningUserMenuToggle label={label} icon={icon} />
</PluginSlot>
);
LearningUserMenuToggleSlot.propTypes = LearningUserMenuTogglePropTypes;
export default LearningUserMenuToggleSlot;

View File

@@ -13,9 +13,11 @@ This slot is used to replace/modify/hide the mobile main menu.
### Modify Items
The following `env.config.jsx` will modify the items in the mobile main menu.
#### Replace All Items
![Screenshot of modified items](./images/mobile_main_menu_modify_items.png)
The following `env.config.jsx` will replace all items in the mobile main menu.
![Screenshot of modified items](./images/mobile_main_menu_replace_all_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
@@ -59,6 +61,58 @@ const config = {
export default config;
```
#### Add Items
The following `env.config.jsx` will add items in the mobile main menu.
![Screenshot of custom marketing links](./images/mobile_main_menu_add_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifyMainMenu = (widget) => {
const existingMenu = widget.RenderWidget.props.menu || [];
const newMarketingLinks = [
{
type: 'item',
href: 'https://example.com/how-it-works',
content: 'How it works',
},
{
type: 'item',
href: 'https://example.com/courses',
content: 'Courses',
},
{
type: 'item',
href: 'https://example.com/schools',
content: 'Schools',
}
];
widget.content.menu = [...existingMenu, ...newMarketingLinks];
return widget;
};
const config = {
pluginSlots: {
'org.openedx.frontend.layout.header_mobile_main_menu.v1': {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyMainMenu,
},
]
},
},
}
export default config;
```
### Replace Menu with Custom Component
The following `env.config.jsx` will replace the mobile main menu entirely (in this case with a centered 🗺️ `h1`)

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -0,0 +1,74 @@
# Mobile User Menu Toggle Slot
### Slot ID: `org.openedx.frontend.layout.header_mobile_user_menu_trigger.v1`
## Description
This slot is used to replace/modify/hide the contents of the user menu toggle button on mobile screens.
## Examples
### Modify Avatar
The following `env.config.jsx` will modify the icon for the user menu toggle button on mobile.
![Screenshot of modified items](./images/mobile_user_menu_toggle_modified_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifyUserMenuToggle = ( widget ) => {
// Shows a dummy image with the resolution marker '30x30'.
widget.content.avatar = "https://dummyimage.com/30x30"
return widget;
};
const config = {
pluginSlots: {
'org.openedx.frontend.layout.header_mobile_user_menu_trigger.v1': {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyUserMenuToggle,
},
]
},
},
}
export default config;
```
### Replace Menu toggle contents with Custom Component
The following `env.config.jsx` will replace the contents of the user menu toggle button's contents entirely (in this case with an emoji).
![Screenshot of replaced with custom component](./images/mobile_user_menu_toggle_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
'org.openedx.frontend.layout.header_mobile_user_menu_trigger.v1': {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_mobile_user_menu_toggle',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<span>🦊</span>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import MobileUserMenuToggle, {
MobileUserMenuTogglePropTypes,
} from '../../mobile-header/MobileUserMenuToggle';
const MobileUserMenuToggleSlot = ({
avatar,
label,
}) => (
<PluginSlot
id="org.openedx.frontend.layout.header_mobile_user_menu_trigger.v1"
slotOptions={{
mergeProps: true,
}}
>
<MobileUserMenuToggle avatar={avatar} label={label} />
</PluginSlot>
);
MobileUserMenuToggleSlot.propTypes = MobileUserMenuTogglePropTypes;
export default MobileUserMenuToggleSlot;

View File

@@ -9,15 +9,21 @@
* [`org.openedx.frontend.layout.header_desktop_main_menu.v1`](./DesktopMainMenuSlot/)
* [`org.openedx.frontend.layout.header_desktop_secondary_menu.v1`](./DesktopSecondaryMenuSlot/)
* [`org.openedx.frontend.layout.header_desktop_user_menu.v1`](./DesktopUserMenuSlot/)
* [`org.openedx.frontend.layout.header_desktop_user_menu_toggle.v1`](./DesktopUserMenuToggleSlot/)
### Learning Header
* [`org.openedx.frontend.layout.header_learning_course_info.v1`](./CourseInfoSlot/)
* [`org.openedx.frontend.layout.header_learning_help.v1`](./LearningHelpSlot/)
* [`org.openedx.frontend.layout.header_learning_logged_out_items.v1`](./LearningLoggedOutItemsSlot/)
* [`org.openedx.frontend.layout.header_learning_user_menu.v1`](./LearningUserMenuSlot/)
* [`org.openedx.frontend.layout.header_learning_user_menu.v1`](./LearningUserMenuSlot/)
### Mobile Header
* [`org.openedx.frontend.layout.header_mobile.v1`](./MobileHeaderSlot/)
* [`org.openedx.frontend.layout.header_mobile_logged_out_items.v1`](./MobileLoggedOutItemsSlot/)
* [`org.openedx.frontend.layout.header_mobile_main_menu.v1`](./MobileMainMenuSlot/)
* [`org.openedx.frontend.layout.header_mobile_user_menu.v1`](./MobileUserMenuSlot/)
* [`org.openedx.frontend.layout.header_mobile_user_menu_trigger.v1`](./MobileUserMenuToggleSlot/)
### Studio Header
* [`org.openedx.frontend.layout.studio_header_search_button_slot.v1`](./StudioHeaderSearchButtonSlot/)

View File

@@ -0,0 +1,89 @@
# Studio Header Search Button Slot
### Slot ID: `org.openedx.frontend.layout.studio_header_search_button_slot.v1`
## Description
This slot is used to replace/modify/hide the search button in the studio header.
## Examples
### Replace search with custom component
The following `env.config.jsx` will replace the search button entirely (in this case with a custom emoji icon):
![Search button being replaced](./images/studio_header_search_button_replace.png)
```jsx
import {
DIRECT_PLUGIN,
PLUGIN_OPERATIONS,
} from "@openedx/frontend-plugin-framework";
const config = {
pluginSlots: {
"org.openedx.frontend.layout.studio_header_search_button_slot.v1": {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: "custom_notification_tray",
type: DIRECT_PLUGIN,
RenderWidget: () => <span>🔔</span>,
},
},
],
},
},
};
export default config;
```
### Add custom component before and after search button
The following `env.config.jsx` will insert emoji after and before the search button
![Add custom component before and after search button](./images/studio_header_search_button_before_after.png)
```jsx
import {
DIRECT_PLUGIN,
PLUGIN_OPERATIONS,
} from "@openedx/frontend-plugin-framework";
const config = {
pluginSlots: {
"org.openedx.frontend.layout.studio_header_search_button_slot.v1": {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
priority: 10,
id: 'custom_notification_tray_before',
type: DIRECT_PLUGIN,
RenderWidget: () => <span>🔔</span>,
},
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
priority: 90,
id: 'custom_notification_tray_after',
type: DIRECT_PLUGIN,
RenderWidget: () => <span>🔕</span>,
},
},
],
},
};
export default config;
```
## API
- **Slot ID:** `org.openedx.frontend.layout.studio_header_search_button_slot.v1`
- **Component:** Uses the [PluginSlot](https://github.com/openedx/frontend-plugin-framework#pluginslot) from the Open edX Frontend Plugin Framework for plugin injection.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import { Nav, IconButton, Icon } from '@openedx/paragon';
import { Search } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../../studio-header/messages';
const StudioHeaderSearchButtonSlot = ({ searchButtonAction }) => {
const intl = useIntl();
return (
<PluginSlot
id="org.openedx.frontend.layout.studio_header_search_button_slot.v1"
>
{searchButtonAction && (
<Nav>
<IconButton
src={Search}
iconAs={Icon}
onClick={searchButtonAction}
aria-label={intl.formatMessage(messages['header.label.search.nav'])}
alt={intl.formatMessage(messages['header.label.search.nav'])}
/>
</Nav>
)}
</PluginSlot>
);
};
StudioHeaderSearchButtonSlot.propTypes = {
searchButtonAction: PropTypes.func,
};
export default StudioHeaderSearchButtonSlot;

View File

@@ -1,8 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import React, { type FunctionComponent } from 'react';
import { Link } from 'react-router-dom';
const BrandNav = ({
interface Props {
studioBaseUrl: string;
logo: string;
logoAltText: string;
}
const BrandNav: FunctionComponent<Props> = ({
studioBaseUrl,
logo,
logoAltText,
@@ -16,10 +21,4 @@ const BrandNav = ({
</Link>
);
BrandNav.propTypes = {
studioBaseUrl: PropTypes.string.isRequired,
logo: PropTypes.string.isRequired,
logoAltText: PropTypes.string.isRequired,
};
export default BrandNav;

View File

@@ -1,6 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import React, { type FunctionComponent } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
OverlayTrigger,
Tooltip,
@@ -9,48 +8,41 @@ import { Link } from 'react-router-dom';
import messages from './messages';
const CourseLockUp = ({
outlineLink,
org,
number,
title,
// injected
intl,
}) => (
<OverlayTrigger
placement="bottom"
overlay={(
<Tooltip id="course-lock-up">
{title}
</Tooltip>
)}
>
<Link
className="course-title-lockup mr-2"
to={outlineLink}
aria-label={intl.formatMessage(messages['header.label.courseOutline'])}
data-testid="course-lock-up-block"
interface Props {
outlineLink?: string;
org?: string;
number?: string;
title?: string;
}
const CourseLockUp: FunctionComponent<Props> = ({
outlineLink = '',
org = '',
number = '',
title = '',
}) => {
const intl = useIntl();
return (
<OverlayTrigger
placement="bottom"
overlay={(
<Tooltip id="course-lock-up">
{title}
</Tooltip>
)}
>
<span className="d-block small m-0 text-gray-800" data-testid="course-org-number">{org} {number}</span>
<span className="d-block m-0 font-weight-bold text-gray-800" data-testid="course-title">{title}</span>
</Link>
</OverlayTrigger>
);
CourseLockUp.propTypes = {
number: PropTypes.string,
org: PropTypes.string,
title: PropTypes.string,
outlineLink: PropTypes.string,
// injected
intl: intlShape.isRequired,
<Link
className="course-title-lockup mr-2"
to={outlineLink}
aria-label={intl.formatMessage(messages['header.label.courseOutline'])}
data-testid="course-lock-up-block"
>
<span className="d-block small m-0 text-gray-800" data-testid="course-org-number">{org} {number}</span>
<span className="d-block m-0 font-weight-bold text-gray-800" data-testid="course-title">{title}</span>
</Link>
</OverlayTrigger>
);
};
CourseLockUp.defaultProps = {
number: null,
org: null,
title: null,
outlineLink: null,
};
export default injectIntl(CourseLockUp);
export default CourseLockUp;

View File

@@ -1,23 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import React, { type ReactNode, type ComponentProps } from 'react';
import classNames from 'classnames';
import {
ActionRow,
Button,
Container,
Icon,
IconButton,
Nav,
Row,
} from '@openedx/paragon';
import { Close, MenuIcon, Search } from '@openedx/paragon/icons';
import { Close, MenuIcon } from '@openedx/paragon/icons';
import CourseLockUp from './CourseLockUp';
import UserMenu from './UserMenu';
import BrandNav from './BrandNav';
import NavDropdownMenu from './NavDropdownMenu';
import messages from './messages';
import StudioHeaderSearchButtonSlot from '../plugin-slots/StudioHeaderSearchButtonSlot';
export interface HeaderBodyProps {
studioBaseUrl: string;
logoutUrl: string;
setModalPopupTarget?: ((instance: HTMLButtonElement | null) => void) | null;
toggleModalPopup?: React.MouseEventHandler<HTMLButtonElement>;
isModalPopupOpen?: boolean;
number?: string;
org?: string;
title: string;
logo: string;
logoAltText: string;
authenticatedUserAvatar?: string;
username?: string;
isAdmin?: boolean;
isMobile?: boolean;
isHiddenMainMenu?: boolean;
mainMenuDropdowns?: {
id: string;
buttonTitle: ReactNode;
items: { title: ReactNode; href: string; }[];
}[];
outlineLink?: string;
searchButtonAction?: React.MouseEventHandler<HTMLButtonElement>;
containerProps?: Omit<ComponentProps<typeof Container>, 'children'>;
}
const HeaderBody = ({
logo,
@@ -31,16 +53,15 @@ const HeaderBody = ({
logoutUrl,
authenticatedUserAvatar,
isMobile,
setModalPopupTarget,
setModalPopupTarget = null,
toggleModalPopup,
isModalPopupOpen,
isHiddenMainMenu,
mainMenuDropdowns,
isModalPopupOpen = false,
isHiddenMainMenu = false,
mainMenuDropdowns = [],
outlineLink,
searchButtonAction,
containerProps,
}) => {
const intl = useIntl();
containerProps = {},
}: HeaderBodyProps) => {
const renderBrandNav = (
<BrandNav
@@ -52,7 +73,7 @@ const HeaderBody = ({
/>
);
const { className: containerClassName, ...restContainerProps } = containerProps || {};
const { className: containerClassName, ...restContainerProps } = containerProps;
return (
<Container
@@ -116,17 +137,9 @@ const HeaderBody = ({
</>
)}
<ActionRow.Spacer />
{searchButtonAction && (
<Nav>
<IconButton
src={Search}
iconAs={Icon}
onClick={searchButtonAction}
aria-label={intl.formatMessage(messages['header.label.search.nav'])}
alt={intl.formatMessage(messages['header.label.search.nav'])}
/>
</Nav>
)}
<StudioHeaderSearchButtonSlot
searchButtonAction={searchButtonAction}
/>
<Nav>
<UserMenu
{...{
@@ -144,53 +157,4 @@ const HeaderBody = ({
);
};
HeaderBody.propTypes = {
studioBaseUrl: PropTypes.string.isRequired,
logoutUrl: PropTypes.string.isRequired,
setModalPopupTarget: PropTypes.func,
toggleModalPopup: PropTypes.func,
isModalPopupOpen: PropTypes.bool,
number: PropTypes.string,
org: PropTypes.string,
title: PropTypes.string,
logo: PropTypes.string,
logoAltText: PropTypes.string,
authenticatedUserAvatar: PropTypes.string,
username: PropTypes.string,
isAdmin: PropTypes.bool,
isMobile: PropTypes.bool,
isHiddenMainMenu: PropTypes.bool,
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
buttonTitle: PropTypes.node,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.node,
})),
})),
outlineLink: PropTypes.string,
searchButtonAction: PropTypes.func,
containerProps: PropTypes.shape(Container.propTypes),
};
HeaderBody.defaultProps = {
setModalPopupTarget: null,
toggleModalPopup: null,
isModalPopupOpen: false,
logo: null,
logoAltText: null,
number: '',
org: '',
title: '',
authenticatedUserAvatar: null,
username: null,
isAdmin: false,
isMobile: false,
isHiddenMainMenu: false,
mainMenuDropdowns: [],
outlineLink: null,
searchButtonAction: null,
containerProps: {},
};
export default HeaderBody;

View File

@@ -1,19 +1,32 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import React, { type FunctionComponent, useState } from 'react';
import { useToggle, ModalPopup } from '@openedx/paragon';
import HeaderBody from './HeaderBody';
import HeaderBody, { type HeaderBodyProps } from './HeaderBody';
import MobileMenu from './MobileMenu';
const MobileHeader = ({
type Props = Pick<HeaderBodyProps,
| 'studioBaseUrl'
| 'logoutUrl'
| 'number'
| 'org'
| 'title'
| 'logo'
| 'logoAltText'
| 'authenticatedUserAvatar'
| 'username'
| 'isAdmin'
| 'mainMenuDropdowns'
| 'outlineLink'
>;
const MobileHeader: FunctionComponent<Props> = ({
mainMenuDropdowns,
...props
}) => {
const [isOpen, , close, toggle] = useToggle(false);
const [target, setTarget] = useState(null);
const [target, setTarget] = useState<HTMLButtonElement | null>(null);
return (
<>
{/* @ts-expect-error The type of 'props' is any until we convert from propTypes to TypeScript interface/types */}
<HeaderBody
{...props}
isMobile
@@ -36,39 +49,4 @@ const MobileHeader = ({
);
};
MobileHeader.propTypes = {
studioBaseUrl: PropTypes.string.isRequired, // eslint-disable-line react/no-unused-prop-types
logoutUrl: PropTypes.string.isRequired, // eslint-disable-line react/no-unused-prop-types
number: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
org: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
title: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
logo: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
logoAltText: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
authenticatedUserAvatar: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
username: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
isAdmin: PropTypes.bool, // eslint-disable-line react/no-unused-prop-types
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
buttonTitle: PropTypes.node,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.node,
})),
})),
outlineLink: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
};
MobileHeader.defaultProps = {
logo: null,
logoAltText: null,
number: null,
org: null,
title: null,
authenticatedUserAvatar: null,
username: null,
isAdmin: false,
mainMenuDropdowns: [],
outlineLink: null,
};
export default MobileHeader;

View File

@@ -1,16 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import React, { type ReactNode } from 'react';
import {
Dropdown,
DropdownButton,
} from '@openedx/paragon';
import { Link } from 'react-router-dom';
interface Props {
id: string;
buttonTitle: ReactNode;
items: { title: ReactNode; href: string; }[];
}
const NavDropdownMenu = ({
id,
buttonTitle,
items,
}) => (
}: Props) => (
<DropdownButton
id={id}
title={buttonTitle}
@@ -30,13 +35,4 @@ const NavDropdownMenu = ({
</DropdownButton>
);
NavDropdownMenu.propTypes = {
id: PropTypes.string.isRequired,
buttonTitle: PropTypes.node.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string.isRequired,
title: PropTypes.node.isRequired,
})).isRequired,
};
export default NavDropdownMenu;

View File

@@ -12,7 +12,6 @@ import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router-dom';
import StudioHeader from './StudioHeader';
import messages from './messages';
const authenticatedUser = {
userId: 3,
@@ -72,12 +71,8 @@ const props: React.ComponentProps<typeof StudioHeader> = {
},
],
outlineLink: 'tEsTLInK',
searchButtonAction: null,
searchButtonAction: undefined,
isNewHomePage: true,
// These default values shouldn't be needed but typescript is confused by propTypes; can remove after converting
// from propTypes to TypeScript:
containerProps: {},
isHiddenMainMenu: false,
};
describe('Header', () => {
@@ -119,16 +114,6 @@ describe('Header', () => {
expect(dropdownOption).toBeVisible();
});
it('maintenance should not be in user menu', async () => {
currentUser = { ...authenticatedUser, administrator: false };
const { getAllByRole, queryByText } = render(<RootWrapper {...props} />);
const userMenu = getAllByRole('button')[1];
await waitFor(() => fireEvent.click(userMenu));
const maintenanceButton = queryByText(messages['header.user.menu.maintenance'].defaultMessage);
expect(maintenanceButton).toBeNull();
});
it('user menu should use avatar icon', async () => {
currentUser = { ...authenticatedUser, avatar: null };
const { getByTestId } = render(<RootWrapper {...props} />);
@@ -157,7 +142,7 @@ describe('Header', () => {
});
it('should not show search button', async () => {
const testProps = { ...props, searchButtonAction: null };
const testProps = { ...props, searchButtonAction: undefined };
const { queryByRole } = render(<RootWrapper {...testProps} />);
expect(queryByRole('button', { name: 'Search content' })).not.toBeInTheDocument();
});
@@ -190,15 +175,6 @@ describe('Header', () => {
expect(desktopMenu).toBeNull();
});
it('maintenance should be in user menu', async () => {
const { getAllByRole, getByText } = render(<RootWrapper {...props} />);
const userMenu = getAllByRole('button')[1];
await waitFor(() => fireEvent.click(userMenu));
const maintenanceButton = getByText(messages['header.user.menu.maintenance'].defaultMessage);
expect(maintenanceButton).toBeVisible();
});
it('user menu should use avatar image', async () => {
const { getByTestId } = render(<RootWrapper {...props} />);
const avatarImage = getByTestId('avatar-image');

View File

@@ -1,11 +1,10 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import React, { type FunctionComponent, useContext } from 'react';
import Responsive from 'react-responsive';
import { AppContext } from '@edx/frontend-platform/react';
import { ensureConfig } from '@edx/frontend-platform';
import MobileHeader from './MobileHeader';
import HeaderBody from './HeaderBody';
import HeaderBody, { HeaderBodyProps } from './HeaderBody';
ensureConfig([
'STUDIO_BASE_URL',
@@ -15,9 +14,29 @@ ensureConfig([
'LOGO_URL',
], 'Studio Header component');
const StudioHeader = ({
number, org, title, containerProps, isHiddenMainMenu, mainMenuDropdowns,
outlineLink, searchButtonAction, isNewHomePage,
type Props = Pick<HeaderBodyProps,
| 'number'
| 'org'
| 'title'
| 'containerProps'
| 'isHiddenMainMenu'
| 'mainMenuDropdowns'
| 'outlineLink'
| 'searchButtonAction'
> & {
isNewHomePage: boolean;
};
const StudioHeader: FunctionComponent<Props> = ({
number,
org,
title,
containerProps,
isHiddenMainMenu,
mainMenuDropdowns,
outlineLink,
searchButtonAction,
isNewHomePage,
}) => {
// @ts-expect-error - frontend-platform doesn't yet have type information :/
const { authenticatedUser, config } = useContext(AppContext);
@@ -52,33 +71,4 @@ const StudioHeader = ({
);
};
StudioHeader.propTypes = {
number: PropTypes.string,
org: PropTypes.string,
title: PropTypes.string.isRequired,
containerProps: HeaderBody.propTypes.containerProps,
isHiddenMainMenu: PropTypes.bool,
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
buttonTitle: PropTypes.node,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.node,
})),
})),
outlineLink: PropTypes.string,
searchButtonAction: PropTypes.func,
isNewHomePage: PropTypes.bool.isRequired,
};
StudioHeader.defaultProps = {
number: '',
org: '',
containerProps: {},
isHiddenMainMenu: false,
mainMenuDropdowns: [],
outlineLink: null,
searchButtonAction: null,
};
export default StudioHeader;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Avatar,
} from '@openedx/paragon';
@@ -14,9 +14,8 @@ const UserMenu = ({
authenticatedUserAvatar,
isMobile,
isAdmin,
// injected
intl,
}) => {
const intl = useIntl();
const avatar = authenticatedUserAvatar ? (
<img
className="d-block w-100 h-100"
@@ -55,8 +54,6 @@ UserMenu.propTypes = {
authenticatedUserAvatar: PropTypes.string,
isMobile: PropTypes.bool,
isAdmin: PropTypes.bool,
// injected
intl: intlShape.isRequired,
};
UserMenu.defaultProps = {
@@ -66,4 +63,4 @@ UserMenu.defaultProps = {
username: null,
};
export default injectIntl(UserMenu);
export default UserMenu;

View File

@@ -6,11 +6,6 @@ const messages = defineMessages({
defaultMessage: 'Studio Home',
description: 'Link to Studio Home',
},
'header.user.menu.maintenance': {
id: 'header.user.menu.maintenance',
defaultMessage: 'Maintenance',
description: 'Link to the Studio maintenance page',
},
'header.user.menu.logout': {
id: 'header.user.menu.logout',
defaultMessage: 'Logout',

View File

@@ -1,4 +1,3 @@
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
const getUserMenuItems = ({
@@ -21,9 +20,6 @@ const getUserMenuItems = ({
{
href: `${studioBaseUrl}`,
title: intl.formatMessage(messages['header.user.menu.studio']),
}, {
href: `${getConfig().STUDIO_BASE_URL}/maintenance`,
title: intl.formatMessage(messages['header.user.menu.maintenance']),
}, {
href: `${logoutUrl}`,
title: intl.formatMessage(messages['header.user.menu.logout']),