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
|
||||
cloudWatchDynamicLabels = true
|
||||
|
||||
# New expandable navigation
|
||||
newNavigation = true
|
||||
|
||||
# feature1 = true
|
||||
# feature2 = false
|
||||
|
||||
|
@ -32,7 +32,6 @@ export interface FeatureToggles {
|
||||
prometheus_azure_auth?: boolean;
|
||||
prometheusAzureOverrideAudience?: boolean;
|
||||
influxdbBackendMigration?: boolean;
|
||||
newNavigation?: boolean;
|
||||
showFeatureFlagsInUI?: boolean;
|
||||
publicDashboards?: boolean;
|
||||
lokiLive?: boolean;
|
||||
|
@ -92,15 +92,10 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error)
|
||||
Id: "plugin-page-" + plugin.ID,
|
||||
Url: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL),
|
||||
Img: plugin.Info.Logos.Small,
|
||||
Section: dtos.NavSectionPlugin,
|
||||
SortWeight: dtos.WeightPlugin,
|
||||
}
|
||||
|
||||
if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) {
|
||||
appLink.Section = dtos.NavSectionPlugin
|
||||
} else {
|
||||
appLink.Section = dtos.NavSectionCore
|
||||
}
|
||||
|
||||
for _, include := range plugin.Includes {
|
||||
if !c.HasUserRole(include.Role) {
|
||||
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)
|
||||
|
||||
dashboardsUrl := "/"
|
||||
if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) {
|
||||
dashboardsUrl = "/dashboards"
|
||||
}
|
||||
dashboardsUrl := "/dashboards"
|
||||
|
||||
navTree = append(navTree, &dtos.NavLink{
|
||||
Text: "Dashboards",
|
||||
@ -359,25 +338,17 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
|
||||
SubTitle: "Organization: " + c.OrgName,
|
||||
Icon: "cog",
|
||||
Url: configNodes[0].Url,
|
||||
Section: dtos.NavSectionConfig,
|
||||
SortWeight: dtos.WeightConfig,
|
||||
Children: configNodes,
|
||||
}
|
||||
if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) {
|
||||
configNode.Section = dtos.NavSectionConfig
|
||||
} else {
|
||||
configNode.Section = dtos.NavSectionCore
|
||||
}
|
||||
navTree = append(navTree, configNode)
|
||||
}
|
||||
|
||||
adminNavLinks := hs.buildAdminNavLinks(c)
|
||||
|
||||
if len(adminNavLinks) > 0 {
|
||||
navSection := dtos.NavSectionCore
|
||||
if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) {
|
||||
navSection = dtos.NavSectionConfig
|
||||
}
|
||||
serverAdminNode := navlinks.GetServerAdminNode(adminNavLinks, navSection)
|
||||
serverAdminNode := navlinks.GetServerAdminNode(adminNavLinks)
|
||||
navTree = append(navTree, serverAdminNode)
|
||||
}
|
||||
|
||||
@ -467,14 +438,6 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
|
||||
}
|
||||
|
||||
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{
|
||||
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{
|
||||
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) &&
|
||||
hasAccess(hs.editorInAnyFolder, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) {
|
||||
if hasAccess(hs.editorInAnyFolder, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) {
|
||||
alertChildNavs = append(alertChildNavs, &dtos.NavLink{
|
||||
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
|
||||
})
|
||||
@ -612,41 +574,6 @@ func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext) []*dtos.NavLink {
|
||||
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 {
|
||||
var children []*dtos.NavLink
|
||||
var navLink *dtos.NavLink
|
||||
|
@ -2,7 +2,7 @@ package navlinks
|
||||
|
||||
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 := ""
|
||||
if len(children) > 0 {
|
||||
url = children[0].Url
|
||||
@ -15,7 +15,7 @@ func GetServerAdminNode(children []*dtos.NavLink, navSection string) *dtos.NavLi
|
||||
Icon: "shield",
|
||||
Url: url,
|
||||
SortWeight: dtos.WeightAdmin,
|
||||
Section: navSection,
|
||||
Section: dtos.NavSectionConfig,
|
||||
Children: children,
|
||||
}
|
||||
}
|
||||
|
@ -95,11 +95,6 @@ var (
|
||||
State: FeatureStateAlpha,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "newNavigation",
|
||||
Description: "Try the next gen navigation model",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
{
|
||||
Name: "showFeatureFlagsInUI",
|
||||
Description: "Show feature flags in the settings UI",
|
||||
|
@ -71,10 +71,6 @@ const (
|
||||
// Query InfluxDB InfluxQL without the proxy
|
||||
FlagInfluxdbBackendMigration = "influxdbBackendMigration"
|
||||
|
||||
// FlagNewNavigation
|
||||
// Try the next gen navigation model
|
||||
FlagNewNavigation = "newNavigation"
|
||||
|
||||
// FlagShowFeatureFlagsInUI
|
||||
// Show feature flags in the settings UI
|
||||
FlagShowFeatureFlagsInUI = "showFeatureFlagsInUI"
|
||||
|
@ -14,7 +14,6 @@ import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabl
|
||||
import { GrafanaApp } from './app';
|
||||
import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList';
|
||||
import { NavBar } from './core/components/NavBar/NavBar';
|
||||
import { NavBarNext } from './core/components/NavBar/Next/NavBarNext';
|
||||
import { I18nProvider } from './core/localisation';
|
||||
import { GrafanaRoute } from './core/navigation/GrafanaRoute';
|
||||
import { RouteDescriptor } from './core/navigation/types';
|
||||
@ -87,8 +86,6 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
|
||||
|
||||
navigationLogger('AppWrapper', false, 'rendering');
|
||||
|
||||
const newNavigationEnabled = Boolean(config.featureToggles.newNavigation);
|
||||
|
||||
const commandPaletteActionSelected = (action: Action) => {
|
||||
reportInteraction('commandPalette_action_selected', {
|
||||
actionId: action.id,
|
||||
@ -98,7 +95,7 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
|
||||
const commandPaletteEnabled = () => !config.isPublicDashboardView && config.featureToggles.commandPalette;
|
||||
|
||||
const renderNavBar = () => {
|
||||
return !config.isPublicDashboardView && ready && <>{newNavigationEnabled ? <NavBarNext /> : <NavBar />}</>;
|
||||
return !config.isPublicDashboardView && ready && <NavBar />;
|
||||
};
|
||||
|
||||
const searchBarEnabled = () => !config.isPublicDashboardView;
|
||||
|
@ -36,6 +36,17 @@ const setup = () => {
|
||||
};
|
||||
|
||||
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');
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { FocusScope } from '@react-aria/focus';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React, { useState } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Icon, IconName, useTheme2 } from '@grafana/ui';
|
||||
import { config, locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { Icon, useTheme2 } from '@grafana/ui';
|
||||
import { Branding } from 'app/core/components/Branding/Branding';
|
||||
import config from 'app/core/config';
|
||||
import { getKioskMode } from 'app/core/navigation/kiosk';
|
||||
import { KioskMode, StoreState } from 'app/types';
|
||||
|
||||
@ -17,169 +17,269 @@ import { OrgSwitcher } from '../OrgSwitcher';
|
||||
import NavBarItem from './NavBarItem';
|
||||
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
||||
import { NavBarMenu } from './NavBarMenu';
|
||||
import { NavBarSection } from './NavBarSection';
|
||||
import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive, SEARCH_ITEM_ID } from './utils';
|
||||
|
||||
const homeUrl = config.appSubUrl || '/';
|
||||
import { NavBarMenuPortalContainer } from './NavBarMenuPortalContainer';
|
||||
import { NavBarScrollContainer } from './NavBarScrollContainer';
|
||||
import { NavBarToggle } from './NavBarToggle';
|
||||
import { NavBarContext } from './context';
|
||||
import {
|
||||
enrichConfigItems,
|
||||
enrichWithInteractionTracking,
|
||||
getActiveItem,
|
||||
isMatchOrChildMatch,
|
||||
isSearchActive,
|
||||
SEARCH_ITEM_ID,
|
||||
} from './utils';
|
||||
|
||||
const onOpenSearch = () => {
|
||||
locationService.partial({ search: 'open' });
|
||||
};
|
||||
|
||||
const searchItem: NavModelItem = {
|
||||
id: SEARCH_ITEM_ID,
|
||||
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) => {
|
||||
export const NavBar = 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 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),
|
||||
location,
|
||||
toggleSwitcherModal
|
||||
);
|
||||
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
|
||||
).map((item) => enrichWithInteractionTracking(item, menuOpen));
|
||||
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname);
|
||||
|
||||
if (kiosk !== KioskMode.Off) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
|
||||
<div className={styles.mobileSidemenuLogo} onClick={() => setMobileMenuOpen(!mobileMenuOpen)} key="hamburger">
|
||||
<Icon name="bars" size="xl" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<NavBarSection>
|
||||
<NavBarItemWithoutMenu label="Home" className={styles.grafanaLogo} url={homeUrl}>
|
||||
<Branding.MenuLogo />
|
||||
</NavBarItemWithoutMenu>
|
||||
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem}>
|
||||
<Icon name="search" size="xl" />
|
||||
</NavBarItem>
|
||||
</NavBarSection>
|
||||
<NavBarToggle
|
||||
className={styles.menuExpandIcon}
|
||||
isExpanded={menuOpen}
|
||||
onClick={() => {
|
||||
reportInteraction('grafana_navigation_expanded');
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<NavBarSection>
|
||||
{topItems.map((link, index) => (
|
||||
<NavBarItem
|
||||
key={`${link.id}-${index}`}
|
||||
isActive={isMatchOrChildMatch(link, activeItem)}
|
||||
link={{ ...link, subTitle: undefined, onClick: undefined }}
|
||||
>
|
||||
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
|
||||
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
|
||||
</NavBarItem>
|
||||
))}
|
||||
</NavBarSection>
|
||||
<NavBarMenuPortalContainer />
|
||||
|
||||
<div className={styles.spacer} />
|
||||
<NavBarItemWithoutMenu
|
||||
elClassName={styles.grafanaLogoInner}
|
||||
label={homeItem.text}
|
||||
className={styles.grafanaLogo}
|
||||
url={homeItem.url}
|
||||
onClick={homeItem.onClick}
|
||||
>
|
||||
<Branding.MenuLogo />
|
||||
</NavBarItemWithoutMenu>
|
||||
|
||||
<NavBarSection>
|
||||
{bottomItems.map((link, index) => (
|
||||
<NavBarItem
|
||||
key={`${link.id}-${index}`}
|
||||
isActive={isMatchOrChildMatch(link, activeItem)}
|
||||
reverseMenuDirection
|
||||
link={link}
|
||||
>
|
||||
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
|
||||
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
|
||||
</NavBarItem>
|
||||
))}
|
||||
</NavBarSection>
|
||||
<NavBarScrollContainer>
|
||||
<ul className={styles.itemList}>
|
||||
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem} />
|
||||
|
||||
{coreItems.map((link, index) => (
|
||||
<NavBarItem
|
||||
key={`${link.id}-${index}`}
|
||||
isActive={isMatchOrChildMatch(link, activeItem)}
|
||||
link={{ ...link, subTitle: undefined }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{pluginItems.length > 0 &&
|
||||
pluginItems.map((link, index) => (
|
||||
<NavBarItem
|
||||
key={`${link.id}-${index}`}
|
||||
isActive={isMatchOrChildMatch(link, activeItem)}
|
||||
link={link}
|
||||
/>
|
||||
))}
|
||||
|
||||
{configItems.map((link, index) => (
|
||||
<NavBarItem
|
||||
key={`${link.id}-${index}`}
|
||||
isActive={isMatchOrChildMatch(link, activeItem)}
|
||||
reverseMenuDirection
|
||||
link={link}
|
||||
className={cx({ [styles.verticalSpacer]: index === 0 })}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</NavBarScrollContainer>
|
||||
</FocusScope>
|
||||
</NavBarContext.Provider>
|
||||
</nav>
|
||||
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
|
||||
{mobileMenuOpen && (
|
||||
<NavBarMenu
|
||||
activeItem={activeItem}
|
||||
navItems={[searchItem, ...topItems, ...bottomItems]}
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
{(menuOpen || menuAnimationInProgress) && (
|
||||
<div className={styles.menuWrapper}>
|
||||
<NavBarMenu
|
||||
activeItem={activeItem}
|
||||
isOpen={menuOpen}
|
||||
setMenuAnimationInProgress={setMenuAnimationInProgress}
|
||||
navItems={[homeItem, searchItem, ...coreItems, ...pluginItems, ...configItems]}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
NavBarUnconnected.displayName = 'NavBar';
|
||||
|
||||
export const NavBar = connector(NavBarUnconnected);
|
||||
NavBar.displayName = 'NavBar';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
search: css`
|
||||
display: none;
|
||||
margin-top: ${theme.spacing(5)};
|
||||
navWrapper: css({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
|
||||
${theme.breakpoints.up('md')} {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
sidemenu: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
z-index: ${theme.zIndex.sidemenu};
|
||||
'.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.up('md')} {
|
||||
background: ${theme.colors.background.primary};
|
||||
border-right: 1px solid ${theme.components.panel.borderColor};
|
||||
padding: 0 0 ${theme.spacing(1)} 0;
|
||||
position: relative;
|
||||
width: ${theme.components.sidemenu.width}px;
|
||||
}
|
||||
[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),
|
||||
|
||||
.sidemenu-hidden & {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
grafanaLogo: css`
|
||||
display: none;
|
||||
img {
|
||||
height: ${theme.spacing(3.5)};
|
||||
width: ${theme.spacing(3.5)};
|
||||
}
|
||||
[theme.breakpoints.up('md')]: {
|
||||
display: 'none',
|
||||
},
|
||||
}),
|
||||
itemList: css({
|
||||
backgroundColor: 'inherit',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
|
||||
${theme.breakpoints.up('md')} {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
`,
|
||||
mobileSidemenuLogo: css`
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: ${theme.spacing(2)};
|
||||
[theme.breakpoints.down('md')]: {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
}),
|
||||
grafanaLogo: css({
|
||||
alignItems: 'stretch',
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
height: theme.spacing(6),
|
||||
justifyContent: 'stretch',
|
||||
|
||||
${theme.breakpoints.up('md')} {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
spacer: css`
|
||||
flex: 1;
|
||||
`,
|
||||
[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,
|
||||
}),
|
||||
});
|
||||
|
@ -19,10 +19,11 @@ jest.mock('history', () => ({
|
||||
}));
|
||||
|
||||
import NavBarItem, { Props } from './NavBarItem';
|
||||
import { NavBarContext } from './context';
|
||||
|
||||
const onClickMock = jest.fn();
|
||||
const setMenuIdOpenMock = jest.fn();
|
||||
const defaults: Props = {
|
||||
children: undefined,
|
||||
link: {
|
||||
text: 'Parent Node',
|
||||
onClick: onClickMock,
|
||||
@ -30,10 +31,11 @@ const defaults: Props = {
|
||||
{ text: 'Child Node 1', onClick: onClickMock, children: [] },
|
||||
{ text: 'Child Node 2', onClick: onClickMock, children: [] },
|
||||
],
|
||||
id: 'MY_NAV_ID',
|
||||
},
|
||||
};
|
||||
|
||||
async function getTestContext(overrides: Partial<Props> = {}, subUrl = '') {
|
||||
async function getTestContext(overrides: Partial<Props> = {}, subUrl = '', isMenuOpen = false) {
|
||||
jest.clearAllMocks();
|
||||
config.appSubUrl = subUrl;
|
||||
locationUtil.initialize({ config, getTimeRangeForUrl: jest.fn(), getVariablesUrlParams: jest.fn() });
|
||||
@ -45,7 +47,14 @@ async function getTestContext(overrides: Partial<Props> = {}, subUrl = '') {
|
||||
const { rerender } = render(
|
||||
<TestProvider>
|
||||
<BrowserRouter>
|
||||
<NavBarItem {...props}>{props.children}</NavBarItem>
|
||||
<NavBarContext.Provider
|
||||
value={{
|
||||
menuIdOpen: isMenuOpen ? props.link.id : undefined,
|
||||
setMenuIdOpen: setMenuIdOpenMock,
|
||||
}}
|
||||
>
|
||||
<NavBarItem {...props} />
|
||||
</NavBarContext.Provider>
|
||||
</BrowserRouter>
|
||||
</TestProvider>
|
||||
);
|
||||
@ -57,6 +66,17 @@ async function getTestContext(overrides: Partial<Props> = {}, subUrl = '') {
|
||||
}
|
||||
|
||||
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', () => {
|
||||
it('then it renders the menu trigger as a button', async () => {
|
||||
await getTestContext();
|
||||
@ -74,32 +94,34 @@ describe('NavBarItem', () => {
|
||||
});
|
||||
|
||||
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 userEvent.hover(screen.getByRole('button'));
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'Parent Node' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
|
||||
expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id);
|
||||
});
|
||||
});
|
||||
|
||||
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 userEvent.tab();
|
||||
|
||||
expect(screen.getByText('Parent Node')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
|
||||
expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the menu when the correct menuIdOpen is set', async () => {
|
||||
await getTestContext(undefined, undefined, true);
|
||||
|
||||
expect(screen.getByText('Parent Node')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('and pressing arrow right on the menu trigger button', () => {
|
||||
it('then the correct menu item should receive focus', async () => {
|
||||
await getTestContext();
|
||||
await getTestContext(undefined, undefined, true);
|
||||
|
||||
await userEvent.tab();
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(3);
|
||||
@ -125,32 +147,36 @@ describe('NavBarItem', () => {
|
||||
});
|
||||
|
||||
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 userEvent.hover(screen.getByRole('link'));
|
||||
|
||||
expect(screen.getByText('Parent Node')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
|
||||
expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id);
|
||||
});
|
||||
});
|
||||
|
||||
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 userEvent.tab();
|
||||
|
||||
expect(screen.getByText('Parent Node')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
|
||||
expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the menu when the correct menuIdOpen is set', async () => {
|
||||
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }, undefined, true);
|
||||
|
||||
expect(screen.getByText('Parent Node')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Node 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Child Node 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('and pressing arrow right on the menu trigger link', () => {
|
||||
it('then the correct menu item should receive focus', async () => {
|
||||
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } });
|
||||
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }, undefined, true);
|
||||
|
||||
await userEvent.tab();
|
||||
expect(screen.getAllByRole('link')[0]).toHaveFocus();
|
||||
@ -170,7 +196,7 @@ describe('NavBarItem', () => {
|
||||
|
||||
describe('and pressing arrow left on a menu item', () => {
|
||||
it('then the nav bar item should receive focus', async () => {
|
||||
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } });
|
||||
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }, undefined, true);
|
||||
|
||||
await userEvent.tab();
|
||||
await userEvent.keyboard('{ArrowRight}');
|
||||
@ -199,15 +225,10 @@ describe('NavBarItem', () => {
|
||||
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 waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledTimes(1);
|
||||
@ -218,19 +239,17 @@ describe('NavBarItem', () => {
|
||||
|
||||
describe('when appSubUrl is not configured and user clicks on menuitem link', () => {
|
||||
it('then location service should be called with correct url', async () => {
|
||||
const { pushMock } = await getTestContext({
|
||||
link: {
|
||||
...defaults.link,
|
||||
url: 'https://www.grafana.com',
|
||||
children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }],
|
||||
const { pushMock } = await getTestContext(
|
||||
{
|
||||
link: {
|
||||
...defaults.link,
|
||||
url: 'https://www.grafana.com',
|
||||
children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.hover(screen.getByRole('link'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Parent Node')).toBeInTheDocument();
|
||||
expect(screen.getByText('New')).toBeInTheDocument();
|
||||
});
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('New'));
|
||||
await waitFor(() => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Item } from '@react-stately/collections';
|
||||
import React, { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2, locationUtil, NavMenuItemType, NavModelItem } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
@ -9,31 +9,24 @@ import { IconName, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { NavBarItemMenu } from './NavBarItemMenu';
|
||||
import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger';
|
||||
import { getNavBarItemWithoutMenuStyles, NavBarItemWithoutMenu } from './NavBarItemWithoutMenu';
|
||||
import { getNavBarItemWithoutMenuStyles } from './NavBarItemWithoutMenu';
|
||||
import { NavBarMenuItem } from './NavBarMenuItem';
|
||||
import { useNavBarContext } from './context';
|
||||
import menuItemTranslations from './navBarItem-translations';
|
||||
import { getNavModelItemKey } from './utils';
|
||||
|
||||
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 NavBarItem = ({ isActive = false, className, reverseMenuDirection = false, 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;
|
||||
@ -51,24 +44,28 @@ const NavBarItem = ({
|
||||
|
||||
const onNavigate = (item: NavModelItem) => {
|
||||
const { url, target, onClick } = item;
|
||||
if (!url) {
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
onClick?.();
|
||||
|
||||
if (!target && url.startsWith('/')) {
|
||||
locationService.push(locationUtil.stripBaseFromUrl(url));
|
||||
} else {
|
||||
window.open(url, target);
|
||||
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;
|
||||
|
||||
return showMenu ? (
|
||||
<li className={cx(styles.container, className)}>
|
||||
<NavBarItemMenuTrigger item={section} isActive={isActive} label={linkText}>
|
||||
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}
|
||||
@ -80,31 +77,19 @@ const NavBarItem = ({
|
||||
{(item: NavModelItem) => {
|
||||
const translationKey = item.id && menuItemTranslations[item.id];
|
||||
const itemText = translationKey ? i18n._(translationKey) : item.text;
|
||||
|
||||
if (item.menuItemType === NavMenuItemType.Section) {
|
||||
return (
|
||||
<Item key={getNavModelItemKey(item)} textValue={item.text}>
|
||||
<NavBarMenuItem
|
||||
target={item.target}
|
||||
text={itemText}
|
||||
url={item.url}
|
||||
onClick={item.onClick}
|
||||
styleOverrides={styles.header}
|
||||
/>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
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={item.divider}
|
||||
icon={item.icon as IconName}
|
||||
onClick={item.onClick}
|
||||
isDivider={!isSection && item.divider}
|
||||
icon={icon}
|
||||
target={item.target}
|
||||
text={itemText}
|
||||
url={item.url}
|
||||
styleOverrides={styles.item}
|
||||
onClick={item.onClick}
|
||||
styleOverrides={cx(styles.primaryText, { [styles.header]: isSection })}
|
||||
/>
|
||||
</Item>
|
||||
);
|
||||
@ -112,18 +97,6 @@ const NavBarItem = ({
|
||||
</NavBarItemMenu>
|
||||
</NavBarItemMenuTrigger>
|
||||
</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) => ({
|
||||
...getNavBarItemWithoutMenuStyles(theme, isActive),
|
||||
header: css`
|
||||
color: ${theme.colors.text.primary};
|
||||
height: ${theme.components.sidemenu.width - (adjustHeightForBorder ? 2 : 1)}px;
|
||||
font-size: ${theme.typography.h4.fontSize};
|
||||
font-weight: ${theme.typography.h4.fontWeight};
|
||||
padding: ${theme.spacing(1)} ${theme.spacing(2)};
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
`,
|
||||
item: css`
|
||||
color: ${theme.colors.text.primary};
|
||||
`,
|
||||
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%',
|
||||
}),
|
||||
});
|
||||
|
@ -9,6 +9,7 @@ import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { NavBarItemMenuItem } from './NavBarItemMenuItem';
|
||||
import { NavBarScrollContainer } from './NavBarScrollContainer';
|
||||
import { useNavBarItemMenuContext } from './context';
|
||||
import { getNavModelItemKey } from './utils';
|
||||
|
||||
@ -51,9 +52,7 @@ export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null
|
||||
|
||||
const menuSubTitle = section.value.subTitle;
|
||||
|
||||
const sectionComponent = (
|
||||
<NavBarItemMenuItem key={section.key} item={section} state={state} onNavigate={onNavigate} />
|
||||
);
|
||||
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} />
|
||||
@ -65,7 +64,14 @@ export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null
|
||||
</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 (
|
||||
<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`
|
||||
background-color: ${theme.colors.background.primary};
|
||||
border: 1px solid ${theme.components.panel.borderColor};
|
||||
bottom: ${reverseDirection ? 0 : 'auto'};
|
||||
box-shadow: ${theme.shadows.z3};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
left: 100%;
|
||||
list-style: none;
|
||||
max-height: 400px;
|
||||
max-width: 300px;
|
||||
min-width: 140px;
|
||||
position: absolute;
|
||||
top: ${reverseDirection ? 'auto' : 0};
|
||||
transition: ${theme.transitions.create('opacity')};
|
||||
z-index: ${theme.zIndex.sidemenu};
|
||||
`,
|
||||
|
@ -9,7 +9,7 @@ import React, { ReactElement, useRef, useState } from 'react';
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { useNavBarItemMenuContext } from './context';
|
||||
import { useNavBarItemMenuContext, useNavBarContext } from './context';
|
||||
|
||||
export interface NavBarItemMenuItemProps {
|
||||
item: Node<NavModelItem>;
|
||||
@ -19,6 +19,7 @@ export interface NavBarItemMenuItemProps {
|
||||
|
||||
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);
|
||||
@ -30,6 +31,7 @@ export function NavBarItemMenuItem({ item, state, onNavigate }: NavBarItemMenuIt
|
||||
const isSection = item.value.menuItemType === 'section';
|
||||
const styles = getStyles(theme, isFocused, isSection);
|
||||
const onAction = () => {
|
||||
setMenuIdOpen(undefined);
|
||||
onNavigate(item.value);
|
||||
onClose();
|
||||
};
|
||||
|
@ -4,7 +4,7 @@ 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, useOverlay } from '@react-aria/overlays';
|
||||
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';
|
||||
@ -13,19 +13,22 @@ import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||
import { reportExperimentView } from '@grafana/runtime';
|
||||
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { getNavMenuPortalContainer } from './NavBarMenuPortalContainer';
|
||||
import { NavFeatureHighlight } from './NavFeatureHighlight';
|
||||
import { NavBarItemMenuContext } from './context';
|
||||
import { NavBarItemMenuContext, useNavBarContext } from './context';
|
||||
|
||||
export interface NavBarItemMenuTriggerProps extends MenuTriggerProps {
|
||||
children: ReactElement;
|
||||
item: NavModelItem;
|
||||
isActive?: boolean;
|
||||
label: string;
|
||||
reverseMenuDirection: boolean;
|
||||
}
|
||||
|
||||
export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactElement {
|
||||
const { item, isActive, label, children: menu, ...rest } = props;
|
||||
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);
|
||||
|
||||
@ -46,23 +49,23 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
||||
onHoverChange: (isHovering) => {
|
||||
if (isHovering) {
|
||||
state.open();
|
||||
setMenuIdOpen(item.id);
|
||||
} else {
|
||||
state.close();
|
||||
setMenuIdOpen(undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { focusWithinProps } = useFocusWithin({
|
||||
onFocusWithinChange: (isFocused) => {
|
||||
if (isFocused) {
|
||||
state.open();
|
||||
}
|
||||
if (!isFocused) {
|
||||
state.close();
|
||||
setMenuHasFocus(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
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) => {
|
||||
@ -70,9 +73,13 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
||||
case 'ArrowRight':
|
||||
if (!state.isOpen) {
|
||||
state.open();
|
||||
setMenuIdOpen(item.id);
|
||||
}
|
||||
setMenuHasFocus(true);
|
||||
break;
|
||||
case 'Tab':
|
||||
setMenuIdOpen(undefined);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -95,6 +102,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
||||
className={styles.element}
|
||||
{...buttonProps}
|
||||
{...keyboardProps}
|
||||
{...hoverProps}
|
||||
ref={ref as React.RefObject<HTMLButtonElement>}
|
||||
onClick={item?.onClick}
|
||||
aria-label={label}
|
||||
@ -109,6 +117,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
||||
<Link
|
||||
{...buttonProps}
|
||||
{...keyboardProps}
|
||||
{...hoverProps}
|
||||
ref={ref as React.RefObject<HTMLAnchorElement>}
|
||||
href={item.url}
|
||||
target={item.target}
|
||||
@ -125,6 +134,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
||||
onClick={item?.onClick}
|
||||
{...buttonProps}
|
||||
{...keyboardProps}
|
||||
{...hoverProps}
|
||||
ref={ref as React.RefObject<HTMLAnchorElement>}
|
||||
className={styles.element}
|
||||
aria-label={label}
|
||||
@ -134,85 +144,117 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE
|
||||
);
|
||||
}
|
||||
|
||||
const overlayRef = React.useRef(null);
|
||||
const overlayRef = React.useRef<HTMLDivElement>(null);
|
||||
const { dialogProps } = useDialog({}, overlayRef);
|
||||
const { overlayProps } = useOverlay(
|
||||
{
|
||||
onClose: () => state.close(),
|
||||
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} {...hoverProps}>
|
||||
<div className={cx(styles.element, 'dropdown')} {...focusWithinProps}>
|
||||
{element}
|
||||
{state.isOpen && (
|
||||
<NavBarItemMenuContext.Provider
|
||||
value={{
|
||||
menuProps,
|
||||
menuHasFocus,
|
||||
onClose: () => state.close(),
|
||||
onLeft: () => {
|
||||
setMenuHasFocus(false);
|
||||
ref.current?.focus();
|
||||
},
|
||||
}}
|
||||
>
|
||||
<FocusScope restoreFocus>
|
||||
<div {...overlayProps} {...dialogProps} ref={overlayRef}>
|
||||
<DismissButton onDismiss={() => state.close()} />
|
||||
{menu}
|
||||
<DismissButton onDismiss={() => state.close()} />
|
||||
</div>
|
||||
</FocusScope>
|
||||
</NavBarItemMenuContext.Provider>
|
||||
<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`
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
line-height: ${theme.components.sidemenu.width}px;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: ${theme.components.sidemenu.width}px;
|
||||
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: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
background-image: ${theme.colors.gradients.brandVertical};
|
||||
}
|
||||
'&::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 {
|
||||
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;
|
||||
}
|
||||
`,
|
||||
icon: css`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
'&: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 {
|
||||
border-radius: 50%;
|
||||
height: ${theme.spacing(3)};
|
||||
width: ${theme.spacing(3)};
|
||||
}
|
||||
`,
|
||||
img: {
|
||||
borderRadius: '50%',
|
||||
height: theme.spacing(3),
|
||||
width: theme.spacing(3),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
@ -10,6 +10,7 @@ export interface NavBarItemWithoutMenuProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
elClassName?: string;
|
||||
url?: string;
|
||||
target?: string;
|
||||
isActive?: boolean;
|
||||
@ -20,113 +21,103 @@ export interface NavBarItemWithoutMenuProps {
|
||||
export function NavBarItemWithoutMenu({
|
||||
label,
|
||||
children,
|
||||
className,
|
||||
url,
|
||||
target,
|
||||
isActive = false,
|
||||
onClick,
|
||||
highlightText,
|
||||
className,
|
||||
elClassName,
|
||||
}: NavBarItemWithoutMenuProps) {
|
||||
const theme = useTheme2();
|
||||
const styles = getNavBarItemWithoutMenuStyles(theme, isActive);
|
||||
|
||||
const content = highlightText ? (
|
||||
<NavFeatureHighlight>
|
||||
<span className={styles.icon}>{children}</span>
|
||||
<div className={styles.icon}>{children}</div>
|
||||
</NavFeatureHighlight>
|
||||
) : (
|
||||
<span className={styles.icon}>{children}</span>
|
||||
<div className={styles.icon}>{children}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<li className={cx(styles.container, className)}>
|
||||
{!url && (
|
||||
<button className={styles.element} onClick={onClick} aria-label={label}>
|
||||
const elStyle = cx(styles.element, elClassName);
|
||||
|
||||
const renderContents = () => {
|
||||
if (!url) {
|
||||
return (
|
||||
<button className={elStyle} onClick={onClick} aria-label={label}>
|
||||
{content}
|
||||
</button>
|
||||
)}
|
||||
{url && (
|
||||
<>
|
||||
{!target && url.startsWith('/') ? (
|
||||
<Link
|
||||
className={styles.element}
|
||||
href={url}
|
||||
target={target}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<a href={url} target={target} className={styles.element} onClick={onClick} aria-label={label}>
|
||||
{content}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
);
|
||||
} 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};
|
||||
container: css({
|
||||
position: 'relative',
|
||||
color: isActive ? theme.colors.text.primary : theme.colors.text.secondary,
|
||||
display: 'grid',
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.colors.action.hover};
|
||||
color: ${theme.colors.text.primary};
|
||||
'&: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',
|
||||
|
||||
// TODO don't use a hardcoded class here, use isVisible in NavBarDropdown
|
||||
.navbar-dropdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
`,
|
||||
element: css`
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
line-height: ${theme.components.sidemenu.width}px;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: ${theme.components.sidemenu.width}px;
|
||||
'&::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,
|
||||
},
|
||||
|
||||
&::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};
|
||||
}
|
||||
'&: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',
|
||||
},
|
||||
}),
|
||||
|
||||
&: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;
|
||||
}
|
||||
`,
|
||||
icon: css({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
|
||||
icon: css`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
img {
|
||||
border-radius: 50%;
|
||||
height: ${theme.spacing(3)};
|
||||
width: ${theme.spacing(3)};
|
||||
}
|
||||
`,
|
||||
img: {
|
||||
borderRadius: '50%',
|
||||
height: theme.spacing(3),
|
||||
width: theme.spacing(3),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
@ -7,12 +7,26 @@ import { NavModelItem } from '@grafana/data';
|
||||
|
||||
import { NavBarMenu } from './NavBarMenu';
|
||||
|
||||
// don't care about interaction tracking in our unit tests
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
reportInteraction: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('NavBarMenu', () => {
|
||||
const mockOnClose = jest.fn();
|
||||
const mockNavItems: NavModelItem[] = [];
|
||||
const mockSetMenuAnimationInProgress = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
render(<NavBarMenu onClose={mockOnClose} navItems={mockNavItems} />);
|
||||
render(
|
||||
<NavBarMenu
|
||||
isOpen
|
||||
onClose={mockOnClose}
|
||||
navItems={mockNavItems}
|
||||
setMenuAnimationInProgress={mockSetMenuAnimationInProgress}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
it('should render the component', () => {
|
||||
@ -21,14 +35,20 @@ describe('NavBarMenu', () => {
|
||||
});
|
||||
|
||||
it('has a close button', () => {
|
||||
const closeButton = screen.getByRole('button', { name: 'Close navigation menu' });
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
const closeButton = screen.getAllByRole('button', { name: 'Close navigation menu' });
|
||||
// this is for mobile, will be hidden with display: none; on desktop
|
||||
expect(closeButton[0]).toBeInTheDocument();
|
||||
// this is for desktop, will be hidden with display: none; on mobile
|
||||
expect(closeButton[1]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking the close button calls the onClose callback', async () => {
|
||||
const closeButton = screen.getByRole('button', { name: 'Close navigation menu' });
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
await userEvent.click(closeButton);
|
||||
const closeButton = screen.getAllByRole('button', { name: 'Close navigation menu' });
|
||||
expect(closeButton[0]).toBeInTheDocument();
|
||||
expect(closeButton[1]).toBeInTheDocument();
|
||||
await userEvent.click(closeButton[0]);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
await userEvent.click(closeButton[1]);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -1,127 +1,450 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useDialog } from '@react-aria/dialog';
|
||||
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 CSSTransition from 'react-transition-group/CSSTransition';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
|
||||
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 { NavBarToggle } from './NavBarToggle';
|
||||
import { NavFeatureHighlight } from './NavFeatureHighlight';
|
||||
import { isMatchOrChildMatch } from './utils';
|
||||
|
||||
const MENU_WIDTH = '350px';
|
||||
|
||||
export interface Props {
|
||||
activeItem?: NavModelItem;
|
||||
isOpen: boolean;
|
||||
navItems: NavModelItem[];
|
||||
setMenuAnimationInProgress: (isInProgress: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NavBarMenu({ activeItem, navItems, onClose }: Props) {
|
||||
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 } = useOverlay(
|
||||
const { overlayProps, underlayProps } = useOverlay(
|
||||
{
|
||||
isDismissable: true,
|
||||
isOpen: true,
|
||||
isOpen,
|
||||
onClose,
|
||||
},
|
||||
ref
|
||||
);
|
||||
|
||||
return (
|
||||
<FocusScope contain restoreFocus autoFocus>
|
||||
<div data-testid="navbarmenu" className={styles.container} ref={ref} {...overlayProps} {...dialogProps}>
|
||||
<div className={styles.header}>
|
||||
<Icon name="bars" size="xl" />
|
||||
<IconButton aria-label="Close navigation menu" name="times" onClick={onClose} size="xl" variant="secondary" />
|
||||
</div>
|
||||
<nav className={styles.content}>
|
||||
<CustomScrollbar>
|
||||
<ul>
|
||||
{navItems.map((link) => (
|
||||
<div className={styles.section} key={link.text}>
|
||||
<NavBarMenuItem
|
||||
isActive={activeItem === link}
|
||||
onClick={() => {
|
||||
link.onClick?.();
|
||||
onClose();
|
||||
}}
|
||||
styleOverrides={styles.sectionHeader}
|
||||
target={link.target}
|
||||
text={link.text}
|
||||
url={link.url}
|
||||
isMobile={true}
|
||||
/>
|
||||
{link.children?.map(
|
||||
(childLink) =>
|
||||
!childLink.divider && (
|
||||
<NavBarMenuItem
|
||||
key={childLink.text}
|
||||
icon={childLink.icon as IconName}
|
||||
isActive={activeItem === childLink}
|
||||
isDivider={childLink.divider}
|
||||
onClick={() => {
|
||||
childLink.onClick?.();
|
||||
onClose();
|
||||
}}
|
||||
styleOverrides={styles.item}
|
||||
target={childLink.target}
|
||||
text={childLink.text}
|
||||
url={childLink.url}
|
||||
isMobile={true}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</CustomScrollbar>
|
||||
</nav>
|
||||
</div>
|
||||
</FocusScope>
|
||||
<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) => ({
|
||||
container: css`
|
||||
background-color: ${theme.colors.background.canvas};
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
left: 0;
|
||||
min-width: 300px;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
|
||||
${theme.breakpoints.up('md')} {
|
||||
border-right: 1px solid ${theme.colors.border.weak};
|
||||
right: unset;
|
||||
}
|
||||
`,
|
||||
content: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
`,
|
||||
header: css`
|
||||
border-bottom: 1px solid ${theme.colors.border.weak};
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: ${theme.spacing(2)};
|
||||
`,
|
||||
item: css`
|
||||
padding: ${theme.spacing(1)} ${theme.spacing(2)};
|
||||
`,
|
||||
section: css`
|
||||
border-bottom: 1px solid ${theme.colors.border.weak};
|
||||
`,
|
||||
sectionHeader: css`
|
||||
color: ${theme.colors.text.primary};
|
||||
font-size: ${theme.typography.h5.fontSize};
|
||||
padding: ${theme.spacing(1)} ${theme.spacing(2)};
|
||||
`,
|
||||
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,4 +1,4 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@ -29,14 +29,12 @@ export function NavBarMenuItem({
|
||||
isMobile = false,
|
||||
}: Props) {
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme, isActive, styleOverrides);
|
||||
|
||||
const styles = getStyles(theme, isActive);
|
||||
const elStyle = cx(styles.element, styleOverrides);
|
||||
const linkContent = (
|
||||
<div className={styles.linkContent}>
|
||||
<div>
|
||||
{icon && <Icon data-testid="dropdown-child-icon" name={icon} className={styles.icon} />}
|
||||
{text}
|
||||
</div>
|
||||
{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} />
|
||||
)}
|
||||
@ -44,7 +42,7 @@ export function NavBarMenuItem({
|
||||
);
|
||||
|
||||
let element = (
|
||||
<button className={styles.element} onClick={onClick} tabIndex={-1}>
|
||||
<button className={elStyle} onClick={onClick} tabIndex={-1}>
|
||||
{linkContent}
|
||||
</button>
|
||||
);
|
||||
@ -52,11 +50,11 @@ export function NavBarMenuItem({
|
||||
if (url) {
|
||||
element =
|
||||
!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}
|
||||
</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}
|
||||
</a>
|
||||
);
|
||||
@ -79,89 +77,74 @@ export function NavBarMenuItem({
|
||||
|
||||
NavBarMenuItem.displayName = 'NavBarMenuItem';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverrides: Props['styleOverrides']) => ({
|
||||
visible: css`
|
||||
color: ${theme.colors.text.primary} !important;
|
||||
opacity: 100% !important;
|
||||
`,
|
||||
divider: css`
|
||||
border-bottom: 1px solid ${theme.colors.border.weak};
|
||||
height: 1px;
|
||||
margin: ${theme.spacing(1)} 0;
|
||||
overflow: hidden;
|
||||
`,
|
||||
listItem: css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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};
|
||||
'&:hover, &:focus-within': {
|
||||
color: theme.colors.text.primary,
|
||||
|
||||
> *:first-child::after {
|
||||
background-color: ${theme.colors.action.hover};
|
||||
}
|
||||
}
|
||||
`,
|
||||
element: css`
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary};
|
||||
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;
|
||||
`,
|
||||
'> *: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,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 { 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';
|
||||
|
||||
@ -226,57 +226,19 @@ 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',
|
||||
});
|
||||
it('returns the dashboards route link if the pathname starts with /d/', () => {
|
||||
const mockPathName = '/d/foo';
|
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
||||
text: 'Dashboards',
|
||||
url: '/dashboards',
|
||||
});
|
||||
});
|
||||
|
||||
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/', () => {
|
||||
const mockPathName = '/d/foo';
|
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
||||
text: 'Dashboards',
|
||||
url: '/dashboards',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a more specific link if one exists', () => {
|
||||
const mockPathName = '/d/moreSpecificDashboard';
|
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({
|
||||
text: 'More specific dashboard',
|
||||
url: '/d/moreSpecificDashboard',
|
||||
});
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -117,8 +117,7 @@ export const getActiveItem = (
|
||||
pathname: string,
|
||||
currentBestMatch?: NavModelItem
|
||||
): NavModelItem | undefined => {
|
||||
const newNavigationEnabled = getConfig().featureToggles.newNavigation;
|
||||
const dashboardLinkMatch = newNavigationEnabled ? '/dashboards' : '/';
|
||||
const dashboardLinkMatch = '/dashboards';
|
||||
|
||||
for (const link of navTree) {
|
||||
const linkPathname = stripQueryParams(link.url);
|
||||
|
Loading…
Reference in New Issue
Block a user