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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 611 additions and 534 deletions

View File

@ -243,6 +243,8 @@
"@opentelemetry/exporter-collector": "0.23.0",
"@opentelemetry/semantic-conventions": "1.0.0",
"@popperjs/core": "2.5.4",
"@react-aria/focus": "3.5.0",
"@react-aria/overlays": "3.7.2",
"@reduxjs/toolkit": "1.6.1",
"@sentry/browser": "5.25.0",
"@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 { css, cx } from '@emotion/css';
import { cloneDeep } from 'lodash';
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
import { Icon, IconName, useTheme2 } from '@grafana/ui';
import { locationService } from '@grafana/runtime';
import appEvents from '../../app_events';
import { Branding } from 'app/core/components/Branding/Branding';
import config from 'app/core/config';
import { CoreEvents, KioskMode } from 'app/types';
import { enrichConfigItems, isLinkActive, isSearchActive } from './utils';
import { KioskMode } from 'app/types';
import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive, SEARCH_ITEM_ID } from './utils';
import { OrgSwitcher } from '../OrgSwitcher';
import NavBarItem from './NavBarItem';
import { NavBarSection } from './NavBarSection';
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 NavBar: FC = React.memo(() => {
const theme = useTheme2();
const styles = getStyles(theme);
@ -32,78 +43,79 @@ export const NavBar: FC = React.memo(() => {
location,
toggleSwitcherModal
);
const activeItemId = isSearchActive(location)
? 'search'
: navTree.find((item) => isLinkActive(location.pathname, item))?.id;
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
const toggleNavBarSmallBreakpoint = useCallback(() => {
appEvents.emit(CoreEvents.toggleSidemenuMobile);
}, []);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
if (kiosk !== null) {
return null;
}
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
};
return (
<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" />
<span className={styles.closeButton}>
<Icon name="times" />
Close
</span>
</div>
<NavBarItem url={homeUrl} label="Home" className={styles.grafanaLogo} showMenu={false}>
<Branding.MenuLogo />
</NavBarItem>
<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`} />}
<NavBarSection>
<NavBarItem url={homeUrl} label="Home" className={styles.grafanaLogo} showMenu={false}>
<Branding.MenuLogo />
</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} />
{bottomItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={activeItemId === link.id}
label={link.text}
menuItems={link.children}
menuSubTitle={link.subTitle}
onClick={link.onClick}
reverseMenuDirection
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>
{bottomItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text}
menuItems={link.children}
menuSubTitle={link.subTitle}
onClick={link.onClick}
reverseMenuDirection
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>
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
{mobileMenuOpen && (
<NavBarMenu
activeItem={activeItem}
navItems={[searchItem, ...topItems, ...bottomItems]}
onClose={() => setMobileMenuOpen(false)}
/>
)}
</nav>
);
});
@ -118,11 +130,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
${theme.breakpoints.up('md')} {
display: block;
}
.sidemenu-open--xs & {
display: block;
margin-top: 0;
}
`,
sidemenu: css`
display: flex;
@ -141,16 +148,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
.sidemenu-hidden & {
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`
display: none;
@ -165,14 +162,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
justify-content: center;
}
`,
closeButton: css`
display: none;
.sidemenu-open--xs & {
display: block;
font-size: ${theme.typography.fontSize}px;
}
`,
mobileSidemenuLogo: css`
align-items: center;
cursor: pointer;
@ -187,9 +176,5 @@ const getStyles = (theme: GrafanaTheme2) => ({
`,
spacer: css`
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', () => {
render(
<BrowserRouter>
<NavBarDropdown headerText={mockHeaderText} headerUrl={mockHeaderUrl} />
<NavBarDropdown headerText={mockHeaderText} headerUrl={mockHeaderUrl} isVisible />
</BrowserRouter>
);
const link = screen.getByRole('link', { name: mockHeaderText });

View File

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

View File

@ -60,7 +60,7 @@ const NavBarItem = ({
}
return (
<div className={cx(styles.container, 'dropdown', className, { dropup: reverseMenuDirection })}>
<div className={cx(styles.container, className)}>
{element}
{showMenu && (
<NavBarDropdown
@ -82,38 +82,16 @@ export default NavBarItem;
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
container: css`
position: relative;
color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary};
@keyframes dropdown-anim {
0% {
opacity: 0;
}
100% {
&:hover {
background-color: ${theme.colors.action.hover};
color: ${theme.colors.text.primary};
// TODO don't use a hardcoded class here, use isVisible in NavBarDropdown
.navbar-dropdown {
opacity: 1;
}
}
${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;
}
visibility: visible;
}
}
`,
@ -147,10 +125,6 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
outline-offset: -2px;
transition: none;
}
.sidemenu-open--xs & {
display: none;
}
`,
icon: css`
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 { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import DropdownChild from './DropdownChild';
import { NavBarMenuItem } from './NavBarMenuItem';
describe('DropdownChild', () => {
describe('NavBarMenuItem', () => {
const mockText = 'MyChildItem';
const mockUrl = '/route';
const mockIcon = 'home-alt';
it('displays the text', () => {
render(<DropdownChild text={mockText} />);
render(<NavBarMenuItem text={mockText} />);
const text = screen.getByText(mockText);
expect(text).toBeInTheDocument();
});
@ -17,7 +17,7 @@ describe('DropdownChild', () => {
it('attaches the url to the text if provided', () => {
render(
<BrowserRouter>
<DropdownChild text={mockText} url={mockUrl} />
<NavBarMenuItem text={mockText} url={mockUrl} />
</BrowserRouter>
);
const link = screen.getByRole('link', { name: mockText });
@ -26,19 +26,19 @@ describe('DropdownChild', () => {
});
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');
expect(icon).toBeInTheDocument();
});
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');
expect(icon).toBeInTheDocument();
});
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
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 { css, cx } from '@emotion/css';
import { cloneDeep } from 'lodash';
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
import { Icon, IconName, useTheme2 } from '@grafana/ui';
import { locationService } from '@grafana/runtime';
import appEvents from '../../app_events';
import { Branding } from 'app/core/components/Branding/Branding';
import config from 'app/core/config';
import { CoreEvents, KioskMode } from 'app/types';
import { enrichConfigItems, isLinkActive, isSearchActive } from './utils';
import { KioskMode } from 'app/types';
import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive, SEARCH_ITEM_ID } from './utils';
import { OrgSwitcher } from '../OrgSwitcher';
import { NavBarSection } from './NavBarSection';
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(() => {
const theme = useTheme2();
@ -34,41 +42,33 @@ export const NavBarNext: FC = React.memo(() => {
location,
toggleSwitcherModal
);
const activeItemId = isSearchActive(location)
? 'search'
: navTree.find((item) => isLinkActive(location.pathname, item))?.id;
const toggleNavBarSmallBreakpoint = useCallback(() => {
appEvents.emit(CoreEvents.toggleSidemenuMobile);
}, []);
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
const [menuOpen, setMenuOpen] = useState(false);
if (kiosk !== null) {
return null;
}
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
};
return (
<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" />
<span className={styles.closeButton}>
<Icon name="times" />
Close
</span>
</div>
<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 />
</NavBarItem>
<NavBarItem
className={styles.search}
isActive={activeItemId === 'search'}
label="Search dashboards"
onClick={onOpenSearch}
isActive={activeItem === searchItem}
label={searchItem.text}
onClick={searchItem.onClick}
>
<Icon name="search" size="xl" />
</NavBarItem>
@ -78,7 +78,7 @@ export const NavBarNext: FC = React.memo(() => {
{coreItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={activeItemId === link.id}
isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text}
menuItems={link.children}
target={link.target}
@ -95,7 +95,7 @@ export const NavBarNext: FC = React.memo(() => {
{pluginItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={activeItemId === link.id}
isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text}
menuItems={link.children}
menuSubTitle={link.subTitle}
@ -116,7 +116,7 @@ export const NavBarNext: FC = React.memo(() => {
{configItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={activeItemId === link.id}
isActive={isMatchOrChildMatch(link, activeItem)}
label={link.text}
menuItems={link.children}
menuSubTitle={link.subTitle}
@ -132,6 +132,13 @@ export const NavBarNext: FC = React.memo(() => {
</NavBarSection>
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
{menuOpen && (
<NavBarMenu
activeItem={activeItem}
navItems={[searchItem, ...coreItems, ...pluginItems, ...configItems]}
onClose={() => setMenuOpen(false)}
/>
)}
</nav>
);
});
@ -146,11 +153,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
${theme.breakpoints.up('md')} {
display: block;
}
.sidemenu-open--xs & {
display: block;
margin-top: 0;
}
`,
sidemenu: css`
display: flex;
@ -159,8 +161,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
z-index: ${theme.zIndex.sidemenu};
${theme.breakpoints.up('md')} {
background: none;
border-right: none;
gap: ${theme.spacing(1)};
margin-left: ${theme.spacing(1)};
padding: ${theme.spacing(1)} 0;
@ -171,37 +171,15 @@ const getStyles = (theme: GrafanaTheme2) => ({
.sidemenu-hidden & {
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`
display: none;
align-items: center;
display: flex;
img {
height: ${theme.spacing(3)};
width: ${theme.spacing(3)};
}
${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;
}
justify-content: center;
`,
mobileSidemenuLogo: css`
align-items: center;
@ -217,9 +195,5 @@ const getStyles = (theme: GrafanaTheme2) => ({
`,
spacer: css`
flex: 1;
.sidemenu-open--xs & {
display: none;
}
`,
});

View File

@ -32,11 +32,5 @@ const getStyles = (theme: GrafanaTheme2, newNavigationEnabled: boolean) => ({
display: flex;
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 { ContextSrv, setContextSrv } from 'app/core/services/context_srv';
import { getConfig, updateConfig } from '../../config';
import { enrichConfigItems, getForcedLoginUrl, isLinkActive, isSearchActive } from './utils';
import { enrichConfigItems, getActiveItem, getForcedLoginUrl, isMatchOrChildMatch, isSearchActive } from './utils';
jest.mock('../../app_events', () => ({
publish: jest.fn(),
@ -123,118 +123,104 @@ describe('enrichConfigItems', () => {
});
});
describe('isLinkActive', () => {
it('returns true if the link url matches the pathname', () => {
const mockPathName = '/test';
const mockLink: NavModelItem = {
text: 'Test',
url: '/test',
};
expect(isLinkActive(mockPathName, mockLink)).toBe(true);
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 pathname starts with the link url', () => {
const mockPathName = '/test/edit';
const mockLink: NavModelItem = {
text: 'Test',
url: '/test',
};
expect(isLinkActive(mockPathName, mockLink)).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 true if a child link url matches the pathname', () => {
const mockPathName = '/testChild2';
const mockLink: NavModelItem = {
text: 'Test',
url: '/test',
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 with children',
url: '/itemWithChildren',
children: [
{
text: 'TestChild',
url: '/testChild',
},
{
text: 'TestChild2',
url: '/testChild2',
text: 'Child',
url: '/child',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(true);
});
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',
},
{
text: 'Alerting item',
url: '/alerting/list',
children: [
{
text: 'TestChild',
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',
},
{
text: 'Base',
url: '/',
children: [
{
text: 'TestChild',
url: '/',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(false);
},
{
text: 'Dashboards',
url: '/dashboards',
},
{
text: 'More specific dashboard',
url: '/d/moreSpecificDashboard',
},
];
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', () => {
@ -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 mockLink: NavModelItem = {
text: 'Test',
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
text: 'Base',
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/', () => {
const mockPathName = '/d/foo';
const mockLink: NavModelItem = {
text: 'Test',
url: '/dashboards',
children: [
{
text: 'TestChild',
url: '/testChild1',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(false);
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',
});
});
});
@ -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 mockLink: NavModelItem = {
text: 'Test',
url: '/',
children: [
{
text: 'TestChild',
url: '/',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(false);
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
text: 'Dashboards',
url: '/dashboards',
});
});
it('returns true for the dashboards route if the pathname starts with /d/', () => {
const mockPathName = '/d/foo';
const mockLink: NavModelItem = {
text: 'Test',
url: '/dashboards',
children: [
{
text: 'TestChild',
url: '/testChild1',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(true);
it('returns a more specific link if one exists', () => {
const mockPathName = '/d/moreSpecificDashboard';
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
text: 'More specific dashboard',
url: '/d/moreSpecificDashboard',
});
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

@ -4840,6 +4840,21 @@ __metadata:
languageName: node
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":
version: 3.3.2
resolution: "@react-aria/i18n@npm:3.3.2"
@ -4856,7 +4871,7 @@ __metadata:
languageName: node
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
resolution: "@react-aria/interactions@npm:3.6.0"
dependencies:
@ -17723,6 +17738,8 @@ __metadata:
"@opentelemetry/semantic-conventions": 1.0.0
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.0-rc.6
"@popperjs/core": 2.5.4
"@react-aria/focus": 3.5.0
"@react-aria/overlays": 3.7.2
"@reduxjs/toolkit": 1.6.1
"@rtsao/plugin-proposal-class-properties": 7.0.1-patch.1
"@sentry/browser": 5.25.0