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:
Ashley Harrison 2022-06-16 10:48:38 +01:00 committed by GitHub
parent d0808bdafb
commit d0fa326798
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1087 additions and 2484 deletions

View File

@ -1184,9 +1184,6 @@ commandPalette = true
# Use dynamic labels in CloudWatch datasource
cloudWatchDynamicLabels = true
# New expandable navigation
newNavigation = true
# feature1 = true
# feature2 = false

View File

@ -32,7 +32,6 @@ export interface FeatureToggles {
prometheus_azure_auth?: boolean;
prometheusAzureOverrideAudience?: boolean;
influxdbBackendMigration?: boolean;
newNavigation?: boolean;
showFeatureFlagsInUI?: boolean;
publicDashboards?: boolean;
lokiLive?: boolean;

View File

@ -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

View File

@ -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,
}
}

View File

@ -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",

View File

@ -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"

View File

@ -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;

View File

@ -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');

View File

@ -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,
}),
});

View File

@ -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(() => {

View File

@ -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%',
}),
});

View File

@ -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};
`,

View File

@ -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();
};

View File

@ -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),
},
}),
});

View File

@ -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),
},
}),
};
}

View File

@ -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();
});
});

View File

@ -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%' }} />;
}
}

View File

@ -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',
}),
});

View File

@ -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;
}
`,
});

View File

@ -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%',
}),
});

View File

@ -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;
`,
};
}

View File

@ -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;
`,
};
}

View File

@ -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),
},
}),
});

View File

@ -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),
},
}),
};
}

View File

@ -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%' }} />;
}
}

View File

@ -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',
}),
});

View File

@ -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();
});
});

View File

@ -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,
}),
});

View File

@ -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',
});
});
});

View File

@ -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);