Navigation: Refactor mobile menu into it's own component (#41308)

* Navigation: Start creating new NavBarMenu component

* Navigation: Apply new NavBarMenu to NavBarNext

* Navigation: Remove everything to do with .sidemenu-open--xs

* Navigation: Ensure search is passed to NavBarMenu

* Navigation: Standardise NavBarMenuItem

* This extra check isn't needed anymore

* Navigation: Refactor <li> out of NavBarMenu

* Navigation: Combine NavBarMenuItem with DropdownChild

* use spread syntax since performance shouldn't be a concern for such small arrays

* Improve active item logic

* Ensure unique keys

* Remove this duplicate code

* Add unit tests for getActiveItem

* Add tests for NavBarMenu

* Rename mobileMenuOpen -> menuOpen in NavBarNext (since it can be used for mobile menu or megamenu)

* just use index to key the items

* Use exact versions of @react-aria packages

* Navigation: Make the dropdown header a NavBarMenuItem

* Navigation: Stop using dropdown-menu for styles

* Navigation: Hide divider in NavBarMenu + tweak color on section header
This commit is contained in:
Ashley Harrison
2021-11-09 13:41:38 +00:00
committed by GitHub
parent 3be452f995
commit 90d2d1f4da
18 changed files with 611 additions and 534 deletions

View File

@@ -243,6 +243,8 @@
"@opentelemetry/exporter-collector": "0.23.0", "@opentelemetry/exporter-collector": "0.23.0",
"@opentelemetry/semantic-conventions": "1.0.0", "@opentelemetry/semantic-conventions": "1.0.0",
"@popperjs/core": "2.5.4", "@popperjs/core": "2.5.4",
"@react-aria/focus": "3.5.0",
"@react-aria/overlays": "3.7.2",
"@reduxjs/toolkit": "1.6.1", "@reduxjs/toolkit": "1.6.1",
"@sentry/browser": "5.25.0", "@sentry/browser": "5.25.0",
"@sentry/types": "5.24.2", "@sentry/types": "5.24.2",

View File

@@ -1,74 +0,0 @@
import React from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
export interface Props {
isDivider?: boolean;
icon?: IconName;
onClick?: () => void;
target?: HTMLAnchorElement['target'];
text: string;
url?: string;
}
const DropdownChild = ({ isDivider = false, icon, onClick, target, text, url }: Props) => {
const theme = useTheme2();
const styles = getStyles(theme);
const linkContent = (
<div className={styles.linkContent}>
<div>
{icon && <Icon data-testid="dropdown-child-icon" name={icon} className={styles.icon} />}
{text}
</div>
{target === '_blank' && (
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} />
)}
</div>
);
let element = (
<button className={styles.element} onClick={onClick}>
{linkContent}
</button>
);
if (url) {
element =
!target && url.startsWith('/') ? (
<Link className={styles.element} onClick={onClick} href={url}>
{linkContent}
</Link>
) : (
<a className={styles.element} href={url} target={target} rel="noopener" onClick={onClick}>
{linkContent}
</a>
);
}
return isDivider ? <li data-testid="dropdown-child-divider" className="divider" /> : <li>{element}</li>;
};
export default DropdownChild;
const getStyles = (theme: GrafanaTheme2) => ({
element: css`
background-color: transparent;
border: none;
display: flex;
width: 100%;
`,
externalLinkIcon: css`
color: ${theme.colors.text.secondary};
margin-left: ${theme.spacing(1)};
`,
icon: css`
margin-right: ${theme.spacing(1)};
`,
linkContent: css`
display: flex;
flex: 1;
flex-direction: row;
justify-content: space-between;
`,
});

View File

@@ -1,20 +1,31 @@
import React, { FC, useCallback, useState } from 'react'; import React, { FC, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data'; import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
import { Icon, IconName, useTheme2 } from '@grafana/ui'; import { Icon, IconName, useTheme2 } from '@grafana/ui';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import appEvents from '../../app_events';
import { Branding } from 'app/core/components/Branding/Branding'; import { Branding } from 'app/core/components/Branding/Branding';
import config from 'app/core/config'; import config from 'app/core/config';
import { CoreEvents, KioskMode } from 'app/types'; import { KioskMode } from 'app/types';
import { enrichConfigItems, isLinkActive, isSearchActive } from './utils'; import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive, SEARCH_ITEM_ID } from './utils';
import { OrgSwitcher } from '../OrgSwitcher'; import { OrgSwitcher } from '../OrgSwitcher';
import NavBarItem from './NavBarItem'; import NavBarItem from './NavBarItem';
import { NavBarSection } from './NavBarSection';
import { NavBarMenu } from './NavBarMenu';
const homeUrl = config.appSubUrl || '/'; const homeUrl = config.appSubUrl || '/';
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
};
const searchItem: NavModelItem = {
id: SEARCH_ITEM_ID,
onClick: onOpenSearch,
text: 'Search dashboards',
};
export const NavBar: FC = React.memo(() => { export const NavBar: FC = React.memo(() => {
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme); const styles = getStyles(theme);
@@ -32,78 +43,79 @@ export const NavBar: FC = React.memo(() => {
location, location,
toggleSwitcherModal toggleSwitcherModal
); );
const activeItemId = isSearchActive(location) const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
? 'search'
: navTree.find((item) => isLinkActive(location.pathname, item))?.id;
const toggleNavBarSmallBreakpoint = useCallback(() => { const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
appEvents.emit(CoreEvents.toggleSidemenuMobile);
}, []);
if (kiosk !== null) { if (kiosk !== null) {
return null; return null;
} }
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
};
return ( return (
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu"> <nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
<div className={styles.mobileSidemenuLogo} onClick={toggleNavBarSmallBreakpoint} key="hamburger"> <div className={styles.mobileSidemenuLogo} onClick={() => setMobileMenuOpen(!mobileMenuOpen)} key="hamburger">
<Icon name="bars" size="xl" /> <Icon name="bars" size="xl" />
<span className={styles.closeButton}>
<Icon name="times" />
Close
</span>
</div> </div>
<NavBarItem url={homeUrl} label="Home" className={styles.grafanaLogo} showMenu={false}> <NavBarSection>
<Branding.MenuLogo /> <NavBarItem url={homeUrl} label="Home" className={styles.grafanaLogo} showMenu={false}>
</NavBarItem> <Branding.MenuLogo />
<NavBarItem
className={styles.search}
isActive={activeItemId === 'search'}
label="Search dashboards"
onClick={onOpenSearch}
>
<Icon name="search" size="xl" />
</NavBarItem>
{topItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={activeItemId === link.id}
label={link.text}
menuItems={link.children}
target={link.target}
url={link.url}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem> </NavBarItem>
))} <NavBarItem
className={styles.search}
isActive={activeItem === searchItem}
label={searchItem.text}
onClick={searchItem.onClick}
>
<Icon name="search" size="xl" />
</NavBarItem>
</NavBarSection>
<NavBarSection>
{topItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text}
menuItems={link.children}
target={link.target}
url={link.url}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
</NavBarSection>
<div className={styles.spacer} /> <div className={styles.spacer} />
{bottomItems.map((link, index) => ( <NavBarSection>
<NavBarItem {bottomItems.map((link, index) => (
key={`${link.id}-${index}`} <NavBarItem
isActive={activeItemId === link.id} key={`${link.id}-${index}`}
label={link.text} isActive={isMatchOrChildMatch(link, activeItem)}
menuItems={link.children} label={link.text}
menuSubTitle={link.subTitle} menuItems={link.children}
onClick={link.onClick} menuSubTitle={link.subTitle}
reverseMenuDirection onClick={link.onClick}
target={link.target} reverseMenuDirection
url={link.url} target={link.target}
> url={link.url}
{link.icon && <Icon name={link.icon as IconName} size="xl" />} >
{link.img && <img src={link.img} alt={`${link.text} logo`} />} {link.icon && <Icon name={link.icon as IconName} size="xl" />}
</NavBarItem> {link.img && <img src={link.img} alt={`${link.text} logo`} />}
))} </NavBarItem>
))}
</NavBarSection>
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />} {showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
{mobileMenuOpen && (
<NavBarMenu
activeItem={activeItem}
navItems={[searchItem, ...topItems, ...bottomItems]}
onClose={() => setMobileMenuOpen(false)}
/>
)}
</nav> </nav>
); );
}); });
@@ -118,11 +130,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
${theme.breakpoints.up('md')} { ${theme.breakpoints.up('md')} {
display: block; display: block;
} }
.sidemenu-open--xs & {
display: block;
margin-top: 0;
}
`, `,
sidemenu: css` sidemenu: css`
display: flex; display: flex;
@@ -141,16 +148,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
.sidemenu-hidden & { .sidemenu-hidden & {
display: none; display: none;
} }
.sidemenu-open--xs & {
background-color: ${theme.colors.background.primary};
box-shadow: ${theme.shadows.z1};
gap: ${theme.spacing(1)};
height: auto;
margin-left: 0;
position: absolute;
width: 100%;
}
`, `,
grafanaLogo: css` grafanaLogo: css`
display: none; display: none;
@@ -165,14 +162,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
justify-content: center; justify-content: center;
} }
`, `,
closeButton: css`
display: none;
.sidemenu-open--xs & {
display: block;
font-size: ${theme.typography.fontSize}px;
}
`,
mobileSidemenuLogo: css` mobileSidemenuLogo: css`
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
@@ -187,9 +176,5 @@ const getStyles = (theme: GrafanaTheme2) => ({
`, `,
spacer: css` spacer: css`
flex: 1; flex: 1;
.sidemenu-open--xs & {
display: none;
}
`, `,
}); });

View File

@@ -26,7 +26,7 @@ describe('NavBarDropdown', () => {
it('attaches the header url to the header text if provided', () => { it('attaches the header url to the header text if provided', () => {
render( render(
<BrowserRouter> <BrowserRouter>
<NavBarDropdown headerText={mockHeaderText} headerUrl={mockHeaderUrl} /> <NavBarDropdown headerText={mockHeaderText} headerUrl={mockHeaderUrl} isVisible />
</BrowserRouter> </BrowserRouter>
); );
const link = screen.getByRole('link', { name: mockHeaderText }); const link = screen.getByRole('link', { name: mockHeaderText });

View File

@@ -1,13 +1,14 @@
import React from 'react'; import React from 'react';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { IconName, Link, useTheme2 } from '@grafana/ui'; import { IconName, useTheme2 } from '@grafana/ui';
import DropdownChild from './DropdownChild'; import { NavBarMenuItem } from './NavBarMenuItem';
interface Props { interface Props {
headerTarget?: HTMLAnchorElement['target']; headerTarget?: HTMLAnchorElement['target'];
headerText: string; headerText: string;
headerUrl?: string; headerUrl?: string;
isVisible?: boolean;
items?: NavModelItem[]; items?: NavModelItem[];
onHeaderClick?: () => void; onHeaderClick?: () => void;
reverseDirection?: boolean; reverseDirection?: boolean;
@@ -18,6 +19,7 @@ const NavBarDropdown = ({
headerTarget, headerTarget,
headerText, headerText,
headerUrl, headerUrl,
isVisible,
items = [], items = [],
onHeaderClick, onHeaderClick,
reverseDirection = false, reverseDirection = false,
@@ -25,32 +27,20 @@ const NavBarDropdown = ({
}: Props) => { }: Props) => {
const filteredItems = items.filter((item) => !item.hideFromMenu); const filteredItems = items.filter((item) => !item.hideFromMenu);
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme, reverseDirection, filteredItems); const styles = getStyles(theme, reverseDirection, filteredItems, isVisible);
let header = (
<button onClick={onHeaderClick} className={styles.header}>
{headerText}
</button>
);
if (headerUrl) {
header =
!headerTarget && headerUrl.startsWith('/') ? (
<Link href={headerUrl} onClick={onHeaderClick} className={styles.header}>
{headerText}
</Link>
) : (
<a href={headerUrl} target={headerTarget} onClick={onHeaderClick} className={styles.header}>
{headerText}
</a>
);
}
return ( return (
<ul className={`${styles.menu} dropdown-menu dropdown-menu--sidemenu`} role="menu"> <ul className={`${styles.menu} navbar-dropdown`} role="menu">
<li>{header}</li> <NavBarMenuItem
onClick={onHeaderClick}
styleOverrides={styles.header}
target={headerTarget}
text={headerText}
url={headerUrl}
/>
{filteredItems.map((child, index) => ( {filteredItems.map((child, index) => (
<DropdownChild <NavBarMenuItem
key={`${child.url}-${index}`} key={index}
isDivider={child.divider} isDivider={child.divider}
icon={child.icon as IconName} icon={child.icon as IconName}
onClick={child.onClick} onClick={child.onClick}
@@ -69,45 +59,37 @@ export default NavBarDropdown;
const getStyles = ( const getStyles = (
theme: GrafanaTheme2, theme: GrafanaTheme2,
reverseDirection: Props['reverseDirection'], reverseDirection: Props['reverseDirection'],
filteredItems: Props['items'] filteredItems: Props['items'],
isVisible: Props['isVisible']
) => { ) => {
const adjustHeightForBorder = filteredItems!.length === 0; const adjustHeightForBorder = filteredItems!.length === 0;
return { return {
header: css` header: css`
align-items: center;
background-color: ${theme.colors.background.secondary}; background-color: ${theme.colors.background.secondary};
border: none;
color: ${theme.colors.text.primary};
height: ${theme.components.sidemenu.width - (adjustHeightForBorder ? 2 : 1)}px; height: ${theme.components.sidemenu.width - (adjustHeightForBorder ? 2 : 1)}px;
font-size: ${theme.typography.h4.fontSize}; font-size: ${theme.typography.h4.fontSize};
font-weight: ${theme.typography.h4.fontWeight}; font-weight: ${theme.typography.h4.fontWeight};
padding: ${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(2)} !important; padding: ${theme.spacing(1)} ${theme.spacing(2)};
white-space: nowrap; white-space: nowrap;
width: 100%; width: 100%;
&:hover {
background-color: ${theme.colors.action.hover};
}
.sidemenu-open--xs & {
display: flex;
font-size: ${theme.typography.body.fontSize};
font-weight: ${theme.typography.body.fontWeight};
padding-left: ${theme.spacing(1)} !important;
}
`, `,
menu: css` menu: css`
background-color: ${theme.colors.background.primary};
border: 1px solid ${theme.components.panel.borderColor}; border: 1px solid ${theme.components.panel.borderColor};
bottom: ${reverseDirection ? 0 : 'auto'};
box-shadow: ${theme.shadows.z3};
display: flex;
flex-direction: ${reverseDirection ? 'column-reverse' : 'column'}; flex-direction: ${reverseDirection ? 'column-reverse' : 'column'};
left: 100%;
.sidemenu-open--xs & { list-style: none;
display: flex; min-width: 140px;
flex-direction: column; opacity: ${isVisible ? 1 : 0};
float: none; position: absolute;
position: unset; top: ${reverseDirection ? 'auto' : 0};
width: 100%; transition: ${theme.transitions.create('opacity')};
} visibility: ${isVisible ? 'visible' : 'hidden'};
z-index: ${theme.zIndex.sidemenu};
`, `,
subtitle: css` subtitle: css`
border-${reverseDirection ? 'bottom' : 'top'}: 1px solid ${theme.colors.border.weak}; border-${reverseDirection ? 'bottom' : 'top'}: 1px solid ${theme.colors.border.weak};
@@ -116,10 +98,6 @@ const getStyles = (
font-weight: ${theme.typography.bodySmall.fontWeight}; font-weight: ${theme.typography.bodySmall.fontWeight};
padding: ${theme.spacing(1)} ${theme.spacing(2)} ${theme.spacing(1)}; padding: ${theme.spacing(1)} ${theme.spacing(2)} ${theme.spacing(1)};
white-space: nowrap; white-space: nowrap;
.sidemenu-open--xs & {
border-${reverseDirection ? 'bottom' : 'top'}: none;
}
`, `,
}; };
}; };

View File

@@ -60,7 +60,7 @@ const NavBarItem = ({
} }
return ( return (
<div className={cx(styles.container, 'dropdown', className, { dropup: reverseMenuDirection })}> <div className={cx(styles.container, className)}>
{element} {element}
{showMenu && ( {showMenu && (
<NavBarDropdown <NavBarDropdown
@@ -82,38 +82,16 @@ export default NavBarItem;
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
container: css` container: css`
position: relative; position: relative;
color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary};
@keyframes dropdown-anim { &:hover {
0% { background-color: ${theme.colors.action.hover};
opacity: 0; color: ${theme.colors.text.primary};
}
100% { // TODO don't use a hardcoded class here, use isVisible in NavBarDropdown
.navbar-dropdown {
opacity: 1; opacity: 1;
} visibility: visible;
}
${theme.breakpoints.up('md')} {
color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary};
&:hover {
background-color: ${theme.colors.action.hover};
color: ${theme.colors.text.primary};
.dropdown-menu {
animation: dropdown-anim 150ms ease-in-out 100ms forwards;
display: flex;
// important to overlap it otherwise it can be hidden
// again by the mouse getting outside the hover space
left: ${theme.components.sidemenu.width - 1}px;
margin: 0;
opacity: 0;
top: 0;
z-index: ${theme.zIndex.sidemenu};
}
&.dropup .dropdown-menu {
top: auto;
}
} }
} }
`, `,
@@ -147,10 +125,6 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
outline-offset: -2px; outline-offset: -2px;
transition: none; transition: none;
} }
.sidemenu-open--xs & {
display: none;
}
`, `,
icon: css` icon: css`
height: 100%; height: 100%;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { NavModelItem } from '@grafana/data';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NavBarMenu } from './NavBarMenu';
describe('NavBarMenu', () => {
const mockOnClose = jest.fn();
const mockNavItems: NavModelItem[] = [];
beforeEach(() => {
render(<NavBarMenu onClose={mockOnClose} navItems={mockNavItems} />);
});
it('should render the component', () => {
const sidemenu = screen.getByTestId('navbarmenu');
expect(sidemenu).toBeInTheDocument();
});
it('has a close button', () => {
const closeButton = screen.getByRole('button', { name: 'Close navigation menu' });
expect(closeButton).toBeInTheDocument();
});
it('clicking the close button calls the onClose callback', () => {
const closeButton = screen.getByRole('button', { name: 'Close navigation menu' });
expect(closeButton).toBeInTheDocument();
userEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,121 @@
import React, { useRef } from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { CustomScrollbar, Icon, IconButton, IconName, useTheme2 } from '@grafana/ui';
import { FocusScope } from '@react-aria/focus';
import { useOverlay } from '@react-aria/overlays';
import { css } from '@emotion/css';
import { NavBarMenuItem } from './NavBarMenuItem';
export interface Props {
activeItem?: NavModelItem;
navItems: NavModelItem[];
onClose: () => void;
}
export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
const theme = useTheme2();
const styles = getStyles(theme);
const ref = useRef(null);
const { overlayProps } = useOverlay(
{
isDismissable: true,
isOpen: true,
onClose,
},
ref
);
return (
<FocusScope contain restoreFocus autoFocus>
<div data-testid="navbarmenu" className={styles.container} ref={ref} {...overlayProps}>
<div className={styles.header}>
<Icon name="bars" size="xl" />
<IconButton aria-label="Close navigation menu" name="times" onClick={onClose} size="xl" variant="secondary" />
</div>
<nav className={styles.content}>
<CustomScrollbar>
<ul>
{navItems.map((link, index) => (
<div className={styles.section} key={index}>
<NavBarMenuItem
isActive={activeItem === link}
onClick={() => {
link.onClick?.();
onClose();
}}
styleOverrides={styles.sectionHeader}
target={link.target}
text={link.text}
url={link.url}
/>
{link.children?.map(
(childLink, childIndex) =>
!childLink.divider && (
<NavBarMenuItem
key={childIndex}
icon={childLink.icon as IconName}
isActive={activeItem === childLink}
isDivider={childLink.divider}
onClick={() => {
childLink.onClick?.();
onClose();
}}
styleOverrides={styles.item}
target={childLink.target}
text={childLink.text}
url={childLink.url}
/>
)
)}
</div>
))}
</ul>
</CustomScrollbar>
</nav>
</div>
</FocusScope>
);
}
NavBarMenu.displayName = 'NavBarMenu';
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
background-color: ${theme.colors.background.canvas};
bottom: 0;
display: flex;
flex-direction: column;
left: 0;
min-width: 300px;
position: fixed;
right: 0;
top: 0;
${theme.breakpoints.up('md')} {
border-right: 1px solid ${theme.colors.border.weak};
right: unset;
}
`,
content: css`
display: flex;
flex-direction: column;
overflow: auto;
`,
header: css`
border-bottom: 1px solid ${theme.colors.border.weak};
display: flex;
justify-content: space-between;
padding: ${theme.spacing(2)};
`,
item: css`
padding: ${theme.spacing(1)} ${theme.spacing(2)};
`,
section: css`
border-bottom: 1px solid ${theme.colors.border.weak};
`,
sectionHeader: css`
color: ${theme.colors.text.primary};
font-size: ${theme.typography.h5.fontSize};
padding: ${theme.spacing(1)} ${theme.spacing(2)};
`,
});

View File

@@ -1,15 +1,15 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import DropdownChild from './DropdownChild'; import { NavBarMenuItem } from './NavBarMenuItem';
describe('DropdownChild', () => { describe('NavBarMenuItem', () => {
const mockText = 'MyChildItem'; const mockText = 'MyChildItem';
const mockUrl = '/route'; const mockUrl = '/route';
const mockIcon = 'home-alt'; const mockIcon = 'home-alt';
it('displays the text', () => { it('displays the text', () => {
render(<DropdownChild text={mockText} />); render(<NavBarMenuItem text={mockText} />);
const text = screen.getByText(mockText); const text = screen.getByText(mockText);
expect(text).toBeInTheDocument(); expect(text).toBeInTheDocument();
}); });
@@ -17,7 +17,7 @@ describe('DropdownChild', () => {
it('attaches the url to the text if provided', () => { it('attaches the url to the text if provided', () => {
render( render(
<BrowserRouter> <BrowserRouter>
<DropdownChild text={mockText} url={mockUrl} /> <NavBarMenuItem text={mockText} url={mockUrl} />
</BrowserRouter> </BrowserRouter>
); );
const link = screen.getByRole('link', { name: mockText }); const link = screen.getByRole('link', { name: mockText });
@@ -26,19 +26,19 @@ describe('DropdownChild', () => {
}); });
it('displays an icon if a valid icon is provided', () => { it('displays an icon if a valid icon is provided', () => {
render(<DropdownChild text={mockText} icon={mockIcon} />); render(<NavBarMenuItem text={mockText} icon={mockIcon} />);
const icon = screen.getByTestId('dropdown-child-icon'); const icon = screen.getByTestId('dropdown-child-icon');
expect(icon).toBeInTheDocument(); expect(icon).toBeInTheDocument();
}); });
it('displays an external link icon if the target is _blank', () => { it('displays an external link icon if the target is _blank', () => {
render(<DropdownChild text={mockText} icon={mockIcon} url={mockUrl} target="_blank" />); render(<NavBarMenuItem text={mockText} icon={mockIcon} url={mockUrl} target="_blank" />);
const icon = screen.getByTestId('external-link-icon'); const icon = screen.getByTestId('external-link-icon');
expect(icon).toBeInTheDocument(); expect(icon).toBeInTheDocument();
}); });
it('displays a divider instead when isDivider is true', () => { it('displays a divider instead when isDivider is true', () => {
render(<DropdownChild text={mockText} icon={mockIcon} url={mockUrl} isDivider />); render(<NavBarMenuItem text={mockText} icon={mockIcon} url={mockUrl} isDivider />);
// Check the divider is shown // Check the divider is shown
const divider = screen.getByTestId('dropdown-child-divider'); const divider = screen.getByTestId('dropdown-child-divider');

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
import { css } from '@emotion/css';
export interface Props {
icon?: IconName;
isActive?: boolean;
isDivider?: boolean;
onClick?: () => void;
styleOverrides?: string;
target?: HTMLAnchorElement['target'];
text: string;
url?: string;
}
export function NavBarMenuItem({ icon, isActive, isDivider, onClick, styleOverrides, target, text, url }: Props) {
const theme = useTheme2();
const styles = getStyles(theme, isActive, styleOverrides);
const linkContent = (
<div className={styles.linkContent}>
<div>
{icon && <Icon data-testid="dropdown-child-icon" name={icon} className={styles.icon} />}
{text}
</div>
{target === '_blank' && (
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} />
)}
</div>
);
let element = (
<button className={styles.element} onClick={onClick}>
{linkContent}
</button>
);
if (url) {
element =
!target && url.startsWith('/') ? (
<Link className={styles.element} href={url} target={target} onClick={onClick}>
{linkContent}
</Link>
) : (
<a href={url} target={target} className={styles.element} onClick={onClick}>
{linkContent}
</a>
);
}
return isDivider ? <li data-testid="dropdown-child-divider" className={styles.divider} /> : <li>{element}</li>;
}
NavBarMenuItem.displayName = 'NavBarMenuItem';
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverrides: Props['styleOverrides']) => ({
divider: css`
border-bottom: 1px solid ${theme.colors.border.weak};
height: 1px;
margin: ${theme.spacing(1)} 0;
overflow: hidden;
`,
element: css`
align-items: center;
background: none;
border: none;
color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary};
display: flex;
font-size: inherit;
height: 100%;
padding: 5px 12px 5px 10px;
position: relative;
text-align: left;
white-space: nowrap;
width: 100%;
&:hover,
&:focus-visible {
background-color: ${theme.colors.action.hover};
color: ${theme.colors.text.primary};
}
&:focus-visible {
box-shadow: none;
outline: 2px solid ${theme.colors.primary.main};
outline-offset: -2px;
transition: none;
}
&::before {
display: ${isActive ? 'block' : 'none'};
content: ' ';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
border-radius: 2px;
background-image: ${theme.colors.gradients.brandVertical};
}
${styleOverrides};
`,
externalLinkIcon: css`
color: ${theme.colors.text.secondary};
margin-left: ${theme.spacing(1)};
`,
icon: css`
margin-right: ${theme.spacing(1)};
`,
linkContent: css`
display: flex;
flex: 1;
flex-direction: row;
justify-content: space-between;
`,
});

View File

@@ -1,20 +1,28 @@
import React, { FC, useCallback, useState } from 'react'; import React, { FC, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data'; import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
import { Icon, IconName, useTheme2 } from '@grafana/ui'; import { Icon, IconName, useTheme2 } from '@grafana/ui';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import appEvents from '../../app_events';
import { Branding } from 'app/core/components/Branding/Branding'; import { Branding } from 'app/core/components/Branding/Branding';
import config from 'app/core/config'; import config from 'app/core/config';
import { CoreEvents, KioskMode } from 'app/types'; import { KioskMode } from 'app/types';
import { enrichConfigItems, isLinkActive, isSearchActive } from './utils'; import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive, SEARCH_ITEM_ID } from './utils';
import { OrgSwitcher } from '../OrgSwitcher'; import { OrgSwitcher } from '../OrgSwitcher';
import { NavBarSection } from './NavBarSection'; import { NavBarSection } from './NavBarSection';
import NavBarItem from './NavBarItem'; import NavBarItem from './NavBarItem';
import { NavBarMenu } from './NavBarMenu';
const homeUrl = config.appSubUrl || '/'; const onOpenSearch = () => {
locationService.partial({ search: 'open' });
};
const searchItem: NavModelItem = {
id: SEARCH_ITEM_ID,
onClick: onOpenSearch,
text: 'Search dashboards',
};
export const NavBarNext: FC = React.memo(() => { export const NavBarNext: FC = React.memo(() => {
const theme = useTheme2(); const theme = useTheme2();
@@ -34,41 +42,33 @@ export const NavBarNext: FC = React.memo(() => {
location, location,
toggleSwitcherModal toggleSwitcherModal
); );
const activeItemId = isSearchActive(location) const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
? 'search' const [menuOpen, setMenuOpen] = useState(false);
: navTree.find((item) => isLinkActive(location.pathname, item))?.id;
const toggleNavBarSmallBreakpoint = useCallback(() => {
appEvents.emit(CoreEvents.toggleSidemenuMobile);
}, []);
if (kiosk !== null) { if (kiosk !== null) {
return null; return null;
} }
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
};
return ( return (
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu"> <nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
<div className={styles.mobileSidemenuLogo} onClick={toggleNavBarSmallBreakpoint} key="hamburger"> <div className={styles.mobileSidemenuLogo} onClick={() => setMenuOpen(!menuOpen)} key="hamburger">
<Icon name="bars" size="xl" /> <Icon name="bars" size="xl" />
<span className={styles.closeButton}>
<Icon name="times" />
Close
</span>
</div> </div>
<NavBarSection> <NavBarSection>
<NavBarItem url={homeUrl} label="Home" className={styles.grafanaLogo} showMenu={false}> <NavBarItem
onClick={() => setMenuOpen(!menuOpen)}
label="Main menu"
className={styles.grafanaLogo}
showMenu={false}
>
<Branding.MenuLogo /> <Branding.MenuLogo />
</NavBarItem> </NavBarItem>
<NavBarItem <NavBarItem
className={styles.search} className={styles.search}
isActive={activeItemId === 'search'} isActive={activeItem === searchItem}
label="Search dashboards" label={searchItem.text}
onClick={onOpenSearch} onClick={searchItem.onClick}
> >
<Icon name="search" size="xl" /> <Icon name="search" size="xl" />
</NavBarItem> </NavBarItem>
@@ -78,7 +78,7 @@ export const NavBarNext: FC = React.memo(() => {
{coreItems.map((link, index) => ( {coreItems.map((link, index) => (
<NavBarItem <NavBarItem
key={`${link.id}-${index}`} key={`${link.id}-${index}`}
isActive={activeItemId === link.id} isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text} label={link.text}
menuItems={link.children} menuItems={link.children}
target={link.target} target={link.target}
@@ -95,7 +95,7 @@ export const NavBarNext: FC = React.memo(() => {
{pluginItems.map((link, index) => ( {pluginItems.map((link, index) => (
<NavBarItem <NavBarItem
key={`${link.id}-${index}`} key={`${link.id}-${index}`}
isActive={activeItemId === link.id} isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text} label={link.text}
menuItems={link.children} menuItems={link.children}
menuSubTitle={link.subTitle} menuSubTitle={link.subTitle}
@@ -116,7 +116,7 @@ export const NavBarNext: FC = React.memo(() => {
{configItems.map((link, index) => ( {configItems.map((link, index) => (
<NavBarItem <NavBarItem
key={`${link.id}-${index}`} key={`${link.id}-${index}`}
isActive={activeItemId === link.id} isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text} label={link.text}
menuItems={link.children} menuItems={link.children}
menuSubTitle={link.subTitle} menuSubTitle={link.subTitle}
@@ -132,6 +132,13 @@ export const NavBarNext: FC = React.memo(() => {
</NavBarSection> </NavBarSection>
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />} {showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
{menuOpen && (
<NavBarMenu
activeItem={activeItem}
navItems={[searchItem, ...coreItems, ...pluginItems, ...configItems]}
onClose={() => setMenuOpen(false)}
/>
)}
</nav> </nav>
); );
}); });
@@ -146,11 +153,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
${theme.breakpoints.up('md')} { ${theme.breakpoints.up('md')} {
display: block; display: block;
} }
.sidemenu-open--xs & {
display: block;
margin-top: 0;
}
`, `,
sidemenu: css` sidemenu: css`
display: flex; display: flex;
@@ -159,8 +161,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
z-index: ${theme.zIndex.sidemenu}; z-index: ${theme.zIndex.sidemenu};
${theme.breakpoints.up('md')} { ${theme.breakpoints.up('md')} {
background: none;
border-right: none;
gap: ${theme.spacing(1)}; gap: ${theme.spacing(1)};
margin-left: ${theme.spacing(1)}; margin-left: ${theme.spacing(1)};
padding: ${theme.spacing(1)} 0; padding: ${theme.spacing(1)} 0;
@@ -171,37 +171,15 @@ const getStyles = (theme: GrafanaTheme2) => ({
.sidemenu-hidden & { .sidemenu-hidden & {
display: none; display: none;
} }
.sidemenu-open--xs & {
background-color: ${theme.colors.background.primary};
box-shadow: ${theme.shadows.z1};
gap: ${theme.spacing(1)};
height: auto;
margin-left: 0;
position: absolute;
width: 100%;
}
`, `,
grafanaLogo: css` grafanaLogo: css`
display: none; align-items: center;
display: flex;
img { img {
height: ${theme.spacing(3)}; height: ${theme.spacing(3)};
width: ${theme.spacing(3)}; width: ${theme.spacing(3)};
} }
justify-content: center;
${theme.breakpoints.up('md')} {
align-items: center;
display: flex;
justify-content: center;
}
`,
closeButton: css`
display: none;
.sidemenu-open--xs & {
display: block;
font-size: ${theme.typography.fontSize}px;
}
`, `,
mobileSidemenuLogo: css` mobileSidemenuLogo: css`
align-items: center; align-items: center;
@@ -217,9 +195,5 @@ const getStyles = (theme: GrafanaTheme2) => ({
`, `,
spacer: css` spacer: css`
flex: 1; flex: 1;
.sidemenu-open--xs & {
display: none;
}
`, `,
}); });

View File

@@ -32,11 +32,5 @@ const getStyles = (theme: GrafanaTheme2, newNavigationEnabled: boolean) => ({
display: flex; display: flex;
flex-direction: inherit; flex-direction: inherit;
} }
.sidemenu-open--xs & {
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
}
`, `,
}); });

View File

@@ -2,7 +2,7 @@ import { Location } from 'history';
import { NavModelItem } from '@grafana/data'; import { NavModelItem } from '@grafana/data';
import { ContextSrv, setContextSrv } from 'app/core/services/context_srv'; import { ContextSrv, setContextSrv } from 'app/core/services/context_srv';
import { getConfig, updateConfig } from '../../config'; import { getConfig, updateConfig } from '../../config';
import { enrichConfigItems, getForcedLoginUrl, isLinkActive, isSearchActive } from './utils'; import { enrichConfigItems, getActiveItem, getForcedLoginUrl, isMatchOrChildMatch, isSearchActive } from './utils';
jest.mock('../../app_events', () => ({ jest.mock('../../app_events', () => ({
publish: jest.fn(), publish: jest.fn(),
@@ -123,118 +123,104 @@ describe('enrichConfigItems', () => {
}); });
}); });
describe('isLinkActive', () => { describe('isMatchOrChildMatch', () => {
it('returns true if the link url matches the pathname', () => { const mockChild: NavModelItem = {
const mockPathName = '/test'; text: 'Child',
const mockLink: NavModelItem = { url: '/dashboards/child',
text: 'Test', };
url: '/test', const mockItemToCheck: NavModelItem = {
}; text: 'Dashboards',
expect(isLinkActive(mockPathName, mockLink)).toBe(true); 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 pathname starts with the link url', () => { it('returns true if the itemToCheck has a child that matches the searchItem', () => {
const mockPathName = '/test/edit'; const searchItem = mockChild;
const mockLink: NavModelItem = { expect(isMatchOrChildMatch(mockItemToCheck, searchItem)).toBe(true);
text: 'Test',
url: '/test',
};
expect(isLinkActive(mockPathName, mockLink)).toBe(true);
}); });
it('returns true if a child link url matches the pathname', () => { it('returns false otherwise', () => {
const mockPathName = '/testChild2'; const searchItem: NavModelItem = {
const mockLink: NavModelItem = { text: 'No match',
text: 'Test', url: '/noMatch',
url: '/test', };
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 with children',
url: '/itemWithChildren',
children: [ children: [
{ {
text: 'TestChild', text: 'Child',
url: '/testChild', url: '/child',
},
{
text: 'TestChild2',
url: '/testChild2',
}, },
], ],
}; },
expect(isLinkActive(mockPathName, mockLink)).toBe(true); {
}); text: 'Alerting item',
it('returns true if the pathname starts with a child link url', () => {
const mockPathName = '/testChild2/edit';
const mockLink: NavModelItem = {
text: 'Test',
url: '/test',
children: [
{
text: 'TestChild',
url: '/testChild',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(true);
});
it('returns true for the alerting link if the pathname is an alert notification', () => {
const mockPathName = '/alerting/notification/foo';
const mockLink: NavModelItem = {
text: 'Test',
url: '/alerting/list', url: '/alerting/list',
children: [ },
{ {
text: 'TestChild', text: 'Base',
url: '/testChild',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(true);
});
it('returns false if none of the link urls match the pathname', () => {
const mockPathName = '/somethingWeird';
const mockLink: NavModelItem = {
text: 'Test',
url: '/test',
children: [
{
text: 'TestChild',
url: '/testChild',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(false);
});
it('returns false for the base route if the pathname is not an exact match', () => {
const mockPathName = '/foo';
const mockLink: NavModelItem = {
text: 'Test',
url: '/', url: '/',
children: [ },
{ {
text: 'TestChild', text: 'Dashboards',
url: '/', url: '/dashboards',
}, },
{ {
text: 'TestChild2', text: 'More specific dashboard',
url: '/testChild2', url: '/d/moreSpecificDashboard',
}, },
], ];
};
expect(isLinkActive(mockPathName, mockLink)).toBe(false); 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 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',
});
}); });
describe('when the newNavigation feature toggle is disabled', () => { describe('when the newNavigation feature toggle is disabled', () => {
@@ -247,42 +233,20 @@ describe('isLinkActive', () => {
}); });
}); });
it('returns true for the base route link if the pathname starts with /d/', () => { it('returns the base route link if the pathname starts with /d/', () => {
const mockPathName = '/d/foo'; const mockPathName = '/d/foo';
const mockLink: NavModelItem = { expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
text: 'Test', text: 'Base',
url: '/', url: '/',
children: [ });
{
text: 'TestChild',
url: '/testChild',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(true);
}); });
it('returns false for the dashboards route if the pathname starts with /d/', () => { it('returns a more specific link if one exists', () => {
const mockPathName = '/d/foo'; const mockPathName = '/d/moreSpecificDashboard';
const mockLink: NavModelItem = { expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
text: 'Test', text: 'More specific dashboard',
url: '/dashboards', url: '/d/moreSpecificDashboard',
children: [ });
{
text: 'TestChild',
url: '/testChild1',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(false);
}); });
}); });
@@ -296,42 +260,20 @@ describe('isLinkActive', () => {
}); });
}); });
it('returns false for the base route if the pathname starts with /d/', () => { it('returns the dashboards route link if the pathname starts with /d/', () => {
const mockPathName = '/d/foo'; const mockPathName = '/d/foo';
const mockLink: NavModelItem = { expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
text: 'Test', text: 'Dashboards',
url: '/', url: '/dashboards',
children: [ });
{
text: 'TestChild',
url: '/',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(false);
}); });
it('returns true for the dashboards route if the pathname starts with /d/', () => { it('returns a more specific link if one exists', () => {
const mockPathName = '/d/foo'; const mockPathName = '/d/moreSpecificDashboard';
const mockLink: NavModelItem = { expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
text: 'Test', text: 'More specific dashboard',
url: '/dashboards', url: '/d/moreSpecificDashboard',
children: [ });
{
text: 'TestChild',
url: '/testChild1',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(true);
}); });
}); });
}); });

View File

@@ -7,6 +7,8 @@ import appEvents from '../../app_events';
import { getFooterLinks } from '../Footer/Footer'; import { getFooterLinks } from '../Footer/Footer';
import { HelpModal } from '../help/HelpModal'; import { HelpModal } from '../help/HelpModal';
export const SEARCH_ITEM_ID = 'search';
export const getForcedLoginUrl = (url: string) => { export const getForcedLoginUrl = (url: string) => {
const queryParams = new URLSearchParams(url.split('?')[1]); const queryParams = new URLSearchParams(url.split('?')[1]);
queryParams.append('forceLogin', 'true'); queryParams.append('forceLogin', 'true');
@@ -73,35 +75,61 @@ export const enrichConfigItems = (
return items; return items;
}; };
export const isLinkActive = (pathname: string, link: NavModelItem) => { export const isMatchOrChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem) => {
// strip out any query params return Boolean(itemToCheck === searchItem || itemToCheck.children?.some((child) => child === searchItem));
const linkPathname = link.url?.split('?')[0]; };
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 newNavigationEnabled = getConfig().featureToggles.newNavigation; const newNavigationEnabled = getConfig().featureToggles.newNavigation;
if (linkPathname) { const dashboardLinkMatch = newNavigationEnabled ? '/dashboards' : '/';
const dashboardLinkMatch = newNavigationEnabled ? '/dashboards' : '/';
if (linkPathname === pathname) { for (const link of navTree) {
// exact match const linkPathname = stripQueryParams(link.url);
return true; if (linkPathname) {
} else if (linkPathname !== '/' && pathname.startsWith(linkPathname)) { if (linkPathname === pathname) {
// partial match // exact match
return true; currentBestMatch = link;
} else if (linkPathname === '/alerting/list' && pathname.startsWith('/alerting/notification/')) { break;
// alert channel match } else if (linkPathname !== '/' && pathname.startsWith(linkPathname)) {
// TODO refactor routes such that we don't need this custom logic // partial match
return true; if (isBetterMatch(link, currentBestMatch)) {
} else if (linkPathname === dashboardLinkMatch && pathname.startsWith('/d/')) { currentBestMatch = link;
// dashboard match }
// TODO refactor routes such that we don't need this custom logic } else if (linkPathname === '/alerting/list' && pathname.startsWith('/alerting/notification/')) {
return true; // 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;
// child match
if (link.children?.some((childLink) => isLinkActive(pathname, childLink))) {
return true;
}
return false;
}; };
export const isSearchActive = (location: Location<unknown>) => { export const isSearchActive = (location: Location<unknown>) => {

View File

@@ -118,10 +118,6 @@ export function grafanaAppDirective() {
$('.preloader').remove(); $('.preloader').remove();
appEvents.on(CoreEvents.toggleSidemenuMobile, () => {
body.toggleClass('sidemenu-open--xs');
});
appEvents.on(CoreEvents.toggleSidemenuHidden, () => { appEvents.on(CoreEvents.toggleSidemenuHidden, () => {
body.toggleClass('sidemenu-hidden'); body.toggleClass('sidemenu-hidden');
}); });

View File

@@ -85,7 +85,6 @@ export interface PanelChangeViewPayload {}
export const dsRequestResponse = eventFactory<DataSourceResponsePayload>('ds-request-response'); export const dsRequestResponse = eventFactory<DataSourceResponsePayload>('ds-request-response');
export const dsRequestError = eventFactory<any>('ds-request-error'); export const dsRequestError = eventFactory<any>('ds-request-error');
export const toggleSidemenuMobile = eventFactory('toggle-sidemenu-mobile');
export const toggleSidemenuHidden = eventFactory('toggle-sidemenu-hidden'); export const toggleSidemenuHidden = eventFactory('toggle-sidemenu-hidden');
export const templateVariableValueUpdated = eventFactory('template-variable-value-updated'); export const templateVariableValueUpdated = eventFactory('template-variable-value-updated');
export const graphClicked = eventFactory<GraphClickedPayload>('graph-click'); export const graphClicked = eventFactory<GraphClickedPayload>('graph-click');

View File

@@ -100,14 +100,7 @@
} }
} }
&--navbar { &--menu {
top: 100%;
min-width: 100%;
}
&--menu,
&--navbar,
&--sidemenu {
background: $menu-dropdown-bg; background: $menu-dropdown-bg;
box-shadow: $menu-dropdown-shadow; box-shadow: $menu-dropdown-shadow;
margin-top: 0px; margin-top: 0px;

View File

@@ -4840,6 +4840,21 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@react-aria/focus@npm:3.5.0":
version: 3.5.0
resolution: "@react-aria/focus@npm:3.5.0"
dependencies:
"@babel/runtime": ^7.6.2
"@react-aria/interactions": ^3.6.0
"@react-aria/utils": ^3.9.0
"@react-types/shared": ^3.9.0
clsx: ^1.1.1
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1
checksum: c03d4433bdb1c19d3119b88bec4d44857cc7efaf9205b1b855731f768dd13231e4e88adb1352fd1c64b5ec0454038b8535d30a07e43b605f41d519ae81dbeb6b
languageName: node
linkType: hard
"@react-aria/i18n@npm:^3.3.2": "@react-aria/i18n@npm:^3.3.2":
version: 3.3.2 version: 3.3.2
resolution: "@react-aria/i18n@npm:3.3.2" resolution: "@react-aria/i18n@npm:3.3.2"
@@ -4856,7 +4871,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@react-aria/interactions@npm:^3.5.1": "@react-aria/interactions@npm:^3.5.1, @react-aria/interactions@npm:^3.6.0":
version: 3.6.0 version: 3.6.0
resolution: "@react-aria/interactions@npm:3.6.0" resolution: "@react-aria/interactions@npm:3.6.0"
dependencies: dependencies:
@@ -17723,6 +17738,8 @@ __metadata:
"@opentelemetry/semantic-conventions": 1.0.0 "@opentelemetry/semantic-conventions": 1.0.0
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.0-rc.6 "@pmmmwh/react-refresh-webpack-plugin": ^0.5.0-rc.6
"@popperjs/core": 2.5.4 "@popperjs/core": 2.5.4
"@react-aria/focus": 3.5.0
"@react-aria/overlays": 3.7.2
"@reduxjs/toolkit": 1.6.1 "@reduxjs/toolkit": 1.6.1
"@rtsao/plugin-proposal-class-properties": 7.0.1-patch.1 "@rtsao/plugin-proposal-class-properties": 7.0.1-patch.1
"@sentry/browser": 5.25.0 "@sentry/browser": 5.25.0