From f047f7dcf6f03be26d51c40ea73e953eafb8ab23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 28 Jun 2022 15:47:12 +0200 Subject: [PATCH] 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 --- .betterer.results | 24 ++--- packages/grafana-ui/src/types/icon.ts | 2 + .../core/components/TopNav/Breadcrumbs.tsx | 82 +++++++++++++++++ .../app/core/components/TopNav/NavToolbar.tsx | 62 +++++++++++++ .../app/core/components/TopNav/TopNavPage.tsx | 80 +++++++++++++++++ .../core/components/TopNav/TopNavUpdate.tsx | 23 +++++ .../core/components/TopNav/TopSearchBar.tsx | 90 +++++++++++++++++++ public/app/core/components/TopNav/types.ts | 1 + public/app/core/navigation/GrafanaRoute.tsx | 10 ++- public/app/core/navigation/types.ts | 2 + .../dashboard/components/DashNav/DashNav.tsx | 10 +++ public/app/routes/routes.tsx | 11 +++ 12 files changed, 383 insertions(+), 14 deletions(-) create mode 100644 public/app/core/components/TopNav/Breadcrumbs.tsx create mode 100644 public/app/core/components/TopNav/NavToolbar.tsx create mode 100644 public/app/core/components/TopNav/TopNavPage.tsx create mode 100644 public/app/core/components/TopNav/TopNavUpdate.tsx create mode 100644 public/app/core/components/TopNav/TopSearchBar.tsx create mode 100644 public/app/core/components/TopNav/types.ts diff --git a/.betterer.results b/.betterer.results index 3054eb7cbd1..e4534e05a0b 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index 5e179b851f5..120d1f35cf5 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -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; diff --git a/public/app/core/components/TopNav/Breadcrumbs.tsx b/public/app/core/components/TopNav/Breadcrumbs.tsx new file mode 100644 index 00000000000..bdfad00315e --- /dev/null +++ b/public/app/core/components/TopNav/Breadcrumbs.tsx @@ -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 ( + + ); +} + +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', + }, + }), + }; +}; diff --git a/public/app/core/components/TopNav/NavToolbar.tsx b/public/app/core/components/TopNav/NavToolbar.tsx new file mode 100644 index 00000000000..afe8474d3d1 --- /dev/null +++ b/public/app/core/components/TopNav/NavToolbar.tsx @@ -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 ( +
+
+ {}} /> +
+ +
+
+ {actions} + +
+
+ ); +} + +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), + }), + }; +}; diff --git a/public/app/core/components/TopNav/TopNavPage.tsx b/public/app/core/components/TopNav/TopNavPage.tsx new file mode 100644 index 00000000000..5924ad69485 --- /dev/null +++ b/public/app/core/components/TopNav/TopNavPage.tsx @@ -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 ( +
+
+ {!searchBarHidden && } + +
+
{children}
+
+ ); +} + +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', + }), + }; +}; diff --git a/public/app/core/components/TopNav/TopNavUpdate.tsx b/public/app/core/components/TopNav/TopNavUpdate.tsx new file mode 100644 index 00000000000..c16ba7a2755 --- /dev/null +++ b/public/app/core/components/TopNav/TopNavUpdate.tsx @@ -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(); +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; +} diff --git a/public/app/core/components/TopNav/TopSearchBar.tsx b/public/app/core/components/TopNav/TopSearchBar.tsx new file mode 100644 index 00000000000..bc6f0863c14 --- /dev/null +++ b/public/app/core/components/TopNav/TopSearchBar.tsx @@ -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 ( +
+ + + +
+ {}} + className={styles.searchInput} + /> +
+
+ + + + + + + + + +
+
+ ); +} + +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', + }, + }), + }; +}; diff --git a/public/app/core/components/TopNav/types.ts b/public/app/core/components/TopNav/types.ts new file mode 100644 index 00000000000..9171a881c48 --- /dev/null +++ b/public/app/core/components/TopNav/types.ts @@ -0,0 +1 @@ +export const TOP_BAR_LEVEL_HEIGHT = 40; diff --git a/public/app/core/navigation/GrafanaRoute.tsx b/public/app/core/navigation/GrafanaRoute.tsx index 3ff44bc54bf..7b2e7445b0d 100644 --- a/public/app/core/navigation/GrafanaRoute.tsx +++ b/public/app/core/navigation/GrafanaRoute.tsx @@ -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 { navigationLogger('GrafanaRoute', false, 'Rendered', props.route); const RouteComponent = props.route.component; + const routeElement = ; - return ; + if (config.featureToggles.topnav && !props.route.navHidden) { + return {routeElement}; + } + + return routeElement; } } diff --git a/public/app/core/navigation/types.ts b/public/app/core/navigation/types.ts index aed3b16a62f..d34089aca8f 100644 --- a/public/app/core/navigation/types.ts +++ b/public/app/core/navigation/types.ts @@ -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; } diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index baf9919d11a..09101d40d5a 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -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) => { const parentHref = locationUtil.getUrlForPartial(location, { search: 'open', folder: 'current' }); const onGoBack = isFullscreen ? onClose : undefined; + if (config.featureToggles.topnav) { + return ( + } + /> + ); + } + return ( 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[] { ? () => : 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',