Chore: Clean up old navigation (#66287)

* remove code outside of the topnav feature flag

* delete NavBar folder

* remove topnav toggle from backend

* restructure AppChrome folder

* fix utils mock

* fix applinks tests

* remove tests since they're covered in e2e

* fix 1 of the approotpage tests

* Fix another dashboardpage test

* remove reverse portalling + test for plugins using deprecated onNavChanged method

* kick drone

* handle correlations
pull/66500/head
Ashley Harrison 2 years ago committed by GitHub
parent 202afb9041
commit 4abe0249ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      .betterer.results
  2. 1
      conf/defaults.ini
  3. 1
      conf/sample.ini
  4. 11
      e2e/cloud-plugins-suite/azure-monitor.spec.ts
  5. 11
      e2e/dashboards-suite/new-constant-variable.spec.ts
  6. 21
      e2e/dashboards-suite/new-custom-variable.spec.ts
  7. 11
      e2e/dashboards-suite/new-datasource-variable.spec.ts
  8. 11
      e2e/dashboards-suite/new-interval-variable.spec.ts
  9. 21
      e2e/dashboards-suite/new-query-variable.spec.ts
  10. 11
      e2e/dashboards-suite/new-text-box-variable.spec.ts
  11. 31
      e2e/dashboards-suite/set-options-from-ui.spec.ts
  12. 11
      e2e/dashboards-suite/templating-dashboard-links-and-variables.spec.ts
  13. 1
      package.json
  14. 2
      packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx
  15. 7
      pkg/api/api.go
  16. 4
      pkg/api/index.go
  17. 10
      pkg/services/licensing/oss.go
  18. 31
      pkg/services/navtree/models.go
  19. 21
      pkg/services/navtree/models_test.go
  20. 28
      pkg/services/navtree/navtreeimpl/admin.go
  21. 16
      pkg/services/navtree/navtreeimpl/applinks.go
  22. 75
      pkg/services/navtree/navtreeimpl/applinks_test.go
  23. 53
      pkg/services/navtree/navtreeimpl/navtree.go
  24. 25
      public/app/core/components/AppChrome/AppChrome.tsx
  25. 2
      public/app/core/components/AppChrome/MegaMenu/MegaMenu.test.tsx
  26. 3
      public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx
  27. 2
      public/app/core/components/AppChrome/MegaMenu/NavBarItemIcon.tsx
  28. 2
      public/app/core/components/AppChrome/MegaMenu/NavBarMenu.tsx
  29. 0
      public/app/core/components/AppChrome/MegaMenu/NavBarMenuItem.tsx
  30. 3
      public/app/core/components/AppChrome/MegaMenu/NavBarMenuItemWrapper.tsx
  31. 7
      public/app/core/components/AppChrome/MegaMenu/NavBarMenuSection.tsx
  32. 0
      public/app/core/components/AppChrome/MegaMenu/NavFeatureHighlight.tsx
  33. 31
      public/app/core/components/AppChrome/MegaMenu/navBarItem-translations.ts
  34. 66
      public/app/core/components/AppChrome/MegaMenu/utils.test.ts
  35. 64
      public/app/core/components/AppChrome/MegaMenu/utils.ts
  36. 6
      public/app/core/components/AppChrome/NavToolbar/NavToolbar.tsx
  37. 7
      public/app/core/components/AppChrome/NavToolbar/NavToolbarSeparator.tsx
  38. 0
      public/app/core/components/AppChrome/OrganizationSwitcher/OrganizationPicker.tsx
  39. 0
      public/app/core/components/AppChrome/OrganizationSwitcher/OrganizationSelect.tsx
  40. 0
      public/app/core/components/AppChrome/OrganizationSwitcher/OrganizationSwitcher.test.tsx
  41. 0
      public/app/core/components/AppChrome/OrganizationSwitcher/OrganizationSwitcher.tsx
  42. 0
      public/app/core/components/AppChrome/OrganizationSwitcher/types.ts
  43. 2
      public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx
  44. 2
      public/app/core/components/AppChrome/TopBar/TopNavBarMenu.tsx
  45. 16
      public/app/core/components/AppChrome/TopBar/TopSearchBar.tsx
  46. 0
      public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx
  47. 51
      public/app/core/components/NavBar/NavBar.test.tsx
  48. 296
      public/app/core/components/NavBar/NavBar.tsx
  49. 247
      public/app/core/components/NavBar/NavBarItem.test.tsx
  50. 115
      public/app/core/components/NavBar/NavBarItem.tsx
  51. 120
      public/app/core/components/NavBar/NavBarItemMenu.tsx
  52. 92
      public/app/core/components/NavBar/NavBarItemMenuItem.tsx
  53. 254
      public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx
  54. 117
      public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx
  55. 54
      public/app/core/components/NavBar/NavBarMenu.test.tsx
  56. 467
      public/app/core/components/NavBar/NavBarMenu.tsx
  57. 56
      public/app/core/components/NavBar/NavBarMenuItem.test.tsx
  58. 153
      public/app/core/components/NavBar/NavBarMenuItem.tsx
  59. 27
      public/app/core/components/NavBar/NavBarMenuPortalContainer.tsx
  60. 43
      public/app/core/components/NavBar/NavBarToggle.tsx
  61. 32
      public/app/core/components/NavBar/context.tsx
  62. 0
      public/app/core/components/NavLandingPage/NavLandingPage.test.tsx
  63. 0
      public/app/core/components/NavLandingPage/NavLandingPage.tsx
  64. 0
      public/app/core/components/NavLandingPage/NavLandingPageCard.test.tsx
  65. 0
      public/app/core/components/NavLandingPage/NavLandingPageCard.tsx
  66. 10
      public/app/core/components/Page/OldNavOnly.tsx
  67. 2
      public/app/core/components/Page/Page.tsx
  68. 2
      public/app/core/components/Page/types.ts
  69. 4
      public/app/core/components/PageNew/Page.tsx
  70. 10
      public/app/core/components/help/HelpModal.tsx
  71. 2
      public/app/core/reducers/navBarTree.ts
  72. 2
      public/app/core/reducers/navModel.ts
  73. 22
      public/app/core/services/keybindingSrv.ts
  74. 25
      public/app/features/alerting/routes.tsx
  75. 3
      public/app/features/alerting/unified/Home.tsx
  76. 2
      public/app/features/connections/Connections.tsx
  77. 3
      public/app/features/connections/pages/DataSourcesListPage.tsx
  78. 7
      public/app/features/correlations/CorrelationsPage.test.tsx
  79. 10
      public/app/features/correlations/CorrelationsPage.tsx
  80. 54
      public/app/features/dashboard/components/DashNav/DashNav.test.tsx
  81. 69
      public/app/features/dashboard/components/DashNav/DashNav.tsx
  82. 35
      public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx
  83. 19
      public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx
  84. 29
      public/app/features/dashboard/containers/DashboardPage.test.tsx
  85. 2
      public/app/features/dashboard/containers/DashboardPage.tsx
  86. 33
      public/app/features/datasources/components/DataSourcesListHeader.tsx
  87. 2
      public/app/features/datasources/pages/DataSourcesListPage.tsx
  88. 12
      public/app/features/explore/Explore.test.tsx
  89. 23
      public/app/features/explore/ExploreToolbar.tsx
  90. 4
      public/app/features/explore/LogsNavigation.tsx
  91. 1
      public/app/features/folders/components/NewDashboardsFolder.tsx
  92. 5
      public/app/features/org/UserInvitePage.tsx
  93. 61
      public/app/features/plugins/components/AppRootPage.test.tsx
  94. 16
      public/app/features/plugins/components/AppRootPage.tsx
  95. 14
      public/app/features/plugins/utils.test.ts
  96. 6
      public/app/features/plugins/utils.ts
  97. 2
      public/app/features/profile/ChangePasswordPage.test.tsx
  98. 3
      public/app/features/profile/ChangePasswordPage.tsx
  99. 10
      public/app/features/scenes/dashboard/DashboardScene.tsx
  100. 115
      public/app/features/search/components/DashboardSearch.tsx
  101. Some files were not shown because too many files have changed in this diff Show More

@ -1537,11 +1537,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"],
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"]
],
"public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"public/app/core/components/OptionsUI/registry.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],

@ -1462,7 +1462,6 @@ index_update_interval = 10s
# Move an app plugin referenced by its id (including all its pages) to a specific navigation section
# Dependencies: needs the `topnav` feature to be enabled
# Format: <Plugin ID> = <Section ID> <Sort Weight>
[navigation.app_sections]

@ -1392,7 +1392,6 @@
;enable_custom_baselayers = true
# Move an app plugin referenced by its id (including all its pages) to a specific navigation section
# Dependencies: needs the `topnav` feature to be enabled
[navigation.app_sections]
# The following will move an app plugin with the id of `my-app-id` under the `starred` section
# my-app-id = admin

@ -2,7 +2,6 @@ import { load } from 'js-yaml';
import { v4 as uuidv4 } from 'uuid';
import { e2e } from '@grafana/e2e';
import { GrafanaBootConfig } from '@grafana/runtime';
import { selectors } from '../../public/app/plugins/datasource/azuremonitor/e2e/selectors';
import {
@ -97,15 +96,7 @@ const addAzureMonitorVariable = (
break;
}
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.pages.Dashboard.Settings.Actions.close().click();
} else {
e2e.components.PageToolbar.item('Go Back').click();
}
});
e2e.pages.Dashboard.Settings.Actions.close().click();
};
e2e.scenario({

@ -1,5 +1,4 @@
import { e2e } from '@grafana/e2e';
import { GrafanaBootConfig } from '@grafana/runtime';
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
const DASHBOARD_NAME = 'Test variable output';
@ -24,15 +23,7 @@ describe('Variables - Constant', () => {
// Navigate back to the homepage and change the selected variable value
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.pages.Dashboard.Settings.Actions.close().click();
} else {
e2e.components.BackButton.backArrow().click({ force: true });
}
});
e2e.pages.Dashboard.Settings.Actions.close().click();
e2e.components.RefreshPicker.runButtonV2().click();
// Assert it was rendered

@ -1,5 +1,4 @@
import { e2e } from '@grafana/e2e';
import { GrafanaBootConfig } from '@grafana/runtime';
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
const DASHBOARD_NAME = 'Test variable output';
@ -33,15 +32,7 @@ describe('Variables - Custom', () => {
// Navigate back to the homepage and change the selected variable value
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.pages.Dashboard.Settings.Actions.close().click();
} else {
e2e.components.BackButton.backArrow().click({ force: true });
}
});
e2e.pages.Dashboard.Settings.Actions.close().click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('one').click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('two').click();
@ -66,15 +57,7 @@ describe('Variables - Custom', () => {
// Navigate back to the homepage and change the selected variable value
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.pages.Dashboard.Settings.Actions.close().click();
} else {
e2e.components.BackButton.backArrow().click({ force: true });
}
});
e2e.pages.Dashboard.Settings.Actions.close().click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('One').click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('Two').click();

@ -1,5 +1,4 @@
import { e2e } from '@grafana/e2e';
import { GrafanaBootConfig } from '@grafana/runtime';
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
const DASHBOARD_NAME = 'Test variable output';
@ -32,15 +31,7 @@ describe('Variables - Datasource', () => {
// Navigate back to the homepage and change the selected variable value
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.pages.Dashboard.Settings.Actions.close().click();
} else {
e2e.components.BackButton.backArrow().click({ force: true });
}
});
e2e.pages.Dashboard.Settings.Actions.close().click();
e2e.components.RefreshPicker.runButtonV2().click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('gdev-prometheus').click();

@ -1,5 +1,4 @@
import { e2e } from '@grafana/e2e';
import { GrafanaBootConfig } from '@grafana/runtime';
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
const DASHBOARD_NAME = 'Test variable output';
@ -34,15 +33,7 @@ describe('Variables - Interval', () => {
// Navigate back to the homepage and change the selected variable value
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.pages.Dashboard.Settings.Actions.close().click();
} else {
e2e.components.BackButton.backArrow().click({ force: true });
}
});
e2e.pages.Dashboard.Settings.Actions.close().click();
e2e.components.RefreshPicker.runButtonV2().click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('10s').click();

@ -1,5 +1,4 @@
import { e2e } from '@grafana/e2e';
import { GrafanaBootConfig } from '@grafana/runtime';
const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
const DASHBOARD_NAME = 'Templating - Nested Template Variables';
@ -106,15 +105,7 @@ describe('Variables - Query - Add variable', () => {
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().scrollIntoView().should('be.visible').click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.pages.Dashboard.Settings.Actions.close().click();
} else {
e2e.components.BackButton.backArrow().click({ force: true });
}
});
e2e.pages.Dashboard.Settings.Actions.close().click();
e2e.pages.Dashboard.SubMenu.submenuItemLabels('a label').should('be.visible');
e2e.pages.Dashboard.SubMenu.submenuItem()
@ -179,15 +170,7 @@ describe('Variables - Query - Add variable', () => {
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().scrollIntoView().should('be.visible').click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.pages.Dashboard.Settings.Actions.close().click();
} else {
e2e.components.BackButton.backArrow().click({ force: true });
}
});
e2e.pages.Dashboard.Settings.Actions.close().click();
e2e.pages.Dashboard.SubMenu.submenuItemLabels('a label').should('be.visible');
e2e.pages.Dashboard.SubMenu.submenuItem()

@ -1,5 +1,4 @@
import { e2e } from '@grafana/e2e';
import { GrafanaBootConfig } from '@grafana/runtime';
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
const DASHBOARD_NAME = 'Test variable output';
@ -24,15 +23,7 @@ describe('Variables - Text box', () => {
// Navigate back to the homepage and change the selected variable value
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.pages.Dashboard.Settings.Actions.close().click();
} else {
e2e.components.BackButton.backArrow().click({ force: true });
}
});
e2e.pages.Dashboard.Settings.Actions.close().click();
e2e().get('#var-VariableUnderTest').clear().type('dog-cat').blur();
// Assert it was rendered

@ -1,5 +1,4 @@
import { e2e } from '@grafana/e2e';
import { GrafanaBootConfig } from '@grafana/runtime';
const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
@ -12,15 +11,7 @@ describe('Variables - Set options from ui', () => {
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible').click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.components.NavToolbar.container().click();
} else {
e2e.components.PageToolbar.container().click();
}
});
e2e.components.NavToolbar.container().click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('B').scrollIntoView().should('be.visible');
@ -72,15 +63,7 @@ describe('Variables - Set options from ui', () => {
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A').should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible').click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.components.NavToolbar.container().click();
} else {
e2e.components.PageToolbar.container().click();
}
});
e2e.components.NavToolbar.container().click();
e2e().wait('@query');
@ -130,15 +113,7 @@ describe('Variables - Set options from ui', () => {
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A + B').should('be.visible').click();
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible').click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.components.NavToolbar.container().click();
} else {
e2e.components.PageToolbar.container().click();
}
});
e2e.components.NavToolbar.container().click();
e2e().wait('@query');

@ -1,5 +1,4 @@
import { e2e } from '@grafana/e2e';
import { GrafanaBootConfig } from '@grafana/runtime';
e2e.scenario({
describeName: 'Templating',
@ -47,15 +46,7 @@ e2e.scenario({
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('p2').should('be.visible').click();
e2e()
.window()
.then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => {
if (win.grafanaBootData.settings.featureToggles.topnav) {
e2e.components.NavToolbar.container().click();
} else {
e2e.components.PageToolbar.container().click();
}
});
e2e.components.NavToolbar.container().click();
e2e.components.DashboardLinks.dropDown()
.scrollIntoView()
.should('be.visible')

@ -384,7 +384,6 @@
"react-popper-tooltip": "4.4.2",
"react-redux": "7.2.6",
"react-resizable": "3.0.4",
"react-reverse-portal": "2.1.1",
"react-router-dom": "5.3.3",
"react-select": "5.7.0",
"react-split-pane": "0.1.92",

@ -194,7 +194,7 @@ const getStyles = (theme: GrafanaTheme2) => {
}
}
`,
default: theme.flags.topnav ? defaultTopNav : defaultOld,
default: defaultTopNav,
canvas: defaultOld,
active: css`
color: ${theme.v1.palette.orangeDark};

@ -104,12 +104,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/configuration", reqGrafanaAdmin, hs.Index)
r.Get("/admin", reqOrgAdmin, hs.Index)
r.Get("/admin/settings", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)), hs.Index)
// Show the combined users page for org admins if topnav is enabled
if hs.Features.IsEnabled(featuremgmt.FlagTopnav) {
r.Get("/admin/users", authorize(reqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))), hs.Index)
} else {
r.Get("/admin/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), hs.Index)
}
r.Get("/admin/users", authorize(reqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))), hs.Index)
r.Get("/admin/users/create", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index)
r.Get("/admin/users/edit/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead)), hs.Index)
r.Get("/admin/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index)

@ -166,8 +166,8 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
hs.HooksService.RunIndexDataHooks(&data, c)
// This will remove empty cfg or admin sections and move sections around if topnav is enabled
data.NavTree.RemoveEmptySectionsAndApplyNewInformationArchitecture(hs.Features.IsEnabled(featuremgmt.FlagTopnav))
// This will remove empty cfg or admin sections and move sections around
data.NavTree.RemoveEmptySectionsAndApplyNewInformationArchitecture()
data.NavTree.Sort()
return &data, nil

@ -59,15 +59,7 @@ func ProvideService(cfg *setting.Cfg, hooksService *hooks.HooksService) *OSSLice
return
}
var adminNodeID string
if cfg.IsFeatureToggleEnabled("topnav") {
adminNodeID = navtree.NavIDCfg
} else {
adminNodeID = navtree.NavIDAdmin
}
if adminNode := indexData.NavTree.FindById(adminNodeID); adminNode != nil {
if adminNode := indexData.NavTree.FindById(navtree.NavIDCfg); adminNode != nil {
adminNode.Children = append(adminNode.Children, &navtree.NavLink{
Text: "Stats and license",
Id: "upgrading",

@ -100,7 +100,7 @@ func (root *NavTreeRoot) FindById(id string) *NavLink {
return FindById(root.Children, id)
}
func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture(topNavEnabled bool) {
func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture() {
// Remove server admin node if it has no children or set the url to first child
if node := root.FindById(NavIDAdmin); node != nil {
if len(node.Children) == 0 {
@ -110,31 +110,26 @@ func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture(t
}
}
if topNavEnabled {
ApplyAdminIA(root)
ApplyAdminIA(root)
// Move reports into dashboards
if reports := root.FindById(NavIDReporting); reports != nil {
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
reports.SortWeight = 0
dashboards.Children = append(dashboards.Children, reports)
root.RemoveSection(reports)
}
}
// Change id of dashboards
// Move reports into dashboards
if reports := root.FindById(NavIDReporting); reports != nil {
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
dashboards.Id = "dashboards/browse"
reports.SortWeight = 0
dashboards.Children = append(dashboards.Children, reports)
root.RemoveSection(reports)
}
}
// Remove top level cfg / administration node if it has no children (needs to be after topnav new info archicture logic above that moves server admin into it)
// Remove server admin node if it has no children or set the url to first child
// Change id of dashboards
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
dashboards.Id = "dashboards/browse"
}
// Remove top level cfg / administration node if it has no children
if node := root.FindById(NavIDCfg); node != nil {
if len(node.Children) == 0 {
root.RemoveSection(node)
} else if !topNavEnabled {
node.Url = node.Children[0].Url
}
}

@ -15,25 +15,12 @@ func TestNavTreeRoot(t *testing.T) {
},
}
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(false)
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture()
require.Equal(t, 0, len(treeRoot.Children))
})
t.Run("Should not remove admin sections when they have children", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: NavIDCfg, Children: []*NavLink{{Id: "child"}}},
{Id: NavIDAdmin, Children: []*NavLink{{Id: "child"}}},
},
}
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(false)
require.Equal(t, 2, len(treeRoot.Children))
})
t.Run("Should create 3 new sections in the Admin node when topnav is enabled", func(t *testing.T) {
t.Run("Should create 3 new sections in the Admin node", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: NavIDCfg},
@ -41,7 +28,7 @@ func TestNavTreeRoot(t *testing.T) {
},
}
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(true)
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture()
require.Equal(t, "Administration", treeRoot.Children[0].Text)
})
@ -54,7 +41,7 @@ func TestNavTreeRoot(t *testing.T) {
},
}
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(true)
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture()
require.Equal(t, NavIDReporting, treeRoot.Children[0].Children[0].Id)
})

@ -36,18 +36,6 @@ func (s *ServiceImpl) getOrgAdminNode(c *contextmodel.ReqContext) (*navtree.NavL
})
}
if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) {
configNodes = append(configNodes, &navtree.NavLink{
Text: "Users",
Id: "users",
SubTitle: "Invite and assign roles to users",
Icon: "user",
Url: s.cfg.AppSubURL + "/org/users",
})
}
}
if hasAccess(s.ReqCanAdminTeams, ac.TeamsAccessEvaluator) {
configNodes = append(configNodes, &navtree.NavLink{
Text: "Teams",
@ -122,18 +110,10 @@ func (s *ServiceImpl) getServerAdminNode(c *contextmodel.ReqContext) *navtree.Na
orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead)
adminNavLinks := []*navtree.NavLink{}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
if hasAccess(ac.ReqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Users", SubTitle: "Manage users in Grafana", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
})
}
} else {
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Users", SubTitle: "Manage and create users across the whole Grafana server", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
})
}
if hasAccess(ac.ReqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Users", SubTitle: "Manage users in Grafana", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
})
}
authConfigUIAvailable := s.license.FeatureEnabled("saml") && s.features.IsEnabled(featuremgmt.FlagAuthenticationConfigUI)

@ -16,7 +16,6 @@ import (
)
func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *contextmodel.ReqContext) error {
topNavEnabled := s.features.IsEnabled(featuremgmt.FlagTopnav)
hasAccess := ac.HasAccess(s.accessControl, c)
appLinks := []*navtree.NavLink{}
@ -47,7 +46,7 @@ func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *contextmodel
continue
}
if appNode := s.processAppPlugin(plugin, c, topNavEnabled, treeRoot); appNode != nil {
if appNode := s.processAppPlugin(plugin, c, treeRoot); appNode != nil {
appLinks = append(appLinks, appNode)
}
}
@ -65,7 +64,7 @@ func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *contextmodel
return nil
}
func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *contextmodel.ReqContext, topNavEnabled bool, treeRoot *navtree.NavTreeRoot) *navtree.NavLink {
func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *contextmodel.ReqContext, treeRoot *navtree.NavTreeRoot) *navtree.NavLink {
hasAccessToInclude := s.hasAccessToInclude(c, plugin.ID)
appLink := &navtree.NavLink{
Text: plugin.Name,
@ -76,12 +75,7 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *contextmodel
SortWeight: navtree.WeightPlugin,
IsSection: true,
PluginID: plugin.ID,
}
if topNavEnabled {
appLink.Url = s.cfg.AppSubURL + "/a/" + plugin.ID
} else {
appLink.Url = path.Join(s.cfg.AppSubURL, plugin.DefaultNavURL)
Url: s.cfg.AppSubURL + "/a/" + plugin.ID,
}
for _, include := range plugin.Includes {
@ -159,10 +153,6 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *contextmodel
appLink.Children = []*navtree.NavLink{}
}
if !topNavEnabled {
return appLink
}
// Remove default nav child
childrenWithoutDefault := []*navtree.NavLink{}
for _, child := range appLink.Children {

@ -119,17 +119,7 @@ func TestAddAppLinks(t *testing.T) {
},
}
t.Run("Should add enabled apps with pages", func(t *testing.T) {
treeRoot := navtree.NavTreeRoot{}
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
require.Equal(t, "Test app1 name", treeRoot.Children[0].Text)
require.Equal(t, "/a/test-app1/catalog", treeRoot.Children[0].Url)
require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[1].Url)
})
t.Run("Should move apps to Apps category when topnav is enabled", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
t.Run("Should move apps to Apps category", func(t *testing.T) {
treeRoot := navtree.NavTreeRoot{}
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
@ -141,8 +131,17 @@ func TestAddAppLinks(t *testing.T) {
require.Equal(t, testApp1.Name, appsNode.Children[0].Text)
})
t.Run("Should remove the default nav child (DefaultNav=true) when topnav is enabled and should set its URL to the plugin nav root", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
t.Run("Should add enabled apps with pages", func(t *testing.T) {
treeRoot := navtree.NavTreeRoot{}
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
appsNode := treeRoot.FindById(navtree.NavIDApps)
require.Equal(t, "Test app1 name", appsNode.Children[0].Text)
require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Url)
require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url)
})
t.Run("Should remove the default nav child (DefaultNav=true) and should set its URL to the plugin nav root", func(t *testing.T) {
treeRoot := navtree.NavTreeRoot{}
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
@ -155,7 +154,6 @@ func TestAddAppLinks(t *testing.T) {
// This can be done by using `[navigation.app_sections]` in the INI config
t.Run("Should move apps that have root nav id configured to the root", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
service.navigationAppConfig = map[string]NavigationAppConfig{
"test-app1": {SectionID: navtree.NavIDRoot},
}
@ -179,7 +177,6 @@ func TestAddAppLinks(t *testing.T) {
// This can be done by using `[navigation.app_sections]` in the INI config
t.Run("Should move apps that have specific nav id configured to correct section", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
service.navigationAppConfig = map[string]NavigationAppConfig{
"test-app1": {SectionID: navtree.NavIDAdmin},
}
@ -207,7 +204,6 @@ func TestAddAppLinks(t *testing.T) {
})
t.Run("Should only add a 'Monitoring' section if a plugin exists that wants to live there", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
service.navigationAppConfig = map[string]NavigationAppConfig{}
// Check if the Monitoring section is not there if no apps try to register to it
@ -231,7 +227,6 @@ func TestAddAppLinks(t *testing.T) {
})
t.Run("Should add a 'Alerts and Incidents' section if a plugin exists that wants to live there", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
service.navigationAppConfig = map[string]NavigationAppConfig{}
// Check if the 'Alerts and Incidents' section is not there if no apps try to register to it
@ -257,7 +252,6 @@ func TestAddAppLinks(t *testing.T) {
})
t.Run("Should add a 'Alerts and Incidents' section if a plugin exists that wants to live there even without an alerting node", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
service.navigationAppConfig = map[string]NavigationAppConfig{}
// Check if the 'Alerts and Incidents' section is not there if no apps try to register to it
@ -281,7 +275,6 @@ func TestAddAppLinks(t *testing.T) {
})
t.Run("Should be able to control app sort order with SortWeight (smaller SortWeight displayed first)", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
service.navigationAppConfig = map[string]NavigationAppConfig{
"test-app2": {SectionID: navtree.NavIDMonitoring, SortWeight: 2},
"test-app1": {SectionID: navtree.NavIDMonitoring, SortWeight: 3},
@ -300,7 +293,7 @@ func TestAddAppLinks(t *testing.T) {
})
t.Run("Should replace page from plugin", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav, featuremgmt.FlagDataConnectionsConsole)
service.features = featuremgmt.WithFeatures(featuremgmt.FlagDataConnectionsConsole)
service.navigationAppConfig = map[string]NavigationAppConfig{}
service.navigationAppPathConfig = map[string]NavigationAppConfig{
"/connections/connect-data": {SectionID: "connections"},
@ -340,7 +333,7 @@ func TestAddAppLinks(t *testing.T) {
})
t.Run("Should not register pages under the app plugin section unless AddToNav=true", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav, featuremgmt.FlagDataConnectionsConsole)
service.features = featuremgmt.WithFeatures(featuremgmt.FlagDataConnectionsConsole)
service.navigationAppPathConfig = map[string]NavigationAppConfig{} // We don't configure it as a standalone plugin page
treeRoot := navtree.NavTreeRoot{}
@ -466,11 +459,12 @@ func TestAddAppLinksAccessControl(t *testing.T) {
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
require.Len(t, treeRoot.Children, 1)
require.Equal(t, "Test app1 name", treeRoot.Children[0].Text)
require.Len(t, treeRoot.Children[0].Children, 2)
require.Equal(t, "/a/test-app1/catalog", treeRoot.Children[0].Children[0].Url)
require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[1].Url)
appsNode := treeRoot.FindById(navtree.NavIDApps)
require.Len(t, appsNode.Children, 1)
require.Equal(t, "Test app1 name", appsNode.Children[0].Text)
require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Url)
require.Len(t, appsNode.Children[0].Children, 1)
require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url)
})
t.Run("Should add one include when the user is a viewer", func(t *testing.T) {
treeRoot := navtree.NavTreeRoot{}
@ -481,10 +475,11 @@ func TestAddAppLinksAccessControl(t *testing.T) {
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
require.Len(t, treeRoot.Children, 1)
require.Equal(t, "Test app1 name", treeRoot.Children[0].Text)
require.Len(t, treeRoot.Children[0].Children, 1)
require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[0].Url)
appsNode := treeRoot.FindById(navtree.NavIDApps)
require.Len(t, appsNode.Children, 1)
require.Equal(t, "Test app1 name", appsNode.Children[0].Text)
require.Len(t, appsNode.Children[0].Children, 1)
require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url)
})
t.Run("Should add both includes when the user is a viewer with catalog read", func(t *testing.T) {
treeRoot := navtree.NavTreeRoot{}
@ -496,11 +491,12 @@ func TestAddAppLinksAccessControl(t *testing.T) {
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
require.Len(t, treeRoot.Children, 1)
require.Equal(t, "Test app1 name", treeRoot.Children[0].Text)
require.Len(t, treeRoot.Children[0].Children, 2)
require.Equal(t, "/a/test-app1/catalog", treeRoot.Children[0].Children[0].Url)
require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[1].Url)
appsNode := treeRoot.FindById(navtree.NavIDApps)
require.Len(t, appsNode.Children, 1)
require.Equal(t, "Test app1 name", appsNode.Children[0].Text)
require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Url)
require.Len(t, appsNode.Children[0].Children, 1)
require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url)
})
t.Run("Should add one include when the user is an editor without catalog read", func(t *testing.T) {
treeRoot := navtree.NavTreeRoot{}
@ -512,9 +508,10 @@ func TestAddAppLinksAccessControl(t *testing.T) {
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
require.Len(t, treeRoot.Children, 1)
require.Equal(t, "Test app1 name", treeRoot.Children[0].Text)
require.Len(t, treeRoot.Children[0].Children, 1)
require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[0].Url)
appsNode := treeRoot.FindById(navtree.NavIDApps)
require.Len(t, appsNode.Children, 1)
require.Equal(t, "Test app1 name", appsNode.Children[0].Text)
require.Len(t, appsNode.Children[0].Children, 1)
require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url)
})
}

@ -195,9 +195,6 @@ func (s *ServiceImpl) getHomeNode(c *contextmodel.ReqContext, prefs *pref.Prefer
Section: navtree.NavSectionCore,
SortWeight: navtree.WeightHome,
}
if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
homeNode.HideFromMenu = true
}
return homeNode
}
@ -345,12 +342,6 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext, hasEdit
dashboardChildNavs := []*navtree.NavLink{}
if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Browse", Id: navtree.NavIDDashboardsBrowse, Url: s.cfg.AppSubURL + "/dashboards", Icon: "sitemap",
})
}
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Playlists", SubTitle: "Groups of dashboards that are displayed in a sequence", Id: "dashboards/playlists", Url: s.cfg.AppSubURL + "/playlists", Icon: "presentation-play",
})
@ -393,12 +384,6 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext, hasEdit
})
}
if hasEditPerm && !s.features.IsEnabled(featuremgmt.FlagTopnav) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
})
}
if hasEditPerm {
if hasAccess(hasEditPermInAnyFolder, ac.EvalPermission(dashboards.ActionDashboardsCreate)) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
@ -412,15 +397,6 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext, hasEdit
}
}
if hasEditPerm && !s.features.IsEnabled(featuremgmt.FlagTopnav) {
if hasAccess(ac.ReqOrgAdminOrEditor, ac.EvalPermission(dashboards.ActionFoldersCreate)) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "New folder", SubTitle: "Create a new folder to organize your dashboards", Id: "dashboards/folder/new",
Icon: "plus", Url: s.cfg.AppSubURL + "/dashboards/folder/new", HideFromTabs: true, ShowIconInNavbar: true,
})
}
}
return dashboardChildNavs
}
@ -445,12 +421,7 @@ func (s *ServiceImpl) buildLegacyAlertNavLinks(c *contextmodel.ReqContext) *navt
Children: alertChildNavs,
Section: navtree.NavSectionCore,
SortWeight: navtree.WeightAlerting,
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
alertNav.Url = s.cfg.AppSubURL + "/alerting"
} else {
alertNav.Url = s.cfg.AppSubURL + "/alerting/list"
Url: s.cfg.AppSubURL + "/alerting",
}
return &alertNav
@ -460,15 +431,6 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext, hasEditPerm
hasAccess := ac.HasAccess(s.accessControl, c)
var alertChildNavs []*navtree.NavLink
if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Home",
Id: "alert-home",
Url: s.cfg.AppSubURL + "/alerting/home",
Icon: "home",
})
}
if hasAccess(ac.ReqViewer, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert rules", SubTitle: "Rules that determine whether an alert will fire", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
@ -498,12 +460,6 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext, hasEditPerm
fallbackHasEditPerm := func(*contextmodel.ReqContext) bool { return hasEditPerm }
if hasAccess(fallbackHasEditPerm, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) {
if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
})
}
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Create alert rule", SubTitle: "Create an alert rule", Id: "alert",
Icon: "plus", Url: s.cfg.AppSubURL + "/alerting/new", HideFromTabs: true, ShowIconInNavbar: true, IsCreateAction: true,
@ -519,12 +475,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext, hasEditPerm
Children: alertChildNavs,
Section: navtree.NavSectionCore,
SortWeight: navtree.WeightAlerting,
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
alertNav.Url = s.cfg.AppSubURL + "/alerting"
} else {
alertNav.Url = s.cfg.AppSubURL + "/alerting/home"
Url: s.cfg.AppSubURL + "/alerting",
}
return &alertNav

@ -2,18 +2,14 @@ import { css, cx } from '@emotion/css';
import React, { PropsWithChildren } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { CommandPalette } from 'app/features/commandPalette/CommandPalette';
import { SearchWrapper } from 'app/features/search';
import { KioskMode } from 'app/types';
import { MegaMenu } from '../MegaMenu/MegaMenu';
import { NavBar } from '../NavBar/NavBar';
import { NavToolbar } from './NavToolbar';
import { TopSearchBar } from './TopSearchBar';
import { MegaMenu } from './MegaMenu/MegaMenu';
import { NavToolbar } from './NavToolbar/NavToolbar';
import { TopSearchBar } from './TopBar/TopSearchBar';
import { TOP_BAR_LEVEL_HEIGHT } from './types';
export interface Props extends PropsWithChildren<{}> {}
@ -23,21 +19,6 @@ export function AppChrome({ children }: Props) {
const { chrome } = useGrafana();
const state = chrome.useState();
if (!config.featureToggles.topnav) {
return (
<>
{!state.chromeless && (
<>
<NavBar />
<SearchWrapper />
<CommandPalette />
</>
)}
<main className="main-view">{children}</main>
</>
);
}
const searchBarHidden = state.searchBarHidden || state.kioskMode === KioskMode.TV;
const contentClass = cx({

@ -6,7 +6,7 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { NavModelItem, NavSection } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { TestProvider } from '../../../../test/helpers/TestProvider';
import { TestProvider } from '../../../../../test/helpers/TestProvider';
import { MegaMenu } from './MegaMenu';

@ -7,9 +7,8 @@ import { GrafanaTheme2, NavSection } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { useSelector } from 'app/types';
import { enrichConfigItems, enrichWithInteractionTracking, getActiveItem } from '../NavBar/utils';
import { NavBarMenu } from './NavBarMenu';
import { enrichConfigItems, enrichWithInteractionTracking, getActiveItem } from './utils';
export interface Props {
onClose: () => void;

@ -4,7 +4,7 @@ import React from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Icon, toIconName, useTheme2 } from '@grafana/ui';
import { Branding } from '../Branding/Branding';
import { Branding } from '../../Branding/Branding';
interface NavBarItemIconProps {
link: NavModelItem;

@ -9,7 +9,7 @@ import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { TOP_BAR_LEVEL_HEIGHT } from '../AppChrome/types';
import { TOP_BAR_LEVEL_HEIGHT } from '../types';
import { NavBarMenuItemWrapper } from './NavBarMenuItemWrapper';

@ -4,10 +4,9 @@ import React from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { toIconName, useStyles2 } from '@grafana/ui';
import { isMatchOrChildMatch } from '../NavBar/utils';
import { NavBarMenuItem } from './NavBarMenuItem';
import { NavBarMenuSection } from './NavBarMenuSection';
import { isMatchOrChildMatch } from './utils';
export function NavBarMenuItemWrapper({
link,

@ -5,11 +5,10 @@ import { useLocalStorage } from 'react-use';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Button, Icon, useStyles2 } from '@grafana/ui';
import { NavBarItemIcon } from '../NavBar/NavBarItemIcon';
import { NavFeatureHighlight } from '../NavBar/NavFeatureHighlight';
import { hasChildMatch } from '../NavBar/utils';
import { NavBarItemIcon } from './NavBarItemIcon';
import { NavBarMenuItem } from './NavBarMenuItem';
import { NavFeatureHighlight } from './NavFeatureHighlight';
import { hasChildMatch } from './utils';
export function NavBarMenuSection({
link,

@ -1,4 +1,3 @@
import { config } from '@grafana/runtime';
import { t } from 'app/core/internationalization';
// Maps the ID of the nav item to a translated phrase to later pass to <Trans />
@ -30,9 +29,7 @@ export function getNavTitle(navId: string | undefined) {
case 'dashboards':
return t('nav.dashboards.title', 'Dashboards');
case 'dashboards/browse':
return config.featureToggles.topnav
? t('nav.dashboards.title', 'Dashboards')
: t('nav.manage-dashboards.title', 'Browse');
return t('nav.dashboards.title', 'Dashboards');
case 'dashboards/playlists':
return t('nav.playlists.title', 'Playlists');
case 'dashboards/snapshots':
@ -72,9 +69,7 @@ export function getNavTitle(navId: string | undefined) {
case 'alerting-admin':
return t('nav.alerting-admin.title', 'Admin');
case 'cfg':
return config.featureToggles.topnav
? t('nav.config.title', 'Administration')
: t('nav.config.titleBeforeTopnav', 'Configuration');
return t('nav.config.title', 'Administration');
case 'datasources':
return t('nav.datasources.title', 'Data sources');
case 'correlations':
@ -86,9 +81,7 @@ export function getNavTitle(navId: string | undefined) {
case 'plugins':
return t('nav.plugins.title', 'Plugins');
case 'org-settings':
return config.featureToggles.topnav
? t('nav.org-settings.title', 'Default preferences')
: t('nav.org-settings.titleBeforeTopnav', 'Preferences');
return t('nav.org-settings.title', 'Default preferences');
case 'apikeys':
return t('nav.api-keys.title', 'API keys');
case 'serviceaccounts':
@ -98,9 +91,7 @@ export function getNavTitle(navId: string | undefined) {
case 'support-bundles':
return t('nav.support-bundles.title', 'Support bundles');
case 'global-users':
return config.featureToggles.topnav
? t('nav.global-users.title', 'Users')
: t('nav.global-users.titleBeforeTopnav', 'Users');
return t('nav.global-users.title', 'Users');
case 'global-orgs':
return t('nav.global-orgs.title', 'Organizations');
case 'server-settings':
@ -137,9 +128,7 @@ export function getNavSubTitle(navId: string | undefined) {
case 'dashboards':
return t('nav.dashboards.subtitle', 'Create and manage dashboards to visualize your data');
case 'dashboards/browse':
return config.featureToggles.topnav
? t('nav.dashboards.subtitle', 'Create and manage dashboards to visualize your data')
: undefined;
return t('nav.dashboards.subtitle', 'Create and manage dashboards to visualize your data');
case 'manage-folder':
return t('nav.manage-folder.subtitle', 'Manage folder dashboards and permissions');
case 'dashboards/playlists':
@ -193,12 +182,10 @@ export function getNavSubTitle(navId: string | undefined) {
case 'support-bundles':
return t('nav.support-bundles.subtitle', 'Download support bundles');
case 'admin':
return config.featureToggles.topnav
? t(
'nav.admin.subtitle',
'Manage server-wide settings and access to resources such as organizations, users, and licenses'
)
: undefined;
return t(
'nav.admin.subtitle',
'Manage server-wide settings and access to resources such as organizations, users, and licenses'
);
case 'apps':
return t('nav.apps.subtitle', 'App plugins that extend the Grafana experience');
case 'monitoring':

@ -3,9 +3,9 @@ import { Location } from 'history';
import { GrafanaConfig, locationUtil, NavModelItem } from '@grafana/data';
import { ContextSrv, setContextSrv } from 'app/core/services/context_srv';
import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive } from './utils';
import { enrichConfigItems, getActiveItem, isMatchOrChildMatch } from './utils';
jest.mock('../../app_events', () => ({
jest.mock('../../../app_events', () => ({
publish: jest.fn(),
}));
@ -33,46 +33,6 @@ describe('enrichConfigItems', () => {
];
});
it('does not add a sign in item if a user signed in', () => {
const contextSrv = new ContextSrv();
contextSrv.user.isSignedIn = false;
setContextSrv(contextSrv);
const enrichedConfigItems = enrichConfigItems(mockItems, mockLocation);
const signInNode = enrichedConfigItems.find((item) => item.id === 'sign-in');
expect(signInNode).toBeDefined();
});
it('adds a sign in item if a user is not signed in', () => {
const contextSrv = new ContextSrv();
contextSrv.user.isSignedIn = true;
setContextSrv(contextSrv);
const enrichedConfigItems = enrichConfigItems(mockItems, mockLocation);
const signInNode = enrichedConfigItems.find((item) => item.id === 'sign-in');
expect(signInNode).toBeDefined();
});
it('does not add an org switcher to the profile node if there is 1 org', () => {
const contextSrv = new ContextSrv();
contextSrv.user.orgCount = 1;
setContextSrv(contextSrv);
const enrichedConfigItems = enrichConfigItems(mockItems, mockLocation);
const profileNode = enrichedConfigItems.find((item) => item.id === 'profile');
expect(profileNode!.children).toBeUndefined();
});
it('adds an org switcher to the profile node if there is more than 1 org', () => {
const contextSrv = new ContextSrv();
contextSrv.user.orgCount = 2;
setContextSrv(contextSrv);
const enrichedConfigItems = enrichConfigItems(mockItems, mockLocation);
const profileNode = enrichedConfigItems.find((item) => item.id === 'profile');
expect(profileNode!.children).toContainEqual(
expect.objectContaining({
text: 'Switch organization',
})
);
});
it('enhances the help node with extra child links', () => {
const contextSrv = new ContextSrv();
setContextSrv(contextSrv);
@ -241,25 +201,3 @@ describe('getActiveItem', () => {
});
});
});
describe('isSearchActive', () => {
it('returns true if the search query parameter is "open"', () => {
const mockLocation = {
hash: '',
pathname: '/',
search: '?search=open',
state: '',
};
expect(isSearchActive(mockLocation)).toBe(true);
});
it('returns false if the search query parameter is missing', () => {
const mockLocation = {
hash: '',
pathname: '/',
search: '',
state: '',
};
expect(isSearchActive(mockLocation)).toBe(false);
});
});

@ -1,52 +1,19 @@
import { Location } from 'history';
import { locationUtil, NavModelItem, NavSection } from '@grafana/data';
import { locationUtil, NavModelItem } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
import { ShowModalReactEvent } from '../../../types/events';
import appEvents from '../../app_events';
import { getFooterLinks } from '../Footer/Footer';
import { OrgSwitcher } from '../OrgSwitcher';
import { HelpModal } from '../help/HelpModal';
export const SEARCH_ITEM_ID = 'search';
export const NAV_MENU_PORTAL_CONTAINER_ID = 'navbar-menu-portal-container';
export const getNavMenuPortalContainer = () => document.getElementById(NAV_MENU_PORTAL_CONTAINER_ID) ?? document.body;
import { ShowModalReactEvent } from '../../../../types/events';
import appEvents from '../../../app_events';
import { getFooterLinks } from '../../Footer/Footer';
import { HelpModal } from '../../help/HelpModal';
export const enrichConfigItems = (items: NavModelItem[], location: Location<unknown>) => {
const { isSignedIn, user } = contextSrv;
const onOpenShortcuts = () => {
appEvents.publish(new ShowModalReactEvent({ component: HelpModal }));
};
const onOpenOrgSwitcher = () => {
appEvents.publish(new ShowModalReactEvent({ component: OrgSwitcher }));
};
if (!config.featureToggles.topnav && user && user.orgCount > 1) {
const profileNode = items.find((bottomNavItem) => bottomNavItem.id === 'profile');
if (profileNode) {
profileNode.showOrgSwitcher = true;
profileNode.subTitle = `Organization: ${user?.orgName}`;
}
}
if (!isSignedIn && !config.featureToggles.topnav) {
const loginUrl = locationUtil.getUrlForPartial(location, { forceLogin: 'true' });
items.unshift({
icon: 'signout',
id: 'sign-in',
section: NavSection.Config,
target: '_self',
text: t('nav.sign-in', 'Sign in'),
url: loginUrl,
});
}
items.forEach((link) => {
let menuItems = link.children || [];
@ -63,18 +30,6 @@ export const enrichConfigItems = (items: NavModelItem[], location: Location<unkn
},
];
}
if (!config.featureToggles.topnav && link.showOrgSwitcher) {
link.children = [
...menuItems,
{
id: 'switch-organization',
text: t('nav.profile/switch-org', 'Switch organization'),
icon: 'arrow-random',
onClick: onOpenOrgSwitcher,
},
];
}
});
return items;
};
@ -163,15 +118,6 @@ export const getActiveItem = (
return currentBestMatch;
};
export const isSearchActive = (location: Location<unknown>) => {
const query = new URLSearchParams(location.search);
return query.get('search') === 'open';
};
export function getNavModelItemKey(item: NavModelItem) {
return item.id ?? item.text;
}
export function getEditionAndUpdateLinks(): NavModelItem[] {
const { buildInfo, licenseInfo } = config;
const stateInfo = licenseInfo.stateInfo ? ` (${licenseInfo.stateInfo})` : '';

@ -8,11 +8,11 @@ import { t } from 'app/core/internationalization';
import { HOME_NAV_ID } from 'app/core/reducers/navModel';
import { useSelector } from 'app/types';
import { Breadcrumbs } from '../Breadcrumbs/Breadcrumbs';
import { buildBreadcrumbs } from '../Breadcrumbs/utils';
import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs';
import { buildBreadcrumbs } from '../../Breadcrumbs/utils';
import { TOP_BAR_LEVEL_HEIGHT } from '../types';
import { NavToolbarSeparator } from './NavToolbarSeparator';
import { TOP_BAR_LEVEL_HEIGHT } from './types';
export interface Props {
onToggleSearchBar(): void;

@ -2,7 +2,6 @@ import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
export interface Props {
@ -17,11 +16,7 @@ export function NavToolbarSeparator({ className, leftActionsSeparator }: Props)
return <div className={cx(className, styles.leftActionsSeparator)} />;
}
if (config.featureToggles.topnav) {
return <div className={cx(className, styles.line)} />;
}
return null;
return <div className={cx(className, styles.line)} />;
}
const getStyles = (theme: GrafanaTheme2) => {

@ -7,7 +7,7 @@ import { Menu, Dropdown, useStyles2, useTheme2, ToolbarButton } from '@grafana/u
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
import { useSelector } from 'app/types';
import { NavToolbarSeparator } from '../NavToolbarSeparator';
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
import { findCreateActions } from './utils';

@ -6,7 +6,7 @@ import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Menu, MenuItem, useStyles2 } from '@grafana/ui';
import { enrichConfigItems, enrichWithInteractionTracking } from '../../NavBar/utils';
import { enrichConfigItems, enrichWithInteractionTracking } from '../MegaMenu/utils';
export interface TopNavBarMenuProps {
node: NavModelItem;

@ -8,16 +8,16 @@ import { config } from 'app/core/config';
import { contextSrv } from 'app/core/core';
import { useSelector } from 'app/types';
import { Branding } from '../Branding/Branding';
import { Branding } from '../../Branding/Branding';
import { NewsContainer } from '../News/NewsContainer';
import { OrganizationSwitcher } from '../OrganizationSwitcher/OrganizationSwitcher';
import { QuickAdd } from '../QuickAdd/QuickAdd';
import { TOP_BAR_LEVEL_HEIGHT } from '../types';
import { NewsContainer } from './News/NewsContainer';
import { OrganizationSwitcher } from './Organization/OrganizationSwitcher';
import { QuickAdd } from './QuickAdd/QuickAdd';
import { SignInLink } from './TopBar/SignInLink';
import { TopNavBarMenu } from './TopBar/TopNavBarMenu';
import { TopSearchBarSection } from './TopBar/TopSearchBarSection';
import { SignInLink } from './SignInLink';
import { TopNavBarMenu } from './TopNavBarMenu';
import { TopSearchBarCommandPaletteTrigger } from './TopSearchBarCommandPaletteTrigger';
import { TOP_BAR_LEVEL_HEIGHT } from './types';
import { TopSearchBarSection } from './TopSearchBarSection';
export const TopSearchBar = React.memo(function TopSearchBar() {
const styles = useStyles2(getStyles);

@ -1,51 +0,0 @@
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { locationService } from '@grafana/runtime';
import { TestProvider } from '../../../../test/helpers/TestProvider';
import { NavBar } from './NavBar';
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
sidemenu: true,
user: {},
isSignedIn: false,
isGrafanaAdmin: false,
isEditor: false,
hasEditPermissionFolders: false,
},
}));
const setup = () => {
return render(
<TestProvider>
<NavBar />
</TestProvider>
);
};
describe('NavBar', () => {
it('should render component', async () => {
setup();
const sidemenu = await screen.findByTestId('sidemenu');
expect(sidemenu).toBeInTheDocument();
});
it('should not render when in kiosk mode is tv', async () => {
setup();
act(() => locationService.partial({ kiosk: 'tv' }));
const sidemenu = screen.queryByTestId('sidemenu');
expect(sidemenu).not.toBeInTheDocument();
});
it('should not render when in kiosk mode is full', async () => {
setup();
act(() => locationService.partial({ kiosk: '1' }));
const sidemenu = screen.queryByTestId('sidemenu');
expect(sidemenu).not.toBeInTheDocument();
});
});

@ -1,296 +0,0 @@
import { css, cx } from '@emotion/css';
import { FocusScope } from '@react-aria/focus';
import { Location as HistoryLocation } from 'history';
import { cloneDeep } from 'lodash';
import React, { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, locationUtil, NavModelItem, NavSection, textUtil } from '@grafana/data';
import { config, locationSearchToObject, locationService, reportInteraction } from '@grafana/runtime';
import { useTheme2, CustomScrollbar, IconButton } from '@grafana/ui';
import { getKioskMode } from 'app/core/navigation/kiosk';
import { useSelector } from 'app/types';
import NavBarItem from './NavBarItem';
import { NavBarItemIcon } from './NavBarItemIcon';
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
import { NavBarMenu } from './NavBarMenu';
import { NavBarMenuPortalContainer } from './NavBarMenuPortalContainer';
import { NavBarToggle } from './NavBarToggle';
import { NavBarContext } from './context';
import {
enrichConfigItems,
enrichWithInteractionTracking,
getActiveItem,
isMatchOrChildMatch,
isSearchActive,
SEARCH_ITEM_ID,
} from './utils';
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
};
export const NavBar = React.memo(() => {
const navBarTree = useSelector((state) => state.navBarTree);
const theme = useTheme2();
const styles = getStyles(theme);
const location = useLocation();
const [menuOpen, setMenuOpen] = useState(false);
const [menuAnimationInProgress, setMenuAnimationInProgress] = useState(false);
const [menuIdOpen, setMenuIdOpen] = useState<string | undefined>(undefined);
// Here we need to hack in a "home" and "search" NavModelItem since this is constructed in the frontend
const searchItem: NavModelItem = enrichWithInteractionTracking(
{
id: SEARCH_ITEM_ID,
onClick: onOpenSearch,
text: 'Search dashboards',
icon: 'search',
},
menuOpen
);
let homeUrl = config.appSubUrl || '/';
if (!config.bootData.user.isSignedIn && !config.anonymousEnabled) {
homeUrl = textUtil.sanitizeUrl(locationUtil.getUrlForPartial(location, { forceLogin: 'true' }));
}
const homeItem: NavModelItem = enrichWithInteractionTracking(
{
id: 'home',
text: 'Home',
url: homeUrl,
icon: 'grafana',
},
menuOpen
);
const navTree = cloneDeep(navBarTree).filter((item) => item.hideFromMenu !== true);
const coreItems = navTree
.filter((item) => item.section === NavSection.Core)
.map((item) => enrichWithInteractionTracking(item, menuOpen));
const pluginItems = navTree
.filter((item) => item.section === NavSection.Plugin)
.map((item) => enrichWithInteractionTracking(item, menuOpen));
const configItems = enrichConfigItems(
navTree.filter((item) => item.section === NavSection.Config),
location
).map((item) => enrichWithInteractionTracking(item, menuOpen));
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
if (shouldHideNavBar(location)) {
return null;
}
return (
<div className={styles.navWrapper}>
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
<NavBarContext.Provider
value={{
menuIdOpen: menuIdOpen,
setMenuIdOpen: setMenuIdOpen,
}}
>
<FocusScope>
<div className={styles.mobileSidemenuLogo} key="hamburger">
<IconButton
name="bars"
tooltip="Toggle menu"
tooltipPlacement="bottom"
size="xl"
onClick={() => setMenuOpen(!menuOpen)}
/>
</div>
<NavBarToggle
className={styles.menuExpandIcon}
isExpanded={menuOpen}
onClick={() => {
reportInteraction('grafana_navigation_expanded');
setMenuOpen(true);
}}
/>
<NavBarMenuPortalContainer />
<NavBarItemWithoutMenu
elClassName={styles.grafanaLogoInner}
label={homeItem.text}
className={styles.grafanaLogo}
url={homeItem.url}
onClick={homeItem.onClick}
>
<NavBarItemIcon link={homeItem} />
</NavBarItemWithoutMenu>
<CustomScrollbar hideHorizontalTrack hideVerticalTrack showScrollIndicators>
<ul className={styles.itemList}>
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem} />
{coreItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
link={{ ...link, subTitle: undefined }}
/>
))}
{pluginItems.length > 0 &&
pluginItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
link={link}
/>
))}
{configItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={isMatchOrChildMatch(link, activeItem)}
reverseMenuDirection
link={link}
className={cx({ [styles.verticalSpacer]: index === 0 })}
/>
))}
</ul>
</CustomScrollbar>
</FocusScope>
</NavBarContext.Provider>
</nav>
{(menuOpen || menuAnimationInProgress) && (
<div className={styles.menuWrapper}>
<NavBarMenu
activeItem={activeItem}
isOpen={menuOpen}
setMenuAnimationInProgress={setMenuAnimationInProgress}
navItems={[homeItem, searchItem, ...coreItems, ...pluginItems, ...configItems]}
onClose={() => setMenuOpen(false)}
/>
</div>
)}
</div>
);
});
function shouldHideNavBar(location: HistoryLocation) {
const queryParams = locationSearchToObject(location.search);
if (getKioskMode(queryParams)) {
return true;
}
// Temporary, can be removed after topnav is made permanent
if ((location.pathname.indexOf('/d/') === 0 && queryParams.editview) || queryParams.editPanel) {
return true;
}
return false;
}
NavBar.displayName = 'NavBar';
const getStyles = (theme: GrafanaTheme2) => ({
navWrapper: css({
position: 'relative',
display: 'flex',
}),
sidemenu: css({
label: 'sidemenu',
display: 'flex',
flexDirection: 'column',
backgroundColor: theme.colors.background.primary,
zIndex: theme.zIndex.sidemenu,
padding: `${theme.spacing(1)} 0`,
position: 'relative',
width: theme.components.sidemenu.width,
borderRight: `1px solid ${theme.colors.border.weak}`,
[theme.breakpoints.down('md')]: {
height: theme.spacing(7),
position: 'fixed',
paddingTop: '0px',
backgroundColor: 'inherit',
borderRight: 0,
},
}),
mobileSidemenuLogo: css({
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
padding: theme.spacing(2),
[theme.breakpoints.up('md')]: {
display: 'none',
},
}),
itemList: css({
backgroundColor: 'inherit',
display: 'flex',
flexDirection: 'column',
height: '100%',
[theme.breakpoints.down('md')]: {
visibility: 'hidden',
},
}),
grafanaLogo: css({
alignItems: 'stretch',
display: 'flex',
flexShrink: 0,
height: theme.spacing(6),
justifyContent: 'stretch',
[theme.breakpoints.down('md')]: {
visibility: 'hidden',
},
}),
grafanaLogoInner: css({
alignItems: 'center',
display: 'flex',
height: '100%',
justifyContent: 'center',
width: '100%',
'> div': {
height: 'auto',
width: 'auto',
},
}),
search: css({
display: 'none',
marginTop: 0,
[theme.breakpoints.up('md')]: {
display: 'grid',
},
}),
verticalSpacer: css({
marginTop: 'auto',
}),
hideFromMobile: css({
[theme.breakpoints.down('md')]: {
display: 'none',
},
}),
menuWrapper: css({
position: 'fixed',
display: 'grid',
gridAutoFlow: 'column',
height: '100%',
zIndex: theme.zIndex.sidemenu,
}),
menuExpandIcon: css({
position: 'absolute',
top: '43px',
right: '0px',
transform: `translateX(50%)`,
}),
menuPortalContainer: css({
zIndex: theme.zIndex.sidemenu,
}),
});

@ -1,247 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { locationUtil } from '@grafana/data';
import { config, LocationService, setLocationService } from '@grafana/runtime';
// Need to mock createBrowserHistory here to avoid errors
jest.mock('history', () => ({
...jest.requireActual('history'),
createBrowserHistory: () => ({
listen: jest.fn(),
location: {},
createHref: jest.fn(),
}),
}));
import NavBarItem, { Props } from './NavBarItem';
import { NavBarContext } from './context';
const onClickMock = jest.fn();
const setMenuIdOpenMock = jest.fn();
const defaults: Props = {
link: {
text: 'Parent Node',
onClick: onClickMock,
children: [
{ text: 'Child Node 1', onClick: onClickMock, children: [] },
{ text: 'Child Node 2', onClick: onClickMock, children: [] },
],
id: 'MY_NAV_ID',
},
};
async function getTestContext(overrides: Partial<Props> = {}, subUrl = '', isMenuOpen = false) {
jest.clearAllMocks();
config.appSubUrl = subUrl;
locationUtil.initialize({ config, getTimeRangeForUrl: jest.fn(), getVariablesUrlParams: jest.fn() });
const pushMock = jest.fn();
const locationService = { push: pushMock } as unknown as LocationService;
setLocationService(locationService);
const props = { ...defaults, ...overrides };
const { rerender } = render(
<BrowserRouter>
<NavBarContext.Provider
value={{
menuIdOpen: isMenuOpen ? props.link.id : undefined,
setMenuIdOpen: setMenuIdOpenMock,
}}
>
<NavBarItem {...props} />
</NavBarContext.Provider>
</BrowserRouter>
);
// Need to click this first to set the correct selection range
// see https://github.com/testing-library/user-event/issues/901#issuecomment-1087192424
await userEvent.click(document.body);
return { rerender, pushMock };
}
describe('NavBarItem', () => {
describe('when url property is not set', () => {
it('then it renders the menu trigger as a button', async () => {
await getTestContext();
expect(screen.getAllByRole('button')).toHaveLength(1);
});
describe('and clicking on the menu trigger button', () => {
it('then the onClick handler should be called', async () => {
await getTestContext();
await userEvent.click(screen.getByRole('button'));
expect(onClickMock).toHaveBeenCalledTimes(1);
});
});
describe('and hovering over the menu trigger button', () => {
it('then the menuIdOpen should be set correctly', async () => {
await getTestContext();
await userEvent.hover(screen.getByRole('button'));
expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id);
});
});
describe('and tabbing to the menu trigger button', () => {
it('then the menuIdOpen should be set correctly', async () => {
await getTestContext();
await userEvent.tab();
expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id);
});
});
it('shows the menu when the correct menuIdOpen is set', async () => {
await getTestContext(undefined, undefined, true);
expect(screen.getByText('Parent Node')).toBeInTheDocument();
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
});
describe('and pressing arrow right on the menu trigger button', () => {
it('then the correct menu item should receive focus', async () => {
await getTestContext(undefined, undefined, true);
await userEvent.tab();
expect(screen.getAllByRole('menuitem')).toHaveLength(3);
expect(screen.getByRole('menuitem', { name: 'Parent Node' })).toHaveAttribute('tabIndex', '-1');
expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1');
expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1');
await userEvent.keyboard('{ArrowRight}');
expect(screen.getAllByRole('menuitem')).toHaveLength(3);
expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '0');
expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1');
expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1');
});
});
});
describe('when url property is set', () => {
it('then it renders the menu trigger as a link', async () => {
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } });
expect(screen.getAllByRole('link')).toHaveLength(1);
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://www.grafana.com');
});
describe('and hovering over the menu trigger link', () => {
it('sets the correct menuIdOpen', async () => {
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } });
await userEvent.hover(screen.getByRole('link'));
expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id);
});
});
describe('and tabbing to the menu trigger link', () => {
it('sets the correct menuIdOpen', async () => {
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } });
await userEvent.tab();
expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id);
});
});
it('shows the menu when the correct menuIdOpen is set', async () => {
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }, undefined, true);
expect(screen.getByText('Parent Node')).toBeInTheDocument();
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
});
describe('and pressing arrow right on the menu trigger link', () => {
it('then the correct menu item should receive focus', async () => {
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }, undefined, true);
await userEvent.tab();
expect(screen.getAllByRole('link')[0]).toHaveFocus();
expect(screen.getAllByRole('menuitem')).toHaveLength(3);
expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '-1');
expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1');
expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1');
await userEvent.keyboard('{ArrowRight}');
expect(screen.getAllByRole('link')[0]).not.toHaveFocus();
expect(screen.getAllByRole('menuitem')).toHaveLength(3);
expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '0');
expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1');
expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1');
});
});
describe('and pressing arrow left on a menu item', () => {
it('then the nav bar item should receive focus', async () => {
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }, undefined, true);
await userEvent.tab();
await userEvent.keyboard('{ArrowRight}');
expect(screen.getAllByRole('link')[0]).not.toHaveFocus();
expect(screen.getAllByRole('menuitem')).toHaveLength(3);
expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '0');
expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1');
expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1');
await userEvent.keyboard('{ArrowLeft}');
expect(screen.getAllByRole('link')[0]).toHaveFocus();
expect(screen.getAllByRole('menuitem')).toHaveLength(3);
expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '-1');
expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1');
expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1');
});
});
describe('when appSubUrl is configured and user clicks on menuitem link', () => {
it('then location service should be called with correct url', async () => {
const { pushMock } = await getTestContext(
{
link: {
...defaults.link,
url: 'https://www.grafana.com',
children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }],
},
},
'/grafana',
true
);
await userEvent.click(screen.getByText('New'));
await waitFor(() => {
expect(pushMock).toHaveBeenCalledTimes(1);
expect(pushMock).toHaveBeenCalledWith('/dashboard/new');
});
});
});
describe('when appSubUrl is not configured and user clicks on menuitem link', () => {
it('then location service should be called with correct url', async () => {
const { pushMock } = await getTestContext(
{
link: {
...defaults.link,
url: 'https://www.grafana.com',
children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }],
},
},
undefined,
true
);
await userEvent.click(screen.getByText('New'));
await waitFor(() => {
expect(pushMock).toHaveBeenCalledTimes(1);
expect(pushMock).toHaveBeenCalledWith('/grafana/dashboard/new');
});
});
});
});
});

@ -1,115 +0,0 @@
import { css, cx } from '@emotion/css';
import { Item } from '@react-stately/collections';
import React from 'react';
import { GrafanaTheme2, locationUtil, NavMenuItemType, NavModelItem } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { toIconName, useTheme2 } from '@grafana/ui';
import { NavBarItemMenu } from './NavBarItemMenu';
import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger';
import { getNavBarItemWithoutMenuStyles } from './NavBarItemWithoutMenu';
import { NavBarMenuItem } from './NavBarMenuItem';
import { useNavBarContext } from './context';
import { getNavModelItemKey } from './utils';
export interface Props {
isActive?: boolean;
className?: string;
reverseMenuDirection?: boolean;
link: NavModelItem;
}
const NavBarItem = ({ isActive = false, className, reverseMenuDirection = false, link }: Props) => {
const theme = useTheme2();
const menuItems = link.children ?? [];
const { menuIdOpen } = useNavBarContext();
// Spreading `menuItems` here as otherwise we'd be mutating props
const menuItemsSorted = reverseMenuDirection ? [...menuItems].reverse() : menuItems;
const filteredItems = menuItemsSorted
.filter((item) => !item.hideFromMenu)
.map((i) => ({ ...i, menuItemType: NavMenuItemType.Item }));
const adjustHeightForBorder = filteredItems.length === 0;
const styles = getStyles(theme, adjustHeightForBorder, isActive);
const section: NavModelItem = {
...link,
children: filteredItems,
menuItemType: NavMenuItemType.Section,
};
const items: NavModelItem[] = [section].concat(filteredItems);
const onNavigate = (item: NavModelItem) => {
const { url, target, onClick } = item;
onClick?.();
if (url) {
if (!target && url.startsWith('/')) {
locationService.push(locationUtil.stripBaseFromUrl(url));
} else {
window.open(url, target);
}
}
};
return (
<li className={cx(styles.container, { [styles.containerHover]: section.id === menuIdOpen }, className)}>
<NavBarItemMenuTrigger
item={section}
isActive={isActive}
label={link.text}
reverseMenuDirection={reverseMenuDirection}
>
<NavBarItemMenu
items={items}
reverseMenuDirection={reverseMenuDirection}
adjustHeightForBorder={adjustHeightForBorder}
disabledKeys={['divider', 'subtitle']}
aria-label={section.text}
onNavigate={onNavigate}
>
{(item: NavModelItem) => {
const isSection = item.menuItemType === NavMenuItemType.Section;
const iconName = item.icon ? toIconName(item.icon) : undefined;
const icon = item.showIconInNavbar && !isSection ? iconName : undefined;
return (
<Item key={getNavModelItemKey(item)} textValue={item.text}>
<NavBarMenuItem
isDivider={!isSection && item.divider}
icon={icon}
target={item.target}
text={item.text}
url={item.url}
onClick={item.onClick}
styleOverrides={cx(styles.primaryText, { [styles.header]: isSection })}
/>
</Item>
);
}}
</NavBarItemMenu>
</NavBarItemMenuTrigger>
</li>
);
};
export default NavBarItem;
const getStyles = (theme: GrafanaTheme2, adjustHeightForBorder: boolean, isActive?: boolean) => ({
...getNavBarItemWithoutMenuStyles(theme, isActive),
containerHover: css({
backgroundColor: theme.colors.action.hover,
color: theme.colors.text.primary,
}),
primaryText: css({
color: theme.colors.text.primary,
}),
header: css({
height: `calc(${theme.spacing(6)} - ${adjustHeightForBorder ? 2 : 1}px)`,
fontSize: theme.typography.h4.fontSize,
fontWeight: theme.typography.h4.fontWeight,
padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
whiteSpace: 'nowrap',
width: '100%',
}),
});

@ -1,120 +0,0 @@
import { css } from '@emotion/css';
import { useMenu } from '@react-aria/menu';
import { mergeProps } from '@react-aria/utils';
import { useTreeState } from '@react-stately/tree';
import { SpectrumMenuProps } from '@react-types/menu';
import React, { ReactElement, useEffect, useRef } from 'react';
import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data';
import { CustomScrollbar, useTheme2 } from '@grafana/ui';
import { NavBarItemMenuItem } from './NavBarItemMenuItem';
import { useNavBarItemMenuContext } from './context';
import { getNavModelItemKey } from './utils';
export interface NavBarItemMenuProps extends SpectrumMenuProps<NavModelItem> {
onNavigate: (item: NavModelItem) => void;
adjustHeightForBorder: boolean;
reverseMenuDirection?: boolean;
}
export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null {
const { reverseMenuDirection, adjustHeightForBorder, disabledKeys, onNavigate, ...rest } = props;
const contextProps = useNavBarItemMenuContext();
const completeProps = {
...mergeProps(contextProps, rest),
};
const { menuHasFocus, menuProps: contextMenuProps = {} } = contextProps;
const theme = useTheme2();
const styles = getStyles(theme, reverseMenuDirection);
const state = useTreeState<NavModelItem>({ ...rest, disabledKeys });
const ref = useRef(null);
const { menuProps } = useMenu(completeProps, { ...state }, ref);
const allItems = [...state.collection];
const items = allItems.filter((item) => item.value.menuItemType === NavMenuItemType.Item);
const section = allItems.find((item) => item.value.menuItemType === NavMenuItemType.Section);
useEffect(() => {
if (menuHasFocus && !state.selectionManager.isFocused) {
state.selectionManager.setFocusedKey(section?.key ?? '');
state.selectionManager.setFocused(true);
} else if (!menuHasFocus) {
state.selectionManager.setFocused(false);
state.selectionManager.setFocusedKey('');
state.selectionManager.clearSelection();
}
}, [menuHasFocus, state.selectionManager, reverseMenuDirection, section?.key]);
if (!section) {
return null;
}
const menuSubTitle = section.value.subTitle;
const headerComponent = <NavBarItemMenuItem key={section.key} item={section} state={state} onNavigate={onNavigate} />;
const itemComponents = items.map((item) => (
<NavBarItemMenuItem key={getNavModelItemKey(item.value)} item={item} state={state} onNavigate={onNavigate} />
));
if (itemComponents.length === 0 && section.value.emptyMessage) {
itemComponents.push(
<div key="empty-message" className={styles.emptyMessage}>
{section.value.emptyMessage}
</div>
);
}
const subTitleComponent = menuSubTitle && (
<li key={menuSubTitle} className={styles.subtitle}>
{menuSubTitle}
</li>
);
const contents = [itemComponents, subTitleComponent];
const contentComponent = (
<CustomScrollbar hideHorizontalTrack hideVerticalTrack showScrollIndicators key="scrollContainer">
{reverseMenuDirection ? contents.reverse() : contents}
</CustomScrollbar>
);
const menu = [headerComponent, contentComponent];
return (
<ul className={styles.menu} ref={ref} {...mergeProps(menuProps, contextMenuProps)} tabIndex={-1}>
{reverseMenuDirection ? menu.reverse() : menu}
</ul>
);
}
function getStyles(theme: GrafanaTheme2, reverseDirection?: boolean) {
return {
menu: css`
background-color: ${theme.colors.background.primary};
border: 1px solid ${theme.components.panel.borderColor};
box-shadow: ${theme.shadows.z3};
display: flex;
flex-direction: column;
list-style: none;
max-height: 400px;
max-width: 300px;
min-width: 140px;
transition: ${theme.transitions.create('opacity')};
z-index: ${theme.zIndex.sidemenu};
`,
subtitle: css`
background-color: transparent;
border-${reverseDirection ? 'bottom' : 'top'}: 1px solid ${theme.colors.border.weak};
color: ${theme.colors.text.secondary};
font-size: ${theme.typography.bodySmall.fontSize};
font-weight: ${theme.typography.bodySmall.fontWeight};
padding: ${theme.spacing(1)} ${theme.spacing(2)} ${theme.spacing(1)};
text-align: left;
white-space: nowrap;
`,
emptyMessage: css`
font-style: italic;
padding: ${theme.spacing(0.5, 2)};
`,
};
}

@ -1,92 +0,0 @@
import { css } from '@emotion/css';
import { useFocus, useKeyboard } from '@react-aria/interactions';
import { useMenuItem } from '@react-aria/menu';
import { mergeProps } from '@react-aria/utils';
import { TreeState } from '@react-stately/tree';
import { Node } from '@react-types/shared';
import React, { ReactElement, useRef, useState } from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { useNavBarItemMenuContext, useNavBarContext } from './context';
export interface NavBarItemMenuItemProps {
item: Node<NavModelItem>;
state: TreeState<NavModelItem>;
onNavigate: (item: NavModelItem) => void;
}
export function NavBarItemMenuItem({ item, state, onNavigate }: NavBarItemMenuItemProps): ReactElement {
const { onClose, onLeft } = useNavBarItemMenuContext();
const { setMenuIdOpen } = useNavBarContext();
const { key, rendered } = item;
const ref = useRef<HTMLLIElement>(null);
const isDisabled = state.disabledKeys.has(key);
// style to the focused menu item
const [isFocused, setFocused] = useState(false);
const { focusProps } = useFocus({ onFocusChange: setFocused, isDisabled });
const theme = useTheme2();
const isSection = item.value.menuItemType === 'section';
const styles = getStyles(theme, isFocused, isSection);
const onAction = () => {
setMenuIdOpen(undefined);
onNavigate(item.value);
onClose();
};
let { menuItemProps } = useMenuItem(
{
isDisabled,
'aria-label': item['aria-label'],
key,
closeOnSelect: true,
onClose,
onAction,
},
state,
ref
);
const { keyboardProps } = useKeyboard({
onKeyDown: (e) => {
if (e.key === 'ArrowLeft') {
onLeft();
}
e.continuePropagation();
},
});
return (
<>
<li {...mergeProps(menuItemProps, focusProps, keyboardProps)} ref={ref} className={styles.menuItem}>
{rendered}
</li>
</>
);
}
function getStyles(theme: GrafanaTheme2, isFocused: boolean, isSection: boolean) {
let backgroundColor = 'transparent';
if (isFocused) {
backgroundColor = theme.colors.action.hover;
} else if (isSection) {
backgroundColor = theme.colors.background.secondary;
}
return {
menuItem: css`
background-color: ${backgroundColor};
color: ${theme.colors.text.primary};
&:focus-visible {
background-color: ${theme.colors.action.hover};
box-shadow: none;
color: ${theme.colors.text.primary};
outline: 2px solid ${theme.colors.primary.main};
outline-offset: -2px;
transition: none;
}
`,
};
}

@ -1,254 +0,0 @@
import { css, cx } from '@emotion/css';
import { useButton } from '@react-aria/button';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { useFocusWithin, useHover, useKeyboard } from '@react-aria/interactions';
import { useMenuTrigger } from '@react-aria/menu';
import { DismissButton, OverlayContainer, useOverlay, useOverlayPosition } from '@react-aria/overlays';
import { useMenuTriggerState } from '@react-stately/menu';
import { MenuTriggerProps } from '@react-types/menu';
import React, { ReactElement, useEffect, useState } from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { reportExperimentView } from '@grafana/runtime';
import { Link, useTheme2 } from '@grafana/ui';
import { NavBarItemIcon } from './NavBarItemIcon';
import { getNavMenuPortalContainer } from './NavBarMenuPortalContainer';
import { NavFeatureHighlight } from './NavFeatureHighlight';
import { NavBarItemMenuContext, useNavBarContext } from './context';
export interface NavBarItemMenuTriggerProps extends MenuTriggerProps {
children: ReactElement;
item: NavModelItem;
isActive?: boolean;
label: string;
reverseMenuDirection: boolean;
}
export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactElement {
const { item, isActive, label, children: menu, reverseMenuDirection, ...rest } = props;
const [menuHasFocus, setMenuHasFocus] = useState(false);
const { menuIdOpen, setMenuIdOpen } = useNavBarContext();
const theme = useTheme2();
const styles = getStyles(theme, isActive);
// Create state based on the incoming props
const state = useMenuTriggerState({ ...rest });
// Get props for the menu trigger and menu elements
const ref = React.useRef<HTMLElement>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
useEffect(() => {
if (item.highlightId) {
reportExperimentView(`feature-highlights-${item.highlightId}-nav`, 'test', '');
}
}, [item.highlightId]);
const { hoverProps } = useHover({
onHoverChange: (isHovering) => {
if (isHovering) {
state.open();
setMenuIdOpen(item.id);
} else {
state.close();
setMenuIdOpen(undefined);
}
},
});
useEffect(() => {
// close the menu when changing submenus
if (menuIdOpen !== item.id) {
state.close();
setMenuHasFocus(false);
} else {
state.open();
}
}, [menuIdOpen, state, item.id]);
const { keyboardProps } = useKeyboard({
onKeyDown: (e) => {
switch (e.key) {
case 'ArrowRight':
if (!state.isOpen) {
state.open();
setMenuIdOpen(item.id);
}
setMenuHasFocus(true);
break;
case 'Tab':
setMenuIdOpen(undefined);
break;
default:
break;
}
},
});
// Get props for the button based on the trigger props from useMenuTrigger
const { buttonProps } = useButton(menuTriggerProps, ref);
const Wrapper = item.highlightText ? NavFeatureHighlight : React.Fragment;
const itemContent = (
<Wrapper>
<span className={styles.icon}>
<NavBarItemIcon link={item} />
</span>
</Wrapper>
);
let element = (
<button
className={styles.element}
{...buttonProps}
{...keyboardProps}
{...hoverProps}
ref={ref as React.RefObject<HTMLButtonElement>}
onClick={item?.onClick}
aria-label={label}
>
{itemContent}
</button>
);
if (item?.url) {
element =
!item.target && item.url.startsWith('/') ? (
<Link
{...buttonProps}
{...keyboardProps}
{...hoverProps}
ref={ref as React.RefObject<HTMLAnchorElement>}
href={item.url}
target={item.target}
onClick={item?.onClick}
className={styles.element}
aria-label={label}
>
{itemContent}
</Link>
) : (
<a
href={item.url}
target={item.target}
onClick={item?.onClick}
{...buttonProps}
{...keyboardProps}
{...hoverProps}
ref={ref as React.RefObject<HTMLAnchorElement>}
className={styles.element}
aria-label={label}
>
{itemContent}
</a>
);
}
const overlayRef = React.useRef<HTMLDivElement>(null);
const { dialogProps } = useDialog({}, overlayRef);
const { overlayProps } = useOverlay(
{
onClose: () => {
state.close();
setMenuIdOpen(undefined);
},
isOpen: state.isOpen,
isDismissable: true,
},
overlayRef
);
let { overlayProps: overlayPositionProps } = useOverlayPosition({
targetRef: ref,
overlayRef,
placement: reverseMenuDirection ? 'right bottom' : 'right top',
isOpen: state.isOpen,
});
const { focusWithinProps } = useFocusWithin({
onFocusWithin: (e) => {
if (e.target.id === ref.current?.id) {
// If focussing on the trigger itself, set the menu id that is open
setMenuIdOpen(item.id);
state.open();
}
e.target.scrollIntoView?.({
block: 'nearest',
});
},
onBlurWithin: (e) => {
if (e.target?.getAttribute('role') === 'menuitem' && !overlayRef.current?.contains(e.relatedTarget)) {
// If it is blurring from a menuitem to an element outside the current overlay
// close the menu that is open
setMenuIdOpen(undefined);
}
},
});
return (
<div className={cx(styles.element, 'dropdown')} {...focusWithinProps}>
{element}
{state.isOpen && (
<OverlayContainer portalContainer={getNavMenuPortalContainer()}>
<NavBarItemMenuContext.Provider
value={{
menuProps,
menuHasFocus,
onClose: () => state.close(),
onLeft: () => {
setMenuHasFocus(false);
ref.current?.focus();
},
}}
>
<FocusScope restoreFocus>
<div {...overlayProps} {...overlayPositionProps} {...dialogProps} {...hoverProps} ref={overlayRef}>
<DismissButton onDismiss={() => state.close()} />
{menu}
<DismissButton onDismiss={() => state.close()} />
</div>
</FocusScope>
</NavBarItemMenuContext.Provider>
</OverlayContainer>
)}
</div>
);
}
const getStyles = (theme: GrafanaTheme2, isActive?: boolean) => ({
element: css({
backgroundColor: 'transparent',
border: 'none',
color: 'inherit',
display: 'grid',
padding: 0,
placeContent: 'center',
height: theme.spacing(6),
width: theme.spacing(7),
'&::before': {
display: isActive ? 'block' : 'none',
content: '" "',
position: 'absolute',
left: theme.spacing(1),
top: theme.spacing(1.5),
bottom: theme.spacing(1.5),
width: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius(1),
backgroundImage: theme.colors.gradients.brandVertical,
},
'&:focus-visible': {
backgroundColor: theme.colors.action.hover,
boxShadow: 'none',
color: theme.colors.text.primary,
outline: `${theme.shape.borderRadius(1)} solid ${theme.colors.primary.main}`,
outlineOffset: `-${theme.shape.borderRadius(1)}`,
transition: 'none',
},
}),
icon: css({
height: '100%',
width: '100%',
}),
});

@ -1,117 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { ReactNode } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Link, useTheme2 } from '@grafana/ui';
import { NavFeatureHighlight } from './NavFeatureHighlight';
export interface NavBarItemWithoutMenuProps {
label: string;
children: ReactNode;
className?: string;
elClassName?: string;
url?: string;
target?: string;
isActive?: boolean;
onClick?: () => void;
highlightText?: string;
}
export function NavBarItemWithoutMenu({
label,
children,
url,
target,
isActive = false,
onClick,
highlightText,
className,
elClassName,
}: NavBarItemWithoutMenuProps) {
const theme = useTheme2();
const styles = getNavBarItemWithoutMenuStyles(theme, isActive);
const content = highlightText ? (
<NavFeatureHighlight>
<div className={styles.icon}>{children}</div>
</NavFeatureHighlight>
) : (
<div className={styles.icon}>{children}</div>
);
const elStyle = cx(styles.element, elClassName);
const renderContents = () => {
if (!url) {
return (
<button className={elStyle} onClick={onClick} aria-label={label}>
{content}
</button>
);
} else if (!target && url.startsWith('/')) {
return (
<Link className={elStyle} href={url} target={target} aria-label={label} onClick={onClick} aria-haspopup="true">
{content}
</Link>
);
} else {
return (
<a href={url} target={target} className={elStyle} onClick={onClick} aria-label={label}>
{content}
</a>
);
}
};
return <div className={cx(styles.container, className)}>{renderContents()}</div>;
}
export function getNavBarItemWithoutMenuStyles(theme: GrafanaTheme2, isActive?: boolean) {
return {
container: css({
position: 'relative',
color: isActive ? theme.colors.text.primary : theme.colors.text.secondary,
display: 'grid',
'&:hover': {
backgroundColor: theme.colors.action.hover,
color: theme.colors.text.primary,
},
}),
element: css({
backgroundColor: 'transparent',
border: 'none',
color: 'inherit',
display: 'block',
padding: 0,
overflowWrap: 'anywhere',
'&::before': {
display: isActive ? 'block' : 'none',
content: "' '",
position: 'absolute',
left: theme.spacing(1),
top: theme.spacing(1.5),
bottom: theme.spacing(1.5),
width: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius(1),
backgroundImage: theme.colors.gradients.brandVertical,
},
'&:focus-visible': {
backgroundColor: theme.colors.action.hover,
boxShadow: 'none',
color: theme.colors.text.primary,
outline: `${theme.shape.borderRadius(1)} solid ${theme.colors.primary.main}`,
outlineOffset: `-${theme.shape.borderRadius(1)}`,
transition: 'none',
},
}),
icon: css({
height: '100%',
width: '100%',
}),
};
}

@ -1,54 +0,0 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { render } from 'test/redux-rtl';
import { NavModelItem } from '@grafana/data';
import { NavBarMenu } from './NavBarMenu';
// don't care about interaction tracking in our unit tests
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
}));
describe('NavBarMenu', () => {
const mockOnClose = jest.fn();
const mockNavItems: NavModelItem[] = [];
const mockSetMenuAnimationInProgress = jest.fn();
beforeEach(() => {
render(
<NavBarMenu
isOpen
onClose={mockOnClose}
navItems={mockNavItems}
setMenuAnimationInProgress={mockSetMenuAnimationInProgress}
/>
);
});
it('should render the component', () => {
const sidemenu = screen.getByTestId('navbarmenu');
expect(sidemenu).toBeInTheDocument();
});
it('has a close button', () => {
const closeButton = screen.getAllByRole('button', { name: 'Close navigation menu' });
// this is for mobile, will be hidden with display: none; on desktop
expect(closeButton[0]).toBeInTheDocument();
// this is for desktop, will be hidden with display: none; on mobile
expect(closeButton[1]).toBeInTheDocument();
});
it('clicking the close button calls the onClose callback', async () => {
const closeButton = screen.getAllByRole('button', { name: 'Close navigation menu' });
expect(closeButton[0]).toBeInTheDocument();
expect(closeButton[1]).toBeInTheDocument();
await userEvent.click(closeButton[0]);
expect(mockOnClose).toHaveBeenCalled();
await userEvent.click(closeButton[1]);
expect(mockOnClose).toHaveBeenCalled();
});
});

@ -1,467 +0,0 @@
import { css, cx } from '@emotion/css';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { OverlayContainer, useOverlay } from '@react-aria/overlays';
import React, { useRef } from 'react';
import CSSTransition from 'react-transition-group/CSSTransition';
import { useLocalStorage } from 'react-use';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { CollapsableSection, CustomScrollbar, Icon, IconButton, toIconName, useStyles2, useTheme2 } from '@grafana/ui';
import { NavBarItemIcon } from './NavBarItemIcon';
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
import { NavBarMenuItem } from './NavBarMenuItem';
import { NavBarToggle } from './NavBarToggle';
import { NavFeatureHighlight } from './NavFeatureHighlight';
import { isMatchOrChildMatch } from './utils';
const MENU_WIDTH = '350px';
export interface Props {
activeItem?: NavModelItem;
isOpen: boolean;
navItems: NavModelItem[];
setMenuAnimationInProgress: (isInProgress: boolean) => void;
onClose: () => void;
}
export function NavBarMenu({ activeItem, isOpen, navItems, onClose, setMenuAnimationInProgress }: Props) {
const theme = useTheme2();
const styles = getStyles(theme);
const ANIMATION_DURATION = theme.transitions.duration.standard;
const animStyles = getAnimStyles(theme, ANIMATION_DURATION);
const ref = useRef(null);
const backdropRef = useRef(null);
const { dialogProps } = useDialog({}, ref);
const { overlayProps, underlayProps } = useOverlay(
{
isDismissable: true,
isOpen,
onClose,
},
ref
);
return (
<OverlayContainer>
<FocusScope contain restoreFocus autoFocus>
<CSSTransition
nodeRef={ref}
onEnter={() => setMenuAnimationInProgress(true)}
onExited={() => setMenuAnimationInProgress(false)}
appear={isOpen}
in={isOpen}
classNames={animStyles.overlay}
timeout={ANIMATION_DURATION}
>
<div data-testid="navbarmenu" ref={ref} {...overlayProps} {...dialogProps} className={styles.container}>
<div className={styles.mobileHeader}>
<Icon name="bars" size="xl" />
<IconButton
aria-label="Close navigation menu"
name="times"
onClick={onClose}
size="xl"
variant="secondary"
/>
</div>
<NavBarToggle
className={styles.menuCollapseIcon}
isExpanded={isOpen}
onClick={() => {
reportInteraction('grafana_navigation_collapsed');
onClose();
}}
/>
<nav className={styles.content}>
<CustomScrollbar hideHorizontalTrack>
<ul className={styles.itemList}>
{navItems.map((link) => (
<NavItem link={link} onClose={onClose} activeItem={activeItem} key={link.text} />
))}
</ul>
</CustomScrollbar>
</nav>
</div>
</CSSTransition>
</FocusScope>
<CSSTransition
nodeRef={backdropRef}
appear={isOpen}
in={isOpen}
classNames={animStyles.backdrop}
timeout={ANIMATION_DURATION}
>
<div className={styles.backdrop} {...underlayProps} ref={backdropRef} />
</CSSTransition>
</OverlayContainer>
);
}
NavBarMenu.displayName = 'NavBarMenu';
const getStyles = (theme: GrafanaTheme2) => ({
backdrop: css({
backdropFilter: 'blur(1px)',
backgroundColor: theme.components.overlay.background,
bottom: 0,
left: 0,
position: 'fixed',
right: 0,
top: 0,
zIndex: theme.zIndex.modalBackdrop,
}),
container: css({
display: 'flex',
bottom: 0,
flexDirection: 'column',
left: 0,
paddingTop: theme.spacing(1),
marginRight: theme.spacing(1.5),
right: 0,
zIndex: theme.zIndex.modal,
position: 'fixed',
top: 0,
boxSizing: 'content-box',
[theme.breakpoints.up('md')]: {
borderRight: `1px solid ${theme.colors.border.weak}`,
right: 'unset',
},
}),
content: css({
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
}),
mobileHeader: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
display: 'flex',
justifyContent: 'space-between',
padding: theme.spacing(1, 2, 2),
[theme.breakpoints.up('md')]: {
display: 'none',
},
}),
itemList: css({
display: 'grid',
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`,
minWidth: MENU_WIDTH,
}),
menuCollapseIcon: css({
position: 'absolute',
top: '43px',
right: '0px',
transform: `translateX(50%)`,
}),
});
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: 'background-color, 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 = {
backgroundColor: theme.colors.background.canvas,
boxShadow: theme.shadows.z3,
width: '100%',
[theme.breakpoints.up('md')]: {
width: MENU_WIDTH,
},
};
const overlayClosed = {
boxShadow: 'none',
width: 0,
[theme.breakpoints.up('md')]: {
backgroundColor: theme.colors.background.primary,
width: theme.spacing(7),
},
};
const backdropOpen = {
opacity: 1,
};
const backdropClosed = {
opacity: 0,
};
return {
backdrop: {
appear: css(backdropClosed),
appearActive: css(backdropTransition, backdropOpen),
appearDone: css(backdropOpen),
exit: css(backdropOpen),
exitActive: css(backdropTransition, backdropClosed),
},
overlay: {
appear: css(overlayClosed),
appearActive: css(overlayTransition, overlayOpen),
appearDone: css(overlayOpen),
exit: css(overlayOpen),
exitActive: css(overlayTransition, overlayClosed),
},
};
};
export function NavItem({
link,
activeItem,
onClose,
}: {
link: NavModelItem;
activeItem?: NavModelItem;
onClose: () => void;
}) {
const styles = useStyles2(getNavItemStyles);
if (linkHasChildren(link)) {
return (
<CollapsibleNavItem onClose={onClose} link={link} isActive={isMatchOrChildMatch(link, activeItem)}>
<ul className={styles.children}>
{link.children.map((childLink) => {
const icon = childLink.icon ? toIconName(childLink.icon) : undefined;
return (
!childLink.divider && (
<NavBarMenuItem
key={`${link.text}-${childLink.text}`}
isActive={activeItem === childLink}
isDivider={childLink.divider}
icon={childLink.showIconInNavbar ? icon : undefined}
onClick={() => {
childLink.onClick?.();
onClose();
}}
styleOverrides={styles.item}
target={childLink.target}
text={childLink.text}
url={childLink.url}
isMobile={true}
/>
)
);
})}
</ul>
</CollapsibleNavItem>
);
} else if (link.emptyMessage) {
return (
<CollapsibleNavItem onClose={onClose} link={link} isActive={isMatchOrChildMatch(link, activeItem)}>
<ul className={styles.children}>
<div className={styles.emptyMessage}>{link.emptyMessage}</div>
</ul>
</CollapsibleNavItem>
);
} else {
const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment;
return (
<li className={styles.flex}>
<NavBarItemWithoutMenu
className={styles.itemWithoutMenu}
elClassName={styles.fullWidth}
label={link.text}
url={link.url}
target={link.target}
onClick={() => {
link.onClick?.();
onClose();
}}
isActive={link === activeItem}
>
<div className={styles.itemWithoutMenuContent}>
<div className={styles.iconContainer}>
<FeatureHighlightWrapper>
<NavBarItemIcon link={link} />
</FeatureHighlightWrapper>
</div>
<span className={styles.linkText}>{link.text}</span>
</div>
</NavBarItemWithoutMenu>
</li>
);
}
}
const getNavItemStyles = (theme: GrafanaTheme2) => ({
children: css({
display: 'flex',
flexDirection: 'column',
}),
item: css({
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
width: `calc(100% - ${theme.spacing(3)})`,
'&::before': {
display: 'none',
},
}),
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',
padding: theme.spacing(0.5, 4.25, 0.5, 0.5),
}),
emptyMessage: css({
color: theme.colors.text.secondary,
fontStyle: 'italic',
padding: theme.spacing(1, 1.5),
}),
});
function CollapsibleNavItem({
link,
isActive,
children,
className,
onClose,
}: {
link: NavModelItem;
isActive?: boolean;
children: React.ReactNode;
className?: string;
onClose: () => void;
}) {
const styles = useStyles2(getCollapsibleStyles);
const [sectionExpanded, setSectionExpanded] = useLocalStorage(`grafana.navigation.expanded[${link.text}]`, false);
const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment;
return (
<li className={cx(styles.menuItem, className)}>
<NavBarItemWithoutMenu
isActive={isActive}
label={link.text}
url={link.url}
target={link.target}
onClick={() => {
link.onClick?.();
onClose();
}}
className={styles.collapsibleMenuItem}
elClassName={styles.collapsibleIcon}
>
<FeatureHighlightWrapper>
<NavBarItemIcon link={link} />
</FeatureHighlightWrapper>
</NavBarItemWithoutMenu>
<div className={styles.collapsibleSectionWrapper}>
<CollapsableSection
isOpen={Boolean(sectionExpanded)}
onToggle={(isOpen) => setSectionExpanded(isOpen)}
className={styles.collapseWrapper}
contentClassName={styles.collapseContent}
label={
<div className={cx(styles.labelWrapper, { [styles.primary]: isActive })}>
<span className={styles.linkText}>{link.text}</span>
</div>
}
>
{children}
</CollapsableSection>
</div>
</li>
);
}
const getCollapsibleStyles = (theme: GrafanaTheme2) => ({
menuItem: css({
position: 'relative',
display: 'grid',
gridAutoFlow: 'column',
gridTemplateColumns: `${theme.spacing(7)} minmax(calc(${MENU_WIDTH} - ${theme.spacing(7)}), auto)`,
}),
collapsibleMenuItem: css({
height: theme.spacing(6),
width: theme.spacing(7),
display: 'grid',
}),
collapsibleIcon: css({
display: 'grid',
placeContent: 'center',
}),
collapsibleSectionWrapper: css({
display: 'flex',
flexGrow: 1,
alignSelf: 'start',
flexDirection: 'column',
}),
collapseWrapper: css({
paddingLeft: theme.spacing(0.5),
paddingRight: theme.spacing(4.25),
minHeight: theme.spacing(6),
overflowWrap: 'anywhere',
alignItems: 'center',
color: theme.colors.text.secondary,
'&:hover, &:focus-within': {
backgroundColor: theme.colors.action.hover,
color: theme.colors.text.primary,
},
'&:focus-within': {
boxShadow: 'none',
outline: `2px solid ${theme.colors.primary.main}`,
outlineOffset: '-2px',
transition: 'none',
},
}),
collapseContent: css({
padding: 0,
}),
labelWrapper: css({
fontSize: '15px',
}),
primary: css({
color: theme.colors.text.primary,
}),
linkText: css({
fontSize: theme.typography.pxToRem(14),
justifySelf: 'start',
}),
});
function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } {
return Boolean(link.children && link.children.length > 0);
}

@ -1,56 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { NavBarMenuItem } from './NavBarMenuItem';
describe('NavBarMenuItem', () => {
const mockText = 'MyChildItem';
const mockUrl = '/route';
const mockIcon = 'home-alt';
it('displays the text', () => {
render(<NavBarMenuItem text={mockText} />);
const text = screen.getByText(mockText);
expect(text).toBeInTheDocument();
});
it('attaches the url to the text if provided', () => {
render(
<BrowserRouter>
<NavBarMenuItem text={mockText} url={mockUrl} />
</BrowserRouter>
);
const link = screen.getByRole('link', { name: mockText });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', mockUrl);
});
it('displays an icon if a valid icon is provided', () => {
render(<NavBarMenuItem text={mockText} icon={mockIcon} />);
const icon = screen.getByTestId('dropdown-child-icon');
expect(icon).toBeInTheDocument();
});
it('displays an external link icon if the target is _blank', () => {
render(<NavBarMenuItem text={mockText} icon={mockIcon} url={mockUrl} target="_blank" />);
const icon = screen.getByTestId('external-link-icon');
expect(icon).toBeInTheDocument();
});
it('displays a divider instead when isDivider is true', () => {
render(<NavBarMenuItem text={mockText} icon={mockIcon} url={mockUrl} isDivider />);
// Check the divider is shown
const divider = screen.getByTestId('dropdown-child-divider');
expect(divider).toBeInTheDocument();
// Check nothing else is rendered
const text = screen.queryByText(mockText);
const icon = screen.queryByTestId('dropdown-child-icon');
const link = screen.queryByRole('link', { name: mockText });
expect(text).not.toBeInTheDocument();
expect(icon).not.toBeInTheDocument();
expect(link).not.toBeInTheDocument();
});
});

@ -1,153 +0,0 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
export interface Props {
icon?: IconName;
isActive?: boolean;
isDivider?: boolean;
onClick?: () => void;
styleOverrides?: string;
target?: HTMLAnchorElement['target'];
text: React.ReactNode;
url?: string;
adjustHeightForBorder?: boolean;
isMobile?: boolean;
}
export function NavBarMenuItem({
icon,
isActive,
isDivider,
onClick,
styleOverrides,
target,
text,
url,
isMobile = false,
}: Props) {
const theme = useTheme2();
const styles = getStyles(theme, isActive);
const elStyle = cx(styles.element, styleOverrides);
const linkContent = (
<div className={styles.linkContent}>
{icon && <Icon data-testid="dropdown-child-icon" name={icon} />}
<div className={styles.linkText}>{text}</div>
{target === '_blank' && (
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} />
)}
</div>
);
let element = (
<button className={elStyle} onClick={onClick} tabIndex={-1}>
{linkContent}
</button>
);
if (url) {
element =
!target && url.startsWith('/') ? (
<Link className={elStyle} href={url} target={target} onClick={onClick} tabIndex={!isMobile ? -1 : 0}>
{linkContent}
</Link>
) : (
<a href={url} target={target} className={elStyle} onClick={onClick} tabIndex={!isMobile ? -1 : 0}>
{linkContent}
</a>
);
}
if (isMobile) {
return isDivider ? (
<div data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled />
) : (
<li className={styles.listItem}>{element}</li>
);
}
return isDivider ? (
<div data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled />
) : (
<div style={{ position: 'relative' }}>{element}</div>
);
}
NavBarMenuItem.displayName = 'NavBarMenuItem';
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
linkContent: css({
alignItems: 'center',
display: 'flex',
gap: '0.5rem',
width: '100%',
}),
linkText: css({
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
}),
externalLinkIcon: css({
color: theme.colors.text.secondary,
gridColumnStart: 3,
}),
element: css({
alignItems: 'center',
background: 'none',
border: 'none',
color: isActive ? theme.colors.text.primary : theme.colors.text.secondary,
display: 'flex',
flex: 1,
fontSize: 'inherit',
height: '100%',
overflowWrap: 'anywhere',
padding: theme.spacing(0.5, 2),
textAlign: 'left',
width: '100%',
'&:hover, &:focus-visible': {
backgroundColor: theme.colors.action.hover,
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: '" "',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius(1),
backgroundImage: theme.colors.gradients.brandVertical,
},
}),
listItem: css({
position: 'relative',
display: 'flex',
alignItems: 'center',
'&:hover, &:focus-within': {
color: theme.colors.text.primary,
'> *:first-child::after': {
backgroundColor: theme.colors.action.hover,
},
},
}),
divider: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
height: '1px',
margin: `${theme.spacing(1)} 0`,
overflow: 'hidden',
}),
});

@ -1,27 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
const NAV_MENU_PORTAL_CONTAINER_ID = 'navbar-menu-portal-container';
export const getNavMenuPortalContainer = () => document.getElementById(NAV_MENU_PORTAL_CONTAINER_ID) ?? document.body;
export const NavBarMenuPortalContainer = () => {
const theme = useTheme2();
const styles = getStyles(theme);
return <div className={styles.menuPortalContainer} id={NAV_MENU_PORTAL_CONTAINER_ID} />;
};
NavBarMenuPortalContainer.displayName = 'NavBarMenuPortalContainer';
const getStyles = (theme: GrafanaTheme2) => ({
menuPortalContainer: css({
left: 0,
position: 'fixed',
right: 0,
top: 0,
zIndex: theme.zIndex.sidemenu,
}),
});

@ -1,43 +0,0 @@
import { css } from '@emotion/css';
import classnames from 'classnames';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, useTheme2 } from '@grafana/ui';
export interface Props {
className?: string;
isExpanded: boolean;
onClick: () => void;
}
export const NavBarToggle = ({ className, isExpanded, onClick }: Props) => {
const theme = useTheme2();
const styles = getStyles(theme);
return (
<IconButton
aria-label={isExpanded ? 'Close navigation menu' : 'Open navigation menu'}
name={isExpanded ? 'angle-left' : 'angle-right'}
className={classnames(className, styles.icon)}
size="xl"
onClick={onClick}
/>
);
};
NavBarToggle.displayName = 'NavBarToggle';
const getStyles = (theme: GrafanaTheme2) => ({
icon: css({
backgroundColor: theme.colors.background.secondary,
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: '50%',
marginRight: 0,
zIndex: theme.zIndex.sidemenu + 1,
[theme.breakpoints.down('md')]: {
display: 'none',
},
}),
});

@ -1,32 +0,0 @@
import { createContext, HTMLAttributes, useContext } from 'react';
export interface NavBarItemMenuContextProps {
menuHasFocus: boolean;
onClose: () => void;
onLeft: () => void;
menuProps?: HTMLAttributes<HTMLElement>;
}
export const NavBarItemMenuContext = createContext<NavBarItemMenuContextProps>({
menuHasFocus: false,
onClose: () => undefined,
onLeft: () => undefined,
});
export function useNavBarItemMenuContext(): NavBarItemMenuContextProps {
return useContext(NavBarItemMenuContext);
}
export interface NavBarContextProps {
menuIdOpen: string | undefined;
setMenuIdOpen: (id: string | undefined) => void;
}
export const NavBarContext = createContext<NavBarContextProps>({
menuIdOpen: undefined,
setMenuIdOpen: () => undefined,
});
export function useNavBarContext(): NavBarContextProps {
return useContext(NavBarContext);
}

@ -1,10 +0,0 @@
import React from 'react';
interface Props {
children: React.ReactNode;
}
/** Remove after topnav feature toggle is removed */
export function OldNavOnly({ children }: Props): React.ReactElement | null {
return <>{children}</>;
}

@ -11,7 +11,6 @@ import { Footer } from '../Footer/Footer';
import { PageHeader } from '../PageHeader/PageHeader';
import { Page as NewPage } from '../PageNew/Page';
import { OldNavOnly } from './OldNavOnly';
import { PageContents } from './PageContents';
import { PageType } from './types';
import { usePageNav } from './usePageNav';
@ -93,7 +92,6 @@ export const OldPage: PageType = ({
};
OldPage.Contents = PageContents;
OldPage.OldNavOnly = OldNavOnly;
export const Page: PageType = config.featureToggles.topnav ? NewPage : OldPage;

@ -2,7 +2,6 @@ import React, { FC, HTMLAttributes, RefCallback } from 'react';
import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
import { OldNavOnly } from './OldNavOnly';
import { PageContents } from './PageContents';
export interface PageProps extends HTMLAttributes<HTMLDivElement> {
@ -34,6 +33,5 @@ export interface PageInfoItem {
}
export interface PageType extends FC<PageProps> {
OldNavOnly: typeof OldNavOnly;
Contents: typeof PageContents;
}

@ -92,10 +92,6 @@ export const Page: PageType = ({
Page.Contents = PageContents;
Page.OldNavOnly = function OldNavOnly() {
return null;
};
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css({

@ -3,7 +3,6 @@ import React, { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Modal, useStyles2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { getModKey } from 'app/core/utils/browser';
const getShortcuts = (modKey: string) => {
@ -12,14 +11,7 @@ const getShortcuts = (modKey: string) => {
{ keys: ['g', 'h'], description: 'Go to Home Dashboard' },
{ keys: ['g', 'e'], description: 'Go to Explore' },
{ keys: ['g', 'p'], description: 'Go to Profile' },
...(config.featureToggles.topnav
? [{ keys: [`${modKey} + k`], description: 'Open search' }]
: [
{ keys: ['s', 'o'], description: 'Open search' },
{ keys: [`${modKey} + k`], description: 'Open command palette' },
]),
{ keys: [`${modKey} + k`], description: 'Open search' },
{ keys: ['esc'], description: 'Exit edit/setting views' },
{ keys: ['h'], description: 'Show all keyboard shortcuts' },
{ keys: ['c', 't'], description: 'Change theme' },

@ -3,7 +3,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { NavModelItem } from '@grafana/data';
import { config } from '@grafana/runtime';
import { getNavSubTitle, getNavTitle } from '../components/NavBar/navBarItem-translations';
import { getNavSubTitle, getNavTitle } from '../components/AppChrome/MegaMenu/navBarItem-translations';
export const initialState: NavModelItem[] = config.bootData?.navTree ?? [];

@ -4,7 +4,7 @@ import { cloneDeep } from 'lodash';
import { NavIndex, NavModel, NavModelItem } from '@grafana/data';
import config from 'app/core/config';
import { getNavSubTitle, getNavTitle } from '../components/NavBar/navBarItem-translations';
import { getNavSubTitle, getNavTitle } from '../components/AppChrome/MegaMenu/navBarItem-translations';
export const HOME_NAV_ID = 'home';

@ -3,7 +3,7 @@ import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind';
import 'mousetrap/plugins/global-bind/mousetrap-global-bind';
import { LegacyGraphHoverClearEvent, locationUtil } from '@grafana/data';
import { config, LocationService } from '@grafana/runtime';
import { LocationService } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import { getExploreUrl } from 'app/core/utils/explore';
import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer';
@ -41,10 +41,6 @@ export class KeybindingSrv {
this.bind('g a', this.openAlerting);
this.bind('g p', this.goToProfile);
this.bind('g e', this.goToExplore);
if (!config.featureToggles.topnav) {
this.bind('s o', this.openSearch);
this.bind('f', this.openSearch);
}
this.bind('t a', this.makeAbsoluteTime);
this.bind('esc', this.exit);
this.bindGlobalEsc();
@ -52,10 +48,6 @@ export class KeybindingSrv {
this.bind('c t', () => toggleTheme(false));
this.bind('c r', () => toggleTheme(true));
if (process.env.NODE_ENV === 'development') {
this.bind('t n', () => this.toggleNav());
}
}
bindGlobalEsc() {
@ -88,18 +80,6 @@ export class KeybindingSrv {
this.exit();
}
toggleNav() {
window.location.href =
config.appSubUrl +
locationUtil.getUrlForPartial(this.locationService.getLocation(), {
'__feature.topnav': (!config.featureToggles.topnav).toString(),
});
}
private openSearch() {
this.locationService.partial({ search: 'open' });
}
private closeSearch() {
this.locationService.partial({ search: null });
}

@ -1,10 +1,9 @@
import { uniq } from 'lodash';
import React from 'react';
import { Redirect } from 'react-router-dom';
import { OrgRole } from '@grafana/data';
import { NavLandingPage } from 'app/core/components/AppChrome/NavLandingPage';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { NavLandingPage } from 'app/core/components/NavLandingPage/NavLandingPage';
import { config } from 'app/core/config';
import { RouteDescriptor } from 'app/core/navigation/types';
import { AccessControlAction } from 'app/types';
@ -17,8 +16,7 @@ const legacyRoutes: RouteDescriptor[] = [
...commonRoutes,
{
path: '/alerting',
component: () =>
config.featureToggles.topnav ? <NavLandingPage navId="alerting-legacy" /> : <Redirect to="/alerting/list" />,
component: () => <NavLandingPage navId="alerting-legacy" />,
},
{
path: '/alerting/list',
@ -90,19 +88,12 @@ const legacyRoutes: RouteDescriptor[] = [
const unifiedRoutes: RouteDescriptor[] = [
...commonRoutes,
config.featureToggles.topnav
? {
path: '/alerting',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertingHome" */ 'app/features/alerting/unified/Home')
),
}
: {
path: '/alerting/home',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertingHome" */ 'app/features/alerting/unified/Home')
),
},
{
path: '/alerting',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertingHome" */ 'app/features/alerting/unified/Home')
),
},
{
path: '/alerting/list',
roles: evaluateAccess(

@ -4,7 +4,6 @@ import SVG from 'react-inlinesvg';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Icon, useStyles2, useTheme2 } from '@grafana/ui';
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
@ -14,7 +13,7 @@ export default function Home() {
const styles = useStyles2(getWelcomePageStyles);
return (
<AlertingPageWrapper pageId={config.featureToggles.topnav ? 'alerting' : 'alert-home'}>
<AlertingPageWrapper pageId={'alerting'}>
<div className={styles.grid}>
<WelcomeHeader className={styles.ctaContainer} />
<ContentBox className={styles.flowBlock}>

@ -1,7 +1,7 @@
import * as React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { NavLandingPage } from 'app/core/components/AppChrome/NavLandingPage';
import { NavLandingPage } from 'app/core/components/NavLandingPage/NavLandingPage';
import { DataSourcesRoutesContext } from 'app/features/datasources/state';
import { StoreState, useSelector } from 'app/types';

@ -1,6 +1,5 @@
import * as React from 'react';
import { config } from '@grafana/runtime';
import { Page } from 'app/core/components/Page/Page';
import { DataSourceAddButton } from 'app/features/datasources/components/DataSourceAddButton';
import { DataSourcesList } from 'app/features/datasources/components/DataSourcesList';
@ -10,7 +9,7 @@ import { StoreState, useSelector } from 'app/types';
export function DataSourcesListPage() {
const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources));
const actions = config.featureToggles.topnav && dataSourcesCount > 0 ? <DataSourceAddButton /> : undefined;
const actions = dataSourcesCount > 0 ? <DataSourceAddButton /> : undefined;
return (
<Page navId={'connections-your-connections-datasources'} actions={actions}>
<Page.Contents>

@ -178,13 +178,6 @@ jest.mock('@grafana/runtime', () => {
return {
...runtime,
config: {
...runtime.config,
featureToggles: {
...runtime.config.featureToggles,
topnav: true,
},
},
reportInteraction: (...args: Parameters<typeof reportInteraction>) => {
mocks.reportInteraction(...args);
},

@ -8,7 +8,6 @@ import {
Badge,
Button,
DeleteButton,
HorizontalGroup,
LoadingPlaceholder,
useStyles2,
Alert,
@ -158,15 +157,6 @@ export default function CorrelationsPage() {
actions={addButton}
>
<Page.Contents>
<div>
<HorizontalGroup justify="space-between">
<Page.OldNavOnly>
<p>Define how data living in different data sources relates to each other.</p>
</Page.OldNavOnly>
<Page.OldNavOnly>{addButton}</Page.OldNavOnly>
</HorizontalGroup>
</div>
<div>
{!data && get.loading && (
<div className={loaderWrapper}>

@ -1,54 +0,0 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime/src';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { getGrafanaContextMock } from '../../../../../test/mocks/getGrafanaContextMock';
import { setStarred } from '../../../../core/reducers/navBarTree';
import { configureStore } from '../../../../store/configureStore';
import { updateTimeZoneForSession } from '../../../profile/state/reducers';
import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures';
import { DashNav } from './DashNav';
describe('Public dashboard title tag', () => {
it('will be rendered when publicDashboardEnabled set to true in dashboard meta', async () => {
let dashboard = createDashboardModelFixture({}, { publicDashboardEnabled: false });
const store = configureStore();
const context = getGrafanaContextMock();
const props = {
setStarred: jest.fn() as unknown as typeof setStarred,
updateTimeZoneForSession: jest.fn() as unknown as typeof updateTimeZoneForSession,
};
render(
<Provider store={store}>
<GrafanaContext.Provider value={context}>
<Router history={locationService.getHistory()}>
<DashNav
{...props}
dashboard={dashboard}
hideTimePicker={true}
isFullscreen={false}
onAddPanel={() => {}}
title="test"
/>
</Router>
</GrafanaContext.Provider>
</Provider>
);
const publicTag = screen.queryByText('Public');
expect(publicTag).not.toBeInTheDocument();
act(() => {
dashboard.updateMeta({ publicDashboardEnabled: true });
});
await waitFor(() => screen.getByText('Public'));
});
});

@ -3,14 +3,13 @@ import React, { FC, ReactNode, useContext, useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { locationUtil, textUtil } from '@grafana/data';
import { textUtil } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
import { locationService } from '@grafana/runtime';
import {
ButtonGroup,
ModalsController,
ToolbarButton,
PageToolbar,
useForceUpdate,
Tag,
ToolbarButtonRow,
@ -18,9 +17,8 @@ import {
ConfirmModal,
} from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbarSeparator';
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
import config from 'app/core/config';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { useAppNotification } from 'app/core/copy/appNotification';
import { appEvents } from 'app/core/core';
import { useBusEvent } from 'app/core/hooks/useBusEvent';
@ -79,8 +77,9 @@ export function addCustomRightAction(content: DashNavButtonModel) {
type Props = OwnProps & ConnectedProps<typeof connector>;
export const DashNav = React.memo<Props>((props) => {
// this ensures the component rerenders when the location changes
useLocation();
const forceUpdate = useForceUpdate();
const { chrome } = useGrafana();
const { showModal, hideModal } = useContext(ModalsContext);
// We don't really care about the event payload here only that it triggeres a re-render of this component
@ -136,14 +135,6 @@ export const DashNav = React.memo<Props>((props) => {
});
};
const onClose = () => {
locationService.partial({ viewPanel: null });
};
const onToggleTVMode = () => {
chrome.onToggleKioskMode();
};
const onOpenSettings = () => {
locationService.partial({ editview: 'settings' });
};
@ -287,21 +278,13 @@ export const DashNav = React.memo<Props>((props) => {
const { snapshot } = dashboard;
const snapshotUrl = snapshot && snapshot.originalUrl;
const buttons: ReactNode[] = [];
const tvButton = config.featureToggles.topnav ? null : (
<ToolbarButton
tooltip={t('dashboard.toolbar.tv-button', 'Cycle view mode')}
icon="monitor"
onClick={onToggleTVMode}
key="tv-button"
/>
);
if (isPlaylistRunning()) {
return [renderPlaylistControls(), renderTimeControls()];
}
if (kioskMode === KioskMode.TV) {
return [renderTimeControls(), tvButton];
return [renderTimeControls()];
}
if (canEdit && !isFullscreen) {
@ -364,7 +347,6 @@ export const DashNav = React.memo<Props>((props) => {
addCustomContent(customRightActions, buttons);
buttons.push(renderTimeControls());
buttons.push(tvButton);
if (config.featureToggles.scenes) {
buttons.push(
@ -379,39 +361,16 @@ export const DashNav = React.memo<Props>((props) => {
return buttons;
};
const { isFullscreen, title, folderTitle } = props;
// this ensures the component rerenders when the location changes
const location = useLocation();
const titleHref = locationUtil.getUrlForPartial(location, { search: 'open' });
const parentHref = locationUtil.getUrlForPartial(location, { search: 'open', query: 'folder:current' });
const onGoBack = isFullscreen ? onClose : undefined;
if (config.featureToggles.topnav) {
return (
<AppChromeUpdate
actions={
<>
{renderLeftActions()}
<NavToolbarSeparator leftActionsSeparator />
<ToolbarButtonRow alignment="right">{renderRightActions()}</ToolbarButtonRow>
</>
}
/>
);
}
return (
<PageToolbar
pageIcon={isFullscreen ? undefined : 'apps'}
title={title}
parent={folderTitle}
titleHref={titleHref}
parentHref={parentHref}
onGoBack={onGoBack}
leftItems={renderLeftActions()}
>
{renderRightActions()}
</PageToolbar>
<AppChromeUpdate
actions={
<>
{renderLeftActions()}
<NavToolbarSeparator leftActionsSeparator />
<ToolbarButtonRow alignment="right">{renderRightActions()}</ToolbarButtonRow>
</>
}
/>
);
});

@ -5,7 +5,7 @@ import { useLocation } from 'react-router-dom';
import { locationUtil, NavModel, NavModelItem } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { locationService } from '@grafana/runtime';
import { Button, PageToolbar, ToolbarButtonRow } from '@grafana/ui';
import { Button, ToolbarButtonRow } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { Page } from 'app/core/components/PageNew/Page';
import config from 'app/core/config';
@ -49,28 +49,25 @@ export function DashboardSettings({ dashboard, editview, pageNav, sectionNav }:
dashboard.meta.hasUnsavedFolderChange = false;
};
const folderTitle = dashboard.meta.folderTitle;
const currentPage = pages.find((page) => page.id === editview) ?? pages[0];
const canSaveAs = contextSrv.hasEditPermissionInFolders;
const canSave = dashboard.meta.canSave;
const location = useLocation();
const editIndex = getEditIndex(location);
const subSectionNav = getSectionNav(pageNav, sectionNav, pages, currentPage, location);
const size = config.featureToggles.topnav ? 'sm' : 'md';
const size = 'sm';
const actions = [
config.featureToggles.topnav && (
<Button
data-testid={selectors.pages.Dashboard.Settings.Actions.close}
variant="secondary"
key="close"
fill="outline"
size={size}
onClick={onClose}
>
Close
</Button>
),
<Button
data-testid={selectors.pages.Dashboard.Settings.Actions.close}
variant="secondary"
key="close"
fill="outline"
size={size}
onClick={onClose}
>
Close
</Button>,
canSaveAs && (
<SaveDashboardAsButton
dashboard={dashboard}
@ -85,13 +82,7 @@ export function DashboardSettings({ dashboard, editview, pageNav, sectionNav }:
return (
<>
{!config.featureToggles.topnav ? (
<PageToolbar title={`${dashboard.title} / Settings`} parent={folderTitle} onGoBack={onClose}>
{actions}
</PageToolbar>
) : (
<AppChromeUpdate actions={<ToolbarButtonRow alignment="right">{actions}</ToolbarButtonRow>} />
)}
<AppChromeUpdate actions={<ToolbarButtonRow alignment="right">{actions}</ToolbarButtonRow>} />
<currentPage.component sectionNav={subSectionNav} dashboard={dashboard} editIndex={editIndex} />
</>
);

@ -7,13 +7,12 @@ import { Subscription } from 'rxjs';
import { FieldConfigSource, GrafanaTheme2, NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Stack } from '@grafana/experimental';
import { config, locationService } from '@grafana/runtime';
import { locationService } from '@grafana/runtime';
import {
Button,
HorizontalGroup,
InlineSwitch,
ModalsController,
PageToolbar,
RadioButtonGroup,
stylesFactory,
Themeable2,
@ -322,7 +321,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
}
renderEditorActions() {
const size = config.featureToggles.topnav ? 'sm' : 'md';
const size = 'sm';
let editorActions = [
<Button
onClick={this.onDiscard}
@ -431,18 +430,8 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
};
renderToolbar() {
if (config.featureToggles.topnav) {
return (
<AppChromeUpdate
actions={<ToolbarButtonRow alignment="right">{this.renderEditorActions()}</ToolbarButtonRow>}
/>
);
}
return (
<PageToolbar title={this.props.dashboard.title} section="Edit Panel" onGoBack={this.onGoBackToDashboard}>
{this.renderEditorActions()}
</PageToolbar>
<AppChromeUpdate actions={<ToolbarButtonRow alignment="right">{this.renderEditorActions()}</ToolbarButtonRow>} />
);
}
@ -514,7 +503,7 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2, props: Props) => {
flexGrow: 1,
minHeight: 0,
display: 'flex',
paddingTop: config.featureToggles.topnav ? theme.spacing(2) : 0,
paddingTop: theme.spacing(2),
}),
verticalSplitPanesWrapper: css`
display: flex;

@ -90,7 +90,7 @@ const mockCleanUpDashboardAndVariables = jest.fn();
function setup(propOverrides?: Partial<Props>) {
config.bootData.navTree = [
{ text: 'Dashboards', id: 'dashboards' },
{ text: 'Dashboards', id: 'dashboards/browse' },
{ text: 'Home', id: HOME_NAV_ID },
];
@ -101,7 +101,11 @@ function setup(propOverrides?: Partial<Props>) {
route: { routeName: DashboardRoutes.Normal } as RouteDescriptor,
}),
navIndex: {
dashboards: { text: 'Dashboards', id: 'dashboards', parentItem: { text: 'Home', id: HOME_NAV_ID } },
'dashboards/browse': {
text: 'Dashboards',
id: 'dashboards/browse',
parentItem: { text: 'Home', id: HOME_NAV_ID },
},
[HOME_NAV_ID]: { text: 'Home', id: HOME_NAV_ID },
},
initPhase: DashboardInitPhase.NotStarted,
@ -226,27 +230,6 @@ describe('DashboardPage', () => {
expect(dashboard.panelInEdit).toBeDefined();
});
});
it('Should render panel editor', async () => {
const dashboard = getTestDashboard();
setup({
dashboard,
queryParams: { editPanel: '1' },
});
expect(await screen.findByTitle('Apply changes and go back to dashboard')).toBeInTheDocument();
});
it('Should reset state when leaving', async () => {
const dashboard = getTestDashboard();
const { rerender } = setup({
dashboard,
queryParams: { editPanel: '1' },
});
rerender({ queryParams: {} });
await waitFor(() => {
expect(screen.queryByTitle('Apply changes and go back to dashboard')).not.toBeInTheDocument();
});
});
});
describe('When dashboard unmounts', () => {

@ -551,7 +551,7 @@ function updateStatePageNavFromProps(props: Props, state: State): State {
pageNav.parentItem = pageNav.parentItem;
}
} else {
sectionNav = getNavModel(props.navIndex, config.featureToggles.topnav ? 'dashboards/browse' : 'dashboards');
sectionNav = getNavModel(props.navIndex, 'dashboards/browse');
}
if (state.editPanel || state.viewPanel) {

@ -1,18 +1,10 @@
import React, { useCallback } from 'react';
import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
import { contextSrv } from 'app/core/core';
import { StoreState, useSelector, useDispatch, AccessControlAction } from 'app/types';
import { StoreState, useSelector, useDispatch } from 'app/types';
import {
getDataSourcesSearchQuery,
getDataSourcesSort,
setDataSourcesSearchQuery,
setIsSortAscending,
useDataSourcesRoutes,
} from '../state';
import { getDataSourcesSearchQuery, getDataSourcesSort, setDataSourcesSearchQuery, setIsSortAscending } from '../state';
const ascendingSortValue = 'alpha-asc';
const descendingSortValue = 'alpha-desc';
@ -30,19 +22,6 @@ export function DataSourcesListHeader() {
const setSearchQuery = useCallback((q: string) => dispatch(setDataSourcesSearchQuery(q)), [dispatch]);
const searchQuery = useSelector(({ dataSources }: StoreState) => getDataSourcesSearchQuery(dataSources));
// TODO remove this logic adding the link button once topnav is live
// instead use the actions in DataSourcesListPage
const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
const dataSourcesRoutes = useDataSourcesRoutes();
const isTopnav = config.featureToggles.topnav;
const linkButton =
!isTopnav && canCreateDataSource
? {
href: dataSourcesRoutes.New,
title: 'Add new data source',
}
: undefined;
const setSort = useCallback(
(sort: SelectableValue) => dispatch(setIsSortAscending(sort.value === ascendingSortValue)),
[dispatch]
@ -56,12 +35,6 @@ export function DataSourcesListHeader() {
};
return (
<PageActionBar
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
key="action-bar"
sortPicker={sortPicker}
linkButton={linkButton}
/>
<PageActionBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} key="action-bar" sortPicker={sortPicker} />
);
}

@ -12,7 +12,7 @@ import { getDataSourcesCount } from '../state';
export function DataSourcesListPage() {
const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources));
const actions = config.featureToggles.topnav && dataSourcesCount > 0 ? <DataSourceAddButton /> : undefined;
const actions = dataSourcesCount > 0 ? <DataSourceAddButton /> : undefined;
return (
<Page navId="datasources" actions={actions}>
<Page.Contents>

@ -1,10 +1,9 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
import { TestProvider } from 'test/helpers/TestProvider';
import { DataSourceApi, LoadingState, CoreApp, createTheme, EventBusSrv } from '@grafana/data';
import { configureStore } from 'app/store/configureStore';
import { ExploreId } from 'app/types/explore';
import { Explore, Props } from './Explore';
@ -120,13 +119,12 @@ jest.mock('react-virtualized-auto-sizer', () => {
});
const setup = (overrideProps?: Partial<Props>) => {
const store = configureStore();
const exploreProps = { ...dummyProps, ...overrideProps };
return render(
<Provider store={store}>
<TestProvider>
<Explore {...exploreProps} />
</Provider>
</TestProvider>
);
};
@ -135,7 +133,7 @@ describe('Explore', () => {
setup();
// Wait for the Explore component to render
await screen.findByText('Explore');
await screen.findByLabelText('Data source picker select container');
expect(screen.queryByTestId('explore-no-data')).not.toBeInTheDocument();
});
@ -145,7 +143,7 @@ describe('Explore', () => {
setup({ queryResponse: queryResp });
// Wait for the Explore component to render
await screen.findByText('Explore');
await screen.findByLabelText('Data source picker select container');
expect(screen.getByTestId('explore-no-data')).toBeInTheDocument();
});

@ -243,7 +243,6 @@ class UnConnectedExploreToolbar extends PureComponent<Props> {
this.props;
const showSmallDataSourcePicker = (splitted ? containerWidth < 700 : containerWidth < 800) || false;
const isTopnav = config.featureToggles.topnav;
const shareButton = (
<DashNavButton
@ -267,27 +266,15 @@ class UnConnectedExploreToolbar extends PureComponent<Props> {
/>
);
const toolbarLeftItems = [
// We only want to show the shortened link button in the left Toolbar if topnav is not enabled as with topnav enabled it sits next to the brecrumbs
!isTopnav && exploreId === ExploreId.left && shareButton,
getDataSourcePicker(),
].filter(Boolean);
const toolbarLeftItems = [getDataSourcePicker()].filter(Boolean);
return (
<div ref={topOfViewRef}>
{refreshInterval && <SetInterval func={this.onRunQuery} interval={refreshInterval} loading={loading} />}
{isTopnav && (
<div ref={topOfViewRef}>
<AppChromeUpdate actions={[shareButton, <div style={{ flex: 1 }} key="spacer" />]} />
</div>
)}
<PageToolbar
aria-label="Explore toolbar"
title={exploreId === ExploreId.left && !isTopnav ? 'Explore' : undefined}
pageIcon={exploreId === ExploreId.left && !isTopnav ? 'compass' : undefined}
leftItems={toolbarLeftItems}
forceShowLeftItems
>
<div ref={topOfViewRef}>
<AppChromeUpdate actions={[shareButton, <div style={{ flex: 1 }} key="spacer" />]} />
</div>
<PageToolbar aria-label="Explore toolbar" leftItems={toolbarLeftItems} forceShowLeftItems>
{this.renderActions()}
</PageToolbar>
</div>

@ -207,9 +207,7 @@ function LogsNavigation({
export default memo(LogsNavigation);
const getStyles = (theme: GrafanaTheme2, oldestLogsFirst: boolean) => {
const navContainerHeight = theme.flags.topnav
? `calc(100vh - 2*${theme.spacing(2)} - 2*${TOP_BAR_LEVEL_HEIGHT}px)`
: '95vh';
const navContainerHeight = `calc(100vh - 2*${theme.spacing(2)} - 2*${TOP_BAR_LEVEL_HEIGHT}px)`;
return {
navContainer: css`
max-height: ${navContainerHeight};

@ -54,7 +54,6 @@ function NewDashboardsFolder({ createNewFolder }: Props) {
return (
<Page navId="dashboards/browse" pageNav={pageNav}>
<Page.Contents>
{!config.featureToggles.topnav && <h3>New dashboard folder</h3>}
<Form defaultValues={initialFormModel} onSubmit={onSubmit}>
{({ register, errors }) => (
<>

@ -1,6 +1,5 @@
import React from 'react';
import { config } from '@grafana/runtime';
import { Page } from 'app/core/components/Page/Page';
import { contextSrv } from 'app/core/core';
@ -14,10 +13,8 @@ export function UserInvitePage() {
</>
);
const navId = config.featureToggles.topnav ? 'global-users' : 'users';
return (
<Page navId={navId} pageNav={{ text: 'Invite user' }} subTitle={subTitle}>
<Page navId="global-users" pageNav={{ text: 'Invite user' }} subTitle={subTitle}>
<Page.Contents>
<UserInviteForm />
</Page.Contents>

@ -36,32 +36,10 @@ const getPluginSettingsMock = getPluginSettings as jest.Mock<
>;
class RootComponent extends Component<AppRootProps> {
static timesMounted = 0;
componentDidMount() {
RootComponent.timesMounted += 1;
const node: NavModelItem = {
text: 'My Great plugin',
children: [
{
text: 'A page',
url: '/apage',
id: 'a',
},
{
text: 'Another page',
url: '/anotherpage',
id: 'b',
},
],
};
this.props.onNavChanged({
main: node,
node,
});
}
static timesRendered = 0;
render() {
return <p>my great plugin</p>;
RootComponent.timesRendered += 1;
return <p>my great component</p>;
}
}
@ -119,36 +97,9 @@ describe('AppRootPage', () => {
enabled: true,
});
it('should not mount plugin twice if nav is changed', async () => {
// reproduces https://github.com/grafana/grafana/pull/28105
getPluginSettingsMock.mockResolvedValue(pluginMeta);
const plugin = new AppPlugin();
plugin.meta = pluginMeta;
plugin.root = RootComponent;
importAppPluginMock.mockResolvedValue(plugin);
renderUnderRouter();
// check that plugin and nav links were rendered, and plugin is mounted only once
expect(await screen.findByText('my great plugin')).toBeVisible();
expect(await screen.findByLabelText('Tab A page')).toBeVisible();
expect(await screen.findByLabelText('Tab Another page')).toBeVisible();
expect(RootComponent.timesMounted).toEqual(1);
});
it('should not render component if not at plugin path', async () => {
getPluginSettingsMock.mockResolvedValue(pluginMeta);
class RootComponent extends Component<AppRootProps> {
static timesRendered = 0;
render() {
RootComponent.timesRendered += 1;
return <p>my great component</p>;
}
}
const plugin = new AppPlugin();
plugin.meta = pluginMeta;
plugin.root = RootComponent;
@ -160,18 +111,18 @@ describe('AppRootPage', () => {
expect(await screen.findByText('my great component')).toBeVisible();
// renders the first time
expect(RootComponent.timesRendered).toEqual(2);
expect(RootComponent.timesRendered).toEqual(1);
await act(async () => {
locationService.push('/foo');
});
expect(RootComponent.timesRendered).toEqual(2);
expect(RootComponent.timesRendered).toEqual(1);
await act(async () => {
locationService.push('/a/my-awesome-plugin');
});
expect(RootComponent.timesRendered).toEqual(4);
expect(RootComponent.timesRendered).toEqual(2);
});
});

@ -1,7 +1,6 @@
// Libraries
import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit';
import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import { useLocation, useRouteMatch } from 'react-router-dom';
import { AppEvents, AppPlugin, AppPluginMeta, NavModel, NavModelItem, PluginType } from '@grafana/data';
@ -37,7 +36,6 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
const match = useRouteMatch();
const location = useLocation();
const [state, dispatch] = useReducer(stateSlice.reducer, initialState);
const portalNode = useMemo(() => createHtmlPortalNode(), []);
const currentUrl = config.appSubUrl + location.pathname + location.search;
const { plugin, loading, pluginNav } = state;
const navModel = buildPluginSectionNav(pluginNavSection, pluginNav, currentUrl);
@ -75,23 +73,18 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
/>
);
if (config.featureToggles.topnav && !pluginNav) {
if (!pluginNav) {
return <PluginPageContext.Provider value={context}>{pluginRoot}</PluginPageContext.Provider>;
}
return (
<>
<InPortal node={portalNode}>{pluginRoot}</InPortal>
{navModel ? (
<Page navModel={navModel} pageNav={pluginNav?.node}>
<Page.Contents isLoading={loading}>
<OutPortal node={portalNode} />
</Page.Contents>
<Page.Contents isLoading={loading}>{pluginRoot}</Page.Contents>
</Page>
) : (
<Page>
<OutPortal node={portalNode} />
</Page>
<Page>{pluginRoot}</Page>
)}
</>
);
@ -112,8 +105,7 @@ const stateSlice = createSlice({
...pluginNav,
node: {
...pluginNav.main,
// Because breadcumbs code is also used to set title when topnav should only set hideFromBreadcrumbs when topnav is enabled
hideFromBreadcrumbs: config.featureToggles.topnav,
hideFromBreadcrumbs: true,
},
};
}

@ -1,5 +1,4 @@
import { NavModelItem } from '@grafana/data';
import { config } from '@grafana/runtime';
import { HOME_NAV_ID } from 'app/core/reducers/navModel';
import { buildPluginSectionNav } from './utils';
@ -50,41 +49,30 @@ describe('buildPluginSectionNav', () => {
app1.parentItem = appsSection;
it('Should return pluginNav if topnav is disabled', () => {
config.featureToggles.topnav = false;
const result = buildPluginSectionNav(appsSection, pluginNav, '/a/plugin1/page1');
expect(result).toBe(pluginNav);
});
it('Should return return section nav if topnav is enabled', () => {
config.featureToggles.topnav = true;
it('Should return return section nav', () => {
const result = buildPluginSectionNav(appsSection, pluginNav, '/a/plugin1/page1');
expect(result?.main.text).toBe('apps');
});
it('Should set active page', () => {
config.featureToggles.topnav = true;
const result = buildPluginSectionNav(appsSection, null, '/a/plugin1/page2');
expect(result?.main.children![0].children![1].active).toBe(true);
expect(result?.node.text).toBe('page2');
});
it('Should only set the most specific match as active (not the parents)', () => {
config.featureToggles.topnav = true;
const result = buildPluginSectionNav(appsSection, null, '/a/plugin1/page2');
expect(result?.main.children![0].children![1].active).toBe(true);
expect(result?.main.children![0].active).not.toBe(true); // Parent should not be active
});
it('Should set app section to active', () => {
config.featureToggles.topnav = true;
const result = buildPluginSectionNav(appsSection, null, '/a/plugin1');
expect(result?.main.children![0].active).toBe(true);
expect(result?.node.text).toBe('App1');
});
it('Should handle standalone page', () => {
config.featureToggles.topnav = true;
const result = buildPluginSectionNav(adminSection, pluginNav, '/a/app2/config');
expect(result?.main.text).toBe('Admin');
expect(result?.node.text).toBe('Standalone page');

@ -1,5 +1,4 @@
import { GrafanaPlugin, NavModel, NavModelItem, PanelPluginMeta, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { importPanelPluginFromMeta } from './importPanelPlugin';
import { getPluginSettings } from './pluginSettings';
@ -35,11 +34,6 @@ export function buildPluginSectionNav(
pluginNav: NavModel | null,
currentUrl: string
): NavModel | undefined {
// When topnav is disabled we only just show pluginNav like before
if (!config.featureToggles.topnav) {
return pluginNav ?? undefined;
}
// shallow clone as we set active flag
let copiedPluginNavSection = { ...pluginNavSection };
let activePage: NavModelItem | undefined;

@ -59,7 +59,6 @@ describe('ChangePasswordPage', () => {
it('should show change password form when user has loaded', async () => {
await getTestContext();
expect(screen.getByText('Change Your Password')).toBeInTheDocument();
expect(screen.getByLabelText('Old password')).toBeInTheDocument();
expect(screen.getByLabelText('New password')).toBeInTheDocument();
@ -70,6 +69,7 @@ describe('ChangePasswordPage', () => {
expect(screen.getByRole('link', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Cancel' })).toHaveAttribute('href', '/profile');
});
it('should call changePassword if change password is valid', async () => {
const { props } = await getTestContext();

@ -36,9 +36,6 @@ export function ChangePasswordPage({ loadUser, isUpdating, user, changePassword
<Page.Contents isLoading={!Boolean(user)}>
{user ? (
<>
<Page.OldNavOnly>
<h3 className="page-sub-heading">Change Your Password</h3>
</Page.OldNavOnly>
<ChangePasswordForm user={user} onChangePassword={changePassword} isSaving={isUpdating} />
</>
) : null}

@ -2,9 +2,9 @@ import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { locationService } from '@grafana/runtime';
import { UrlSyncManager, SceneObjectBase, SceneComponentProps, SceneObject, SceneObjectState } from '@grafana/scenes';
import { PageToolbar, ToolbarButton, useStyles2 } from '@grafana/ui';
import { ToolbarButton, useStyles2 } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { Page } from 'app/core/components/Page/Page';
@ -48,11 +48,7 @@ function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>)
/>
);
}
const pageToolbar = config.featureToggles.topnav ? (
<AppChromeUpdate actions={toolbarActions} />
) : (
<PageToolbar title={title}>{toolbarActions}</PageToolbar>
);
const pageToolbar = <AppChromeUpdate actions={toolbarActions} />;
return (
<Page navId="scenes" pageNav={{ text: title }} layout={PageLayoutType.Canvas} toolbar={pageToolbar}>

@ -1,115 +0,0 @@
import { css } from '@emotion/css';
import React, { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, stylesFactory, useStyles2 } from '@grafana/ui';
import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection';
import { SearchView } from '../page/components/SearchView';
import { getSearchStateManager } from '../state/SearchStateManager';
export interface Props {}
export function DashboardSearch({}: Props) {
const styles = useStyles2(getStyles);
const stateManager = getSearchStateManager();
const state = stateManager.useState();
useEffect(() => stateManager.initStateFromUrl(), [stateManager]);
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
return (
<div className={styles.overlay}>
<div className={styles.container}>
<div className={styles.searchField}>
<div>
<input
type="text"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
placeholder={state.includePanels ? 'Search dashboards and panels by name' : 'Search dashboards by name'}
value={state.query ?? ''}
onChange={(e) => stateManager.onQueryChange(e.currentTarget.value)}
onKeyDown={onKeyDown}
spellCheck={false}
className={styles.input}
/>
</div>
<div className={styles.closeBtn}>
<IconButton name="times" onClick={stateManager.onCloseSearch} size="xxl" tooltip="Close search" />
</div>
</div>
<div className={styles.search}>
<SearchView showManage={false} keyboardEvents={keyboardEvents} />
</div>
</div>
</div>
);
}
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
overlay: css`
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: ${theme.zIndex.sidemenu};
position: fixed;
background: ${theme.colors.background.canvas};
padding: ${theme.spacing(1)};
${theme.breakpoints.up('md')} {
left: ${theme.components.sidemenu.width}px;
z-index: ${theme.zIndex.navbarFixed + 1};
padding: ${theme.spacing(2)};
}
`,
container: css`
display: flex;
flex-direction: column;
max-width: 1400px;
margin: 0 auto;
padding: ${theme.spacing(1)};
background: ${theme.colors.background.primary};
border: 1px solid ${theme.components.panel.borderColor};
height: 100%;
${theme.breakpoints.up('md')} {
padding: ${theme.spacing(3)};
}
`,
closeBtn: css`
right: -5px;
top: 2px;
z-index: 1;
position: absolute;
`,
searchField: css`
position: relative;
`,
search: css`
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
padding: ${theme.spacing(2, 0, 3, 0)};
`,
input: css`
box-sizing: border-box;
outline: none;
background-color: transparent;
background: transparent;
border-bottom: 2px solid ${theme.v1.colors.border1};
font-size: 20px;
line-height: 38px;
width: 100%;
&::placeholder {
color: ${theme.v1.colors.textWeak};
}
`,
};
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save