mirror of https://github.com/grafana/grafana
TopNav: A possible approach having a TopNav that lives outside route (#51301)
* Add topnav in Route * TopBar: Good progress on a approach that looks promising * Added some elements to top level * Get page nav from route * Progress * Making breadcrumbs slightly more real * Updates * Memoize selector * Removed some console.log * correctly type iconName * betterer updates * Change setting to hideNav * Rename again Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>pull/51504/head
parent
6c43eb0b4d
commit
f047f7dcf6
@ -0,0 +1,82 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; |
||||
import { useStyles2, Icon, IconName } from '@grafana/ui'; |
||||
|
||||
import { TopNavProps } from './TopNavUpdate'; |
||||
|
||||
export interface Props extends TopNavProps { |
||||
sectionNav: NavModelItem; |
||||
subNav?: NavModelItem; |
||||
} |
||||
|
||||
export interface Breadcrumb { |
||||
text?: string; |
||||
icon?: IconName; |
||||
href?: string; |
||||
} |
||||
|
||||
export function Breadcrumbs({ sectionNav, subNav }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const crumbs: Breadcrumb[] = [{ icon: 'home', href: '/' }]; |
||||
|
||||
function addCrumbs(node: NavModelItem) { |
||||
if (node.parentItem) { |
||||
addCrumbs(node.parentItem); |
||||
} |
||||
|
||||
crumbs.push({ text: node.text, href: node.url }); |
||||
} |
||||
|
||||
addCrumbs(sectionNav); |
||||
|
||||
if (subNav) { |
||||
addCrumbs(subNav); |
||||
} |
||||
|
||||
return ( |
||||
<ul className={styles.breadcrumbs}> |
||||
{crumbs.map((breadcrumb, index) => ( |
||||
<li className={styles.breadcrumb} key={index}> |
||||
{breadcrumb.href && ( |
||||
<a className={styles.breadcrumbLink} href={breadcrumb.href}> |
||||
{breadcrumb.text} |
||||
{breadcrumb.icon && <Icon name={breadcrumb.icon} />} |
||||
</a> |
||||
)} |
||||
{!breadcrumb.href && <span className={styles.breadcrumbLink}>{breadcrumb.text}</span>} |
||||
{index + 1 < crumbs.length && ( |
||||
<div className={styles.separator}> |
||||
<Icon name="angle-right" /> |
||||
</div> |
||||
)} |
||||
</li> |
||||
))} |
||||
</ul> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
breadcrumbs: css({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
fontWeight: theme.typography.fontWeightMedium, |
||||
}), |
||||
breadcrumb: css({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
}), |
||||
separator: css({ |
||||
color: theme.colors.text.secondary, |
||||
padding: theme.spacing(0, 0.5), |
||||
}), |
||||
breadcrumbLink: css({ |
||||
color: theme.colors.text.primary, |
||||
'&:hover': { |
||||
textDecoration: 'underline', |
||||
}, |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,62 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data'; |
||||
import { IconButton, ToolbarButton, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { Breadcrumbs } from './Breadcrumbs'; |
||||
import { TopNavProps } from './TopNavUpdate'; |
||||
import { TOP_BAR_LEVEL_HEIGHT } from './types'; |
||||
|
||||
export interface Props extends TopNavProps { |
||||
onToggleSearchBar(): void; |
||||
searchBarHidden?: boolean; |
||||
sectionNav: NavModelItem; |
||||
subNav?: NavModelItem; |
||||
} |
||||
|
||||
export function NavToolbar({ actions, onToggleSearchBar, searchBarHidden, sectionNav, subNav }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<div className={styles.pageToolbar}> |
||||
<div className={styles.menuButton}> |
||||
<IconButton name="bars" tooltip="Toggle menu" tooltipPlacement="bottom" size="xl" onClick={() => {}} /> |
||||
</div> |
||||
<Breadcrumbs sectionNav={sectionNav} subNav={subNav} /> |
||||
<div className={styles.leftActions}></div> |
||||
<div className={styles.rightActions}> |
||||
{actions} |
||||
<ToolbarButton icon={searchBarHidden ? 'angle-down' : 'angle-up'} onClick={onToggleSearchBar} /> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
pageToolbar: css({ |
||||
height: TOP_BAR_LEVEL_HEIGHT, |
||||
display: 'flex', |
||||
padding: theme.spacing(0, 2), |
||||
alignItems: 'center', |
||||
justifyContent: 'space-between', |
||||
}), |
||||
menuButton: css({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
paddingRight: theme.spacing(1), |
||||
}), |
||||
leftActions: css({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
flexGrow: 1, |
||||
gap: theme.spacing(2), |
||||
}), |
||||
rightActions: css({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
gap: theme.spacing(2), |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,80 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import React, { PropsWithChildren } from 'react'; |
||||
import { useSelector } from 'react-redux'; |
||||
import { useObservable, useToggle } from 'react-use'; |
||||
import { createSelector } from 'reselect'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
import { getNavModel } from 'app/core/selectors/navModel'; |
||||
import { StoreState } from 'app/types'; |
||||
|
||||
import { NavToolbar } from './NavToolbar'; |
||||
import { topNavDefaultProps, topNavUpdates } from './TopNavUpdate'; |
||||
import { TopSearchBar } from './TopSearchBar'; |
||||
import { TOP_BAR_LEVEL_HEIGHT } from './types'; |
||||
|
||||
export interface Props extends PropsWithChildren<{}> { |
||||
/** This is nav tree id provided by route. |
||||
* It's not enough for item navigation. For that pages will need provide an item nav model as well via TopNavUpdate |
||||
*/ |
||||
navId?: string; |
||||
} |
||||
|
||||
export function TopNavPage({ children, navId }: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
const [searchBarHidden, toggleSearchBar] = useToggle(false); // repace with local storage
|
||||
const props = useObservable(topNavUpdates, topNavDefaultProps); |
||||
const navModel = useSelector(createSelector(getNavIndex, (navIndex) => getNavModel(navIndex, navId ?? 'home'))); |
||||
|
||||
return ( |
||||
<div className={styles.viewport}> |
||||
<div className={styles.topNav}> |
||||
{!searchBarHidden && <TopSearchBar />} |
||||
<NavToolbar |
||||
{...props} |
||||
searchBarHidden={searchBarHidden} |
||||
onToggleSearchBar={toggleSearchBar} |
||||
sectionNav={navModel.node} |
||||
/> |
||||
</div> |
||||
<div className={cx(styles.content, searchBarHidden && styles.contentNoSearchBar)}>{children}</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function getNavIndex(store: StoreState) { |
||||
return store.navIndex; |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
const shadow = theme.isDark |
||||
? `0 0.6px 1.5px rgb(0 0 0), 0 2px 4px rgb(0 0 0 / 40%), 0 5px 10px rgb(0 0 0 / 23%)` |
||||
: '0 0.6px 1.5px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 6%), 0 5px 10px rgb(0 0 0 / 5%)'; |
||||
|
||||
return { |
||||
viewport: css({ |
||||
display: 'flex', |
||||
flexGrow: 1, |
||||
height: '100%', |
||||
}), |
||||
content: css({ |
||||
display: 'flex', |
||||
paddingTop: TOP_BAR_LEVEL_HEIGHT * 2 + 16, |
||||
flexGrow: 1, |
||||
}), |
||||
contentNoSearchBar: css({ |
||||
paddingTop: TOP_BAR_LEVEL_HEIGHT + 16, |
||||
}), |
||||
topNav: css({ |
||||
display: 'flex', |
||||
position: 'fixed', |
||||
zIndex: theme.zIndex.navbarFixed, |
||||
left: 0, |
||||
right: 0, |
||||
boxShadow: shadow, |
||||
background: theme.colors.background.primary, |
||||
flexDirection: 'column', |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,23 @@ |
||||
import { useEffect } from 'react'; |
||||
import { Subject } from 'rxjs'; |
||||
|
||||
import { NavModelItem } from '@grafana/data'; |
||||
|
||||
export interface TopNavProps { |
||||
subNav?: NavModelItem; |
||||
actions?: React.ReactNode; |
||||
} |
||||
|
||||
export const topNavUpdates = new Subject<TopNavProps>(); |
||||
export const topNavDefaultProps: TopNavProps = {}; |
||||
|
||||
/** |
||||
* This needs to be moved to @grafana/ui or runtime. |
||||
* This is the way core pages and plugins update the breadcrumbs and page toolbar actions |
||||
*/ |
||||
export function TopNavUpdate(props: TopNavProps) { |
||||
useEffect(() => { |
||||
topNavUpdates.next(props); |
||||
}); |
||||
return null; |
||||
} |
@ -0,0 +1,90 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { FilterInput, Icon, Tooltip, useStyles2 } from '@grafana/ui'; |
||||
import { contextSrv } from 'app/core/core'; |
||||
|
||||
import { TOP_BAR_LEVEL_HEIGHT } from './types'; |
||||
|
||||
export function TopSearchBar() { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<div className={styles.searchBar}> |
||||
<a className={styles.logo} href="/" title="Go to home"> |
||||
<Icon name="grafana" size="xl" /> |
||||
</a> |
||||
<div className={styles.searchWrapper}> |
||||
<FilterInput |
||||
width={50} |
||||
placeholder="Search grafana" |
||||
value={''} |
||||
onChange={() => {}} |
||||
className={styles.searchInput} |
||||
/> |
||||
</div> |
||||
<div className={styles.actions}> |
||||
<Tooltip placement="bottom" content="Help menu (todo)"> |
||||
<button className={styles.actionItem}> |
||||
<Icon name="question-circle" size="lg" /> |
||||
</button> |
||||
</Tooltip> |
||||
<Tooltip placement="bottom" content="Grafana news (todo)"> |
||||
<button className={styles.actionItem}> |
||||
<Icon name="rss" size="lg" /> |
||||
</button> |
||||
</Tooltip> |
||||
<Tooltip placement="bottom" content="User profile (todo)"> |
||||
<button className={styles.actionItem}> |
||||
<img src={contextSrv.user.gravatarUrl} /> |
||||
</button> |
||||
</Tooltip> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
searchBar: css({ |
||||
height: TOP_BAR_LEVEL_HEIGHT, |
||||
display: 'flex', |
||||
padding: theme.spacing(0, 2), |
||||
alignItems: 'center', |
||||
justifyContent: 'space-between', |
||||
border: `1px solid ${theme.colors.border.weak}`, |
||||
}), |
||||
logo: css({ |
||||
display: 'flex', |
||||
}), |
||||
searchWrapper: css({}), |
||||
searchInput: css({}), |
||||
actions: css({ |
||||
display: 'flex', |
||||
flexGrow: 0, |
||||
gap: theme.spacing(1), |
||||
position: 'relative', |
||||
width: 25, // this and the left pos is to make search input perfectly centered
|
||||
left: -83, |
||||
}), |
||||
actionItem: css({ |
||||
display: 'flex', |
||||
flexGrow: 0, |
||||
border: 'none', |
||||
boxShadow: 'none', |
||||
background: 'none', |
||||
alignItems: 'center', |
||||
|
||||
color: theme.colors.text.secondary, |
||||
'&:hover': { |
||||
background: theme.colors.background.secondary, |
||||
}, |
||||
img: { |
||||
borderRadius: '50%', |
||||
width: '24px', |
||||
height: '24px', |
||||
}, |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1 @@ |
||||
export const TOP_BAR_LEVEL_HEIGHT = 40; |
Loading…
Reference in new issue