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 <ashley.harrison@grafana.com>
This commit is contained in:
Torkel Ödegaard 2022-07-05 15:06:07 +02:00 committed by GitHub
parent 7eec92988e
commit 18b481cedc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 371 additions and 6 deletions

View File

@ -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(
<Provider store={store}>
<TestProvider>
<Router history={locationService.getHistory()}>
<MegaMenu onClose={() => {}} />
</Router>
</TestProvider>
</Provider>
);
};
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();
});
});

View File

@ -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<Props>(({ 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 (
<div className={styles.menuWrapper}>
<NavBarMenu
activeItem={activeItem}
navItems={[homeItem, ...coreItems, ...pluginItems, ...configItems]}
onClose={onClose}
searchBarHidden={searchBarHidden}
/>
</div>
);
});
MegaMenu.displayName = 'MegaMenu';
const getStyles = (theme: GrafanaTheme2) => ({
menuWrapper: css({
position: 'fixed',
display: 'grid',
gridAutoFlow: 'column',
height: '100%',
zIndex: theme.zIndex.sidemenu,
}),
});

View File

@ -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 (
<OverlayContainer>
<FocusScope contain autoFocus>
<CSSTransition appear={true} in={true} classNames={animStyles.overlay} timeout={animationSpeed}>
<div data-testid="navbarmenu" ref={ref} {...overlayProps} {...dialogProps} className={styles.container}>
<div className={styles.mobileHeader}>
<Icon name="bars" size="xl" />
<IconButton
aria-label="Close navigation menu"
name="times"
onClick={onClose}
size="xl"
variant="secondary"
/>
</div>
<NavBarToggle
className={styles.menuCollapseIcon}
isExpanded={true}
onClick={() => {
reportInteraction('grafana_navigation_collapsed');
onClose();
}}
/>
<nav className={styles.content}>
<CustomScrollbar hideHorizontalTrack>
<ul className={styles.itemList}>
{navItems.map((link) => (
<NavItem link={link} onClose={onClose} activeItem={activeItem} key={link.text} />
))}
</ul>
</CustomScrollbar>
</nav>
</div>
</CSSTransition>
<CSSTransition appear={true} in={true} classNames={animStyles.backdrop} timeout={animationSpeed}>
<div className={styles.backdrop} {...underlayProps} />
</CSSTransition>
</FocusScope>
</OverlayContainer>
);
}
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),
},
};
};

View File

@ -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 <Branding.MenuLogo />;
} else if (link.icon) {
return <Icon name={link.icon as IconName} size="xl" />;

View File

@ -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 (
<div className={styles.pageToolbar}>
<div className={styles.menuButton}>
<IconButton name="bars" tooltip="Toggle menu" tooltipPlacement="bottom" size="xl" onClick={() => {}} />
<IconButton name="bars" tooltip="Toggle menu" tooltipPlacement="bottom" size="xl" onClick={onToggleMegaMenu} />
</div>
<Breadcrumbs sectionNav={sectionNav} subNav={subNav} />
<div className={styles.leftActions}></div>

View File

@ -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 (
<div className={styles.viewport}>
@ -35,10 +38,12 @@ export function TopNavPage({ children, navId }: Props) {
{...props}
searchBarHidden={searchBarHidden}
onToggleSearchBar={toggleSearchBar}
onToggleMegaMenu={() => setMegaMenuOpen(!megaMenuOpen)}
sectionNav={navModel.node}
/>
</div>
<div className={cx(styles.content, searchBarHidden && styles.contentNoSearchBar)}>{children}</div>
{megaMenuOpen && <MegaMenu searchBarHidden={searchBarHidden} onClose={() => setMegaMenuOpen(false)} />}
</div>
);
}

View File

@ -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 ?? [];