diff --git a/src/studio-header/Avatar.jsx b/src/studio-header/Avatar.jsx
deleted file mode 100644
index d01da7c1e8..0000000000
--- a/src/studio-header/Avatar.jsx
+++ /dev/null
@@ -1,43 +0,0 @@
-// This file was copied from edx/frontend-component-header-edx.
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { AvatarIcon } from './Icons';
-
-const Avatar = ({
- size,
- src,
- alt,
- className,
-}) => {
- const avatar = src ? (
-
- ) : (
-
- );
-
- return (
-
- {avatar}
-
- );
-};
-
-Avatar.propTypes = {
- src: PropTypes.string,
- size: PropTypes.string,
- alt: PropTypes.string,
- className: PropTypes.string,
-};
-
-Avatar.defaultProps = {
- src: null,
- size: '2rem',
- alt: null,
- className: null,
-};
-
-export default Avatar;
diff --git a/src/studio-header/BrandNav.jsx b/src/studio-header/BrandNav.jsx
new file mode 100644
index 0000000000..e546922e46
--- /dev/null
+++ b/src/studio-header/BrandNav.jsx
@@ -0,0 +1,23 @@
+import PropTypes from 'prop-types';
+
+const BrandNav = ({
+ studioBaseUrl,
+ logo,
+ logoAltText,
+}) => (
+
+
+
+);
+
+BrandNav.propTypes = {
+ studioBaseUrl: PropTypes.string.isRequired,
+ logo: PropTypes.string.isRequired,
+ logoAltText: PropTypes.string.isRequired,
+};
+
+export default BrandNav;
diff --git a/src/studio-header/CourseLockUp.jsx b/src/studio-header/CourseLockUp.jsx
new file mode 100644
index 0000000000..3cf871cf7f
--- /dev/null
+++ b/src/studio-header/CourseLockUp.jsx
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import {
+ OverlayTrigger,
+ Tooltip,
+} from '@edx/paragon';
+import { getPagePath } from '../utils';
+import messages from './messages';
+
+const CourseLockUp = ({
+ courseId,
+ courseOrg,
+ courseNumber,
+ courseTitle,
+ // injected
+ intl,
+}) => (
+
+ {courseTitle}
+
+ )}
+ >
+
+ {courseOrg} {courseNumber}
+ {courseTitle}
+
+
+);
+
+CourseLockUp.propTypes = {
+ courseId: PropTypes.string,
+ courseNumber: PropTypes.string,
+ courseOrg: PropTypes.string,
+ courseTitle: PropTypes.string,
+ // injected
+ intl: intlShape.isRequired,
+};
+
+CourseLockUp.defaultProps = {
+ courseNumber: null,
+ courseOrg: null,
+ courseId: null,
+ courseTitle: null,
+};
+
+export default injectIntl(CourseLockUp);
diff --git a/src/studio-header/DesktopHeader.jsx b/src/studio-header/DesktopHeader.jsx
deleted file mode 100644
index f542fd25c2..0000000000
--- a/src/studio-header/DesktopHeader.jsx
+++ /dev/null
@@ -1,154 +0,0 @@
-// This file was copied from edx/frontend-component-header-edx.
-import React from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
-
-// 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);
- }
-
- renderMainMenu() {
- const { mainMenu } = this.props;
-
- // Nodes are accepted as a prop
- if (!Array.isArray(mainMenu)) {
- return mainMenu;
- }
-
- return mainMenu.map((menuItem) => {
- const {
- type,
- href,
- content,
- submenuContent,
- } = menuItem;
-
- if (type === 'item') {
- return (
- {content}
- );
- }
-
- return (
-
- );
- });
- }
-
- renderUserMenu() {
- const {
- userMenu,
- avatar,
- username,
- intl,
- } = this.props;
-
- return (
-
- );
- }
-
- render() {
- const {
- logo,
- logoAltText,
- logoDestination,
- intl,
- } = this.props;
- const logoProps = { src: logo, alt: logoAltText, href: logoDestination };
-
- return (
-
- );
- }
-}
-
-DesktopHeader.propTypes = {
- mainMenu: PropTypes.oneOfType([
- PropTypes.node,
- PropTypes.array,
- ]),
- userMenu: PropTypes.arrayOf(PropTypes.shape({
- type: PropTypes.oneOf(['item', 'menu']),
- href: PropTypes.string,
- content: PropTypes.string,
- })),
- logo: PropTypes.string,
- logoAltText: PropTypes.string,
- logoDestination: PropTypes.string,
- courseId: PropTypes.string,
- avatar: PropTypes.string,
- username: PropTypes.string,
- loggedIn: PropTypes.bool,
- courseLockUp: PropTypes.node.isRequired,
-
- // i18n
- intl: intlShape.isRequired,
-};
-
-DesktopHeader.defaultProps = {
- mainMenu: [],
- userMenu: [],
- logo: null,
- logoAltText: null,
- logoDestination: null,
- courseId: null,
- avatar: null,
- username: null,
- loggedIn: false,
-};
-
-export default injectIntl(DesktopHeader);
diff --git a/src/studio-header/Header.jsx b/src/studio-header/Header.jsx
index 9979d23a41..e4ef255c7f 100644
--- a/src/studio-header/Header.jsx
+++ b/src/studio-header/Header.jsx
@@ -5,16 +5,9 @@ import React, { useContext } from 'react';
import Responsive from 'react-responsive';
import { AppContext } from '@edx/frontend-platform/react';
import { ensureConfig } from '@edx/frontend-platform';
-import { OverlayTrigger, Tooltip } from '@edx/paragon';
-import {
- injectIntl,
- intlShape,
-} from '@edx/frontend-platform/i18n';
-import { getPagePath } from '../utils';
-import DesktopHeader from './DesktopHeader';
import MobileHeader from './MobileHeader';
-import messages from './Header.messages';
+import HeaderBody from './HeaderBody';
ensureConfig([
'STUDIO_BASE_URL',
@@ -24,156 +17,31 @@ ensureConfig([
], 'Header component');
const Header = ({
- courseId, courseNumber, courseOrg, courseTitle, intl,
+ courseId, courseNumber, courseOrg, courseTitle,
}) => {
const { authenticatedUser, config } = useContext(AppContext);
- const mainMenu = [
- {
- type: 'submenu',
- content: intl.formatMessage(messages['header.links.content']),
- submenuContent: (
- <>
-
-
-
-
-
- {process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' && (
-
- )}
- >
- ),
- },
- {
- type: 'submenu',
- content: intl.formatMessage(messages['header.links.settings']),
- submenuContent: (
- <>
-
-
-
-
-
-
- >
- ),
- },
- {
- type: 'submenu',
- content: intl.formatMessage(messages['header.links.tools']),
- submenuContent: (
- <>
-
-
-
- >
- ),
- },
- ];
-
- const studioHomeItem = {
- type: 'item',
- href: config.STUDIO_BASE_URL,
- content: intl.formatMessage(messages['header.user.menu.studio']),
- };
-
- const logoutItem = {
- type: 'item',
- href: config.LOGOUT_URL,
- content: intl.formatMessage(messages['header.user.menu.logout']),
- };
-
- let userMenu = [];
-
- if (authenticatedUser !== null) {
- if (authenticatedUser.administrator) {
- userMenu = [
- studioHomeItem,
- {
- type: 'item',
- href: `${config.STUDIO_BASE_URL}/maintenance`,
- content: intl.formatMessage(messages['header.user.menu.maintenance']),
- },
- logoutItem,
- ];
- } else {
- userMenu = [
- studioHomeItem,
- logoutItem,
- ];
- }
- }
-
- const courseLockUp = (
-
- {courseTitle}
-
- )}
- >
-
- {courseOrg} {courseNumber}
- {courseTitle}
-
-
- );
-
const props = {
logo: config.LOGO_URL,
- logoAltText: 'Studio edX',
- siteName: 'edX',
- logoDestination: process.env.ENABLE_NEW_HOME_PAGE === 'true' ? '/home' : config.STUDIO_BASE_URL,
- courseLockUp,
+ logoAltText: `Studio ${config.SITE_NAME}`,
courseId,
- username: authenticatedUser !== null ? authenticatedUser.username : null,
- avatar: authenticatedUser !== null ? authenticatedUser.avatar : null,
- mainMenu,
- userMenu,
+ courseNumber,
+ courseOrg,
+ courseTitle,
+ username: authenticatedUser?.username,
+ isAdmin: authenticatedUser?.administrator,
+ authenticatedUserAvatar: authenticatedUser?.avatar,
+ studioBaseUrl: config.STUDIO_BASE_URL,
+ logoutUrl: config.LOGOUT_URL,
};
+
return (
<>
-
+
>
);
@@ -184,7 +52,6 @@ Header.propTypes = {
courseNumber: PropTypes.string,
courseOrg: PropTypes.string,
courseTitle: PropTypes.string.isRequired,
- intl: intlShape.isRequired,
};
Header.defaultProps = {
@@ -192,4 +59,4 @@ Header.defaultProps = {
courseOrg: null,
};
-export default injectIntl(Header);
+export default Header;
diff --git a/src/studio-header/Header.test.jsx b/src/studio-header/Header.test.jsx
index b5d86cff0e..b881d3742f 100644
--- a/src/studio-header/Header.test.jsx
+++ b/src/studio-header/Header.test.jsx
@@ -1,151 +1,132 @@
-/* eslint-disable react/jsx-no-constructed-context-values */
-// This file was copied from edx/frontend-component-header-edx.
-import React from 'react';
-import { IntlProvider } from '@edx/frontend-platform/i18n';
-import { AppContext } from '@edx/frontend-platform/react';
-import { Context as ResponsiveContext } from 'react-responsive';
import {
- cleanup,
- fireEvent,
render,
+ fireEvent,
screen,
+ waitFor,
} from '@testing-library/react';
-import Header from './Header';
-
-describe('', () => {
- function createComponent(screenWidth, component) {
- return (
-
-
-
- {component}
-
-
-
- );
- }
-
- it('renders desktop header correctly with API call', async () => {
- const component = createComponent(
- 1280, (
-
- ),
- );
-
- render(component);
- expect(screen.getByTestId('course-org-number').textContent).toEqual(expect.stringContaining('edX DemoX'));
- expect(screen.getByTestId('course-title').textContent).toEqual(expect.stringContaining('Demonstration Course'));
- });
-
- it('renders mobile header correctly with API call', async () => {
- const component = createComponent(
- 500, (
-
- ),
- );
-
- render(component);
- expect(screen.getByTestId('edx-header-logo'));
- });
-
- it('renders desktop header correctly with bad API call', async () => {
- const component = createComponent(
- 1280, (
-
- ),
- );
-
- render(component);
- expect(screen.getByTestId('course-title').textContent).toEqual(expect.stringContaining('course-v1:edX+DemoX+Demo_Course'));
- });
-
- it('renders mobile header correctly with bad API call', async () => {
- const component = createComponent(
- 500, (
-
- ),
- );
-
- render(component);
- expect(screen.getByTestId('edx-header-logo'));
- });
-
- it('renders Video Uploads link', () => {
- process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = 'true';
-
- const component = createComponent(
- 1280, (
-
- ),
- );
-
- render(component);
- fireEvent.click(screen.getByText('Content'));
-
- expect(screen.getByText('Video Uploads')).toBeInTheDocument();
- });
-
- it('does not render Video Uploads link', () => {
- process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = 'false';
-
- const component = createComponent(
- 1280, (
-
- ),
- );
-
- render(component);
- fireEvent.click(screen.getByText('Content'));
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { Context as ResponsiveContext } from 'react-responsive';
- expect(screen.queryByText('Video Uploads')).toBeNull();
+import initializeStore from '../store';
+import Header from './Header';
+import messages from './messages';
+
+let store;
+
+const courseId = 'testEd123';
+const courseNumber = '123';
+const courseOrg = 'Ed';
+const courseTitle = 'test';
+
+const renderComponent = (screenWidth) => {
+ render(
+
+
+
+
+
+
+ ,
+ );
+};
+
+describe('Header', () => {
+ describe('desktop', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: false,
+ roles: [],
+ },
+ });
+ store = initializeStore({});
+ });
+ it('course lock up should be visible', () => {
+ renderComponent(1280);
+ const courseLockUpBlock = screen.getByTestId('course-lock-up-block');
+ expect(courseLockUpBlock).toBeVisible();
+ });
+ it('mobile menu should not be visible', () => {
+ renderComponent(1280);
+ const mobileMenuButton = screen.queryByTestId('mobile-menu-button');
+ expect(mobileMenuButton).toBeNull();
+ });
+ it('desktop menu should be visible', () => {
+ renderComponent(1280);
+ const desktopMenu = screen.getByTestId('desktop-menu');
+ expect(desktopMenu).toBeVisible();
+ });
+ it('video uploads should be in content menu', async () => {
+ renderComponent(1280);
+ const contentMenu = screen.getAllByRole('button')[0];
+ await waitFor(() => fireEvent.click(contentMenu));
+ const videoUploadButton = screen.getByText(messages['header.links.videoUploads'].defaultMessage);
+ expect(videoUploadButton).toBeVisible();
+ });
+ it('maintenance should not be in user menu', async () => {
+ renderComponent(1280);
+ const userMenu = screen.getAllByRole('button')[3];
+ await waitFor(() => fireEvent.click(userMenu));
+ const maintenanceButton = screen.queryByText(messages['header.user.menu.maintenance'].defaultMessage);
+ expect(maintenanceButton).toBeNull();
+ });
+ it('user menu should use avatar icon', async () => {
+ renderComponent(1280);
+ const avatarIcon = screen.getByTestId('avatar-icon');
+ expect(avatarIcon).toBeVisible();
+ });
});
-
- afterEach(() => {
- cleanup();
+ describe('mobile', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ avatar: '/imges/test.png',
+ },
+ });
+ store = initializeStore({});
+ });
+ it('course lock up should not be visible', async () => {
+ renderComponent(500);
+ const courseLockUpBlock = screen.queryByTestId('course-lock-up-block');
+ expect(courseLockUpBlock).toBeNull();
+ });
+ it('mobile menu should be visible', async () => {
+ renderComponent(500);
+ const mobileMenuButton = screen.getByTestId('mobile-menu-button');
+ expect(mobileMenuButton).toBeVisible();
+ await waitFor(() => fireEvent.click(mobileMenuButton));
+ const mobileMenu = screen.getByTestId('mobile-menu');
+ expect(mobileMenu).toBeVisible();
+ });
+ it('desktop menu should not be visible', () => {
+ renderComponent(500);
+ const desktopMenu = screen.queryByTestId('desktop-menu');
+ expect(desktopMenu).toBeNull();
+ });
+ it('maintenance should be in user menu', async () => {
+ renderComponent(500);
+ const userMenu = screen.getAllByRole('button')[1];
+ await waitFor(() => fireEvent.click(userMenu));
+ const maintenanceButton = screen.getByText(messages['header.user.menu.maintenance'].defaultMessage);
+ expect(maintenanceButton).toBeVisible();
+ });
+ it('user menu should use avatar image', async () => {
+ renderComponent(1280);
+ const avatarImage = screen.getByTestId('avatar-image');
+ expect(avatarImage).toBeVisible();
+ });
});
});
diff --git a/src/studio-header/HeaderBody.jsx b/src/studio-header/HeaderBody.jsx
new file mode 100644
index 0000000000..e99800ebb7
--- /dev/null
+++ b/src/studio-header/HeaderBody.jsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import {
+ ActionRow,
+ Button,
+ Nav,
+ Row,
+} from '@edx/paragon';
+import { Close, MenuIcon } from '@edx/paragon/icons';
+
+import messages from './messages';
+import CourseLockUp from './CourseLockUp';
+import UserMenu from './UserMenu';
+import BrandNav from './BrandNav';
+import NavDropdownMenu from './NavDropdownMenu';
+import { getContentMenuItem, getSettingMenuItems, getToolsMenuItems } from './utils';
+
+const HeaderBody = ({
+ logo,
+ logoAltText,
+ courseId,
+ courseNumber,
+ courseOrg,
+ courseTitle,
+ username,
+ isAdmin,
+ studioBaseUrl,
+ logoutUrl,
+ authenticatedUserAvatar,
+ isMobile,
+ setModalPopupTarget,
+ toggleModalPopup,
+ isModalPopupOpen,
+ // injected
+ intl,
+}) => (
+
+ {isMobile ? (
+
+ ) : (
+
+
+
+
+ )}
+ {isMobile ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+);
+
+HeaderBody.propTypes = {
+ studioBaseUrl: PropTypes.string.isRequired,
+ logoutUrl: PropTypes.string.isRequired,
+ setModalPopupTarget: PropTypes.func.isRequired,
+ toggleModalPopup: PropTypes.func.isRequired,
+ isModalPopupOpen: PropTypes.bool.isRequired,
+ courseNumber: PropTypes.string,
+ courseOrg: PropTypes.string,
+ courseTitle: PropTypes.string,
+ logo: PropTypes.string,
+ logoAltText: PropTypes.string,
+ courseId: PropTypes.string,
+ authenticatedUserAvatar: PropTypes.string,
+ username: PropTypes.string,
+ isAdmin: PropTypes.bool,
+ isMobile: PropTypes.bool,
+ // injected
+ intl: intlShape.isRequired,
+};
+
+HeaderBody.defaultProps = {
+ logo: null,
+ logoAltText: null,
+ courseId: null,
+ courseNumber: null,
+ courseOrg: null,
+ courseTitle: null,
+ authenticatedUserAvatar: null,
+ username: null,
+ isAdmin: false,
+ isMobile: false,
+};
+
+export default injectIntl(HeaderBody);
diff --git a/src/studio-header/Icons.jsx b/src/studio-header/Icons.jsx
deleted file mode 100644
index 8ae883b63f..0000000000
--- a/src/studio-header/Icons.jsx
+++ /dev/null
@@ -1,47 +0,0 @@
-// This file was copied from edx/frontend-component-header-edx.
-import React from 'react';
-
-export const MenuIcon = (props) => (
-
-);
-
-export const AvatarIcon = (props) => (
-
-);
-
-export const CaretIcon = (props) => (
-
-);
diff --git a/src/studio-header/Logo.jsx b/src/studio-header/Logo.jsx
deleted file mode 100644
index bdb24c6d55..0000000000
--- a/src/studio-header/Logo.jsx
+++ /dev/null
@@ -1,32 +0,0 @@
-// This file was copied from edx/frontend-component-header-edx.
-import React from 'react';
-import PropTypes from 'prop-types';
-
-const Logo = ({ src, alt, ...attributes }) => (
-
-);
-
-Logo.propTypes = {
- src: PropTypes.string.isRequired,
- alt: PropTypes.string.isRequired,
-};
-
-const LinkedLogo = ({
- href,
- src,
- alt,
- ...attributes
-}) => (
-
-
-
-);
-
-LinkedLogo.propTypes = {
- href: PropTypes.string.isRequired,
- src: PropTypes.string.isRequired,
- alt: PropTypes.string.isRequired,
-};
-
-export { LinkedLogo, Logo };
-export default Logo;
diff --git a/src/studio-header/Menu/Menu.jsx b/src/studio-header/Menu/Menu.jsx
deleted file mode 100644
index a172cb4fe5..0000000000
--- a/src/studio-header/Menu/Menu.jsx
+++ /dev/null
@@ -1,275 +0,0 @@
-// This file was copied from edx/frontend-component-header-edx.
-import React from 'react';
-import { CSSTransition } from 'react-transition-group';
-import PropTypes from 'prop-types';
-
-const MenuTrigger = ({ tag, className, ...attributes }) => React.createElement(tag, {
- className: `menu-trigger ${className}`,
- ...attributes,
-});
-MenuTrigger.propTypes = {
- tag: PropTypes.string,
- className: PropTypes.string,
-};
-MenuTrigger.defaultProps = {
- tag: 'div',
- className: null,
-};
-const MenuTriggerType = .type;
-
-const MenuContent = ({ tag, className, ...attributes }) => React.createElement(tag, {
- className: ['menu-content', className].join(' '),
- ...attributes,
-});
-MenuContent.propTypes = {
- tag: PropTypes.string,
- className: PropTypes.string,
-};
-MenuContent.defaultProps = {
- tag: 'div',
- className: null,
-};
-
-class Menu extends React.Component {
- constructor(props) {
- super(props);
-
- this.menu = React.createRef();
- this.state = {
- expanded: false,
- };
-
- this.onTriggerClick = this.onTriggerClick.bind(this);
- this.onCloseClick = this.onCloseClick.bind(this);
- this.onKeyDown = this.onKeyDown.bind(this);
- this.onDocumentClick = this.onDocumentClick.bind(this);
- this.onMouseEnter = this.onMouseEnter.bind(this);
- this.onMouseLeave = this.onMouseLeave.bind(this);
- }
-
- // Lifecycle Events
- componentWillUnmount() {
- document.removeEventListener('touchend', this.onDocumentClick, true);
- document.removeEventListener('click', this.onDocumentClick, true);
-
- // Call onClose callback when unmounting and open
- if (this.state.expanded && this.props.onClose) {
- this.props.onClose();
- }
- }
-
- // Event handlers
- onDocumentClick(e) {
- if (!this.props.closeOnDocumentClick) {
- return;
- }
-
- const clickIsInMenu = this.menu.current === e.target || this.menu.current.contains(e.target);
- if (clickIsInMenu) {
- return;
- }
-
- this.close();
- }
-
- onTriggerClick(e) {
- // Let the browser follow the link of the trigger if the menu
- // is already expanded and the trigger has an href attribute
- if (this.state.expanded && e.target.getAttribute('href')) {
- return;
- }
-
- e.preventDefault();
- this.toggle();
- }
-
- onCloseClick() {
- this.getFocusableElements()[0].focus();
- this.close();
- }
-
- onKeyDown(e) {
- if (!this.state.expanded) {
- return;
- }
- switch (e.key) {
- case 'Escape': {
- e.preventDefault();
- e.stopPropagation();
- this.getFocusableElements()[0].focus();
- this.close();
- break;
- }
- case 'Enter': {
- // Using focusable elements instead of a ref to the trigger
- // because Hyperlink and Button can handle refs as functional components
- if (document.activeElement === this.getFocusableElements()[0]) {
- e.preventDefault();
- this.toggle();
- }
- break;
- }
- case 'Tab': {
- e.preventDefault();
- if (e.shiftKey) {
- this.focusPrevious();
- } else {
- this.focusNext();
- }
- break;
- }
- case 'ArrowDown': {
- e.preventDefault();
- this.focusNext();
- break;
- }
- case 'ArrowUp': {
- e.preventDefault();
- this.focusPrevious();
- break;
- }
- default:
- }
- }
-
- onMouseEnter() {
- if (!this.props.respondToPointerEvents) {
- return;
- }
- this.open();
- }
-
- onMouseLeave() {
- if (!this.props.respondToPointerEvents) {
- return;
- }
- this.close();
- }
-
- // Internal functions
-
- getFocusableElements() {
- return this.menu.current.querySelectorAll('button:not([disabled]), [href]:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])');
- }
-
- getAttributesFromProps() {
- // Any extra props are attributes for the menu
- const attributes = {};
- Object.keys(this.props)
- // eslint-disable-next-line react/forbid-foreign-prop-types
- .filter(property => Menu.propTypes[property] === undefined)
- .forEach((property) => {
- attributes[property] = this.props[property];
- });
- return attributes;
- }
-
- focusNext() {
- const focusableElements = Array.from(this.getFocusableElements());
- const activeIndex = focusableElements.indexOf(document.activeElement);
- const nextIndex = (activeIndex + 1) % focusableElements.length;
- focusableElements[nextIndex].focus();
- }
-
- focusPrevious() {
- const focusableElements = Array.from(this.getFocusableElements());
- const activeIndex = focusableElements.indexOf(document.activeElement);
- const previousIndex = (activeIndex || focusableElements.length) - 1;
- focusableElements[previousIndex].focus();
- }
-
- open() {
- if (this.props.onOpen) {
- this.props.onOpen();
- }
- this.setState({ expanded: true });
- // Listen to touchend and click events to ensure the menu
- // can be closed on mobile, pointer, and mixed input devices
- document.addEventListener('touchend', this.onDocumentClick, true);
- document.addEventListener('click', this.onDocumentClick, true);
- }
-
- close() {
- if (this.props.onClose) {
- this.props.onClose();
- }
- this.setState({ expanded: false });
- document.removeEventListener('touchend', this.onDocumentClick, true);
- document.removeEventListener('click', this.onDocumentClick, true);
- }
-
- toggle() {
- if (this.state.expanded) {
- this.close();
- } else {
- this.open();
- }
- }
-
- renderTrigger(node) {
- return React.cloneElement(node, {
- onClick: this.onTriggerClick,
- 'aria-haspopup': 'menu',
- 'aria-expanded': this.state.expanded,
- });
- }
-
- renderMenuContent(node) {
- return (
-
- {node}
-
- );
- }
-
- render() {
- const { className } = this.props;
-
- const wrappedChildren = React.Children.map(this.props.children, (child) => {
- if (child.type === MenuTriggerType) {
- return this.renderTrigger(child);
- }
- return this.renderMenuContent(child);
- });
-
- const rootClassName = this.state.expanded ? 'menu expanded' : 'menu';
-
- return React.createElement(this.props.tag, {
- className: `${rootClassName} ${className}`,
- ref: this.menu,
- onKeyDown: this.onKeyDown,
- onMouseEnter: this.onMouseEnter,
- onMouseLeave: this.onMouseLeave,
- ...this.getAttributesFromProps(),
- }, wrappedChildren);
- }
-}
-
-Menu.propTypes = {
- tag: PropTypes.string,
- onClose: PropTypes.func,
- onOpen: PropTypes.func,
- closeOnDocumentClick: PropTypes.bool,
- respondToPointerEvents: PropTypes.bool,
- className: PropTypes.string,
- transitionTimeout: PropTypes.number,
- transitionClassName: PropTypes.string,
- children: PropTypes.arrayOf(PropTypes.node).isRequired,
-};
-Menu.defaultProps = {
- tag: 'div',
- className: null,
- onClose: null,
- onOpen: null,
- respondToPointerEvents: false,
- closeOnDocumentClick: true,
- transitionTimeout: 250,
- transitionClassName: 'menu-content',
-};
-
-export { Menu, MenuTrigger, MenuContent };
diff --git a/src/studio-header/Menu/Menu.test.jsx b/src/studio-header/Menu/Menu.test.jsx
deleted file mode 100644
index 3d4feefb58..0000000000
--- a/src/studio-header/Menu/Menu.test.jsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import React from 'react';
-import { render, screen, fireEvent } from '@testing-library/react';
-import { IntlProvider } from '@edx/frontend-platform/i18n';
-
-import { Menu, MenuTrigger, MenuContent } from './Menu';
-
-jest.mock('react-transition-group', () => ({
- // eslint-disable-next-line react/jsx-no-useless-fragment, react/prop-types
- CSSTransition: ({ children }) => <>{children}>,
-}));
-
-const RootWrapper = () => (
-
- ,
-
-);
-
-describe('', () => {
- it('should render Menu component', () => {
- const { getByText } = render();
- const menuTrigger = getByText(/Toggle Menu/i);
- const menuContent = getByText(/Menu Content/i);
- expect(menuTrigger).toBeInTheDocument();
- expect(menuContent).toBeInTheDocument();
- });
- it('should expand menu on trigger click', () => {
- const { getByText } = render();
- const menuTrigger = getByText(/Toggle Menu/i);
- const menuContent = getByText(/Menu Content/i);
- fireEvent.click(menuTrigger);
- expect(menuContent).toHaveClass('menu-content');
- expect(menuTrigger).toHaveAttribute('aria-expanded', 'true');
- });
- it('should close menu on document click outside the menu', () => {
- const { getByText } = render();
- const menuTrigger = getByText(/Toggle Menu/i);
- const menuContent = getByText(/Menu Content/i);
- fireEvent.click(menuTrigger);
- fireEvent.click(document.body);
- expect(menuContent).toBeInTheDocument();
- expect(menuTrigger).toHaveAttribute('aria-expanded', 'false');
- });
- it('should not close menu on document click inside the menu', () => {
- const { getByText, getByRole } = render(
- ,
- );
- const menuTrigger = getByText(/Toggle Menu/i);
- const menuContent = getByRole('menu');
- fireEvent.click(menuTrigger);
- fireEvent.click(screen.getByText(/Menu Item 1/i));
- expect(menuContent).toBeVisible();
- expect(menuTrigger).toHaveAttribute('aria-expanded', 'true');
- });
- it('should close menu after press Escape keyboard key', () => {
- render(
- ,
- );
- const menuTrigger = screen.getByText(/Toggle Menu/i);
- fireEvent.click(menuTrigger);
- fireEvent.keyDown(menuTrigger, { key: 'Escape' });
- expect(menuTrigger).toHaveAttribute('aria-expanded', 'false');
- });
-});
diff --git a/src/studio-header/Menu/index.jsx b/src/studio-header/Menu/index.jsx
deleted file mode 100644
index 9ad4449bbe..0000000000
--- a/src/studio-header/Menu/index.jsx
+++ /dev/null
@@ -1,4 +0,0 @@
-// This file was copied from edx/frontend-component-header-edx.
-import { Menu, MenuTrigger, MenuContent } from './Menu';
-
-export { Menu, MenuTrigger, MenuContent };
diff --git a/src/studio-header/Menu/menu.scss b/src/studio-header/Menu/menu.scss
deleted file mode 100644
index 380f8d23ce..0000000000
--- a/src/studio-header/Menu/menu.scss
+++ /dev/null
@@ -1,47 +0,0 @@
-// This file was copied from edx/frontend-component-header-edx.
-.menu {
- position: relative;
-}
-
-.menu-content {
- position: absolute;
- top: 100%;
- z-index: 10;
- background: $white;
- min-width: 10rem;
-
- &.pin-left {
- left: 0;
- }
-
- &.pin-right {
- right: 0;
- }
-}
-
-
-.menu-dropdown-enter {
- opacity: 0;
- transform-origin: 75% 0;
- transform: scale3d(.8, .8, 1);
-}
-
-.menu-dropdown-enter-active {
- transform-origin: 75% 0;
- transition: all 250ms cubic-bezier(.4, 0, .2, 1);
- transform: scale3d(1, 1, 1);
- opacity: 1;
-}
-
-.menu-dropdown-exit {
- transform-origin: 75% 0;
- transform: scale3d(1, 1, 1);
- opacity: 1;
-}
-
-.menu-dropdown-exit-active {
- transform-origin: 75% 0;
- transform: scale3d(.8, .8, 1);
- transition: all 250ms cubic-bezier(.8, 0, .6, 1);
- opacity: 0;
-}
diff --git a/src/studio-header/MobileHeader.jsx b/src/studio-header/MobileHeader.jsx
index e3f75baafa..26f88dd12c 100644
--- a/src/studio-header/MobileHeader.jsx
+++ b/src/studio-header/MobileHeader.jsx
@@ -1,166 +1,69 @@
// This file was copied from edx/frontend-component-header-edx.
-import React from 'react';
+import React, { useState } from 'react';
import PropTypes from 'prop-types';
-import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
-
-// 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 { MenuIcon } from './Icons';
-
-class MobileHeader extends React.Component {
- constructor(props) { // eslint-disable-line no-useless-constructor
- super(props);
- }
-
- renderMainMenu() {
- const { mainMenu } = this.props;
-
- // Nodes are accepted as a prop
- if (!Array.isArray(mainMenu)) {
- return mainMenu;
- }
-
- return mainMenu.map((menuItem) => {
- const {
- type,
- href,
- content,
- submenuContent,
- } = menuItem;
-
- if (type === 'item') {
- return (
-
- {content}
-
- );
- }
-
- return (
-
- );
- });
- }
-
- renderUserMenuItems() {
- const { userMenu } = this.props;
-
- return userMenu.map(({ type, href, content }) => (
-
- {content}
-
- ));
- }
-
- render() {
- const {
- logo,
- logoAltText,
- logoDestination,
- avatar,
- username,
- stickyOnMobile,
- mainMenu,
- intl,
- } = this.props;
- const logoProps = { src: logo, alt: logoAltText, href: logoDestination };
- const stickyClassName = stickyOnMobile ? 'sticky-top' : '';
- return (
-
- );
- }
-}
+
+
+ >
+ );
+};
MobileHeader.propTypes = {
- mainMenu: PropTypes.oneOfType([
- PropTypes.node,
- PropTypes.array,
- ]),
-
- userMenu: PropTypes.arrayOf(PropTypes.shape({
- type: PropTypes.oneOf(['item', 'menu']),
- href: PropTypes.string,
- content: PropTypes.string,
- })),
+ studioBaseUrl: PropTypes.string.isRequired,
+ logoutUrl: PropTypes.string.isRequired,
+ setModalPopupTarget: PropTypes.func.isRequired,
+ toggleModalPopup: PropTypes.func.isRequired,
+ isModalPopupOpen: PropTypes.bool.isRequired,
+ courseNumber: PropTypes.string,
+ courseOrg: PropTypes.string,
+ courseTitle: PropTypes.string,
logo: PropTypes.string,
logoAltText: PropTypes.string,
- logoDestination: PropTypes.string,
- avatar: PropTypes.string,
+ courseId: PropTypes.string,
+ authenticatedUserAvatar: PropTypes.string,
username: PropTypes.string,
- stickyOnMobile: PropTypes.bool,
- courseLockUp: PropTypes.node.isRequired,
-
- // i18n
- intl: intlShape.isRequired,
+ isAdmin: PropTypes.bool,
};
MobileHeader.defaultProps = {
- mainMenu: [],
- userMenu: [],
logo: null,
logoAltText: null,
- logoDestination: null,
- avatar: null,
+ courseId: null,
+ courseNumber: null,
+ courseOrg: null,
+ courseTitle: null,
+ authenticatedUserAvatar: null,
username: null,
- stickyOnMobile: true,
+ isAdmin: false,
};
-export default injectIntl(MobileHeader);
+export default MobileHeader;
diff --git a/src/studio-header/MobileMenu.jsx b/src/studio-header/MobileMenu.jsx
new file mode 100644
index 0000000000..0f35c8ffcf
--- /dev/null
+++ b/src/studio-header/MobileMenu.jsx
@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { Collapsible } from '@edx/paragon';
+import messages from './messages';
+import { getContentMenuItem, getSettingMenuItems, getToolsMenuItems } from './utils';
+
+const MobileMenu = ({
+ studioBaseUrl,
+ courseId,
+ // injected
+ intl,
+}) => {
+ const contentItems = getContentMenuItem({ studioBaseUrl, courseId, intl });
+ const settingsItems = getSettingMenuItems({ studioBaseUrl, courseId, intl });
+ const toolsItems = getToolsMenuItems({ studioBaseUrl, courseId, intl });
+
+ return (
+
+ );
+};
+
+MobileMenu.propTypes = {
+ courseId: PropTypes.string.isRequired,
+ studioBaseUrl: PropTypes.string.isRequired,
+ // injected
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(MobileMenu);
diff --git a/src/studio-header/NavDropdownMenu.jsx b/src/studio-header/NavDropdownMenu.jsx
new file mode 100644
index 0000000000..52c4cbe819
--- /dev/null
+++ b/src/studio-header/NavDropdownMenu.jsx
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import {
+ Dropdown,
+ DropdownButton,
+} from '@edx/paragon';
+
+const NavDropdownMenu = ({
+ id,
+ buttonTitle,
+ items,
+}) => (
+
+ {items.map(item => (
+
+ {item.title}
+
+ ))}
+
+);
+
+NavDropdownMenu.propTypes = {
+ id: PropTypes.string.isRequired,
+ buttonTitle: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.shape({
+ href: PropTypes.string,
+ title: PropTypes.string,
+ })).isRequired,
+};
+
+export default NavDropdownMenu;
diff --git a/src/studio-header/UserMenu.jsx b/src/studio-header/UserMenu.jsx
new file mode 100644
index 0000000000..81579518a0
--- /dev/null
+++ b/src/studio-header/UserMenu.jsx
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import {
+ Avatar,
+} from '@edx/paragon';
+import NavDropdownMenu from './NavDropdownMenu';
+import { getUserMenuItems } from './utils';
+
+const UserMenu = ({
+ username,
+ studioBaseUrl,
+ logoutUrl,
+ authenticatedUserAvatar,
+ isMobile,
+ isAdmin,
+ // injected
+ intl,
+}) => {
+ const avatar = authenticatedUserAvatar ? (
+
+ ) : (
+
+ );
+ const title = isMobile ? avatar : <>{avatar}{username}>;
+
+ return (
+
+ );
+};
+
+UserMenu.propTypes = {
+ username: PropTypes.string,
+ studioBaseUrl: PropTypes.string.isRequired,
+ logoutUrl: PropTypes.string.isRequired,
+ authenticatedUserAvatar: PropTypes.string,
+ isMobile: PropTypes.bool,
+ isAdmin: PropTypes.bool,
+ // injected
+ intl: intlShape.isRequired,
+};
+
+UserMenu.defaultProps = {
+ isMobile: false,
+ isAdmin: false,
+ authenticatedUserAvatar: null,
+ username: null,
+};
+
+export default injectIntl(UserMenu);
diff --git a/src/studio-header/header.scss b/src/studio-header/header.scss
index 677d5eb484..7596cd0d27 100644
--- a/src/studio-header/header.scss
+++ b/src/studio-header/header.scss
@@ -3,7 +3,10 @@ $spacer: 1rem;
$blue: #007DB8;
$white: #FFFFFF;
-@import "./Menu/menu";
+.btn-tertiary:hover {
+ color: white;
+ background-color: #00262B;
+}
.course-title-lockup {
@media only screen and (max-width: 768px) {
@@ -15,6 +18,7 @@ $white: #FFFFFF;
padding: .5rem;
padding-right: $spacer;
border-right: 1px solid #E5E5E5;
+ min-width: 70%;
}
overflow: hidden;
@@ -28,33 +32,45 @@ $white: #FFFFFF;
}
}
-.mobile-lockup {
- max-width: 50%;
+.mobile-menu-container {
+ background-color: white;
+
+ .pgn__modal-popup__arrow::after {
+ border-top-color: white;
+ }
}
.dropdown-item a {
text-decoration: none;
}
-.icon-button {
- display: inline-flex;
- line-height: 3rem;
- background: transparent;
- vertical-align: middle;
- text-align: center;
- border: none;
- height: 3rem;
- width: 3rem;
- padding: .75rem;
- justify-content: center;
- align-items: center;
-
- &:hover,
- &:focus {
- background: rgb(0 0 0 / .1);
+.mobile-menu-item {
+ padding: 8px 0;
+
+ a {
+ color: #454545;
}
}
+// .icon-button {
+// display: inline-flex;
+// line-height: 3rem;
+// background: transparent;
+// vertical-align: middle;
+// text-align: center;
+// border: none;
+// height: 3rem;
+// width: 3rem;
+// padding: .75rem;
+// justify-content: center;
+// align-items: center;
+
+// &:hover,
+// &:focus {
+// background: rgb(0 0 0 / .1);
+// }
+// }
+
.site-header-mobile,
.site-header-desktop {
position: relative;
diff --git a/src/studio-header/Header.messages.jsx b/src/studio-header/messages.js
similarity index 100%
rename from src/studio-header/Header.messages.jsx
rename to src/studio-header/messages.js
diff --git a/src/studio-header/utils.js b/src/studio-header/utils.js
new file mode 100644
index 0000000000..2c1a56ff30
--- /dev/null
+++ b/src/studio-header/utils.js
@@ -0,0 +1,105 @@
+import { getPagePath } from '../utils';
+import messages from './messages';
+
+export const getContentMenuItem = ({ studioBaseUrl, courseId, intl }) => {
+ const items = [
+ {
+ href: `${studioBaseUrl}/course/${courseId}`,
+ title: intl.formatMessage(messages['header.links.outline']),
+ },
+ {
+ href: `${studioBaseUrl}/course_info/${courseId}`,
+ title: intl.formatMessage(messages['header.links.updates']),
+ },
+ {
+ href: getPagePath(courseId, 'true', 'tabs'),
+ title: intl.formatMessage(messages['header.links.pages']),
+ },
+ {
+ href: `${studioBaseUrl}/assets/${courseId}`,
+ title: intl.formatMessage(messages['header.links.filesAndUploads']),
+ },
+ ];
+ if (process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true') {
+ items.push({
+ href: `${studioBaseUrl}/videos/${courseId}`,
+ title: intl.formatMessage(messages['header.links.videoUploads']),
+ });
+ }
+
+ return items;
+};
+
+export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
+ {
+ href: `${studioBaseUrl}/settings/details/${courseId}`,
+ title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
+ },
+ {
+ href: `${studioBaseUrl}/settings/grading/${courseId}`,
+ title: intl.formatMessage(messages['header.links.grading']),
+ },
+ {
+ href: `${studioBaseUrl}/course_team/${courseId}`,
+ title: intl.formatMessage(messages['header.links.pages']),
+ },
+ {
+ href: `${studioBaseUrl}/group_configurations/course-v1:${courseId}`,
+ title: intl.formatMessage(messages['header.links.groupConfigurations']),
+ },
+ {
+ href: `${studioBaseUrl}/settings/advanced/${courseId}`,
+ title: intl.formatMessage(messages['header.links.advancedSettings']),
+ },
+ {
+ href: `${studioBaseUrl}/certificates/${courseId}`,
+ title: intl.formatMessage(messages['header.links.certificates']),
+ },
+]);
+
+export const getToolsMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
+ {
+ href: `${studioBaseUrl}/import/${courseId}`,
+ title: intl.formatMessage(messages['header.links.import']),
+ },
+ {
+ href: `${studioBaseUrl}/export/${courseId}`,
+ title: intl.formatMessage(messages['header.links.export']),
+ }, {
+ href: `${studioBaseUrl}/checklists/${courseId}`,
+ title: intl.formatMessage(messages['header.links.checklists']),
+ },
+]);
+
+export const getUserMenuItems = ({
+ studioBaseUrl,
+ logoutUrl,
+ intl,
+ isAdmin,
+}) => {
+ let items = [
+ {
+ href: `${studioBaseUrl}}`,
+ title: intl.formatMessage(messages['header.user.menu.studio']),
+ }, {
+ href: `${logoutUrl}`,
+ title: intl.formatMessage(messages['header.user.menu.logout']),
+ },
+ ];
+ if (isAdmin) {
+ items = [
+ {
+ href: `${studioBaseUrl}}`,
+ title: intl.formatMessage(messages['header.user.menu.studio']),
+ }, {
+ href: `${studioBaseUrl}/maintenance`,
+ title: intl.formatMessage(messages['header.user.menu.maintenance']),
+ }, {
+ href: `${logoutUrl}`,
+ title: intl.formatMessage(messages['header.user.menu.logout']),
+ },
+ ];
+ }
+
+ return items;
+};