mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
@@ -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.", "5"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"]
|
[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": [
|
"public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx:5381": [
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
[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 |
|
| `scenes` | Experimental framework to build interactive dashboards |
|
||||||
| `disableSecretsCompatibility` | Disable duplicated secret storage in legacy tables |
|
| `disableSecretsCompatibility` | Disable duplicated secret storage in legacy tables |
|
||||||
| `logRequestsInstrumentedAsUnknown` | Logs the path for requests that are instrumented as unknown |
|
| `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 |
|
| `returnToPrevious` | Enables the return to previous context functionality |
|
||||||
| `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema |
|
| `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema |
|
||||||
| `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query |
|
| `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', () => {
|
it('Loads the app page without the sandbox div wrapper', () => {
|
||||||
e2e.pages.Home.visit();
|
cy.visit(`/a/${APP_ID}`);
|
||||||
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.wait(200); // wait to prevent false positives because cypress checks too fast
|
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-plugin-sandbox="sandbox-app-test"]').should('not.exist');
|
||||||
cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist');
|
cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Loads the app configuration without the sandbox div wrapper', () => {
|
it('Loads the app configuration without the sandbox div wrapper', () => {
|
||||||
e2e.pages.Home.visit();
|
cy.visit(`/plugins/${APP_ID}`);
|
||||||
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.wait(200); // wait to prevent false positives because cypress checks too fast
|
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-plugin-sandbox="sandbox-app-test"]').should('not.exist');
|
||||||
cy.get('div[data-testid="sandbox-app-test-config-page"]').should('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', () => {
|
it('Loads the app page with the sandbox div wrapper', () => {
|
||||||
e2e.pages.Home.visit();
|
cy.visit(`/a/${APP_ID}`);
|
||||||
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.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist');
|
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist');
|
||||||
cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist');
|
cy.get('div[data-testid="sandbox-app-test-page-one"]').should('exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Loads the app configuration with the sandbox div wrapper', () => {
|
it('Loads the app configuration with the sandbox div wrapper', () => {
|
||||||
e2e.pages.Home.visit();
|
cy.visit(`/plugins/${APP_ID}`);
|
||||||
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.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist');
|
cy.get('div[data-plugin-sandbox="sandbox-app-test"]').should('exist');
|
||||||
cy.get('div[data-testid="sandbox-app-test-config-page"]').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');
|
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')
|
e2e.components.Panels.Panel.title('05:00')
|
||||||
.should('exist')
|
.should('exist')
|
||||||
|
|||||||
@@ -6,11 +6,7 @@ describe('Docked Navigation', () => {
|
|||||||
cy.viewport(1280, 800);
|
cy.viewport(1280, 800);
|
||||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||||
|
|
||||||
cy.visit(fromBaseUrl('/'), {
|
cy.visit(fromBaseUrl('/'));
|
||||||
onBeforeLoad(window) {
|
|
||||||
window.localStorage.setItem('grafana.featureToggles', 'dockedMegaMenu=1');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remain docked when reloading the page', () => {
|
it('should remain docked when reloading the page', () => {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export interface FeatureToggles {
|
|||||||
logRequestsInstrumentedAsUnknown?: boolean;
|
logRequestsInstrumentedAsUnknown?: boolean;
|
||||||
dataConnectionsConsole?: boolean;
|
dataConnectionsConsole?: boolean;
|
||||||
topnav?: boolean;
|
topnav?: boolean;
|
||||||
dockedMegaMenu?: boolean;
|
|
||||||
returnToPrevious?: boolean;
|
returnToPrevious?: boolean;
|
||||||
grpcServer?: boolean;
|
grpcServer?: boolean;
|
||||||
unifiedStorage?: boolean;
|
unifiedStorage?: boolean;
|
||||||
|
|||||||
@@ -203,14 +203,6 @@ var (
|
|||||||
Owner: grafanaFrontendPlatformSquad,
|
Owner: grafanaFrontendPlatformSquad,
|
||||||
Created: time.Date(2022, time.June, 20, 12, 0, 0, 0, time.UTC),
|
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",
|
Name: "returnToPrevious",
|
||||||
Description: "Enables the return to previous context functionality",
|
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
|
logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,2022-06-10,false,false,false
|
||||||
dataConnectionsConsole,GA,@grafana/plugins-platform-backend,2022-06-01,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
|
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
|
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
|
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
|
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.
|
// Enables topnav support in external plugins. The new Grafana navigation cannot be disabled.
|
||||||
FlagTopnav = "topnav"
|
FlagTopnav = "topnav"
|
||||||
|
|
||||||
// FlagDockedMegaMenu
|
|
||||||
// Enable support for a persistent (docked) navigation menu
|
|
||||||
FlagDockedMegaMenu = "dockedMegaMenu"
|
|
||||||
|
|
||||||
// FlagReturnToPrevious
|
// FlagReturnToPrevious
|
||||||
// Enables the return to previous context functionality
|
// Enables the return to previous context functionality
|
||||||
FlagReturnToPrevious = "returnToPrevious"
|
FlagReturnToPrevious = "returnToPrevious"
|
||||||
|
|||||||
@@ -289,41 +289,12 @@ func (s *ServiceImpl) hasAccessToInclude(c *contextmodel.ReqContext, pluginID st
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServiceImpl) readNavigationSettings() {
|
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{
|
s.navigationAppConfig = map[string]NavigationAppConfig{
|
||||||
"grafana-k8s-app": k8sCfg,
|
"grafana-k8s-app": {SectionID: navtree.NavIDInfrastructure, SortWeight: 1, Text: "Kubernetes"},
|
||||||
"grafana-app-observability-app": appO11yCfg,
|
"grafana-app-observability-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightApplication, Text: "Application", Icon: "graph-bar"},
|
||||||
"grafana-pyroscope-app": profilesCfg,
|
"grafana-pyroscope-app": {SectionID: navtree.NavIDExplore, SortWeight: 1, Text: "Profiles"},
|
||||||
"grafana-kowalski-app": frontendCfg,
|
"grafana-kowalski-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightFrontend, Text: "Frontend", Icon: "frontend-observability"},
|
||||||
"grafana-synthetic-monitoring-app": syntheticsCfg,
|
"grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDTestingAndSynthetics, SortWeight: 2, Text: "Synthetics"},
|
||||||
"grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1, Text: "OnCall"},
|
"grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1, Text: "OnCall"},
|
||||||
"grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incidents"},
|
"grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2, Text: "Incidents"},
|
||||||
"grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "Machine Learning"},
|
"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-adaptive-metrics-app": {SectionID: navtree.NavIDCfg, Text: "Adaptive Metrics"},
|
||||||
"grafana-logvolumeexplorer-app": {SectionID: navtree.NavIDCfg, Text: "Log Volume Explorer"},
|
"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"},
|
"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"},
|
"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.cfg.Raw.NewSection("navigation.app_sections")
|
||||||
service.readNavigationSettings()
|
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) {
|
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 { TestProvider } from 'test/helpers/TestProvider';
|
||||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
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 { config } from '@grafana/runtime';
|
||||||
import { HOME_NAV_ID } from 'app/core/reducers/navModel';
|
import { HOME_NAV_ID } from 'app/core/reducers/navModel';
|
||||||
import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from 'app/features/search/service';
|
import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from 'app/features/search/service';
|
||||||
@@ -19,14 +19,6 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
|
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 = {
|
const searchData: DataFrame = {
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'kind', type: FieldType.string, config: {}, values: [] },
|
{ name: 'kind', type: FieldType.string, config: {}, values: [] },
|
||||||
@@ -92,30 +84,6 @@ describe('AppChrome', () => {
|
|||||||
jest.clearAllMocks();
|
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 () => {
|
it('should create a skip link to skip to main content', async () => {
|
||||||
setup(<Page navId="child1">Children</Page>);
|
setup(<Page navId="child1">Children</Page>);
|
||||||
expect(await screen.findByRole('link', { name: 'Skip to main content' })).toBeInTheDocument();
|
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 classNames from 'classnames';
|
||||||
import React, { PropsWithChildren, useEffect } from 'react';
|
import React, { PropsWithChildren, useEffect } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui';
|
import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui';
|
||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
@@ -14,11 +14,9 @@ import { KioskMode } from 'app/types';
|
|||||||
|
|
||||||
import { AppChromeMenu } from './AppChromeMenu';
|
import { AppChromeMenu } from './AppChromeMenu';
|
||||||
import { DOCKED_LOCAL_STORAGE_KEY, DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY } from './AppChromeService';
|
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 { MegaMenu } from './MegaMenu/MegaMenu';
|
||||||
import { NavToolbar } from './NavToolbar/NavToolbar';
|
import { NavToolbar } from './NavToolbar/NavToolbar';
|
||||||
import { ReturnToPrevious } from './ReturnToPrevious/ReturnToPrevious';
|
import { ReturnToPrevious } from './ReturnToPrevious/ReturnToPrevious';
|
||||||
import { SectionNav } from './SectionNav/SectionNav';
|
|
||||||
import { TopSearchBar } from './TopBar/TopSearchBar';
|
import { TopSearchBar } from './TopBar/TopSearchBar';
|
||||||
import { TOP_BAR_LEVEL_HEIGHT } from './types';
|
import { TOP_BAR_LEVEL_HEIGHT } from './types';
|
||||||
|
|
||||||
@@ -36,7 +34,7 @@ export function AppChrome({ children }: Props) {
|
|||||||
useMediaQueryChange({
|
useMediaQueryChange({
|
||||||
breakpoint: dockedMenuBreakpoint,
|
breakpoint: dockedMenuBreakpoint,
|
||||||
onChange: (e) => {
|
onChange: (e) => {
|
||||||
if (config.featureToggles.dockedMegaMenu && dockedMenuLocalStorageState) {
|
if (dockedMenuLocalStorageState) {
|
||||||
chrome.setMegaMenuDocked(e.matches, false);
|
chrome.setMegaMenuDocked(e.matches, false);
|
||||||
chrome.setMegaMenuOpen(
|
chrome.setMegaMenuOpen(
|
||||||
e.matches ? store.getBool(DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, state.megaMenuOpen) : false
|
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}>
|
<main className={contentClass}>
|
||||||
<div className={styles.panes}>
|
<div className={styles.panes}>
|
||||||
{state.layout === PageLayoutType.Standard && state.sectionNav && !config.featureToggles.dockedMegaMenu && (
|
{!state.chromeless && state.megaMenuDocked && state.megaMenuOpen && (
|
||||||
<SectionNav model={state.sectionNav} />
|
<MegaMenu className={styles.dockedMegaMenu} onClose={() => chrome.setMegaMenuOpen(false)} />
|
||||||
)}
|
|
||||||
{config.featureToggles.dockedMegaMenu && !state.chromeless && state.megaMenuDocked && state.megaMenuOpen && (
|
|
||||||
<DockedMegaMenu className={styles.dockedMegaMenu} onClose={() => chrome.setMegaMenuOpen(false)} />
|
|
||||||
)}
|
)}
|
||||||
<div className={styles.pageContainer} id="pageContent">
|
<div className={styles.pageContainer} id="pageContent">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{!state.chromeless && !state.megaMenuDocked && (
|
{!state.chromeless && !state.megaMenuDocked && <AppChromeMenu />}
|
||||||
<>
|
|
||||||
{config.featureToggles.dockedMegaMenu ? (
|
|
||||||
<AppChromeMenu />
|
|
||||||
) : (
|
|
||||||
<MegaMenu searchBarHidden={searchBarHidden} onClose={() => chrome.setMegaMenuOpen(false)} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!state.chromeless && <CommandPalette />}
|
{!state.chromeless && <CommandPalette />}
|
||||||
{shouldShowReturnToPrevious && state.returnToPrevious && (
|
{shouldShowReturnToPrevious && state.returnToPrevious && (
|
||||||
<ReturnToPrevious href={state.returnToPrevious.href} title={state.returnToPrevious.title} />
|
<ReturnToPrevious href={state.returnToPrevious.href} title={state.returnToPrevious.title} />
|
||||||
@@ -128,10 +115,6 @@ export function AppChrome({ children }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
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 {
|
return {
|
||||||
content: css({
|
content: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -162,7 +145,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
zIndex: theme.zIndex.navbarFixed,
|
zIndex: theme.zIndex.navbarFixed,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
boxShadow: config.featureToggles.dockedMegaMenu ? undefined : shadow,
|
|
||||||
background: theme.colors.background.primary,
|
background: theme.colors.background.primary,
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useStyles2, useTheme2 } from '@grafana/ui';
|
|||||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||||
import { KioskMode } from 'app/types';
|
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 { TOGGLE_BUTTON_ID } from './NavToolbar/NavToolbar';
|
||||||
import { TOP_BAR_LEVEL_HEIGHT } from './types';
|
import { TOP_BAR_LEVEL_HEIGHT } from './types';
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,8 @@ export class AppChromeService {
|
|||||||
private routeChangeHandled = true;
|
private routeChangeHandled = true;
|
||||||
|
|
||||||
private megaMenuDocked = Boolean(
|
private megaMenuDocked = Boolean(
|
||||||
config.featureToggles.dockedMegaMenu &&
|
window.innerWidth >= config.theme2.breakpoints.values.xl &&
|
||||||
window.innerWidth >= config.theme2.breakpoints.values.xl &&
|
store.getBool(DOCKED_LOCAL_STORAGE_KEY, Boolean(window.innerWidth >= config.theme2.breakpoints.values.xxl))
|
||||||
store.getBool(
|
|
||||||
DOCKED_LOCAL_STORAGE_KEY,
|
|
||||||
Boolean(config.featureToggles.dockedMegaMenu && window.innerWidth >= config.theme2.breakpoints.values.xxl)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private sessionStorageData = window.sessionStorage.getItem('returnToPrevious');
|
private sessionStorageData = window.sessionStorage.getItem('returnToPrevious');
|
||||||
@@ -130,14 +126,10 @@ export class AppChromeService {
|
|||||||
|
|
||||||
public setMegaMenuOpen = (newOpenState: boolean) => {
|
public setMegaMenuOpen = (newOpenState: boolean) => {
|
||||||
const { megaMenuDocked } = this.state.getValue();
|
const { megaMenuDocked } = this.state.getValue();
|
||||||
if (config.featureToggles.dockedMegaMenu) {
|
if (megaMenuDocked) {
|
||||||
if (megaMenuDocked) {
|
store.set(DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, newOpenState);
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
|
reportInteraction('grafana_mega_menu_open', { state: newOpenState });
|
||||||
this.update({
|
this.update({
|
||||||
megaMenuOpen: newOpenState,
|
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 { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Router } from 'react-router-dom';
|
import { Router } from 'react-router-dom';
|
||||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||||
@@ -18,7 +19,12 @@ const setup = () => {
|
|||||||
id: 'section',
|
id: 'section',
|
||||||
url: 'section',
|
url: 'section',
|
||||||
children: [
|
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' },
|
{ text: 'Child2', id: 'child2', url: 'section/child2' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -42,6 +48,9 @@ const setup = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('MegaMenu', () => {
|
describe('MegaMenu', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
it('should render component', async () => {
|
it('should render component', async () => {
|
||||||
setup();
|
setup();
|
||||||
|
|
||||||
@@ -49,10 +58,25 @@ describe('MegaMenu', () => {
|
|||||||
expect(await screen.findByRole('link', { name: 'Section name' })).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 () => {
|
it('should filter out profile', async () => {
|
||||||
setup();
|
setup();
|
||||||
|
|
||||||
expect(await screen.findByTestId(selectors.components.NavMenu.Menu)).toBeInTheDocument();
|
|
||||||
expect(screen.queryByLabelText('Profile')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText('Profile')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,50 +1,132 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { cloneDeep } from 'lodash';
|
import { DOMAttributes } from '@react-types/shared';
|
||||||
import React from 'react';
|
import React, { forwardRef } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
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 { useSelector } from 'app/types';
|
||||||
|
|
||||||
import { NavBarMenu } from './NavBarMenu';
|
import { MegaMenuItem } from './MegaMenuItem';
|
||||||
import { enrichWithInteractionTracking, getActiveItem } from './utils';
|
import { enrichWithInteractionTracking, getActiveItem } from './utils';
|
||||||
|
|
||||||
export interface Props {
|
export const MENU_WIDTH = '300px';
|
||||||
|
|
||||||
|
export interface Props extends DOMAttributes {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
searchBarHidden?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MegaMenu = React.memo<Props>(({ onClose, searchBarHidden }) => {
|
export const MegaMenu = React.memo(
|
||||||
const navBarTree = useSelector((state) => state.navBarTree);
|
forwardRef<HTMLDivElement, Props>(({ onClose, ...restProps }, ref) => {
|
||||||
const theme = useTheme2();
|
const navTree = useSelector((state) => state.navBarTree);
|
||||||
const styles = getStyles(theme);
|
const styles = useStyles2(getStyles);
|
||||||
const location = useLocation();
|
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 activeItem = getActiveItem(navItems, location.pathname);
|
||||||
const navItems = navTree
|
|
||||||
.filter((item) => item.id !== 'profile' && item.id !== 'help')
|
|
||||||
.map((item) => enrichWithInteractionTracking(item, true));
|
|
||||||
|
|
||||||
const activeItem = getActiveItem(navItems, location.pathname);
|
const handleDockedMenu = () => {
|
||||||
|
chrome.setMegaMenuDocked(!state.megaMenuDocked);
|
||||||
|
if (state.megaMenuDocked) {
|
||||||
|
chrome.setMegaMenuOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
// refocus on undock/menu open button when changing state
|
||||||
<div className={styles.menuWrapper}>
|
setTimeout(() => {
|
||||||
<NavBarMenu activeItem={activeItem} navItems={navItems} onClose={onClose} searchBarHidden={searchBarHidden} />
|
document.getElementById(state.megaMenuDocked ? 'mega-menu-toggle' : 'dock-menu-button')?.focus();
|
||||||
</div>
|
});
|
||||||
);
|
};
|
||||||
});
|
|
||||||
|
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';
|
MegaMenu.displayName = 'MegaMenu';
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
menuWrapper: css({
|
content: css({
|
||||||
position: 'fixed',
|
display: 'flex',
|
||||||
display: 'grid',
|
flexDirection: 'column',
|
||||||
gridAutoFlow: 'column',
|
|
||||||
height: '100%',
|
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;
|
return helpItem;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const enrichWithInteractionTracking = (item: NavModelItem, expandedState: boolean) => {
|
export const enrichWithInteractionTracking = (item: NavModelItem, megaMenuDockedState: boolean) => {
|
||||||
const onClick = item.onClick;
|
// creating a new object here to not mutate the original item object
|
||||||
item.onClick = () => {
|
const newItem = { ...item };
|
||||||
|
const onClick = newItem.onClick;
|
||||||
|
newItem.onClick = () => {
|
||||||
reportInteraction('grafana_navigation_item_clicked', {
|
reportInteraction('grafana_navigation_item_clicked', {
|
||||||
path: item.url ?? item.id,
|
path: newItem.url ?? newItem.id,
|
||||||
state: expandedState ? 'expanded' : 'collapsed',
|
menuIsDocked: megaMenuDockedState,
|
||||||
});
|
});
|
||||||
onClick?.();
|
onClick?.();
|
||||||
};
|
};
|
||||||
if (item.children) {
|
if (newItem.children) {
|
||||||
item.children = item.children.map((item) => enrichWithInteractionTracking(item, expandedState));
|
newItem.children = newItem.children.map((item) => enrichWithInteractionTracking(item, megaMenuDockedState));
|
||||||
}
|
}
|
||||||
return item;
|
return newItem;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isMatchOrChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem) => {
|
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 { NavModelItem } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
|
|
||||||
import { Breadcrumb } from './types';
|
import { Breadcrumb } from './types';
|
||||||
|
|
||||||
@@ -16,12 +15,9 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
|
|||||||
// construct the URL to match
|
// construct the URL to match
|
||||||
const urlParts = node.url?.split('?') ?? ['', ''];
|
const urlParts = node.url?.split('?') ?? ['', ''];
|
||||||
let urlToMatch = urlParts[0];
|
let urlToMatch = urlParts[0];
|
||||||
|
const urlSearchParams = new URLSearchParams(urlParts[1]);
|
||||||
if (config.featureToggles.dockedMegaMenu) {
|
if (urlSearchParams.has('editview')) {
|
||||||
const urlSearchParams = new URLSearchParams(urlParts[1]);
|
urlToMatch += `?editview=${urlSearchParams.get('editview')}`;
|
||||||
if (urlSearchParams.has('editview')) {
|
|
||||||
urlToMatch += `?editview=${urlSearchParams.get('editview')}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we found home/root if if so return early
|
// 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 React, { useLayoutEffect } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
|
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { CustomScrollbar, useStyles2 } from '@grafana/ui';
|
import { CustomScrollbar, useStyles2 } from '@grafana/ui';
|
||||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||||
|
|
||||||
@@ -96,34 +95,20 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
label: 'page-content',
|
label: 'page-content',
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
}),
|
}),
|
||||||
pageInner: css(
|
pageInner: css({
|
||||||
{
|
label: 'page-inner',
|
||||||
label: 'page-inner',
|
padding: theme.spacing(2),
|
||||||
padding: theme.spacing(2),
|
borderBottom: 'none',
|
||||||
borderBottom: 'none',
|
background: theme.colors.background.primary,
|
||||||
background: theme.colors.background.primary,
|
display: 'flex',
|
||||||
display: 'flex',
|
flexDirection: 'column',
|
||||||
flexDirection: 'column',
|
flexGrow: 1,
|
||||||
flexGrow: 1,
|
margin: theme.spacing(0, 0, 0, 0),
|
||||||
margin: theme.spacing(0, 0, 0, 0),
|
|
||||||
|
[theme.breakpoints.up('md')]: {
|
||||||
|
padding: theme.spacing(4),
|
||||||
},
|
},
|
||||||
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),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
canvasContent: css({
|
canvasContent: css({
|
||||||
label: 'canvas-content',
|
label: 'canvas-content',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { BouncingLoader } from '../components/BouncingLoader/BouncingLoader';
|
import { BouncingLoader } from '../components/BouncingLoader/BouncingLoader';
|
||||||
@@ -11,11 +10,7 @@ export function GrafanaRouteLoading() {
|
|||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={styles.loadingPage}>
|
||||||
className={cx(styles.loadingPage, {
|
|
||||||
[styles.loadingPageDockedNav]: config.featureToggles.dockedMegaMenu,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<BouncingLoader />
|
<BouncingLoader />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -23,13 +18,11 @@ export function GrafanaRouteLoading() {
|
|||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
loadingPage: css({
|
loadingPage: css({
|
||||||
|
backgroundColor: theme.colors.background.primary,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
flexDrection: 'column',
|
flexDrection: 'column',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: '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';
|
import { t } from 'app/core/internationalization';
|
||||||
|
|
||||||
// Maps the ID of the nav item to a translated phrase to later pass to <Trans />
|
// 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':
|
case 'plugin-page-grafana-slo-app':
|
||||||
return t('nav.slo.title', 'SLO');
|
return t('nav.slo.title', 'SLO');
|
||||||
case 'plugin-page-k6-app':
|
case 'plugin-page-k6-app':
|
||||||
return config.featureToggles.dockedMegaMenu
|
return t('nav.k6.title', 'Performance');
|
||||||
? t('nav.k6.title', 'Performance')
|
|
||||||
: t('nav.performance-testing.title', 'Performance testing');
|
|
||||||
case 'monitoring':
|
case 'monitoring':
|
||||||
return t('nav.observability.title', 'Observability');
|
return t('nav.observability.title', 'Observability');
|
||||||
case 'plugin-page-grafana-k8s-app':
|
case 'plugin-page-grafana-k8s-app':
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { Permissions } from 'app/core/components/AccessControl';
|
import { Permissions } from 'app/core/components/AccessControl';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { contextSrv } from 'app/core/core';
|
||||||
@@ -10,7 +9,7 @@ import { SettingsPageProps } from '../DashboardSettings/types';
|
|||||||
|
|
||||||
export const AccessControlDashboardPermissions = ({ dashboard, sectionNav }: SettingsPageProps) => {
|
export const AccessControlDashboardPermissions = ({ dashboard, sectionNav }: SettingsPageProps) => {
|
||||||
const canSetPermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsWrite);
|
const canSetPermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsWrite);
|
||||||
const pageNav = config.featureToggles.dockedMegaMenu ? sectionNav.node.parentItem : undefined;
|
const pageNav = sectionNav.node.parentItem;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page navModel={sectionNav} pageNav={pageNav}>
|
<Page navModel={sectionNav} pageNav={pageNav}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { AnnotationQuery, getDataSourceRef, NavModelItem } from '@grafana/data';
|
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 { Page } from 'app/core/components/Page/Page';
|
||||||
|
|
||||||
import { DashboardModel } from '../../state';
|
import { DashboardModel } from '../../state';
|
||||||
@@ -41,7 +41,7 @@ function getSubPageNav(
|
|||||||
editIndex: number | undefined,
|
editIndex: number | undefined,
|
||||||
node: NavModelItem
|
node: NavModelItem
|
||||||
): NavModelItem | undefined {
|
): NavModelItem | undefined {
|
||||||
const parentItem = config.featureToggles.dockedMegaMenu ? node.parentItem : undefined;
|
const parentItem = node.parentItem;
|
||||||
if (editIndex == null) {
|
if (editIndex == null) {
|
||||||
return parentItem;
|
return parentItem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { locationService } from '@grafana/runtime';
|
|||||||
import { Button, ToolbarButtonRow } from '@grafana/ui';
|
import { Button, ToolbarButtonRow } from '@grafana/ui';
|
||||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import config from 'app/core/config';
|
|
||||||
import { t } from 'app/core/internationalization';
|
import { t } from 'app/core/internationalization';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { AccessControlAction } from 'app/types';
|
import { AccessControlAction } from 'app/types';
|
||||||
@@ -185,14 +184,10 @@ function getSectionNav(
|
|||||||
text: t('dashboard-settings.settings.title', 'Settings'),
|
text: t('dashboard-settings.settings.title', 'Settings'),
|
||||||
children: [],
|
children: [],
|
||||||
icon: 'apps',
|
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) => ({
|
main.children = pages.map((page) => ({
|
||||||
text: page.title,
|
text: page.title,
|
||||||
icon: page.icon,
|
icon: page.icon,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function GeneralSettingsUnconnected({
|
|||||||
const [renderCounter, setRenderCounter] = useState(0);
|
const [renderCounter, setRenderCounter] = useState(0);
|
||||||
const [dashboardTitle, setDashboardTitle] = useState(dashboard.title);
|
const [dashboardTitle, setDashboardTitle] = useState(dashboard.title);
|
||||||
const [dashboardDescription, setDashboardDescription] = useState(dashboard.description);
|
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) => {
|
const onFolderChange = (newUID: string | undefined, newTitle: string | undefined) => {
|
||||||
dashboard.meta.folderUid = newUID;
|
dashboard.meta.folderUid = newUID;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { css } from '@emotion/css';
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { Button, CodeEditor, useStyles2 } from '@grafana/ui';
|
import { Button, CodeEditor, useStyles2 } from '@grafana/ui';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { Trans } from 'app/core/internationalization';
|
import { Trans } from 'app/core/internationalization';
|
||||||
@@ -14,7 +13,7 @@ import { SettingsPageProps } from './types';
|
|||||||
|
|
||||||
export function JsonEditorSettings({ dashboard, sectionNav }: SettingsPageProps) {
|
export function JsonEditorSettings({ dashboard, sectionNav }: SettingsPageProps) {
|
||||||
const [dashboardJson, setDashboardJson] = useState<string>(JSON.stringify(dashboard.getSaveModelClone(), null, 2));
|
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 () => {
|
const onClick = async () => {
|
||||||
await getDashboardSrv().saveJSONDashboard(dashboardJson);
|
await getDashboardSrv().saveJSONDashboard(dashboardJson);
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ describe('LinksSettings', () => {
|
|||||||
const linklessDashboard = createDashboardModelFixture({ links: [] });
|
const linklessDashboard = createDashboardModelFixture({ links: [] });
|
||||||
setup(linklessDashboard);
|
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(
|
expect(
|
||||||
screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add dashboard link'))
|
screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add dashboard link'))
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { NavModelItem } from '@grafana/data';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { config, locationService } from '@grafana/runtime';
|
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { NEW_LINK } from 'app/features/dashboard-scene/settings/links/utils';
|
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;
|
const isEditing = editIndex !== undefined;
|
||||||
|
|
||||||
let pageNav: NavModelItem | undefined;
|
let pageNav = sectionNav.node.parentItem;
|
||||||
|
|
||||||
if (config.featureToggles.dockedMegaMenu) {
|
|
||||||
pageNav = sectionNav.node.parentItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
const title = isNew ? 'New link' : 'Edit link';
|
const title = isNew ? 'New link' : 'Edit link';
|
||||||
@@ -46,13 +41,11 @@ export function LinksSettings({ dashboard, sectionNav, editIndex }: SettingsPage
|
|||||||
subTitle: description,
|
subTitle: description,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.featureToggles.dockedMegaMenu) {
|
const parentUrl = sectionNav.node.url;
|
||||||
const parentUrl = sectionNav.node.url;
|
pageNav.parentItem = sectionNav.node.parentItem && {
|
||||||
pageNav.parentItem = sectionNav.node.parentItem && {
|
...sectionNav.node.parentItem,
|
||||||
...sectionNav.node.parentItem,
|
url: parentUrl,
|
||||||
url: parentUrl,
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
|
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { Spinner, HorizontalGroup } from '@grafana/ui';
|
import { Spinner, HorizontalGroup } from '@grafana/ui';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import {
|
import {
|
||||||
@@ -139,7 +138,7 @@ export class VersionsSettings extends PureComponent<Props, State> {
|
|||||||
const canCompare = versions.filter((version) => version.checked).length === 2;
|
const canCompare = versions.filter((version) => version.checked).length === 2;
|
||||||
const showButtons = versions.length > 1;
|
const showButtons = versions.length > 1;
|
||||||
const hasMore = versions.length >= this.limit;
|
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') {
|
if (viewMode === 'compare') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
put: putMock,
|
put: putMock,
|
||||||
}),
|
}),
|
||||||
config: {
|
config: {
|
||||||
|
...jest.requireActual('@grafana/runtime').config,
|
||||||
loginError: false,
|
loginError: false,
|
||||||
buildInfo: {
|
buildInfo: {
|
||||||
version: 'v1.0',
|
version: 'v1.0',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ jest.mock('@grafana/runtime', () => ({
|
|||||||
get: jest.fn().mockResolvedValue([{ userId: 1, login: 'Test' }]),
|
get: jest.fn().mockResolvedValue([{ userId: 1, login: 'Test' }]),
|
||||||
}),
|
}),
|
||||||
config: {
|
config: {
|
||||||
|
...jest.requireActual('@grafana/runtime').config,
|
||||||
licenseInfo: {
|
licenseInfo: {
|
||||||
enabledFeatures: { teamsync: true },
|
enabledFeatures: { teamsync: true },
|
||||||
stateInfo: '',
|
stateInfo: '',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { PureComponent } from 'react';
|
|||||||
import { connect, ConnectedProps } from 'react-redux';
|
import { connect, ConnectedProps } from 'react-redux';
|
||||||
import { bindActionCreators } from '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 { Page } from 'app/core/components/Page/Page';
|
||||||
import { SettingsPageProps } from 'app/features/dashboard/components/DashboardSettings/types';
|
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 { editIndex, variables, sectionNav } = this.props;
|
||||||
const variableToEdit = editIndex != null ? variables[editIndex] : undefined;
|
const variableToEdit = editIndex != null ? variables[editIndex] : undefined;
|
||||||
const node = sectionNav.node;
|
const node = sectionNav.node;
|
||||||
const parentItem =
|
const parentItem = node.parentItem
|
||||||
config.featureToggles.dockedMegaMenu && node.parentItem
|
? {
|
||||||
? {
|
...node.parentItem,
|
||||||
...node.parentItem,
|
url: node.url,
|
||||||
url: node.url,
|
}
|
||||||
}
|
: undefined;
|
||||||
: undefined;
|
|
||||||
const subPageNav = variableToEdit ? { text: variableToEdit.name, parentItem } : parentItem;
|
const subPageNav = variableToEdit ? { text: variableToEdit.name, parentItem } : parentItem;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -883,9 +883,6 @@
|
|||||||
"subtitle": "Manage preferences across an organization",
|
"subtitle": "Manage preferences across an organization",
|
||||||
"title": "Default preferences"
|
"title": "Default preferences"
|
||||||
},
|
},
|
||||||
"performance-testing": {
|
|
||||||
"title": "Performance testing"
|
|
||||||
},
|
|
||||||
"playlists": {
|
"playlists": {
|
||||||
"subtitle": "Groups of dashboards that are displayed in a sequence",
|
"subtitle": "Groups of dashboards that are displayed in a sequence",
|
||||||
"title": "Playlists"
|
"title": "Playlists"
|
||||||
|
|||||||
@@ -883,9 +883,6 @@
|
|||||||
"subtitle": "Mäʼnäģę přęƒęřęʼnčęş äčřőşş äʼn őřģäʼnįžäŧįőʼn",
|
"subtitle": "Mäʼnäģę přęƒęřęʼnčęş äčřőşş äʼn őřģäʼnįžäŧįőʼn",
|
||||||
"title": "Đęƒäūľŧ přęƒęřęʼnčęş"
|
"title": "Đęƒäūľŧ přęƒęřęʼnčęş"
|
||||||
},
|
},
|
||||||
"performance-testing": {
|
|
||||||
"title": "Pęřƒőřmäʼnčę ŧęşŧįʼnģ"
|
|
||||||
},
|
|
||||||
"playlists": {
|
"playlists": {
|
||||||
"subtitle": "Ğřőūpş őƒ đäşĥþőäřđş ŧĥäŧ äřę đįşpľäyęđ įʼn ä şęqūęʼnčę",
|
"subtitle": "Ğřőūpş őƒ đäşĥþőäřđş ŧĥäŧ äřę đįşpľäyęđ įʼn ä şęqūęʼnčę",
|
||||||
"title": "Pľäyľįşŧş"
|
"title": "Pľäyľįşŧş"
|
||||||
|
|||||||
Reference in New Issue
Block a user