From 18b481cedc0db8fdf013e564541b2589da892996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 5 Jul 2022 15:06:07 +0200 Subject: [PATCH] MegaMenu: Add mega menu to new top nav design (#51616) * MegaMenu: Add mega menu to new top nav design * Copy NavBar in order to make changes more cleanly * Adding tests * whoops, forgot to resolve conflicts... * Review fixes Co-authored-by: Ashley Harrison --- .../components/MegaMenu/MegaMenu.test.tsx | 61 +++++ .../app/core/components/MegaMenu/MegaMenu.tsx | 80 +++++++ .../core/components/MegaMenu/NavBarMenu.tsx | 211 ++++++++++++++++++ .../app/core/components/NavBar/NavBarMenu.tsx | 4 +- .../app/core/components/TopNav/NavToolbar.tsx | 12 +- .../app/core/components/TopNav/TopNavPage.tsx | 7 +- public/app/core/reducers/navBarTree.ts | 2 +- 7 files changed, 371 insertions(+), 6 deletions(-) create mode 100644 public/app/core/components/MegaMenu/MegaMenu.test.tsx create mode 100644 public/app/core/components/MegaMenu/MegaMenu.tsx create mode 100644 public/app/core/components/MegaMenu/NavBarMenu.tsx diff --git a/public/app/core/components/MegaMenu/MegaMenu.test.tsx b/public/app/core/components/MegaMenu/MegaMenu.test.tsx new file mode 100644 index 00000000000..04283424de5 --- /dev/null +++ b/public/app/core/components/MegaMenu/MegaMenu.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; + +import { NavModelItem, NavSection } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { configureStore } from 'app/store/configureStore'; + +import TestProvider from '../../../../test/helpers/TestProvider'; + +import { MegaMenu } from './MegaMenu'; + +const setup = () => { + const navBarTree: NavModelItem[] = [ + { + text: 'Section name', + section: NavSection.Core, + id: 'section', + url: 'section', + children: [ + { text: 'Child1', id: 'child1', url: 'section/child1' }, + { text: 'Child2', id: 'child2', url: 'section/child2' }, + ], + }, + { + text: 'Profile', + id: 'profile', + section: NavSection.Config, + url: 'profile', + }, + ]; + + const store = configureStore({ navBarTree }); + + return render( + + + + {}} /> + + + + ); +}; + +describe('MegaMenu', () => { + it('should render component', async () => { + setup(); + + expect(await screen.findByTestId('navbarmenu')).toBeInTheDocument(); + expect(await screen.findByLabelText('Home')).toBeInTheDocument(); + expect(screen.queryAllByLabelText('Section name').length).toBe(2); + }); + + it('should filter out profile', async () => { + setup(); + + expect(screen.queryByLabelText('Profile')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/core/components/MegaMenu/MegaMenu.tsx b/public/app/core/components/MegaMenu/MegaMenu.tsx new file mode 100644 index 00000000000..894949df2c4 --- /dev/null +++ b/public/app/core/components/MegaMenu/MegaMenu.tsx @@ -0,0 +1,80 @@ +import { css } from '@emotion/css'; +import { cloneDeep } from 'lodash'; +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; + +import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { useTheme2 } from '@grafana/ui'; +import { StoreState } from 'app/types'; + +import { enrichConfigItems, enrichWithInteractionTracking, getActiveItem } from '../NavBar/utils'; + +import { NavBarMenu } from './NavBarMenu'; + +export interface Props { + onClose: () => void; + searchBarHidden?: boolean; +} + +export const MegaMenu = React.memo(({ onClose, searchBarHidden }) => { + const navBarTree = useSelector((state: StoreState) => state.navBarTree); + const theme = useTheme2(); + const styles = getStyles(theme); + const location = useLocation(); + const [showSwitcherModal, setShowSwitcherModal] = useState(false); + + const toggleSwitcherModal = () => { + setShowSwitcherModal(!showSwitcherModal); + }; + + const homeItem: NavModelItem = enrichWithInteractionTracking( + { + id: 'home', + text: 'Home', + url: config.appSubUrl || '/', + icon: 'home-alt', + }, + true + ); + + const navTree = cloneDeep(navBarTree); + + const coreItems = navTree + .filter((item) => item.section === NavSection.Core) + .map((item) => enrichWithInteractionTracking(item, true)); + const pluginItems = navTree + .filter((item) => item.section === NavSection.Plugin) + .map((item) => enrichWithInteractionTracking(item, true)); + const configItems = enrichConfigItems( + navTree.filter((item) => item.section === NavSection.Config && item && item.id !== 'help' && item.id !== 'profile'), + location, + toggleSwitcherModal + ).map((item) => enrichWithInteractionTracking(item, true)); + + const activeItem = getActiveItem(navTree, location.pathname); + + return ( +
+ +
+ ); +}); + +MegaMenu.displayName = 'MegaMenu'; + +const getStyles = (theme: GrafanaTheme2) => ({ + menuWrapper: css({ + position: 'fixed', + display: 'grid', + gridAutoFlow: 'column', + height: '100%', + zIndex: theme.zIndex.sidemenu, + }), +}); diff --git a/public/app/core/components/MegaMenu/NavBarMenu.tsx b/public/app/core/components/MegaMenu/NavBarMenu.tsx new file mode 100644 index 00000000000..248d2f7aa07 --- /dev/null +++ b/public/app/core/components/MegaMenu/NavBarMenu.tsx @@ -0,0 +1,211 @@ +import { css } from '@emotion/css'; +import { useDialog } from '@react-aria/dialog'; +import { FocusScope } from '@react-aria/focus'; +import { OverlayContainer, useOverlay } from '@react-aria/overlays'; +import React, { useRef } from 'react'; +import CSSTransition from 'react-transition-group/CSSTransition'; + +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui'; + +import { NavItem } from '../NavBar/NavBarMenu'; +import { NavBarToggle } from '../NavBar/NavBarToggle'; +import { TOP_BAR_LEVEL_HEIGHT } from '../TopNav/types'; + +const MENU_WIDTH = '350px'; + +export interface Props { + activeItem?: NavModelItem; + navItems: NavModelItem[]; + searchBarHidden?: boolean; + onClose: () => void; +} + +export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: Props) { + const theme = useTheme2(); + const styles = getStyles(theme, searchBarHidden); + const animationSpeed = theme.transitions.duration.shortest; + const animStyles = getAnimStyles(theme, animationSpeed); + const ref = useRef(null); + const { dialogProps } = useDialog({}, ref); + + const { overlayProps, underlayProps } = useOverlay( + { + isDismissable: true, + isOpen: true, + onClose, + }, + ref + ); + + return ( + + + +
+
+ + +
+ { + reportInteraction('grafana_navigation_collapsed'); + onClose(); + }} + /> + +
+
+ +
+ + + + ); +} + +NavBarMenu.displayName = 'NavBarMenu'; + +const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { + const topPosition = (searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2) + 1; + + return { + backdrop: css({ + backdropFilter: 'blur(1px)', + backgroundColor: theme.components.overlay.background, + bottom: 0, + left: 0, + position: 'fixed', + right: 0, + top: topPosition, + zIndex: theme.zIndex.navbarFixed - 2, + }), + container: css({ + display: 'flex', + bottom: 0, + flexDirection: 'column', + left: 0, + paddingTop: theme.spacing(1), + marginRight: theme.spacing(1.5), + right: 0, + // Needs to below navbar should we change the navbarFixed? add add a new level? + zIndex: theme.zIndex.navbarFixed - 1, + position: 'fixed', + top: topPosition, + boxSizing: 'content-box', + [theme.breakpoints.up('md')]: { + borderRight: `1px solid ${theme.colors.border.weak}`, + right: 'unset', + }, + }), + content: css({ + display: 'flex', + flexDirection: 'column', + overflow: 'auto', + }), + mobileHeader: css({ + borderBottom: `1px solid ${theme.colors.border.weak}`, + display: 'flex', + justifyContent: 'space-between', + padding: theme.spacing(1, 2, 2), + [theme.breakpoints.up('md')]: { + display: 'none', + }, + }), + itemList: css({ + display: 'grid', + gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, + minWidth: MENU_WIDTH, + }), + menuCollapseIcon: css({ + position: 'absolute', + top: '43px', + right: '0px', + transform: `translateX(50%)`, + }), + }; +}; + +const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { + const commonTransition = { + transitionDuration: `${animationDuration}ms`, + transitionTimingFunction: theme.transitions.easing.easeInOut, + [theme.breakpoints.down('md')]: { + overflow: 'hidden', + }, + }; + + const overlayTransition = { + ...commonTransition, + transitionProperty: 'background-color, box-shadow, width', + // this is needed to prevent a horizontal scrollbar during the animation on firefox + '.scrollbar-view': { + overflow: 'hidden !important', + }, + }; + + const backdropTransition = { + ...commonTransition, + transitionProperty: 'opacity', + }; + + const overlayOpen = { + backgroundColor: theme.colors.background.primary, + boxShadow: theme.shadows.z3, + width: '100%', + [theme.breakpoints.up('md')]: { + width: MENU_WIDTH, + }, + }; + + const overlayClosed = { + boxShadow: 'none', + width: 0, + [theme.breakpoints.up('md')]: { + backgroundColor: theme.colors.background.primary, + width: theme.spacing(7), + }, + }; + + const backdropOpen = { + opacity: 1, + }; + + const backdropClosed = { + opacity: 0, + }; + + return { + backdrop: { + appear: css(backdropClosed), + appearActive: css(backdropTransition, backdropOpen), + appearDone: css(backdropOpen), + exit: css(backdropOpen), + exitActive: css(backdropTransition, backdropClosed), + }, + overlay: { + appear: css(overlayClosed), + appearActive: css(overlayTransition, overlayOpen), + appearDone: css(overlayOpen), + exit: css(overlayOpen), + exitActive: css(overlayTransition, overlayClosed), + }, + }; +}; diff --git a/public/app/core/components/NavBar/NavBarMenu.tsx b/public/app/core/components/NavBar/NavBarMenu.tsx index e549cd222b7..53ac531b05a 100644 --- a/public/app/core/components/NavBar/NavBarMenu.tsx +++ b/public/app/core/components/NavBar/NavBarMenu.tsx @@ -220,7 +220,7 @@ const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { }; }; -function NavItem({ +export function NavItem({ link, activeItem, onClose, @@ -458,7 +458,7 @@ function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: } function getLinkIcon(link: NavModelItem) { - if (link.id === 'home') { + if (link.icon === 'grafana') { return ; } else if (link.icon) { return ; diff --git a/public/app/core/components/TopNav/NavToolbar.tsx b/public/app/core/components/TopNav/NavToolbar.tsx index afe8474d3d1..9eef5e98a54 100644 --- a/public/app/core/components/TopNav/NavToolbar.tsx +++ b/public/app/core/components/TopNav/NavToolbar.tsx @@ -10,18 +10,26 @@ import { TOP_BAR_LEVEL_HEIGHT } from './types'; export interface Props extends TopNavProps { onToggleSearchBar(): void; + onToggleMegaMenu(): void; searchBarHidden?: boolean; sectionNav: NavModelItem; subNav?: NavModelItem; } -export function NavToolbar({ actions, onToggleSearchBar, searchBarHidden, sectionNav, subNav }: Props) { +export function NavToolbar({ + actions, + searchBarHidden, + sectionNav, + subNav, + onToggleMegaMenu, + onToggleSearchBar, +}: Props) { const styles = useStyles2(getStyles); return (
- {}} /> +
diff --git a/public/app/core/components/TopNav/TopNavPage.tsx b/public/app/core/components/TopNav/TopNavPage.tsx index 5924ad69485..9b8220ecfab 100644 --- a/public/app/core/components/TopNav/TopNavPage.tsx +++ b/public/app/core/components/TopNav/TopNavPage.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import React, { PropsWithChildren } from 'react'; +import React, { PropsWithChildren, useState } from 'react'; import { useSelector } from 'react-redux'; import { useObservable, useToggle } from 'react-use'; import { createSelector } from 'reselect'; @@ -9,6 +9,8 @@ import { useStyles2 } from '@grafana/ui'; import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState } from 'app/types'; +import { MegaMenu } from '../MegaMenu/MegaMenu'; + import { NavToolbar } from './NavToolbar'; import { topNavDefaultProps, topNavUpdates } from './TopNavUpdate'; import { TopSearchBar } from './TopSearchBar'; @@ -26,6 +28,7 @@ export function TopNavPage({ children, navId }: Props) { const [searchBarHidden, toggleSearchBar] = useToggle(false); // repace with local storage const props = useObservable(topNavUpdates, topNavDefaultProps); const navModel = useSelector(createSelector(getNavIndex, (navIndex) => getNavModel(navIndex, navId ?? 'home'))); + const [megaMenuOpen, setMegaMenuOpen] = useState(false); return (
@@ -35,10 +38,12 @@ export function TopNavPage({ children, navId }: Props) { {...props} searchBarHidden={searchBarHidden} onToggleSearchBar={toggleSearchBar} + onToggleMegaMenu={() => setMegaMenuOpen(!megaMenuOpen)} sectionNav={navModel.node} />
{children}
+ {megaMenuOpen && setMegaMenuOpen(false)} />}
); } diff --git a/public/app/core/reducers/navBarTree.ts b/public/app/core/reducers/navBarTree.ts index b05979ac042..6907d7af6b9 100644 --- a/public/app/core/reducers/navBarTree.ts +++ b/public/app/core/reducers/navBarTree.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { NavModelItem } from '@grafana/data'; -import config from 'app/core/config'; +import { config } from '@grafana/runtime'; export const initialState: NavModelItem[] = config.bootData?.navTree ?? [];