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", "0"],
|
||||||
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"]
|
[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": [
|
"public/app/core/components/OptionsUI/registry.tsx:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
[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
|
# 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>
|
# Format: <Plugin ID> = <Section ID> <Sort Weight>
|
||||||
[navigation.app_sections]
|
[navigation.app_sections]
|
||||||
|
|
||||||
|
@ -1392,7 +1392,6 @@
|
|||||||
;enable_custom_baselayers = true
|
;enable_custom_baselayers = true
|
||||||
|
|
||||||
# Move an app plugin referenced by its id (including all its pages) to a specific navigation section
|
# 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]
|
[navigation.app_sections]
|
||||||
# The following will move an app plugin with the id of `my-app-id` under the `starred` section
|
# The following will move an app plugin with the id of `my-app-id` under the `starred` section
|
||||||
# my-app-id = admin
|
# my-app-id = admin
|
||||||
|
@ -2,7 +2,6 @@ import { load } from 'js-yaml';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { e2e } from '@grafana/e2e';
|
import { e2e } from '@grafana/e2e';
|
||||||
import { GrafanaBootConfig } from '@grafana/runtime';
|
|
||||||
|
|
||||||
import { selectors } from '../../public/app/plugins/datasource/azuremonitor/e2e/selectors';
|
import { selectors } from '../../public/app/plugins/datasource/azuremonitor/e2e/selectors';
|
||||||
import {
|
import {
|
||||||
@ -97,15 +96,7 @@ const addAzureMonitorVariable = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
||||||
e2e()
|
e2e.pages.Dashboard.Settings.Actions.close().click();
|
||||||
.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({
|
e2e.scenario({
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { e2e } from '@grafana/e2e';
|
import { e2e } from '@grafana/e2e';
|
||||||
import { GrafanaBootConfig } from '@grafana/runtime';
|
|
||||||
|
|
||||||
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
||||||
const DASHBOARD_NAME = '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
|
// Navigate back to the homepage and change the selected variable value
|
||||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
||||||
e2e()
|
e2e.pages.Dashboard.Settings.Actions.close().click();
|
||||||
.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.components.RefreshPicker.runButtonV2().click();
|
||||||
|
|
||||||
// Assert it was rendered
|
// Assert it was rendered
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { e2e } from '@grafana/e2e';
|
import { e2e } from '@grafana/e2e';
|
||||||
import { GrafanaBootConfig } from '@grafana/runtime';
|
|
||||||
|
|
||||||
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
||||||
const DASHBOARD_NAME = '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
|
// Navigate back to the homepage and change the selected variable value
|
||||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
||||||
e2e()
|
e2e.pages.Dashboard.Settings.Actions.close().click();
|
||||||
.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.submenuItemValueDropDownValueLinkTexts('one').click();
|
||||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('two').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
|
// Navigate back to the homepage and change the selected variable value
|
||||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
||||||
e2e()
|
e2e.pages.Dashboard.Settings.Actions.close().click();
|
||||||
.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.submenuItemValueDropDownValueLinkTexts('One').click();
|
||||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('Two').click();
|
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('Two').click();
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { e2e } from '@grafana/e2e';
|
import { e2e } from '@grafana/e2e';
|
||||||
import { GrafanaBootConfig } from '@grafana/runtime';
|
|
||||||
|
|
||||||
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
||||||
const DASHBOARD_NAME = '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
|
// Navigate back to the homepage and change the selected variable value
|
||||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
||||||
e2e()
|
e2e.pages.Dashboard.Settings.Actions.close().click();
|
||||||
.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.components.RefreshPicker.runButtonV2().click();
|
||||||
|
|
||||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('gdev-prometheus').click();
|
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('gdev-prometheus').click();
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { e2e } from '@grafana/e2e';
|
import { e2e } from '@grafana/e2e';
|
||||||
import { GrafanaBootConfig } from '@grafana/runtime';
|
|
||||||
|
|
||||||
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
||||||
const DASHBOARD_NAME = '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
|
// Navigate back to the homepage and change the selected variable value
|
||||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
||||||
e2e()
|
e2e.pages.Dashboard.Settings.Actions.close().click();
|
||||||
.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.components.RefreshPicker.runButtonV2().click();
|
||||||
|
|
||||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('10s').click();
|
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('10s').click();
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { e2e } from '@grafana/e2e';
|
import { e2e } from '@grafana/e2e';
|
||||||
import { GrafanaBootConfig } from '@grafana/runtime';
|
|
||||||
|
|
||||||
const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
|
const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
|
||||||
const DASHBOARD_NAME = '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.pages.Dashboard.Settings.Variables.Edit.General.submitButton().scrollIntoView().should('be.visible').click();
|
||||||
|
|
||||||
e2e()
|
e2e.pages.Dashboard.Settings.Actions.close().click();
|
||||||
.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.submenuItemLabels('a label').should('be.visible');
|
||||||
e2e.pages.Dashboard.SubMenu.submenuItem()
|
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.pages.Dashboard.Settings.Variables.Edit.General.submitButton().scrollIntoView().should('be.visible').click();
|
||||||
|
|
||||||
e2e()
|
e2e.pages.Dashboard.Settings.Actions.close().click();
|
||||||
.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.submenuItemLabels('a label').should('be.visible');
|
||||||
e2e.pages.Dashboard.SubMenu.submenuItem()
|
e2e.pages.Dashboard.SubMenu.submenuItem()
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { e2e } from '@grafana/e2e';
|
import { e2e } from '@grafana/e2e';
|
||||||
import { GrafanaBootConfig } from '@grafana/runtime';
|
|
||||||
|
|
||||||
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
|
||||||
const DASHBOARD_NAME = '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
|
// Navigate back to the homepage and change the selected variable value
|
||||||
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
|
||||||
e2e()
|
e2e.pages.Dashboard.Settings.Actions.close().click();
|
||||||
.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();
|
e2e().get('#var-VariableUnderTest').clear().type('dog-cat').blur();
|
||||||
|
|
||||||
// Assert it was rendered
|
// Assert it was rendered
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { e2e } from '@grafana/e2e';
|
import { e2e } from '@grafana/e2e';
|
||||||
import { GrafanaBootConfig } from '@grafana/runtime';
|
|
||||||
|
|
||||||
const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
|
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('A').should('be.visible').click();
|
||||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible').click();
|
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible').click();
|
||||||
|
|
||||||
e2e()
|
e2e.components.NavToolbar.container().click();
|
||||||
.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');
|
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.submenuItemValueDropDownValueLinkTexts('A').should('be.visible').click();
|
||||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible').click();
|
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('B').should('be.visible').click();
|
||||||
|
|
||||||
e2e()
|
e2e.components.NavToolbar.container().click();
|
||||||
.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');
|
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.submenuItemValueDropDownValueLinkTexts('A + B').should('be.visible').click();
|
||||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible').click();
|
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('A').should('be.visible').click();
|
||||||
|
|
||||||
e2e()
|
e2e.components.NavToolbar.container().click();
|
||||||
.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');
|
e2e().wait('@query');
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { e2e } from '@grafana/e2e';
|
import { e2e } from '@grafana/e2e';
|
||||||
import { GrafanaBootConfig } from '@grafana/runtime';
|
|
||||||
|
|
||||||
e2e.scenario({
|
e2e.scenario({
|
||||||
describeName: 'Templating',
|
describeName: 'Templating',
|
||||||
@ -47,15 +46,7 @@ e2e.scenario({
|
|||||||
|
|
||||||
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('p2').should('be.visible').click();
|
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('p2').should('be.visible').click();
|
||||||
|
|
||||||
e2e()
|
e2e.components.NavToolbar.container().click();
|
||||||
.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()
|
e2e.components.DashboardLinks.dropDown()
|
||||||
.scrollIntoView()
|
.scrollIntoView()
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
|
@ -384,7 +384,6 @@
|
|||||||
"react-popper-tooltip": "4.4.2",
|
"react-popper-tooltip": "4.4.2",
|
||||||
"react-redux": "7.2.6",
|
"react-redux": "7.2.6",
|
||||||
"react-resizable": "3.0.4",
|
"react-resizable": "3.0.4",
|
||||||
"react-reverse-portal": "2.1.1",
|
|
||||||
"react-router-dom": "5.3.3",
|
"react-router-dom": "5.3.3",
|
||||||
"react-select": "5.7.0",
|
"react-select": "5.7.0",
|
||||||
"react-split-pane": "0.1.92",
|
"react-split-pane": "0.1.92",
|
||||||
|
@ -194,7 +194,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
default: theme.flags.topnav ? defaultTopNav : defaultOld,
|
default: defaultTopNav,
|
||||||
canvas: defaultOld,
|
canvas: defaultOld,
|
||||||
active: css`
|
active: css`
|
||||||
color: ${theme.v1.palette.orangeDark};
|
color: ${theme.v1.palette.orangeDark};
|
||||||
|
@ -104,12 +104,7 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
r.Get("/configuration", reqGrafanaAdmin, hs.Index)
|
r.Get("/configuration", reqGrafanaAdmin, hs.Index)
|
||||||
r.Get("/admin", reqOrgAdmin, hs.Index)
|
r.Get("/admin", reqOrgAdmin, hs.Index)
|
||||||
r.Get("/admin/settings", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)), 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
|
r.Get("/admin/users", authorize(reqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))), hs.Index)
|
||||||
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/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/users/edit/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead)), hs.Index)
|
||||||
r.Get("/admin/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.OrgsAccessEvaluator), 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)
|
hs.HooksService.RunIndexDataHooks(&data, c)
|
||||||
|
|
||||||
// This will remove empty cfg or admin sections and move sections around if topnav is enabled
|
// This will remove empty cfg or admin sections and move sections around
|
||||||
data.NavTree.RemoveEmptySectionsAndApplyNewInformationArchitecture(hs.Features.IsEnabled(featuremgmt.FlagTopnav))
|
data.NavTree.RemoveEmptySectionsAndApplyNewInformationArchitecture()
|
||||||
data.NavTree.Sort()
|
data.NavTree.Sort()
|
||||||
|
|
||||||
return &data, nil
|
return &data, nil
|
||||||
|
@ -59,15 +59,7 @@ func ProvideService(cfg *setting.Cfg, hooksService *hooks.HooksService) *OSSLice
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var adminNodeID string
|
if adminNode := indexData.NavTree.FindById(navtree.NavIDCfg); adminNode != nil {
|
||||||
|
|
||||||
if cfg.IsFeatureToggleEnabled("topnav") {
|
|
||||||
adminNodeID = navtree.NavIDCfg
|
|
||||||
} else {
|
|
||||||
adminNodeID = navtree.NavIDAdmin
|
|
||||||
}
|
|
||||||
|
|
||||||
if adminNode := indexData.NavTree.FindById(adminNodeID); adminNode != nil {
|
|
||||||
adminNode.Children = append(adminNode.Children, &navtree.NavLink{
|
adminNode.Children = append(adminNode.Children, &navtree.NavLink{
|
||||||
Text: "Stats and license",
|
Text: "Stats and license",
|
||||||
Id: "upgrading",
|
Id: "upgrading",
|
||||||
|
@ -100,7 +100,7 @@ func (root *NavTreeRoot) FindById(id string) *NavLink {
|
|||||||
return FindById(root.Children, id)
|
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
|
// Remove server admin node if it has no children or set the url to first child
|
||||||
if node := root.FindById(NavIDAdmin); node != nil {
|
if node := root.FindById(NavIDAdmin); node != nil {
|
||||||
if len(node.Children) == 0 {
|
if len(node.Children) == 0 {
|
||||||
@ -110,31 +110,26 @@ func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture(t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if topNavEnabled {
|
ApplyAdminIA(root)
|
||||||
ApplyAdminIA(root)
|
|
||||||
|
|
||||||
// Move reports into dashboards
|
// Move reports into dashboards
|
||||||
if reports := root.FindById(NavIDReporting); reports != nil {
|
if reports := root.FindById(NavIDReporting); reports != nil {
|
||||||
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
|
|
||||||
reports.SortWeight = 0
|
|
||||||
dashboards.Children = append(dashboards.Children, reports)
|
|
||||||
root.RemoveSection(reports)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change id of dashboards
|
|
||||||
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
|
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
|
||||||
dashboards.Id = "dashboards/browse"
|
reports.SortWeight = 0
|
||||||
|
dashboards.Children = append(dashboards.Children, reports)
|
||||||
|
root.RemoveSection(reports)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove top level cfg / administration node if it has no children (needs to be after topnav new info archicture logic above that moves server admin into it)
|
// Change id of dashboards
|
||||||
// Remove server admin node if it has no children or set the url to first child
|
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
|
||||||
|
dashboards.Id = "dashboards/browse"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove top level cfg / administration node if it has no children
|
||||||
if node := root.FindById(NavIDCfg); node != nil {
|
if node := root.FindById(NavIDCfg); node != nil {
|
||||||
if len(node.Children) == 0 {
|
if len(node.Children) == 0 {
|
||||||
root.RemoveSection(node)
|
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))
|
require.Equal(t, 0, len(treeRoot.Children))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Should not remove admin sections when they have children", 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, 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) {
|
|
||||||
treeRoot := NavTreeRoot{
|
treeRoot := NavTreeRoot{
|
||||||
Children: []*NavLink{
|
Children: []*NavLink{
|
||||||
{Id: NavIDCfg},
|
{Id: NavIDCfg},
|
||||||
@ -41,7 +28,7 @@ func TestNavTreeRoot(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(true)
|
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture()
|
||||||
|
|
||||||
require.Equal(t, "Administration", treeRoot.Children[0].Text)
|
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)
|
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) {
|
if hasAccess(s.ReqCanAdminTeams, ac.TeamsAccessEvaluator) {
|
||||||
configNodes = append(configNodes, &navtree.NavLink{
|
configNodes = append(configNodes, &navtree.NavLink{
|
||||||
Text: "Teams",
|
Text: "Teams",
|
||||||
@ -122,18 +110,10 @@ func (s *ServiceImpl) getServerAdminNode(c *contextmodel.ReqContext) *navtree.Na
|
|||||||
orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead)
|
orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead)
|
||||||
adminNavLinks := []*navtree.NavLink{}
|
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))) {
|
||||||
if hasAccess(ac.ReqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionOrgUsersRead), ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll))) {
|
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
|
||||||
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
|
Text: "Users", SubTitle: "Manage users in Grafana", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
|
||||||
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)
|
authConfigUIAvailable := s.license.FeatureEnabled("saml") && s.features.IsEnabled(featuremgmt.FlagAuthenticationConfigUI)
|
||||||
|
@ -16,7 +16,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *contextmodel.ReqContext) error {
|
func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *contextmodel.ReqContext) error {
|
||||||
topNavEnabled := s.features.IsEnabled(featuremgmt.FlagTopnav)
|
|
||||||
hasAccess := ac.HasAccess(s.accessControl, c)
|
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||||
appLinks := []*navtree.NavLink{}
|
appLinks := []*navtree.NavLink{}
|
||||||
|
|
||||||
@ -47,7 +46,7 @@ func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *contextmodel
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if appNode := s.processAppPlugin(plugin, c, topNavEnabled, treeRoot); appNode != nil {
|
if appNode := s.processAppPlugin(plugin, c, treeRoot); appNode != nil {
|
||||||
appLinks = append(appLinks, appNode)
|
appLinks = append(appLinks, appNode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -65,7 +64,7 @@ func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *contextmodel
|
|||||||
return nil
|
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)
|
hasAccessToInclude := s.hasAccessToInclude(c, plugin.ID)
|
||||||
appLink := &navtree.NavLink{
|
appLink := &navtree.NavLink{
|
||||||
Text: plugin.Name,
|
Text: plugin.Name,
|
||||||
@ -76,12 +75,7 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *contextmodel
|
|||||||
SortWeight: navtree.WeightPlugin,
|
SortWeight: navtree.WeightPlugin,
|
||||||
IsSection: true,
|
IsSection: true,
|
||||||
PluginID: plugin.ID,
|
PluginID: plugin.ID,
|
||||||
}
|
Url: s.cfg.AppSubURL + "/a/" + plugin.ID,
|
||||||
|
|
||||||
if topNavEnabled {
|
|
||||||
appLink.Url = s.cfg.AppSubURL + "/a/" + plugin.ID
|
|
||||||
} else {
|
|
||||||
appLink.Url = path.Join(s.cfg.AppSubURL, plugin.DefaultNavURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, include := range plugin.Includes {
|
for _, include := range plugin.Includes {
|
||||||
@ -159,10 +153,6 @@ func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *contextmodel
|
|||||||
appLink.Children = []*navtree.NavLink{}
|
appLink.Children = []*navtree.NavLink{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !topNavEnabled {
|
|
||||||
return appLink
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove default nav child
|
// Remove default nav child
|
||||||
childrenWithoutDefault := []*navtree.NavLink{}
|
childrenWithoutDefault := []*navtree.NavLink{}
|
||||||
for _, child := range appLink.Children {
|
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) {
|
t.Run("Should move apps to Apps category", 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)
|
|
||||||
treeRoot := navtree.NavTreeRoot{}
|
treeRoot := navtree.NavTreeRoot{}
|
||||||
err := service.addAppLinks(&treeRoot, reqCtx)
|
err := service.addAppLinks(&treeRoot, reqCtx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@ -141,8 +131,17 @@ func TestAddAppLinks(t *testing.T) {
|
|||||||
require.Equal(t, testApp1.Name, appsNode.Children[0].Text)
|
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) {
|
t.Run("Should add enabled apps with pages", func(t *testing.T) {
|
||||||
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
|
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{}
|
treeRoot := navtree.NavTreeRoot{}
|
||||||
err := service.addAppLinks(&treeRoot, reqCtx)
|
err := service.addAppLinks(&treeRoot, reqCtx)
|
||||||
require.NoError(t, err)
|
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
|
// 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) {
|
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{
|
service.navigationAppConfig = map[string]NavigationAppConfig{
|
||||||
"test-app1": {SectionID: navtree.NavIDRoot},
|
"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
|
// 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) {
|
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{
|
service.navigationAppConfig = map[string]NavigationAppConfig{
|
||||||
"test-app1": {SectionID: navtree.NavIDAdmin},
|
"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) {
|
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{}
|
service.navigationAppConfig = map[string]NavigationAppConfig{}
|
||||||
|
|
||||||
// Check if the Monitoring section is not there if no apps try to register to it
|
// 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) {
|
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{}
|
service.navigationAppConfig = map[string]NavigationAppConfig{}
|
||||||
|
|
||||||
// Check if the 'Alerts and Incidents' section is not there if no apps try to register to it
|
// 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) {
|
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{}
|
service.navigationAppConfig = map[string]NavigationAppConfig{}
|
||||||
|
|
||||||
// Check if the 'Alerts and Incidents' section is not there if no apps try to register to it
|
// 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) {
|
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{
|
service.navigationAppConfig = map[string]NavigationAppConfig{
|
||||||
"test-app2": {SectionID: navtree.NavIDMonitoring, SortWeight: 2},
|
"test-app2": {SectionID: navtree.NavIDMonitoring, SortWeight: 2},
|
||||||
"test-app1": {SectionID: navtree.NavIDMonitoring, SortWeight: 3},
|
"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) {
|
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.navigationAppConfig = map[string]NavigationAppConfig{}
|
||||||
service.navigationAppPathConfig = map[string]NavigationAppConfig{
|
service.navigationAppPathConfig = map[string]NavigationAppConfig{
|
||||||
"/connections/connect-data": {SectionID: "connections"},
|
"/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) {
|
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
|
service.navigationAppPathConfig = map[string]NavigationAppConfig{} // We don't configure it as a standalone plugin page
|
||||||
|
|
||||||
treeRoot := navtree.NavTreeRoot{}
|
treeRoot := navtree.NavTreeRoot{}
|
||||||
@ -466,11 +459,12 @@ func TestAddAppLinksAccessControl(t *testing.T) {
|
|||||||
|
|
||||||
err := service.addAppLinks(&treeRoot, reqCtx)
|
err := service.addAppLinks(&treeRoot, reqCtx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, treeRoot.Children, 1)
|
appsNode := treeRoot.FindById(navtree.NavIDApps)
|
||||||
require.Equal(t, "Test app1 name", treeRoot.Children[0].Text)
|
require.Len(t, appsNode.Children, 1)
|
||||||
require.Len(t, treeRoot.Children[0].Children, 2)
|
require.Equal(t, "Test app1 name", appsNode.Children[0].Text)
|
||||||
require.Equal(t, "/a/test-app1/catalog", treeRoot.Children[0].Children[0].Url)
|
require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Url)
|
||||||
require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[1].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) {
|
t.Run("Should add one include when the user is a viewer", func(t *testing.T) {
|
||||||
treeRoot := navtree.NavTreeRoot{}
|
treeRoot := navtree.NavTreeRoot{}
|
||||||
@ -481,10 +475,11 @@ func TestAddAppLinksAccessControl(t *testing.T) {
|
|||||||
|
|
||||||
err := service.addAppLinks(&treeRoot, reqCtx)
|
err := service.addAppLinks(&treeRoot, reqCtx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, treeRoot.Children, 1)
|
appsNode := treeRoot.FindById(navtree.NavIDApps)
|
||||||
require.Equal(t, "Test app1 name", treeRoot.Children[0].Text)
|
require.Len(t, appsNode.Children, 1)
|
||||||
require.Len(t, treeRoot.Children[0].Children, 1)
|
require.Equal(t, "Test app1 name", appsNode.Children[0].Text)
|
||||||
require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].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 both includes when the user is a viewer with catalog read", func(t *testing.T) {
|
t.Run("Should add both includes when the user is a viewer with catalog read", func(t *testing.T) {
|
||||||
treeRoot := navtree.NavTreeRoot{}
|
treeRoot := navtree.NavTreeRoot{}
|
||||||
@ -496,11 +491,12 @@ func TestAddAppLinksAccessControl(t *testing.T) {
|
|||||||
|
|
||||||
err := service.addAppLinks(&treeRoot, reqCtx)
|
err := service.addAppLinks(&treeRoot, reqCtx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, treeRoot.Children, 1)
|
appsNode := treeRoot.FindById(navtree.NavIDApps)
|
||||||
require.Equal(t, "Test app1 name", treeRoot.Children[0].Text)
|
require.Len(t, appsNode.Children, 1)
|
||||||
require.Len(t, treeRoot.Children[0].Children, 2)
|
require.Equal(t, "Test app1 name", appsNode.Children[0].Text)
|
||||||
require.Equal(t, "/a/test-app1/catalog", treeRoot.Children[0].Children[0].Url)
|
require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Url)
|
||||||
require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[1].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) {
|
t.Run("Should add one include when the user is an editor without catalog read", func(t *testing.T) {
|
||||||
treeRoot := navtree.NavTreeRoot{}
|
treeRoot := navtree.NavTreeRoot{}
|
||||||
@ -512,9 +508,10 @@ func TestAddAppLinksAccessControl(t *testing.T) {
|
|||||||
|
|
||||||
err := service.addAppLinks(&treeRoot, reqCtx)
|
err := service.addAppLinks(&treeRoot, reqCtx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, treeRoot.Children, 1)
|
appsNode := treeRoot.FindById(navtree.NavIDApps)
|
||||||
require.Equal(t, "Test app1 name", treeRoot.Children[0].Text)
|
require.Len(t, appsNode.Children, 1)
|
||||||
require.Len(t, treeRoot.Children[0].Children, 1)
|
require.Equal(t, "Test app1 name", appsNode.Children[0].Text)
|
||||||
require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[0].Url)
|
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,
|
Section: navtree.NavSectionCore,
|
||||||
SortWeight: navtree.WeightHome,
|
SortWeight: navtree.WeightHome,
|
||||||
}
|
}
|
||||||
if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
|
||||||
homeNode.HideFromMenu = true
|
|
||||||
}
|
|
||||||
return homeNode
|
return homeNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -345,12 +342,6 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext, hasEdit
|
|||||||
|
|
||||||
dashboardChildNavs := []*navtree.NavLink{}
|
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{
|
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",
|
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 hasEditPerm {
|
||||||
if hasAccess(hasEditPermInAnyFolder, ac.EvalPermission(dashboards.ActionDashboardsCreate)) {
|
if hasAccess(hasEditPermInAnyFolder, ac.EvalPermission(dashboards.ActionDashboardsCreate)) {
|
||||||
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
|
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
|
return dashboardChildNavs
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -445,12 +421,7 @@ func (s *ServiceImpl) buildLegacyAlertNavLinks(c *contextmodel.ReqContext) *navt
|
|||||||
Children: alertChildNavs,
|
Children: alertChildNavs,
|
||||||
Section: navtree.NavSectionCore,
|
Section: navtree.NavSectionCore,
|
||||||
SortWeight: navtree.WeightAlerting,
|
SortWeight: navtree.WeightAlerting,
|
||||||
}
|
Url: s.cfg.AppSubURL + "/alerting",
|
||||||
|
|
||||||
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
|
||||||
alertNav.Url = s.cfg.AppSubURL + "/alerting"
|
|
||||||
} else {
|
|
||||||
alertNav.Url = s.cfg.AppSubURL + "/alerting/list"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &alertNav
|
return &alertNav
|
||||||
@ -460,15 +431,6 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext, hasEditPerm
|
|||||||
hasAccess := ac.HasAccess(s.accessControl, c)
|
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||||
var alertChildNavs []*navtree.NavLink
|
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))) {
|
if hasAccess(ac.ReqViewer, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
||||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
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",
|
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 }
|
fallbackHasEditPerm := func(*contextmodel.ReqContext) bool { return hasEditPerm }
|
||||||
|
|
||||||
if hasAccess(fallbackHasEditPerm, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) {
|
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{
|
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||||
Text: "Create alert rule", SubTitle: "Create an alert rule", Id: "alert",
|
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,
|
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,
|
Children: alertChildNavs,
|
||||||
Section: navtree.NavSectionCore,
|
Section: navtree.NavSectionCore,
|
||||||
SortWeight: navtree.WeightAlerting,
|
SortWeight: navtree.WeightAlerting,
|
||||||
}
|
Url: s.cfg.AppSubURL + "/alerting",
|
||||||
|
|
||||||
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
|
||||||
alertNav.Url = s.cfg.AppSubURL + "/alerting"
|
|
||||||
} else {
|
|
||||||
alertNav.Url = s.cfg.AppSubURL + "/alerting/home"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &alertNav
|
return &alertNav
|
||||||
|
@ -2,18 +2,14 @@ import { css, cx } from '@emotion/css';
|
|||||||
import React, { PropsWithChildren } from 'react';
|
import React, { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||||
import { CommandPalette } from 'app/features/commandPalette/CommandPalette';
|
import { CommandPalette } from 'app/features/commandPalette/CommandPalette';
|
||||||
import { SearchWrapper } from 'app/features/search';
|
|
||||||
import { KioskMode } from 'app/types';
|
import { KioskMode } from 'app/types';
|
||||||
|
|
||||||
import { MegaMenu } from '../MegaMenu/MegaMenu';
|
import { MegaMenu } from './MegaMenu/MegaMenu';
|
||||||
import { NavBar } from '../NavBar/NavBar';
|
import { NavToolbar } from './NavToolbar/NavToolbar';
|
||||||
|
import { TopSearchBar } from './TopBar/TopSearchBar';
|
||||||
import { NavToolbar } from './NavToolbar';
|
|
||||||
import { TopSearchBar } from './TopSearchBar';
|
|
||||||
import { TOP_BAR_LEVEL_HEIGHT } from './types';
|
import { TOP_BAR_LEVEL_HEIGHT } from './types';
|
||||||
|
|
||||||
export interface Props extends PropsWithChildren<{}> {}
|
export interface Props extends PropsWithChildren<{}> {}
|
||||||
@ -23,21 +19,6 @@ export function AppChrome({ children }: Props) {
|
|||||||
const { chrome } = useGrafana();
|
const { chrome } = useGrafana();
|
||||||
const state = chrome.useState();
|
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 searchBarHidden = state.searchBarHidden || state.kioskMode === KioskMode.TV;
|
||||||
|
|
||||||
const contentClass = cx({
|
const contentClass = cx({
|
||||||
|
@ -6,7 +6,7 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
|||||||
import { NavModelItem, NavSection } from '@grafana/data';
|
import { NavModelItem, NavSection } from '@grafana/data';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
|
|
||||||
import { TestProvider } from '../../../../test/helpers/TestProvider';
|
import { TestProvider } from '../../../../../test/helpers/TestProvider';
|
||||||
|
|
||||||
import { MegaMenu } from './MegaMenu';
|
import { MegaMenu } from './MegaMenu';
|
||||||
|
|
@ -7,9 +7,8 @@ import { GrafanaTheme2, NavSection } from '@grafana/data';
|
|||||||
import { useTheme2 } from '@grafana/ui';
|
import { useTheme2 } from '@grafana/ui';
|
||||||
import { useSelector } from 'app/types';
|
import { useSelector } from 'app/types';
|
||||||
|
|
||||||
import { enrichConfigItems, enrichWithInteractionTracking, getActiveItem } from '../NavBar/utils';
|
|
||||||
|
|
||||||
import { NavBarMenu } from './NavBarMenu';
|
import { NavBarMenu } from './NavBarMenu';
|
||||||
|
import { enrichConfigItems, enrichWithInteractionTracking, getActiveItem } from './utils';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
@ -4,7 +4,7 @@ import React from 'react';
|
|||||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||||
import { Icon, toIconName, useTheme2 } from '@grafana/ui';
|
import { Icon, toIconName, useTheme2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { Branding } from '../Branding/Branding';
|
import { Branding } from '../../Branding/Branding';
|
||||||
|
|
||||||
interface NavBarItemIconProps {
|
interface NavBarItemIconProps {
|
||||||
link: NavModelItem;
|
link: NavModelItem;
|
@ -9,7 +9,7 @@ import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
|||||||
import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui';
|
import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui';
|
||||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
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';
|
import { NavBarMenuItemWrapper } from './NavBarMenuItemWrapper';
|
||||||
|
|
@ -4,10 +4,9 @@ import React from 'react';
|
|||||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||||
import { toIconName, useStyles2 } from '@grafana/ui';
|
import { toIconName, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { isMatchOrChildMatch } from '../NavBar/utils';
|
|
||||||
|
|
||||||
import { NavBarMenuItem } from './NavBarMenuItem';
|
import { NavBarMenuItem } from './NavBarMenuItem';
|
||||||
import { NavBarMenuSection } from './NavBarMenuSection';
|
import { NavBarMenuSection } from './NavBarMenuSection';
|
||||||
|
import { isMatchOrChildMatch } from './utils';
|
||||||
|
|
||||||
export function NavBarMenuItemWrapper({
|
export function NavBarMenuItemWrapper({
|
||||||
link,
|
link,
|
@ -5,11 +5,10 @@ import { useLocalStorage } from 'react-use';
|
|||||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||||
import { Button, Icon, useStyles2 } from '@grafana/ui';
|
import { Button, Icon, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { NavBarItemIcon } from '../NavBar/NavBarItemIcon';
|
import { NavBarItemIcon } from './NavBarItemIcon';
|
||||||
import { NavFeatureHighlight } from '../NavBar/NavFeatureHighlight';
|
|
||||||
import { hasChildMatch } from '../NavBar/utils';
|
|
||||||
|
|
||||||
import { NavBarMenuItem } from './NavBarMenuItem';
|
import { NavBarMenuItem } from './NavBarMenuItem';
|
||||||
|
import { NavFeatureHighlight } from './NavFeatureHighlight';
|
||||||
|
import { hasChildMatch } from './utils';
|
||||||
|
|
||||||
export function NavBarMenuSection({
|
export function NavBarMenuSection({
|
||||||
link,
|
link,
|
@ -1,4 +1,3 @@
|
|||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { t } from 'app/core/internationalization';
|
import { t } from 'app/core/internationalization';
|
||||||
|
|
||||||
// Maps the ID of the nav item to a translated phrase to later pass to <Trans />
|
// Maps the ID of the nav item to a translated phrase to later pass to <Trans />
|
||||||
@ -30,9 +29,7 @@ export function getNavTitle(navId: string | undefined) {
|
|||||||
case 'dashboards':
|
case 'dashboards':
|
||||||
return t('nav.dashboards.title', 'Dashboards');
|
return t('nav.dashboards.title', 'Dashboards');
|
||||||
case 'dashboards/browse':
|
case 'dashboards/browse':
|
||||||
return config.featureToggles.topnav
|
return t('nav.dashboards.title', 'Dashboards');
|
||||||
? t('nav.dashboards.title', 'Dashboards')
|
|
||||||
: t('nav.manage-dashboards.title', 'Browse');
|
|
||||||
case 'dashboards/playlists':
|
case 'dashboards/playlists':
|
||||||
return t('nav.playlists.title', 'Playlists');
|
return t('nav.playlists.title', 'Playlists');
|
||||||
case 'dashboards/snapshots':
|
case 'dashboards/snapshots':
|
||||||
@ -72,9 +69,7 @@ export function getNavTitle(navId: string | undefined) {
|
|||||||
case 'alerting-admin':
|
case 'alerting-admin':
|
||||||
return t('nav.alerting-admin.title', 'Admin');
|
return t('nav.alerting-admin.title', 'Admin');
|
||||||
case 'cfg':
|
case 'cfg':
|
||||||
return config.featureToggles.topnav
|
return t('nav.config.title', 'Administration');
|
||||||
? t('nav.config.title', 'Administration')
|
|
||||||
: t('nav.config.titleBeforeTopnav', 'Configuration');
|
|
||||||
case 'datasources':
|
case 'datasources':
|
||||||
return t('nav.datasources.title', 'Data sources');
|
return t('nav.datasources.title', 'Data sources');
|
||||||
case 'correlations':
|
case 'correlations':
|
||||||
@ -86,9 +81,7 @@ export function getNavTitle(navId: string | undefined) {
|
|||||||
case 'plugins':
|
case 'plugins':
|
||||||
return t('nav.plugins.title', 'Plugins');
|
return t('nav.plugins.title', 'Plugins');
|
||||||
case 'org-settings':
|
case 'org-settings':
|
||||||
return config.featureToggles.topnav
|
return t('nav.org-settings.title', 'Default preferences');
|
||||||
? t('nav.org-settings.title', 'Default preferences')
|
|
||||||
: t('nav.org-settings.titleBeforeTopnav', 'Preferences');
|
|
||||||
case 'apikeys':
|
case 'apikeys':
|
||||||
return t('nav.api-keys.title', 'API keys');
|
return t('nav.api-keys.title', 'API keys');
|
||||||
case 'serviceaccounts':
|
case 'serviceaccounts':
|
||||||
@ -98,9 +91,7 @@ export function getNavTitle(navId: string | undefined) {
|
|||||||
case 'support-bundles':
|
case 'support-bundles':
|
||||||
return t('nav.support-bundles.title', 'Support bundles');
|
return t('nav.support-bundles.title', 'Support bundles');
|
||||||
case 'global-users':
|
case 'global-users':
|
||||||
return config.featureToggles.topnav
|
return t('nav.global-users.title', 'Users');
|
||||||
? t('nav.global-users.title', 'Users')
|
|
||||||
: t('nav.global-users.titleBeforeTopnav', 'Users');
|
|
||||||
case 'global-orgs':
|
case 'global-orgs':
|
||||||
return t('nav.global-orgs.title', 'Organizations');
|
return t('nav.global-orgs.title', 'Organizations');
|
||||||
case 'server-settings':
|
case 'server-settings':
|
||||||
@ -137,9 +128,7 @@ export function getNavSubTitle(navId: string | undefined) {
|
|||||||
case 'dashboards':
|
case 'dashboards':
|
||||||
return t('nav.dashboards.subtitle', 'Create and manage dashboards to visualize your data');
|
return t('nav.dashboards.subtitle', 'Create and manage dashboards to visualize your data');
|
||||||
case 'dashboards/browse':
|
case 'dashboards/browse':
|
||||||
return config.featureToggles.topnav
|
return t('nav.dashboards.subtitle', 'Create and manage dashboards to visualize your data');
|
||||||
? t('nav.dashboards.subtitle', 'Create and manage dashboards to visualize your data')
|
|
||||||
: undefined;
|
|
||||||
case 'manage-folder':
|
case 'manage-folder':
|
||||||
return t('nav.manage-folder.subtitle', 'Manage folder dashboards and permissions');
|
return t('nav.manage-folder.subtitle', 'Manage folder dashboards and permissions');
|
||||||
case 'dashboards/playlists':
|
case 'dashboards/playlists':
|
||||||
@ -193,12 +182,10 @@ export function getNavSubTitle(navId: string | undefined) {
|
|||||||
case 'support-bundles':
|
case 'support-bundles':
|
||||||
return t('nav.support-bundles.subtitle', 'Download support bundles');
|
return t('nav.support-bundles.subtitle', 'Download support bundles');
|
||||||
case 'admin':
|
case 'admin':
|
||||||
return config.featureToggles.topnav
|
return t(
|
||||||
? t(
|
'nav.admin.subtitle',
|
||||||
'nav.admin.subtitle',
|
'Manage server-wide settings and access to resources such as organizations, users, and licenses'
|
||||||
'Manage server-wide settings and access to resources such as organizations, users, and licenses'
|
);
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
case 'apps':
|
case 'apps':
|
||||||
return t('nav.apps.subtitle', 'App plugins that extend the Grafana experience');
|
return t('nav.apps.subtitle', 'App plugins that extend the Grafana experience');
|
||||||
case 'monitoring':
|
case 'monitoring':
|
@ -3,9 +3,9 @@ import { Location } from 'history';
|
|||||||
import { GrafanaConfig, locationUtil, NavModelItem } from '@grafana/data';
|
import { GrafanaConfig, locationUtil, NavModelItem } from '@grafana/data';
|
||||||
import { ContextSrv, setContextSrv } from 'app/core/services/context_srv';
|
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(),
|
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', () => {
|
it('enhances the help node with extra child links', () => {
|
||||||
const contextSrv = new ContextSrv();
|
const contextSrv = new ContextSrv();
|
||||||
setContextSrv(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 { Location } from 'history';
|
||||||
|
|
||||||
import { locationUtil, NavModelItem, NavSection } from '@grafana/data';
|
import { locationUtil, NavModelItem } from '@grafana/data';
|
||||||
import { config, reportInteraction } from '@grafana/runtime';
|
import { config, reportInteraction } from '@grafana/runtime';
|
||||||
import { t } from 'app/core/internationalization';
|
import { t } from 'app/core/internationalization';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
|
||||||
|
|
||||||
import { ShowModalReactEvent } from '../../../types/events';
|
import { ShowModalReactEvent } from '../../../../types/events';
|
||||||
import appEvents from '../../app_events';
|
import appEvents from '../../../app_events';
|
||||||
import { getFooterLinks } from '../Footer/Footer';
|
import { getFooterLinks } from '../../Footer/Footer';
|
||||||
import { OrgSwitcher } from '../OrgSwitcher';
|
import { HelpModal } from '../../help/HelpModal';
|
||||||
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;
|
|
||||||
|
|
||||||
export const enrichConfigItems = (items: NavModelItem[], location: Location<unknown>) => {
|
export const enrichConfigItems = (items: NavModelItem[], location: Location<unknown>) => {
|
||||||
const { isSignedIn, user } = contextSrv;
|
|
||||||
const onOpenShortcuts = () => {
|
const onOpenShortcuts = () => {
|
||||||
appEvents.publish(new ShowModalReactEvent({ component: HelpModal }));
|
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) => {
|
items.forEach((link) => {
|
||||||
let menuItems = link.children || [];
|
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;
|
return items;
|
||||||
};
|
};
|
||||||
@ -163,15 +118,6 @@ export const getActiveItem = (
|
|||||||
return currentBestMatch;
|
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[] {
|
export function getEditionAndUpdateLinks(): NavModelItem[] {
|
||||||
const { buildInfo, licenseInfo } = config;
|
const { buildInfo, licenseInfo } = config;
|
||||||
const stateInfo = licenseInfo.stateInfo ? ` (${licenseInfo.stateInfo})` : '';
|
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 { HOME_NAV_ID } from 'app/core/reducers/navModel';
|
||||||
import { useSelector } from 'app/types';
|
import { useSelector } from 'app/types';
|
||||||
|
|
||||||
import { Breadcrumbs } from '../Breadcrumbs/Breadcrumbs';
|
import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs';
|
||||||
import { buildBreadcrumbs } from '../Breadcrumbs/utils';
|
import { buildBreadcrumbs } from '../../Breadcrumbs/utils';
|
||||||
|
import { TOP_BAR_LEVEL_HEIGHT } from '../types';
|
||||||
|
|
||||||
import { NavToolbarSeparator } from './NavToolbarSeparator';
|
import { NavToolbarSeparator } from './NavToolbarSeparator';
|
||||||
import { TOP_BAR_LEVEL_HEIGHT } from './types';
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
onToggleSearchBar(): void;
|
onToggleSearchBar(): void;
|
@ -2,7 +2,6 @@ import { css, cx } from '@emotion/css';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
@ -17,11 +16,7 @@ export function NavToolbarSeparator({ className, leftActionsSeparator }: Props)
|
|||||||
return <div className={cx(className, styles.leftActionsSeparator)} />;
|
return <div className={cx(className, styles.leftActionsSeparator)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.featureToggles.topnav) {
|
return <div className={cx(className, styles.line)} />;
|
||||||
return <div className={cx(className, styles.line)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
@ -7,7 +7,7 @@ import { Menu, Dropdown, useStyles2, useTheme2, ToolbarButton } from '@grafana/u
|
|||||||
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
|
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
|
||||||
import { useSelector } from 'app/types';
|
import { useSelector } from 'app/types';
|
||||||
|
|
||||||
import { NavToolbarSeparator } from '../NavToolbarSeparator';
|
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
|
||||||
|
|
||||||
import { findCreateActions } from './utils';
|
import { findCreateActions } from './utils';
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import { useLocation } from 'react-router-dom';
|
|||||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||||
import { Menu, MenuItem, useStyles2 } from '@grafana/ui';
|
import { Menu, MenuItem, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { enrichConfigItems, enrichWithInteractionTracking } from '../../NavBar/utils';
|
import { enrichConfigItems, enrichWithInteractionTracking } from '../MegaMenu/utils';
|
||||||
|
|
||||||
export interface TopNavBarMenuProps {
|
export interface TopNavBarMenuProps {
|
||||||
node: NavModelItem;
|
node: NavModelItem;
|
||||||
|
@ -8,16 +8,16 @@ import { config } from 'app/core/config';
|
|||||||
import { contextSrv } from 'app/core/core';
|
import { contextSrv } from 'app/core/core';
|
||||||
import { useSelector } from 'app/types';
|
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 { SignInLink } from './SignInLink';
|
||||||
import { OrganizationSwitcher } from './Organization/OrganizationSwitcher';
|
import { TopNavBarMenu } from './TopNavBarMenu';
|
||||||
import { QuickAdd } from './QuickAdd/QuickAdd';
|
|
||||||
import { SignInLink } from './TopBar/SignInLink';
|
|
||||||
import { TopNavBarMenu } from './TopBar/TopNavBarMenu';
|
|
||||||
import { TopSearchBarSection } from './TopBar/TopSearchBarSection';
|
|
||||||
import { TopSearchBarCommandPaletteTrigger } from './TopSearchBarCommandPaletteTrigger';
|
import { TopSearchBarCommandPaletteTrigger } from './TopSearchBarCommandPaletteTrigger';
|
||||||
import { TOP_BAR_LEVEL_HEIGHT } from './types';
|
import { TopSearchBarSection } from './TopSearchBarSection';
|
||||||
|
|
||||||
export const TopSearchBar = React.memo(function TopSearchBar() {
|
export const TopSearchBar = React.memo(function TopSearchBar() {
|
||||||
const styles = useStyles2(getStyles);
|
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 { PageHeader } from '../PageHeader/PageHeader';
|
||||||
import { Page as NewPage } from '../PageNew/Page';
|
import { Page as NewPage } from '../PageNew/Page';
|
||||||
|
|
||||||
import { OldNavOnly } from './OldNavOnly';
|
|
||||||
import { PageContents } from './PageContents';
|
import { PageContents } from './PageContents';
|
||||||
import { PageType } from './types';
|
import { PageType } from './types';
|
||||||
import { usePageNav } from './usePageNav';
|
import { usePageNav } from './usePageNav';
|
||||||
@ -93,7 +92,6 @@ export const OldPage: PageType = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
OldPage.Contents = PageContents;
|
OldPage.Contents = PageContents;
|
||||||
OldPage.OldNavOnly = OldNavOnly;
|
|
||||||
|
|
||||||
export const Page: PageType = config.featureToggles.topnav ? NewPage : OldPage;
|
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 { NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
|
||||||
|
|
||||||
import { OldNavOnly } from './OldNavOnly';
|
|
||||||
import { PageContents } from './PageContents';
|
import { PageContents } from './PageContents';
|
||||||
|
|
||||||
export interface PageProps extends HTMLAttributes<HTMLDivElement> {
|
export interface PageProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
@ -34,6 +33,5 @@ export interface PageInfoItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PageType extends FC<PageProps> {
|
export interface PageType extends FC<PageProps> {
|
||||||
OldNavOnly: typeof OldNavOnly;
|
|
||||||
Contents: typeof PageContents;
|
Contents: typeof PageContents;
|
||||||
}
|
}
|
||||||
|
@ -92,10 +92,6 @@ export const Page: PageType = ({
|
|||||||
|
|
||||||
Page.Contents = PageContents;
|
Page.Contents = PageContents;
|
||||||
|
|
||||||
Page.OldNavOnly = function OldNavOnly() {
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
wrapper: css({
|
wrapper: css({
|
||||||
|
@ -3,7 +3,6 @@ import React, { useMemo } from 'react';
|
|||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Modal, useStyles2 } from '@grafana/ui';
|
import { Modal, useStyles2 } from '@grafana/ui';
|
||||||
import { config } from 'app/core/config';
|
|
||||||
import { getModKey } from 'app/core/utils/browser';
|
import { getModKey } from 'app/core/utils/browser';
|
||||||
|
|
||||||
const getShortcuts = (modKey: string) => {
|
const getShortcuts = (modKey: string) => {
|
||||||
@ -12,14 +11,7 @@ const getShortcuts = (modKey: string) => {
|
|||||||
{ keys: ['g', 'h'], description: 'Go to Home Dashboard' },
|
{ keys: ['g', 'h'], description: 'Go to Home Dashboard' },
|
||||||
{ keys: ['g', 'e'], description: 'Go to Explore' },
|
{ keys: ['g', 'e'], description: 'Go to Explore' },
|
||||||
{ keys: ['g', 'p'], description: 'Go to Profile' },
|
{ keys: ['g', 'p'], description: 'Go to Profile' },
|
||||||
|
{ keys: [`${modKey} + k`], description: 'Open search' },
|
||||||
...(config.featureToggles.topnav
|
|
||||||
? [{ keys: [`${modKey} + k`], description: 'Open search' }]
|
|
||||||
: [
|
|
||||||
{ keys: ['s', 'o'], description: 'Open search' },
|
|
||||||
{ keys: [`${modKey} + k`], description: 'Open command palette' },
|
|
||||||
]),
|
|
||||||
|
|
||||||
{ keys: ['esc'], description: 'Exit edit/setting views' },
|
{ keys: ['esc'], description: 'Exit edit/setting views' },
|
||||||
{ keys: ['h'], description: 'Show all keyboard shortcuts' },
|
{ keys: ['h'], description: 'Show all keyboard shortcuts' },
|
||||||
{ keys: ['c', 't'], description: 'Change theme' },
|
{ keys: ['c', 't'], description: 'Change theme' },
|
||||||
|
@ -3,7 +3,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
|||||||
import { NavModelItem } from '@grafana/data';
|
import { NavModelItem } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
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 ?? [];
|
export const initialState: NavModelItem[] = config.bootData?.navTree ?? [];
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import { cloneDeep } from 'lodash';
|
|||||||
import { NavIndex, NavModel, NavModelItem } from '@grafana/data';
|
import { NavIndex, NavModel, NavModelItem } from '@grafana/data';
|
||||||
import config from 'app/core/config';
|
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';
|
export const HOME_NAV_ID = 'home';
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import Mousetrap from 'mousetrap';
|
|||||||
import 'mousetrap-global-bind';
|
import 'mousetrap-global-bind';
|
||||||
import 'mousetrap/plugins/global-bind/mousetrap-global-bind';
|
import 'mousetrap/plugins/global-bind/mousetrap-global-bind';
|
||||||
import { LegacyGraphHoverClearEvent, locationUtil } from '@grafana/data';
|
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 appEvents from 'app/core/app_events';
|
||||||
import { getExploreUrl } from 'app/core/utils/explore';
|
import { getExploreUrl } from 'app/core/utils/explore';
|
||||||
import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer';
|
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 a', this.openAlerting);
|
||||||
this.bind('g p', this.goToProfile);
|
this.bind('g p', this.goToProfile);
|
||||||
this.bind('g e', this.goToExplore);
|
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('t a', this.makeAbsoluteTime);
|
||||||
this.bind('esc', this.exit);
|
this.bind('esc', this.exit);
|
||||||
this.bindGlobalEsc();
|
this.bindGlobalEsc();
|
||||||
@ -52,10 +48,6 @@ export class KeybindingSrv {
|
|||||||
|
|
||||||
this.bind('c t', () => toggleTheme(false));
|
this.bind('c t', () => toggleTheme(false));
|
||||||
this.bind('c r', () => toggleTheme(true));
|
this.bind('c r', () => toggleTheme(true));
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
this.bind('t n', () => this.toggleNav());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bindGlobalEsc() {
|
bindGlobalEsc() {
|
||||||
@ -88,18 +80,6 @@ export class KeybindingSrv {
|
|||||||
this.exit();
|
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() {
|
private closeSearch() {
|
||||||
this.locationService.partial({ search: null });
|
this.locationService.partial({ search: null });
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { uniq } from 'lodash';
|
import { uniq } from 'lodash';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Redirect } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { OrgRole } from '@grafana/data';
|
import { OrgRole } from '@grafana/data';
|
||||||
import { NavLandingPage } from 'app/core/components/AppChrome/NavLandingPage';
|
|
||||||
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
|
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
|
||||||
|
import { NavLandingPage } from 'app/core/components/NavLandingPage/NavLandingPage';
|
||||||
import { config } from 'app/core/config';
|
import { config } from 'app/core/config';
|
||||||
import { RouteDescriptor } from 'app/core/navigation/types';
|
import { RouteDescriptor } from 'app/core/navigation/types';
|
||||||
import { AccessControlAction } from 'app/types';
|
import { AccessControlAction } from 'app/types';
|
||||||
@ -17,8 +16,7 @@ const legacyRoutes: RouteDescriptor[] = [
|
|||||||
...commonRoutes,
|
...commonRoutes,
|
||||||
{
|
{
|
||||||
path: '/alerting',
|
path: '/alerting',
|
||||||
component: () =>
|
component: () => <NavLandingPage navId="alerting-legacy" />,
|
||||||
config.featureToggles.topnav ? <NavLandingPage navId="alerting-legacy" /> : <Redirect to="/alerting/list" />,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/alerting/list',
|
path: '/alerting/list',
|
||||||
@ -90,19 +88,12 @@ const legacyRoutes: RouteDescriptor[] = [
|
|||||||
|
|
||||||
const unifiedRoutes: RouteDescriptor[] = [
|
const unifiedRoutes: RouteDescriptor[] = [
|
||||||
...commonRoutes,
|
...commonRoutes,
|
||||||
config.featureToggles.topnav
|
{
|
||||||
? {
|
path: '/alerting',
|
||||||
path: '/alerting',
|
component: SafeDynamicImport(
|
||||||
component: SafeDynamicImport(
|
() => import(/* webpackChunkName: "AlertingHome" */ 'app/features/alerting/unified/Home')
|
||||||
() => import(/* webpackChunkName: "AlertingHome" */ 'app/features/alerting/unified/Home')
|
),
|
||||||
),
|
},
|
||||||
}
|
|
||||||
: {
|
|
||||||
path: '/alerting/home',
|
|
||||||
component: SafeDynamicImport(
|
|
||||||
() => import(/* webpackChunkName: "AlertingHome" */ 'app/features/alerting/unified/Home')
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/alerting/list',
|
path: '/alerting/list',
|
||||||
roles: evaluateAccess(
|
roles: evaluateAccess(
|
||||||
|
@ -4,7 +4,6 @@ import SVG from 'react-inlinesvg';
|
|||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Stack } from '@grafana/experimental';
|
import { Stack } from '@grafana/experimental';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { Icon, useStyles2, useTheme2 } from '@grafana/ui';
|
import { Icon, useStyles2, useTheme2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||||
@ -14,7 +13,7 @@ export default function Home() {
|
|||||||
const styles = useStyles2(getWelcomePageStyles);
|
const styles = useStyles2(getWelcomePageStyles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertingPageWrapper pageId={config.featureToggles.topnav ? 'alerting' : 'alert-home'}>
|
<AlertingPageWrapper pageId={'alerting'}>
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
<WelcomeHeader className={styles.ctaContainer} />
|
<WelcomeHeader className={styles.ctaContainer} />
|
||||||
<ContentBox className={styles.flowBlock}>
|
<ContentBox className={styles.flowBlock}>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
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 { DataSourcesRoutesContext } from 'app/features/datasources/state';
|
||||||
import { StoreState, useSelector } from 'app/types';
|
import { StoreState, useSelector } from 'app/types';
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { DataSourceAddButton } from 'app/features/datasources/components/DataSourceAddButton';
|
import { DataSourceAddButton } from 'app/features/datasources/components/DataSourceAddButton';
|
||||||
import { DataSourcesList } from 'app/features/datasources/components/DataSourcesList';
|
import { DataSourcesList } from 'app/features/datasources/components/DataSourcesList';
|
||||||
@ -10,7 +9,7 @@ import { StoreState, useSelector } from 'app/types';
|
|||||||
export function DataSourcesListPage() {
|
export function DataSourcesListPage() {
|
||||||
const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources));
|
const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources));
|
||||||
|
|
||||||
const actions = config.featureToggles.topnav && dataSourcesCount > 0 ? <DataSourceAddButton /> : undefined;
|
const actions = dataSourcesCount > 0 ? <DataSourceAddButton /> : undefined;
|
||||||
return (
|
return (
|
||||||
<Page navId={'connections-your-connections-datasources'} actions={actions}>
|
<Page navId={'connections-your-connections-datasources'} actions={actions}>
|
||||||
<Page.Contents>
|
<Page.Contents>
|
||||||
|
@ -178,13 +178,6 @@ jest.mock('@grafana/runtime', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...runtime,
|
...runtime,
|
||||||
config: {
|
|
||||||
...runtime.config,
|
|
||||||
featureToggles: {
|
|
||||||
...runtime.config.featureToggles,
|
|
||||||
topnav: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reportInteraction: (...args: Parameters<typeof reportInteraction>) => {
|
reportInteraction: (...args: Parameters<typeof reportInteraction>) => {
|
||||||
mocks.reportInteraction(...args);
|
mocks.reportInteraction(...args);
|
||||||
},
|
},
|
||||||
|
@ -8,7 +8,6 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
DeleteButton,
|
DeleteButton,
|
||||||
HorizontalGroup,
|
|
||||||
LoadingPlaceholder,
|
LoadingPlaceholder,
|
||||||
useStyles2,
|
useStyles2,
|
||||||
Alert,
|
Alert,
|
||||||
@ -158,15 +157,6 @@ export default function CorrelationsPage() {
|
|||||||
actions={addButton}
|
actions={addButton}
|
||||||
>
|
>
|
||||||
<Page.Contents>
|
<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>
|
<div>
|
||||||
{!data && get.loading && (
|
{!data && get.loading && (
|
||||||
<div className={loaderWrapper}>
|
<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 { connect, ConnectedProps } from 'react-redux';
|
||||||
import { useLocation } from 'react-router-dom';
|
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 { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import {
|
import {
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
ModalsController,
|
ModalsController,
|
||||||
ToolbarButton,
|
ToolbarButton,
|
||||||
PageToolbar,
|
|
||||||
useForceUpdate,
|
useForceUpdate,
|
||||||
Tag,
|
Tag,
|
||||||
ToolbarButtonRow,
|
ToolbarButtonRow,
|
||||||
@ -18,9 +17,8 @@ import {
|
|||||||
ConfirmModal,
|
ConfirmModal,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
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 config from 'app/core/config';
|
||||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
import { appEvents } from 'app/core/core';
|
import { appEvents } from 'app/core/core';
|
||||||
import { useBusEvent } from 'app/core/hooks/useBusEvent';
|
import { useBusEvent } from 'app/core/hooks/useBusEvent';
|
||||||
@ -79,8 +77,9 @@ export function addCustomRightAction(content: DashNavButtonModel) {
|
|||||||
type Props = OwnProps & ConnectedProps<typeof connector>;
|
type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||||
|
|
||||||
export const DashNav = React.memo<Props>((props) => {
|
export const DashNav = React.memo<Props>((props) => {
|
||||||
|
// this ensures the component rerenders when the location changes
|
||||||
|
useLocation();
|
||||||
const forceUpdate = useForceUpdate();
|
const forceUpdate = useForceUpdate();
|
||||||
const { chrome } = useGrafana();
|
|
||||||
const { showModal, hideModal } = useContext(ModalsContext);
|
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
|
// 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 = () => {
|
const onOpenSettings = () => {
|
||||||
locationService.partial({ editview: 'settings' });
|
locationService.partial({ editview: 'settings' });
|
||||||
};
|
};
|
||||||
@ -287,21 +278,13 @@ export const DashNav = React.memo<Props>((props) => {
|
|||||||
const { snapshot } = dashboard;
|
const { snapshot } = dashboard;
|
||||||
const snapshotUrl = snapshot && snapshot.originalUrl;
|
const snapshotUrl = snapshot && snapshot.originalUrl;
|
||||||
const buttons: ReactNode[] = [];
|
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()) {
|
if (isPlaylistRunning()) {
|
||||||
return [renderPlaylistControls(), renderTimeControls()];
|
return [renderPlaylistControls(), renderTimeControls()];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kioskMode === KioskMode.TV) {
|
if (kioskMode === KioskMode.TV) {
|
||||||
return [renderTimeControls(), tvButton];
|
return [renderTimeControls()];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canEdit && !isFullscreen) {
|
if (canEdit && !isFullscreen) {
|
||||||
@ -364,7 +347,6 @@ export const DashNav = React.memo<Props>((props) => {
|
|||||||
addCustomContent(customRightActions, buttons);
|
addCustomContent(customRightActions, buttons);
|
||||||
|
|
||||||
buttons.push(renderTimeControls());
|
buttons.push(renderTimeControls());
|
||||||
buttons.push(tvButton);
|
|
||||||
|
|
||||||
if (config.featureToggles.scenes) {
|
if (config.featureToggles.scenes) {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
@ -379,39 +361,16 @@ export const DashNav = React.memo<Props>((props) => {
|
|||||||
return buttons;
|
return buttons;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { isFullscreen, title, folderTitle } = props;
|
|
||||||
// this ensures the component rerenders when the location changes
|
|
||||||
const location = useLocation();
|
|
||||||
const titleHref = locationUtil.getUrlForPartial(location, { search: 'open' });
|
|
||||||
const parentHref = locationUtil.getUrlForPartial(location, { search: 'open', query: 'folder:current' });
|
|
||||||
const onGoBack = isFullscreen ? onClose : undefined;
|
|
||||||
|
|
||||||
if (config.featureToggles.topnav) {
|
|
||||||
return (
|
|
||||||
<AppChromeUpdate
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
{renderLeftActions()}
|
|
||||||
<NavToolbarSeparator leftActionsSeparator />
|
|
||||||
<ToolbarButtonRow alignment="right">{renderRightActions()}</ToolbarButtonRow>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageToolbar
|
<AppChromeUpdate
|
||||||
pageIcon={isFullscreen ? undefined : 'apps'}
|
actions={
|
||||||
title={title}
|
<>
|
||||||
parent={folderTitle}
|
{renderLeftActions()}
|
||||||
titleHref={titleHref}
|
<NavToolbarSeparator leftActionsSeparator />
|
||||||
parentHref={parentHref}
|
<ToolbarButtonRow alignment="right">{renderRightActions()}</ToolbarButtonRow>
|
||||||
onGoBack={onGoBack}
|
</>
|
||||||
leftItems={renderLeftActions()}
|
}
|
||||||
>
|
/>
|
||||||
{renderRightActions()}
|
|
||||||
</PageToolbar>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ import { useLocation } from 'react-router-dom';
|
|||||||
import { locationUtil, NavModel, NavModelItem } from '@grafana/data';
|
import { locationUtil, NavModel, NavModelItem } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { locationService } from '@grafana/runtime';
|
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 { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||||
import { Page } from 'app/core/components/PageNew/Page';
|
import { Page } from 'app/core/components/PageNew/Page';
|
||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
@ -49,28 +49,25 @@ export function DashboardSettings({ dashboard, editview, pageNav, sectionNav }:
|
|||||||
dashboard.meta.hasUnsavedFolderChange = false;
|
dashboard.meta.hasUnsavedFolderChange = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const folderTitle = dashboard.meta.folderTitle;
|
|
||||||
const currentPage = pages.find((page) => page.id === editview) ?? pages[0];
|
const currentPage = pages.find((page) => page.id === editview) ?? pages[0];
|
||||||
const canSaveAs = contextSrv.hasEditPermissionInFolders;
|
const canSaveAs = contextSrv.hasEditPermissionInFolders;
|
||||||
const canSave = dashboard.meta.canSave;
|
const canSave = dashboard.meta.canSave;
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const editIndex = getEditIndex(location);
|
const editIndex = getEditIndex(location);
|
||||||
const subSectionNav = getSectionNav(pageNav, sectionNav, pages, currentPage, location);
|
const subSectionNav = getSectionNav(pageNav, sectionNav, pages, currentPage, location);
|
||||||
const size = config.featureToggles.topnav ? 'sm' : 'md';
|
const size = 'sm';
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
config.featureToggles.topnav && (
|
<Button
|
||||||
<Button
|
data-testid={selectors.pages.Dashboard.Settings.Actions.close}
|
||||||
data-testid={selectors.pages.Dashboard.Settings.Actions.close}
|
variant="secondary"
|
||||||
variant="secondary"
|
key="close"
|
||||||
key="close"
|
fill="outline"
|
||||||
fill="outline"
|
size={size}
|
||||||
size={size}
|
onClick={onClose}
|
||||||
onClick={onClose}
|
>
|
||||||
>
|
Close
|
||||||
Close
|
</Button>,
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
canSaveAs && (
|
canSaveAs && (
|
||||||
<SaveDashboardAsButton
|
<SaveDashboardAsButton
|
||||||
dashboard={dashboard}
|
dashboard={dashboard}
|
||||||
@ -85,13 +82,7 @@ export function DashboardSettings({ dashboard, editview, pageNav, sectionNav }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!config.featureToggles.topnav ? (
|
<AppChromeUpdate actions={<ToolbarButtonRow alignment="right">{actions}</ToolbarButtonRow>} />
|
||||||
<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} />
|
<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 { FieldConfigSource, GrafanaTheme2, NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Stack } from '@grafana/experimental';
|
import { Stack } from '@grafana/experimental';
|
||||||
import { config, locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
HorizontalGroup,
|
HorizontalGroup,
|
||||||
InlineSwitch,
|
InlineSwitch,
|
||||||
ModalsController,
|
ModalsController,
|
||||||
PageToolbar,
|
|
||||||
RadioButtonGroup,
|
RadioButtonGroup,
|
||||||
stylesFactory,
|
stylesFactory,
|
||||||
Themeable2,
|
Themeable2,
|
||||||
@ -322,7 +321,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderEditorActions() {
|
renderEditorActions() {
|
||||||
const size = config.featureToggles.topnav ? 'sm' : 'md';
|
const size = 'sm';
|
||||||
let editorActions = [
|
let editorActions = [
|
||||||
<Button
|
<Button
|
||||||
onClick={this.onDiscard}
|
onClick={this.onDiscard}
|
||||||
@ -431,18 +430,8 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderToolbar() {
|
renderToolbar() {
|
||||||
if (config.featureToggles.topnav) {
|
|
||||||
return (
|
|
||||||
<AppChromeUpdate
|
|
||||||
actions={<ToolbarButtonRow alignment="right">{this.renderEditorActions()}</ToolbarButtonRow>}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageToolbar title={this.props.dashboard.title} section="Edit Panel" onGoBack={this.onGoBackToDashboard}>
|
<AppChromeUpdate actions={<ToolbarButtonRow alignment="right">{this.renderEditorActions()}</ToolbarButtonRow>} />
|
||||||
{this.renderEditorActions()}
|
|
||||||
</PageToolbar>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -514,7 +503,7 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2, props: Props) => {
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
paddingTop: config.featureToggles.topnav ? theme.spacing(2) : 0,
|
paddingTop: theme.spacing(2),
|
||||||
}),
|
}),
|
||||||
verticalSplitPanesWrapper: css`
|
verticalSplitPanesWrapper: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -90,7 +90,7 @@ const mockCleanUpDashboardAndVariables = jest.fn();
|
|||||||
|
|
||||||
function setup(propOverrides?: Partial<Props>) {
|
function setup(propOverrides?: Partial<Props>) {
|
||||||
config.bootData.navTree = [
|
config.bootData.navTree = [
|
||||||
{ text: 'Dashboards', id: 'dashboards' },
|
{ text: 'Dashboards', id: 'dashboards/browse' },
|
||||||
{ text: 'Home', id: HOME_NAV_ID },
|
{ text: 'Home', id: HOME_NAV_ID },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -101,7 +101,11 @@ function setup(propOverrides?: Partial<Props>) {
|
|||||||
route: { routeName: DashboardRoutes.Normal } as RouteDescriptor,
|
route: { routeName: DashboardRoutes.Normal } as RouteDescriptor,
|
||||||
}),
|
}),
|
||||||
navIndex: {
|
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 },
|
[HOME_NAV_ID]: { text: 'Home', id: HOME_NAV_ID },
|
||||||
},
|
},
|
||||||
initPhase: DashboardInitPhase.NotStarted,
|
initPhase: DashboardInitPhase.NotStarted,
|
||||||
@ -226,27 +230,6 @@ describe('DashboardPage', () => {
|
|||||||
expect(dashboard.panelInEdit).toBeDefined();
|
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', () => {
|
describe('When dashboard unmounts', () => {
|
||||||
|
@ -551,7 +551,7 @@ function updateStatePageNavFromProps(props: Props, state: State): State {
|
|||||||
pageNav.parentItem = pageNav.parentItem;
|
pageNav.parentItem = pageNav.parentItem;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sectionNav = getNavModel(props.navIndex, config.featureToggles.topnav ? 'dashboards/browse' : 'dashboards');
|
sectionNav = getNavModel(props.navIndex, 'dashboards/browse');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.editPanel || state.viewPanel) {
|
if (state.editPanel || state.viewPanel) {
|
||||||
|
@ -1,18 +1,10 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
|
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { StoreState, useSelector, useDispatch } from 'app/types';
|
||||||
import { StoreState, useSelector, useDispatch, AccessControlAction } from 'app/types';
|
|
||||||
|
|
||||||
import {
|
import { getDataSourcesSearchQuery, getDataSourcesSort, setDataSourcesSearchQuery, setIsSortAscending } from '../state';
|
||||||
getDataSourcesSearchQuery,
|
|
||||||
getDataSourcesSort,
|
|
||||||
setDataSourcesSearchQuery,
|
|
||||||
setIsSortAscending,
|
|
||||||
useDataSourcesRoutes,
|
|
||||||
} from '../state';
|
|
||||||
|
|
||||||
const ascendingSortValue = 'alpha-asc';
|
const ascendingSortValue = 'alpha-asc';
|
||||||
const descendingSortValue = 'alpha-desc';
|
const descendingSortValue = 'alpha-desc';
|
||||||
@ -30,19 +22,6 @@ export function DataSourcesListHeader() {
|
|||||||
const setSearchQuery = useCallback((q: string) => dispatch(setDataSourcesSearchQuery(q)), [dispatch]);
|
const setSearchQuery = useCallback((q: string) => dispatch(setDataSourcesSearchQuery(q)), [dispatch]);
|
||||||
const searchQuery = useSelector(({ dataSources }: StoreState) => getDataSourcesSearchQuery(dataSources));
|
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(
|
const setSort = useCallback(
|
||||||
(sort: SelectableValue) => dispatch(setIsSortAscending(sort.value === ascendingSortValue)),
|
(sort: SelectableValue) => dispatch(setIsSortAscending(sort.value === ascendingSortValue)),
|
||||||
[dispatch]
|
[dispatch]
|
||||||
@ -56,12 +35,6 @@ export function DataSourcesListHeader() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageActionBar
|
<PageActionBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} key="action-bar" sortPicker={sortPicker} />
|
||||||
searchQuery={searchQuery}
|
|
||||||
setSearchQuery={setSearchQuery}
|
|
||||||
key="action-bar"
|
|
||||||
sortPicker={sortPicker}
|
|
||||||
linkButton={linkButton}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ import { getDataSourcesCount } from '../state';
|
|||||||
export function DataSourcesListPage() {
|
export function DataSourcesListPage() {
|
||||||
const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources));
|
const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources));
|
||||||
|
|
||||||
const actions = config.featureToggles.topnav && dataSourcesCount > 0 ? <DataSourceAddButton /> : undefined;
|
const actions = dataSourcesCount > 0 ? <DataSourceAddButton /> : undefined;
|
||||||
return (
|
return (
|
||||||
<Page navId="datasources" actions={actions}>
|
<Page navId="datasources" actions={actions}>
|
||||||
<Page.Contents>
|
<Page.Contents>
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
|
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
|
||||||
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
|
|
||||||
import { DataSourceApi, LoadingState, CoreApp, createTheme, EventBusSrv } from '@grafana/data';
|
import { DataSourceApi, LoadingState, CoreApp, createTheme, EventBusSrv } from '@grafana/data';
|
||||||
import { configureStore } from 'app/store/configureStore';
|
|
||||||
import { ExploreId } from 'app/types/explore';
|
import { ExploreId } from 'app/types/explore';
|
||||||
|
|
||||||
import { Explore, Props } from './Explore';
|
import { Explore, Props } from './Explore';
|
||||||
@ -120,13 +119,12 @@ jest.mock('react-virtualized-auto-sizer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const setup = (overrideProps?: Partial<Props>) => {
|
const setup = (overrideProps?: Partial<Props>) => {
|
||||||
const store = configureStore();
|
|
||||||
const exploreProps = { ...dummyProps, ...overrideProps };
|
const exploreProps = { ...dummyProps, ...overrideProps };
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<Provider store={store}>
|
<TestProvider>
|
||||||
<Explore {...exploreProps} />
|
<Explore {...exploreProps} />
|
||||||
</Provider>
|
</TestProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -135,7 +133,7 @@ describe('Explore', () => {
|
|||||||
setup();
|
setup();
|
||||||
|
|
||||||
// Wait for the Explore component to render
|
// 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();
|
expect(screen.queryByTestId('explore-no-data')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -145,7 +143,7 @@ describe('Explore', () => {
|
|||||||
setup({ queryResponse: queryResp });
|
setup({ queryResponse: queryResp });
|
||||||
|
|
||||||
// Wait for the Explore component to render
|
// 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();
|
expect(screen.getByTestId('explore-no-data')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
@ -243,7 +243,6 @@ class UnConnectedExploreToolbar extends PureComponent<Props> {
|
|||||||
this.props;
|
this.props;
|
||||||
|
|
||||||
const showSmallDataSourcePicker = (splitted ? containerWidth < 700 : containerWidth < 800) || false;
|
const showSmallDataSourcePicker = (splitted ? containerWidth < 700 : containerWidth < 800) || false;
|
||||||
const isTopnav = config.featureToggles.topnav;
|
|
||||||
|
|
||||||
const shareButton = (
|
const shareButton = (
|
||||||
<DashNavButton
|
<DashNavButton
|
||||||
@ -267,27 +266,15 @@ class UnConnectedExploreToolbar extends PureComponent<Props> {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const toolbarLeftItems = [
|
const toolbarLeftItems = [getDataSourcePicker()].filter(Boolean);
|
||||||
// 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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={topOfViewRef}>
|
<div ref={topOfViewRef}>
|
||||||
{refreshInterval && <SetInterval func={this.onRunQuery} interval={refreshInterval} loading={loading} />}
|
{refreshInterval && <SetInterval func={this.onRunQuery} interval={refreshInterval} loading={loading} />}
|
||||||
{isTopnav && (
|
<div ref={topOfViewRef}>
|
||||||
<div ref={topOfViewRef}>
|
<AppChromeUpdate actions={[shareButton, <div style={{ flex: 1 }} key="spacer" />]} />
|
||||||
<AppChromeUpdate actions={[shareButton, <div style={{ flex: 1 }} key="spacer" />]} />
|
</div>
|
||||||
</div>
|
<PageToolbar aria-label="Explore toolbar" leftItems={toolbarLeftItems} forceShowLeftItems>
|
||||||
)}
|
|
||||||
<PageToolbar
|
|
||||||
aria-label="Explore toolbar"
|
|
||||||
title={exploreId === ExploreId.left && !isTopnav ? 'Explore' : undefined}
|
|
||||||
pageIcon={exploreId === ExploreId.left && !isTopnav ? 'compass' : undefined}
|
|
||||||
leftItems={toolbarLeftItems}
|
|
||||||
forceShowLeftItems
|
|
||||||
>
|
|
||||||
{this.renderActions()}
|
{this.renderActions()}
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
</div>
|
</div>
|
||||||
|
@ -207,9 +207,7 @@ function LogsNavigation({
|
|||||||
export default memo(LogsNavigation);
|
export default memo(LogsNavigation);
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2, oldestLogsFirst: boolean) => {
|
const getStyles = (theme: GrafanaTheme2, oldestLogsFirst: boolean) => {
|
||||||
const navContainerHeight = theme.flags.topnav
|
const navContainerHeight = `calc(100vh - 2*${theme.spacing(2)} - 2*${TOP_BAR_LEVEL_HEIGHT}px)`;
|
||||||
? `calc(100vh - 2*${theme.spacing(2)} - 2*${TOP_BAR_LEVEL_HEIGHT}px)`
|
|
||||||
: '95vh';
|
|
||||||
return {
|
return {
|
||||||
navContainer: css`
|
navContainer: css`
|
||||||
max-height: ${navContainerHeight};
|
max-height: ${navContainerHeight};
|
||||||
|
@ -54,7 +54,6 @@ function NewDashboardsFolder({ createNewFolder }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Page navId="dashboards/browse" pageNav={pageNav}>
|
<Page navId="dashboards/browse" pageNav={pageNav}>
|
||||||
<Page.Contents>
|
<Page.Contents>
|
||||||
{!config.featureToggles.topnav && <h3>New dashboard folder</h3>}
|
|
||||||
<Form defaultValues={initialFormModel} onSubmit={onSubmit}>
|
<Form defaultValues={initialFormModel} onSubmit={onSubmit}>
|
||||||
{({ register, errors }) => (
|
{({ register, errors }) => (
|
||||||
<>
|
<>
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { contextSrv } from 'app/core/core';
|
||||||
|
|
||||||
@ -14,10 +13,8 @@ export function UserInvitePage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const navId = config.featureToggles.topnav ? 'global-users' : 'users';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page navId={navId} pageNav={{ text: 'Invite user' }} subTitle={subTitle}>
|
<Page navId="global-users" pageNav={{ text: 'Invite user' }} subTitle={subTitle}>
|
||||||
<Page.Contents>
|
<Page.Contents>
|
||||||
<UserInviteForm />
|
<UserInviteForm />
|
||||||
</Page.Contents>
|
</Page.Contents>
|
||||||
|
@ -36,32 +36,10 @@ const getPluginSettingsMock = getPluginSettings as jest.Mock<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
class RootComponent extends Component<AppRootProps> {
|
class RootComponent extends Component<AppRootProps> {
|
||||||
static timesMounted = 0;
|
static timesRendered = 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <p>my great plugin</p>;
|
RootComponent.timesRendered += 1;
|
||||||
|
return <p>my great component</p>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,36 +97,9 @@ describe('AppRootPage', () => {
|
|||||||
enabled: true,
|
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 () => {
|
it('should not render component if not at plugin path', async () => {
|
||||||
getPluginSettingsMock.mockResolvedValue(pluginMeta);
|
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();
|
const plugin = new AppPlugin();
|
||||||
plugin.meta = pluginMeta;
|
plugin.meta = pluginMeta;
|
||||||
plugin.root = RootComponent;
|
plugin.root = RootComponent;
|
||||||
@ -160,18 +111,18 @@ describe('AppRootPage', () => {
|
|||||||
expect(await screen.findByText('my great component')).toBeVisible();
|
expect(await screen.findByText('my great component')).toBeVisible();
|
||||||
|
|
||||||
// renders the first time
|
// renders the first time
|
||||||
expect(RootComponent.timesRendered).toEqual(2);
|
expect(RootComponent.timesRendered).toEqual(1);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
locationService.push('/foo');
|
locationService.push('/foo');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(RootComponent.timesRendered).toEqual(2);
|
expect(RootComponent.timesRendered).toEqual(1);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
locationService.push('/a/my-awesome-plugin');
|
locationService.push('/a/my-awesome-plugin');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(RootComponent.timesRendered).toEqual(4);
|
expect(RootComponent.timesRendered).toEqual(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
// Libraries
|
// Libraries
|
||||||
import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
|
import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
|
||||||
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
|
|
||||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
import { useLocation, useRouteMatch } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppEvents, AppPlugin, AppPluginMeta, NavModel, NavModelItem, PluginType } from '@grafana/data';
|
import { AppEvents, AppPlugin, AppPluginMeta, NavModel, NavModelItem, PluginType } from '@grafana/data';
|
||||||
@ -37,7 +36,6 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
|
|||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [state, dispatch] = useReducer(stateSlice.reducer, initialState);
|
const [state, dispatch] = useReducer(stateSlice.reducer, initialState);
|
||||||
const portalNode = useMemo(() => createHtmlPortalNode(), []);
|
|
||||||
const currentUrl = config.appSubUrl + location.pathname + location.search;
|
const currentUrl = config.appSubUrl + location.pathname + location.search;
|
||||||
const { plugin, loading, pluginNav } = state;
|
const { plugin, loading, pluginNav } = state;
|
||||||
const navModel = buildPluginSectionNav(pluginNavSection, pluginNav, currentUrl);
|
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 <PluginPageContext.Provider value={context}>{pluginRoot}</PluginPageContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<InPortal node={portalNode}>{pluginRoot}</InPortal>
|
|
||||||
{navModel ? (
|
{navModel ? (
|
||||||
<Page navModel={navModel} pageNav={pluginNav?.node}>
|
<Page navModel={navModel} pageNav={pluginNav?.node}>
|
||||||
<Page.Contents isLoading={loading}>
|
<Page.Contents isLoading={loading}>{pluginRoot}</Page.Contents>
|
||||||
<OutPortal node={portalNode} />
|
|
||||||
</Page.Contents>
|
|
||||||
</Page>
|
</Page>
|
||||||
) : (
|
) : (
|
||||||
<Page>
|
<Page>{pluginRoot}</Page>
|
||||||
<OutPortal node={portalNode} />
|
|
||||||
</Page>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -112,8 +105,7 @@ const stateSlice = createSlice({
|
|||||||
...pluginNav,
|
...pluginNav,
|
||||||
node: {
|
node: {
|
||||||
...pluginNav.main,
|
...pluginNav.main,
|
||||||
// Because breadcumbs code is also used to set title when topnav should only set hideFromBreadcrumbs when topnav is enabled
|
hideFromBreadcrumbs: true,
|
||||||
hideFromBreadcrumbs: config.featureToggles.topnav,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { NavModelItem } from '@grafana/data';
|
import { NavModelItem } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { HOME_NAV_ID } from 'app/core/reducers/navModel';
|
import { HOME_NAV_ID } from 'app/core/reducers/navModel';
|
||||||
|
|
||||||
import { buildPluginSectionNav } from './utils';
|
import { buildPluginSectionNav } from './utils';
|
||||||
@ -50,41 +49,30 @@ describe('buildPluginSectionNav', () => {
|
|||||||
|
|
||||||
app1.parentItem = appsSection;
|
app1.parentItem = appsSection;
|
||||||
|
|
||||||
it('Should return pluginNav if topnav is disabled', () => {
|
it('Should return return section nav', () => {
|
||||||
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;
|
|
||||||
const result = buildPluginSectionNav(appsSection, pluginNav, '/a/plugin1/page1');
|
const result = buildPluginSectionNav(appsSection, pluginNav, '/a/plugin1/page1');
|
||||||
expect(result?.main.text).toBe('apps');
|
expect(result?.main.text).toBe('apps');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should set active page', () => {
|
it('Should set active page', () => {
|
||||||
config.featureToggles.topnav = true;
|
|
||||||
const result = buildPluginSectionNav(appsSection, null, '/a/plugin1/page2');
|
const result = buildPluginSectionNav(appsSection, null, '/a/plugin1/page2');
|
||||||
expect(result?.main.children![0].children![1].active).toBe(true);
|
expect(result?.main.children![0].children![1].active).toBe(true);
|
||||||
expect(result?.node.text).toBe('page2');
|
expect(result?.node.text).toBe('page2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should only set the most specific match as active (not the parents)', () => {
|
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');
|
const result = buildPluginSectionNav(appsSection, null, '/a/plugin1/page2');
|
||||||
expect(result?.main.children![0].children![1].active).toBe(true);
|
expect(result?.main.children![0].children![1].active).toBe(true);
|
||||||
expect(result?.main.children![0].active).not.toBe(true); // Parent should not be active
|
expect(result?.main.children![0].active).not.toBe(true); // Parent should not be active
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should set app section to active', () => {
|
it('Should set app section to active', () => {
|
||||||
config.featureToggles.topnav = true;
|
|
||||||
const result = buildPluginSectionNav(appsSection, null, '/a/plugin1');
|
const result = buildPluginSectionNav(appsSection, null, '/a/plugin1');
|
||||||
expect(result?.main.children![0].active).toBe(true);
|
expect(result?.main.children![0].active).toBe(true);
|
||||||
expect(result?.node.text).toBe('App1');
|
expect(result?.node.text).toBe('App1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should handle standalone page', () => {
|
it('Should handle standalone page', () => {
|
||||||
config.featureToggles.topnav = true;
|
|
||||||
const result = buildPluginSectionNav(adminSection, pluginNav, '/a/app2/config');
|
const result = buildPluginSectionNav(adminSection, pluginNav, '/a/app2/config');
|
||||||
expect(result?.main.text).toBe('Admin');
|
expect(result?.main.text).toBe('Admin');
|
||||||
expect(result?.node.text).toBe('Standalone page');
|
expect(result?.node.text).toBe('Standalone page');
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { GrafanaPlugin, NavModel, NavModelItem, PanelPluginMeta, PluginType } from '@grafana/data';
|
import { GrafanaPlugin, NavModel, NavModelItem, PanelPluginMeta, PluginType } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
|
|
||||||
import { importPanelPluginFromMeta } from './importPanelPlugin';
|
import { importPanelPluginFromMeta } from './importPanelPlugin';
|
||||||
import { getPluginSettings } from './pluginSettings';
|
import { getPluginSettings } from './pluginSettings';
|
||||||
@ -35,11 +34,6 @@ export function buildPluginSectionNav(
|
|||||||
pluginNav: NavModel | null,
|
pluginNav: NavModel | null,
|
||||||
currentUrl: string
|
currentUrl: string
|
||||||
): NavModel | undefined {
|
): 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
|
// shallow clone as we set active flag
|
||||||
let copiedPluginNavSection = { ...pluginNavSection };
|
let copiedPluginNavSection = { ...pluginNavSection };
|
||||||
let activePage: NavModelItem | undefined;
|
let activePage: NavModelItem | undefined;
|
||||||
|
@ -59,7 +59,6 @@ describe('ChangePasswordPage', () => {
|
|||||||
|
|
||||||
it('should show change password form when user has loaded', async () => {
|
it('should show change password form when user has loaded', async () => {
|
||||||
await getTestContext();
|
await getTestContext();
|
||||||
expect(screen.getByText('Change Your Password')).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.getByLabelText('Old password')).toBeInTheDocument();
|
expect(screen.getByLabelText('Old password')).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('New 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' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('link', { name: 'Cancel' })).toHaveAttribute('href', '/profile');
|
expect(screen.getByRole('link', { name: 'Cancel' })).toHaveAttribute('href', '/profile');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call changePassword if change password is valid', async () => {
|
it('should call changePassword if change password is valid', async () => {
|
||||||
const { props } = await getTestContext();
|
const { props } = await getTestContext();
|
||||||
|
|
||||||
|
@ -36,9 +36,6 @@ export function ChangePasswordPage({ loadUser, isUpdating, user, changePassword
|
|||||||
<Page.Contents isLoading={!Boolean(user)}>
|
<Page.Contents isLoading={!Boolean(user)}>
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<Page.OldNavOnly>
|
|
||||||
<h3 className="page-sub-heading">Change Your Password</h3>
|
|
||||||
</Page.OldNavOnly>
|
|
||||||
<ChangePasswordForm user={user} onChangePassword={changePassword} isSaving={isUpdating} />
|
<ChangePasswordForm user={user} onChangePassword={changePassword} isSaving={isUpdating} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
@ -2,9 +2,9 @@ import { css } from '@emotion/css';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
|
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 { 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 { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
|
||||||
@ -48,11 +48,7 @@ function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>)
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const pageToolbar = config.featureToggles.topnav ? (
|
const pageToolbar = <AppChromeUpdate actions={toolbarActions} />;
|
||||||
<AppChromeUpdate actions={toolbarActions} />
|
|
||||||
) : (
|
|
||||||
<PageToolbar title={title}>{toolbarActions}</PageToolbar>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page navId="scenes" pageNav={{ text: title }} layout={PageLayoutType.Canvas} toolbar={pageToolbar}>
|
<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