mirror of https://github.com/grafana/grafana
Chore: Clean up old navigation (#66287)
* remove code outside of the topnav feature flag * delete NavBar folder * remove topnav toggle from backend * restructure AppChrome folder * fix utils mock * fix applinks tests * remove tests since they're covered in e2e * fix 1 of the approotpage tests * Fix another dashboardpage test * remove reverse portalling + test for plugins using deprecated onNavChanged method * kick drone * handle correlationspull/66500/head
parent
202afb9041
commit
4abe0249ba
@ -1,51 +0,0 @@ |
||||
import { act, render, screen } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
|
||||
import { locationService } from '@grafana/runtime'; |
||||
|
||||
import { TestProvider } from '../../../../test/helpers/TestProvider'; |
||||
|
||||
import { NavBar } from './NavBar'; |
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({ |
||||
contextSrv: { |
||||
sidemenu: true, |
||||
user: {}, |
||||
isSignedIn: false, |
||||
isGrafanaAdmin: false, |
||||
isEditor: false, |
||||
hasEditPermissionFolders: false, |
||||
}, |
||||
})); |
||||
|
||||
const setup = () => { |
||||
return render( |
||||
<TestProvider> |
||||
<NavBar /> |
||||
</TestProvider> |
||||
); |
||||
}; |
||||
|
||||
describe('NavBar', () => { |
||||
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(); |
||||
|
||||
act(() => 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(); |
||||
|
||||
act(() => 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 { Location as HistoryLocation } from 'history'; |
||||
import { cloneDeep } from 'lodash'; |
||||
import React, { useState } from 'react'; |
||||
import { useLocation } from 'react-router-dom'; |
||||
|
||||
import { GrafanaTheme2, locationUtil, NavModelItem, NavSection, textUtil } from '@grafana/data'; |
||||
import { config, locationSearchToObject, locationService, reportInteraction } from '@grafana/runtime'; |
||||
import { useTheme2, CustomScrollbar, IconButton } from '@grafana/ui'; |
||||
import { getKioskMode } from 'app/core/navigation/kiosk'; |
||||
import { useSelector } from 'app/types'; |
||||
|
||||
import NavBarItem from './NavBarItem'; |
||||
import { NavBarItemIcon } from './NavBarItemIcon'; |
||||
import { NavBarItemWithoutMenu } from './NavBarItemWithoutMenu'; |
||||
import { NavBarMenu } from './NavBarMenu'; |
||||
import { NavBarMenuPortalContainer } from './NavBarMenuPortalContainer'; |
||||
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' }); |
||||
}; |
||||
|
||||
export const NavBar = React.memo(() => { |
||||
const navBarTree = useSelector((state) => state.navBarTree); |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
const location = useLocation(); |
||||
const [menuOpen, setMenuOpen] = useState(false); |
||||
const [menuAnimationInProgress, setMenuAnimationInProgress] = useState(false); |
||||
const [menuIdOpen, setMenuIdOpen] = useState<string | undefined>(undefined); |
||||
|
||||
// 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 |
||||
); |
||||
|
||||
let homeUrl = config.appSubUrl || '/'; |
||||
if (!config.bootData.user.isSignedIn && !config.anonymousEnabled) { |
||||
homeUrl = textUtil.sanitizeUrl(locationUtil.getUrlForPartial(location, { forceLogin: 'true' })); |
||||
} |
||||
|
||||
const homeItem: NavModelItem = enrichWithInteractionTracking( |
||||
{ |
||||
id: 'home', |
||||
text: 'Home', |
||||
url: homeUrl, |
||||
icon: 'grafana', |
||||
}, |
||||
menuOpen |
||||
); |
||||
|
||||
const navTree = cloneDeep(navBarTree).filter((item) => item.hideFromMenu !== true); |
||||
|
||||
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 |
||||
).map((item) => enrichWithInteractionTracking(item, menuOpen)); |
||||
|
||||
const activeItem = isSearchActive(location) ? searchItem : getActiveItem(navTree, location.pathname); |
||||
|
||||
if (shouldHideNavBar(location)) { |
||||
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} key="hamburger"> |
||||
<IconButton |
||||
name="bars" |
||||
tooltip="Toggle menu" |
||||
tooltipPlacement="bottom" |
||||
size="xl" |
||||
onClick={() => setMenuOpen(!menuOpen)} |
||||
/> |
||||
</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} |
||||
> |
||||
<NavBarItemIcon link={homeItem} /> |
||||
</NavBarItemWithoutMenu> |
||||
|
||||
<CustomScrollbar hideHorizontalTrack hideVerticalTrack showScrollIndicators> |
||||
<ul className={styles.itemList}> |
||||
<NavBarItem className={styles.search} isActive={activeItem === searchItem} link={searchItem} /> |
||||
|
||||
{coreItems.map((link, index) => ( |
||||
<NavBarItem |
||||
key={`${link.id}-${index}`} |
||||
isActive={isMatchOrChildMatch(link, activeItem)} |
||||
link={{ ...link, subTitle: undefined }} |
||||
/> |
||||
))} |
||||
|
||||
{pluginItems.length > 0 && |
||||
pluginItems.map((link, index) => ( |
||||
<NavBarItem |
||||
key={`${link.id}-${index}`} |
||||
isActive={isMatchOrChildMatch(link, activeItem)} |
||||
link={link} |
||||
/> |
||||
))} |
||||
|
||||
{configItems.map((link, index) => ( |
||||
<NavBarItem |
||||
key={`${link.id}-${index}`} |
||||
isActive={isMatchOrChildMatch(link, activeItem)} |
||||
reverseMenuDirection |
||||
link={link} |
||||
className={cx({ [styles.verticalSpacer]: index === 0 })} |
||||
/> |
||||
))} |
||||
</ul> |
||||
</CustomScrollbar> |
||||
</FocusScope> |
||||
</NavBarContext.Provider> |
||||
</nav> |
||||
{(menuOpen || menuAnimationInProgress) && ( |
||||
<div className={styles.menuWrapper}> |
||||
<NavBarMenu |
||||
activeItem={activeItem} |
||||
isOpen={menuOpen} |
||||
setMenuAnimationInProgress={setMenuAnimationInProgress} |
||||
navItems={[homeItem, searchItem, ...coreItems, ...pluginItems, ...configItems]} |
||||
onClose={() => setMenuOpen(false)} |
||||
/> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
}); |
||||
|
||||
function shouldHideNavBar(location: HistoryLocation) { |
||||
const queryParams = locationSearchToObject(location.search); |
||||
|
||||
if (getKioskMode(queryParams)) { |
||||
return true; |
||||
} |
||||
|
||||
// Temporary, can be removed after topnav is made permanent
|
||||
if ((location.pathname.indexOf('/d/') === 0 && queryParams.editview) || queryParams.editPanel) { |
||||
return true; |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
NavBar.displayName = 'NavBar'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
navWrapper: css({ |
||||
position: 'relative', |
||||
display: 'flex', |
||||
}), |
||||
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', |
||||
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, |
||||
}), |
||||
}); |
@ -1,247 +0,0 @@ |
||||
import { render, screen, waitFor } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import React from 'react'; |
||||
import { BrowserRouter } from 'react-router-dom'; |
||||
|
||||
import { locationUtil } from '@grafana/data'; |
||||
import { config, LocationService, setLocationService } from '@grafana/runtime'; |
||||
|
||||
// Need to mock createBrowserHistory here to avoid errors
|
||||
jest.mock('history', () => ({ |
||||
...jest.requireActual('history'), |
||||
createBrowserHistory: () => ({ |
||||
listen: jest.fn(), |
||||
location: {}, |
||||
createHref: jest.fn(), |
||||
}), |
||||
})); |
||||
|
||||
import NavBarItem, { Props } from './NavBarItem'; |
||||
import { NavBarContext } from './context'; |
||||
|
||||
const onClickMock = jest.fn(); |
||||
const setMenuIdOpenMock = jest.fn(); |
||||
const defaults: Props = { |
||||
link: { |
||||
text: 'Parent Node', |
||||
onClick: onClickMock, |
||||
children: [ |
||||
{ text: 'Child Node 1', onClick: onClickMock, children: [] }, |
||||
{ text: 'Child Node 2', onClick: onClickMock, children: [] }, |
||||
], |
||||
id: 'MY_NAV_ID', |
||||
}, |
||||
}; |
||||
|
||||
async function getTestContext(overrides: Partial<Props> = {}, subUrl = '', isMenuOpen = false) { |
||||
jest.clearAllMocks(); |
||||
config.appSubUrl = subUrl; |
||||
locationUtil.initialize({ config, getTimeRangeForUrl: jest.fn(), getVariablesUrlParams: jest.fn() }); |
||||
const pushMock = jest.fn(); |
||||
const locationService = { push: pushMock } as unknown as LocationService; |
||||
setLocationService(locationService); |
||||
const props = { ...defaults, ...overrides }; |
||||
|
||||
const { rerender } = render( |
||||
<BrowserRouter> |
||||
<NavBarContext.Provider |
||||
value={{ |
||||
menuIdOpen: isMenuOpen ? props.link.id : undefined, |
||||
setMenuIdOpen: setMenuIdOpenMock, |
||||
}} |
||||
> |
||||
<NavBarItem {...props} /> |
||||
</NavBarContext.Provider> |
||||
</BrowserRouter> |
||||
); |
||||
|
||||
// Need to click this first to set the correct selection range
|
||||
// see https://github.com/testing-library/user-event/issues/901#issuecomment-1087192424
|
||||
await userEvent.click(document.body); |
||||
return { rerender, pushMock }; |
||||
} |
||||
|
||||
describe('NavBarItem', () => { |
||||
describe('when url property is not set', () => { |
||||
it('then it renders the menu trigger as a button', async () => { |
||||
await getTestContext(); |
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1); |
||||
}); |
||||
|
||||
describe('and clicking on the menu trigger button', () => { |
||||
it('then the onClick handler should be called', async () => { |
||||
await getTestContext(); |
||||
|
||||
await userEvent.click(screen.getByRole('button')); |
||||
expect(onClickMock).toHaveBeenCalledTimes(1); |
||||
}); |
||||
}); |
||||
|
||||
describe('and hovering over the menu trigger button', () => { |
||||
it('then the menuIdOpen should be set correctly', async () => { |
||||
await getTestContext(); |
||||
|
||||
await userEvent.hover(screen.getByRole('button')); |
||||
expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id); |
||||
}); |
||||
}); |
||||
|
||||
describe('and tabbing to the menu trigger button', () => { |
||||
it('then the menuIdOpen should be set correctly', async () => { |
||||
await getTestContext(); |
||||
|
||||
await userEvent.tab(); |
||||
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(undefined, undefined, true); |
||||
|
||||
await userEvent.tab(); |
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(3); |
||||
expect(screen.getByRole('menuitem', { name: 'Parent Node' })).toHaveAttribute('tabIndex', '-1'); |
||||
expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); |
||||
expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); |
||||
|
||||
await userEvent.keyboard('{ArrowRight}'); |
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(3); |
||||
expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '0'); |
||||
expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); |
||||
expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('when url property is set', () => { |
||||
it('then it renders the menu trigger as a link', async () => { |
||||
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); |
||||
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1); |
||||
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://www.grafana.com'); |
||||
}); |
||||
|
||||
describe('and hovering over the menu trigger link', () => { |
||||
it('sets the correct menuIdOpen', async () => { |
||||
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); |
||||
|
||||
await userEvent.hover(screen.getByRole('link')); |
||||
|
||||
expect(setMenuIdOpenMock).toHaveBeenCalledWith(defaults.link.id); |
||||
}); |
||||
}); |
||||
|
||||
describe('and tabbing to the menu trigger link', () => { |
||||
it('sets the correct menuIdOpen', async () => { |
||||
await getTestContext({ link: { ...defaults.link, url: 'https://www.grafana.com' } }); |
||||
|
||||
await userEvent.tab(); |
||||
|
||||
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' } }, undefined, true); |
||||
|
||||
await userEvent.tab(); |
||||
expect(screen.getAllByRole('link')[0]).toHaveFocus(); |
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(3); |
||||
expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '-1'); |
||||
expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); |
||||
expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); |
||||
|
||||
await userEvent.keyboard('{ArrowRight}'); |
||||
expect(screen.getAllByRole('link')[0]).not.toHaveFocus(); |
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(3); |
||||
expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '0'); |
||||
expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); |
||||
expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); |
||||
}); |
||||
}); |
||||
|
||||
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' } }, undefined, true); |
||||
|
||||
await userEvent.tab(); |
||||
await userEvent.keyboard('{ArrowRight}'); |
||||
expect(screen.getAllByRole('link')[0]).not.toHaveFocus(); |
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(3); |
||||
expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '0'); |
||||
expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); |
||||
expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); |
||||
|
||||
await userEvent.keyboard('{ArrowLeft}'); |
||||
expect(screen.getAllByRole('link')[0]).toHaveFocus(); |
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(3); |
||||
expect(screen.getAllByRole('menuitem')[0]).toHaveAttribute('tabIndex', '-1'); |
||||
expect(screen.getAllByRole('menuitem')[1]).toHaveAttribute('tabIndex', '-1'); |
||||
expect(screen.getAllByRole('menuitem')[2]).toHaveAttribute('tabIndex', '-1'); |
||||
}); |
||||
}); |
||||
|
||||
describe('when appSubUrl is 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: [] }], |
||||
}, |
||||
}, |
||||
'/grafana', |
||||
true |
||||
); |
||||
|
||||
await userEvent.click(screen.getByText('New')); |
||||
await waitFor(() => { |
||||
expect(pushMock).toHaveBeenCalledTimes(1); |
||||
expect(pushMock).toHaveBeenCalledWith('/dashboard/new'); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
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: [] }], |
||||
}, |
||||
}, |
||||
undefined, |
||||
true |
||||
); |
||||
|
||||
await userEvent.click(screen.getByText('New')); |
||||
await waitFor(() => { |
||||
expect(pushMock).toHaveBeenCalledTimes(1); |
||||
expect(pushMock).toHaveBeenCalledWith('/grafana/dashboard/new'); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -1,115 +0,0 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import { Item } from '@react-stately/collections'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2, locationUtil, NavMenuItemType, NavModelItem } from '@grafana/data'; |
||||
import { locationService } from '@grafana/runtime'; |
||||
import { toIconName, useTheme2 } from '@grafana/ui'; |
||||
|
||||
import { NavBarItemMenu } from './NavBarItemMenu'; |
||||
import { NavBarItemMenuTrigger } from './NavBarItemMenuTrigger'; |
||||
import { getNavBarItemWithoutMenuStyles } from './NavBarItemWithoutMenu'; |
||||
import { NavBarMenuItem } from './NavBarMenuItem'; |
||||
import { useNavBarContext } from './context'; |
||||
import { getNavModelItemKey } from './utils'; |
||||
|
||||
export interface Props { |
||||
isActive?: boolean; |
||||
className?: string; |
||||
reverseMenuDirection?: boolean; |
||||
link: NavModelItem; |
||||
} |
||||
|
||||
const NavBarItem = ({ isActive = false, className, reverseMenuDirection = false, link }: Props) => { |
||||
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); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<li className={cx(styles.container, { [styles.containerHover]: section.id === menuIdOpen }, className)}> |
||||
<NavBarItemMenuTrigger |
||||
item={section} |
||||
isActive={isActive} |
||||
label={link.text} |
||||
reverseMenuDirection={reverseMenuDirection} |
||||
> |
||||
<NavBarItemMenu |
||||
items={items} |
||||
reverseMenuDirection={reverseMenuDirection} |
||||
adjustHeightForBorder={adjustHeightForBorder} |
||||
disabledKeys={['divider', 'subtitle']} |
||||
aria-label={section.text} |
||||
onNavigate={onNavigate} |
||||
> |
||||
{(item: NavModelItem) => { |
||||
const isSection = item.menuItemType === NavMenuItemType.Section; |
||||
const iconName = item.icon ? toIconName(item.icon) : undefined; |
||||
const icon = item.showIconInNavbar && !isSection ? iconName : undefined; |
||||
|
||||
return ( |
||||
<Item key={getNavModelItemKey(item)} textValue={item.text}> |
||||
<NavBarMenuItem |
||||
isDivider={!isSection && item.divider} |
||||
icon={icon} |
||||
target={item.target} |
||||
text={item.text} |
||||
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,120 +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 { CustomScrollbar, useTheme2 } from '@grafana/ui'; |
||||
|
||||
import { NavBarItemMenuItem } from './NavBarItemMenuItem'; |
||||
import { useNavBarItemMenuContext } from './context'; |
||||
import { getNavModelItemKey } from './utils'; |
||||
|
||||
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} /> |
||||
)); |
||||
|
||||
if (itemComponents.length === 0 && section.value.emptyMessage) { |
||||
itemComponents.push( |
||||
<div key="empty-message" className={styles.emptyMessage}> |
||||
{section.value.emptyMessage} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const subTitleComponent = menuSubTitle && ( |
||||
<li key={menuSubTitle} className={styles.subtitle}> |
||||
{menuSubTitle} |
||||
</li> |
||||
); |
||||
|
||||
const contents = [itemComponents, subTitleComponent]; |
||||
const contentComponent = ( |
||||
<CustomScrollbar hideHorizontalTrack hideVerticalTrack showScrollIndicators key="scrollContainer"> |
||||
{reverseMenuDirection ? contents.reverse() : contents} |
||||
</CustomScrollbar> |
||||
); |
||||
|
||||
const menu = [headerComponent, contentComponent]; |
||||
|
||||
return ( |
||||
<ul className={styles.menu} ref={ref} {...mergeProps(menuProps, contextMenuProps)} tabIndex={-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; |
||||
`,
|
||||
emptyMessage: css` |
||||
font-style: italic; |
||||
padding: ${theme.spacing(0.5, 2)}; |
||||
`,
|
||||
}; |
||||
} |
@ -1,92 +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; |
||||
} |
||||
`,
|
||||
}; |
||||
} |
@ -1,254 +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 { Link, useTheme2 } from '@grafana/ui'; |
||||
|
||||
import { NavBarItemIcon } from './NavBarItemIcon'; |
||||
import { getNavMenuPortalContainer } from './NavBarMenuPortalContainer'; |
||||
import { NavFeatureHighlight } from './NavFeatureHighlight'; |
||||
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, 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}> |
||||
<NavBarItemIcon link={item} /> |
||||
</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%', |
||||
}), |
||||
}); |
@ -1,117 +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%', |
||||
}), |
||||
}; |
||||
} |
@ -1,54 +0,0 @@ |
||||
import { screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import React from 'react'; |
||||
import { render } from 'test/redux-rtl'; |
||||
|
||||
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( |
||||
<NavBarMenu |
||||
isOpen |
||||
onClose={mockOnClose} |
||||
navItems={mockNavItems} |
||||
setMenuAnimationInProgress={mockSetMenuAnimationInProgress} |
||||
/> |
||||
); |
||||
}); |
||||
|
||||
it('should render the component', () => { |
||||
const sidemenu = screen.getByTestId('navbarmenu'); |
||||
expect(sidemenu).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('has a close button', () => { |
||||
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.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(); |
||||
}); |
||||
}); |
@ -1,467 +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, toIconName, useStyles2, useTheme2 } from '@grafana/ui'; |
||||
|
||||
import { NavBarItemIcon } from './NavBarItemIcon'; |
||||
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, 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 backdropRef = useRef(null); |
||||
const { dialogProps } = useDialog({}, ref); |
||||
|
||||
const { overlayProps, underlayProps } = useOverlay( |
||||
{ |
||||
isDismissable: true, |
||||
isOpen, |
||||
onClose, |
||||
}, |
||||
ref |
||||
); |
||||
|
||||
return ( |
||||
<OverlayContainer> |
||||
<FocusScope contain restoreFocus autoFocus> |
||||
<CSSTransition |
||||
nodeRef={ref} |
||||
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 |
||||
nodeRef={backdropRef} |
||||
appear={isOpen} |
||||
in={isOpen} |
||||
classNames={animStyles.backdrop} |
||||
timeout={ANIMATION_DURATION} |
||||
> |
||||
<div className={styles.backdrop} {...underlayProps} ref={backdropRef} /> |
||||
</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), |
||||
}, |
||||
}; |
||||
}; |
||||
|
||||
export 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) => { |
||||
const icon = childLink.icon ? toIconName(childLink.icon) : undefined; |
||||
return ( |
||||
!childLink.divider && ( |
||||
<NavBarMenuItem |
||||
key={`${link.text}-${childLink.text}`} |
||||
isActive={activeItem === childLink} |
||||
isDivider={childLink.divider} |
||||
icon={childLink.showIconInNavbar ? icon : undefined} |
||||
onClick={() => { |
||||
childLink.onClick?.(); |
||||
onClose(); |
||||
}} |
||||
styleOverrides={styles.item} |
||||
target={childLink.target} |
||||
text={childLink.text} |
||||
url={childLink.url} |
||||
isMobile={true} |
||||
/> |
||||
) |
||||
); |
||||
})} |
||||
</ul> |
||||
</CollapsibleNavItem> |
||||
); |
||||
} else if (link.emptyMessage) { |
||||
return ( |
||||
<CollapsibleNavItem onClose={onClose} link={link} isActive={isMatchOrChildMatch(link, activeItem)}> |
||||
<ul className={styles.children}> |
||||
<div className={styles.emptyMessage}>{link.emptyMessage}</div> |
||||
</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> |
||||
<NavBarItemIcon link={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), |
||||
}), |
||||
emptyMessage: css({ |
||||
color: theme.colors.text.secondary, |
||||
fontStyle: 'italic', |
||||
padding: theme.spacing(1, 1.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> |
||||
<NavBarItemIcon link={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); |
||||
} |
@ -1,56 +0,0 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
import { BrowserRouter } from 'react-router-dom'; |
||||
|
||||
import { NavBarMenuItem } from './NavBarMenuItem'; |
||||
|
||||
describe('NavBarMenuItem', () => { |
||||
const mockText = 'MyChildItem'; |
||||
const mockUrl = '/route'; |
||||
const mockIcon = 'home-alt'; |
||||
|
||||
it('displays the text', () => { |
||||
render(<NavBarMenuItem text={mockText} />); |
||||
const text = screen.getByText(mockText); |
||||
expect(text).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('attaches the url to the text if provided', () => { |
||||
render( |
||||
<BrowserRouter> |
||||
<NavBarMenuItem text={mockText} url={mockUrl} /> |
||||
</BrowserRouter> |
||||
); |
||||
const link = screen.getByRole('link', { name: mockText }); |
||||
expect(link).toBeInTheDocument(); |
||||
expect(link).toHaveAttribute('href', mockUrl); |
||||
}); |
||||
|
||||
it('displays an icon if a valid icon is provided', () => { |
||||
render(<NavBarMenuItem text={mockText} icon={mockIcon} />); |
||||
const icon = screen.getByTestId('dropdown-child-icon'); |
||||
expect(icon).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('displays an external link icon if the target is _blank', () => { |
||||
render(<NavBarMenuItem text={mockText} icon={mockIcon} url={mockUrl} target="_blank" />); |
||||
const icon = screen.getByTestId('external-link-icon'); |
||||
expect(icon).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('displays a divider instead when isDivider is true', () => { |
||||
render(<NavBarMenuItem text={mockText} icon={mockIcon} url={mockUrl} isDivider />); |
||||
|
||||
// Check the divider is shown
|
||||
const divider = screen.getByTestId('dropdown-child-divider'); |
||||
expect(divider).toBeInTheDocument(); |
||||
|
||||
// Check nothing else is rendered
|
||||
const text = screen.queryByText(mockText); |
||||
const icon = screen.queryByTestId('dropdown-child-icon'); |
||||
const link = screen.queryByRole('link', { name: mockText }); |
||||
expect(text).not.toBeInTheDocument(); |
||||
expect(icon).not.toBeInTheDocument(); |
||||
expect(link).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
@ -1,153 +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 ? ( |
||||
<div 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,27 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useTheme2 } from '@grafana/ui'; |
||||
|
||||
const NAV_MENU_PORTAL_CONTAINER_ID = 'navbar-menu-portal-container'; |
||||
|
||||
export const getNavMenuPortalContainer = () => document.getElementById(NAV_MENU_PORTAL_CONTAINER_ID) ?? document.body; |
||||
|
||||
export const NavBarMenuPortalContainer = () => { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
return <div className={styles.menuPortalContainer} id={NAV_MENU_PORTAL_CONTAINER_ID} />; |
||||
}; |
||||
|
||||
NavBarMenuPortalContainer.displayName = 'NavBarMenuPortalContainer'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
menuPortalContainer: css({ |
||||
left: 0, |
||||
position: 'fixed', |
||||
right: 0, |
||||
top: 0, |
||||
zIndex: theme.zIndex.sidemenu, |
||||
}), |
||||
}); |
@ -1,43 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import classnames from 'classnames'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { IconButton, useTheme2 } from '@grafana/ui'; |
||||
|
||||
export interface Props { |
||||
className?: string; |
||||
isExpanded: boolean; |
||||
onClick: () => void; |
||||
} |
||||
|
||||
export const NavBarToggle = ({ className, isExpanded, onClick }: Props) => { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
|
||||
return ( |
||||
<IconButton |
||||
aria-label={isExpanded ? 'Close navigation menu' : 'Open navigation menu'} |
||||
name={isExpanded ? 'angle-left' : 'angle-right'} |
||||
className={classnames(className, styles.icon)} |
||||
size="xl" |
||||
onClick={onClick} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
NavBarToggle.displayName = 'NavBarToggle'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
icon: css({ |
||||
backgroundColor: theme.colors.background.secondary, |
||||
border: `1px solid ${theme.colors.border.weak}`, |
||||
borderRadius: '50%', |
||||
marginRight: 0, |
||||
zIndex: theme.zIndex.sidemenu + 1, |
||||
|
||||
[theme.breakpoints.down('md')]: { |
||||
display: 'none', |
||||
}, |
||||
}), |
||||
}); |
@ -1,32 +0,0 @@ |
||||
import { createContext, HTMLAttributes, useContext } from 'react'; |
||||
|
||||
export interface NavBarItemMenuContextProps { |
||||
menuHasFocus: boolean; |
||||
onClose: () => void; |
||||
onLeft: () => void; |
||||
menuProps?: HTMLAttributes<HTMLElement>; |
||||
} |
||||
|
||||
export const NavBarItemMenuContext = createContext<NavBarItemMenuContextProps>({ |
||||
menuHasFocus: false, |
||||
onClose: () => undefined, |
||||
onLeft: () => undefined, |
||||
}); |
||||
|
||||
export function useNavBarItemMenuContext(): NavBarItemMenuContextProps { |
||||
return useContext(NavBarItemMenuContext); |
||||
} |
||||
|
||||
export interface NavBarContextProps { |
||||
menuIdOpen: string | undefined; |
||||
setMenuIdOpen: (id: string | undefined) => void; |
||||
} |
||||
|
||||
export const NavBarContext = createContext<NavBarContextProps>({ |
||||
menuIdOpen: undefined, |
||||
setMenuIdOpen: () => undefined, |
||||
}); |
||||
|
||||
export function useNavBarContext(): NavBarContextProps { |
||||
return useContext(NavBarContext); |
||||
} |
@ -1,10 +0,0 @@ |
||||
import React from 'react'; |
||||
|
||||
interface Props { |
||||
children: React.ReactNode; |
||||
} |
||||
|
||||
/** Remove after topnav feature toggle is removed */ |
||||
export function OldNavOnly({ children }: Props): React.ReactElement | null { |
||||
return <>{children}</>; |
||||
} |
@ -1,54 +0,0 @@ |
||||
import { act, render, screen, waitFor } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
import { Provider } from 'react-redux'; |
||||
import { Router } from 'react-router-dom'; |
||||
|
||||
import { locationService } from '@grafana/runtime/src'; |
||||
import { GrafanaContext } from 'app/core/context/GrafanaContext'; |
||||
|
||||
import { getGrafanaContextMock } from '../../../../../test/mocks/getGrafanaContextMock'; |
||||
import { setStarred } from '../../../../core/reducers/navBarTree'; |
||||
import { configureStore } from '../../../../store/configureStore'; |
||||
import { updateTimeZoneForSession } from '../../../profile/state/reducers'; |
||||
import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures'; |
||||
|
||||
import { DashNav } from './DashNav'; |
||||
|
||||
describe('Public dashboard title tag', () => { |
||||
it('will be rendered when publicDashboardEnabled set to true in dashboard meta', async () => { |
||||
let dashboard = createDashboardModelFixture({}, { publicDashboardEnabled: false }); |
||||
|
||||
const store = configureStore(); |
||||
const context = getGrafanaContextMock(); |
||||
const props = { |
||||
setStarred: jest.fn() as unknown as typeof setStarred, |
||||
updateTimeZoneForSession: jest.fn() as unknown as typeof updateTimeZoneForSession, |
||||
}; |
||||
|
||||
render( |
||||
<Provider store={store}> |
||||
<GrafanaContext.Provider value={context}> |
||||
<Router history={locationService.getHistory()}> |
||||
<DashNav |
||||
{...props} |
||||
dashboard={dashboard} |
||||
hideTimePicker={true} |
||||
isFullscreen={false} |
||||
onAddPanel={() => {}} |
||||
title="test" |
||||
/> |
||||
</Router> |
||||
</GrafanaContext.Provider> |
||||
</Provider> |
||||
); |
||||
|
||||
const publicTag = screen.queryByText('Public'); |
||||
expect(publicTag).not.toBeInTheDocument(); |
||||
|
||||
act(() => { |
||||
dashboard.updateMeta({ publicDashboardEnabled: true }); |
||||
}); |
||||
|
||||
await waitFor(() => screen.getByText('Public')); |
||||
}); |
||||
}); |
@ -1,115 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React, { useEffect } from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { IconButton, stylesFactory, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection'; |
||||
import { SearchView } from '../page/components/SearchView'; |
||||
import { getSearchStateManager } from '../state/SearchStateManager'; |
||||
|
||||
export interface Props {} |
||||
|
||||
export function DashboardSearch({}: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const stateManager = getSearchStateManager(); |
||||
const state = stateManager.useState(); |
||||
|
||||
useEffect(() => stateManager.initStateFromUrl(), [stateManager]); |
||||
|
||||
const { onKeyDown, keyboardEvents } = useKeyNavigationListener(); |
||||
|
||||
return ( |
||||
<div className={styles.overlay}> |
||||
<div className={styles.container}> |
||||
<div className={styles.searchField}> |
||||
<div> |
||||
<input |
||||
type="text" |
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus |
||||
placeholder={state.includePanels ? 'Search dashboards and panels by name' : 'Search dashboards by name'} |
||||
value={state.query ?? ''} |
||||
onChange={(e) => stateManager.onQueryChange(e.currentTarget.value)} |
||||
onKeyDown={onKeyDown} |
||||
spellCheck={false} |
||||
className={styles.input} |
||||
/> |
||||
</div> |
||||
|
||||
<div className={styles.closeBtn}> |
||||
<IconButton name="times" onClick={stateManager.onCloseSearch} size="xxl" tooltip="Close search" /> |
||||
</div> |
||||
</div> |
||||
<div className={styles.search}> |
||||
<SearchView showManage={false} keyboardEvents={keyboardEvents} /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => { |
||||
return { |
||||
overlay: css` |
||||
left: 0; |
||||
top: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
z-index: ${theme.zIndex.sidemenu}; |
||||
position: fixed; |
||||
background: ${theme.colors.background.canvas}; |
||||
padding: ${theme.spacing(1)}; |
||||
|
||||
${theme.breakpoints.up('md')} { |
||||
left: ${theme.components.sidemenu.width}px; |
||||
z-index: ${theme.zIndex.navbarFixed + 1}; |
||||
padding: ${theme.spacing(2)}; |
||||
} |
||||
`,
|
||||
container: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
max-width: 1400px; |
||||
margin: 0 auto; |
||||
padding: ${theme.spacing(1)}; |
||||
background: ${theme.colors.background.primary}; |
||||
border: 1px solid ${theme.components.panel.borderColor}; |
||||
height: 100%; |
||||
|
||||
${theme.breakpoints.up('md')} { |
||||
padding: ${theme.spacing(3)}; |
||||
} |
||||
`,
|
||||
closeBtn: css` |
||||
right: -5px; |
||||
top: 2px; |
||||
z-index: 1; |
||||
position: absolute; |
||||
`,
|
||||
searchField: css` |
||||
position: relative; |
||||
`,
|
||||
search: css` |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
height: 100%; |
||||
padding: ${theme.spacing(2, 0, 3, 0)}; |
||||
`,
|
||||
input: css` |
||||
box-sizing: border-box; |
||||
outline: none; |
||||
background-color: transparent; |
||||
background: transparent; |
||||
border-bottom: 2px solid ${theme.v1.colors.border1}; |
||||
font-size: 20px; |
||||
line-height: 38px; |
||||
width: 100%; |
||||
|
||||
&::placeholder { |
||||
color: ${theme.v1.colors.textWeak}; |
||||
} |
||||
`,
|
||||
}; |
||||
}); |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue