mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
de2d8f50e8
commit
930c753340
@ -64,7 +64,6 @@ Some features are enabled by default. You can disable these feature by setting t
|
||||
| `newDBLibrary` | Use jmoiron/sqlx rather than xorm for a few backend services |
|
||||
| `autoMigrateOldPanels` | Migrate old angular panels to supported versions (graph, table-old, worldmap, etc) |
|
||||
| `disableAngular` | Dynamic flag to disable angular at runtime. The preferred method is to set `angular_support_enabled` to `false` in the [security] settings, which allows you to change the state at runtime. |
|
||||
| `dockedMegaMenu` | Enable support for a persistent (docked) navigation menu |
|
||||
| `grpcServer` | Run the GRPC server |
|
||||
| `accessControlOnCall` | Access control primitives for OnCall |
|
||||
| `nestedFolders` | Enable folder nesting |
|
||||
@ -97,6 +96,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `scenes` | Experimental framework to build interactive dashboards |
|
||||
| `disableSecretsCompatibility` | Disable duplicated secret storage in legacy tables |
|
||||
| `logRequestsInstrumentedAsUnknown` | Logs the path for requests that are instrumented as unknown |
|
||||
| `dockedMegaMenu` | Enable support for a persistent (docked) navigation menu |
|
||||
| `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema |
|
||||
| `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query |
|
||||
| `alertingBacktesting` | Rule backtesting API for alerting |
|
||||
|
@ -225,6 +225,7 @@ export const availableIconsIndex = {
|
||||
'vertical-align-bottom': true,
|
||||
'vertical-align-center': true,
|
||||
'vertical-align-top': true,
|
||||
'web-section-alt': true,
|
||||
'wrap-text': true,
|
||||
rss: true,
|
||||
x: true,
|
||||
|
@ -164,7 +164,7 @@ var (
|
||||
{
|
||||
Name: "dockedMegaMenu",
|
||||
Description: "Enable support for a persistent (docked) navigation menu",
|
||||
Stage: FeatureStagePublicPreview,
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaFrontendPlatformSquad,
|
||||
},
|
||||
|
@ -22,7 +22,7 @@ disableSecretsCompatibility,experimental,@grafana/hosted-grafana-team,false,fals
|
||||
logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,false,false,false,false
|
||||
dataConnectionsConsole,GA,@grafana/plugins-platform-backend,false,false,false,false
|
||||
topnav,deprecated,@grafana/grafana-frontend-platform,false,false,false,false
|
||||
dockedMegaMenu,preview,@grafana/grafana-frontend-platform,false,false,false,true
|
||||
dockedMegaMenu,experimental,@grafana/grafana-frontend-platform,false,false,false,true
|
||||
grpcServer,preview,@grafana/grafana-app-platform-squad,false,false,false,false
|
||||
entityStore,experimental,@grafana/grafana-app-platform-squad,true,false,false,false
|
||||
cloudWatchCrossAccountQuerying,GA,@grafana/aws-datasources,false,false,false,false
|
||||
|
|
@ -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',
|
||||
|
@ -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(
|
||||
{
|
||||
|
@ -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 = () => {
|
||||
|
@ -35,7 +35,7 @@ const setup = () => {
|
||||
];
|
||||
|
||||
const grafanaContext = getGrafanaContextMock();
|
||||
grafanaContext.chrome.onToggleMegaMenu();
|
||||
grafanaContext.chrome.setMegaMenu('open');
|
||||
|
||||
return render(
|
||||
<TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}>
|
||||
|
@ -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),
|
||||
}),
|
||||
});
|
||||
|
@ -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,
|
||||
|
||||
|
@ -29,7 +29,7 @@ const setup = () => {
|
||||
];
|
||||
|
||||
const grafanaContext = getGrafanaContextMock();
|
||||
grafanaContext.chrome.onToggleMegaMenu();
|
||||
grafanaContext.chrome.setMegaMenu('open');
|
||||
|
||||
return render(
|
||||
<TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}>
|
||||
|
@ -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>
|
||||
|
@ -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),
|
||||
},
|
||||
}),
|
||||
|
@ -722,6 +722,11 @@
|
||||
"kiosk": {
|
||||
"tv-alert": "Drücke ESC, um den Kiosk-Modus zu verlassen"
|
||||
},
|
||||
"megamenu": {
|
||||
"close": "",
|
||||
"dock": "",
|
||||
"undock": ""
|
||||
},
|
||||
"toolbar": {
|
||||
"enable-kiosk": "Kiosk-Modus aktivieren",
|
||||
"toggle-menu": "Menü umschalten",
|
||||
|
@ -722,6 +722,11 @@
|
||||
"kiosk": {
|
||||
"tv-alert": "Press ESC to exit kiosk mode"
|
||||
},
|
||||
"megamenu": {
|
||||
"close": "Close menu",
|
||||
"dock": "Dock menu",
|
||||
"undock": "Undock menu"
|
||||
},
|
||||
"toolbar": {
|
||||
"enable-kiosk": "Enable kiosk mode",
|
||||
"toggle-menu": "Toggle menu",
|
||||
|
@ -728,6 +728,11 @@
|
||||
"kiosk": {
|
||||
"tv-alert": "Pulse ESC para salir del modo de quiosco"
|
||||
},
|
||||
"megamenu": {
|
||||
"close": "",
|
||||
"dock": "",
|
||||
"undock": ""
|
||||
},
|
||||
"toolbar": {
|
||||
"enable-kiosk": "Activar el modo de quiosco",
|
||||
"toggle-menu": "Activar o desactivar menú",
|
||||
|
@ -728,6 +728,11 @@
|
||||
"kiosk": {
|
||||
"tv-alert": "Appuyez sur ESC pour quitter le mode kiosque"
|
||||
},
|
||||
"megamenu": {
|
||||
"close": "",
|
||||
"dock": "",
|
||||
"undock": ""
|
||||
},
|
||||
"toolbar": {
|
||||
"enable-kiosk": "Activer le mode kiosque",
|
||||
"toggle-menu": "Afficher/Masquer le menu",
|
||||
|
@ -722,6 +722,11 @@
|
||||
"kiosk": {
|
||||
"tv-alert": "Přęşş ĒŜC ŧő ęχįŧ ĸįőşĸ mőđę"
|
||||
},
|
||||
"megamenu": {
|
||||
"close": "Cľőşę męʼnū",
|
||||
"dock": "Đőčĸ męʼnū",
|
||||
"undock": "Ůʼnđőčĸ męʼnū"
|
||||
},
|
||||
"toolbar": {
|
||||
"enable-kiosk": "Ēʼnäþľę ĸįőşĸ mőđę",
|
||||
"toggle-menu": "Ŧőģģľę męʼnū",
|
||||
|
@ -716,6 +716,11 @@
|
||||
"kiosk": {
|
||||
"tv-alert": "按 ESC 退出 kiosk 模式"
|
||||
},
|
||||
"megamenu": {
|
||||
"close": "",
|
||||
"dock": "",
|
||||
"undock": ""
|
||||
},
|
||||
"toolbar": {
|
||||
"enable-kiosk": "启用 kiosk 模式",
|
||||
"toggle-menu": "切换菜单",
|
||||
|
Loading…
Reference in New Issue
Block a user