DockedMegaMenu: Clean up toggle and old code (#81878)

* remove toggle

* remove code not behind toggle

* remove old MegaMenu

* rename DockedMegaMenu -> MegaMenu and clean up go code

* fix backend test

* run yarn i18n:extract

* fix some unit tests

* fix remaining unit tests

* fix remaining e2e/unit tests
pull/81969/head
Ashley Harrison 1 year ago committed by GitHub
parent 390461f9e1
commit 28b336ac80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .betterer.results
  2. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  3. 22
      e2e/various-suite/frontend-sandbox-app.spec.ts
  4. 2
      e2e/various-suite/graph-auto-migrate.spec.ts
  5. 6
      e2e/various-suite/navigation.spec.ts
  6. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  7. 8
      pkg/services/featuremgmt/registry.go
  8. 1
      pkg/services/featuremgmt/toggles_gen.csv
  9. 4
      pkg/services/featuremgmt/toggles_gen.go
  10. 41
      pkg/services/navtree/navtreeimpl/applinks.go
  11. 2
      pkg/services/navtree/navtreeimpl/applinks_test.go
  12. 34
      public/app/core/components/AppChrome/AppChrome.test.tsx
  13. 28
      public/app/core/components/AppChrome/AppChrome.tsx
  14. 2
      public/app/core/components/AppChrome/AppChromeMenu.tsx
  15. 18
      public/app/core/components/AppChrome/AppChromeService.tsx
  16. 82
      public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.test.tsx
  17. 132
      public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx
  18. 186
      public/app/core/components/AppChrome/DockedMegaMenu/utils.test.ts
  19. 142
      public/app/core/components/AppChrome/DockedMegaMenu/utils.ts
  20. 0
      public/app/core/components/AppChrome/MegaMenu/FeatureHighlight.tsx
  21. 28
      public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx
  22. 138
      public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx
  23. 0
      public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx
  24. 0
      public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx
  25. 38
      public/app/core/components/AppChrome/MegaMenu/NavBarItemIcon.tsx
  26. 231
      public/app/core/components/AppChrome/MegaMenu/NavBarMenu.tsx
  27. 139
      public/app/core/components/AppChrome/MegaMenu/NavBarMenuItem.tsx
  28. 105
      public/app/core/components/AppChrome/MegaMenu/NavBarMenuItemWrapper.tsx
  29. 117
      public/app/core/components/AppChrome/MegaMenu/NavBarMenuSection.tsx
  30. 34
      public/app/core/components/AppChrome/MegaMenu/NavFeatureHighlight.tsx
  31. 18
      public/app/core/components/AppChrome/MegaMenu/utils.ts
  32. 109
      public/app/core/components/AppChrome/SectionNav/SectionNav.tsx
  33. 25
      public/app/core/components/AppChrome/SectionNav/SectionNavItem.test.tsx
  34. 131
      public/app/core/components/AppChrome/SectionNav/SectionNavItem.tsx
  35. 68
      public/app/core/components/AppChrome/SectionNav/SectionNavToggle.tsx
  36. 10
      public/app/core/components/Breadcrumbs/utils.ts
  37. 41
      public/app/core/components/Page/Page.tsx
  38. 13
      public/app/core/navigation/GrafanaRouteLoading.tsx
  39. 5
      public/app/core/utils/navBarItem-translations.ts
  40. 3
      public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx
  41. 4
      public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx
  42. 9
      public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx
  43. 2
      public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx
  44. 3
      public/app/features/dashboard/components/DashboardSettings/JsonEditorSettings.tsx
  45. 4
      public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx
  46. 21
      public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx
  47. 3
      public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx
  48. 1
      public/app/features/serviceaccounts/ServiceAccountCreatePage.test.tsx
  49. 1
      public/app/features/teams/TeamPages.test.tsx
  50. 15
      public/app/features/variables/editor/VariableEditorContainer.tsx
  51. 3
      public/locales/en-US/grafana.json
  52. 3
      public/locales/pseudo-LOCALE/grafana.json

@ -1175,12 +1175,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
],
"public/app/core/components/AppChrome/SectionNav/SectionNavItem.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"]
],
"public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],

@ -105,7 +105,6 @@ 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 |
| `returnToPrevious` | Enables the return to previous context functionality |
| `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema |
| `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query |

@ -26,21 +26,14 @@ describe('Datasource sandbox', () => {
});
it('Loads the app page without the sandbox div wrapper', () => {
e2e.pages.Home.visit();
e2e.components.NavBar.Toggle.button().click();
e2e.components.NavToolbar.container().get('[aria-label="Expand section Apps"]').click();
e2e.components.NavMenu.item().contains('Sandbox app test plugin').click();
cy.visit(`/a/${APP_ID}`);
cy.wait(200); // wait to prevent false positives because cypress checks too fast
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('not.exist');
cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist');
});
it('Loads the app configuration without the sandbox div wrapper', () => {
e2e.pages.Home.visit();
e2e.components.NavBar.Toggle.button().click();
e2e.components.NavToolbar.container().get('[aria-label="Expand section Apps"]').click();
e2e.components.NavMenu.item().contains('Apps').click();
cy.get('a[aria-label="Tab Sandbox App Page"]').click();
cy.visit(`/plugins/${APP_ID}`);
cy.wait(200); // wait to prevent false positives because cypress checks too fast
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('not.exist');
cy.get('div[data-testid="sandbox-app-test-config-page"]').should('exist');
@ -55,20 +48,13 @@ describe('Datasource sandbox', () => {
});
it('Loads the app page with the sandbox div wrapper', () => {
e2e.pages.Home.visit();
e2e.components.NavBar.Toggle.button().click();
e2e.components.NavToolbar.container().get('[aria-label="Expand section Apps"]').click();
e2e.components.NavMenu.item().contains('Sandbox app test plugin').click();
cy.visit(`/a/${APP_ID}`);
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist');
cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist');
});
it('Loads the app configuration with the sandbox div wrapper', () => {
e2e.pages.Home.visit();
e2e.components.NavBar.Toggle.button().click();
e2e.components.NavToolbar.container().get('[aria-label="Expand section Apps"]').click();
e2e.components.NavMenu.item().contains('Apps').click();
cy.get('a[aria-label="Tab Sandbox App Page"]').click();
cy.visit(`/plugins/${APP_ID}`);
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist');
cy.get('div[data-testid="sandbox-app-test-config-page"]').should('exist');
});

@ -32,7 +32,7 @@ describe('Auto-migrate graph panel', () => {
e2e.pages.Dashboard.Annotations.marker().should('exist');
});
cy.get('body').children().find('.scrollbar-view').first().scrollTo('bottom');
cy.get('#pageContent .scrollbar-view').first().scrollTo('bottom');
e2e.components.Panels.Panel.title('05:00')
.should('exist')

@ -6,11 +6,7 @@ describe('Docked Navigation', () => {
cy.viewport(1280, 800);
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
cy.visit(fromBaseUrl('/'), {
onBeforeLoad(window) {
window.localStorage.setItem('grafana.featureToggles', 'dockedMegaMenu=1');
},
});
cy.visit(fromBaseUrl('/'));
});
it('should remain docked when reloading the page', () => {

@ -41,7 +41,6 @@ export interface FeatureToggles {
logRequestsInstrumentedAsUnknown?: boolean;
dataConnectionsConsole?: boolean;
topnav?: boolean;
dockedMegaMenu?: boolean;
returnToPrevious?: boolean;
grpcServer?: boolean;
unifiedStorage?: boolean;

@ -203,14 +203,6 @@ var (
Owner: grafanaFrontendPlatformSquad,
Created: time.Date(2022, time.June, 20, 12, 0, 0, 0, time.UTC),
},
{
Name: "dockedMegaMenu",
Description: "Enable support for a persistent (docked) navigation menu",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaFrontendPlatformSquad,
Created: time.Date(2023, time.September, 18, 12, 0, 0, 0, time.UTC),
},
{
Name: "returnToPrevious",
Description: "Enables the return to previous context functionality",

@ -22,7 +22,6 @@ disableSecretsCompatibility,experimental,@grafana/hosted-grafana-team,2022-07-13
logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,2022-06-10,false,false,false
dataConnectionsConsole,GA,@grafana/plugins-platform-backend,2022-06-01,false,false,false
topnav,deprecated,@grafana/grafana-frontend-platform,2022-06-20,false,false,false
dockedMegaMenu,experimental,@grafana/grafana-frontend-platform,2023-09-18,false,false,true
returnToPrevious,experimental,@grafana/grafana-frontend-platform,2024-01-09,false,false,true
grpcServer,preview,@grafana/grafana-app-platform-squad,2022-09-27,false,false,false
unifiedStorage,experimental,@grafana/grafana-app-platform-squad,2022-12-01,true,true,false

1 Name Stage Owner Created requiresDevMode RequiresRestart FrontendOnly
22 logRequestsInstrumentedAsUnknown experimental @grafana/hosted-grafana-team 2022-06-10 false false false
23 dataConnectionsConsole GA @grafana/plugins-platform-backend 2022-06-01 false false false
24 topnav deprecated @grafana/grafana-frontend-platform 2022-06-20 false false false
dockedMegaMenu experimental @grafana/grafana-frontend-platform 2023-09-18 false false true
25 returnToPrevious experimental @grafana/grafana-frontend-platform 2024-01-09 false false true
26 grpcServer preview @grafana/grafana-app-platform-squad 2022-09-27 false false false
27 unifiedStorage experimental @grafana/grafana-app-platform-squad 2022-12-01 true true false

@ -99,10 +99,6 @@ const (
// Enables topnav support in external plugins. The new Grafana navigation cannot be disabled.
FlagTopnav = "topnav"
// FlagDockedMegaMenu
// Enable support for a persistent (docked) navigation menu
FlagDockedMegaMenu = "dockedMegaMenu"
// FlagReturnToPrevious
// Enables the return to previous context functionality
FlagReturnToPrevious = "returnToPrevious"

@ -289,41 +289,12 @@ func (s *ServiceImpl) hasAccessToInclude(c *contextmodel.ReqContext, pluginID st
}
func (s *ServiceImpl) readNavigationSettings() {
k8sCfg := NavigationAppConfig{SectionID: navtree.NavIDMonitoring, SortWeight: 1, Text: "Kubernetes"}
appO11yCfg := NavigationAppConfig{SectionID: navtree.NavIDMonitoring, SortWeight: 2, Text: "Application"}
profilesCfg := NavigationAppConfig{SectionID: navtree.NavIDMonitoring, SortWeight: 3, Text: "Profiles"}
frontendCfg := NavigationAppConfig{SectionID: navtree.NavIDMonitoring, SortWeight: 4, Text: "Frontend"}
k6Cfg := NavigationAppConfig{SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightAlertsAndIncidents + 1, Text: "Performance testing", Icon: "k6"}
syntheticsCfg := NavigationAppConfig{SectionID: navtree.NavIDMonitoring, SortWeight: 5, Text: "Synthetics"}
if s.features.IsEnabledGlobally(featuremgmt.FlagDockedMegaMenu) {
k8sCfg.SectionID = navtree.NavIDInfrastructure
appO11yCfg.SectionID = navtree.NavIDRoot
appO11yCfg.SortWeight = navtree.WeightApplication
appO11yCfg.Icon = "graph-bar"
profilesCfg.SectionID = navtree.NavIDExplore
profilesCfg.SortWeight = 1
frontendCfg.SectionID = navtree.NavIDRoot
frontendCfg.SortWeight = navtree.WeightFrontend
frontendCfg.Icon = "frontend-observability"
k6Cfg.SectionID = navtree.NavIDTestingAndSynthetics
k6Cfg.SortWeight = 1
k6Cfg.Text = "Performance"
syntheticsCfg.SectionID = navtree.NavIDTestingAndSynthetics
syntheticsCfg.SortWeight = 2
}
s.navigationAppConfig = map[string]NavigationAppConfig{
"grafana-k8s-app": k8sCfg,
"grafana-app-observability-app": appO11yCfg,
"grafana-pyroscope-app": profilesCfg,
"grafana-kowalski-app": frontendCfg,
"grafana-synthetic-monitoring-app": syntheticsCfg,
"grafana-k8s-app": {SectionID: navtree.NavIDInfrastructure, SortWeight: 1, Text: "Kubernetes"},
"grafana-app-observability-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightApplication, Text: "Application", Icon: "graph-bar"},
"grafana-pyroscope-app": {SectionID: navtree.NavIDExplore, SortWeight: 1, Text: "Profiles"},
"grafana-kowalski-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightFrontend, Text: "Frontend", Icon: "frontend-observability"},
"grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDTestingAndSynthetics, SortWeight: 2, Text: "Synthetics"},
"grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1, Text: "OnCall"},
"grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incidents"},
"grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "Machine Learning"},
@ -332,7 +303,7 @@ func (s *ServiceImpl) readNavigationSettings() {
"grafana-adaptive-metrics-app": {SectionID: navtree.NavIDCfg, Text: "Adaptive Metrics"},
"grafana-logvolumeexplorer-app": {SectionID: navtree.NavIDCfg, Text: "Log Volume Explorer"},
"grafana-easystart-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightApps + 1, Text: "Connections", Icon: "adjust-circle"},
"k6-app": k6Cfg,
"k6-app": {SectionID: navtree.NavIDTestingAndSynthetics, SortWeight: 1, Text: "Performance"},
"grafana-asserts-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightAsserts, Icon: "asserts"},
}

@ -367,7 +367,7 @@ func TestReadingNavigationSettings(t *testing.T) {
_, _ = service.cfg.Raw.NewSection("navigation.app_sections")
service.readNavigationSettings()
require.Equal(t, "monitoring", service.navigationAppConfig["grafana-k8s-app"].SectionID)
require.Equal(t, "infrastructure", service.navigationAppConfig["grafana-k8s-app"].SectionID)
})
t.Run("Can add additional overrides via ini system", func(t *testing.T) {

@ -5,7 +5,7 @@ import React, { ReactNode } from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { DataFrame, DataFrameView, FieldType, NavModelItem } from '@grafana/data';
import { DataFrame, DataFrameView, FieldType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { HOME_NAV_ID } from 'app/core/reducers/navModel';
import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from 'app/features/search/service';
@ -19,14 +19,6 @@ jest.mock('@grafana/runtime', () => ({
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
}));
const pageNav: NavModelItem = {
text: 'pageNav title',
children: [
{ text: 'pageNav child1', url: '1', active: true },
{ text: 'pageNav child2', url: '2' },
],
};
const searchData: DataFrame = {
fields: [
{ name: 'kind', type: FieldType.string, config: {}, values: [] },
@ -92,30 +84,6 @@ describe('AppChrome', () => {
jest.clearAllMocks();
});
it('should render section nav model based on navId', async () => {
setup(<Page navId="child1">Children</Page>);
expect(await screen.findByTestId('page-children')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tab Section name' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tab Child1' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tab Child1' })).toBeInTheDocument();
expect(screen.getAllByRole('tab').length).toBe(3);
});
it('should render section nav model based on navId and item page nav', async () => {
setup(
<Page navId="child1" pageNav={pageNav}>
Children
</Page>
);
expect(await screen.findByTestId('page-children')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tab Section name' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'pageNav title' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tab Child1' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tab pageNav child1' })).toBeInTheDocument();
});
it('should create a skip link to skip to main content', async () => {
setup(<Page navId="child1">Children</Page>);
expect(await screen.findByRole('link', { name: 'Skip to main content' })).toBeInTheDocument();

@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css';
import classNames from 'classnames';
import React, { PropsWithChildren, useEffect } from 'react';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui';
import config from 'app/core/config';
@ -14,11 +14,9 @@ import { KioskMode } from 'app/types';
import { AppChromeMenu } from './AppChromeMenu';
import { DOCKED_LOCAL_STORAGE_KEY, DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY } from './AppChromeService';
import { MegaMenu as DockedMegaMenu } from './DockedMegaMenu/MegaMenu';
import { MegaMenu } from './MegaMenu/MegaMenu';
import { NavToolbar } from './NavToolbar/NavToolbar';
import { ReturnToPrevious } from './ReturnToPrevious/ReturnToPrevious';
import { SectionNav } from './SectionNav/SectionNav';
import { TopSearchBar } from './TopBar/TopSearchBar';
import { TOP_BAR_LEVEL_HEIGHT } from './types';
@ -36,7 +34,7 @@ export function AppChrome({ children }: Props) {
useMediaQueryChange({
breakpoint: dockedMenuBreakpoint,
onChange: (e) => {
if (config.featureToggles.dockedMegaMenu && dockedMenuLocalStorageState) {
if (dockedMenuLocalStorageState) {
chrome.setMegaMenuDocked(e.matches, false);
chrome.setMegaMenuOpen(
e.matches ? store.getBool(DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, state.megaMenuOpen) : false
@ -99,26 +97,15 @@ export function AppChrome({ children }: Props) {
)}
<main className={contentClass}>
<div className={styles.panes}>
{state.layout === PageLayoutType.Standard && state.sectionNav && !config.featureToggles.dockedMegaMenu && (
<SectionNav model={state.sectionNav} />
)}
{config.featureToggles.dockedMegaMenu && !state.chromeless && state.megaMenuDocked && state.megaMenuOpen && (
<DockedMegaMenu className={styles.dockedMegaMenu} onClose={() => chrome.setMegaMenuOpen(false)} />
{!state.chromeless && state.megaMenuDocked && state.megaMenuOpen && (
<MegaMenu className={styles.dockedMegaMenu} onClose={() => chrome.setMegaMenuOpen(false)} />
)}
<div className={styles.pageContainer} id="pageContent">
{children}
</div>
</div>
</main>
{!state.chromeless && !state.megaMenuDocked && (
<>
{config.featureToggles.dockedMegaMenu ? (
<AppChromeMenu />
) : (
<MegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenuOpen(false)} />
)}
</>
)}
{!state.chromeless && !state.megaMenuDocked && <AppChromeMenu />}
{!state.chromeless && <CommandPalette />}
{shouldShowReturnToPrevious && state.returnToPrevious && (
<ReturnToPrevious href={state.returnToPrevious.href} title={state.returnToPrevious.title} />
@ -128,10 +115,6 @@ export function AppChrome({ children }: Props) {
}
const getStyles = (theme: GrafanaTheme2) => {
const shadow = theme.isDark
? `0 0.6px 1.5px rgb(0 0 0), 0 2px 4px rgb(0 0 0 / 40%), 0 5px 10px rgb(0 0 0 / 23%)`
: '0 4px 8px rgb(0 0 0 / 4%)';
return {
content: css({
display: 'flex',
@ -162,7 +145,6 @@ const getStyles = (theme: GrafanaTheme2) => {
zIndex: theme.zIndex.navbarFixed,
left: 0,
right: 0,
boxShadow: config.featureToggles.dockedMegaMenu ? undefined : shadow,
background: theme.colors.background.primary,
flexDirection: 'column',
}),

@ -10,7 +10,7 @@ import { useStyles2, useTheme2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { KioskMode } from 'app/types';
import { MegaMenu, MENU_WIDTH } from './DockedMegaMenu/MegaMenu';
import { MegaMenu, MENU_WIDTH } from './MegaMenu/MegaMenu';
import { TOGGLE_BUTTON_ID } from './NavToolbar/NavToolbar';
import { TOP_BAR_LEVEL_HEIGHT } from './types';

@ -38,12 +38,8 @@ export class AppChromeService {
private routeChangeHandled = true;
private megaMenuDocked = Boolean(
config.featureToggles.dockedMegaMenu &&
window.innerWidth >= config.theme2.breakpoints.values.xl &&
store.getBool(
DOCKED_LOCAL_STORAGE_KEY,
Boolean(config.featureToggles.dockedMegaMenu && window.innerWidth >= config.theme2.breakpoints.values.xxl)
)
window.innerWidth >= config.theme2.breakpoints.values.xl &&
store.getBool(DOCKED_LOCAL_STORAGE_KEY, Boolean(window.innerWidth >= config.theme2.breakpoints.values.xxl))
);
private sessionStorageData = window.sessionStorage.getItem('returnToPrevious');
@ -130,14 +126,10 @@ export class AppChromeService {
public setMegaMenuOpen = (newOpenState: boolean) => {
const { megaMenuDocked } = this.state.getValue();
if (config.featureToggles.dockedMegaMenu) {
if (megaMenuDocked) {
store.set(DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, newOpenState);
}
reportInteraction('grafana_mega_menu_open', { state: newOpenState });
} else {
reportInteraction('grafana_toggle_menu_clicked', { action: newOpenState ? 'open' : 'close' });
if (megaMenuDocked) {
store.set(DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, newOpenState);
}
reportInteraction('grafana_mega_menu_open', { state: newOpenState });
this.update({
megaMenuOpen: newOpenState,
});

@ -1,82 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Router } from 'react-router-dom';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { NavModelItem } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { locationService } from '@grafana/runtime';
import { TestProvider } from '../../../../../test/helpers/TestProvider';
import { MegaMenu } from './MegaMenu';
const setup = () => {
const navBarTree: NavModelItem[] = [
{
text: 'Section name',
id: 'section',
url: 'section',
children: [
{
text: 'Child1',
id: 'child1',
url: 'section/child1',
children: [{ text: 'Grandchild1', id: 'grandchild1', url: 'section/child1/grandchild1' }],
},
{ text: 'Child2', id: 'child2', url: 'section/child2' },
],
},
{
text: 'Profile',
id: 'profile',
url: 'profile',
},
];
const grafanaContext = getGrafanaContextMock();
grafanaContext.chrome.setMegaMenuOpen(true);
return render(
<TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}>
<Router history={locationService.getHistory()}>
<MegaMenu onClose={() => {}} />
</Router>
</TestProvider>
);
};
describe('MegaMenu', () => {
afterEach(() => {
window.localStorage.clear();
});
it('should render component', async () => {
setup();
expect(await screen.findByTestId(selectors.components.NavMenu.Menu)).toBeInTheDocument();
expect(await screen.findByRole('link', { name: 'Section name' })).toBeInTheDocument();
});
it('should render children', async () => {
setup();
await userEvent.click(await screen.findByRole('button', { name: 'Expand section Section name' }));
expect(await screen.findByRole('link', { name: 'Child1' })).toBeInTheDocument();
expect(await screen.findByRole('link', { name: 'Child2' })).toBeInTheDocument();
});
it('should render grandchildren', async () => {
setup();
await userEvent.click(await screen.findByRole('button', { name: 'Expand section Section name' }));
expect(await screen.findByRole('link', { name: 'Child1' })).toBeInTheDocument();
await userEvent.click(await screen.findByRole('button', { name: 'Expand section Child1' }));
expect(await screen.findByRole('link', { name: 'Grandchild1' })).toBeInTheDocument();
expect(await screen.findByRole('link', { name: 'Child2' })).toBeInTheDocument();
});
it('should filter out profile', async () => {
setup();
expect(screen.queryByLabelText('Profile')).not.toBeInTheDocument();
});
});

@ -1,132 +0,0 @@
import { css } from '@emotion/css';
import { DOMAttributes } from '@react-types/shared';
import React, { forwardRef } from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { CustomScrollbar, Icon, IconButton, useStyles2, Stack } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { t } from 'app/core/internationalization';
import { useSelector } from 'app/types';
import { MegaMenuItem } from './MegaMenuItem';
import { enrichWithInteractionTracking, getActiveItem } from './utils';
export const MENU_WIDTH = '300px';
export interface Props extends DOMAttributes {
onClose: () => void;
}
export const MegaMenu = React.memo(
forwardRef<HTMLDivElement, Props>(({ onClose, ...restProps }, ref) => {
const navTree = useSelector((state) => state.navBarTree);
const styles = useStyles2(getStyles);
const location = useLocation();
const { chrome } = useGrafana();
const state = chrome.useState();
// Remove profile + help from tree
const navItems = navTree
.filter((item) => item.id !== 'profile' && item.id !== 'help')
.map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked));
const activeItem = getActiveItem(navItems, location.pathname);
const handleDockedMenu = () => {
chrome.setMegaMenuDocked(!state.megaMenuDocked);
if (state.megaMenuDocked) {
chrome.setMegaMenuOpen(false);
}
// refocus on undock/menu open button when changing state
setTimeout(() => {
document.getElementById(state.megaMenuDocked ? 'mega-menu-toggle' : 'dock-menu-button')?.focus();
});
};
return (
<div data-testid={selectors.components.NavMenu.Menu} ref={ref} {...restProps}>
<div className={styles.mobileHeader}>
<Icon name="bars" size="xl" />
<IconButton
tooltip={t('navigation.megamenu.close', 'Close menu')}
name="times"
onClick={onClose}
size="xl"
variant="secondary"
/>
</div>
<nav className={styles.content}>
<CustomScrollbar showScrollIndicators hideHorizontalTrack>
<ul className={styles.itemList} aria-label={t('navigation.megamenu.list-label', 'Navigation')}>
{navItems.map((link, index) => (
<Stack key={link.text} direction={index === 0 ? 'row-reverse' : 'row'} alignItems="center">
{index === 0 && (
<IconButton
id="dock-menu-button"
className={styles.dockMenuButton}
tooltip={
state.megaMenuDocked
? t('navigation.megamenu.undock', 'Undock menu')
: t('navigation.megamenu.dock', 'Dock menu')
}
name="web-section-alt"
onClick={handleDockedMenu}
variant="secondary"
/>
)}
<MegaMenuItem
link={link}
onClick={state.megaMenuDocked ? undefined : onClose}
activeItem={activeItem}
/>
</Stack>
))}
</ul>
</CustomScrollbar>
</nav>
</div>
);
})
);
MegaMenu.displayName = 'MegaMenu';
const getStyles = (theme: GrafanaTheme2) => ({
content: css({
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
position: 'relative',
}),
mobileHeader: css({
display: 'flex',
justifyContent: 'space-between',
padding: theme.spacing(1, 1, 1, 2),
borderBottom: `1px solid ${theme.colors.border.weak}`,
[theme.breakpoints.up('md')]: {
display: 'none',
},
}),
itemList: css({
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
listStyleType: 'none',
padding: theme.spacing(1),
[theme.breakpoints.up('md')]: {
width: MENU_WIDTH,
},
}),
dockMenuButton: css({
display: 'none',
[theme.breakpoints.up('xl')]: {
display: 'inline-flex',
},
}),
});

@ -1,186 +0,0 @@
import { GrafanaConfig, locationUtil, NavModelItem } from '@grafana/data';
import { ContextSrv, setContextSrv } from 'app/core/services/context_srv';
import { enrichHelpItem, getActiveItem, isMatchOrChildMatch } from './utils';
jest.mock('../../../app_events', () => ({
publish: jest.fn(),
}));
describe('enrichConfigItems', () => {
let mockHelpNode: NavModelItem;
beforeEach(() => {
mockHelpNode = {
id: 'help',
text: 'Help',
};
});
it('enhances the help node with extra child links', () => {
const contextSrv = new ContextSrv();
setContextSrv(contextSrv);
const helpNode = enrichHelpItem(mockHelpNode);
expect(helpNode!.children).toContainEqual(
expect.objectContaining({
text: 'Documentation',
})
);
expect(helpNode!.children).toContainEqual(
expect.objectContaining({
text: 'Support',
})
);
expect(helpNode!.children).toContainEqual(
expect.objectContaining({
text: 'Community',
})
);
expect(helpNode!.children).toContainEqual(
expect.objectContaining({
text: 'Keyboard shortcuts',
})
);
});
});
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 itemToCheck has a child that matches the searchItem', () => {
const searchItem = mockChild;
expect(isMatchOrChildMatch(mockItemToCheck, searchItem)).toBe(true);
});
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 after subpath',
url: '/subUrl/itemAfterSubpath',
},
{
text: 'Item with children',
url: '/itemWithChildren',
children: [
{
text: 'Child',
url: '/child',
},
],
},
{
text: 'Alerting item',
url: '/alerting/list',
},
{
text: 'Base',
url: '/',
},
{
text: 'Starred',
url: '/dashboards?starred',
id: 'starred',
},
{
text: 'Dashboards',
url: '/dashboards',
},
{
text: 'More specific dashboard',
url: '/d/moreSpecificDashboard',
},
];
beforeEach(() => {
locationUtil.initialize({
config: { appSubUrl: '/subUrl' } as GrafanaConfig,
getVariablesUrlParams: () => ({}),
getTimeRangeForUrl: () => ({ from: 'now-7d', to: 'now' }),
});
});
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 root subpath', () => {
const mockPathName = '/itemAfterSubpath';
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
text: 'Item after subpath',
url: '/subUrl/itemAfterSubpath',
});
});
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',
});
});
it('returns the dashboards route link if the pathname starts with /d/', () => {
const mockPathName = '/d/foo';
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
text: 'Dashboards',
url: '/dashboards',
});
});
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',
});
});
});

@ -1,142 +0,0 @@
import { locationUtil, NavModelItem } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { t } from 'app/core/internationalization';
import { ShowModalReactEvent } from '../../../../types/events';
import appEvents from '../../../app_events';
import { getFooterLinks } from '../../Footer/Footer';
import { HelpModal } from '../../help/HelpModal';
export const enrichHelpItem = (helpItem: NavModelItem) => {
let menuItems = helpItem.children || [];
if (helpItem.id === 'help') {
const onOpenShortcuts = () => {
appEvents.publish(new ShowModalReactEvent({ component: HelpModal }));
};
helpItem.children = [
...menuItems,
...getFooterLinks(),
...getEditionAndUpdateLinks(),
{
id: 'keyboard-shortcuts',
text: t('nav.help/keyboard-shortcuts', 'Keyboard shortcuts'),
icon: 'keyboard',
onClick: onOpenShortcuts,
},
];
}
return helpItem;
};
export const enrichWithInteractionTracking = (item: NavModelItem, megaMenuDockedState: boolean) => {
// creating a new object here to not mutate the original item object
const newItem = { ...item };
const onClick = newItem.onClick;
newItem.onClick = () => {
reportInteraction('grafana_navigation_item_clicked', {
path: newItem.url ?? newItem.id,
menuIsDocked: megaMenuDockedState,
});
onClick?.();
};
if (newItem.children) {
newItem.children = newItem.children.map((item) => enrichWithInteractionTracking(item, megaMenuDockedState));
}
return newItem;
};
export const isMatchOrChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem) => {
return Boolean(itemToCheck === searchItem || hasChildMatch(itemToCheck, searchItem));
};
export const hasChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem): boolean => {
return Boolean(
itemToCheck.children?.some((child) => {
if (child === searchItem) {
return true;
} else {
return hasChildMatch(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 dashboardLinkMatch = '/dashboards';
for (const link of navTree) {
const linkWithoutParams = stripQueryParams(link.url);
const linkPathname = locationUtil.stripBaseFromUrl(linkWithoutParams);
if (linkPathname && link.id !== 'starred') {
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;
}
}
return currentBestMatch;
};
export function getEditionAndUpdateLinks(): NavModelItem[] {
const { buildInfo, licenseInfo } = config;
const stateInfo = licenseInfo.stateInfo ? ` (${licenseInfo.stateInfo})` : '';
const links: NavModelItem[] = [];
links.push({
target: '_blank',
id: 'version',
text: `${buildInfo.edition}${stateInfo}`,
url: licenseInfo.licenseUrl,
icon: 'external-link-alt',
});
if (buildInfo.hasUpdate) {
links.push({
target: '_blank',
id: 'updateVersion',
text: `New version available!`,
icon: 'download-alt',
url: 'https://grafana.com/grafana/download?utm_source=grafana_footer',
});
}
return links;
}

@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Router } from 'react-router-dom';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
@ -18,7 +19,12 @@ const setup = () => {
id: 'section',
url: 'section',
children: [
{ text: 'Child1', id: 'child1', url: 'section/child1' },
{
text: 'Child1',
id: 'child1',
url: 'section/child1',
children: [{ text: 'Grandchild1', id: 'grandchild1', url: 'section/child1/grandchild1' }],
},
{ text: 'Child2', id: 'child2', url: 'section/child2' },
],
},
@ -42,6 +48,9 @@ const setup = () => {
};
describe('MegaMenu', () => {
afterEach(() => {
window.localStorage.clear();
});
it('should render component', async () => {
setup();
@ -49,10 +58,25 @@ describe('MegaMenu', () => {
expect(await screen.findByRole('link', { name: 'Section name' })).toBeInTheDocument();
});
it('should render children', async () => {
setup();
await userEvent.click(await screen.findByRole('button', { name: 'Expand section Section name' }));
expect(await screen.findByRole('link', { name: 'Child1' })).toBeInTheDocument();
expect(await screen.findByRole('link', { name: 'Child2' })).toBeInTheDocument();
});
it('should render grandchildren', async () => {
setup();
await userEvent.click(await screen.findByRole('button', { name: 'Expand section Section name' }));
expect(await screen.findByRole('link', { name: 'Child1' })).toBeInTheDocument();
await userEvent.click(await screen.findByRole('button', { name: 'Expand section Child1' }));
expect(await screen.findByRole('link', { name: 'Grandchild1' })).toBeInTheDocument();
expect(await screen.findByRole('link', { name: 'Child2' })).toBeInTheDocument();
});
it('should filter out profile', async () => {
setup();
expect(await screen.findByTestId(selectors.components.NavMenu.Menu)).toBeInTheDocument();
expect(screen.queryByLabelText('Profile')).not.toBeInTheDocument();
});
});

@ -1,50 +1,132 @@
import { css } from '@emotion/css';
import { cloneDeep } from 'lodash';
import React from 'react';
import { DOMAttributes } from '@react-types/shared';
import React, { forwardRef } from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { CustomScrollbar, Icon, IconButton, useStyles2, Stack } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { t } from 'app/core/internationalization';
import { useSelector } from 'app/types';
import { NavBarMenu } from './NavBarMenu';
import { MegaMenuItem } from './MegaMenuItem';
import { enrichWithInteractionTracking, getActiveItem } from './utils';
export interface Props {
export const MENU_WIDTH = '300px';
export interface Props extends DOMAttributes {
onClose: () => void;
searchBarHidden?: boolean;
}
export const MegaMenu = React.memo<Props>(({ onClose, searchBarHidden }) => {
const navBarTree = useSelector((state) => state.navBarTree);
const theme = useTheme2();
const styles = getStyles(theme);
const location = useLocation();
export const MegaMenu = React.memo(
forwardRef<HTMLDivElement, Props>(({ onClose, ...restProps }, ref) => {
const navTree = useSelector((state) => state.navBarTree);
const styles = useStyles2(getStyles);
const location = useLocation();
const { chrome } = useGrafana();
const state = chrome.useState();
const navTree = cloneDeep(navBarTree);
// Remove profile + help from tree
const navItems = navTree
.filter((item) => item.id !== 'profile' && item.id !== 'help')
.map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked));
// Remove profile + help from tree
const navItems = navTree
.filter((item) => item.id !== 'profile' && item.id !== 'help')
.map((item) => enrichWithInteractionTracking(item, true));
const activeItem = getActiveItem(navItems, location.pathname);
const activeItem = getActiveItem(navItems, location.pathname);
const handleDockedMenu = () => {
chrome.setMegaMenuDocked(!state.megaMenuDocked);
if (state.megaMenuDocked) {
chrome.setMegaMenuOpen(false);
}
return (
<div className={styles.menuWrapper}>
<NavBarMenu activeItem={activeItem} navItems={navItems} onClose={onClose} searchBarHidden={searchBarHidden} />
</div>
);
});
// refocus on undock/menu open button when changing state
setTimeout(() => {
document.getElementById(state.megaMenuDocked ? 'mega-menu-toggle' : 'dock-menu-button')?.focus();
});
};
return (
<div data-testid={selectors.components.NavMenu.Menu} ref={ref} {...restProps}>
<div className={styles.mobileHeader}>
<Icon name="bars" size="xl" />
<IconButton
tooltip={t('navigation.megamenu.close', 'Close menu')}
name="times"
onClick={onClose}
size="xl"
variant="secondary"
/>
</div>
<nav className={styles.content}>
<CustomScrollbar showScrollIndicators hideHorizontalTrack>
<ul className={styles.itemList} aria-label={t('navigation.megamenu.list-label', 'Navigation')}>
{navItems.map((link, index) => (
<Stack key={link.text} direction={index === 0 ? 'row-reverse' : 'row'} alignItems="center">
{index === 0 && (
<IconButton
id="dock-menu-button"
className={styles.dockMenuButton}
tooltip={
state.megaMenuDocked
? t('navigation.megamenu.undock', 'Undock menu')
: t('navigation.megamenu.dock', 'Dock menu')
}
name="web-section-alt"
onClick={handleDockedMenu}
variant="secondary"
/>
)}
<MegaMenuItem
link={link}
onClick={state.megaMenuDocked ? undefined : onClose}
activeItem={activeItem}
/>
</Stack>
))}
</ul>
</CustomScrollbar>
</nav>
</div>
);
})
);
MegaMenu.displayName = 'MegaMenu';
const getStyles = (theme: GrafanaTheme2) => ({
menuWrapper: css({
position: 'fixed',
display: 'grid',
gridAutoFlow: 'column',
content: css({
display: 'flex',
flexDirection: 'column',
height: '100%',
zIndex: theme.zIndex.sidemenu,
minHeight: 0,
position: 'relative',
}),
mobileHeader: css({
display: 'flex',
justifyContent: 'space-between',
padding: theme.spacing(1, 1, 1, 2),
borderBottom: `1px solid ${theme.colors.border.weak}`,
[theme.breakpoints.up('md')]: {
display: 'none',
},
}),
itemList: css({
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
listStyleType: 'none',
padding: theme.spacing(1),
[theme.breakpoints.up('md')]: {
width: MENU_WIDTH,
},
}),
dockMenuButton: css({
display: 'none',
[theme.breakpoints.up('xl')]: {
display: 'inline-flex',
},
}),
});

@ -1,38 +0,0 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Icon, toIconName, useTheme2 } from '@grafana/ui';
import { Branding } from '../../Branding/Branding';
interface NavBarItemIconProps {
link: NavModelItem;
}
export function NavBarItemIcon({ link }: NavBarItemIconProps) {
const theme = useTheme2();
const styles = getStyles(theme);
if (link.icon === 'grafana') {
return <Branding.MenuLogo className={styles.img} />;
} else if (link.icon) {
const iconName = toIconName(link.icon);
return <Icon name={iconName ?? 'link'} size="xl" />;
} else {
// consumer of NavBarItemIcon gives enclosing element an appropriate label
return <img className={cx(styles.img, link.roundIcon && styles.round)} src={link.img} alt="" />;
}
}
function getStyles(theme: GrafanaTheme2) {
return {
img: css({
height: theme.spacing(3),
width: theme.spacing(3),
}),
round: css({
borderRadius: theme.shape.radius.circle,
}),
};
}

@ -1,231 +0,0 @@
import { css } from '@emotion/css';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { OverlayContainer, useOverlay } from '@react-aria/overlays';
import React, { useEffect, useRef, useState } from 'react';
import CSSTransition from 'react-transition-group/CSSTransition';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { TOP_BAR_LEVEL_HEIGHT } from '../types';
import { NavBarMenuItemWrapper } from './NavBarMenuItemWrapper';
const MENU_WIDTH = '350px';
export interface Props {
activeItem?: NavModelItem;
navItems: NavModelItem[];
searchBarHidden?: boolean;
onClose: () => void;
}
export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: Props) {
const theme = useTheme2();
const styles = getStyles(theme, searchBarHidden);
const animationSpeed = theme.transitions.duration.shortest;
const animStyles = getAnimStyles(theme, animationSpeed);
const { chrome } = useGrafana();
const state = chrome.useState();
const ref = useRef(null);
const backdropRef = useRef(null);
const { dialogProps } = useDialog({}, ref);
const [isOpen, setIsOpen] = useState(false);
const onMenuClose = () => setIsOpen(false);
const { overlayProps, underlayProps } = useOverlay(
{
isDismissable: true,
isOpen: true,
onClose: onMenuClose,
},
ref
);
useEffect(() => {
if (state.megaMenuOpen) {
setIsOpen(true);
}
}, [state.megaMenuOpen]);
return (
<OverlayContainer>
<CSSTransition
nodeRef={ref}
in={isOpen}
unmountOnExit={true}
classNames={animStyles.overlay}
timeout={{ enter: animationSpeed, exit: 0 }}
onExited={onClose}
>
<FocusScope contain autoFocus>
<div
data-testid={selectors.components.NavMenu.Menu}
ref={ref}
{...overlayProps}
{...dialogProps}
className={styles.container}
>
<div className={styles.mobileHeader}>
<Icon name="bars" size="xl" />
<IconButton
aria-label="Close navigation menu"
tooltip="Close menu"
name="times"
onClick={onMenuClose}
size="xl"
variant="secondary"
/>
</div>
<nav className={styles.content}>
<CustomScrollbar showScrollIndicators hideHorizontalTrack>
<ul className={styles.itemList}>
{navItems.map((link) => (
<NavBarMenuItemWrapper link={link} onClose={onMenuClose} activeItem={activeItem} key={link.text} />
))}
</ul>
</CustomScrollbar>
</nav>
</div>
</FocusScope>
</CSSTransition>
<CSSTransition
nodeRef={backdropRef}
in={isOpen}
unmountOnExit={true}
classNames={animStyles.backdrop}
timeout={{ enter: animationSpeed, exit: 0 }}
>
<div ref={backdropRef} className={styles.backdrop} {...underlayProps} />
</CSSTransition>
</OverlayContainer>
);
}
NavBarMenu.displayName = 'NavBarMenu';
const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => {
const topPosition = (searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2) + 1;
return {
backdrop: css({
backdropFilter: 'blur(1px)',
backgroundColor: theme.components.overlay.background,
bottom: 0,
left: 0,
position: 'fixed',
right: 0,
top: searchBarHidden ? 0 : TOP_BAR_LEVEL_HEIGHT,
zIndex: theme.zIndex.modalBackdrop,
[theme.breakpoints.up('md')]: {
top: topPosition,
},
}),
container: css({
display: 'flex',
bottom: 0,
flexDirection: 'column',
left: 0,
marginRight: theme.spacing(1.5),
right: 0,
// Needs to below navbar should we change the navbarFixed? add add a new level?
zIndex: theme.zIndex.modal,
position: 'fixed',
top: searchBarHidden ? 0 : TOP_BAR_LEVEL_HEIGHT,
backgroundColor: theme.colors.background.primary,
boxSizing: 'content-box',
flex: '1 1 0',
[theme.breakpoints.up('md')]: {
borderRight: `1px solid ${theme.colors.border.weak}`,
right: 'unset',
top: topPosition,
},
}),
content: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
}),
mobileHeader: css({
display: 'flex',
justifyContent: 'space-between',
padding: theme.spacing(1, 1, 1, 2),
borderBottom: `1px solid ${theme.colors.border.weak}`,
[theme.breakpoints.up('md')]: {
display: 'none',
},
}),
itemList: css({
display: 'grid',
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`,
gridTemplateColumns: `minmax(${MENU_WIDTH}, auto)`,
minWidth: MENU_WIDTH,
}),
};
};
const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => {
const commonTransition = {
transitionDuration: `${animationDuration}ms`,
transitionTimingFunction: theme.transitions.easing.easeInOut,
[theme.breakpoints.down('md')]: {
overflow: 'hidden',
},
};
const overlayTransition = {
...commonTransition,
transitionProperty: 'box-shadow, width',
// this is needed to prevent a horizontal scrollbar during the animation on firefox
'.scrollbar-view': {
overflow: 'hidden !important',
},
};
const backdropTransition = {
...commonTransition,
transitionProperty: 'opacity',
};
const overlayOpen = {
width: '100%',
[theme.breakpoints.up('md')]: {
boxShadow: theme.shadows.z3,
width: MENU_WIDTH,
},
};
const overlayClosed = {
boxShadow: 'none',
width: 0,
};
const backdropOpen = {
opacity: 1,
};
const backdropClosed = {
opacity: 0,
};
return {
backdrop: {
enter: css(backdropClosed),
enterActive: css(backdropTransition, backdropOpen),
enterDone: css(backdropOpen),
},
overlay: {
enter: css(overlayClosed),
enterActive: css(overlayTransition, overlayOpen),
enterDone: css(overlayOpen),
},
};
};

@ -1,139 +0,0 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
export interface Props {
children: React.ReactNode;
icon?: IconName;
isActive?: boolean;
isChild?: boolean;
onClick?: () => void;
target?: HTMLAnchorElement['target'];
url?: string;
}
export function NavBarMenuItem({ children, icon, isActive, isChild, onClick, target, url }: Props) {
const theme = useTheme2();
const styles = getStyles(theme, isActive, isChild);
const linkContent = (
<div className={styles.linkContent}>
{icon && <Icon data-testid="dropdown-child-icon" name={icon} />}
<div className={styles.linkText}>{children}</div>
{target === '_blank' && (
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} />
)}
</div>
);
let element = (
<button
data-testid={selectors.components.NavMenu.item}
className={cx(styles.button, styles.element)}
onClick={onClick}
>
{linkContent}
</button>
);
if (url) {
element =
!target && url.startsWith('/') ? (
<Link
data-testid={selectors.components.NavMenu.item}
className={styles.element}
href={url}
target={target}
onClick={onClick}
>
{linkContent}
</Link>
) : (
<a
data-testid={selectors.components.NavMenu.item}
href={url}
target={target}
className={styles.element}
onClick={onClick}
>
{linkContent}
</a>
);
}
return <li className={styles.listItem}>{element}</li>;
}
NavBarMenuItem.displayName = 'NavBarMenuItem';
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], isChild: Props['isActive']) => ({
button: css({
backgroundColor: 'unset',
borderStyle: 'unset',
}),
linkContent: css({
alignItems: 'center',
display: 'flex',
gap: '0.5rem',
height: '100%',
width: '100%',
}),
linkText: css({
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
}),
externalLinkIcon: css({
color: theme.colors.text.secondary,
}),
element: css({
alignItems: 'center',
boxSizing: 'border-box',
position: 'relative',
color: isActive ? theme.colors.text.primary : theme.colors.text.secondary,
padding: theme.spacing(1, 1, 1, isChild ? 5 : 0),
...(isChild && {
borderRadius: theme.shape.radius.default,
}),
width: '100%',
'&:hover, &:focus-visible': {
...(isChild && {
background: theme.colors.emphasize(theme.colors.background.primary, 0.03),
}),
textDecoration: 'underline',
color: theme.colors.text.primary,
},
'&:focus-visible': {
boxShadow: 'none',
outline: `2px solid ${theme.colors.primary.main}`,
outlineOffset: '-2px',
transition: 'none',
},
'&::before': {
display: isActive ? 'block' : 'none',
content: '" "',
height: theme.spacing(3),
position: 'absolute',
left: theme.spacing(1),
top: '50%',
transform: 'translateY(-50%)',
width: theme.spacing(0.5),
borderRadius: theme.shape.radius.default,
backgroundImage: theme.colors.gradients.brandVertical,
},
}),
listItem: css({
boxSizing: 'border-box',
position: 'relative',
display: 'flex',
width: '100%',
...(isChild && {
padding: theme.spacing(0, 2),
}),
}),
});

@ -1,105 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { NavBarMenuItem } from './NavBarMenuItem';
import { NavBarMenuSection } from './NavBarMenuSection';
import { isMatchOrChildMatch } from './utils';
export function NavBarMenuItemWrapper({
link,
activeItem,
onClose,
}: {
link: NavModelItem;
activeItem?: NavModelItem;
onClose: () => void;
}) {
const styles = useStyles2(getStyles);
if (link.emptyMessage && !linkHasChildren(link)) {
return (
<NavBarMenuSection onClose={onClose} link={link} activeItem={activeItem}>
<ul className={styles.children}>
<div className={styles.emptyMessage}>{link.emptyMessage}</div>
</ul>
</NavBarMenuSection>
);
}
return (
<NavBarMenuSection onClose={onClose} link={link} activeItem={activeItem}>
{linkHasChildren(link) && (
<ul className={styles.children}>
{link.children.map((childLink) => {
return (
!childLink.isCreateAction && (
<NavBarMenuItem
key={`${link.text}-${childLink.text}`}
isActive={isMatchOrChildMatch(childLink, activeItem)}
isChild
onClick={() => {
childLink.onClick?.();
onClose();
}}
target={childLink.target}
url={childLink.url}
>
{childLink.text}
</NavBarMenuItem>
)
);
})}
</ul>
)}
</NavBarMenuSection>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
children: css({
display: 'flex',
flexDirection: 'column',
}),
flex: css({
display: 'flex',
}),
itemWithoutMenu: css({
position: 'relative',
placeItems: 'inherit',
justifyContent: 'start',
display: 'flex',
flexGrow: 1,
alignItems: 'center',
}),
fullWidth: css({
height: '100%',
width: '100%',
}),
iconContainer: css({
display: 'flex',
placeContent: 'center',
}),
itemWithoutMenuContent: css({
display: 'grid',
gridAutoFlow: 'column',
gridTemplateColumns: `${theme.spacing(7)} auto`,
alignItems: 'center',
height: '100%',
}),
linkText: css({
fontSize: theme.typography.pxToRem(14),
justifySelf: 'start',
}),
emptyMessage: css({
color: theme.colors.text.secondary,
fontStyle: 'italic',
padding: theme.spacing(1, 1.5, 1, 7),
}),
});
function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } {
return Boolean(link.children && link.children.length > 0);
}

@ -1,117 +0,0 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { useLocalStorage } from 'react-use';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Button, Icon, useStyles2 } from '@grafana/ui';
import { NavBarItemIcon } from './NavBarItemIcon';
import { NavBarMenuItem } from './NavBarMenuItem';
import { NavFeatureHighlight } from './NavFeatureHighlight';
import { hasChildMatch } from './utils';
export function NavBarMenuSection({
link,
activeItem,
children,
className,
onClose,
}: {
link: NavModelItem;
activeItem?: NavModelItem;
children: React.ReactNode;
className?: string;
onClose?: () => void;
}) {
const styles = useStyles2(getStyles);
const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment;
const isActive = link === activeItem;
const hasActiveChild = hasChildMatch(link, activeItem);
const [sectionExpanded, setSectionExpanded] =
useLocalStorage(`grafana.navigation.expanded[${link.text}]`, false) ?? Boolean(hasActiveChild);
return (
<>
<div className={cx(styles.collapsibleSectionWrapper, className)}>
<NavBarMenuItem
isActive={link === activeItem}
onClick={() => {
link.onClick?.();
onClose?.();
}}
target={link.target}
url={link.url}
>
<div
className={cx(styles.labelWrapper, {
[styles.isActive]: isActive,
[styles.hasActiveChild]: hasActiveChild,
})}
>
<FeatureHighlightWrapper>
<NavBarItemIcon link={link} />
</FeatureHighlightWrapper>
{link.text}
</div>
</NavBarMenuItem>
{children && (
<Button
aria-label={`${sectionExpanded ? 'Collapse' : 'Expand'} section ${link.text}`}
variant="secondary"
fill="text"
className={styles.collapseButton}
onClick={() => setSectionExpanded(!sectionExpanded)}
>
<Icon name={sectionExpanded ? 'angle-up' : 'angle-down'} size="xl" />
</Button>
)}
</div>
{sectionExpanded && children}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
collapsibleSectionWrapper: css({
alignItems: 'center',
display: 'flex',
}),
collapseButton: css({
color: theme.colors.text.disabled,
padding: theme.spacing(0, 0.5),
marginRight: theme.spacing(1),
}),
collapseWrapperActive: css({
backgroundColor: theme.colors.action.disabledBackground,
}),
collapseContent: css({
padding: 0,
}),
labelWrapper: css({
display: 'grid',
fontSize: theme.typography.pxToRem(14),
gridAutoFlow: 'column',
gridTemplateColumns: `${theme.spacing(7)} auto`,
placeItems: 'center',
fontWeight: theme.typography.fontWeightMedium,
}),
isActive: css({
color: theme.colors.text.primary,
'&::before': {
display: 'block',
content: '" "',
height: theme.spacing(3),
position: 'absolute',
left: theme.spacing(1),
top: '50%',
transform: 'translateY(-50%)',
width: theme.spacing(0.5),
borderRadius: theme.shape.radius.default,
backgroundImage: theme.colors.gradients.brandVertical,
},
}),
hasActiveChild: css({
color: theme.colors.text.primary,
}),
});

@ -1,34 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
export interface Props {
children: JSX.Element;
}
export const NavFeatureHighlight = ({ children }: Props): JSX.Element => {
const styles = useStyles2(getStyles);
return (
<div>
{children}
<span className={styles.highlight} />
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
highlight: css({
backgroundColor: theme.colors.success.main,
borderRadius: theme.shape.radius.circle,
width: '6px',
height: '6px',
display: 'inline-block',
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
}),
};
};

@ -29,19 +29,21 @@ export const enrichHelpItem = (helpItem: NavModelItem) => {
return helpItem;
};
export const enrichWithInteractionTracking = (item: NavModelItem, expandedState: boolean) => {
const onClick = item.onClick;
item.onClick = () => {
export const enrichWithInteractionTracking = (item: NavModelItem, megaMenuDockedState: boolean) => {
// creating a new object here to not mutate the original item object
const newItem = { ...item };
const onClick = newItem.onClick;
newItem.onClick = () => {
reportInteraction('grafana_navigation_item_clicked', {
path: item.url ?? item.id,
state: expandedState ? 'expanded' : 'collapsed',
path: newItem.url ?? newItem.id,
menuIsDocked: megaMenuDockedState,
});
onClick?.();
};
if (item.children) {
item.children = item.children.map((item) => enrichWithInteractionTracking(item, expandedState));
if (newItem.children) {
newItem.children = newItem.children.map((item) => enrichWithInteractionTracking(item, megaMenuDockedState));
}
return item;
return newItem;
};
export const isMatchOrChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem) => {

@ -1,109 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { useEffect, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { NavModel, GrafanaTheme2 } from '@grafana/data';
import { useStyles2, CustomScrollbar, useTheme2 } from '@grafana/ui';
import { SectionNavItem } from './SectionNavItem';
import { SectionNavToggle } from './SectionNavToggle';
export interface Props {
model: NavModel;
}
export function SectionNav({ model }: Props) {
const styles = useStyles2(getStyles);
const { isExpanded, onToggleSectionNav } = useSectionNavState();
if (!Boolean(model.main?.children?.length)) {
return null;
}
return (
<div className={styles.navContainer}>
<nav
className={cx(styles.nav, {
[styles.navExpanded]: isExpanded,
})}
>
<CustomScrollbar showScrollIndicators>
<div className={styles.items} role="tablist">
<SectionNavItem item={model.main} isSectionRoot />
</div>
</CustomScrollbar>
</nav>
<SectionNavToggle isExpanded={isExpanded} onClick={onToggleSectionNav} />
</div>
);
}
function useSectionNavState() {
const theme = useTheme2();
const isSmallScreen = window.matchMedia(`(max-width: ${theme.breakpoints.values.lg}px)`).matches;
const [navExpandedPreference, setNavExpandedPreference] = useLocalStorage<boolean>(
'grafana.sectionNav.expanded',
!isSmallScreen
);
const [isExpanded, setIsExpanded] = useState(!isSmallScreen && navExpandedPreference);
useEffect(() => {
const mediaQuery = window.matchMedia(`(max-width: ${theme.breakpoints.values.lg}px)`);
const onMediaQueryChange = (e: MediaQueryListEvent) => setIsExpanded(e.matches ? false : navExpandedPreference);
mediaQuery.addEventListener('change', onMediaQueryChange);
return () => mediaQuery.removeEventListener('change', onMediaQueryChange);
}, [navExpandedPreference, theme.breakpoints.values.lg]);
const onToggleSectionNav = () => {
setNavExpandedPreference(!isExpanded);
setIsExpanded(!isExpanded);
};
return { isExpanded, onToggleSectionNav };
}
const getStyles = (theme: GrafanaTheme2) => {
return {
navContainer: css({
display: 'flex',
flexDirection: 'column',
position: 'relative',
[theme.breakpoints.up('md')]: {
flexDirection: 'row',
},
}),
nav: css({
display: 'flex',
flexDirection: 'column',
background: theme.colors.background.canvas,
flexShrink: 0,
transition: theme.transitions.create(['width', 'max-height']),
maxHeight: 0,
visibility: 'hidden',
[theme.breakpoints.up('md')]: {
width: 0,
maxHeight: 'unset',
},
}),
navExpanded: css({
maxHeight: '50vh',
visibility: 'visible',
[theme.breakpoints.up('md')]: {
width: '250px',
maxHeight: 'unset',
},
}),
items: css({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(2, 1, 2, 2),
minWidth: '250px',
[theme.breakpoints.up('md')]: {
padding: theme.spacing(4.5, 1, 2, 2),
},
}),
};
};

@ -1,25 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { NavModelItem } from '@grafana/data';
import { SectionNavItem } from './SectionNavItem';
describe('SectionNavItem', () => {
it('should only show the img for a section root if both img and icon are present', () => {
const item: NavModelItem = {
text: 'Test',
icon: 'k6',
img: 'img',
children: [
{
text: 'Child',
},
],
};
render(<SectionNavItem item={item} isSectionRoot />);
expect(screen.getByTestId('section-image')).toBeInTheDocument();
expect(screen.queryByTestId('section-icon')).not.toBeInTheDocument();
});
});

@ -1,131 +0,0 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { useStyles2, Icon } from '@grafana/ui';
import { getActiveItem, hasChildMatch } from '../MegaMenu/utils';
export interface Props {
item: NavModelItem;
isSectionRoot?: boolean;
level?: number;
}
// max level depth to render
const MAX_DEPTH = 2;
export function SectionNavItem({ item, isSectionRoot = false, level = 0 }: Props) {
const styles = useStyles2(getStyles);
const children = item.children?.filter((x) => !x.hideFromTabs);
const activeItem = item.children && getActiveItem(item.children, location.pathname);
// If first root child is a section skip the bottom margin (as sections have top margin already)
const noRootMargin = isSectionRoot && Boolean(item.children![0].children?.length);
const linkClass = cx({
[styles.link]: true,
[styles.activeStyle]: item.active || (level === MAX_DEPTH && hasChildMatch(item, activeItem)),
[styles.isSection]: level < MAX_DEPTH && (Boolean(children?.length) || item.isSection),
[styles.isSectionRoot]: isSectionRoot,
[styles.noRootMargin]: noRootMargin,
});
let icon: React.ReactNode | null = null;
if (item.img) {
icon = <img data-testid="section-image" className={styles.sectionImg} src={item.img} alt="" />;
} else if (item.icon) {
icon = <Icon data-testid="section-icon" className={styles.sectionImg} name={item.icon} />;
}
const onItemClicked = () => {
reportInteraction('grafana_navigation_item_clicked', {
path: item.url ?? item.id,
sectionNav: true,
});
};
return (
<>
<a
onClick={onItemClicked}
href={item.url}
className={linkClass}
aria-label={selectors.components.Tab.title(item.text)}
role="tab"
aria-selected={item.active}
>
{isSectionRoot && icon}
{item.text}
{item.tabSuffix && <item.tabSuffix className={styles.suffix} />}
</a>
{level < MAX_DEPTH &&
children?.map((child, index) => <SectionNavItem item={child} key={index} level={level + 1} />)}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
link: css`
padding: ${theme.spacing(1, 0, 1, 1.5)};
display: flex;
align-items: flex-start;
border-radius: ${theme.shape.radius.default};
gap: ${theme.spacing(1)};
height: 100%;
position: relative;
color: ${theme.colors.text.secondary};
&:hover,
&:focus {
text-decoration: underline;
z-index: 1;
}
`,
activeStyle: css`
label: activeTabStyle;
color: ${theme.colors.text.primary};
font-weight: ${theme.typography.fontWeightMedium};
background: ${theme.colors.emphasize(theme.colors.background.canvas, 0.03)};
&::before {
display: block;
content: ' ';
position: absolute;
left: 0;
width: 4px;
bottom: 2px;
top: 2px;
border-radius: ${theme.shape.radius.default};
background-image: ${theme.colors.gradients.brandVertical};
}
`,
suffix: css`
margin-left: ${theme.spacing(1)};
`,
sectionImg: css({
margin: '6px 0',
width: theme.spacing(2),
}),
isSectionRoot: css({
fontSize: theme.typography.h4.fontSize,
marginTop: 0,
marginBottom: theme.spacing(2),
fontWeight: theme.typography.fontWeightMedium,
}),
isSection: css({
color: theme.colors.text.primary,
fontSize: theme.typography.h5.fontSize,
marginTop: theme.spacing(2),
fontWeight: theme.typography.fontWeightMedium,
}),
noRootMargin: css({
marginBottom: 0,
}),
};
};

@ -1,68 +0,0 @@
import { css } from '@emotion/css';
import classnames from 'classnames';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, useTheme2 } from '@grafana/ui';
export interface Props {
isExpanded?: boolean;
onClick: () => void;
}
export const SectionNavToggle = ({ isExpanded, onClick }: Props) => {
const theme = useTheme2();
const styles = getStyles(theme);
return (
<Button
title={'Toggle section navigation'}
aria-label={isExpanded ? 'Close section navigation' : 'Open section navigation'}
icon="arrow-to-right"
className={classnames(styles.icon, {
[styles.iconExpanded]: isExpanded,
})}
variant="secondary"
fill="text"
size="md"
onClick={onClick}
/>
);
};
SectionNavToggle.displayName = 'SectionNavToggle';
const getStyles = (theme: GrafanaTheme2) => ({
icon: css({
alignSelf: 'center',
margin: theme.spacing(1, 0),
transform: 'rotate(90deg)',
transition: theme.transitions.create('opacity'),
color: theme.colors.text.secondary,
zIndex: 1,
[theme.breakpoints.up('md')]: {
alignSelf: 'flex-start',
position: 'relative',
left: 0,
margin: theme.spacing(0, 0, 0, 1),
top: theme.spacing(2),
transform: 'none',
},
'div:hover > &, &:focus': {
opacity: 1,
},
}),
iconExpanded: css({
rotate: '180deg',
[theme.breakpoints.up('md')]: {
opacity: 0,
margin: 0,
position: 'absolute',
right: 0,
left: 'initial',
},
}),
});

@ -1,5 +1,4 @@
import { NavModelItem } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Breadcrumb } from './types';
@ -16,12 +15,9 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
// construct the URL to match
const urlParts = node.url?.split('?') ?? ['', ''];
let urlToMatch = urlParts[0];
if (config.featureToggles.dockedMegaMenu) {
const urlSearchParams = new URLSearchParams(urlParts[1]);
if (urlSearchParams.has('editview')) {
urlToMatch += `?editview=${urlSearchParams.get('editview')}`;
}
const urlSearchParams = new URLSearchParams(urlParts[1]);
if (urlSearchParams.has('editview')) {
urlToMatch += `?editview=${urlSearchParams.get('editview')}`;
}
// Check if we found home/root if if so return early

@ -3,7 +3,6 @@ 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';
@ -96,34 +95,20 @@ const getStyles = (theme: GrafanaTheme2) => {
label: 'page-content',
flexGrow: 1,
}),
pageInner: css(
{
label: 'page-inner',
padding: theme.spacing(2),
borderBottom: 'none',
background: theme.colors.background.primary,
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
margin: theme.spacing(0, 0, 0, 0),
},
config.featureToggles.dockedMegaMenu
? {
[theme.breakpoints.up('md')]: {
padding: theme.spacing(4),
},
}
: {
borderRadius: theme.shape.radius.default,
border: `1px solid ${theme.colors.border.weak}`,
[theme.breakpoints.up('md')]: {
margin: theme.spacing(2, 2, 0, 1),
padding: theme.spacing(3),
},
}
),
pageInner: css({
label: 'page-inner',
padding: theme.spacing(2),
borderBottom: 'none',
background: theme.colors.background.primary,
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
margin: theme.spacing(0, 0, 0, 0),
[theme.breakpoints.up('md')]: {
padding: theme.spacing(4),
},
}),
canvasContent: css({
label: 'canvas-content',
display: 'flex',

@ -1,8 +1,7 @@
import { css, cx } from '@emotion/css';
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { BouncingLoader } from '../components/BouncingLoader/BouncingLoader';
@ -11,11 +10,7 @@ export function GrafanaRouteLoading() {
const styles = useStyles2(getStyles);
return (
<div
className={cx(styles.loadingPage, {
[styles.loadingPageDockedNav]: config.featureToggles.dockedMegaMenu,
})}
>
<div className={styles.loadingPage}>
<BouncingLoader />
</div>
);
@ -23,13 +18,11 @@ export function GrafanaRouteLoading() {
const getStyles = (theme: GrafanaTheme2) => ({
loadingPage: css({
backgroundColor: theme.colors.background.primary,
height: '100%',
flexDrection: 'column',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}),
loadingPageDockedNav: css({
backgroundColor: theme.colors.background.primary,
}),
});

@ -1,4 +1,3 @@
import { config } from '@grafana/runtime';
import { t } from 'app/core/internationalization';
// Maps the ID of the nav item to a translated phrase to later pass to <Trans />
@ -140,9 +139,7 @@ export function getNavTitle(navId: string | undefined) {
case 'plugin-page-grafana-slo-app':
return t('nav.slo.title', 'SLO');
case 'plugin-page-k6-app':
return config.featureToggles.dockedMegaMenu
? t('nav.k6.title', 'Performance')
: t('nav.performance-testing.title', 'Performance testing');
return t('nav.k6.title', 'Performance');
case 'monitoring':
return t('nav.observability.title', 'Observability');
case 'plugin-page-grafana-k8s-app':

@ -1,6 +1,5 @@
import React from 'react';
import { config } from '@grafana/runtime';
import { Permissions } from 'app/core/components/AccessControl';
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
@ -10,7 +9,7 @@ import { SettingsPageProps } from '../DashboardSettings/types';
export const AccessControlDashboardPermissions = ({ dashboard, sectionNav }: SettingsPageProps) => {
const canSetPermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsWrite);
const pageNav = config.featureToggles.dockedMegaMenu ? sectionNav.node.parentItem : undefined;
const pageNav = sectionNav.node.parentItem;
return (
<Page navModel={sectionNav} pageNav={pageNav}>

@ -1,7 +1,7 @@
import React from 'react';
import { AnnotationQuery, getDataSourceRef, NavModelItem } from '@grafana/data';
import { config, getDataSourceSrv, locationService } from '@grafana/runtime';
import { getDataSourceSrv, locationService } from '@grafana/runtime';
import { Page } from 'app/core/components/Page/Page';
import { DashboardModel } from '../../state';
@ -41,7 +41,7 @@ function getSubPageNav(
editIndex: number | undefined,
node: NavModelItem
): NavModelItem | undefined {
const parentItem = config.featureToggles.dockedMegaMenu ? node.parentItem : undefined;
const parentItem = node.parentItem;
if (editIndex == null) {
return parentItem;
}

@ -8,7 +8,6 @@ import { locationService } from '@grafana/runtime';
import { Button, ToolbarButtonRow } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { Page } from 'app/core/components/Page/Page';
import config from 'app/core/config';
import { t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types';
@ -185,14 +184,10 @@ function getSectionNav(
text: t('dashboard-settings.settings.title', 'Settings'),
children: [],
icon: 'apps',
hideFromBreadcrumbs: true,
hideFromBreadcrumbs: false,
url: locationUtil.getUrlForPartial(location, { editview: 'settings', editIndex: null }),
};
if (config.featureToggles.dockedMegaMenu) {
main.hideFromBreadcrumbs = false;
main.url = locationUtil.getUrlForPartial(location, { editview: 'settings', editIndex: null });
}
main.children = pages.map((page) => ({
text: page.title,
icon: page.icon,

@ -43,7 +43,7 @@ export function GeneralSettingsUnconnected({
const [renderCounter, setRenderCounter] = useState(0);
const [dashboardTitle, setDashboardTitle] = useState(dashboard.title);
const [dashboardDescription, setDashboardDescription] = useState(dashboard.description);
const pageNav = config.featureToggles.dockedMegaMenu ? sectionNav.node.parentItem : undefined;
const pageNav = sectionNav.node.parentItem;
const onFolderChange = (newUID: string | undefined, newTitle: string | undefined) => {
dashboard.meta.folderUid = newUID;

@ -2,7 +2,6 @@ import { css } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Button, CodeEditor, useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { Trans } from 'app/core/internationalization';
@ -14,7 +13,7 @@ import { SettingsPageProps } from './types';
export function JsonEditorSettings({ dashboard, sectionNav }: SettingsPageProps) {
const [dashboardJson, setDashboardJson] = useState<string>(JSON.stringify(dashboard.getSaveModelClone(), null, 2));
const pageNav = config.featureToggles.dockedMegaMenu ? sectionNav.node.parentItem : undefined;
const pageNav = sectionNav.node.parentItem;
const onClick = async () => {
await getDashboardSrv().saveJSONDashboard(dashboardJson);

@ -90,7 +90,9 @@ describe('LinksSettings', () => {
const linklessDashboard = createDashboardModelFixture({ links: [] });
setup(linklessDashboard);
expect(screen.getByRole('heading', { name: 'Links' })).toBeInTheDocument();
const linksTab = screen.getByRole('tab', { name: 'Tab Links' });
expect(linksTab).toBeInTheDocument();
expect(linksTab).toHaveAttribute('aria-selected', 'true');
expect(
screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add dashboard link'))
).toBeInTheDocument();

@ -1,7 +1,6 @@
import React, { useState } from 'react';
import { NavModelItem } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { locationService } from '@grafana/runtime';
import { Page } from 'app/core/components/Page/Page';
import { NEW_LINK } from 'app/features/dashboard-scene/settings/links/utils';
@ -32,11 +31,7 @@ export function LinksSettings({ dashboard, sectionNav, editIndex }: SettingsPage
const isEditing = editIndex !== undefined;
let pageNav: NavModelItem | undefined;
if (config.featureToggles.dockedMegaMenu) {
pageNav = sectionNav.node.parentItem;
}
let pageNav = sectionNav.node.parentItem;
if (isEditing) {
const title = isNew ? 'New link' : 'Edit link';
@ -46,13 +41,11 @@ export function LinksSettings({ dashboard, sectionNav, editIndex }: SettingsPage
subTitle: description,
};
if (config.featureToggles.dockedMegaMenu) {
const parentUrl = sectionNav.node.url;
pageNav.parentItem = sectionNav.node.parentItem && {
...sectionNav.node.parentItem,
url: parentUrl,
};
}
const parentUrl = sectionNav.node.url;
pageNav.parentItem = sectionNav.node.parentItem && {
...sectionNav.node.parentItem,
url: parentUrl,
};
}
return (

@ -1,6 +1,5 @@
import React, { PureComponent } from 'react';
import { config } from '@grafana/runtime';
import { Spinner, HorizontalGroup } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import {
@ -139,7 +138,7 @@ export class VersionsSettings extends PureComponent<Props, State> {
const canCompare = versions.filter((version) => version.checked).length === 2;
const showButtons = versions.length > 1;
const hasMore = versions.length >= this.limit;
const pageNav = config.featureToggles.dockedMegaMenu ? this.props.sectionNav.node.parentItem : undefined;
const pageNav = this.props.sectionNav.node.parentItem;
if (viewMode === 'compare') {
return (

@ -17,6 +17,7 @@ jest.mock('@grafana/runtime', () => ({
put: putMock,
}),
config: {
...jest.requireActual('@grafana/runtime').config,
loginError: false,
buildInfo: {
version: 'v1.0',

@ -29,6 +29,7 @@ jest.mock('@grafana/runtime', () => ({
get: jest.fn().mockResolvedValue([{ userId: 1, login: 'Test' }]),
}),
config: {
...jest.requireActual('@grafana/runtime').config,
licenseInfo: {
enabledFeatures: { teamsync: true },
stateInfo: '',

@ -2,7 +2,7 @@ import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { bindActionCreators } from 'redux';
import { config, locationService } from '@grafana/runtime';
import { locationService } from '@grafana/runtime';
import { Page } from 'app/core/components/Page/Page';
import { SettingsPageProps } from 'app/features/dashboard/components/DashboardSettings/types';
@ -108,13 +108,12 @@ class VariableEditorContainerUnconnected extends PureComponent<Props, State> {
const { editIndex, variables, sectionNav } = this.props;
const variableToEdit = editIndex != null ? variables[editIndex] : undefined;
const node = sectionNav.node;
const parentItem =
config.featureToggles.dockedMegaMenu && node.parentItem
? {
...node.parentItem,
url: node.url,
}
: undefined;
const parentItem = node.parentItem
? {
...node.parentItem,
url: node.url,
}
: undefined;
const subPageNav = variableToEdit ? { text: variableToEdit.name, parentItem } : parentItem;
return (

@ -883,9 +883,6 @@
"subtitle": "Manage preferences across an organization",
"title": "Default preferences"
},
"performance-testing": {
"title": "Performance testing"
},
"playlists": {
"subtitle": "Groups of dashboards that are displayed in a sequence",
"title": "Playlists"

@ -883,9 +883,6 @@
"subtitle": "Mäʼnäģę přęƒęřęʼnčęş äčřőşş äʼn őřģäʼnįžäŧįőʼn",
"title": "Đęƒäūľŧ přęƒęřęʼnčęş"
},
"performance-testing": {
"title": "Pęřƒőřmäʼnčę ŧęşŧįʼnģ"
},
"playlists": {
"subtitle": "Ğřőūpş őƒ đäşĥþőäřđş ŧĥäŧ äřę đįşpľäyęđ įʼn ä şęqūęʼnčę",
"title": "Pľäyľįşŧş"

Loading…
Cancel
Save