diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index 944a4e2c3c8..f1abdfc2a68 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -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) { {!state.chromeless && ( <> - chrome.setMegaMenu(false)} /> + {config.featureToggles.dockedMegaMenu ? ( + chrome.setMegaMenu(false)} /> + ) : ( + chrome.setMegaMenu(false)} /> + )} )} diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/DockedMegaMenu.test.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/DockedMegaMenu.test.tsx new file mode 100644 index 00000000000..8462f161341 --- /dev/null +++ b/public/app/core/components/AppChrome/DockedMegaMenu/DockedMegaMenu.test.tsx @@ -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( + + + {}} /> + + + ); +}; + +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(); + }); +}); diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/DockedMegaMenu.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/DockedMegaMenu.tsx new file mode 100644 index 00000000000..71fbdf52e3f --- /dev/null +++ b/public/app/core/components/AppChrome/DockedMegaMenu/DockedMegaMenu.tsx @@ -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(({ 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 ( +
+ +
+ ); +}); + +DockedMegaMenu.displayName = 'DockedMegaMenu'; + +const getStyles = (theme: GrafanaTheme2) => ({ + menuWrapper: css({ + position: 'fixed', + display: 'grid', + gridAutoFlow: 'column', + height: '100%', + zIndex: theme.zIndex.sidemenu, + }), +}); diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/NavBarItemIcon.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/NavBarItemIcon.tsx new file mode 100644 index 00000000000..b259eb97660 --- /dev/null +++ b/public/app/core/components/AppChrome/DockedMegaMenu/NavBarItemIcon.tsx @@ -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 ; + } else if (link.icon) { + const iconName = toIconName(link.icon); + return ; + } else { + // consumer of NavBarItemIcon gives enclosing element an appropriate label + return ; + } +} + +function getStyles(theme: GrafanaTheme2) { + return { + img: css({ + height: theme.spacing(3), + width: theme.spacing(3), + }), + round: css({ + borderRadius: theme.shape.radius.circle, + }), + }; +} diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/NavBarMenu.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/NavBarMenu.tsx new file mode 100644 index 00000000000..6879e4f1cda --- /dev/null +++ b/public/app/core/components/AppChrome/DockedMegaMenu/NavBarMenu.tsx @@ -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 ( + + + +
+
+ + +
+ +
+
+
+ +
+ + + ); +} + +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), + }, + }; +}; diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/NavBarMenuItem.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/NavBarMenuItem.tsx new file mode 100644 index 00000000000..4829d36701a --- /dev/null +++ b/public/app/core/components/AppChrome/DockedMegaMenu/NavBarMenuItem.tsx @@ -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 = ( +
+ {icon && } + +
{children}
+ + {target === '_blank' && ( + + )} +
+ ); + + let element = ( + + ); + + if (url) { + element = + !target && url.startsWith('/') ? ( + + {linkContent} + + ) : ( + + {linkContent} + + ); + } + + return
  • {element}
  • ; +} + +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), + }), + }), +}); diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/NavBarMenuItemWrapper.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/NavBarMenuItemWrapper.tsx new file mode 100644 index 00000000000..d64073c6f6c --- /dev/null +++ b/public/app/core/components/AppChrome/DockedMegaMenu/NavBarMenuItemWrapper.tsx @@ -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 ( + +
      +
      {link.emptyMessage}
      +
    +
    + ); + } + + return ( + + {linkHasChildren(link) && ( +
      + {link.children.map((childLink) => { + return ( + !childLink.isCreateAction && ( + { + childLink.onClick?.(); + onClose(); + }} + target={childLink.target} + url={childLink.url} + > + {childLink.text} + + ) + ); + })} +
    + )} +
    + ); +} + +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); +} diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/NavBarMenuSection.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/NavBarMenuSection.tsx new file mode 100644 index 00000000000..978181f6b08 --- /dev/null +++ b/public/app/core/components/AppChrome/DockedMegaMenu/NavBarMenuSection.tsx @@ -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 ( + <> +
    + { + link.onClick?.(); + onClose?.(); + }} + target={link.target} + url={link.url} + > +
    + + + + {link.text} +
    +
    + {children && ( + + )} +
    + {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, + }), +}); diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/NavFeatureHighlight.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/NavFeatureHighlight.tsx new file mode 100644 index 00000000000..9992d9fe2c8 --- /dev/null +++ b/public/app/core/components/AppChrome/DockedMegaMenu/NavFeatureHighlight.tsx @@ -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 ( +
    + {children} + +
    + ); +}; + +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%); + `, + }; +}; diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/utils.test.ts b/public/app/core/components/AppChrome/DockedMegaMenu/utils.test.ts new file mode 100644 index 00000000000..eacea3cfdf9 --- /dev/null +++ b/public/app/core/components/AppChrome/DockedMegaMenu/utils.test.ts @@ -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', + }); + }); +}); diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/utils.ts b/public/app/core/components/AppChrome/DockedMegaMenu/utils.ts new file mode 100644 index 00000000000..2f180c5e674 --- /dev/null +++ b/public/app/core/components/AppChrome/DockedMegaMenu/utils.ts @@ -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; +} diff --git a/public/app/core/reducers/navBarTree.ts b/public/app/core/reducers/navBarTree.ts index 57c9922c508..e4e81bb3c8b 100644 --- a/public/app/core/reducers/navBarTree.ts +++ b/public/app/core/reducers/navBarTree.ts @@ -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 ?? []; diff --git a/public/app/core/reducers/navModel.ts b/public/app/core/reducers/navModel.ts index c559a1980e5..d4968f54684 100644 --- a/public/app/core/reducers/navModel.ts +++ b/public/app/core/reducers/navModel.ts @@ -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'; diff --git a/public/app/core/components/AppChrome/MegaMenu/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts similarity index 100% rename from public/app/core/components/AppChrome/MegaMenu/navBarItem-translations.ts rename to public/app/core/utils/navBarItem-translations.ts diff --git a/public/app/features/folders/state/navModel.ts b/public/app/features/folders/state/navModel.ts index 2f1cecbe76d..7e714ffeb21 100644 --- a/public/app/features/folders/state/navModel.ts +++ b/public/app/features/folders/state/navModel.ts @@ -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';