diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx
index 56fdcf3142bef..ed619f50ec63b 100644
--- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx
+++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx
@@ -164,7 +164,7 @@ export class ChromeService {
const navControls = this.navControls.start();
const navLinks = this.navLinks.start({ application, http });
- const projectNavigation = this.projectNavigation.start({ application, navLinks });
+ const projectNavigation = this.projectNavigation.start({ application, navLinks, http });
const recentlyAccessed = await this.recentlyAccessed.start({ http });
const docTitle = this.docTitle.start();
const { customBranding$ } = customBranding;
@@ -262,18 +262,12 @@ export class ChromeService {
const getHeaderComponent = () => {
if (chromeStyle$.getValue() === 'project') {
- // const projectNavigationConfig = projectNavigation.getProjectNavigation$();
- // TODO: Uncommented when we support the project navigation config
- // if (!projectNavigationConfig) {
- // throw new Erorr(`Project navigation config must be provided for project.`);
- // }
-
const projectNavigationComponent$ = projectNavigation.getProjectSideNavComponent$();
- // const projectNavigation$ = projectNavigation.getProjectNavigation$();
+ const activeNodes$ = projectNavigation.getActiveNodes$();
const ProjectHeaderWithNavigation = () => {
const CustomSideNavComponent = useObservable(projectNavigationComponent$, undefined);
- // const projectNavigationConfig = useObservable(projectNavigation$, undefined);
+ const activeNodes = useObservable(activeNodes$, []);
let SideNavComponent: ISideNavComponent = () => null;
@@ -311,8 +305,7 @@ export class ChromeService {
kibanaVersion={injectedMetadata.getKibanaVersion()}
prependBasePath={http.basePath.prepend}
>
- {/* TODO: pass down the SideNavCompProps once they are defined */}
-
+
);
};
@@ -428,12 +421,14 @@ export class ChromeService {
setNavigation: setProjectNavigation,
setSideNavComponent: setProjectSideNavComponent,
setBreadcrumbs: setProjectBreadcrumbs,
+ getActiveNavigationNodes$: () => projectNavigation.getActiveNodes$(),
},
};
}
public stop() {
this.navLinks.stop();
+ this.projectNavigation.stop();
this.stop$.next();
}
}
diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts
index 675a32ea6d8b7..3db6e67c5219c 100644
--- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts
+++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.test.ts
@@ -6,16 +6,46 @@
* Side Public License, v 1.
*/
-import { firstValueFrom } from 'rxjs';
-import { ProjectNavigationService } from './project_navigation_service';
+import { History } 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 => {
+ 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 = () => {
+const setup = ({ locationPathName = '/' }: { locationPathName?: string } = {}) => {
const projectNavigationService = new ProjectNavigationService();
const projectNavigation = projectNavigationService.start({
- application: applicationServiceMock.createInternalStartContract(),
+ application: {
+ ...applicationServiceMock.createInternalStartContract(),
+ history: createHistoryMock({ locationPathName }),
+ },
navLinks: {} as unknown as ChromeNavLinks,
+ http: httpServiceMock.createStartContract(),
});
return { projectNavigation };
@@ -89,3 +119,108 @@ describe('breadcrumbs', () => {
`);
});
});
+
+describe('getActiveNodes$()', () => {
+ test('should set the active nodes from history location', async () => {
+ const currentLocationPathName = '/foo/item1';
+ const { projectNavigation } = setup({ locationPathName: currentLocationPathName });
+
+ let activeNodes = await lastValueFrom(projectNavigation.getActiveNodes$().pipe(take(1)));
+ expect(activeNodes).toEqual([]);
+
+ projectNavigation.setProjectNavigation({
+ navigationTree: [
+ {
+ id: 'root',
+ title: 'Root',
+ path: ['root'],
+ children: [
+ {
+ id: 'item1',
+ title: 'Item 1',
+ path: ['root', 'item1'],
+ deepLink: {
+ id: 'item1',
+ title: 'Item 1',
+ url: '/foo/item1',
+ baseUrl: '',
+ href: '',
+ },
+ },
+ ],
+ },
+ ],
+ });
+
+ activeNodes = await lastValueFrom(projectNavigation.getActiveNodes$().pipe(take(1)));
+
+ expect(activeNodes).toEqual([
+ [
+ {
+ id: 'root',
+ title: 'Root',
+ isActive: true,
+ path: ['root'],
+ },
+ {
+ id: 'item1',
+ title: 'Item 1',
+ isActive: true,
+ path: ['root', 'item1'],
+ deepLink: {
+ id: 'item1',
+ title: 'Item 1',
+ url: '/foo/item1',
+ baseUrl: '',
+ href: '',
+ },
+ },
+ ],
+ ]);
+ });
+
+ test('should set the active nodes from getIsActive() handler', async () => {
+ const { projectNavigation } = setup();
+
+ let activeNodes = await lastValueFrom(projectNavigation.getActiveNodes$().pipe(take(1)));
+ expect(activeNodes).toEqual([]);
+
+ projectNavigation.setProjectNavigation({
+ navigationTree: [
+ {
+ id: 'root',
+ title: 'Root',
+ path: ['root'],
+ children: [
+ {
+ id: 'item1',
+ title: 'Item 1',
+ path: ['root', 'item1'],
+ getIsActive: () => true,
+ },
+ ],
+ },
+ ],
+ });
+
+ activeNodes = await lastValueFrom(projectNavigation.getActiveNodes$().pipe(take(1)));
+
+ expect(activeNodes).toEqual([
+ [
+ {
+ id: 'root',
+ title: 'Root',
+ isActive: true,
+ path: ['root'],
+ },
+ {
+ id: 'item1',
+ title: 'Item 1',
+ isActive: true,
+ path: ['root', 'item1'],
+ getIsActive: expect.any(Function),
+ },
+ ],
+ ]);
+ });
+});
diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts
index ac6bf79e0ead9..a9749731aad35 100644
--- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts
+++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/project_navigation_service.ts
@@ -13,13 +13,20 @@ import {
SideNavComponent,
ChromeProjectBreadcrumb,
ChromeSetProjectBreadcrumbsParams,
+ ChromeProjectNavigationNode,
} from '@kbn/core-chrome-browser';
-import { BehaviorSubject, Observable, combineLatest, map } from 'rxjs';
+import type { HttpStart } from '@kbn/core-http-browser';
+import { BehaviorSubject, Observable, combineLatest, map, takeUntil, ReplaySubject } from 'rxjs';
+import type { Location } from 'history';
+import deepEqual from 'react-fast-compare';
+
import { createHomeBreadcrumb } from './home_breadcrumbs';
+import { findActiveNodes, flattenNav, stripQueryParams } from './utils';
interface StartDeps {
application: InternalApplicationStart;
navLinks: ChromeNavLinks;
+ http: HttpStart;
}
export class ProjectNavigationService {
@@ -28,17 +35,23 @@ export class ProjectNavigationService {
}>({ current: null });
private projectHome$ = new BehaviorSubject(undefined);
private projectNavigation$ = new BehaviorSubject(undefined);
+ private activeNodes$ = new BehaviorSubject([]);
+ private projectNavigationNavTreeFlattened: Record = {};
private projectBreadcrumbs$ = new BehaviorSubject<{
breadcrumbs: ChromeProjectBreadcrumb[];
params: ChromeSetProjectBreadcrumbsParams;
}>({ breadcrumbs: [], params: { absolute: false } });
+ private readonly stop$ = new ReplaySubject(1);
+ private application?: InternalApplicationStart;
+ private http?: HttpStart;
+ private unlistenHistory?: () => void;
- public start({ application, navLinks }: StartDeps) {
- // TODO: use application, navLink and projectNavigation$ to:
- // 1. validate projectNavigation$ against navLinks,
- // 2. filter disabled/missing links from projectNavigation
- // 3. keep track of currently active link / path (path will be used to highlight the link in the sidenav and display part of the breadcrumbs)
+ public start({ application, navLinks, http }: StartDeps) {
+ this.application = application;
+ this.http = http;
+ this.onHistoryLocationChange(application.history.location);
+ this.unlistenHistory = application.history.listen(this.onHistoryLocationChange.bind(this));
return {
setProjectHome: (homeHref: string) => {
@@ -49,10 +62,15 @@ export class ProjectNavigationService {
},
setProjectNavigation: (projectNavigation: ChromeProjectNavigation) => {
this.projectNavigation$.next(projectNavigation);
+ this.projectNavigationNavTreeFlattened = flattenNav(projectNavigation.navigationTree);
+ this.setActiveProjectNavigationNodes();
},
getProjectNavigation$: () => {
return this.projectNavigation$.asObservable();
},
+ getActiveNodes$: () => {
+ return this.activeNodes$.pipe(takeUntil(this.stop$));
+ },
setProjectSideNavComponent: (component: SideNavComponent | null) => {
this.customProjectSideNavComponent$.next({ current: component });
},
@@ -88,4 +106,41 @@ export class ProjectNavigationService {
},
};
}
+
+ private setActiveProjectNavigationNodes(_location?: Location) {
+ if (!this.application) return;
+ if (!Object.keys(this.projectNavigationNavTreeFlattened).length) return;
+
+ const location = _location ?? this.application.history.location;
+ let currentPathname = this.http?.basePath.prepend(location.pathname) ?? location.pathname;
+
+ // We add possible hash to the current pathname
+ // e.g. /app/kibana#/management
+ currentPathname = stripQueryParams(`${currentPathname}${location.hash}`);
+
+ const activeNodes = findActiveNodes(
+ currentPathname,
+ this.projectNavigationNavTreeFlattened,
+ location
+ );
+
+ // Each time we call findActiveNodes() we create a new array of activeNodes. As this array is used
+ // in React in useCallback() and useMemo() dependencies arrays it triggers an infinite navigation
+ // tree registration loop. To avoid that we only notify the listeners when the activeNodes array
+ // has actually changed.
+ const requiresUpdate = !deepEqual(activeNodes, this.activeNodes$.value);
+
+ if (!requiresUpdate) return;
+
+ this.activeNodes$.next(activeNodes);
+ }
+
+ private onHistoryLocationChange(location: Location) {
+ this.setActiveProjectNavigationNodes(location);
+ }
+
+ public stop() {
+ this.stop$.next();
+ this.unlistenHistory?.();
+ }
}
diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.test.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.test.ts
new file mode 100644
index 0000000000000..940a350f47c06
--- /dev/null
+++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.test.ts
@@ -0,0 +1,401 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+import { createLocation } from 'history';
+import type { ChromeNavLink, ChromeProjectNavigationNode } from '@kbn/core-chrome-browser/src';
+import { flattenNav, findActiveNodes } from './utils';
+
+const getDeepLink = (id: string, path: string, title = ''): ChromeNavLink => ({
+ id,
+ url: `/foo/${path}`,
+ href: `http://mocked/kibana/foo/${path}`,
+ title,
+ baseUrl: '',
+});
+
+describe('flattenNav', () => {
+ test('should flatten the navigation tree', () => {
+ const navTree: ChromeProjectNavigationNode[] = [
+ {
+ id: 'root',
+ title: 'Root',
+ path: ['root'],
+ children: [
+ {
+ id: 'item1',
+ title: 'Item 1',
+ path: ['root', 'item1'],
+ },
+ {
+ id: 'item2',
+ title: 'Item 2',
+ path: ['root', 'item2'],
+ },
+ {
+ id: 'group1',
+ title: 'Group 1',
+ path: ['root', 'group1'],
+ children: [
+ {
+ id: 'item3',
+ title: 'Item 3',
+ path: ['root', 'group1', 'item3'],
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const expected = {
+ '[0]': {
+ id: 'root',
+ title: 'Root',
+ path: ['root'],
+ },
+ '[0][0]': {
+ id: 'item1',
+ title: 'Item 1',
+ path: ['root', 'item1'],
+ },
+ '[0][1]': {
+ id: 'item2',
+ title: 'Item 2',
+ path: ['root', 'item2'],
+ },
+ '[0][2]': {
+ id: 'group1',
+ title: 'Group 1',
+ path: ['root', 'group1'],
+ },
+ '[0][2][0]': {
+ id: 'item3',
+ title: 'Item 3',
+ path: ['root', 'group1', 'item3'],
+ },
+ };
+
+ expect(flattenNav(navTree)).toEqual(expected);
+ });
+});
+
+describe('findActiveNodes', () => {
+ test('should find the active node', () => {
+ const flattendNavTree: Record = {
+ '[0]': {
+ id: 'root',
+ title: 'Root',
+ path: ['root'],
+ },
+ '[0][0]': {
+ id: 'group1',
+ title: 'Group 1',
+ path: ['root', 'group1'],
+ },
+ '[0][0][0]': {
+ id: 'item1',
+ title: 'Item 1',
+ deepLink: getDeepLink('item1', 'item1'),
+ path: ['root', 'group1', 'item1'],
+ },
+ };
+
+ expect(findActiveNodes('/foo/item1', flattendNavTree)).toEqual([
+ [
+ {
+ id: 'root',
+ title: 'Root',
+ isActive: true,
+ path: ['root'],
+ },
+ {
+ id: 'group1',
+ title: 'Group 1',
+ isActive: true,
+ path: ['root', 'group1'],
+ },
+ {
+ id: 'item1',
+ title: 'Item 1',
+ isActive: true,
+ deepLink: getDeepLink('item1', 'item1'),
+ path: ['root', 'group1', 'item1'],
+ },
+ ],
+ ]);
+ });
+
+ test('should find multiple active node that match', () => {
+ const flattendNavTree: Record = {
+ '[0]': {
+ id: 'root',
+ title: 'Root',
+ path: ['root'],
+ },
+ '[0][0]': {
+ id: 'group1',
+ title: 'Group 1',
+ deepLink: getDeepLink('group1', 'group1'),
+ path: ['root', 'group1'],
+ },
+ '[0][0][0]': {
+ id: 'group1A',
+ title: 'Group 1A',
+ path: ['root', 'group1', 'group1A'],
+ },
+ '[0][0][0][0]': {
+ id: 'item1',
+ title: 'Item 1',
+ deepLink: getDeepLink('item1', 'item1'),
+ path: ['root', 'group1', 'group1A', 'item1'],
+ },
+ '[0][1]': {
+ id: 'group2',
+ title: 'Group 2',
+ path: ['root', 'group2'],
+ },
+ '[0][1][0]': {
+ id: 'item2',
+ title: 'Item 2',
+ deepLink: getDeepLink('item1', 'item1'), // Same link as above, should match both
+ path: ['root', 'group2', 'item2'],
+ },
+ };
+
+ // Should match both item1 and item2
+ expect(findActiveNodes('/foo/item1', flattendNavTree)).toEqual([
+ [
+ {
+ id: 'root',
+ title: 'Root',
+ isActive: true,
+ path: ['root'],
+ },
+ {
+ id: 'group1',
+ title: 'Group 1',
+ isActive: true,
+ deepLink: getDeepLink('group1', 'group1'),
+ path: ['root', 'group1'],
+ },
+ {
+ id: 'group1A',
+ title: 'Group 1A',
+ isActive: true,
+ path: ['root', 'group1', 'group1A'],
+ },
+ {
+ id: 'item1',
+ title: 'Item 1',
+ isActive: true,
+ deepLink: getDeepLink('item1', 'item1'),
+ path: ['root', 'group1', 'group1A', 'item1'],
+ },
+ ],
+ [
+ {
+ id: 'root',
+ title: 'Root',
+ isActive: true,
+ path: ['root'],
+ },
+ {
+ id: 'group2',
+ title: 'Group 2',
+ isActive: true,
+ path: ['root', 'group2'],
+ },
+ {
+ id: 'item2',
+ title: 'Item 2',
+ isActive: true,
+ deepLink: getDeepLink('item1', 'item1'),
+ path: ['root', 'group2', 'item2'],
+ },
+ ],
+ ]);
+ });
+
+ test('should find the active node that contains hash routes', () => {
+ const flattendNavTree: Record = {
+ '[0]': {
+ id: 'root',
+ title: 'Root',
+ path: ['root'],
+ },
+ '[0][1]': {
+ id: 'item1',
+ title: 'Item 1',
+ deepLink: getDeepLink('item1', `item1#/foo/bar`),
+ path: ['root', 'item1'],
+ },
+ };
+
+ expect(findActiveNodes(`/foo/item1#/foo/bar`, flattendNavTree)).toEqual([
+ [
+ {
+ id: 'root',
+ title: 'Root',
+ isActive: true,
+ path: ['root'],
+ },
+ {
+ id: 'item1',
+ title: 'Item 1',
+ isActive: true,
+ deepLink: getDeepLink('item1', `item1#/foo/bar`),
+ path: ['root', 'item1'],
+ },
+ ],
+ ]);
+ });
+
+ test('should match the longest matching node', () => {
+ const flattendNavTree: Record = {
+ '[0]': {
+ id: 'root',
+ title: 'Root',
+ path: ['root'],
+ },
+ '[0][1]': {
+ id: 'item1',
+ title: 'Item 1',
+ deepLink: getDeepLink('item1', `item1#/foo`),
+ path: ['root', 'item1'],
+ },
+ '[0][2]': {
+ id: 'item2',
+ title: 'Item 2',
+ deepLink: getDeepLink('item2', `item1#/foo/bar`), // Should match this one
+ path: ['root', 'item2'],
+ },
+ };
+
+ expect(findActiveNodes(`/foo/item1#/foo/bar`, flattendNavTree)).toEqual([
+ [
+ {
+ id: 'root',
+ title: 'Root',
+ isActive: true,
+ path: ['root'],
+ },
+ {
+ id: 'item2',
+ title: 'Item 2',
+ isActive: true,
+ deepLink: getDeepLink('item2', `item1#/foo/bar`),
+ path: ['root', 'item2'],
+ },
+ ],
+ ]);
+ });
+
+ test('should match all the routes under an app root', () => {
+ const flattendNavTree: Record = {
+ '[0]': {
+ id: 'root',
+ title: 'Root',
+ path: ['root'],
+ },
+ '[0][1]': {
+ id: 'item1',
+ title: 'Item 1',
+ deepLink: getDeepLink('item1', `appRoot`),
+ path: ['root', 'item1'],
+ },
+ };
+
+ const expected = [
+ [
+ {
+ id: 'root',
+ title: 'Root',
+ isActive: true,
+ path: ['root'],
+ },
+ {
+ id: 'item1',
+ title: 'Item 1',
+ isActive: true,
+ deepLink: getDeepLink('item1', `appRoot`),
+ path: ['root', 'item1'],
+ },
+ ],
+ ];
+
+ expect(findActiveNodes(`/foo/appRoot`, flattendNavTree)).toEqual(expected);
+ expect(findActiveNodes(`/foo/appRoot/foo`, flattendNavTree)).toEqual(expected);
+ expect(findActiveNodes(`/foo/appRoot/bar`, flattendNavTree)).toEqual(expected);
+ expect(findActiveNodes(`/foo/appRoot/bar?q=hello`, flattendNavTree)).toEqual(expected);
+ expect(findActiveNodes(`/foo/other`, flattendNavTree)).toEqual([]);
+ });
+
+ test('should use isActive() handler if passed', () => {
+ const flattendNavTree: Record = {
+ '[0]': {
+ id: 'root',
+ title: 'Root',
+ path: ['root'],
+ },
+ '[0][1]': {
+ id: 'item1',
+ title: 'Item 1',
+ path: ['root', 'item1'],
+ getIsActive: (loc) => loc.pathname.startsWith('/foo'), // Should match
+ },
+ '[0][2]': {
+ id: 'item2',
+ title: 'Item 2',
+ deepLink: getDeepLink('item2', 'item2'), // Should match
+ path: ['root', 'item2'],
+ },
+ };
+
+ let currentPathname = '/other/bad';
+
+ expect(
+ findActiveNodes(currentPathname, flattendNavTree, createLocation(currentPathname))
+ ).toEqual([]);
+
+ currentPathname = '/foo/item2/bar';
+
+ expect(
+ findActiveNodes(currentPathname, flattendNavTree, createLocation(currentPathname))
+ ).toEqual([
+ [
+ {
+ id: 'root',
+ title: 'Root',
+ isActive: true,
+ path: ['root'],
+ },
+ {
+ id: 'item1',
+ title: 'Item 1',
+ isActive: true,
+ getIsActive: expect.any(Function),
+ path: ['root', 'item1'],
+ },
+ ],
+ [
+ {
+ id: 'root',
+ title: 'Root',
+ isActive: true,
+ path: ['root'],
+ },
+ {
+ id: 'item2',
+ title: 'Item 2',
+ isActive: true,
+ deepLink: getDeepLink('item2', 'item2'),
+ path: ['root', 'item2'],
+ },
+ ],
+ ]);
+ });
+});
diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.ts b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.ts
new file mode 100644
index 0000000000000..e8a3750ade156
--- /dev/null
+++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/utils.ts
@@ -0,0 +1,148 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser/src';
+import type { Location } from 'history';
+
+const wrapIdx = (index: number): string => `[${index}]`;
+
+/**
+ * Flatten the navigation tree into a record of path => node
+ * for quicker access when detecting the active path
+ *
+ * @param navTree The navigation tree to flatten
+ * @param prefix Array of path prefix (used in the recursion)
+ * @returns The flattened navigation tree
+ */
+export const flattenNav = (
+ navTree: ChromeProjectNavigationNode[],
+ prefix: string[] = []
+): Record => {
+ return navTree.reduce>((acc, node, idx) => {
+ const updatedPrefix = [...prefix, `${wrapIdx(idx)}`];
+ const nodePath = () => updatedPrefix.join('');
+
+ if (node.children && node.children.length > 0) {
+ const { children, ...rest } = node;
+ return {
+ ...acc,
+ [nodePath()]: rest,
+ ...flattenNav(children, updatedPrefix),
+ };
+ }
+
+ acc[nodePath()] = node;
+
+ return acc;
+ }, {});
+};
+
+function trim(str: string) {
+ return (divider: string) => {
+ const position = str.indexOf(divider);
+
+ if (position !== -1) {
+ str = str.slice(0, position);
+ }
+
+ return str;
+ };
+}
+
+export const stripQueryParams = (url: string) => trim(url)('?');
+
+function serializeDeeplinkUrl(url?: string) {
+ if (!url) {
+ return undefined;
+ }
+ return stripQueryParams(url);
+}
+
+/**
+ * Extract the parent paths from a key
+ *
+ * @example
+ * IN: "[0][1][2][0]"
+ * OUT: ["[0]", "[0][1]", "[0][1][2]", "[0][1][2][0]"]
+ *
+ * @param key The key to extract parent paths from
+ * @returns An array of parent paths
+ */
+function extractParentPaths(key: string) {
+ // Split the string on every '][' to get an array of values without the brackets.
+ const arr = key.split('][');
+ // Add the brackets back in for the first and last elements, and all elements in between.
+ arr[0] = `${arr[0]}]`;
+ arr[arr.length - 1] = `[${arr[arr.length - 1]}`;
+ for (let i = 1; i < arr.length - 1; i++) {
+ arr[i] = `[${arr[i]}]`;
+ }
+
+ return arr.reduce((acc, currentValue, currentIndex) => {
+ acc.push(arr.slice(0, currentIndex + 1).join(''));
+ return acc;
+ }, []);
+}
+
+/**
+ * Find the active nodes in the navigation tree based on the current Location.pathname
+ * Note that the pathname cand match multiple navigation tree branches, each branch
+ * will be returned as an array of nodes.
+ *
+ * @param currentPathname The current Location.pathname
+ * @param navTree The flattened navigation tree
+ * @returns The active nodes
+ */
+export const findActiveNodes = (
+ currentPathname: string,
+ navTree: Record,
+ location?: Location
+): ChromeProjectNavigationNode[][] => {
+ const activeNodes: ChromeProjectNavigationNode[][] = [];
+ const matches: string[][] = [];
+
+ const activeNodeFromKey = (key: string): ChromeProjectNavigationNode => ({
+ ...navTree[key],
+ isActive: true,
+ });
+
+ Object.entries(navTree).forEach(([key, node]) => {
+ if (node.getIsActive && location) {
+ const isActive = node.getIsActive(location);
+ if (isActive) {
+ const keysWithParents = extractParentPaths(key);
+ activeNodes.push(keysWithParents.map(activeNodeFromKey));
+ }
+ return;
+ }
+
+ const nodePath = serializeDeeplinkUrl(node.deepLink?.url);
+
+ if (nodePath) {
+ const match = currentPathname.startsWith(nodePath);
+
+ if (match) {
+ const { length } = nodePath;
+ if (!matches[length]) {
+ matches[length] = [];
+ }
+ matches[length].push(key);
+ }
+ }
+ });
+
+ if (matches.length > 0) {
+ const longestMatch = matches[matches.length - 1];
+ longestMatch.forEach((key) => {
+ const keysWithParents = extractParentPaths(key);
+ activeNodes.push(keysWithParents.map(activeNodeFromKey));
+ });
+ }
+
+ return activeNodes;
+};
diff --git a/packages/core/chrome/core-chrome-browser-internal/src/types.ts b/packages/core/chrome/core-chrome-browser-internal/src/types.ts
index 427fb2c90d7e0..43ae77dee434a 100644
--- a/packages/core/chrome/core-chrome-browser-internal/src/types.ts
+++ b/packages/core/chrome/core-chrome-browser-internal/src/types.ts
@@ -13,6 +13,7 @@ import type {
ChromeProjectBreadcrumb,
ChromeSetProjectBreadcrumbsParams,
} from '@kbn/core-chrome-browser';
+import { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser/src';
import type { Observable } from 'rxjs';
/** @internal */
@@ -52,6 +53,11 @@ export interface InternalChromeStart extends ChromeStart {
*/
setNavigation(projectNavigation: ChromeProjectNavigation): void;
+ /**
+ * Returns an observable of the active nodes in the project navigation.
+ */
+ getActiveNavigationNodes$(): Observable;
+
/**
* Set custom project sidenav component to be used instead of the default project sidenav.
* @param component A getter function returning a CustomNavigationComponent.
diff --git a/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts b/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts
index a2e511a68b90f..76192aff162c7 100644
--- a/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts
+++ b/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts
@@ -68,6 +68,7 @@ const createStartContractMock = () => {
setNavigation: jest.fn(),
setSideNavComponent: jest.fn(),
setBreadcrumbs: jest.fn(),
+ getActiveNavigationNodes$: jest.fn(),
},
};
startContract.navLinks.getAll.mockReturnValue([]);
diff --git a/packages/core/chrome/core-chrome-browser/src/project_navigation.ts b/packages/core/chrome/core-chrome-browser/src/project_navigation.ts
index 00bdc67629b74..baae479b1f005 100644
--- a/packages/core/chrome/core-chrome-browser/src/project_navigation.ts
+++ b/packages/core/chrome/core-chrome-browser/src/project_navigation.ts
@@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import type { ComponentType } from 'react';
+import type { Location } from 'history';
import type { AppId as DevToolsApp, DeepLinkId as DevToolsLink } from '@kbn/deeplinks-devtools';
import type {
AppId as AnalyticsApp,
@@ -58,11 +59,17 @@ export interface ChromeProjectNavigationNode {
/** Optional children of the navigation node */
children?: ChromeProjectNavigationNode[];
/**
- * Temporarilly we allow href to be passed.
- * Once all the deeplinks will be exposed in packages we will not allow href anymore
- * and force deeplink id to be passed
+ * href for absolute links only. Internal links should use "link".
*/
href?: string;
+ /**
+ * Flag to indicate if the node is currently active.
+ */
+ isActive?: boolean;
+ /**
+ * Optional function to get the active state. This function is called whenever the location changes.
+ */
+ getIsActive?: (location: Location) => boolean;
}
/** @public */
@@ -74,10 +81,8 @@ export interface ChromeProjectNavigation {
}
/** @public */
-// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SideNavCompProps {
- // TODO: provide the Chrome state to the component through props
- // e.g. "navTree", "activeRoute", "recentItems"...
+ activeNodes: ChromeProjectNavigationNode[][];
}
/** @public */
@@ -120,6 +125,10 @@ export interface NodeDefinition<
* Use href for absolute links only. Internal links should use "link".
*/
href?: string;
+ /**
+ * Optional function to get the active state. This function is called whenever the location changes.
+ */
+ getIsActive?: (location: Location) => boolean;
}
/**
diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml
index fe708fe8c95c5..0ecc0c930f55b 100644
--- a/packages/kbn-optimizer/limits.yml
+++ b/packages/kbn-optimizer/limits.yml
@@ -118,8 +118,8 @@ pageLoadAssetSize:
security: 65433
securitySolution: 66738
serverless: 16573
- serverlessObservability: 30000
- serverlessSearch: 45934
+ serverlessObservability: 68747
+ serverlessSearch: 71995
serverlessSecurity: 70441
sessionView: 77750
share: 71239
diff --git a/packages/shared-ux/chrome/navigation/index.ts b/packages/shared-ux/chrome/navigation/index.ts
index 1bbb6c33a1482..c6d56e6011fe9 100644
--- a/packages/shared-ux/chrome/navigation/index.ts
+++ b/packages/shared-ux/chrome/navigation/index.ts
@@ -19,10 +19,4 @@ export type {
RootNavigationItemDefinition,
} from './src/ui';
-export type {
- ChromeNavigation,
- ChromeNavigationNode,
- ChromeNavigationNodeViewModel,
- ChromeNavigationViewModel,
- NavigationServices,
-} from './types';
+export type { NavigationServices } from './types';
diff --git a/packages/shared-ux/chrome/navigation/mocks/index.ts b/packages/shared-ux/chrome/navigation/mocks/index.ts
index 81c4cf165ee91..d75c847f74ecd 100644
--- a/packages/shared-ux/chrome/navigation/mocks/index.ts
+++ b/packages/shared-ux/chrome/navigation/mocks/index.ts
@@ -6,17 +6,7 @@
* Side Public License, v 1.
*/
-export {
- getServicesMock as getNavigationServicesMock,
- getSolutionPropertiesMock,
-} from './src/jest';
+export { getServicesMock as getNavigationServicesMock } from './src/jest';
export { StorybookMock as NavigationStorybookMock } from './src/storybook';
export type { Params as NavigationStorybookParams } from './src/storybook';
-export {
- defaultNavigationTree,
- defaultAnalyticsNavGroup,
- defaultDevtoolsNavGroup,
- defaultManagementNavGroup,
- defaultMlNavGroup,
-} from './src/default_navigation.test.helpers';
export { navLinksMock } from './src/navlinks';
diff --git a/packages/shared-ux/chrome/navigation/mocks/src/default_navigation.test.helpers.ts b/packages/shared-ux/chrome/navigation/mocks/src/default_navigation.test.helpers.ts
deleted file mode 100644
index f850b79a588c7..0000000000000
--- a/packages/shared-ux/chrome/navigation/mocks/src/default_navigation.test.helpers.ts
+++ /dev/null
@@ -1,660 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0 and the Server Side Public License, v 1; you may not use this file except
- * in compliance with, at your election, the Elastic License 2.0 or the Server
- * Side Public License, v 1.
- */
-
-/**
- * This is the default navigation tree that is added to a project
- * when only a project navigation tree is provided.
- * NOTE: This will have to be updated once we add the deep link ids as each of the node
- * will contain the deep link information.
- */
-
-export const defaultAnalyticsNavGroup = {
- id: 'rootNav:analytics',
- title: 'Data exploration',
- icon: 'stats',
- path: ['rootNav:analytics'],
- children: [
- {
- id: 'root',
- path: ['rootNav:analytics', 'root'],
- title: '',
- children: [
- {
- id: 'discover',
- path: ['rootNav:analytics', 'root', 'discover'],
- title: 'Deeplink discover',
- deepLink: {
- id: 'discover',
- title: 'Deeplink discover',
- href: 'http://mocked/discover',
- baseUrl: '/mocked',
- url: '/mocked/discover',
- },
- },
- {
- id: 'dashboards',
- path: ['rootNav:analytics', 'root', 'dashboards'],
- title: 'Deeplink dashboards',
- deepLink: {
- id: 'dashboards',
- title: 'Deeplink dashboards',
- href: 'http://mocked/dashboards',
- baseUrl: '/mocked',
- url: '/mocked/dashboards',
- },
- },
- {
- id: 'visualize',
- path: ['rootNav:analytics', 'root', 'visualize'],
- title: 'Deeplink visualize',
- deepLink: {
- id: 'visualize',
- title: 'Deeplink visualize',
- href: 'http://mocked/visualize',
- baseUrl: '/mocked',
- url: '/mocked/visualize',
- },
- },
- ],
- },
- ],
-};
-
-export const defaultMlNavGroup = {
- id: 'rootNav:ml',
- title: 'Machine learning',
- icon: 'indexMapping',
- path: ['rootNav:ml'],
- children: [
- {
- title: '',
- id: 'root',
- path: ['rootNav:ml', 'root'],
- children: [
- {
- id: 'ml:overview',
- path: ['rootNav:ml', 'root', 'ml:overview'],
- title: 'Deeplink ml:overview',
- deepLink: {
- id: 'ml:overview',
- title: 'Deeplink ml:overview',
- href: 'http://mocked/ml:overview',
- baseUrl: '/mocked',
- url: '/mocked/ml:overview',
- },
- },
- {
- id: 'ml:notifications',
- path: ['rootNav:ml', 'root', 'ml:notifications'],
- title: 'Deeplink ml:notifications',
- deepLink: {
- id: 'ml:notifications',
- title: 'Deeplink ml:notifications',
- href: 'http://mocked/ml:notifications',
- baseUrl: '/mocked',
- url: '/mocked/ml:notifications',
- },
- },
- ],
- },
- {
- title: 'Anomaly Detection',
- id: 'anomaly_detection',
- path: ['rootNav:ml', 'anomaly_detection'],
- children: [
- {
- title: 'Jobs',
- id: 'ml:anomalyDetection',
- path: ['rootNav:ml', 'anomaly_detection', 'ml:anomalyDetection'],
- deepLink: {
- id: 'ml:anomalyDetection',
- title: 'Deeplink ml:anomalyDetection',
- href: 'http://mocked/ml:anomalyDetection',
- baseUrl: '/mocked',
- url: '/mocked/ml:anomalyDetection',
- },
- },
- {
- id: 'ml:anomalyExplorer',
- path: ['rootNav:ml', 'anomaly_detection', 'ml:anomalyExplorer'],
- title: 'Deeplink ml:anomalyExplorer',
- deepLink: {
- id: 'ml:anomalyExplorer',
- title: 'Deeplink ml:anomalyExplorer',
- href: 'http://mocked/ml:anomalyExplorer',
- baseUrl: '/mocked',
- url: '/mocked/ml:anomalyExplorer',
- },
- },
- {
- id: 'ml:singleMetricViewer',
- path: ['rootNav:ml', 'anomaly_detection', 'ml:singleMetricViewer'],
- title: 'Deeplink ml:singleMetricViewer',
- deepLink: {
- id: 'ml:singleMetricViewer',
- title: 'Deeplink ml:singleMetricViewer',
- href: 'http://mocked/ml:singleMetricViewer',
- baseUrl: '/mocked',
- url: '/mocked/ml:singleMetricViewer',
- },
- },
- {
- id: 'ml:settings',
- path: ['rootNav:ml', 'anomaly_detection', 'ml:settings'],
- title: 'Deeplink ml:settings',
- deepLink: {
- id: 'ml:settings',
- title: 'Deeplink ml:settings',
- href: 'http://mocked/ml:settings',
- baseUrl: '/mocked',
- url: '/mocked/ml:settings',
- },
- },
- ],
- },
- {
- id: 'data_frame_analytics',
- title: 'Data frame analytics',
- path: ['rootNav:ml', 'data_frame_analytics'],
- children: [
- {
- title: 'Jobs',
- id: 'ml:dataFrameAnalytics',
- path: ['rootNav:ml', 'data_frame_analytics', 'ml:dataFrameAnalytics'],
- deepLink: {
- id: 'ml:dataFrameAnalytics',
- title: 'Deeplink ml:dataFrameAnalytics',
- href: 'http://mocked/ml:dataFrameAnalytics',
- baseUrl: '/mocked',
- url: '/mocked/ml:dataFrameAnalytics',
- },
- },
- {
- id: 'ml:resultExplorer',
- path: ['rootNav:ml', 'data_frame_analytics', 'ml:resultExplorer'],
- title: 'Deeplink ml:resultExplorer',
- deepLink: {
- id: 'ml:resultExplorer',
- title: 'Deeplink ml:resultExplorer',
- href: 'http://mocked/ml:resultExplorer',
- baseUrl: '/mocked',
- url: '/mocked/ml:resultExplorer',
- },
- },
- {
- id: 'ml:analyticsMap',
- path: ['rootNav:ml', 'data_frame_analytics', 'ml:analyticsMap'],
- title: 'Deeplink ml:analyticsMap',
- deepLink: {
- id: 'ml:analyticsMap',
- title: 'Deeplink ml:analyticsMap',
- href: 'http://mocked/ml:analyticsMap',
- baseUrl: '/mocked',
- url: '/mocked/ml:analyticsMap',
- },
- },
- ],
- },
- {
- id: 'model_management',
- title: 'Model management',
- path: ['rootNav:ml', 'model_management'],
- children: [
- {
- id: 'ml:nodesOverview',
- path: ['rootNav:ml', 'model_management', 'ml:nodesOverview'],
- title: 'Deeplink ml:nodesOverview',
- deepLink: {
- id: 'ml:nodesOverview',
- title: 'Deeplink ml:nodesOverview',
- href: 'http://mocked/ml:nodesOverview',
- baseUrl: '/mocked',
- url: '/mocked/ml:nodesOverview',
- },
- },
- {
- id: 'ml:nodes',
- path: ['rootNav:ml', 'model_management', 'ml:nodes'],
- title: 'Deeplink ml:nodes',
- deepLink: {
- id: 'ml:nodes',
- title: 'Deeplink ml:nodes',
- href: 'http://mocked/ml:nodes',
- baseUrl: '/mocked',
- url: '/mocked/ml:nodes',
- },
- },
- ],
- },
- {
- id: 'data_visualizer',
- title: 'Data visualizer',
- path: ['rootNav:ml', 'data_visualizer'],
- children: [
- {
- title: 'File',
- id: 'ml:fileUpload',
- path: ['rootNav:ml', 'data_visualizer', 'ml:fileUpload'],
- deepLink: {
- id: 'ml:fileUpload',
- title: 'Deeplink ml:fileUpload',
- href: 'http://mocked/ml:fileUpload',
- baseUrl: '/mocked',
- url: '/mocked/ml:fileUpload',
- },
- },
- {
- title: 'Data view',
- id: 'ml:indexDataVisualizer',
- path: ['rootNav:ml', 'data_visualizer', 'ml:indexDataVisualizer'],
- deepLink: {
- id: 'ml:indexDataVisualizer',
- title: 'Deeplink ml:indexDataVisualizer',
- href: 'http://mocked/ml:indexDataVisualizer',
- baseUrl: '/mocked',
- url: '/mocked/ml:indexDataVisualizer',
- },
- },
- ],
- },
- {
- id: 'aiops_labs',
- title: 'AIOps labs',
- path: ['rootNav:ml', 'aiops_labs'],
- children: [
- {
- title: 'Explain log rate spikes',
- id: 'ml:explainLogRateSpikes',
- path: ['rootNav:ml', 'aiops_labs', 'ml:explainLogRateSpikes'],
- deepLink: {
- id: 'ml:explainLogRateSpikes',
- title: 'Deeplink ml:explainLogRateSpikes',
- href: 'http://mocked/ml:explainLogRateSpikes',
- baseUrl: '/mocked',
- url: '/mocked/ml:explainLogRateSpikes',
- },
- },
- {
- id: 'ml:logPatternAnalysis',
- path: ['rootNav:ml', 'aiops_labs', 'ml:logPatternAnalysis'],
- title: 'Deeplink ml:logPatternAnalysis',
- deepLink: {
- id: 'ml:logPatternAnalysis',
- title: 'Deeplink ml:logPatternAnalysis',
- href: 'http://mocked/ml:logPatternAnalysis',
- baseUrl: '/mocked',
- url: '/mocked/ml:logPatternAnalysis',
- },
- },
- ],
- },
- ],
-};
-
-export const defaultDevtoolsNavGroup = {
- title: 'Developer tools',
- id: 'rootNav:devtools',
- icon: 'editorCodeBlock',
- path: ['rootNav:devtools'],
- children: [
- {
- id: 'root',
- path: ['rootNav:devtools', 'root'],
- title: '',
- children: [
- {
- id: 'dev_tools:console',
- path: ['rootNav:devtools', 'root', 'dev_tools:console'],
- title: 'Deeplink dev_tools:console',
- deepLink: {
- id: 'dev_tools:console',
- title: 'Deeplink dev_tools:console',
- href: 'http://mocked/dev_tools:console',
- baseUrl: '/mocked',
- url: '/mocked/dev_tools:console',
- },
- },
- {
- id: 'dev_tools:searchprofiler',
- path: ['rootNav:devtools', 'root', 'dev_tools:searchprofiler'],
- title: 'Deeplink dev_tools:searchprofiler',
- deepLink: {
- id: 'dev_tools:searchprofiler',
- title: 'Deeplink dev_tools:searchprofiler',
- href: 'http://mocked/dev_tools:searchprofiler',
- baseUrl: '/mocked',
- url: '/mocked/dev_tools:searchprofiler',
- },
- },
- {
- id: 'dev_tools:grokdebugger',
- path: ['rootNav:devtools', 'root', 'dev_tools:grokdebugger'],
- title: 'Deeplink dev_tools:grokdebugger',
- deepLink: {
- id: 'dev_tools:grokdebugger',
- title: 'Deeplink dev_tools:grokdebugger',
- href: 'http://mocked/dev_tools:grokdebugger',
- baseUrl: '/mocked',
- url: '/mocked/dev_tools:grokdebugger',
- },
- },
- {
- id: 'dev_tools:painless_lab',
- path: ['rootNav:devtools', 'root', 'dev_tools:painless_lab'],
- title: 'Deeplink dev_tools:painless_lab',
- deepLink: {
- id: 'dev_tools:painless_lab',
- title: 'Deeplink dev_tools:painless_lab',
- href: 'http://mocked/dev_tools:painless_lab',
- baseUrl: '/mocked',
- url: '/mocked/dev_tools:painless_lab',
- },
- },
- ],
- },
- ],
-};
-
-export const defaultManagementNavGroup = {
- id: 'rootNav:management',
- title: 'Management',
- icon: 'gear',
- path: ['rootNav:management'],
- children: [
- {
- id: 'root',
- title: '',
- path: ['rootNav:management', 'root'],
- children: [
- {
- id: 'monitoring',
- path: ['rootNav:management', 'root', 'monitoring'],
- title: 'Deeplink monitoring',
- deepLink: {
- id: 'monitoring',
- title: 'Deeplink monitoring',
- href: 'http://mocked/monitoring',
- baseUrl: '/mocked',
- url: '/mocked/monitoring',
- },
- },
- ],
- },
- {
- id: 'integration_management',
- title: 'Integration management',
- path: ['rootNav:management', 'integration_management'],
- children: [
- {
- id: 'integrations',
- path: ['rootNav:management', 'integration_management', 'integrations'],
- title: 'Deeplink integrations',
- deepLink: {
- id: 'integrations',
- title: 'Deeplink integrations',
- href: 'http://mocked/integrations',
- baseUrl: '/mocked',
- url: '/mocked/integrations',
- },
- },
- {
- id: 'fleet',
- path: ['rootNav:management', 'integration_management', 'fleet'],
- title: 'Deeplink fleet',
- deepLink: {
- id: 'fleet',
- title: 'Deeplink fleet',
- href: 'http://mocked/fleet',
- baseUrl: '/mocked',
- url: '/mocked/fleet',
- },
- },
- {
- id: 'osquery',
- path: ['rootNav:management', 'integration_management', 'osquery'],
- title: 'Deeplink osquery',
- deepLink: {
- id: 'osquery',
- title: 'Deeplink osquery',
- href: 'http://mocked/osquery',
- baseUrl: '/mocked',
- url: '/mocked/osquery',
- },
- },
- ],
- },
- {
- id: 'stack_management',
- title: 'Stack management',
- path: ['rootNav:management', 'stack_management'],
- children: [
- {
- id: 'ingest',
- title: 'Ingest',
- path: ['rootNav:management', 'stack_management', 'ingest'],
- children: [
- {
- id: 'management:ingest_pipelines',
- path: [
- 'rootNav:management',
- 'stack_management',
- 'ingest',
- 'management:ingest_pipelines',
- ],
- title: 'Deeplink management:ingest_pipelines',
- deepLink: {
- id: 'management:ingest_pipelines',
- title: 'Deeplink management:ingest_pipelines',
- href: 'http://mocked/management:ingest_pipelines',
- baseUrl: '/mocked',
- url: '/mocked/management:ingest_pipelines',
- },
- },
- {
- id: 'management:pipelines',
- path: ['rootNav:management', 'stack_management', 'ingest', 'management:pipelines'],
- title: 'Deeplink management:pipelines',
- deepLink: {
- id: 'management:pipelines',
- title: 'Deeplink management:pipelines',
- href: 'http://mocked/management:pipelines',
- baseUrl: '/mocked',
- url: '/mocked/management:pipelines',
- },
- },
- ],
- },
- {
- id: 'data',
- title: 'Data',
- path: ['rootNav:management', 'stack_management', 'data'],
- children: [
- {
- id: 'management:index_management',
- path: [
- 'rootNav:management',
- 'stack_management',
- 'data',
- 'management:index_management',
- ],
- title: 'Deeplink management:index_management',
- deepLink: {
- id: 'management:index_management',
- title: 'Deeplink management:index_management',
- href: 'http://mocked/management:index_management',
- baseUrl: '/mocked',
- url: '/mocked/management:index_management',
- },
- },
- {
- id: 'management:transform',
- path: ['rootNav:management', 'stack_management', 'data', 'management:transform'],
- title: 'Deeplink management:transform',
- deepLink: {
- id: 'management:transform',
- title: 'Deeplink management:transform',
- href: 'http://mocked/management:transform',
- baseUrl: '/mocked',
- url: '/mocked/management:transform',
- },
- },
- ],
- },
- {
- id: 'alerts_and_insights',
- title: 'Alerts and insights',
- path: ['rootNav:management', 'stack_management', 'alerts_and_insights'],
- children: [
- {
- id: 'management:triggersActions',
- path: [
- 'rootNav:management',
- 'stack_management',
- 'alerts_and_insights',
- 'management:triggersActions',
- ],
- title: 'Deeplink management:triggersActions',
- deepLink: {
- id: 'management:triggersActions',
- title: 'Deeplink management:triggersActions',
- href: 'http://mocked/management:triggersActions',
- baseUrl: '/mocked',
- url: '/mocked/management:triggersActions',
- },
- },
- {
- id: 'management:cases',
- path: [
- 'rootNav:management',
- 'stack_management',
- 'alerts_and_insights',
- 'management:cases',
- ],
- title: 'Deeplink management:cases',
- deepLink: {
- id: 'management:cases',
- title: 'Deeplink management:cases',
- href: 'http://mocked/management:cases',
- baseUrl: '/mocked',
- url: '/mocked/management:cases',
- },
- },
- {
- id: 'management:triggersActionsConnectors',
- path: [
- 'rootNav:management',
- 'stack_management',
- 'alerts_and_insights',
- 'management:triggersActionsConnectors',
- ],
- title: 'Deeplink management:triggersActionsConnectors',
- deepLink: {
- id: 'management:triggersActionsConnectors',
- title: 'Deeplink management:triggersActionsConnectors',
- href: 'http://mocked/management:triggersActionsConnectors',
- baseUrl: '/mocked',
- url: '/mocked/management:triggersActionsConnectors',
- },
- },
- {
- id: 'management:jobsListLink',
- path: [
- 'rootNav:management',
- 'stack_management',
- 'alerts_and_insights',
- 'management:jobsListLink',
- ],
- title: 'Deeplink management:jobsListLink',
- deepLink: {
- id: 'management:jobsListLink',
- title: 'Deeplink management:jobsListLink',
- href: 'http://mocked/management:jobsListLink',
- baseUrl: '/mocked',
- url: '/mocked/management:jobsListLink',
- },
- },
- ],
- },
- {
- id: 'kibana',
- title: 'Kibana',
- path: ['rootNav:management', 'stack_management', 'kibana'],
- children: [
- {
- id: 'management:dataViews',
- path: ['rootNav:management', 'stack_management', 'kibana', 'management:dataViews'],
- title: 'Deeplink management:dataViews',
- deepLink: {
- id: 'management:dataViews',
- title: 'Deeplink management:dataViews',
- href: 'http://mocked/management:dataViews',
- baseUrl: '/mocked',
- url: '/mocked/management:dataViews',
- },
- },
- {
- id: 'management:objects',
- path: ['rootNav:management', 'stack_management', 'kibana', 'management:objects'],
- title: 'Deeplink management:objects',
- deepLink: {
- id: 'management:objects',
- title: 'Deeplink management:objects',
- href: 'http://mocked/management:objects',
- baseUrl: '/mocked',
- url: '/mocked/management:objects',
- },
- },
- {
- id: 'management:tags',
- path: ['rootNav:management', 'stack_management', 'kibana', 'management:tags'],
- title: 'Deeplink management:tags',
- deepLink: {
- id: 'management:tags',
- title: 'Deeplink management:tags',
- href: 'http://mocked/management:tags',
- baseUrl: '/mocked',
- url: '/mocked/management:tags',
- },
- },
- {
- id: 'management:spaces',
- path: ['rootNav:management', 'stack_management', 'kibana', 'management:spaces'],
- title: 'Deeplink management:spaces',
- deepLink: {
- id: 'management:spaces',
- title: 'Deeplink management:spaces',
- href: 'http://mocked/management:spaces',
- baseUrl: '/mocked',
- url: '/mocked/management:spaces',
- },
- },
- {
- id: 'management:settings',
- path: ['rootNav:management', 'stack_management', 'kibana', 'management:settings'],
- title: 'Deeplink management:settings',
- deepLink: {
- id: 'management:settings',
- title: 'Deeplink management:settings',
- href: 'http://mocked/management:settings',
- baseUrl: '/mocked',
- url: '/mocked/management:settings',
- },
- },
- ],
- },
- ],
- },
- ],
-};
-
-export const defaultNavigationTree = [
- defaultAnalyticsNavGroup,
- defaultMlNavGroup,
- defaultDevtoolsNavGroup,
- defaultManagementNavGroup,
-];
diff --git a/packages/shared-ux/chrome/navigation/mocks/src/jest.ts b/packages/shared-ux/chrome/navigation/mocks/src/jest.ts
index b41d324ab5b46..4fd298811b5d8 100644
--- a/packages/shared-ux/chrome/navigation/mocks/src/jest.ts
+++ b/packages/shared-ux/chrome/navigation/mocks/src/jest.ts
@@ -6,11 +6,13 @@
* Side Public License, v 1.
*/
-import { ChromeNavLink } from '@kbn/core-chrome-browser';
-import { BehaviorSubject } from 'rxjs';
-import { NavigationServices, ChromeNavigationNodeViewModel } from '../../types';
+import { ChromeNavLink, ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
+import { BehaviorSubject, of } from 'rxjs';
+import { NavigationServices } from '../../types';
import { navLinksMock } from './navlinks';
+const activeNodes: ChromeProjectNavigationNode[][] = [];
+
export const getServicesMock = ({
navLinks = navLinksMock,
}: { navLinks?: ChromeNavLink[] } = {}): NavigationServices => {
@@ -26,55 +28,6 @@ export const getServicesMock = ({
navIsOpen: true,
navigateToUrl,
onProjectNavigationChange: jest.fn(),
+ activeNodes$: of(activeNodes),
};
};
-
-export const getSolutionPropertiesMock = (): ChromeNavigationNodeViewModel => ({
- id: 'example_project',
- icon: 'logoObservability',
- title: 'Example project',
- items: [
- {
- id: 'root',
- title: '',
- items: [
- {
- id: 'get_started',
- title: 'Get started',
- href: '/app/example_project/get_started',
- },
- {
- id: 'alerts',
- title: 'Alerts',
- href: '/app/example_project/alerts',
- },
- {
- id: 'cases',
- title: 'Cases',
- href: '/app/example_project/cases',
- },
- ],
- },
- {
- id: 'example_settings',
- title: 'Settings',
- items: [
- {
- id: 'logs',
- title: 'Logs',
- href: '/app/management/logs',
- },
- {
- id: 'signals',
- title: 'Signals',
- href: '/app/management/signals',
- },
- {
- id: 'tracing',
- title: 'Tracing',
- href: '/app/management/tracing',
- },
- ],
- },
- ],
-});
diff --git a/packages/shared-ux/chrome/navigation/mocks/src/storybook.ts b/packages/shared-ux/chrome/navigation/mocks/src/storybook.ts
index 28be60ca54538..4514ffe30fcd4 100644
--- a/packages/shared-ux/chrome/navigation/mocks/src/storybook.ts
+++ b/packages/shared-ux/chrome/navigation/mocks/src/storybook.ts
@@ -9,25 +9,15 @@
import { AbstractStorybookMock } from '@kbn/shared-ux-storybook-mock';
import { action } from '@storybook/addon-actions';
import { BehaviorSubject } from 'rxjs';
-import { ChromeNavigationViewModel, NavigationServices } from '../../types';
+import { NavigationServices } from '../../types';
-type Arguments = ChromeNavigationViewModel & NavigationServices;
+type Arguments = NavigationServices;
export type Params = Pick<
Arguments,
- | 'activeNavItemId'
- | 'navIsOpen'
- | 'navigationTree'
- | 'platformConfig'
- | 'recentlyAccessed$'
- | 'navLinks$'
- | 'recentlyAccessedFilter'
- | 'onProjectNavigationChange'
+ 'navIsOpen' | 'recentlyAccessed$' | 'navLinks$' | 'onProjectNavigationChange'
>;
-export class StorybookMock extends AbstractStorybookMock<
- ChromeNavigationViewModel,
- NavigationServices
-> {
+export class StorybookMock extends AbstractStorybookMock<{}, NavigationServices> {
propArguments = {};
serviceArguments = {
@@ -53,13 +43,11 @@ export class StorybookMock extends AbstractStorybookMock<
recentlyAccessed$: params.recentlyAccessed$ ?? new BehaviorSubject([]),
navLinks$: params.navLinks$ ?? new BehaviorSubject([]),
onProjectNavigationChange: params.onProjectNavigationChange ?? (() => undefined),
+ activeNodes$: new BehaviorSubject([]),
};
}
- getProps(params: Params): ChromeNavigationViewModel {
- return {
- ...params,
- recentlyAccessedFilter: params.recentlyAccessedFilter,
- };
+ getProps(params: Params) {
+ return params;
}
}
diff --git a/packages/shared-ux/chrome/navigation/src/services.tsx b/packages/shared-ux/chrome/navigation/src/services.tsx
index 655d37fc63c2c..d9b7f3804db1b 100644
--- a/packages/shared-ux/chrome/navigation/src/services.tsx
+++ b/packages/shared-ux/chrome/navigation/src/services.tsx
@@ -37,6 +37,7 @@ export const NavigationKibanaProvider: FC = ({
navigateToUrl,
navIsOpen: true,
onProjectNavigationChange: serverless.setNavigation,
+ activeNodes$: serverless.getActiveNavigationNodes$(),
};
return (
diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx
index 9860b8b231289..1c9655eaf27ce 100644
--- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx
+++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx
@@ -6,23 +6,33 @@
* Side Public License, v 1.
*/
-import type { ChromeNavLink } from '@kbn/core-chrome-browser';
+import type {
+ ChromeNavLink,
+ ChromeProjectNavigation,
+ ChromeProjectNavigationNode,
+} from '@kbn/core-chrome-browser';
import { render } from '@testing-library/react';
import React from 'react';
-import { of, type Observable } from 'rxjs';
-import {
- defaultAnalyticsNavGroup,
- defaultDevtoolsNavGroup,
- defaultManagementNavGroup,
- defaultMlNavGroup,
-} from '../../../mocks/src/default_navigation.test.helpers';
+import { act } from 'react-dom/test-utils';
+import { BehaviorSubject, of, type Observable } from 'rxjs';
import { getServicesMock } from '../../../mocks/src/jest';
import { NavigationProvider } from '../../services';
import { Navigation } from './navigation';
+// There is a 100ms debounce to update project navigation tree
+const SET_NAVIGATION_DELAY = 100;
+
describe('', () => {
const services = getServicesMock();
+ beforeAll(() => {
+ jest.useFakeTimers();
+ });
+
+ afterAll(() => {
+ jest.useRealTimers();
+ });
+
describe('builds the navigation tree', () => {
test('render reference UI and build the navigation tree', async () => {
const onProjectNavigationChange = jest.fn();
@@ -44,6 +54,10 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(await findByTestId('nav-item-group1.item1')).toBeVisible();
expect(await findByTestId('nav-item-group1.item2')).toBeVisible();
expect(await findByTestId('nav-item-group1.group1A')).toBeVisible();
@@ -60,55 +74,60 @@ describe('', () => {
onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1];
const [navTree] = lastCall;
- expect(navTree).toEqual({
- navigationTree: [
- {
- id: 'group1',
- path: ['group1'],
- title: '',
- children: [
- {
- id: 'item1',
- title: 'Item 1',
- href: 'https://foo',
- path: ['group1', 'item1'],
- },
- {
- id: 'item2',
- title: 'Item 2',
- href: 'https://foo',
- path: ['group1', 'item2'],
- },
- {
- id: 'group1A',
- title: 'Group1A',
- path: ['group1', 'group1A'],
- children: [
- {
- id: 'item1',
- href: 'https://foo',
- title: 'Group 1A Item 1',
- path: ['group1', 'group1A', 'item1'],
- },
- {
- id: 'group1A_1',
- title: 'Group1A_1',
- path: ['group1', 'group1A', 'group1A_1'],
- children: [
- {
- id: 'item1',
- title: 'Group 1A_1 Item 1',
- href: 'https://foo',
- path: ['group1', 'group1A', 'group1A_1', 'item1'],
- },
- ],
- },
- ],
- },
- ],
- },
- ],
- });
+ expect(navTree.navigationTree).toEqual([
+ {
+ id: 'group1',
+ path: ['group1'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'item1',
+ title: 'Item 1',
+ href: 'https://foo',
+ isActive: false,
+ path: ['group1', 'item1'],
+ },
+ {
+ id: 'item2',
+ title: 'Item 2',
+ href: 'https://foo',
+ isActive: false,
+ path: ['group1', 'item2'],
+ },
+ {
+ id: 'group1A',
+ title: 'Group1A',
+ isActive: false,
+ path: ['group1', 'group1A'],
+ children: [
+ {
+ id: 'item1',
+ href: 'https://foo',
+ title: 'Group 1A Item 1',
+ isActive: false,
+ path: ['group1', 'group1A', 'item1'],
+ },
+ {
+ id: 'group1A_1',
+ title: 'Group1A_1',
+ isActive: false,
+ path: ['group1', 'group1A', 'group1A_1'],
+ children: [
+ {
+ id: 'item1',
+ title: 'Group 1A_1 Item 1',
+ isActive: false,
+ href: 'https://foo',
+ path: ['group1', 'group1A', 'group1A_1', 'item1'],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ]);
});
test('should read the title from props, children or deeplink', async () => {
@@ -144,63 +163,71 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(onProjectNavigationChange).toHaveBeenCalled();
const lastCall =
onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1];
const [navTree] = lastCall;
- expect(navTree).toEqual({
- navigationTree: [
- {
- id: 'root',
- path: ['root'],
- title: '',
- children: [
- {
- id: 'group1',
- path: ['root', 'group1'],
- title: '',
- children: [
- {
+ expect(navTree.navigationTree).toEqual([
+ {
+ id: 'root',
+ path: ['root'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'group1',
+ path: ['root', 'group1'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'item1',
+ path: ['root', 'group1', 'item1'],
+ title: 'Title from deeplink',
+ isActive: false,
+ deepLink: {
id: 'item1',
- path: ['root', 'group1', 'item1'],
title: 'Title from deeplink',
- deepLink: {
- id: 'item1',
- title: 'Title from deeplink',
- baseUrl: '',
- url: '',
- href: '',
- },
- },
- {
- id: 'item2',
- title: 'Overwrite deeplink title',
- path: ['root', 'group1', 'item2'],
- deepLink: {
- id: 'item1',
- title: 'Title from deeplink',
- baseUrl: '',
- url: '',
- href: '',
- },
- },
- {
- id: 'item3',
- title: 'Title in props',
- path: ['root', 'group1', 'item3'],
+ baseUrl: '',
+ url: '',
+ href: '',
},
- {
- id: 'item4',
- path: ['root', 'group1', 'item4'],
- title: 'Title in children',
+ },
+ {
+ id: 'item2',
+ title: 'Overwrite deeplink title',
+ path: ['root', 'group1', 'item2'],
+ isActive: false,
+ deepLink: {
+ id: 'item1',
+ title: 'Title from deeplink',
+ baseUrl: '',
+ url: '',
+ href: '',
},
- ],
- },
- ],
- },
- ],
- });
+ },
+ {
+ id: 'item3',
+ title: 'Title in props',
+ isActive: false,
+ path: ['root', 'group1', 'item3'],
+ },
+ {
+ id: 'item4',
+ path: ['root', 'group1', 'item4'],
+ title: 'Title in children',
+ isActive: false,
+ },
+ ],
+ },
+ ],
+ },
+ ]);
});
test('should filter out unknown deeplinks', async () => {
@@ -235,6 +262,10 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(await findByTestId('nav-item-root.group1.item1')).toBeVisible();
expect(await findByTestId('nav-item-root.group1.item1')).toBeVisible();
@@ -243,36 +274,37 @@ describe('', () => {
onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1];
const [navTree] = lastCall;
- expect(navTree).toEqual({
- navigationTree: [
- {
- id: 'root',
- path: ['root'],
- title: '',
- children: [
- {
- id: 'group1',
- path: ['root', 'group1'],
- title: '',
- children: [
- {
+ expect(navTree.navigationTree).toEqual([
+ {
+ id: 'root',
+ path: ['root'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'group1',
+ path: ['root', 'group1'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'item1',
+ path: ['root', 'group1', 'item1'],
+ title: 'Title from deeplink',
+ isActive: false,
+ deepLink: {
id: 'item1',
- path: ['root', 'group1', 'item1'],
title: 'Title from deeplink',
- deepLink: {
- id: 'item1',
- title: 'Title from deeplink',
- baseUrl: '',
- url: '',
- href: '',
- },
+ baseUrl: '',
+ url: '',
+ href: '',
},
- ],
- },
- ],
- },
- ],
- });
+ },
+ ],
+ },
+ ],
+ },
+ ]);
});
test('should not render the group if it does not have children AND no href or deeplink', async () => {
@@ -306,6 +338,10 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(queryByTestId('nav-group-root.group1')).toBeNull();
expect(queryByTestId('nav-item-root.group2.item1')).toBeVisible();
@@ -314,41 +350,43 @@ describe('', () => {
onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1];
const [navTree] = lastCall;
- expect(navTree).toEqual({
- navigationTree: [
- {
- id: 'root',
- path: ['root'],
- title: '',
- children: [
- {
- id: 'group1',
- path: ['root', 'group1'],
- title: '',
- },
- {
- id: 'group2',
- path: ['root', 'group2'],
- title: '',
- children: [
- {
+ expect(navTree.navigationTree).toEqual([
+ {
+ id: 'root',
+ path: ['root'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'group1',
+ path: ['root', 'group1'],
+ title: '',
+ isActive: false,
+ },
+ {
+ id: 'group2',
+ path: ['root', 'group2'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'item1',
+ path: ['root', 'group2', 'item1'],
+ title: 'Title from deeplink',
+ isActive: false,
+ deepLink: {
id: 'item1',
- path: ['root', 'group2', 'item1'],
title: 'Title from deeplink',
- deepLink: {
- id: 'item1',
- title: 'Title from deeplink',
- baseUrl: '',
- url: '',
- href: '',
- },
+ baseUrl: '',
+ url: '',
+ href: '',
},
- ],
- },
- ],
- },
- ],
- });
+ },
+ ],
+ },
+ ],
+ },
+ ]);
});
test('should render custom react element', async () => {
@@ -385,6 +423,10 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(await findByTestId('my-custom-element')).toBeVisible();
expect(await findByTestId('my-other-custom-element')).toBeVisible();
expect((await findByTestId('my-other-custom-element')).textContent).toBe('Children prop');
@@ -394,44 +436,46 @@ describe('', () => {
onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1];
const [navTree] = lastCall;
- expect(navTree).toEqual({
- navigationTree: [
- {
- id: 'root',
- path: ['root'],
- title: '',
- children: [
- {
- id: 'group1',
- path: ['root', 'group1'],
- title: '',
- children: [
- {
+ expect(navTree.navigationTree).toEqual([
+ {
+ id: 'root',
+ path: ['root'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'group1',
+ path: ['root', 'group1'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'item1',
+ path: ['root', 'group1', 'item1'],
+ title: 'Title from deeplink',
+ renderItem: expect.any(Function),
+ isActive: false,
+ deepLink: {
id: 'item1',
- path: ['root', 'group1', 'item1'],
title: 'Title from deeplink',
- renderItem: expect.any(Function),
- deepLink: {
- id: 'item1',
- title: 'Title from deeplink',
- baseUrl: '',
- url: '',
- href: '',
- },
+ baseUrl: '',
+ url: '',
+ href: '',
},
- {
- id: 'item2',
- href: 'http://foo',
- path: ['root', 'group1', 'item2'],
- title: 'Children prop',
- renderItem: expect.any(Function),
- },
- ],
- },
- ],
- },
- ],
- });
+ },
+ {
+ id: 'item2',
+ href: 'http://foo',
+ path: ['root', 'group1', 'item2'],
+ title: 'Children prop',
+ isActive: false,
+ renderItem: expect.any(Function),
+ },
+ ],
+ },
+ ],
+ },
+ ]);
});
test('should render group preset (analytics, ml...)', async () => {
@@ -448,6 +492,10 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(onProjectNavigationChange).toHaveBeenCalled();
const lastCall =
onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1];
@@ -456,18 +504,6 @@ describe('', () => {
expect(navTreeGenerated).toEqual({
navigationTree: expect.any(Array),
});
-
- // The default navigation tree for analytics
- expect(navTreeGenerated.navigationTree[0]).toEqual(defaultAnalyticsNavGroup);
-
- // The default navigation tree for ml
- expect(navTreeGenerated.navigationTree[1]).toEqual(defaultMlNavGroup);
-
- // The default navigation tree for devtools+
- expect(navTreeGenerated.navigationTree[2]).toEqual(defaultDevtoolsNavGroup);
-
- // The default navigation tree for management
- expect(navTreeGenerated.navigationTree[3]).toEqual(defaultManagementNavGroup);
});
test('should render recently accessed items', async () => {
@@ -488,6 +524,10 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(await findByTestId('nav-bucket-recentlyAccessed')).toBeVisible();
expect((await findByTestId('nav-bucket-recentlyAccessed')).textContent).toBe(
'RecentThis is an exampleAnother example'
@@ -507,28 +547,32 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(onProjectNavigationChange).toHaveBeenCalled();
const lastCall =
onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1];
const [navTreeGenerated] = lastCall;
- expect(navTreeGenerated).toEqual({
- navigationTree: [
- {
- id: 'group1',
- path: ['group1'],
- title: '',
- children: [
- {
- id: 'item1',
- title: 'Item 1',
- href: 'https://example.com',
- path: ['group1', 'item1'],
- },
- ],
- },
- ],
- });
+ expect(navTreeGenerated.navigationTree).toEqual([
+ {
+ id: 'group1',
+ path: ['group1'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'item1',
+ title: 'Item 1',
+ isActive: false,
+ href: 'https://example.com',
+ path: ['group1', 'item1'],
+ },
+ ],
+ },
+ ]);
});
test('should throw if href is not an absolute links', async () => {
@@ -558,5 +602,137 @@ describe('', () => {
// eslint-disable-next-line no-console
console.error.mockRestore();
});
+
+ test('should set the active node', async () => {
+ const navLinks$: Observable = of([
+ {
+ id: 'item1',
+ title: 'Item 1',
+ baseUrl: '',
+ url: '',
+ href: '',
+ },
+ {
+ id: 'item2',
+ title: 'Item 2',
+ baseUrl: '',
+ url: '',
+ href: '',
+ },
+ ]);
+
+ const activeNodes$ = new BehaviorSubject([
+ [
+ {
+ id: 'group1',
+ title: 'Group 1',
+ path: ['group1'],
+ },
+ {
+ id: 'item1',
+ title: 'Item 1',
+ path: ['group1', 'item1'],
+ },
+ ],
+ ]);
+
+ const getActiveNodes$ = () => activeNodes$;
+
+ const { findByTestId } = render(
+
+
+
+ link="item1" title="Item 1" />
+ link="item2" title="Item 2" />
+
+
+
+ );
+
+ expect(await findByTestId('nav-item-group1.item1')).toHaveClass(
+ 'euiSideNavItemButton-isSelected'
+ );
+ expect(await findByTestId('nav-item-group1.item2')).not.toHaveClass(
+ 'euiSideNavItemButton-isSelected'
+ );
+
+ await act(async () => {
+ activeNodes$.next([
+ [
+ {
+ id: 'group1',
+ title: 'Group 1',
+ path: ['group1'],
+ },
+ {
+ id: 'item2',
+ title: 'Item 2',
+ path: ['group1', 'item2'],
+ },
+ ],
+ ]);
+ });
+
+ expect(await findByTestId('nav-item-group1.item1')).not.toHaveClass(
+ 'euiSideNavItemButton-isSelected'
+ );
+ expect(await findByTestId('nav-item-group1.item2')).toHaveClass(
+ 'euiSideNavItemButton-isSelected'
+ );
+ });
+
+ test('should override the history behaviour to set the active node', async () => {
+ const navLinks$: Observable = of([
+ {
+ id: 'item1',
+ title: 'Item 1',
+ baseUrl: '',
+ url: '',
+ href: '',
+ },
+ ]);
+
+ const activeNodes$ = new BehaviorSubject([]);
+ const getActiveNodes$ = () => activeNodes$;
+
+ const onProjectNavigationChange = (nav: ChromeProjectNavigation) => {
+ nav.navigationTree.forEach((node) => {
+ if (node.children) {
+ node.children.forEach((child) => {
+ if (child.getIsActive?.('mockLocation' as any)) {
+ activeNodes$.next([[child]]);
+ }
+ });
+ }
+ });
+ };
+
+ const { findByTestId } = render(
+
+
+
+
+ link="item1"
+ title="Item 1"
+ getIsActive={() => {
+ return true;
+ }}
+ />
+
+
+
+ );
+
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+
+ expect(await findByTestId('nav-item-group1.item1')).toHaveClass(
+ 'euiSideNavItemButton-isSelected'
+ );
+ });
});
});
diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation.tsx
index 073318df6f838..4530a0c8b6b09 100644
--- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation.tsx
+++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation.tsx
@@ -17,6 +17,8 @@ import React, {
useRef,
} from 'react';
import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
+import useDebounce from 'react-use/lib/useDebounce';
+import useObservable from 'react-use/lib/useObservable';
import { useNavigation as useNavigationServices } from '../../services';
import { RegisterFunction, UnRegisterFunction } from '../types';
@@ -30,6 +32,7 @@ interface Context {
register: RegisterFunction;
updateFooterChildren: (children: ReactNode) => void;
unstyled: boolean;
+ activeNodes: ChromeProjectNavigationNode[][];
}
const NavigationContext = createContext({
@@ -39,6 +42,7 @@ const NavigationContext = createContext({
}),
updateFooterChildren: () => {},
unstyled: false,
+ activeNodes: [],
});
interface Props {
@@ -52,7 +56,7 @@ interface Props {
}
export function Navigation({ children, unstyled = false, dataTestSubj }: Props) {
- const { onProjectNavigationChange } = useNavigationServices();
+ const { onProjectNavigationChange, activeNodes$ } = useNavigationServices();
// We keep a reference of the order of the children that register themselves when mounting.
// This guarantees that the navTree items sent to the Chrome service has the same order
@@ -60,9 +64,13 @@ export function Navigation({ children, unstyled = false, dataTestSubj }: Props)
const orderChildrenRef = useRef>({});
const idx = useRef(0);
+ const activeNodes = useObservable(activeNodes$, []);
const [navigationItems, setNavigationItems] = useState<
Record
>({});
+ const [debouncedNavigationItems, setDebouncedNavigationItems] = useState<
+ Record
+ >({});
const [footerChildren, setFooterChildren] = useState(null);
const unregister: UnRegisterFunction = useCallback((id: string) => {
@@ -75,7 +83,9 @@ export function Navigation({ children, unstyled = false, dataTestSubj }: Props)
const register = useCallback(
(navNode: ChromeProjectNavigationNode) => {
- orderChildrenRef.current[navNode.id] = idx.current++;
+ if (orderChildrenRef.current[navNode.id] === undefined) {
+ orderChildrenRef.current[navNode.id] = idx.current++;
+ }
setNavigationItems((prevItems) => {
return {
@@ -97,20 +107,31 @@ export function Navigation({ children, unstyled = false, dataTestSubj }: Props)
register,
updateFooterChildren: setFooterChildren,
unstyled,
+ activeNodes,
}),
- [register, unstyled]
+ [register, unstyled, activeNodes]
+ );
+
+ useDebounce(
+ () => {
+ setDebouncedNavigationItems(navigationItems);
+ },
+ 100,
+ [navigationItems]
);
useEffect(() => {
+ const navigationTree = Object.values(debouncedNavigationItems).sort((a, b) => {
+ const aOrder = orderChildrenRef.current[a.id];
+ const bOrder = orderChildrenRef.current[b.id];
+ return aOrder - bOrder;
+ });
+
// This will update the navigation tree in the Chrome service (calling the serverless.setNavigation())
onProjectNavigationChange({
- navigationTree: Object.values(navigationItems).sort((a, b) => {
- const aOrder = orderChildrenRef.current[a.id];
- const bOrder = orderChildrenRef.current[b.id];
- return aOrder - bOrder;
- }),
+ navigationTree,
});
- }, [navigationItems, onProjectNavigationChange]);
+ }, [debouncedNavigationItems, onProjectNavigationChange]);
return (
diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_group.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_group.tsx
index 457de9a5c4c38..dcfa47d92d641 100644
--- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_group.tsx
+++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_group.tsx
@@ -47,7 +47,10 @@ function NavigationGroupInternalComp<
>(props: Props) {
const navigationContext = useNavigation();
const { children, defaultIsCollapsed, ...node } = props;
- const { navNode, registerChildNode, path, childrenNodes } = useInitNavNode(node);
+ const { navNode, registerChildNode, path, childrenNodes } = useInitNavNode({
+ ...node,
+ isActive: defaultIsCollapsed !== undefined ? defaultIsCollapsed === false : undefined,
+ });
const unstyled = props.unstyled ?? navigationContext.unstyled;
@@ -68,11 +71,7 @@ function NavigationGroupInternalComp<
return (
<>
{isTopLevel && (
-
+
)}
{/* We render the children so they mount and can register themselves but
visually they don't appear here in the DOM. They are rendered inside the
@@ -80,7 +79,7 @@ function NavigationGroupInternalComp<
{children}
>
);
- }, [navNode, path, childrenNodes, children, defaultIsCollapsed, unstyled]);
+ }, [navNode, path, childrenNodes, children, unstyled]);
const contextValue = useMemo(() => {
return {
diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx
index c0b18fd60e7eb..9e3e8ec812b5c 100644
--- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx
+++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import React, { FC } from 'react';
+import React, { FC, useEffect, useRef, useState } from 'react';
import {
EuiCollapsibleNavGroup,
EuiIcon,
@@ -46,9 +46,12 @@ const navigationNodeToEuiItem = (
);
};
+ const isSelected = item.children && item.children.length > 0 ? false : item.isActive;
+
return {
id,
name: item.title,
+ isSelected,
onClick:
href !== undefined
? (event: React.MouseEvent) => {
@@ -71,16 +74,13 @@ const navigationNodeToEuiItem = (
interface Props {
navNode: ChromeProjectNavigationNodeEnhanced;
items?: ChromeProjectNavigationNodeEnhanced[];
- defaultIsCollapsed?: boolean;
}
-export const NavigationSectionUI: FC = ({
- navNode,
- items = [],
- defaultIsCollapsed = true,
-}) => {
- const { id, title, icon } = navNode;
+export const NavigationSectionUI: FC = ({ navNode, items = [] }) => {
+ const { id, title, icon, isActive } = navNode;
const { navigateToUrl, basePath } = useServices();
+ const [isCollapsed, setIsCollapsed] = useState(!isActive);
+ const initialTime = useRef(Date.now());
// If the item has no link and no cildren, we don't want to render it
const itemHasLinkOrChildren = (item: ChromeProjectNavigationNodeEnhanced) => {
@@ -107,6 +107,15 @@ export const NavigationSectionUI: FC = ({
const groupHasLink = Boolean(navNode.deepLink) || Boolean(navNode.href);
+ useEffect(() => {
+ // We only want to set the collapsed state during the "mounting" phase (500ms).
+ // After that, even if the URL does not match the group and the group is open we don't
+ // want to collapse it automatically.
+ if (Date.now() - initialTime.current < 500) {
+ setIsCollapsed(!isActive);
+ }
+ }, [isActive]);
+
if (!groupHasLink && !filteredItems.some(itemHasLinkOrChildren)) {
return null;
}
@@ -117,7 +126,9 @@ export const NavigationSectionUI: FC = ({
title={title}
iconType={icon}
isCollapsible={true}
- initialIsOpen={!defaultIsCollapsed}
+ initialIsOpen={isActive}
+ onToggle={(isOpen) => setIsCollapsed(!isOpen)}
+ forceState={isCollapsed ? 'closed' : 'open'}
data-test-subj={`nav-bucket-${id}`}
>
diff --git a/packages/shared-ux/chrome/navigation/src/ui/default_navigation.test.tsx b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.test.tsx
index 4ad3dc75a9d93..d7eb8d75a7b56 100644
--- a/packages/shared-ux/chrome/navigation/src/ui/default_navigation.test.tsx
+++ b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.test.tsx
@@ -8,23 +8,32 @@
import React from 'react';
import { render } from '@testing-library/react';
-import { type Observable, of } from 'rxjs';
-import type { ChromeNavLink } from '@kbn/core-chrome-browser';
+import { type Observable, of, BehaviorSubject } from 'rxjs';
+import type {
+ ChromeNavLink,
+ ChromeProjectNavigation,
+ ChromeProjectNavigationNode,
+} from '@kbn/core-chrome-browser';
import { getServicesMock } from '../../mocks/src/jest';
import { NavigationProvider } from '../services';
import { DefaultNavigation } from './default_navigation';
import type { ProjectNavigationTreeDefinition, RootNavigationItemDefinition } from './types';
-import {
- defaultAnalyticsNavGroup,
- defaultDevtoolsNavGroup,
- defaultManagementNavGroup,
- defaultMlNavGroup,
-} from '../../mocks/src/default_navigation.test.helpers';
import { navLinksMock } from '../../mocks/src/navlinks';
+import { act } from 'react-dom/test-utils';
+
+// There is a 100ms debounce to update project navigation tree
+const SET_NAVIGATION_DELAY = 100;
describe('', () => {
const services = getServicesMock();
+ beforeAll(() => {
+ jest.useFakeTimers();
+ });
+
+ afterAll(() => {
+ jest.useRealTimers();
+ });
describe('builds custom navigation tree', () => {
test('render reference UI and build the navigation tree', async () => {
@@ -77,6 +86,10 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(await findByTestId('nav-item-group1.item1')).toBeVisible();
expect(await findByTestId('nav-item-group1.item2')).toBeVisible();
expect(await findByTestId('nav-item-group1.group1A')).toBeVisible();
@@ -93,55 +106,60 @@ describe('', () => {
onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1];
const [navTreeGenerated] = lastCall;
- expect(navTreeGenerated).toEqual({
- navigationTree: [
- {
- id: 'group1',
- path: ['group1'],
- title: '',
- children: [
- {
- id: 'item1',
- title: 'Item 1',
- href: 'http://foo',
- path: ['group1', 'item1'],
- },
- {
- id: 'item2',
- title: 'Item 2',
- href: 'http://foo',
- path: ['group1', 'item2'],
- },
- {
- id: 'group1A',
- title: 'Group1A',
- path: ['group1', 'group1A'],
- children: [
- {
- id: 'item1',
- title: 'Group 1A Item 1',
- href: 'http://foo',
- path: ['group1', 'group1A', 'item1'],
- },
- {
- id: 'group1A_1',
- title: 'Group1A_1',
- path: ['group1', 'group1A', 'group1A_1'],
- children: [
- {
- id: 'item1',
- title: 'Group 1A_1 Item 1',
- href: 'http://foo',
- path: ['group1', 'group1A', 'group1A_1', 'item1'],
- },
- ],
- },
- ],
- },
- ],
- },
- ],
- });
+ expect(navTreeGenerated.navigationTree).toEqual([
+ {
+ id: 'group1',
+ path: ['group1'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'item1',
+ title: 'Item 1',
+ href: 'http://foo',
+ isActive: false,
+ path: ['group1', 'item1'],
+ },
+ {
+ id: 'item2',
+ title: 'Item 2',
+ href: 'http://foo',
+ isActive: false,
+ path: ['group1', 'item2'],
+ },
+ {
+ id: 'group1A',
+ title: 'Group1A',
+ isActive: false,
+ path: ['group1', 'group1A'],
+ children: [
+ {
+ id: 'item1',
+ title: 'Group 1A Item 1',
+ href: 'http://foo',
+ isActive: false,
+ path: ['group1', 'group1A', 'item1'],
+ },
+ {
+ id: 'group1A_1',
+ title: 'Group1A_1',
+ isActive: false,
+ path: ['group1', 'group1A', 'group1A_1'],
+ children: [
+ {
+ id: 'item1',
+ title: 'Group 1A_1 Item 1',
+ href: 'http://foo',
+ isActive: false,
+ path: ['group1', 'group1A', 'group1A_1', 'item1'],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ]);
});
test('should read the title from deeplink', async () => {
@@ -196,53 +214,59 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(onProjectNavigationChange).toHaveBeenCalled();
const lastCall =
onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1];
const [navTreeGenerated] = lastCall;
- expect(navTreeGenerated).toEqual({
- navigationTree: [
- {
- id: 'root',
- path: ['root'],
- title: '',
- children: [
- {
- id: 'group1',
- path: ['root', 'group1'],
- title: '',
- children: [
- {
+ expect(navTreeGenerated.navigationTree).toEqual([
+ {
+ id: 'root',
+ path: ['root'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'group1',
+ path: ['root', 'group1'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'item1',
+ path: ['root', 'group1', 'item1'],
+ title: 'Title from deeplink',
+ isActive: false,
+ deepLink: {
id: 'item1',
- path: ['root', 'group1', 'item1'],
title: 'Title from deeplink',
- deepLink: {
- id: 'item1',
- title: 'Title from deeplink',
- baseUrl: '',
- url: '',
- href: '',
- },
+ baseUrl: '',
+ url: '',
+ href: '',
},
- {
- id: 'item2',
- title: 'Overwrite deeplink title',
- path: ['root', 'group1', 'item2'],
- deepLink: {
- id: 'item1',
- title: 'Title from deeplink',
- baseUrl: '',
- url: '',
- href: '',
- },
+ },
+ {
+ id: 'item2',
+ title: 'Overwrite deeplink title',
+ path: ['root', 'group1', 'item2'],
+ isActive: false,
+ deepLink: {
+ id: 'item1',
+ title: 'Title from deeplink',
+ baseUrl: '',
+ url: '',
+ href: '',
},
- ],
- },
- ],
- },
- ],
- });
+ },
+ ],
+ },
+ ],
+ },
+ ]);
});
test('should allow href for absolute links', async () => {
@@ -273,35 +297,40 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(onProjectNavigationChange).toHaveBeenCalled();
const lastCall =
onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1];
const [navTreeGenerated] = lastCall;
- expect(navTreeGenerated).toEqual({
- navigationTree: [
- {
- id: 'root',
- path: ['root'],
- title: '',
- children: [
- {
- id: 'group1',
- path: ['root', 'group1'],
- title: '',
- children: [
- {
- id: 'item1',
- path: ['root', 'group1', 'item1'],
- title: 'Absolute link',
- href: 'https://example.com',
- },
- ],
- },
- ],
- },
- ],
- });
+ expect(navTreeGenerated.navigationTree).toEqual([
+ {
+ id: 'root',
+ path: ['root'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'group1',
+ path: ['root', 'group1'],
+ title: '',
+ isActive: false,
+ children: [
+ {
+ id: 'item1',
+ path: ['root', 'group1', 'item1'],
+ title: 'Absolute link',
+ href: 'https://example.com',
+ isActive: false,
+ },
+ ],
+ },
+ ],
+ },
+ ]);
});
test('should throw if href is not an absolute links', async () => {
@@ -365,11 +394,145 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(await findByTestId('nav-bucket-recentlyAccessed')).toBeVisible();
expect((await findByTestId('nav-bucket-recentlyAccessed')).textContent).toBe(
'RecentThis is an exampleAnother example'
);
});
+
+ test('should set the active node', async () => {
+ const navLinks$: Observable = of([
+ {
+ id: 'item1',
+ title: 'Item 1',
+ baseUrl: '',
+ url: '',
+ href: '',
+ },
+ {
+ id: 'item2',
+ title: 'Item 2',
+ baseUrl: '',
+ url: '',
+ href: '',
+ },
+ ]);
+
+ const navigationBody: RootNavigationItemDefinition[] = [
+ {
+ type: 'navGroup',
+ id: 'group1',
+ children: [
+ {
+ link: 'item1' as any,
+ title: 'Item 1',
+ },
+ {
+ link: 'item2' as any,
+ title: 'Item 2',
+ },
+ ],
+ },
+ ];
+
+ const activeNodes$ = new BehaviorSubject([
+ [
+ {
+ id: 'group1',
+ title: 'Group 1',
+ path: ['group1'],
+ },
+ {
+ id: 'item1',
+ title: 'Item 1',
+ path: ['group1', 'item1'],
+ },
+ ],
+ ]);
+
+ const getActiveNodes$ = () => activeNodes$;
+
+ const { findByTestId } = render(
+
+
+
+ );
+
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
+ expect(await findByTestId('nav-item-group1.item1')).toHaveClass(
+ 'euiSideNavItemButton-isSelected'
+ );
+ expect(await findByTestId('nav-item-group1.item2')).not.toHaveClass(
+ 'euiSideNavItemButton-isSelected'
+ );
+ });
+
+ test('should override the history behaviour to set the active node', async () => {
+ const navLinks$: Observable = of([
+ {
+ id: 'item1',
+ title: 'Item 1',
+ baseUrl: '',
+ url: '',
+ href: '',
+ },
+ ]);
+
+ const navigationBody: RootNavigationItemDefinition[] = [
+ {
+ type: 'navGroup',
+ id: 'group1',
+ children: [
+ {
+ link: 'item1' as any,
+ title: 'Item 1',
+ getIsActive: () => true,
+ },
+ ],
+ },
+ ];
+
+ const activeNodes$ = new BehaviorSubject([[]]);
+ const getActiveNodes$ = () => activeNodes$;
+
+ const onProjectNavigationChange = (nav: ChromeProjectNavigation) => {
+ nav.navigationTree.forEach((node) => {
+ if (node.children) {
+ node.children.forEach((child) => {
+ if (child.getIsActive) {
+ activeNodes$.next([[child]]);
+ }
+ });
+ }
+ });
+ };
+
+ const { findByTestId } = render(
+
+
+
+ );
+
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
+ expect(await findByTestId('nav-item-group1.item1')).toHaveClass(
+ 'euiSideNavItemButton-isSelected'
+ );
+ });
});
describe('builds the full navigation tree when only custom project is provided', () => {
@@ -424,6 +587,10 @@ describe('', () => {
);
+ await act(async () => {
+ jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
+ });
+
expect(onProjectNavigationChange).toHaveBeenCalled();
const lastCall =
onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1];
@@ -438,16 +605,19 @@ describe('', () => {
id: 'group1',
title: 'Group 1',
path: ['group1'],
+ isActive: false,
children: [
{
id: 'item1',
title: 'Item 1',
+ isActive: false,
path: ['group1', 'item1'],
},
{
id: 'item2',
path: ['group1', 'item2'],
title: 'Title from deeplink!',
+ isActive: false,
deepLink: {
id: 'item2',
title: 'Title from deeplink!',
@@ -460,6 +630,7 @@ describe('', () => {
id: 'item3',
title: 'Deeplink title overriden',
path: ['group1', 'item3'],
+ isActive: false,
deepLink: {
id: 'item2',
title: 'Title from deeplink!',
@@ -470,18 +641,6 @@ describe('', () => {
},
],
});
-
- // The default navigation tree for analytics
- expect(navTreeGenerated.navigationTree[1]).toEqual(defaultAnalyticsNavGroup);
-
- // The default navigation tree for ml
- expect(navTreeGenerated.navigationTree[2]).toEqual(defaultMlNavGroup);
-
- // The default navigation tree for devtools+
- expect(navTreeGenerated.navigationTree[3]).toEqual(defaultDevtoolsNavGroup);
-
- // The default navigation tree for management
- expect(navTreeGenerated.navigationTree[4]).toEqual(defaultManagementNavGroup);
});
});
});
diff --git a/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx
index a42ef5b802e6c..75a7d983f1cdb 100644
--- a/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx
+++ b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx
@@ -105,16 +105,12 @@ export const DefaultNavigation: FC
- {copy.children ? (
-
- {renderItems(copy.children, [...path, id])}
-
- ) : (
-
- )}
-
+ return copy.children ? (
+
+ {renderItems(copy.children, [...path, id])}
+
+ ) : (
+
);
});
},
diff --git a/packages/shared-ux/chrome/navigation/src/ui/hooks/use_init_navnode.ts b/packages/shared-ux/chrome/navigation/src/ui/hooks/use_init_navnode.ts
index f5bc9ebf35fb9..8f805c4b5d8ed 100644
--- a/packages/shared-ux/chrome/navigation/src/ui/hooks/use_init_navnode.ts
+++ b/packages/shared-ux/chrome/navigation/src/ui/hooks/use_init_navnode.ts
@@ -16,6 +16,7 @@ import useObservable from 'react-use/lib/useObservable';
import { useNavigation as useNavigationServices } from '../../services';
import { isAbsoluteLink } from '../../utils';
+import { useNavigation } from '../components/navigation';
import {
ChromeProjectNavigationNodeEnhanced,
NodeProps,
@@ -60,7 +61,8 @@ function createInternalNavNode<
id: string,
_navNode: NodePropsEnhanced,
deepLinks: Readonly,
- path: string[] | null
+ path: string[] | null,
+ isActive: boolean
): ChromeProjectNavigationNodeEnhanced | null {
const { children, link, href, ...navNode } = _navNode;
const deepLink = deepLinks.find((dl) => dl.id === link);
@@ -84,9 +86,19 @@ function createInternalNavNode<
title: title ?? '',
deepLink,
href,
+ isActive,
};
}
+function isSamePath(pathA: string[] | null, pathB: string[] | null) {
+ if (pathA === null || pathB === null) {
+ return false;
+ }
+ const pathAToString = pathA.join('.');
+ const pathBToString = pathB.join('.');
+ return pathAToString === pathBToString;
+}
+
export const useInitNavNode = <
LinkId extends AppDeepLinkId = AppDeepLinkId,
Id extends string = string,
@@ -94,6 +106,8 @@ export const useInitNavNode = <
>(
node: NodePropsEnhanced
) => {
+ const { isActive: isActiveControlled } = node;
+
/**
* Map of children nodes
*/
@@ -103,11 +117,6 @@ export const useInitNavNode = <
const isMounted = useRef(false);
- /**
- * Flag to indicate if the current node has been registered
- */
- const isRegistered = useRef(false);
-
/**
* Reference to the unregister function
*/
@@ -130,22 +139,19 @@ export const useInitNavNode = <
* the list of active routes based on current URL location (passed by the Chrome service)
*/
const [nodePath, setNodePath] = useState(null);
-
- /**
- * Whenever a child node is registered, we need to re-register the current node
- * on the parent. This state keeps track when child node register.
- */
- const [childrenNodesUpdated, setChildrenNodesUpdated] = useState([]);
+ const [isActiveState, setIsActive] = useState(false);
+ const isActive = isActiveControlled ?? isActiveState;
const { navLinks$ } = useNavigationServices();
const deepLinks = useObservable(navLinks$, []);
const { register: registerNodeOnParent } = useRegisterTreeNode();
+ const { activeNodes } = useNavigation();
const id = getIdFromNavigationNode(node);
const internalNavNode = useMemo(
- () => createInternalNavNode(id, node, deepLinks, nodePath),
- [node, id, deepLinks, nodePath]
+ () => createInternalNavNode(id, node, deepLinks, nodePath, isActive),
+ [node, id, deepLinks, nodePath, isActive]
);
// Register the node on the parent whenever its properties change or whenever
@@ -155,30 +161,30 @@ export const useInitNavNode = <
return;
}
- if (!isRegistered.current || childrenNodesUpdated.length > 0) {
- const children = Object.values(childrenNodes).sort((a, b) => {
- const aOrder = orderChildrenRef.current[a.id];
- const bOrder = orderChildrenRef.current[b.id];
- return aOrder - bOrder;
- });
+ const children = Object.values(childrenNodes).sort((a, b) => {
+ const aOrder = orderChildrenRef.current[a.id];
+ const bOrder = orderChildrenRef.current[b.id];
+ return aOrder - bOrder;
+ });
- const { unregister, path } = registerNodeOnParent({
- ...internalNavNode,
- children: children.length ? children : undefined,
- });
+ const { unregister, path } = registerNodeOnParent({
+ ...internalNavNode,
+ children: children.length ? children : undefined,
+ });
- setNodePath(path);
- setChildrenNodesUpdated([]);
+ setNodePath((prev) => {
+ if (!isSamePath(prev, path)) {
+ return path;
+ }
+ return prev;
+ });
- unregisterRef.current = unregister;
- isRegistered.current = true;
- }
- }, [internalNavNode, childrenNodesUpdated.length, childrenNodes, registerNodeOnParent]);
+ unregisterRef.current = unregister;
+ }, [internalNavNode, childrenNodes, registerNodeOnParent]);
// Un-register from the parent. This will happen when the node is unmounted or if the deeplink
// is not active anymore.
const unregister = useCallback(() => {
- isRegistered.current = false;
if (unregisterRef.current) {
unregisterRef.current(id);
unregisterRef.current = undefined;
@@ -199,8 +205,9 @@ export const useInitNavNode = <
};
});
- orderChildrenRef.current[childNode.id] = idx.current++;
- setChildrenNodesUpdated((prev) => [...prev, childNode.id]);
+ if (orderChildrenRef.current[childNode.id] === undefined) {
+ orderChildrenRef.current[childNode.id] = idx.current++;
+ }
return {
unregister: (childId: string) => {
@@ -216,6 +223,14 @@ export const useInitNavNode = <
[nodePath]
);
+ useEffect(() => {
+ const updatedIsActive = activeNodes.reduce((acc, nodesBranch) => {
+ return acc === true ? acc : nodesBranch.some((_node) => isSamePath(_node.path, nodePath));
+ }, false);
+
+ setIsActive(updatedIsActive);
+ }, [activeNodes, nodePath]);
+
/** Register when mounting and whenever the internal nav node changes */
useEffect(() => {
if (!isMounted.current) {
diff --git a/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx b/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx
index 1de6bb828c138..241e53cb8242f 100644
--- a/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx
+++ b/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx
@@ -28,7 +28,7 @@ import { NavigationStorybookMock, navLinksMock } from '../../mocks';
import mdx from '../../README.mdx';
import { NavigationProvider } from '../services';
import { DefaultNavigation } from './default_navigation';
-import type { ChromeNavigationViewModel, NavigationServices } from '../../types';
+import type { NavigationServices } from '../../types';
import { Navigation } from './components';
import type { NonEmptyArray, ProjectNavigationDefinition } from './types';
import { getPresets } from './nav_tree_presets';
@@ -171,7 +171,7 @@ const simpleNavigationDefinition: ProjectNavigationDefinition = {
],
};
-export const SimpleObjectDefinition = (args: ChromeNavigationViewModel & NavigationServices) => {
+export const SimpleObjectDefinition = (args: NavigationServices) => {
const services = storybookMock.getServices({
...args,
navLinks$: of([...navLinksMock, ...deepLinks]),
@@ -286,7 +286,7 @@ const navigationDefinition: ProjectNavigationDefinition = {
},
};
-export const ComplexObjectDefinition = (args: ChromeNavigationViewModel & NavigationServices) => {
+export const ComplexObjectDefinition = (args: NavigationServices) => {
const services = storybookMock.getServices({
...args,
navLinks$: of([...navLinksMock, ...deepLinks]),
@@ -308,7 +308,7 @@ export const ComplexObjectDefinition = (args: ChromeNavigationViewModel & Naviga
);
};
-export const WithUIComponents = (args: ChromeNavigationViewModel & NavigationServices) => {
+export const WithUIComponents = (args: NavigationServices) => {
const services = storybookMock.getServices({
...args,
navLinks$: of([...navLinksMock, ...deepLinks]),
@@ -372,7 +372,7 @@ export const WithUIComponents = (args: ChromeNavigationViewModel & NavigationSer
);
};
-export const MinimalUI = (args: ChromeNavigationViewModel & NavigationServices) => {
+export const MinimalUI = (args: NavigationServices) => {
const services = storybookMock.getServices({
...args,
navLinks$: of([...navLinksMock, ...deepLinks]),
@@ -434,7 +434,7 @@ export default {
component: WithUIComponents,
} as ComponentMeta;
-export const CreativeUI = (args: ChromeNavigationViewModel & NavigationServices) => {
+export const CreativeUI = (args: NavigationServices) => {
const services = storybookMock.getServices({
...args,
navLinks$: of([...navLinksMock, ...deepLinks]),
diff --git a/packages/shared-ux/chrome/navigation/src/ui/types.ts b/packages/shared-ux/chrome/navigation/src/ui/types.ts
index 91f37e8d3c63d..f4414ae2e003f 100644
--- a/packages/shared-ux/chrome/navigation/src/ui/types.ts
+++ b/packages/shared-ux/chrome/navigation/src/ui/types.ts
@@ -50,6 +50,11 @@ export interface NodePropsEnhanced<
* the EuiSideNavItemType (see navigation_section_ui.tsx)
*/
renderItem?: () => ReactElement;
+ /**
+ * Forces the node to be active. This is used to force a collapisble nav group to be open
+ * even if the URL does not match any of the nodes in the group.
+ */
+ isActive?: boolean;
}
/**
@@ -86,7 +91,15 @@ export interface GroupDefinition<
ChildrenId extends string = Id
> extends NodeDefinition {
type: 'navGroup';
- /** Flag to indicate if the group is initially collapsed or not. */
+ /**
+ * Flag to indicate if the group is initially collapsed or not.
+ *
+ * `false`: the group will be opened event if none of its children nodes matches the current URL.
+ *
+ * `true`: the group will be collapsed event if any of its children nodes matches the current URL.
+ *
+ * `undefined`: the group will be opened if any of its children nodes matches the current URL.
+ */
defaultIsCollapsed?: boolean;
preset?: NavigationGroupPreset;
}
diff --git a/packages/shared-ux/chrome/navigation/types/index.ts b/packages/shared-ux/chrome/navigation/types/index.ts
index e262b61d13622..dc6014dcdd28b 100644
--- a/packages/shared-ux/chrome/navigation/types/index.ts
+++ b/packages/shared-ux/chrome/navigation/types/index.ts
@@ -6,10 +6,13 @@
* Side Public License, v 1.
*/
-import type { ReactNode } from 'react';
import type { Observable } from 'rxjs';
-import type { ChromeNavLink, ChromeProjectNavigation } from '@kbn/core-chrome-browser';
+import type {
+ ChromeNavLink,
+ ChromeProjectNavigation,
+ ChromeProjectNavigationNode,
+} from '@kbn/core-chrome-browser';
import type { BasePathService, NavigateToUrlFn, RecentItem } from './internal';
/**
@@ -17,13 +20,13 @@ import type { BasePathService, NavigateToUrlFn, RecentItem } from './internal';
* @public
*/
export interface NavigationServices {
- activeNavItemId?: string;
basePath: BasePathService;
recentlyAccessed$: Observable;
navLinks$: Observable>;
navIsOpen: boolean;
navigateToUrl: NavigateToUrlFn;
onProjectNavigationChange: (chromeProjectNavigation: ChromeProjectNavigation) => void;
+ activeNodes$: Observable;
}
/**
@@ -46,107 +49,10 @@ export interface NavigationKibanaDependencies {
};
};
serverless: {
- setNavigation: (projectNavigation: ChromeProjectNavigation) => void;
+ setNavigation: (
+ projectNavigation: ChromeProjectNavigation,
+ navigationTreeFlattened?: Record
+ ) => void;
+ getActiveNavigationNodes$: () => Observable;
};
}
-
-/** @public */
-export type ChromeNavigationLink = string;
-
-/**
- * Chrome navigation node definition.
- *
- * @public
- */
-export interface ChromeNavigationNode {
- /** An optional id. If not provided a link must be passed */
- id?: string;
- /** An optional title for the node */
- title?: string | ReactNode;
- /** An optional eui icon */
- icon?: string;
- /** An app id or a deeplink id */
- link?: ChromeNavigationLink;
- /** Sub navigation item for this node */
- items?: ChromeNavigationNode[];
-}
-
-/**
- * Chrome navigation definition used internally in the components.
- * Each "link" (if present) has been converted to a propert href. Additional metadata has been added
- * like the "isActive" flag or the "path" (indicating the full path of the node in the nav tree).
- *
- * @public
- */
-export interface ChromeNavigationNodeViewModel extends Omit {
- id: string;
- /**
- * Full path that points to this node (includes all parent ids). If not set
- * the path is the id
- */
- path?: string;
- isActive?: boolean;
- href?: string;
- items?: ChromeNavigationNodeViewModel[];
-}
-
-/**
- * External definition of the side navigation.
- *
- * @public
- */
-export interface ChromeNavigation {
- /**
- * The navigation tree definition.
- *
- * NOTE: For now this tree will _only_ contain the solution tree and we will concatenate
- * the different platform trees inside the component.
- * In a following work we will build the full navigation tree inside a "buildNavigationTree()"
- * helper exposed from this package. This helper will allow an array of PlatformId to be disabled
- *
- * e.g. buildNavigationTree({ solutionTree: [...], disable: ['devTools'] })
- */
- navigationTree: ChromeNavigationNode[];
- /**
- * Controls over which Platform nav sections is enabled or disabled.
- * NOTE: this is a temporary solution until we have the buildNavigationTree() helper mentioned
- * above.
- */
- platformConfig?: Partial;
- /**
- * Filter function to allow consumer to remove items from the recently accessed section
- */
- recentlyAccessedFilter?: (items: RecentItem[]) => RecentItem[];
-}
-
-/**
- * Internal definition of the side navigation.
- *
- * @internal
- */
-export interface ChromeNavigationViewModel
- extends Pick {
- /**
- * The navigation tree definition
- */
- navigationTree: ChromeNavigationNodeViewModel[];
-}
-
-/**
- * @public
- */
-export interface PlatformSectionConfig {
- enabled?: boolean;
- properties?: Record;
-}
-
-/**
- * @public
- */
-export type PlatformId = 'analytics' | 'ml' | 'devTools' | 'management';
-
-/**
- * Object that will allow parts of the platform-controlled nav to be hidden
- * @public
- */
-export type PlatformConfigSet = Record;
diff --git a/x-pack/plugins/serverless/public/mocks.ts b/x-pack/plugins/serverless/public/mocks.ts
index ffe61c833e6dc..d6068df919c90 100644
--- a/x-pack/plugins/serverless/public/mocks.ts
+++ b/x-pack/plugins/serverless/public/mocks.ts
@@ -12,6 +12,7 @@ const startMock = (): ServerlessPluginStart => ({
setBreadcrumbs: jest.fn(),
setProjectHome: jest.fn(),
setSideNavComponent: jest.fn(),
+ getActiveNavigationNodes$: jest.fn(),
});
export const serverlessMock = {
diff --git a/x-pack/plugins/serverless/public/plugin.tsx b/x-pack/plugins/serverless/public/plugin.tsx
index 73f199eb3c468..fbc4e739804a7 100644
--- a/x-pack/plugins/serverless/public/plugin.tsx
+++ b/x-pack/plugins/serverless/public/plugin.tsx
@@ -71,6 +71,8 @@ export class ServerlessPlugin
setNavigation: (projectNavigation) => project.setNavigation(projectNavigation),
setBreadcrumbs: (breadcrumbs, params) => project.setBreadcrumbs(breadcrumbs, params),
setProjectHome: (homeHref: string) => project.setHome(homeHref),
+ getActiveNavigationNodes$: () =>
+ (core.chrome as InternalChromeStart).project.getActiveNavigationNodes$(),
};
}
diff --git a/x-pack/plugins/serverless/public/types.ts b/x-pack/plugins/serverless/public/types.ts
index 8e9e8672f7e69..685e8757f9a98 100644
--- a/x-pack/plugins/serverless/public/types.ts
+++ b/x-pack/plugins/serverless/public/types.ts
@@ -10,8 +10,10 @@ import type {
ChromeProjectNavigation,
ChromeSetProjectBreadcrumbsParams,
SideNavComponent,
+ ChromeProjectNavigationNode,
} from '@kbn/core-chrome-browser';
import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public';
+import type { Observable } from 'rxjs';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ServerlessPluginSetup {}
@@ -24,6 +26,7 @@ export interface ServerlessPluginStart {
setNavigation(projectNavigation: ChromeProjectNavigation): void;
setProjectHome(homeHref: string): void;
setSideNavComponent: (navigation: SideNavComponent) => void;
+ getActiveNavigationNodes$: () => Observable;
}
export interface ServerlessPluginSetupDependencies {