mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
469a5e4a85
commit
727a4bd9e4
@ -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.
|
||||
*/
|
||||
|
@ -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"`
|
||||
|
219
pkg/api/index.go
219
pkg/api/index.go
@ -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"},
|
||||
|
@ -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()} />
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
`,
|
||||
});
|
@ -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;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
@ -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)};
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
46
public/app/core/components/NavBar/NavBarNext.test.tsx
Normal file
46
public/app/core/components/NavBar/NavBarNext.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
225
public/app/core/components/NavBar/NavBarNext.tsx
Normal file
225
public/app/core/components/NavBar/NavBarNext.tsx
Normal 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;
|
||||
}
|
||||
`,
|
||||
});
|
42
public/app/core/components/NavBar/NavBarSection.tsx
Normal file
42
public/app/core/components/NavBar/NavBarSection.tsx
Normal 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)};
|
||||
}
|
||||
`,
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
`,
|
||||
});
|
@ -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', () => {
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user