Navigation: Implement logic for docking nav menu (#76188)

* Create a state for dockedMegaMenu and the function to manage it

* Add the dockedMenu icon and handle the status when clicking it

* Add Megamenu to section nav area when it is docked

* get logic working

* fix mobile

* refactor state + persist in localStorage

* adjust icon and don't use position absolute

* restore old rudderstack tracking

* use Flex instead

* adjust feature toggle to be experimental

* extract out localStorage handling into utils

* don't need separate file

* use store.set/get instead

---------

Co-authored-by: eledobleefe <laura.fernandez@grafana.com>
This commit is contained in:
Ashley Harrison
2023-10-10 14:55:52 +01:00
committed by GitHub
parent de2d8f50e8
commit 930c753340
19 changed files with 117 additions and 35 deletions

View File

@@ -10,6 +10,7 @@ import { CommandPalette } from 'app/features/commandPalette/CommandPalette';
import { KioskMode } from 'app/types';
import { AppChromeMenu } from './AppChromeMenu';
import { MegaMenu as DockedMegaMenu } from './DockedMegaMenu/MegaMenu';
import { MegaMenu } from './MegaMenu/MegaMenu';
import { NavToolbar } from './NavToolbar/NavToolbar';
import { SectionNav } from './SectionNav/SectionNav';
@@ -53,7 +54,7 @@ export function AppChrome({ children }: Props) {
pageNav={state.pageNav}
actions={state.actions}
onToggleSearchBar={chrome.onToggleSearchBar}
onToggleMegaMenu={chrome.onToggleMegaMenu}
onToggleMegaMenu={() => chrome.setMegaMenu(state.megaMenu === 'closed' ? 'open' : 'closed')}
onToggleKioskMode={chrome.onToggleKioskMode}
/>
</div>
@@ -64,6 +65,9 @@ export function AppChrome({ children }: Props) {
{state.layout === PageLayoutType.Standard && state.sectionNav && !config.featureToggles.dockedMegaMenu && (
<SectionNav model={state.sectionNav} />
)}
{config.featureToggles.dockedMegaMenu && !state.chromeless && state.megaMenu === 'docked' && (
<DockedMegaMenu className={styles.dockedMegaMenu} onClose={() => chrome.setMegaMenu('closed')} />
)}
<div className={styles.pageContainer} id="pageContent">
{children}
</div>
@@ -74,7 +78,7 @@ export function AppChrome({ children }: Props) {
{config.featureToggles.dockedMegaMenu ? (
<AppChromeMenu />
) : (
<MegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenu(false)} />
<MegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenu('closed')} />
)}
<CommandPalette />
</>
@@ -102,6 +106,12 @@ const getStyles = (theme: GrafanaTheme2) => {
contentChromeless: css({
paddingTop: 0,
}),
dockedMegaMenu: css({
background: theme.colors.background.primary,
borderRight: `1px solid ${theme.colors.border.weak}`,
borderTop: `1px solid ${theme.colors.border.weak}`,
zIndex: theme.zIndex.navbarFixed,
}),
topNav: css({
display: 'flex',
position: 'fixed',

View File

@@ -27,8 +27,8 @@ export function AppChromeMenu({}: Props) {
const animationSpeed = theme.transitions.duration.shortest;
const animationStyles = useStyles2(getAnimStyles, animationSpeed);
const isOpen = state.megaMenuOpen;
const onClose = () => chrome.setMegaMenu(false);
const isOpen = state.megaMenu === 'open';
const onClose = () => chrome.setMegaMenu('closed');
const { overlayProps, underlayProps } = useOverlay(
{

View File

@@ -2,7 +2,7 @@ import { useObservable } from 'react-use';
import { BehaviorSubject } from 'rxjs';
import { AppEvents, NavModel, NavModelItem, PageLayoutType, UrlQueryValue } from '@grafana/data';
import { locationService, reportInteraction } from '@grafana/runtime';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import { t } from 'app/core/internationalization';
import store from 'app/core/store';
@@ -17,11 +17,13 @@ export interface AppChromeState {
pageNav?: NavModelItem;
actions?: React.ReactNode;
searchBarHidden?: boolean;
megaMenuOpen?: boolean;
megaMenu: 'open' | 'closed' | 'docked';
kioskMode: KioskMode | null;
layout: PageLayoutType;
}
const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked';
export class AppChromeService {
searchBarStorageKey = 'SearchBar_Hidden';
private currentRoute?: RouteDescriptor;
@@ -31,6 +33,8 @@ export class AppChromeService {
chromeless: true, // start out hidden to not flash it on pages without chrome
sectionNav: { node: { text: t('nav.home.title', 'Home') }, main: { text: '' } },
searchBarHidden: store.getBool(this.searchBarStorageKey, false),
megaMenu:
config.featureToggles.dockedMegaMenu && store.getBool(DOCKED_LOCAL_STORAGE_KEY, false) ? 'docked' : 'closed',
kioskMode: null,
layout: PageLayoutType.Canvas,
});
@@ -93,14 +97,14 @@ export class AppChromeService {
return useObservable(this.state, this.state.getValue());
}
public onToggleMegaMenu = () => {
const isOpen = !this.state.getValue().megaMenuOpen;
reportInteraction('grafana_toggle_menu_clicked', { action: isOpen ? 'open' : 'close' });
this.update({ megaMenuOpen: isOpen });
};
public setMegaMenu = (megaMenuOpen: boolean) => {
this.update({ megaMenuOpen });
public setMegaMenu = (newMegaMenuState: AppChromeState['megaMenu']) => {
if (config.featureToggles.dockedMegaMenu) {
store.set(DOCKED_LOCAL_STORAGE_KEY, newMegaMenuState === 'docked');
reportInteraction('grafana_mega_menu_state', { state: newMegaMenuState });
} else {
reportInteraction('grafana_toggle_menu_clicked', { action: newMegaMenuState === 'open' ? 'open' : 'close' });
}
this.update({ megaMenu: newMegaMenuState });
};
public onToggleSearchBar = () => {

View File

@@ -35,7 +35,7 @@ const setup = () => {
];
const grafanaContext = getGrafanaContextMock();
grafanaContext.chrome.onToggleMegaMenu();
grafanaContext.chrome.setMegaMenu('open');
return render(
<TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}>

View File

@@ -6,6 +6,9 @@ import { useLocation } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { CustomScrollbar, Icon, IconButton, useStyles2 } from '@grafana/ui';
import { Flex } from '@grafana/ui/src/unstable';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { t } from 'app/core/internationalization';
import { useSelector } from 'app/types';
import { MegaMenuItem } from './MegaMenuItem';
@@ -22,6 +25,8 @@ export const MegaMenu = React.memo(
const navBarTree = useSelector((state) => state.navBarTree);
const styles = useStyles2(getStyles);
const location = useLocation();
const { chrome } = useGrafana();
const state = chrome.useState();
const navTree = cloneDeep(navBarTree);
@@ -32,13 +37,16 @@ export const MegaMenu = React.memo(
const activeItem = getActiveItem(navItems, location.pathname);
const handleDockedMenu = () => {
chrome.setMegaMenu(state.megaMenu === 'docked' ? 'closed' : 'docked');
};
return (
<div data-testid="navbarmenu" ref={ref} {...restProps}>
<div className={styles.mobileHeader}>
<Icon name="bars" size="xl" />
<IconButton
aria-label="Close navigation menu"
tooltip="Close menu"
tooltip={t('navigation.megamenu.close', 'Close menu')}
name="times"
onClick={onClose}
size="xl"
@@ -48,8 +56,27 @@ export const MegaMenu = React.memo(
<nav className={styles.content}>
<CustomScrollbar showScrollIndicators hideHorizontalTrack>
<ul className={styles.itemList}>
{navItems.map((link) => (
<MegaMenuItem link={link} onClose={onClose} activeItem={activeItem} key={link.text} />
{navItems.map((link, index) => (
<Flex key={link.text} direction="row" alignItems="center">
<MegaMenuItem
link={link}
onClick={state.megaMenu === 'open' ? onClose : undefined}
activeItem={activeItem}
/>
{index === 0 && (
<IconButton
className={styles.dockMenuButton}
tooltip={
state.megaMenu === 'docked'
? t('navigation.megamenu.undock', 'Undock menu')
: t('navigation.megamenu.dock', 'Dock menu')
}
name="web-section-alt"
onClick={handleDockedMenu}
variant="secondary"
/>
)}
</Flex>
))}
</ul>
</CustomScrollbar>
@@ -65,8 +92,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
content: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
minHeight: 0,
position: 'relative',
}),
mobileHeader: css({
display: 'flex',
@@ -79,10 +107,15 @@ const getStyles = (theme: GrafanaTheme2) => ({
},
}),
itemList: css({
display: 'grid',
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`,
gridTemplateColumns: `minmax(${MENU_WIDTH}, auto)`,
display: 'flex',
flexDirection: 'column',
listStyleType: 'none',
minWidth: MENU_WIDTH,
[theme.breakpoints.up('md')]: {
width: MENU_WIDTH,
},
}),
dockMenuButton: css({
marginRight: theme.spacing(2),
}),
});

View File

@@ -15,11 +15,11 @@ import { hasChildMatch } from './utils';
interface Props {
link: NavModelItem;
activeItem?: NavModelItem;
onClose?: () => void;
onClick?: () => void;
level?: number;
}
export function MegaMenuItem({ link, activeItem, level = 0, onClose }: Props) {
export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) {
const styles = useStyles2(getStyles);
const FeatureHighlightWrapper = link.highlightText ? FeatureHighlight : React.Fragment;
const isActive = link === activeItem;
@@ -29,13 +29,13 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClose }: Props) {
const showExpandButton = linkHasChildren(link) || link.emptyMessage;
return (
<li>
<li className={styles.listItem}>
<div className={styles.collapsibleSectionWrapper}>
<MegaMenuItemText
isActive={isActive}
onClick={() => {
link.onClick?.();
onClose?.();
onClick?.();
}}
target={link.target}
url={link.url}
@@ -75,7 +75,7 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClose }: Props) {
key={`${link.text}-${childLink.text}`}
link={childLink}
activeItem={activeItem}
onClose={onClose}
onClick={onClick}
level={level + 1}
/>
))
@@ -121,6 +121,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
alignItems: 'center',
fontWeight: theme.typography.fontWeightMedium,
}),
listItem: css({
flex: 1,
}),
isActive: css({
color: theme.colors.text.primary,

View File

@@ -29,7 +29,7 @@ const setup = () => {
];
const grafanaContext = getGrafanaContextMock();
grafanaContext.chrome.onToggleMegaMenu();
grafanaContext.chrome.setMegaMenu('open');
return render(
<TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}>

View File

@@ -46,10 +46,10 @@ export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: P
);
useEffect(() => {
if (state.megaMenuOpen) {
if (state.megaMenu === 'open') {
setIsOpen(true);
}
}, [state.megaMenuOpen]);
}, [state.megaMenu]);
return (
<OverlayContainer>

View File

@@ -3,6 +3,7 @@ import { css, cx } from '@emotion/css';
import React, { useLayoutEffect } from 'react';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { CustomScrollbar, useStyles2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
@@ -108,7 +109,7 @@ const getStyles = (theme: GrafanaTheme2) => {
margin: theme.spacing(0, 0, 0, 0),
[theme.breakpoints.up('md')]: {
margin: theme.spacing(2, 2, 0, 1),
margin: theme.spacing(2, 2, 0, config.featureToggles.dockedMegaMenu ? 2 : 1),
padding: theme.spacing(3),
},
}),