diff --git a/src/StudioHeader.jsx b/src/StudioHeader.jsx
deleted file mode 100644
index 7e12ed6b0..000000000
--- a/src/StudioHeader.jsx
+++ /dev/null
@@ -1,200 +0,0 @@
-import React, { useContext } from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
-import { AppContext } from '@edx/frontend-platform/react';
-import {
- APP_CONFIG_INITIALIZED,
- ensureConfig,
- getConfig,
- mergeConfig,
- subscribe,
-} from '@edx/frontend-platform';
-import { ActionRow } from '@edx/paragon';
-
-import { Menu, MenuTrigger, MenuContent } from './Menu';
-import Avatar from './Avatar';
-import { LinkedLogo, Logo } from './Logo';
-
-import { CaretIcon } from './Icons';
-
-import messages from './Header.messages';
-
-ensureConfig([
- 'STUDIO_BASE_URL',
- 'LOGOUT_URL',
- 'LOGIN_URL',
- 'SITE_NAME',
- 'LOGO_URL',
- 'ORDER_HISTORY_URL',
-], 'StudioHeader component');
-
-subscribe(APP_CONFIG_INITIALIZED, () => {
- mergeConfig({
- AUTHN_MINIMAL_HEADER: !!process.env.AUTHN_MINIMAL_HEADER,
- }, 'StudioHeader additional config');
-});
-
-class StudioDesktopHeaderBase extends React.Component {
- constructor(props) { // eslint-disable-line no-useless-constructor
- super(props);
- }
-
- renderUserMenu() {
- const {
- userMenu,
- avatar,
- username,
- intl,
- } = this.props;
-
- return (
-
- );
- }
-
- renderLoggedOutItems() {
- const { loggedOutItems } = this.props;
-
- return loggedOutItems.map((item, i, arr) => (
-
- {item.content}
-
- ));
- }
-
- render() {
- const {
- logo,
- logoAltText,
- logoDestination,
- loggedIn,
- intl,
- actionRowContent,
- } = this.props;
- const logoProps = { src: logo, alt: logoAltText, href: logoDestination };
- const logoClasses = getConfig().AUTHN_MINIMAL_HEADER ? 'mw-100' : null;
-
- return (
-
- );
- }
-}
-
-StudioDesktopHeaderBase.propTypes = {
- userMenu: PropTypes.arrayOf(PropTypes.shape({
- type: PropTypes.oneOf(['item', 'menu']),
- href: PropTypes.string,
- content: PropTypes.string,
- })),
- 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,
- actionRowContent: PropTypes.element,
-
- // i18n
- intl: intlShape.isRequired,
-};
-
-StudioDesktopHeaderBase.defaultProps = {
- userMenu: [],
- loggedOutItems: [],
- logo: null,
- logoAltText: null,
- logoDestination: null,
- avatar: null,
- username: null,
- loggedIn: false,
- actionRowContent: null,
-};
-
-const StudioDesktopHeader = injectIntl(StudioDesktopHeaderBase);
-
-const StudioHeader = ({ intl, actionRowContent }) => {
- const { authenticatedUser, config } = useContext(AppContext);
-
- const userMenu = authenticatedUser === null ? [] : [
- {
- type: 'item',
- href: `${config.STUDIO_BASE_URL}`,
- content: intl.formatMessage(messages['header.user.menu.studio.home']),
- },
- {
- type: 'item',
- href: `${config.STUDIO_BASE_URL}/maintenance`,
- content: intl.formatMessage(messages['header.user.menu.studio.maintenance']),
- },
- {
- type: 'item',
- href: config.LOGOUT_URL,
- content: intl.formatMessage(messages['header.user.menu.logout']),
- },
- ];
-
- const props = {
- logo: config.LOGO_URL,
- logoAltText: config.SITE_NAME,
- logoDestination: config.STUDIO_BASE_URL,
- loggedIn: authenticatedUser !== null,
- username: authenticatedUser !== null ? authenticatedUser.username : null,
- avatar: authenticatedUser !== null ? authenticatedUser.avatar : null,
- actionRowContent,
- userMenu,
- loggedOutItems: [],
- };
-
- return ;
-};
-
-StudioHeader.propTypes = {
- intl: intlShape.isRequired,
- actionRowContent: PropTypes.element,
-};
-
-StudioHeader.defaultProps = {
- // eslint-disable-next-line react/jsx-no-useless-fragment
- actionRowContent: <>>,
-};
-
-export default injectIntl(StudioHeader);
diff --git a/src/StudioHeader.test.jsx b/src/StudioHeader.test.jsx
deleted file mode 100644
index 320f44266..000000000
--- a/src/StudioHeader.test.jsx
+++ /dev/null
@@ -1,108 +0,0 @@
-/* eslint-disable react/prop-types */
-import React, { useMemo } from 'react';
-import { IntlProvider } from '@edx/frontend-platform/i18n';
-import TestRenderer from 'react-test-renderer';
-import { Link } from 'react-router-dom';
-import { AppContext } from '@edx/frontend-platform/react';
-import {
- ActionRow,
- Button,
- Dropdown,
-} from '@edx/paragon';
-
-import { StudioHeader } from './index';
-
-const StudioHeaderComponent = ({ contextValue, appMenu = null, mainMenu = [] }) => (
-
-
-
-
-
-);
-
-const StudioHeaderContext = ({ actionRowContent = null }) => {
- const headerContextValue = useMemo(() => ({
- authenticatedUser: {
- userId: 'abc123',
- username: 'edX',
- roles: [],
- administrator: false,
- },
- config: {
- STUDIO_BASE_URL: process.env.STUDIO_BASE_URL,
- SITE_NAME: process.env.SITE_NAME,
- LOGIN_URL: process.env.LOGIN_URL,
- LOGOUT_URL: process.env.LOGOUT_URL,
- LOGO_URL: process.env.LOGO_URL,
- },
- }), []);
- return (
-
-
-
-
-
- );
-};
-
-describe('', () => {
- it('renders correctly', () => {
- const contextValue = {
- authenticatedUser: {
- userId: 'abc123',
- username: 'edX',
- roles: [],
- administrator: false,
- },
- config: {
- STUDIO_BASE_URL: process.env.STUDIO_BASE_URL,
- SITE_NAME: process.env.SITE_NAME,
- LOGIN_URL: process.env.LOGIN_URL,
- LOGOUT_URL: process.env.LOGOUT_URL,
- LOGO_URL: process.env.LOGO_URL,
- },
- };
-
- const component = ;
-
- const wrapper = TestRenderer.create(component);
-
- expect(wrapper.toJSON()).toMatchSnapshot();
- });
-
- it('renders correctly with optional action row content', () => {
- const actionRowContent = (
- <>
-
-
-
- Dropdown Item 1
- Dropdown Item 2
- Dropdown Item 3
-
-
-
-
- >
- );
-
- const component = ;
-
- const wrapper = TestRenderer.create(component);
-
- expect(wrapper.toJSON()).toMatchSnapshot();
- });
-});
diff --git a/src/__snapshots__/StudioHeader.test.jsx.snap b/src/__snapshots__/StudioHeader.test.jsx.snap
deleted file mode 100644
index 498e160d1..000000000
--- a/src/__snapshots__/StudioHeader.test.jsx.snap
+++ /dev/null
@@ -1,226 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[` renders correctly 1`] = `
-
-`;
-
-exports[` renders correctly with optional action row content 1`] = `
-
-`;
diff --git a/src/index.jsx b/src/index.jsx
index d5f394af4..9d36af355 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -1,7 +1,7 @@
import Header from './Header';
import LearningHeader from './learning-header/LearningHeader';
import messages from './i18n/index';
-import StudioHeader from './StudioHeader';
+import StudioHeader from './studio-header';
export { LearningHeader, messages, StudioHeader };
diff --git a/src/index.scss b/src/index.scss
index f6d231488..355ae0771 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -3,6 +3,7 @@ $blue: #007db8;
$white: #fff;
@import './Menu/menu.scss';
+@import './studio-header/header.scss';
.dropdown-item a {
text-decoration: none;
diff --git a/src/studio-header/BrandNav.jsx b/src/studio-header/BrandNav.jsx
new file mode 100644
index 000000000..9342c3b62
--- /dev/null
+++ b/src/studio-header/BrandNav.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+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 000000000..d4946c93d
--- /dev/null
+++ b/src/studio-header/CourseLockUp.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import {
+ OverlayTrigger,
+ Tooltip,
+} from '@edx/paragon';
+import messages from './messages';
+
+const CourseLockUp = ({
+ outlineLink,
+ org,
+ number,
+ title,
+ // injected
+ intl,
+}) => (
+
+ {title}
+
+ )}
+ >
+
+ {org} {number}
+ {title}
+
+
+);
+
+CourseLockUp.propTypes = {
+ number: PropTypes.string,
+ org: PropTypes.string,
+ title: PropTypes.string,
+ outlineLink: PropTypes.string,
+ // injected
+ intl: intlShape.isRequired,
+};
+
+CourseLockUp.defaultProps = {
+ number: null,
+ org: null,
+ title: null,
+ outlineLink: null,
+};
+
+export default injectIntl(CourseLockUp);
diff --git a/src/studio-header/HeaderBody.jsx b/src/studio-header/HeaderBody.jsx
new file mode 100644
index 000000000..4c6d23bb6
--- /dev/null
+++ b/src/studio-header/HeaderBody.jsx
@@ -0,0 +1,155 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ ActionRow,
+ Button,
+ Container,
+ Nav,
+ Row,
+} from '@edx/paragon';
+import { Close, MenuIcon } from '@edx/paragon/icons';
+
+import CourseLockUp from './CourseLockUp';
+import UserMenu from './UserMenu';
+import BrandNav from './BrandNav';
+import NavDropdownMenu from './NavDropdownMenu';
+
+const HeaderBody = ({
+ logo,
+ logoAltText,
+ number,
+ org,
+ title,
+ username,
+ isAdmin,
+ studioBaseUrl,
+ logoutUrl,
+ authenticatedUserAvatar,
+ isMobile,
+ setModalPopupTarget,
+ toggleModalPopup,
+ isModalPopupOpen,
+ isHiddenMainMenu,
+ mainMenuDropdowns,
+ outlineLink,
+}) => {
+ const renderBrandNav = (
+
+ );
+
+ return (
+
+
+ {isHiddenMainMenu ? (
+
+ {renderBrandNav}
+
+ ) : (
+ <>
+ {isMobile ? (
+
+ ) : (
+
+ {renderBrandNav}
+
+
+ )}
+ {isMobile ? (
+ <>
+
+ {renderBrandNav}
+ >
+ ) : (
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+};
+
+HeaderBody.propTypes = {
+ studioBaseUrl: PropTypes.string.isRequired,
+ logoutUrl: PropTypes.string.isRequired,
+ setModalPopupTarget: PropTypes.func.isRequired,
+ toggleModalPopup: PropTypes.func.isRequired,
+ isModalPopupOpen: PropTypes.bool.isRequired,
+ number: PropTypes.string,
+ org: PropTypes.string,
+ title: PropTypes.string,
+ logo: PropTypes.string,
+ logoAltText: PropTypes.string,
+ authenticatedUserAvatar: PropTypes.string,
+ username: PropTypes.string,
+ isAdmin: PropTypes.bool,
+ isMobile: PropTypes.bool,
+ isHiddenMainMenu: PropTypes.bool,
+ mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.string,
+ buttonTitle: PropTypes.string,
+ items: PropTypes.arrayOf(PropTypes.shape({
+ href: PropTypes.string,
+ title: PropTypes.string,
+ })),
+ })),
+ outlineLink: PropTypes.string,
+};
+
+HeaderBody.defaultProps = {
+ logo: null,
+ logoAltText: null,
+ number: '',
+ org: '',
+ title: '',
+ authenticatedUserAvatar: null,
+ username: null,
+ isAdmin: false,
+ isMobile: false,
+ isHiddenMainMenu: false,
+ mainMenuDropdowns: [],
+ outlineLink: null,
+};
+
+export default HeaderBody;
diff --git a/src/studio-header/MobileHeader.jsx b/src/studio-header/MobileHeader.jsx
new file mode 100644
index 000000000..7445ffb7c
--- /dev/null
+++ b/src/studio-header/MobileHeader.jsx
@@ -0,0 +1,76 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { useToggle, ModalPopup } from '@edx/paragon';
+import HeaderBody from './HeaderBody';
+import MobileMenu from './MobileMenu';
+
+const MobileHeader = ({
+ mainMenuDropdowns,
+ ...props
+}) => {
+ const [isOpen, , close, toggle] = useToggle(false);
+ const [target, setTarget] = useState(null);
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+MobileHeader.propTypes = {
+ studioBaseUrl: PropTypes.string.isRequired,
+ logoutUrl: PropTypes.string.isRequired,
+ setModalPopupTarget: PropTypes.func.isRequired,
+ toggleModalPopup: PropTypes.func.isRequired,
+ isModalPopupOpen: PropTypes.bool.isRequired,
+ number: PropTypes.string,
+ org: PropTypes.string,
+ title: PropTypes.string,
+ logo: PropTypes.string,
+ logoAltText: PropTypes.string,
+ authenticatedUserAvatar: PropTypes.string,
+ username: PropTypes.string,
+ isAdmin: PropTypes.bool,
+ mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.string,
+ buttonTitle: PropTypes.string,
+ items: PropTypes.arrayOf(PropTypes.shape({
+ href: PropTypes.string,
+ title: PropTypes.string,
+ })),
+ })),
+ outlineLink: PropTypes.string,
+};
+
+MobileHeader.defaultProps = {
+ logo: null,
+ logoAltText: null,
+ number: null,
+ org: null,
+ title: null,
+ authenticatedUserAvatar: null,
+ username: null,
+ isAdmin: false,
+ mainMenuDropdowns: [],
+ outlineLink: null,
+};
+
+export default MobileHeader;
diff --git a/src/studio-header/MobileMenu.jsx b/src/studio-header/MobileMenu.jsx
new file mode 100644
index 000000000..3ff31e6c1
--- /dev/null
+++ b/src/studio-header/MobileMenu.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Collapsible } from '@edx/paragon';
+
+const MobileMenu = ({
+ mainMenuDropdowns,
+}) => (
+
+
+ {mainMenuDropdowns.map(dropdown => {
+ const { id, buttonTitle, items } = dropdown;
+ return (
+
+
+
+ );
+ })}
+
+
+);
+
+MobileMenu.propTypes = {
+ mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.string,
+ buttonTitle: PropTypes.string,
+ items: PropTypes.arrayOf(PropTypes.shape({
+ href: PropTypes.string,
+ title: PropTypes.string,
+ })),
+ })),
+};
+MobileMenu.defaultProps = {
+ mainMenuDropdowns: [],
+};
+
+export default MobileMenu;
diff --git a/src/studio-header/NavDropdownMenu.jsx b/src/studio-header/NavDropdownMenu.jsx
new file mode 100644
index 000000000..4cacf3adb
--- /dev/null
+++ b/src/studio-header/NavDropdownMenu.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+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/StudioHeader.jsx b/src/studio-header/StudioHeader.jsx
new file mode 100644
index 000000000..dba52347f
--- /dev/null
+++ b/src/studio-header/StudioHeader.jsx
@@ -0,0 +1,74 @@
+import React, { useContext } from 'react';
+import PropTypes from 'prop-types';
+import Responsive from 'react-responsive';
+import { AppContext } from '@edx/frontend-platform/react';
+import { ensureConfig } from '@edx/frontend-platform';
+
+import MobileHeader from './MobileHeader';
+import HeaderBody from './HeaderBody';
+
+ensureConfig([
+ 'STUDIO_BASE_URL',
+ 'SITE_NAME',
+ 'LOGOUT_URL',
+ 'LOGIN_URL',
+ 'LOGO_URL',
+], 'Studio Header component');
+
+const StudioHeader = ({
+ number, org, title, isHiddenMainMenu, mainMenuDropdowns, outlineLink,
+}) => {
+ const { authenticatedUser, config } = useContext(AppContext);
+ const props = {
+ logo: config.LOGO_URL,
+ logoAltText: `Studio ${config.SITE_NAME}`,
+ number,
+ org,
+ title,
+ username: authenticatedUser?.username,
+ isAdmin: authenticatedUser?.administrator,
+ authenticatedUserAvatar: authenticatedUser?.avatar,
+ studioBaseUrl: config.STUDIO_BASE_URL,
+ logoutUrl: config.LOGOUT_URL,
+ isHiddenMainMenu,
+ mainMenuDropdowns,
+ outlineLink,
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
+
+StudioHeader.propTypes = {
+ number: PropTypes.string,
+ org: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ isHiddenMainMenu: PropTypes.bool,
+ mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.string,
+ buttonTitle: PropTypes.string,
+ items: PropTypes.arrayOf(PropTypes.shape({
+ href: PropTypes.string,
+ title: PropTypes.string,
+ })),
+ })),
+ outlineLink: PropTypes.string,
+};
+
+StudioHeader.defaultProps = {
+ number: '',
+ org: '',
+ isHiddenMainMenu: false,
+ mainMenuDropdowns: [],
+ outlineLink: null,
+};
+
+export default StudioHeader;
diff --git a/src/studio-header/StudioHeader.test.jsx b/src/studio-header/StudioHeader.test.jsx
new file mode 100644
index 000000000..8ebda05cd
--- /dev/null
+++ b/src/studio-header/StudioHeader.test.jsx
@@ -0,0 +1,197 @@
+/* eslint-disable react/prop-types */
+import React, { useMemo } from 'react';
+import {
+ render,
+ fireEvent,
+ waitFor,
+} from '@testing-library/react';
+
+import { AppContext } from '@edx/frontend-platform/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { Context as ResponsiveContext } from 'react-responsive';
+
+import StudioHeader from './StudioHeader';
+import messages from './messages';
+
+const authenticatedUser = {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ avatar: '/imges/test.png',
+};
+let currentUser;
+let screenWidth = 1280;
+
+const RootWrapper = ({
+ ...props
+}) => {
+ const appContextValue = useMemo(() => ({
+ authenticatedUser: currentUser,
+ config: {
+ LOGOUT_URL: process.env.LOGOUT_URL,
+ LOGO_URL: process.env.LOGO_URL,
+ SITE_NAME: process.env.SITE_NAME,
+ STUDIO_BASE_URL: process.env.STUDIO_BASE_URL,
+ LOGIN_URL: process.env.LOGIN_URL,
+ },
+ }), []);
+ const responsiveContextValue = useMemo(() => ({ width: screenWidth }), []);
+
+ return (
+ // eslint-disable-next-line react/jsx-no-constructed-context-values, react/prop-types
+
+
+
+
+
+
+
+ );
+};
+
+const props = {
+ number: '123',
+ org: 'Ed',
+ title: 'test',
+ mainMenuDropdowns: [
+ {
+ id: 'testId',
+ buttonTitle: 'test',
+ items: [
+ {
+ title: 'link',
+ href: '#',
+ },
+ ],
+ },
+ ],
+ outlineLink: 'tEsTLInK',
+};
+
+describe('Header', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ currentUser = authenticatedUser;
+ });
+ describe('desktop', () => {
+ it('course lock up should be visible', () => {
+ const { getByTestId } = render();
+ const courseLockUpBlock = getByTestId('course-lock-up-block');
+
+ expect(courseLockUpBlock).toBeVisible();
+ });
+
+ it('mobile menu should not be visible', () => {
+ const { queryByTestId } = render();
+ const mobileMenuButton = queryByTestId('mobile-menu-button');
+
+ expect(mobileMenuButton).toBeNull();
+ });
+
+ it('desktop menu should be visible', () => {
+ const { getByTestId } = render();
+ const desktopMenu = getByTestId('desktop-menu');
+
+ expect(desktopMenu).toBeVisible();
+ });
+
+ it('should render one dropdown', async () => {
+ const { getAllByRole, getByText } = render();
+ const dropdownMenu = getAllByRole('button')[0];
+
+ expect(dropdownMenu).toBeVisible();
+
+ await waitFor(() => fireEvent.click(dropdownMenu));
+ const dropdownOption = getByText('link');
+
+ expect(dropdownOption).toBeVisible();
+ });
+
+ it('maintenance should not be in user menu', async () => {
+ currentUser = { ...authenticatedUser, administrator: false };
+ const { getAllByRole, queryByText } = render();
+ const userMenu = getAllByRole('button')[1];
+ await waitFor(() => fireEvent.click(userMenu));
+ const maintenanceButton = queryByText(messages['header.user.menu.maintenance'].defaultMessage);
+
+ expect(maintenanceButton).toBeNull();
+ });
+
+ it('user menu should use avatar icon', async () => {
+ currentUser = { ...authenticatedUser, avatar: null };
+ const { getByTestId } = render();
+ const avatarIcon = getByTestId('avatar-icon');
+
+ expect(avatarIcon).toBeVisible();
+ });
+
+ it('should hide nav items if prop isHiddenMainMenu true', async () => {
+ const initialProps = { ...props, isHiddenMainMenu: true };
+ const { queryByTestId } = render();
+ const desktopMenu = queryByTestId('desktop-menu');
+ const mobileMenuButton = queryByTestId('mobile-menu-button');
+
+ expect(mobileMenuButton).toBeNull();
+
+ expect(desktopMenu).toBeNull();
+ });
+ });
+
+ describe('mobile', () => {
+ beforeEach(() => { screenWidth = 500; });
+ it('course lock up should not be visible', async () => {
+ const { queryByTestId } = render();
+ const courseLockUpBlock = queryByTestId('course-lock-up-block');
+
+ expect(courseLockUpBlock).toBeNull();
+ });
+
+ it('mobile menu should be visible', async () => {
+ const { getByTestId } = render();
+ const mobileMenuButton = getByTestId('mobile-menu-button');
+
+ expect(mobileMenuButton).toBeVisible();
+ await waitFor(() => fireEvent.click(mobileMenuButton));
+ const mobileMenu = getByTestId('mobile-menu');
+
+ expect(mobileMenu).toBeVisible();
+ });
+
+ it('desktop menu should not be visible', () => {
+ const { queryByTestId } = render();
+ const desktopMenu = queryByTestId('desktop-menu');
+
+ expect(desktopMenu).toBeNull();
+ });
+
+ it('maintenance should be in user menu', async () => {
+ const { getAllByRole, getByText } = render();
+ const userMenu = getAllByRole('button')[1];
+ await waitFor(() => fireEvent.click(userMenu));
+ const maintenanceButton = getByText(messages['header.user.menu.maintenance'].defaultMessage);
+
+ expect(maintenanceButton).toBeVisible();
+ });
+
+ it('user menu should use avatar image', async () => {
+ const { getByTestId } = render();
+ const avatarImage = getByTestId('avatar-image');
+
+ expect(avatarImage).toBeVisible();
+ });
+
+ it('should hide nav items if prop isHiddenMainMenu true', async () => {
+ const initialProps = { ...props, isHiddenMainMenu: true };
+ const { queryByTestId } = render();
+ const desktopMenu = queryByTestId('desktop-menu');
+ const mobileMenuButton = queryByTestId('mobile-menu-button');
+
+ expect(mobileMenuButton).toBeNull();
+
+ expect(desktopMenu).toBeNull();
+ });
+ });
+});
diff --git a/src/studio-header/UserMenu.jsx b/src/studio-header/UserMenu.jsx
new file mode 100644
index 000000000..0c9708077
--- /dev/null
+++ b/src/studio-header/UserMenu.jsx
@@ -0,0 +1,69 @@
+import React from 'react';
+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
new file mode 100644
index 000000000..7885ef64e
--- /dev/null
+++ b/src/studio-header/header.scss
@@ -0,0 +1,64 @@
+// This SCSS was partly copied from edx/frontend-app-support-tools/src/support-header/index.scss.
+$spacer: 1rem;
+$white: #FFFFFF;
+
+.btn-tertiary:hover {
+ color: white;
+ background-color: #00262B;
+}
+
+.course-title-lockup {
+ @media only screen and (max-width: 768px) {
+ padding-left: .5rem;
+ max-width: 70%;
+ }
+
+ @media only screen and (min-width: 769px) {
+ padding: .5rem;
+ padding-right: $spacer;
+ border-right: 1px solid #E5E5E5;
+ min-width: 70%;
+ }
+
+ overflow: hidden;
+
+ span {
+ color: #333333;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.375rem;
+ }
+}
+
+.site-header-mobile,
+.site-header-desktop {
+ position: relative;
+ z-index: 1000;
+}
+
+.site-header-mobile {img {
+ height: 1.5rem;
+ }
+}
+
+.site-header-desktop {
+ height: 3.75rem;
+ box-shadow: 0 1px 0 0 rgb(0 0 0 / .1);
+ background: $white;
+
+ .logo {
+ display: block;
+ box-sizing: content-box;
+ position: relative;
+ top: -.05em;
+ height: 1.75rem;
+ padding: $spacer 0;
+ margin-right: $spacer;
+
+ img {
+ display: block;
+ height: 100%;
+ }
+ }
+}
diff --git a/src/studio-header/index.js b/src/studio-header/index.js
new file mode 100644
index 000000000..9f6787e7a
--- /dev/null
+++ b/src/studio-header/index.js
@@ -0,0 +1,3 @@
+import StudioHeader from './StudioHeader';
+
+export default StudioHeader;
diff --git a/src/studio-header/messages.js b/src/studio-header/messages.js
new file mode 100644
index 000000000..576b93cd4
--- /dev/null
+++ b/src/studio-header/messages.js
@@ -0,0 +1,156 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ 'header.links.content': {
+ id: 'header.links.content',
+ defaultMessage: 'Content',
+ description: 'Label for Content menu trigger',
+ },
+ 'header.links.settings': {
+ id: 'header.links.settings',
+ defaultMessage: 'Settings',
+ description: 'Label for Settings menu trigger',
+ },
+ 'header.links.tools': {
+ id: 'header.links.content.tools',
+ defaultMessage: 'Tools',
+ description: 'Label for Tools menu trigger',
+ },
+ 'header.links.outline': {
+ id: 'header.links.outline',
+ defaultMessage: 'Outline',
+ description: 'Link to Studio Outline page',
+ },
+ 'header.links.updates': {
+ id: 'header.links.updates',
+ defaultMessage: 'Updates',
+ description: 'Link to Studio Updates page',
+ },
+ 'header.links.pages': {
+ id: 'header.links.pages',
+ defaultMessage: 'Pages & Resources',
+ description: 'Link to Studio Pages page',
+ },
+ 'header.links.filesAndUploads': {
+ id: 'header.links.filesAndUploads',
+ defaultMessage: 'Files & Uploads',
+ description: 'Link to Studio Files & Uploads page',
+ },
+ 'header.links.textbooks': {
+ id: 'header.links.textbooks',
+ defaultMessage: 'Textbooks',
+ description: 'Link to Studio Textbooks page',
+ },
+ 'header.links.videoUploads': {
+ id: 'header.links.videoUploads',
+ defaultMessage: 'Video Uploads',
+ description: 'Link to Studio Video Uploads page',
+ },
+ 'header.links.scheduleAndDetails': {
+ id: 'header.links.scheduleAndDetails',
+ defaultMessage: 'Schedule & Details',
+ description: 'Link to Studio Schedule & Details page',
+ },
+ 'header.links.grading': {
+ id: 'header.links.grading',
+ defaultMessage: 'Grading',
+ description: 'Link to Studio Grading page',
+ },
+ 'header.links.courseTeam': {
+ id: 'header.links.courseTeam',
+ defaultMessage: 'Course Team',
+ description: 'Link to Studio Course Team page',
+ },
+ 'header.links.groupConfigurations': {
+ id: 'header.links.groupConfigurations',
+ defaultMessage: 'Group Configurations',
+ description: 'Link to Studio Group Configurations page',
+ },
+ 'header.links.proctoredExamSettings': {
+ id: 'header.links.proctoredExamSettings',
+ defaultMessage: 'Proctored Exam Settings',
+ description: 'Link to Studio Proctored Exam Settings page',
+ },
+ 'header.links.advancedSettings': {
+ id: 'header.links.advancedSettings',
+ defaultMessage: 'Advanced Settings',
+ description: 'Link to Studio Advanced Settings page',
+ },
+ 'header.links.certificates': {
+ id: 'header.links.certificates',
+ defaultMessage: 'Certificates',
+ description: 'Link to Studio Certificates page',
+ },
+ 'header.links.publisher': {
+ id: 'header.links.publisher',
+ defaultMessage: 'Publisher',
+ description: 'Link to Publisher',
+ },
+ 'header.links.import': {
+ id: 'header.links.import',
+ defaultMessage: 'Import',
+ description: 'Link to Studio Import page',
+ },
+ 'header.links.export': {
+ id: 'header.links.export',
+ defaultMessage: 'Export',
+ description: 'Link to Studio Export page',
+ },
+ 'header.links.checklists': {
+ id: 'header.links.checklists',
+ defaultMessage: 'Checklists',
+ description: 'Link to Studio Checklists page',
+ },
+ 'header.user.menu.studio': {
+ id: 'header.user.menu.studio',
+ defaultMessage: 'Studio Home',
+ description: 'Link to Studio Home',
+ },
+ 'header.user.menu.maintenance': {
+ id: 'header.user.menu.maintenance',
+ defaultMessage: 'Maintenance',
+ description: 'Link to the Studio maintenance page',
+ },
+ 'header.user.menu.logout': {
+ id: 'header.user.menu.logout',
+ defaultMessage: 'Logout',
+ description: 'Logout link',
+ },
+ 'header.label.account.menu': {
+ id: 'header.label.account.menu',
+ defaultMessage: 'Account Menu',
+ description: 'The aria label for the account menu trigger',
+ },
+ 'header.label.account.menu.for': {
+ id: 'header.label.account.menu.for',
+ defaultMessage: 'Account menu for {username}',
+ description: 'The aria label for the account menu trigger when the username is displayed in it',
+ },
+ 'header.label.main.nav': {
+ id: 'header.label.main.nav',
+ defaultMessage: 'Main',
+ description: 'The aria label for the main menu nav',
+ },
+ 'header.label.main.menu': {
+ id: 'header.label.main.menu',
+ defaultMessage: 'Main Menu',
+ description: 'The aria label for the main menu trigger',
+ },
+ 'header.label.main.header': {
+ id: 'header.label.main.header',
+ defaultMessage: 'Main',
+ description: 'The aria label for the main header',
+ },
+ 'header.label.secondary.nav': {
+ id: 'header.label.secondary.nav',
+ defaultMessage: 'Secondary',
+ description: 'The aria label for the seconary nav',
+ },
+ 'header.label.courseOutline': {
+ id: 'header.label.courseOutline',
+ defaultMessage: 'Back to course outline in Studio',
+ description: 'The aria label for the link back to the Studio Course Outline',
+ },
+});
+
+export default messages;
diff --git a/src/studio-header/utils.js b/src/studio-header/utils.js
new file mode 100644
index 000000000..c4b36589c
--- /dev/null
+++ b/src/studio-header/utils.js
@@ -0,0 +1,36 @@
+import messages from './messages';
+
+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;
+};
+
+export default getUserMenuItems;