Navigation: New NavBar designs behind feature toggle (#41045)

* Navigation: Remove plus button behind feature toggle

* Navigation: Add home button behind feature toggle

* Navigation: Move settings/admin to bottom section behind feature toggle

* Navigation: Refactor grafana logo to be a NavBarItem

* Navigation: Create new PluginSection and styling changes to support new sections

* Navigation: Hack to use mobile menu as a mega menu for now

* Navigation: Only render plugin section if there are items

* Navigation: mobile menu is always 100% width if toggle is off

* Navigation: Reset width back to 48 and fix broken css property

* Navigation: Create generic NavBarSection component to reduce repetition

* Navigation: Don't show sublinks for core items

* Navigation: Comments from UX review

* Navigation: Remove mobile menu hack

* Navigation: Unit tests for enrichConfigItems and other minor review comments

* Navigation: Move section logic to backend

* Navigation: Refactor alerting links out into a separate function

* Navigation: More tests for isLinkActive

* Linting...

* Navigation: Create new NavBar component for when feature toggle is enabled
This commit is contained in:
Ashley Harrison 2021-11-02 11:19:18 +00:00 committed by GitHub
parent 469a5e4a85
commit 727a4bd9e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 889 additions and 443 deletions

View File

@ -13,10 +13,17 @@ export interface NavModelItem {
breadcrumbs?: NavModelBreadcrumb[];
target?: string;
parentItem?: NavModelItem;
section?: NavSection;
showOrgSwitcher?: boolean;
onClick?: () => void;
}
export enum NavSection {
Core = 'core',
Plugin = 'plugin',
Config = 'config',
}
/**
* Interface used to describe different kinds of page titles and page navigation. Navmodels are usually generated in the backend and stored in Redux.
*/

View File

@ -37,21 +37,29 @@ const (
// are negative to ensure that the default items are placed above
// any items with default weight.
WeightCreate = (iota - 20) * 100
WeightHome = (iota - 20) * 100
WeightCreate
WeightDashboard
WeightExplore
WeightProfile
WeightAlerting
WeightPlugin
WeightConfig
WeightAdmin
WeightProfile
WeightHelp
)
const (
NavSectionCore string = "core"
NavSectionPlugin string = "plugin"
NavSectionConfig string = "config"
)
type NavLink struct {
Id string `json:"id,omitempty"`
Text string `json:"text,omitempty"`
Description string `json:"description,omitempty"`
Section string `json:"section,omitempty"`
SubTitle string `json:"subTitle,omitempty"`
Icon string `json:"icon,omitempty"`
Img string `json:"img,omitempty"`

View File

@ -54,14 +54,14 @@ func (hs *HTTPServer) getProfileNode(c *models.ReqContext) *dtos.NavLink {
}
return &dtos.NavLink{
Text: c.SignedInUser.NameOrFallback(),
SubTitle: login,
Id: "profile",
Img: gravatarURL,
Url: hs.Cfg.AppSubURL + "/profile",
HideFromMenu: true,
SortWeight: dtos.WeightProfile,
Children: children,
Text: c.SignedInUser.NameOrFallback(),
SubTitle: login,
Id: "profile",
Img: gravatarURL,
Url: hs.Cfg.AppSubURL + "/profile",
Section: dtos.NavSectionConfig,
SortWeight: dtos.WeightProfile,
Children: children,
}
}
@ -85,6 +85,12 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error)
SortWeight: dtos.WeightPlugin,
}
if hs.Cfg.IsNewNavigationEnabled() {
appLink.Section = dtos.NavSectionPlugin
} else {
appLink.Section = dtos.NavSectionCore
}
for _, include := range plugin.Includes {
if !c.HasUserRole(include.Role) {
continue
@ -136,7 +142,18 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
hasAccess := ac.HasAccess(hs.AccessControl, c)
navTree := []*dtos.NavLink{}
if hasEditPerm {
if hs.Cfg.IsNewNavigationEnabled() {
navTree = append(navTree, &dtos.NavLink{
Text: "Home",
Id: "home",
Icon: "home-alt",
Url: hs.Cfg.AppSubURL + "/",
Section: dtos.NavSectionCore,
SortWeight: dtos.WeightHome,
})
}
if hasEditPerm && !hs.Cfg.IsNewNavigationEnabled() {
children := hs.buildCreateNavLinks(c)
navTree = append(navTree, &dtos.NavLink{
Text: "Create",
@ -144,31 +161,16 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
Icon: "plus",
Url: hs.Cfg.AppSubURL + "/dashboard/new",
Children: children,
Section: dtos.NavSectionCore,
SortWeight: dtos.WeightCreate,
})
}
dashboardChildNavs := []*dtos.NavLink{
{Text: "Home", Id: "home", Url: hs.Cfg.AppSubURL + "/", Icon: "home-alt", HideFromTabs: true},
{Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true},
{Text: "Manage", Id: "manage-dashboards", Url: hs.Cfg.AppSubURL + "/dashboards", Icon: "sitemap"},
{Text: "Playlists", Id: "playlists", Url: hs.Cfg.AppSubURL + "/playlists", Icon: "presentation-play"},
}
dashboardChildLinks := hs.buildDashboardNavLinks(c, hasEditPerm)
if c.IsSignedIn {
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Snapshots",
Id: "snapshots",
Url: hs.Cfg.AppSubURL + "/dashboard/snapshots",
Icon: "camera",
})
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Library panels",
Id: "library-panels",
Url: hs.Cfg.AppSubURL + "/library-panels",
Icon: "library-panel",
})
dashboardsUrl := "/"
if hs.Cfg.IsNewNavigationEnabled() {
dashboardsUrl = "/dashboards"
}
navTree = append(navTree, &dtos.NavLink{
@ -176,9 +178,10 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
Id: "dashboards",
SubTitle: "Manage dashboards and folders",
Icon: "apps",
Url: hs.Cfg.AppSubURL + "/",
Url: hs.Cfg.AppSubURL + dashboardsUrl,
SortWeight: dtos.WeightDashboard,
Children: dashboardChildNavs,
Section: dtos.NavSectionCore,
Children: dashboardChildLinks,
})
canExplore := func(context *models.ReqContext) bool {
@ -192,6 +195,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
SubTitle: "Explore your data",
Icon: "compass",
SortWeight: dtos.WeightExplore,
Section: dtos.NavSectionCore,
Url: hs.Cfg.AppSubURL + "/explore",
})
}
@ -204,34 +208,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
uaVisibleForOrg := hs.Cfg.UnifiedAlerting.Enabled && !uaIsDisabledForOrg
if setting.AlertingEnabled || uaVisibleForOrg {
alertChildNavs := []*dtos.NavLink{
{Text: "Alert rules", Id: "alert-list", Url: hs.Cfg.AppSubURL + "/alerting/list", Icon: "list-ul"},
}
if uaVisibleForOrg {
alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Alert groups", Id: "groups", Url: hs.Cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"})
alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Silences", Id: "silences", Url: hs.Cfg.AppSubURL + "/alerting/silences", Icon: "bell-slash"})
}
if c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR {
if uaVisibleForOrg {
alertChildNavs = append(alertChildNavs, &dtos.NavLink{
Text: "Contact points", Id: "receivers", Url: hs.Cfg.AppSubURL + "/alerting/notifications",
Icon: "comment-alt-share",
})
alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Notification policies", Id: "am-routes", Url: hs.Cfg.AppSubURL + "/alerting/routes", Icon: "sitemap"})
} else {
alertChildNavs = append(alertChildNavs, &dtos.NavLink{
Text: "Notification channels", Id: "channels", Url: hs.Cfg.AppSubURL + "/alerting/notifications",
Icon: "comment-alt-share",
})
}
}
if c.OrgRole == models.ROLE_ADMIN && uaVisibleForOrg {
alertChildNavs = append(alertChildNavs, &dtos.NavLink{
Text: "Admin", Id: "alerting-admin", Url: hs.Cfg.AppSubURL + "/alerting/admin",
Icon: "cog",
})
}
alertChildNavs := hs.buildAlertNavLinks(c, uaVisibleForOrg)
navTree = append(navTree, &dtos.NavLink{
Text: "Alerting",
SubTitle: "Alert rules and notifications",
@ -239,6 +216,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
Icon: "bell",
Url: hs.Cfg.AppSubURL + "/alerting/list",
Children: alertChildNavs,
Section: dtos.NavSectionCore,
SortWeight: dtos.WeightAlerting,
})
}
@ -325,7 +303,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
Icon: "exchange-alt",
Url: hs.Cfg.AppSubURL + "/live",
Children: liveNavLinks,
HideFromMenu: true,
Section: dtos.NavSectionConfig,
HideFromTabs: true,
})
}
@ -340,12 +318,32 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
SortWeight: dtos.WeightConfig,
Children: configNodes,
})
configNode := &dtos.NavLink{
Id: dtos.NavIDCfg,
Text: "Configuration",
SubTitle: "Organization: " + c.OrgName,
Icon: "cog",
Url: configNodes[0].Url,
SortWeight: dtos.WeightConfig,
Children: configNodes,
}
if hs.Cfg.IsNewNavigationEnabled() {
configNode.Section = dtos.NavSectionConfig
} else {
configNode.Section = dtos.NavSectionCore
}
navTree = append(navTree, configNode)
}
adminNavLinks := hs.buildAdminNavLinks(c)
if len(adminNavLinks) > 0 {
serverAdminNode := navlinks.GetServerAdminNode(adminNavLinks)
if hs.Cfg.IsNewNavigationEnabled() {
serverAdminNode.Section = dtos.NavSectionConfig
} else {
serverAdminNode.Section = dtos.NavSectionCore
}
navTree = append(navTree, serverAdminNode)
}
@ -355,19 +353,104 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
}
navTree = append(navTree, &dtos.NavLink{
Text: "Help",
SubTitle: helpVersion,
Id: "help",
Url: "#",
Icon: "question-circle",
HideFromMenu: true,
SortWeight: dtos.WeightHelp,
Children: []*dtos.NavLink{},
Text: "Help",
SubTitle: helpVersion,
Id: "help",
Url: "#",
Icon: "question-circle",
SortWeight: dtos.WeightHelp,
Section: dtos.NavSectionConfig,
Children: []*dtos.NavLink{},
})
return navTree, nil
}
func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm bool) []*dtos.NavLink {
dashboardChildNavs := []*dtos.NavLink{}
if !hs.Cfg.IsNewNavigationEnabled() {
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",
})
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Playlists", Id: "playlists", Url: hs.Cfg.AppSubURL + "/playlists", Icon: "presentation-play",
})
if c.IsSignedIn {
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Snapshots",
Id: "snapshots",
Url: hs.Cfg.AppSubURL + "/dashboard/snapshots",
Icon: "camera",
})
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Library panels",
Id: "library-panels",
Url: hs.Cfg.AppSubURL + "/library-panels",
Icon: "library-panel",
})
}
if hasEditPerm && hs.Cfg.IsNewNavigationEnabled() {
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
})
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "New dashboard", Icon: "plus", Url: hs.Cfg.AppSubURL + "/dashboard/new", HideFromTabs: true,
})
if c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR {
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "New folder", SubTitle: "Create a new folder to organize your dashboards", Id: "folder",
Icon: "plus", Url: hs.Cfg.AppSubURL + "/dashboards/folder/new", HideFromTabs: true,
})
}
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "plus",
Url: hs.Cfg.AppSubURL + "/dashboard/import", HideFromTabs: true,
})
}
return dashboardChildNavs
}
func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext, uaVisibleForOrg bool) []*dtos.NavLink {
alertChildNavs := []*dtos.NavLink{
{Text: "Alert rules", Id: "alert-list", Url: hs.Cfg.AppSubURL + "/alerting/list", Icon: "list-ul"},
}
if uaVisibleForOrg {
alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Alert groups", Id: "groups", Url: hs.Cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"})
alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Silences", Id: "silences", Url: hs.Cfg.AppSubURL + "/alerting/silences", Icon: "bell-slash"})
}
if c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR {
if uaVisibleForOrg {
alertChildNavs = append(alertChildNavs, &dtos.NavLink{
Text: "Contact points", Id: "receivers", Url: hs.Cfg.AppSubURL + "/alerting/notifications",
Icon: "comment-alt-share",
})
alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Notification policies", Id: "am-routes", Url: hs.Cfg.AppSubURL + "/alerting/routes", Icon: "sitemap"})
} else {
alertChildNavs = append(alertChildNavs, &dtos.NavLink{
Text: "Notification channels", Id: "channels", Url: hs.Cfg.AppSubURL + "/alerting/notifications",
Icon: "comment-alt-share",
})
}
}
if c.OrgRole == models.ROLE_ADMIN && uaVisibleForOrg {
alertChildNavs = append(alertChildNavs, &dtos.NavLink{
Text: "Admin", Id: "alerting-admin", Url: hs.Cfg.AppSubURL + "/alerting/admin",
Icon: "cog",
})
}
return alertChildNavs
}
func (hs *HTTPServer) buildCreateNavLinks(c *models.ReqContext) []*dtos.NavLink {
children := []*dtos.NavLink{
{Text: "Dashboard", Icon: "apps", Url: hs.Cfg.AppSubURL + "/dashboard/new"},

View File

@ -10,6 +10,7 @@ import { ConfigContext, ThemeProvider } from './core/utils/ConfigProvider';
import { RouteDescriptor } from './core/navigation/types';
import { contextSrv } from './core/services/context_srv';
import { NavBar } from './core/components/NavBar/NavBar';
import { NavBarNext } from './core/components/NavBar/NavBarNext';
import { GrafanaRoute } from './core/navigation/GrafanaRoute';
import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList';
import { SearchWrapper } from 'app/features/search';
@ -90,6 +91,7 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
// @ts-ignore
const appSeed = `<grafana-app ng-cloak></app-notifications-list></grafana-app>`;
const newNavigationEnabled = config.featureToggles.newNavigation;
return (
<Provider store={store}>
@ -100,7 +102,7 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
<GlobalStyles />
<div className="grafana-app">
<Router history={locationService.getHistory()}>
<NavBar />
{newNavigationEnabled ? <NavBarNext /> : <NavBar />}
<main className="main-view">
{pageBanners.map((Banner, index) => (
<Banner key={index.toString()} />

View File

@ -1,107 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import { ShowModalReactEvent } from '../../../types/events';
import { HelpModal } from '../help/HelpModal';
import appEvents from '../../app_events';
import BottomSection from './BottomSection';
jest.mock('./utils', () => ({
getForcedLoginUrl: () => '/mockForcedLoginUrl',
isLinkActive: () => false,
isSearchActive: () => false,
}));
jest.mock('../../app_events', () => ({
publish: jest.fn(),
}));
jest.mock('../../config', () => ({
bootData: {
navTree: [
{
id: 'profile',
hideFromMenu: true,
},
{
id: 'help',
hideFromMenu: true,
},
{
hideFromMenu: false,
},
{
hideFromMenu: true,
},
],
},
}));
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
sidemenu: true,
isSignedIn: true,
isGrafanaAdmin: false,
hasEditPermissionFolders: false,
user: {
orgCount: 5,
orgName: 'Grafana',
},
},
}));
describe('BottomSection', () => {
it('should render the correct children', () => {
render(
<BrowserRouter>
<BottomSection />
</BrowserRouter>
);
expect(screen.getByTestId('bottom-section-items').children.length).toBe(3);
});
it('creates the correct children for the help link', () => {
render(
<BrowserRouter>
<div className="sidemenu-open--xs">
<BottomSection />
</div>
</BrowserRouter>
);
const documentation = screen.getByRole('link', { name: 'Documentation' });
const support = screen.getByRole('link', { name: 'Support' });
const community = screen.getByRole('link', { name: 'Community' });
const keyboardShortcuts = screen.getByText('Keyboard shortcuts');
expect(documentation).toBeInTheDocument();
expect(support).toBeInTheDocument();
expect(community).toBeInTheDocument();
expect(keyboardShortcuts).toBeInTheDocument();
});
it('clicking the keyboard shortcuts button shows the modal', () => {
render(
<BrowserRouter>
<BottomSection />
</BrowserRouter>
);
const keyboardShortcuts = screen.getByText('Keyboard shortcuts');
expect(keyboardShortcuts).toBeInTheDocument();
userEvent.click(keyboardShortcuts);
expect(appEvents.publish).toHaveBeenCalledWith(new ShowModalReactEvent({ component: HelpModal }));
});
it('shows the current organization and organization switcher if showOrgSwitcher is true', () => {
render(
<BrowserRouter>
<BottomSection />
</BrowserRouter>
);
const currentOrg = screen.getByText(new RegExp('Grafana', 'i'));
const orgSwitcher = screen.getByText('Switch organization');
expect(currentOrg).toBeInTheDocument();
expect(orgSwitcher).toBeInTheDocument();
});
});

View File

@ -1,113 +0,0 @@
import React, { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { cloneDeep } from 'lodash';
import { css } from '@emotion/css';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Icon, IconName, useTheme2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import appEvents from '../../app_events';
import { ShowModalReactEvent } from '../../../types/events';
import config from '../../config';
import { OrgSwitcher } from '../OrgSwitcher';
import { getFooterLinks } from '../Footer/Footer';
import { HelpModal } from '../help/HelpModal';
import NavBarItem from './NavBarItem';
import { getForcedLoginUrl, isLinkActive, isSearchActive } from './utils';
export default function BottomSection() {
const theme = useTheme2();
const styles = getStyles(theme);
const navTree: NavModelItem[] = cloneDeep(config.bootData.navTree);
const bottomNav = navTree.filter((item) => item.hideFromMenu);
const isSignedIn = contextSrv.isSignedIn;
const location = useLocation();
const activeItemId = bottomNav.find((item) => isLinkActive(location.pathname, item))?.id;
const forcedLoginUrl = getForcedLoginUrl(location.pathname + location.search);
const user = contextSrv.user;
const [showSwitcherModal, setShowSwitcherModal] = useState(false);
const toggleSwitcherModal = () => {
setShowSwitcherModal(!showSwitcherModal);
};
const onOpenShortcuts = () => {
appEvents.publish(new ShowModalReactEvent({ component: HelpModal }));
};
if (user && user.orgCount > 1) {
const profileNode = bottomNav.find((bottomNavItem) => bottomNavItem.id === 'profile');
if (profileNode) {
profileNode.showOrgSwitcher = true;
profileNode.subTitle = `Current Org.: ${user?.orgName}`;
}
}
return (
<div data-testid="bottom-section-items" className={styles.container}>
{!isSignedIn && (
<NavBarItem label="Sign In" target="_self" url={forcedLoginUrl}>
<Icon name="signout" size="xl" />
</NavBarItem>
)}
{bottomNav.map((link, index) => {
let menuItems = link.children || [];
if (link.id === 'help') {
menuItems = [
...getFooterLinks(),
{
text: 'Keyboard shortcuts',
icon: 'keyboard',
onClick: onOpenShortcuts,
},
];
}
if (link.showOrgSwitcher) {
menuItems = [
...menuItems,
{
text: 'Switch organization',
icon: 'arrow-random',
onClick: toggleSwitcherModal,
},
];
}
return (
<NavBarItem
key={`${link.url}-${index}`}
isActive={!isSearchActive(location) && activeItemId === link.id}
label={link.text}
menuItems={menuItems}
menuSubTitle={link.subTitle}
onClick={link.onClick}
reverseMenuDirection
target={link.target}
url={link.url}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
);
})}
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
display: none;
${theme.breakpoints.up('md')} {
display: flex;
flex-direction: inherit;
margin-bottom: ${theme.spacing(2)};
}
.sidemenu-open--xs & {
display: block;
}
`,
});

View File

@ -1,14 +1,17 @@
import React, { FC, useCallback } from 'react';
import React, { FC, useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, useTheme2 } from '@grafana/ui';
import { cloneDeep } from 'lodash';
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
import { Icon, IconName, useTheme2 } from '@grafana/ui';
import { locationService } from '@grafana/runtime';
import appEvents from '../../app_events';
import { Branding } from 'app/core/components/Branding/Branding';
import config from 'app/core/config';
import { CoreEvents, KioskMode } from 'app/types';
import TopSection from './TopSection';
import BottomSection from './BottomSection';
import { enrichConfigItems, isLinkActive, isSearchActive } from './utils';
import { OrgSwitcher } from '../OrgSwitcher';
import NavBarItem from './NavBarItem';
const homeUrl = config.appSubUrl || '/';
@ -18,6 +21,20 @@ export const NavBar: FC = React.memo(() => {
const location = useLocation();
const query = new URLSearchParams(location.search);
const kiosk = query.get('kiosk') as KioskMode;
const [showSwitcherModal, setShowSwitcherModal] = useState(false);
const toggleSwitcherModal = () => {
setShowSwitcherModal(!showSwitcherModal);
};
const navTree: NavModelItem[] = cloneDeep(config.bootData.navTree);
const topItems = navTree.filter((item) => item.section === NavSection.Core);
const bottomItems = enrichConfigItems(
navTree.filter((item) => item.section === NavSection.Config),
location,
toggleSwitcherModal
);
const activeItemId = isSearchActive(location)
? 'search'
: navTree.find((item) => isLinkActive(location.pathname, item))?.id;
const toggleNavBarSmallBreakpoint = useCallback(() => {
appEvents.emit(CoreEvents.toggleSidemenuMobile);
@ -27,11 +44,12 @@ export const NavBar: FC = React.memo(() => {
return null;
}
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
};
return (
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
<a href={homeUrl} className={styles.homeLogo}>
<Branding.MenuLogo />
</a>
<div className={styles.mobileSidemenuLogo} onClick={toggleNavBarSmallBreakpoint} key="hamburger">
<Icon name="bars" size="xl" />
<span className={styles.closeButton}>
@ -39,8 +57,53 @@ export const NavBar: FC = React.memo(() => {
Close
</span>
</div>
<TopSection />
<BottomSection />
<NavBarItem url={homeUrl} label="Home" className={styles.grafanaLogo} showMenu={false}>
<Branding.MenuLogo />
</NavBarItem>
<NavBarItem
className={styles.search}
isActive={activeItemId === 'search'}
label="Search dashboards"
onClick={onOpenSearch}
>
<Icon name="search" size="xl" />
</NavBarItem>
{topItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={activeItemId === link.id}
label={link.text}
menuItems={link.children}
target={link.target}
url={link.url}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
<div className={styles.spacer} />
{bottomItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={activeItemId === link.id}
label={link.text}
menuItems={link.children}
menuSubTitle={link.subTitle}
onClick={link.onClick}
reverseMenuDirection
target={link.target}
url={link.url}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
</nav>
);
});
@ -48,6 +111,19 @@ export const NavBar: FC = React.memo(() => {
NavBar.displayName = 'NavBar';
const getStyles = (theme: GrafanaTheme2) => ({
search: css`
display: none;
margin-top: ${theme.spacing(5)};
${theme.breakpoints.up('md')} {
display: block;
}
.sidemenu-open--xs & {
display: block;
margin-top: 0;
}
`,
sidemenu: css`
display: flex;
flex-direction: column;
@ -55,8 +131,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
z-index: ${theme.zIndex.sidemenu};
${theme.breakpoints.up('md')} {
background-color: ${theme.colors.background.primary};
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;
}
@ -68,29 +145,17 @@ const getStyles = (theme: GrafanaTheme2) => ({
.sidemenu-open--xs & {
background-color: ${theme.colors.background.primary};
box-shadow: ${theme.shadows.z1};
gap: ${theme.spacing(1)};
height: auto;
margin-left: 0;
position: absolute;
width: 100%;
}
`,
homeLogo: css`
grafanaLogo: css`
display: none;
min-height: ${theme.components.sidemenu.width}px;
&:focus-visible,
&:hover {
background-color: ${theme.colors.action.hover};
}
&:focus-visible {
box-shadow: none;
color: ${theme.colors.text.primary};
outline: 2px solid ${theme.colors.primary.main};
outline-offset: -2px;
transition: none;
}
img {
height: ${theme.spacing(3.5)};
width: ${theme.spacing(3.5)};
}
@ -120,4 +185,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: none;
}
`,
spacer: css`
flex: 1;
.sidemenu-open--xs & {
display: none;
}
`,
});

View File

@ -105,23 +105,20 @@ const getStyles = (
display: flex;
flex-direction: column;
float: none;
margin-bottom: ${theme.spacing(1)};
position: unset;
width: 100%;
}
`,
subtitle: css`
border-bottom: 1px solid ${theme.colors.border.weak};
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};
margin-bottom: ${theme.spacing(1)};
padding: ${theme.spacing(1)} ${theme.spacing(2)} ${theme.spacing(1)};
white-space: nowrap;
.sidemenu-open--xs & {
border-bottom: none;
margin-bottom: 0;
border-${reverseDirection ? 'bottom' : 'top'}: none;
}
`,
};

View File

@ -7,11 +7,13 @@ import NavBarDropdown from './NavBarDropdown';
export interface Props {
isActive?: boolean;
children: ReactNode;
className?: string;
label: string;
menuItems?: NavModelItem[];
menuSubTitle?: string;
onClick?: () => void;
reverseMenuDirection?: boolean;
showMenu?: boolean;
target?: HTMLAnchorElement['target'];
url?: string;
}
@ -19,11 +21,13 @@ export interface Props {
const NavBarItem = ({
isActive = false,
children,
className,
label,
menuItems = [],
menuSubTitle,
onClick,
reverseMenuDirection = false,
showMenu = true,
target,
url,
}: Props) => {
@ -56,17 +60,19 @@ const NavBarItem = ({
}
return (
<div className={cx(styles.container, 'dropdown', { dropup: reverseMenuDirection })}>
<div className={cx(styles.container, 'dropdown', className, { dropup: reverseMenuDirection })}>
{element}
<NavBarDropdown
headerTarget={target}
headerText={label}
headerUrl={url}
items={menuItems}
onHeaderClick={onClick}
reverseDirection={reverseMenuDirection}
subtitleText={menuSubTitle}
/>
{showMenu && (
<NavBarDropdown
headerTarget={target}
headerText={label}
headerUrl={url}
items={menuItems}
onHeaderClick={onClick}
reverseDirection={reverseMenuDirection}
subtitleText={menuSubTitle}
/>
)}
</div>
);
};
@ -152,8 +158,8 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
img {
border-radius: 50%;
height: 24px;
width: 24px;
height: ${theme.spacing(3)};
width: ${theme.spacing(3)};
}
`,
});

View File

@ -0,0 +1,46 @@
import React from 'react';
import { NavBarNext } from './NavBarNext';
import { render, screen } from '@testing-library/react';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { Provider } from 'react-redux';
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}>
<Router history={locationService.getHistory()}>
<NavBarNext />
</Router>
</Provider>
);
};
describe('Render', () => {
it('should render component', async () => {
setup();
const sidemenu = await screen.findByTestId('sidemenu');
expect(sidemenu).toBeInTheDocument();
});
it('should not render when in kiosk mode', async () => {
setup();
locationService.partial({ kiosk: 'full' });
const sidemenu = screen.queryByTestId('sidemenu');
expect(sidemenu).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,225 @@
import React, { FC, useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { css, cx } from '@emotion/css';
import { cloneDeep } from 'lodash';
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
import { Icon, IconName, useTheme2 } from '@grafana/ui';
import { locationService } from '@grafana/runtime';
import appEvents from '../../app_events';
import { Branding } from 'app/core/components/Branding/Branding';
import config from 'app/core/config';
import { CoreEvents, KioskMode } from 'app/types';
import { enrichConfigItems, isLinkActive, isSearchActive } from './utils';
import { OrgSwitcher } from '../OrgSwitcher';
import { NavBarSection } from './NavBarSection';
import NavBarItem from './NavBarItem';
const homeUrl = config.appSubUrl || '/';
export const NavBarNext: FC = React.memo(() => {
const theme = useTheme2();
const styles = getStyles(theme);
const location = useLocation();
const query = new URLSearchParams(location.search);
const kiosk = query.get('kiosk') as KioskMode;
const [showSwitcherModal, setShowSwitcherModal] = useState(false);
const toggleSwitcherModal = () => {
setShowSwitcherModal(!showSwitcherModal);
};
const navTree: NavModelItem[] = cloneDeep(config.bootData.navTree);
const coreItems = navTree.filter((item) => item.section === NavSection.Core);
const pluginItems = navTree.filter((item) => item.section === NavSection.Plugin);
const configItems = enrichConfigItems(
navTree.filter((item) => item.section === NavSection.Config),
location,
toggleSwitcherModal
);
const activeItemId = isSearchActive(location)
? 'search'
: navTree.find((item) => isLinkActive(location.pathname, item))?.id;
const toggleNavBarSmallBreakpoint = useCallback(() => {
appEvents.emit(CoreEvents.toggleSidemenuMobile);
}, []);
if (kiosk !== null) {
return null;
}
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
};
return (
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu">
<div className={styles.mobileSidemenuLogo} onClick={toggleNavBarSmallBreakpoint} key="hamburger">
<Icon name="bars" size="xl" />
<span className={styles.closeButton}>
<Icon name="times" />
Close
</span>
</div>
<NavBarSection>
<NavBarItem url={homeUrl} label="Home" className={styles.grafanaLogo} showMenu={false}>
<Branding.MenuLogo />
</NavBarItem>
<NavBarItem
className={styles.search}
isActive={activeItemId === 'search'}
label="Search dashboards"
onClick={onOpenSearch}
>
<Icon name="search" size="xl" />
</NavBarItem>
</NavBarSection>
<NavBarSection>
{coreItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={activeItemId === link.id}
label={link.text}
menuItems={link.children}
target={link.target}
url={link.url}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
</NavBarSection>
{pluginItems.length > 0 && (
<NavBarSection>
{pluginItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={activeItemId === link.id}
label={link.text}
menuItems={link.children}
menuSubTitle={link.subTitle}
onClick={link.onClick}
target={link.target}
url={link.url}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
</NavBarSection>
)}
<div className={styles.spacer} />
<NavBarSection>
{configItems.map((link, index) => (
<NavBarItem
key={`${link.id}-${index}`}
isActive={activeItemId === link.id}
label={link.text}
menuItems={link.children}
menuSubTitle={link.subTitle}
onClick={link.onClick}
reverseMenuDirection
target={link.target}
url={link.url}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
))}
</NavBarSection>
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />}
</nav>
);
});
NavBarNext.displayName = 'NavBar';
const getStyles = (theme: GrafanaTheme2) => ({
search: css`
display: none;
margin-top: 0;
${theme.breakpoints.up('md')} {
display: block;
}
.sidemenu-open--xs & {
display: block;
margin-top: 0;
}
`,
sidemenu: css`
display: flex;
flex-direction: column;
position: fixed;
z-index: ${theme.zIndex.sidemenu};
${theme.breakpoints.up('md')} {
background: none;
border-right: none;
gap: ${theme.spacing(1)};
margin-left: ${theme.spacing(1)};
padding: ${theme.spacing(1)} 0;
position: relative;
width: ${theme.components.sidemenu.width}px;
}
.sidemenu-hidden & {
display: none;
}
.sidemenu-open--xs & {
background-color: ${theme.colors.background.primary};
box-shadow: ${theme.shadows.z1};
gap: ${theme.spacing(1)};
height: auto;
margin-left: 0;
position: absolute;
width: 100%;
}
`,
grafanaLogo: css`
display: none;
img {
height: ${theme.spacing(3)};
width: ${theme.spacing(3)};
}
${theme.breakpoints.up('md')} {
align-items: center;
display: flex;
justify-content: center;
}
`,
closeButton: css`
display: none;
.sidemenu-open--xs & {
display: block;
font-size: ${theme.typography.fontSize}px;
}
`,
mobileSidemenuLogo: css`
align-items: center;
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: space-between;
padding: ${theme.spacing(2)};
${theme.breakpoints.up('md')} {
display: none;
}
`,
spacer: css`
flex: 1;
.sidemenu-open--xs & {
display: none;
}
`,
});

View File

@ -0,0 +1,42 @@
import React, { ReactNode } from 'react';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import config from '../../config';
export interface Props {
children: ReactNode;
className?: string;
}
export function NavBarSection({ children, className }: Props) {
const newNavigationEnabled = config.featureToggles.newNavigation;
const theme = useTheme2();
const styles = getStyles(theme, newNavigationEnabled);
return (
<div data-testid="navbar-section" className={cx(styles.container, className)}>
{children}
</div>
);
}
const getStyles = (theme: GrafanaTheme2, newNavigationEnabled: boolean) => ({
container: css`
display: none;
${theme.breakpoints.up('md')} {
background-color: ${newNavigationEnabled ? theme.colors.background.primary : 'inherit'};
border: ${newNavigationEnabled ? `1px solid ${theme.components.panel.borderColor}` : 'none'};
border-radius: 2px;
display: flex;
flex-direction: inherit;
}
.sidemenu-open--xs & {
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
}
`,
});

View File

@ -1,38 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import TopSection from './TopSection';
jest.mock('../../config', () => ({
bootData: {
navTree: [
{ id: '1', hideFromMenu: true },
{ id: '2', hideFromMenu: true },
{ id: '3', hideFromMenu: false },
{ id: '4', hideFromMenu: true },
{ id: '4', hideFromMenu: false },
],
},
}));
describe('Render', () => {
it('should render search when empty', () => {
render(
<BrowserRouter>
<TopSection />
</BrowserRouter>
);
expect(screen.getByText('Search dashboards')).toBeInTheDocument();
});
it('should render items and search item', () => {
render(
<BrowserRouter>
<TopSection />
</BrowserRouter>
);
expect(screen.getByTestId('top-section-items').children.length).toBe(3);
});
});

View File

@ -1,65 +0,0 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { cloneDeep } from 'lodash';
import { css } from '@emotion/css';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { Icon, IconName, useTheme2 } from '@grafana/ui';
import config from '../../config';
import { isLinkActive, isSearchActive } from './utils';
import NavBarItem from './NavBarItem';
const TopSection = () => {
const location = useLocation();
const theme = useTheme2();
const styles = getStyles(theme);
const navTree: NavModelItem[] = cloneDeep(config.bootData.navTree);
const mainLinks = navTree.filter((item) => !item.hideFromMenu);
const activeItemId = mainLinks.find((item) => isLinkActive(location.pathname, item))?.id;
const onOpenSearch = () => {
locationService.partial({ search: 'open' });
};
return (
<div data-testid="top-section-items" className={styles.container}>
<NavBarItem isActive={isSearchActive(location)} label="Search dashboards" onClick={onOpenSearch}>
<Icon name="search" size="xl" />
</NavBarItem>
{mainLinks.map((link, index) => {
return (
<NavBarItem
key={`${link.id}-${index}`}
isActive={!isSearchActive(location) && activeItemId === link.id}
label={link.text}
menuItems={link.children}
target={link.target}
url={link.url}
>
{link.icon && <Icon name={link.icon as IconName} size="xl" />}
{link.img && <img src={link.img} alt={`${link.text} logo`} />}
</NavBarItem>
);
})}
</div>
);
};
export default TopSection;
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
display: none;
flex-grow: 1;
${theme.breakpoints.up('md')} {
display: flex;
flex-direction: inherit;
margin-top: ${theme.spacing(5)};
}
.sidemenu-open--xs & {
display: block;
}
`,
});

View File

@ -1,6 +1,12 @@
import { Location } from 'history';
import { NavModelItem } from '@grafana/data';
import { updateConfig } from '../../config';
import { getForcedLoginUrl, isLinkActive, isSearchActive } from './utils';
import { ContextSrv, setContextSrv } from 'app/core/services/context_srv';
import { getConfig, updateConfig } from '../../config';
import { enrichConfigItems, getForcedLoginUrl, isLinkActive, isSearchActive } from './utils';
jest.mock('../../app_events', () => ({
publish: jest.fn(),
}));
describe('getForcedLoginUrl', () => {
it.each`
@ -25,6 +31,98 @@ describe('getForcedLoginUrl', () => {
);
});
describe('enrichConfigItems', () => {
let mockItems: NavModelItem[];
const mockLocation: Location<unknown> = {
hash: '',
pathname: '/',
search: '',
state: '',
};
beforeEach(() => {
mockItems = [
{
id: 'profile',
text: 'Profile',
hideFromMenu: true,
},
{
id: 'help',
text: 'Help',
hideFromMenu: true,
},
];
});
it('does not add a sign in item if a user signed in', () => {
const contextSrv = new ContextSrv();
contextSrv.user.isSignedIn = false;
setContextSrv(contextSrv);
const enrichedConfigItems = enrichConfigItems(mockItems, mockLocation, jest.fn());
const signInNode = enrichedConfigItems.find((item) => item.id === 'signin');
expect(signInNode).toBeDefined();
});
it('adds a sign in item if a user is not signed in', () => {
const contextSrv = new ContextSrv();
contextSrv.user.isSignedIn = true;
setContextSrv(contextSrv);
const enrichedConfigItems = enrichConfigItems(mockItems, mockLocation, jest.fn());
const signInNode = enrichedConfigItems.find((item) => item.id === 'signin');
expect(signInNode).toBeDefined();
});
it('does not add an org switcher to the profile node if there is 1 org', () => {
const contextSrv = new ContextSrv();
contextSrv.user.orgCount = 1;
setContextSrv(contextSrv);
const enrichedConfigItems = enrichConfigItems(mockItems, mockLocation, jest.fn());
const profileNode = enrichedConfigItems.find((item) => item.id === 'profile');
expect(profileNode!.children).toBeUndefined();
});
it('adds an org switcher to the profile node if there is more than 1 org', () => {
const contextSrv = new ContextSrv();
contextSrv.user.orgCount = 2;
setContextSrv(contextSrv);
const enrichedConfigItems = enrichConfigItems(mockItems, mockLocation, jest.fn());
const profileNode = enrichedConfigItems.find((item) => item.id === 'profile');
expect(profileNode!.children).toContainEqual(
expect.objectContaining({
text: 'Switch organization',
})
);
});
it('enhances the help node with extra child links', () => {
const contextSrv = new ContextSrv();
setContextSrv(contextSrv);
const enrichedConfigItems = enrichConfigItems(mockItems, mockLocation, jest.fn());
const helpNode = enrichedConfigItems.find((item) => item.id === 'help');
expect(helpNode!.children).toContainEqual(
expect.objectContaining({
text: 'Documentation',
})
);
expect(helpNode!.children).toContainEqual(
expect.objectContaining({
text: 'Support',
})
);
expect(helpNode!.children).toContainEqual(
expect.objectContaining({
text: 'Community',
})
);
expect(helpNode!.children).toContainEqual(
expect.objectContaining({
text: 'Keyboard shortcuts',
})
);
});
});
describe('isLinkActive', () => {
it('returns true if the link url matches the pathname', () => {
const mockPathName = '/test';
@ -82,6 +180,25 @@ describe('isLinkActive', () => {
expect(isLinkActive(mockPathName, mockLink)).toBe(true);
});
it('returns true for the alerting link if the pathname is an alert notification', () => {
const mockPathName = '/alerting/notification/foo';
const mockLink: NavModelItem = {
text: 'Test',
url: '/alerting/list',
children: [
{
text: 'TestChild',
url: '/testChild',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(true);
});
it('returns false if none of the link urls match the pathname', () => {
const mockPathName = '/somethingWeird';
const mockLink: NavModelItem = {
@ -119,6 +236,104 @@ describe('isLinkActive', () => {
};
expect(isLinkActive(mockPathName, mockLink)).toBe(false);
});
describe('when the newNavigation feature toggle is disabled', () => {
beforeEach(() => {
updateConfig({
featureToggles: {
...getConfig().featureToggles,
newNavigation: false,
},
});
});
it('returns true for the base route link if the pathname starts with /d/', () => {
const mockPathName = '/d/foo';
const mockLink: NavModelItem = {
text: 'Test',
url: '/',
children: [
{
text: 'TestChild',
url: '/testChild',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(true);
});
it('returns false for the dashboards route if the pathname starts with /d/', () => {
const mockPathName = '/d/foo';
const mockLink: NavModelItem = {
text: 'Test',
url: '/dashboards',
children: [
{
text: 'TestChild',
url: '/testChild1',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(false);
});
});
describe('when the newNavigation feature toggle is enabled', () => {
beforeEach(() => {
updateConfig({
featureToggles: {
...getConfig().featureToggles,
newNavigation: true,
},
});
});
it('returns false for the base route if the pathname starts with /d/', () => {
const mockPathName = '/d/foo';
const mockLink: NavModelItem = {
text: 'Test',
url: '/',
children: [
{
text: 'TestChild',
url: '/',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(false);
});
it('returns true for the dashboards route if the pathname starts with /d/', () => {
const mockPathName = '/d/foo';
const mockLink: NavModelItem = {
text: 'Test',
url: '/dashboards',
children: [
{
text: 'TestChild',
url: '/testChild1',
},
{
text: 'TestChild2',
url: '/testChild2',
},
],
};
expect(isLinkActive(mockPathName, mockLink)).toBe(true);
});
});
});
describe('isSearchActive', () => {

View File

@ -1,6 +1,11 @@
import { NavModelItem } from '@grafana/data';
import { getConfig } from 'app/core/config';
import { Location } from 'history';
import { NavModelItem, NavSection } from '@grafana/data';
import { getConfig } from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import { ShowModalReactEvent } from '../../../types/events';
import appEvents from '../../app_events';
import { getFooterLinks } from '../Footer/Footer';
import { HelpModal } from '../help/HelpModal';
export const getForcedLoginUrl = (url: string) => {
const queryParams = new URLSearchParams(url.split('?')[1]);
@ -9,10 +14,71 @@ export const getForcedLoginUrl = (url: string) => {
return `${getConfig().appSubUrl}${url.split('?')[0]}?${queryParams.toString()}`;
};
export const enrichConfigItems = (
items: NavModelItem[],
location: Location<unknown>,
toggleOrgSwitcher: () => void
) => {
const { isSignedIn, user } = contextSrv;
const onOpenShortcuts = () => {
appEvents.publish(new ShowModalReactEvent({ component: HelpModal }));
};
if (user && user.orgCount > 1) {
const profileNode = items.find((bottomNavItem) => bottomNavItem.id === 'profile');
if (profileNode) {
profileNode.showOrgSwitcher = true;
profileNode.subTitle = `Current Org.: ${user?.orgName}`;
}
}
if (!isSignedIn) {
const forcedLoginUrl = getForcedLoginUrl(location.pathname + location.search);
items.unshift({
icon: 'signout',
id: 'signin',
section: NavSection.Config,
target: '_self',
text: 'Sign in',
url: forcedLoginUrl,
});
}
items.forEach((link, index) => {
let menuItems = link.children || [];
if (link.id === 'help') {
link.children = [
...getFooterLinks(),
{
text: 'Keyboard shortcuts',
icon: 'keyboard',
onClick: onOpenShortcuts,
},
];
}
if (link.showOrgSwitcher) {
link.children = [
...menuItems,
{
text: 'Switch organization',
icon: 'arrow-random',
onClick: toggleOrgSwitcher,
},
];
}
});
return items;
};
export const isLinkActive = (pathname: string, link: NavModelItem) => {
// strip out any query params
const linkPathname = link.url?.split('?')[0];
const newNavigationEnabled = getConfig().featureToggles.newNavigation;
if (linkPathname) {
const dashboardLinkMatch = newNavigationEnabled ? '/dashboards' : '/';
if (linkPathname === pathname) {
// exact match
return true;
@ -23,7 +89,7 @@ export const isLinkActive = (pathname: string, link: NavModelItem) => {
// alert channel match
// TODO refactor routes such that we don't need this custom logic
return true;
} else if (linkPathname === '/' && pathname.startsWith('/d/')) {
} else if (linkPathname === dashboardLinkMatch && pathname.startsWith('/d/')) {
// dashboard match
// TODO refactor routes such that we don't need this custom logic
return true;