Navigation: show breadcrumbs correctly when on the home page (#55759)

* show breadcrumbs correctly when on the home page

* adjust breadcrumb unit tests

* update betterer

* fix backend tests

* update getSectionRoot to look at the home nav id

* remove redundant setting of home dashboard

* construct a home navmodelitem in the backend

* fix cases when the feature toggle is off

* fix unit test

* fix more unit tests

* refactor how buildBreadcrumbs works

* use HOME_NAV_ID

* move homeNav useSelector into NavToolbar

* remove unnecesary cloneDeep

* don't need locationUtil here

* restore using getUrlForPartial in DashboardPage

* special case for the editview query param

* remove commented out code

* add comment to clarify splice behaviour

* slightly cleaner syntax
This commit is contained in:
Ashley Harrison
2022-10-03 15:05:19 +01:00
committed by GitHub
parent 0d348dc0b1
commit 8984507291
14 changed files with 158 additions and 51 deletions

View File

@@ -3,6 +3,8 @@ import React from 'react';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Icon, IconButton, ToolbarButton, useStyles2 } from '@grafana/ui';
import { HOME_NAV_ID } from 'app/core/reducers/navModel';
import { useSelector } from 'app/types';
import { Breadcrumbs } from '../Breadcrumbs/Breadcrumbs';
import { buildBreadcrumbs } from '../Breadcrumbs/utils';
@@ -29,8 +31,9 @@ export function NavToolbar({
onToggleSearchBar,
onToggleKioskMode,
}: Props) {
const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID];
const styles = useStyles2(getStyles);
const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav);
const breadcrumbs = buildBreadcrumbs(homeNav, sectionNav, pageNav);
return (
<div className={styles.pageToolbar}>

View File

@@ -2,26 +2,20 @@ import { NavModelItem } from '@grafana/data';
import { buildBreadcrumbs } from './utils';
const mockHomeNav: NavModelItem = {
text: 'Home',
url: '/home',
id: 'home',
};
describe('breadcrumb utils', () => {
describe('buildBreadcrumbs', () => {
it('includes the home breadcrumb at the root', () => {
const sectionNav: NavModelItem = {
text: 'My section',
url: '/my-section',
};
const result = buildBreadcrumbs(sectionNav);
expect(result[0]).toEqual({ href: '/', text: 'Home' });
});
it('includes breadcrumbs for the section nav', () => {
const sectionNav: NavModelItem = {
text: 'My section',
url: '/my-section',
};
expect(buildBreadcrumbs(sectionNav)).toEqual([
{ href: '/', text: 'Home' },
{ text: 'My section', href: '/my-section' },
]);
expect(buildBreadcrumbs(mockHomeNav, sectionNav)).toEqual([{ text: 'My section', href: '/my-section' }]);
});
it('includes breadcrumbs for the page nav', () => {
@@ -34,8 +28,7 @@ describe('breadcrumb utils', () => {
text: 'My page',
url: '/my-page',
};
expect(buildBreadcrumbs(sectionNav, pageNav)).toEqual([
{ href: '/', text: 'Home' },
expect(buildBreadcrumbs(mockHomeNav, sectionNav, pageNav)).toEqual([
{ text: 'My section', href: '/my-section' },
{ text: 'My page', href: '/my-page' },
]);
@@ -50,8 +43,7 @@ describe('breadcrumb utils', () => {
url: '/my-parent-section',
},
};
expect(buildBreadcrumbs(sectionNav)).toEqual([
{ href: '/', text: 'Home' },
expect(buildBreadcrumbs(mockHomeNav, sectionNav)).toEqual([
{ text: 'My parent section', href: '/my-parent-section' },
{ text: 'My section', href: '/my-section' },
]);
@@ -74,13 +66,83 @@ describe('breadcrumb utils', () => {
url: '/my-parent-section',
},
};
expect(buildBreadcrumbs(sectionNav, pageNav)).toEqual([
{ href: '/', text: 'Home' },
expect(buildBreadcrumbs(mockHomeNav, sectionNav, pageNav)).toEqual([
{ text: 'My parent section', href: '/my-parent-section' },
{ text: 'My section', href: '/my-section' },
{ text: 'My parent page', href: '/my-parent-page' },
{ text: 'My page', href: '/my-page' },
]);
});
it('shortcircuits if the home nav is found early', () => {
const pageNav: NavModelItem = {
text: 'My page',
url: '/my-page',
parentItem: {
text: 'My parent page',
url: '/home',
},
};
const sectionNav: NavModelItem = {
text: 'My section',
url: '/my-section',
parentItem: {
text: 'My parent section',
url: '/my-parent-section',
},
};
expect(buildBreadcrumbs(mockHomeNav, sectionNav, pageNav)).toEqual([
{ text: 'Home', href: '/home' },
{ text: 'My page', href: '/my-page' },
]);
});
it('matches the home nav ignoring query parameters', () => {
const pageNav: NavModelItem = {
text: 'My page',
url: '/my-page',
parentItem: {
text: 'My parent page',
url: '/home?orgId=1',
},
};
const sectionNav: NavModelItem = {
text: 'My section',
url: '/my-section',
parentItem: {
text: 'My parent section',
url: '/my-parent-section',
},
};
expect(buildBreadcrumbs(mockHomeNav, sectionNav, pageNav)).toEqual([
{ text: 'Home', href: '/home?orgId=1' },
{ text: 'My page', href: '/my-page' },
]);
});
it('does not match the home nav if the editview param is different', () => {
const pageNav: NavModelItem = {
text: 'My page',
url: '/my-page',
parentItem: {
text: 'My parent page',
url: '/home?orgId=1&editview=settings',
},
};
const sectionNav: NavModelItem = {
text: 'My section',
url: '/my-section',
parentItem: {
text: 'My parent section',
url: '/my-parent-section',
},
};
expect(buildBreadcrumbs(mockHomeNav, sectionNav, pageNav)).toEqual([
{ text: 'My parent section', href: '/my-parent-section' },
{ text: 'My section', href: '/my-section' },
{ text: 'My parent page', href: '/home?orgId=1&editview=settings' },
{ text: 'My page', href: '/my-page' },
]);
});
});
});

View File

@@ -2,24 +2,37 @@ import { NavModelItem } from '@grafana/data';
import { Breadcrumb } from './types';
export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelItem) {
const crumbs: Breadcrumb[] = [{ href: '/', text: 'Home' }];
export function buildBreadcrumbs(homeNav: NavModelItem, sectionNav: NavModelItem, pageNav?: NavModelItem) {
const crumbs: Breadcrumb[] = [];
let foundHome = false;
function addCrumbs(node: NavModelItem) {
// construct the URL to match
// we want to ignore query params except for the editview query param
const urlSearchParams = new URLSearchParams(node.url?.split('?')[1]);
let urlToMatch = `${node.url?.split('?')[0]}`;
if (urlSearchParams.has('editview')) {
urlToMatch += `?editview=${urlSearchParams.get('editview')}`;
}
if (!foundHome && !node.hideFromBreadcrumbs) {
if (urlToMatch === homeNav.url) {
crumbs.unshift({ text: homeNav.text, href: node.url ?? '' });
foundHome = true;
} else {
crumbs.unshift({ text: node.text, href: node.url ?? '' });
}
}
if (node.parentItem) {
addCrumbs(node.parentItem);
}
if (!node.hideFromBreadcrumbs) {
crumbs.push({ text: node.text, href: node.url ?? '' });
}
}
addCrumbs(sectionNav);
if (pageNav) {
addCrumbs(pageNav);
}
addCrumbs(sectionNav);
return crumbs;
}

View File

@@ -56,7 +56,6 @@ describe('MegaMenu', () => {
setup();
expect(await screen.findByTestId('navbarmenu')).toBeInTheDocument();
expect(await screen.findByRole('link', { name: 'Home' })).toBeInTheDocument();
expect(await screen.findByRole('link', { name: 'Section name' })).toBeInTheDocument();
});

View File

@@ -3,8 +3,7 @@ import { cloneDeep } from 'lodash';
import React from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data';
import { config } from '@grafana/runtime';
import { GrafanaTheme2, NavSection } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { useSelector } from 'app/types';
@@ -23,16 +22,6 @@ export const MegaMenu = React.memo<Props>(({ onClose, searchBarHidden }) => {
const styles = getStyles(theme);
const location = useLocation();
const homeItem: NavModelItem = enrichWithInteractionTracking(
{
id: 'home',
text: 'Home',
url: config.appSubUrl || '/',
icon: 'home-alt',
},
true
);
const navTree = cloneDeep(navBarTree);
const coreItems = navTree
@@ -46,7 +35,7 @@ export const MegaMenu = React.memo<Props>(({ onClose, searchBarHidden }) => {
location
).map((item) => enrichWithInteractionTracking(item, true));
const navItems = [homeItem, ...coreItems, ...pluginItems, ...configItems];
const navItems = [...coreItems, ...pluginItems, ...configItems];
const activeItem = getActiveItem(navItems, location.pathname);

View File

@@ -4,10 +4,19 @@ import { cloneDeep } from 'lodash';
import { NavIndex, NavModel, NavModelItem } from '@grafana/data';
import config from 'app/core/config';
export const HOME_NAV_ID = 'home';
export function buildInitialState(): NavIndex {
const navIndex: NavIndex = {};
const rootNodes = cloneDeep(config.bootData.navTree as NavModelItem[]);
buildNavIndex(navIndex, rootNodes);
const homeNav = rootNodes.find((node) => node.id === HOME_NAV_ID);
// set home as parent for the rootNodes
buildNavIndex(navIndex, rootNodes, homeNav);
// remove circular parent reference on the home node
if (navIndex[HOME_NAV_ID]) {
delete navIndex[HOME_NAV_ID].parentItem;
}
return navIndex;
}

View File

@@ -1,5 +1,7 @@
import { NavModel, NavModelItem, NavIndex } from '@grafana/data';
import { HOME_NAV_ID } from '../reducers/navModel';
const getNotFoundModel = (): NavModel => {
const node: NavModelItem = {
id: 'not-found',
@@ -35,7 +37,7 @@ export const getNavModel = (navIndex: NavIndex, id: string, fallback?: NavModel,
};
function getSectionRoot(node: NavModelItem): NavModelItem {
return node.parentItem ? getSectionRoot(node.parentItem) : node;
return node.parentItem && node.parentItem.id !== HOME_NAV_ID ? getSectionRoot(node.parentItem) : node;
}
function enrichNodeWithActiveState(node: NavModelItem, activeId: string): NavModelItem {

View File

@@ -2,7 +2,7 @@ import { cx } from '@emotion/css';
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { locationUtil, NavModel, NavModelItem, TimeRange, PageLayoutType } from '@grafana/data';
import { NavModel, NavModelItem, TimeRange, PageLayoutType, locationUtil } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config, locationService } from '@grafana/runtime';
import { Themeable2, withTheme2 } from '@grafana/ui';
@@ -459,6 +459,7 @@ function updateStatePageNavFromProps(props: Props, state: State): State {
...pageNav,
text: `${state.editPanel ? 'Edit' : 'View'} panel`,
parentItem: pageNav,
url: undefined,
};
}

View File

@@ -21,6 +21,7 @@ jest.mock('@grafana/runtime', () => ({
get: jest.fn().mockResolvedValue([{ userId: 1, login: 'Test' }]),
}),
config: {
...jest.requireActual('@grafana/runtime').config,
bootData: { navTree: [], user: {} },
},
}));