diff --git a/conf/defaults.ini b/conf/defaults.ini index 250a8396e1e..b2034acc9ac 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1184,9 +1184,6 @@ commandPalette = true # Use dynamic labels in CloudWatch datasource cloudWatchDynamicLabels = true -# New expandable navigation -newNavigation = true - # feature1 = true # feature2 = false diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 33708fe1626..6190c7a3f7d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -32,7 +32,6 @@ export interface FeatureToggles { prometheus_azure_auth?: boolean; prometheusAzureOverrideAudience?: boolean; influxdbBackendMigration?: boolean; - newNavigation?: boolean; showFeatureFlagsInUI?: boolean; publicDashboards?: boolean; lokiLive?: boolean; diff --git a/pkg/api/index.go b/pkg/api/index.go index bdcef87cf94..9804dc02222 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -92,15 +92,10 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) Id: "plugin-page-" + plugin.ID, Url: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL), Img: plugin.Info.Logos.Small, + Section: dtos.NavSectionPlugin, SortWeight: dtos.WeightPlugin, } - if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { - appLink.Section = dtos.NavSectionPlugin - } else { - appLink.Section = dtos.NavSectionCore - } - for _, include := range plugin.Includes { if !c.HasUserRole(include.Role) { continue @@ -187,25 +182,9 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs * }) } - if hasEditPerm && !hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { - children := hs.buildCreateNavLinks(c) - navTree = append(navTree, &dtos.NavLink{ - Text: "Create", - Id: "create", - Icon: "plus", - Url: hs.Cfg.AppSubURL + "/dashboard/new", - Children: children, - Section: dtos.NavSectionCore, - SortWeight: dtos.WeightCreate, - }) - } - dashboardChildLinks := hs.buildDashboardNavLinks(c, hasEditPerm) - dashboardsUrl := "/" - if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { - dashboardsUrl = "/dashboards" - } + dashboardsUrl := "/dashboards" navTree = append(navTree, &dtos.NavLink{ Text: "Dashboards", @@ -359,25 +338,17 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs * SubTitle: "Organization: " + c.OrgName, Icon: "cog", Url: configNodes[0].Url, + Section: dtos.NavSectionConfig, SortWeight: dtos.WeightConfig, Children: configNodes, } - if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { - configNode.Section = dtos.NavSectionConfig - } else { - configNode.Section = dtos.NavSectionCore - } navTree = append(navTree, configNode) } adminNavLinks := hs.buildAdminNavLinks(c) if len(adminNavLinks) > 0 { - navSection := dtos.NavSectionCore - if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { - navSection = dtos.NavSectionConfig - } - serverAdminNode := navlinks.GetServerAdminNode(adminNavLinks, navSection) + serverAdminNode := navlinks.GetServerAdminNode(adminNavLinks) navTree = append(navTree, serverAdminNode) } @@ -467,14 +438,6 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b } dashboardChildNavs := []*dtos.NavLink{} - if !hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "Home", Id: "home", Url: hs.Cfg.AppSubURL + "/", Icon: "home-alt", HideFromTabs: true, - }) - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, - }) - } dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ Text: "Browse", Id: "manage-dashboards", Url: hs.Cfg.AppSubURL + "/dashboards", Icon: "sitemap", }) @@ -498,7 +461,7 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b }) } - if hasEditPerm && hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { + if hasEditPerm { dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, }) @@ -583,8 +546,7 @@ func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext) []*dtos.NavLink { }) } - if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) && - hasAccess(hs.editorInAnyFolder, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) { + if hasAccess(hs.editorInAnyFolder, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) { alertChildNavs = append(alertChildNavs, &dtos.NavLink{ Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, }) @@ -612,41 +574,6 @@ func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext) []*dtos.NavLink { return nil } -func (hs *HTTPServer) buildCreateNavLinks(c *models.ReqContext) []*dtos.NavLink { - hasAccess := ac.HasAccess(hs.AccessControl, c) - var children []*dtos.NavLink - - if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsCreate)) { - children = append(children, &dtos.NavLink{Text: "Dashboard", Icon: "apps", Url: hs.Cfg.AppSubURL + "/dashboard/new", Id: "create-dashboard"}) - } - - if hasAccess(ac.ReqOrgAdminOrEditor, ac.EvalPermission(dashboards.ActionFoldersCreate)) { - children = append(children, &dtos.NavLink{ - Text: "Folder", SubTitle: "Create a new folder to organize your dashboards", Id: "folder", - Icon: "folder-plus", Url: hs.Cfg.AppSubURL + "/dashboards/folder/new", - }) - } - - if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsCreate)) { - children = append(children, &dtos.NavLink{ - Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "import", Icon: "import", - Url: hs.Cfg.AppSubURL + "/dashboard/import", - }) - } - - _, uaIsDisabledForOrg := hs.Cfg.UnifiedAlerting.DisabledOrgs[c.OrgId] - uaVisibleForOrg := hs.Cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg - - if uaVisibleForOrg && hasAccess(ac.ReqSignedIn, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) { - children = append(children, &dtos.NavLink{ - Text: "New alert rule", SubTitle: "Create an alert rule", Id: "alert", - Icon: "bell", Url: hs.Cfg.AppSubURL + "/alerting/new", - }) - } - - return children -} - func (hs *HTTPServer) buildDataConnectionsNavLink(c *models.ReqContext) *dtos.NavLink { var children []*dtos.NavLink var navLink *dtos.NavLink diff --git a/pkg/api/navlinks/navlinks.go b/pkg/api/navlinks/navlinks.go index 6d7ddfa535b..1f39f0d3db7 100644 --- a/pkg/api/navlinks/navlinks.go +++ b/pkg/api/navlinks/navlinks.go @@ -2,7 +2,7 @@ package navlinks import "github.com/grafana/grafana/pkg/api/dtos" -func GetServerAdminNode(children []*dtos.NavLink, navSection string) *dtos.NavLink { +func GetServerAdminNode(children []*dtos.NavLink) *dtos.NavLink { url := "" if len(children) > 0 { url = children[0].Url @@ -15,7 +15,7 @@ func GetServerAdminNode(children []*dtos.NavLink, navSection string) *dtos.NavLi Icon: "shield", Url: url, SortWeight: dtos.WeightAdmin, - Section: navSection, + Section: dtos.NavSectionConfig, Children: children, } } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 620282f855d..97cb32c0228 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -95,11 +95,6 @@ var ( State: FeatureStateAlpha, FrontendOnly: true, }, - { - Name: "newNavigation", - Description: "Try the next gen navigation model", - State: FeatureStateAlpha, - }, { Name: "showFeatureFlagsInUI", Description: "Show feature flags in the settings UI", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 7e8fca720d2..0e53265b025 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -71,10 +71,6 @@ const ( // Query InfluxDB InfluxQL without the proxy FlagInfluxdbBackendMigration = "influxdbBackendMigration" - // FlagNewNavigation - // Try the next gen navigation model - FlagNewNavigation = "newNavigation" - // FlagShowFeatureFlagsInUI // Show feature flags in the settings UI FlagShowFeatureFlagsInUI = "showFeatureFlagsInUI" diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index ea4a1bca653..d1464843e82 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -14,7 +14,6 @@ import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabl import { GrafanaApp } from './app'; import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList'; import { NavBar } from './core/components/NavBar/NavBar'; -import { NavBarNext } from './core/components/NavBar/Next/NavBarNext'; import { I18nProvider } from './core/localisation'; import { GrafanaRoute } from './core/navigation/GrafanaRoute'; import { RouteDescriptor } from './core/navigation/types'; @@ -87,8 +86,6 @@ export class AppWrapper extends React.Component { reportInteraction('commandPalette_action_selected', { actionId: action.id, @@ -98,7 +95,7 @@ export class AppWrapper extends React.Component !config.isPublicDashboardView && config.featureToggles.commandPalette; const renderNavBar = () => { - return !config.isPublicDashboardView && ready && <>{newNavigationEnabled ? : }; + return !config.isPublicDashboardView && ready && ; }; const searchBarEnabled = () => !config.isPublicDashboardView; diff --git a/public/app/core/components/NavBar/NavBar.test.tsx b/public/app/core/components/NavBar/NavBar.test.tsx index 39e2949a651..066c4c3f652 100644 --- a/public/app/core/components/NavBar/NavBar.test.tsx +++ b/public/app/core/components/NavBar/NavBar.test.tsx @@ -36,6 +36,17 @@ const setup = () => { }; describe('Render', () => { + beforeEach(() => { + // IntersectionObserver isn't available in test environment + const mockIntersectionObserver = jest.fn(); + mockIntersectionObserver.mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null, + }); + window.IntersectionObserver = mockIntersectionObserver; + }); + it('should render component', async () => { setup(); const sidemenu = await screen.findByTestId('sidemenu'); diff --git a/public/app/core/components/NavBar/NavBar.tsx b/public/app/core/components/NavBar/NavBar.tsx index deb4a58b24e..842ecccfbd9 100644 --- a/public/app/core/components/NavBar/NavBar.tsx +++ b/public/app/core/components/NavBar/NavBar.tsx @@ -1,14 +1,14 @@ import { css, cx } from '@emotion/css'; +import { FocusScope } from '@react-aria/focus'; import { cloneDeep } from 'lodash'; import React, { useState } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; -import { Icon, IconName, useTheme2 } from '@grafana/ui'; +import { config, locationService, reportInteraction } from '@grafana/runtime'; +import { Icon, useTheme2 } from '@grafana/ui'; import { Branding } from 'app/core/components/Branding/Branding'; -import config from 'app/core/config'; import { getKioskMode } from 'app/core/navigation/kiosk'; import { KioskMode, StoreState } from 'app/types'; @@ -17,169 +17,269 @@ import { OrgSwitcher } from '../OrgSwitcher'; import NavBarItem from './NavBarItem'; import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; import { NavBarMenu } from './NavBarMenu'; -import { NavBarSection } from './NavBarSection'; -import { enrichConfigItems, getActiveItem, isMatchOrChildMatch, isSearchActive, SEARCH_ITEM_ID } from './utils'; - -const homeUrl = config.appSubUrl || '/'; +import { NavBarMenuPortalContainer } from './NavBarMenuPortalContainer'; +import { NavBarScrollContainer } from './NavBarScrollContainer'; +import { NavBarToggle } from './NavBarToggle'; +import { NavBarContext } from './context'; +import { + enrichConfigItems, + enrichWithInteractionTracking, + getActiveItem, + isMatchOrChildMatch, + isSearchActive, + SEARCH_ITEM_ID, +} from './utils'; const onOpenSearch = () => { locationService.partial({ search: 'open' }); }; -const searchItem: NavModelItem = { - id: SEARCH_ITEM_ID, - onClick: onOpenSearch, - text: 'Search dashboards', - icon: 'search', -}; - -const mapStateToProps = (state: StoreState) => ({ - navBarTree: state.navBarTree, -}); - -const mapDispatchToProps = {}; - -const connector = connect(mapStateToProps, mapDispatchToProps); - -export interface Props extends ConnectedProps {} - -export const NavBarUnconnected = React.memo(({ navBarTree }: Props) => { +export const NavBar = React.memo(() => { + const navBarTree = useSelector((state: StoreState) => state.navBarTree); const theme = useTheme2(); const styles = getStyles(theme); const location = useLocation(); const kiosk = getKioskMode(); const [showSwitcherModal, setShowSwitcherModal] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const [menuAnimationInProgress, setMenuAnimationInProgress] = useState(false); + const [menuIdOpen, setMenuIdOpen] = useState(undefined); + const toggleSwitcherModal = () => { setShowSwitcherModal(!showSwitcherModal); }; + + // Here we need to hack in a "home" and "search" NavModelItem since this is constructed in the frontend + const searchItem: NavModelItem = enrichWithInteractionTracking( + { + id: SEARCH_ITEM_ID, + onClick: onOpenSearch, + text: 'Search dashboards', + icon: 'search', + }, + menuOpen + ); + + const homeItem: NavModelItem = enrichWithInteractionTracking( + { + id: 'home', + text: 'Home', + url: config.appSubUrl || '/', + icon: 'grafana', + }, + menuOpen + ); + const navTree = cloneDeep(navBarTree); - const topItems = navTree.filter((item) => item.section === NavSection.Core); - const bottomItems = enrichConfigItems( + + const coreItems = navTree + .filter((item) => item.section === NavSection.Core) + .map((item) => enrichWithInteractionTracking(item, menuOpen)); + const pluginItems = navTree + .filter((item) => item.section === NavSection.Plugin) + .map((item) => enrichWithInteractionTracking(item, menuOpen)); + const configItems = enrichConfigItems( navTree.filter((item) => item.section === NavSection.Config), location, toggleSwitcherModal - ); - const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname); + ).map((item) => enrichWithInteractionTracking(item, menuOpen)); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname); if (kiosk !== KioskMode.Off) { return null; } - return ( - + ); }); -NavBarUnconnected.displayName = 'NavBar'; - -export const NavBar = connector(NavBarUnconnected); +NavBar.displayName = 'NavBar'; const getStyles = (theme: GrafanaTheme2) => ({ - search: css` - display: none; - margin-top: ${theme.spacing(5)}; + navWrapper: css({ + position: 'relative', + display: 'flex', - ${theme.breakpoints.up('md')} { - display: block; - } - `, - sidemenu: css` - display: flex; - flex-direction: column; - position: fixed; - z-index: ${theme.zIndex.sidemenu}; + '.sidemenu-hidden &': { + display: 'none', + }, + }), + sidemenu: css({ + label: 'sidemenu', + display: 'flex', + flexDirection: 'column', + backgroundColor: theme.colors.background.primary, + zIndex: theme.zIndex.sidemenu, + padding: `${theme.spacing(1)} 0`, + position: 'relative', + width: theme.components.sidemenu.width, + borderRight: `1px solid ${theme.colors.border.weak}`, - ${theme.breakpoints.up('md')} { - background: ${theme.colors.background.primary}; - border-right: 1px solid ${theme.components.panel.borderColor}; - padding: 0 0 ${theme.spacing(1)} 0; - position: relative; - width: ${theme.components.sidemenu.width}px; - } + [theme.breakpoints.down('md')]: { + height: theme.spacing(7), + position: 'fixed', + paddingTop: '0px', + backgroundColor: 'inherit', + borderRight: 0, + }, + }), + mobileSidemenuLogo: css({ + alignItems: 'center', + cursor: 'pointer', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + padding: theme.spacing(2), - .sidemenu-hidden & { - display: none; - } - `, - grafanaLogo: css` - display: none; - img { - height: ${theme.spacing(3.5)}; - width: ${theme.spacing(3.5)}; - } + [theme.breakpoints.up('md')]: { + display: 'none', + }, + }), + itemList: css({ + backgroundColor: 'inherit', + display: 'flex', + flexDirection: 'column', + height: '100%', - ${theme.breakpoints.up('md')} { - align-items: center; - display: flex; - justify-content: center; - } - `, - mobileSidemenuLogo: css` - align-items: center; - cursor: pointer; - display: flex; - flex-direction: row; - justify-content: space-between; - padding: ${theme.spacing(2)}; + [theme.breakpoints.down('md')]: { + visibility: 'hidden', + }, + }), + grafanaLogo: css({ + alignItems: 'stretch', + display: 'flex', + flexShrink: 0, + height: theme.spacing(6), + justifyContent: 'stretch', - ${theme.breakpoints.up('md')} { - display: none; - } - `, - spacer: css` - flex: 1; - `, + [theme.breakpoints.down('md')]: { + visibility: 'hidden', + }, + }), + grafanaLogoInner: css({ + alignItems: 'center', + display: 'flex', + height: '100%', + justifyContent: 'center', + width: '100%', + + '> div': { + height: 'auto', + width: 'auto', + }, + }), + search: css({ + display: 'none', + marginTop: 0, + + [theme.breakpoints.up('md')]: { + display: 'grid', + }, + }), + verticalSpacer: css({ + marginTop: 'auto', + }), + hideFromMobile: css({ + [theme.breakpoints.down('md')]: { + display: 'none', + }, + }), + menuWrapper: css({ + position: 'fixed', + display: 'grid', + gridAutoFlow: 'column', + height: '100%', + zIndex: theme.zIndex.sidemenu, + }), + menuExpandIcon: css({ + position: 'absolute', + top: '43px', + right: '0px', + transform: `translateX(50%)`, + }), + menuPortalContainer: css({ + zIndex: theme.zIndex.sidemenu, + }), }); diff --git a/public/app/core/components/NavBar/NavBarItem.test.tsx b/public/app/core/components/NavBar/NavBarItem.test.tsx index 1f72a08b4ee..77ccbf9400b 100644 --- a/public/app/core/components/NavBar/NavBarItem.test.tsx +++ b/public/app/core/components/NavBar/NavBarItem.test.tsx @@ -19,10 +19,11 @@ jest.mock('history', () => ({ })); import NavBarItem, { Props } from './NavBarItem'; +import { NavBarContext } from './context'; const onClickMock = jest.fn(); +const setMenuIdOpenMock = jest.fn(); const defaults: Props = { - children: undefined, link: { text: 'Parent Node', onClick: onClickMock, @@ -30,10 +31,11 @@ const defaults: Props = { { text: 'Child Node 1', onClick: onClickMock, children: [] }, { text: 'Child Node 2', onClick: onClickMock, children: [] }, ], + id: 'MY_NAV_ID', }, }; -async function getTestContext(overrides: Partial = {}, subUrl = '') { +async function getTestContext(overrides: Partial = {}, subUrl = '', isMenuOpen = false) { jest.clearAllMocks(); config.appSubUrl = subUrl; locationUtil.initialize({ config, getTimeRangeForUrl: jest.fn(), getVariablesUrlParams: jest.fn() }); @@ -45,7 +47,14 @@ async function getTestContext(overrides: Partial = {}, subUrl = '') { const { rerender } = render( - {props.children} + + + ); @@ -57,6 +66,17 @@ async function getTestContext(overrides: Partial = {}, subUrl = '') { } describe('NavBarItem', () => { + beforeEach(() => { + // IntersectionObserver isn't available in test environment + const mockIntersectionObserver = jest.fn(); + mockIntersectionObserver.mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null, + }); + window.IntersectionObserver = mockIntersectionObserver; + }); + describe('when url property is not set', () => { it('then it renders the menu trigger as a button', async () => { await getTestContext(); @@ -74,32 +94,34 @@ describe('NavBarItem', () => { }); describe('and hovering over the menu trigger button', () => { - it('then the menu items should be visible', async () => { + it('then the menuIdOpen should be set correctly', async () => { await getTestContext(); await userEvent.hover(screen.getByRole('button')); - - expect(screen.getByRole('menuitem', { name: 'Parent Node' })).toBeInTheDocument(); - expect(screen.getByText('Child Node 1')).toBeInTheDocument(); - expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id); }); }); describe('and tabbing to the menu trigger button', () => { - it('then the menu items should be visible', async () => { + it('then the menuIdOpen should be set correctly', async () => { await getTestContext(); await userEvent.tab(); - - expect(screen.getByText('Parent Node')).toBeInTheDocument(); - expect(screen.getByText('Child Node 1')).toBeInTheDocument(); - expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id); }); }); + it('shows the menu when the correct menuIdOpen is set', async () => { + await getTestContext(undefined, undefined, true); + + expect(screen.getByText('Parent Node')).toBeInTheDocument(); + expect(screen.getByText('Child Node 1')).toBeInTheDocument(); + expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + }); + describe('and pressing arrow right on the menu trigger button', () => { it('then the correct menu item should receive focus', async () => { - await getTestContext(); + await getTestContext(undefined, undefined, true); await userEvent.tab(); expect(screen.getAllByRole('menuitem')).toHaveLength(3); @@ -125,32 +147,36 @@ describe('NavBarItem', () => { }); describe('and hovering over the menu trigger link', () => { - it('then the menu items should be visible', async () => { + it('sets the correct menuIdOpen', async () => { await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); await userEvent.hover(screen.getByRole('link')); - expect(screen.getByText('Parent Node')).toBeInTheDocument(); - expect(screen.getByText('Child Node 1')).toBeInTheDocument(); - expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id); }); }); describe('and tabbing to the menu trigger link', () => { - it('then the menu items should be visible', async () => { + it('sets the correct menuIdOpen', async () => { await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); await userEvent.tab(); - expect(screen.getByText('Parent Node')).toBeInTheDocument(); - expect(screen.getByText('Child Node 1')).toBeInTheDocument(); - expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id); }); }); + it('shows the menu when the correct menuIdOpen is set', async () => { + await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }, undefined, true); + + expect(screen.getByText('Parent Node')).toBeInTheDocument(); + expect(screen.getByText('Child Node 1')).toBeInTheDocument(); + expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + }); + describe('and pressing arrow right on the menu trigger link', () => { it('then the correct menu item should receive focus', async () => { - await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); + await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }, undefined, true); await userEvent.tab(); expect(screen.getAllByRole('link')[0]).toHaveFocus(); @@ -170,7 +196,7 @@ describe('NavBarItem', () => { describe('and pressing arrow left on a menu item', () => { it('then the nav bar item should receive focus', async () => { - await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); + await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }, undefined, true); await userEvent.tab(); await userEvent.keyboard('{ArrowRight}'); @@ -199,15 +225,10 @@ describe('NavBarItem', () => { children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }], }, }, - '/grafana' + '/grafana', + true ); - await userEvent.hover(screen.getByRole('link')); - await waitFor(() => { - expect(screen.getByText('Parent Node')).toBeInTheDocument(); - expect(screen.getByText('New')).toBeInTheDocument(); - }); - await userEvent.click(screen.getByText('New')); await waitFor(() => { expect(pushMock).toHaveBeenCalledTimes(1); @@ -218,19 +239,17 @@ describe('NavBarItem', () => { describe('when appSubUrl is not configured and user clicks on menuitem link', () => { it('then location service should be called with correct url', async () => { - const { pushMock } = await getTestContext({ - link: { - ...defaults.link, - url: 'https://www.grafana.com', - children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }], + const { pushMock } = await getTestContext( + { + link: { + ...defaults.link, + url: 'https://www.grafana.com', + children: [{ text: 'New', url: '/grafana/dashboard/new', children: [] }], + }, }, - }); - - await userEvent.hover(screen.getByRole('link')); - await waitFor(() => { - expect(screen.getByText('Parent Node')).toBeInTheDocument(); - expect(screen.getByText('New')).toBeInTheDocument(); - }); + undefined, + true + ); await userEvent.click(screen.getByText('New')); await waitFor(() => { diff --git a/public/app/core/components/NavBar/NavBarItem.tsx b/public/app/core/components/NavBar/NavBarItem.tsx index 55c2b339b0d..495b4662518 100644 --- a/public/app/core/components/NavBar/NavBarItem.tsx +++ b/public/app/core/components/NavBar/NavBarItem.tsx @@ -1,7 +1,7 @@ import { css, cx } from '@emotion/css'; import { useLingui } from '@lingui/react'; import { Item } from '@react-stately/collections'; -import React, { ReactNode } from 'react'; +import React from 'react'; import { GrafanaTheme2, locationUtil, NavMenuItemType, NavModelItem } from '@grafana/data'; import { locationService } from '@grafana/runtime'; @@ -9,31 +9,24 @@ import { IconName, useTheme2 } from '@grafana/ui'; import { NavBarItemMenu } from './NavBarItemMenu'; import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger'; -import { getNavBarItemWithoutMenuStyles, NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; +import { getNavBarItemWithoutMenuStyles } from './NavBarItemWithoutMenu'; import { NavBarMenuItem } from './NavBarMenuItem'; +import { useNavBarContext } from './context'; import menuItemTranslations from './navBarItem-translations'; import { getNavModelItemKey } from './utils'; export interface Props { isActive?: boolean; - children: ReactNode; className?: string; reverseMenuDirection?: boolean; - showMenu?: boolean; link: NavModelItem; } -const NavBarItem = ({ - isActive = false, - children, - className, - reverseMenuDirection = false, - showMenu = true, - link, -}: Props) => { +const NavBarItem = ({ isActive = false, className, reverseMenuDirection = false, link }: Props) => { const { i18n } = useLingui(); const theme = useTheme2(); const menuItems = link.children ?? []; + const { menuIdOpen } = useNavBarContext(); // Spreading `menuItems` here as otherwise we'd be mutating props const menuItemsSorted = reverseMenuDirection ? [...menuItems].reverse() : menuItems; @@ -51,24 +44,28 @@ const NavBarItem = ({ const onNavigate = (item: NavModelItem) => { const { url, target, onClick } = item; - if (!url) { - onClick?.(); - return; - } + onClick?.(); - if (!target && url.startsWith('/')) { - locationService.push(locationUtil.stripBaseFromUrl(url)); - } else { - window.open(url, target); + if (url) { + if (!target && url.startsWith('/')) { + locationService.push(locationUtil.stripBaseFromUrl(url)); + } else { + window.open(url, target); + } } }; const translationKey = link.id && menuItemTranslations[link.id]; const linkText = translationKey ? i18n._(translationKey) : link.text; - return showMenu ? ( -
  • - + return ( +
  • + { const translationKey = item.id && menuItemTranslations[item.id]; const itemText = translationKey ? i18n._(translationKey) : item.text; - - if (item.menuItemType === NavMenuItemType.Section) { - return ( - - - - ); - } + const isSection = item.menuItemType === NavMenuItemType.Section; + const icon = item.showIconInNavbar && !isSection ? (item.icon as IconName) : undefined; return ( ); @@ -112,18 +97,6 @@ const NavBarItem = ({
  • - ) : ( - - {children} - ); }; @@ -131,16 +104,19 @@ export default NavBarItem; const getStyles = (theme: GrafanaTheme2, adjustHeightForBorder: boolean, isActive?: boolean) => ({ ...getNavBarItemWithoutMenuStyles(theme, isActive), - header: css` - color: ${theme.colors.text.primary}; - height: ${theme.components.sidemenu.width - (adjustHeightForBorder ? 2 : 1)}px; - font-size: ${theme.typography.h4.fontSize}; - font-weight: ${theme.typography.h4.fontWeight}; - padding: ${theme.spacing(1)} ${theme.spacing(2)}; - white-space: nowrap; - width: 100%; - `, - item: css` - color: ${theme.colors.text.primary}; - `, + containerHover: css({ + backgroundColor: theme.colors.action.hover, + color: theme.colors.text.primary, + }), + primaryText: css({ + color: theme.colors.text.primary, + }), + header: css({ + height: `calc(${theme.spacing(6)} - ${adjustHeightForBorder ? 2 : 1}px)`, + fontSize: theme.typography.h4.fontSize, + fontWeight: theme.typography.h4.fontWeight, + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, + whiteSpace: 'nowrap', + width: '100%', + }), }); diff --git a/public/app/core/components/NavBar/NavBarItemMenu.tsx b/public/app/core/components/NavBar/NavBarItemMenu.tsx index 0fe05ead0ab..8e2f7e830d5 100644 --- a/public/app/core/components/NavBar/NavBarItemMenu.tsx +++ b/public/app/core/components/NavBar/NavBarItemMenu.tsx @@ -9,6 +9,7 @@ import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data'; import { useTheme2 } from '@grafana/ui'; import { NavBarItemMenuItem } from './NavBarItemMenuItem'; +import { NavBarScrollContainer } from './NavBarScrollContainer'; import { useNavBarItemMenuContext } from './context'; import { getNavModelItemKey } from './utils'; @@ -51,9 +52,7 @@ export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null const menuSubTitle = section.value.subTitle; - const sectionComponent = ( - - ); + const headerComponent = ; const itemComponents = items.map((item) => ( @@ -65,7 +64,14 @@ export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null ); - const menu = [sectionComponent, itemComponents, subTitleComponent]; + const contents = [itemComponents, subTitleComponent]; + const contentComponent = ( + + {reverseMenuDirection ? contents.reverse() : contents} + + ); + + const menu = [headerComponent, contentComponent]; return (
      @@ -79,15 +85,13 @@ function getStyles(theme: GrafanaTheme2, reverseDirection?: boolean) { menu: css` background-color: ${theme.colors.background.primary}; border: 1px solid ${theme.components.panel.borderColor}; - bottom: ${reverseDirection ? 0 : 'auto'}; box-shadow: ${theme.shadows.z3}; display: flex; flex-direction: column; - left: 100%; list-style: none; + max-height: 400px; + max-width: 300px; min-width: 140px; - position: absolute; - top: ${reverseDirection ? 'auto' : 0}; transition: ${theme.transitions.create('opacity')}; z-index: ${theme.zIndex.sidemenu}; `, diff --git a/public/app/core/components/NavBar/NavBarItemMenuItem.tsx b/public/app/core/components/NavBar/NavBarItemMenuItem.tsx index ce1878789af..fb631b82cfd 100644 --- a/public/app/core/components/NavBar/NavBarItemMenuItem.tsx +++ b/public/app/core/components/NavBar/NavBarItemMenuItem.tsx @@ -9,7 +9,7 @@ import React, { ReactElement, useRef, useState } from 'react'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { useTheme2 } from '@grafana/ui'; -import { useNavBarItemMenuContext } from './context'; +import { useNavBarItemMenuContext, useNavBarContext } from './context'; export interface NavBarItemMenuItemProps { item: Node; @@ -19,6 +19,7 @@ export interface NavBarItemMenuItemProps { export function NavBarItemMenuItem({ item, state, onNavigate }: NavBarItemMenuItemProps): ReactElement { const { onClose, onLeft } = useNavBarItemMenuContext(); + const { setMenuIdOpen } = useNavBarContext(); const { key, rendered } = item; const ref = useRef(null); const isDisabled = state.disabledKeys.has(key); @@ -30,6 +31,7 @@ export function NavBarItemMenuItem({ item, state, onNavigate }: NavBarItemMenuIt const isSection = item.value.menuItemType === 'section'; const styles = getStyles(theme, isFocused, isSection); const onAction = () => { + setMenuIdOpen(undefined); onNavigate(item.value); onClose(); }; diff --git a/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx b/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx index cabdb29c934..15e7489804f 100644 --- a/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx +++ b/public/app/core/components/NavBar/NavBarItemMenuTrigger.tsx @@ -4,7 +4,7 @@ import { useDialog } from '@react-aria/dialog'; import { FocusScope } from '@react-aria/focus'; import { useFocusWithin, useHover, useKeyboard } from '@react-aria/interactions'; import { useMenuTrigger } from '@react-aria/menu'; -import { DismissButton, useOverlay } from '@react-aria/overlays'; +import { DismissButton, OverlayContainer, useOverlay, useOverlayPosition } from '@react-aria/overlays'; import { useMenuTriggerState } from '@react-stately/menu'; import { MenuTriggerProps } from '@react-types/menu'; import React, { ReactElement, useEffect, useState } from 'react'; @@ -13,19 +13,22 @@ import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { reportExperimentView } from '@grafana/runtime'; import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; +import { getNavMenuPortalContainer } from './NavBarMenuPortalContainer'; import { NavFeatureHighlight } from './NavFeatureHighlight'; -import { NavBarItemMenuContext } from './context'; +import { NavBarItemMenuContext, useNavBarContext } from './context'; export interface NavBarItemMenuTriggerProps extends MenuTriggerProps { children: ReactElement; item: NavModelItem; isActive?: boolean; label: string; + reverseMenuDirection: boolean; } export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactElement { - const { item, isActive, label, children: menu, ...rest } = props; + const { item, isActive, label, children: menu, reverseMenuDirection, ...rest } = props; const [menuHasFocus, setMenuHasFocus] = useState(false); + const { menuIdOpen, setMenuIdOpen } = useNavBarContext(); const theme = useTheme2(); const styles = getStyles(theme, isActive); @@ -46,23 +49,23 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE onHoverChange: (isHovering) => { if (isHovering) { state.open(); + setMenuIdOpen(item.id); } else { state.close(); + setMenuIdOpen(undefined); } }, }); - const { focusWithinProps } = useFocusWithin({ - onFocusWithinChange: (isFocused) => { - if (isFocused) { - state.open(); - } - if (!isFocused) { - state.close(); - setMenuHasFocus(false); - } - }, - }); + useEffect(() => { + // close the menu when changing submenus + if (menuIdOpen !== item.id) { + state.close(); + setMenuHasFocus(false); + } else { + state.open(); + } + }, [menuIdOpen, state, item.id]); const { keyboardProps } = useKeyboard({ onKeyDown: (e) => { @@ -70,9 +73,13 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE case 'ArrowRight': if (!state.isOpen) { state.open(); + setMenuIdOpen(item.id); } setMenuHasFocus(true); break; + case 'Tab': + setMenuIdOpen(undefined); + break; default: break; } @@ -95,6 +102,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE className={styles.element} {...buttonProps} {...keyboardProps} + {...hoverProps} ref={ref as React.RefObject} onClick={item?.onClick} aria-label={label} @@ -109,6 +117,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE } href={item.url} target={item.target} @@ -125,6 +134,7 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE onClick={item?.onClick} {...buttonProps} {...keyboardProps} + {...hoverProps} ref={ref as React.RefObject} className={styles.element} aria-label={label} @@ -134,85 +144,117 @@ export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactE ); } - const overlayRef = React.useRef(null); + const overlayRef = React.useRef(null); const { dialogProps } = useDialog({}, overlayRef); const { overlayProps } = useOverlay( { - onClose: () => state.close(), + onClose: () => { + state.close(); + setMenuIdOpen(undefined); + }, isOpen: state.isOpen, isDismissable: true, }, overlayRef ); + let { overlayProps: overlayPositionProps } = useOverlayPosition({ + targetRef: ref, + overlayRef, + placement: reverseMenuDirection ? 'right bottom' : 'right top', + isOpen: state.isOpen, + }); + + const { focusWithinProps } = useFocusWithin({ + onFocusWithin: (e) => { + if (e.target.id === ref.current?.id) { + // If focussing on the trigger itself, set the menu id that is open + setMenuIdOpen(item.id); + state.open(); + } + e.target.scrollIntoView?.({ + block: 'nearest', + }); + }, + onBlurWithin: (e) => { + if (e.target?.getAttribute('role') === 'menuitem' && !overlayRef.current?.contains(e.relatedTarget)) { + // If it is blurring from a menuitem to an element outside the current overlay + // close the menu that is open + setMenuIdOpen(undefined); + } + }, + }); + return ( -
      +
      {element} {state.isOpen && ( - state.close(), - onLeft: () => { - setMenuHasFocus(false); - ref.current?.focus(); - }, - }} - > - -
      - state.close()} /> - {menu} - state.close()} /> -
      -
      -
      + + state.close(), + onLeft: () => { + setMenuHasFocus(false); + ref.current?.focus(); + }, + }} + > + +
      + state.close()} /> + {menu} + state.close()} /> +
      +
      +
      +
      )}
      ); } const getStyles = (theme: GrafanaTheme2, isActive?: boolean) => ({ - element: css` - background-color: transparent; - border: none; - color: inherit; - display: block; - line-height: ${theme.components.sidemenu.width}px; - padding: 0; - text-align: center; - width: ${theme.components.sidemenu.width}px; + element: css({ + backgroundColor: 'transparent', + border: 'none', + color: 'inherit', + display: 'grid', + padding: 0, + placeContent: 'center', + height: theme.spacing(6), + width: theme.spacing(7), - &::before { - display: ${isActive ? 'block' : 'none'}; - content: ' '; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - border-radius: 2px; - background-image: ${theme.colors.gradients.brandVertical}; - } + '&::before': { + display: isActive ? 'block' : 'none', + content: '" "', + position: 'absolute', + left: theme.spacing(1), + top: theme.spacing(1.5), + bottom: theme.spacing(1.5), + width: theme.spacing(0.5), + borderRadius: theme.shape.borderRadius(1), + backgroundImage: theme.colors.gradients.brandVertical, + }, - &:focus-visible { - background-color: ${theme.colors.action.hover}; - box-shadow: none; - color: ${theme.colors.text.primary}; - outline: 2px solid ${theme.colors.primary.main}; - outline-offset: -2px; - transition: none; - } - `, - icon: css` - height: 100%; - width: 100%; + '&:focus-visible': { + backgroundColor: theme.colors.action.hover, + boxShadow: 'none', + color: theme.colors.text.primary, + outline: `${theme.shape.borderRadius(1)} solid ${theme.colors.primary.main}`, + outlineOffset: `-${theme.shape.borderRadius(1)}`, + transition: 'none', + }, + }), + icon: css({ + height: '100%', + width: '100%', - img { - border-radius: 50%; - height: ${theme.spacing(3)}; - width: ${theme.spacing(3)}; - } - `, + img: { + borderRadius: '50%', + height: theme.spacing(3), + width: theme.spacing(3), + }, + }), }); diff --git a/public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx b/public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx index 3bbe1e4603a..be56bb3e68c 100644 --- a/public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx +++ b/public/app/core/components/NavBar/NavBarItemWithoutMenu.tsx @@ -10,6 +10,7 @@ export interface NavBarItemWithoutMenuProps { label: string; children: ReactNode; className?: string; + elClassName?: string; url?: string; target?: string; isActive?: boolean; @@ -20,113 +21,103 @@ export interface NavBarItemWithoutMenuProps { export function NavBarItemWithoutMenu({ label, children, - className, url, target, isActive = false, onClick, highlightText, + className, + elClassName, }: NavBarItemWithoutMenuProps) { const theme = useTheme2(); const styles = getNavBarItemWithoutMenuStyles(theme, isActive); const content = highlightText ? ( - {children} +
      {children}
      ) : ( - {children} +
      {children}
      ); - return ( -
    • - {!url && ( - - )} - {url && ( - <> - {!target && url.startsWith('/') ? ( - - {content} - - ) : ( - - {content} - - )} - - )} -
    • - ); + ); + } else if (!target && url.startsWith('/')) { + return ( + + {content} + + ); + } else { + return ( + + {content} + + ); + } + }; + + return
      {renderContents()}
      ; } export function getNavBarItemWithoutMenuStyles(theme: GrafanaTheme2, isActive?: boolean) { return { - container: css` - position: relative; - color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary}; + container: css({ + position: 'relative', + color: isActive ? theme.colors.text.primary : theme.colors.text.secondary, + display: 'grid', - &:hover { - background-color: ${theme.colors.action.hover}; - color: ${theme.colors.text.primary}; + '&:hover': { + backgroundColor: theme.colors.action.hover, + color: theme.colors.text.primary, + }, + }), + element: css({ + backgroundColor: 'transparent', + border: 'none', + color: 'inherit', + display: 'block', + padding: 0, + overflowWrap: 'anywhere', - // TODO don't use a hardcoded class here, use isVisible in NavBarDropdown - .navbar-dropdown { - opacity: 1; - visibility: visible; - } - } - `, - element: css` - background-color: transparent; - border: none; - color: inherit; - display: block; - line-height: ${theme.components.sidemenu.width}px; - padding: 0; - text-align: center; - width: ${theme.components.sidemenu.width}px; + '&::before': { + display: isActive ? 'block' : 'none', + content: "' '", + position: 'absolute', + left: theme.spacing(1), + top: theme.spacing(1.5), + bottom: theme.spacing(1.5), + width: theme.spacing(0.5), + borderRadius: theme.shape.borderRadius(1), + backgroundImage: theme.colors.gradients.brandVertical, + }, - &::before { - display: ${isActive ? 'block' : 'none'}; - content: ' '; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - border-radius: 2px; - background-image: ${theme.colors.gradients.brandVertical}; - } + '&:focus-visible': { + backgroundColor: theme.colors.action.hover, + boxShadow: 'none', + color: theme.colors.text.primary, + outline: `${theme.shape.borderRadius(1)} solid ${theme.colors.primary.main}`, + outlineOffset: `-${theme.shape.borderRadius(1)}`, + transition: 'none', + }, + }), - &:focus-visible { - background-color: ${theme.colors.action.hover}; - box-shadow: none; - color: ${theme.colors.text.primary}; - outline: 2px solid ${theme.colors.primary.main}; - outline-offset: -2px; - transition: none; - } - `, + icon: css({ + height: '100%', + width: '100%', - icon: css` - height: 100%; - width: 100%; - - img { - border-radius: 50%; - height: ${theme.spacing(3)}; - width: ${theme.spacing(3)}; - } - `, + img: { + borderRadius: '50%', + height: theme.spacing(3), + width: theme.spacing(3), + }, + }), }; } diff --git a/public/app/core/components/NavBar/NavBarMenu.test.tsx b/public/app/core/components/NavBar/NavBarMenu.test.tsx index 34059b553ff..14670f47ccd 100644 --- a/public/app/core/components/NavBar/NavBarMenu.test.tsx +++ b/public/app/core/components/NavBar/NavBarMenu.test.tsx @@ -7,12 +7,26 @@ import { NavModelItem } from '@grafana/data'; import { NavBarMenu } from './NavBarMenu'; +// don't care about interaction tracking in our unit tests +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), +})); + describe('NavBarMenu', () => { const mockOnClose = jest.fn(); const mockNavItems: NavModelItem[] = []; + const mockSetMenuAnimationInProgress = jest.fn(); beforeEach(() => { - render(); + render( + + ); }); it('should render the component', () => { @@ -21,14 +35,20 @@ describe('NavBarMenu', () => { }); it('has a close button', () => { - const closeButton = screen.getByRole('button', { name: 'Close navigation menu' }); - expect(closeButton).toBeInTheDocument(); + const closeButton = screen.getAllByRole('button', { name: 'Close navigation menu' }); + // this is for mobile, will be hidden with display: none; on desktop + expect(closeButton[0]).toBeInTheDocument(); + // this is for desktop, will be hidden with display: none; on mobile + expect(closeButton[1]).toBeInTheDocument(); }); it('clicking the close button calls the onClose callback', async () => { - const closeButton = screen.getByRole('button', { name: 'Close navigation menu' }); - expect(closeButton).toBeInTheDocument(); - await userEvent.click(closeButton); + const closeButton = screen.getAllByRole('button', { name: 'Close navigation menu' }); + expect(closeButton[0]).toBeInTheDocument(); + expect(closeButton[1]).toBeInTheDocument(); + await userEvent.click(closeButton[0]); + expect(mockOnClose).toHaveBeenCalled(); + await userEvent.click(closeButton[1]); expect(mockOnClose).toHaveBeenCalled(); }); }); diff --git a/public/app/core/components/NavBar/NavBarMenu.tsx b/public/app/core/components/NavBar/NavBarMenu.tsx index 6cd7becbb61..4d1b63f9080 100644 --- a/public/app/core/components/NavBar/NavBarMenu.tsx +++ b/public/app/core/components/NavBar/NavBarMenu.tsx @@ -1,127 +1,450 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { useDialog } from '@react-aria/dialog'; import { FocusScope } from '@react-aria/focus'; -import { useOverlay } from '@react-aria/overlays'; +import { OverlayContainer, useOverlay } from '@react-aria/overlays'; import React, { useRef } from 'react'; +import CSSTransition from 'react-transition-group/CSSTransition'; +import { useLocalStorage } from 'react-use'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { CustomScrollbar, Icon, IconButton, IconName, useTheme2 } from '@grafana/ui'; +import { reportInteraction } from '@grafana/runtime'; +import { CollapsableSection, CustomScrollbar, Icon, IconButton, IconName, useStyles2, useTheme2 } from '@grafana/ui'; +import { Branding } from '../Branding/Branding'; + +import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; import { NavBarMenuItem } from './NavBarMenuItem'; +import { NavBarToggle } from './NavBarToggle'; +import { NavFeatureHighlight } from './NavFeatureHighlight'; +import { isMatchOrChildMatch } from './utils'; + +const MENU_WIDTH = '350px'; export interface Props { activeItem?: NavModelItem; + isOpen: boolean; navItems: NavModelItem[]; + setMenuAnimationInProgress: (isInProgress: boolean) => void; onClose: () => void; } -export function NavBarMenu({ activeItem, navItems, onClose }: Props) { +export function NavBarMenu({ activeItem, isOpen, navItems, onClose, setMenuAnimationInProgress }: Props) { const theme = useTheme2(); const styles = getStyles(theme); + const ANIMATION_DURATION = theme.transitions.duration.standard; + const animStyles = getAnimStyles(theme, ANIMATION_DURATION); const ref = useRef(null); const { dialogProps } = useDialog({}, ref); - const { overlayProps } = useOverlay( + const { overlayProps, underlayProps } = useOverlay( { isDismissable: true, - isOpen: true, + isOpen, onClose, }, ref ); return ( - -
      -
      - - -
      - -
      -
      + + + setMenuAnimationInProgress(true)} + onExited={() => setMenuAnimationInProgress(false)} + appear={isOpen} + in={isOpen} + classNames={animStyles.overlay} + timeout={ANIMATION_DURATION} + > +
      +
      + + +
      + { + reportInteraction('grafana_navigation_collapsed'); + onClose(); + }} + /> + +
      +
      +
      + +
      + + ); } NavBarMenu.displayName = 'NavBarMenu'; const getStyles = (theme: GrafanaTheme2) => ({ - container: css` - background-color: ${theme.colors.background.canvas}; - bottom: 0; - display: flex; - flex-direction: column; - left: 0; - min-width: 300px; - position: fixed; - right: 0; - top: 0; - - ${theme.breakpoints.up('md')} { - border-right: 1px solid ${theme.colors.border.weak}; - right: unset; - } - `, - content: css` - display: flex; - flex-direction: column; - overflow: auto; - `, - header: css` - border-bottom: 1px solid ${theme.colors.border.weak}; - display: flex; - justify-content: space-between; - padding: ${theme.spacing(2)}; - `, - item: css` - padding: ${theme.spacing(1)} ${theme.spacing(2)}; - `, - section: css` - border-bottom: 1px solid ${theme.colors.border.weak}; - `, - sectionHeader: css` - color: ${theme.colors.text.primary}; - font-size: ${theme.typography.h5.fontSize}; - padding: ${theme.spacing(1)} ${theme.spacing(2)}; - `, + backdrop: css({ + backdropFilter: 'blur(1px)', + backgroundColor: theme.components.overlay.background, + bottom: 0, + left: 0, + position: 'fixed', + right: 0, + top: 0, + zIndex: theme.zIndex.modalBackdrop, + }), + container: css({ + display: 'flex', + bottom: 0, + flexDirection: 'column', + left: 0, + paddingTop: theme.spacing(1), + marginRight: theme.spacing(1.5), + right: 0, + zIndex: theme.zIndex.modal, + position: 'fixed', + top: 0, + boxSizing: 'content-box', + [theme.breakpoints.up('md')]: { + borderRight: `1px solid ${theme.colors.border.weak}`, + right: 'unset', + }, + }), + content: css({ + display: 'flex', + flexDirection: 'column', + overflow: 'auto', + }), + mobileHeader: css({ + borderBottom: `1px solid ${theme.colors.border.weak}`, + display: 'flex', + justifyContent: 'space-between', + padding: theme.spacing(1, 2, 2), + [theme.breakpoints.up('md')]: { + display: 'none', + }, + }), + itemList: css({ + display: 'grid', + gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, + minWidth: MENU_WIDTH, + }), + menuCollapseIcon: css({ + position: 'absolute', + top: '43px', + right: '0px', + transform: `translateX(50%)`, + }), }); + +const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { + const commonTransition = { + transitionDuration: `${animationDuration}ms`, + transitionTimingFunction: theme.transitions.easing.easeInOut, + [theme.breakpoints.down('md')]: { + overflow: 'hidden', + }, + }; + + const overlayTransition = { + ...commonTransition, + transitionProperty: 'background-color, box-shadow, width', + // this is needed to prevent a horizontal scrollbar during the animation on firefox + '.scrollbar-view': { + overflow: 'hidden !important', + }, + }; + + const backdropTransition = { + ...commonTransition, + transitionProperty: 'opacity', + }; + + const overlayOpen = { + backgroundColor: theme.colors.background.canvas, + boxShadow: theme.shadows.z3, + width: '100%', + [theme.breakpoints.up('md')]: { + width: MENU_WIDTH, + }, + }; + + const overlayClosed = { + boxShadow: 'none', + width: 0, + [theme.breakpoints.up('md')]: { + backgroundColor: theme.colors.background.primary, + width: theme.spacing(7), + }, + }; + + const backdropOpen = { + opacity: 1, + }; + + const backdropClosed = { + opacity: 0, + }; + + return { + backdrop: { + appear: css(backdropClosed), + appearActive: css(backdropTransition, backdropOpen), + appearDone: css(backdropOpen), + exit: css(backdropOpen), + exitActive: css(backdropTransition, backdropClosed), + }, + overlay: { + appear: css(overlayClosed), + appearActive: css(overlayTransition, overlayOpen), + appearDone: css(overlayOpen), + exit: css(overlayOpen), + exitActive: css(overlayTransition, overlayClosed), + }, + }; +}; + +function NavItem({ + link, + activeItem, + onClose, +}: { + link: NavModelItem; + activeItem?: NavModelItem; + onClose: () => void; +}) { + const styles = useStyles2(getNavItemStyles); + + if (linkHasChildren(link)) { + return ( + +
        + {link.children.map( + (childLink) => + !childLink.divider && ( + { + childLink.onClick?.(); + onClose(); + }} + styleOverrides={styles.item} + target={childLink.target} + text={childLink.text} + url={childLink.url} + isMobile={true} + /> + ) + )} +
      +
      + ); + } else { + const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment; + return ( +
    • + { + link.onClick?.(); + onClose(); + }} + isActive={link === activeItem} + > +
      +
      + {getLinkIcon(link)} +
      + {link.text} +
      +
      +
    • + ); + } +} + +const getNavItemStyles = (theme: GrafanaTheme2) => ({ + children: css({ + display: 'flex', + flexDirection: 'column', + }), + item: css({ + padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, + width: `calc(100% - ${theme.spacing(3)})`, + '&::before': { + display: 'none', + }, + }), + flex: css({ + display: 'flex', + }), + itemWithoutMenu: css({ + position: 'relative', + placeItems: 'inherit', + justifyContent: 'start', + display: 'flex', + flexGrow: 1, + alignItems: 'center', + }), + fullWidth: css({ + height: '100%', + width: '100%', + }), + iconContainer: css({ + display: 'flex', + placeContent: 'center', + }), + itemWithoutMenuContent: css({ + display: 'grid', + gridAutoFlow: 'column', + gridTemplateColumns: `${theme.spacing(7)} auto`, + alignItems: 'center', + height: '100%', + }), + linkText: css({ + fontSize: theme.typography.pxToRem(14), + justifySelf: 'start', + padding: theme.spacing(0.5, 4.25, 0.5, 0.5), + }), +}); + +function CollapsibleNavItem({ + link, + isActive, + children, + className, + onClose, +}: { + link: NavModelItem; + isActive?: boolean; + children: React.ReactNode; + className?: string; + onClose: () => void; +}) { + const styles = useStyles2(getCollapsibleStyles); + const [sectionExpanded, setSectionExpanded] = useLocalStorage(`grafana.navigation.expanded[${link.text}]`, false); + const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment; + + return ( +
    • + { + link.onClick?.(); + onClose(); + }} + className={styles.collapsibleMenuItem} + elClassName={styles.collapsibleIcon} + > + {getLinkIcon(link)} + +
      + setSectionExpanded(isOpen)} + className={styles.collapseWrapper} + contentClassName={styles.collapseContent} + label={ +
      + {link.text} +
      + } + > + {children} +
      +
      +
    • + ); +} + +const getCollapsibleStyles = (theme: GrafanaTheme2) => ({ + menuItem: css({ + position: 'relative', + display: 'grid', + gridAutoFlow: 'column', + gridTemplateColumns: `${theme.spacing(7)} minmax(calc(${MENU_WIDTH} - ${theme.spacing(7)}), auto)`, + }), + collapsibleMenuItem: css({ + height: theme.spacing(6), + width: theme.spacing(7), + display: 'grid', + }), + collapsibleIcon: css({ + display: 'grid', + placeContent: 'center', + }), + collapsibleSectionWrapper: css({ + display: 'flex', + flexGrow: 1, + alignSelf: 'start', + flexDirection: 'column', + }), + collapseWrapper: css({ + paddingLeft: theme.spacing(0.5), + paddingRight: theme.spacing(4.25), + minHeight: theme.spacing(6), + overflowWrap: 'anywhere', + alignItems: 'center', + color: theme.colors.text.secondary, + '&:hover, &:focus-within': { + backgroundColor: theme.colors.action.hover, + color: theme.colors.text.primary, + }, + '&:focus-within': { + boxShadow: 'none', + outline: `2px solid ${theme.colors.primary.main}`, + outlineOffset: '-2px', + transition: 'none', + }, + }), + collapseContent: css({ + padding: 0, + }), + labelWrapper: css({ + fontSize: '15px', + }), + primary: css({ + color: theme.colors.text.primary, + }), + linkText: css({ + fontSize: theme.typography.pxToRem(14), + justifySelf: 'start', + }), +}); + +function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } { + return Boolean(link.children && link.children.length > 0); +} + +function getLinkIcon(link: NavModelItem) { + if (link.id === 'home') { + return ; + } else if (link.icon) { + return ; + } else { + return {`${link.text}; + } +} diff --git a/public/app/core/components/NavBar/NavBarMenuItem.tsx b/public/app/core/components/NavBar/NavBarMenuItem.tsx index 3c84f54a806..8eb61b400ae 100644 --- a/public/app/core/components/NavBar/NavBarMenuItem.tsx +++ b/public/app/core/components/NavBar/NavBarMenuItem.tsx @@ -1,4 +1,4 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -29,14 +29,12 @@ export function NavBarMenuItem({ isMobile = false, }: Props) { const theme = useTheme2(); - const styles = getStyles(theme, isActive, styleOverrides); - + const styles = getStyles(theme, isActive); + const elStyle = cx(styles.element, styleOverrides); const linkContent = (
      -
      - {icon && } - {text} -
      + {icon && } +
      {text}
      {target === '_blank' && ( )} @@ -44,7 +42,7 @@ export function NavBarMenuItem({ ); let element = ( - ); @@ -52,11 +50,11 @@ export function NavBarMenuItem({ if (url) { element = !target && url.startsWith('/') ? ( - + {linkContent} ) : ( - + {linkContent} ); @@ -79,89 +77,74 @@ export function NavBarMenuItem({ NavBarMenuItem.displayName = 'NavBarMenuItem'; -const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], styleOverrides: Props['styleOverrides']) => ({ - visible: css` - color: ${theme.colors.text.primary} !important; - opacity: 100% !important; - `, - divider: css` - border-bottom: 1px solid ${theme.colors.border.weak}; - height: 1px; - margin: ${theme.spacing(1)} 0; - overflow: hidden; - `, - listItem: css` - position: relative; - display: flex; - align-items: center; +const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ + linkContent: css({ + alignItems: 'center', + display: 'flex', + gap: '0.5rem', + width: '100%', + }), + linkText: css({ + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }), + externalLinkIcon: css({ + color: theme.colors.text.secondary, + gridColumnStart: 3, + }), + element: css({ + alignItems: 'center', + background: 'none', + border: 'none', + color: isActive ? theme.colors.text.primary : theme.colors.text.secondary, + display: 'flex', + flex: 1, + fontSize: 'inherit', + height: '100%', + overflowWrap: 'anywhere', + padding: theme.spacing(0.5, 2), + textAlign: 'left', + width: '100%', + '&:hover, &:focus-visible': { + backgroundColor: theme.colors.action.hover, + color: theme.colors.text.primary, + }, + '&:focus-visible': { + boxShadow: 'none', + outline: `2px solid ${theme.colors.primary.main}`, + outlineOffset: '-2px', + transition: 'none', + }, + '&::before': { + display: isActive ? 'block' : 'none', + content: '" "', + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: theme.spacing(0.5), + borderRadius: theme.shape.borderRadius(1), + backgroundImage: theme.colors.gradients.brandVertical, + }, + }), + listItem: css({ + position: 'relative', + display: 'flex', + alignItems: 'center', - &:hover, - &:focus-within { - color: ${theme.colors.text.primary}; + '&:hover, &:focus-within': { + color: theme.colors.text.primary, - > *:first-child::after { - background-color: ${theme.colors.action.hover}; - } - } - `, - element: css` - align-items: center; - background: none; - border: none; - color: ${isActive ? theme.colors.text.primary : theme.colors.text.secondary}; - display: flex; - font-size: inherit; - height: 100%; - padding: 5px 12px 5px 10px; - text-align: left; - white-space: nowrap; - - &:focus-visible { - outline: none; - box-shadow: none; - - &::after { - box-shadow: none; - outline: 2px solid ${theme.colors.primary.main}; - outline-offset: -2px; - transition: none; - } - } - - &::before { - display: ${isActive ? 'block' : 'none'}; - content: ' '; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - border-radius: 2px; - background-image: ${theme.colors.gradients.brandVertical}; - } - - &::after { - position: absolute; - content: ''; - left: 0; - top: 0; - bottom: 0; - right: 0; - } - - ${styleOverrides}; - `, - externalLinkIcon: css` - color: ${theme.colors.text.secondary}; - margin-left: ${theme.spacing(1)}; - `, - icon: css` - margin-right: ${theme.spacing(1)}; - `, - linkContent: css` - display: flex; - flex: 1; - flex-direction: row; - justify-content: space-between; - `, + '> *:first-child::after': { + backgroundColor: theme.colors.action.hover, + }, + }, + }), + divider: css({ + borderBottom: `1px solid ${theme.colors.border.weak}`, + height: '1px', + margin: `${theme.spacing(1)} 0`, + overflow: 'hidden', + }), }); diff --git a/public/app/core/components/NavBar/Next/NavBarMenuPortalContainer.tsx b/public/app/core/components/NavBar/NavBarMenuPortalContainer.tsx similarity index 100% rename from public/app/core/components/NavBar/Next/NavBarMenuPortalContainer.tsx rename to public/app/core/components/NavBar/NavBarMenuPortalContainer.tsx diff --git a/public/app/core/components/NavBar/Next/NavBarScrollContainer.tsx b/public/app/core/components/NavBar/NavBarScrollContainer.tsx similarity index 100% rename from public/app/core/components/NavBar/Next/NavBarScrollContainer.tsx rename to public/app/core/components/NavBar/NavBarScrollContainer.tsx diff --git a/public/app/core/components/NavBar/NavBarSection.tsx b/public/app/core/components/NavBar/NavBarSection.tsx deleted file mode 100644 index f4409b1e2a8..00000000000 --- a/public/app/core/components/NavBar/NavBarSection.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React, { ReactNode } from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { useTheme2 } from '@grafana/ui'; - -export interface Props { - children: ReactNode; - className?: string; -} - -export function NavBarSection({ children, className }: Props) { - const theme = useTheme2(); - const styles = getStyles(theme); - - return ( -
        - {children} -
      - ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - container: css` - display: none; - list-style: none; - - ${theme.breakpoints.up('md')} { - display: flex; - flex-direction: inherit; - } - `, -}); diff --git a/public/app/core/components/NavBar/Next/NavBarToggle.tsx b/public/app/core/components/NavBar/NavBarToggle.tsx similarity index 100% rename from public/app/core/components/NavBar/Next/NavBarToggle.tsx rename to public/app/core/components/NavBar/NavBarToggle.tsx diff --git a/public/app/core/components/NavBar/Next/NavBarItem.tsx b/public/app/core/components/NavBar/Next/NavBarItem.tsx deleted file mode 100644 index c0d6490d623..00000000000 --- a/public/app/core/components/NavBar/Next/NavBarItem.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { css, cx } from '@emotion/css'; -import { useLingui } from '@lingui/react'; -import { Item } from '@react-stately/collections'; -import React, { ReactNode } from 'react'; - -import { GrafanaTheme2, locationUtil, NavMenuItemType, NavModelItem } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; -import { IconName, useTheme2 } from '@grafana/ui'; - -import { useNavBarContext } from '../context'; -import menuItemTranslations from '../navBarItem-translations'; -import { getNavModelItemKey } from '../utils'; - -import { NavBarItemMenu } from './NavBarItemMenu'; -import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger'; -import { getNavBarItemWithoutMenuStyles, NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; -import { NavBarMenuItem } from './NavBarMenuItem'; - -export interface Props { - isActive?: boolean; - children: ReactNode; - className?: string; - reverseMenuDirection?: boolean; - showMenu?: boolean; - link: NavModelItem; -} - -const NavBarItem = ({ - isActive = false, - children, - className, - reverseMenuDirection = false, - showMenu = true, - link, -}: Props) => { - const { i18n } = useLingui(); - const theme = useTheme2(); - const menuItems = link.children ?? []; - const { menuIdOpen } = useNavBarContext(); - - // Spreading `menuItems` here as otherwise we'd be mutating props - const menuItemsSorted = reverseMenuDirection ? [...menuItems].reverse() : menuItems; - const filteredItems = menuItemsSorted - .filter((item) => !item.hideFromMenu) - .map((i) => ({ ...i, menuItemType: NavMenuItemType.Item })); - const adjustHeightForBorder = filteredItems.length === 0; - const styles = getStyles(theme, adjustHeightForBorder, isActive); - const section: NavModelItem = { - ...link, - children: filteredItems, - menuItemType: NavMenuItemType.Section, - }; - const items: NavModelItem[] = [section].concat(filteredItems); - - const onNavigate = (item: NavModelItem) => { - const { url, target, onClick } = item; - onClick?.(); - - if (url) { - if (!target && url.startsWith('/')) { - locationService.push(locationUtil.stripBaseFromUrl(url)); - } else { - window.open(url, target); - } - } - }; - - const translationKey = link.id && menuItemTranslations[link.id]; - const linkText = translationKey ? i18n._(translationKey) : link.text; - - if (!showMenu) { - return ( - - {children} - - ); - } else { - return ( -
    • - - - {(item: NavModelItem) => { - const translationKey = item.id && menuItemTranslations[item.id]; - const itemText = translationKey ? i18n._(translationKey) : item.text; - const isSection = item.menuItemType === NavMenuItemType.Section; - const icon = item.showIconInNavbar && !isSection ? (item.icon as IconName) : undefined; - - return ( - - - - ); - }} - - -
    • - ); - } -}; - -export default NavBarItem; - -const getStyles = (theme: GrafanaTheme2, adjustHeightForBorder: boolean, isActive?: boolean) => ({ - ...getNavBarItemWithoutMenuStyles(theme, isActive), - containerHover: css({ - backgroundColor: theme.colors.action.hover, - color: theme.colors.text.primary, - }), - primaryText: css({ - color: theme.colors.text.primary, - }), - header: css({ - height: `calc(${theme.spacing(6)} - ${adjustHeightForBorder ? 2 : 1}px)`, - fontSize: theme.typography.h4.fontSize, - fontWeight: theme.typography.h4.fontWeight, - padding: `${theme.spacing(1)} ${theme.spacing(2)}`, - whiteSpace: 'nowrap', - width: '100%', - }), -}); diff --git a/public/app/core/components/NavBar/Next/NavBarItemMenu.tsx b/public/app/core/components/NavBar/Next/NavBarItemMenu.tsx deleted file mode 100644 index 08feb876127..00000000000 --- a/public/app/core/components/NavBar/Next/NavBarItemMenu.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { css } from '@emotion/css'; -import { useMenu } from '@react-aria/menu'; -import { mergeProps } from '@react-aria/utils'; -import { useTreeState } from '@react-stately/tree'; -import { SpectrumMenuProps } from '@react-types/menu'; -import React, { ReactElement, useEffect, useRef } from 'react'; - -import { GrafanaTheme2, NavMenuItemType, NavModelItem } from '@grafana/data'; -import { useTheme2 } from '@grafana/ui'; - -import { useNavBarItemMenuContext } from '../context'; -import { getNavModelItemKey } from '../utils'; - -import { NavBarItemMenuItem } from './NavBarItemMenuItem'; -import { NavBarScrollContainer } from './NavBarScrollContainer'; - -export interface NavBarItemMenuProps extends SpectrumMenuProps { - onNavigate: (item: NavModelItem) => void; - adjustHeightForBorder: boolean; - reverseMenuDirection?: boolean; -} - -export function NavBarItemMenu(props: NavBarItemMenuProps): ReactElement | null { - const { reverseMenuDirection, adjustHeightForBorder, disabledKeys, onNavigate, ...rest } = props; - const contextProps = useNavBarItemMenuContext(); - const completeProps = { - ...mergeProps(contextProps, rest), - }; - const { menuHasFocus, menuProps: contextMenuProps = {} } = contextProps; - const theme = useTheme2(); - const styles = getStyles(theme, reverseMenuDirection); - const state = useTreeState({ ...rest, disabledKeys }); - const ref = useRef(null); - const { menuProps } = useMenu(completeProps, { ...state }, ref); - const allItems = [...state.collection]; - const items = allItems.filter((item) => item.value.menuItemType === NavMenuItemType.Item); - const section = allItems.find((item) => item.value.menuItemType === NavMenuItemType.Section); - - useEffect(() => { - if (menuHasFocus && !state.selectionManager.isFocused) { - state.selectionManager.setFocusedKey(section?.key ?? ''); - state.selectionManager.setFocused(true); - } else if (!menuHasFocus) { - state.selectionManager.setFocused(false); - state.selectionManager.setFocusedKey(''); - state.selectionManager.clearSelection(); - } - }, [menuHasFocus, state.selectionManager, reverseMenuDirection, section?.key]); - - if (!section) { - return null; - } - - const menuSubTitle = section.value.subTitle; - - const headerComponent = ; - - const itemComponents = items.map((item) => ( - - )); - - const subTitleComponent = menuSubTitle && ( -
    • - {menuSubTitle} -
    • - ); - - const contents = [itemComponents, subTitleComponent]; - const contentComponent = ( - - {reverseMenuDirection ? contents.reverse() : contents} - - ); - - const menu = [headerComponent, contentComponent]; - - return ( -
        - {reverseMenuDirection ? menu.reverse() : menu} -
      - ); -} - -function getStyles(theme: GrafanaTheme2, reverseDirection?: boolean) { - return { - menu: css` - background-color: ${theme.colors.background.primary}; - border: 1px solid ${theme.components.panel.borderColor}; - box-shadow: ${theme.shadows.z3}; - display: flex; - flex-direction: column; - list-style: none; - max-height: 400px; - max-width: 300px; - min-width: 140px; - transition: ${theme.transitions.create('opacity')}; - z-index: ${theme.zIndex.sidemenu}; - `, - subtitle: css` - background-color: transparent; - border-${reverseDirection ? 'bottom' : 'top'}: 1px solid ${theme.colors.border.weak}; - color: ${theme.colors.text.secondary}; - font-size: ${theme.typography.bodySmall.fontSize}; - font-weight: ${theme.typography.bodySmall.fontWeight}; - padding: ${theme.spacing(1)} ${theme.spacing(2)} ${theme.spacing(1)}; - text-align: left; - white-space: nowrap; - `, - }; -} diff --git a/public/app/core/components/NavBar/Next/NavBarItemMenuItem.tsx b/public/app/core/components/NavBar/Next/NavBarItemMenuItem.tsx deleted file mode 100644 index 122b4fb38a3..00000000000 --- a/public/app/core/components/NavBar/Next/NavBarItemMenuItem.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { css } from '@emotion/css'; -import { useFocus, useKeyboard } from '@react-aria/interactions'; -import { useMenuItem } from '@react-aria/menu'; -import { mergeProps } from '@react-aria/utils'; -import { TreeState } from '@react-stately/tree'; -import { Node } from '@react-types/shared'; -import React, { ReactElement, useRef, useState } from 'react'; - -import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { useTheme2 } from '@grafana/ui'; - -import { useNavBarItemMenuContext, useNavBarContext } from '../context'; - -export interface NavBarItemMenuItemProps { - item: Node; - state: TreeState; - onNavigate: (item: NavModelItem) => void; -} - -export function NavBarItemMenuItem({ item, state, onNavigate }: NavBarItemMenuItemProps): ReactElement { - const { onClose, onLeft } = useNavBarItemMenuContext(); - const { setMenuIdOpen } = useNavBarContext(); - const { key, rendered } = item; - const ref = useRef(null); - const isDisabled = state.disabledKeys.has(key); - - // style to the focused menu item - const [isFocused, setFocused] = useState(false); - const { focusProps } = useFocus({ onFocusChange: setFocused, isDisabled }); - const theme = useTheme2(); - const isSection = item.value.menuItemType === 'section'; - const styles = getStyles(theme, isFocused, isSection); - const onAction = () => { - setMenuIdOpen(undefined); - onNavigate(item.value); - onClose(); - }; - - let { menuItemProps } = useMenuItem( - { - isDisabled, - 'aria-label': item['aria-label'], - key, - closeOnSelect: true, - onClose, - onAction, - }, - state, - ref - ); - - const { keyboardProps } = useKeyboard({ - onKeyDown: (e) => { - if (e.key === 'ArrowLeft') { - onLeft(); - } - e.continuePropagation(); - }, - }); - - return ( - <> -
    • - {rendered} -
    • - - ); -} - -function getStyles(theme: GrafanaTheme2, isFocused: boolean, isSection: boolean) { - let backgroundColor = 'transparent'; - if (isFocused) { - backgroundColor = theme.colors.action.hover; - } else if (isSection) { - backgroundColor = theme.colors.background.secondary; - } - return { - menuItem: css` - background-color: ${backgroundColor}; - color: ${theme.colors.text.primary}; - - &:focus-visible { - background-color: ${theme.colors.action.hover}; - box-shadow: none; - color: ${theme.colors.text.primary}; - outline: 2px solid ${theme.colors.primary.main}; - outline-offset: -2px; - transition: none; - } - `, - upgradeBoxContainer: css` - padding: ${theme.spacing(1)}; - `, - upgradeBox: css` - width: 300px; - `, - }; -} diff --git a/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx b/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx deleted file mode 100644 index 5bd6b2c11e6..00000000000 --- a/public/app/core/components/NavBar/Next/NavBarItemMenuTrigger.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { css, cx } from '@emotion/css'; -import { useButton } from '@react-aria/button'; -import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; -import { useFocusWithin, useHover, useKeyboard } from '@react-aria/interactions'; -import { useMenuTrigger } from '@react-aria/menu'; -import { DismissButton, OverlayContainer, useOverlay, useOverlayPosition } from '@react-aria/overlays'; -import { useMenuTriggerState } from '@react-stately/menu'; -import { MenuTriggerProps } from '@react-types/menu'; -import React, { ReactElement, useEffect, useState } from 'react'; - -import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { reportExperimentView } from '@grafana/runtime'; -import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; - -import { NavFeatureHighlight } from '../NavFeatureHighlight'; -import { NavBarItemMenuContext, useNavBarContext } from '../context'; - -import { getNavMenuPortalContainer } from './NavBarMenuPortalContainer'; - -export interface NavBarItemMenuTriggerProps extends MenuTriggerProps { - children: ReactElement; - item: NavModelItem; - isActive?: boolean; - label: string; - reverseMenuDirection: boolean; -} - -export function NavBarItemMenuTrigger(props: NavBarItemMenuTriggerProps): ReactElement { - const { item, isActive, label, children: menu, reverseMenuDirection, ...rest } = props; - const [menuHasFocus, setMenuHasFocus] = useState(false); - const { menuIdOpen, setMenuIdOpen } = useNavBarContext(); - const theme = useTheme2(); - const styles = getStyles(theme, isActive); - - // Create state based on the incoming props - const state = useMenuTriggerState({ ...rest }); - - // Get props for the menu trigger and menu elements - const ref = React.useRef(null); - const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref); - - useEffect(() => { - if (item.highlightId) { - reportExperimentView(`feature-highlights-${item.highlightId}-nav`, 'test', ''); - } - }, [item.highlightId]); - - const { hoverProps } = useHover({ - onHoverChange: (isHovering) => { - if (isHovering) { - state.open(); - setMenuIdOpen(item.id); - } else { - state.close(); - setMenuIdOpen(undefined); - } - }, - }); - - useEffect(() => { - // close the menu when changing submenus - if (menuIdOpen !== item.id) { - state.close(); - setMenuHasFocus(false); - } else { - state.open(); - } - }, [menuIdOpen, state, item.id]); - - const { keyboardProps } = useKeyboard({ - onKeyDown: (e) => { - switch (e.key) { - case 'ArrowRight': - if (!state.isOpen) { - state.open(); - setMenuIdOpen(item.id); - } - setMenuHasFocus(true); - break; - case 'Tab': - setMenuIdOpen(undefined); - break; - default: - break; - } - }, - }); - - // Get props for the button based on the trigger props from useMenuTrigger - const { buttonProps } = useButton(menuTriggerProps, ref); - const Wrapper = item.highlightText ? NavFeatureHighlight : React.Fragment; - const itemContent = ( - - - {item?.icon && } - {item?.img && {`${item.text}} - - - ); - let element = ( - - ); - - if (item?.url) { - element = - !item.target && item.url.startsWith('/') ? ( - } - href={item.url} - target={item.target} - onClick={item?.onClick} - className={styles.element} - aria-label={label} - > - {itemContent} - - ) : ( - } - className={styles.element} - aria-label={label} - > - {itemContent} - - ); - } - - const overlayRef = React.useRef(null); - const { dialogProps } = useDialog({}, overlayRef); - const { overlayProps } = useOverlay( - { - onClose: () => { - state.close(); - setMenuIdOpen(undefined); - }, - isOpen: state.isOpen, - isDismissable: true, - }, - overlayRef - ); - - let { overlayProps: overlayPositionProps } = useOverlayPosition({ - targetRef: ref, - overlayRef, - placement: reverseMenuDirection ? 'right bottom' : 'right top', - isOpen: state.isOpen, - }); - - const { focusWithinProps } = useFocusWithin({ - onFocusWithin: (e) => { - if (e.target.id === ref.current?.id) { - // If focussing on the trigger itself, set the menu id that is open - setMenuIdOpen(item.id); - state.open(); - } - e.target.scrollIntoView({ - block: 'nearest', - }); - }, - onBlurWithin: (e) => { - if (e.target?.getAttribute('role') === 'menuitem' && !overlayRef.current?.contains(e.relatedTarget)) { - // If it is blurring from a menuitem to an element outside the current overlay - // close the menu that is open - setMenuIdOpen(undefined); - } - }, - }); - - return ( -
      - {element} - {state.isOpen && ( - - state.close(), - onLeft: () => { - setMenuHasFocus(false); - ref.current?.focus(); - }, - }} - > - -
      - state.close()} /> - {menu} - state.close()} /> -
      -
      -
      -
      - )} -
      - ); -} - -const getStyles = (theme: GrafanaTheme2, isActive?: boolean) => ({ - element: css({ - backgroundColor: 'transparent', - border: 'none', - color: 'inherit', - display: 'grid', - padding: 0, - placeContent: 'center', - height: theme.spacing(6), - width: theme.spacing(7), - - '&::before': { - display: isActive ? 'block' : 'none', - content: '" "', - position: 'absolute', - left: theme.spacing(1), - top: theme.spacing(1.5), - bottom: theme.spacing(1.5), - width: theme.spacing(0.5), - borderRadius: theme.shape.borderRadius(1), - backgroundImage: theme.colors.gradients.brandVertical, - }, - - '&:focus-visible': { - backgroundColor: theme.colors.action.hover, - boxShadow: 'none', - color: theme.colors.text.primary, - outline: `${theme.shape.borderRadius(1)} solid ${theme.colors.primary.main}`, - outlineOffset: `-${theme.shape.borderRadius(1)}`, - transition: 'none', - }, - }), - icon: css({ - height: '100%', - width: '100%', - - img: { - borderRadius: '50%', - height: theme.spacing(3), - width: theme.spacing(3), - }, - }), -}); diff --git a/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx b/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx deleted file mode 100644 index 160cc967e35..00000000000 --- a/public/app/core/components/NavBar/Next/NavBarItemWithoutMenu.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React, { ReactNode } from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Link, useTheme2 } from '@grafana/ui'; - -import { NavFeatureHighlight } from '../NavFeatureHighlight'; - -export interface NavBarItemWithoutMenuProps { - label: string; - children: ReactNode; - className?: string; - elClassName?: string; - url?: string; - target?: string; - isActive?: boolean; - onClick?: () => void; - highlightText?: string; -} - -export function NavBarItemWithoutMenu({ - label, - children, - url, - target, - isActive = false, - onClick, - highlightText, - className, - elClassName, -}: NavBarItemWithoutMenuProps) { - const theme = useTheme2(); - const styles = getNavBarItemWithoutMenuStyles(theme, isActive); - - const content = highlightText ? ( - -
      {children}
      -
      - ) : ( -
      {children}
      - ); - - const elStyle = cx(styles.element, elClassName); - - const renderContents = () => { - if (!url) { - return ( - - ); - } else if (!target && url.startsWith('/')) { - return ( - - {content} - - ); - } else { - return ( - - {content} - - ); - } - }; - - return
      {renderContents()}
      ; -} - -export function getNavBarItemWithoutMenuStyles(theme: GrafanaTheme2, isActive?: boolean) { - return { - container: css({ - position: 'relative', - color: isActive ? theme.colors.text.primary : theme.colors.text.secondary, - display: 'grid', - - '&:hover': { - backgroundColor: theme.colors.action.hover, - color: theme.colors.text.primary, - }, - }), - element: css({ - backgroundColor: 'transparent', - border: 'none', - color: 'inherit', - display: 'block', - padding: 0, - overflowWrap: 'anywhere', - - '&::before': { - display: isActive ? 'block' : 'none', - content: "' '", - position: 'absolute', - left: theme.spacing(1), - top: theme.spacing(1.5), - bottom: theme.spacing(1.5), - width: theme.spacing(0.5), - borderRadius: theme.shape.borderRadius(1), - backgroundImage: theme.colors.gradients.brandVertical, - }, - - '&:focus-visible': { - backgroundColor: theme.colors.action.hover, - boxShadow: 'none', - color: theme.colors.text.primary, - outline: `${theme.shape.borderRadius(1)} solid ${theme.colors.primary.main}`, - outlineOffset: `-${theme.shape.borderRadius(1)}`, - transition: 'none', - }, - }), - - icon: css({ - height: '100%', - width: '100%', - - img: { - borderRadius: '50%', - height: theme.spacing(3), - width: theme.spacing(3), - }, - }), - }; -} diff --git a/public/app/core/components/NavBar/Next/NavBarMenu.tsx b/public/app/core/components/NavBar/Next/NavBarMenu.tsx deleted file mode 100644 index 3ea31a4e153..00000000000 --- a/public/app/core/components/NavBar/Next/NavBarMenu.tsx +++ /dev/null @@ -1,450 +0,0 @@ -import { css, cx } from '@emotion/css'; -import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; -import { OverlayContainer, useOverlay } from '@react-aria/overlays'; -import React, { useRef } from 'react'; -import CSSTransition from 'react-transition-group/CSSTransition'; -import { useLocalStorage } from 'react-use'; - -import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; -import { CollapsableSection, CustomScrollbar, Icon, IconButton, IconName, useStyles2, useTheme2 } from '@grafana/ui'; - -import { Branding } from '../../Branding/Branding'; -import { NavFeatureHighlight } from '../NavFeatureHighlight'; -import { isMatchOrChildMatch } from '../utils'; - -import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; -import { NavBarMenuItem } from './NavBarMenuItem'; -import { NavBarToggle } from './NavBarToggle'; - -const MENU_WIDTH = '350px'; - -export interface Props { - activeItem?: NavModelItem; - isOpen: boolean; - navItems: NavModelItem[]; - setMenuAnimationInProgress: (isInProgress: boolean) => void; - onClose: () => void; -} - -export function NavBarMenu({ activeItem, isOpen, navItems, onClose, setMenuAnimationInProgress }: Props) { - const theme = useTheme2(); - const styles = getStyles(theme); - const ANIMATION_DURATION = theme.transitions.duration.standard; - const animStyles = getAnimStyles(theme, ANIMATION_DURATION); - const ref = useRef(null); - const { dialogProps } = useDialog({}, ref); - const { overlayProps, underlayProps } = useOverlay( - { - isDismissable: true, - isOpen, - onClose, - }, - ref - ); - - return ( - - - setMenuAnimationInProgress(true)} - onExited={() => setMenuAnimationInProgress(false)} - appear={isOpen} - in={isOpen} - classNames={animStyles.overlay} - timeout={ANIMATION_DURATION} - > -
      -
      - - -
      - { - reportInteraction('grafana_navigation_collapsed'); - onClose(); - }} - /> - -
      -
      -
      - -
      - - - ); -} - -NavBarMenu.displayName = 'NavBarMenu'; - -const getStyles = (theme: GrafanaTheme2) => ({ - backdrop: css({ - backdropFilter: 'blur(1px)', - backgroundColor: theme.components.overlay.background, - bottom: 0, - left: 0, - position: 'fixed', - right: 0, - top: 0, - zIndex: theme.zIndex.modalBackdrop, - }), - container: css({ - display: 'flex', - bottom: 0, - flexDirection: 'column', - left: 0, - paddingTop: theme.spacing(1), - marginRight: theme.spacing(1.5), - right: 0, - zIndex: theme.zIndex.modal, - position: 'fixed', - top: 0, - boxSizing: 'content-box', - [theme.breakpoints.up('md')]: { - borderRight: `1px solid ${theme.colors.border.weak}`, - right: 'unset', - }, - }), - content: css({ - display: 'flex', - flexDirection: 'column', - overflow: 'auto', - }), - mobileHeader: css({ - borderBottom: `1px solid ${theme.colors.border.weak}`, - display: 'flex', - justifyContent: 'space-between', - padding: theme.spacing(1, 2, 2), - [theme.breakpoints.up('md')]: { - display: 'none', - }, - }), - itemList: css({ - display: 'grid', - gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, - minWidth: MENU_WIDTH, - }), - menuCollapseIcon: css({ - position: 'absolute', - top: '43px', - right: '0px', - transform: `translateX(50%)`, - }), -}); - -const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { - const commonTransition = { - transitionDuration: `${animationDuration}ms`, - transitionTimingFunction: theme.transitions.easing.easeInOut, - [theme.breakpoints.down('md')]: { - overflow: 'hidden', - }, - }; - - const overlayTransition = { - ...commonTransition, - transitionProperty: 'background-color, box-shadow, width', - // this is needed to prevent a horizontal scrollbar during the animation on firefox - '.scrollbar-view': { - overflow: 'hidden !important', - }, - }; - - const backdropTransition = { - ...commonTransition, - transitionProperty: 'opacity', - }; - - const overlayOpen = { - backgroundColor: theme.colors.background.canvas, - boxShadow: theme.shadows.z3, - width: '100%', - [theme.breakpoints.up('md')]: { - width: MENU_WIDTH, - }, - }; - - const overlayClosed = { - boxShadow: 'none', - width: 0, - [theme.breakpoints.up('md')]: { - backgroundColor: theme.colors.background.primary, - width: theme.spacing(7), - }, - }; - - const backdropOpen = { - opacity: 1, - }; - - const backdropClosed = { - opacity: 0, - }; - - return { - backdrop: { - appear: css(backdropClosed), - appearActive: css(backdropTransition, backdropOpen), - appearDone: css(backdropOpen), - exit: css(backdropOpen), - exitActive: css(backdropTransition, backdropClosed), - }, - overlay: { - appear: css(overlayClosed), - appearActive: css(overlayTransition, overlayOpen), - appearDone: css(overlayOpen), - exit: css(overlayOpen), - exitActive: css(overlayTransition, overlayClosed), - }, - }; -}; - -function NavItem({ - link, - activeItem, - onClose, -}: { - link: NavModelItem; - activeItem?: NavModelItem; - onClose: () => void; -}) { - const styles = useStyles2(getNavItemStyles); - - if (linkHasChildren(link)) { - return ( - -
        - {link.children.map( - (childLink) => - !childLink.divider && ( - { - childLink.onClick?.(); - onClose(); - }} - styleOverrides={styles.item} - target={childLink.target} - text={childLink.text} - url={childLink.url} - isMobile={true} - /> - ) - )} -
      -
      - ); - } else { - const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment; - return ( -
    • - { - link.onClick?.(); - onClose(); - }} - isActive={link === activeItem} - > -
      -
      - {getLinkIcon(link)} -
      - {link.text} -
      -
      -
    • - ); - } -} - -const getNavItemStyles = (theme: GrafanaTheme2) => ({ - children: css({ - display: 'flex', - flexDirection: 'column', - }), - item: css({ - padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`, - width: `calc(100% - ${theme.spacing(3)})`, - '&::before': { - display: 'none', - }, - }), - flex: css({ - display: 'flex', - }), - itemWithoutMenu: css({ - position: 'relative', - placeItems: 'inherit', - justifyContent: 'start', - display: 'flex', - flexGrow: 1, - alignItems: 'center', - }), - fullWidth: css({ - height: '100%', - width: '100%', - }), - iconContainer: css({ - display: 'flex', - placeContent: 'center', - }), - itemWithoutMenuContent: css({ - display: 'grid', - gridAutoFlow: 'column', - gridTemplateColumns: `${theme.spacing(7)} auto`, - alignItems: 'center', - height: '100%', - }), - linkText: css({ - fontSize: theme.typography.pxToRem(14), - justifySelf: 'start', - padding: theme.spacing(0.5, 4.25, 0.5, 0.5), - }), -}); - -function CollapsibleNavItem({ - link, - isActive, - children, - className, - onClose, -}: { - link: NavModelItem; - isActive?: boolean; - children: React.ReactNode; - className?: string; - onClose: () => void; -}) { - const styles = useStyles2(getCollapsibleStyles); - const [sectionExpanded, setSectionExpanded] = useLocalStorage(`grafana.navigation.expanded[${link.text}]`, false); - const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment; - - return ( -
    • - { - link.onClick?.(); - onClose(); - }} - className={styles.collapsibleMenuItem} - elClassName={styles.collapsibleIcon} - > - {getLinkIcon(link)} - -
      - setSectionExpanded(isOpen)} - className={styles.collapseWrapper} - contentClassName={styles.collapseContent} - label={ -
      - {link.text} -
      - } - > - {children} -
      -
      -
    • - ); -} - -const getCollapsibleStyles = (theme: GrafanaTheme2) => ({ - menuItem: css({ - position: 'relative', - display: 'grid', - gridAutoFlow: 'column', - gridTemplateColumns: `${theme.spacing(7)} minmax(calc(${MENU_WIDTH} - ${theme.spacing(7)}), auto)`, - }), - collapsibleMenuItem: css({ - height: theme.spacing(6), - width: theme.spacing(7), - display: 'grid', - }), - collapsibleIcon: css({ - display: 'grid', - placeContent: 'center', - }), - collapsibleSectionWrapper: css({ - display: 'flex', - flexGrow: 1, - alignSelf: 'start', - flexDirection: 'column', - }), - collapseWrapper: css({ - paddingLeft: theme.spacing(0.5), - paddingRight: theme.spacing(4.25), - minHeight: theme.spacing(6), - overflowWrap: 'anywhere', - alignItems: 'center', - color: theme.colors.text.secondary, - '&:hover, &:focus-within': { - backgroundColor: theme.colors.action.hover, - color: theme.colors.text.primary, - }, - '&:focus-within': { - boxShadow: 'none', - outline: `2px solid ${theme.colors.primary.main}`, - outlineOffset: '-2px', - transition: 'none', - }, - }), - collapseContent: css({ - padding: 0, - }), - labelWrapper: css({ - fontSize: '15px', - }), - primary: css({ - color: theme.colors.text.primary, - }), - linkText: css({ - fontSize: theme.typography.pxToRem(14), - justifySelf: 'start', - }), -}); - -function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } { - return Boolean(link.children && link.children.length > 0); -} - -function getLinkIcon(link: NavModelItem) { - if (link.id === 'home') { - return ; - } else if (link.icon) { - return ; - } else { - return {`${link.text}; - } -} diff --git a/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx b/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx deleted file mode 100644 index 8eb61b400ae..00000000000 --- a/public/app/core/components/NavBar/Next/NavBarMenuItem.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; - -export interface Props { - icon?: IconName; - isActive?: boolean; - isDivider?: boolean; - onClick?: () => void; - styleOverrides?: string; - target?: HTMLAnchorElement['target']; - text: React.ReactNode; - url?: string; - adjustHeightForBorder?: boolean; - isMobile?: boolean; -} - -export function NavBarMenuItem({ - icon, - isActive, - isDivider, - onClick, - styleOverrides, - target, - text, - url, - isMobile = false, -}: Props) { - const theme = useTheme2(); - const styles = getStyles(theme, isActive); - const elStyle = cx(styles.element, styleOverrides); - const linkContent = ( -
      - {icon && } -
      {text}
      - {target === '_blank' && ( - - )} -
      - ); - - let element = ( - - ); - - if (url) { - element = - !target && url.startsWith('/') ? ( - - {linkContent} - - ) : ( - - {linkContent} - - ); - } - - if (isMobile) { - return isDivider ? ( -
    • - ) : ( -
    • {element}
    • - ); - } - - return isDivider ? ( -
      - ) : ( -
      {element}
      - ); -} - -NavBarMenuItem.displayName = 'NavBarMenuItem'; - -const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ - linkContent: css({ - alignItems: 'center', - display: 'flex', - gap: '0.5rem', - width: '100%', - }), - linkText: css({ - textOverflow: 'ellipsis', - overflow: 'hidden', - whiteSpace: 'nowrap', - }), - externalLinkIcon: css({ - color: theme.colors.text.secondary, - gridColumnStart: 3, - }), - element: css({ - alignItems: 'center', - background: 'none', - border: 'none', - color: isActive ? theme.colors.text.primary : theme.colors.text.secondary, - display: 'flex', - flex: 1, - fontSize: 'inherit', - height: '100%', - overflowWrap: 'anywhere', - padding: theme.spacing(0.5, 2), - textAlign: 'left', - width: '100%', - '&:hover, &:focus-visible': { - backgroundColor: theme.colors.action.hover, - color: theme.colors.text.primary, - }, - '&:focus-visible': { - boxShadow: 'none', - outline: `2px solid ${theme.colors.primary.main}`, - outlineOffset: '-2px', - transition: 'none', - }, - '&::before': { - display: isActive ? 'block' : 'none', - content: '" "', - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - width: theme.spacing(0.5), - borderRadius: theme.shape.borderRadius(1), - backgroundImage: theme.colors.gradients.brandVertical, - }, - }), - listItem: css({ - position: 'relative', - display: 'flex', - alignItems: 'center', - - '&:hover, &:focus-within': { - color: theme.colors.text.primary, - - '> *:first-child::after': { - backgroundColor: theme.colors.action.hover, - }, - }, - }), - divider: css({ - borderBottom: `1px solid ${theme.colors.border.weak}`, - height: '1px', - margin: `${theme.spacing(1)} 0`, - overflow: 'hidden', - }), -}); diff --git a/public/app/core/components/NavBar/Next/NavBarNext.test.tsx b/public/app/core/components/NavBar/Next/NavBarNext.test.tsx deleted file mode 100644 index 9c3c96f4de1..00000000000 --- a/public/app/core/components/NavBar/Next/NavBarNext.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import { Provider } from 'react-redux'; -import { Router } from 'react-router-dom'; - -import { locationService } from '@grafana/runtime'; -import { configureStore } from 'app/store/configureStore'; - -import TestProvider from '../../../../../test/helpers/TestProvider'; - -import { NavBarNext } from './NavBarNext'; - -jest.mock('app/core/services/context_srv', () => ({ - contextSrv: { - sidemenu: true, - user: {}, - isSignedIn: false, - isGrafanaAdmin: false, - isEditor: false, - hasEditPermissionFolders: false, - }, -})); - -const setup = () => { - const store = configureStore(); - - return render( - - - - - - - - ); -}; - -describe('Render', () => { - beforeEach(() => { - // IntersectionObserver isn't available in test environment - const mockIntersectionObserver = jest.fn(); - mockIntersectionObserver.mockReturnValue({ - observe: () => null, - unobserve: () => null, - disconnect: () => null, - }); - window.IntersectionObserver = mockIntersectionObserver; - }); - - it('should render component', async () => { - setup(); - const sidemenu = await screen.findByTestId('sidemenu'); - expect(sidemenu).toBeInTheDocument(); - }); - - it('should not render when in kiosk mode is tv', async () => { - setup(); - - locationService.partial({ kiosk: 'tv' }); - const sidemenu = screen.queryByTestId('sidemenu'); - expect(sidemenu).not.toBeInTheDocument(); - }); - - it('should not render when in kiosk mode is full', async () => { - setup(); - - locationService.partial({ kiosk: '1' }); - const sidemenu = screen.queryByTestId('sidemenu'); - expect(sidemenu).not.toBeInTheDocument(); - }); -}); diff --git a/public/app/core/components/NavBar/Next/NavBarNext.tsx b/public/app/core/components/NavBar/Next/NavBarNext.tsx deleted file mode 100644 index fa1031aca37..00000000000 --- a/public/app/core/components/NavBar/Next/NavBarNext.tsx +++ /dev/null @@ -1,296 +0,0 @@ -import { css, cx } from '@emotion/css'; -import { FocusScope } from '@react-aria/focus'; -import { cloneDeep } from 'lodash'; -import React, { useState } from 'react'; -import { useSelector } from 'react-redux'; -import { useLocation } from 'react-router-dom'; - -import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data'; -import { config, locationService, reportInteraction } from '@grafana/runtime'; -import { Icon, IconName, useTheme2 } from '@grafana/ui'; -import { Branding } from 'app/core/components/Branding/Branding'; -import { getKioskMode } from 'app/core/navigation/kiosk'; -import { KioskMode, StoreState } from 'app/types'; - -import { OrgSwitcher } from '../../OrgSwitcher'; -import { NavBarContext } from '../context'; -import { - enrichConfigItems, - enrichWithInteractionTracking, - getActiveItem, - isMatchOrChildMatch, - isSearchActive, - SEARCH_ITEM_ID, -} from '../utils'; - -import NavBarItem from './NavBarItem'; -import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; -import { NavBarMenu } from './NavBarMenu'; -import { NavBarMenuPortalContainer } from './NavBarMenuPortalContainer'; -import { NavBarScrollContainer } from './NavBarScrollContainer'; -import { NavBarToggle } from './NavBarToggle'; - -const onOpenSearch = () => { - locationService.partial({ search: 'open' }); -}; - -export const NavBarNext = React.memo(() => { - const navBarTree = useSelector((state: StoreState) => state.navBarTree); - const theme = useTheme2(); - const styles = getStyles(theme); - const location = useLocation(); - const kiosk = getKioskMode(); - const [showSwitcherModal, setShowSwitcherModal] = useState(false); - const [menuOpen, setMenuOpen] = useState(false); - const [menuAnimationInProgress, setMenuAnimationInProgress] = useState(false); - const [menuIdOpen, setMenuIdOpen] = useState(undefined); - - const toggleSwitcherModal = () => { - setShowSwitcherModal(!showSwitcherModal); - }; - - // Here we need to hack in a "home" and "search" NavModelItem since this is constructed in the frontend - const searchItem: NavModelItem = enrichWithInteractionTracking( - { - id: SEARCH_ITEM_ID, - onClick: onOpenSearch, - text: 'Search dashboards', - icon: 'search', - }, - menuOpen - ); - - const homeItem: NavModelItem = enrichWithInteractionTracking( - { - id: 'home', - text: 'Home', - url: config.appSubUrl || '/', - icon: 'grafana', - }, - menuOpen - ); - - const navTree = cloneDeep(navBarTree); - - const coreItems = navTree - .filter((item) => item.section === NavSection.Core) - .map((item) => enrichWithInteractionTracking(item, menuOpen)); - const pluginItems = navTree - .filter((item) => item.section === NavSection.Plugin) - .map((item) => enrichWithInteractionTracking(item, menuOpen)); - const configItems = enrichConfigItems( - navTree.filter((item) => item.section === NavSection.Config), - location, - toggleSwitcherModal - ).map((item) => enrichWithInteractionTracking(item, menuOpen)); - - const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname); - - if (kiosk !== KioskMode.Off) { - return null; - } - return ( -
      - - {showSwitcherModal && } - {(menuOpen || menuAnimationInProgress) && ( -
      - setMenuOpen(false)} - /> -
      - )} -
      - ); -}); - -NavBarNext.displayName = 'NavBarNext'; - -const getStyles = (theme: GrafanaTheme2) => ({ - navWrapper: css({ - position: 'relative', - display: 'flex', - - '.sidemenu-hidden &': { - display: 'none', - }, - }), - sidemenu: css({ - label: 'sidemenu', - display: 'flex', - flexDirection: 'column', - backgroundColor: theme.colors.background.primary, - zIndex: theme.zIndex.sidemenu, - padding: `${theme.spacing(1)} 0`, - position: 'relative', - width: theme.components.sidemenu.width, - borderRight: `1px solid ${theme.colors.border.weak}`, - - [theme.breakpoints.down('md')]: { - height: theme.spacing(7), - position: 'fixed', - paddingTop: '0px', - backgroundColor: 'inherit', - borderRight: 0, - }, - }), - mobileSidemenuLogo: css({ - alignItems: 'center', - cursor: 'pointer', - display: 'flex', - flexDirection: 'row', - justifyContent: 'space-between', - padding: theme.spacing(2), - - [theme.breakpoints.up('md')]: { - display: 'none', - }, - }), - itemList: css({ - backgroundColor: 'inherit', - display: 'flex', - flexDirection: 'column', - height: '100%', - - [theme.breakpoints.down('md')]: { - visibility: 'hidden', - }, - }), - grafanaLogo: css({ - alignItems: 'stretch', - display: 'flex', - flexShrink: 0, - height: theme.spacing(6), - justifyContent: 'stretch', - - [theme.breakpoints.down('md')]: { - visibility: 'hidden', - }, - }), - grafanaLogoInner: css({ - alignItems: 'center', - display: 'flex', - height: '100%', - justifyContent: 'center', - width: '100%', - - '> div': { - height: 'auto', - width: 'auto', - }, - }), - search: css({ - display: 'none', - marginTop: 0, - - [theme.breakpoints.up('md')]: { - display: 'grid', - }, - }), - verticalSpacer: css({ - marginTop: 'auto', - }), - hideFromMobile: css({ - [theme.breakpoints.down('md')]: { - display: 'none', - }, - }), - menuWrapper: css({ - position: 'fixed', - display: 'grid', - gridAutoFlow: 'column', - height: '100%', - zIndex: theme.zIndex.sidemenu, - }), - menuExpandIcon: css({ - position: 'absolute', - top: '43px', - right: '0px', - transform: `translateX(50%)`, - }), - menuPortalContainer: css({ - zIndex: theme.zIndex.sidemenu, - }), -}); diff --git a/public/app/core/components/NavBar/utils.test.ts b/public/app/core/components/NavBar/utils.test.ts index 23ac4adb156..b062a199130 100644 --- a/public/app/core/components/NavBar/utils.test.ts +++ b/public/app/core/components/NavBar/utils.test.ts @@ -3,7 +3,7 @@ import { Location } from 'history'; import { NavModelItem } from '@grafana/data'; import { ContextSrv, setContextSrv } from 'app/core/services/context_srv'; -import { getConfig, updateConfig } from '../../config'; +import { updateConfig } from '../../config'; import { enrichConfigItems, getActiveItem, getForcedLoginUrl, isMatchOrChildMatch, isSearchActive } from './utils'; @@ -226,57 +226,19 @@ describe('getActiveItem', () => { }); }); - describe('when the newNavigation feature toggle is disabled', () => { - beforeEach(() => { - updateConfig({ - featureToggles: { - ...getConfig().featureToggles, - newNavigation: false, - }, - }); - }); - - it('returns the base route link if the pathname starts with /d/', () => { - const mockPathName = '/d/foo'; - expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ - text: 'Base', - url: '/', - }); - }); - - it('returns a more specific link if one exists', () => { - const mockPathName = '/d/moreSpecificDashboard'; - expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ - text: 'More specific dashboard', - url: '/d/moreSpecificDashboard', - }); + it('returns the dashboards route link if the pathname starts with /d/', () => { + const mockPathName = '/d/foo'; + expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ + text: 'Dashboards', + url: '/dashboards', }); }); - describe('when the newNavigation feature toggle is enabled', () => { - beforeEach(() => { - updateConfig({ - featureToggles: { - ...getConfig().featureToggles, - newNavigation: true, - }, - }); - }); - - it('returns the dashboards route link if the pathname starts with /d/', () => { - const mockPathName = '/d/foo'; - expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ - text: 'Dashboards', - url: '/dashboards', - }); - }); - - it('returns a more specific link if one exists', () => { - const mockPathName = '/d/moreSpecificDashboard'; - expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ - text: 'More specific dashboard', - url: '/d/moreSpecificDashboard', - }); + it('returns a more specific link if one exists', () => { + const mockPathName = '/d/moreSpecificDashboard'; + expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ + text: 'More specific dashboard', + url: '/d/moreSpecificDashboard', }); }); }); diff --git a/public/app/core/components/NavBar/utils.ts b/public/app/core/components/NavBar/utils.ts index af29773bca7..80c0838c661 100644 --- a/public/app/core/components/NavBar/utils.ts +++ b/public/app/core/components/NavBar/utils.ts @@ -117,8 +117,7 @@ export const getActiveItem = ( pathname: string, currentBestMatch?: NavModelItem ): NavModelItem | undefined => { - const newNavigationEnabled = getConfig().featureToggles.newNavigation; - const dashboardLinkMatch = newNavigationEnabled ? '/dashboards' : '/'; + const dashboardLinkMatch = '/dashboards'; for (const link of navTree) { const linkPathname = stripQueryParams(link.url);