mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Navigation: create the DockedMegaMenu
component and use the toggle to switch between it and MegaMenu
(#75084)
This commit is contained in:
parent
9608da4fdf
commit
efeff6bbb1
@ -9,6 +9,7 @@ import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { CommandPalette } from 'app/features/commandPalette/CommandPalette';
|
||||
import { KioskMode } from 'app/types';
|
||||
|
||||
import { DockedMegaMenu } from './DockedMegaMenu/DockedMegaMenu';
|
||||
import { MegaMenu } from './MegaMenu/MegaMenu';
|
||||
import { NavToolbar } from './NavToolbar/NavToolbar';
|
||||
import { SectionNav } from './SectionNav/SectionNav';
|
||||
@ -70,7 +71,11 @@ export function AppChrome({ children }: Props) {
|
||||
</main>
|
||||
{!state.chromeless && (
|
||||
<>
|
||||
<MegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenu(false)} />
|
||||
{config.featureToggles.dockedMegaMenu ? (
|
||||
<DockedMegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenu(false)} />
|
||||
) : (
|
||||
<MegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenu(false)} />
|
||||
)}
|
||||
<CommandPalette />
|
||||
</>
|
||||
)}
|
||||
|
@ -0,0 +1,56 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
|
||||
import { TestProvider } from '../../../../../test/helpers/TestProvider';
|
||||
|
||||
import { DockedMegaMenu } from './DockedMegaMenu';
|
||||
|
||||
const setup = () => {
|
||||
const navBarTree: NavModelItem[] = [
|
||||
{
|
||||
text: 'Section name',
|
||||
id: 'section',
|
||||
url: 'section',
|
||||
children: [
|
||||
{ text: 'Child1', id: 'child1', url: 'section/child1' },
|
||||
{ text: 'Child2', id: 'child2', url: 'section/child2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Profile',
|
||||
id: 'profile',
|
||||
url: 'profile',
|
||||
},
|
||||
];
|
||||
|
||||
const grafanaContext = getGrafanaContextMock();
|
||||
grafanaContext.chrome.onToggleMegaMenu();
|
||||
|
||||
return render(
|
||||
<TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}>
|
||||
<Router history={locationService.getHistory()}>
|
||||
<DockedMegaMenu onClose={() => {}} />
|
||||
</Router>
|
||||
</TestProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('MegaMenu', () => {
|
||||
it('should render component', async () => {
|
||||
setup();
|
||||
|
||||
expect(await screen.findByTestId('navbarmenu')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('link', { name: 'Section name' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter out profile', async () => {
|
||||
setup();
|
||||
|
||||
expect(screen.queryByLabelText('Profile')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -0,0 +1,50 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
import { useSelector } from 'app/types';
|
||||
|
||||
import { NavBarMenu } from './NavBarMenu';
|
||||
import { enrichWithInteractionTracking, getActiveItem } from './utils';
|
||||
|
||||
export interface Props {
|
||||
onClose: () => void;
|
||||
searchBarHidden?: boolean;
|
||||
}
|
||||
|
||||
export const DockedMegaMenu = React.memo<Props>(({ onClose, searchBarHidden }) => {
|
||||
const navBarTree = useSelector((state) => state.navBarTree);
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme);
|
||||
const location = useLocation();
|
||||
|
||||
const navTree = cloneDeep(navBarTree);
|
||||
|
||||
// Remove profile + help from tree
|
||||
const navItems = navTree
|
||||
.filter((item) => item.id !== 'profile' && item.id !== 'help')
|
||||
.map((item) => enrichWithInteractionTracking(item, true));
|
||||
|
||||
const activeItem = getActiveItem(navItems, location.pathname);
|
||||
|
||||
return (
|
||||
<div className={styles.menuWrapper}>
|
||||
<NavBarMenu activeItem={activeItem} navItems={navItems} onClose={onClose} searchBarHidden={searchBarHidden} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DockedMegaMenu.displayName = 'DockedMegaMenu';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
menuWrapper: css({
|
||||
position: 'fixed',
|
||||
display: 'grid',
|
||||
gridAutoFlow: 'column',
|
||||
height: '100%',
|
||||
zIndex: theme.zIndex.sidemenu,
|
||||
}),
|
||||
});
|
@ -0,0 +1,38 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||
import { Icon, toIconName, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { Branding } from '../../Branding/Branding';
|
||||
|
||||
interface NavBarItemIconProps {
|
||||
link: NavModelItem;
|
||||
}
|
||||
|
||||
export function NavBarItemIcon({ link }: NavBarItemIconProps) {
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
if (link.icon === 'grafana') {
|
||||
return <Branding.MenuLogo className={styles.img} />;
|
||||
} else if (link.icon) {
|
||||
const iconName = toIconName(link.icon);
|
||||
return <Icon name={iconName ?? 'link'} size="xl" />;
|
||||
} else {
|
||||
// consumer of NavBarItemIcon gives enclosing element an appropriate label
|
||||
return <img className={cx(styles.img, link.roundIcon && styles.round)} src={link.img} alt="" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
img: css({
|
||||
height: theme.spacing(3),
|
||||
width: theme.spacing(3),
|
||||
}),
|
||||
round: css({
|
||||
borderRadius: theme.shape.radius.circle,
|
||||
}),
|
||||
};
|
||||
}
|
@ -0,0 +1,224 @@
|
||||
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, { useEffect, useRef, useState } from 'react';
|
||||
import CSSTransition from 'react-transition-group/CSSTransition';
|
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||
import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
|
||||
import { TOP_BAR_LEVEL_HEIGHT } from '../types';
|
||||
|
||||
import { NavBarMenuItemWrapper } from './NavBarMenuItemWrapper';
|
||||
|
||||
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 { chrome } = useGrafana();
|
||||
const state = chrome.useState();
|
||||
const ref = useRef(null);
|
||||
const backdropRef = useRef(null);
|
||||
const { dialogProps } = useDialog({}, ref);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const onMenuClose = () => setIsOpen(false);
|
||||
|
||||
const { overlayProps, underlayProps } = useOverlay(
|
||||
{
|
||||
isDismissable: true,
|
||||
isOpen: true,
|
||||
onClose: onMenuClose,
|
||||
},
|
||||
ref
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.megaMenuOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [state.megaMenuOpen]);
|
||||
|
||||
return (
|
||||
<OverlayContainer>
|
||||
<CSSTransition
|
||||
nodeRef={ref}
|
||||
in={isOpen}
|
||||
unmountOnExit={true}
|
||||
classNames={animStyles.overlay}
|
||||
timeout={{ enter: animationSpeed, exit: 0 }}
|
||||
onExited={onClose}
|
||||
>
|
||||
<FocusScope contain autoFocus>
|
||||
<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"
|
||||
tooltip="Close menu"
|
||||
name="times"
|
||||
onClick={onMenuClose}
|
||||
size="xl"
|
||||
variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
<nav className={styles.content}>
|
||||
<CustomScrollbar showScrollIndicators hideHorizontalTrack>
|
||||
<ul className={styles.itemList}>
|
||||
{navItems.map((link) => (
|
||||
<NavBarMenuItemWrapper link={link} onClose={onMenuClose} activeItem={activeItem} key={link.text} />
|
||||
))}
|
||||
</ul>
|
||||
</CustomScrollbar>
|
||||
</nav>
|
||||
</div>
|
||||
</FocusScope>
|
||||
</CSSTransition>
|
||||
<CSSTransition
|
||||
nodeRef={backdropRef}
|
||||
in={isOpen}
|
||||
unmountOnExit={true}
|
||||
classNames={animStyles.backdrop}
|
||||
timeout={{ enter: animationSpeed, exit: 0 }}
|
||||
>
|
||||
<div ref={backdropRef} className={styles.backdrop} {...underlayProps} />
|
||||
</CSSTransition>
|
||||
</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: searchBarHidden ? 0 : TOP_BAR_LEVEL_HEIGHT,
|
||||
zIndex: theme.zIndex.modalBackdrop,
|
||||
|
||||
[theme.breakpoints.up('md')]: {
|
||||
top: topPosition,
|
||||
},
|
||||
}),
|
||||
container: css({
|
||||
display: 'flex',
|
||||
bottom: 0,
|
||||
flexDirection: 'column',
|
||||
left: 0,
|
||||
marginRight: theme.spacing(1.5),
|
||||
right: 0,
|
||||
// Needs to below navbar should we change the navbarFixed? add add a new level?
|
||||
zIndex: theme.zIndex.modal,
|
||||
position: 'fixed',
|
||||
top: searchBarHidden ? 0 : TOP_BAR_LEVEL_HEIGHT,
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
boxSizing: 'content-box',
|
||||
flex: '1 1 0',
|
||||
|
||||
[theme.breakpoints.up('md')]: {
|
||||
borderRight: `1px solid ${theme.colors.border.weak}`,
|
||||
right: 'unset',
|
||||
top: topPosition,
|
||||
},
|
||||
}),
|
||||
content: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
}),
|
||||
mobileHeader: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: theme.spacing(1, 1, 1, 2),
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
|
||||
[theme.breakpoints.up('md')]: {
|
||||
display: 'none',
|
||||
},
|
||||
}),
|
||||
itemList: css({
|
||||
display: 'grid',
|
||||
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`,
|
||||
gridTemplateColumns: `minmax(${MENU_WIDTH}, auto)`,
|
||||
minWidth: MENU_WIDTH,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
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: '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 = {
|
||||
width: '100%',
|
||||
[theme.breakpoints.up('md')]: {
|
||||
boxShadow: theme.shadows.z3,
|
||||
width: MENU_WIDTH,
|
||||
},
|
||||
};
|
||||
|
||||
const overlayClosed = {
|
||||
boxShadow: 'none',
|
||||
width: 0,
|
||||
};
|
||||
|
||||
const backdropOpen = {
|
||||
opacity: 1,
|
||||
};
|
||||
|
||||
const backdropClosed = {
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
backdrop: {
|
||||
enter: css(backdropClosed),
|
||||
enterActive: css(backdropTransition, backdropOpen),
|
||||
enterDone: css(backdropOpen),
|
||||
},
|
||||
overlay: {
|
||||
enter: css(overlayClosed),
|
||||
enterActive: css(overlayTransition, overlayOpen),
|
||||
enterDone: css(overlayOpen),
|
||||
},
|
||||
};
|
||||
};
|
@ -0,0 +1,139 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
|
||||
|
||||
export interface Props {
|
||||
children: React.ReactNode;
|
||||
icon?: IconName;
|
||||
isActive?: boolean;
|
||||
isChild?: boolean;
|
||||
onClick?: () => void;
|
||||
target?: HTMLAnchorElement['target'];
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export function NavBarMenuItem({ children, icon, isActive, isChild, onClick, target, url }: Props) {
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme, isActive, isChild);
|
||||
|
||||
const linkContent = (
|
||||
<div className={styles.linkContent}>
|
||||
{icon && <Icon data-testid="dropdown-child-icon" name={icon} />}
|
||||
|
||||
<div className={styles.linkText}>{children}</div>
|
||||
|
||||
{target === '_blank' && (
|
||||
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
let element = (
|
||||
<button
|
||||
data-testid={selectors.components.NavMenu.item}
|
||||
className={cx(styles.button, styles.element)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{linkContent}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (url) {
|
||||
element =
|
||||
!target && url.startsWith('/') ? (
|
||||
<Link
|
||||
data-testid={selectors.components.NavMenu.item}
|
||||
className={styles.element}
|
||||
href={url}
|
||||
target={target}
|
||||
onClick={onClick}
|
||||
>
|
||||
{linkContent}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
data-testid={selectors.components.NavMenu.item}
|
||||
href={url}
|
||||
target={target}
|
||||
className={styles.element}
|
||||
onClick={onClick}
|
||||
>
|
||||
{linkContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <li className={styles.listItem}>{element}</li>;
|
||||
}
|
||||
|
||||
NavBarMenuItem.displayName = 'NavBarMenuItem';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], isChild: Props['isActive']) => ({
|
||||
button: css({
|
||||
backgroundColor: 'unset',
|
||||
borderStyle: 'unset',
|
||||
}),
|
||||
linkContent: css({
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}),
|
||||
linkText: css({
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}),
|
||||
externalLinkIcon: css({
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
element: css({
|
||||
alignItems: 'center',
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
color: isActive ? theme.colors.text.primary : theme.colors.text.secondary,
|
||||
padding: theme.spacing(1, 1, 1, isChild ? 5 : 0),
|
||||
...(isChild && {
|
||||
borderRadius: theme.shape.radius.default,
|
||||
}),
|
||||
width: '100%',
|
||||
'&:hover, &:focus-visible': {
|
||||
...(isChild && {
|
||||
background: theme.colors.emphasize(theme.colors.background.primary, 0.03),
|
||||
}),
|
||||
textDecoration: 'underline',
|
||||
color: theme.colors.text.primary,
|
||||
},
|
||||
'&:focus-visible': {
|
||||
boxShadow: 'none',
|
||||
outline: `2px solid ${theme.colors.primary.main}`,
|
||||
outlineOffset: '-2px',
|
||||
transition: 'none',
|
||||
},
|
||||
'&::before': {
|
||||
display: isActive ? 'block' : 'none',
|
||||
content: '" "',
|
||||
height: theme.spacing(3),
|
||||
position: 'absolute',
|
||||
left: theme.spacing(1),
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: theme.spacing(0.5),
|
||||
borderRadius: theme.shape.radius.default,
|
||||
backgroundImage: theme.colors.gradients.brandVertical,
|
||||
},
|
||||
}),
|
||||
listItem: css({
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
...(isChild && {
|
||||
padding: theme.spacing(0, 2),
|
||||
}),
|
||||
}),
|
||||
});
|
@ -0,0 +1,105 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { NavBarMenuItem } from './NavBarMenuItem';
|
||||
import { NavBarMenuSection } from './NavBarMenuSection';
|
||||
import { isMatchOrChildMatch } from './utils';
|
||||
|
||||
export function NavBarMenuItemWrapper({
|
||||
link,
|
||||
activeItem,
|
||||
onClose,
|
||||
}: {
|
||||
link: NavModelItem;
|
||||
activeItem?: NavModelItem;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (link.emptyMessage && !linkHasChildren(link)) {
|
||||
return (
|
||||
<NavBarMenuSection onClose={onClose} link={link} activeItem={activeItem}>
|
||||
<ul className={styles.children}>
|
||||
<div className={styles.emptyMessage}>{link.emptyMessage}</div>
|
||||
</ul>
|
||||
</NavBarMenuSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavBarMenuSection onClose={onClose} link={link} activeItem={activeItem}>
|
||||
{linkHasChildren(link) && (
|
||||
<ul className={styles.children}>
|
||||
{link.children.map((childLink) => {
|
||||
return (
|
||||
!childLink.isCreateAction && (
|
||||
<NavBarMenuItem
|
||||
key={`${link.text}-${childLink.text}`}
|
||||
isActive={isMatchOrChildMatch(childLink, activeItem)}
|
||||
isChild
|
||||
onClick={() => {
|
||||
childLink.onClick?.();
|
||||
onClose();
|
||||
}}
|
||||
target={childLink.target}
|
||||
url={childLink.url}
|
||||
>
|
||||
{childLink.text}
|
||||
</NavBarMenuItem>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</NavBarMenuSection>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
children: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
flex: css({
|
||||
display: 'flex',
|
||||
}),
|
||||
itemWithoutMenu: css({
|
||||
position: 'relative',
|
||||
placeItems: 'inherit',
|
||||
justifyContent: 'start',
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
}),
|
||||
fullWidth: css({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}),
|
||||
iconContainer: css({
|
||||
display: 'flex',
|
||||
placeContent: 'center',
|
||||
}),
|
||||
itemWithoutMenuContent: css({
|
||||
display: 'grid',
|
||||
gridAutoFlow: 'column',
|
||||
gridTemplateColumns: `${theme.spacing(7)} auto`,
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}),
|
||||
linkText: css({
|
||||
fontSize: theme.typography.pxToRem(14),
|
||||
justifySelf: 'start',
|
||||
}),
|
||||
emptyMessage: css({
|
||||
color: theme.colors.text.secondary,
|
||||
fontStyle: 'italic',
|
||||
padding: theme.spacing(1, 1.5, 1, 7),
|
||||
}),
|
||||
});
|
||||
|
||||
function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } {
|
||||
return Boolean(link.children && link.children.length > 0);
|
||||
}
|
@ -0,0 +1,117 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||
import { Button, Icon, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { NavBarItemIcon } from './NavBarItemIcon';
|
||||
import { NavBarMenuItem } from './NavBarMenuItem';
|
||||
import { NavFeatureHighlight } from './NavFeatureHighlight';
|
||||
import { hasChildMatch } from './utils';
|
||||
|
||||
export function NavBarMenuSection({
|
||||
link,
|
||||
activeItem,
|
||||
children,
|
||||
className,
|
||||
onClose,
|
||||
}: {
|
||||
link: NavModelItem;
|
||||
activeItem?: NavModelItem;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment;
|
||||
const isActive = link === activeItem;
|
||||
const hasActiveChild = hasChildMatch(link, activeItem);
|
||||
const [sectionExpanded, setSectionExpanded] =
|
||||
useLocalStorage(`grafana.navigation.expanded[${link.text}]`, false) ?? Boolean(hasActiveChild);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.collapsibleSectionWrapper, className)}>
|
||||
<NavBarMenuItem
|
||||
isActive={link === activeItem}
|
||||
onClick={() => {
|
||||
link.onClick?.();
|
||||
onClose?.();
|
||||
}}
|
||||
target={link.target}
|
||||
url={link.url}
|
||||
>
|
||||
<div
|
||||
className={cx(styles.labelWrapper, {
|
||||
[styles.isActive]: isActive,
|
||||
[styles.hasActiveChild]: hasActiveChild,
|
||||
})}
|
||||
>
|
||||
<FeatureHighlightWrapper>
|
||||
<NavBarItemIcon link={link} />
|
||||
</FeatureHighlightWrapper>
|
||||
{link.text}
|
||||
</div>
|
||||
</NavBarMenuItem>
|
||||
{children && (
|
||||
<Button
|
||||
aria-label={`${sectionExpanded ? 'Collapse' : 'Expand'} section ${link.text}`}
|
||||
variant="secondary"
|
||||
fill="text"
|
||||
className={styles.collapseButton}
|
||||
onClick={() => setSectionExpanded(!sectionExpanded)}
|
||||
>
|
||||
<Icon name={sectionExpanded ? 'angle-up' : 'angle-down'} size="xl" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{sectionExpanded && children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
collapsibleSectionWrapper: css({
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
}),
|
||||
collapseButton: css({
|
||||
color: theme.colors.text.disabled,
|
||||
padding: theme.spacing(0, 0.5),
|
||||
marginRight: theme.spacing(1),
|
||||
}),
|
||||
collapseWrapperActive: css({
|
||||
backgroundColor: theme.colors.action.disabledBackground,
|
||||
}),
|
||||
collapseContent: css({
|
||||
padding: 0,
|
||||
}),
|
||||
labelWrapper: css({
|
||||
display: 'grid',
|
||||
fontSize: theme.typography.pxToRem(14),
|
||||
gridAutoFlow: 'column',
|
||||
gridTemplateColumns: `${theme.spacing(7)} auto`,
|
||||
placeItems: 'center',
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
}),
|
||||
isActive: css({
|
||||
color: theme.colors.text.primary,
|
||||
|
||||
'&::before': {
|
||||
display: 'block',
|
||||
content: '" "',
|
||||
height: theme.spacing(3),
|
||||
position: 'absolute',
|
||||
left: theme.spacing(1),
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: theme.spacing(0.5),
|
||||
borderRadius: theme.shape.radius.default,
|
||||
backgroundImage: theme.colors.gradients.brandVertical,
|
||||
},
|
||||
}),
|
||||
hasActiveChild: css({
|
||||
color: theme.colors.text.primary,
|
||||
}),
|
||||
});
|
@ -0,0 +1,34 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
export interface Props {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export const NavFeatureHighlight = ({ children }: Props): JSX.Element => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<div>
|
||||
{children}
|
||||
<span className={styles.highlight} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
highlight: css`
|
||||
background-color: ${theme.colors.success.main};
|
||||
border-radius: ${theme.shape.radius.circle};
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
`,
|
||||
};
|
||||
};
|
@ -0,0 +1,186 @@
|
||||
import { GrafanaConfig, locationUtil, NavModelItem } from '@grafana/data';
|
||||
import { ContextSrv, setContextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
import { enrichHelpItem, getActiveItem, isMatchOrChildMatch } from './utils';
|
||||
|
||||
jest.mock('../../../app_events', () => ({
|
||||
publish: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('enrichConfigItems', () => {
|
||||
let mockHelpNode: NavModelItem;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHelpNode = {
|
||||
id: 'help',
|
||||
text: 'Help',
|
||||
};
|
||||
});
|
||||
|
||||
it('enhances the help node with extra child links', () => {
|
||||
const contextSrv = new ContextSrv();
|
||||
setContextSrv(contextSrv);
|
||||
const helpNode = enrichHelpItem(mockHelpNode);
|
||||
expect(helpNode!.children).toContainEqual(
|
||||
expect.objectContaining({
|
||||
text: 'Documentation',
|
||||
})
|
||||
);
|
||||
expect(helpNode!.children).toContainEqual(
|
||||
expect.objectContaining({
|
||||
text: 'Support',
|
||||
})
|
||||
);
|
||||
expect(helpNode!.children).toContainEqual(
|
||||
expect.objectContaining({
|
||||
text: 'Community',
|
||||
})
|
||||
);
|
||||
expect(helpNode!.children).toContainEqual(
|
||||
expect.objectContaining({
|
||||
text: 'Keyboard shortcuts',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMatchOrChildMatch', () => {
|
||||
const mockChild: NavModelItem = {
|
||||
text: 'Child',
|
||||
url: '/dashboards/child',
|
||||
};
|
||||
const mockItemToCheck: NavModelItem = {
|
||||
text: 'Dashboards',
|
||||
url: '/dashboards',
|
||||
children: [mockChild],
|
||||
};
|
||||
|
||||
it('returns true if the itemToCheck is an exact match with the searchItem', () => {
|
||||
const searchItem = mockItemToCheck;
|
||||
expect(isMatchOrChildMatch(mockItemToCheck, searchItem)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true if the itemToCheck has a child that matches the searchItem', () => {
|
||||
const searchItem = mockChild;
|
||||
expect(isMatchOrChildMatch(mockItemToCheck, searchItem)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false otherwise', () => {
|
||||
const searchItem: NavModelItem = {
|
||||
text: 'No match',
|
||||
url: '/noMatch',
|
||||
};
|
||||
expect(isMatchOrChildMatch(mockItemToCheck, searchItem)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveItem', () => {
|
||||
const mockNavTree: NavModelItem[] = [
|
||||
{
|
||||
text: 'Item',
|
||||
url: '/item',
|
||||
},
|
||||
{
|
||||
text: 'Item with query param',
|
||||
url: '/itemWithQueryParam?foo=bar',
|
||||
},
|
||||
{
|
||||
text: 'Item after subpath',
|
||||
url: '/subUrl/itemAfterSubpath',
|
||||
},
|
||||
{
|
||||
text: 'Item with children',
|
||||
url: '/itemWithChildren',
|
||||
children: [
|
||||
{
|
||||
text: 'Child',
|
||||
url: '/child',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Alerting item',
|
||||
url: '/alerting/list',
|
||||
},
|
||||
{
|
||||
text: 'Base',
|
||||
url: '/',
|
||||
},
|
||||
{
|
||||
text: 'Starred',
|
||||
url: '/dashboards?starred',
|
||||
id: 'starred',
|
||||
},
|
||||
{
|
||||
text: 'Dashboards',
|
||||
url: '/dashboards',
|
||||
},
|
||||
{
|
||||
text: 'More specific dashboard',
|
||||
url: '/d/moreSpecificDashboard',
|
||||
},
|
||||
];
|
||||
beforeEach(() => {
|
||||
locationUtil.initialize({
|
||||
config: { appSubUrl: '/subUrl' } as GrafanaConfig,
|
||||
getVariablesUrlParams: () => ({}),
|
||||
getTimeRangeForUrl: () => ({ from: 'now-7d', to: 'now' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an exact match at the top level', () => {
|
||||
const mockPathName = '/item';
|
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
||||
text: 'Item',
|
||||
url: '/item',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an exact match ignoring root subpath', () => {
|
||||
const mockPathName = '/itemAfterSubpath';
|
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
||||
text: 'Item after subpath',
|
||||
url: '/subUrl/itemAfterSubpath',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an exact match ignoring query params', () => {
|
||||
const mockPathName = '/itemWithQueryParam?bar=baz';
|
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
||||
text: 'Item with query param',
|
||||
url: '/itemWithQueryParam?foo=bar',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an exact child match', () => {
|
||||
const mockPathName = '/child';
|
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
||||
text: 'Child',
|
||||
url: '/child',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the alerting link if the pathname is an alert notification', () => {
|
||||
const mockPathName = '/alerting/notification/foo';
|
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
||||
text: 'Alerting item',
|
||||
url: '/alerting/list',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the dashboards route link if the pathname starts with /d/', () => {
|
||||
const mockPathName = '/d/foo';
|
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
||||
text: 'Dashboards',
|
||||
url: '/dashboards',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a more specific link if one exists', () => {
|
||||
const mockPathName = '/d/moreSpecificDashboard';
|
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
||||
text: 'More specific dashboard',
|
||||
url: '/d/moreSpecificDashboard',
|
||||
});
|
||||
});
|
||||
});
|
140
public/app/core/components/AppChrome/DockedMegaMenu/utils.ts
Normal file
140
public/app/core/components/AppChrome/DockedMegaMenu/utils.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { locationUtil, NavModelItem } from '@grafana/data';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { ShowModalReactEvent } from '../../../../types/events';
|
||||
import appEvents from '../../../app_events';
|
||||
import { getFooterLinks } from '../../Footer/Footer';
|
||||
import { HelpModal } from '../../help/HelpModal';
|
||||
|
||||
export const enrichHelpItem = (helpItem: NavModelItem) => {
|
||||
let menuItems = helpItem.children || [];
|
||||
|
||||
if (helpItem.id === 'help') {
|
||||
const onOpenShortcuts = () => {
|
||||
appEvents.publish(new ShowModalReactEvent({ component: HelpModal }));
|
||||
};
|
||||
helpItem.children = [
|
||||
...menuItems,
|
||||
...getFooterLinks(),
|
||||
...getEditionAndUpdateLinks(),
|
||||
{
|
||||
id: 'keyboard-shortcuts',
|
||||
text: t('nav.help/keyboard-shortcuts', 'Keyboard shortcuts'),
|
||||
icon: 'keyboard',
|
||||
onClick: onOpenShortcuts,
|
||||
},
|
||||
];
|
||||
}
|
||||
return helpItem;
|
||||
};
|
||||
|
||||
export const enrichWithInteractionTracking = (item: NavModelItem, expandedState: boolean) => {
|
||||
const onClick = item.onClick;
|
||||
item.onClick = () => {
|
||||
reportInteraction('grafana_navigation_item_clicked', {
|
||||
path: item.url ?? item.id,
|
||||
state: expandedState ? 'expanded' : 'collapsed',
|
||||
});
|
||||
onClick?.();
|
||||
};
|
||||
if (item.children) {
|
||||
item.children = item.children.map((item) => enrichWithInteractionTracking(item, expandedState));
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
export const isMatchOrChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem) => {
|
||||
return Boolean(itemToCheck === searchItem || hasChildMatch(itemToCheck, searchItem));
|
||||
};
|
||||
|
||||
export const hasChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem): boolean => {
|
||||
return Boolean(
|
||||
itemToCheck.children?.some((child) => {
|
||||
if (child === searchItem) {
|
||||
return true;
|
||||
} else {
|
||||
return hasChildMatch(child, searchItem);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const stripQueryParams = (url?: string) => {
|
||||
return url?.split('?')[0] ?? '';
|
||||
};
|
||||
|
||||
const isBetterMatch = (newMatch: NavModelItem, currentMatch?: NavModelItem) => {
|
||||
const currentMatchUrl = stripQueryParams(currentMatch?.url);
|
||||
const newMatchUrl = stripQueryParams(newMatch.url);
|
||||
return newMatchUrl && newMatchUrl.length > currentMatchUrl?.length;
|
||||
};
|
||||
|
||||
export const getActiveItem = (
|
||||
navTree: NavModelItem[],
|
||||
pathname: string,
|
||||
currentBestMatch?: NavModelItem
|
||||
): NavModelItem | undefined => {
|
||||
const dashboardLinkMatch = '/dashboards';
|
||||
|
||||
for (const link of navTree) {
|
||||
const linkWithoutParams = stripQueryParams(link.url);
|
||||
const linkPathname = locationUtil.stripBaseFromUrl(linkWithoutParams);
|
||||
if (linkPathname && link.id !== 'starred') {
|
||||
if (linkPathname === pathname) {
|
||||
// exact match
|
||||
currentBestMatch = link;
|
||||
break;
|
||||
} else if (linkPathname !== '/' && pathname.startsWith(linkPathname)) {
|
||||
// partial match
|
||||
if (isBetterMatch(link, currentBestMatch)) {
|
||||
currentBestMatch = link;
|
||||
}
|
||||
} else if (linkPathname === '/alerting/list' && pathname.startsWith('/alerting/notification/')) {
|
||||
// alert channel match
|
||||
// TODO refactor routes such that we don't need this custom logic
|
||||
currentBestMatch = link;
|
||||
break;
|
||||
} else if (linkPathname === dashboardLinkMatch && pathname.startsWith('/d/')) {
|
||||
// dashboard match
|
||||
// TODO refactor routes such that we don't need this custom logic
|
||||
if (isBetterMatch(link, currentBestMatch)) {
|
||||
currentBestMatch = link;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (link.children) {
|
||||
currentBestMatch = getActiveItem(link.children, pathname, currentBestMatch);
|
||||
}
|
||||
if (stripQueryParams(currentBestMatch?.url) === pathname) {
|
||||
return currentBestMatch;
|
||||
}
|
||||
}
|
||||
return currentBestMatch;
|
||||
};
|
||||
|
||||
export function getEditionAndUpdateLinks(): NavModelItem[] {
|
||||
const { buildInfo, licenseInfo } = config;
|
||||
const stateInfo = licenseInfo.stateInfo ? ` (${licenseInfo.stateInfo})` : '';
|
||||
const links: NavModelItem[] = [];
|
||||
|
||||
links.push({
|
||||
target: '_blank',
|
||||
id: 'version',
|
||||
text: `${buildInfo.edition}${stateInfo}`,
|
||||
url: licenseInfo.licenseUrl,
|
||||
icon: 'external-link-alt',
|
||||
});
|
||||
|
||||
if (buildInfo.hasUpdate) {
|
||||
links.push({
|
||||
target: '_blank',
|
||||
id: 'updateVersion',
|
||||
text: `New version available!`,
|
||||
icon: 'download-alt',
|
||||
url: 'https://grafana.com/grafana/download?utm_source=grafana_footer',
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
@ -3,7 +3,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { getNavSubTitle, getNavTitle } from '../components/AppChrome/MegaMenu/navBarItem-translations';
|
||||
import { getNavSubTitle, getNavTitle } from '../utils/navBarItem-translations';
|
||||
|
||||
export const initialState: NavModelItem[] = config.bootData?.navTree ?? [];
|
||||
|
||||
|
@ -4,7 +4,7 @@ import { cloneDeep } from 'lodash';
|
||||
import { NavIndex, NavModel, NavModelItem } from '@grafana/data';
|
||||
import config from 'app/core/config';
|
||||
|
||||
import { getNavSubTitle, getNavTitle } from '../components/AppChrome/MegaMenu/navBarItem-translations';
|
||||
import { getNavSubTitle, getNavTitle } from '../utils/navBarItem-translations';
|
||||
|
||||
export const HOME_NAV_ID = 'home';
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { NavModel, NavModelItem } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { getNavSubTitle } from 'app/core/components/AppChrome/MegaMenu/navBarItem-translations';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { getNavSubTitle } from 'app/core/utils/navBarItem-translations';
|
||||
import { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
|
||||
import { AccessControlAction, FolderDTO } from 'app/types';
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user