diff --git a/pkg/api/index.go b/pkg/api/index.go index a4069de409b..98bf217821e 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -105,7 +105,7 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) } if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { - appLink.Url = path.Join(hs.Cfg.AppSubURL, "a", plugin.ID) + appLink.Url = hs.Cfg.AppSubURL + "/a/" + plugin.ID } else { appLink.Url = path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL) } @@ -521,6 +521,7 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b Text: "Browse", Id: "dashboards/browse", Url: hs.Cfg.AppSubURL + "/dashboards", Icon: "sitemap", }) } + dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ Text: "Playlists", Id: "dashboards/playlists", Url: hs.Cfg.AppSubURL + "/playlists", Icon: "presentation-play", }) diff --git a/public/app/core/components/MegaMenu/MegaMenu.test.tsx b/public/app/core/components/MegaMenu/MegaMenu.test.tsx index 6ca38ce58a2..c8cdd3b531c 100644 --- a/public/app/core/components/MegaMenu/MegaMenu.test.tsx +++ b/public/app/core/components/MegaMenu/MegaMenu.test.tsx @@ -56,8 +56,8 @@ describe('MegaMenu', () => { setup(); expect(await screen.findByTestId('navbarmenu')).toBeInTheDocument(); - expect(await screen.findByLabelText('Home')).toBeInTheDocument(); - expect(screen.queryAllByLabelText('Section name').length).toBe(2); + expect(await screen.findByRole('link', { name: 'Home' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Section name' })).toBeInTheDocument(); }); it('should filter out profile', async () => { diff --git a/public/app/core/components/MegaMenu/NavBarMenu.tsx b/public/app/core/components/MegaMenu/NavBarMenu.tsx index ca81a5a15c4..95be7b68ac3 100644 --- a/public/app/core/components/MegaMenu/NavBarMenu.tsx +++ b/public/app/core/components/MegaMenu/NavBarMenu.tsx @@ -11,9 +11,10 @@ import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { TOP_BAR_LEVEL_HEIGHT } from '../AppChrome/types'; -import { NavItem } from '../NavBar/NavBarMenu'; import { NavBarToggle } from '../NavBar/NavBarToggle'; +import { NavBarMenuItemWrapper } from './NavBarMenuItemWrapper'; + const MENU_WIDTH = '350px'; export interface Props { @@ -59,7 +60,7 @@ export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: P in={isOpen} unmountOnExit={true} classNames={animStyles.overlay} - timeout={animationSpeed} + timeout={{ enter: animationSpeed, exit: 0 }} onExited={onClose} >
@@ -86,7 +87,7 @@ export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: P @@ -121,18 +122,17 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { position: 'fixed', right: 0, top: topPosition, - zIndex: theme.zIndex.navbarFixed - 2, + 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, // Needs to below navbar should we change the navbarFixed? add add a new level? - zIndex: theme.zIndex.navbarFixed - 1, + zIndex: theme.zIndex.modal, position: 'fixed', top: topPosition, backgroundColor: theme.colors.background.primary, @@ -151,7 +151,7 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { borderBottom: `1px solid ${theme.colors.border.weak}`, display: 'flex', justifyContent: 'space-between', - padding: theme.spacing(1, 2, 2), + padding: theme.spacing(1, 2), [theme.breakpoints.up('md')]: { display: 'none', }, @@ -159,11 +159,12 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { itemList: css({ display: 'grid', gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, + gridTemplateColumns: `minmax(${MENU_WIDTH}, auto)`, minWidth: MENU_WIDTH, }), menuCollapseIcon: css({ position: 'absolute', - top: '43px', + top: '20px', right: '0px', transform: `translateX(50%)`, }), @@ -219,15 +220,11 @@ const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { enter: css(backdropClosed), enterActive: css(backdropTransition, backdropOpen), enterDone: css(backdropOpen), - exit: css(backdropOpen), - exitActive: css(backdropTransition, backdropClosed), }, overlay: { enter: css(overlayClosed), enterActive: css(overlayTransition, overlayOpen), enterDone: css(overlayOpen), - exit: css(overlayOpen), - exitActive: css(overlayTransition, overlayClosed), }, }; }; diff --git a/public/app/core/components/MegaMenu/NavBarMenuItem.tsx b/public/app/core/components/MegaMenu/NavBarMenuItem.tsx new file mode 100644 index 00000000000..c3dbe7b08b0 --- /dev/null +++ b/public/app/core/components/MegaMenu/NavBarMenuItem.tsx @@ -0,0 +1,122 @@ +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 { + children: React.ReactNode; + icon?: IconName; + isActive?: boolean; + isChild?: boolean; + onClick?: () => void; + target?: HTMLAnchorElement['target']; + url?: string; +} + +export function NavBarMenuItem({ children, icon, isActive, isChild, onClick, target, url }: Props) { + const theme = useTheme2(); + const styles = getStyles(theme, isActive, isChild); + + const linkContent = ( +
+ {icon && } + +
{children}
+ + {target === '_blank' && ( + + )} +
+ ); + + let element = ( + + ); + + if (url) { + element = + !target && url.startsWith('/') ? ( + + {linkContent} + + ) : ( + + {linkContent} + + ); + } + + return
  • {element}
  • ; +} + +NavBarMenuItem.displayName = 'NavBarMenuItem'; + +const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], isChild: Props['isActive']) => ({ + button: css({ + backgroundColor: 'unset', + borderStyle: 'unset', + }), + linkContent: css({ + alignItems: 'center', + display: 'flex', + gap: '0.5rem', + height: '100%', + width: '100%', + }), + linkText: css({ + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }), + externalLinkIcon: css({ + color: theme.colors.text.secondary, + }), + element: css({ + alignItems: 'center', + boxSizing: 'border-box', + position: 'relative', + color: isActive ? theme.colors.text.primary : theme.colors.text.secondary, + padding: theme.spacing(1, 1, 1, isChild ? 5 : 0), + ...(isChild && { + borderRadius: theme.shape.borderRadius(), + }), + width: '100%', + '&:hover, &:focus-visible': { + ...(isChild && { + background: theme.colors.emphasize(theme.colors.background.primary, 0.03), + }), + textDecoration: 'underline', + 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: '" "', + height: theme.spacing(3), + position: 'absolute', + left: theme.spacing(1), + top: '50%', + transform: 'translateY(-50%)', + width: theme.spacing(0.5), + borderRadius: theme.shape.borderRadius(1), + backgroundImage: theme.colors.gradients.brandVertical, + }, + }), + listItem: css({ + boxSizing: 'border-box', + position: 'relative', + display: 'flex', + width: '100%', + ...(isChild && { + padding: theme.spacing(0, 2), + }), + }), +}); diff --git a/public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx b/public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx new file mode 100644 index 00000000000..deba44a0ed0 --- /dev/null +++ b/public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx @@ -0,0 +1,112 @@ +import { css } from '@emotion/css'; +import { useLingui } from '@lingui/react'; +import React from 'react'; + +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { toIconName, useStyles2 } from '@grafana/ui'; + +import menuItemTranslations from '../NavBar/navBarItem-translations'; +import { isMatchOrChildMatch } from '../NavBar/utils'; + +import { NavBarMenuItem } from './NavBarMenuItem'; +import { NavBarMenuSection } from './NavBarMenuSection'; + +export function NavBarMenuItemWrapper({ + link, + activeItem, + onClose, +}: { + link: NavModelItem; + activeItem?: NavModelItem; + onClose: () => void; +}) { + const { i18n } = useLingui(); + const styles = useStyles2(getStyles); + + if (link.emptyMessageId && !linkHasChildren(link)) { + const emptyMessageTranslated = i18n._(menuItemTranslations[link.emptyMessageId]); + return ( + + + + ); + } + + return ( + + {linkHasChildren(link) && ( + + )} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + children: css({ + display: 'flex', + flexDirection: 'column', + }), + 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', + }), + emptyMessage: css({ + color: theme.colors.text.secondary, + fontStyle: 'italic', + padding: theme.spacing(1, 1.5, 1, 7), + }), +}); + +function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } { + return Boolean(link.children && link.children.length > 0); +} diff --git a/public/app/core/components/MegaMenu/NavBarMenuSection.tsx b/public/app/core/components/MegaMenu/NavBarMenuSection.tsx new file mode 100644 index 00000000000..626bcff47dd --- /dev/null +++ b/public/app/core/components/MegaMenu/NavBarMenuSection.tsx @@ -0,0 +1,116 @@ +import { css, cx } from '@emotion/css'; +import React, { useState } from 'react'; + +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { Button, Icon, useStyles2 } from '@grafana/ui'; + +import { NavBarItemIcon } from '../NavBar/NavBarItemIcon'; +import { NavFeatureHighlight } from '../NavBar/NavFeatureHighlight'; +import { hasChildMatch } from '../NavBar/utils'; + +import { NavBarMenuItem } from './NavBarMenuItem'; + +export function NavBarMenuSection({ + link, + activeItem, + children, + className, + onClose, +}: { + link: NavModelItem; + activeItem?: NavModelItem; + children: React.ReactNode; + className?: string; + onClose?: () => void; +}) { + const styles = useStyles2(getStyles); + const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment; + const isActive = link === activeItem; + const hasActiveChild = hasChildMatch(link, activeItem); + const [sectionExpanded, setSectionExpanded] = useState(Boolean(hasActiveChild)); + + return ( + <> +
    + { + link.onClick?.(); + onClose?.(); + }} + target={link.target} + url={link.url} + > +
    + + + + {link.text} +
    +
    + {Boolean(link.children?.length) && ( + + )} +
    + {sectionExpanded && children} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + collapsibleSectionWrapper: css({ + alignItems: 'center', + display: 'flex', + }), + collapseButton: css({ + color: theme.colors.text.disabled, + padding: theme.spacing(0, 0.5), + marginRight: theme.spacing(1), + }), + collapseWrapperActive: css({ + backgroundColor: theme.colors.action.disabledBackground, + }), + collapseContent: css({ + padding: 0, + }), + labelWrapper: css({ + display: 'grid', + fontSize: theme.typography.pxToRem(14), + gridAutoFlow: 'column', + gridTemplateColumns: `${theme.spacing(7)} auto`, + placeItems: 'center', + fontWeight: theme.typography.fontWeightMedium, + }), + isActive: css({ + color: theme.colors.text.primary, + + '&::before': { + display: 'block', + content: '" "', + height: theme.spacing(3), + position: 'absolute', + left: theme.spacing(1), + top: '50%', + transform: 'translateY(-50%)', + width: theme.spacing(0.5), + borderRadius: theme.shape.borderRadius(1), + backgroundImage: theme.colors.gradients.brandVertical, + }, + }), + hasActiveChild: css({ + color: theme.colors.text.primary, + }), +}); diff --git a/public/app/core/components/NavBar/utils.ts b/public/app/core/components/NavBar/utils.ts index 05935e3603c..24eeaa5212f 100644 --- a/public/app/core/components/NavBar/utils.ts +++ b/public/app/core/components/NavBar/utils.ts @@ -100,7 +100,19 @@ export const enrichWithInteractionTracking = (item: NavModelItem, expandedState: }; export const isMatchOrChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem) => { - return Boolean(itemToCheck === searchItem || itemToCheck.children?.some((child) => child === searchItem)); + return Boolean(itemToCheck === searchItem || hasChildMatch(itemToCheck, searchItem)); +}; + +export const hasChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem): boolean => { + return Boolean( + itemToCheck.children?.some((child) => { + if (child === searchItem) { + return true; + } else { + return hasChildMatch(child, searchItem); + } + }) + ); }; const stripQueryParams = (url?: string) => { diff --git a/public/app/core/components/PageNew/SectionNavItem.tsx b/public/app/core/components/PageNew/SectionNavItem.tsx index bf6034685ea..ff7b3348866 100644 --- a/public/app/core/components/PageNew/SectionNavItem.tsx +++ b/public/app/core/components/PageNew/SectionNavItem.tsx @@ -68,7 +68,7 @@ const getStyles = (theme: GrafanaTheme2) => { activeStyle: css` label: activeTabStyle; color: ${theme.colors.text.primary}; - background-color: ${theme.colors.action.disabledBackground}; + background: ${theme.colors.emphasize(theme.colors.background.canvas, 0.03)}; border-radius: ${theme.shape.borderRadius(2)}; font-weight: ${theme.typography.fontWeightMedium}; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 4680ec0d367..5d39368ca06 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -4,11 +4,10 @@ import { connect, ConnectedProps } from 'react-redux'; import { locationUtil, NavModel, NavModelItem, TimeRange, PageLayoutType } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { Themeable2, withTheme2 } from '@grafana/ui'; import { notifyApp } from 'app/core/actions'; import { Page } from 'app/core/components/Page/Page'; -import { config } from 'app/core/config'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { getKioskMode } from 'app/core/navigation/kiosk';