mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:  Search:  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:
parent
1919eef5cf
commit
d803d8317c
9 changed files with 126 additions and 53 deletions
|
@ -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$()}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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$()', () => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -25,6 +25,7 @@ const navigationTree: NavigationTreeDefinition = {
|
|||
title: 'Observability',
|
||||
icon: 'logoObservability',
|
||||
defaultIsCollapsed: false,
|
||||
breadcrumbStatus: 'hidden',
|
||||
children: [
|
||||
{
|
||||
id: 'services-infra',
|
||||
|
|
|
@ -25,6 +25,7 @@ const navigationTree: NavigationTreeDefinition = {
|
|||
title: 'Elasticsearch',
|
||||
icon: 'logoElasticsearch',
|
||||
defaultIsCollapsed: false,
|
||||
breadcrumbStatus: 'hidden',
|
||||
children: [
|
||||
{
|
||||
id: 'search_getting_started',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue