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 ? ( - {alt} - ) : ( - - ); - - 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, +}) => ( + + {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 ( - - - {content} - - - {submenuContent} - - - ); - }); - } - - renderUserMenu() { - const { - userMenu, - avatar, - username, - intl, - } = this.props; - - return ( - - - - {username} - - - {userMenu.map(({ type, href, content }) => ( - {content} - ))} - - - ); - } - - render() { - const { - logo, - logoAltText, - logoDestination, - intl, - } = this.props; - const logoProps = { src: logo, alt: logoAltText, href: logoDestination }; - - return ( -
-
-
- {logoDestination === null ? : } - {/* This lockup HTML was copied from edx/frontend-app-learning/src/course-header/Header.jsx. */} - { this.props.courseLockUp } - - -
-
-
- ); - } -} - -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: ( - <> -
- {intl.formatMessage(messages['header.links.outline'])} -
-
- {intl.formatMessage(messages['header.links.updates'])} -
-
- {intl.formatMessage(messages['header.links.pages'])} -
-
- {intl.formatMessage(messages['header.links.filesAndUploads'])} -
-
- {intl.formatMessage(messages['header.links.textbooks'])} -
- {process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' && ( -
- {intl.formatMessage(messages['header.links.videoUploads'])} -
- )} - - ), - }, - { - type: 'submenu', - content: intl.formatMessage(messages['header.links.settings']), - submenuContent: ( - <> -
- {intl.formatMessage(messages['header.links.scheduleAndDetails'])} -
-
- {intl.formatMessage(messages['header.links.grading'])} -
-
- {intl.formatMessage(messages['header.links.courseTeam'])} -
-
- {intl.formatMessage(messages['header.links.groupConfigurations'])} -
-
- {intl.formatMessage(messages['header.links.advancedSettings'])} -
-
- {intl.formatMessage(messages['header.links.certificates'])} -
- - ), - }, - { - type: 'submenu', - content: intl.formatMessage(messages['header.links.tools']), - submenuContent: ( - <> -
- {intl.formatMessage(messages['header.links.import'])} -
-
- {intl.formatMessage(messages['header.links.export'])} -
-
- {intl.formatMessage(messages['header.links.checklists'])} -
- - ), - }, - ]; - - 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 }) => ( - {alt} -); - -Logo.propTypes = { - src: PropTypes.string.isRequired, - alt: PropTypes.string.isRequired, -}; - -const LinkedLogo = ({ - href, - src, - alt, - ...attributes -}) => ( - - {alt} - -); - -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 = () => ( - - - Toggle Menu - Menu Content - , - -); - -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( - - Toggle Menu - - - - - , - ); - 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( - - Toggle Menu - - - - - , - ); - 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 ( - - - {content} - - - {submenuContent} - - - ); - }); - } - - 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 ( -
    { + const [isOpen, , close, toggle] = useToggle(false); + const [target, setTarget] = useState(null); + + return ( + <> + + -
    - {mainMenu.length > 0 - ? ( - - - - - - {this.renderMainMenu()} - - - ) : null} -
    -
    - {logoDestination === null ? : } -
    -
    - - - - - - {this.renderUserMenuItems()} - - -
    -
    - ); - } -} + + + + ); +}; 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 ? ( + {username} + ) : ( + + ); + 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; +};