From 0bbcdaaee7ab114486fdb4861b4f3bd661120f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 31 Oct 2023 10:46:57 +0000 Subject: [PATCH] [Serverless nav] Tests for navigation panels (#169915) --- .../project_navigation.test.tsx.snap} | 2 +- .../navigation/__jest__/active_node.test.tsx | 232 +++++ .../__jest__/build_nav_tree.test.tsx | 738 +++++++++++++++ .../chrome/navigation/__jest__/links.test.tsx | 234 +++++ .../chrome/navigation/__jest__/panel.test.tsx | 546 +++++++++++ .../__jest__/project_navigation.test.tsx | 80 ++ .../navigation/__jest__/setup_jest_mocks.ts | 22 + .../chrome/navigation/__jest__/utils.tsx | 75 ++ packages/shared-ux/chrome/navigation/index.ts | 1 + .../navigation/src/ui/components/index.ts | 6 +- .../src/ui/components/navigation.test.tsx | 894 ------------------ .../src/ui/components/navigation.tsx | 4 +- .../ui/components/navigation_section_ui.tsx | 60 +- .../src/ui/components/panel/panel_group.tsx | 15 +- .../ui/components/panel/panel_nav_item.tsx | 2 +- .../src/ui/default_navigation.test.tsx | 871 ----------------- .../src/ui/hooks/use_init_navnode.ts | 13 +- .../chrome/navigation/src/ui/index.ts | 2 +- .../navigation_tree/navigation_tree.ts | 1 - 19 files changed, 1991 insertions(+), 1807 deletions(-) rename packages/shared-ux/chrome/navigation/{src/ui/__snapshots__/default_navigation.test.tsx.snap => __jest__/__snapshots__/project_navigation.test.tsx.snap} (99%) create mode 100644 packages/shared-ux/chrome/navigation/__jest__/active_node.test.tsx create mode 100644 packages/shared-ux/chrome/navigation/__jest__/build_nav_tree.test.tsx create mode 100644 packages/shared-ux/chrome/navigation/__jest__/links.test.tsx create mode 100644 packages/shared-ux/chrome/navigation/__jest__/panel.test.tsx create mode 100644 packages/shared-ux/chrome/navigation/__jest__/project_navigation.test.tsx create mode 100644 packages/shared-ux/chrome/navigation/__jest__/setup_jest_mocks.ts create mode 100644 packages/shared-ux/chrome/navigation/__jest__/utils.tsx delete mode 100644 packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx delete mode 100644 packages/shared-ux/chrome/navigation/src/ui/default_navigation.test.tsx diff --git a/packages/shared-ux/chrome/navigation/src/ui/__snapshots__/default_navigation.test.tsx.snap b/packages/shared-ux/chrome/navigation/__jest__/__snapshots__/project_navigation.test.tsx.snap similarity index 99% rename from packages/shared-ux/chrome/navigation/src/ui/__snapshots__/default_navigation.test.tsx.snap rename to packages/shared-ux/chrome/navigation/__jest__/__snapshots__/project_navigation.test.tsx.snap index b4121ace0bf7f..65ed8b80aa8a3 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/__snapshots__/default_navigation.test.tsx.snap +++ b/packages/shared-ux/chrome/navigation/__jest__/__snapshots__/project_navigation.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` builds the full navigation tree when only custom project is provided reading the title from config or deeplink 1`] = ` +exports[`Default navigation builds the full navigation tree when only the project is provided 1`] = ` Array [ Object { "children": Array [ diff --git a/packages/shared-ux/chrome/navigation/__jest__/active_node.test.tsx b/packages/shared-ux/chrome/navigation/__jest__/active_node.test.tsx new file mode 100644 index 0000000000000..a0c35ace442e4 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/__jest__/active_node.test.tsx @@ -0,0 +1,232 @@ +/* + * 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 './setup_jest_mocks'; +import React from 'react'; +import { type RenderResult, act } from '@testing-library/react'; +import { type Observable, of, BehaviorSubject } from 'rxjs'; +import type { + ChromeNavLink, + ChromeProjectNavigation, + ChromeProjectNavigationNode, +} from '@kbn/core-chrome-browser'; + +import { Navigation } from '../src/ui/components/navigation'; +import type { RootNavigationItemDefinition } from '../src/ui/types'; + +import { renderNavigation, errorHandler, TestType } from './utils'; + +describe('Active node', () => { + 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: '', + }, + ]); + + let activeNodes$: BehaviorSubject; + + const getActiveNodes$ = () => { + activeNodes$ = new BehaviorSubject([ + [ + { + id: 'group1', + title: 'Group 1', + path: ['group1'], + }, + { + id: 'item1', + title: 'Item 1', + path: ['group1', 'item1'], + }, + ], + ]); + + return activeNodes$; + }; + + const runTests = async (type: TestType, { findByTestId }: RenderResult) => { + try { + expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).toMatch( + /nav-item-isActive/ + ); + expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).not.toMatch( + /nav-item-isActive/ + ); + + 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/)).dataset.testSubj).not.toMatch( + /nav-item-isActive/ + ); + expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).toMatch( + /nav-item-isActive/ + ); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'group1', + children: [ + { link: 'item1', title: 'Item 1' }, + { link: 'item2', title: 'Item 2' }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + services: { navLinks$, activeNodes$: getActiveNodes$() }, + }); + + await runTests('treeDef', renderResult); + + renderResult.unmount(); + } + + // -- With UI Components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + link="item1" title="Item 1" /> + link="item2" title="Item 2" /> + + + ), + services: { navLinks$, activeNodes$: getActiveNodes$() }, + }); + + await runTests('uiComponents', renderResult); + } + }); + + test('should override the URL location to set the active node', async () => { + const navLinks$: Observable = of([ + { + id: 'item1', + title: 'Item 1', + baseUrl: '', + url: '', + href: '', + }, + ]); + + let activeNodes$: BehaviorSubject; + + const getActiveNodes$ = () => { + activeNodes$ = new BehaviorSubject([]); + + return activeNodes$; + }; + + const onProjectNavigationChange = (nav: ChromeProjectNavigation) => { + nav.navigationTree.forEach((node) => { + node.children?.forEach((child) => { + if (child.getIsActive?.({} as any)) { + activeNodes$.next([[child]]); + } + }); + }); + }; + + const runTests = async (type: TestType, { findByTestId }: RenderResult) => { + try { + expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).toMatch( + /nav-item-isActive/ + ); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'group1', + children: [ + { + link: 'item1', + title: 'Item 1', + getIsActive: () => { + return true; // Always active + }, + }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + services: { navLinks$, activeNodes$: getActiveNodes$() }, + onProjectNavigationChange, + }); + + await runTests('treeDef', renderResult); + + renderResult.unmount(); + } + + // -- With UI Components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + + link="item1" + title="Item 1" + getIsActive={() => { + return true; + }} + /> + + + ), + onProjectNavigationChange, + services: { navLinks$, activeNodes$: getActiveNodes$() }, + }); + + await runTests('uiComponents', renderResult); + } + }); +}); diff --git a/packages/shared-ux/chrome/navigation/__jest__/build_nav_tree.test.tsx b/packages/shared-ux/chrome/navigation/__jest__/build_nav_tree.test.tsx new file mode 100644 index 0000000000000..4947bcabac552 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/__jest__/build_nav_tree.test.tsx @@ -0,0 +1,738 @@ +/* + * 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 './setup_jest_mocks'; +import React from 'react'; +import { type RenderResult } from '@testing-library/react'; +import { type Observable, of } from 'rxjs'; +import type { ChromeNavLink } from '@kbn/core-chrome-browser'; + +import { navLinksMock } from '../mocks/src/navlinks'; +import { Navigation } from '../src/ui/components/navigation'; +import type { RootNavigationItemDefinition } from '../src/ui/types'; + +import { + getMockFn, + renderNavigation, + errorHandler, + type TestType, + type ProjectNavigationChangeListener, +} from './utils'; + +describe('builds navigation tree', () => { + test('render reference UI and build the navigation tree', async () => { + const onProjectNavigationChange = getMockFn(); + + const runTests = async (type: TestType, { findByTestId }: RenderResult) => { + try { + expect(await findByTestId(/nav-item-group1.item1\s/)).toBeVisible(); + expect(await findByTestId(/nav-item-group1.item2\s/)).toBeVisible(); + expect(await findByTestId(/nav-item-group1.group1A\s/)).toBeVisible(); + expect(await findByTestId(/nav-item-group1.group1A.item1\s/)).toBeVisible(); + expect(await findByTestId(/nav-item-group1.group1A.group1A_1\s/)).toBeVisible(); + + // Click the last group to expand and show the last depth + (await findByTestId(/nav-item-group1.group1A.group1A_1\s/)).click(); + + expect(await findByTestId(/nav-item-group1.group1A.group1A_1.item1/)).toBeVisible(); + + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [{ navigationTree }] = lastCall; + return navigationTree; + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const renderResult = renderNavigation({ + navTreeDef: { + body: [ + { + type: 'navGroup', + id: 'group1', + defaultIsCollapsed: false, + children: [ + { + id: 'item1', + title: 'Item 1', + href: 'https://foo', + }, + { + id: 'item2', + title: 'Item 2', + href: 'https://foo', + }, + { + id: 'group1A', + title: 'Group1A', + defaultIsCollapsed: false, + children: [ + { + id: 'item1', + title: 'Group 1A Item 1', + href: 'https://foo', + }, + { + id: 'group1A_1', + title: 'Group1A_1', + children: [ + { + id: 'item1', + title: 'Group 1A_1 Item 1', + href: 'https://foo', + }, + ], + }, + ], + }, + ], + }, + ], + }, + onProjectNavigationChange, + }); + + const navigationTree = await runTests('treeDef', renderResult); + + expect(navigationTree).toMatchInlineSnapshot(` + Array [ + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://foo", + "id": "item1", + "isActive": false, + "isGroup": false, + "path": Array [ + "group1", + "item1", + ], + "sideNavStatus": "visible", + "title": "Item 1", + }, + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://foo", + "id": "item2", + "isActive": false, + "isGroup": false, + "path": Array [ + "group1", + "item2", + ], + "sideNavStatus": "visible", + "title": "Item 2", + }, + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://foo", + "id": "item1", + "isActive": false, + "isGroup": false, + "path": Array [ + "group1", + "group1A", + "item1", + ], + "sideNavStatus": "visible", + "title": "Group 1A Item 1", + }, + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://foo", + "id": "item1", + "isActive": false, + "isGroup": false, + "path": Array [ + "group1", + "group1A", + "group1A_1", + "item1", + ], + "sideNavStatus": "visible", + "title": "Group 1A_1 Item 1", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "group1A_1", + "isActive": false, + "isGroup": true, + "path": Array [ + "group1", + "group1A", + "group1A_1", + ], + "sideNavStatus": "visible", + "title": "Group1A_1", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "group1A", + "isActive": true, + "isGroup": true, + "path": Array [ + "group1", + "group1A", + ], + "sideNavStatus": "visible", + "title": "Group1A", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "group1", + "isActive": true, + "isGroup": true, + "path": Array [ + "group1", + ], + "sideNavStatus": "visible", + "title": "", + "type": "navGroup", + }, + ] + `); + + onProjectNavigationChange.mockReset(); + renderResult.unmount(); + } + + // -- With UI Components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + + + + + + + + + + + ), + onProjectNavigationChange, + }); + + const navigationTree = await runTests('uiComponents', renderResult); + + expect(navigationTree).toMatchInlineSnapshot(` + Array [ + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://foo", + "id": "item1", + "isActive": false, + "isGroup": false, + "path": Array [ + "group1", + "item1", + ], + "sideNavStatus": "visible", + "title": "Item 1", + }, + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://foo", + "id": "item2", + "isActive": false, + "isGroup": false, + "path": Array [ + "group1", + "item2", + ], + "sideNavStatus": "visible", + "title": "Item 2", + }, + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://foo", + "id": "item1", + "isActive": false, + "isGroup": false, + "path": Array [ + "group1", + "group1A", + "item1", + ], + "sideNavStatus": "visible", + "title": "Group 1A Item 1", + }, + Object { + "children": Array [ + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://foo", + "id": "item1", + "isActive": false, + "isGroup": false, + "path": Array [ + "group1", + "group1A", + "group1A_1", + "item1", + ], + "sideNavStatus": "visible", + "title": "Group 1A_1 Item 1", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "group1A_1", + "isActive": false, + "isGroup": true, + "path": Array [ + "group1", + "group1A", + "group1A_1", + ], + "sideNavStatus": "visible", + "title": "Group1A_1", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "group1A", + "isActive": false, + "isGroup": true, + "path": Array [ + "group1", + "group1A", + ], + "sideNavStatus": "visible", + "title": "Group1A", + }, + ], + "deepLink": undefined, + "href": undefined, + "id": "group1", + "isActive": true, + "isGroup": true, + "path": Array [ + "group1", + ], + "sideNavStatus": "visible", + "title": "", + }, + ] + `); + } + }); + + test('should read the title from deeplink, prop or React children', async () => { + const navLinks$: Observable = of([ + ...navLinksMock, + { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + ]); + + const onProjectNavigationChange = getMockFn(); + + const runTests = (type: TestType) => { + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [{ navigationTree }] = lastCall; + + const groupChildren = navigationTree[0].children?.[0].children; + + if (!groupChildren) { + throw new Error('Expected group children to be defined'); + } + + try { + expect(groupChildren[0].title).toBe('Title from deeplink'); + expect(groupChildren[1].title).toBe('Overwrite deeplink title'); + expect(groupChildren[2].title).toBe('Title in props'); // Unknown deeplink, has not been rendered + } catch (e) { + errorHandler(type)(e); + } + + return groupChildren; + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'root', + children: [ + { + id: 'group1', + children: [ + { + id: 'item1', + link: 'item1', // Title from deeplink + }, + { + id: 'item2', + link: 'item1', // Overwrite title from deeplink + title: 'Overwrite deeplink title', + }, + { + id: 'item3', + title: 'Title in props', + }, + { + id: 'item4', + link: 'unknown', // Unknown deeplink + title: 'Should not be rendered', + }, + ], + }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + services: { navLinks$ }, + onProjectNavigationChange, + }); + + const groupChildren = runTests('treeDef'); + expect(groupChildren.length).toBe(3); + expect(groupChildren[3]).toBeUndefined(); // Unknown deeplink, has not been rendered + + onProjectNavigationChange.mockReset(); + renderResult.unmount(); + } + + // -- With UI components + { + renderNavigation({ + navigationElement: ( + + + + {/* Title from deeplink */} + id="item1" link="item1" /> + id="item2" link="item1" title="Overwrite deeplink title" /> + + id="item4" link="unknown" title="Should not be rendered" /> + Title in children + + + + ), + services: { navLinks$ }, + onProjectNavigationChange, + }); + + const groupChildren = runTests('uiComponents'); + expect(groupChildren.length).toBe(4); + // "item4" has been skipped as it is an unknown deeplink and we have the next item in the list + expect(groupChildren[3].title).toBe('Title in children'); + } + }); + + test('should not render the group if it does not have children AND no href or deeplink', async () => { + const navLinks$: Observable = of([ + { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + ]); + const onProjectNavigationChange = getMockFn(); + + const runTests = (type: TestType, { queryByTestId }: RenderResult) => { + expect(onProjectNavigationChange).toHaveBeenCalled(); + + try { + // Check the DOM + expect(queryByTestId(/nav-group-root.group1/)).toBeNull(); + expect(queryByTestId(/nav-item-root.group2.item1/)).toBeVisible(); + + // Check the navigation tree + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [navTree] = lastCall; + const [rootNode] = navTree.navigationTree; + expect(rootNode.id).toBe('root'); + expect(rootNode.children?.length).toBe(2); + expect(rootNode.children?.[0]?.id).toBe('group1'); + expect(rootNode.children?.[0]?.children).toBeUndefined(); // No children mounted and registered itself + expect(rootNode.children?.[1]?.id).toBe('group2'); + return navTree; + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'root', + isCollapsible: false, + children: [ + { + id: 'group1', + children: [{ link: 'notRegistered' }], + }, + { + id: 'group2', + children: [{ link: 'item1' }], + }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + services: { navLinks$ }, + onProjectNavigationChange, + }); + + await runTests('treeDef', renderResult); + + onProjectNavigationChange.mockReset(); + renderResult.unmount(); + } + + // -- With UI components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + + link="notRegistered" /> + + + link="item1" /> + + + + ), + services: { navLinks$ }, + onProjectNavigationChange, + }); + + await runTests('uiComponents', renderResult); + } + }); + + test('should render group preset (analytics, ml...)', async () => { + const onProjectNavigationChange = getMockFn(); + + const runTests = async (type: TestType) => { + try { + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [navTreeGenerated] = lastCall; + + expect(navTreeGenerated).toEqual({ + navigationTree: expect.any(Array), + }); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'preset', + preset: 'analytics', + }, + { + type: 'preset', + preset: 'ml', + }, + { + type: 'preset', + preset: 'devtools', + }, + { + type: 'preset', + preset: 'management', + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + onProjectNavigationChange, + }); + + await runTests('treeDef'); + + renderResult.unmount(); + onProjectNavigationChange.mockReset(); + } + + // -- With UI Components + { + renderNavigation({ + navigationElement: ( + + + + + + + ), + onProjectNavigationChange, + }); + + await runTests('uiComponents'); + } + }); + + test('should render recently accessed items', async () => { + const recentlyAccessed$ = of([ + { label: 'This is an example', link: '/app/example/39859', id: '39850' }, + { label: 'Another example', link: '/app/example/5235', id: '5235' }, + ]); + + const runTests = async (type: TestType, { findByTestId }: RenderResult) => { + try { + expect(await findByTestId('nav-bucket-recentlyAccessed')).toBeVisible(); + expect((await findByTestId('nav-bucket-recentlyAccessed')).textContent).toBe( + 'RecentThis is an exampleAnother example' + ); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'recentlyAccessed', + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + services: { recentlyAccessed$ }, + }); + + await runTests('treeDef', renderResult); + renderResult.unmount(); + } + + // -- With UI Components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + + + + + + ), + services: { recentlyAccessed$ }, + }); + + await runTests('uiComponents', renderResult); + } + }); + + test('should render the cloud links', async () => { + const runTests = async (type: TestType, { findByTestId }: RenderResult) => { + try { + expect(await findByTestId(/nav-item-group1.cloudLink1/)).toBeVisible(); + expect(await findByTestId(/nav-item-group1.cloudLink2/)).toBeVisible(); + expect(await findByTestId(/nav-item-group1.cloudLink3/)).toBeVisible(); + + expect((await findByTestId(/nav-item-group1.cloudLink1/)).textContent).toBe( + 'Mock Users & RolesExternal link' + ); + expect((await findByTestId(/nav-item-group1.cloudLink2/)).textContent).toBe( + 'Mock PerformanceExternal link' + ); + expect((await findByTestId(/nav-item-group1.cloudLink3/)).textContent).toBe( + 'Mock Billing & SubscriptionsExternal link' + ); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'group1', + defaultIsCollapsed: false, + children: [ + { id: 'cloudLink1', cloudLink: 'userAndRoles' }, + { id: 'cloudLink2', cloudLink: 'performance' }, + { id: 'cloudLink3', cloudLink: 'billingAndSub' }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + }); + + await runTests('treeDef', renderResult); + renderResult.unmount(); + } + + // -- With UI Components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + + + + + + ), + }); + + await runTests('uiComponents', renderResult); + } + }); +}); diff --git a/packages/shared-ux/chrome/navigation/__jest__/links.test.tsx b/packages/shared-ux/chrome/navigation/__jest__/links.test.tsx new file mode 100644 index 0000000000000..56da3d4494c89 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/__jest__/links.test.tsx @@ -0,0 +1,234 @@ +/* + * 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 './setup_jest_mocks'; +import React from 'react'; +import { type RenderResult } from '@testing-library/react'; +import { type Observable, of } from 'rxjs'; +import type { ChromeNavLink } from '@kbn/core-chrome-browser'; + +import { Navigation } from '../src/ui/components/navigation'; +import type { RootNavigationItemDefinition } from '../src/ui/types'; + +import { + getMockFn, + renderNavigation, + errorHandler, + TestType, + type ProjectNavigationChangeListener, +} from './utils'; + +describe('Links', () => { + test('should filter out unknown deeplinks', async () => { + const onProjectNavigationChange = getMockFn(); + const unknownLinkId = 'unknown'; + + const navLinks$: Observable = of([ + { + id: 'item1', + title: 'Title from deeplink', + baseUrl: '', + url: '', + href: '', + }, + ]); + + const runTests = async (type: TestType, { findByTestId, queryByTestId }: RenderResult) => { + try { + expect(await queryByTestId(new RegExp(`nav-item-root.group1.${unknownLinkId}`))).toBeNull(); + expect(await findByTestId(/nav-item-root.group1.item1/)).toBeVisible(); + expect(await findByTestId(/nav-item-root.group1.item1/)).toBeVisible(); + + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [{ navigationTree }] = lastCall; + const [root] = navigationTree; + expect(root.id).toBe('root'); + expect(root.children?.length).toBe(1); + expect(root.children?.[0].children?.length).toBe(1); + expect(root.children?.[0].children?.[0].id).toBe('item1'); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'root', + defaultIsCollapsed: false, + children: [ + { + id: 'group1', + defaultIsCollapsed: false, + children: [ + { + link: 'item1', + }, + { + link: unknownLinkId, + }, + ], + }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + onProjectNavigationChange, + services: { navLinks$ }, + }); + + await runTests('treeDef', renderResult); + + renderResult.unmount(); + onProjectNavigationChange.mockReset(); + } + + // -- With UI Components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + + link="item1" /> + {/* Should be removed */} + link={unknownLinkId} title="Should NOT be there" /> + + + + ), + onProjectNavigationChange, + services: { navLinks$ }, + }); + + await runTests('uiComponents', renderResult); + } + }); + + test('should allow href for absolute links', async () => { + const onProjectNavigationChange = getMockFn(); + + const runTests = async (type: TestType, { debug }: RenderResult) => { + try { + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [{ navigationTree }] = lastCall; + + const [root] = navigationTree; + expect(root.children?.[0].href).toBe('https://example.com'); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'root', + defaultIsCollapsed: false, + children: [ + { + id: 'item1', + title: 'Item 1', + href: 'https://example.com', + }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + onProjectNavigationChange, + }); + + await runTests('treeDef', renderResult); + + renderResult.unmount(); + onProjectNavigationChange.mockReset(); + } + + // -- With UI Components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + + + + ), + onProjectNavigationChange, + }); + + await runTests('uiComponents', renderResult); + } + }); + + test('should throw if href is not an absolute links', async () => { + // We'll mock the console.error to avoid dumping the (expected) error in the console + // source: https://github.com/jestjs/jest/pull/5267#issuecomment-356605468 + jest.spyOn(console, 'error'); + // @ts-expect-error we're mocking the console so "mockImplementation" exists + // eslint-disable-next-line no-console + console.error.mockImplementation(() => {}); + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'root', + defaultIsCollapsed: false, + children: [ + { + id: 'item1', + title: 'Item 1', + href: '../dashboards', + }, + ], + }, + ]; + + const expectToThrow = () => { + renderNavigation({ + navTreeDef: { body: navigationBody }, + }); + }; + + expect(expectToThrow).toThrowError('href must be an absolute URL. Node id [item1].'); + } + + // -- With UI Components + { + const expectToThrow = () => { + renderNavigation({ + navigationElement: ( + + + + + + ), + }); + }; + + expect(expectToThrow).toThrowError('href must be an absolute URL. Node id [item1].'); + // @ts-expect-error we're mocking the console so "mockImplementation" exists + // eslint-disable-next-line no-console + console.error.mockRestore(); + } + }); +}); diff --git a/packages/shared-ux/chrome/navigation/__jest__/panel.test.tsx b/packages/shared-ux/chrome/navigation/__jest__/panel.test.tsx new file mode 100644 index 0000000000000..40641eb31d2d2 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/__jest__/panel.test.tsx @@ -0,0 +1,546 @@ +/* + * 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 './setup_jest_mocks'; +import React from 'react'; +import { type RenderResult } from '@testing-library/react'; +import { BehaviorSubject } from 'rxjs'; +import type { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; + +import { Navigation } from '../src/ui/components/navigation'; +import type { RootNavigationItemDefinition } from '../src/ui/types'; + +import { + renderNavigation, + errorHandler, + TestType, + getMockFn, + ProjectNavigationChangeListener, +} from './utils'; +import { PanelContentProvider } from '../src/ui'; + +describe('Panel', () => { + test('should render group as panel opener', async () => { + const onProjectNavigationChange = getMockFn(); + + const runTests = async (type: TestType, { findByTestId, queryByTestId }: RenderResult) => { + try { + expect(await findByTestId(/panelOpener-root.group1/)).toBeVisible(); + expect(queryByTestId(/sideNavPanel/)).toBeNull(); + (await findByTestId(/panelOpener-root.group1/)).click(); // open the panel + expect(queryByTestId(/sideNavPanel/)).toBeVisible(); + + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [{ navigationTree }] = lastCall; + + const [root] = navigationTree; + expect(root.id).toBe('root'); + expect(root.children?.[0]).toMatchObject({ + id: 'group1', + renderAs: 'panelOpener', + children: [ + { + id: 'management', + title: 'Deeplink management', + }, + ], + }); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: RootNavigationItemDefinition[] = [ + { + type: 'navGroup', + id: 'root', + isCollapsible: false, + children: [ + { + id: 'group1', + link: 'dashboards', + renderAs: 'panelOpener', + children: [{ link: 'management' }], + }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + onProjectNavigationChange, + }); + + await runTests('treeDef', renderResult); + + renderResult.unmount(); + } + + // -- With UI Components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + + + + + + ), + onProjectNavigationChange, + }); + await runTests('uiComponents', renderResult); + } + }); + + test('should not render group if all children are hidden', async () => { + const onProjectNavigationChange = getMockFn(); + + const runTests = async (type: TestType, { queryByTestId }: RenderResult) => { + try { + expect(queryByTestId(/panelOpener-root.group1/)).toBeNull(); + expect(queryByTestId(/panelOpener-root.group2/)).toBeNull(); + expect(queryByTestId(/panelOpener-root.group3/)).toBeVisible(); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'root', + isCollapsible: false, + children: [ + { + id: 'group1', + link: 'dashboards', + renderAs: 'panelOpener', + children: [{ link: 'unknown' }], + }, + { + id: 'group2', + link: 'dashboards', + renderAs: 'panelOpener', + children: [{ link: 'management', sideNavStatus: 'hidden' }], + }, + { + id: 'group3', + link: 'dashboards', + renderAs: 'panelOpener', + children: [{ link: 'management' }], // sideNavStatus is "visible" by default + }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + onProjectNavigationChange, + }); + + await runTests('treeDef', renderResult); + + renderResult.unmount(); + } + + // -- With UI Components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + + link="unknown" /> + + + + + + + + + + ), + onProjectNavigationChange, + }); + await runTests('uiComponents', renderResult); + } + }); + + describe('custom content', () => { + test('should render custom component inside the panel', async () => { + const panelContentProvider: PanelContentProvider = (id) => { + return { + content: ({ closePanel, selectedNode, activeNodes }) => { + const [path0 = []] = activeNodes; + return ( +
+

{selectedNode.id}

+
    + {path0.map((node) => ( +
  • {node.id}
  • + ))} +
+ +
+ ); + }, + }; + }; + + const activeNodes$ = new BehaviorSubject([ + [ + { + id: 'activeGroup1', + title: 'Group 1', + path: ['activeGroup1'], + }, + { + id: 'activeItem1', + title: 'Item 1', + path: ['activeGroup1', 'activeItem1'], + }, + ], + ]); + + const runTests = async (type: TestType, { queryByTestId }: RenderResult) => { + try { + queryByTestId(/panelOpener-root.group1/)?.click(); // open the panel + + expect(queryByTestId(/customPanelContent/)).toBeVisible(); + // Test that the selected node is correclty passed + expect(queryByTestId(/customPanelSelectedNode/)?.textContent).toBe('root.group1'); + // Test that the active nodes are correclty passed + expect(queryByTestId(/customPanelActiveNodes/)?.textContent).toBe( + 'activeGroup1activeItem1' + ); + // Test that handler to close the panel is correctly passed + queryByTestId(/customPanelCloseBtn/)?.click(); // close the panel + expect(queryByTestId(/customPanelContent/)).toBeNull(); + expect(queryByTestId(/sideNavPanel/)).toBeNull(); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'root', + isCollapsible: false, + children: [ + { + id: 'group1', + link: 'dashboards', + renderAs: 'panelOpener', + children: [{ link: 'management' }], + }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + panelContentProvider, + services: { activeNodes$ }, + }); + + await runTests('treeDef', renderResult); + + renderResult.unmount(); + } + + // -- With UI Components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + + + + + + ), + services: { activeNodes$ }, + }); + await runTests('uiComponents', renderResult); + } + }); + }); + + describe('auto generated content', () => { + test('should rendre block groups with title', async () => { + const runTests = async ( + type: TestType, + { queryByTestId, queryAllByTestId }: RenderResult + ) => { + try { + queryByTestId(/panelOpener-root.group1/)?.click(); // open the panel + + expect(queryByTestId(/panelGroupId-foo/)).toBeVisible(); + expect(queryByTestId(/panelGroupTitleId-foo/)?.textContent).toBe('Foo'); + + const panelNavItems = queryAllByTestId(/panelNavItem/); + expect(panelNavItems.length).toBe(2); // "item2" has been filtered out as it is hidden + expect(panelNavItems.map(({ textContent }) => textContent?.trim())).toEqual([ + 'Item 1', + 'Item 3', + ]); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'root', + isCollapsible: false, + children: [ + { + id: 'group1', + link: 'dashboards', + renderAs: 'panelOpener', + children: [ + { + id: 'foo', + title: 'Foo', + children: [ + { id: 'item1', link: 'management', title: 'Item 1' }, + { id: 'item2', link: 'management', title: 'Item 2', sideNavStatus: 'hidden' }, + { id: 'item3', link: 'management', title: 'Item 3' }, + ], + }, + ], + }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + }); + + await runTests('treeDef', renderResult); + + renderResult.unmount(); + } + + // -- With UI Components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + + + + + + + + + + ), + }); + await runTests('uiComponents', renderResult); + } + }); + + test('should rendre block groups without title', async () => { + const runTests = async ( + type: TestType, + { queryByTestId, queryAllByTestId }: RenderResult + ) => { + try { + queryByTestId(/panelOpener-root.group1/)?.click(); // open the panel + + expect(queryByTestId(/panelGroupTitleId-foo/)).toBeNull(); // No title rendered + + const panelNavItems = queryAllByTestId(/panelNavItem/); + expect(panelNavItems.length).toBe(2); // "item2" has been filtered out as it is hidden + expect(panelNavItems.map(({ textContent }) => textContent?.trim())).toEqual([ + 'Item 1', + 'Item 3', + ]); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'root', + isCollapsible: false, + children: [ + { + id: 'group1', + link: 'dashboards', + renderAs: 'panelOpener', + children: [ + { + id: 'foo', + children: [ + { id: 'item1', link: 'management', title: 'Item 1' }, + { id: 'item2', link: 'management', title: 'Item 2', sideNavStatus: 'hidden' }, + { id: 'item3', link: 'management', title: 'Item 3' }, + ], + }, + ], + }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + }); + + await runTests('treeDef', renderResult); + + renderResult.unmount(); + } + + // -- With UI Components + { + // const renderResult = renderNavigation({ + // navigationElement: ( + // + // + // + // + // + // + // + // + // + // + // + // ), + // }); + // await runTests('uiComponents', renderResult); + } + }); + + test('should rendre accordion groups', async () => { + const runTests = async ( + type: TestType, + { queryByTestId, queryAllByTestId, findByRole }: RenderResult + ) => { + try { + queryByTestId(/panelOpener-root.group1/)?.click(); // open the panel + + expect(queryByTestId(/panelGroupId-foo/)).toBeVisible(); + + const panelNavItems = queryAllByTestId(/panelNavItem/); + expect(panelNavItems.length).toBe(2); // "item2" has been filtered out as it is hidden + + expect(queryByTestId(/panelNavItem-id-item1/)).not.toBeVisible(); // Accordion is collapsed + expect(queryByTestId(/panelNavItem-id-item3/)).not.toBeVisible(); // Accordion is collapsed + + queryByTestId(/panelAccordionBtnId-foo/)?.click(); // Expand accordion + + expect(queryByTestId(/panelNavItem-id-item1/)).toBeVisible(); + expect(queryByTestId(/panelNavItem-id-item3/)).toBeVisible(); + } catch (e) { + errorHandler(type)(e); + } + }; + + // -- Default navigation + { + const navigationBody: Array> = [ + { + type: 'navGroup', + id: 'root', + isCollapsible: false, + children: [ + { + id: 'group1', + link: 'dashboards', + renderAs: 'panelOpener', + children: [ + { + id: 'foo', + title: 'Foo', + renderAs: 'accordion', + children: [ + { id: 'item1', link: 'management', title: 'Item 1' }, + { id: 'item2', link: 'management', title: 'Item 2', sideNavStatus: 'hidden' }, + { id: 'item3', link: 'management', title: 'Item 3' }, + ], + }, + ], + }, + ], + }, + ]; + + const renderResult = renderNavigation({ + navTreeDef: { body: navigationBody }, + }); + + await runTests('treeDef', renderResult); + + renderResult.unmount(); + } + + // -- With UI Components + { + const renderResult = renderNavigation({ + navigationElement: ( + + + + + + + + + + + + ), + }); + await runTests('uiComponents', renderResult); + } + }); + }); +}); diff --git a/packages/shared-ux/chrome/navigation/__jest__/project_navigation.test.tsx b/packages/shared-ux/chrome/navigation/__jest__/project_navigation.test.tsx new file mode 100644 index 0000000000000..7b47c466b838b --- /dev/null +++ b/packages/shared-ux/chrome/navigation/__jest__/project_navigation.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 './setup_jest_mocks'; +import { type Observable, of } from 'rxjs'; +import type { ChromeNavLink } from '@kbn/core-chrome-browser'; + +import { navLinksMock } from '../mocks/src/navlinks'; +import type { ProjectNavigationTreeDefinition } from '../src/ui/types'; + +import { getMockFn, renderNavigation, type ProjectNavigationChangeListener } from './utils'; + +describe('Default navigation', () => { + /** + * INFO: the navigation system support providing "just" the serverless project navigation and we + * render all the rest (other sections, footer, recently accessed...) + * For now, none of the serverless project uses this feature as they all have completely different navs + */ + test('builds the full navigation tree when only the project is provided', async () => { + const onProjectNavigationChange = getMockFn(); + const navLinks$: Observable = of([ + ...navLinksMock, + { + id: 'item2', + title: 'Title from deeplink!', + baseUrl: '', + url: '', + href: '', + }, + ]); + + const projectNavigationTree: ProjectNavigationTreeDefinition = [ + { + id: 'group1', + title: 'Group 1', + children: [ + { + id: 'item1', + title: 'Item 1', + }, + { + id: 'item2', + link: 'item2', // Title from deeplink + }, + { + id: 'item3', + link: 'item2', + title: 'Deeplink title overriden', // Override title from deeplink + }, + { + link: 'disabled', + title: 'Should NOT be there', + }, + ], + }, + ]; + + renderNavigation({ + projectNavigationTree, + onProjectNavigationChange, + services: { navLinks$ }, + }); + + expect(onProjectNavigationChange).toHaveBeenCalled(); + const lastCall = + onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; + const [navTreeGenerated] = lastCall; + + expect(navTreeGenerated).toEqual({ + navigationTree: expect.any(Array), + }); + + // The project navigation tree passed + expect(navTreeGenerated.navigationTree).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-ux/chrome/navigation/__jest__/setup_jest_mocks.ts b/packages/shared-ux/chrome/navigation/__jest__/setup_jest_mocks.ts new file mode 100644 index 0000000000000..9d41d7f29236a --- /dev/null +++ b/packages/shared-ux/chrome/navigation/__jest__/setup_jest_mocks.ts @@ -0,0 +1,22 @@ +/* + * 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 { useEffect } from 'react'; + +const mockUseEffect = useEffect; + +// Replace useDebounce() with a normal useEffect() +jest.mock('react-use/lib/useDebounce', () => { + return (cb: () => void, ms: number, deps: any[]) => { + mockUseEffect(() => { + cb(); + }, deps); + }; +}); + +export {}; diff --git a/packages/shared-ux/chrome/navigation/__jest__/utils.tsx b/packages/shared-ux/chrome/navigation/__jest__/utils.tsx new file mode 100644 index 0000000000000..547f71277683b --- /dev/null +++ b/packages/shared-ux/chrome/navigation/__jest__/utils.tsx @@ -0,0 +1,75 @@ +/* + * 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 React from 'react'; +import { render, type RenderResult } from '@testing-library/react'; +import type { ChromeProjectNavigation } from '@kbn/core-chrome-browser'; + +import { EuiThemeProvider } from '@elastic/eui'; +import { getServicesMock } from '../mocks/src/jest'; +import { NavigationProvider } from '../src/services'; +import { DefaultNavigation } from '../src/ui/default_navigation'; +import type { PanelContentProvider } from '../src/ui'; +import type { NavigationTreeDefinition, ProjectNavigationTreeDefinition } from '../src/ui/types'; +import { NavigationServices } from '../types'; + +const services = getServicesMock(); + +export type ProjectNavigationChangeListener = (projectNavigation: ChromeProjectNavigation) => void; +export type TestType = 'treeDef' | 'uiComponents'; + +export const renderNavigation = ({ + navTreeDef, + projectNavigationTree, + navigationElement, + services: overrideServices = {}, + onProjectNavigationChange = () => undefined, + panelContentProvider, +}: { + navTreeDef?: NavigationTreeDefinition; + projectNavigationTree?: ProjectNavigationTreeDefinition; + navigationElement?: React.ReactElement; + services?: Partial; + onProjectNavigationChange?: ProjectNavigationChangeListener; + panelContentProvider?: PanelContentProvider; +}): RenderResult => { + const element = navigationElement ?? ( + + ); + + const renderResult = render( + + + {element} + + + ); + + return renderResult; +}; + +export const errorHandler = (type: TestType) => (e: Error) => { + const err = new Error(`Failed to run tests for ${type}.`); + err.stack = e.stack; + // eslint-disable-next-line no-console + console.error(err.message); + throw err; +}; + +type ArgsType = T extends (...args: infer A) => any ? A : never; + +export function getMockFn any>() { + return jest.fn() as jest.Mock>; +} diff --git a/packages/shared-ux/chrome/navigation/index.ts b/packages/shared-ux/chrome/navigation/index.ts index 0659fe0461664..1d2c89f25e4d6 100644 --- a/packages/shared-ux/chrome/navigation/index.ts +++ b/packages/shared-ux/chrome/navigation/index.ts @@ -21,6 +21,7 @@ export type { RootNavigationItemDefinition, PanelComponentProps, PanelContent, + PanelContentProvider, } from './src/ui'; export type { NavigationServices } from './types'; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/index.ts b/packages/shared-ux/chrome/navigation/src/ui/components/index.ts index 29de9b8d21dad..c4e66fcb72e75 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/index.ts +++ b/packages/shared-ux/chrome/navigation/src/ui/components/index.ts @@ -8,4 +8,8 @@ export { Navigation } from './navigation'; export type { Props as RecentlyAccessedProps } from './recently_accessed'; -export type { PanelContent, PanelComponentProps } from './panel'; +export type { + PanelContent, + PanelComponentProps, + ContentProvider as PanelContentProvider, +} from './panel'; 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 deleted file mode 100644 index 16a952281aec2..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation.test.tsx +++ /dev/null @@ -1,894 +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. - */ - -import type { - ChromeNavLink, - ChromeProjectNavigation, - ChromeProjectNavigationNode, -} from '@kbn/core-chrome-browser'; -import { render } from '@testing-library/react'; -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { BehaviorSubject, of, type Observable } from 'rxjs'; -import { EuiThemeProvider } from '@elastic/eui'; - -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(); - - const { findByTestId } = render( - - - - - - - - - - - - - - - - - ); - - 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\s/)).toBeVisible(); - expect(await findByTestId(/nav-item-group1.group1A.item1/)).toBeVisible(); - expect(await findByTestId(/nav-item-group1.group1A.group1A_1\s/)).toBeVisible(); - - // Click the last group to expand and show the last depth - (await findByTestId(/nav-item-group1.group1A.group1A_1\s/)).click(); - - expect(await findByTestId(/nav-item-group1.group1A.group1A_1.item1/)).toBeVisible(); - - expect(onProjectNavigationChange).toHaveBeenCalled(); - const lastCall = - onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; - const [navTree] = lastCall; - - expect(navTree.navigationTree).toMatchInlineSnapshot(` - Array [ - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": undefined, - "href": "https://foo", - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "group1", - "item1", - ], - "sideNavStatus": "visible", - "title": "Item 1", - }, - Object { - "children": undefined, - "deepLink": undefined, - "href": "https://foo", - "id": "item2", - "isActive": false, - "isGroup": false, - "path": Array [ - "group1", - "item2", - ], - "sideNavStatus": "visible", - "title": "Item 2", - }, - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": undefined, - "href": "https://foo", - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "group1", - "group1A", - "item1", - ], - "sideNavStatus": "visible", - "title": "Group 1A Item 1", - }, - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": undefined, - "href": "https://foo", - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "group1", - "group1A", - "group1A_1", - "item1", - ], - "sideNavStatus": "visible", - "title": "Group 1A_1 Item 1", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group1A_1", - "isActive": true, - "isGroup": true, - "path": Array [ - "group1", - "group1A", - "group1A_1", - ], - "renderAs": "accordion", - "sideNavStatus": "visible", - "title": "Group1A_1", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group1A", - "isActive": true, - "isGroup": true, - "path": Array [ - "group1", - "group1A", - ], - "renderAs": "accordion", - "sideNavStatus": "visible", - "title": "Group1A", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group1", - "isActive": true, - "isGroup": true, - "path": Array [ - "group1", - ], - "renderAs": "accordion", - "sideNavStatus": "visible", - "title": "", - }, - ] - `); - }); - - test('should read the title from props, children or deeplink', async () => { - const navLinks$: Observable = of([ - { - id: 'item1', - title: 'Title from deeplink', - baseUrl: '', - url: '', - href: '', - }, - ]); - - const onProjectNavigationChange = jest.fn(); - - render( - - - - - - {/* Title from deeplink */} - id="item1" link="item1" /> - id="item2" link="item1" title="Overwrite deeplink title" /> - - Title in children - - - - - - ); - - 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.navigationTree).toMatchInlineSnapshot(` - Array [ - Object { - "children": Array [ - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "", - "href": "", - "id": "item1", - "title": "Title from deeplink", - "url": "", - }, - "href": undefined, - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "root", - "group1", - "item1", - ], - "sideNavStatus": "visible", - "title": "Title from deeplink", - }, - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "", - "href": "", - "id": "item1", - "title": "Title from deeplink", - "url": "", - }, - "href": undefined, - "id": "item2", - "isActive": false, - "isGroup": false, - "path": Array [ - "root", - "group1", - "item2", - ], - "sideNavStatus": "visible", - "title": "Overwrite deeplink title", - }, - Object { - "children": undefined, - "deepLink": undefined, - "href": undefined, - "id": "item3", - "isActive": false, - "isGroup": false, - "path": Array [ - "root", - "group1", - "item3", - ], - "sideNavStatus": "visible", - "title": "Title in props", - }, - Object { - "children": undefined, - "deepLink": undefined, - "href": undefined, - "id": "item4", - "isActive": false, - "isGroup": false, - "path": Array [ - "root", - "group1", - "item4", - ], - "sideNavStatus": "visible", - "title": "Title in children", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group1", - "isActive": false, - "isGroup": true, - "path": Array [ - "root", - "group1", - ], - "sideNavStatus": "visible", - "title": "", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "root", - "isActive": false, - "isGroup": true, - "path": Array [ - "root", - ], - "sideNavStatus": "visible", - "title": "", - }, - ] - `); - }); - - test('should filter out unknown deeplinks', async () => { - const navLinks$: Observable = of([ - { - id: 'item1', - title: 'Title from deeplink', - baseUrl: '', - url: '', - href: '', - }, - ]); - - const onProjectNavigationChange = jest.fn(); - - const { findByTestId } = render( - - - - - - {/* Title from deeplink */} - id="item1" link="item1" /> - {/* Should not appear */} - - id="unknownLink" - link="unknown" - title="Should NOT be there" - /> - - - - - - ); - - 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(); - - expect(onProjectNavigationChange).toHaveBeenCalled(); - const lastCall = - onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; - const [navTree] = lastCall; - - expect(navTree.navigationTree).toMatchInlineSnapshot(` - Array [ - Object { - "children": Array [ - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "", - "href": "", - "id": "item1", - "title": "Title from deeplink", - "url": "", - }, - "href": undefined, - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "root", - "group1", - "item1", - ], - "sideNavStatus": "visible", - "title": "Title from deeplink", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group1", - "isActive": true, - "isGroup": true, - "path": Array [ - "root", - "group1", - ], - "sideNavStatus": "visible", - "title": "", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "root", - "isActive": true, - "isGroup": true, - "path": Array [ - "root", - ], - "sideNavStatus": "visible", - "title": "", - }, - ] - `); - }); - - test('should not render the group if it does not have children AND no href or deeplink', async () => { - const navLinks$: Observable = of([ - { - id: 'item1', - title: 'Title from deeplink', - baseUrl: '', - url: '', - href: '', - }, - ]); - const onProjectNavigationChange = jest.fn(); - - const { queryByTestId } = render( - - - - - - id="item1" link="notRegistered" /> - - - id="item1" link="item1" /> - - - - - - ); - - await act(async () => { - jest.advanceTimersByTime(SET_NAVIGATION_DELAY); - }); - - expect(queryByTestId(/nav-group-root.group1/)).toBeNull(); - expect(queryByTestId(/nav-item-root.group2.item1/)).toBeVisible(); - - expect(onProjectNavigationChange).toHaveBeenCalled(); - const lastCall = - onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; - const [navTree] = lastCall; - - expect(navTree.navigationTree).toMatchInlineSnapshot(` - Array [ - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": undefined, - "href": undefined, - "id": "group1", - "isActive": true, - "isGroup": true, - "path": Array [ - "root", - "group1", - ], - "sideNavStatus": "visible", - "title": "", - }, - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "", - "href": "", - "id": "item1", - "title": "Title from deeplink", - "url": "", - }, - "href": undefined, - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "root", - "group2", - "item1", - ], - "sideNavStatus": "visible", - "title": "Title from deeplink", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group2", - "isActive": true, - "isGroup": true, - "path": Array [ - "root", - "group2", - ], - "sideNavStatus": "visible", - "title": "", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "root", - "isActive": true, - "isGroup": true, - "path": Array [ - "root", - ], - "sideNavStatus": "visible", - "title": "", - }, - ] - `); - }); - - test('should render group preset (analytics, ml...)', async () => { - const onProjectNavigationChange = jest.fn(); - - render( - - - - - - - - - - - ); - - 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: expect.any(Array), - }); - }); - - test('should render recently accessed items', async () => { - const recentlyAccessed$ = of([ - { label: 'This is an example', link: '/app/example/39859', id: '39850' }, - { label: 'Another example', link: '/app/example/5235', id: '5235' }, - ]); - - const { findByTestId } = render( - - - - - - - - - - - - ); - - 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 allow href for absolute links', async () => { - const onProjectNavigationChange = jest.fn(); - - render( - - - - - - - - - - ); - - 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.navigationTree).toMatchInlineSnapshot(` - Array [ - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": undefined, - "href": "https://example.com", - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "group1", - "item1", - ], - "sideNavStatus": "visible", - "title": "Item 1", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group1", - "isActive": false, - "isGroup": true, - "path": Array [ - "group1", - ], - "sideNavStatus": "visible", - "title": "", - }, - ] - `); - }); - - test('should throw if href is not an absolute links', async () => { - // We'll mock the console.error to avoid dumping the (expected) error in the console - // source: https://github.com/jestjs/jest/pull/5267#issuecomment-356605468 - jest.spyOn(console, 'error'); - // @ts-expect-error we're mocking the console so "mockImplementation" exists - // eslint-disable-next-line no-console - console.error.mockImplementation(() => {}); - - const onProjectNavigationChange = jest.fn(); - - const expectToThrow = () => { - render( - - - - - - - - - - ); - }; - - expect(expectToThrow).toThrowError('href must be an absolute URL. Node id [item1].'); - // @ts-expect-error we're mocking the console so "mockImplementation" exists - // 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/)).dataset.testSubj).toMatch( - /nav-item-isActive/ - ); - expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).not.toMatch( - /nav-item-isActive/ - ); - - 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/)).dataset.testSubj).not.toMatch( - /nav-item-isActive/ - ); - expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).toMatch( - /nav-item-isActive/ - ); - }); - - 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/)).dataset.testSubj).toMatch( - /nav-item-isActive/ - ); - }); - }); - - describe('cloud links', () => { - test('render the cloud link', async () => { - const onProjectNavigationChange = jest.fn(); - - const { findByTestId } = render( - - - - - - - - - - - - ); - - expect(await findByTestId(/nav-item-group1.cloudLink1/)).toBeVisible(); - expect(await findByTestId(/nav-item-group1.cloudLink2/)).toBeVisible(); - expect(await findByTestId(/nav-item-group1.cloudLink3/)).toBeVisible(); - - expect((await findByTestId(/nav-item-group1.cloudLink1/)).textContent).toBe( - 'Mock Users & RolesExternal link' - ); - expect((await findByTestId(/nav-item-group1.cloudLink2/)).textContent).toBe( - 'Mock PerformanceExternal link' - ); - expect((await findByTestId(/nav-item-group1.cloudLink3/)).textContent).toBe( - 'Mock Billing & SubscriptionsExternal link' - ); - }); - }); -}); 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 83dc748047a93..8f74abee6a110 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation.tsx @@ -93,8 +93,8 @@ export function Navigation({ }); }, []); - const register = useCallback( - (navNode: ChromeProjectNavigationNode) => { + const register = useCallback( + (navNode) => { if (orderChildrenRef.current[navNode.id] === undefined) { orderChildrenRef.current[navNode.id] = idx.current++; } 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 8fc2a2adf800a..d91c59d6988ef 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 @@ -60,6 +60,15 @@ const getRenderAs = (navNode: ChromeProjectNavigationNode): RenderAs => { return 'block'; }; +const getTestSubj = (navNode: ChromeProjectNavigationNode, isActive = false): string => { + const { id, deepLink } = navNode; + return classnames(`nav-item`, `nav-item-${id}`, { + [`nav-item-deepLinkId-${deepLink?.id}`]: !!deepLink, + [`nav-item-id-${id}`]: id, + [`nav-item-isActive`]: isActive, + }); +}; + const filterChildren = ( children?: ChromeProjectNavigationNode[] ): ChromeProjectNavigationNode[] | undefined => { @@ -94,15 +103,18 @@ const isEuiCollapsibleNavItemProps = ( }; const renderBlockTitle: ( - { title }: ChromeProjectNavigationNode, + navNode: ChromeProjectNavigationNode, { spaceBefore }: { spaceBefore: EuiThemeSize | null } ) => Required['renderItem'] = - ({ title }, { spaceBefore }) => - () => - ( + (navNode, { spaceBefore }) => + () => { + const { title } = navNode; + const dataTestSubj = getTestSubj(navNode); + return ( { return { marginTop: spaceBefore ? euiTheme.size[spaceBefore] : undefined, @@ -114,6 +126,7 @@ const renderBlockTitle: (
{title}
); + }; const renderGroup = ( navGroup: ChromeProjectNavigationNode, @@ -165,27 +178,14 @@ const nodeToEuiCollapsibleNavProps = ( } => { const { navNode, isItem, hasChildren, hasLink } = serializeNavNode(_navNode); - const { - id, - title, - href, - icon, - renderAs, - isActive, - deepLink, - spaceBefore: _spaceBefore, - } = navNode; + const { id, title, href, icon, renderAs, isActive, spaceBefore: _spaceBefore } = navNode; const isExternal = Boolean(href) && isAbsoluteLink(href!); const isAccordion = hasChildren && !isItem; const isAccordionExpanded = (itemsState[id]?.isCollapsed ?? DEFAULT_IS_COLLAPSED) === false; const isSelected = isAccordion && isAccordionExpanded ? false : isActive; - const dataTestSubj = classnames(`nav-item`, `nav-item-${id}`, { - [`nav-item-deepLinkId-${deepLink?.id}`]: !!deepLink, - [`nav-item-id-${id}`]: id, - [`nav-item-isActive`]: isSelected, - }); + const dataTestSubj = getTestSubj(navNode, isSelected); let spaceBefore = _spaceBefore; if (spaceBefore === undefined && treeDepth === 1 && hasChildren) { @@ -291,6 +291,18 @@ const nodeToEuiCollapsibleNavProps = ( return { items, isVisible }; }; +// Temporary solution to prevent showing the outline when the page load when the +// accordion is auto-expanded if one of its children is active +// Once https://github.com/elastic/eui/pull/7314 is released in Kibana we can +// safely remove this CSS class. +const className = css` + .euiAccordion__childWrapper, + .euiAccordion__children, + .euiCollapsibleNavAccordion__children { + outline: none; + } +`; + interface AccordionItemsState { [navNodeId: string]: { isCollapsible: boolean; @@ -453,15 +465,7 @@ export const NavigationSectionUI: FC = ({ navNode }) => { return ( diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/panel/panel_group.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/panel/panel_group.tsx index d3f39f291f25d..56c27124a4a06 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/panel/panel_group.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/panel/panel_group.tsx @@ -69,6 +69,7 @@ export const PanelGroup: FC = ({ navNode, isFirstInList, hasHorizontalRul const removePaddingTop = !hasTitle && !isFirstInList; const someChildIsGroup = filteredChildren?.some((child) => !!child.children); const firstChildIsGroup = !!filteredChildren?.[0]?.children; + const groupTestSubj = `panelGroup panelGroupId-${navNode.id}`; let spaceBefore = _spaceBefore; if (spaceBefore === undefined) { @@ -106,6 +107,10 @@ export const PanelGroup: FC = ({ navNode, isFirstInList, hasHorizontalRul buttonContent={title} className={classNames.accordion} buttonClassName={accordionButtonClassName} + data-test-subj={groupTestSubj} + buttonProps={{ + 'data-test-subj': `panelAccordionBtnId-${navNode.id}`, + }} > <> {!firstChildIsGroup && } @@ -118,10 +123,14 @@ export const PanelGroup: FC = ({ navNode, isFirstInList, hasHorizontalRul } return ( - <> +
{spaceBefore !== null && } {hasTitle && ( - +

{title}

)} @@ -139,6 +148,6 @@ export const PanelGroup: FC = ({ navNode, isFirstInList, hasHorizontalRul {renderChildren()} {appendHorizontalRule && } - +
); }; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/panel/panel_nav_item.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/panel/panel_nav_item.tsx index 651408697169a..aa87582497b07 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/panel/panel_nav_item.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/panel/panel_nav_item.tsx @@ -42,7 +42,7 @@ export const PanelNavItem: FC = ({ item }) => { wrapText className="sideNavPanelLink" size="s" - data-test-subj={`nav-item-id-${item.id}`} + data-test-subj={`panelNavItem panelNavItem-id-${item.id}`} href={href} iconType={icon} onClick={onClick} 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 deleted file mode 100644 index 9cc7d72525f92..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/default_navigation.test.tsx +++ /dev/null @@ -1,871 +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. - */ - -import React from 'react'; -import { render } from '@testing-library/react'; -import { type Observable, of, BehaviorSubject } from 'rxjs'; -import type { - ChromeNavLink, - ChromeProjectNavigation, - ChromeProjectNavigationNode, -} from '@kbn/core-chrome-browser'; -import { EuiThemeProvider } from '@elastic/eui'; - -import { getServicesMock } from '../../mocks/src/jest'; -import { NavigationProvider } from '../services'; -import { DefaultNavigation } from './default_navigation'; -import type { ProjectNavigationTreeDefinition, RootNavigationItemDefinition } from './types'; -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 () => { - const onProjectNavigationChange = jest.fn(); - - const navigationBody: RootNavigationItemDefinition[] = [ - { - type: 'navGroup', - id: 'group1', - children: [ - { - id: 'item1', - title: 'Item 1', - href: 'http://foo', - }, - { - id: 'item2', - title: 'Item 2', - href: 'http://foo', - }, - { - id: 'group1A', - title: 'Group1A', - children: [ - { - id: 'item1', - title: 'Group 1A Item 1', - href: 'http://foo', - }, - { - id: 'group1A_1', - title: 'Group1A_1', - children: [ - { - id: 'item1', - title: 'Group 1A_1 Item 1', - href: 'http://foo', - }, - ], - }, - ], - }, - ], - }, - ]; - - const { findAllByTestId } = render( - - - - - - ); - - await act(async () => { - jest.advanceTimersByTime(SET_NAVIGATION_DELAY); - }); - - // Click the last group to expand and show the last depth - (await findAllByTestId(/nav-item-group1.group1A.group1A_1/))[0].click(); - - expect(onProjectNavigationChange).toHaveBeenCalled(); - const lastCall = - onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; - const [navTreeGenerated] = lastCall; - - expect(navTreeGenerated.navigationTree).toMatchInlineSnapshot(` - Array [ - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": undefined, - "href": "http://foo", - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "group1", - "item1", - ], - "sideNavStatus": "visible", - "title": "Item 1", - }, - Object { - "children": undefined, - "deepLink": undefined, - "href": "http://foo", - "id": "item2", - "isActive": false, - "isGroup": false, - "path": Array [ - "group1", - "item2", - ], - "sideNavStatus": "visible", - "title": "Item 2", - }, - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": undefined, - "href": "http://foo", - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "group1", - "group1A", - "item1", - ], - "sideNavStatus": "visible", - "title": "Group 1A Item 1", - }, - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": undefined, - "href": "http://foo", - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "group1", - "group1A", - "group1A_1", - "item1", - ], - "sideNavStatus": "visible", - "title": "Group 1A_1 Item 1", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group1A_1", - "isActive": false, - "isGroup": true, - "path": Array [ - "group1", - "group1A", - "group1A_1", - ], - "sideNavStatus": "visible", - "title": "Group1A_1", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group1A", - "isActive": false, - "isGroup": true, - "path": Array [ - "group1", - "group1A", - ], - "sideNavStatus": "visible", - "title": "Group1A", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group1", - "isActive": false, - "isGroup": true, - "path": Array [ - "group1", - ], - "sideNavStatus": "visible", - "title": "", - "type": "navGroup", - }, - ] - `); - }); - - test('should read the title from deeplink', async () => { - const navLinks$: Observable = of([ - ...navLinksMock, - { - id: 'item1', - title: 'Title from deeplink', - baseUrl: '', - url: '', - href: '', - }, - ]); - - const onProjectNavigationChange = jest.fn(); - - const navigationBody: Array> = [ - { - type: 'navGroup', - id: 'root', - children: [ - { - id: 'group1', - children: [ - { - id: 'item1', - link: 'item1', // Title from deeplink - }, - { - id: 'item2', - link: 'item1', // Overwrite title from deeplink - title: 'Overwrite deeplink title', - }, - { - id: 'item3', - link: 'unknown', // Unknown deeplink - title: 'Should not be rendered', - }, - ], - }, - ], - }, - ]; - - render( - - - - - - ); - - 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.navigationTree).toMatchInlineSnapshot(` - Array [ - Object { - "children": Array [ - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "", - "href": "", - "id": "item1", - "title": "Title from deeplink", - "url": "", - }, - "href": undefined, - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "root", - "group1", - "item1", - ], - "sideNavStatus": "visible", - "title": "Title from deeplink", - }, - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "", - "href": "", - "id": "item1", - "title": "Title from deeplink", - "url": "", - }, - "href": undefined, - "id": "item2", - "isActive": false, - "isGroup": false, - "path": Array [ - "root", - "group1", - "item2", - ], - "sideNavStatus": "visible", - "title": "Overwrite deeplink title", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group1", - "isActive": false, - "isGroup": true, - "path": Array [ - "root", - "group1", - ], - "sideNavStatus": "visible", - "title": "", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "root", - "isActive": false, - "isGroup": true, - "path": Array [ - "root", - ], - "sideNavStatus": "visible", - "title": "", - "type": "navGroup", - }, - ] - `); - }); - - test("shouldn't render hidden deeplink", async () => { - const navLinks$: Observable = of([ - ...navLinksMock, - { - id: 'item1', - title: 'Item 1', - baseUrl: '', - url: '', - href: '', - }, - { - id: 'item', - title: 'Item 2', - hidden: true, - baseUrl: '', - url: '', - href: '', - }, - ]); - - const onProjectNavigationChange = jest.fn(); - - const navigationBody: Array> = [ - { - type: 'navGroup', - id: 'root', - children: [ - { - id: 'group1', - children: [ - { - id: 'item1', - link: 'item1', - }, - { - id: 'item2', - link: 'item2', // this should be hidden from sidenav - }, - ], - }, - ], - }, - ]; - - const { queryByTestId } = render( - - - - ); - - 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.navigationTree).toMatchInlineSnapshot(` - Array [ - Object { - "children": Array [ - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "", - "href": "", - "id": "item1", - "title": "Item 1", - "url": "", - }, - "href": undefined, - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "root", - "group1", - "item1", - ], - "sideNavStatus": "visible", - "title": "Item 1", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group1", - "isActive": false, - "isGroup": true, - "path": Array [ - "root", - "group1", - ], - "sideNavStatus": "visible", - "title": "", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "root", - "isActive": false, - "isGroup": true, - "path": Array [ - "root", - ], - "sideNavStatus": "visible", - "title": "", - "type": "navGroup", - }, - ] - `); - - expect(await queryByTestId(/nav-item-deepLinkId-item1/)).not.toBeNull(); - expect(await queryByTestId(/nav-item-deepLinkId-item2/)).toBeNull(); - }); - - test('should allow href for absolute links', async () => { - const onProjectNavigationChange = jest.fn(); - - const navigationBody: Array> = [ - { - type: 'navGroup', - id: 'root', - children: [ - { - id: 'group1', - children: [ - { - id: 'item1', - title: 'Absolute link', - href: 'https://example.com', - }, - ], - }, - ], - }, - ]; - - render( - - - - - - ); - - 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.navigationTree).toMatchInlineSnapshot(` - Array [ - Object { - "children": Array [ - Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": undefined, - "href": "https://example.com", - "id": "item1", - "isActive": false, - "isGroup": false, - "path": Array [ - "root", - "group1", - "item1", - ], - "sideNavStatus": "visible", - "title": "Absolute link", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "group1", - "isActive": false, - "isGroup": true, - "path": Array [ - "root", - "group1", - ], - "sideNavStatus": "visible", - "title": "", - }, - ], - "deepLink": undefined, - "href": undefined, - "id": "root", - "isActive": false, - "isGroup": true, - "path": Array [ - "root", - ], - "sideNavStatus": "visible", - "title": "", - "type": "navGroup", - }, - ] - `); - }); - - test('should throw if href is not an absolute links', async () => { - // We'll mock the console.error to avoid dumping the (expected) error in the console - // source: https://github.com/jestjs/jest/pull/5267#issuecomment-356605468 - jest.spyOn(console, 'error'); - // @ts-expect-error we're mocking the console so "mockImplementation" exists - // eslint-disable-next-line no-console - console.error.mockImplementation(() => {}); - - const onProjectNavigationChange = jest.fn(); - - const navigationBody: Array> = [ - { - type: 'navGroup', - id: 'root', - children: [ - { - id: 'group1', - children: [ - { - id: 'item1', - title: 'Absolute link', - href: '../dashboards', - }, - ], - }, - ], - }, - ]; - - const expectToThrow = () => { - render( - - - - - - ); - }; - - expect(expectToThrow).toThrowError('href must be an absolute URL. Node id [item1].'); - // @ts-expect-error we're mocking the console so "mockImplementation" exists - // eslint-disable-next-line no-console - console.error.mockRestore(); - }); - - test('should render recently accessed items', async () => { - const recentlyAccessed$ = of([ - { label: 'This is an example', link: '/app/example/39859', id: '39850' }, - { label: 'Another example', link: '/app/example/5235', id: '5235' }, - ]); - - const navigationBody: RootNavigationItemDefinition[] = [ - { - type: 'recentlyAccessed', - }, - ]; - - const { findByTestId } = render( - - - - - - ); - - 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/)).dataset.testSubj).toMatch( - /nav-item-isActive/ - ); - expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).not.toMatch( - /nav-item-isActive/ - ); - }); - - 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/)).dataset.testSubj).toMatch( - /nav-item-isActive/ - ); - }); - }); - - describe('builds the full navigation tree when only custom project is provided', () => { - test('reading the title from config or deeplink', async () => { - const navLinks$: Observable = of([ - ...navLinksMock, - { - id: 'item2', - title: 'Title from deeplink!', - baseUrl: '', - url: '', - href: '', - }, - ]); - - const onProjectNavigationChange = jest.fn(); - - // Custom project navigation tree definition - const projectNavigationTree: ProjectNavigationTreeDefinition = [ - { - id: 'group1', - title: 'Group 1', - children: [ - { - id: 'item1', - title: 'Item 1', - }, - { - id: 'item2', - link: 'item2', // Title from deeplink - }, - { - id: 'item3', - link: 'item2', - title: 'Deeplink title overriden', // Override title from deeplink - }, - { - link: 'disabled', - title: 'Should NOT be there', - }, - ], - }, - ]; - - render( - - - - - - ); - - 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: expect.any(Array), - }); - - // The project navigation tree passed - expect(navTreeGenerated.navigationTree).toMatchSnapshot(); - }); - - describe('cloud links', () => { - test('render the cloud link', async () => { - const { findByTestId } = render( - - - - - - ); - - expect( - (await findByTestId(/nav-item-project_settings_project_nav.cloudLinkUserAndRoles/)) - .textContent - ).toBe('Mock Users & RolesExternal link'); - - expect( - (await findByTestId(/nav-item-project_settings_project_nav.cloudLinkBilling/)).textContent - ).toBe('Mock Billing & SubscriptionsExternal link'); - }); - }); - }); -}); 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 4e24c887b1d02..f8d56bc785269 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 @@ -120,6 +120,11 @@ function validateNodeProps< `[Chrome navigation] Error in node [${id}]. If renderAs is set to "panelOpener", a "link" must also be provided.` ); } + if (renderAs === 'item' && !link) { + throw new Error( + `[Chrome navigation] Error in node [${id}]. If renderAs is set to "item", a "link" must also be provided.` + ); + } if (appendHorizontalRule && !isGroup) { throw new Error( `[Chrome navigation] Error in node [${id}]. "appendHorizontalRule" can only be added for group with children.` @@ -279,6 +284,10 @@ export const useInitNavNode = < const registerChildNode = useCallback( (childNode) => { + if (orderChildrenRef.current[childNode.id] === undefined) { + orderChildrenRef.current[childNode.id] = idx.current++; + } + const childPath = nodePath ? [...nodePath, childNode.id] : []; setChildrenNodes((prev) => { @@ -291,10 +300,6 @@ export const useInitNavNode = < }; }); - if (orderChildrenRef.current[childNode.id] === undefined) { - orderChildrenRef.current[childNode.id] = idx.current++; - } - return { unregister: (childId: string) => { setChildrenNodes((prev) => { diff --git a/packages/shared-ux/chrome/navigation/src/ui/index.ts b/packages/shared-ux/chrome/navigation/src/ui/index.ts index 60b55fc5d200a..2e94b144161dd 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/index.ts +++ b/packages/shared-ux/chrome/navigation/src/ui/index.ts @@ -7,7 +7,7 @@ */ export { Navigation } from './components'; -export type { PanelContent, PanelComponentProps } from './components'; +export type { PanelContent, PanelComponentProps, PanelContentProvider } from './components'; export { DefaultNavigation } from './default_navigation'; diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.ts index bb8f610f7275d..f717a19e371c2 100644 --- a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.ts +++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/navigation_tree.ts @@ -163,7 +163,6 @@ const formatFooterNodesFromLinks = ( title: category.label, icon: category.iconType, breadcrumbStatus: 'hidden', - defaultIsCollapsed: true, children: category.linkIds?.reduce((acc, linkId) => { const projectNavLink = projectNavLinks.find(({ id }) => id === linkId);