diff --git a/packages/grafana-ui/src/components/Tabs/VerticalTab.tsx b/packages/grafana-ui/src/components/Tabs/VerticalTab.tsx index fe1e90b2e24..4351430ebaf 100644 --- a/packages/grafana-ui/src/components/Tabs/VerticalTab.tsx +++ b/packages/grafana-ui/src/components/Tabs/VerticalTab.tsx @@ -25,20 +25,18 @@ export const VerticalTab = React.forwardRef( const linkClass = cx(tabsStyles.link, active && tabsStyles.activeStyle); return ( -
- - {content()} - -
+ + {content()} + ); } ); @@ -47,17 +45,12 @@ VerticalTab.displayName = 'Tab'; const getTabStyles = (theme: GrafanaTheme2) => { return { - item: css` - list-style: none; - margin-right: ${theme.spacing(2)}; - position: relative; - display: block; - `, link: css` padding: 6px 12px; display: block; height: 100%; cursor: pointer; + position: relative; color: ${theme.colors.text.primary}; diff --git a/pkg/api/index.go b/pkg/api/index.go index 04411cc98a0..844f39ecc1b 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -328,7 +328,8 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs * // Move server admin into Configuration and rename to administration if configNode != nil && serverAdminNode != nil { configNode.Text = "Administration" - serverAdminNode.Url = "/admin" + serverAdminNode.Url = "/admin/server" + serverAdminNode.HideFromTabs = false configNode.Children = append(configNode.Children, serverAdminNode) adminNodeIndex := len(navTree) - 1 navTree = navTree[:adminNodeIndex] diff --git a/public/app/core/components/AppChrome/NavToolbar.tsx b/public/app/core/components/AppChrome/NavToolbar.tsx index 78b54fbea46..48d39dba974 100644 --- a/public/app/core/components/AppChrome/NavToolbar.tsx +++ b/public/app/core/components/AppChrome/NavToolbar.tsx @@ -59,6 +59,7 @@ const getStyles = (theme: GrafanaTheme2) => { menuButton: css({ display: 'flex', alignItems: 'center', + marginRight: theme.spacing(1), }), actions: css({ display: 'flex', diff --git a/public/app/core/components/Breadcrumbs/BreadcrumbItem.tsx b/public/app/core/components/Breadcrumbs/BreadcrumbItem.tsx index 61a0f084e88..0a5f9b4a6ff 100644 --- a/public/app/core/components/Breadcrumbs/BreadcrumbItem.tsx +++ b/public/app/core/components/Breadcrumbs/BreadcrumbItem.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Icon, LinkButton, useStyles2 } from '@grafana/ui'; +import { Icon, useStyles2 } from '@grafana/ui'; import { Breadcrumb } from './types'; @@ -20,20 +20,9 @@ export function BreadcrumbItem(props: Props) { ) : ( <> - {'icon' in props ? ( - - ) : ( - - {props.text} - - )} + + {props.text} +
diff --git a/public/app/core/components/Breadcrumbs/Breadcrumbs.test.tsx b/public/app/core/components/Breadcrumbs/Breadcrumbs.test.tsx index af1897b747f..146ab7697db 100644 --- a/public/app/core/components/Breadcrumbs/Breadcrumbs.test.tsx +++ b/public/app/core/components/Breadcrumbs/Breadcrumbs.test.tsx @@ -5,7 +5,7 @@ import { Breadcrumbs } from './Breadcrumbs'; import { Breadcrumb } from './types'; const mockBreadcrumbs: Breadcrumb[] = [ - { text: 'Home', href: '/home', icon: 'home-alt' }, + { text: 'Home', href: '/home' }, { text: 'First', href: '/first' }, { text: 'Second', href: '/second' }, ]; diff --git a/public/app/core/components/Breadcrumbs/utils.test.ts b/public/app/core/components/Breadcrumbs/utils.test.ts index f2f22b45f9e..2c9fa635af3 100644 --- a/public/app/core/components/Breadcrumbs/utils.test.ts +++ b/public/app/core/components/Breadcrumbs/utils.test.ts @@ -10,7 +10,7 @@ describe('breadcrumb utils', () => { url: '/my-section', }; const result = buildBreadcrumbs(sectionNav); - expect(result[0]).toEqual({ icon: 'home-alt', href: '/', text: 'Home' }); + expect(result[0]).toEqual({ href: '/', text: 'Home' }); }); it('includes breadcrumbs for the section nav', () => { @@ -19,7 +19,7 @@ describe('breadcrumb utils', () => { url: '/my-section', }; expect(buildBreadcrumbs(sectionNav)).toEqual([ - { icon: 'home-alt', href: '/', text: 'Home' }, + { href: '/', text: 'Home' }, { text: 'My section', href: '/my-section' }, ]); }); @@ -35,7 +35,7 @@ describe('breadcrumb utils', () => { url: '/my-page', }; expect(buildBreadcrumbs(sectionNav, pageNav)).toEqual([ - { icon: 'home-alt', href: '/', text: 'Home' }, + { href: '/', text: 'Home' }, { text: 'My section', href: '/my-section' }, { text: 'My page', href: '/my-page' }, ]); @@ -51,7 +51,7 @@ describe('breadcrumb utils', () => { }, }; expect(buildBreadcrumbs(sectionNav)).toEqual([ - { icon: 'home-alt', href: '/', text: 'Home' }, + { href: '/', text: 'Home' }, { text: 'My parent section', href: '/my-parent-section' }, { text: 'My section', href: '/my-section' }, ]); @@ -75,7 +75,7 @@ describe('breadcrumb utils', () => { }, }; expect(buildBreadcrumbs(sectionNav, pageNav)).toEqual([ - { icon: 'home-alt', href: '/', text: 'Home' }, + { href: '/', text: 'Home' }, { text: 'My parent section', href: '/my-parent-section' }, { text: 'My section', href: '/my-section' }, { text: 'My parent page', href: '/my-parent-page' }, diff --git a/public/app/core/components/Breadcrumbs/utils.ts b/public/app/core/components/Breadcrumbs/utils.ts index c8dc2b45c33..a5ec30b3bc2 100644 --- a/public/app/core/components/Breadcrumbs/utils.ts +++ b/public/app/core/components/Breadcrumbs/utils.ts @@ -3,7 +3,7 @@ import { NavModelItem } from '@grafana/data'; import { Breadcrumb } from './types'; export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelItem) { - const crumbs: Breadcrumb[] = [{ icon: 'home-alt', href: '/', text: 'Home' }]; + const crumbs: Breadcrumb[] = [{ href: '/', text: 'Home' }]; function addCrumbs(node: NavModelItem) { if (node.parentItem) { diff --git a/public/app/core/components/PageNew/Page.test.tsx b/public/app/core/components/PageNew/Page.test.tsx index 20cf2406c54..795c2182acd 100644 --- a/public/app/core/components/PageNew/Page.test.tsx +++ b/public/app/core/components/PageNew/Page.test.tsx @@ -69,10 +69,10 @@ describe('Render', () => { it('should render section nav model based on navId', async () => { setup({ navId: 'child1' }); - expect(screen.getByRole('heading', { name: 'Section name' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Child1' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Tab Section name' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Tab Child1' })).toBeInTheDocument(); - expect(screen.getAllByRole('tab').length).toBe(2); + expect(screen.getByRole('tab', { name: 'Tab Child1' })).toBeInTheDocument(); + expect(screen.getAllByRole('tab').length).toBe(3); }); it('should update chrome with section and pageNav', async () => { @@ -84,7 +84,7 @@ describe('Render', () => { it('should render section nav model based on navId and item page nav', async () => { setup({ navId: 'child1', pageNav }); - expect(screen.getByRole('heading', { name: 'Section name' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Tab Section name' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'pageNav title' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Tab Child1' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Tab pageNav child1' })).toBeInTheDocument(); diff --git a/public/app/core/components/PageNew/SectionNav.tsx b/public/app/core/components/PageNew/SectionNav.tsx index 7c0b43e9517..59c31a5bde9 100644 --- a/public/app/core/components/PageNew/SectionNav.tsx +++ b/public/app/core/components/PageNew/SectionNav.tsx @@ -2,55 +2,22 @@ import { css } from '@emotion/css'; import React from 'react'; import { NavModel, GrafanaTheme2 } from '@grafana/data'; -import { useStyles2, Icon, VerticalTab, toIconName, CustomScrollbar } from '@grafana/ui'; +import { useStyles2, CustomScrollbar } from '@grafana/ui'; + +import { SectionNavItem } from './SectionNavItem'; export interface Props { model: NavModel; } -export function SectionNav(props: Props) { +export function SectionNav({ model }: Props) { const styles = useStyles2(getStyles); - const main = props.model.main; - const directChildren = props.model.main.children!.filter((x) => !x.hideFromTabs && !x.children); - const nestedItems = props.model.main.children!.filter((x) => x.children && x.children.length); - const icon = main.icon ? toIconName(main.icon) : undefined; - return ( @@ -63,27 +30,15 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'flex', flexDirection: 'column', background: theme.colors.background.canvas, - padding: theme.spacing(3, 2), flexShrink: 0, [theme.breakpoints.up('md')]: { width: '250px', }, }), - sectionName: css({ + items: css({ display: 'flex', - alignItems: 'center', - gap: theme.spacing(1), - padding: theme.spacing(0.5, 0, 3, 0.25), - fontSize: theme.typography.h4.fontSize, - margin: 0, - }), - items: css({}), - sectionImg: css({ - height: 48, - }), - subSection: css({ - padding: theme.spacing(3, 0, 0.5, 1), - fontWeight: 500, + flexDirection: 'column', + padding: theme.spacing(4.5, 1, 2, 2), }), }; }; diff --git a/public/app/core/components/PageNew/SectionNavItem.tsx b/public/app/core/components/PageNew/SectionNavItem.tsx new file mode 100644 index 00000000000..31d4062e7a5 --- /dev/null +++ b/public/app/core/components/PageNew/SectionNavItem.tsx @@ -0,0 +1,112 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { useStyles2, Icon } from '@grafana/ui'; + +export interface Props { + item: NavModelItem; +} + +export function SectionNavItem({ item }: Props) { + const styles = useStyles2(getStyles); + + const children = item.children?.filter((x) => !x.hideFromTabs); + const isRoot = item.parentItem == null; + const hasActiveChild = Boolean(children?.length && children.find((x) => x.active)); + + // If first root child is a section skip the bottom margin (as sections have top margin already) + const noRootMargin = isRoot && Boolean(item.children![0].children?.length); + + const linkClass = cx({ + [styles.link]: true, + [styles.activeStyle]: item.active, + [styles.isSection]: Boolean(children?.length), + [styles.hasActiveChild]: hasActiveChild, + [styles.isRoot]: isRoot, + [styles.noRootMargin]: noRootMargin, + }); + + return ( + <> + + {isRoot && item.icon && } + {isRoot && item.img && {`logo} + {item.text} + {item.tabSuffix && } + + {children?.map((child, index) => ( + + ))} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + link: css` + padding: ${theme.spacing(1, 0, 1, 1.5)}; + display: flex; + align-items: center; + gap: ${theme.spacing(1)}; + height: 100%; + position: relative; + color: ${theme.colors.text.secondary}; + + &:hover, + &:focus { + text-decoration: underline; + } + `, + activeStyle: css` + label: activeTabStyle; + color: ${theme.colors.text.primary}; + background-color: ${theme.colors.action.disabledBackground}; + border-radius: ${theme.shape.borderRadius(2)}; + fontweight: theme.typography.fontWeightMedium; + + &::before { + display: block; + content: ' '; + position: absolute; + left: 0; + width: 4px; + bottom: 2px; + top: 2px; + border-radius: 2px; + background-image: ${theme.colors.gradients.brandVertical}; + } + `, + suffix: css` + margin-left: ${theme.spacing(1)}; + `, + sectionImg: css({ + height: 18, + }), + isRoot: css({ + color: theme.colors.text.primary, + fontSize: theme.typography.h4.fontSize, + marginTop: 0, + marginBottom: theme.spacing(2), + fontWeight: theme.typography.fontWeightMedium, + }), + isSection: css({ + fontSize: theme.typography.h5.fontSize, + marginTop: theme.spacing(2), + fontWeight: theme.typography.fontWeightMedium, + }), + noRootMargin: css({ + marginBottom: 0, + }), + hasActiveChild: css({ + color: theme.colors.text.primary, + }), + }; +}; diff --git a/public/app/core/selectors/navModel.test.ts b/public/app/core/selectors/navModel.test.ts index 951bc5fa3e0..052a423dad5 100644 --- a/public/app/core/selectors/navModel.test.ts +++ b/public/app/core/selectors/navModel.test.ts @@ -46,7 +46,7 @@ describe('getNavModel', () => { const navModel = getNavModel(navIndex, 'apps/subapp/child1'); expect(navModel.main.id).toBe('apps'); expect(navModel.node.id).toBe('apps/subapp/child1'); - expect(navModel.main.children![2].active).toBe(true); + expect(navModel.main.children![2].active).toBe(undefined); expect(navModel.main.children![2].children![0].active).toBe(true); }); }); diff --git a/public/app/core/selectors/navModel.ts b/public/app/core/selectors/navModel.ts index 49ea7f6c21e..53e4259eaea 100644 --- a/public/app/core/selectors/navModel.ts +++ b/public/app/core/selectors/navModel.ts @@ -18,12 +18,12 @@ const getNotFoundModel = (): NavModel => { export const getNavModel = (navIndex: NavIndex, id: string, fallback?: NavModel, onlyChild = false): NavModel => { if (navIndex[id]) { const node = navIndex[id]; - const nodeWithActive = enrichNodeWithActiveState(node); - const main = onlyChild ? nodeWithActive : getSectionRoot(nodeWithActive); + const main = onlyChild ? node : getSectionRoot(node); + const mainWithActive = enrichNodeWithActiveState(main, id); return { - node: nodeWithActive, - main, + node: node, + main: mainWithActive, }; } @@ -38,27 +38,19 @@ function getSectionRoot(node: NavModelItem): NavModelItem { return node.parentItem ? getSectionRoot(node.parentItem) : node; } -function enrichNodeWithActiveState(node: NavModelItem): NavModelItem { - const nodeCopy = { ...node }; - - if (nodeCopy.parentItem) { - nodeCopy.parentItem = { ...nodeCopy.parentItem }; - const root = nodeCopy.parentItem; - - if (root.children) { - root.children = root.children.map((item) => { - if (item.id === node.id) { - return { ...nodeCopy, active: true }; - } - - return item; - }); - } - - nodeCopy.parentItem = enrichNodeWithActiveState(root); +function enrichNodeWithActiveState(node: NavModelItem, activeId: string): NavModelItem { + if (node.id === activeId) { + return { ...node, active: true }; } - return nodeCopy; + if (node.children && node.children.length > 0) { + return { + ...node, + children: node.children.map((child) => enrichNodeWithActiveState(child, activeId)), + }; + } + + return node; } export const getTitleFromNavModel = (navModel: NavModel) => { diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx index aab64dc1b6b..a7cb7cf6b9e 100644 --- a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx @@ -51,6 +51,6 @@ describe('DashboardSettings', () => { ); - expect(await screen.findByRole('heading', { name: 'Settings' })).toBeInTheDocument(); + expect(await screen.findByRole('tab', { name: 'Tab Settings' })).toBeInTheDocument(); }); }); diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index a99aab8a344..009f784a511 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -298,11 +298,15 @@ export function getAppRoutes(): RouteDescriptor[] { component: SafeDynamicImport(() => import(/* webpackChunkName: "TeamPages" */ 'app/features/teams/TeamPages')), }, // ADMIN - { path: '/admin', component: () => (config.featureToggles.topnav ? : ), }, + { + path: '/admin/server', + component: () => + config.featureToggles.topnav ? : , + }, { path: '/admin/settings', component: SafeDynamicImport(