mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
7eec92988e
commit
18b481cedc
61
public/app/core/components/MegaMenu/MegaMenu.test.tsx
Normal file
61
public/app/core/components/MegaMenu/MegaMenu.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
80
public/app/core/components/MegaMenu/MegaMenu.tsx
Normal file
80
public/app/core/components/MegaMenu/MegaMenu.tsx
Normal 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,
|
||||
}),
|
||||
});
|
211
public/app/core/components/MegaMenu/NavBarMenu.tsx
Normal file
211
public/app/core/components/MegaMenu/NavBarMenu.tsx
Normal 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),
|
||||
},
|
||||
};
|
||||
};
|
@ -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" />;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 ?? [];
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user