mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
202afb9041
commit
4abe0249ba
@ -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.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.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.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.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.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.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.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.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().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.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().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().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.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/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,7 +110,6 @@ func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture(t
|
||||
}
|
||||
}
|
||||
|
||||
if topNavEnabled {
|
||||
ApplyAdminIA(root)
|
||||
|
||||
// Move reports into dashboards
|
||||
@ -126,15 +125,11 @@ func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture(t
|
||||
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
|
||||
dashboards.Id = "dashboards/browse"
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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,19 +110,11 @@ 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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
authConfigUIAvailable := s.license.FeatureEnabled("saml") && s.features.IsEnabled(featuremgmt.FlagAuthenticationConfigUI)
|
||||
if authConfigUIAvailable && hasAccess(ac.ReqGrafanaAdmin, evalAuthenticationSettings()) {
|
||||
|
@ -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(
|
||||
return t(
|
||||
'nav.admin.subtitle',
|
||||
'Manage server-wide settings and access to resources such as organizations, users, and licenses'
|
||||
)
|
||||
: undefined;
|
||||
);
|
||||
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,13 +16,9 @@ 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;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
leftActionsSeparator: css({
|
@ -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,18 +88,11 @@ 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/list',
|
||||
|
@ -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,14 +361,6 @@ 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={
|
||||
@ -398,21 +372,6 @@ export const DashNav = React.memo<Props>((props) => {
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageToolbar
|
||||
pageIcon={isFullscreen ? undefined : 'apps'}
|
||||
title={title}
|
||||
parent={folderTitle}
|
||||
titleHref={titleHref}
|
||||
parentHref={parentHref}
|
||||
onGoBack={onGoBack}
|
||||
leftItems={renderLeftActions()}
|
||||
>
|
||||
{renderRightActions()}
|
||||
</PageToolbar>
|
||||
);
|
||||
});
|
||||
|
||||
DashNav.displayName = 'DashNav';
|
||||
|
@ -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,17 +49,15 @@ 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"
|
||||
@ -69,8 +67,7 @@ export function DashboardSettings({ dashboard, editview, pageNav, sectionNav }:
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
),
|
||||
</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>} />
|
||||
)}
|
||||
<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
|
||||
>
|
||||
<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…
Reference in New Issue
Block a user