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
Torkel Ödegaard 3 years ago committed by GitHub
parent 6c43eb0b4d
commit f047f7dcf6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 24
      .betterer.results
  2. 2
      packages/grafana-ui/src/types/icon.ts
  3. 82
      public/app/core/components/TopNav/Breadcrumbs.tsx
  4. 62
      public/app/core/components/TopNav/NavToolbar.tsx
  5. 80
      public/app/core/components/TopNav/TopNavPage.tsx
  6. 23
      public/app/core/components/TopNav/TopNavUpdate.tsx
  7. 90
      public/app/core/components/TopNav/TopSearchBar.tsx
  8. 1
      public/app/core/components/TopNav/types.ts
  9. 10
      public/app/core/navigation/GrafanaRoute.tsx
  10. 2
      public/app/core/navigation/types.ts
  11. 10
      public/app/features/dashboard/components/DashNav/DashNav.tsx
  12. 11
      public/app/routes/routes.tsx

@ -3544,7 +3544,7 @@ exports[`better eslint`] = {
[11, 6, 169, "Do not use any type assertions.", "2248693214"],
[17, 11, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/core/navigation/types.ts:2017971154": [
"public/app/core/navigation/types.ts:2395305220": [
[10, 38, 3, "Unexpected any. Specify a different type.", "193409811"],
[14, 35, 3, "Unexpected any. Specify a different type.", "193409811"]
],
@ -4653,8 +4653,8 @@ exports[`better eslint`] = {
[176, 29, 13, "Do not use any type assertions.", "2146830713"],
[195, 32, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/dashboard/components/DashNav/DashNav.tsx:574528540": [
[66, 87, 3, "Unexpected any. Specify a different type.", "193409811"]
"public/app/features/dashboard/components/DashNav/DashNav.tsx:1533394562": [
[67, 87, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/dashboard/components/DashNav/DashNavTimeControls.test.tsx:3825334541": [
[49, 17, 3, "Unexpected any. Specify a different type.", "193409811"],
@ -6580,16 +6580,16 @@ exports[`better eslint`] = {
"public/app/features/profile/state/reducers.test.ts:1105044753": [
[18, 15, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/query/components/QueryEditorRow.test.ts:589354782": [
[5, 9, 60, "Do not use any type assertions.", "155833034"],
[48, 22, 101, "Do not use any type assertions.", "3734235969"],
[48, 22, 88, "Do not use any type assertions.", "3757401717"]
"public/app/features/query/components/QueryEditorRow.test.ts:4201471442": [
[7, 9, 60, "Do not use any type assertions.", "155833034"],
[50, 22, 101, "Do not use any type assertions.", "3734235969"],
[50, 22, 88, "Do not use any type assertions.", "3757401717"]
],
"public/app/features/query/components/QueryEditorRow.tsx:3534885470": [
[95, 22, 20, "Do not use any type assertions.", "2923490522"],
[142, 18, 46, "Do not use any type assertions.", "1673417097"],
[142, 18, 21, "Do not use any type assertions.", "1354497810"],
[317, 22, 40, "Do not use any type assertions.", "1350130209"]
"public/app/features/query/components/QueryEditorRow.tsx:209136238": [
[97, 22, 20, "Do not use any type assertions.", "2923490522"],
[144, 18, 46, "Do not use any type assertions.", "1673417097"],
[144, 18, 21, "Do not use any type assertions.", "1354497810"],
[356, 22, 40, "Do not use any type assertions.", "1350130209"]
],
"public/app/features/query/components/QueryEditorRowHeader.test.tsx:2607197828": [
[99, 16, 32, "Do not use any type assertions.", "2255106576"]

@ -106,6 +106,7 @@ export const getAvailableIcons = () =>
'heart',
'heart-break',
'history',
'home',
'home-alt',
'horizontal-align-center',
'horizontal-align-left',
@ -179,6 +180,7 @@ export const getAvailableIcons = () =>
'vertical-align-center',
'vertical-align-top',
'wrap-text',
'rss',
'x',
] as const;

@ -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;

@ -2,8 +2,9 @@ import React from 'react';
// @ts-ignore
import Drop from 'tether-drop';
import { locationSearchToObject, navigationLogger, reportPageview } from '@grafana/runtime';
import { config, locationSearchToObject, navigationLogger, reportPageview } from '@grafana/runtime';
import { TopNavPage } from '../components/TopNav/TopNavPage';
import { keybindingSrv } from '../services/keybindingSrv';
import { GrafanaRouteComponentProps } from './types';
@ -70,7 +71,12 @@ export class GrafanaRoute extends React.Component<Props> {
navigationLogger('GrafanaRoute', false, 'Rendered', props.route);
const RouteComponent = props.route.component;
const routeElement = <RouteComponent {...props} queryParams={locationSearchToObject(props.location.search)} />;
return <RouteComponent {...props} queryParams={locationSearchToObject(props.location.search)} />;
if (config.featureToggles.topnav && !props.route.navHidden) {
return <TopNavPage navId={props.route.navId}>{routeElement}</TopNavPage>;
}
return routeElement;
}
}

@ -17,5 +17,7 @@ export interface RouteDescriptor {
pageClass?: string;
/** Can be used like an id for the route if the same component is used by many routes */
routeName?: string;
navHidden?: boolean;
exact?: boolean;
navId?: string;
}

@ -5,6 +5,7 @@ import { useLocation } from 'react-router-dom';
import { locationUtil, textUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { ButtonGroup, ModalsController, ToolbarButton, PageToolbar, useForceUpdate } from '@grafana/ui';
import { TopNavUpdate } from 'app/core/components/TopNav/TopNavUpdate';
import config from 'app/core/config';
import { toggleKioskMode } from 'app/core/navigation/kiosk';
import { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal';
@ -274,6 +275,15 @@ export const DashNav = React.memo<Props>((props) => {
const parentHref = locationUtil.getUrlForPartial(location, { search: 'open', folder: 'current' });
const onGoBack = isFullscreen ? onClose : undefined;
if (config.featureToggles.topnav) {
return (
<TopNavUpdate
subNav={{ text: title }}
actions={<ToolbarButton onClick={onOpenSettings} icon="cog"></ToolbarButton>}
/>
);
}
return (
<PageToolbar
pageIcon={isFullscreen ? undefined : 'apps'}

@ -33,6 +33,7 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/d/:uid/:slug?',
navId: 'dashboards',
pageClass: 'page-dashboard',
routeName: DashboardRoutes.Normal,
component: SafeDynamicImport(
@ -90,6 +91,7 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/datasources',
navId: 'datasources',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/DataSourcesListPage')
),
@ -191,6 +193,7 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/org/users',
navId: 'users',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "UsersListPage" */ 'app/features/users/UsersListPage')
),
@ -203,6 +206,7 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/org/apikeys',
navId: 'apikeys',
roles: () => contextSrv.evaluatePermission(() => ['Admin'], [AccessControlAction.ActionAPIKeysRead]),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage')
@ -231,6 +235,7 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/org/teams',
navId: 'teams',
roles: () =>
contextSrv.evaluatePermission(
() => (config.editorsCanAdmin ? ['Editor', 'Admin'] : ['Admin']),
@ -316,6 +321,7 @@ export function getAppRoutes(): RouteDescriptor[] {
path: '/login',
component: LoginPage,
pageClass: 'login-page sidemenu-hidden',
navHidden: true,
},
{
path: '/invite/:code',
@ -323,6 +329,7 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "SignupInvited" */ 'app/features/invites/SignupInvited')
),
pageClass: 'sidemenu-hidden',
navHidden: true,
},
{
path: '/verify',
@ -332,6 +339,7 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName "VerifyEmailPage"*/ 'app/core/components/Signup/VerifyEmailPage')
),
pageClass: 'login-page sidemenu-hidden',
navHidden: true,
},
{
path: '/signup',
@ -339,10 +347,12 @@ export function getAppRoutes(): RouteDescriptor[] {
? () => <Redirect to="/login" />
: SafeDynamicImport(() => import(/* webpackChunkName "SignupPage"*/ 'app/core/components/Signup/SignupPage')),
pageClass: 'sidemenu-hidden login-page',
navHidden: true,
},
{
path: '/user/password/send-reset-email',
pageClass: 'sidemenu-hidden',
navHidden: true,
component: SafeDynamicImport(
() =>
import(/* webpackChunkName: "SendResetMailPage" */ 'app/core/components/ForgottenPassword/SendResetMailPage')
@ -357,6 +367,7 @@ export function getAppRoutes(): RouteDescriptor[] {
)
),
pageClass: 'sidemenu-hidden login-page',
navHidden: true,
},
{
path: '/dashboard/snapshots',

Loading…
Cancel
Save