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 {