mirror of https://github.com/grafana/grafana
DockedMegaMenu: Clean up toggle and old code (#81878)
* remove toggle * remove code not behind toggle * remove old MegaMenu * rename DockedMegaMenu -> MegaMenu and clean up go code * fix backend test * run yarn i18n:extract * fix some unit tests * fix remaining unit tests * fix remaining e2e/unit testspull/81969/head
parent
390461f9e1
commit
28b336ac80
|
@ -1,82 +0,0 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import React from 'react'; |
||||
import { Router } from 'react-router-dom'; |
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; |
||||
|
||||
import { NavModelItem } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import { locationService } from '@grafana/runtime'; |
||||
|
||||
import { TestProvider } from '../../../../../test/helpers/TestProvider'; |
||||
|
||||
import { MegaMenu } from './MegaMenu'; |
||||
|
||||
const setup = () => { |
||||
const navBarTree: NavModelItem[] = [ |
||||
{ |
||||
text: 'Section name', |
||||
id: 'section', |
||||
url: 'section', |
||||
children: [ |
||||
{ |
||||
text: 'Child1', |
||||
id: 'child1', |
||||
url: 'section/child1', |
||||
children: [{ text: 'Grandchild1', id: 'grandchild1', url: 'section/child1/grandchild1' }], |
||||
}, |
||||
{ text: 'Child2', id: 'child2', url: 'section/child2' }, |
||||
], |
||||
}, |
||||
{ |
||||
text: 'Profile', |
||||
id: 'profile', |
||||
url: 'profile', |
||||
}, |
||||
]; |
||||
|
||||
const grafanaContext = getGrafanaContextMock(); |
||||
grafanaContext.chrome.setMegaMenuOpen(true); |
||||
|
||||
return render( |
||||
<TestProvider storeState={{ navBarTree }} grafanaContext={grafanaContext}> |
||||
<Router history={locationService.getHistory()}> |
||||
<MegaMenu onClose={() => {}} /> |
||||
</Router> |
||||
</TestProvider> |
||||
); |
||||
}; |
||||
|
||||
describe('MegaMenu', () => { |
||||
afterEach(() => { |
||||
window.localStorage.clear(); |
||||
}); |
||||
it('should render component', async () => { |
||||
setup(); |
||||
|
||||
expect(await screen.findByTestId(selectors.components.NavMenu.Menu)).toBeInTheDocument(); |
||||
expect(await screen.findByRole('link', { name: 'Section name' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should render children', async () => { |
||||
setup(); |
||||
await userEvent.click(await screen.findByRole('button', { name: 'Expand section Section name' })); |
||||
expect(await screen.findByRole('link', { name: 'Child1' })).toBeInTheDocument(); |
||||
expect(await screen.findByRole('link', { name: 'Child2' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should render grandchildren', async () => { |
||||
setup(); |
||||
await userEvent.click(await screen.findByRole('button', { name: 'Expand section Section name' })); |
||||
expect(await screen.findByRole('link', { name: 'Child1' })).toBeInTheDocument(); |
||||
await userEvent.click(await screen.findByRole('button', { name: 'Expand section Child1' })); |
||||
expect(await screen.findByRole('link', { name: 'Grandchild1' })).toBeInTheDocument(); |
||||
expect(await screen.findByRole('link', { name: 'Child2' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should filter out profile', async () => { |
||||
setup(); |
||||
|
||||
expect(screen.queryByLabelText('Profile')).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
@ -1,132 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { DOMAttributes } from '@react-types/shared'; |
||||
import React, { forwardRef } from 'react'; |
||||
import { useLocation } from 'react-router-dom'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import { CustomScrollbar, Icon, IconButton, useStyles2, Stack } from '@grafana/ui'; |
||||
import { useGrafana } from 'app/core/context/GrafanaContext'; |
||||
import { t } from 'app/core/internationalization'; |
||||
import { useSelector } from 'app/types'; |
||||
|
||||
import { MegaMenuItem } from './MegaMenuItem'; |
||||
import { enrichWithInteractionTracking, getActiveItem } from './utils'; |
||||
|
||||
export const MENU_WIDTH = '300px'; |
||||
|
||||
export interface Props extends DOMAttributes { |
||||
onClose: () => void; |
||||
} |
||||
|
||||
export const MegaMenu = React.memo( |
||||
forwardRef<HTMLDivElement, Props>(({ onClose, ...restProps }, ref) => { |
||||
const navTree = useSelector((state) => state.navBarTree); |
||||
const styles = useStyles2(getStyles); |
||||
const location = useLocation(); |
||||
const { chrome } = useGrafana(); |
||||
const state = chrome.useState(); |
||||
|
||||
// Remove profile + help from tree
|
||||
const navItems = navTree |
||||
.filter((item) => item.id !== 'profile' && item.id !== 'help') |
||||
.map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked)); |
||||
|
||||
const activeItem = getActiveItem(navItems, location.pathname); |
||||
|
||||
const handleDockedMenu = () => { |
||||
chrome.setMegaMenuDocked(!state.megaMenuDocked); |
||||
if (state.megaMenuDocked) { |
||||
chrome.setMegaMenuOpen(false); |
||||
} |
||||
|
||||
// refocus on undock/menu open button when changing state
|
||||
setTimeout(() => { |
||||
document.getElementById(state.megaMenuDocked ? 'mega-menu-toggle' : 'dock-menu-button')?.focus(); |
||||
}); |
||||
}; |
||||
|
||||
return ( |
||||
<div data-testid={selectors.components.NavMenu.Menu} ref={ref} {...restProps}> |
||||
<div className={styles.mobileHeader}> |
||||
<Icon name="bars" size="xl" /> |
||||
<IconButton |
||||
tooltip={t('navigation.megamenu.close', 'Close menu')} |
||||
name="times" |
||||
onClick={onClose} |
||||
size="xl" |
||||
variant="secondary" |
||||
/> |
||||
</div> |
||||
<nav className={styles.content}> |
||||
<CustomScrollbar showScrollIndicators hideHorizontalTrack> |
||||
<ul className={styles.itemList} aria-label={t('navigation.megamenu.list-label', 'Navigation')}> |
||||
{navItems.map((link, index) => ( |
||||
<Stack key={link.text} direction={index === 0 ? 'row-reverse' : 'row'} alignItems="center"> |
||||
{index === 0 && ( |
||||
<IconButton |
||||
id="dock-menu-button" |
||||
className={styles.dockMenuButton} |
||||
tooltip={ |
||||
state.megaMenuDocked |
||||
? t('navigation.megamenu.undock', 'Undock menu') |
||||
: t('navigation.megamenu.dock', 'Dock menu') |
||||
} |
||||
name="web-section-alt" |
||||
onClick={handleDockedMenu} |
||||
variant="secondary" |
||||
/> |
||||
)} |
||||
<MegaMenuItem |
||||
link={link} |
||||
onClick={state.megaMenuDocked ? undefined : onClose} |
||||
activeItem={activeItem} |
||||
/> |
||||
</Stack> |
||||
))} |
||||
</ul> |
||||
</CustomScrollbar> |
||||
</nav> |
||||
</div> |
||||
); |
||||
}) |
||||
); |
||||
|
||||
MegaMenu.displayName = 'MegaMenu'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
content: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
height: '100%', |
||||
minHeight: 0, |
||||
position: 'relative', |
||||
}), |
||||
mobileHeader: css({ |
||||
display: 'flex', |
||||
justifyContent: 'space-between', |
||||
padding: theme.spacing(1, 1, 1, 2), |
||||
borderBottom: `1px solid ${theme.colors.border.weak}`, |
||||
|
||||
[theme.breakpoints.up('md')]: { |
||||
display: 'none', |
||||
}, |
||||
}), |
||||
itemList: css({ |
||||
boxSizing: 'border-box', |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
listStyleType: 'none', |
||||
padding: theme.spacing(1), |
||||
[theme.breakpoints.up('md')]: { |
||||
width: MENU_WIDTH, |
||||
}, |
||||
}), |
||||
dockMenuButton: css({ |
||||
display: 'none', |
||||
|
||||
[theme.breakpoints.up('xl')]: { |
||||
display: 'inline-flex', |
||||
}, |
||||
}), |
||||
}); |
@ -1,186 +0,0 @@ |
||||
import { GrafanaConfig, locationUtil, NavModelItem } from '@grafana/data'; |
||||
import { ContextSrv, setContextSrv } from 'app/core/services/context_srv'; |
||||
|
||||
import { enrichHelpItem, getActiveItem, isMatchOrChildMatch } from './utils'; |
||||
|
||||
jest.mock('../../../app_events', () => ({ |
||||
publish: jest.fn(), |
||||
})); |
||||
|
||||
describe('enrichConfigItems', () => { |
||||
let mockHelpNode: NavModelItem; |
||||
|
||||
beforeEach(() => { |
||||
mockHelpNode = { |
||||
id: 'help', |
||||
text: 'Help', |
||||
}; |
||||
}); |
||||
|
||||
it('enhances the help node with extra child links', () => { |
||||
const contextSrv = new ContextSrv(); |
||||
setContextSrv(contextSrv); |
||||
const helpNode = enrichHelpItem(mockHelpNode); |
||||
expect(helpNode!.children).toContainEqual( |
||||
expect.objectContaining({ |
||||
text: 'Documentation', |
||||
}) |
||||
); |
||||
expect(helpNode!.children).toContainEqual( |
||||
expect.objectContaining({ |
||||
text: 'Support', |
||||
}) |
||||
); |
||||
expect(helpNode!.children).toContainEqual( |
||||
expect.objectContaining({ |
||||
text: 'Community', |
||||
}) |
||||
); |
||||
expect(helpNode!.children).toContainEqual( |
||||
expect.objectContaining({ |
||||
text: 'Keyboard shortcuts', |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('isMatchOrChildMatch', () => { |
||||
const mockChild: NavModelItem = { |
||||
text: 'Child', |
||||
url: '/dashboards/child', |
||||
}; |
||||
const mockItemToCheck: NavModelItem = { |
||||
text: 'Dashboards', |
||||
url: '/dashboards', |
||||
children: [mockChild], |
||||
}; |
||||
|
||||
it('returns true if the itemToCheck is an exact match with the searchItem', () => { |
||||
const searchItem = mockItemToCheck; |
||||
expect(isMatchOrChildMatch(mockItemToCheck, searchItem)).toBe(true); |
||||
}); |
||||
|
||||
it('returns true if the itemToCheck has a child that matches the searchItem', () => { |
||||
const searchItem = mockChild; |
||||
expect(isMatchOrChildMatch(mockItemToCheck, searchItem)).toBe(true); |
||||
}); |
||||
|
||||
it('returns false otherwise', () => { |
||||
const searchItem: NavModelItem = { |
||||
text: 'No match', |
||||
url: '/noMatch', |
||||
}; |
||||
expect(isMatchOrChildMatch(mockItemToCheck, searchItem)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('getActiveItem', () => { |
||||
const mockNavTree: NavModelItem[] = [ |
||||
{ |
||||
text: 'Item', |
||||
url: '/item', |
||||
}, |
||||
{ |
||||
text: 'Item with query param', |
||||
url: '/itemWithQueryParam?foo=bar', |
||||
}, |
||||
{ |
||||
text: 'Item after subpath', |
||||
url: '/subUrl/itemAfterSubpath', |
||||
}, |
||||
{ |
||||
text: 'Item with children', |
||||
url: '/itemWithChildren', |
||||
children: [ |
||||
{ |
||||
text: 'Child', |
||||
url: '/child', |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
text: 'Alerting item', |
||||
url: '/alerting/list', |
||||
}, |
||||
{ |
||||
text: 'Base', |
||||
url: '/', |
||||
}, |
||||
{ |
||||
text: 'Starred', |
||||
url: '/dashboards?starred', |
||||
id: 'starred', |
||||
}, |
||||
{ |
||||
text: 'Dashboards', |
||||
url: '/dashboards', |
||||
}, |
||||
{ |
||||
text: 'More specific dashboard', |
||||
url: '/d/moreSpecificDashboard', |
||||
}, |
||||
]; |
||||
beforeEach(() => { |
||||
locationUtil.initialize({ |
||||
config: { appSubUrl: '/subUrl' } as GrafanaConfig, |
||||
getVariablesUrlParams: () => ({}), |
||||
getTimeRangeForUrl: () => ({ from: 'now-7d', to: 'now' }), |
||||
}); |
||||
}); |
||||
|
||||
it('returns an exact match at the top level', () => { |
||||
const mockPathName = '/item'; |
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ |
||||
text: 'Item', |
||||
url: '/item', |
||||
}); |
||||
}); |
||||
|
||||
it('returns an exact match ignoring root subpath', () => { |
||||
const mockPathName = '/itemAfterSubpath'; |
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ |
||||
text: 'Item after subpath', |
||||
url: '/subUrl/itemAfterSubpath', |
||||
}); |
||||
}); |
||||
|
||||
it('returns an exact match ignoring query params', () => { |
||||
const mockPathName = '/itemWithQueryParam?bar=baz'; |
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ |
||||
text: 'Item with query param', |
||||
url: '/itemWithQueryParam?foo=bar', |
||||
}); |
||||
}); |
||||
|
||||
it('returns an exact child match', () => { |
||||
const mockPathName = '/child'; |
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ |
||||
text: 'Child', |
||||
url: '/child', |
||||
}); |
||||
}); |
||||
|
||||
it('returns the alerting link if the pathname is an alert notification', () => { |
||||
const mockPathName = '/alerting/notification/foo'; |
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ |
||||
text: 'Alerting item', |
||||
url: '/alerting/list', |
||||
}); |
||||
}); |
||||
|
||||
it('returns the dashboards route link if the pathname starts with /d/', () => { |
||||
const mockPathName = '/d/foo'; |
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ |
||||
text: 'Dashboards', |
||||
url: '/dashboards', |
||||
}); |
||||
}); |
||||
|
||||
it('returns a more specific link if one exists', () => { |
||||
const mockPathName = '/d/moreSpecificDashboard'; |
||||
expect(getActiveItem(mockNavTree, mockPathName)).toEqual({ |
||||
text: 'More specific dashboard', |
||||
url: '/d/moreSpecificDashboard', |
||||
}); |
||||
}); |
||||
}); |
@ -1,142 +0,0 @@ |
||||
import { locationUtil, NavModelItem } from '@grafana/data'; |
||||
import { config, reportInteraction } from '@grafana/runtime'; |
||||
import { t } from 'app/core/internationalization'; |
||||
|
||||
import { ShowModalReactEvent } from '../../../../types/events'; |
||||
import appEvents from '../../../app_events'; |
||||
import { getFooterLinks } from '../../Footer/Footer'; |
||||
import { HelpModal } from '../../help/HelpModal'; |
||||
|
||||
export const enrichHelpItem = (helpItem: NavModelItem) => { |
||||
let menuItems = helpItem.children || []; |
||||
|
||||
if (helpItem.id === 'help') { |
||||
const onOpenShortcuts = () => { |
||||
appEvents.publish(new ShowModalReactEvent({ component: HelpModal })); |
||||
}; |
||||
helpItem.children = [ |
||||
...menuItems, |
||||
...getFooterLinks(), |
||||
...getEditionAndUpdateLinks(), |
||||
{ |
||||
id: 'keyboard-shortcuts', |
||||
text: t('nav.help/keyboard-shortcuts', 'Keyboard shortcuts'), |
||||
icon: 'keyboard', |
||||
onClick: onOpenShortcuts, |
||||
}, |
||||
]; |
||||
} |
||||
return helpItem; |
||||
}; |
||||
|
||||
export const enrichWithInteractionTracking = (item: NavModelItem, megaMenuDockedState: boolean) => { |
||||
// creating a new object here to not mutate the original item object
|
||||
const newItem = { ...item }; |
||||
const onClick = newItem.onClick; |
||||
newItem.onClick = () => { |
||||
reportInteraction('grafana_navigation_item_clicked', { |
||||
path: newItem.url ?? newItem.id, |
||||
menuIsDocked: megaMenuDockedState, |
||||
}); |
||||
onClick?.(); |
||||
}; |
||||
if (newItem.children) { |
||||
newItem.children = newItem.children.map((item) => enrichWithInteractionTracking(item, megaMenuDockedState)); |
||||
} |
||||
return newItem; |
||||
}; |
||||
|
||||
export const isMatchOrChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem) => { |
||||
return Boolean(itemToCheck === searchItem || hasChildMatch(itemToCheck, searchItem)); |
||||
}; |
||||
|
||||
export const hasChildMatch = (itemToCheck: NavModelItem, searchItem?: NavModelItem): boolean => { |
||||
return Boolean( |
||||
itemToCheck.children?.some((child) => { |
||||
if (child === searchItem) { |
||||
return true; |
||||
} else { |
||||
return hasChildMatch(child, searchItem); |
||||
} |
||||
}) |
||||
); |
||||
}; |
||||
|
||||
const stripQueryParams = (url?: string) => { |
||||
return url?.split('?')[0] ?? ''; |
||||
}; |
||||
|
||||
const isBetterMatch = (newMatch: NavModelItem, currentMatch?: NavModelItem) => { |
||||
const currentMatchUrl = stripQueryParams(currentMatch?.url); |
||||
const newMatchUrl = stripQueryParams(newMatch.url); |
||||
return newMatchUrl && newMatchUrl.length > currentMatchUrl?.length; |
||||
}; |
||||
|
||||
export const getActiveItem = ( |
||||
navTree: NavModelItem[], |
||||
pathname: string, |
||||
currentBestMatch?: NavModelItem |
||||
): NavModelItem | undefined => { |
||||
const dashboardLinkMatch = '/dashboards'; |
||||
|
||||
for (const link of navTree) { |
||||
const linkWithoutParams = stripQueryParams(link.url); |
||||
const linkPathname = locationUtil.stripBaseFromUrl(linkWithoutParams); |
||||
if (linkPathname && link.id !== 'starred') { |
||||
if (linkPathname === pathname) { |
||||
// exact match
|
||||
currentBestMatch = link; |
||||
break; |
||||
} else if (linkPathname !== '/' && pathname.startsWith(linkPathname)) { |
||||
// partial match
|
||||
if (isBetterMatch(link, currentBestMatch)) { |
||||
currentBestMatch = link; |
||||
} |
||||
} else if (linkPathname === '/alerting/list' && pathname.startsWith('/alerting/notification/')) { |
||||
// alert channel match
|
||||
// TODO refactor routes such that we don't need this custom logic
|
||||
currentBestMatch = link; |
||||
break; |
||||
} else if (linkPathname === dashboardLinkMatch && pathname.startsWith('/d/')) { |
||||
// dashboard match
|
||||
// TODO refactor routes such that we don't need this custom logic
|
||||
if (isBetterMatch(link, currentBestMatch)) { |
||||
currentBestMatch = link; |
||||
} |
||||
} |
||||
} |
||||
if (link.children) { |
||||
currentBestMatch = getActiveItem(link.children, pathname, currentBestMatch); |
||||
} |
||||
if (stripQueryParams(currentBestMatch?.url) === pathname) { |
||||
return currentBestMatch; |
||||
} |
||||
} |
||||
return currentBestMatch; |
||||
}; |
||||
|
||||
export function getEditionAndUpdateLinks(): NavModelItem[] { |
||||
const { buildInfo, licenseInfo } = config; |
||||
const stateInfo = licenseInfo.stateInfo ? ` (${licenseInfo.stateInfo})` : ''; |
||||
const links: NavModelItem[] = []; |
||||
|
||||
links.push({ |
||||
target: '_blank', |
||||
id: 'version', |
||||
text: `${buildInfo.edition}${stateInfo}`, |
||||
url: licenseInfo.licenseUrl, |
||||
icon: 'external-link-alt', |
||||
}); |
||||
|
||||
if (buildInfo.hasUpdate) { |
||||
links.push({ |
||||
target: '_blank', |
||||
id: 'updateVersion', |
||||
text: `New version available!`, |
||||
icon: 'download-alt', |
||||
url: 'https://grafana.com/grafana/download?utm_source=grafana_footer', |
||||
}); |
||||
} |
||||
|
||||
return links; |
||||
} |
@ -1,50 +1,132 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { cloneDeep } from 'lodash'; |
||||
import React from 'react'; |
||||
import { DOMAttributes } from '@react-types/shared'; |
||||
import React, { forwardRef } from 'react'; |
||||
import { useLocation } from 'react-router-dom'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useTheme2 } from '@grafana/ui'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import { CustomScrollbar, Icon, IconButton, useStyles2, Stack } from '@grafana/ui'; |
||||
import { useGrafana } from 'app/core/context/GrafanaContext'; |
||||
import { t } from 'app/core/internationalization'; |
||||
import { useSelector } from 'app/types'; |
||||
|
||||
import { NavBarMenu } from './NavBarMenu'; |
||||
import { MegaMenuItem } from './MegaMenuItem'; |
||||
import { enrichWithInteractionTracking, getActiveItem } from './utils'; |
||||
|
||||
export interface Props { |
||||
export const MENU_WIDTH = '300px'; |
||||
|
||||
export interface Props extends DOMAttributes { |
||||
onClose: () => void; |
||||
searchBarHidden?: boolean; |
||||
} |
||||
|
||||
export const MegaMenu = React.memo<Props>(({ onClose, searchBarHidden }) => { |
||||
const navBarTree = useSelector((state) => state.navBarTree); |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
const location = useLocation(); |
||||
export const MegaMenu = React.memo( |
||||
forwardRef<HTMLDivElement, Props>(({ onClose, ...restProps }, ref) => { |
||||
const navTree = useSelector((state) => state.navBarTree); |
||||
const styles = useStyles2(getStyles); |
||||
const location = useLocation(); |
||||
const { chrome } = useGrafana(); |
||||
const state = chrome.useState(); |
||||
|
||||
const navTree = cloneDeep(navBarTree); |
||||
// Remove profile + help from tree
|
||||
const navItems = navTree |
||||
.filter((item) => item.id !== 'profile' && item.id !== 'help') |
||||
.map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked)); |
||||
|
||||
// Remove profile + help from tree
|
||||
const navItems = navTree |
||||
.filter((item) => item.id !== 'profile' && item.id !== 'help') |
||||
.map((item) => enrichWithInteractionTracking(item, true)); |
||||
const activeItem = getActiveItem(navItems, location.pathname); |
||||
|
||||
const activeItem = getActiveItem(navItems, location.pathname); |
||||
const handleDockedMenu = () => { |
||||
chrome.setMegaMenuDocked(!state.megaMenuDocked); |
||||
if (state.megaMenuDocked) { |
||||
chrome.setMegaMenuOpen(false); |
||||
} |
||||
|
||||
return ( |
||||
<div className={styles.menuWrapper}> |
||||
<NavBarMenu activeItem={activeItem} navItems={navItems} onClose={onClose} searchBarHidden={searchBarHidden} /> |
||||
</div> |
||||
); |
||||
}); |
||||
// refocus on undock/menu open button when changing state
|
||||
setTimeout(() => { |
||||
document.getElementById(state.megaMenuDocked ? 'mega-menu-toggle' : 'dock-menu-button')?.focus(); |
||||
}); |
||||
}; |
||||
|
||||
return ( |
||||
<div data-testid={selectors.components.NavMenu.Menu} ref={ref} {...restProps}> |
||||
<div className={styles.mobileHeader}> |
||||
<Icon name="bars" size="xl" /> |
||||
<IconButton |
||||
tooltip={t('navigation.megamenu.close', 'Close menu')} |
||||
name="times" |
||||
onClick={onClose} |
||||
size="xl" |
||||
variant="secondary" |
||||
/> |
||||
</div> |
||||
<nav className={styles.content}> |
||||
<CustomScrollbar showScrollIndicators hideHorizontalTrack> |
||||
<ul className={styles.itemList} aria-label={t('navigation.megamenu.list-label', 'Navigation')}> |
||||
{navItems.map((link, index) => ( |
||||
<Stack key={link.text} direction={index === 0 ? 'row-reverse' : 'row'} alignItems="center"> |
||||
{index === 0 && ( |
||||
<IconButton |
||||
id="dock-menu-button" |
||||
className={styles.dockMenuButton} |
||||
tooltip={ |
||||
state.megaMenuDocked |
||||
? t('navigation.megamenu.undock', 'Undock menu') |
||||
: t('navigation.megamenu.dock', 'Dock menu') |
||||
} |
||||
name="web-section-alt" |
||||
onClick={handleDockedMenu} |
||||
variant="secondary" |
||||
/> |
||||
)} |
||||
<MegaMenuItem |
||||
link={link} |
||||
onClick={state.megaMenuDocked ? undefined : onClose} |
||||
activeItem={activeItem} |
||||
/> |
||||
</Stack> |
||||
))} |
||||
</ul> |
||||
</CustomScrollbar> |
||||
</nav> |
||||
</div> |
||||
); |
||||
}) |
||||
); |
||||
|
||||
MegaMenu.displayName = 'MegaMenu'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
menuWrapper: css({ |
||||
position: 'fixed', |
||||
display: 'grid', |
||||
gridAutoFlow: 'column', |
||||
content: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
height: '100%', |
||||
zIndex: theme.zIndex.sidemenu, |
||||
minHeight: 0, |
||||
position: 'relative', |
||||
}), |
||||
mobileHeader: css({ |
||||
display: 'flex', |
||||
justifyContent: 'space-between', |
||||
padding: theme.spacing(1, 1, 1, 2), |
||||
borderBottom: `1px solid ${theme.colors.border.weak}`, |
||||
|
||||
[theme.breakpoints.up('md')]: { |
||||
display: 'none', |
||||
}, |
||||
}), |
||||
itemList: css({ |
||||
boxSizing: 'border-box', |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
listStyleType: 'none', |
||||
padding: theme.spacing(1), |
||||
[theme.breakpoints.up('md')]: { |
||||
width: MENU_WIDTH, |
||||
}, |
||||
}), |
||||
dockMenuButton: css({ |
||||
display: 'none', |
||||
|
||||
[theme.breakpoints.up('xl')]: { |
||||
display: 'inline-flex', |
||||
}, |
||||
}), |
||||
}); |
||||
|
@ -1,38 +0,0 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; |
||||
import { Icon, toIconName, useTheme2 } from '@grafana/ui'; |
||||
|
||||
import { Branding } from '../../Branding/Branding'; |
||||
|
||||
interface NavBarItemIconProps { |
||||
link: NavModelItem; |
||||
} |
||||
|
||||
export function NavBarItemIcon({ link }: NavBarItemIconProps) { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
|
||||
if (link.icon === 'grafana') { |
||||
return <Branding.MenuLogo className={styles.img} />; |
||||
} else if (link.icon) { |
||||
const iconName = toIconName(link.icon); |
||||
return <Icon name={iconName ?? 'link'} size="xl" />; |
||||
} else { |
||||
// consumer of NavBarItemIcon gives enclosing element an appropriate label
|
||||
return <img className={cx(styles.img, link.roundIcon && styles.round)} src={link.img} alt="" />; |
||||
} |
||||
} |
||||
|
||||
function getStyles(theme: GrafanaTheme2) { |
||||
return { |
||||
img: css({ |
||||
height: theme.spacing(3), |
||||
width: theme.spacing(3), |
||||
}), |
||||
round: css({ |
||||
borderRadius: theme.shape.radius.circle, |
||||
}), |
||||
}; |
||||
} |
@ -1,231 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { useDialog } from '@react-aria/dialog'; |
||||
import { FocusScope } from '@react-aria/focus'; |
||||
import { OverlayContainer, useOverlay } from '@react-aria/overlays'; |
||||
import React, { useEffect, useRef, useState } from 'react'; |
||||
import CSSTransition from 'react-transition-group/CSSTransition'; |
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui'; |
||||
import { useGrafana } from 'app/core/context/GrafanaContext'; |
||||
|
||||
import { TOP_BAR_LEVEL_HEIGHT } from '../types'; |
||||
|
||||
import { NavBarMenuItemWrapper } from './NavBarMenuItemWrapper'; |
||||
|
||||
const MENU_WIDTH = '350px'; |
||||
|
||||
export interface Props { |
||||
activeItem?: NavModelItem; |
||||
navItems: NavModelItem[]; |
||||
searchBarHidden?: boolean; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
export function NavBarMenu({ activeItem, navItems, searchBarHidden, onClose }: Props) { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme, searchBarHidden); |
||||
const animationSpeed = theme.transitions.duration.shortest; |
||||
const animStyles = getAnimStyles(theme, animationSpeed); |
||||
const { chrome } = useGrafana(); |
||||
const state = chrome.useState(); |
||||
const ref = useRef(null); |
||||
const backdropRef = useRef(null); |
||||
const { dialogProps } = useDialog({}, ref); |
||||
const [isOpen, setIsOpen] = useState(false); |
||||
|
||||
const onMenuClose = () => setIsOpen(false); |
||||
|
||||
const { overlayProps, underlayProps } = useOverlay( |
||||
{ |
||||
isDismissable: true, |
||||
isOpen: true, |
||||
onClose: onMenuClose, |
||||
}, |
||||
ref |
||||
); |
||||
|
||||
useEffect(() => { |
||||
if (state.megaMenuOpen) { |
||||
setIsOpen(true); |
||||
} |
||||
}, [state.megaMenuOpen]); |
||||
|
||||
return ( |
||||
<OverlayContainer> |
||||
<CSSTransition |
||||
nodeRef={ref} |
||||
in={isOpen} |
||||
unmountOnExit={true} |
||||
classNames={animStyles.overlay} |
||||
timeout={{ enter: animationSpeed, exit: 0 }} |
||||
onExited={onClose} |
||||
> |
||||
<FocusScope contain autoFocus> |
||||
<div |
||||
data-testid={selectors.components.NavMenu.Menu} |
||||
ref={ref} |
||||
{...overlayProps} |
||||
{...dialogProps} |
||||
className={styles.container} |
||||
> |
||||
<div className={styles.mobileHeader}> |
||||
<Icon name="bars" size="xl" /> |
||||
<IconButton |
||||
aria-label="Close navigation menu" |
||||
tooltip="Close menu" |
||||
name="times" |
||||
onClick={onMenuClose} |
||||
size="xl" |
||||
variant="secondary" |
||||
/> |
||||
</div> |
||||
<nav className={styles.content}> |
||||
<CustomScrollbar showScrollIndicators hideHorizontalTrack> |
||||
<ul className={styles.itemList}> |
||||
{navItems.map((link) => ( |
||||
<NavBarMenuItemWrapper link={link} onClose={onMenuClose} activeItem={activeItem} key={link.text} /> |
||||
))} |
||||
</ul> |
||||
</CustomScrollbar> |
||||
</nav> |
||||
</div> |
||||
</FocusScope> |
||||
</CSSTransition> |
||||
<CSSTransition |
||||
nodeRef={backdropRef} |
||||
in={isOpen} |
||||
unmountOnExit={true} |
||||
classNames={animStyles.backdrop} |
||||
timeout={{ enter: animationSpeed, exit: 0 }} |
||||
> |
||||
<div ref={backdropRef} className={styles.backdrop} {...underlayProps} /> |
||||
</CSSTransition> |
||||
</OverlayContainer> |
||||
); |
||||
} |
||||
|
||||
NavBarMenu.displayName = 'NavBarMenu'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { |
||||
const topPosition = (searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2) + 1; |
||||
|
||||
return { |
||||
backdrop: css({ |
||||
backdropFilter: 'blur(1px)', |
||||
backgroundColor: theme.components.overlay.background, |
||||
bottom: 0, |
||||
left: 0, |
||||
position: 'fixed', |
||||
right: 0, |
||||
top: searchBarHidden ? 0 : TOP_BAR_LEVEL_HEIGHT, |
||||
zIndex: theme.zIndex.modalBackdrop, |
||||
|
||||
[theme.breakpoints.up('md')]: { |
||||
top: topPosition, |
||||
}, |
||||
}), |
||||
container: css({ |
||||
display: 'flex', |
||||
bottom: 0, |
||||
flexDirection: 'column', |
||||
left: 0, |
||||
marginRight: theme.spacing(1.5), |
||||
right: 0, |
||||
// Needs to below navbar should we change the navbarFixed? add add a new level?
|
||||
zIndex: theme.zIndex.modal, |
||||
position: 'fixed', |
||||
top: searchBarHidden ? 0 : TOP_BAR_LEVEL_HEIGHT, |
||||
backgroundColor: theme.colors.background.primary, |
||||
boxSizing: 'content-box', |
||||
flex: '1 1 0', |
||||
|
||||
[theme.breakpoints.up('md')]: { |
||||
borderRight: `1px solid ${theme.colors.border.weak}`, |
||||
right: 'unset', |
||||
top: topPosition, |
||||
}, |
||||
}), |
||||
content: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
flexGrow: 1, |
||||
minHeight: 0, |
||||
}), |
||||
mobileHeader: css({ |
||||
display: 'flex', |
||||
justifyContent: 'space-between', |
||||
padding: theme.spacing(1, 1, 1, 2), |
||||
borderBottom: `1px solid ${theme.colors.border.weak}`, |
||||
|
||||
[theme.breakpoints.up('md')]: { |
||||
display: 'none', |
||||
}, |
||||
}), |
||||
itemList: css({ |
||||
display: 'grid', |
||||
gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, |
||||
gridTemplateColumns: `minmax(${MENU_WIDTH}, auto)`, |
||||
minWidth: MENU_WIDTH, |
||||
}), |
||||
}; |
||||
}; |
||||
|
||||
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: '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 = { |
||||
width: '100%', |
||||
[theme.breakpoints.up('md')]: { |
||||
boxShadow: theme.shadows.z3, |
||||
width: MENU_WIDTH, |
||||
}, |
||||
}; |
||||
|
||||
const overlayClosed = { |
||||
boxShadow: 'none', |
||||
width: 0, |
||||
}; |
||||
|
||||
const backdropOpen = { |
||||
opacity: 1, |
||||
}; |
||||
|
||||
const backdropClosed = { |
||||
opacity: 0, |
||||
}; |
||||
|
||||
return { |
||||
backdrop: { |
||||
enter: css(backdropClosed), |
||||
enterActive: css(backdropTransition, backdropOpen), |
||||
enterDone: css(backdropOpen), |
||||
}, |
||||
overlay: { |
||||
enter: css(overlayClosed), |
||||
enterActive: css(overlayTransition, overlayOpen), |
||||
enterDone: css(overlayOpen), |
||||
}, |
||||
}; |
||||
}; |
@ -1,139 +0,0 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import { Icon, IconName, Link, useTheme2 } from '@grafana/ui'; |
||||
|
||||
export interface Props { |
||||
children: React.ReactNode; |
||||
icon?: IconName; |
||||
isActive?: boolean; |
||||
isChild?: boolean; |
||||
onClick?: () => void; |
||||
target?: HTMLAnchorElement['target']; |
||||
url?: string; |
||||
} |
||||
|
||||
export function NavBarMenuItem({ children, icon, isActive, isChild, onClick, target, url }: Props) { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme, isActive, isChild); |
||||
|
||||
const linkContent = ( |
||||
<div className={styles.linkContent}> |
||||
{icon && <Icon data-testid="dropdown-child-icon" name={icon} />} |
||||
|
||||
<div className={styles.linkText}>{children}</div> |
||||
|
||||
{target === '_blank' && ( |
||||
<Icon data-testid="external-link-icon" name="external-link-alt" className={styles.externalLinkIcon} /> |
||||
)} |
||||
</div> |
||||
); |
||||
|
||||
let element = ( |
||||
<button |
||||
data-testid={selectors.components.NavMenu.item} |
||||
className={cx(styles.button, styles.element)} |
||||
onClick={onClick} |
||||
> |
||||
{linkContent} |
||||
</button> |
||||
); |
||||
|
||||
if (url) { |
||||
element = |
||||
!target && url.startsWith('/') ? ( |
||||
<Link |
||||
data-testid={selectors.components.NavMenu.item} |
||||
className={styles.element} |
||||
href={url} |
||||
target={target} |
||||
onClick={onClick} |
||||
> |
||||
{linkContent} |
||||
</Link> |
||||
) : ( |
||||
<a |
||||
data-testid={selectors.components.NavMenu.item} |
||||
href={url} |
||||
target={target} |
||||
className={styles.element} |
||||
onClick={onClick} |
||||
> |
||||
{linkContent} |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
return <li className={styles.listItem}>{element}</li>; |
||||
} |
||||
|
||||
NavBarMenuItem.displayName = 'NavBarMenuItem'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive'], isChild: Props['isActive']) => ({ |
||||
button: css({ |
||||
backgroundColor: 'unset', |
||||
borderStyle: 'unset', |
||||
}), |
||||
linkContent: css({ |
||||
alignItems: 'center', |
||||
display: 'flex', |
||||
gap: '0.5rem', |
||||
height: '100%', |
||||
width: '100%', |
||||
}), |
||||
linkText: css({ |
||||
textOverflow: 'ellipsis', |
||||
overflow: 'hidden', |
||||
whiteSpace: 'nowrap', |
||||
}), |
||||
externalLinkIcon: css({ |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
element: css({ |
||||
alignItems: 'center', |
||||
boxSizing: 'border-box', |
||||
position: 'relative', |
||||
color: isActive ? theme.colors.text.primary : theme.colors.text.secondary, |
||||
padding: theme.spacing(1, 1, 1, isChild ? 5 : 0), |
||||
...(isChild && { |
||||
borderRadius: theme.shape.radius.default, |
||||
}), |
||||
width: '100%', |
||||
'&:hover, &:focus-visible': { |
||||
...(isChild && { |
||||
background: theme.colors.emphasize(theme.colors.background.primary, 0.03), |
||||
}), |
||||
textDecoration: 'underline', |
||||
color: theme.colors.text.primary, |
||||
}, |
||||
'&:focus-visible': { |
||||
boxShadow: 'none', |
||||
outline: `2px solid ${theme.colors.primary.main}`, |
||||
outlineOffset: '-2px', |
||||
transition: 'none', |
||||
}, |
||||
'&::before': { |
||||
display: isActive ? 'block' : 'none', |
||||
content: '" "', |
||||
height: theme.spacing(3), |
||||
position: 'absolute', |
||||
left: theme.spacing(1), |
||||
top: '50%', |
||||
transform: 'translateY(-50%)', |
||||
width: theme.spacing(0.5), |
||||
borderRadius: theme.shape.radius.default, |
||||
backgroundImage: theme.colors.gradients.brandVertical, |
||||
}, |
||||
}), |
||||
listItem: css({ |
||||
boxSizing: 'border-box', |
||||
position: 'relative', |
||||
display: 'flex', |
||||
width: '100%', |
||||
...(isChild && { |
||||
padding: theme.spacing(0, 2), |
||||
}), |
||||
}), |
||||
}); |
@ -1,105 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { NavBarMenuItem } from './NavBarMenuItem'; |
||||
import { NavBarMenuSection } from './NavBarMenuSection'; |
||||
import { isMatchOrChildMatch } from './utils'; |
||||
|
||||
export function NavBarMenuItemWrapper({ |
||||
link, |
||||
activeItem, |
||||
onClose, |
||||
}: { |
||||
link: NavModelItem; |
||||
activeItem?: NavModelItem; |
||||
onClose: () => void; |
||||
}) { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
if (link.emptyMessage && !linkHasChildren(link)) { |
||||
return ( |
||||
<NavBarMenuSection onClose={onClose} link={link} activeItem={activeItem}> |
||||
<ul className={styles.children}> |
||||
<div className={styles.emptyMessage}>{link.emptyMessage}</div> |
||||
</ul> |
||||
</NavBarMenuSection> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<NavBarMenuSection onClose={onClose} link={link} activeItem={activeItem}> |
||||
{linkHasChildren(link) && ( |
||||
<ul className={styles.children}> |
||||
{link.children.map((childLink) => { |
||||
return ( |
||||
!childLink.isCreateAction && ( |
||||
<NavBarMenuItem |
||||
key={`${link.text}-${childLink.text}`} |
||||
isActive={isMatchOrChildMatch(childLink, activeItem)} |
||||
isChild |
||||
onClick={() => { |
||||
childLink.onClick?.(); |
||||
onClose(); |
||||
}} |
||||
target={childLink.target} |
||||
url={childLink.url} |
||||
> |
||||
{childLink.text} |
||||
</NavBarMenuItem> |
||||
) |
||||
); |
||||
})} |
||||
</ul> |
||||
)} |
||||
</NavBarMenuSection> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
children: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
}), |
||||
flex: css({ |
||||
display: 'flex', |
||||
}), |
||||
itemWithoutMenu: css({ |
||||
position: 'relative', |
||||
placeItems: 'inherit', |
||||
justifyContent: 'start', |
||||
display: 'flex', |
||||
flexGrow: 1, |
||||
alignItems: 'center', |
||||
}), |
||||
fullWidth: css({ |
||||
height: '100%', |
||||
width: '100%', |
||||
}), |
||||
iconContainer: css({ |
||||
display: 'flex', |
||||
placeContent: 'center', |
||||
}), |
||||
itemWithoutMenuContent: css({ |
||||
display: 'grid', |
||||
gridAutoFlow: 'column', |
||||
gridTemplateColumns: `${theme.spacing(7)} auto`, |
||||
alignItems: 'center', |
||||
height: '100%', |
||||
}), |
||||
linkText: css({ |
||||
fontSize: theme.typography.pxToRem(14), |
||||
justifySelf: 'start', |
||||
}), |
||||
emptyMessage: css({ |
||||
color: theme.colors.text.secondary, |
||||
fontStyle: 'italic', |
||||
padding: theme.spacing(1, 1.5, 1, 7), |
||||
}), |
||||
}); |
||||
|
||||
function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: NavModelItem[] } { |
||||
return Boolean(link.children && link.children.length > 0); |
||||
} |
@ -1,117 +0,0 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import React from 'react'; |
||||
import { useLocalStorage } from 'react-use'; |
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; |
||||
import { Button, Icon, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { NavBarItemIcon } from './NavBarItemIcon'; |
||||
import { NavBarMenuItem } from './NavBarMenuItem'; |
||||
import { NavFeatureHighlight } from './NavFeatureHighlight'; |
||||
import { hasChildMatch } from './utils'; |
||||
|
||||
export function NavBarMenuSection({ |
||||
link, |
||||
activeItem, |
||||
children, |
||||
className, |
||||
onClose, |
||||
}: { |
||||
link: NavModelItem; |
||||
activeItem?: NavModelItem; |
||||
children: React.ReactNode; |
||||
className?: string; |
||||
onClose?: () => void; |
||||
}) { |
||||
const styles = useStyles2(getStyles); |
||||
const FeatureHighlightWrapper = link.highlightText ? NavFeatureHighlight : React.Fragment; |
||||
const isActive = link === activeItem; |
||||
const hasActiveChild = hasChildMatch(link, activeItem); |
||||
const [sectionExpanded, setSectionExpanded] = |
||||
useLocalStorage(`grafana.navigation.expanded[${link.text}]`, false) ?? Boolean(hasActiveChild); |
||||
|
||||
return ( |
||||
<> |
||||
<div className={cx(styles.collapsibleSectionWrapper, className)}> |
||||
<NavBarMenuItem |
||||
isActive={link === activeItem} |
||||
onClick={() => { |
||||
link.onClick?.(); |
||||
onClose?.(); |
||||
}} |
||||
target={link.target} |
||||
url={link.url} |
||||
> |
||||
<div |
||||
className={cx(styles.labelWrapper, { |
||||
[styles.isActive]: isActive, |
||||
[styles.hasActiveChild]: hasActiveChild, |
||||
})} |
||||
> |
||||
<FeatureHighlightWrapper> |
||||
<NavBarItemIcon link={link} /> |
||||
</FeatureHighlightWrapper> |
||||
{link.text} |
||||
</div> |
||||
</NavBarMenuItem> |
||||
{children && ( |
||||
<Button |
||||
aria-label={`${sectionExpanded ? 'Collapse' : 'Expand'} section ${link.text}`} |
||||
variant="secondary" |
||||
fill="text" |
||||
className={styles.collapseButton} |
||||
onClick={() => setSectionExpanded(!sectionExpanded)} |
||||
> |
||||
<Icon name={sectionExpanded ? 'angle-up' : 'angle-down'} size="xl" /> |
||||
</Button> |
||||
)} |
||||
</div> |
||||
{sectionExpanded && children} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
collapsibleSectionWrapper: css({ |
||||
alignItems: 'center', |
||||
display: 'flex', |
||||
}), |
||||
collapseButton: css({ |
||||
color: theme.colors.text.disabled, |
||||
padding: theme.spacing(0, 0.5), |
||||
marginRight: theme.spacing(1), |
||||
}), |
||||
collapseWrapperActive: css({ |
||||
backgroundColor: theme.colors.action.disabledBackground, |
||||
}), |
||||
collapseContent: css({ |
||||
padding: 0, |
||||
}), |
||||
labelWrapper: css({ |
||||
display: 'grid', |
||||
fontSize: theme.typography.pxToRem(14), |
||||
gridAutoFlow: 'column', |
||||
gridTemplateColumns: `${theme.spacing(7)} auto`, |
||||
placeItems: 'center', |
||||
fontWeight: theme.typography.fontWeightMedium, |
||||
}), |
||||
isActive: css({ |
||||
color: theme.colors.text.primary, |
||||
|
||||
'&::before': { |
||||
display: 'block', |
||||
content: '" "', |
||||
height: theme.spacing(3), |
||||
position: 'absolute', |
||||
left: theme.spacing(1), |
||||
top: '50%', |
||||
transform: 'translateY(-50%)', |
||||
width: theme.spacing(0.5), |
||||
borderRadius: theme.shape.radius.default, |
||||
backgroundImage: theme.colors.gradients.brandVertical, |
||||
}, |
||||
}), |
||||
hasActiveChild: css({ |
||||
color: theme.colors.text.primary, |
||||
}), |
||||
}); |
@ -1,34 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
|
||||
export interface Props { |
||||
children: JSX.Element; |
||||
} |
||||
|
||||
export const NavFeatureHighlight = ({ children }: Props): JSX.Element => { |
||||
const styles = useStyles2(getStyles); |
||||
return ( |
||||
<div> |
||||
{children} |
||||
<span className={styles.highlight} /> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
highlight: css({ |
||||
backgroundColor: theme.colors.success.main, |
||||
borderRadius: theme.shape.radius.circle, |
||||
width: '6px', |
||||
height: '6px', |
||||
display: 'inline-block', |
||||
position: 'absolute', |
||||
top: '50%', |
||||
transform: 'translateY(-50%)', |
||||
}), |
||||
}; |
||||
}; |
@ -1,109 +0,0 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import React, { useEffect, useState } from 'react'; |
||||
import { useLocalStorage } from 'react-use'; |
||||
|
||||
import { NavModel, GrafanaTheme2 } from '@grafana/data'; |
||||
import { useStyles2, CustomScrollbar, useTheme2 } from '@grafana/ui'; |
||||
|
||||
import { SectionNavItem } from './SectionNavItem'; |
||||
import { SectionNavToggle } from './SectionNavToggle'; |
||||
|
||||
export interface Props { |
||||
model: NavModel; |
||||
} |
||||
|
||||
export function SectionNav({ model }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const { isExpanded, onToggleSectionNav } = useSectionNavState(); |
||||
|
||||
if (!Boolean(model.main?.children?.length)) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<div className={styles.navContainer}> |
||||
<nav |
||||
className={cx(styles.nav, { |
||||
[styles.navExpanded]: isExpanded, |
||||
})} |
||||
> |
||||
<CustomScrollbar showScrollIndicators> |
||||
<div className={styles.items} role="tablist"> |
||||
<SectionNavItem item={model.main} isSectionRoot /> |
||||
</div> |
||||
</CustomScrollbar> |
||||
</nav> |
||||
<SectionNavToggle isExpanded={isExpanded} onClick={onToggleSectionNav} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function useSectionNavState() { |
||||
const theme = useTheme2(); |
||||
|
||||
const isSmallScreen = window.matchMedia(`(max-width: ${theme.breakpoints.values.lg}px)`).matches; |
||||
const [navExpandedPreference, setNavExpandedPreference] = useLocalStorage<boolean>( |
||||
'grafana.sectionNav.expanded', |
||||
!isSmallScreen |
||||
); |
||||
const [isExpanded, setIsExpanded] = useState(!isSmallScreen && navExpandedPreference); |
||||
|
||||
useEffect(() => { |
||||
const mediaQuery = window.matchMedia(`(max-width: ${theme.breakpoints.values.lg}px)`); |
||||
const onMediaQueryChange = (e: MediaQueryListEvent) => setIsExpanded(e.matches ? false : navExpandedPreference); |
||||
mediaQuery.addEventListener('change', onMediaQueryChange); |
||||
return () => mediaQuery.removeEventListener('change', onMediaQueryChange); |
||||
}, [navExpandedPreference, theme.breakpoints.values.lg]); |
||||
|
||||
const onToggleSectionNav = () => { |
||||
setNavExpandedPreference(!isExpanded); |
||||
setIsExpanded(!isExpanded); |
||||
}; |
||||
|
||||
return { isExpanded, onToggleSectionNav }; |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
navContainer: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
position: 'relative', |
||||
|
||||
[theme.breakpoints.up('md')]: { |
||||
flexDirection: 'row', |
||||
}, |
||||
}), |
||||
nav: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
background: theme.colors.background.canvas, |
||||
flexShrink: 0, |
||||
transition: theme.transitions.create(['width', 'max-height']), |
||||
maxHeight: 0, |
||||
visibility: 'hidden', |
||||
[theme.breakpoints.up('md')]: { |
||||
width: 0, |
||||
maxHeight: 'unset', |
||||
}, |
||||
}), |
||||
navExpanded: css({ |
||||
maxHeight: '50vh', |
||||
visibility: 'visible', |
||||
[theme.breakpoints.up('md')]: { |
||||
width: '250px', |
||||
maxHeight: 'unset', |
||||
}, |
||||
}), |
||||
items: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
padding: theme.spacing(2, 1, 2, 2), |
||||
minWidth: '250px', |
||||
|
||||
[theme.breakpoints.up('md')]: { |
||||
padding: theme.spacing(4.5, 1, 2, 2), |
||||
}, |
||||
}), |
||||
}; |
||||
}; |
@ -1,25 +0,0 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
|
||||
import { NavModelItem } from '@grafana/data'; |
||||
|
||||
import { SectionNavItem } from './SectionNavItem'; |
||||
|
||||
describe('SectionNavItem', () => { |
||||
it('should only show the img for a section root if both img and icon are present', () => { |
||||
const item: NavModelItem = { |
||||
text: 'Test', |
||||
icon: 'k6', |
||||
img: 'img', |
||||
children: [ |
||||
{ |
||||
text: 'Child', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
render(<SectionNavItem item={item} isSectionRoot />); |
||||
expect(screen.getByTestId('section-image')).toBeInTheDocument(); |
||||
expect(screen.queryByTestId('section-icon')).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
@ -1,131 +0,0 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import { reportInteraction } from '@grafana/runtime'; |
||||
import { useStyles2, Icon } from '@grafana/ui'; |
||||
|
||||
import { getActiveItem, hasChildMatch } from '../MegaMenu/utils'; |
||||
|
||||
export interface Props { |
||||
item: NavModelItem; |
||||
isSectionRoot?: boolean; |
||||
level?: number; |
||||
} |
||||
|
||||
// max level depth to render
|
||||
const MAX_DEPTH = 2; |
||||
|
||||
export function SectionNavItem({ item, isSectionRoot = false, level = 0 }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const children = item.children?.filter((x) => !x.hideFromTabs); |
||||
const activeItem = item.children && getActiveItem(item.children, location.pathname); |
||||
|
||||
// If first root child is a section skip the bottom margin (as sections have top margin already)
|
||||
const noRootMargin = isSectionRoot && Boolean(item.children![0].children?.length); |
||||
|
||||
const linkClass = cx({ |
||||
[styles.link]: true, |
||||
[styles.activeStyle]: item.active || (level === MAX_DEPTH && hasChildMatch(item, activeItem)), |
||||
[styles.isSection]: level < MAX_DEPTH && (Boolean(children?.length) || item.isSection), |
||||
[styles.isSectionRoot]: isSectionRoot, |
||||
[styles.noRootMargin]: noRootMargin, |
||||
}); |
||||
|
||||
let icon: React.ReactNode | null = null; |
||||
|
||||
if (item.img) { |
||||
icon = <img data-testid="section-image" className={styles.sectionImg} src={item.img} alt="" />; |
||||
} else if (item.icon) { |
||||
icon = <Icon data-testid="section-icon" className={styles.sectionImg} name={item.icon} />; |
||||
} |
||||
|
||||
const onItemClicked = () => { |
||||
reportInteraction('grafana_navigation_item_clicked', { |
||||
path: item.url ?? item.id, |
||||
sectionNav: true, |
||||
}); |
||||
}; |
||||
|
||||
return ( |
||||
<> |
||||
<a |
||||
onClick={onItemClicked} |
||||
href={item.url} |
||||
className={linkClass} |
||||
aria-label={selectors.components.Tab.title(item.text)} |
||||
role="tab" |
||||
aria-selected={item.active} |
||||
> |
||||
{isSectionRoot && icon} |
||||
{item.text} |
||||
{item.tabSuffix && <item.tabSuffix className={styles.suffix} />} |
||||
</a> |
||||
{level < MAX_DEPTH && |
||||
children?.map((child, index) => <SectionNavItem item={child} key={index} level={level + 1} />)} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
link: css` |
||||
padding: ${theme.spacing(1, 0, 1, 1.5)}; |
||||
display: flex; |
||||
align-items: flex-start; |
||||
border-radius: ${theme.shape.radius.default}; |
||||
gap: ${theme.spacing(1)}; |
||||
height: 100%; |
||||
position: relative; |
||||
color: ${theme.colors.text.secondary}; |
||||
|
||||
&:hover, |
||||
&:focus { |
||||
text-decoration: underline; |
||||
z-index: 1; |
||||
} |
||||
`,
|
||||
activeStyle: css` |
||||
label: activeTabStyle; |
||||
color: ${theme.colors.text.primary}; |
||||
font-weight: ${theme.typography.fontWeightMedium}; |
||||
background: ${theme.colors.emphasize(theme.colors.background.canvas, 0.03)}; |
||||
|
||||
&::before { |
||||
display: block; |
||||
content: ' '; |
||||
position: absolute; |
||||
left: 0; |
||||
width: 4px; |
||||
bottom: 2px; |
||||
top: 2px; |
||||
border-radius: ${theme.shape.radius.default}; |
||||
background-image: ${theme.colors.gradients.brandVertical}; |
||||
} |
||||
`,
|
||||
suffix: css` |
||||
margin-left: ${theme.spacing(1)}; |
||||
`,
|
||||
sectionImg: css({ |
||||
margin: '6px 0', |
||||
width: theme.spacing(2), |
||||
}), |
||||
isSectionRoot: css({ |
||||
fontSize: theme.typography.h4.fontSize, |
||||
marginTop: 0, |
||||
marginBottom: theme.spacing(2), |
||||
fontWeight: theme.typography.fontWeightMedium, |
||||
}), |
||||
isSection: css({ |
||||
color: theme.colors.text.primary, |
||||
fontSize: theme.typography.h5.fontSize, |
||||
marginTop: theme.spacing(2), |
||||
fontWeight: theme.typography.fontWeightMedium, |
||||
}), |
||||
noRootMargin: css({ |
||||
marginBottom: 0, |
||||
}), |
||||
}; |
||||
}; |
@ -1,68 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import classnames from 'classnames'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { Button, useTheme2 } from '@grafana/ui'; |
||||
|
||||
export interface Props { |
||||
isExpanded?: boolean; |
||||
onClick: () => void; |
||||
} |
||||
|
||||
export const SectionNavToggle = ({ isExpanded, onClick }: Props) => { |
||||
const theme = useTheme2(); |
||||
const styles = getStyles(theme); |
||||
|
||||
return ( |
||||
<Button |
||||
title={'Toggle section navigation'} |
||||
aria-label={isExpanded ? 'Close section navigation' : 'Open section navigation'} |
||||
icon="arrow-to-right" |
||||
className={classnames(styles.icon, { |
||||
[styles.iconExpanded]: isExpanded, |
||||
})} |
||||
variant="secondary" |
||||
fill="text" |
||||
size="md" |
||||
onClick={onClick} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
SectionNavToggle.displayName = 'SectionNavToggle'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
icon: css({ |
||||
alignSelf: 'center', |
||||
margin: theme.spacing(1, 0), |
||||
transform: 'rotate(90deg)', |
||||
transition: theme.transitions.create('opacity'), |
||||
color: theme.colors.text.secondary, |
||||
zIndex: 1, |
||||
|
||||
[theme.breakpoints.up('md')]: { |
||||
alignSelf: 'flex-start', |
||||
position: 'relative', |
||||
left: 0, |
||||
margin: theme.spacing(0, 0, 0, 1), |
||||
top: theme.spacing(2), |
||||
transform: 'none', |
||||
}, |
||||
|
||||
'div:hover > &, &:focus': { |
||||
opacity: 1, |
||||
}, |
||||
}), |
||||
iconExpanded: css({ |
||||
rotate: '180deg', |
||||
|
||||
[theme.breakpoints.up('md')]: { |
||||
opacity: 0, |
||||
margin: 0, |
||||
position: 'absolute', |
||||
right: 0, |
||||
left: 'initial', |
||||
}, |
||||
}), |
||||
}); |
Loading…
Reference in new issue