Navigation: Implement active state for items in the Sidemenu (#39030)

* Navigation: Implement active state for items in the Sidemenu

* Navigation: Improve logic for when link is active and extract isSearchActive into a util function

* Navigation: Implement custom rule for dashboards under /d/ and fix minor bugs

* Navigation: only show first matching active state + strip query params from link urls
This commit is contained in:
Ashley Harrison 2021-09-13 16:49:27 +01:00 committed by GitHub
parent 1a71f0fe13
commit ca53f5c8da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 218 additions and 22 deletions

View File

@ -9,6 +9,8 @@ import BottomSection from './BottomSection';
jest.mock('./utils', () => ({ jest.mock('./utils', () => ({
getForcedLoginUrl: () => '/mockForcedLoginUrl', getForcedLoginUrl: () => '/mockForcedLoginUrl',
isLinkActive: () => false,
isSearchActive: () => false,
})); }));
jest.mock('../../app_events', () => ({ jest.mock('../../app_events', () => ({
publish: jest.fn(), publish: jest.fn(),

View File

@ -12,7 +12,7 @@ import { OrgSwitcher } from '../OrgSwitcher';
import { getFooterLinks } from '../Footer/Footer'; import { getFooterLinks } from '../Footer/Footer';
import { HelpModal } from '../help/HelpModal'; import { HelpModal } from '../help/HelpModal';
import SideMenuItem from './SideMenuItem'; import SideMenuItem from './SideMenuItem';
import { getForcedLoginUrl } from './utils'; import { getForcedLoginUrl, isLinkActive, isSearchActive } from './utils';
export default function BottomSection() { export default function BottomSection() {
const theme = useTheme2(); const theme = useTheme2();
@ -21,6 +21,7 @@ export default function BottomSection() {
const bottomNav = navTree.filter((item) => item.hideFromMenu); const bottomNav = navTree.filter((item) => item.hideFromMenu);
const isSignedIn = contextSrv.isSignedIn; const isSignedIn = contextSrv.isSignedIn;
const location = useLocation(); const location = useLocation();
const activeItemId = bottomNav.find((item) => isLinkActive(location.pathname, item))?.id;
const forcedLoginUrl = getForcedLoginUrl(location.pathname + location.search); const forcedLoginUrl = getForcedLoginUrl(location.pathname + location.search);
const user = contextSrv.user; const user = contextSrv.user;
const [showSwitcherModal, setShowSwitcherModal] = useState(false); const [showSwitcherModal, setShowSwitcherModal] = useState(false);
@ -76,6 +77,7 @@ export default function BottomSection() {
return ( return (
<SideMenuItem <SideMenuItem
key={`${link.url}-${index}`} key={`${link.url}-${index}`}
isActive={!isSearchActive(location) && activeItemId === link.id}
label={link.text} label={link.text}
menuItems={menuItems} menuItems={menuItems}
menuSubTitle={link.subTitle} menuSubTitle={link.subTitle}

View File

@ -77,10 +77,19 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: none; display: none;
min-height: ${theme.components.sidemenu.width}px; min-height: ${theme.components.sidemenu.width}px;
&:focus-visible,
&:hover { &:hover {
background-color: ${theme.colors.action.hover}; background-color: ${theme.colors.action.hover};
} }
&:focus-visible {
box-shadow: none;
color: ${theme.colors.text.primary};
outline: 2px solid ${theme.colors.primary.main};
outline-offset: -2px;
transition: none;
}
img { img {
width: ${theme.spacing(3.5)}; width: ${theme.spacing(3.5)};
} }

View File

@ -5,6 +5,7 @@ import { Link, styleMixins, useTheme2 } from '@grafana/ui';
import SideMenuDropDown from './SideMenuDropDown'; import SideMenuDropDown from './SideMenuDropDown';
export interface Props { export interface Props {
isActive?: boolean;
children: ReactNode; children: ReactNode;
label: string; label: string;
menuItems?: NavModelItem[]; menuItems?: NavModelItem[];
@ -16,6 +17,7 @@ export interface Props {
} }
const SideMenuItem = ({ const SideMenuItem = ({
isActive = false,
children, children,
label, label,
menuItems = [], menuItems = [],
@ -26,7 +28,7 @@ const SideMenuItem = ({
url, url,
}: Props) => { }: Props) => {
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme); const styles = getStyles(theme, isActive);
let element = ( let element = (
<button className={styles.element} onClick={onClick} aria-label={label}> <button className={styles.element} onClick={onClick} aria-label={label}>
<span className={styles.icon}>{children}</span> <span className={styles.icon}>{children}</span>
@ -71,7 +73,7 @@ const SideMenuItem = ({
export default SideMenuItem; export default SideMenuItem;
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
container: css` container: css`
position: relative; position: relative;
@ -85,20 +87,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
} }
@media ${styleMixins.mediaUp(`${theme.breakpoints.values.md}px`)} { @media ${styleMixins.mediaUp(`${theme.breakpoints.values.md}px`)} {
// needs to be in here to work on safari... color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary};
&:not(:hover) {
border-left: 2px solid transparent;
}
&:hover { &:hover {
background-color: ${theme.colors.action.hover}; background-color: ${theme.colors.action.hover};
border-image: ${theme.colors.gradients.brandVertical}; color: ${theme.colors.text.primary};
border-image-slice: 1;
border-style: solid;
border-top: 0;
border-right: 0;
border-bottom: 0;
border-left-width: 2px;
.dropdown-menu { .dropdown-menu {
animation: dropdown-anim 150ms ease-in-out 100ms forwards; animation: dropdown-anim 150ms ease-in-out 100ms forwards;
@ -106,7 +99,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: flex; display: flex;
// important to overlap it otherwise it can be hidden // important to overlap it otherwise it can be hidden
// again by the mouse getting outside the hover space // again by the mouse getting outside the hover space
left: ${theme.components.sidemenu.width - 3}px; left: ${theme.components.sidemenu.width - 1}px;
margin: 0; margin: 0;
opacity: 0; opacity: 0;
top: 0; top: 0;
@ -121,12 +114,33 @@ const getStyles = (theme: GrafanaTheme2) => ({
`, `,
element: css` element: css`
background-color: transparent; background-color: transparent;
border: 1px solid transparent; border: none;
color: ${theme.colors.text.secondary}; color: inherit;
display: block; display: block;
line-height: 42px; line-height: 42px;
text-align: center; text-align: center;
width: ${theme.components.sidemenu.width - 2}px; width: ${theme.components.sidemenu.width - 1}px;
&::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};
}
&:focus-visible {
background-color: ${theme.colors.action.hover};
box-shadow: none;
color: ${theme.colors.text.primary};
outline: 2px solid ${theme.colors.primary.main};
outline-offset: -2px;
transition: none;
}
.sidemenu-open--xs & { .sidemenu-open--xs & {
display: none; display: none;

View File

@ -1,5 +1,6 @@
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 TopSection from './TopSection'; import TopSection from './TopSection';
jest.mock('../../config', () => ({ jest.mock('../../config', () => ({
@ -16,13 +17,21 @@ jest.mock('../../config', () => ({
describe('Render', () => { describe('Render', () => {
it('should render search when empty', () => { it('should render search when empty', () => {
render(<TopSection />); render(
<BrowserRouter>
<TopSection />
</BrowserRouter>
);
expect(screen.getByText('Search dashboards')).toBeInTheDocument(); expect(screen.getByText('Search dashboards')).toBeInTheDocument();
}); });
it('should render items and search item', () => { it('should render items and search item', () => {
render(<TopSection />); render(
<BrowserRouter>
<TopSection />
</BrowserRouter>
);
expect(screen.getByTestId('top-section-items').children.length).toBe(3); expect(screen.getByTestId('top-section-items').children.length).toBe(3);
}); });

View File

@ -1,17 +1,21 @@
import React from 'react'; import React from 'react';
import { useLocation } from 'react-router-dom';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { Icon, IconName, styleMixins, useTheme2 } from '@grafana/ui'; import { Icon, IconName, styleMixins, useTheme2 } from '@grafana/ui';
import config from '../../config'; import config from '../../config';
import { isLinkActive, isSearchActive } from './utils';
import SideMenuItem from './SideMenuItem'; import SideMenuItem from './SideMenuItem';
const TopSection = () => { const TopSection = () => {
const location = useLocation();
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme); const styles = getStyles(theme);
const navTree: NavModelItem[] = cloneDeep(config.bootData.navTree); const navTree: NavModelItem[] = cloneDeep(config.bootData.navTree);
const mainLinks = navTree.filter((item) => !item.hideFromMenu); const mainLinks = navTree.filter((item) => !item.hideFromMenu);
const activeItemId = mainLinks.find((item) => isLinkActive(location.pathname, item))?.id;
const onOpenSearch = () => { const onOpenSearch = () => {
locationService.partial({ search: 'open' }); locationService.partial({ search: 'open' });
@ -19,13 +23,14 @@ const TopSection = () => {
return ( return (
<div data-testid="top-section-items" className={styles.container}> <div data-testid="top-section-items" className={styles.container}>
<SideMenuItem label="Search dashboards" onClick={onOpenSearch}> <SideMenuItem isActive={isSearchActive(location)} label="Search dashboards" onClick={onOpenSearch}>
<Icon name="search" size="xl" /> <Icon name="search" size="xl" />
</SideMenuItem> </SideMenuItem>
{mainLinks.map((link, index) => { {mainLinks.map((link, index) => {
return ( return (
<SideMenuItem <SideMenuItem
key={`${link.id}-${index}`} key={`${link.id}-${index}`}
isActive={!isSearchActive(location) && activeItemId === link.id}
label={link.text} label={link.text}
menuItems={link.children} menuItems={link.children}
target={link.target} target={link.target}

View File

@ -1,5 +1,6 @@
import { NavModelItem } from '@grafana/data';
import { updateConfig } from '../../config'; import { updateConfig } from '../../config';
import { getForcedLoginUrl } from './utils'; import { getForcedLoginUrl, isLinkActive, isSearchActive } from './utils';
describe('getForcedLoginUrl', () => { describe('getForcedLoginUrl', () => {
it.each` it.each`
@ -23,3 +24,121 @@ describe('getForcedLoginUrl', () => {
} }
); );
}); });
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);
});
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 a child link url matches the pathname', () => {
const mockPathName = '/testChild2';
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 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 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: '/',
children: [
{
text: 'TestChild',
url: '/',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(false);
});
});
describe('isSearchActive', () => {
it('returns true if the search query parameter is "open"', () => {
const mockLocation = {
hash: '',
pathname: '/',
search: '?search=open',
state: '',
};
expect(isSearchActive(mockLocation)).toBe(true);
});
it('returns false if the search query parameter is missing', () => {
const mockLocation = {
hash: '',
pathname: '/',
search: '',
state: '',
};
expect(isSearchActive(mockLocation)).toBe(false);
});
});

View File

@ -1,4 +1,6 @@
import { NavModelItem } from '@grafana/data';
import { getConfig } from 'app/core/config'; import { getConfig } from 'app/core/config';
import { Location } from 'history';
export const getForcedLoginUrl = (url: string) => { export const getForcedLoginUrl = (url: string) => {
const queryParams = new URLSearchParams(url.split('?')[1]); const queryParams = new URLSearchParams(url.split('?')[1]);
@ -6,3 +8,37 @@ export const getForcedLoginUrl = (url: string) => {
return `${getConfig().appSubUrl}${url.split('?')[0]}?${queryParams.toString()}`; return `${getConfig().appSubUrl}${url.split('?')[0]}?${queryParams.toString()}`;
}; };
export const isLinkActive = (pathname: string, link: NavModelItem) => {
// strip out any query params
const linkPathname = link.url?.split('?')[0];
if (linkPathname) {
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 === '/' && pathname.startsWith('/d/')) {
// dashboard match
// TODO refactor routes such that we don't need this custom logic
return true;
}
}
// child match
if (link.children?.some((childLink) => isLinkActive(pathname, childLink))) {
return true;
}
return false;
};
export const isSearchActive = (location: Location<unknown>) => {
const query = new URLSearchParams(location.search);
return query.get('search') === 'open';
};