diff --git a/package-lock.json b/package-lock.json index 307b02fcd..1e6d1f874 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "classnames": "^2.5.1", "jest-environment-jsdom": "^29.7.0", "react-responsive": "8.2.0", - "react-transition-group": "4.4.5" + "react-transition-group": "4.4.5", + "universal-cookie": "^7.2.0" }, "devDependencies": { "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", @@ -2089,6 +2090,12 @@ "redux": "^4.0.4" } }, + "node_modules/@edx/frontend-platform/node_modules/@types/cookie": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", + "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==", + "dev": true + }, "node_modules/@edx/frontend-platform/node_modules/axios": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", @@ -2100,6 +2107,25 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/@edx/frontend-platform/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@edx/frontend-platform/node_modules/universal-cookie": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz", + "integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==", + "dev": true, + "dependencies": { + "@types/cookie": "^0.3.3", + "cookie": "^0.4.0" + } + }, "node_modules/@edx/new-relic-source-map-webpack-plugin": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@edx/new-relic-source-map-webpack-plugin/-/new-relic-source-map-webpack-plugin-2.1.0.tgz", @@ -4107,9 +4133,9 @@ } }, "node_modules/@types/cookie": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", - "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" }, "node_modules/@types/eslint": { "version": "8.56.12", @@ -16490,22 +16516,12 @@ "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz", "integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==", "dependencies": { - "@types/cookie": "^0.3.3", - "cookie": "^0.4.0" - } - }, - "node_modules/universal-cookie/node_modules/cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", - "engines": { - "node": ">= 0.6" + "@types/cookie": "^0.3.3" } }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "engines": { "node": ">= 10.0.0" } diff --git a/package.json b/package.json index 16c061ee2..dad87f3e5 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "classnames": "^2.5.1", "jest-environment-jsdom": "^29.7.0", "react-responsive": "8.2.0", - "react-transition-group": "4.4.5" + "react-transition-group": "4.4.5", + "universal-cookie": "^7.2.0" }, "peerDependencies": { "@edx/frontend-platform": "^7.0.0 || ^8.0.0", diff --git a/src/DesktopHeader.jsx b/src/DesktopHeader.jsx new file mode 100644 index 000000000..d915f7812 --- /dev/null +++ b/src/DesktopHeader.jsx @@ -0,0 +1,224 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; +import ThemeToggleButton from './ThemeToggleButton'; + +// 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); + } + + renderMenu(menu) { + // Nodes are accepted as a prop + if (!Array.isArray(menu)) { + return menu; + } + + return menu.map((menuItem) => { + const { + type, + href, + content, + submenuContent, + disabled, + isActive, + onClick, + } = menuItem; + + if (type === 'item') { + return ( + + {content} + + ); + } + + return ( + + + {content} + + + {submenuContent} + + + ); + }); + } + + renderMainMenu() { + const { mainMenu } = this.props; + return this.renderMenu(mainMenu); + } + + renderSecondaryMenu() { + const { secondaryMenu } = this.props; + return this.renderMenu(secondaryMenu); + } + + renderUserMenu() { + const { + userMenu, + avatar, + username, + intl, + } = this.props; + + return ( + + + + {username} + + + {userMenu.map((group, index) => ( + // eslint-disable-next-line react/jsx-no-comment-textnodes,react/no-array-index-key + + {group.heading &&
{group.heading}
} + {group.items.map(({ + type, content, href, disabled, isActive, onClick, + }) => ( + + {content} + + ))} + {index < userMenu.length - 1 &&
} + + ))} + +
+ ); + } + + renderLoggedOutItems() { + const { loggedOutItems } = this.props; + + return loggedOutItems.map((item, i, arr) => ( + + {item.content} + + )); + } + + render() { + const { + logo, + logoAltText, + logoDestination, + loggedIn, + intl, + } = 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 ? : } + + +
+
+
+ ); + } +} + +DesktopHeader.propTypes = { + mainMenu: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.array, + ]), + secondaryMenu: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.array, + ]), + userMenu: PropTypes.arrayOf(PropTypes.shape({ + heading: PropTypes.string, + items: PropTypes.arrayOf(PropTypes.shape({ + type: PropTypes.oneOf(['item', 'menu']), + href: PropTypes.string, + content: PropTypes.string, + isActive: PropTypes.bool, + onClick: PropTypes.func, + })), + })), + 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, + + // i18n + intl: intlShape.isRequired, +}; + +DesktopHeader.defaultProps = { + mainMenu: [], + secondaryMenu: [], + userMenu: [], + loggedOutItems: [], + logo: null, + logoAltText: null, + logoDestination: null, + avatar: null, + username: null, + loggedIn: false, +}; + +export default injectIntl(DesktopHeader); diff --git a/src/ThemeToggleButton.jsx b/src/ThemeToggleButton.jsx new file mode 100644 index 000000000..862e4d01e --- /dev/null +++ b/src/ThemeToggleButton.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { getConfig } from '@edx/frontend-platform'; +import Cookies from 'universal-cookie'; + +const ThemeToggleButton = () => { + const getNextWeek = () => { + const today = new Date(); + return new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7); + }; + + const onToggleTheme = () => { + const cookies = new Cookies(); + const serverURL = new URL(getConfig().LMS_BASE_URL); + const themeCookieName = getConfig().THEME_COOKIE_NAME; + + const options = { domain: serverURL.hostname, path: '/', expires: getNextWeek() }; + let themeName = ''; + + if (cookies.get(themeCookieName) === 'dark') { + document.body.classList.remove('indigo-dark-theme'); + themeName = 'light'; + } else { + document.body.classList.add('indigo-dark-theme'); + themeName = 'dark'; + } + cookies.set(themeCookieName, themeName, options); + }; + + return ( +
+ +
+ + + + + + + + +
+
+
+ ); +}; + +export default ThemeToggleButton; diff --git a/src/__snapshots__/Header.test.jsx.snap b/src/__snapshots__/Header.test.jsx.snap index c34c5706e..eb320bef2 100644 --- a/src/__snapshots__/Header.test.jsx.snap +++ b/src/__snapshots__/Header.test.jsx.snap @@ -42,6 +42,46 @@ exports[`
renders correctly for anonymous desktop 1`] = ` aria-label="Secondary" className="nav secondary-menu-container align-items-center ml-auto" > +
+ +
+ + + + + + + + +
+
+
renders correctly for anonymous mobile 1`] = ` /> +
+ +
+ + + + + + + + +
+
+
@@ -225,6 +305,46 @@ exports[`
renders correctly for authenticated desktop 1`] = ` aria-label="Secondary" className="nav secondary-menu-container align-items-center ml-auto" > +
+ +
+ + + + + + + + +
+
+
renders correctly for authenticated mobile 1`] = ` />
+
+ +
+ + + + + + + + +
+
+
diff --git a/src/learning-header/LearningHeader.jsx b/src/learning-header/LearningHeader.jsx index 178e470ea..8f701f532 100644 --- a/src/learning-header/LearningHeader.jsx +++ b/src/learning-header/LearningHeader.jsx @@ -33,6 +33,7 @@ const LearningHeader = ({
+ {showUserDropdown && authenticatedUser && ( <> diff --git a/src/mobile-header/MobileHeader.jsx b/src/mobile-header/MobileHeader.jsx index 838eff19f..62c42226d 100644 --- a/src/mobile-header/MobileHeader.jsx +++ b/src/mobile-header/MobileHeader.jsx @@ -87,6 +87,7 @@ class MobileHeader extends React.Component {
+ {userMenu.length > 0 || loggedOutItems.length > 0 ? (