From 727a4bd9e4bda52fbd648d9be5fd48b2888b4051 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 2 Nov 2021 11:19:18 +0000 Subject: [PATCH] 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 --- packages/grafana-data/src/types/navModel.ts | 7 + pkg/api/dtos/index.go | 12 +- pkg/api/index.go | 219 +++++++++++------ public/app/AppWrapper.tsx | 4 +- .../components/NavBar/BottomSection.test.tsx | 107 --------- .../core/components/NavBar/BottomSection.tsx | 113 --------- public/app/core/components/NavBar/NavBar.tsx | 126 +++++++--- .../core/components/NavBar/NavBarDropdown.tsx | 7 +- .../app/core/components/NavBar/NavBarItem.tsx | 30 ++- .../components/NavBar/NavBarNext.test.tsx | 46 ++++ .../app/core/components/NavBar/NavBarNext.tsx | 225 ++++++++++++++++++ .../core/components/NavBar/NavBarSection.tsx | 42 ++++ .../components/NavBar/TopSection.test.tsx | 38 --- .../app/core/components/NavBar/TopSection.tsx | 65 ----- .../app/core/components/NavBar/utils.test.ts | 219 ++++++++++++++++- public/app/core/components/NavBar/utils.ts | 72 +++++- 16 files changed, 889 insertions(+), 443 deletions(-) delete mode 100644 public/app/core/components/NavBar/BottomSection.test.tsx delete mode 100644 public/app/core/components/NavBar/BottomSection.tsx create mode 100644 public/app/core/components/NavBar/NavBarNext.test.tsx create mode 100644 public/app/core/components/NavBar/NavBarNext.tsx create mode 100644 public/app/core/components/NavBar/NavBarSection.tsx delete mode 100644 public/app/core/components/NavBar/TopSection.test.tsx delete mode 100644 public/app/core/components/NavBar/TopSection.tsx diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 814e648985d..4ef66f69dbf 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -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. */ diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 25743584f3f..59388d6c293 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -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"` diff --git a/pkg/api/index.go b/pkg/api/index.go index 1cebaf4f35e..e2132247670 100644 --- a/pkg/api/index.go +++ b/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"}, diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index 9502fe3e60c..eed25f8f2ee 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -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`; + const newNavigationEnabled = config.featureToggles.newNavigation; return ( @@ -100,7 +102,7 @@ export class AppWrapper extends React.Component
- + {newNavigationEnabled ? : }
{pageBanners.map((Banner, index) => ( diff --git a/public/app/core/components/NavBar/BottomSection.test.tsx b/public/app/core/components/NavBar/BottomSection.test.tsx deleted file mode 100644 index 7cb4ac70e8c..00000000000 --- a/public/app/core/components/NavBar/BottomSection.test.tsx +++ /dev/null @@ -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( - - - - ); - - expect(screen.getByTestId('bottom-section-items').children.length).toBe(3); - }); - - it('creates the correct children for the help link', () => { - render( - -
- -
-
- ); - - 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( - - - - ); - - 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( - - - - ); - - const currentOrg = screen.getByText(new RegExp('Grafana', 'i')); - const orgSwitcher = screen.getByText('Switch organization'); - expect(currentOrg).toBeInTheDocument(); - expect(orgSwitcher).toBeInTheDocument(); - }); -}); diff --git a/public/app/core/components/NavBar/BottomSection.tsx b/public/app/core/components/NavBar/BottomSection.tsx deleted file mode 100644 index 596fdfd945a..00000000000 --- a/public/app/core/components/NavBar/BottomSection.tsx +++ /dev/null @@ -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 ( -
- {!isSignedIn && ( - - - - )} - {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 ( - - {link.icon && } - {link.img && {`${link.text}} - - ); - })} - {showSwitcherModal && } -
- ); -} - -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; - } - `, -}); diff --git a/public/app/core/components/NavBar/NavBar.tsx b/public/app/core/components/NavBar/NavBar.tsx index 25fdfaa35ae..4dbfd54cf54 100644 --- a/public/app/core/components/NavBar/NavBar.tsx +++ b/public/app/core/components/NavBar/NavBar.tsx @@ -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 ( ); }); @@ -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; + } + `, }); diff --git a/public/app/core/components/NavBar/NavBarDropdown.tsx b/public/app/core/components/NavBar/NavBarDropdown.tsx index a3911dcde90..3da5e6e063d 100644 --- a/public/app/core/components/NavBar/NavBarDropdown.tsx +++ b/public/app/core/components/NavBar/NavBarDropdown.tsx @@ -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; } `, }; diff --git a/public/app/core/components/NavBar/NavBarItem.tsx b/public/app/core/components/NavBar/NavBarItem.tsx index d15ffb14ab9..d9ebd84b2f5 100644 --- a/public/app/core/components/NavBar/NavBarItem.tsx +++ b/public/app/core/components/NavBar/NavBarItem.tsx @@ -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 ( -
+
{element} - + {showMenu && ( + + )}
); }; @@ -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)}; } `, }); diff --git a/public/app/core/components/NavBar/NavBarNext.test.tsx b/public/app/core/components/NavBar/NavBarNext.test.tsx new file mode 100644 index 00000000000..2b48ab83124 --- /dev/null +++ b/public/app/core/components/NavBar/NavBarNext.test.tsx @@ -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( + + + + + + ); +}; + +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(); + }); +}); diff --git a/public/app/core/components/NavBar/NavBarNext.tsx b/public/app/core/components/NavBar/NavBarNext.tsx new file mode 100644 index 00000000000..1d9997da006 --- /dev/null +++ b/public/app/core/components/NavBar/NavBarNext.tsx @@ -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 ( + + ); +}); + +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; + } + `, +}); diff --git a/public/app/core/components/NavBar/NavBarSection.tsx b/public/app/core/components/NavBar/NavBarSection.tsx new file mode 100644 index 00000000000..fa0ffb4675c --- /dev/null +++ b/public/app/core/components/NavBar/NavBarSection.tsx @@ -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 ( +
+ {children} +
+ ); +} + +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)}; + } + `, +}); diff --git a/public/app/core/components/NavBar/TopSection.test.tsx b/public/app/core/components/NavBar/TopSection.test.tsx deleted file mode 100644 index b285c1407c2..00000000000 --- a/public/app/core/components/NavBar/TopSection.test.tsx +++ /dev/null @@ -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( - - - - ); - - expect(screen.getByText('Search dashboards')).toBeInTheDocument(); - }); - - it('should render items and search item', () => { - render( - - - - ); - - expect(screen.getByTestId('top-section-items').children.length).toBe(3); - }); -}); diff --git a/public/app/core/components/NavBar/TopSection.tsx b/public/app/core/components/NavBar/TopSection.tsx deleted file mode 100644 index a46b91e30a9..00000000000 --- a/public/app/core/components/NavBar/TopSection.tsx +++ /dev/null @@ -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 ( -
- - - - {mainLinks.map((link, index) => { - return ( - - {link.icon && } - {link.img && {`${link.text}} - - ); - })} -
- ); -}; - -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; - } - `, -}); diff --git a/public/app/core/components/NavBar/utils.test.ts b/public/app/core/components/NavBar/utils.test.ts index b0ac98dc2f4..ae3f38d235e 100644 --- a/public/app/core/components/NavBar/utils.test.ts +++ b/public/app/core/components/NavBar/utils.test.ts @@ -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 = { + 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', () => { diff --git a/public/app/core/components/NavBar/utils.ts b/public/app/core/components/NavBar/utils.ts index 97f5b6fc026..d78ca04ed3d 100644 --- a/public/app/core/components/NavBar/utils.ts +++ b/public/app/core/components/NavBar/utils.ts @@ -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, + 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;