Navigation: create the DockedMegaMenu component and use the toggle to switch between it and MegaMenu (#75084)

This commit is contained in:
Laura Fernández 2023-09-19 17:47:03 +02:00 committed by GitHub
parent 9608da4fdf
commit efeff6bbb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1098 additions and 4 deletions

View File

@ -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 && (
<>
{config.featureToggles.dockedMegaMenu ? (
<DockedMegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenu(false)} />
) : (
<MegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenu(false)} />
)}
<CommandPalette />
</>
)}

View File

@ -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();
});
});

View File

@ -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,
}),
});

View File

@ -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,
}),
};
}

View File

@ -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),
},
};
};

View File

@ -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),
}),
}),
});

View File

@ -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);
}

View File

@ -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,
}),
});

View File

@ -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%);
`,
};
};

View File

@ -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',
});
});
});

View 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;
}

View File

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

View File

@ -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';

View File

@ -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';