diff --git a/package-lock.json b/package-lock.json index d687bbc15..8b2f3db51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1385,6 +1385,30 @@ "babel-polyfill": "6.26.0", "react-responsive": "8.0.3", "react-transition-group": "4.3.0" + }, + "dependencies": { + "react-responsive": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.0.3.tgz", + "integrity": "sha512-F9VXyLao7O8XHXbLjQbIr4+mC6Zr0RDTwNjd7ixTmYEAyKyNanBkLkFchNaMZgszoSK6PgSs/3m/QDWw33/gpg==", + "requires": { + "hyphenate-style-name": "^1.0.0", + "matchmediaquery": "^0.3.0", + "prop-types": "^15.6.1", + "shallow-equal": "^1.1.0" + } + }, + "react-transition-group": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.3.0.tgz", + "integrity": "sha512-1qRV1ZuVSdxPlPf4O8t7inxUGpdyO5zG9IoNfJxSO0ImU2A1YWkEQvFPuIPZmMLkg5hYs7vv5mMOyfgSkvAwvw==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + } } }, "@edx/frontend-platform": { @@ -16706,9 +16730,9 @@ } }, "react-responsive": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.0.3.tgz", - "integrity": "sha512-F9VXyLao7O8XHXbLjQbIr4+mC6Zr0RDTwNjd7ixTmYEAyKyNanBkLkFchNaMZgszoSK6PgSs/3m/QDWw33/gpg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.1.0.tgz", + "integrity": "sha512-U8Nv2/ZWACIw/fAE9XNPbc2Xo33X5q1bcCASc2SufvJ9ifB+o/rokfogfznSVcvS22hN1rafGi0uZD6GiVFEHw==", "requires": { "hyphenate-style-name": "^1.0.0", "matchmediaquery": "^0.3.0", @@ -16747,10 +16771,34 @@ "tiny-warning": "^1.0.0" } }, + "react-test-renderer": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.9.0.tgz", + "integrity": "sha512-R62stB73qZyhrJo7wmCW9jgl/07ai+YzvouvCXIJLBkRlRqLx4j9RqcLEAfNfU3OxTGucqR2Whmn3/Aad6L3hQ==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "react-is": "^16.9.0", + "scheduler": "^0.15.0" + }, + "dependencies": { + "scheduler": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.15.0.tgz", + "integrity": "sha512-xAefmSfN6jqAa7Kuq7LIJY0bwAPG3xlCj0HMEBQk1lxYiDKZscY2xJ5U/61ZTrYbmNQbXa+gc7czPkVo11tnCg==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + } + } + }, "react-transition-group": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.3.0.tgz", - "integrity": "sha512-1qRV1ZuVSdxPlPf4O8t7inxUGpdyO5zG9IoNfJxSO0ImU2A1YWkEQvFPuIPZmMLkg5hYs7vv5mMOyfgSkvAwvw==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==", "requires": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", diff --git a/package.json b/package.json index 795a0a2e1..f88e021b0 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,10 @@ "react": "16.12.0", "react-dom": "16.12.0", "react-redux": "7.1.3", + "react-responsive": "^8.1.0", "react-router": "5.1.2", "react-router-dom": "5.1.2", + "react-transition-group": "^4.4.1", "redux": "4.0.5" }, "devDependencies": { @@ -64,6 +66,7 @@ "glob": "7.1.6", "husky": "3.1.0", "jest": "24.9.0", - "reactifex": "1.1.1" + "reactifex": "1.1.1", + "react-test-renderer": "16.9.0" } } diff --git a/src/index.jsx b/src/index.jsx index b1001a9ae..bb5816050 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -14,6 +14,7 @@ import Footer, { messages as footerMessages } from '@edx/frontend-component-foot import appMessages from './i18n'; import ExamplePage from './example/ExamplePage'; import ProctoredExamSettings from './proctored-exam-settings/ProctoredExamSettings'; +import StudioHeader from './studio-header/Header'; import './index.scss'; import './assets/favicon.ico'; @@ -21,16 +22,22 @@ import './assets/favicon.ico'; subscribe(APP_READY, () => { ReactDOM.render( -
( - - )} + render={({ match }) => { + const courseId = decodeURIComponent(match.params.course_id); + return ( + <> + + + + ); + }} /> +
diff --git a/src/index.scss b/src/index.scss index c0820ecdb..5c7b5f703 100755 --- a/src/index.scss +++ b/src/index.scss @@ -6,3 +6,4 @@ @import "~@edx/frontend-component-footer/dist/footer"; @import "proctored-exam-settings/proctoredExamSettings.scss"; +@import "studio-header/header.scss"; diff --git a/src/studio-header/Avatar.jsx b/src/studio-header/Avatar.jsx new file mode 100644 index 000000000..1fbe193ca --- /dev/null +++ b/src/studio-header/Avatar.jsx @@ -0,0 +1,43 @@ +// This file was copied from edx/frontend-component-header-edx. +import React from 'react'; +import PropTypes from 'prop-types'; + +import { AvatarIcon } from './Icons'; + +function 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/DesktopHeader.jsx b/src/studio-header/DesktopHeader.jsx new file mode 100644 index 000000000..812dd5239 --- /dev/null +++ b/src/studio-header/DesktopHeader.jsx @@ -0,0 +1,157 @@ +// This file was copied from edx/frontend-component-header-edx. +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl } from '@edx/frontend-platform/i18n'; + +// Local Components +import { Menu, MenuTrigger, MenuContent } from './Menu'; +import Avatar from './Avatar'; +import { LinkedLogo, Logo } from './Logo'; + +// 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, + } = this.props; + + return ( + + + + {username} + + + {userMenu.map(({ type, href, content }) => ( + {content} + ))} + + + ); + } + + render() { + const { + logo, + logoAltText, + logoDestination, + courseTitleDestination, + } = 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.courseId} + + + +
+
+
+ ); + } +} + +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, + courseTitleDestination: PropTypes.string, + courseId: PropTypes.string, + avatar: PropTypes.string, + username: PropTypes.string, + loggedIn: PropTypes.bool, +}; + +DesktopHeader.defaultProps = { + mainMenu: [], + userMenu: [], + logo: null, + logoAltText: null, + logoDestination: null, + courseTitleDestination: 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 new file mode 100644 index 000000000..bb8882dd8 --- /dev/null +++ b/src/studio-header/Header.jsx @@ -0,0 +1,78 @@ +// This file was copied from edx/frontend-component-header-edx. +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import Responsive from 'react-responsive'; +import { injectIntl } from '@edx/frontend-platform/i18n'; +import { AppContext } from '@edx/frontend-platform/react'; +import { ensureConfig } from '@edx/frontend-platform'; + +import DesktopHeader from './DesktopHeader'; +import MobileHeader from './MobileHeader'; + +import StudioLogoPNG from './assets/studio-logo.png'; + +ensureConfig([ + 'STUDIO_BASE_URL', + 'LOGOUT_URL', + 'LOGIN_URL', +], 'Header component'); + +function Header({ courseId }) { + const { authenticatedUser, config } = useContext(AppContext); + + const desktopMainMenu = []; + const mobileMainMenu = [ + { + type: 'item', + href: `${config.STUDIO_BASE_URL}/course/${courseId}`, + content: 'Back to Studio Course Outline', + }, + ]; + + const userMenu = authenticatedUser === null ? [] : [ + { + type: 'item', + href: config.STUDIO_BASE_URL, + content: 'Studio Home', + }, + { + type: 'item', + href: `${config.STUDIO_BASE_URL}/maintenance`, + content: 'Maintenance', + }, + { + type: 'item', + href: config.LOGOUT_URL, + content: 'Sign Out', + }, + ]; + + const props = { + logo: StudioLogoPNG, + logoAltText: 'Studio edX', + siteName: 'edX', + logoDestination: config.STUDIO_BASE_URL, + courseTitleDestination: `${config.STUDIO_BASE_URL}/course/${courseId}`, + courseId, + username: authenticatedUser !== null ? authenticatedUser.username : null, + avatar: authenticatedUser !== null ? authenticatedUser.avatar : null, + userMenu, + }; + + return ( + <> + + + + + + + + ); +} + +Header.propTypes = { + courseId: PropTypes.string.isRequired, +}; + +export default injectIntl(Header); diff --git a/src/studio-header/Header.test.jsx b/src/studio-header/Header.test.jsx new file mode 100644 index 000000000..269633964 --- /dev/null +++ b/src/studio-header/Header.test.jsx @@ -0,0 +1,73 @@ +// This file was copied from edx/frontend-component-header-edx. +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import TestRenderer from 'react-test-renderer'; +import { AppContext } from '@edx/frontend-platform/react'; +import { getConfig } from '@edx/frontend-platform'; +import { Context as ResponsiveContext } from 'react-responsive'; + +import Header from './Header'; + +jest.mock('@edx/frontend-platform'); + +getConfig.mockReturnValue({}); + +describe('
', () => { + it('renders correctly for authenticated users on desktop', () => { + const component = ( + + + +
+ + + + ); + + const wrapper = TestRenderer.create(component); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('renders correctly for authenticated users on mobile', () => { + const component = ( + + + +
+ + + + ); + + const wrapper = TestRenderer.create(component); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/src/studio-header/Icons.jsx b/src/studio-header/Icons.jsx new file mode 100644 index 000000000..0a486b18d --- /dev/null +++ b/src/studio-header/Icons.jsx @@ -0,0 +1,47 @@ +// 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 new file mode 100644 index 000000000..6a37bf82c --- /dev/null +++ b/src/studio-header/Logo.jsx @@ -0,0 +1,36 @@ +// This file was copied from edx/frontend-component-header-edx. +import React from 'react'; +import PropTypes from 'prop-types'; + +function Logo({ src, alt, ...attributes }) { + return ( + {alt} + ); +} + +Logo.propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string.isRequired, +}; + +function LinkedLogo({ + href, + src, + alt, + ...attributes +}) { + return ( + + {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 new file mode 100644 index 000000000..1593fb1e5 --- /dev/null +++ b/src/studio-header/Menu/Menu.jsx @@ -0,0 +1,279 @@ +// 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'; + +function MenuTrigger({ tag, className, ...attributes }) { + return 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; + +function MenuContent({ tag, className, ...attributes }) { + return 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/index.jsx b/src/studio-header/Menu/index.jsx new file mode 100644 index 000000000..9ad4449bb --- /dev/null +++ b/src/studio-header/Menu/index.jsx @@ -0,0 +1,4 @@ +// 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 new file mode 100644 index 000000000..c8374ff18 --- /dev/null +++ b/src/studio-header/Menu/menu.scss @@ -0,0 +1,46 @@ +// This file was copied from edx/frontend-component-header-edx. +.menu { + position: relative; +} +.menu-content { + position: absolute; + top: 100%; + z-index: 10; + background: #fff; + min-width: 10rem; + &.pin-left { + left: 0; + } + &.pin-right { + right: 0; + } +} + + +.menu-dropdown-enter { + opacity: 0; + transform-origin: 75% 0; + transform: scale3d(0.8, 0.8, 1); +} +.menu-dropdown-enter-active { + transform-origin: 75% 0; + transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1); + transform: scale3d(1, 1, 1); + opacity: 1; +} +.menu-dropdown-enter-done { +} + +.menu-dropdown-exit { + transform-origin: 75% 0; + transform: scale3d(1, 1, 1); + opacity: 1; +} +.menu-dropdown-exit-active { + transform-origin: 75% 0; + transform: scale3d(0.8, 0.8, 1); + transition: all 250ms cubic-bezier(0.8, 0, 0.6, 1); + opacity: 0; +} +.menu-dropdown-exit-done { +} diff --git a/src/studio-header/MobileHeader.jsx b/src/studio-header/MobileHeader.jsx new file mode 100644 index 000000000..3a6554a38 --- /dev/null +++ b/src/studio-header/MobileHeader.jsx @@ -0,0 +1,158 @@ +// This file was copied from edx/frontend-component-header-edx. +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl } from '@edx/frontend-platform/i18n'; +// Local Components +import { Menu, MenuTrigger, MenuContent } from './Menu'; +import Avatar from './Avatar'; +import { LinkedLogo, Logo } from './Logo'; + +// 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, + } = this.props; + const logoProps = { src: logo, alt: logoAltText, href: logoDestination }; + const stickyClassName = stickyOnMobile ? 'sticky-top' : ''; + 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, + })), + logo: PropTypes.string, + logoAltText: PropTypes.string, + logoDestination: PropTypes.string, + avatar: PropTypes.string, + username: PropTypes.string, + stickyOnMobile: PropTypes.bool, +}; + +MobileHeader.defaultProps = { + mainMenu: [], + userMenu: [], + logo: null, + logoAltText: null, + logoDestination: null, + avatar: null, + username: null, + stickyOnMobile: true, + +}; + +export default injectIntl(MobileHeader); diff --git a/src/studio-header/__snapshots__/Header.test.jsx.snap b/src/studio-header/__snapshots__/Header.test.jsx.snap new file mode 100644 index 000000000..39d520ac2 --- /dev/null +++ b/src/studio-header/__snapshots__/Header.test.jsx.snap @@ -0,0 +1,253 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`
    renders correctly for authenticated users on desktop 1`] = ` +
    + +
    +`; + +exports[`
    renders correctly for authenticated users on mobile 1`] = ` +
    +
    +
    + +
    +
    +
    + + Studio edX + +
    +
    + +
    +
    +`; diff --git a/src/studio-header/assets/studio-logo.png b/src/studio-header/assets/studio-logo.png new file mode 100644 index 000000000..7d53e50be Binary files /dev/null and b/src/studio-header/assets/studio-logo.png differ diff --git a/src/studio-header/header.scss b/src/studio-header/header.scss new file mode 100644 index 000000000..b8105bd6e --- /dev/null +++ b/src/studio-header/header.scss @@ -0,0 +1,12 @@ +// This SCSS was copied from edx/frontend-app-learning/src/index.scss. +.course-title-lockup { + padding: 1rem 0; + padding-right: 1rem; + border-right: 1px solid #e5e5e5; + min-width: 0; + span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +}