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 ( - - - - {username} - - - {userMenu.map(({ type, href, content }) => ( - {content} - ))} - - - ); - } - - 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 ( -
- {intl.formatMessage(messages['header.label.skip.nav'])} -
-
- {logoDestination === null ? : } - - {actionRowContent} - - -
-
-
- ); - } -} - -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 = ( - <> - - - Settings - - - 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`] = ` -
- - Skip to main content - -
-
- edX -
- -
-
-
-
-`; - -exports[` renders correctly with optional action row content 1`] = ` -
- - Skip to main content - -
-
- edX -
-
- -
- - - Help - - -
-
-
-
-`; 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, +}) => ( + + {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 ? ( + {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 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;