From 0c680d7783858172dfbabde6e0f18143c18a97a0 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 27 Sep 2023 14:22:46 -0700 Subject: [PATCH] Project Side Navigation: Use EuiCollapsibleNavBeta component (#164910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes https://github.com/elastic/kibana/issues/162507 Relates to https://github.com/elastic/kibana/issues/166545 ^ additional IA-related tasks - related to the alignment discussions - can be found here ## Work for next steps In this PR, some work items are being saved for a next PR: 1. _Only affects Search solution_: Navigation "group titles" do not create a breadcrumb item, as sub-items in the group are not hierarchically under the title. To address this, group titles may be going away from the design. https://github.com/elastic/kibana/issues/167323 2. _Only affects Observability solution_: Navigation accordions can not be collapsed and do not show arrow icons. To address this, in a later PR we will add internal state management for the open/closed state of each accordion. https://github.com/elastic/kibana/issues/167328 3. _Affects all solutions:_ The "collapsed" state of the side nav should show a docked view with icons-only. To address this, in later PRs we will bring Security solution into the unified nav components. 4. https://github.com/elastic/kibana/issues/167326 5. https://github.com/elastic/kibana/issues/167330 6. https://github.com/elastic/kibana/issues/167332 ### Recordings These videos show a before-and-after with the new UI. | project | old | new | |--|--|--| |observability| https://github.com/elastic/kibana/assets/908371/663765a3-4e4b-416e-b7d5-7d87eece83e8 | CleanShot 2023-09-22 at 14 20 48@2x | |search| https://github.com/elastic/kibana/assets/908371/f383773e-27a8-4485-8289-274d8231b960 | CleanShot 2023-09-22 at 14 18 43@2x | |security| https://github.com/elastic/kibana/assets/908371/481f4533-64e5-41db-bc8e-5012f82c188a | *will change to the new style after this PR and the flyout/panel support are completed | ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: Sébastien Loix --- .../src/ui/project/app_menu.tsx | 1 - .../src/ui/project/header.test.tsx | 31 +- .../src/ui/project/header.tsx | 50 +-- .../src/ui/project/navigation.tsx | 60 +--- .../src/project_navigation.ts | 15 +- .../analytics/default_navigation.ts | 19 +- .../devtools/default_navigation.ts | 25 +- .../management/default_navigation.ts | 8 +- packages/default-nav/ml/default_navigation.ts | 14 +- .../chrome/navigation/mocks/src/storybook.ts | 4 +- .../shared-ux/chrome/navigation/src/styles.ts | 15 - .../default_navigation.test.tsx.snap | 289 ++++++---------- .../src/ui/components/group_as_link.tsx | 61 ---- .../src/ui/components/navigation.test.tsx | 205 ++++------- .../src/ui/components/navigation_group.tsx | 2 +- .../src/ui/components/navigation_item.tsx | 21 +- .../ui/components/navigation_section_ui.tsx | 116 +++---- .../src/ui/components/navigation_ui.tsx | 19 +- .../src/ui/components/recently_accessed.tsx | 54 ++- .../src/ui/default_navigation.test.tsx | 42 +-- .../navigation/src/ui/default_navigation.tsx | 29 +- .../src/ui/hooks/use_init_navnode.ts | 16 +- .../navigation/src/ui/navigation.stories.tsx | 325 +++++++++++------- .../chrome/navigation/src/ui/types.ts | 27 +- .../components/side_navigation/index.tsx | 236 +++++++------ .../serverless_search/public/layout/nav.tsx | 150 ++++---- .../page_objects/svl_common_navigation.ts | 24 +- .../test_suites/search/navigation.ts | 17 +- 28 files changed, 763 insertions(+), 1112 deletions(-) delete mode 100644 packages/shared-ux/chrome/navigation/src/styles.ts delete mode 100644 packages/shared-ux/chrome/navigation/src/ui/components/group_as_link.tsx diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/app_menu.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/app_menu.tsx index 0fb7a3f1bd94c..22ff7c9415ba8 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/app_menu.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/app_menu.tsx @@ -13,7 +13,6 @@ import React from 'react'; import { HeaderActionMenu } from '../header/header_action_menu'; interface AppMenuBarProps { - isOpen: boolean; headerActionMenuMounter: { mount: MountPoint | undefined }; } export const AppMenuBar = ({ headerActionMenuMounter }: AppMenuBarProps) => { diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.test.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.test.tsx index 167b11629ce55..d4886d8180c47 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.test.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.test.tsx @@ -9,7 +9,7 @@ import { EuiHeader } from '@elastic/eui'; import { applicationServiceMock } from '@kbn/core-application-browser-mocks'; import { docLinksServiceMock } from '@kbn/core-doc-links-browser-mocks'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import React from 'react'; import * as Rx from 'rxjs'; import { ProjectHeader, Props as ProjectHeaderProps } from './header'; @@ -45,35 +45,10 @@ describe('Header', () => { ); - expect(await screen.findByTestId('toggleNavButton')).toBeVisible(); + expect(await screen.findByTestId('euiCollapsibleNavButton')).toBeVisible(); expect(await screen.findByText('Hello, world!')).toBeVisible(); }); - it('can collapse and uncollapse', async () => { - render( - - Hello, goodbye! - - ); - - expect(await screen.findByTestId('toggleNavButton')).toBeVisible(); - expect(await screen.findByText('Hello, goodbye!')).toBeVisible(); // title is shown - - const toggleNav = async () => { - fireEvent.click(await screen.findByTestId('toggleNavButton')); // click - - expect(await screen.findByText('Hello, goodbye!')).not.toBeVisible(); - - fireEvent.click(await screen.findByTestId('toggleNavButton')); // click again - - expect(await screen.findByText('Hello, goodbye!')).toBeVisible(); // title is shown - }; - - await toggleNav(); - await toggleNav(); - await toggleNav(); - }); - it('displays the link to projects', async () => { render( @@ -81,7 +56,7 @@ describe('Header', () => { ); - const projectsLink = await screen.getByTestId('projectsLink'); + const projectsLink = screen.getByTestId('projectsLink'); expect(projectsLink).toHaveAttribute('href', '/projects/'); expect(projectsLink).toHaveTextContent('My Project'); }); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx index 1cbf8eaa9af0a..8767c1f9c53f7 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/header.tsx @@ -12,10 +12,7 @@ import { EuiHeaderLogo, EuiHeaderSection, EuiHeaderSectionItem, - EuiHeaderSectionItemButton, - EuiIcon, EuiLoadingSpinner, - htmlIdGenerator, useEuiTheme, EuiThemeComputed, } from '@elastic/eui'; @@ -35,8 +32,7 @@ import { MountPoint } from '@kbn/core-mount-utils-browser'; import { i18n } from '@kbn/i18n'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { Router } from '@kbn/shared-ux-router'; -import React, { createRef, useCallback, useState } from 'react'; -import useLocalStorage from 'react-use/lib/useLocalStorage'; +import React, { useCallback } from 'react'; import useObservable from 'react-use/lib/useObservable'; import { debounceTime, Observable, of } from 'rxjs'; import { useHeaderActionMenuMounter } from '../header/header_action_menu'; @@ -66,13 +62,6 @@ const getHeaderCss = ({ size }: EuiThemeComputed) => ({ top: 2px; `, }, - nav: { - toggleNavButton: css` - border-right: 1px solid #d3dae6; - margin-left: -1px; - padding-right: ${size.xs}; - `, - }, projectName: { link: css` /* TODO: make header layout more flexible? */ @@ -123,7 +112,6 @@ export interface Props { prependBasePath: (url: string) => string; } -const LOCAL_STORAGE_IS_OPEN_KEY = 'PROJECT_NAVIGATION_OPEN' as const; const LOADING_DEBOUNCE_TIME = 80; type LogoProps = Pick & { @@ -186,9 +174,6 @@ export const ProjectHeader = ({ docLinks, ...observables }: Props) => { - const [navId] = useState(htmlIdGenerator()()); - const [isOpen, setIsOpen] = useLocalStorage(LOCAL_STORAGE_IS_OPEN_KEY, true); - const toggleCollapsibleNavRef = createRef void }>(); const headerActionMenuMounter = useHeaderActionMenuMounter(observables.actionMenu$); const projectsUrl = useObservable(observables.projectsUrl$); const projectName = useObservable(observables.projectName$); @@ -210,34 +195,9 @@ export const ProjectHeader = ({
- - - { - setIsOpen(false); - if (toggleCollapsibleNavRef.current) { - toggleCollapsibleNavRef.current.focus(); - } - }} - button={ - setIsOpen(!isOpen)} - aria-expanded={isOpen!} - aria-pressed={isOpen!} - aria-controls={navId} - ref={toggleCollapsibleNavRef} - > - - - } - > - {children} - - - + + {children} + {headerActionMenuMounter.mount && ( - + )} ); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx index 1d48a6eccfbb5..ae5c8c773e449 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx @@ -6,55 +6,25 @@ * Side Public License, v 1. */ +import { EuiCollapsibleNavBeta } from '@elastic/eui'; import React from 'react'; -import { css } from '@emotion/react'; -import { EuiCollapsibleNav, EuiCollapsibleNavProps } from '@elastic/eui'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; -const SIZE_EXPANDED = 248; -const SIZE_COLLAPSED = 0; +const LOCAL_STORAGE_IS_COLLAPSED_KEY = 'PROJECT_NAVIGATION_COLLAPSED' as const; -export interface ProjectNavigationProps { - isOpen: boolean; - closeNav: () => void; - button: EuiCollapsibleNavProps['button']; -} - -export const ProjectNavigation: React.FC = ({ - children, - isOpen, - closeNav, - button, -}) => { - const collabsibleNavCSS = css` - border-inline-end-width: 1, - display: flex, - flex-direction: row, - `; - - const DOCKED_BREAKPOINT = 's' as const; - const isVisible = isOpen; +export const ProjectNavigation: React.FC = ({ children }) => { + const [isCollapsed, setIsCollapsed] = useLocalStorage(LOCAL_STORAGE_IS_COLLAPSED_KEY, false); + const onCollapseToggle = (nextIsCollapsed: boolean) => { + setIsCollapsed(nextIsCollapsed); + }; return ( - <> - { - /* must render the tree to initialize the navigation, even if it shouldn't be visible */ - !isOpen && - } - - {isOpen && children} - - + + {children} + ); }; 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 b2a0384e3a1a9..2d35272e43679 100644 --- a/packages/core/chrome/core-chrome-browser/src/project_navigation.ts +++ b/packages/core/chrome/core-chrome-browser/src/project_navigation.ts @@ -5,8 +5,10 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + import type { ComponentType } from 'react'; import type { Location } from 'history'; +import { EuiAccordionProps } from '@elastic/eui'; import type { AppId as DevToolsApp, DeepLinkId as DevToolsLink } from '@kbn/deeplinks-devtools'; import type { AppId as AnalyticsApp, @@ -68,6 +70,8 @@ export interface ChromeProjectNavigationNode { deepLink?: ChromeNavLink; /** Optional icon for the navigation node. Note: not all navigation depth will render the icon */ icon?: string; + /** Optional flag to indicate if the node must be treated as a group title */ + isGroupTitle?: boolean; /** Optional children of the navigation node */ children?: ChromeProjectNavigationNode[]; /** @@ -88,6 +92,8 @@ export interface ChromeProjectNavigationNode { * @default 'visible' */ breadcrumbStatus?: 'hidden' | 'visible'; + + accordionProps?: Partial; } /** @public */ @@ -139,7 +145,12 @@ export interface NodeDefinition< cloudLink?: CloudLinkId; /** Optional icon for the navigation node. Note: not all navigation depth will render the icon */ icon?: string; - /** Optional children of the navigation node */ + /** + * Optional flag to indicate if the node must be treated as a group title. + * Can not be used with `children` + */ + isGroupTitle?: boolean; + /** Optional children of the navigation node. Can not be used with `isGroupTitle` */ children?: NonEmptyArray>; /** * Use href for absolute links only. Internal links should use "link". @@ -155,6 +166,8 @@ export interface NodeDefinition< * @default 'visible' */ breadcrumbStatus?: 'hidden' | 'visible'; + + accordionProps?: Partial; } /** diff --git a/packages/default-nav/analytics/default_navigation.ts b/packages/default-nav/analytics/default_navigation.ts index a9c0c414936b5..0ea0b5cd822f1 100644 --- a/packages/default-nav/analytics/default_navigation.ts +++ b/packages/default-nav/analytics/default_navigation.ts @@ -21,18 +21,13 @@ export const defaultNavigation: AnalyticsNodeDefinition = { icon: 'stats', children: [ { - id: 'root', - children: [ - { - link: 'discover', - }, - { - link: 'dashboards', - }, - { - link: 'visualize', - }, - ], + link: 'discover', + }, + { + link: 'dashboards', + }, + { + link: 'visualize', }, ], }; diff --git a/packages/default-nav/devtools/default_navigation.ts b/packages/default-nav/devtools/default_navigation.ts index 3a2f8db48c563..8235af8b602a5 100644 --- a/packages/default-nav/devtools/default_navigation.ts +++ b/packages/default-nav/devtools/default_navigation.ts @@ -21,21 +21,16 @@ export const defaultNavigation: DevToolsNodeDefinition = { icon: 'editorCodeBlock', children: [ { - id: 'root', - children: [ - { - link: 'dev_tools:console', - }, - { - link: 'dev_tools:searchprofiler', - }, - { - link: 'dev_tools:grokdebugger', - }, - { - link: 'dev_tools:painless_lab', - }, - ], + link: 'dev_tools:console', + }, + { + link: 'dev_tools:searchprofiler', + }, + { + link: 'dev_tools:grokdebugger', + }, + { + link: 'dev_tools:painless_lab', }, ], }; diff --git a/packages/default-nav/management/default_navigation.ts b/packages/default-nav/management/default_navigation.ts index afc889c9a1e1b..180f9d74378f8 100644 --- a/packages/default-nav/management/default_navigation.ts +++ b/packages/default-nav/management/default_navigation.ts @@ -29,13 +29,7 @@ export const defaultNavigation: ManagementNodeDefinition = { icon: 'gear', children: [ { - id: 'root', - title: '', - children: [ - { - link: 'monitoring', - }, - ], + link: 'monitoring', }, { id: 'integration_management', diff --git a/packages/default-nav/ml/default_navigation.ts b/packages/default-nav/ml/default_navigation.ts index c0035f6f25db0..97f53d6346653 100644 --- a/packages/default-nav/ml/default_navigation.ts +++ b/packages/default-nav/ml/default_navigation.ts @@ -28,16 +28,10 @@ export const defaultNavigation: MlNodeDefinition = { icon: 'machineLearningApp', children: [ { - title: '', - id: 'root', - children: [ - { - link: 'ml:overview', - }, - { - link: 'ml:notifications', - }, - ], + link: 'ml:overview', + }, + { + link: 'ml:notifications', }, { title: i18n.translate('defaultNavigation.ml.anomalyDetection', { diff --git a/packages/shared-ux/chrome/navigation/mocks/src/storybook.ts b/packages/shared-ux/chrome/navigation/mocks/src/storybook.ts index 28184ee086cc7..d8ed78c48e7a9 100644 --- a/packages/shared-ux/chrome/navigation/mocks/src/storybook.ts +++ b/packages/shared-ux/chrome/navigation/mocks/src/storybook.ts @@ -14,7 +14,7 @@ import { NavigationServices } from '../../types'; type Arguments = NavigationServices; export type Params = Pick< Arguments, - 'navIsOpen' | 'recentlyAccessed$' | 'navLinks$' | 'onProjectNavigationChange' + 'navIsOpen' | 'recentlyAccessed$' | 'activeNodes$' | 'navLinks$' | 'onProjectNavigationChange' >; export class StorybookMock extends AbstractStorybookMock<{}, NavigationServices> { @@ -43,7 +43,7 @@ export class StorybookMock extends AbstractStorybookMock<{}, NavigationServices> recentlyAccessed$: params.recentlyAccessed$ ?? new BehaviorSubject([]), navLinks$: params.navLinks$ ?? new BehaviorSubject([]), onProjectNavigationChange: params.onProjectNavigationChange ?? (() => undefined), - activeNodes$: new BehaviorSubject([]), + activeNodes$: params.activeNodes$ ?? new BehaviorSubject([]), cloudLinks: { billingAndSub: { title: 'Billing & Subscriptions', diff --git a/packages/shared-ux/chrome/navigation/src/styles.ts b/packages/shared-ux/chrome/navigation/src/styles.ts deleted file mode 100644 index 72db66e12f7bf..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/styles.ts +++ /dev/null @@ -1,15 +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 { css } from '@emotion/react'; - -export const navigationStyles = { - euiSideNavItems: css` - padding-left: 45px; - `, -}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/__snapshots__/default_navigation.test.tsx.snap b/packages/shared-ux/chrome/navigation/src/ui/__snapshots__/default_navigation.test.tsx.snap index d73d9c68385bc..46ffd04af56d8 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/__snapshots__/default_navigation.test.tsx.snap +++ b/packages/shared-ux/chrome/navigation/src/ui/__snapshots__/default_navigation.test.tsx.snap @@ -14,7 +14,6 @@ Array [ "group1", "item1", ], - "renderItem": undefined, "title": "Item 1", }, Object { @@ -33,7 +32,6 @@ Array [ "group1", "item2", ], - "renderItem": undefined, "title": "Title from deeplink!", }, Object { @@ -52,7 +50,6 @@ Array [ "group1", "item3", ], - "renderItem": undefined, "title": "Deeplink title overriden", }, ], @@ -69,77 +66,58 @@ Array [ Object { "children": Array [ Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "/mocked", - "href": "http://mocked/discover", - "id": "discover", - "title": "Deeplink discover", - "url": "/mocked/discover", - }, - "href": undefined, - "id": "discover", - "isActive": false, - "path": Array [ - "rootNav:analytics", - "root", - "discover", - ], - "renderItem": undefined, - "title": "Deeplink discover", - }, - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "/mocked", - "href": "http://mocked/dashboards", - "id": "dashboards", - "title": "Deeplink dashboards", - "url": "/mocked/dashboards", - }, - "href": undefined, - "id": "dashboards", - "isActive": false, - "path": Array [ - "rootNav:analytics", - "root", - "dashboards", - ], - "renderItem": undefined, - "title": "Deeplink dashboards", - }, - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "/mocked", - "href": "http://mocked/visualize", - "id": "visualize", - "title": "Deeplink visualize", - "url": "/mocked/visualize", - }, - "href": undefined, - "id": "visualize", - "isActive": false, - "path": Array [ - "rootNav:analytics", - "root", - "visualize", - ], - "renderItem": undefined, - "title": "Deeplink visualize", - }, + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/discover", + "id": "discover", + "title": "Deeplink discover", + "url": "/mocked/discover", + }, + "href": undefined, + "id": "discover", + "isActive": false, + "path": Array [ + "rootNav:analytics", + "discover", ], - "deepLink": undefined, + "title": "Deeplink discover", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/dashboards", + "id": "dashboards", + "title": "Deeplink dashboards", + "url": "/mocked/dashboards", + }, "href": undefined, - "id": "root", + "id": "dashboards", "isActive": false, "path": Array [ "rootNav:analytics", - "root", + "dashboards", ], - "title": "", + "title": "Deeplink dashboards", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/visualize", + "id": "visualize", + "title": "Deeplink visualize", + "url": "/mocked/visualize", + }, + "href": undefined, + "id": "visualize", + "isActive": false, + "path": Array [ + "rootNav:analytics", + "visualize", + ], + "title": "Deeplink visualize", }, ], "deepLink": undefined, @@ -156,57 +134,40 @@ Array [ Object { "children": Array [ Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "/mocked", - "href": "http://mocked/ml:overview", - "id": "ml:overview", - "title": "Deeplink ml:overview", - "url": "/mocked/ml:overview", - }, - "href": undefined, - "id": "ml:overview", - "isActive": false, - "path": Array [ - "rootNav:ml", - "root", - "ml:overview", - ], - "renderItem": undefined, - "title": "Deeplink ml:overview", - }, - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "/mocked", - "href": "http://mocked/ml:notifications", - "id": "ml:notifications", - "title": "Deeplink ml:notifications", - "url": "/mocked/ml:notifications", - }, - "href": undefined, - "id": "ml:notifications", - "isActive": false, - "path": Array [ - "rootNav:ml", - "root", - "ml:notifications", - ], - "renderItem": undefined, - "title": "Deeplink ml:notifications", - }, + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:overview", + "id": "ml:overview", + "title": "Deeplink ml:overview", + "url": "/mocked/ml:overview", + }, + "href": undefined, + "id": "ml:overview", + "isActive": false, + "path": Array [ + "rootNav:ml", + "ml:overview", ], - "deepLink": undefined, + "title": "Deeplink ml:overview", + }, + Object { + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/ml:notifications", + "id": "ml:notifications", + "title": "Deeplink ml:notifications", + "url": "/mocked/ml:notifications", + }, "href": undefined, - "id": "root", + "id": "ml:notifications", "isActive": false, "path": Array [ "rootNav:ml", - "root", + "ml:notifications", ], - "title": "", + "title": "Deeplink ml:notifications", }, Object { "children": Array [ @@ -227,7 +188,6 @@ Array [ "anomaly_detection", "ml:anomalyDetection", ], - "renderItem": undefined, "title": "Jobs", }, Object { @@ -247,7 +207,6 @@ Array [ "anomaly_detection", "ml:anomalyExplorer", ], - "renderItem": undefined, "title": "Deeplink ml:anomalyExplorer", }, Object { @@ -267,7 +226,6 @@ Array [ "anomaly_detection", "ml:singleMetricViewer", ], - "renderItem": undefined, "title": "Deeplink ml:singleMetricViewer", }, Object { @@ -287,7 +245,6 @@ Array [ "anomaly_detection", "ml:settings", ], - "renderItem": undefined, "title": "Deeplink ml:settings", }, ], @@ -320,7 +277,6 @@ Array [ "data_frame_analytics", "ml:dataFrameAnalytics", ], - "renderItem": undefined, "title": "Jobs", }, Object { @@ -340,7 +296,6 @@ Array [ "data_frame_analytics", "ml:resultExplorer", ], - "renderItem": undefined, "title": "Deeplink ml:resultExplorer", }, Object { @@ -360,7 +315,6 @@ Array [ "data_frame_analytics", "ml:analyticsMap", ], - "renderItem": undefined, "title": "Deeplink ml:analyticsMap", }, ], @@ -393,7 +347,6 @@ Array [ "model_management", "ml:nodesOverview", ], - "renderItem": undefined, "title": "Deeplink ml:nodesOverview", }, Object { @@ -413,7 +366,6 @@ Array [ "model_management", "ml:nodes", ], - "renderItem": undefined, "title": "Deeplink ml:nodes", }, ], @@ -446,7 +398,6 @@ Array [ "data_visualizer", "ml:fileUpload", ], - "renderItem": undefined, "title": "File", }, Object { @@ -466,7 +417,6 @@ Array [ "data_visualizer", "ml:indexDataVisualizer", ], - "renderItem": undefined, "title": "Data view", }, Object { @@ -486,7 +436,6 @@ Array [ "data_visualizer", "ml:dataDrift", ], - "renderItem": undefined, "title": "Data drift", }, ], @@ -519,7 +468,6 @@ Array [ "aiops_labs", "ml:logRateAnalysis", ], - "renderItem": undefined, "title": "Deeplink ml:logRateAnalysis", }, Object { @@ -539,7 +487,6 @@ Array [ "aiops_labs", "ml:logPatternAnalysis", ], - "renderItem": undefined, "title": "Deeplink ml:logPatternAnalysis", }, Object { @@ -559,7 +506,6 @@ Array [ "aiops_labs", "ml:changePointDetections", ], - "renderItem": undefined, "title": "Deeplink ml:changePointDetections", }, ], @@ -608,65 +554,46 @@ Array [ "breadcrumbStatus": "hidden", "children": Array [ Object { - "children": Array [ - Object { - "children": undefined, - "deepLink": Object { - "baseUrl": "/mocked", - "href": "http://mocked/management", - "id": "management", - "title": "Deeplink management", - "url": "/mocked/management", - }, - "href": undefined, - "id": "management", - "isActive": false, - "path": Array [ - "project_settings_project_nav", - "settings", - "management", - ], - "renderItem": undefined, - "title": "Management", - }, - Object { - "children": undefined, - "deepLink": undefined, - "href": "https://cloud.elastic.co/deployments/123456789/security/users", - "id": "cloudLinkUserAndRoles", - "isActive": false, - "path": Array [ - "project_settings_project_nav", - "settings", - "cloudLinkUserAndRoles", - ], - "renderItem": undefined, - "title": "Mock Users & Roles", - }, - Object { - "children": undefined, - "deepLink": undefined, - "href": "https://cloud.elastic.co/account/billing", - "id": "cloudLinkBilling", - "isActive": false, - "path": Array [ - "project_settings_project_nav", - "settings", - "cloudLinkBilling", - ], - "renderItem": undefined, - "title": "Mock Billing & Subscriptions", - }, + "children": undefined, + "deepLink": Object { + "baseUrl": "/mocked", + "href": "http://mocked/management", + "id": "management", + "title": "Deeplink management", + "url": "/mocked/management", + }, + "href": undefined, + "id": "management", + "isActive": false, + "path": Array [ + "project_settings_project_nav", + "management", ], + "title": "Management", + }, + Object { + "children": undefined, "deepLink": undefined, - "href": undefined, - "id": "settings", + "href": "https://cloud.elastic.co/deployments/123456789/security/users", + "id": "cloudLinkUserAndRoles", + "isActive": false, + "path": Array [ + "project_settings_project_nav", + "cloudLinkUserAndRoles", + ], + "title": "Mock Users & Roles", + }, + Object { + "children": undefined, + "deepLink": undefined, + "href": "https://cloud.elastic.co/account/billing", + "id": "cloudLinkBilling", "isActive": false, "path": Array [ "project_settings_project_nav", - "settings", + "cloudLinkBilling", ], - "title": "", + "title": "Mock Billing & Subscriptions", }, ], "deepLink": undefined, diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/group_as_link.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/group_as_link.tsx deleted file mode 100644 index 092d243722cd4..0000000000000 --- a/packages/shared-ux/chrome/navigation/src/ui/components/group_as_link.tsx +++ /dev/null @@ -1,61 +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 { - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiLink, - EuiTitle, - useGeneratedHtmlId, -} from '@elastic/eui'; - -import type { NavigateToUrlFn } from '../../../types/internal'; - -interface Props { - title: string; - href: string; - navigateToUrl: NavigateToUrlFn; - iconType?: string; -} - -export const GroupAsLink = ({ title, href, navigateToUrl, iconType }: Props) => { - const groupID = useGeneratedHtmlId(); - const titleID = `${groupID}__title`; - const TitleElement = 'h3'; - - return ( - - {iconType && ( - - - - )} - - - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - { - e.preventDefault(); - e.stopPropagation(); - navigateToUrl(href); - }} - href={href} - > - - - {title} - - - - - - ); -}; 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 b804f65e31d6c..a6a40347068e3 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 @@ -40,12 +40,12 @@ describe('', () => { const { findByTestId } = render( - + - + - + @@ -62,10 +62,10 @@ describe('', () => { 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/)).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/)).click(); + (await findByTestId(/nav-item-group1.group1A.group1A_1\s/)).click(); expect(await findByTestId(/nav-item-group1.group1A.group1A_1.item1/)).toBeVisible(); @@ -76,56 +76,56 @@ describe('', () => { expect(navTree.navigationTree).toEqual([ { - id: 'group1', - path: ['group1'], - title: '', - isActive: false, children: [ { - id: 'item1', - title: 'Item 1', href: 'https://foo', + id: 'item1', isActive: false, path: ['group1', 'item1'], + title: 'Item 1', }, { - id: 'item2', - title: 'Item 2', href: 'https://foo', + id: 'item2', isActive: false, path: ['group1', 'item2'], + title: 'Item 2', }, { - id: 'group1A', - title: 'Group1A', - isActive: false, - path: ['group1', 'group1A'], children: [ { - id: 'item1', href: 'https://foo', - title: 'Group 1A Item 1', + id: 'item1', isActive: false, path: ['group1', 'group1A', 'item1'], + title: 'Group 1A Item 1', }, { - id: 'group1A_1', - title: 'Group1A_1', - isActive: false, - path: ['group1', 'group1A', 'group1A_1'], children: [ { + href: 'https://foo', id: 'item1', - title: 'Group 1A_1 Item 1', isActive: false, - href: 'https://foo', path: ['group1', 'group1A', 'group1A_1', 'item1'], + title: 'Group 1A_1 Item 1', }, ], + id: 'group1A_1', + isActive: true, + path: ['group1', 'group1A', 'group1A_1'], + title: 'Group1A_1', }, ], + id: 'group1A', + isActive: true, + path: ['group1', 'group1A'], + title: 'Group1A', }, ], + id: 'group1', + isActive: true, + path: ['group1'], + title: '', }, ]); }); @@ -250,8 +250,8 @@ describe('', () => { onProjectNavigationChange={onProjectNavigationChange} > - - + + {/* Title from deeplink */} id="item1" link="item1" /> {/* Should not appear */} @@ -279,13 +279,13 @@ describe('', () => { id: 'root', path: ['root'], title: '', - isActive: false, + isActive: true, children: [ { id: 'group1', path: ['root', 'group1'], title: '', - isActive: false, + isActive: true, children: [ { id: 'item1', @@ -326,11 +326,11 @@ describe('', () => { onProjectNavigationChange={onProjectNavigationChange} > - - + + id="item1" link="notRegistered" /> - + id="item1" link="item1" /> @@ -352,128 +352,39 @@ describe('', () => { expect(navTree.navigationTree).toEqual([ { - id: 'root', - path: ['root'], - title: '', - isActive: false, children: [ { id: 'group1', + isActive: true, 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', - title: 'Title from deeplink', baseUrl: '', - url: '', href: '', - }, - }, - ], - }, - ], - }, - ]); - }); - - test('should render custom react element', async () => { - const navLinks$: Observable = of([ - { - id: 'item1', - title: 'Title from deeplink', - baseUrl: '', - url: '', - href: '', - }, - ]); - - const onProjectNavigationChange = jest.fn(); - - const { findByTestId } = render( - - - - - link="item1"> -
Custom element
-
- - {(navNode) =>
{navNode.title}
} -
-
-
-
-
- ); - - 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'); - - expect(onProjectNavigationChange).toHaveBeenCalled(); - const lastCall = - onProjectNavigationChange.mock.calls[onProjectNavigationChange.mock.calls.length - 1]; - const [navTree] = lastCall; - - 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', title: 'Title from deeplink', - baseUrl: '', url: '', - href: '', }, - }, - { - id: 'item2', - href: 'http://foo', - path: ['root', 'group1', 'item2'], - title: 'Children prop', + id: 'item1', isActive: false, - renderItem: expect.any(Function), + path: ['root', 'group2', 'item1'], + title: 'Title from deeplink', }, ], + id: 'group2', + isActive: true, + path: ['root', 'group2'], + title: '', }, ], + id: 'root', + isActive: true, + path: ['root'], + title: '', }, ]); }); @@ -649,11 +560,11 @@ describe('', () => { ); - expect(await findByTestId(/nav-item-group1.item1/)).toHaveClass( - 'euiSideNavItemButton-isSelected' + expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).toMatch( + /nav-item-isActive/ ); - expect(await findByTestId(/nav-item-group1.item2/)).not.toHaveClass( - 'euiSideNavItemButton-isSelected' + expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).not.toMatch( + /nav-item-isActive/ ); await act(async () => { @@ -673,11 +584,11 @@ describe('', () => { ]); }); - expect(await findByTestId(/nav-item-group1.item1/)).not.toHaveClass( - 'euiSideNavItemButton-isSelected' + expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).not.toMatch( + /nav-item-isActive/ ); - expect(await findByTestId(/nav-item-group1.item2/)).toHaveClass( - 'euiSideNavItemButton-isSelected' + expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).toMatch( + /nav-item-isActive/ ); }); @@ -730,8 +641,8 @@ describe('', () => { jest.advanceTimersByTime(SET_NAVIGATION_DELAY); - expect(await findByTestId(/nav-item-group1.item1/)).toHaveClass( - 'euiSideNavItemButton-isSelected' + expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).toMatch( + /nav-item-isActive/ ); }); }); @@ -743,7 +654,7 @@ describe('', () => { const { findByTestId } = render( - + @@ -756,13 +667,13 @@ describe('', () => { expect(await findByTestId(/nav-item-group1.cloudLink2/)).toBeVisible(); expect(await findByTestId(/nav-item-group1.cloudLink3/)).toBeVisible(); - expect(await (await findByTestId(/nav-item-group1.cloudLink1/)).textContent).toBe( + expect((await findByTestId(/nav-item-group1.cloudLink1/)).textContent).toBe( 'Mock Users & RolesExternal link' ); - expect(await (await findByTestId(/nav-item-group1.cloudLink2/)).textContent).toBe( + expect((await findByTestId(/nav-item-group1.cloudLink2/)).textContent).toBe( 'Mock PerformanceExternal link' ); - expect(await (await findByTestId(/nav-item-group1.cloudLink3/)).textContent).toBe( + 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_group.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_group.tsx index 76180b799991a..070f156943b27 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 @@ -85,7 +85,7 @@ function NavigationGroupInternalComp< )} {/* 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 - "items" prop (see ) */} + "items" prop (see ) */} {children} ); diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_item.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_item.tsx index 540b9cc6afe08..a48e0b771ece5 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_item.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_item.tsx @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import React, { Fragment, ReactElement, ReactNode, useEffect, useMemo } from 'react'; +import React, { Fragment, useEffect, useMemo } from 'react'; -import type { AppDeepLinkId } from '@kbn/core-chrome-browser'; +import type { AppDeepLinkId, ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; import { useNavigation as useNavigationServices } from '../../services'; -import type { ChromeProjectNavigationNodeEnhanced, NodeProps } from '../types'; import { useInitNavNode } from '../hooks'; +import type { NodeProps } from '../types'; import { useNavigation } from './navigation'; export interface Props< @@ -22,10 +22,6 @@ export interface Props< unstyled?: boolean; } -function isReactElement(element: ReactNode): element is ReactElement { - return React.isValidElement(element); -} - function NavigationItemComp< LinkId extends AppDeepLinkId = AppDeepLinkId, Id extends string = string, @@ -33,7 +29,7 @@ function NavigationItemComp< >(props: Props) { const { cloudLinks } = useNavigationServices(); const navigationContext = useNavigation(); - const navNodeRef = React.useRef(null); + const navNodeRef = React.useRef(null); const { children, node } = useMemo(() => { const { children: _children, ...rest } = props; @@ -44,14 +40,7 @@ function NavigationItemComp< }, [props]); const unstyled = props.unstyled ?? navigationContext.unstyled; - let renderItem: (() => ReactElement) | undefined; - - if (!unstyled && children && (typeof children === 'function' || isReactElement(children))) { - renderItem = - typeof children === 'function' ? () => children(navNodeRef.current) : () => children; - } - - const { navNode } = useInitNavNode({ ...node, children, renderItem }, { cloudLinks }); + const { navNode } = useInitNavNode({ ...node, children }, { cloudLinks }); useEffect(() => { navNodeRef.current = navNode; 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 3e4a3c3327162..1f60ade15930b 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 @@ -7,28 +7,22 @@ */ import React, { FC, useEffect, useState } from 'react'; + import { - EuiCollapsibleNavGroup, - EuiIcon, - EuiLink, - EuiSideNav, - EuiSideNavItemType, - EuiText, + EuiCollapsibleNavItem, + EuiCollapsibleNavItemProps, + EuiCollapsibleNavSubItemGroupTitle, } from '@elastic/eui'; +import { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; import classnames from 'classnames'; import type { BasePathService, NavigateToUrlFn } from '../../../types/internal'; -import { navigationStyles as styles } from '../../styles'; import { useNavigation as useServices } from '../../services'; -import { ChromeProjectNavigationNodeEnhanced } from '../types'; import { isAbsoluteLink } from '../../utils'; -import { GroupAsLink } from './group_as_link'; - -type RenderItem = EuiSideNavItemType['renderItem']; const navigationNodeToEuiItem = ( - item: ChromeProjectNavigationNodeEnhanced, + item: ChromeProjectNavigationNode, { navigateToUrl, basePath }: { navigateToUrl: NavigateToUrlFn; basePath: BasePathService } -): EuiSideNavItemType => { +): EuiCollapsibleNavSubItemGroupTitle | EuiCollapsibleNavItemProps => { const href = item.deepLink?.url ?? item.href; const id = item.path ? item.path.join('.') : item.id; const isExternal = Boolean(href) && isAbsoluteLink(href!); @@ -39,24 +33,16 @@ const navigationNodeToEuiItem = ( [`nav-item-isActive`]: isSelected, }); - const getRenderItem = (): RenderItem | undefined => { - if (!isExternal || item.renderItem) { - return item.renderItem; - } - - return () => ( -
- - {item.title} - -
- ); - }; - return { id, - name: item.title, + isGroupTitle: item.isGroupTitle, + title: item.title, isSelected, + accordionProps: { + ...item.accordionProps, + initialIsOpen: true, // FIXME open state is controlled on component mount + }, + linkProps: { external: isExternal }, onClick: href !== undefined ? (event: React.MouseEvent) => { @@ -65,20 +51,18 @@ const navigationNodeToEuiItem = ( } : undefined, href, - renderItem: getRenderItem(), items: item.children?.map((_item) => navigationNodeToEuiItem(_item, { navigateToUrl, basePath }) ), ['data-test-subj']: dataTestSubj, - ...(item.icon && { - icon: , - }), + icon: item.icon, + iconProps: { size: 's' }, }; }; interface Props { - navNode: ChromeProjectNavigationNodeEnhanced; - items?: ChromeProjectNavigationNodeEnhanced[]; + navNode: ChromeProjectNavigationNode; + items?: ChromeProjectNavigationNode[]; } export const NavigationSectionUI: FC = ({ navNode, items = [] }) => { @@ -90,8 +74,12 @@ export const NavigationSectionUI: FC = ({ navNode, items = [] }) => { const [doCollapseFromActiveState, setDoCollapseFromActiveState] = useState(true); // If the item has no link and no cildren, we don't want to render it - const itemHasLinkOrChildren = (item: ChromeProjectNavigationNodeEnhanced) => { + const itemHasLinkOrChildren = (item: ChromeProjectNavigationNode) => { + const isGroupTitle = Boolean(item.isGroupTitle); const hasLink = Boolean(item.deepLink) || Boolean(item.href); + if (isGroupTitle) { + return true; + } if (hasLink) { return true; } @@ -128,49 +116,39 @@ export const NavigationSectionUI: FC = ({ navNode, items = [] }) => { return null; } - const propsForGroupAsLink = groupIsLink + const propsForGroupAsLink: Partial = groupIsLink ? { - buttonElement: 'div' as const, - // If we don't force the state there is a little UI animation as if the - // accordion was openin/closing. We don't want any animation when it is a link. - forceState: 'closed' as const, - buttonContent: ( - - ), - arrowProps: { style: { display: 'none' } }, + linkProps: { + href: groupHref, + onClick: (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + navigateToUrl(groupHref); + }, + }, } : {}; return ( - { - setIsCollapsed(!isOpen); - setDoCollapseFromActiveState(false); + icon={icon} + iconProps={{ size: 'm' }} + accordionProps={{ + initialIsOpen: isActive, + forceState: isCollapsed ? 'closed' : 'open', + onToggle: (isOpen) => { + setIsCollapsed(!isOpen); + setDoCollapseFromActiveState(false); + }, + ...navNode.accordionProps, }} - forceState={isCollapsed ? 'closed' : 'open'} data-test-subj={`nav-bucket-${id}`} {...propsForGroupAsLink} - > - - - navigationNodeToEuiItem(item, { navigateToUrl, basePath }) - )} - css={styles.euiSideNavItems} - /> - - + items={filteredItems.map((item) => + navigationNodeToEuiItem(item, { navigateToUrl, basePath }) + )} + /> ); }; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_ui.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_ui.tsx index 898a3b6829821..113bfc0add6d6 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_ui.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_ui.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlyoutBody, EuiFlyoutFooter } from '@elastic/eui'; import React, { FC } from 'react'; interface Props { @@ -21,17 +21,12 @@ export const NavigationUI: FC = ({ children, unstyled, footerChildren, da {unstyled ? ( <>{children} ) : ( - - {children} - - {footerChildren && {footerChildren}} - + <> + + {children} + + {footerChildren && {footerChildren}} + )} ); diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/recently_accessed.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/recently_accessed.tsx index 051beb931a371..501695e5ae6a7 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/recently_accessed.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/recently_accessed.tsx @@ -6,14 +6,13 @@ * Side Public License, v 1. */ -import { EuiCollapsibleNavGroup, EuiSideNav, EuiSideNavItemType } from '@elastic/eui'; +import { EuiCollapsibleNavItem } from '@elastic/eui'; import React, { FC } from 'react'; import useObservable from 'react-use/lib/useObservable'; import type { Observable } from 'rxjs'; import { RecentItem } from '../../../types/internal'; import { useNavigation as useServices } from '../../services'; -import { navigationStyles as styles } from '../../styles'; import { getI18nStrings } from '../i18n_strings'; @@ -42,40 +41,31 @@ export const RecentlyAccessed: FC = ({ return null; } - const navItems: Array> = [ - { - name: '', // no list header title - id: 'recents_root', - items: recentlyAccessed.map((recent) => { - const { id, label, link } = recent; - const href = basePath.prepend(link); + const navItems = recentlyAccessed.map((recent) => { + const { id, label, link } = recent; + const href = basePath.prepend(link); - return { - id, - name: label, - href, - onClick: (e: React.MouseEvent) => { - e.preventDefault(); - navigateToUrl(href); - }, - }; - }), - }, - ]; + return { + id, + title: label, + href, + onClick: (e: React.MouseEvent) => { + e.preventDefault(); + navigateToUrl(href); + }, + }; + }); return ( - - - + items={navItems} + /> ); }; 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 e6fddce7e5a75..9a6c9d5598a75 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 @@ -80,7 +80,7 @@ describe('', () => { }, ]; - const { findByTestId } = render( + const { findAllByTestId } = render( @@ -90,16 +90,8 @@ describe('', () => { 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/)).toBeVisible(); - // Click the last group to expand and show the last depth - (await findByTestId(/nav-item-group1.group1A.group1A_1/)).click(); - - expect(await findByTestId(/nav-item-group1.group1A.group1A_1.item1/)).toBeVisible(); + (await findAllByTestId(/nav-item-group1.group1A.group1A_1/))[0].click(); expect(onProjectNavigationChange).toHaveBeenCalled(); const lastCall = @@ -120,7 +112,6 @@ describe('', () => { "group1", "item1", ], - "renderItem": undefined, "title": "Item 1", }, Object { @@ -133,7 +124,6 @@ describe('', () => { "group1", "item2", ], - "renderItem": undefined, "title": "Item 2", }, Object { @@ -149,7 +139,6 @@ describe('', () => { "group1A", "item1", ], - "renderItem": undefined, "title": "Group 1A Item 1", }, Object { @@ -166,7 +155,6 @@ describe('', () => { "group1A_1", "item1", ], - "renderItem": undefined, "title": "Group 1A_1 Item 1", }, ], @@ -291,7 +279,6 @@ describe('', () => { "group1", "item1", ], - "renderItem": undefined, "title": "Title from deeplink", }, Object { @@ -311,7 +298,6 @@ describe('', () => { "group1", "item2", ], - "renderItem": undefined, "title": "Overwrite deeplink title", }, ], @@ -394,7 +380,6 @@ describe('', () => { "group1", "item1", ], - "renderItem": undefined, "title": "Absolute link", }, ], @@ -556,11 +541,11 @@ describe('', () => { jest.advanceTimersByTime(SET_NAVIGATION_DELAY); }); - expect(await findByTestId(/nav-item-group1.item1/)).toHaveClass( - 'euiSideNavItemButton-isSelected' + expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).toMatch( + /nav-item-isActive/ ); - expect(await findByTestId(/nav-item-group1.item2/)).not.toHaveClass( - 'euiSideNavItemButton-isSelected' + expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).not.toMatch( + /nav-item-isActive/ ); }); @@ -619,8 +604,8 @@ describe('', () => { jest.advanceTimersByTime(SET_NAVIGATION_DELAY); }); - expect(await findByTestId(/nav-item-group1.item1/)).toHaveClass( - 'euiSideNavItemButton-isSelected' + expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).toMatch( + /nav-item-isActive/ ); }); }); @@ -703,17 +688,12 @@ describe('', () => { ); expect( - await ( - await findByTestId( - /nav-item-project_settings_project_nav.settings.cloudLinkUserAndRoles/ - ) - ).textContent + (await findByTestId(/nav-item-project_settings_project_nav.cloudLinkUserAndRoles/)) + .textContent ).toBe('Mock Users & RolesExternal link'); expect( - await ( - await findByTestId(/nav-item-project_settings_project_nav.settings.cloudLinkBilling/) - ).textContent + (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/default_navigation.tsx b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx index 4cdb108ef426b..2457fa44a5096 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/default_navigation.tsx @@ -72,23 +72,18 @@ const getDefaultNavigationTree = ( breadcrumbStatus: 'hidden', children: [ { - id: 'settings', - children: [ - { - link: 'management', - title: i18n.translate('sharedUXPackages.chrome.sideNavigation.mngt', { - defaultMessage: 'Management', - }), - }, - { - id: 'cloudLinkUserAndRoles', - cloudLink: 'userAndRoles', - }, - { - id: 'cloudLinkBilling', - cloudLink: 'billingAndSub', - }, - ], + link: 'management', + title: i18n.translate('sharedUXPackages.chrome.sideNavigation.mngt', { + defaultMessage: 'Management', + }), + }, + { + id: 'cloudLinkUserAndRoles', + cloudLink: 'userAndRoles', + }, + { + id: 'cloudLinkBilling', + cloudLink: 'billingAndSub', }, ], }, 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 f569115250e11..cedfd9c1b91e9 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 @@ -19,13 +19,7 @@ import { CloudLinks } from '../../cloud_links'; import { useNavigation as useNavigationServices } from '../../services'; import { isAbsoluteLink } from '../../utils'; import { useNavigation } from '../components/navigation'; -import { - ChromeProjectNavigationNodeEnhanced, - NodeProps, - NodePropsEnhanced, - RegisterFunction, - UnRegisterFunction, -} from '../types'; +import { NodeProps, NodePropsEnhanced, RegisterFunction, UnRegisterFunction } from '../types'; import { useRegisterTreeNode } from './use_register_tree_node'; function getIdFromNavigationNode< @@ -135,7 +129,7 @@ function createInternalNavNode< path: string[] | null, isActive: boolean, { cloudLinks }: { cloudLinks: CloudLinks } -): ChromeProjectNavigationNodeEnhanced | null { +): ChromeProjectNavigationNode | null { validateNodeProps(_navNode); const { children, link, cloudLink, ...navNode } = _navNode; @@ -185,9 +179,9 @@ export const useInitNavNode = < /** * Map of children nodes */ - const [childrenNodes, setChildrenNodes] = useState< - Record - >({}); + const [childrenNodes, setChildrenNodes] = useState>( + {} + ); const isMounted = useRef(false); 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 eeff6afd945e9..c014678265ce9 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/navigation.stories.tsx @@ -6,84 +6,61 @@ * Side Public License, v 1. */ -import React, { FC, useCallback, useState } from 'react'; -import { of } from 'rxjs'; -import { ComponentMeta } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import type { ChromeNavLink } from '@kbn/core-chrome-browser'; +import { useState } from '@storybook/addons'; +import { ComponentMeta } from '@storybook/react'; +import React, { EventHandler, FC, PropsWithChildren, MouseEvent } from 'react'; +import { BehaviorSubject, of } from 'rxjs'; import { EuiButton, - EuiButtonIcon, - EuiCollapsibleNav, + EuiCollapsibleNavBeta, + EuiCollapsibleNavBetaProps, EuiFlexGroup, EuiFlexItem, + EuiHeader, + EuiHeaderSection, EuiLink, + EuiPageTemplate, EuiText, - EuiThemeProvider, EuiTitle, } from '@elastic/eui'; -import { css } from '@emotion/react'; + +import type { ChromeNavLink, ChromeProjectNavigationNode } from '@kbn/core-chrome-browser'; import { NavigationStorybookMock, navLinksMock } from '../../mocks'; import mdx from '../../README.mdx'; -import { NavigationProvider } from '../services'; -import { DefaultNavigation } from './default_navigation'; import type { NavigationServices } from '../../types'; +import { NavigationProvider } from '../services'; import { Navigation } from './components'; -import type { NonEmptyArray, ProjectNavigationDefinition } from './types'; +import { DefaultNavigation } from './default_navigation'; import { getPresets } from './nav_tree_presets'; +import type { GroupDefinition, NonEmptyArray, ProjectNavigationDefinition } from './types'; const storybookMock = new NavigationStorybookMock(); -const SIZE_OPEN = 248; -const SIZE_CLOSED = 40; - -const NavigationWrapper: FC = ({ children }) => { - const [isOpen, setIsOpen] = useState(true); - - const collabsibleNavCSS = css` - border-inline-end-width: 1, - display: flex, - flex-direction: row, - `; - - const CollapseButton = () => { - const buttonCSS = css` - margin-left: -32px; - position: fixed; - z-index: 1000; - `; - return ( - - - - ); - }; - - const toggleOpen = useCallback(() => { - setIsOpen(!isOpen); - }, [isOpen, setIsOpen]); - +const NavigationWrapper: FC< + PropsWithChildren<{ clickAction?: EventHandler; clickActionText?: string }> & + Partial +> = (props) => { return ( - - } - > - {isOpen && children} - - + <> + + + + + + + + {props.clickAction ? ( + + {props.clickActionText ?? 'Click me'} + + ) : ( +

Hello world

+ )} +
+
+ ); }; @@ -123,30 +100,25 @@ const simpleNavigationDefinition: ProjectNavigationDefinition = { defaultIsCollapsed: false, children: [ { - id: 'root', - children: [ - { - id: 'item1', - title: 'Get started', - }, - { - id: 'item2', - title: 'Alerts', - }, - { - id: 'item3', - title: 'Dashboards', - }, - { - id: 'item4', - title: 'External link', - href: 'https://elastic.co', - }, - { - id: 'item5', - title: 'Another link', - }, - ], + id: 'item1', + title: 'Get started', + }, + { + id: 'item2', + title: 'Alerts', + }, + { + id: 'item3', + title: 'Dashboards', + }, + { + id: 'item4', + title: 'External link', + href: 'https://elastic.co', + }, + { + id: 'item5', + title: 'Another link', }, { id: 'group:settings', @@ -205,21 +177,16 @@ const navigationDefinition: ProjectNavigationDefinition = { defaultIsCollapsed: false, children: [ { - id: 'root', - children: [ - { - id: 'item1', - title: 'Get started', - }, - { - id: 'item2', - title: 'Alerts', - }, - { - id: 'item3', - title: 'Some other node', - }, - ], + id: 'item1', + title: 'Get started', + }, + { + id: 'item2', + title: 'Alerts', + }, + { + id: 'item3', + title: 'Some other node', }, { id: 'group:settings', @@ -333,24 +300,22 @@ export const WithUIComponents = (args: NavigationServices) => { icon="logoObservability" defaultIsCollapsed={false} > - - id="item1" link="item1" /> - - {(navNode) => { - return ( -
- {`Render prop: ${navNode.id} - ${navNode.title}`} -
- ); - }} -
- -
- Title in ReactNode -
-
- -
+ id="item1" link="item1" /> + + {(navNode) => { + return ( +
+ {`Render prop: ${navNode.id} - ${navNode.title}`} +
+ ); + }} +
+ +
+ Title in ReactNode +
+
+ @@ -370,12 +335,10 @@ export const WithUIComponents = (args: NavigationServices) => { breadcrumbStatus="hidden" icon="gear" > - - - - - - + + + +
@@ -551,3 +514,121 @@ export const CreativeUI = (args: NavigationServices) => { ); }; + +export const UpdatingState = (args: NavigationServices) => { + const simpleGroupDef: GroupDefinition = { + type: 'navGroup', + id: 'observability_project_nav', + title: 'Observability', + icon: 'logoObservability', + children: [ + { + id: 'aiops', + title: 'AIOps', + icon: 'branch', + children: [ + { + title: 'Anomaly detection', + id: 'ml:anomalyDetection', + link: 'ml:anomalyDetection', + }, + { + title: 'Log Rate Analysis', + id: 'ml:logRateAnalysis', + link: 'ml:logRateAnalysis', + }, + { + title: 'Change Point Detections', + link: 'ml:changePointDetections', + id: 'ml:changePointDetections', + }, + { + title: 'Job Notifications', + link: 'ml:notifications', + id: 'ml:notifications', + }, + ], + }, + { + id: 'project_settings_project_nav', + title: 'Project settings', + icon: 'gear', + children: [ + { id: 'management', link: 'management' }, + { id: 'integrations', link: 'integrations' }, + { id: 'fleet', link: 'fleet' }, + ], + }, + ], + }; + const firstSection = simpleGroupDef.children![0]; + const firstSectionFirstChild = firstSection.children![0]; + const secondSection = simpleGroupDef.children![1]; + const secondSectionFirstChild = secondSection.children![0]; + + const activeNodeSets: ChromeProjectNavigationNode[][][] = [ + [ + [ + { + ...simpleGroupDef, + path: [simpleGroupDef.id], + } as unknown as ChromeProjectNavigationNode, + { + ...firstSection, + path: [simpleGroupDef.id, firstSection.id], + } as unknown as ChromeProjectNavigationNode, + { + ...firstSectionFirstChild, + path: [simpleGroupDef.id, firstSection.id, firstSectionFirstChild.id], + } as unknown as ChromeProjectNavigationNode, + ], + ], + [ + [ + { + ...simpleGroupDef, + path: [simpleGroupDef.id], + } as unknown as ChromeProjectNavigationNode, + { + ...secondSection, + path: [simpleGroupDef.id, secondSection.id], + } as unknown as ChromeProjectNavigationNode, + { + ...secondSectionFirstChild, + path: [simpleGroupDef.id, secondSection.id, secondSectionFirstChild.id], + } as unknown as ChromeProjectNavigationNode, + ], + ], + ]; + + // use state to track which element of activeNodeSets is active + const [activeNodeIndex, setActiveNodeIndex] = useState(0); + const changeActiveNode = () => { + const value = (activeNodeIndex + 1) % 2; // toggle between 0 and 1 + setActiveNodeIndex(value); + }; + + const activeNodes$ = new BehaviorSubject([]); + activeNodes$.next(activeNodeSets[activeNodeIndex]); + + const services = storybookMock.getServices({ + ...args, + activeNodes$, + navLinks$: of([...navLinksMock, ...deepLinks]), + onProjectNavigationChange: (updated) => { + action('Update chrome navigation')(JSON.stringify(updated, null, 2)); + }, + }); + + return ( + + + + + + ); +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/types.ts b/packages/shared-ux/chrome/navigation/src/ui/types.ts index 5175425b6ada4..e7c642fc9d0b9 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/types.ts +++ b/packages/shared-ux/chrome/navigation/src/ui/types.ts @@ -6,13 +6,14 @@ * Side Public License, v 1. */ -import type { ReactElement, ReactNode } from 'react'; +import type { ReactNode } from 'react'; + +import type { EuiAccordionProps } from '@elastic/eui'; import type { AppDeepLinkId, ChromeProjectNavigationNode, NodeDefinition, } from '@kbn/core-chrome-browser'; - import type { RecentlyAccessedProps } from './components'; export type NonEmptyArray = [T, ...T[]]; @@ -45,11 +46,6 @@ export interface NodePropsEnhanced< Id extends string = string, ChildrenId extends string = Id > extends NodeProps { - /** - * This function correspond to the same "itemRender" function that can be passed to - * 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. @@ -57,17 +53,6 @@ export interface NodePropsEnhanced< isActive?: boolean; } -/** - * @internal - */ -export interface ChromeProjectNavigationNodeEnhanced extends ChromeProjectNavigationNode { - /** - * This function correspond to the same "itemRender" function that can be passed to - * the EuiSideNavItemType (see navigation_section_ui.tsx) - */ - renderItem?: () => ReactElement; -} - /** The preset that can be pass to the NavigationBucket component */ export type NavigationGroupPreset = 'analytics' | 'devtools' | 'ml' | 'management'; @@ -101,6 +86,10 @@ export interface GroupDefinition< * `true`: the group will be collapsed event if any of its children nodes matches the current URL. */ defaultIsCollapsed?: boolean; + /* + * Pass props to the EUI accordion component used to represent a nav group + */ + accordionProps?: Partial; preset?: NavigationGroupPreset; } @@ -172,7 +161,7 @@ export type UnRegisterFunction = (id: string) => void; * * A function to register a navigation node on its parent. */ -export type RegisterFunction = (navNode: ChromeProjectNavigationNodeEnhanced) => { +export type RegisterFunction = (navNode: ChromeProjectNavigationNode) => { /** The function to unregister the node. */ unregister: UnRegisterFunction; /** The full path of the node in the navigation tree. */ diff --git a/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx b/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx index 55cf3960ab0bd..f0c012c19f564 100644 --- a/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx +++ b/x-pack/plugins/serverless_observability/public/components/side_navigation/index.tsx @@ -25,136 +25,135 @@ const navigationTree: NavigationTreeDefinition = { title: 'Observability', icon: 'logoObservability', defaultIsCollapsed: false, + accordionProps: { + arrowProps: { css: { display: 'none' } }, + }, breadcrumbStatus: 'hidden', children: [ { - id: 'discover-dashboard-alerts-slos', + title: i18n.translate('xpack.serverlessObservability.nav.logExplorer', { + defaultMessage: 'Log Explorer', + }), + link: 'observability-log-explorer', + }, + { + title: i18n.translate('xpack.serverlessObservability.nav.dashboards', { + defaultMessage: 'Dashboards', + }), + link: 'dashboards', + getIsActive: ({ pathNameSerialized, prepend }) => { + return pathNameSerialized.startsWith(prepend('/app/dashboards')); + }, + }, + { + link: 'observability-overview:alerts', + }, + { + link: 'observability-overview:slos', + }, + { + id: 'aiops', + title: 'AIOps', + accordionProps: { + arrowProps: { css: { display: 'none' } }, + }, children: [ { - title: i18n.translate('xpack.serverlessObservability.nav.logExplorer', { - defaultMessage: 'Log Explorer', + title: i18n.translate('xpack.serverlessObservability.nav.ml.jobs', { + defaultMessage: 'Anomaly detection', }), - link: 'observability-log-explorer', + link: 'ml:anomalyDetection', }, { - title: i18n.translate('xpack.serverlessObservability.nav.dashboards', { - defaultMessage: 'Dashboards', + title: i18n.translate('xpack.serverlessObservability.ml.logRateAnalysis', { + defaultMessage: 'Log rate analysis', }), - link: 'dashboards', + link: 'ml:logRateAnalysis', getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.startsWith(prepend('/app/dashboards')); + return pathNameSerialized.includes(prepend('/app/ml/aiops/log_rate_analysis')); }, }, { - link: 'observability-overview:alerts', - }, - { - link: 'observability-overview:slos', + title: i18n.translate('xpack.serverlessObservability.ml.changePointDetection', { + defaultMessage: 'Change point detection', + }), + link: 'ml:changePointDetections', + getIsActive: ({ pathNameSerialized, prepend }) => { + return pathNameSerialized.includes(prepend('/app/ml/aiops/change_point_detection')); + }, }, { - id: 'aiops', - title: 'AIOps', - children: [ - { - title: i18n.translate('xpack.serverlessObservability.nav.ml.jobs', { - defaultMessage: 'Anomaly detection', - }), - link: 'ml:anomalyDetection', - }, - { - title: i18n.translate('xpack.serverlessObservability.ml.logRateAnalysis', { - defaultMessage: 'Log rate analysis', - }), - link: 'ml:logRateAnalysis', - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.includes(prepend('/app/ml/aiops/log_rate_analysis')); - }, - }, - { - title: i18n.translate('xpack.serverlessObservability.ml.changePointDetection', { - defaultMessage: 'Change point detection', - }), - link: 'ml:changePointDetections', - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.includes( - prepend('/app/ml/aiops/change_point_detection') - ); - }, - }, - { - title: i18n.translate('xpack.serverlessObservability.nav.ml.job.notifications', { - defaultMessage: 'Job notifications', - }), - link: 'ml:notifications', - }, - ], + title: i18n.translate('xpack.serverlessObservability.nav.ml.job.notifications', { + defaultMessage: 'Job notifications', + }), + link: 'ml:notifications', }, ], }, { - id: 'applications', - children: [ - { - id: 'apm', - title: i18n.translate('xpack.serverlessObservability.nav.applications', { - defaultMessage: 'Applications', - }), - children: [ - { - link: 'apm:services', - getIsActive: ({ pathNameSerialized, prepend }) => { - const regex = /app\/apm\/.*service.*/; - return regex.test(pathNameSerialized); - }, - }, - { - link: 'apm:traces', - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.startsWith(prepend('/app/apm/traces')); - }, - }, - { - link: 'apm:dependencies', - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.startsWith(prepend('/app/apm/dependencies')); - }, - }, - ], - }, - ], + id: 'groups-spacer-1', + isGroupTitle: true, }, { - id: 'cases-vis', + id: 'apm', + title: i18n.translate('xpack.serverlessObservability.nav.applications', { + defaultMessage: 'Applications', + }), + accordionProps: { + arrowProps: { css: { display: 'none' } }, + }, children: [ { - link: 'observability-overview:cases', + link: 'apm:services', + getIsActive: ({ pathNameSerialized }) => { + const regex = /app\/apm\/.*service.*/; + return regex.test(pathNameSerialized); + }, }, { - title: i18n.translate('xpack.serverlessObservability.nav.visualizations', { - defaultMessage: 'Visualizations', - }), - link: 'visualize', + link: 'apm:traces', getIsActive: ({ pathNameSerialized, prepend }) => { - return ( - pathNameSerialized.startsWith(prepend('/app/visualize')) || - pathNameSerialized.startsWith(prepend('/app/lens')) || - pathNameSerialized.startsWith(prepend('/app/maps')) - ); + return pathNameSerialized.startsWith(prepend('/app/apm/traces')); }, }, - ], - }, - { - id: 'on-boarding', - children: [ { - title: i18n.translate('xpack.serverlessObservability.nav.getStarted', { - defaultMessage: 'Add data', - }), - link: 'observabilityOnboarding', + link: 'apm:dependencies', + getIsActive: ({ pathNameSerialized, prepend }) => { + return pathNameSerialized.startsWith(prepend('/app/apm/dependencies')); + }, }, ], }, + { + id: 'groups-spacer-2', + isGroupTitle: true, + }, + { + link: 'observability-overview:cases', + }, + { + title: i18n.translate('xpack.serverlessObservability.nav.visualizations', { + defaultMessage: 'Visualizations', + }), + link: 'visualize', + getIsActive: ({ pathNameSerialized, prepend }) => { + return ( + pathNameSerialized.startsWith(prepend('/app/visualize')) || + pathNameSerialized.startsWith(prepend('/app/lens')) || + pathNameSerialized.startsWith(prepend('/app/maps')) + ); + }, + }, + { + id: 'groups-spacer-3', + isGroupTitle: true, + }, + { + title: i18n.translate('xpack.serverlessObservability.nav.getStarted', { + defaultMessage: 'Add data', + }), + link: 'observabilityOnboarding', + }, ], }, ], @@ -178,29 +177,24 @@ const navigationTree: NavigationTreeDefinition = { breadcrumbStatus: 'hidden', children: [ { - id: 'settings', - children: [ - { - link: 'management', - title: i18n.translate('xpack.serverlessObservability.nav.mngt', { - defaultMessage: 'Management', - }), - }, - { - link: 'integrations', - }, - { - link: 'fleet', - }, - { - id: 'cloudLinkUserAndRoles', - cloudLink: 'userAndRoles', - }, - { - id: 'cloudLinkBilling', - cloudLink: 'billingAndSub', - }, - ], + link: 'management', + title: i18n.translate('xpack.serverlessObservability.nav.mngt', { + defaultMessage: 'Management', + }), + }, + { + link: 'integrations', + }, + { + link: 'fleet', + }, + { + id: 'cloudLinkUserAndRoles', + cloudLink: 'userAndRoles', + }, + { + id: 'cloudLinkBilling', + cloudLink: 'billingAndSub', }, ], }, diff --git a/x-pack/plugins/serverless_search/public/layout/nav.tsx b/x-pack/plugins/serverless_search/public/layout/nav.tsx index 047b490fcb137..b6f1cdbaa56ff 100644 --- a/x-pack/plugins/serverless_search/public/layout/nav.tsx +++ b/x-pack/plugins/serverless_search/public/layout/nav.tsx @@ -25,6 +25,9 @@ const navigationTree: NavigationTreeDefinition = { title: 'Elasticsearch', icon: 'logoElasticsearch', defaultIsCollapsed: false, + accordionProps: { + arrowProps: { css: { display: 'none' } }, + }, breadcrumbStatus: 'hidden', children: [ { @@ -39,77 +42,75 @@ const navigationTree: NavigationTreeDefinition = { title: i18n.translate('xpack.serverlessSearch.nav.devTools', { defaultMessage: 'Dev Tools', }), - children: [{ link: 'dev_tools:console' }, { link: 'dev_tools:searchprofiler' }], + isGroupTitle: true, }, + { link: 'dev_tools:console' }, + { link: 'dev_tools:searchprofiler' }, { id: 'explore', title: i18n.translate('xpack.serverlessSearch.nav.explore', { defaultMessage: 'Explore', }), - children: [ - { - link: 'discover', - }, - { - link: 'dashboards', - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.startsWith(prepend('/app/dashboards')); - }, - }, - { - link: 'visualize', - getIsActive: ({ pathNameSerialized, prepend }) => { - return ( - pathNameSerialized.startsWith(prepend('/app/visualize')) || - pathNameSerialized.startsWith(prepend('/app/lens')) || - pathNameSerialized.startsWith(prepend('/app/maps')) - ); - }, - }, - { - link: 'management:triggersActions', - title: i18n.translate('xpack.serverlessSearch.nav.alerts', { - defaultMessage: 'Alerts', - }), - }, - ], + isGroupTitle: true, }, + { + link: 'discover', + }, + { + link: 'dashboards', + getIsActive: ({ pathNameSerialized, prepend }) => { + return pathNameSerialized.startsWith(prepend('/app/dashboards')); + }, + }, + { + link: 'visualize', + getIsActive: ({ pathNameSerialized, prepend }) => { + return ( + pathNameSerialized.startsWith(prepend('/app/visualize')) || + pathNameSerialized.startsWith(prepend('/app/lens')) || + pathNameSerialized.startsWith(prepend('/app/maps')) + ); + }, + }, + { + link: 'management:triggersActions', + title: i18n.translate('xpack.serverlessSearch.nav.alerts', { + defaultMessage: 'Alerts', + }), + }, + { id: 'content', title: i18n.translate('xpack.serverlessSearch.nav.content', { defaultMessage: 'Content', }), - children: [ - { - title: i18n.translate('xpack.serverlessSearch.nav.content.indices', { - defaultMessage: 'Index Management', - }), - link: 'management:index_management', - breadcrumbStatus: - 'hidden' /* management sub-pages set their breadcrumbs themselves */, - }, - { - title: i18n.translate('xpack.serverlessSearch.nav.content.pipelines', { - defaultMessage: 'Pipelines', - }), - link: 'management:ingest_pipelines', - breadcrumbStatus: - 'hidden' /* management sub-pages set their breadcrumbs themselves */, - }, - ], + isGroupTitle: true, }, + { + title: i18n.translate('xpack.serverlessSearch.nav.content.indices', { + defaultMessage: 'Index Management', + }), + link: 'management:index_management', + breadcrumbStatus: 'hidden' /* management sub-pages set their breadcrumbs themselves */, + }, + { + title: i18n.translate('xpack.serverlessSearch.nav.content.pipelines', { + defaultMessage: 'Pipelines', + }), + link: 'management:ingest_pipelines', + breadcrumbStatus: 'hidden' /* management sub-pages set their breadcrumbs themselves */, + }, + { id: 'security', title: i18n.translate('xpack.serverlessSearch.nav.security', { defaultMessage: 'Security', }), - children: [ - { - link: 'management:api_keys', - breadcrumbStatus: - 'hidden' /* management sub-pages set their breadcrumbs themselves */, - }, - ], + isGroupTitle: true, + }, + { + link: 'management:api_keys', + breadcrumbStatus: 'hidden' /* management sub-pages set their breadcrumbs themselves */, }, ], }, @@ -125,30 +126,25 @@ const navigationTree: NavigationTreeDefinition = { breadcrumbStatus: 'hidden', children: [ { - id: 'settings', - children: [ - { - link: 'management', - title: i18n.translate('xpack.serverlessSearch.nav.mngt', { - defaultMessage: 'Management', - }), - }, - { - id: 'cloudLinkDeployment', - cloudLink: 'deployment', - title: i18n.translate('xpack.serverlessSearch.nav.performance', { - defaultMessage: 'Performance', - }), - }, - { - id: 'cloudLinkUserAndRoles', - cloudLink: 'userAndRoles', - }, - { - id: 'cloudLinkBilling', - cloudLink: 'billingAndSub', - }, - ], + link: 'management', + title: i18n.translate('xpack.serverlessSearch.nav.mngt', { + defaultMessage: 'Management', + }), + }, + { + id: 'cloudLinkDeployment', + cloudLink: 'deployment', + title: i18n.translate('xpack.serverlessSearch.nav.performance', { + defaultMessage: 'Performance', + }), + }, + { + id: 'cloudLinkUserAndRoles', + cloudLink: 'userAndRoles', + }, + { + id: 'cloudLinkBilling', + cloudLink: 'billingAndSub', }, ], }, diff --git a/x-pack/test_serverless/functional/page_objects/svl_common_navigation.ts b/x-pack/test_serverless/functional/page_objects/svl_common_navigation.ts index 3c3b10a5d03c8..56351af9b43f1 100644 --- a/x-pack/test_serverless/functional/page_objects/svl_common_navigation.ts +++ b/x-pack/test_serverless/functional/page_objects/svl_common_navigation.ts @@ -23,6 +23,7 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) { const testSubjects = ctx.getService('testSubjects'); const browser = ctx.getService('browser'); const retry = ctx.getService('retry'); + const log = ctx.getService('log'); async function getByVisibleText( selector: string | (() => Promise), @@ -93,16 +94,20 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) { } }, async expectSectionExists(sectionId: NavigationId) { + log.debug('ServerlessCommonNavigation.sidenav.expectSectionExists', sectionId); await testSubjects.existOrFail(`~nav-bucket-${sectionId}`); }, async isSectionOpen(sectionId: NavigationId) { await this.expectSectionExists(sectionId); const section = await testSubjects.find(`~nav-bucket-${sectionId}`); - const collapseBtn = await section.findByCssSelector(`[aria-controls="${sectionId}"]`); + const collapseBtn = await section.findByCssSelector( + `[aria-controls="${sectionId}"][aria-expanded]` + ); const isExpanded = await collapseBtn.getAttribute('aria-expanded'); return isExpanded === 'true'; }, async expectSectionOpen(sectionId: NavigationId) { + log.debug('ServerlessCommonNavigation.sidenav.expectSectionOpen', sectionId); await this.expectSectionExists(sectionId); await retry.waitFor(`section ${sectionId} to be open`, async () => { const isOpen = await this.isSectionOpen(sectionId); @@ -117,11 +122,14 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) { }); }, async openSection(sectionId: NavigationId) { + log.debug('ServerlessCommonNavigation.sidenav.openSection', sectionId); await this.expectSectionExists(sectionId); const isOpen = await this.isSectionOpen(sectionId); if (isOpen) return; const section = await testSubjects.find(`~nav-bucket-${sectionId}`); - const collapseBtn = await section.findByCssSelector(`[aria-controls="${sectionId}"]`); + const collapseBtn = await section.findByCssSelector( + `[aria-controls="${sectionId}"][aria-expanded]` + ); await collapseBtn.click(); await this.expectSectionOpen(sectionId); }, @@ -130,7 +138,9 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) { const isOpen = await this.isSectionOpen(sectionId); if (!isOpen) return; const section = await testSubjects.find(`~nav-bucket-${sectionId}`); - const collapseBtn = await section.findByCssSelector(`[aria-controls="${sectionId}"]`); + const collapseBtn = await section.findByCssSelector( + `[aria-controls="${sectionId}"][aria-expanded]` + ); await collapseBtn.click(); await this.expectSectionClosed(sectionId); }, @@ -143,6 +153,10 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) { await testSubjects.click('~breadcrumb-home'); }, async expectBreadcrumbExists(by: { deepLinkId: AppDeepLinkId } | { text: string }) { + log.debug( + 'ServerlessCommonNavigation.breadcrumbs.expectBreadcrumbExists', + JSON.stringify(by) + ); if ('deepLinkId' in by) { await testSubjects.existOrFail(`~breadcrumb-deepLinkId-${by.deepLinkId}`); } else { @@ -161,6 +175,10 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) { } }, async expectBreadcrumbTexts(expectedBreadcrumbTexts: string[]) { + log.debug( + 'ServerlessCommonNavigation.breadcrumbs.expectBreadcrumbTexts', + JSON.stringify(expectedBreadcrumbTexts) + ); await retry.try(async () => { const breadcrumbsContainer = await testSubjects.find('breadcrumbs'); const breadcrumbs = await breadcrumbsContainer.findAllByTestSubject('~breadcrumb'); diff --git a/x-pack/test_serverless/functional/test_suites/search/navigation.ts b/x-pack/test_serverless/functional/test_suites/search/navigation.ts index 13eaca8526366..9b7e0d545394d 100644 --- a/x-pack/test_serverless/functional/test_suites/search/navigation.ts +++ b/x-pack/test_serverless/functional/test_suites/search/navigation.ts @@ -48,9 +48,8 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { // navigate to discover await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'discover' }); await svlCommonNavigation.sidenav.expectLinkActive({ deepLinkId: 'discover' }); - await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ text: `Explore` }); await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ deepLinkId: 'discover' }); - await expect(await browser.getCurrentUrl()).contain('/app/discover'); + expect(await browser.getCurrentUrl()).contain('/app/discover'); // navigate to a different section await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'management:index_management' }); @@ -73,20 +72,16 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { it("management apps from the sidenav hide the 'stack management' root from the breadcrumbs", async () => { await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'management:triggersActions' }); - await svlCommonNavigation.breadcrumbs.expectBreadcrumbTexts(['Explore', 'Alerts', 'Rules']); + await svlCommonNavigation.breadcrumbs.expectBreadcrumbTexts(['Alerts', 'Rules']); await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'management:index_management' }); - await svlCommonNavigation.breadcrumbs.expectBreadcrumbTexts([ - 'Content', - 'Index Management', - 'Indices', - ]); + await svlCommonNavigation.breadcrumbs.expectBreadcrumbTexts(['Index Management', 'Indices']); await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'management:ingest_pipelines' }); - await svlCommonNavigation.breadcrumbs.expectBreadcrumbTexts(['Content', 'Ingest Pipelines']); + await svlCommonNavigation.breadcrumbs.expectBreadcrumbTexts(['Ingest Pipelines']); await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'management:api_keys' }); - await svlCommonNavigation.breadcrumbs.expectBreadcrumbTexts(['Security', 'API keys']); + await svlCommonNavigation.breadcrumbs.expectBreadcrumbTexts(['API keys']); }); it('navigate management', async () => { @@ -104,7 +99,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { await svlCommonNavigation.search.clickOnOption(0); await svlCommonNavigation.search.hideSearch(); - await expect(await browser.getCurrentUrl()).contain('/app/discover'); + expect(await browser.getCurrentUrl()).contain('/app/discover'); }); it('does not show cases in sidebar navigation', async () => {