mirror of https://github.com/grafana/grafana
Chore: Remove `newNavigation` feature toggle and old navbar code (#50872)
* Remove newNavigation feature toggle + old code * fix unit tests * remove buildCreateNavLinkspull/51013/head
parent
d0808bdafb
commit
d0fa326798
@ -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 ( |
||||
<FocusScope contain restoreFocus autoFocus> |
||||
<div data-testid="navbarmenu" className={styles.container} ref={ref} {...overlayProps} {...dialogProps}> |
||||
<div className={styles.header}> |
||||
<Icon name="bars" size="xl" /> |
||||
<IconButton aria-label="Close navigation menu" name="times" onClick={onClose} size="xl" variant="secondary" /> |
||||
</div> |
||||
<nav className={styles.content}> |
||||
<CustomScrollbar> |
||||
<ul> |
||||
{navItems.map((link) => ( |
||||
<div className={styles.section} key={link.text}> |
||||
<NavBarMenuItem |
||||
isActive={activeItem === link} |
||||
onClick={() => { |
||||
link.onClick?.(); |
||||
onClose(); |
||||
}} |
||||
styleOverrides={styles.sectionHeader} |
||||
target={link.target} |
||||
text={link.text} |
||||
url={link.url} |
||||
isMobile={true} |
||||
/> |
||||
{link.children?.map( |
||||
(childLink) => |
||||
!childLink.divider && ( |
||||
<NavBarMenuItem |
||||
key={childLink.text} |
||||
icon={childLink.icon as IconName} |
||||
isActive={activeItem === childLink} |
||||
isDivider={childLink.divider} |
||||
onClick={() => { |
||||
childLink.onClick?.(); |
||||
onClose(); |
||||
}} |
||||
styleOverrides={styles.item} |
||||
target={childLink.target} |
||||
text={childLink.text} |
||||
url={childLink.url} |
||||
isMobile={true} |
||||
/> |
||||
) |
||||
)} |
||||
</div> |
||||
))} |
||||
</ul> |
||||
</CustomScrollbar> |
||||
</nav> |
||||
</div> |
||||
</FocusScope> |
||||
<OverlayContainer> |
||||
<FocusScope contain restoreFocus autoFocus> |
||||
<CSSTransition |
||||
onEnter={() => setMenuAnimationInProgress(true)} |
||||
onExited={() => setMenuAnimationInProgress(false)} |
||||
appear={isOpen} |
||||
in={isOpen} |
||||
classNames={animStyles.overlay} |
||||
timeout={ANIMATION_DURATION} |
||||
> |
||||
<div data-testid="navbarmenu" ref={ref} {...overlayProps} {...dialogProps} className={styles.container}> |
||||
<div className={styles.mobileHeader}> |
||||
<Icon name="bars" size="xl" /> |
||||
<IconButton |
||||
aria-label="Close navigation menu" |
||||
name="times" |
||||
onClick={onClose} |
||||
size="xl" |
||||
variant="secondary" |
||||
/> |
||||
</div> |
||||
<NavBarToggle |
||||
className={styles.menuCollapseIcon} |
||||
isExpanded={isOpen} |
||||
onClick={() => { |
||||
reportInteraction('grafana_navigation_collapsed'); |
||||
onClose(); |
||||
}} |
||||
/> |
||||
<nav className={styles.content}> |
||||
<CustomScrollbar hideHorizontalTrack> |
||||
<ul className={styles.itemList}> |
||||
{navItems.map((link) => ( |
||||
<NavItem link={link} onClose={onClose} activeItem={activeItem} key={link.text} /> |
||||
))} |
||||
</ul> |
||||
</CustomScrollbar> |
||||
</nav> |
||||
</div> |
||||
</CSSTransition> |
||||
</FocusScope> |
||||
<CSSTransition appear={isOpen} in={isOpen} classNames={animStyles.backdrop} timeout={ANIMATION_DURATION}> |
||||
<div className={styles.backdrop} {...underlayProps} /> |
||||
</CSSTransition> |
||||
</OverlayContainer> |
||||
); |
||||
} |
||||
|
||||
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 ( |
||||
<CollapsibleNavItem onClose={onClose} link={link} isActive={isMatchOrChildMatch(link, activeItem)}> |
||||
<ul className={styles.children}> |
||||
{link.children.map( |
||||
(childLink) => |
||||
!childLink.divider && ( |
||||
<NavBarMenuItem |
||||
key={`${link.text}-${childLink.text}`} |
||||
isActive={activeItem === childLink} |
||||
isDivider={childLink.divider} |
||||
icon={childLink.showIconInNavbar ? (childLink.icon as IconName) : undefined} |
||||
onClick={() => { |
||||
childLink.onClick?.(); |
||||
onClose(); |
||||
}} |
||||
styleOverrides={styles.item} |
||||
target={childLink.target} |
||||
text={childLink.text} |
||||
url={childLink.url} |
||||
isMobile={true} |
||||
/> |
||||
) |
||||
)} |
||||
</ul> |
||||
</CollapsibleNavItem> |
||||
); |
||||
} else { |
||||
const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment; |
||||
return ( |
||||
<li className={styles.flex}> |
||||
<NavBarItemWithoutMenu |
||||
className={styles.itemWithoutMenu} |
||||
elClassName={styles.fullWidth} |
||||
label={link.text} |
||||
url={link.url} |
||||
target={link.target} |
||||
onClick={() => { |
||||
link.onClick?.(); |
||||
onClose(); |
||||
}} |
||||
isActive={link === activeItem} |
||||
> |
||||
<div className={styles.itemWithoutMenuContent}> |
||||
<div className={styles.iconContainer}> |
||||
<FeatureHighlightWrapper>{getLinkIcon(link)}</FeatureHighlightWrapper> |
||||
</div> |
||||
<span className={styles.linkText}>{link.text}</span> |
||||
</div> |
||||
</NavBarItemWithoutMenu> |
||||
</li> |
||||
); |
||||
} |
||||
} |
||||
|
||||
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 ( |
||||
<li className={cx(styles.menuItem, className)}> |
||||
<NavBarItemWithoutMenu |
||||
isActive={isActive} |
||||
label={link.text} |
||||
url={link.url} |
||||
target={link.target} |
||||
onClick={() => { |
||||
link.onClick?.(); |
||||
onClose(); |
||||
}} |
||||
className={styles.collapsibleMenuItem} |
||||
elClassName={styles.collapsibleIcon} |
||||
> |
||||
<FeatureHighlightWrapper>{getLinkIcon(link)}</FeatureHighlightWrapper> |
||||
</NavBarItemWithoutMenu> |
||||
<div className={styles.collapsibleSectionWrapper}> |
||||
<CollapsableSection |
||||
isOpen={Boolean(sectionExpanded)} |
||||
onToggle={(isOpen) => setSectionExpanded(isOpen)} |
||||
className={styles.collapseWrapper} |
||||
contentClassName={styles.collapseContent} |
||||
label={ |
||||
<div className={cx(styles.labelWrapper, { [styles.primary]: isActive })}> |
||||
<span className={styles.linkText}>{link.text}</span> |
||||
</div> |
||||
} |
||||
> |
||||
{children} |
||||
</CollapsableSection> |
||||
</div> |
||||
</li> |
||||
); |
||||
} |
||||
|
||||
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 <Branding.MenuLogo />; |
||||
} else if (link.icon) { |
||||
return <Icon name={link.icon as IconName} size="xl" />; |
||||
} else { |
||||
return <img src={link.img} alt={`${link.text} logo`} height="24" width="24" style={{ borderRadius: '50%' }} />; |
||||
} |
||||
} |
||||
|
||||
@ -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 ( |
||||
<ul data-testid="navbar-section" className={cx(styles.container, className)}> |
||||
{children} |
||||
</ul> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
container: css` |
||||
display: none; |
||||
list-style: none; |
||||
|
||||
${theme.breakpoints.up('md')} { |
||||
display: flex; |
||||
flex-direction: inherit; |
||||
} |
||||
`,
|
||||
}); |
||||
@ -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 ( |
||||
<NavBarItemWithoutMenu |
||||
label={link.text} |
||||
className={className} |
||||
isActive={isActive} |
||||
url={link.url} |
||||
onClick={link.onClick} |
||||
target={link.target} |
||||
highlightText={link.highlightText} |
||||
> |
||||
{children} |
||||
</NavBarItemWithoutMenu> |
||||
); |
||||
} else { |
||||
return ( |
||||
<li className={cx(styles.container, { [styles.containerHover]: section.id === menuIdOpen }, className)}> |
||||
<NavBarItemMenuTrigger |
||||
item={section} |
||||
isActive={isActive} |
||||
label={linkText} |
||||
reverseMenuDirection={reverseMenuDirection} |
||||
> |
||||
<NavBarItemMenu |
||||
items={items} |
||||
reverseMenuDirection={reverseMenuDirection} |
||||
adjustHeightForBorder={adjustHeightForBorder} |
||||
disabledKeys={['divider', 'subtitle']} |
||||
aria-label={section.text} |
||||
onNavigate={onNavigate} |
||||
> |
||||
{(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 ( |
||||
<Item key={getNavModelItemKey(item)} textValue={item.text}> |
||||
<NavBarMenuItem |
||||
isDivider={!isSection && item.divider} |
||||
icon={icon} |
||||
target={item.target} |
||||
text={itemText} |
||||
url={item.url} |
||||
onClick={item.onClick} |
||||
styleOverrides={cx(styles.primaryText, { [styles.header]: isSection })} |
||||
/> |
||||
</Item> |
||||
); |
||||
}} |
||||
</NavBarItemMenu> |
||||
</NavBarItemMenuTrigger> |
||||
</li> |
||||
); |
||||
} |
||||
}; |
||||
|
||||
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%', |
||||
}), |
||||
}); |
||||
@ -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<NavModelItem> { |
||||
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<NavModelItem>({ ...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 = <NavBarItemMenuItem key={section.key} item={section} state={state} onNavigate={onNavigate} />; |
||||
|
||||
const itemComponents = items.map((item) => ( |
||||
<NavBarItemMenuItem key={getNavModelItemKey(item.value)} item={item} state={state} onNavigate={onNavigate} /> |
||||
)); |
||||
|
||||
const subTitleComponent = menuSubTitle && ( |
||||
<li key={menuSubTitle} className={styles.subtitle}> |
||||
{menuSubTitle} |
||||
</li> |
||||
); |
||||
|
||||
const contents = [itemComponents, subTitleComponent]; |
||||
const contentComponent = ( |
||||
<NavBarScrollContainer key="scrollContainer"> |
||||
{reverseMenuDirection ? contents.reverse() : contents} |
||||
</NavBarScrollContainer> |
||||
); |
||||
|
||||
const menu = [headerComponent, contentComponent]; |
||||
|
||||
return ( |
||||
<ul className={styles.menu} ref={ref} {...mergeProps(menuProps, contextMenuProps)} tabIndex={menuHasFocus ? 0 : -1}> |
||||
{reverseMenuDirection ? menu.reverse() : menu} |
||||
</ul> |
||||
); |
||||
} |
||||
|
||||
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; |
||||
`,
|
||||
}; |
||||
} |
||||
@ -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<NavModelItem>; |
||||
state: TreeState<NavModelItem>; |
||||
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<HTMLLIElement>(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 ( |
||||
<> |
||||
<li {...mergeProps(menuItemProps, focusProps, keyboardProps)} ref={ref} className={styles.menuItem}> |
||||
{rendered} |
||||
</li> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
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; |
||||
`,
|
||||
}; |
||||
} |
||||
@ -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<HTMLElement>(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 = ( |
||||
<Wrapper> |
||||
<span className={styles.icon}> |
||||
{item?.icon && <Icon name={item.icon as IconName} size="xl" />} |
||||
{item?.img && <img src={item.img} alt={`${item.text} logo`} />} |
||||
</span> |
||||
</Wrapper> |
||||
); |
||||
let element = ( |
||||
<button |
||||
className={styles.element} |
||||
{...buttonProps} |
||||
{...keyboardProps} |
||||
{...hoverProps} |
||||
ref={ref as React.RefObject<HTMLButtonElement>} |
||||
onClick={item?.onClick} |
||||
aria-label={label} |
||||
> |
||||
{itemContent} |
||||
</button> |
||||
); |
||||
|
||||
if (item?.url) { |
||||
element = |
||||
!item.target && item.url.startsWith('/') ? ( |
||||
<Link |
||||
{...buttonProps} |
||||
{...keyboardProps} |
||||
{...hoverProps} |
||||
ref={ref as React.RefObject<HTMLAnchorElement>} |
||||
href={item.url} |
||||
target={item.target} |
||||
onClick={item?.onClick} |
||||
className={styles.element} |
||||
aria-label={label} |
||||
> |
||||
{itemContent} |
||||
</Link> |
||||
) : ( |
||||
<a |
||||
href={item.url} |
||||
target={item.target} |
||||
onClick={item?.onClick} |
||||
{...buttonProps} |
||||
{...keyboardProps} |
||||
{...hoverProps} |
||||
ref={ref as React.RefObject<HTMLAnchorElement>} |
||||
className={styles.element} |
||||
aria-label={label} |
||||
> |
||||
{itemContent} |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
const overlayRef = React.useRef<HTMLDivElement>(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 ( |
||||
<div className={cx(styles.element, 'dropdown')} {...focusWithinProps}> |
||||
{element} |
||||
{state.isOpen && ( |
||||
<OverlayContainer portalContainer={getNavMenuPortalContainer()}> |
||||
<NavBarItemMenuContext.Provider |
||||
value={{ |
||||
menuProps, |
||||
menuHasFocus, |
||||
onClose: () => state.close(), |
||||
onLeft: () => { |
||||
setMenuHasFocus(false); |
||||
ref.current?.focus(); |
||||
}, |
||||
}} |
||||
> |
||||
<FocusScope restoreFocus> |
||||
<div {...overlayProps} {...overlayPositionProps} {...dialogProps} {...hoverProps} ref={overlayRef}> |
||||
<DismissButton onDismiss={() => state.close()} /> |
||||
{menu} |
||||
<DismissButton onDismiss={() => state.close()} /> |
||||
</div> |
||||
</FocusScope> |
||||
</NavBarItemMenuContext.Provider> |
||||
</OverlayContainer> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
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), |
||||
}, |
||||
}), |
||||
}); |
||||
@ -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 ? ( |
||||
<NavFeatureHighlight> |
||||
<div className={styles.icon}>{children}</div> |
||||
</NavFeatureHighlight> |
||||
) : ( |
||||
<div className={styles.icon}>{children}</div> |
||||
); |
||||
|
||||
const elStyle = cx(styles.element, elClassName); |
||||
|
||||
const renderContents = () => { |
||||
if (!url) { |
||||
return ( |
||||
<button className={elStyle} onClick={onClick} aria-label={label}> |
||||
{content} |
||||
</button> |
||||
); |
||||
} else if (!target && url.startsWith('/')) { |
||||
return ( |
||||
<Link className={elStyle} href={url} target={target} aria-label={label} onClick={onClick} aria-haspopup="true"> |
||||
{content} |
||||
</Link> |
||||
); |
||||
} else { |
||||
return ( |
||||
<a href={url} target={target} className={elStyle} onClick={onClick} aria-label={label}> |
||||
{content} |
||||
</a> |
||||
); |
||||
} |
||||
}; |
||||
|
||||
return <div className={cx(styles.container, className)}>{renderContents()}</div>; |
||||
} |
||||
|
||||
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), |
||||
}, |
||||
}), |
||||
}; |
||||
} |
||||
@ -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 ( |
||||
<OverlayContainer> |
||||
<FocusScope contain restoreFocus autoFocus> |
||||
<CSSTransition |
||||
onEnter={() => setMenuAnimationInProgress(true)} |
||||
onExited={() => setMenuAnimationInProgress(false)} |
||||
appear={isOpen} |
||||
in={isOpen} |
||||
classNames={animStyles.overlay} |
||||
timeout={ANIMATION_DURATION} |
||||
> |
||||
<div data-testid="navbarmenu" ref={ref} {...overlayProps} {...dialogProps} className={styles.container}> |
||||
<div className={styles.mobileHeader}> |
||||
<Icon name="bars" size="xl" /> |
||||
<IconButton |
||||
aria-label="Close navigation menu" |
||||
name="times" |
||||
onClick={onClose} |
||||
size="xl" |
||||
variant="secondary" |
||||
/> |
||||
</div> |
||||
<NavBarToggle |
||||
className={styles.menuCollapseIcon} |
||||
isExpanded={isOpen} |
||||
onClick={() => { |
||||
reportInteraction('grafana_navigation_collapsed'); |
||||
onClose(); |
||||
}} |
||||
/> |
||||
<nav className={styles.content}> |
||||
<CustomScrollbar hideHorizontalTrack> |
||||
<ul className={styles.itemList}> |
||||
{navItems.map((link) => ( |
||||
<NavItem link={link} onClose={onClose} activeItem={activeItem} key={link.text} /> |
||||
))} |
||||
</ul> |
||||
</CustomScrollbar> |
||||
</nav> |
||||
</div> |
||||
</CSSTransition> |
||||
</FocusScope> |
||||
<CSSTransition appear={isOpen} in={isOpen} classNames={animStyles.backdrop} timeout={ANIMATION_DURATION}> |
||||
<div className={styles.backdrop} {...underlayProps} /> |
||||
</CSSTransition> |
||||
</OverlayContainer> |
||||
); |
||||
} |
||||
|
||||
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 ( |
||||
<CollapsibleNavItem onClose={onClose} link={link} isActive={isMatchOrChildMatch(link, activeItem)}> |
||||
<ul className={styles.children}> |
||||
{link.children.map( |
||||
(childLink) => |
||||
!childLink.divider && ( |
||||
<NavBarMenuItem |
||||
key={`${link.text}-${childLink.text}`} |
||||
isActive={activeItem === childLink} |
||||
isDivider={childLink.divider} |
||||
icon={childLink.showIconInNavbar ? (childLink.icon as IconName) : undefined} |
||||
onClick={() => { |
||||
childLink.onClick?.(); |
||||
onClose(); |
||||
}} |
||||
styleOverrides={styles.item} |
||||
target={childLink.target} |
||||
text={childLink.text} |
||||
url={childLink.url} |
||||
isMobile={true} |
||||
/> |
||||
) |
||||
)} |
||||
</ul> |
||||
</CollapsibleNavItem> |
||||
); |
||||
} else { |
||||
const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment; |
||||
return ( |
||||
<li className={styles.flex}> |
||||
<NavBarItemWithoutMenu |
||||
className={styles.itemWithoutMenu} |
||||
elClassName={styles.fullWidth} |
||||
label={link.text} |
||||
url={link.url} |
||||
target={link.target} |
||||
onClick={() => { |
||||
link.onClick?.(); |
||||
onClose(); |
||||
}} |
||||
isActive={link === activeItem} |
||||
> |
||||
<div className={styles.itemWithoutMenuContent}> |
||||
<div className={styles.iconContainer}> |
||||
<FeatureHighlightWrapper>{getLinkIcon(link)}</FeatureHighlightWrapper> |
||||
</div> |
||||
<span className={styles.linkText}>{link.text}</span> |
||||
</div> |
||||
</NavBarItemWithoutMenu> |
||||
</li> |
||||
); |
||||
} |
||||
} |
||||
|
||||
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 ( |
||||
<li className={cx(styles.menuItem, className)}> |
||||
<NavBarItemWithoutMenu |
||||
isActive={isActive} |
||||
label={link.text} |
||||
url={link.url} |
||||
target={link.target} |
||||
onClick={() => { |
||||
link.onClick?.(); |
||||
onClose(); |
||||
}} |
||||
className={styles.collapsibleMenuItem} |
||||
elClassName={styles.collapsibleIcon} |
||||
> |
||||
<FeatureHighlightWrapper>{getLinkIcon(link)}</FeatureHighlightWrapper> |
||||
</NavBarItemWithoutMenu> |
||||
<div className={styles.collapsibleSectionWrapper}> |
||||
<CollapsableSection |
||||
isOpen={Boolean(sectionExpanded)} |
||||
onToggle={(isOpen) => setSectionExpanded(isOpen)} |
||||
className={styles.collapseWrapper} |
||||
contentClassName={styles.collapseContent} |
||||
label={ |
||||
<div className={cx(styles.labelWrapper, { [styles.primary]: isActive })}> |
||||
<span className={styles.linkText}>{link.text}</span> |
||||
</div> |
||||
} |
||||
> |
||||
{children} |
||||
</CollapsableSection> |
||||
</div> |
||||
</li> |
||||
); |
||||
} |
||||
|
||||
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 <Branding.MenuLogo />; |
||||
} else if (link.icon) { |
||||
return <Icon name={link.icon as IconName} size="xl" />; |
||||
} else { |
||||
return <img src={link.img} alt={`${link.text} logo`} height="24" width="24" style={{ borderRadius: '50%' }} />; |
||||
} |
||||
} |
||||
@ -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 = ( |
||||
<div className={styles.linkContent}> |
||||
{icon && <Icon data-testid="dropdown-child-icon" name={icon} />} |
||||
<div className={styles.linkText}>{text}</div> |
||||
{target === '_blank' && ( |
||||
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} /> |
||||
)} |
||||
</div> |
||||
); |
||||
|
||||
let element = ( |
||||
<button className={elStyle} onClick={onClick} tabIndex={-1}> |
||||
{linkContent} |
||||
</button> |
||||
); |
||||
|
||||
if (url) { |
||||
element = |
||||
!target && url.startsWith('/') ? ( |
||||
<Link className={elStyle} href={url} target={target} onClick={onClick} tabIndex={!isMobile ? -1 : 0}> |
||||
{linkContent} |
||||
</Link> |
||||
) : ( |
||||
<a href={url} target={target} className={elStyle} onClick={onClick} tabIndex={!isMobile ? -1 : 0}> |
||||
{linkContent} |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
if (isMobile) { |
||||
return isDivider ? ( |
||||
<li data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled /> |
||||
) : ( |
||||
<li className={styles.listItem}>{element}</li> |
||||
); |
||||
} |
||||
|
||||
return isDivider ? ( |
||||
<div data-testid="dropdown-child-divider" className={styles.divider} tabIndex={-1} aria-disabled /> |
||||
) : ( |
||||
<div style={{ position: 'relative' }}>{element}</div> |
||||
); |
||||
} |
||||
|
||||
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', |
||||
}), |
||||
}); |
||||
@ -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( |
||||
<Provider store={store}> |
||||
<TestProvider> |
||||
<Router history={locationService.getHistory()}> |
||||
<NavBarNext /> |
||||
</Router> |
||||
</TestProvider> |
||||
</Provider> |
||||
); |
||||
}; |
||||
|
||||
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(); |
||||
}); |
||||
}); |
||||
@ -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<string | undefined>(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 ( |
||||
<div className={styles.navWrapper}> |
||||
<nav className={cx(styles.sidemenu, 'sidemenu')} data-testid="sidemenu" aria-label="Main menu"> |
||||
<NavBarContext.Provider |
||||
value={{ |
||||
menuIdOpen: menuIdOpen, |
||||
setMenuIdOpen: setMenuIdOpen, |
||||
}} |
||||
> |
||||
<FocusScope> |
||||
<div className={styles.mobileSidemenuLogo} onClick={() => setMenuOpen(!menuOpen)} key="hamburger"> |
||||
<Icon name="bars" size="xl" /> |
||||
</div> |
||||
|
||||
<NavBarToggle |
||||
className={styles.menuExpandIcon} |
||||
isExpanded={menuOpen} |
||||
onClick={() => { |
||||
reportInteraction('grafana_navigation_expanded'); |
||||
setMenuOpen(true); |
||||
}} |
||||
/> |
||||
|
||||
<NavBarMenuPortalContainer /> |
||||
|
||||
<NavBarItemWithoutMenu |
||||
elClassName={styles.grafanaLogoInner} |
||||
label={homeItem.text} |
||||
className={styles.grafanaLogo} |
||||
url={homeItem.url} |
||||
onClick={homeItem.onClick} |
||||
> |
||||
<Branding.MenuLogo /> |
||||
</NavBarItemWithoutMenu> |
||||
|
||||
<NavBarScrollContainer> |
||||
<ul className={styles.itemList}> |
||||
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem}> |
||||
<Icon name="search" size="xl" /> |
||||
</NavBarItem> |
||||
|
||||
{coreItems.map((link, index) => ( |
||||
<NavBarItem |
||||
key={`${link.id}-${index}`} |
||||
isActive={isMatchOrChildMatch(link, activeItem)} |
||||
link={{ ...link, subTitle: undefined }} |
||||
> |
||||
{link.icon && <Icon name={link.icon as IconName} size="xl" />} |
||||
{link.img && <img src={link.img} alt={`${link.text} logo`} />} |
||||
</NavBarItem> |
||||
))} |
||||
|
||||
{pluginItems.length > 0 && |
||||
pluginItems.map((link, index) => ( |
||||
<NavBarItem |
||||
key={`${link.id}-${index}`} |
||||
isActive={isMatchOrChildMatch(link, activeItem)} |
||||
link={link} |
||||
> |
||||
{link.icon && <Icon name={link.icon as IconName} size="xl" />} |
||||
{link.img && <img src={link.img} alt={`${link.text} logo`} />} |
||||
</NavBarItem> |
||||
))} |
||||
|
||||
{configItems.map((link, index) => ( |
||||
<NavBarItem |
||||
key={`${link.id}-${index}`} |
||||
isActive={isMatchOrChildMatch(link, activeItem)} |
||||
reverseMenuDirection |
||||
link={link} |
||||
className={cx({ [styles.verticalSpacer]: index === 0 })} |
||||
> |
||||
{link.icon && <Icon name={link.icon as IconName} size="xl" />} |
||||
{link.img && <img src={link.img} alt={`${link.text} logo`} />} |
||||
</NavBarItem> |
||||
))} |
||||
</ul> |
||||
</NavBarScrollContainer> |
||||
</FocusScope> |
||||
</NavBarContext.Provider> |
||||
</nav> |
||||
{showSwitcherModal && <OrgSwitcher onDismiss={toggleSwitcherModal} />} |
||||
{(menuOpen || menuAnimationInProgress) && ( |
||||
<div className={styles.menuWrapper}> |
||||
<NavBarMenu |
||||
activeItem={activeItem} |
||||
isOpen={menuOpen} |
||||
setMenuAnimationInProgress={setMenuAnimationInProgress} |
||||
navItems={[homeItem, searchItem, ...coreItems, ...pluginItems, ...configItems]} |
||||
onClose={() => setMenuOpen(false)} |
||||
/> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
}); |
||||
|
||||
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, |
||||
}), |
||||
}); |
||||
Loading…
Reference in new issue