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 { CommandPalette } from 'app/features/commandPalette/CommandPalette';
|
||||||
import { KioskMode } from 'app/types';
|
import { KioskMode } from 'app/types';
|
||||||
|
|
||||||
|
import { DockedMegaMenu } from './DockedMegaMenu/DockedMegaMenu';
|
||||||
import { MegaMenu } from './MegaMenu/MegaMenu';
|
import { MegaMenu } from './MegaMenu/MegaMenu';
|
||||||
import { NavToolbar } from './NavToolbar/NavToolbar';
|
import { NavToolbar } from './NavToolbar/NavToolbar';
|
||||||
import { SectionNav } from './SectionNav/SectionNav';
|
import { SectionNav } from './SectionNav/SectionNav';
|
||||||
@ -70,7 +71,11 @@ export function AppChrome({ children }: Props) {
|
|||||||
</main>
|
</main>
|
||||||
{!state.chromeless && (
|
{!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 />
|
<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 { NavModelItem } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
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 ?? [];
|
export const initialState: NavModelItem[] = config.bootData?.navTree ?? [];
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import { cloneDeep } from 'lodash';
|
|||||||
import { NavIndex, NavModel, NavModelItem } from '@grafana/data';
|
import { NavIndex, NavModel, NavModelItem } from '@grafana/data';
|
||||||
import config from 'app/core/config';
|
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';
|
export const HOME_NAV_ID = 'home';
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { NavModel, NavModelItem } from '@grafana/data';
|
import { NavModel, NavModelItem } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { getNavSubTitle } from 'app/core/components/AppChrome/MegaMenu/navBarItem-translations';
|
|
||||||
import { t } from 'app/core/internationalization';
|
import { t } from 'app/core/internationalization';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
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 { newBrowseDashboardsEnabled } from 'app/features/browse-dashboards/featureFlag';
|
||||||
import { AccessControlAction, FolderDTO } from 'app/types';
|
import { AccessControlAction, FolderDTO } from 'app/types';
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user