Compare commits

...

17 Commits

Author SHA1 Message Date
Kyle McCormick
a229c34535 fix: Remove Studio Maintenance link (#553)
This Studio Maintenance app has been broken for a long time,
so it is being removed from edx-platform:
https://github.com/openedx/edx-platform/pull/35852
2024-11-15 10:52:36 -05:00
renovate[bot]
5d7b4fecf4 chore(deps): update dependency react-router-dom to v6.28.0 (#548)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-11 07:42:32 +00:00
renovate[bot]
f04130a7c6 fix(deps): update dependency @openedx/frontend-plugin-framework to v1.4.0 2024-11-04 07:18:59 +00:00
Peter Kulko
cb7774b325 feat: improved SPA routes 2024-11-01 13:03:59 -03:00
Bilal Qamar
3e4eb21d8c test: Remove support for Node 18 (#536) 2024-10-31 16:05:16 -04:00
Brian Smith
a346dccd4c feat: add frontend-plugin-framework slots (#545)
Add the following `frontend-plugin-framework` slots:
* `logo_slot`
* `desktop_main_menu_slot`
* `desktop_secondary_menu_slot`
* `mobile_main_menu_slot`
* `course_info_slot`
* `learning_help_slot`
* `desktop_logged_out_items_slot`
* `mobile_logged_out_items_slot`
* `mobile_user_menu_slot`
* `desktop_user_menu_slot`
* `learning_user_menu_slot`
* `learning_logged_out_items_slot`
* `desktop_header_slot`
2024-10-22 12:18:11 -04:00
renovate[bot]
c64a201072 chore(deps): update dependency @openedx/paragon to v22.9.0 2024-10-21 04:45:01 +00:00
renovate[bot]
6496642643 chore(deps): update dependency react-router-dom to v6.27.0 2024-10-14 04:43:06 +00:00
renovate[bot]
a6c36654b4 chore(deps): update dependency @edx/frontend-platform to v8.1.2 2024-10-07 07:34:11 +00:00
Rômulo Penido
ae5253c822 feat: expose containerProps in StudioHeader [FC-0062] (#529) 2024-10-01 09:20:34 -04:00
renovate[bot]
e44001e945 chore(deps): update dependency @openedx/paragon to v22.8.1 2024-09-30 10:31:20 +00:00
renovate[bot]
e07cf665a4 chore(deps): update dependency @openedx/frontend-build to v14.1.5 2024-09-30 06:36:53 +00:00
Brian Smith
8213ee7460 feat: add frontend-plugin-framework LogoSlot (#528) 2024-09-26 16:07:41 -04:00
renovate[bot]
8a7d6eecdf chore(deps): update dependency react-router-dom to v6.26.2 2024-09-23 07:57:54 +00:00
renovate[bot]
a2497eeb22 chore(deps): update dependency @openedx/frontend-build to v14.1.4 2024-09-23 07:57:36 +00:00
Bilal Qamar
a703abad76 build: Upgrade to Node 20 (#535) 2024-09-19 17:50:06 -04:00
Bilal Qamar
3f4d987d12 test: Add Node 20 to CI matrix (#533) 2024-09-16 11:42:21 -04:00
102 changed files with 4158 additions and 3805 deletions

View File

@@ -14,12 +14,10 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs - name: Setup Nodejs
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VER }} node-version-file: '.nvmrc'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Validate package-lock.json changes - name: Validate package-lock.json changes

View File

@@ -10,4 +10,4 @@ on:
jobs: jobs:
version-check: version-check:
uses: openedx/.github/.github/workflows/lockfile-check.yml@master uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

2
.nvmrc
View File

@@ -1 +1 @@
18 20

View File

@@ -95,6 +95,12 @@ 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>`_ * ``messages``: Internationalization messages suitable for use with `@edx/frontend-platform/i18n <https://edx.github.io/frontend-platform/module-Internationalization.html>`_
* ``dist/index.scss``: A SASS file which contains style information for the component. It should be imported into the micro-frontend's own SCSS file. * ``dist/index.scss``: A SASS file which contains style information for the component. It should be imported into the micro-frontend's own SCSS file.
Plugins
-------
This can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
The parts of this that can be customized in that manner are documented `here </src/plugin-slots>`_.
Examples Examples
======== ========

4607
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,10 +35,10 @@
"devDependencies": { "devDependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/browserslist-config": "^1.1.1", "@edx/browserslist-config": "^1.1.1",
"@edx/frontend-platform": "8.1.1", "@edx/frontend-platform": "8.1.2",
"@edx/reactifex": "^2.1.1", "@edx/reactifex": "^2.1.1",
"@openedx/frontend-build": "14.1.2", "@openedx/frontend-build": "14.1.5",
"@openedx/paragon": "22.7.0", "@openedx/paragon": "22.9.0",
"@testing-library/dom": "10.4.0", "@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "5.17.0", "@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "10.4.9", "@testing-library/react": "10.4.9",
@@ -49,7 +49,7 @@
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-redux": "7.2.9", "react-redux": "7.2.9",
"react-router-dom": "6.26.1", "react-router-dom": "6.28.0",
"react-test-renderer": "17.0.2", "react-test-renderer": "17.0.2",
"redux": "4.2.1", "redux": "4.2.1",
"redux-saga": "1.3.0" "redux-saga": "1.3.0"
@@ -60,8 +60,10 @@
"@fortawesome/free-regular-svg-icons": "6.6.0", "@fortawesome/free-regular-svg-icons": "6.6.0",
"@fortawesome/free-solid-svg-icons": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@openedx/frontend-plugin-framework": "^1.3.0",
"axios-mock-adapter": "1.22.0", "axios-mock-adapter": "1.22.0",
"babel-polyfill": "6.26.0", "babel-polyfill": "6.26.0",
"classnames": "^2.5.1",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"react-responsive": "8.2.0", "react-responsive": "8.2.0",
"react-transition-group": "4.4.5" "react-transition-group": "4.4.5"
@@ -71,6 +73,7 @@
"@openedx/paragon": ">= 21.5.7 < 23.0.0", "@openedx/paragon": ">= 21.5.7 < 23.0.0",
"prop-types": "^15.5.10", "prop-types": "^15.5.10",
"react": "^16.9.0 || ^17.0.0", "react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0" "react-dom": "^16.9.0 || ^17.0.0",
"react-router-dom": "^6.14.2"
} }
} }

View File

@@ -1,222 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
// Local Components
import { Menu, MenuTrigger, MenuContent } from './Menu';
import Avatar from './Avatar';
import { LinkedLogo, Logo } from './Logo';
// i18n
import messages from './Header.messages';
// Assets
import { CaretIcon } from './Icons';
class DesktopHeader extends React.Component {
constructor(props) { // eslint-disable-line no-useless-constructor
super(props);
}
renderMenu(menu) {
// Nodes are accepted as a prop
if (!Array.isArray(menu)) {
return menu;
}
return menu.map((menuItem) => {
const {
type,
href,
content,
submenuContent,
disabled,
isActive,
onClick,
} = menuItem;
if (type === 'item') {
return (
<a
key={`${type}-${content}`}
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
href={href}
onClick={onClick || null}
>
{content}
</a>
);
}
return (
<Menu key={`${type}-${content}`} tag="div" className="nav-item" respondToPointerEvents>
<MenuTrigger onClick={onClick || null} tag="a" className="nav-link d-inline-flex align-items-center" href={href}>
{content} <CaretIcon role="img" aria-hidden focusable="false" />
</MenuTrigger>
<MenuContent className="pin-left pin-right shadow py-2">
{submenuContent}
</MenuContent>
</Menu>
);
});
}
renderMainMenu() {
const { mainMenu } = this.props;
return this.renderMenu(mainMenu);
}
renderSecondaryMenu() {
const { secondaryMenu } = this.props;
return this.renderMenu(secondaryMenu);
}
renderUserMenu() {
const {
userMenu,
avatar,
username,
intl,
} = this.props;
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">
{userMenu.map((group, index) => (
// eslint-disable-next-line react/jsx-no-comment-textnodes,react/no-array-index-key
<React.Fragment key={index}>
{group.heading && <div className="dropdown-header" role="heading" aria-level="1">{group.heading}</div>}
{group.items.map(({
type, content, href, disabled, isActive, onClick,
}) => (
<a
className={`dropdown-${type}${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
key={`${type}-${content}`}
href={href}
onClick={onClick || null}
>
{content}
</a>
))}
{index < userMenu.length - 1 && <div className="dropdown-divider" role="separator" />}
</React.Fragment>
))}
</MenuContent>
</Menu>
);
}
renderLoggedOutItems() {
const { loggedOutItems } = this.props;
return loggedOutItems.map((item, i, arr) => (
<a
key={`${item.type}-${item.content}`}
className={i < arr.length - 1 ? 'btn mr-2 btn-link' : 'btn mr-2 btn-outline-primary'}
href={item.href}
>
{item.content}
</a>
));
}
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">
{logoDestination === null ? <Logo className="logo" src={logo} alt={logoAltText} /> : <LinkedLogo className="logo" {...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>
</div>
</header>
);
}
}
DesktopHeader.propTypes = {
mainMenu: PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]),
secondaryMenu: PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]),
userMenu: PropTypes.arrayOf(PropTypes.shape({
heading: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
isActive: PropTypes.bool,
onClick: PropTypes.func,
})),
})),
loggedOutItems: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
})),
logo: PropTypes.string,
logoAltText: PropTypes.string,
logoDestination: PropTypes.string,
avatar: PropTypes.string,
username: PropTypes.string,
loggedIn: PropTypes.bool,
// i18n
intl: intlShape.isRequired,
};
DesktopHeader.defaultProps = {
mainMenu: [],
secondaryMenu: [],
userMenu: [],
loggedOutItems: [],
logo: null,
logoAltText: null,
logoDestination: null,
avatar: null,
username: null,
loggedIn: false,
};
export default injectIntl(DesktopHeader);

View File

@@ -11,8 +11,8 @@ import {
} from '@edx/frontend-platform'; } from '@edx/frontend-platform';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import DesktopHeader from './DesktopHeader'; import DesktopHeaderSlot from './plugin-slots/DesktopHeaderSlot';
import MobileHeader from './MobileHeader'; import MobileHeaderSlot from './plugin-slots/MobileHeaderSlot';
import messages from './Header.messages'; import messages from './Header.messages';
@@ -123,10 +123,10 @@ const Header = ({
return ( return (
<> <>
<Responsive maxWidth={769}> <Responsive maxWidth={769}>
<MobileHeader {...props} /> <MobileHeaderSlot props={props} />
</Responsive> </Responsive>
<Responsive minWidth={769}> <Responsive minWidth={769}>
<DesktopHeader {...props} /> <DesktopHeaderSlot props={props} />
</Responsive> </Responsive>
</> </>
); );

View File

@@ -61,11 +61,6 @@ const messages = defineMessages({
defaultMessage: 'Studio Home', defaultMessage: 'Studio Home',
description: 'Link to the 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': { 'header.label.account.nav': {
id: 'header.label.account.nav', id: 'header.label.account.nav',
defaultMessage: 'Account', defaultMessage: 'Account',

View File

@@ -1,31 +1,23 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
const Logo = ({ src, alt, ...attributes }) => ( const Logo = ({
<img src={src} alt={alt} {...attributes} />
);
Logo.propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
};
const LinkedLogo = ({
href, href,
src, src,
alt, alt,
...attributes ...attributes
}) => ( }) => (
<a href={href} {...attributes}> <a href={href} className="logo" {...attributes}>
<img className="d-block" src={src} alt={alt} /> <img className="d-block" src={src} alt={alt} />
</a> </a>
); );
LinkedLogo.propTypes = { export const logoDataShape = {
href: PropTypes.string.isRequired, href: PropTypes.string.isRequired,
src: PropTypes.string.isRequired, src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired, alt: PropTypes.string.isRequired,
}; };
export { LinkedLogo, Logo }; Logo.propTypes = logoDataShape;
export default Logo; export default Logo;

View File

@@ -0,0 +1,153 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
// Local Components
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';
import DesktopMainMenuSlot from '../plugin-slots/DesktopMainMenuSlot';
import { desktopHeaderMainOrSecondaryMenuDataShape } from './DesktopHeaderMainOrSecondaryMenu';
import DesktopSecondaryMenuSlot from '../plugin-slots/DesktopSecondaryMenuSlot';
import DesktopUserMenuSlot from '../plugin-slots/DesktopUserMenuSlot';
import { desktopUserMenuDataShape } from './DesktopHeaderUserMenu';
// i18n
import messages from '../Header.messages';
// Assets
import { CaretIcon } from '../Icons';
class DesktopHeader extends React.Component {
constructor(props) { // eslint-disable-line no-useless-constructor
super(props);
}
renderMainMenu() {
const { mainMenu } = this.props;
return <DesktopMainMenuSlot menu={mainMenu} />;
}
renderSecondaryMenu() {
const { secondaryMenu } = this.props;
return <DesktopSecondaryMenuSlot menu={secondaryMenu} />;
}
renderUserMenu() {
const {
userMenu,
avatar,
username,
intl,
} = this.props;
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>
);
}
renderLoggedOutItems() {
const { loggedOutItems } = this.props;
return <DesktopLoggedOutItemsSlot items={loggedOutItems} />;
}
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>
</div>
</header>
);
}
}
export const desktopHeaderDataShape = {
mainMenu: desktopHeaderMainOrSecondaryMenuDataShape,
secondaryMenu: desktopHeaderMainOrSecondaryMenuDataShape,
userMenu: desktopUserMenuDataShape,
loggedOutItems: desktopLoggedOutItemsDataShape,
logo: PropTypes.string,
logoAltText: PropTypes.string,
logoDestination: PropTypes.string,
avatar: PropTypes.string,
username: PropTypes.string,
loggedIn: PropTypes.bool,
};
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,
};
DesktopHeader.defaultProps = {
mainMenu: [],
secondaryMenu: [],
userMenu: [],
loggedOutItems: [],
logo: null,
logoAltText: null,
logoDestination: null,
avatar: null,
username: null,
loggedIn: false,
};
export default injectIntl(DesktopHeader);

View File

@@ -0,0 +1,59 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Menu, MenuTrigger, MenuContent } from '../Menu';
import { CaretIcon } from '../Icons';
const DesktopHeaderMainOrSecondaryMenu = ({ menu }) => {
// Nodes are accepted as a prop
if (!Array.isArray(menu)) {
return menu;
}
return menu.map((menuItem) => {
const {
type,
href,
content,
submenuContent,
disabled,
isActive,
onClick,
} = menuItem;
if (type === 'item') {
return (
<a
key={`${type}-${content}`}
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
href={href}
onClick={onClick || null}
>
{content}
</a>
);
}
return (
<Menu key={`${type}-${content}`} tag="div" className="nav-item" respondToPointerEvents>
<MenuTrigger onClick={onClick || null} tag="a" className="nav-link d-inline-flex align-items-center" href={href}>
{content} <CaretIcon role="img" aria-hidden focusable="false" />
</MenuTrigger>
<MenuContent className="pin-left pin-right shadow py-2">
{submenuContent}
</MenuContent>
</Menu>
);
});
};
export const desktopHeaderMainOrSecondaryMenuDataShape = PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]);
DesktopHeaderMainOrSecondaryMenu.propTypes = {
menu: desktopHeaderMainOrSecondaryMenuDataShape,
};
export default DesktopHeaderMainOrSecondaryMenu;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import PropTypes from 'prop-types';
const DesktopHeaderUserMenu = ({ menu }) => menu.map((group, index) => (
// eslint-disable-next-line react/jsx-no-comment-textnodes,react/no-array-index-key
<React.Fragment key={index}>
{group.heading && <div className="dropdown-header" role="heading" aria-level="1">{group.heading}</div>}
{group.items.map(({
type, content, href, disabled, isActive, onClick,
}) => (
<a
className={`dropdown-${type}${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
key={`${type}-${content}`}
href={href}
onClick={onClick || null}
>
{content}
</a>
))}
{index < menu.length - 1 && <div className="dropdown-divider" role="separator" />}
</React.Fragment>
));
export const desktopUserMenuDataShape = PropTypes.arrayOf(PropTypes.shape({
heading: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
isActive: PropTypes.bool,
onClick: PropTypes.func,
})),
}));
DesktopHeaderUserMenu.propTypes = {
menu: desktopUserMenuDataShape,
};
export default DesktopHeaderUserMenu;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
const DesktopLoggedOutItems = ({ items }) => items.map((item, i, arr) => (
<a
key={`${item.type}-${item.content}`}
className={i < arr.length - 1 ? 'btn mr-2 btn-link' : 'btn mr-2 btn-outline-primary'}
href={item.href}
>
{item.content}
</a>
));
export const desktopLoggedOutItemsDataShape = PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
}));
DesktopLoggedOutItems.propTypes = {
items: desktopLoggedOutItemsDataShape,
};
export default DesktopLoggedOutItems;

View File

@@ -3,27 +3,25 @@ import React from 'react';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth'; import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon'; import LearningLoggedOutItemsSlot from '../plugin-slots/LearningLoggedOutItemsSlot';
import genericMessages from '../generic/messages'; import genericMessages from '../generic/messages';
const AnonymousUserMenu = ({ intl }) => ( const AnonymousUserMenu = ({ intl }) => {
<div> const buttonsInfo = [
<Button {
className="mr-3" message: intl.formatMessage(genericMessages.registerSentenceCase),
variant="outline-primary" href: `${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`,
href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`} },
> {
{intl.formatMessage(genericMessages.registerSentenceCase)} message: intl.formatMessage(genericMessages.signInSentenceCase),
</Button> href: getLoginRedirectUrl(global.location.href),
<Button variant: 'primary',
variant="primary" },
href={`${getLoginRedirectUrl(global.location.href)}`} ];
>
{intl.formatMessage(genericMessages.signInSentenceCase)} return <LearningLoggedOutItemsSlot buttonsInfo={buttonsInfo} />;
</Button> };
</div>
);
AnonymousUserMenu.propTypes = { AnonymousUserMenu.propTypes = {
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -7,44 +7,46 @@ import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@openedx/paragon'; import { Dropdown } from '@openedx/paragon';
import LearningUserMenuSlot from '../plugin-slots/LearningUserMenuSlot';
import messages from './messages'; import messages from './messages';
const AuthenticatedUserDropdown = ({ intl, username }) => { const AuthenticatedUserDropdown = ({ intl, username }) => {
const dashboardMenuItem = ( const dropdownItems = [
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}> {
{intl.formatMessage(messages.dashboard)} message: intl.formatMessage(messages.dashboard),
</Dropdown.Item> href: `${getConfig().LMS_BASE_URL}/dashboard`,
); },
{
message: intl.formatMessage(messages.profile),
href: `${getConfig().ACCOUNT_PROFILE_URL}/u/${username}`,
},
{
message: intl.formatMessage(messages.account),
href: getConfig().ACCOUNT_SETTINGS_URL,
},
...(getConfig().ORDER_HISTORY_URL ? [{
message: intl.formatMessage(messages.orderHistory),
href: getConfig().ORDER_HISTORY_URL,
}] : []),
{
message: intl.formatMessage(messages.signOut),
href: getConfig().LOGOUT_URL,
},
];
return ( return (
<> <Dropdown className="user-dropdown ml-3">
<a className="text-gray-700" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a> <Dropdown.Toggle variant="outline-primary">
<Dropdown className="user-dropdown ml-3"> <FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
<Dropdown.Toggle variant="outline-primary"> <span data-hj-suppress className="d-none d-md-inline">
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" /> {username}
<span data-hj-suppress className="d-none d-md-inline"> </span>
{username} </Dropdown.Toggle>
</span> <Dropdown.Menu className="dropdown-menu-right">
</Dropdown.Toggle> <LearningUserMenuSlot items={dropdownItems} />
<Dropdown.Menu className="dropdown-menu-right"> </Dropdown.Menu>
{dashboardMenuItem} </Dropdown>
<Dropdown.Item href={`${getConfig().ACCOUNT_PROFILE_URL}/u/${username}`}>
{intl.formatMessage(messages.profile)}
</Dropdown.Item>
<Dropdown.Item href={getConfig().ACCOUNT_SETTINGS_URL}>
{intl.formatMessage(messages.account)}
</Dropdown.Item>
{ getConfig().ORDER_HISTORY_URL && (
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>
{intl.formatMessage(messages.orderHistory)}
</Dropdown.Item>
)}
<Dropdown.Item href={getConfig().LOGOUT_URL}>
{intl.formatMessage(messages.signOut)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</>
); );
}; };

View File

@@ -6,24 +6,11 @@ import { AppContext } from '@edx/frontend-platform/react';
import AnonymousUserMenu from './AnonymousUserMenu'; import AnonymousUserMenu from './AnonymousUserMenu';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import LogoSlot from '../plugin-slots/LogoSlot';
import CourseInfoSlot from '../plugin-slots/CourseInfoSlot';
import { courseInfoDataShape } from './LearningHeaderCourseInfo';
import messages from './messages'; import messages from './messages';
import LearningHelpSlot from '../plugin-slots/LearningHelpSlot';
const LinkedLogo = ({
href,
src,
alt,
...attributes
}) => (
<a href={href} {...attributes}>
<img className="d-block" src={src} alt={alt} />
</a>
);
LinkedLogo.propTypes = {
href: PropTypes.string.isRequired,
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
};
const LearningHeader = ({ const LearningHeader = ({
courseOrg, courseNumber, courseTitle, intl, showUserDropdown, courseOrg, courseNumber, courseTitle, intl, showUserDropdown,
@@ -31,8 +18,7 @@ const LearningHeader = ({
const { authenticatedUser } = useContext(AppContext); const { authenticatedUser } = useContext(AppContext);
const headerLogo = ( const headerLogo = (
<LinkedLogo <LogoSlot
className="logo"
href={`${getConfig().LMS_BASE_URL}/dashboard`} href={`${getConfig().LMS_BASE_URL}/dashboard`}
src={getConfig().LOGO_URL} src={getConfig().LOGO_URL}
alt={getConfig().SITE_NAME} alt={getConfig().SITE_NAME}
@@ -44,14 +30,16 @@ const LearningHeader = ({
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a> <a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
<div className="container-xl py-2 d-flex align-items-center"> <div className="container-xl py-2 d-flex align-items-center">
{headerLogo} {headerLogo}
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}> <div className="flex-grow-1 course-title-lockup d-flex" style={{ lineHeight: 1 }}>
<span className="d-block small m-0">{courseOrg} {courseNumber}</span> <CourseInfoSlot courseOrg={courseOrg} courseNumber={courseNumber} courseTitle={courseTitle} />
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
</div> </div>
{showUserDropdown && authenticatedUser && ( {showUserDropdown && authenticatedUser && (
<AuthenticatedUserDropdown <>
username={authenticatedUser.username} <LearningHelpSlot />
/> <AuthenticatedUserDropdown
username={authenticatedUser.username}
/>
</>
)} )}
{showUserDropdown && !authenticatedUser && ( {showUserDropdown && !authenticatedUser && (
<AnonymousUserMenu /> <AnonymousUserMenu />
@@ -62,9 +50,9 @@ const LearningHeader = ({
}; };
LearningHeader.propTypes = { LearningHeader.propTypes = {
courseOrg: PropTypes.string, courseOrg: courseInfoDataShape.courseOrg,
courseNumber: PropTypes.string, courseNumber: courseInfoDataShape.courseNumber,
courseTitle: PropTypes.string, courseTitle: courseInfoDataShape.courseTitle,
intl: intlShape.isRequired, intl: intlShape.isRequired,
showUserDropdown: PropTypes.bool, showUserDropdown: PropTypes.bool,
}; };

View File

@@ -0,0 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
const LearningHeaderCourseInfo = ({
courseOrg,
courseNumber,
courseTitle,
}) => (
<div style={{ minWidth: 0 }}>
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
</div>
);
export const courseInfoDataShape = {
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
};
LearningHeaderCourseInfo.propTypes = courseInfoDataShape;
export default LearningHeaderCourseInfo;

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const LearningHeaderHelpLink = () => {
const intl = useIntl();
return (
<a className="text-gray-700" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
);
};
export default LearningHeaderHelpLink;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Dropdown } from '@openedx/paragon';
const LearningHeaderUserMenuItems = ({ items }) => items.map((item) => (
<Dropdown.Item href={item.href}>
{item.message}
</Dropdown.Item>
));
export const learningHeaderUserMenuDataShape = {
items: PropTypes.arrayOf(PropTypes.shape({
message: PropTypes.string,
href: PropTypes.string,
})),
};
LearningHeaderUserMenuItems.propTypes = learningHeaderUserMenuDataShape;
export default LearningHeaderUserMenuItems;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@openedx/paragon';
const LearningLoggedOutButtons = ({ buttonsInfo }) => buttonsInfo.map(buttonInfo => (
<Button
className="ml-3"
variant={buttonInfo.variant ?? 'outline-primary'}
href={buttonInfo.href}
>
{buttonInfo.message}
</Button>
));
export const learningHeaderLoggedOutItemsDataShape = {
buttonsInfo: PropTypes.arrayOf(PropTypes.shape({
message: PropTypes.string,
href: PropTypes.string,
variant: PropTypes.string,
})),
};
LearningLoggedOutButtons.propTypes = learningHeaderLoggedOutItemsDataShape;
export default LearningLoggedOutButtons;

View File

@@ -4,107 +4,40 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
// Local Components // Local Components
import { Menu, MenuTrigger, MenuContent } from './Menu'; import { Menu, MenuTrigger, MenuContent } from '../Menu';
import Avatar from './Avatar'; import Avatar from '../Avatar';
import { LinkedLogo, Logo } from './Logo'; import LogoSlot from '../plugin-slots/LogoSlot';
import MobileLoggedOutItemsSlot from '../plugin-slots/MobileLoggedOutItemsSlot';
import { mobileHeaderLoggedOutItemsDataShape } from './MobileLoggedOutItems';
import MobileMainMenuSlot from '../plugin-slots/MobileMainMenuSlot';
import { mobileHeaderMainMenuDataShape } from './MobileHeaderMainMenu';
import MobileUserMenuSlot from '../plugin-slots/MobileUserMenuSlot';
import { mobileHeaderUserMenuDataShape } from './MobileHeaderUserMenu';
// i18n // i18n
import messages from './Header.messages'; import messages from '../Header.messages';
// Assets // Assets
import { MenuIcon } from './Icons'; import { MenuIcon } from '../Icons';
class MobileHeader extends React.Component { class MobileHeader extends React.Component {
constructor(props) { // eslint-disable-line no-useless-constructor constructor(props) { // eslint-disable-line no-useless-constructor
super(props); super(props);
} }
renderMenu(menu) {
// Nodes are accepted as a prop
if (!Array.isArray(menu)) {
return menu;
}
return menu.map((menuItem) => {
const {
type,
href,
content,
submenuContent,
disabled,
isActive,
onClick,
} = menuItem;
if (type === 'item') {
return (
<a
key={`${type}-${content}`}
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
href={href}
onClick={onClick || null}
>
{content}
</a>
);
}
return (
<Menu key={`${type}-${content}`} tag="div" className="nav-item">
<MenuTrigger onClick={onClick || null} tag="a" role="button" tabIndex="0" className="nav-link">
{content}
</MenuTrigger>
<MenuContent className="position-static pin-left pin-right py-2">
{submenuContent}
</MenuContent>
</Menu>
);
});
}
renderMainMenu() { renderMainMenu() {
const { mainMenu } = this.props; const { mainMenu, secondaryMenu } = this.props;
return this.renderMenu(mainMenu); return <MobileMainMenuSlot menu={[...mainMenu, ...secondaryMenu]} />;
}
renderSecondaryMenu() {
const { secondaryMenu } = this.props;
return this.renderMenu(secondaryMenu);
} }
renderUserMenuItems() { renderUserMenuItems() {
const { userMenu } = this.props; const { userMenu } = this.props;
return <MobileUserMenuSlot menu={userMenu} />;
return userMenu.map((group) => (
group.items.map(({
type, content, href, disabled, isActive, onClick,
}) => (
<li className="nav-item" key={`${type}-${content}`}>
<a
className={`nav-link${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
href={href}
onClick={onClick || null}
>
{content}
</a>
</li>
))
));
} }
renderLoggedOutItems() { renderLoggedOutItems() {
const { loggedOutItems } = this.props; const { loggedOutItems } = this.props;
return <MobileLoggedOutItemsSlot items={loggedOutItems} />;
return loggedOutItems.map(({ type, href, content }, i, arr) => (
<li className="nav-item px-3 my-2" key={`${type}-${content}`}>
<a
className={i < arr.length - 1 ? 'btn btn-block btn-outline-primary' : 'btn btn-block btn-primary'}
href={href}
>
{content}
</a>
</li>
));
} }
render() { render() {
@@ -149,13 +82,12 @@ class MobileHeader extends React.Component {
className="nav flex-column pin-left pin-right border-top shadow py-2" className="nav flex-column pin-left pin-right border-top shadow py-2"
> >
{this.renderMainMenu()} {this.renderMainMenu()}
{this.renderSecondaryMenu()}
</MenuContent> </MenuContent>
</Menu> </Menu>
</div> </div>
) : null} ) : null}
<div className={`w-100 d-flex ${logoClasses}`}> <div className={`w-100 d-flex ${logoClasses}`}>
{ logoDestination === null ? <Logo className="logo" src={logo} alt={logoAltText} /> : <LinkedLogo className="logo" {...logoProps} itemType="http://schema.org/Organization" />} <LogoSlot {...logoProps} itemType="http://schema.org/Organization" />
</div> </div>
{userMenu.length > 0 || loggedOutItems.length > 0 ? ( {userMenu.length > 0 || loggedOutItems.length > 0 ? (
<div className="w-100 d-flex justify-content-end align-items-center"> <div className="w-100 d-flex justify-content-end align-items-center">
@@ -179,30 +111,11 @@ class MobileHeader extends React.Component {
} }
} }
MobileHeader.propTypes = { export const mobileHeaderDataShape = {
mainMenu: PropTypes.oneOfType([ mainMenu: mobileHeaderMainMenuDataShape,
PropTypes.node, secondaryMenu: mobileHeaderMainMenuDataShape,
PropTypes.array, userMenu: mobileHeaderUserMenuDataShape,
]), loggedOutItems: mobileHeaderLoggedOutItemsDataShape,
secondaryMenu: PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]),
userMenu: PropTypes.arrayOf(PropTypes.shape({
heading: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
isActive: PropTypes.bool,
onClick: PropTypes.func,
})),
})),
loggedOutItems: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
})),
logo: PropTypes.string, logo: PropTypes.string,
logoAltText: PropTypes.string, logoAltText: PropTypes.string,
logoDestination: PropTypes.string, logoDestination: PropTypes.string,
@@ -210,6 +123,20 @@ MobileHeader.propTypes = {
username: PropTypes.string, username: PropTypes.string,
loggedIn: PropTypes.bool, loggedIn: PropTypes.bool,
stickyOnMobile: PropTypes.bool, stickyOnMobile: PropTypes.bool,
};
MobileHeader.propTypes = {
mainMenu: mobileHeaderDataShape.mainMenu,
secondaryMenu: mobileHeaderDataShape.secondaryMenu,
userMenu: mobileHeaderDataShape.userMenu,
loggedOutItems: mobileHeaderDataShape.loggedOutItems,
logo: mobileHeaderDataShape.logo,
logoAltText: mobileHeaderDataShape.logoAltText,
logoDestination: mobileHeaderDataShape.logoDestination,
avatar: mobileHeaderDataShape.avatar,
username: mobileHeaderDataShape.username,
loggedIn: mobileHeaderDataShape.loggedIn,
stickyOnMobile: mobileHeaderDataShape.stickyOnMobile,
// i18n // i18n
intl: intlShape.isRequired, intl: intlShape.isRequired,

View File

@@ -0,0 +1,58 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Menu, MenuTrigger, MenuContent } from '../Menu';
const MobileHeaderMainMenu = ({ menu }) => {
// Nodes are accepted as a prop
if (!Array.isArray(menu)) {
return menu;
}
return menu.map((menuItem) => {
const {
type,
href,
content,
submenuContent,
disabled,
isActive,
onClick,
} = menuItem;
if (type === 'item') {
return (
<a
key={`${type}-${content}`}
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
href={href}
onClick={onClick || null}
>
{content}
</a>
);
}
return (
<Menu key={`${type}-${content}`} tag="div" className="nav-item">
<MenuTrigger onClick={onClick || null} tag="a" role="button" tabIndex="0" className="nav-link">
{content}
</MenuTrigger>
<MenuContent className="position-static pin-left pin-right py-2">
{submenuContent}
</MenuContent>
</Menu>
);
});
};
export const mobileHeaderMainMenuDataShape = PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]);
MobileHeaderMainMenu.propTypes = {
menu: mobileHeaderMainMenuDataShape,
};
export default MobileHeaderMainMenu;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
const MobileHeaderUserMenu = ({ menu }) => menu.map((group) => (
group.items.map(({
type, content, href, disabled, isActive, onClick,
}) => (
<li className="nav-item" key={`${type}-${content}`}>
<a
className={`nav-link${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
href={href}
onClick={onClick || null}
>
{content}
</a>
</li>
))
));
export const mobileHeaderUserMenuDataShape = PropTypes.arrayOf(PropTypes.shape({
heading: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
isActive: PropTypes.bool,
onClick: PropTypes.func,
})),
}));
MobileHeaderUserMenu.propTypes = {
menu: mobileHeaderUserMenuDataShape,
};
export default MobileHeaderUserMenu;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
const MobileLoggedOutItems = ({ items }) => items.map(({ type, href, content }, i, arr) => (
<li className="nav-item px-3 my-2" key={`${type}-${content}`}>
<a
className={i < arr.length - 1 ? 'btn btn-block btn-outline-primary' : 'btn btn-block btn-primary'}
href={href}
>
{content}
</a>
</li>
));
export const mobileHeaderLoggedOutItemsDataShape = PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
}));
MobileLoggedOutItems.propTypes = {
menu: mobileHeaderLoggedOutItemsDataShape,
};
export default MobileLoggedOutItems;

View File

@@ -0,0 +1,125 @@
# Course Info Slot
### Slot ID: `course_info_slot`
## Description
This slot is used to replace/modify/hide the course info.
## Examples
### Replace Course Title
The following `env.config.jsx` will replace the course title.
![Screenshot of replaced course title](./images/replace_course_title.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const replaceCourseTitle = ( widget ) => {
widget.content.courseTitle = "Custom Course Title";
return widget;
};
const config = {
pluginSlots: {
course_info_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: replaceCourseTitle,
},
]
},
},
}
export default config;
```
### Replace Course Info with Custom Component
The following `env.config.jsx` will replace the course info entirely (in this case with a centered 🗺️ `h1`)
![Screenshot of replaced course info with custom component](./images/replace_course_info_with_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
course_info_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_course_info_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
}
},
}
export default config;
```
### Add Custom Components before and after Course Info
The following `env.config.jsx` will place custom components before and after the course info (in this case centered `h1`s with 🌜 and 🌛).
![Screenshot of added custom components before and after course info](./images/add_custom_components_before_and_after_course_info.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
course_info_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_before_course_info_component',
type: DIRECT_PLUGIN,
priority: 10,
RenderWidget: () => (
<h3 style={{
marginTop: 'auto',
marginBottom: 'auto',
marginRight: '0.5rem',
}}>🌜</h3>
),
},
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_after_course_info_component',
type: DIRECT_PLUGIN,
priority: 90,
RenderWidget: () => (
<h3 style={{
marginTop: 'auto',
marginBottom: 'auto',
marginLeft: '0.5rem',
}}>🌛</h3>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import LearningHeaderCourseInfo, { courseInfoDataShape } from '../../learning-header/LearningHeaderCourseInfo';
const CourseInfoSlot = ({
courseOrg,
courseNumber,
courseTitle,
...attributes
}) => (
<PluginSlot
id="course_info_slot"
slotOptions={{
mergeProps: true,
}}
>
<LearningHeaderCourseInfo
courseOrg={courseOrg}
courseNumber={courseNumber}
courseTitle={courseTitle}
{...attributes}
/>
</PluginSlot>
);
CourseInfoSlot.propTypes = courseInfoDataShape;
export default CourseInfoSlot;

View File

@@ -0,0 +1,41 @@
# Desktop Header Slot
### Slot ID: `desktop_header_slot`
## Description
This slot is used to replace/modify/hide the entire desktop header.
## Examples
### Custom Component
The following `env.config.jsx` will replace the desktop header entirely (in this case with a centered 🗺️ `h1`)
![Screenshot of custom component](./images/desktop_header_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
desktop_header_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_desktop_header_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
}
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import DesktopHeader, { desktopHeaderDataShape } from '../../desktop-header/DesktopHeader';
const DesktopHeaderSlot = ({
props,
}) => (
<PluginSlot
id="desktop_header_slot"
slotOptions={{
mergeProps: true,
}}
>
<DesktopHeader {...props} />
</PluginSlot>
);
DesktopHeaderSlot.propTypes = desktopHeaderDataShape;
export default DesktopHeaderSlot;

View File

@@ -0,0 +1,134 @@
# Desktop Logged Out Items Slot
### Slot ID: `desktop_logged_out_items_slot`
## Description
This slot is used to replace/modify/hide the items shown on desktop when the user is logged out.
## Examples
### Modify Items
The following `env.config.jsx` will modify the items shown on desktop when the user is logged out.
![Screenshot of modified items](./images/desktop_logged_out_items_modify_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifyLoggedOutItems = ( widget ) => {
widget.content.items = [
{
type: 'item',
href: 'https://openedx.org/',
content: 'openedx.org',
},
{
type: 'item',
href: 'https://docs.openedx.org/en/latest/',
content: 'Documentation',
},
{
type: 'item',
href: 'https://discuss.openedx.org/',
content: 'Forums',
}
];
return widget;
};
const config = {
pluginSlots: {
desktop_logged_out_items_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyLoggedOutItems,
},
]
},
},
}
export default config;
```
### Replace with Custom Component
The following `env.config.jsx` will replace the items shown on desktop when the user is logged out entirely (in this case with a centered 🗺️ `h1`)
![Screenshot of custom component](./images/desktop_logged_out_items_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
desktop_logged_out_items_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_logged_out_items_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
},
},
}
export default config;
```
### Add Custom Components before and after
The following `env.config.jsx` will place custom components before and after the items shown on desktop when the user is logged out (in this case centered `h1`s with 🌜 and 🌛).
![Screenshot of custom components before and after](./images/desktop_logged_out_items_custom_components_before_after.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
desktop_logged_out_items_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_before_logged_out_items_component',
type: DIRECT_PLUGIN,
priority: 10,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌜</h1>
),
},
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_after_logged_out_items_component',
type: DIRECT_PLUGIN,
priority: 90,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌛</h1>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import DesktopLoggedOutItems, { desktopLoggedOutItemsDataShape } from '../../desktop-header/DesktopLoggedOutItems';
const DesktopLoggedOutItemsSlot = ({
items,
}) => (
<PluginSlot
id="desktop_logged_out_items_slot"
slotOptions={{
mergeProps: true,
}}
>
<DesktopLoggedOutItems items={items} />
</PluginSlot>
);
DesktopLoggedOutItemsSlot.propTypes = {
items: desktopLoggedOutItemsDataShape,
};
export default DesktopLoggedOutItemsSlot;

View File

@@ -0,0 +1,134 @@
# Desktop Main Menu Slot
### Slot ID: `desktop_main_menu_slot`
## Description
This slot is used to replace/modify/hide the desktop main menu.
## Examples
### Modify Items
The following `env.config.jsx` will modify the items in the desktop main menu.
![Screenshot of modified items](./images/desktop_main_menu_modify_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifyMainMenu = ( widget ) => {
widget.content.menu = [
{
type: 'item',
href: 'https://openedx.org/',
content: 'openedx.org',
},
{
type: 'item',
href: 'https://docs.openedx.org/en/latest/',
content: 'Documentation',
},
{
type: 'item',
href: 'https://discuss.openedx.org/',
content: 'Forums',
}
];
return widget;
};
const config = {
pluginSlots: {
desktop_main_menu_slot: {
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`)
![Screenshot of custom component](./images/desktop_main_menu_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
desktop_main_menu_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_main_menu_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
},
},
}
export default config;
```
### Add Custom Components before and after Menu
The following `env.config.jsx` will place custom components before and after the desktop main menu (in this case centered `h1`s with 🌜 and 🌛).
![Screenshot of custom components before and after](./images/desktop_main_menu_custom_components_before_after.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
desktop_main_menu_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_before_main_menu_component',
type: DIRECT_PLUGIN,
priority: 10,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌜</h1>
),
},
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_after_main_menu_component',
type: DIRECT_PLUGIN,
priority: 90,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌛</h1>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import DesktopHeaderMainOrSecondaryMenu, { desktopHeaderMainOrSecondaryMenuDataShape } from '../../desktop-header/DesktopHeaderMainOrSecondaryMenu';
const DesktopMainMenuSlot = ({
menu,
}) => (
<PluginSlot
id="desktop_main_menu_slot"
slotOptions={{
mergeProps: true,
}}
>
<DesktopHeaderMainOrSecondaryMenu menu={menu} />
</PluginSlot>
);
DesktopMainMenuSlot.propTypes = {
menu: desktopHeaderMainOrSecondaryMenuDataShape,
};
export default DesktopMainMenuSlot;

View File

@@ -0,0 +1,129 @@
# Desktop Secondary Menu Slot
### Slot ID: `desktop_secondary_menu_slot`
## Description
This slot is used to replace/modify/hide the desktop secondary menu.
## Examples
### Modify Items
The following `env.config.jsx` will modify the items in the desktop secondary menu.
![Screenshot of modified items](./images/desktop_secondary_menu_modify_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifySecondaryMenu = ( widget ) => {
widget.content.menu = [
{
type: 'item',
href: 'https://www.youtube.com/c/openedx',
content: 'Open edX on YouTube',
},
{
type: 'item',
href: 'https://github.com/openedx/',
content: 'Open edX on GitHub',
}
];
return widget;
};
const config = {
pluginSlots: {
desktop_secondary_menu_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifySecondaryMenu,
},
]
},
},
}
export default config;
```
### Replace Menu with Custom Component
The following `env.config.jsx` will replace the desktop secondary menu entirely (in this case with a centered 🗺️ `h1`)
![Screenshot of custom component](./images/desktop_secondary_menu_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
desktop_secondary_menu_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_secondary_menu_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
},
},
}
export default config;
```
### Add Custom Components before and after Menu
The following `env.config.jsx` will place custom components before and after the desktop secondary menu (in this case centered `h1`s with 🌜 and 🌛).
![Screenshot of custom components before and after](./images/desktop_secondary_menu_custom_components_before_after.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
desktop_secondary_menu_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_before_secondary_menu_component',
type: DIRECT_PLUGIN,
priority: 10,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌜</h1>
),
},
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_after_secondary_menu_component',
type: DIRECT_PLUGIN,
priority: 90,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌛</h1>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import DesktopHeaderMainOrSecondaryMenu, { desktopHeaderMainOrSecondaryMenuDataShape } from '../../desktop-header/DesktopHeaderMainOrSecondaryMenu';
const DesktopSecondaryMenuSlot = ({
menu,
}) => (
<PluginSlot
id="desktop_secondary_menu_slot"
slotOptions={{
mergeProps: true,
}}
>
<DesktopHeaderMainOrSecondaryMenu menu={menu} />
</PluginSlot>
);
DesktopSecondaryMenuSlot.propTypes = {
menu: desktopHeaderMainOrSecondaryMenuDataShape,
};
export default DesktopSecondaryMenuSlot;

View File

@@ -0,0 +1,141 @@
# Desktop User Menu Slot
### Slot ID: `desktop_user_menu_slot`
## Description
This slot is used to replace/modify/hide the desktop user menu.
## Examples
### Modify Items
The following `env.config.jsx` will modify the items in the desktop user menu.
![Screenshot of modified items](./images/desktop_user_menu_modify_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifyUserMenu = ( widget ) => {
widget.content.menu = [
{
items: [
{
type: 'item',
href: 'https://openedx.org/',
content: 'openedx.org',
},
{
type: 'item',
href: 'https://docs.openedx.org/en/latest/',
content: 'Documentation',
},
]
},
{
items: [
{
type: 'item',
href: 'https://discuss.openedx.org/',
content: 'Forums',
}
]
}
];
return widget;
};
const config = {
pluginSlots: {
desktop_user_menu_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyUserMenu,
},
]
},
},
}
export default config;
```
### Replace Menu with Custom Component
The following `env.config.jsx` will replace the desktop user menu entirely (in this case with a centered 🗺️ `h1`)
![Screenshot of custom component](./images/desktop_user_menu_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
desktop_user_menu_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_user_menu_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
},
},
}
export default config;
```
### Add Custom Components before and after Menu
The following `env.config.jsx` will place custom components before and after the desktop user menu (in this case centered `h1`s with 🌞 and 🌚).
![Screenshot of custom components before and after](./images/desktop_user_menu_custom_components_before_after.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
desktop_user_menu_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_before_user_menu_component',
type: DIRECT_PLUGIN,
priority: 10,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌞</h1>
),
},
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_after_user_menu_component',
type: DIRECT_PLUGIN,
priority: 90,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌚</h1>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import DesktopHeaderUserMenu, { desktopUserMenuDataShape } from '../../desktop-header/DesktopHeaderUserMenu';
const DesktopUserMenuSlot = ({
menu,
}) => (
<PluginSlot
id="desktop_user_menu_slot"
slotOptions={{
mergeProps: true,
}}
>
<DesktopHeaderUserMenu menu={menu} />
</PluginSlot>
);
DesktopUserMenuSlot.propTypes = {
menu: desktopUserMenuDataShape,
};
export default DesktopUserMenuSlot;

View File

@@ -0,0 +1,41 @@
# Learning Help Slot
### Slot ID: `learning_help_slot`
## Description
This slot is used to replace/modify/hide the learning help link.
## Examples
### Custom Component
The following `env.config.jsx` will replace the help link entirely (in this case with a centered 🗺️ `h1`)
![Screenshot of replaced learning help with custom component](./images/learning_help_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
learning_help_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_learning_help_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
}
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import LearningHeaderHelpLink from '../../learning-header/LearningHeaderHelpLink';
const LearningHelpSlot = () => (
<PluginSlot id="learning_help_slot">
<LearningHeaderHelpLink />
</PluginSlot>
);
export default LearningHelpSlot;

View File

@@ -0,0 +1,132 @@
# Learning Logged Out Items Slot
### Slot ID: `learning_logged_out_items_slot`
## Description
This slot is used to replace/modify/hide the items shown on the learning header when the user is logged out.
## Examples
### Modify Items
The following `env.config.jsx` will modify the items shown on the learning header when the user is logged out.
![Screenshot of modified items](./images/learning_logged_out_items_modified_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifyLoggedOutItems = ( widget ) => {
widget.content.buttonsInfo = [
{
href: 'https://docs.openedx.org/en/latest/',
message: 'Documentation',
},
{
href: 'https://discuss.openedx.org/',
message: 'Forums',
},
{
href: 'https://openedx.org/',
message: 'openedx.org',
variant: 'primary',
},
];
return widget;
};
const config = {
pluginSlots: {
learning_logged_out_items_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyLoggedOutItems,
},
]
},
},
}
export default config;
```
### Replace with Custom Component
The following `env.config.jsx` will replace the items shown in the learning header when the user is logged out entirely (in this case with a centered 🗺️ `h1`)
![Screenshot of replaced with custom component](./images/learning_logged_out_items_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
learning_logged_out_items_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_logged_out_items_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
},
},
}
export default config;
```
### Add Custom Components before and after
The following `env.config.jsx` will place custom components before and after the items shown in the learning header when the user is logged out (in this case centered `h1`s with 🌜 and 🌛).
![Screenshot of added custom components before and after](./images/learning_logged_out_items_custom_components_before_after.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
learning_logged_out_items_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_before_logged_out_items_component',
type: DIRECT_PLUGIN,
priority: 10,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌜</h1>
),
},
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_after_logged_out_items_component',
type: DIRECT_PLUGIN,
priority: 90,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌛</h1>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import LearningLoggedOutButtons, { learningHeaderLoggedOutItemsDataShape } from '../../learning-header/LearningLoggedOutButtons';
const LearningLoggedOutItemsSlot = ({
buttonsInfo,
}) => (
<PluginSlot
id="learning_logged_out_items_slot"
slotOptions={{
mergeProps: true,
}}
>
<LearningLoggedOutButtons buttonsInfo={buttonsInfo} />
</PluginSlot>
);
LearningLoggedOutItemsSlot.propTypes = learningHeaderLoggedOutItemsDataShape;
export default LearningLoggedOutItemsSlot;

View File

@@ -0,0 +1,130 @@
# Learning User Menu Slot
### Slot ID: `learning_user_menu_slot`
## Description
This slot is used to replace/modify/hide the learning user menu.
## Examples
### Modify Items
The following `env.config.jsx` will modify the items in the learning user menu.
![Screenshot of modified items](./images/learning_user_menu_modified_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifyUserMenu = ( widget ) => {
widget.content.items = [
{
href: 'https://openedx.org/',
message: 'openedx.org',
},
{
href: 'https://docs.openedx.org/en/latest/',
message: 'Documentation',
},
{
href: 'https://discuss.openedx.org/',
message: 'Forums',
}
];
return widget;
};
const config = {
pluginSlots: {
learning_user_menu_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyUserMenu,
},
]
},
},
}
export default config;
```
### Replace Menu with Custom Component
The following `env.config.jsx` will replace the items in the learning user menu entirely (in this case with a centered 🗺️ `h1`)
![Screenshot of replaced with custom component](./images/learning_user_menu_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
learning_user_menu_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_user_menu_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
},
},
}
export default config;
```
### Add Custom Components before and after Menu
The following `env.config.jsx` will place custom components before and after the learning user menu (in this case centered `h1`s with 🌞 and 🌚).
![Screenshot of custom components before and after](./images/learning_user_menu_custom_components_before_after.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
learning_user_menu_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_before_user_menu_component',
type: DIRECT_PLUGIN,
priority: 10,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌞</h1>
),
},
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_after_user_menu_component',
type: DIRECT_PLUGIN,
priority: 90,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌚</h1>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import LearningHeaderUserMenuItems, { learningHeaderUserMenuDataShape } from '../../learning-header/LearningHeaderUserMenuItems';
const LearningUserMenuSlot = ({
items,
}) => (
<PluginSlot
id="learning_user_menu_slot"
slotOptions={{
mergeProps: true,
}}
>
<LearningHeaderUserMenuItems items={items} />
</PluginSlot>
);
LearningUserMenuSlot.propTypes = learningHeaderUserMenuDataShape;
export default LearningUserMenuSlot;

View File

@@ -0,0 +1,69 @@
# Logo Slot
### Slot ID: `logo_slot`
## Description
This slot is used to replace/modify/hide the logo.
## Examples
### Modify URL
The following `env.config.jsx` will modify the link href for the logo.
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifyLogoHref = ( widget ) => {
widget.content.href = "https://openedx.org/";
return widget;
};
const config = {
pluginSlots: {
logo_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyLogoHref,
},
]
},
},
}
export default config;
```
### Custom Component
The following `env.config.jsx` will replace the logo entirely (in this case with a centered 🗺️ `h1`)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
logo_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_logo_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
}
},
}
export default config;
```

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import Logo, { logoDataShape } from '../../Logo';
const LogoSlot = ({
href, src, alt, ...attributes
}) => (
<PluginSlot
id="logo_slot"
slotOptions={{
mergeProps: true,
}}
>
<Logo href={href} src={src} alt={alt} {...attributes} />
</PluginSlot>
);
LogoSlot.propTypes = logoDataShape;
export default LogoSlot;

View File

@@ -0,0 +1,41 @@
# Mobile Header Slot
### Slot ID: `mobile_header_slot`
## Description
This slot is used to replace/modify/hide the entire mobile header.
## Examples
### Custom Component
The following `env.config.jsx` will replace the mobile header entirely (in this case with a centered 🗺️ `h1`)
![Screenshot of custom component](./images/mobile_header_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
mobile_header_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_mobile_header_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
}
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import MobileHeader, { mobileHeaderDataShape } from '../../mobile-header/MobileHeader';
const MobileHeaderSlot = ({
props,
}) => (
<PluginSlot
id="mobile_header_slot"
slotOptions={{
mergeProps: true,
}}
>
<MobileHeader {...props} />
</PluginSlot>
);
MobileHeaderSlot.propTypes = mobileHeaderDataShape;
export default MobileHeaderSlot;

View File

@@ -0,0 +1,134 @@
# Mobile Logged Out Items Slot
### Slot ID: `mobile_logged_out_items_slot`
## Description
This slot is used to replace/modify/hide the mobile user menu when logged out.
## Examples
### Modify Items
The following `env.config.jsx` will modify the items in mobile user menu when logged out.
![Screenshot of modified items](./images/mobile_logged_out_items_modify_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifyLoggedOutItems = ( widget ) => {
widget.content.items = [
{
type: 'item',
href: 'https://openedx.org/',
content: 'openedx.org',
},
{
type: 'item',
href: 'https://docs.openedx.org/en/latest/',
content: 'Documentation',
},
{
type: 'item',
href: 'https://discuss.openedx.org/',
content: 'Forums',
}
];
return widget;
};
const config = {
pluginSlots: {
mobile_logged_out_items_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyLoggedOutItems,
},
]
},
},
}
export default config;
```
### Replace Items with Custom Component
The following `env.config.jsx` will replace the items in mobile user menu when logged out entirely (in this case with a centered 🗺️ `h1`)
![Screenshot of custom component](./images/mobile_logged_out_items_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
mobile_logged_out_items_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_logged_out_items_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
},
},
}
export default config;
```
### Add Custom Components before and after Items
The following `env.config.jsx` will place custom components before and after the items in mobile user menu when logged out (in this case centered `h1`s with 🌞 and 🌚).
![Screenshot of custom components before and after](./images/mobile_logged_out_items_custom_components_before_after.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
mobile_logged_out_items_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_before_logged_out_items_component',
type: DIRECT_PLUGIN,
priority: 10,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌞</h1>
),
},
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_after_logged_out_items_component',
type: DIRECT_PLUGIN,
priority: 90,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌚</h1>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import MobileLoggedOutItems, { mobileHeaderLoggedOutItemsDataShape } from '../../mobile-header/MobileLoggedOutItems';
const MobileLoggedOutItemsSlot = ({
items,
}) => (
<PluginSlot
id="mobile_logged_out_items_slot"
slotOptions={{
mergeProps: true,
}}
>
<MobileLoggedOutItems items={items} />
</PluginSlot>
);
MobileLoggedOutItemsSlot.propTypes = {
items: mobileHeaderLoggedOutItemsDataShape,
};
export default MobileLoggedOutItemsSlot;

View File

@@ -0,0 +1,134 @@
# Mobile Main Menu Slot
### Slot ID: `mobile_main_menu_slot`
## Description
This slot is used to replace/modify/hide the mobile main menu.
## Examples
### Modify Items
The following `env.config.jsx` will modify the items in the mobile main menu.
![Screenshot of modified items](./images/mobile_main_menu_modify_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifyMainMenu = ( widget ) => {
widget.content.menu = [
{
type: 'item',
href: 'https://openedx.org/',
content: 'openedx.org',
},
{
type: 'item',
href: 'https://docs.openedx.org/en/latest/',
content: 'Documentation',
},
{
type: 'item',
href: 'https://discuss.openedx.org/',
content: 'Forums',
}
];
return widget;
};
const config = {
pluginSlots: {
mobile_main_menu_slot: {
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`)
![Screenshot of custom component](./images/mobile_main_menu_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
mobile_main_menu_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_main_menu_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
},
},
}
export default config;
```
### Add Custom Components before and after Menu
The following `env.config.jsx` will place custom components before and after the mobile main menu (in this case centered `h1`s with 🌞 and 🌚).
![Screenshot of custom components before and after](./images/mobile_main_menu_custom_components_before_after.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
mobile_main_menu_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_before_main_menu_component',
type: DIRECT_PLUGIN,
priority: 10,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌞</h1>
),
},
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_after_main_menu_component',
type: DIRECT_PLUGIN,
priority: 90,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌚</h1>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import MobileHeaderMainMenu, { mobileHeaderMainMenuDataShape } from '../../mobile-header/MobileHeaderMainMenu';
const MobileMainMenuSlot = ({
menu,
}) => (
<PluginSlot
id="mobile_main_menu_slot"
slotOptions={{
mergeProps: true,
}}
>
<MobileHeaderMainMenu menu={menu} />
</PluginSlot>
);
MobileMainMenuSlot.propTypes = {
menu: mobileHeaderMainMenuDataShape,
};
export default MobileMainMenuSlot;

View File

@@ -0,0 +1,142 @@
# Mobile User Menu Slot
### Slot ID: `mobile_user_menu_slot`
## Description
This slot is used to replace/modify/hide the mobile user menu.
## Examples
### Modify Items
The following `env.config.jsx` will modify the items in the mobile user menu.
![Screenshot of modified items](./images/mobile_user_menu_modify_items.png)
```jsx
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const modifyUserMenu = ( widget ) => {
widget.content.menu = [
{
items: [
{
type: 'item',
href: 'https://openedx.org/',
content: 'openedx.org',
},
{
type: 'item',
href: 'https://docs.openedx.org/en/latest/',
content: 'Documentation',
},
]
},
{
items: [
{
type: 'item',
href: 'https://discuss.openedx.org/',
content: 'Forums',
}
]
}
];
return widget;
};
const config = {
pluginSlots: {
mobile_user_menu_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyUserMenu,
},
]
},
},
}
export default config;
```
### Replace Menu with Custom Component
The following `env.config.jsx` will replace the mobile main user entirely (in this case with a centered 🗺️ `h1`)
![Screenshot of custom component](./images/mobile_user_menu_custom_component.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
mobile_user_menu_slot: {
keepDefault: false,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_user_menu_component',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🗺</h1>
),
},
},
]
},
},
}
export default config;
```
### Add Custom Components before and after Menu
The following `env.config.jsx` will place custom components before and after the mobile user menu (in this case centered `h1`s with 🌞 and 🌚).
![Screenshot of custom components before and after](./images/mobile_user_menu_custom_components_before_after.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
mobile_user_menu_slot: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_before_user_menu_component',
type: DIRECT_PLUGIN,
priority: 10,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌞</h1>
),
},
},
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_after_user_menu_component',
type: DIRECT_PLUGIN,
priority: 90,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🌚</h1>
),
},
},
]
},
},
}
export default config;
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import MobileHeaderUserMenu, { mobileHeaderUserMenuDataShape } from '../../mobile-header/MobileHeaderUserMenu';
const MobileUserMenuSlot = ({
menu,
}) => (
<PluginSlot
id="mobile_user_menu_slot"
slotOptions={{
mergeProps: true,
}}
>
<MobileHeaderUserMenu menu={menu} />
</PluginSlot>
);
MobileUserMenuSlot.propTypes = {
menu: mobileHeaderUserMenuDataShape,
};
export default MobileUserMenuSlot;

View File

@@ -0,0 +1,15 @@
# `frontend-component-header` Plugin Slots
* [`logo_slot`](./LogoSlot/)
* [`desktop_main_menu_slot`](./DesktopMainMenuSlot/)
* [`desktop_secondary_menu_slot`](./DesktopSecondaryMenuSlot/)
* [`mobile_main_menu_slot`](./MobileMainMenuSlot/)
* [`course_info_slot`](./CourseInfoSlot/)
* [`learning_help_slot`](./LearningHelpSlot/)
* [`desktop_logged_out_items_slot`](./DesktopLoggedOutItemsSlot/)
* [`mobile_logged_out_items_slot`](./MobileLoggedOutItemsSlot/)
* [`mobile_user_menu_slot`](./MobileUserMenuSlot/)
* [`desktop_user_menu_slot`](./DesktopUserMenuSlot/)
* [`learning_user_menu_slot`](./LearningUserMenuSlot/)
* [`learning_logged_out_items_slot`](./LearningLoggedOutItemsSlot/)
* [`desktop_header_slot`](./DesktopHeaderSlot/)

View File

@@ -1,18 +1,19 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
const BrandNav = ({ const BrandNav = ({
studioBaseUrl, studioBaseUrl,
logo, logo,
logoAltText, logoAltText,
}) => ( }) => (
<a href={studioBaseUrl}> <Link to={studioBaseUrl}>
<img <img
src={logo} src={logo}
alt={logoAltText} alt={logoAltText}
className="d-block logo" className="d-block logo"
/> />
</a> </Link>
); );
BrandNav.propTypes = { BrandNav.propTypes = {

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { MemoryRouter } from 'react-router-dom';
import BrandNav from './BrandNav';
const studioBaseUrl = 'https://example.com/';
const logo = 'logo.png';
const logoAltText = 'Example Logo';
const RootWrapper = () => (
<MemoryRouter>
<BrandNav
studioBaseUrl={studioBaseUrl}
logo={logo}
logoAltText={logoAltText}
/>
</MemoryRouter>
);
describe('BrandNav Component', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders the logo with the correct alt text', () => {
render(<RootWrapper />);
const img = screen.getByAltText(logoAltText);
expect(img).toHaveAttribute('src', logo);
});
it('displays a link that navigates to studioBaseUrl', () => {
render(<RootWrapper />);
const link = screen.getByRole('link');
expect(link.href).toBe(studioBaseUrl);
});
});

View File

@@ -5,6 +5,8 @@ import {
OverlayTrigger, OverlayTrigger,
Tooltip, Tooltip,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import messages from './messages'; import messages from './messages';
const CourseLockUp = ({ const CourseLockUp = ({
@@ -23,15 +25,15 @@ const CourseLockUp = ({
</Tooltip> </Tooltip>
)} )}
> >
<a <Link
className="course-title-lockup mr-2" className="course-title-lockup mr-2"
href={outlineLink} to={outlineLink}
aria-label={intl.formatMessage(messages['header.label.courseOutline'])} aria-label={intl.formatMessage(messages['header.label.courseOutline'])}
data-testid="course-lock-up-block" 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 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> <span className="d-block m-0 font-weight-bold text-gray-800" data-testid="course-title">{title}</span>
</a> </Link>
</OverlayTrigger> </OverlayTrigger>
); );

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { MemoryRouter } from 'react-router-dom';
import CourseLockUp from './CourseLockUp';
import messages from './messages';
const mockProps = {
number: '101',
org: 'EDX',
title: 'Course Title',
outlineLink: 'https://example.com/course-outline',
};
const RootWrapper = (props) => (
<MemoryRouter>
<IntlProvider locale="en" messages={messages}>
<CourseLockUp {...props} />
</IntlProvider>
</MemoryRouter>
);
describe('CourseLockUp Component', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders course org, number, and title', () => {
render(<RootWrapper {...mockProps} />);
const courseOrgNumber = screen.getByTestId('course-org-number');
const courseTitle = screen.getByTestId('course-title');
expect(courseOrgNumber).toBeInTheDocument();
expect(courseOrgNumber).toHaveTextContent(`${mockProps.org} ${mockProps.number}`);
expect(courseTitle).toBeInTheDocument();
expect(courseTitle).toHaveTextContent(mockProps.title);
});
it('renders the link with correct aria-label', () => {
render(<RootWrapper {...mockProps} />);
const link = screen.getByTestId('course-lock-up-block');
expect(link).toHaveAttribute(
'aria-label',
messages['header.label.courseOutline'].defaultMessage,
);
});
it('navigates to an absolute URL when clicked', () => {
render(<RootWrapper {...mockProps} />);
const link = screen.getByTestId('course-lock-up-block');
expect(link.href).toBe(mockProps.outlineLink);
});
});

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import { import {
ActionRow, ActionRow,
Button, Button,
@@ -37,6 +38,7 @@ const HeaderBody = ({
mainMenuDropdowns, mainMenuDropdowns,
outlineLink, outlineLink,
searchButtonAction, searchButtonAction,
containerProps,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@@ -50,8 +52,14 @@ const HeaderBody = ({
/> />
); );
const { className: containerClassName, ...restContainerProps } = containerProps || {};
return ( return (
<Container size="xl" className="px-2.5"> <Container
size="xl"
className={classNames('px-2.5', containerClassName)}
{...restContainerProps}
>
<ActionRow as="header"> <ActionRow as="header">
{isHiddenMainMenu ? ( {isHiddenMainMenu ? (
<Row className="flex-nowrap ml-4"> <Row className="flex-nowrap ml-4">
@@ -95,7 +103,12 @@ const HeaderBody = ({
{mainMenuDropdowns.map(dropdown => { {mainMenuDropdowns.map(dropdown => {
const { id, buttonTitle, items } = dropdown; const { id, buttonTitle, items } = dropdown;
return ( return (
<NavDropdownMenu key={id} {...{ id, buttonTitle, items }} /> <NavDropdownMenu
key={id}
{...{
id, buttonTitle, items,
}}
/>
); );
})} })}
</Nav> </Nav>
@@ -110,6 +123,7 @@ const HeaderBody = ({
iconAs={Icon} iconAs={Icon}
onClick={searchButtonAction} onClick={searchButtonAction}
aria-label={intl.formatMessage(messages['header.label.search.nav'])} aria-label={intl.formatMessage(messages['header.label.search.nav'])}
alt={intl.formatMessage(messages['header.label.search.nav'])}
/> />
</Nav> </Nav>
)} )}
@@ -147,14 +161,15 @@ HeaderBody.propTypes = {
isHiddenMainMenu: PropTypes.bool, isHiddenMainMenu: PropTypes.bool,
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({ mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
buttonTitle: PropTypes.string, buttonTitle: PropTypes.node,
items: PropTypes.arrayOf(PropTypes.shape({ items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string, href: PropTypes.string,
title: PropTypes.string, title: PropTypes.node,
})), })),
})), })),
outlineLink: PropTypes.string, outlineLink: PropTypes.string,
searchButtonAction: PropTypes.func, searchButtonAction: PropTypes.func,
containerProps: PropTypes.shape(Container.propTypes),
}; };
HeaderBody.defaultProps = { HeaderBody.defaultProps = {
@@ -174,6 +189,7 @@ HeaderBody.defaultProps = {
mainMenuDropdowns: [], mainMenuDropdowns: [],
outlineLink: null, outlineLink: null,
searchButtonAction: null, searchButtonAction: null,
containerProps: {},
}; };
export default HeaderBody; export default HeaderBody;

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { MemoryRouter } from 'react-router-dom';
import HeaderBody from './HeaderBody';
import messages from './messages';
const mockOnNavigate = jest.fn();
const mockSearchButtonAction = jest.fn();
const mockToggleModalPopup = jest.fn();
const mockSetModalPopupTarget = jest.fn();
const defaultProps = {
studioBaseUrl: 'https://example.com',
logoutUrl: 'https://example.com/logout',
onNavigate: mockOnNavigate,
setModalPopupTarget: mockSetModalPopupTarget,
toggleModalPopup: mockToggleModalPopup,
searchButtonAction: mockSearchButtonAction,
username: 'testuser',
authenticatedUserAvatar: 'avatar.png',
isAdmin: true,
isMobile: false,
isHiddenMainMenu: false,
mainMenuDropdowns: [],
logo: 'logo.png',
logoAltText: 'Test Logo',
number: '101',
org: 'EDX',
title: 'Test Course',
outlineLink: '/courses/edx/course-101',
};
const RootWrapper = (props) => (
<MemoryRouter>
<IntlProvider locale="en" messages={messages}>
<HeaderBody {...props} />
</IntlProvider>
</MemoryRouter>
);
describe('HeaderBody Component', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders the logo and brand navigation', () => {
render(<RootWrapper {...defaultProps} />);
const logoImage = screen.getByAltText(defaultProps.logoAltText);
expect(logoImage).toBeInTheDocument();
expect(logoImage).toHaveAttribute('src', defaultProps.logo);
});
it('renders course lockup information', () => {
render(<RootWrapper {...defaultProps} />);
const courseTitle = screen.getByText(defaultProps.title);
const courseOrgNumber = screen.getByText(`${defaultProps.org} ${defaultProps.number}`);
expect(courseTitle).toBeInTheDocument();
expect(courseOrgNumber).toBeInTheDocument();
});
it('renders a course lock-up link with the correct outline URL', () => {
render(<RootWrapper {...defaultProps} />);
const courseLockUpLink = screen.getByTestId('course-lock-up-block');
expect(courseLockUpLink.getAttribute('href')).toBe(defaultProps.outlineLink);
});
it('displays search button and triggers searchButtonAction on click', () => {
render(<RootWrapper {...defaultProps} />);
const searchButton = screen.getByLabelText(messages['header.label.search.nav'].defaultMessage);
expect(searchButton).toBeInTheDocument();
fireEvent.click(searchButton);
expect(mockSearchButtonAction).toHaveBeenCalled();
});
it('displays user menu with username and avatar', () => {
render(<RootWrapper {...defaultProps} />);
const userMenu = screen.getByText(defaultProps.username);
const avatarImage = screen.getByAltText(defaultProps.username);
expect(userMenu).toBeInTheDocument();
expect(avatarImage).toHaveAttribute('src', defaultProps.authenticatedUserAvatar);
});
it('toggles mobile menu popup when button is clicked in mobile view', () => {
render(<RootWrapper {...defaultProps} isMobile isModalPopupOpen={false} />);
const menuButton = screen.getByTestId('mobile-menu-button');
fireEvent.click(menuButton);
expect(mockToggleModalPopup).toHaveBeenCalled();
});
});

View File

@@ -48,10 +48,10 @@ MobileHeader.propTypes = {
isAdmin: PropTypes.bool, isAdmin: PropTypes.bool,
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({ mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
buttonTitle: PropTypes.string, buttonTitle: PropTypes.node,
items: PropTypes.arrayOf(PropTypes.shape({ items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string, href: PropTypes.string,
title: PropTypes.string, title: PropTypes.node,
})), })),
})), })),
outlineLink: PropTypes.string, outlineLink: PropTypes.string,

View File

@@ -1,10 +1,9 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Collapsible } from '@openedx/paragon'; import { Collapsible } from '@openedx/paragon';
import { Link } from 'react-router-dom';
const MobileMenu = ({ const MobileMenu = ({ mainMenuDropdowns }) => (
mainMenuDropdowns,
}) => (
<div <div
className="ml-4 p-2 bg-light-100 border border-gray-200 small rounded" className="ml-4 p-2 bg-light-100 border border-gray-200 small rounded"
data-testid="mobile-menu" data-testid="mobile-menu"
@@ -21,9 +20,9 @@ const MobileMenu = ({
<ul className="p-0" style={{ listStyleType: 'none' }}> <ul className="p-0" style={{ listStyleType: 'none' }}>
{items.map(item => ( {items.map(item => (
<li className="mobile-menu-item"> <li className="mobile-menu-item">
<a href={item.href}> <Link to={item.href}>
{item.title} {item.title}
</a> </Link>
</li> </li>
))} ))}
</ul> </ul>
@@ -37,10 +36,10 @@ const MobileMenu = ({
MobileMenu.propTypes = { MobileMenu.propTypes = {
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({ mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
buttonTitle: PropTypes.string, buttonTitle: PropTypes.node,
items: PropTypes.arrayOf(PropTypes.shape({ items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string, href: PropTypes.string,
title: PropTypes.string, title: PropTypes.node,
})), })),
})), })),
}; };

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import '@testing-library/jest-dom/extend-expect';
import MobileMenu from './MobileMenu';
const mockOnNavigate = jest.fn();
const defaultProps = {
mainMenuDropdowns: [
{
id: 'menu1',
buttonTitle: 'Menu 1',
items: [
{ href: '/menu1/item1', title: 'Item 1' },
{ href: '/menu1/item2', title: 'Item 2' },
],
},
{
id: 'menu2',
buttonTitle: 'Menu 2',
items: [
{ href: 'https://external-link.com', title: 'External Link' },
],
},
],
onNavigate: mockOnNavigate,
};
const RootWrapper = (props) => (
<MemoryRouter>
<MobileMenu {...props} />
</MemoryRouter>
);
describe('MobileMenu Component', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('renders the mobile menu with dropdowns and items', () => {
render(<RootWrapper {...defaultProps} />);
const menu1Title = screen.getByText('Menu 1');
const menu2Title = screen.getByText('Menu 2');
expect(menu1Title).toBeInTheDocument();
expect(menu2Title).toBeInTheDocument();
});
test('navigates to internal URL when item is clicked', () => {
render(<RootWrapper {...defaultProps} />);
const menu1Title = screen.getByText(defaultProps.mainMenuDropdowns[0].buttonTitle);
fireEvent.click(menu1Title);
const menuItem = screen.getByText(defaultProps.mainMenuDropdowns[0].items[0].title);
expect(menuItem.getAttribute('href')).toBe(defaultProps.mainMenuDropdowns[0].items[0].href);
});
test('navigates to an external URL when external link is clicked', () => {
render(<RootWrapper {...defaultProps} />);
const menu2Title = screen.getByText(defaultProps.mainMenuDropdowns[1].buttonTitle);
fireEvent.click(menu2Title);
const externalLink = screen.getByText(defaultProps.mainMenuDropdowns[1].items[0].title);
expect(externalLink.getAttribute('href')).toBe(defaultProps.mainMenuDropdowns[1].items[0].href);
});
test('renders empty state when there are no dropdowns', () => {
render(<RootWrapper mainMenuDropdowns={[]} onNavigate={mockOnNavigate} />);
const mobileMenu = screen.getByTestId('mobile-menu');
expect(mobileMenu).toBeInTheDocument();
const menuItems = screen.queryAllByRole('listitem');
expect(menuItems.length).toBe(0);
});
});

View File

@@ -4,6 +4,7 @@ import {
Dropdown, Dropdown,
DropdownButton, DropdownButton,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { Link } from 'react-router-dom';
const NavDropdownMenu = ({ const NavDropdownMenu = ({
id, id,
@@ -18,8 +19,9 @@ const NavDropdownMenu = ({
> >
{items.map(item => ( {items.map(item => (
<Dropdown.Item <Dropdown.Item
as={Link}
key={`${item.title}-dropdown-item`} key={`${item.title}-dropdown-item`}
href={item.href} to={item.href}
className="small" className="small"
> >
{item.title} {item.title}
@@ -30,10 +32,10 @@ const NavDropdownMenu = ({
NavDropdownMenu.propTypes = { NavDropdownMenu.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
buttonTitle: PropTypes.string.isRequired, buttonTitle: PropTypes.node.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({ items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string, href: PropTypes.string.isRequired,
title: PropTypes.string, title: PropTypes.node.isRequired,
})).isRequired, })).isRequired,
}; };

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { MemoryRouter } from 'react-router-dom';
import NavDropdownMenu from './NavDropdownMenu';
const defaultProps = {
id: 'menu-id',
buttonTitle: 'Menu',
items: [
{ href: '/item1', title: 'Item 1' },
{ href: 'https://external.com', title: 'External Link' },
],
};
const RootWrapper = (props) => (
<MemoryRouter>
<NavDropdownMenu {...props} />
</MemoryRouter>
);
describe('NavDropdownMenu Component', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('renders the dropdown button with correct title', () => {
render(<NavDropdownMenu {...defaultProps} />);
const dropdownButton = screen.getByRole('button', { name: defaultProps.buttonTitle });
expect(dropdownButton).toBeInTheDocument();
});
test('renders all dropdown items', () => {
render(<RootWrapper {...defaultProps} />);
const dropdownButton = screen.getByRole('button', { name: defaultProps.buttonTitle });
fireEvent.click(dropdownButton);
const item1 = screen.getByText(defaultProps.items[0].title);
const externalLink = screen.getByText(defaultProps.items[1].title);
expect(item1).toBeInTheDocument();
expect(externalLink).toBeInTheDocument();
});
test('calls onNavigate with the correct URL for internal link', () => {
render(<RootWrapper {...defaultProps} />);
const dropdownButton = screen.getByRole('button', { name: defaultProps.buttonTitle });
fireEvent.click(dropdownButton);
const item1 = screen.getByText(defaultProps.items[0].title);
expect(item1.getAttribute('href')).toBe(defaultProps.items[0].href);
});
test('navigates to external URL when external link is clicked', () => {
render(<RootWrapper {...defaultProps} />);
const dropdownButton = screen.getByRole('button', { name: defaultProps.buttonTitle });
fireEvent.click(dropdownButton);
const externalLink = screen.getByText(defaultProps.items[1].title);
expect(externalLink.getAttribute('href')).toBe(defaultProps.items[1].href);
});
});

View File

@@ -16,7 +16,8 @@ ensureConfig([
], 'Studio Header component'); ], 'Studio Header component');
const StudioHeader = ({ const StudioHeader = ({
number, org, title, isHiddenMainMenu, mainMenuDropdowns, outlineLink, searchButtonAction, number, org, title, containerProps, isHiddenMainMenu, mainMenuDropdowns,
outlineLink, searchButtonAction, isNewHomePage,
}) => { }) => {
const { authenticatedUser, config } = useContext(AppContext); const { authenticatedUser, config } = useContext(AppContext);
const props = { const props = {
@@ -25,10 +26,11 @@ const StudioHeader = ({
number, number,
org, org,
title, title,
containerProps,
username: authenticatedUser?.username, username: authenticatedUser?.username,
isAdmin: authenticatedUser?.administrator, isAdmin: authenticatedUser?.administrator,
authenticatedUserAvatar: authenticatedUser?.avatar, authenticatedUserAvatar: authenticatedUser?.avatar,
studioBaseUrl: config.STUDIO_BASE_URL, studioBaseUrl: isNewHomePage ? '/home' : config.STUDIO_BASE_URL,
logoutUrl: config.LOGOUT_URL, logoutUrl: config.LOGOUT_URL,
isHiddenMainMenu, isHiddenMainMenu,
mainMenuDropdowns, mainMenuDropdowns,
@@ -53,22 +55,25 @@ StudioHeader.propTypes = {
number: PropTypes.string, number: PropTypes.string,
org: PropTypes.string, org: PropTypes.string,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
containerProps: HeaderBody.propTypes.containerProps,
isHiddenMainMenu: PropTypes.bool, isHiddenMainMenu: PropTypes.bool,
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({ mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
buttonTitle: PropTypes.string, buttonTitle: PropTypes.node,
items: PropTypes.arrayOf(PropTypes.shape({ items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string, href: PropTypes.string,
title: PropTypes.string, title: PropTypes.node,
})), })),
})), })),
outlineLink: PropTypes.string, outlineLink: PropTypes.string,
searchButtonAction: PropTypes.func, searchButtonAction: PropTypes.func,
isNewHomePage: PropTypes.bool.isRequired,
}; };
StudioHeader.defaultProps = { StudioHeader.defaultProps = {
number: '', number: '',
org: '', org: '',
containerProps: {},
isHiddenMainMenu: false, isHiddenMainMenu: false,
mainMenuDropdowns: [], mainMenuDropdowns: [],
outlineLink: null, outlineLink: null,

View File

@@ -9,9 +9,9 @@ import {
import { AppContext } from '@edx/frontend-platform/react'; import { AppContext } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { Context as ResponsiveContext } from 'react-responsive'; import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router-dom';
import StudioHeader from './StudioHeader'; import StudioHeader from './StudioHeader';
import messages from './messages';
const authenticatedUser = { const authenticatedUser = {
userId: 3, userId: 3,
@@ -40,15 +40,17 @@ const RootWrapper = ({
return ( return (
// eslint-disable-next-line react/jsx-no-constructed-context-values, react/prop-types // eslint-disable-next-line react/jsx-no-constructed-context-values, react/prop-types
<IntlProvider locale="en"> <MemoryRouter>
<AppContext.Provider value={appContextValue}> <IntlProvider locale="en">
<ResponsiveContext.Provider value={responsiveContextValue}> <AppContext.Provider value={appContextValue}>
<StudioHeader <ResponsiveContext.Provider value={responsiveContextValue}>
{...props} <StudioHeader
/> {...props}
</ResponsiveContext.Provider> />
</AppContext.Provider> </ResponsiveContext.Provider>
</IntlProvider> </AppContext.Provider>
</IntlProvider>
</MemoryRouter>
); );
}; };
@@ -70,6 +72,7 @@ const props = {
], ],
outlineLink: 'tEsTLInK', outlineLink: 'tEsTLInK',
searchButtonAction: null, searchButtonAction: null,
isNewHomePage: true,
}; };
describe('Header', () => { describe('Header', () => {
@@ -111,16 +114,6 @@ describe('Header', () => {
expect(dropdownOption).toBeVisible(); 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 () => { it('user menu should use avatar icon', async () => {
currentUser = { ...authenticatedUser, avatar: null }; currentUser = { ...authenticatedUser, avatar: null };
const { getByTestId } = render(<RootWrapper {...props} />); const { getByTestId } = render(<RootWrapper {...props} />);
@@ -182,15 +175,6 @@ describe('Header', () => {
expect(desktopMenu).toBeNull(); 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 () => { it('user menu should use avatar image', async () => {
const { getByTestId } = render(<RootWrapper {...props} />); const { getByTestId } = render(<RootWrapper {...props} />);
const avatarImage = getByTestId('avatar-image'); const avatarImage = getByTestId('avatar-image');

Some files were not shown because too many files have changed in this diff Show More