[Serverless] Project Breadcrumbs (#160252)

## Summary

- close https://github.com/elastic/kibana/issues/156517
- built on top of https://github.com/elastic/kibana/pull/156855
- [Serverless chrome breadcrumbs
requirements](https://docs.google.com/document/d/1e5SbDPpySiPeBrjgLJD6Qw6fJyiy34uO2dmGLHlu38E/edit)
- [Serverless chrome breadcrumbs API tech
do](https://docs.google.com/document/d/1_qL14NMGYdI0913eclJd3DXG0lQ2jkE0V3578iaDASs/edit#heading=h.ndqge1i76y6p)

This PR implements serverless (project) breadcrumbs. Now Chrome
automatically renders the main part of the breadcrumbs based on the
project navigation tree and current active path. Plugins can append
their deeper context breadcrumbs or override the navigation breadcrumbs.

```
plugins.serverless.setBreadcrumbs(myDeeperContextBreadcrumbs);

plugins.serverless.setBreadcrumbs(myCustomBreadcrumbs, {absolute: true});
```


Oblt: 

![Screenshot 2023-06-22 at 14 44
32](48bdb397-916c-4861-8a6d-1440f1be7cd4)


Search: 

![Screenshot 2023-06-22 at 14 45
27](0da376d4-c918-4ac5-9869-4154f6c23b36)


Security:

Security hasn't changed because they don't set the project navigation
tree yet. They still have regular breadcrumbs.




-----

Notes: We thought of a possible edge case where an app would set deeper
breadcrumbs `plugins.serverless.set breadcrumbs({text: 'foo', href:
'/foo'});` but the project navigation already have this link as part of
the path. If we hit this edge in the real world, we can workaround this
by merging the same consequent breadcrumbs by a deep link id.

---------
This commit is contained in:
Anton Dosov 2023-06-26 16:25:42 +02:00 committed by GitHub
parent 1919eef5cf
commit d803d8317c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 126 additions and 53 deletions

View file

@ -263,12 +263,24 @@ export class ChromeService {
const getHeaderComponent = () => {
if (chromeStyle$.getValue() === 'project') {
const projectNavigationComponent$ = projectNavigation.getProjectSideNavComponent$();
const projectNavigation$ = projectNavigation
.getProjectNavigation$()
.pipe(takeUntil(this.stop$));
const projectBreadcrumbs$ = projectNavigation
.getProjectBreadcrumbs$()
.pipe(takeUntil(this.stop$));
const activeNodes$ = projectNavigation.getActiveNodes$();
const ProjectHeaderWithNavigation = () => {
const CustomSideNavComponent = useObservable(projectNavigationComponent$, undefined);
const activeNodes = useObservable(activeNodes$, []);
const currentProjectNavigation = useObservable(projectNavigation$, undefined);
// TODO: remove this switch once security sets project navigation tree
const currentProjectBreadcrumbs$ = currentProjectNavigation
? projectBreadcrumbs$
: breadcrumbs$;
let SideNavComponent: ISideNavComponent = () => null;
if (CustomSideNavComponent !== undefined) {
@ -279,13 +291,6 @@ export class ChromeService {
: ProjectSideNavigation;
}
// if projectNavigation wasn't set fallback to the default breadcrumbs
// TODO: Uncommented when we support the project navigation config
// const projectBreadcrumbs$ = projectNavigationConfig
// ? projectNavigation.getProjectBreadcrumbs$()
// : breadcrumbs$;
const projectBreadcrumbs$ = breadcrumbs$;
return (
<ProjectHeader
{...{
@ -293,7 +298,7 @@ export class ChromeService {
globalHelpExtensionMenuLinks$,
}}
actionMenu$={application.currentActionMenu$}
breadcrumbs$={projectBreadcrumbs$.pipe(takeUntil(this.stop$))}
breadcrumbs$={currentProjectBreadcrumbs$}
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
navControlsLeft$={navControls.getLeft$()}

View file

@ -9,6 +9,7 @@
import { ChromeProjectBreadcrumb } from '@kbn/core-chrome-browser';
import { EuiIcon } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
export const createHomeBreadcrumb = ({
homeHref,
@ -17,7 +18,7 @@ export const createHomeBreadcrumb = ({
}): ChromeProjectBreadcrumb => {
return {
text: <EuiIcon type="home" />,
title: 'Home',
title: i18n.translate('core.ui.chrome.breadcrumbs.homeLink', { defaultMessage: 'Home' }),
href: homeHref,
};
};

View file

@ -6,62 +6,76 @@
* Side Public License, v 1.
*/
import { History } from 'history';
import { createMemoryHistory } from 'history';
import { firstValueFrom, lastValueFrom, take } from 'rxjs';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import type { ChromeNavLinks } from '@kbn/core-chrome-browser';
import { ProjectNavigationService } from './project_navigation_service';
const createHistoryMock = ({
locationPathName = '/',
}: { locationPathName?: string } = {}): jest.Mocked<History> => {
return {
block: jest.fn(),
createHref: jest.fn(),
go: jest.fn(),
goBack: jest.fn(),
goForward: jest.fn(),
listen: jest.fn(),
push: jest.fn(),
replace: jest.fn(),
action: 'PUSH',
length: 1,
location: {
pathname: locationPathName,
search: '',
hash: '',
key: '',
state: undefined,
},
};
};
const setup = ({ locationPathName = '/' }: { locationPathName?: string } = {}) => {
const projectNavigationService = new ProjectNavigationService();
const history = createMemoryHistory();
history.replace(locationPathName);
const projectNavigation = projectNavigationService.start({
application: {
...applicationServiceMock.createInternalStartContract(),
history: createHistoryMock({ locationPathName }),
history,
},
navLinks: {} as unknown as ChromeNavLinks,
http: httpServiceMock.createStartContract(),
});
return { projectNavigation };
return { projectNavigation, history };
};
describe('breadcrumbs', () => {
test('should return list of breadcrumbs home / nav / custom', async () => {
const { projectNavigation } = setup();
const setupWithNavTree = () => {
const currentLocationPathName = '/foo/item1';
const { projectNavigation, history } = setup({ locationPathName: currentLocationPathName });
projectNavigation.setProjectNavigation({
navigationTree: [
{
id: 'root',
title: 'Root',
path: ['root'],
breadcrumbStatus: 'hidden',
children: [
{
id: 'subNav',
path: ['root', 'subNav'],
title: '', // intentionally empty to skip rendering
children: [
{
id: 'navItem1',
title: 'Nav Item 1',
path: ['root', 'subNav', 'navItem1'],
deepLink: {
id: 'navItem1',
title: 'Nav Item 1',
url: '/foo/item1',
baseUrl: '',
href: '/foo/item1',
},
},
],
},
],
},
],
});
return { projectNavigation, history };
};
test('should set breadcrumbs home / nav / custom', async () => {
const { projectNavigation } = setupWithNavTree();
projectNavigation.setProjectBreadcrumbs([
{ text: 'custom1', href: '/custom1' },
{ text: 'custom2', href: '/custom1/custom2' },
]);
// TODO: add projectNavigation.setProjectNavigation() to test the part of breadcrumbs extracted from the nav tree
const breadcrumbs = await firstValueFrom(projectNavigation.getProjectBreadcrumbs$());
expect(breadcrumbs).toMatchInlineSnapshot(`
Array [
@ -72,6 +86,10 @@ describe('breadcrumbs', () => {
/>,
"title": "Home",
},
Object {
"href": "/foo/item1",
"text": "Nav Item 1",
},
Object {
"href": "/custom1",
"text": "custom1",
@ -85,7 +103,7 @@ describe('breadcrumbs', () => {
});
test('should skip the default navigation from project navigation when absolute: true is used', async () => {
const { projectNavigation } = setup();
const { projectNavigation } = setupWithNavTree();
projectNavigation.setProjectBreadcrumbs(
[
@ -95,8 +113,6 @@ describe('breadcrumbs', () => {
{ absolute: true }
);
// TODO: add projectNavigation.setProjectNavigation() to test the part of breadcrumbs extracted from the nav tree
const breadcrumbs = await firstValueFrom(projectNavigation.getProjectBreadcrumbs$());
expect(breadcrumbs).toMatchInlineSnapshot(`
Array [
@ -118,6 +134,20 @@ describe('breadcrumbs', () => {
]
`);
});
test('should reset custom breadcrumbs when active path changes', async () => {
const { projectNavigation, history } = setupWithNavTree();
projectNavigation.setProjectBreadcrumbs([
{ text: 'custom1', href: '/custom1' },
{ text: 'custom2', href: '/custom1/custom2' },
]);
let breadcrumbs = await firstValueFrom(projectNavigation.getProjectBreadcrumbs$());
expect(breadcrumbs).toHaveLength(4);
history.push('/foo/item2');
breadcrumbs = await firstValueFrom(projectNavigation.getProjectBreadcrumbs$());
expect(breadcrumbs).toHaveLength(1); // only home is left
});
});
describe('getActiveNodes$()', () => {

View file

@ -53,6 +53,11 @@ export class ProjectNavigationService {
this.onHistoryLocationChange(application.history.location);
this.unlistenHistory = application.history.listen(this.onHistoryLocationChange.bind(this));
this.activeNodes$.pipe(takeUntil(this.stop$)).subscribe(() => {
// reset the breadcrumbs when the active nodes change
this.projectBreadcrumbs$.next({ breadcrumbs: [], params: { absolute: false } });
});
return {
setProjectHome: (homeHref: string) => {
this.projectHome$.next(homeHref);
@ -87,19 +92,33 @@ export class ProjectNavigationService {
});
},
getProjectBreadcrumbs$: (): Observable<ChromeProjectBreadcrumb[]> => {
return combineLatest([this.projectBreadcrumbs$, this.projectNavigation$]).pipe(
map(([breadcrumbs, projectNavigation]) => {
/* TODO: point home breadcrumb to the correct place */
const homeBreadcrumb = createHomeBreadcrumb({ homeHref: '/' });
return combineLatest([
this.projectBreadcrumbs$,
this.activeNodes$,
this.projectHome$.pipe(map((homeHref) => homeHref ?? '/')),
]).pipe(
map(([breadcrumbs, activeNodes, homeHref]) => {
const homeBreadcrumb = createHomeBreadcrumb({
homeHref: this.http?.basePath.prepend?.(homeHref) ?? homeHref,
});
if (breadcrumbs.params.absolute) {
return [homeBreadcrumb, ...breadcrumbs.breadcrumbs];
} else {
return [
homeBreadcrumb,
/* TODO: insert nav breadcrumbs based on projectNavigation and application path */
...breadcrumbs.breadcrumbs,
];
// breadcrumbs take the first active path
const activePath: ChromeProjectNavigationNode[] = activeNodes[0] ?? [];
const navBreadcrumbs = activePath
.filter((n) => Boolean(n.title) && n.breadcrumbStatus !== 'hidden')
.map(
(node): ChromeProjectBreadcrumb => ({
href: node.deepLink?.url ?? node.href,
text: node.title,
})
);
const result = [homeBreadcrumb, ...navBreadcrumbs, ...breadcrumbs.breadcrumbs];
return result;
}
})
);

View file

@ -19,6 +19,7 @@ import {
} from '@elastic/eui';
import { css } from '@emotion/react';
import type { InternalApplicationStart } from '@kbn/core-application-browser-internal';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import {
ChromeBreadcrumb,
ChromeGlobalHelpExtensionMenuLink,
@ -221,7 +222,9 @@ export const ProjectHeader = ({
</EuiHeaderSectionItem>
<EuiHeaderSectionItem>
<HeaderBreadcrumbs breadcrumbs$={observables.breadcrumbs$} />
<RedirectAppLinks coreStart={{ application }}>
<HeaderBreadcrumbs breadcrumbs$={observables.breadcrumbs$} />
</RedirectAppLinks>
</EuiHeaderSectionItem>
</EuiHeaderSection>

View file

@ -42,6 +42,7 @@
"@kbn/core-analytics-browser-mocks",
"@kbn/core-analytics-browser",
"@kbn/shared-ux-router",
"@kbn/shared-ux-link-redirect-app",
],
"exclude": [
"target/**/*",

View file

@ -70,6 +70,12 @@ export interface ChromeProjectNavigationNode {
* Optional function to get the active state. This function is called whenever the location changes.
*/
getIsActive?: (location: Location) => boolean;
/**
* Optional flag to indicate if the breadcrumb should be hidden when this node is active.
* @default 'visible'
*/
breadcrumbStatus?: 'hidden' | 'visible';
}
/** @public */
@ -129,6 +135,12 @@ export interface NodeDefinition<
* Optional function to get the active state. This function is called whenever the location changes.
*/
getIsActive?: (location: Location) => boolean;
/**
* Optional flag to indicate if the breadcrumb should be hidden when this node is active.
* @default 'visible'
*/
breadcrumbStatus?: 'hidden' | 'visible';
}
/**

View file

@ -25,6 +25,7 @@ const navigationTree: NavigationTreeDefinition = {
title: 'Observability',
icon: 'logoObservability',
defaultIsCollapsed: false,
breadcrumbStatus: 'hidden',
children: [
{
id: 'services-infra',

View file

@ -25,6 +25,7 @@ const navigationTree: NavigationTreeDefinition = {
title: 'Elasticsearch',
icon: 'logoElasticsearch',
defaultIsCollapsed: false,
breadcrumbStatus: 'hidden',
children: [
{
id: 'search_getting_started',