mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Chore: Remove newNavigation
feature toggle and old navbar code (#50872)
* Remove newNavigation feature toggle + old code * fix unit tests * remove buildCreateNavLinks
This commit is contained in:
parent
d0808bdafb
commit
d0fa326798
@ -1184,9 +1184,6 @@ commandPalette = true
|
|||||||
# Use dynamic labels in CloudWatch datasource
|
# Use dynamic labels in CloudWatch datasource
|
||||||
cloudWatchDynamicLabels = true
|
cloudWatchDynamicLabels = true
|
||||||
|
|
||||||
# New expandable navigation
|
|
||||||
newNavigation = true
|
|
||||||
|
|
||||||
# feature1 = true
|
# feature1 = true
|
||||||
# feature2 = false
|
# feature2 = false
|
||||||
|
|
||||||
|
@ -32,7 +32,6 @@ export interface FeatureToggles {
|
|||||||
prometheus_azure_auth?: boolean;
|
prometheus_azure_auth?: boolean;
|
||||||
prometheusAzureOverrideAudience?: boolean;
|
prometheusAzureOverrideAudience?: boolean;
|
||||||
influxdbBackendMigration?: boolean;
|
influxdbBackendMigration?: boolean;
|
||||||
newNavigation?: boolean;
|
|
||||||
showFeatureFlagsInUI?: boolean;
|
showFeatureFlagsInUI?: boolean;
|
||||||
publicDashboards?: boolean;
|
publicDashboards?: boolean;
|
||||||
lokiLive?: boolean;
|
lokiLive?: boolean;
|
||||||
|
@ -92,15 +92,10 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error)
|
|||||||
Id: "plugin-page-" + plugin.ID,
|
Id: "plugin-page-" + plugin.ID,
|
||||||
Url: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL),
|
Url: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL),
|
||||||
Img: plugin.Info.Logos.Small,
|
Img: plugin.Info.Logos.Small,
|
||||||
|
Section: dtos.NavSectionPlugin,
|
||||||
SortWeight: dtos.WeightPlugin,
|
SortWeight: dtos.WeightPlugin,
|
||||||
}
|
}
|
||||||
|
|
||||||
if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) {
|
|
||||||
appLink.Section = dtos.NavSectionPlugin
|
|
||||||
} else {
|
|
||||||
appLink.Section = dtos.NavSectionCore
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, include := range plugin.Includes {
|
for _, include := range plugin.Includes {
|
||||||
if !c.HasUserRole(include.Role) {
|
if !c.HasUserRole(include.Role) {
|
||||||
continue
|
continue
|
||||||
@ -187,25 +182,9 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasEditPerm && !hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) {
|
|
||||||
children := hs.buildCreateNavLinks(c)
|
|
||||||
navTree = append(navTree, &dtos.NavLink{
|
|
||||||
Text: "Create",
|
|
||||||
Id: "create",
|
|
||||||
Icon: "plus",
|
|
||||||
Url: hs.Cfg.AppSubURL + "/dashboard/new",
|
|
||||||
Children: children,
|
|
||||||
Section: dtos.NavSectionCore,
|
|
||||||
SortWeight: dtos.WeightCreate,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
dashboardChildLinks := hs.buildDashboardNavLinks(c, hasEditPerm)
|
dashboardChildLinks := hs.buildDashboardNavLinks(c, hasEditPerm)
|
||||||
|
|
||||||
dashboardsUrl := "/"
|
dashboardsUrl := "/dashboards"
|
||||||
if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) {
|
|
||||||
dashboardsUrl = "/dashboards"
|
|
||||||
}
|
|
||||||
|
|
||||||
navTree = append(navTree, &dtos.NavLink{
|
navTree = append(navTree, &dtos.NavLink{
|
||||||
Text: "Dashboards",
|
Text: "Dashboards",
|
||||||
@ -359,25 +338,17 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
|
|||||||
SubTitle: "Organization: " + c.OrgName,
|
SubTitle: "Organization: " + c.OrgName,
|
||||||
Icon: "cog",
|
Icon: "cog",
|
||||||
Url: configNodes[0].Url,
|
Url: configNodes[0].Url,
|
||||||
|
Section: dtos.NavSectionConfig,
|
||||||
SortWeight: dtos.WeightConfig,
|
SortWeight: dtos.WeightConfig,
|
||||||
Children: configNodes,
|
Children: configNodes,
|
||||||
}
|
}
|
||||||
if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) {
|
|
||||||
configNode.Section = dtos.NavSectionConfig
|
|
||||||
} else {
|
|
||||||
configNode.Section = dtos.NavSectionCore
|
|
||||||
}
|
|
||||||
navTree = append(navTree, configNode)
|
navTree = append(navTree, configNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
adminNavLinks := hs.buildAdminNavLinks(c)
|
adminNavLinks := hs.buildAdminNavLinks(c)
|
||||||
|
|
||||||
if len(adminNavLinks) > 0 {
|
if len(adminNavLinks) > 0 {
|
||||||
navSection := dtos.NavSectionCore
|
serverAdminNode := navlinks.GetServerAdminNode(adminNavLinks)
|
||||||
if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) {
|
|
||||||
navSection = dtos.NavSectionConfig
|
|
||||||
}
|
|
||||||
serverAdminNode := navlinks.GetServerAdminNode(adminNavLinks, navSection)
|
|
||||||
navTree = append(navTree, serverAdminNode)
|
navTree = append(navTree, serverAdminNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -467,14 +438,6 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
|
|||||||
}
|
}
|
||||||
|
|
||||||
dashboardChildNavs := []*dtos.NavLink{}
|
dashboardChildNavs := []*dtos.NavLink{}
|
||||||
if !hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) {
|
|
||||||
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
|
|
||||||
Text: "Home", Id: "home", Url: hs.Cfg.AppSubURL + "/", Icon: "home-alt", HideFromTabs: true,
|
|
||||||
})
|
|
||||||
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
|
|
||||||
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
|
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
|
||||||
Text: "Browse", Id: "manage-dashboards", Url: hs.Cfg.AppSubURL + "/dashboards", Icon: "sitemap",
|
Text: "Browse", Id: "manage-dashboards", Url: hs.Cfg.AppSubURL + "/dashboards", Icon: "sitemap",
|
||||||
})
|
})
|
||||||
@ -498,7 +461,7 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasEditPerm && hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) {
|
if hasEditPerm {
|
||||||
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
|
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
|
||||||
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
|
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
|
||||||
})
|
})
|
||||||
@ -583,8 +546,7 @@ func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext) []*dtos.NavLink {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) &&
|
if hasAccess(hs.editorInAnyFolder, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) {
|
||||||
hasAccess(hs.editorInAnyFolder, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) {
|
|
||||||
alertChildNavs = append(alertChildNavs, &dtos.NavLink{
|
alertChildNavs = append(alertChildNavs, &dtos.NavLink{
|
||||||
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
|
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
|
||||||
})
|
})
|
||||||
@ -612,41 +574,6 @@ func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext) []*dtos.NavLink {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hs *HTTPServer) buildCreateNavLinks(c *models.ReqContext) []*dtos.NavLink {
|
|
||||||
hasAccess := ac.HasAccess(hs.AccessControl, c)
|
|
||||||
var children []*dtos.NavLink
|
|
||||||
|
|
||||||
if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsCreate)) {
|
|
||||||
children = append(children, &dtos.NavLink{Text: "Dashboard", Icon: "apps", Url: hs.Cfg.AppSubURL + "/dashboard/new", Id: "create-dashboard"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasAccess(ac.ReqOrgAdminOrEditor, ac.EvalPermission(dashboards.ActionFoldersCreate)) {
|
|
||||||
children = append(children, &dtos.NavLink{
|
|
||||||
Text: "Folder", SubTitle: "Create a new folder to organize your dashboards", Id: "folder",
|
|
||||||
Icon: "folder-plus", Url: hs.Cfg.AppSubURL + "/dashboards/folder/new",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsCreate)) {
|
|
||||||
children = append(children, &dtos.NavLink{
|
|
||||||
Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "import",
|
|
||||||
Url: hs.Cfg.AppSubURL + "/dashboard/import",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
_, uaIsDisabledForOrg := hs.Cfg.UnifiedAlerting.DisabledOrgs[c.OrgId]
|
|
||||||
uaVisibleForOrg := hs.Cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg
|
|
||||||
|
|
||||||
if uaVisibleForOrg && hasAccess(ac.ReqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) {
|
|
||||||
children = append(children, &dtos.NavLink{
|
|
||||||
Text: "New alert rule", SubTitle: "Create an alert rule", Id: "alert",
|
|
||||||
Icon: "bell", Url: hs.Cfg.AppSubURL + "/alerting/new",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
|
|
||||||
func (hs *HTTPServer) buildDataConnectionsNavLink(c *models.ReqContext) *dtos.NavLink {
|
func (hs *HTTPServer) buildDataConnectionsNavLink(c *models.ReqContext) *dtos.NavLink {
|
||||||
var children []*dtos.NavLink
|
var children []*dtos.NavLink
|
||||||
var navLink *dtos.NavLink
|
var navLink *dtos.NavLink
|
||||||
|
@ -2,7 +2,7 @@ package navlinks
|
|||||||
|
|
||||||
import "github.com/grafana/grafana/pkg/api/dtos"
|
import "github.com/grafana/grafana/pkg/api/dtos"
|
||||||
|
|
||||||
func GetServerAdminNode(children []*dtos.NavLink, navSection string) *dtos.NavLink {
|
func GetServerAdminNode(children []*dtos.NavLink) *dtos.NavLink {
|
||||||
url := ""
|
url := ""
|
||||||
if len(children) > 0 {
|
if len(children) > 0 {
|
||||||
url = children[0].Url
|
url = children[0].Url
|
||||||
@ -15,7 +15,7 @@ func GetServerAdminNode(children []*dtos.NavLink, navSection string) *dtos.NavLi
|
|||||||
Icon: "shield",
|
Icon: "shield",
|
||||||
Url: url,
|
Url: url,
|
||||||
SortWeight: dtos.WeightAdmin,
|
SortWeight: dtos.WeightAdmin,
|
||||||
Section: navSection,
|
Section: dtos.NavSectionConfig,
|
||||||
Children: children,
|
Children: children,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -95,11 +95,6 @@ var (
|
|||||||
State: FeatureStateAlpha,
|
State: FeatureStateAlpha,
|
||||||
FrontendOnly: true,
|
FrontendOnly: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Name: "newNavigation",
|
|
||||||
Description: "Try the next gen navigation model",
|
|
||||||
State: FeatureStateAlpha,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Name: "showFeatureFlagsInUI",
|
Name: "showFeatureFlagsInUI",
|
||||||
Description: "Show feature flags in the settings UI",
|
Description: "Show feature flags in the settings UI",
|
||||||
|
@ -71,10 +71,6 @@ const (
|
|||||||
// Query InfluxDB InfluxQL without the proxy
|
// Query InfluxDB InfluxQL without the proxy
|
||||||
FlagInfluxdbBackendMigration = "influxdbBackendMigration"
|
FlagInfluxdbBackendMigration = "influxdbBackendMigration"
|
||||||
|
|
||||||
// FlagNewNavigation
|
|
||||||
// Try the next gen navigation model
|
|
||||||
FlagNewNavigation = "newNavigation"
|
|
||||||
|
|
||||||
// FlagShowFeatureFlagsInUI
|
// FlagShowFeatureFlagsInUI
|
||||||
// Show feature flags in the settings UI
|
// Show feature flags in the settings UI
|
||||||
FlagShowFeatureFlagsInUI = "showFeatureFlagsInUI"
|
FlagShowFeatureFlagsInUI = "showFeatureFlagsInUI"
|
||||||
|
@ -14,7 +14,6 @@ import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabl
|
|||||||
import { GrafanaApp } from './app';
|
import { GrafanaApp } from './app';
|
||||||
import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList';
|
import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList';
|
||||||
import { NavBar } from './core/components/NavBar/NavBar';
|
import { NavBar } from './core/components/NavBar/NavBar';
|
||||||
import { NavBarNext } from './core/components/NavBar/Next/NavBarNext';
|
|
||||||
import { I18nProvider } from './core/localisation';
|
import { I18nProvider } from './core/localisation';
|
||||||
import { GrafanaRoute } from './core/navigation/GrafanaRoute';
|
import { GrafanaRoute } from './core/navigation/GrafanaRoute';
|
||||||
import { RouteDescriptor } from './core/navigation/types';
|
import { RouteDescriptor } from './core/navigation/types';
|
||||||
@ -87,8 +86,6 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
|
|||||||
|
|
||||||
navigationLogger('AppWrapper', false, 'rendering');
|
navigationLogger('AppWrapper', false, 'rendering');
|
||||||
|
|
||||||
const newNavigationEnabled = Boolean(config.featureToggles.newNavigation);
|
|
||||||
|
|
||||||
const commandPaletteActionSelected = (action: Action) => {
|
const commandPaletteActionSelected = (action: Action) => {
|
||||||
reportInteraction('commandPalette_action_selected', {
|
reportInteraction('commandPalette_action_selected', {
|
||||||
actionId: action.id,
|
actionId: action.id,
|
||||||
@ -98,7 +95,7 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
|
|||||||
const commandPaletteEnabled = () => !config.isPublicDashboardView && config.featureToggles.commandPalette;
|
const commandPaletteEnabled = () => !config.isPublicDashboardView && config.featureToggles.commandPalette;
|
||||||
|
|
||||||
const renderNavBar = () => {
|
const renderNavBar = () => {
|
||||||
return !config.isPublicDashboardView && ready && <>{newNavigationEnabled ? <NavBarNext /> : <NavBar />}</>;
|
return !config.isPublicDashboardView && ready && <NavBar />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchBarEnabled = () => !config.isPublicDashboardView;
|
const searchBarEnabled = () => !config.isPublicDashboardView;
|
||||||
|
@ -36,6 +36,17 @@ const setup = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('Render', () => {
|
describe('Render', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// IntersectionObserver isn't available in test environment
|
||||||
|
const mockIntersectionObserver = jest.fn();
|
||||||
|
mockIntersectionObserver.mockReturnValue({
|
||||||
|
observe: () => null,
|
||||||
|
unobserve: () => null,
|
||||||
|
disconnect: () => null,
|
||||||
|
});
|
||||||
|
window.IntersectionObserver = mockIntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
it('should render component', async () => {
|
it('should render component', async () => {
|
||||||
setup();
|
setup();
|
||||||
const sidemenu = await screen.findByTestId('sidemenu');
|
const sidemenu = await screen.findByTestId('sidemenu');
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
|
import { FocusScope } from '@react-aria/focus';
|
||||||
import { cloneDeep } from 'lodash';
|
import { cloneDeep } from 'lodash';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
|
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { config, locationService, reportInteraction } from '@grafana/runtime';
|
||||||
import { Icon, IconName, useTheme2 } from '@grafana/ui';
|
import { Icon, useTheme2 } from '@grafana/ui';
|
||||||
import { Branding } from 'app/core/components/Branding/Branding';
|
import { Branding } from 'app/core/components/Branding/Branding';
|
||||||
import config from 'app/core/config';
|
|
||||||
import { getKioskMode } from 'app/core/navigation/kiosk';
|
import { getKioskMode } from 'app/core/navigation/kiosk';
|
||||||
import { KioskMode, StoreState } from 'app/types';
|
import { KioskMode, StoreState } from 'app/types';
|
||||||
|
|
||||||
@ -17,169 +17,269 @@ import { OrgSwitcher } from '../OrgSwitcher';
|
|||||||
import NavBarItem from './NavBarItem';
|
import NavBarItem from './NavBarItem';
|
||||||
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
||||||
import { NavBarMenu } from './NavBarMenu';
|
import { NavBarMenu } from './NavBarMenu';
|
||||||
import { NavBarSection } from './NavBarSection';
|
import { NavBarMenuPortalContainer } from './NavBarMenuPortalContainer';
|
||||||
import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive, SEARCH_ITEM_ID } from './utils';
|
import { NavBarScrollContainer } from './NavBarScrollContainer';
|
||||||
|
import { NavBarToggle } from './NavBarToggle';
|
||||||
const homeUrl = config.appSubUrl || '/';
|
import { NavBarContext } from './context';
|
||||||
|
import {
|
||||||
|
enrichConfigItems,
|
||||||
|
enrichWithInteractionTracking,
|
||||||
|
getActiveItem,
|
||||||
|
isMatchOrChildMatch,
|
||||||
|
isSearchActive,
|
||||||
|
SEARCH_ITEM_ID,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
const onOpenSearch = () => {
|
const onOpenSearch = () => {
|
||||||
locationService.partial({ search: 'open' });
|
locationService.partial({ search: 'open' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchItem: NavModelItem = {
|
export const NavBar = React.memo(() => {
|
||||||
id: SEARCH_ITEM_ID,
|
const navBarTree = useSelector((state: StoreState) => state.navBarTree);
|
||||||
onClick: onOpenSearch,
|
|
||||||
text: 'Search dashboards',
|
|
||||||
icon: 'search',
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapStateToProps = (state: StoreState) => ({
|
|
||||||
navBarTree: state.navBarTree,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = {};
|
|
||||||
|
|
||||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
|
||||||
|
|
||||||
export interface Props extends ConnectedProps<typeof connector> {}
|
|
||||||
|
|
||||||
export const NavBarUnconnected = React.memo(({ navBarTree }: Props) => {
|
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = getStyles(theme);
|
const styles = getStyles(theme);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const kiosk = getKioskMode();
|
const kiosk = getKioskMode();
|
||||||
const [showSwitcherModal, setShowSwitcherModal] = useState(false);
|
const [showSwitcherModal, setShowSwitcherModal] = useState(false);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [menuAnimationInProgress, setMenuAnimationInProgress] = useState(false);
|
||||||
|
const [menuIdOpen, setMenuIdOpen] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const toggleSwitcherModal = () => {
|
const toggleSwitcherModal = () => {
|
||||||
setShowSwitcherModal(!showSwitcherModal);
|
setShowSwitcherModal(!showSwitcherModal);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
|
||||||
|
const homeItem: NavModelItem = enrichWithInteractionTracking(
|
||||||
|
{
|
||||||
|
id: 'home',
|
||||||
|
text: 'Home',
|
||||||
|
url: config.appSubUrl || '/',
|
||||||
|
icon: 'grafana',
|
||||||
|
},
|
||||||
|
menuOpen
|
||||||
|
);
|
||||||
|
|
||||||
const navTree = cloneDeep(navBarTree);
|
const navTree = cloneDeep(navBarTree);
|
||||||
const topItems = navTree.filter((item) => item.section === NavSection.Core);
|
|
||||||
const bottomItems = enrichConfigItems(
|
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),
|
navTree.filter((item) => item.section === NavSection.Config),
|
||||||
location,
|
location,
|
||||||
toggleSwitcherModal
|
toggleSwitcherModal
|
||||||
);
|
).map((item) => enrichWithInteractionTracking(item, menuOpen));
|
||||||
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
|
|
||||||
|
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
|
||||||
|
|
||||||
if (kiosk !== KioskMode.Off) {
|
if (kiosk !== KioskMode.Off) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={styles.navWrapper}>
|
||||||
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
|
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
|
||||||
<div className={styles.mobileSidemenuLogo} onClick={() => setMobileMenuOpen(!mobileMenuOpen)} key="hamburger">
|
<NavBarContext.Provider
|
||||||
|
value={{
|
||||||
|
menuIdOpen: menuIdOpen,
|
||||||
|
setMenuIdOpen: setMenuIdOpen,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FocusScope>
|
||||||
|
<div className={styles.mobileSidemenuLogo} onClick={() => setMenuOpen(!menuOpen)} key="hamburger">
|
||||||
<Icon name="bars" size="xl" />
|
<Icon name="bars" size="xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NavBarSection>
|
<NavBarToggle
|
||||||
<NavBarItemWithoutMenu label="Home" className={styles.grafanaLogo} url={homeUrl}>
|
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}
|
||||||
|
>
|
||||||
<Branding.MenuLogo />
|
<Branding.MenuLogo />
|
||||||
</NavBarItemWithoutMenu>
|
</NavBarItemWithoutMenu>
|
||||||
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem}>
|
|
||||||
<Icon name="search" size="xl" />
|
|
||||||
</NavBarItem>
|
|
||||||
</NavBarSection>
|
|
||||||
|
|
||||||
<NavBarSection>
|
<NavBarScrollContainer>
|
||||||
{topItems.map((link, index) => (
|
<ul className={styles.itemList}>
|
||||||
|
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem} />
|
||||||
|
|
||||||
|
{coreItems.map((link, index) => (
|
||||||
<NavBarItem
|
<NavBarItem
|
||||||
key={`${link.id}-${index}`}
|
key={`${link.id}-${index}`}
|
||||||
isActive={isMatchOrChildMatch(link, activeItem)}
|
isActive={isMatchOrChildMatch(link, activeItem)}
|
||||||
link={{ ...link, subTitle: undefined, onClick: undefined }}
|
link={{ ...link, subTitle: undefined }}
|
||||||
>
|
/>
|
||||||
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
|
|
||||||
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
|
|
||||||
</NavBarItem>
|
|
||||||
))}
|
))}
|
||||||
</NavBarSection>
|
|
||||||
|
|
||||||
<div className={styles.spacer} />
|
{pluginItems.length > 0 &&
|
||||||
|
pluginItems.map((link, index) => (
|
||||||
|
<NavBarItem
|
||||||
|
key={`${link.id}-${index}`}
|
||||||
|
isActive={isMatchOrChildMatch(link, activeItem)}
|
||||||
|
link={link}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
<NavBarSection>
|
{configItems.map((link, index) => (
|
||||||
{bottomItems.map((link, index) => (
|
|
||||||
<NavBarItem
|
<NavBarItem
|
||||||
key={`${link.id}-${index}`}
|
key={`${link.id}-${index}`}
|
||||||
isActive={isMatchOrChildMatch(link, activeItem)}
|
isActive={isMatchOrChildMatch(link, activeItem)}
|
||||||
reverseMenuDirection
|
reverseMenuDirection
|
||||||
link={link}
|
link={link}
|
||||||
>
|
className={cx({ [styles.verticalSpacer]: index === 0 })}
|
||||||
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
|
/>
|
||||||
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
|
|
||||||
</NavBarItem>
|
|
||||||
))}
|
))}
|
||||||
</NavBarSection>
|
</ul>
|
||||||
|
</NavBarScrollContainer>
|
||||||
|
</FocusScope>
|
||||||
|
</NavBarContext.Provider>
|
||||||
|
</nav>
|
||||||
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
|
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
|
||||||
{mobileMenuOpen && (
|
{(menuOpen || menuAnimationInProgress) && (
|
||||||
|
<div className={styles.menuWrapper}>
|
||||||
<NavBarMenu
|
<NavBarMenu
|
||||||
activeItem={activeItem}
|
activeItem={activeItem}
|
||||||
navItems={[searchItem, ...topItems, ...bottomItems]}
|
isOpen={menuOpen}
|
||||||
onClose={() => setMobileMenuOpen(false)}
|
setMenuAnimationInProgress={setMenuAnimationInProgress}
|
||||||
|
navItems={[homeItem, searchItem, ...coreItems, ...pluginItems, ...configItems]}
|
||||||
|
onClose={() => setMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
NavBarUnconnected.displayName = 'NavBar';
|
NavBar.displayName = 'NavBar';
|
||||||
|
|
||||||
export const NavBar = connector(NavBarUnconnected);
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
search: css`
|
navWrapper: css({
|
||||||
display: none;
|
position: 'relative',
|
||||||
margin-top: ${theme.spacing(5)};
|
display: 'flex',
|
||||||
|
|
||||||
${theme.breakpoints.up('md')} {
|
'.sidemenu-hidden &': {
|
||||||
display: block;
|
display: 'none',
|
||||||
}
|
},
|
||||||
`,
|
}),
|
||||||
sidemenu: css`
|
sidemenu: css({
|
||||||
display: flex;
|
label: 'sidemenu',
|
||||||
flex-direction: column;
|
display: 'flex',
|
||||||
position: fixed;
|
flexDirection: 'column',
|
||||||
z-index: ${theme.zIndex.sidemenu};
|
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.up('md')} {
|
[theme.breakpoints.down('md')]: {
|
||||||
background: ${theme.colors.background.primary};
|
height: theme.spacing(7),
|
||||||
border-right: 1px solid ${theme.components.panel.borderColor};
|
position: 'fixed',
|
||||||
padding: 0 0 ${theme.spacing(1)} 0;
|
paddingTop: '0px',
|
||||||
position: relative;
|
backgroundColor: 'inherit',
|
||||||
width: ${theme.components.sidemenu.width}px;
|
borderRight: 0,
|
||||||
}
|
},
|
||||||
|
}),
|
||||||
|
mobileSidemenuLogo: css({
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
|
||||||
.sidemenu-hidden & {
|
[theme.breakpoints.up('md')]: {
|
||||||
display: none;
|
display: 'none',
|
||||||
}
|
},
|
||||||
`,
|
}),
|
||||||
grafanaLogo: css`
|
itemList: css({
|
||||||
display: none;
|
backgroundColor: 'inherit',
|
||||||
img {
|
display: 'flex',
|
||||||
height: ${theme.spacing(3.5)};
|
flexDirection: 'column',
|
||||||
width: ${theme.spacing(3.5)};
|
height: '100%',
|
||||||
}
|
|
||||||
|
|
||||||
${theme.breakpoints.up('md')} {
|
[theme.breakpoints.down('md')]: {
|
||||||
align-items: center;
|
visibility: 'hidden',
|
||||||
display: flex;
|
},
|
||||||
justify-content: center;
|
}),
|
||||||
}
|
grafanaLogo: css({
|
||||||
`,
|
alignItems: 'stretch',
|
||||||
mobileSidemenuLogo: css`
|
display: 'flex',
|
||||||
align-items: center;
|
flexShrink: 0,
|
||||||
cursor: pointer;
|
height: theme.spacing(6),
|
||||||
display: flex;
|
justifyContent: 'stretch',
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: ${theme.spacing(2)};
|
|
||||||
|
|
||||||
${theme.breakpoints.up('md')} {
|
[theme.breakpoints.down('md')]: {
|
||||||
display: none;
|
visibility: 'hidden',
|
||||||
}
|
},
|
||||||
`,
|
}),
|
||||||
spacer: css`
|
grafanaLogoInner: css({
|
||||||
flex: 1;
|
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,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
@ -19,10 +19,11 @@ jest.mock('history', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import NavBarItem, { Props } from './NavBarItem';
|
import NavBarItem, { Props } from './NavBarItem';
|
||||||
|
import { NavBarContext } from './context';
|
||||||
|
|
||||||
const onClickMock = jest.fn();
|
const onClickMock = jest.fn();
|
||||||
|
const setMenuIdOpenMock = jest.fn();
|
||||||
const defaults: Props = {
|
const defaults: Props = {
|
||||||
children: undefined,
|
|
||||||
link: {
|
link: {
|
||||||
text: 'Parent Node',
|
text: 'Parent Node',
|
||||||
onClick: onClickMock,
|
onClick: onClickMock,
|
||||||
@ -30,10 +31,11 @@ const defaults: Props = {
|
|||||||
{ text: 'Child Node 1', onClick: onClickMock, children: [] },
|
{ text: 'Child Node 1', onClick: onClickMock, children: [] },
|
||||||
{ text: 'Child Node 2', onClick: onClickMock, children: [] },
|
{ text: 'Child Node 2', onClick: onClickMock, children: [] },
|
||||||
],
|
],
|
||||||
|
id: 'MY_NAV_ID',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getTestContext(overrides: Partial<Props> = {}, subUrl = '') {
|
async function getTestContext(overrides: Partial<Props> = {}, subUrl = '', isMenuOpen = false) {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
config.appSubUrl = subUrl;
|
config.appSubUrl = subUrl;
|
||||||
locationUtil.initialize({ config, getTimeRangeForUrl: jest.fn(), getVariablesUrlParams: jest.fn() });
|
locationUtil.initialize({ config, getTimeRangeForUrl: jest.fn(), getVariablesUrlParams: jest.fn() });
|
||||||
@ -45,7 +47,14 @@ async function getTestContext(overrides: Partial<Props> = {}, subUrl = '') {
|
|||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<TestProvider>
|
<TestProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<NavBarItem {...props}>{props.children}</NavBarItem>
|
<NavBarContext.Provider
|
||||||
|
value={{
|
||||||
|
menuIdOpen: isMenuOpen ? props.link.id : undefined,
|
||||||
|
setMenuIdOpen: setMenuIdOpenMock,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavBarItem {...props} />
|
||||||
|
</NavBarContext.Provider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TestProvider>
|
</TestProvider>
|
||||||
);
|
);
|
||||||
@ -57,6 +66,17 @@ async function getTestContext(overrides: Partial<Props> = {}, subUrl = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('NavBarItem', () => {
|
describe('NavBarItem', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// IntersectionObserver isn't available in test environment
|
||||||
|
const mockIntersectionObserver = jest.fn();
|
||||||
|
mockIntersectionObserver.mockReturnValue({
|
||||||
|
observe: () => null,
|
||||||
|
unobserve: () => null,
|
||||||
|
disconnect: () => null,
|
||||||
|
});
|
||||||
|
window.IntersectionObserver = mockIntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
describe('when url property is not set', () => {
|
describe('when url property is not set', () => {
|
||||||
it('then it renders the menu trigger as a button', async () => {
|
it('then it renders the menu trigger as a button', async () => {
|
||||||
await getTestContext();
|
await getTestContext();
|
||||||
@ -74,32 +94,34 @@ describe('NavBarItem', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('and hovering over the menu trigger button', () => {
|
describe('and hovering over the menu trigger button', () => {
|
||||||
it('then the menu items should be visible', async () => {
|
it('then the menuIdOpen should be set correctly', async () => {
|
||||||
await getTestContext();
|
await getTestContext();
|
||||||
|
|
||||||
await userEvent.hover(screen.getByRole('button'));
|
await userEvent.hover(screen.getByRole('button'));
|
||||||
|
expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id);
|
||||||
expect(screen.getByRole('menuitem', { name: 'Parent Node' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('and tabbing to the menu trigger button', () => {
|
describe('and tabbing to the menu trigger button', () => {
|
||||||
it('then the menu items should be visible', async () => {
|
it('then the menuIdOpen should be set correctly', async () => {
|
||||||
await getTestContext();
|
await getTestContext();
|
||||||
|
|
||||||
await userEvent.tab();
|
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('Parent Node')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
|
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
|
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('and pressing arrow right on the menu trigger button', () => {
|
describe('and pressing arrow right on the menu trigger button', () => {
|
||||||
it('then the correct menu item should receive focus', async () => {
|
it('then the correct menu item should receive focus', async () => {
|
||||||
await getTestContext();
|
await getTestContext(undefined, undefined, true);
|
||||||
|
|
||||||
await userEvent.tab();
|
await userEvent.tab();
|
||||||
expect(screen.getAllByRole('menuitem')).toHaveLength(3);
|
expect(screen.getAllByRole('menuitem')).toHaveLength(3);
|
||||||
@ -125,32 +147,36 @@ describe('NavBarItem', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('and hovering over the menu trigger link', () => {
|
describe('and hovering over the menu trigger link', () => {
|
||||||
it('then the menu items should be visible', async () => {
|
it('sets the correct menuIdOpen', async () => {
|
||||||
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } });
|
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } });
|
||||||
|
|
||||||
await userEvent.hover(screen.getByRole('link'));
|
await userEvent.hover(screen.getByRole('link'));
|
||||||
|
|
||||||
expect(screen.getByText('Parent Node')).toBeInTheDocument();
|
expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id);
|
||||||
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('and tabbing to the menu trigger link', () => {
|
describe('and tabbing to the menu trigger link', () => {
|
||||||
it('then the menu items should be visible', async () => {
|
it('sets the correct menuIdOpen', async () => {
|
||||||
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } });
|
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } });
|
||||||
|
|
||||||
await userEvent.tab();
|
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('Parent Node')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
|
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
|
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('and pressing arrow right on the menu trigger link', () => {
|
describe('and pressing arrow right on the menu trigger link', () => {
|
||||||
it('then the correct menu item should receive focus', async () => {
|
it('then the correct menu item should receive focus', async () => {
|
||||||
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } });
|
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }, undefined, true);
|
||||||
|
|
||||||
await userEvent.tab();
|
await userEvent.tab();
|
||||||
expect(screen.getAllByRole('link')[0]).toHaveFocus();
|
expect(screen.getAllByRole('link')[0]).toHaveFocus();
|
||||||
@ -170,7 +196,7 @@ describe('NavBarItem', () => {
|
|||||||
|
|
||||||
describe('and pressing arrow left on a menu item', () => {
|
describe('and pressing arrow left on a menu item', () => {
|
||||||
it('then the nav bar item should receive focus', async () => {
|
it('then the nav bar item should receive focus', async () => {
|
||||||
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } });
|
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }, undefined, true);
|
||||||
|
|
||||||
await userEvent.tab();
|
await userEvent.tab();
|
||||||
await userEvent.keyboard('{ArrowRight}');
|
await userEvent.keyboard('{ArrowRight}');
|
||||||
@ -199,15 +225,10 @@ describe('NavBarItem', () => {
|
|||||||
children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }],
|
children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/grafana'
|
'/grafana',
|
||||||
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.hover(screen.getByRole('link'));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Parent Node')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('New')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText('New'));
|
await userEvent.click(screen.getByText('New'));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(pushMock).toHaveBeenCalledTimes(1);
|
expect(pushMock).toHaveBeenCalledTimes(1);
|
||||||
@ -218,19 +239,17 @@ describe('NavBarItem', () => {
|
|||||||
|
|
||||||
describe('when appSubUrl is not configured and user clicks on menuitem link', () => {
|
describe('when appSubUrl is not configured and user clicks on menuitem link', () => {
|
||||||
it('then location service should be called with correct url', async () => {
|
it('then location service should be called with correct url', async () => {
|
||||||
const { pushMock } = await getTestContext({
|
const { pushMock } = await getTestContext(
|
||||||
|
{
|
||||||
link: {
|
link: {
|
||||||
...defaults.link,
|
...defaults.link,
|
||||||
url: 'https://www.grafana.com',
|
url: 'https://www.grafana.com',
|
||||||
children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }],
|
children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }],
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
undefined,
|
||||||
await userEvent.hover(screen.getByRole('link'));
|
true
|
||||||
await waitFor(() => {
|
);
|
||||||
expect(screen.getByText('Parent Node')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('New')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText('New'));
|
await userEvent.click(screen.getByText('New'));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Item } from '@react-stately/collections';
|
import { Item } from '@react-stately/collections';
|
||||||
import React, { ReactNode } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2, locationUtil, NavMenuItemType, NavModelItem } from '@grafana/data';
|
import { GrafanaTheme2, locationUtil, NavMenuItemType, NavModelItem } from '@grafana/data';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
@ -9,31 +9,24 @@ import { IconName, useTheme2 } from '@grafana/ui';
|
|||||||
|
|
||||||
import { NavBarItemMenu } from './NavBarItemMenu';
|
import { NavBarItemMenu } from './NavBarItemMenu';
|
||||||
import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger';
|
import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger';
|
||||||
import { getNavBarItemWithoutMenuStyles, NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
import { getNavBarItemWithoutMenuStyles } from './NavBarItemWithoutMenu';
|
||||||
import { NavBarMenuItem } from './NavBarMenuItem';
|
import { NavBarMenuItem } from './NavBarMenuItem';
|
||||||
|
import { useNavBarContext } from './context';
|
||||||
import menuItemTranslations from './navBarItem-translations';
|
import menuItemTranslations from './navBarItem-translations';
|
||||||
import { getNavModelItemKey } from './utils';
|
import { getNavModelItemKey } from './utils';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
reverseMenuDirection?: boolean;
|
reverseMenuDirection?: boolean;
|
||||||
showMenu?: boolean;
|
|
||||||
link: NavModelItem;
|
link: NavModelItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavBarItem = ({
|
const NavBarItem = ({ isActive = false, className, reverseMenuDirection = false, link }: Props) => {
|
||||||
isActive = false,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
reverseMenuDirection = false,
|
|
||||||
showMenu = true,
|
|
||||||
link,
|
|
||||||
}: Props) => {
|
|
||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const menuItems = link.children ?? [];
|
const menuItems = link.children ?? [];
|
||||||
|
const { menuIdOpen } = useNavBarContext();
|
||||||
|
|
||||||
// Spreading `menuItems` here as otherwise we'd be mutating props
|
// Spreading `menuItems` here as otherwise we'd be mutating props
|
||||||
const menuItemsSorted = reverseMenuDirection ? [...menuItems].reverse() : menuItems;
|
const menuItemsSorted = reverseMenuDirection ? [...menuItems].reverse() : menuItems;
|
||||||
@ -51,24 +44,28 @@ const NavBarItem = ({
|
|||||||
|
|
||||||
const onNavigate = (item: NavModelItem) => {
|
const onNavigate = (item: NavModelItem) => {
|
||||||
const { url, target, onClick } = item;
|
const { url, target, onClick } = item;
|
||||||
if (!url) {
|
|
||||||
onClick?.();
|
onClick?.();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (url) {
|
||||||
if (!target && url.startsWith('/')) {
|
if (!target && url.startsWith('/')) {
|
||||||
locationService.push(locationUtil.stripBaseFromUrl(url));
|
locationService.push(locationUtil.stripBaseFromUrl(url));
|
||||||
} else {
|
} else {
|
||||||
window.open(url, target);
|
window.open(url, target);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const translationKey = link.id && menuItemTranslations[link.id];
|
const translationKey = link.id && menuItemTranslations[link.id];
|
||||||
const linkText = translationKey ? i18n._(translationKey) : link.text;
|
const linkText = translationKey ? i18n._(translationKey) : link.text;
|
||||||
|
|
||||||
return showMenu ? (
|
return (
|
||||||
<li className={cx(styles.container, className)}>
|
<li className={cx(styles.container, { [styles.containerHover]: section.id === menuIdOpen }, className)}>
|
||||||
<NavBarItemMenuTrigger item={section} isActive={isActive} label={linkText}>
|
<NavBarItemMenuTrigger
|
||||||
|
item={section}
|
||||||
|
isActive={isActive}
|
||||||
|
label={linkText}
|
||||||
|
reverseMenuDirection={reverseMenuDirection}
|
||||||
|
>
|
||||||
<NavBarItemMenu
|
<NavBarItemMenu
|
||||||
items={items}
|
items={items}
|
||||||
reverseMenuDirection={reverseMenuDirection}
|
reverseMenuDirection={reverseMenuDirection}
|
||||||
@ -80,31 +77,19 @@ const NavBarItem = ({
|
|||||||
{(item: NavModelItem) => {
|
{(item: NavModelItem) => {
|
||||||
const translationKey = item.id && menuItemTranslations[item.id];
|
const translationKey = item.id && menuItemTranslations[item.id];
|
||||||
const itemText = translationKey ? i18n._(translationKey) : item.text;
|
const itemText = translationKey ? i18n._(translationKey) : item.text;
|
||||||
|
const isSection = item.menuItemType === NavMenuItemType.Section;
|
||||||
if (item.menuItemType === NavMenuItemType.Section) {
|
const icon = item.showIconInNavbar && !isSection ? (item.icon as IconName) : undefined;
|
||||||
return (
|
|
||||||
<Item key={getNavModelItemKey(item)} textValue={item.text}>
|
|
||||||
<NavBarMenuItem
|
|
||||||
target={item.target}
|
|
||||||
text={itemText}
|
|
||||||
url={item.url}
|
|
||||||
onClick={item.onClick}
|
|
||||||
styleOverrides={styles.header}
|
|
||||||
/>
|
|
||||||
</Item>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item key={getNavModelItemKey(item)} textValue={item.text}>
|
<Item key={getNavModelItemKey(item)} textValue={item.text}>
|
||||||
<NavBarMenuItem
|
<NavBarMenuItem
|
||||||
isDivider={item.divider}
|
isDivider={!isSection && item.divider}
|
||||||
icon={item.icon as IconName}
|
icon={icon}
|
||||||
onClick={item.onClick}
|
|
||||||
target={item.target}
|
target={item.target}
|
||||||
text={itemText}
|
text={itemText}
|
||||||
url={item.url}
|
url={item.url}
|
||||||
styleOverrides={styles.item}
|
onClick={item.onClick}
|
||||||
|
styleOverrides={cx(styles.primaryText, { [styles.header]: isSection })}
|
||||||
/>
|
/>
|
||||||
</Item>
|
</Item>
|
||||||
);
|
);
|
||||||
@ -112,18 +97,6 @@ const NavBarItem = ({
|
|||||||
</NavBarItemMenu>
|
</NavBarItemMenu>
|
||||||
</NavBarItemMenuTrigger>
|
</NavBarItemMenuTrigger>
|
||||||
</li>
|
</li>
|
||||||
) : (
|
|
||||||
<NavBarItemWithoutMenu
|
|
||||||
label={link.text}
|
|
||||||
className={className}
|
|
||||||
isActive={isActive}
|
|
||||||
url={link.url}
|
|
||||||
onClick={link.onClick}
|
|
||||||
target={link.target}
|
|
||||||
highlightText={link.highlightText}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</NavBarItemWithoutMenu>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -131,16 +104,19 @@ export default NavBarItem;
|
|||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2, adjustHeightForBorder: boolean, isActive?: boolean) => ({
|
const getStyles = (theme: GrafanaTheme2, adjustHeightForBorder: boolean, isActive?: boolean) => ({
|
||||||
...getNavBarItemWithoutMenuStyles(theme, isActive),
|
...getNavBarItemWithoutMenuStyles(theme, isActive),
|
||||||
header: css`
|
containerHover: css({
|
||||||
color: ${theme.colors.text.primary};
|
backgroundColor: theme.colors.action.hover,
|
||||||
height: ${theme.components.sidemenu.width - (adjustHeightForBorder ? 2 : 1)}px;
|
color: theme.colors.text.primary,
|
||||||
font-size: ${theme.typography.h4.fontSize};
|
}),
|
||||||
font-weight: ${theme.typography.h4.fontWeight};
|
primaryText: css({
|
||||||
padding: ${theme.spacing(1)} ${theme.spacing(2)};
|
color: theme.colors.text.primary,
|
||||||
white-space: nowrap;
|
}),
|
||||||
width: 100%;
|
header: css({
|
||||||
`,
|
height: `calc(${theme.spacing(6)} - ${adjustHeightForBorder ? 2 : 1}px)`,
|
||||||
item: css`
|
fontSize: theme.typography.h4.fontSize,
|
||||||
color: ${theme.colors.text.primary};
|
fontWeight: theme.typography.h4.fontWeight,
|
||||||
`,
|
padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
width: '100%',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
@ -9,6 +9,7 @@ import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data';
|
|||||||
import { useTheme2 } from '@grafana/ui';
|
import { useTheme2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { NavBarItemMenuItem } from './NavBarItemMenuItem';
|
import { NavBarItemMenuItem } from './NavBarItemMenuItem';
|
||||||
|
import { NavBarScrollContainer } from './NavBarScrollContainer';
|
||||||
import { useNavBarItemMenuContext } from './context';
|
import { useNavBarItemMenuContext } from './context';
|
||||||
import { getNavModelItemKey } from './utils';
|
import { getNavModelItemKey } from './utils';
|
||||||
|
|
||||||
@ -51,9 +52,7 @@ export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null
|
|||||||
|
|
||||||
const menuSubTitle = section.value.subTitle;
|
const menuSubTitle = section.value.subTitle;
|
||||||
|
|
||||||
const sectionComponent = (
|
const headerComponent = <NavBarItemMenuItem key={section.key} item={section} state={state} onNavigate={onNavigate} />;
|
||||||
<NavBarItemMenuItem key={section.key} item={section} state={state} onNavigate={onNavigate} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const itemComponents = items.map((item) => (
|
const itemComponents = items.map((item) => (
|
||||||
<NavBarItemMenuItem key={getNavModelItemKey(item.value)} item={item} state={state} onNavigate={onNavigate} />
|
<NavBarItemMenuItem key={getNavModelItemKey(item.value)} item={item} state={state} onNavigate={onNavigate} />
|
||||||
@ -65,7 +64,14 @@ export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
||||||
const menu = [sectionComponent, itemComponents, subTitleComponent];
|
const contents = [itemComponents, subTitleComponent];
|
||||||
|
const contentComponent = (
|
||||||
|
<NavBarScrollContainer key="scrollContainer">
|
||||||
|
{reverseMenuDirection ? contents.reverse() : contents}
|
||||||
|
</NavBarScrollContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
const menu = [headerComponent, contentComponent];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={styles.menu} ref={ref} {...mergeProps(menuProps, contextMenuProps)} tabIndex={menuHasFocus ? 0 : -1}>
|
<ul className={styles.menu} ref={ref} {...mergeProps(menuProps, contextMenuProps)} tabIndex={menuHasFocus ? 0 : -1}>
|
||||||
@ -79,15 +85,13 @@ function getStyles(theme: GrafanaTheme2, reverseDirection?: boolean) {
|
|||||||
menu: css`
|
menu: css`
|
||||||
background-color: ${theme.colors.background.primary};
|
background-color: ${theme.colors.background.primary};
|
||||||
border: 1px solid ${theme.components.panel.borderColor};
|
border: 1px solid ${theme.components.panel.borderColor};
|
||||||
bottom: ${reverseDirection ? 0 : 'auto'};
|
|
||||||
box-shadow: ${theme.shadows.z3};
|
box-shadow: ${theme.shadows.z3};
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
left: 100%;
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
max-height: 400px;
|
||||||
|
max-width: 300px;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
position: absolute;
|
|
||||||
top: ${reverseDirection ? 'auto' : 0};
|
|
||||||
transition: ${theme.transitions.create('opacity')};
|
transition: ${theme.transitions.create('opacity')};
|
||||||
z-index: ${theme.zIndex.sidemenu};
|
z-index: ${theme.zIndex.sidemenu};
|
||||||
`,
|
`,
|
||||||
|
@ -9,7 +9,7 @@ import React, { ReactElement, useRef, useState } from 'react';
|
|||||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||||
import { useTheme2 } from '@grafana/ui';
|
import { useTheme2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { useNavBarItemMenuContext } from './context';
|
import { useNavBarItemMenuContext, useNavBarContext } from './context';
|
||||||
|
|
||||||
export interface NavBarItemMenuItemProps {
|
export interface NavBarItemMenuItemProps {
|
||||||
item: Node<NavModelItem>;
|
item: Node<NavModelItem>;
|
||||||
@ -19,6 +19,7 @@ export interface NavBarItemMenuItemProps {
|
|||||||
|
|
||||||
export function NavBarItemMenuItem({ item, state, onNavigate }: NavBarItemMenuItemProps): ReactElement {
|
export function NavBarItemMenuItem({ item, state, onNavigate }: NavBarItemMenuItemProps): ReactElement {
|
||||||
const { onClose, onLeft } = useNavBarItemMenuContext();
|
const { onClose, onLeft } = useNavBarItemMenuContext();
|
||||||
|
const { setMenuIdOpen } = useNavBarContext();
|
||||||
const { key, rendered } = item;
|
const { key, rendered } = item;
|
||||||
const ref = useRef<HTMLLIElement>(null);
|
const ref = useRef<HTMLLIElement>(null);
|
||||||
const isDisabled = state.disabledKeys.has(key);
|
const isDisabled = state.disabledKeys.has(key);
|
||||||
@ -30,6 +31,7 @@ export function NavBarItemMenuItem({ item, state, onNavigate }: NavBarItemMenuIt
|
|||||||
const isSection = item.value.menuItemType === 'section';
|
const isSection = item.value.menuItemType === 'section';
|
||||||
const styles = getStyles(theme, isFocused, isSection);
|
const styles = getStyles(theme, isFocused, isSection);
|
||||||
const onAction = () => {
|
const onAction = () => {
|
||||||
|
setMenuIdOpen(undefined);
|
||||||
onNavigate(item.value);
|
onNavigate(item.value);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
@ -4,7 +4,7 @@ import { useDialog } from '@react-aria/dialog';
|
|||||||
import { FocusScope } from '@react-aria/focus';
|
import { FocusScope } from '@react-aria/focus';
|
||||||
import { useFocusWithin, useHover, useKeyboard } from '@react-aria/interactions';
|
import { useFocusWithin, useHover, useKeyboard } from '@react-aria/interactions';
|
||||||
import { useMenuTrigger } from '@react-aria/menu';
|
import { useMenuTrigger } from '@react-aria/menu';
|
||||||
import { DismissButton, useOverlay } from '@react-aria/overlays';
|
import { DismissButton, OverlayContainer, useOverlay, useOverlayPosition } from '@react-aria/overlays';
|
||||||
import { useMenuTriggerState } from '@react-stately/menu';
|
import { useMenuTriggerState } from '@react-stately/menu';
|
||||||
import { MenuTriggerProps } from '@react-types/menu';
|
import { MenuTriggerProps } from '@react-types/menu';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
@ -13,19 +13,22 @@ import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
|||||||
import { reportExperimentView } from '@grafana/runtime';
|
import { reportExperimentView } from '@grafana/runtime';
|
||||||
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
|
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { getNavMenuPortalContainer } from './NavBarMenuPortalContainer';
|
||||||
import { NavFeatureHighlight } from './NavFeatureHighlight';
|
import { NavFeatureHighlight } from './NavFeatureHighlight';
|
||||||
import { NavBarItemMenuContext } from './context';
|
import { NavBarItemMenuContext, useNavBarContext } from './context';
|
||||||
|
|
||||||
export interface NavBarItemMenuTriggerProps extends MenuTriggerProps {
|
export interface NavBarItemMenuTriggerProps extends MenuTriggerProps {
|
||||||
children: ReactElement;
|
children: ReactElement;
|
||||||
item: NavModelItem;
|
item: NavModelItem;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
|
reverseMenuDirection: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactElement {
|
export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactElement {
|
||||||
const { item, isActive, label, children: menu, ...rest } = props;
|
const { item, isActive, label, children: menu, reverseMenuDirection, ...rest } = props;
|
||||||
const [menuHasFocus, setMenuHasFocus] = useState(false);
|
const [menuHasFocus, setMenuHasFocus] = useState(false);
|
||||||
|
const { menuIdOpen, setMenuIdOpen } = useNavBarContext();
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = getStyles(theme, isActive);
|
const styles = getStyles(theme, isActive);
|
||||||
|
|
||||||
@ -46,23 +49,23 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
|||||||
onHoverChange: (isHovering) => {
|
onHoverChange: (isHovering) => {
|
||||||
if (isHovering) {
|
if (isHovering) {
|
||||||
state.open();
|
state.open();
|
||||||
|
setMenuIdOpen(item.id);
|
||||||
} else {
|
} else {
|
||||||
state.close();
|
state.close();
|
||||||
|
setMenuIdOpen(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { focusWithinProps } = useFocusWithin({
|
useEffect(() => {
|
||||||
onFocusWithinChange: (isFocused) => {
|
// close the menu when changing submenus
|
||||||
if (isFocused) {
|
if (menuIdOpen !== item.id) {
|
||||||
state.open();
|
|
||||||
}
|
|
||||||
if (!isFocused) {
|
|
||||||
state.close();
|
state.close();
|
||||||
setMenuHasFocus(false);
|
setMenuHasFocus(false);
|
||||||
|
} else {
|
||||||
|
state.open();
|
||||||
}
|
}
|
||||||
},
|
}, [menuIdOpen, state, item.id]);
|
||||||
});
|
|
||||||
|
|
||||||
const { keyboardProps } = useKeyboard({
|
const { keyboardProps } = useKeyboard({
|
||||||
onKeyDown: (e) => {
|
onKeyDown: (e) => {
|
||||||
@ -70,9 +73,13 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
|||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
if (!state.isOpen) {
|
if (!state.isOpen) {
|
||||||
state.open();
|
state.open();
|
||||||
|
setMenuIdOpen(item.id);
|
||||||
}
|
}
|
||||||
setMenuHasFocus(true);
|
setMenuHasFocus(true);
|
||||||
break;
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
setMenuIdOpen(undefined);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -95,6 +102,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
|||||||
className={styles.element}
|
className={styles.element}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
{...keyboardProps}
|
{...keyboardProps}
|
||||||
|
{...hoverProps}
|
||||||
ref={ref as React.RefObject<HTMLButtonElement>}
|
ref={ref as React.RefObject<HTMLButtonElement>}
|
||||||
onClick={item?.onClick}
|
onClick={item?.onClick}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
@ -109,6 +117,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
|||||||
<Link
|
<Link
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
{...keyboardProps}
|
{...keyboardProps}
|
||||||
|
{...hoverProps}
|
||||||
ref={ref as React.RefObject<HTMLAnchorElement>}
|
ref={ref as React.RefObject<HTMLAnchorElement>}
|
||||||
href={item.url}
|
href={item.url}
|
||||||
target={item.target}
|
target={item.target}
|
||||||
@ -125,6 +134,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
|||||||
onClick={item?.onClick}
|
onClick={item?.onClick}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
{...keyboardProps}
|
{...keyboardProps}
|
||||||
|
{...hoverProps}
|
||||||
ref={ref as React.RefObject<HTMLAnchorElement>}
|
ref={ref as React.RefObject<HTMLAnchorElement>}
|
||||||
className={styles.element}
|
className={styles.element}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
@ -134,21 +144,52 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlayRef = React.useRef(null);
|
const overlayRef = React.useRef<HTMLDivElement>(null);
|
||||||
const { dialogProps } = useDialog({}, overlayRef);
|
const { dialogProps } = useDialog({}, overlayRef);
|
||||||
const { overlayProps } = useOverlay(
|
const { overlayProps } = useOverlay(
|
||||||
{
|
{
|
||||||
onClose: () => state.close(),
|
onClose: () => {
|
||||||
|
state.close();
|
||||||
|
setMenuIdOpen(undefined);
|
||||||
|
},
|
||||||
isOpen: state.isOpen,
|
isOpen: state.isOpen,
|
||||||
isDismissable: true,
|
isDismissable: true,
|
||||||
},
|
},
|
||||||
overlayRef
|
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 (
|
return (
|
||||||
<div className={cx(styles.element, 'dropdown')} {...focusWithinProps} {...hoverProps}>
|
<div className={cx(styles.element, 'dropdown')} {...focusWithinProps}>
|
||||||
{element}
|
{element}
|
||||||
{state.isOpen && (
|
{state.isOpen && (
|
||||||
|
<OverlayContainer portalContainer={getNavMenuPortalContainer()}>
|
||||||
<NavBarItemMenuContext.Provider
|
<NavBarItemMenuContext.Provider
|
||||||
value={{
|
value={{
|
||||||
menuProps,
|
menuProps,
|
||||||
@ -161,58 +202,59 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FocusScope restoreFocus>
|
<FocusScope restoreFocus>
|
||||||
<div {...overlayProps} {...dialogProps} ref={overlayRef}>
|
<div {...overlayProps} {...overlayPositionProps} {...dialogProps} {...hoverProps} ref={overlayRef}>
|
||||||
<DismissButton onDismiss={() => state.close()} />
|
<DismissButton onDismiss={() => state.close()} />
|
||||||
{menu}
|
{menu}
|
||||||
<DismissButton onDismiss={() => state.close()} />
|
<DismissButton onDismiss={() => state.close()} />
|
||||||
</div>
|
</div>
|
||||||
</FocusScope>
|
</FocusScope>
|
||||||
</NavBarItemMenuContext.Provider>
|
</NavBarItemMenuContext.Provider>
|
||||||
|
</OverlayContainer>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2, isActive?: boolean) => ({
|
const getStyles = (theme: GrafanaTheme2, isActive?: boolean) => ({
|
||||||
element: css`
|
element: css({
|
||||||
background-color: transparent;
|
backgroundColor: 'transparent',
|
||||||
border: none;
|
border: 'none',
|
||||||
color: inherit;
|
color: 'inherit',
|
||||||
display: block;
|
display: 'grid',
|
||||||
line-height: ${theme.components.sidemenu.width}px;
|
padding: 0,
|
||||||
padding: 0;
|
placeContent: 'center',
|
||||||
text-align: center;
|
height: theme.spacing(6),
|
||||||
width: ${theme.components.sidemenu.width}px;
|
width: theme.spacing(7),
|
||||||
|
|
||||||
&::before {
|
'&::before': {
|
||||||
display: ${isActive ? 'block' : 'none'};
|
display: isActive ? 'block' : 'none',
|
||||||
content: ' ';
|
content: '" "',
|
||||||
position: absolute;
|
position: 'absolute',
|
||||||
left: 0;
|
left: theme.spacing(1),
|
||||||
top: 0;
|
top: theme.spacing(1.5),
|
||||||
bottom: 0;
|
bottom: theme.spacing(1.5),
|
||||||
width: 4px;
|
width: theme.spacing(0.5),
|
||||||
border-radius: 2px;
|
borderRadius: theme.shape.borderRadius(1),
|
||||||
background-image: ${theme.colors.gradients.brandVertical};
|
backgroundImage: theme.colors.gradients.brandVertical,
|
||||||
}
|
},
|
||||||
|
|
||||||
&:focus-visible {
|
'&:focus-visible': {
|
||||||
background-color: ${theme.colors.action.hover};
|
backgroundColor: theme.colors.action.hover,
|
||||||
box-shadow: none;
|
boxShadow: 'none',
|
||||||
color: ${theme.colors.text.primary};
|
color: theme.colors.text.primary,
|
||||||
outline: 2px solid ${theme.colors.primary.main};
|
outline: `${theme.shape.borderRadius(1)} solid ${theme.colors.primary.main}`,
|
||||||
outline-offset: -2px;
|
outlineOffset: `-${theme.shape.borderRadius(1)}`,
|
||||||
transition: none;
|
transition: 'none',
|
||||||
}
|
},
|
||||||
`,
|
}),
|
||||||
icon: css`
|
icon: css({
|
||||||
height: 100%;
|
height: '100%',
|
||||||
width: 100%;
|
width: '100%',
|
||||||
|
|
||||||
img {
|
img: {
|
||||||
border-radius: 50%;
|
borderRadius: '50%',
|
||||||
height: ${theme.spacing(3)};
|
height: theme.spacing(3),
|
||||||
width: ${theme.spacing(3)};
|
width: theme.spacing(3),
|
||||||
}
|
},
|
||||||
`,
|
}),
|
||||||
});
|
});
|
||||||
|
@ -10,6 +10,7 @@ export interface NavBarItemWithoutMenuProps {
|
|||||||
label: string;
|
label: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
elClassName?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
target?: string;
|
target?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
@ -20,113 +21,103 @@ export interface NavBarItemWithoutMenuProps {
|
|||||||
export function NavBarItemWithoutMenu({
|
export function NavBarItemWithoutMenu({
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
className,
|
|
||||||
url,
|
url,
|
||||||
target,
|
target,
|
||||||
isActive = false,
|
isActive = false,
|
||||||
onClick,
|
onClick,
|
||||||
highlightText,
|
highlightText,
|
||||||
|
className,
|
||||||
|
elClassName,
|
||||||
}: NavBarItemWithoutMenuProps) {
|
}: NavBarItemWithoutMenuProps) {
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = getNavBarItemWithoutMenuStyles(theme, isActive);
|
const styles = getNavBarItemWithoutMenuStyles(theme, isActive);
|
||||||
|
|
||||||
const content = highlightText ? (
|
const content = highlightText ? (
|
||||||
<NavFeatureHighlight>
|
<NavFeatureHighlight>
|
||||||
<span className={styles.icon}>{children}</span>
|
<div className={styles.icon}>{children}</div>
|
||||||
</NavFeatureHighlight>
|
</NavFeatureHighlight>
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.icon}>{children}</span>
|
<div className={styles.icon}>{children}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const elStyle = cx(styles.element, elClassName);
|
||||||
|
|
||||||
|
const renderContents = () => {
|
||||||
|
if (!url) {
|
||||||
return (
|
return (
|
||||||
<li className={cx(styles.container, className)}>
|
<button className={elStyle} onClick={onClick} aria-label={label}>
|
||||||
{!url && (
|
|
||||||
<button className={styles.element} onClick={onClick} aria-label={label}>
|
|
||||||
{content}
|
{content}
|
||||||
</button>
|
</button>
|
||||||
)}
|
);
|
||||||
{url && (
|
} else if (!target && url.startsWith('/')) {
|
||||||
<>
|
return (
|
||||||
{!target && url.startsWith('/') ? (
|
<Link className={elStyle} href={url} target={target} aria-label={label} onClick={onClick} aria-haspopup="true">
|
||||||
<Link
|
|
||||||
className={styles.element}
|
|
||||||
href={url}
|
|
||||||
target={target}
|
|
||||||
aria-label={label}
|
|
||||||
onClick={onClick}
|
|
||||||
aria-haspopup="true"
|
|
||||||
>
|
|
||||||
{content}
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
);
|
||||||
<a href={url} target={target} className={styles.element} onClick={onClick} aria-label={label}>
|
} else {
|
||||||
|
return (
|
||||||
|
<a href={url} target={target} className={elStyle} onClick={onClick} aria-label={label}>
|
||||||
{content}
|
{content}
|
||||||
</a>
|
</a>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div className={cx(styles.container, className)}>{renderContents()}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNavBarItemWithoutMenuStyles(theme: GrafanaTheme2, isActive?: boolean) {
|
export function getNavBarItemWithoutMenuStyles(theme: GrafanaTheme2, isActive?: boolean) {
|
||||||
return {
|
return {
|
||||||
container: css`
|
container: css({
|
||||||
position: relative;
|
position: 'relative',
|
||||||
color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary};
|
color: isActive ? theme.colors.text.primary : theme.colors.text.secondary,
|
||||||
|
display: 'grid',
|
||||||
|
|
||||||
&:hover {
|
'&:hover': {
|
||||||
background-color: ${theme.colors.action.hover};
|
backgroundColor: theme.colors.action.hover,
|
||||||
color: ${theme.colors.text.primary};
|
color: theme.colors.text.primary,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
element: css({
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
display: 'block',
|
||||||
|
padding: 0,
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
|
||||||
// TODO don't use a hardcoded class here, use isVisible in NavBarDropdown
|
'&::before': {
|
||||||
.navbar-dropdown {
|
display: isActive ? 'block' : 'none',
|
||||||
opacity: 1;
|
content: "' '",
|
||||||
visibility: visible;
|
position: 'absolute',
|
||||||
}
|
left: theme.spacing(1),
|
||||||
}
|
top: theme.spacing(1.5),
|
||||||
`,
|
bottom: theme.spacing(1.5),
|
||||||
element: css`
|
width: theme.spacing(0.5),
|
||||||
background-color: transparent;
|
borderRadius: theme.shape.borderRadius(1),
|
||||||
border: none;
|
backgroundImage: theme.colors.gradients.brandVertical,
|
||||||
color: inherit;
|
},
|
||||||
display: block;
|
|
||||||
line-height: ${theme.components.sidemenu.width}px;
|
|
||||||
padding: 0;
|
|
||||||
text-align: center;
|
|
||||||
width: ${theme.components.sidemenu.width}px;
|
|
||||||
|
|
||||||
&::before {
|
'&:focus-visible': {
|
||||||
display: ${isActive ? 'block' : 'none'};
|
backgroundColor: theme.colors.action.hover,
|
||||||
content: ' ';
|
boxShadow: 'none',
|
||||||
position: absolute;
|
color: theme.colors.text.primary,
|
||||||
left: 0;
|
outline: `${theme.shape.borderRadius(1)} solid ${theme.colors.primary.main}`,
|
||||||
top: 0;
|
outlineOffset: `-${theme.shape.borderRadius(1)}`,
|
||||||
bottom: 0;
|
transition: 'none',
|
||||||
width: 4px;
|
},
|
||||||
border-radius: 2px;
|
}),
|
||||||
background-image: ${theme.colors.gradients.brandVertical};
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
icon: css({
|
||||||
background-color: ${theme.colors.action.hover};
|
height: '100%',
|
||||||
box-shadow: none;
|
width: '100%',
|
||||||
color: ${theme.colors.text.primary};
|
|
||||||
outline: 2px solid ${theme.colors.primary.main};
|
|
||||||
outline-offset: -2px;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
|
|
||||||
icon: css`
|
img: {
|
||||||
height: 100%;
|
borderRadius: '50%',
|
||||||
width: 100%;
|
height: theme.spacing(3),
|
||||||
|
width: theme.spacing(3),
|
||||||
img {
|
},
|
||||||
border-radius: 50%;
|
}),
|
||||||
height: ${theme.spacing(3)};
|
|
||||||
width: ${theme.spacing(3)};
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -7,12 +7,26 @@ import { NavModelItem } from '@grafana/data';
|
|||||||
|
|
||||||
import { NavBarMenu } from './NavBarMenu';
|
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', () => {
|
describe('NavBarMenu', () => {
|
||||||
const mockOnClose = jest.fn();
|
const mockOnClose = jest.fn();
|
||||||
const mockNavItems: NavModelItem[] = [];
|
const mockNavItems: NavModelItem[] = [];
|
||||||
|
const mockSetMenuAnimationInProgress = jest.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
render(<NavBarMenu onClose={mockOnClose} navItems={mockNavItems} />);
|
render(
|
||||||
|
<NavBarMenu
|
||||||
|
isOpen
|
||||||
|
onClose={mockOnClose}
|
||||||
|
navItems={mockNavItems}
|
||||||
|
setMenuAnimationInProgress={mockSetMenuAnimationInProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render the component', () => {
|
it('should render the component', () => {
|
||||||
@ -21,14 +35,20 @@ describe('NavBarMenu', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('has a close button', () => {
|
it('has a close button', () => {
|
||||||
const closeButton = screen.getByRole('button', { name: 'Close navigation menu' });
|
const closeButton = screen.getAllByRole('button', { name: 'Close navigation menu' });
|
||||||
expect(closeButton).toBeInTheDocument();
|
// 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 () => {
|
it('clicking the close button calls the onClose callback', async () => {
|
||||||
const closeButton = screen.getByRole('button', { name: 'Close navigation menu' });
|
const closeButton = screen.getAllByRole('button', { name: 'Close navigation menu' });
|
||||||
expect(closeButton).toBeInTheDocument();
|
expect(closeButton[0]).toBeInTheDocument();
|
||||||
await userEvent.click(closeButton);
|
expect(closeButton[1]).toBeInTheDocument();
|
||||||
|
await userEvent.click(closeButton[0]);
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
await userEvent.click(closeButton[1]);
|
||||||
expect(mockOnClose).toHaveBeenCalled();
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,66 +1,245 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { useDialog } from '@react-aria/dialog';
|
import { useDialog } from '@react-aria/dialog';
|
||||||
import { FocusScope } from '@react-aria/focus';
|
import { FocusScope } from '@react-aria/focus';
|
||||||
import { useOverlay } from '@react-aria/overlays';
|
import { OverlayContainer, useOverlay } from '@react-aria/overlays';
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
|
import CSSTransition from 'react-transition-group/CSSTransition';
|
||||||
|
import { useLocalStorage } from 'react-use';
|
||||||
|
|
||||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||||
import { CustomScrollbar, Icon, IconButton, IconName, useTheme2 } from '@grafana/ui';
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
|
import { CollapsableSection, CustomScrollbar, Icon, IconButton, IconName, useStyles2, useTheme2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { Branding } from '../Branding/Branding';
|
||||||
|
|
||||||
|
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
||||||
import { NavBarMenuItem } from './NavBarMenuItem';
|
import { NavBarMenuItem } from './NavBarMenuItem';
|
||||||
|
import { NavBarToggle } from './NavBarToggle';
|
||||||
|
import { NavFeatureHighlight } from './NavFeatureHighlight';
|
||||||
|
import { isMatchOrChildMatch } from './utils';
|
||||||
|
|
||||||
|
const MENU_WIDTH = '350px';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
activeItem?: NavModelItem;
|
activeItem?: NavModelItem;
|
||||||
|
isOpen: boolean;
|
||||||
navItems: NavModelItem[];
|
navItems: NavModelItem[];
|
||||||
|
setMenuAnimationInProgress: (isInProgress: boolean) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
|
export function NavBarMenu({ activeItem, isOpen, navItems, onClose, setMenuAnimationInProgress }: Props) {
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = getStyles(theme);
|
const styles = getStyles(theme);
|
||||||
|
const ANIMATION_DURATION = theme.transitions.duration.standard;
|
||||||
|
const animStyles = getAnimStyles(theme, ANIMATION_DURATION);
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const { dialogProps } = useDialog({}, ref);
|
const { dialogProps } = useDialog({}, ref);
|
||||||
const { overlayProps } = useOverlay(
|
const { overlayProps, underlayProps } = useOverlay(
|
||||||
{
|
{
|
||||||
isDismissable: true,
|
isDismissable: true,
|
||||||
isOpen: true,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<OverlayContainer>
|
||||||
<FocusScope contain restoreFocus autoFocus>
|
<FocusScope contain restoreFocus autoFocus>
|
||||||
<div data-testid="navbarmenu" className={styles.container} ref={ref} {...overlayProps} {...dialogProps}>
|
<CSSTransition
|
||||||
<div className={styles.header}>
|
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" />
|
<Icon name="bars" size="xl" />
|
||||||
<IconButton aria-label="Close navigation menu" name="times" onClick={onClose} size="xl" variant="secondary" />
|
<IconButton
|
||||||
|
aria-label="Close navigation menu"
|
||||||
|
name="times"
|
||||||
|
onClick={onClose}
|
||||||
|
size="xl"
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<nav className={styles.content}>
|
<NavBarToggle
|
||||||
<CustomScrollbar>
|
className={styles.menuCollapseIcon}
|
||||||
<ul>
|
isExpanded={isOpen}
|
||||||
{navItems.map((link) => (
|
|
||||||
<div className={styles.section} key={link.text}>
|
|
||||||
<NavBarMenuItem
|
|
||||||
isActive={activeItem === link}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
link.onClick?.();
|
reportInteraction('grafana_navigation_collapsed');
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
styleOverrides={styles.sectionHeader}
|
|
||||||
target={link.target}
|
|
||||||
text={link.text}
|
|
||||||
url={link.url}
|
|
||||||
isMobile={true}
|
|
||||||
/>
|
/>
|
||||||
{link.children?.map(
|
<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 appear={isOpen} in={isOpen} classNames={animStyles.backdrop} timeout={ANIMATION_DURATION}>
|
||||||
|
<div className={styles.backdrop} {...underlayProps} />
|
||||||
|
</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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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) =>
|
(childLink) =>
|
||||||
!childLink.divider && (
|
!childLink.divider && (
|
||||||
<NavBarMenuItem
|
<NavBarMenuItem
|
||||||
key={childLink.text}
|
key={`${link.text}-${childLink.text}`}
|
||||||
icon={childLink.icon as IconName}
|
|
||||||
isActive={activeItem === childLink}
|
isActive={activeItem === childLink}
|
||||||
isDivider={childLink.divider}
|
isDivider={childLink.divider}
|
||||||
|
icon={childLink.showIconInNavbar ? (childLink.icon as IconName) : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
childLink.onClick?.();
|
childLink.onClick?.();
|
||||||
onClose();
|
onClose();
|
||||||
@ -73,55 +252,199 @@ export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
</CustomScrollbar>
|
</CollapsibleNavItem>
|
||||||
</nav>
|
);
|
||||||
|
} 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>{getLinkIcon(link)}</FeatureHighlightWrapper>
|
||||||
</div>
|
</div>
|
||||||
</FocusScope>
|
<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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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>{getLinkIcon(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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
NavBarMenu.displayName = 'NavBarMenu';
|
const getCollapsibleStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
menuItem: css({
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
position: 'relative',
|
||||||
container: css`
|
display: 'grid',
|
||||||
background-color: ${theme.colors.background.canvas};
|
gridAutoFlow: 'column',
|
||||||
bottom: 0;
|
gridTemplateColumns: `${theme.spacing(7)} minmax(calc(${MENU_WIDTH} - ${theme.spacing(7)}), auto)`,
|
||||||
display: flex;
|
}),
|
||||||
flex-direction: column;
|
collapsibleMenuItem: css({
|
||||||
left: 0;
|
height: theme.spacing(6),
|
||||||
min-width: 300px;
|
width: theme.spacing(7),
|
||||||
position: fixed;
|
display: 'grid',
|
||||||
right: 0;
|
}),
|
||||||
top: 0;
|
collapsibleIcon: css({
|
||||||
|
display: 'grid',
|
||||||
${theme.breakpoints.up('md')} {
|
placeContent: 'center',
|
||||||
border-right: 1px solid ${theme.colors.border.weak};
|
}),
|
||||||
right: unset;
|
collapsibleSectionWrapper: css({
|
||||||
}
|
display: 'flex',
|
||||||
`,
|
flexGrow: 1,
|
||||||
content: css`
|
alignSelf: 'start',
|
||||||
display: flex;
|
flexDirection: 'column',
|
||||||
flex-direction: column;
|
}),
|
||||||
overflow: auto;
|
collapseWrapper: css({
|
||||||
`,
|
paddingLeft: theme.spacing(0.5),
|
||||||
header: css`
|
paddingRight: theme.spacing(4.25),
|
||||||
border-bottom: 1px solid ${theme.colors.border.weak};
|
minHeight: theme.spacing(6),
|
||||||
display: flex;
|
overflowWrap: 'anywhere',
|
||||||
justify-content: space-between;
|
alignItems: 'center',
|
||||||
padding: ${theme.spacing(2)};
|
color: theme.colors.text.secondary,
|
||||||
`,
|
'&:hover, &:focus-within': {
|
||||||
item: css`
|
backgroundColor: theme.colors.action.hover,
|
||||||
padding: ${theme.spacing(1)} ${theme.spacing(2)};
|
color: theme.colors.text.primary,
|
||||||
`,
|
},
|
||||||
section: css`
|
'&:focus-within': {
|
||||||
border-bottom: 1px solid ${theme.colors.border.weak};
|
boxShadow: 'none',
|
||||||
`,
|
outline: `2px solid ${theme.colors.primary.main}`,
|
||||||
sectionHeader: css`
|
outlineOffset: '-2px',
|
||||||
color: ${theme.colors.text.primary};
|
transition: 'none',
|
||||||
font-size: ${theme.typography.h5.fontSize};
|
},
|
||||||
padding: ${theme.spacing(1)} ${theme.spacing(2)};
|
}),
|
||||||
`,
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkIcon(link: NavModelItem) {
|
||||||
|
if (link.id === 'home') {
|
||||||
|
return <Branding.MenuLogo />;
|
||||||
|
} else if (link.icon) {
|
||||||
|
return <Icon name={link.icon as IconName} size="xl" />;
|
||||||
|
} else {
|
||||||
|
return <img src={link.img} alt={`${link.text} logo`} height="24" width="24" style={{ borderRadius: '50%' }} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
@ -29,14 +29,12 @@ export function NavBarMenuItem({
|
|||||||
isMobile = false,
|
isMobile = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = getStyles(theme, isActive, styleOverrides);
|
const styles = getStyles(theme, isActive);
|
||||||
|
const elStyle = cx(styles.element, styleOverrides);
|
||||||
const linkContent = (
|
const linkContent = (
|
||||||
<div className={styles.linkContent}>
|
<div className={styles.linkContent}>
|
||||||
<div>
|
{icon && <Icon data-testid="dropdown-child-icon" name={icon} />}
|
||||||
{icon && <Icon data-testid="dropdown-child-icon" name={icon} className={styles.icon} />}
|
<div className={styles.linkText}>{text}</div>
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
{target === '_blank' && (
|
{target === '_blank' && (
|
||||||
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} />
|
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} />
|
||||||
)}
|
)}
|
||||||
@ -44,7 +42,7 @@ export function NavBarMenuItem({
|
|||||||
);
|
);
|
||||||
|
|
||||||
let element = (
|
let element = (
|
||||||
<button className={styles.element} onClick={onClick} tabIndex={-1}>
|
<button className={elStyle} onClick={onClick} tabIndex={-1}>
|
||||||
{linkContent}
|
{linkContent}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@ -52,11 +50,11 @@ export function NavBarMenuItem({
|
|||||||
if (url) {
|
if (url) {
|
||||||
element =
|
element =
|
||||||
!target && url.startsWith('/') ? (
|
!target && url.startsWith('/') ? (
|
||||||
<Link className={styles.element} href={url} target={target} onClick={onClick} tabIndex={!isMobile ? -1 : 0}>
|
<Link className={elStyle} href={url} target={target} onClick={onClick} tabIndex={!isMobile ? -1 : 0}>
|
||||||
{linkContent}
|
{linkContent}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<a href={url} target={target} className={styles.element} onClick={onClick} tabIndex={!isMobile ? -1 : 0}>
|
<a href={url} target={target} className={elStyle} onClick={onClick} tabIndex={!isMobile ? -1 : 0}>
|
||||||
{linkContent}
|
{linkContent}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@ -79,89 +77,74 @@ export function NavBarMenuItem({
|
|||||||
|
|
||||||
NavBarMenuItem.displayName = 'NavBarMenuItem';
|
NavBarMenuItem.displayName = 'NavBarMenuItem';
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverrides: Props['styleOverrides']) => ({
|
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
|
||||||
visible: css`
|
linkContent: css({
|
||||||
color: ${theme.colors.text.primary} !important;
|
alignItems: 'center',
|
||||||
opacity: 100% !important;
|
display: 'flex',
|
||||||
`,
|
gap: '0.5rem',
|
||||||
divider: css`
|
width: '100%',
|
||||||
border-bottom: 1px solid ${theme.colors.border.weak};
|
}),
|
||||||
height: 1px;
|
linkText: css({
|
||||||
margin: ${theme.spacing(1)} 0;
|
textOverflow: 'ellipsis',
|
||||||
overflow: hidden;
|
overflow: 'hidden',
|
||||||
`,
|
whiteSpace: 'nowrap',
|
||||||
listItem: css`
|
}),
|
||||||
position: relative;
|
externalLinkIcon: css({
|
||||||
display: flex;
|
color: theme.colors.text.secondary,
|
||||||
align-items: center;
|
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,
|
'&:hover, &:focus-within': {
|
||||||
&:focus-within {
|
color: theme.colors.text.primary,
|
||||||
color: ${theme.colors.text.primary};
|
|
||||||
|
|
||||||
> *:first-child::after {
|
'> *:first-child::after': {
|
||||||
background-color: ${theme.colors.action.hover};
|
backgroundColor: theme.colors.action.hover,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
`,
|
}),
|
||||||
element: css`
|
divider: css({
|
||||||
align-items: center;
|
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||||
background: none;
|
height: '1px',
|
||||||
border: none;
|
margin: `${theme.spacing(1)} 0`,
|
||||||
color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary};
|
overflow: 'hidden',
|
||||||
display: flex;
|
}),
|
||||||
font-size: inherit;
|
|
||||||
height: 100%;
|
|
||||||
padding: 5px 12px 5px 10px;
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
box-shadow: none;
|
|
||||||
outline: 2px solid ${theme.colors.primary.main};
|
|
||||||
outline-offset: -2px;
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
display: ${isActive ? 'block' : 'none'};
|
|
||||||
content: ' ';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background-image: ${theme.colors.gradients.brandVertical};
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
position: absolute;
|
|
||||||
content: '';
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
${styleOverrides};
|
|
||||||
`,
|
|
||||||
externalLinkIcon: css`
|
|
||||||
color: ${theme.colors.text.secondary};
|
|
||||||
margin-left: ${theme.spacing(1)};
|
|
||||||
`,
|
|
||||||
icon: css`
|
|
||||||
margin-right: ${theme.spacing(1)};
|
|
||||||
`,
|
|
||||||
linkContent: css`
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
`,
|
|
||||||
});
|
});
|
||||||
|
@ -1,33 +0,0 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
|
||||||
import React, { ReactNode } from 'react';
|
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { useTheme2 } from '@grafana/ui';
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NavBarSection({ children, className }: Props) {
|
|
||||||
const theme = useTheme2();
|
|
||||||
const styles = getStyles(theme);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul data-testid="navbar-section" className={cx(styles.container, className)}>
|
|
||||||
{children}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
|
||||||
container: css`
|
|
||||||
display: none;
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
${theme.breakpoints.up('md')} {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: inherit;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
});
|
|
@ -1,148 +0,0 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Item } from '@react-stately/collections';
|
|
||||||
import React, { ReactNode } from 'react';
|
|
||||||
|
|
||||||
import { GrafanaTheme2, locationUtil, NavMenuItemType, NavModelItem } from '@grafana/data';
|
|
||||||
import { locationService } from '@grafana/runtime';
|
|
||||||
import { IconName, useTheme2 } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { useNavBarContext } from '../context';
|
|
||||||
import menuItemTranslations from '../navBarItem-translations';
|
|
||||||
import { getNavModelItemKey } from '../utils';
|
|
||||||
|
|
||||||
import { NavBarItemMenu } from './NavBarItemMenu';
|
|
||||||
import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger';
|
|
||||||
import { getNavBarItemWithoutMenuStyles, NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
|
||||||
import { NavBarMenuItem } from './NavBarMenuItem';
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
isActive?: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
reverseMenuDirection?: boolean;
|
|
||||||
showMenu?: boolean;
|
|
||||||
link: NavModelItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NavBarItem = ({
|
|
||||||
isActive = false,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
reverseMenuDirection = false,
|
|
||||||
showMenu = true,
|
|
||||||
link,
|
|
||||||
}: Props) => {
|
|
||||||
const { i18n } = useLingui();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const translationKey = link.id && menuItemTranslations[link.id];
|
|
||||||
const linkText = translationKey ? i18n._(translationKey) : link.text;
|
|
||||||
|
|
||||||
if (!showMenu) {
|
|
||||||
return (
|
|
||||||
<NavBarItemWithoutMenu
|
|
||||||
label={link.text}
|
|
||||||
className={className}
|
|
||||||
isActive={isActive}
|
|
||||||
url={link.url}
|
|
||||||
onClick={link.onClick}
|
|
||||||
target={link.target}
|
|
||||||
highlightText={link.highlightText}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</NavBarItemWithoutMenu>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<li className={cx(styles.container, { [styles.containerHover]: section.id === menuIdOpen }, className)}>
|
|
||||||
<NavBarItemMenuTrigger
|
|
||||||
item={section}
|
|
||||||
isActive={isActive}
|
|
||||||
label={linkText}
|
|
||||||
reverseMenuDirection={reverseMenuDirection}
|
|
||||||
>
|
|
||||||
<NavBarItemMenu
|
|
||||||
items={items}
|
|
||||||
reverseMenuDirection={reverseMenuDirection}
|
|
||||||
adjustHeightForBorder={adjustHeightForBorder}
|
|
||||||
disabledKeys={['divider', 'subtitle']}
|
|
||||||
aria-label={section.text}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
>
|
|
||||||
{(item: NavModelItem) => {
|
|
||||||
const translationKey = item.id && menuItemTranslations[item.id];
|
|
||||||
const itemText = translationKey ? i18n._(translationKey) : item.text;
|
|
||||||
const isSection = item.menuItemType === NavMenuItemType.Section;
|
|
||||||
const icon = item.showIconInNavbar && !isSection ? (item.icon as IconName) : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Item key={getNavModelItemKey(item)} textValue={item.text}>
|
|
||||||
<NavBarMenuItem
|
|
||||||
isDivider={!isSection && item.divider}
|
|
||||||
icon={icon}
|
|
||||||
target={item.target}
|
|
||||||
text={itemText}
|
|
||||||
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,110 +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 { useTheme2 } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { useNavBarItemMenuContext } from '../context';
|
|
||||||
import { getNavModelItemKey } from '../utils';
|
|
||||||
|
|
||||||
import { NavBarItemMenuItem } from './NavBarItemMenuItem';
|
|
||||||
import { NavBarScrollContainer } from './NavBarScrollContainer';
|
|
||||||
|
|
||||||
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} />
|
|
||||||
));
|
|
||||||
|
|
||||||
const subTitleComponent = menuSubTitle && (
|
|
||||||
<li key={menuSubTitle} className={styles.subtitle}>
|
|
||||||
{menuSubTitle}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
|
|
||||||
const contents = [itemComponents, subTitleComponent];
|
|
||||||
const contentComponent = (
|
|
||||||
<NavBarScrollContainer key="scrollContainer">
|
|
||||||
{reverseMenuDirection ? contents.reverse() : contents}
|
|
||||||
</NavBarScrollContainer>
|
|
||||||
);
|
|
||||||
|
|
||||||
const menu = [headerComponent, contentComponent];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul className={styles.menu} ref={ref} {...mergeProps(menuProps, contextMenuProps)} tabIndex={menuHasFocus ? 0 : -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;
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,98 +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;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
upgradeBoxContainer: css`
|
|
||||||
padding: ${theme.spacing(1)};
|
|
||||||
`,
|
|
||||||
upgradeBox: css`
|
|
||||||
width: 300px;
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,261 +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 { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { NavFeatureHighlight } from '../NavFeatureHighlight';
|
|
||||||
import { NavBarItemMenuContext, useNavBarContext } from '../context';
|
|
||||||
|
|
||||||
import { getNavMenuPortalContainer } from './NavBarMenuPortalContainer';
|
|
||||||
|
|
||||||
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}>
|
|
||||||
{item?.icon && <Icon name={item.icon as IconName} size="xl" />}
|
|
||||||
{item?.img && <img src={item.img} alt={`${item.text} logo`} />}
|
|
||||||
</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%',
|
|
||||||
|
|
||||||
img: {
|
|
||||||
borderRadius: '50%',
|
|
||||||
height: theme.spacing(3),
|
|
||||||
width: theme.spacing(3),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
@ -1,123 +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%',
|
|
||||||
|
|
||||||
img: {
|
|
||||||
borderRadius: '50%',
|
|
||||||
height: theme.spacing(3),
|
|
||||||
width: theme.spacing(3),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,450 +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, IconName, useStyles2, useTheme2 } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { Branding } from '../../Branding/Branding';
|
|
||||||
import { NavFeatureHighlight } from '../NavFeatureHighlight';
|
|
||||||
import { isMatchOrChildMatch } from '../utils';
|
|
||||||
|
|
||||||
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
|
||||||
import { NavBarMenuItem } from './NavBarMenuItem';
|
|
||||||
import { NavBarToggle } from './NavBarToggle';
|
|
||||||
|
|
||||||
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 { dialogProps } = useDialog({}, ref);
|
|
||||||
const { overlayProps, underlayProps } = useOverlay(
|
|
||||||
{
|
|
||||||
isDismissable: true,
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OverlayContainer>
|
|
||||||
<FocusScope contain restoreFocus autoFocus>
|
|
||||||
<CSSTransition
|
|
||||||
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 appear={isOpen} in={isOpen} classNames={animStyles.backdrop} timeout={ANIMATION_DURATION}>
|
|
||||||
<div className={styles.backdrop} {...underlayProps} />
|
|
||||||
</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),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
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) =>
|
|
||||||
!childLink.divider && (
|
|
||||||
<NavBarMenuItem
|
|
||||||
key={`${link.text}-${childLink.text}`}
|
|
||||||
isActive={activeItem === childLink}
|
|
||||||
isDivider={childLink.divider}
|
|
||||||
icon={childLink.showIconInNavbar ? (childLink.icon as IconName) : undefined}
|
|
||||||
onClick={() => {
|
|
||||||
childLink.onClick?.();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
styleOverrides={styles.item}
|
|
||||||
target={childLink.target}
|
|
||||||
text={childLink.text}
|
|
||||||
url={childLink.url}
|
|
||||||
isMobile={true}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</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>{getLinkIcon(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),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
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>{getLinkIcon(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLinkIcon(link: NavModelItem) {
|
|
||||||
if (link.id === 'home') {
|
|
||||||
return <Branding.MenuLogo />;
|
|
||||||
} else if (link.icon) {
|
|
||||||
return <Icon name={link.icon as IconName} size="xl" />;
|
|
||||||
} else {
|
|
||||||
return <img src={link.img} alt={`${link.text} logo`} height="24" width="24" style={{ borderRadius: '50%' }} />;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,150 +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 ? (
|
|
||||||
<li 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,71 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import React from 'react';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { Router } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { locationService } from '@grafana/runtime';
|
|
||||||
import { configureStore } from 'app/store/configureStore';
|
|
||||||
|
|
||||||
import TestProvider from '../../../../../test/helpers/TestProvider';
|
|
||||||
|
|
||||||
import { NavBarNext } from './NavBarNext';
|
|
||||||
|
|
||||||
jest.mock('app/core/services/context_srv', () => ({
|
|
||||||
contextSrv: {
|
|
||||||
sidemenu: true,
|
|
||||||
user: {},
|
|
||||||
isSignedIn: false,
|
|
||||||
isGrafanaAdmin: false,
|
|
||||||
isEditor: false,
|
|
||||||
hasEditPermissionFolders: false,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const setup = () => {
|
|
||||||
const store = configureStore();
|
|
||||||
|
|
||||||
return render(
|
|
||||||
<Provider store={store}>
|
|
||||||
<TestProvider>
|
|
||||||
<Router history={locationService.getHistory()}>
|
|
||||||
<NavBarNext />
|
|
||||||
</Router>
|
|
||||||
</TestProvider>
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Render', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// IntersectionObserver isn't available in test environment
|
|
||||||
const mockIntersectionObserver = jest.fn();
|
|
||||||
mockIntersectionObserver.mockReturnValue({
|
|
||||||
observe: () => null,
|
|
||||||
unobserve: () => null,
|
|
||||||
disconnect: () => null,
|
|
||||||
});
|
|
||||||
window.IntersectionObserver = mockIntersectionObserver;
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
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 { cloneDeep } from 'lodash';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
|
|
||||||
import { config, locationService, reportInteraction } from '@grafana/runtime';
|
|
||||||
import { Icon, IconName, useTheme2 } from '@grafana/ui';
|
|
||||||
import { Branding } from 'app/core/components/Branding/Branding';
|
|
||||||
import { getKioskMode } from 'app/core/navigation/kiosk';
|
|
||||||
import { KioskMode, StoreState } from 'app/types';
|
|
||||||
|
|
||||||
import { OrgSwitcher } from '../../OrgSwitcher';
|
|
||||||
import { NavBarContext } from '../context';
|
|
||||||
import {
|
|
||||||
enrichConfigItems,
|
|
||||||
enrichWithInteractionTracking,
|
|
||||||
getActiveItem,
|
|
||||||
isMatchOrChildMatch,
|
|
||||||
isSearchActive,
|
|
||||||
SEARCH_ITEM_ID,
|
|
||||||
} from '../utils';
|
|
||||||
|
|
||||||
import NavBarItem from './NavBarItem';
|
|
||||||
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
|
||||||
import { NavBarMenu } from './NavBarMenu';
|
|
||||||
import { NavBarMenuPortalContainer } from './NavBarMenuPortalContainer';
|
|
||||||
import { NavBarScrollContainer } from './NavBarScrollContainer';
|
|
||||||
import { NavBarToggle } from './NavBarToggle';
|
|
||||||
|
|
||||||
const onOpenSearch = () => {
|
|
||||||
locationService.partial({ search: 'open' });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NavBarNext = React.memo(() => {
|
|
||||||
const navBarTree = useSelector((state: StoreState) => state.navBarTree);
|
|
||||||
const theme = useTheme2();
|
|
||||||
const styles = getStyles(theme);
|
|
||||||
const location = useLocation();
|
|
||||||
const kiosk = getKioskMode();
|
|
||||||
const [showSwitcherModal, setShowSwitcherModal] = useState(false);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
const [menuAnimationInProgress, setMenuAnimationInProgress] = useState(false);
|
|
||||||
const [menuIdOpen, setMenuIdOpen] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const toggleSwitcherModal = () => {
|
|
||||||
setShowSwitcherModal(!showSwitcherModal);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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
|
|
||||||
);
|
|
||||||
|
|
||||||
const homeItem: NavModelItem = enrichWithInteractionTracking(
|
|
||||||
{
|
|
||||||
id: 'home',
|
|
||||||
text: 'Home',
|
|
||||||
url: config.appSubUrl || '/',
|
|
||||||
icon: 'grafana',
|
|
||||||
},
|
|
||||||
menuOpen
|
|
||||||
);
|
|
||||||
|
|
||||||
const navTree = cloneDeep(navBarTree);
|
|
||||||
|
|
||||||
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,
|
|
||||||
toggleSwitcherModal
|
|
||||||
).map((item) => enrichWithInteractionTracking(item, menuOpen));
|
|
||||||
|
|
||||||
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
|
|
||||||
|
|
||||||
if (kiosk !== KioskMode.Off) {
|
|
||||||
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} onClick={() => setMenuOpen(!menuOpen)} key="hamburger">
|
|
||||||
<Icon name="bars" size="xl" />
|
|
||||||
</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}
|
|
||||||
>
|
|
||||||
<Branding.MenuLogo />
|
|
||||||
</NavBarItemWithoutMenu>
|
|
||||||
|
|
||||||
<NavBarScrollContainer>
|
|
||||||
<ul className={styles.itemList}>
|
|
||||||
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem}>
|
|
||||||
<Icon name="search" size="xl" />
|
|
||||||
</NavBarItem>
|
|
||||||
|
|
||||||
{coreItems.map((link, index) => (
|
|
||||||
<NavBarItem
|
|
||||||
key={`${link.id}-${index}`}
|
|
||||||
isActive={isMatchOrChildMatch(link, activeItem)}
|
|
||||||
link={{ ...link, subTitle: undefined }}
|
|
||||||
>
|
|
||||||
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
|
|
||||||
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
|
|
||||||
</NavBarItem>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{pluginItems.length > 0 &&
|
|
||||||
pluginItems.map((link, index) => (
|
|
||||||
<NavBarItem
|
|
||||||
key={`${link.id}-${index}`}
|
|
||||||
isActive={isMatchOrChildMatch(link, activeItem)}
|
|
||||||
link={link}
|
|
||||||
>
|
|
||||||
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
|
|
||||||
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
|
|
||||||
</NavBarItem>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{configItems.map((link, index) => (
|
|
||||||
<NavBarItem
|
|
||||||
key={`${link.id}-${index}`}
|
|
||||||
isActive={isMatchOrChildMatch(link, activeItem)}
|
|
||||||
reverseMenuDirection
|
|
||||||
link={link}
|
|
||||||
className={cx({ [styles.verticalSpacer]: index === 0 })}
|
|
||||||
>
|
|
||||||
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
|
|
||||||
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
|
|
||||||
</NavBarItem>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</NavBarScrollContainer>
|
|
||||||
</FocusScope>
|
|
||||||
</NavBarContext.Provider>
|
|
||||||
</nav>
|
|
||||||
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
|
|
||||||
{(menuOpen || menuAnimationInProgress) && (
|
|
||||||
<div className={styles.menuWrapper}>
|
|
||||||
<NavBarMenu
|
|
||||||
activeItem={activeItem}
|
|
||||||
isOpen={menuOpen}
|
|
||||||
setMenuAnimationInProgress={setMenuAnimationInProgress}
|
|
||||||
navItems={[homeItem, searchItem, ...coreItems, ...pluginItems, ...configItems]}
|
|
||||||
onClose={() => setMenuOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
NavBarNext.displayName = 'NavBarNext';
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
|
||||||
navWrapper: css({
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
|
|
||||||
'.sidemenu-hidden &': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
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',
|
|
||||||
cursor: 'pointer',
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
});
|
|
@ -3,7 +3,7 @@ import { Location } from 'history';
|
|||||||
import { NavModelItem } from '@grafana/data';
|
import { NavModelItem } from '@grafana/data';
|
||||||
import { ContextSrv, setContextSrv } from 'app/core/services/context_srv';
|
import { ContextSrv, setContextSrv } from 'app/core/services/context_srv';
|
||||||
|
|
||||||
import { getConfig, updateConfig } from '../../config';
|
import { updateConfig } from '../../config';
|
||||||
|
|
||||||
import { enrichConfigItems, getActiveItem, getForcedLoginUrl, isMatchOrChildMatch, isSearchActive } from './utils';
|
import { enrichConfigItems, getActiveItem, getForcedLoginUrl, isMatchOrChildMatch, isSearchActive } from './utils';
|
||||||
|
|
||||||
@ -226,43 +226,6 @@ describe('getActiveItem', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when the newNavigation feature toggle is disabled', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
updateConfig({
|
|
||||||
featureToggles: {
|
|
||||||
...getConfig().featureToggles,
|
|
||||||
newNavigation: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the base route link if the pathname starts with /d/', () => {
|
|
||||||
const mockPathName = '/d/foo';
|
|
||||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
|
||||||
text: 'Base',
|
|
||||||
url: '/',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a more specific link if one exists', () => {
|
|
||||||
const mockPathName = '/d/moreSpecificDashboard';
|
|
||||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
|
||||||
text: 'More specific dashboard',
|
|
||||||
url: '/d/moreSpecificDashboard',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when the newNavigation feature toggle is enabled', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
updateConfig({
|
|
||||||
featureToggles: {
|
|
||||||
...getConfig().featureToggles,
|
|
||||||
newNavigation: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the dashboards route link if the pathname starts with /d/', () => {
|
it('returns the dashboards route link if the pathname starts with /d/', () => {
|
||||||
const mockPathName = '/d/foo';
|
const mockPathName = '/d/foo';
|
||||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
||||||
@ -278,7 +241,6 @@ describe('getActiveItem', () => {
|
|||||||
url: '/d/moreSpecificDashboard',
|
url: '/d/moreSpecificDashboard',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isSearchActive', () => {
|
describe('isSearchActive', () => {
|
||||||
|
@ -117,8 +117,7 @@ export const getActiveItem = (
|
|||||||
pathname: string,
|
pathname: string,
|
||||||
currentBestMatch?: NavModelItem
|
currentBestMatch?: NavModelItem
|
||||||
): NavModelItem | undefined => {
|
): NavModelItem | undefined => {
|
||||||
const newNavigationEnabled = getConfig().featureToggles.newNavigation;
|
const dashboardLinkMatch = '/dashboards';
|
||||||
const dashboardLinkMatch = newNavigationEnabled ? '/dashboards' : '/';
|
|
||||||
|
|
||||||
for (const link of navTree) {
|
for (const link of navTree) {
|
||||||
const linkPathname = stripQueryParams(link.url);
|
const linkPathname = stripQueryParams(link.url);
|
||||||
|
Loading…
Reference in New Issue
Block a user