Project Side Navigation: Use EuiCollapsibleNavBeta component (#164910)

## 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|
663765a3-4e4b-416e-b7d5-7d87eece83e8
| <img width="298" alt="CleanShot 2023-09-22 at 14 20 48@2x"
src="d61f6fe0-a6a9-4806-bc27-08b0ff2afb49">
|
|search|
f383773e-27a8-4485-8289-274d8231b960
| <img width="281" alt="CleanShot 2023-09-22 at 14 18 43@2x"
src="901b8fc1-9945-4cee-b566-5e4539f08043">
|
|security|
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 <sebastien.loix@elastic.co>
This commit is contained in:
Tim Sullivan 2023-09-27 14:22:46 -07:00 committed by GitHub
parent 9fba1e3f24
commit 0c680d7783
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 780 additions and 1129 deletions

View file

@ -13,7 +13,6 @@ import React from 'react';
import { HeaderActionMenu } from '../header/header_action_menu'; import { HeaderActionMenu } from '../header/header_action_menu';
interface AppMenuBarProps { interface AppMenuBarProps {
isOpen: boolean;
headerActionMenuMounter: { mount: MountPoint<HTMLElement> | undefined }; headerActionMenuMounter: { mount: MountPoint<HTMLElement> | undefined };
} }
export const AppMenuBar = ({ headerActionMenuMounter }: AppMenuBarProps) => { export const AppMenuBar = ({ headerActionMenuMounter }: AppMenuBarProps) => {

View file

@ -9,7 +9,7 @@
import { EuiHeader } from '@elastic/eui'; import { EuiHeader } from '@elastic/eui';
import { applicationServiceMock } from '@kbn/core-application-browser-mocks'; import { applicationServiceMock } from '@kbn/core-application-browser-mocks';
import { docLinksServiceMock } from '@kbn/core-doc-links-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 React from 'react';
import * as Rx from 'rxjs'; import * as Rx from 'rxjs';
import { ProjectHeader, Props as ProjectHeaderProps } from './header'; import { ProjectHeader, Props as ProjectHeaderProps } from './header';
@ -45,35 +45,10 @@ describe('Header', () => {
</ProjectHeader> </ProjectHeader>
); );
expect(await screen.findByTestId('toggleNavButton')).toBeVisible(); expect(await screen.findByTestId('euiCollapsibleNavButton')).toBeVisible();
expect(await screen.findByText('Hello, world!')).toBeVisible(); expect(await screen.findByText('Hello, world!')).toBeVisible();
}); });
it('can collapse and uncollapse', async () => {
render(
<ProjectHeader {...mockProps}>
<EuiHeader>Hello, goodbye!</EuiHeader>
</ProjectHeader>
);
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 () => { it('displays the link to projects', async () => {
render( render(
<ProjectHeader {...mockProps}> <ProjectHeader {...mockProps}>
@ -81,7 +56,7 @@ describe('Header', () => {
</ProjectHeader> </ProjectHeader>
); );
const projectsLink = await screen.getByTestId('projectsLink'); const projectsLink = screen.getByTestId('projectsLink');
expect(projectsLink).toHaveAttribute('href', '/projects/'); expect(projectsLink).toHaveAttribute('href', '/projects/');
expect(projectsLink).toHaveTextContent('My Project'); expect(projectsLink).toHaveTextContent('My Project');
}); });

View file

@ -12,10 +12,7 @@ import {
EuiHeaderLogo, EuiHeaderLogo,
EuiHeaderSection, EuiHeaderSection,
EuiHeaderSectionItem, EuiHeaderSectionItem,
EuiHeaderSectionItemButton,
EuiIcon,
EuiLoadingSpinner, EuiLoadingSpinner,
htmlIdGenerator,
useEuiTheme, useEuiTheme,
EuiThemeComputed, EuiThemeComputed,
} from '@elastic/eui'; } from '@elastic/eui';
@ -35,8 +32,7 @@ import { MountPoint } from '@kbn/core-mount-utils-browser';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { Router } from '@kbn/shared-ux-router'; import { Router } from '@kbn/shared-ux-router';
import React, { createRef, useCallback, useState } from 'react'; import React, { useCallback } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import useObservable from 'react-use/lib/useObservable'; import useObservable from 'react-use/lib/useObservable';
import { debounceTime, Observable, of } from 'rxjs'; import { debounceTime, Observable, of } from 'rxjs';
import { useHeaderActionMenuMounter } from '../header/header_action_menu'; import { useHeaderActionMenuMounter } from '../header/header_action_menu';
@ -66,13 +62,6 @@ const getHeaderCss = ({ size }: EuiThemeComputed) => ({
top: 2px; top: 2px;
`, `,
}, },
nav: {
toggleNavButton: css`
border-right: 1px solid #d3dae6;
margin-left: -1px;
padding-right: ${size.xs};
`,
},
projectName: { projectName: {
link: css` link: css`
/* TODO: make header layout more flexible? */ /* TODO: make header layout more flexible? */
@ -123,7 +112,6 @@ export interface Props {
prependBasePath: (url: string) => string; prependBasePath: (url: string) => string;
} }
const LOCAL_STORAGE_IS_OPEN_KEY = 'PROJECT_NAVIGATION_OPEN' as const;
const LOADING_DEBOUNCE_TIME = 80; const LOADING_DEBOUNCE_TIME = 80;
type LogoProps = Pick<Props, 'application' | 'homeHref$' | 'loadingCount$' | 'prependBasePath'> & { type LogoProps = Pick<Props, 'application' | 'homeHref$' | 'loadingCount$' | 'prependBasePath'> & {
@ -186,9 +174,6 @@ export const ProjectHeader = ({
docLinks, docLinks,
...observables ...observables
}: Props) => { }: Props) => {
const [navId] = useState(htmlIdGenerator()());
const [isOpen, setIsOpen] = useLocalStorage(LOCAL_STORAGE_IS_OPEN_KEY, true);
const toggleCollapsibleNavRef = createRef<HTMLButtonElement & { euiAnimate: () => void }>();
const headerActionMenuMounter = useHeaderActionMenuMounter(observables.actionMenu$); const headerActionMenuMounter = useHeaderActionMenuMounter(observables.actionMenu$);
const projectsUrl = useObservable(observables.projectsUrl$); const projectsUrl = useObservable(observables.projectsUrl$);
const projectName = useObservable(observables.projectName$); const projectName = useObservable(observables.projectName$);
@ -210,34 +195,9 @@ export const ProjectHeader = ({
<div id="globalHeaderBars" data-test-subj="headerGlobalNav" className="header__bars"> <div id="globalHeaderBars" data-test-subj="headerGlobalNav" className="header__bars">
<EuiHeader position="fixed" className="header__firstBar"> <EuiHeader position="fixed" className="header__firstBar">
<EuiHeaderSection grow={false}> <EuiHeaderSection grow={false}>
<EuiHeaderSectionItem css={headerCss.nav.toggleNavButton}> <Router history={application.history}>
<Router history={application.history}> <ProjectNavigation>{children}</ProjectNavigation>
<ProjectNavigation </Router>
isOpen={isOpen!}
closeNav={() => {
setIsOpen(false);
if (toggleCollapsibleNavRef.current) {
toggleCollapsibleNavRef.current.focus();
}
}}
button={
<EuiHeaderSectionItemButton
data-test-subj="toggleNavButton"
aria-label={headerStrings.nav.closeNavAriaLabel}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen!}
aria-pressed={isOpen!}
aria-controls={navId}
ref={toggleCollapsibleNavRef}
>
<EuiIcon type={isOpen ? 'menuLeft' : 'menuRight'} size="m" />
</EuiHeaderSectionItemButton>
}
>
{children}
</ProjectNavigation>
</Router>
</EuiHeaderSectionItem>
<EuiHeaderSectionItem> <EuiHeaderSectionItem>
<Logo <Logo
@ -297,7 +257,7 @@ export const ProjectHeader = ({
</header> </header>
{headerActionMenuMounter.mount && ( {headerActionMenuMounter.mount && (
<AppMenuBar isOpen={isOpen ?? false} headerActionMenuMounter={headerActionMenuMounter} /> <AppMenuBar headerActionMenuMounter={headerActionMenuMounter} />
)} )}
</> </>
); );

View file

@ -6,55 +6,25 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import { EuiCollapsibleNavBeta } from '@elastic/eui';
import React from 'react'; import React from 'react';
import { css } from '@emotion/react'; import useLocalStorage from 'react-use/lib/useLocalStorage';
import { EuiCollapsibleNav, EuiCollapsibleNavProps } from '@elastic/eui';
const SIZE_EXPANDED = 248; const LOCAL_STORAGE_IS_COLLAPSED_KEY = 'PROJECT_NAVIGATION_COLLAPSED' as const;
const SIZE_COLLAPSED = 0;
export interface ProjectNavigationProps { export const ProjectNavigation: React.FC = ({ children }) => {
isOpen: boolean; const [isCollapsed, setIsCollapsed] = useLocalStorage(LOCAL_STORAGE_IS_COLLAPSED_KEY, false);
closeNav: () => void; const onCollapseToggle = (nextIsCollapsed: boolean) => {
button: EuiCollapsibleNavProps['button']; setIsCollapsed(nextIsCollapsed);
} };
export const ProjectNavigation: React.FC<ProjectNavigationProps> = ({
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;
return ( return (
<> <EuiCollapsibleNavBeta
{ initialIsCollapsed={isCollapsed}
/* must render the tree to initialize the navigation, even if it shouldn't be visible */ onCollapseToggle={onCollapseToggle}
!isOpen && <div hidden>{children}</div> css={isCollapsed ? { display: 'none;' } : {}}
} >
<EuiCollapsibleNav {children}
className="projectLayoutSideNav" </EuiCollapsibleNavBeta>
css={collabsibleNavCSS}
isOpen={isVisible /* only affects docked state */}
showButtonIfDocked={true}
onClose={closeNav}
isDocked={true}
size={isVisible ? SIZE_EXPANDED : SIZE_COLLAPSED}
hideCloseButton={false}
dockedBreakpoint={DOCKED_BREAKPOINT}
ownFocus={false}
button={button}
>
{isOpen && children}
</EuiCollapsibleNav>
</>
); );
}; };

View file

@ -5,8 +5,10 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server * in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { Location } from 'history'; 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 DevToolsApp, DeepLinkId as DevToolsLink } from '@kbn/deeplinks-devtools';
import type { import type {
AppId as AnalyticsApp, AppId as AnalyticsApp,
@ -68,6 +70,8 @@ export interface ChromeProjectNavigationNode {
deepLink?: ChromeNavLink; deepLink?: ChromeNavLink;
/** Optional icon for the navigation node. Note: not all navigation depth will render the icon */ /** Optional icon for the navigation node. Note: not all navigation depth will render the icon */
icon?: string; icon?: string;
/** Optional flag to indicate if the node must be treated as a group title */
isGroupTitle?: boolean;
/** Optional children of the navigation node */ /** Optional children of the navigation node */
children?: ChromeProjectNavigationNode[]; children?: ChromeProjectNavigationNode[];
/** /**
@ -88,6 +92,8 @@ export interface ChromeProjectNavigationNode {
* @default 'visible' * @default 'visible'
*/ */
breadcrumbStatus?: 'hidden' | 'visible'; breadcrumbStatus?: 'hidden' | 'visible';
accordionProps?: Partial<EuiAccordionProps>;
} }
/** @public */ /** @public */
@ -139,7 +145,12 @@ export interface NodeDefinition<
cloudLink?: CloudLinkId; cloudLink?: CloudLinkId;
/** Optional icon for the navigation node. Note: not all navigation depth will render the icon */ /** Optional icon for the navigation node. Note: not all navigation depth will render the icon */
icon?: string; 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<NodeDefinition<LinkId, Id, ChildrenId>>; children?: NonEmptyArray<NodeDefinition<LinkId, Id, ChildrenId>>;
/** /**
* Use href for absolute links only. Internal links should use "link". * Use href for absolute links only. Internal links should use "link".
@ -155,6 +166,8 @@ export interface NodeDefinition<
* @default 'visible' * @default 'visible'
*/ */
breadcrumbStatus?: 'hidden' | 'visible'; breadcrumbStatus?: 'hidden' | 'visible';
accordionProps?: Partial<EuiAccordionProps>;
} }
/** /**

View file

@ -21,18 +21,13 @@ export const defaultNavigation: AnalyticsNodeDefinition = {
icon: 'stats', icon: 'stats',
children: [ children: [
{ {
id: 'root', link: 'discover',
children: [ },
{ {
link: 'discover', link: 'dashboards',
}, },
{ {
link: 'dashboards', link: 'visualize',
},
{
link: 'visualize',
},
],
}, },
], ],
}; };

View file

@ -21,21 +21,16 @@ export const defaultNavigation: DevToolsNodeDefinition = {
icon: 'editorCodeBlock', icon: 'editorCodeBlock',
children: [ children: [
{ {
id: 'root', link: 'dev_tools:console',
children: [ },
{ {
link: 'dev_tools:console', link: 'dev_tools:searchprofiler',
}, },
{ {
link: 'dev_tools:searchprofiler', link: 'dev_tools:grokdebugger',
}, },
{ {
link: 'dev_tools:grokdebugger', link: 'dev_tools:painless_lab',
},
{
link: 'dev_tools:painless_lab',
},
],
}, },
], ],
}; };

View file

@ -29,13 +29,7 @@ export const defaultNavigation: ManagementNodeDefinition = {
icon: 'gear', icon: 'gear',
children: [ children: [
{ {
id: 'root', link: 'monitoring',
title: '',
children: [
{
link: 'monitoring',
},
],
}, },
{ {
id: 'integration_management', id: 'integration_management',

View file

@ -28,16 +28,10 @@ export const defaultNavigation: MlNodeDefinition = {
icon: 'machineLearningApp', icon: 'machineLearningApp',
children: [ children: [
{ {
title: '', link: 'ml:overview',
id: 'root', },
children: [ {
{ link: 'ml:notifications',
link: 'ml:overview',
},
{
link: 'ml:notifications',
},
],
}, },
{ {
title: i18n.translate('defaultNavigation.ml.anomalyDetection', { title: i18n.translate('defaultNavigation.ml.anomalyDetection', {

View file

@ -14,7 +14,7 @@ import { NavigationServices } from '../../types';
type Arguments = NavigationServices; type Arguments = NavigationServices;
export type Params = Pick< export type Params = Pick<
Arguments, Arguments,
'navIsOpen' | 'recentlyAccessed$' | 'navLinks$' | 'onProjectNavigationChange' 'navIsOpen' | 'recentlyAccessed$' | 'activeNodes$' | 'navLinks$' | 'onProjectNavigationChange'
>; >;
export class StorybookMock extends AbstractStorybookMock<{}, NavigationServices> { export class StorybookMock extends AbstractStorybookMock<{}, NavigationServices> {
@ -43,7 +43,7 @@ export class StorybookMock extends AbstractStorybookMock<{}, NavigationServices>
recentlyAccessed$: params.recentlyAccessed$ ?? new BehaviorSubject([]), recentlyAccessed$: params.recentlyAccessed$ ?? new BehaviorSubject([]),
navLinks$: params.navLinks$ ?? new BehaviorSubject([]), navLinks$: params.navLinks$ ?? new BehaviorSubject([]),
onProjectNavigationChange: params.onProjectNavigationChange ?? (() => undefined), onProjectNavigationChange: params.onProjectNavigationChange ?? (() => undefined),
activeNodes$: new BehaviorSubject([]), activeNodes$: params.activeNodes$ ?? new BehaviorSubject([]),
cloudLinks: { cloudLinks: {
billingAndSub: { billingAndSub: {
title: 'Billing & Subscriptions', title: 'Billing & Subscriptions',

View file

@ -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;
`,
};

View file

@ -14,7 +14,6 @@ Array [
"group1", "group1",
"item1", "item1",
], ],
"renderItem": undefined,
"title": "Item 1", "title": "Item 1",
}, },
Object { Object {
@ -33,7 +32,6 @@ Array [
"group1", "group1",
"item2", "item2",
], ],
"renderItem": undefined,
"title": "Title from deeplink!", "title": "Title from deeplink!",
}, },
Object { Object {
@ -52,7 +50,6 @@ Array [
"group1", "group1",
"item3", "item3",
], ],
"renderItem": undefined,
"title": "Deeplink title overriden", "title": "Deeplink title overriden",
}, },
], ],
@ -69,77 +66,58 @@ Array [
Object { Object {
"children": Array [ "children": Array [
Object { Object {
"children": Array [ "children": undefined,
Object { "deepLink": Object {
"children": undefined, "baseUrl": "/mocked",
"deepLink": Object { "href": "http://mocked/discover",
"baseUrl": "/mocked", "id": "discover",
"href": "http://mocked/discover", "title": "Deeplink discover",
"id": "discover", "url": "/mocked/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",
},
],
"deepLink": undefined,
"href": undefined, "href": undefined,
"id": "root", "id": "discover",
"isActive": false, "isActive": false,
"path": Array [ "path": Array [
"rootNav:analytics", "rootNav:analytics",
"root", "discover",
], ],
"title": "", "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",
"dashboards",
],
"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, "deepLink": undefined,
@ -156,57 +134,40 @@ Array [
Object { Object {
"children": Array [ "children": Array [
Object { Object {
"children": Array [ "children": undefined,
Object { "deepLink": Object {
"children": undefined, "baseUrl": "/mocked",
"deepLink": Object { "href": "http://mocked/ml:overview",
"baseUrl": "/mocked", "id": "ml:overview",
"href": "http://mocked/ml:overview", "title": "Deeplink ml:overview",
"id": "ml:overview", "url": "/mocked/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",
},
],
"deepLink": undefined,
"href": undefined, "href": undefined,
"id": "root", "id": "ml:overview",
"isActive": false, "isActive": false,
"path": Array [ "path": Array [
"rootNav:ml", "rootNav:ml",
"root", "ml:overview",
], ],
"title": "", "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",
"ml:notifications",
],
"title": "Deeplink ml:notifications",
}, },
Object { Object {
"children": Array [ "children": Array [
@ -227,7 +188,6 @@ Array [
"anomaly_detection", "anomaly_detection",
"ml:anomalyDetection", "ml:anomalyDetection",
], ],
"renderItem": undefined,
"title": "Jobs", "title": "Jobs",
}, },
Object { Object {
@ -247,7 +207,6 @@ Array [
"anomaly_detection", "anomaly_detection",
"ml:anomalyExplorer", "ml:anomalyExplorer",
], ],
"renderItem": undefined,
"title": "Deeplink ml:anomalyExplorer", "title": "Deeplink ml:anomalyExplorer",
}, },
Object { Object {
@ -267,7 +226,6 @@ Array [
"anomaly_detection", "anomaly_detection",
"ml:singleMetricViewer", "ml:singleMetricViewer",
], ],
"renderItem": undefined,
"title": "Deeplink ml:singleMetricViewer", "title": "Deeplink ml:singleMetricViewer",
}, },
Object { Object {
@ -287,7 +245,6 @@ Array [
"anomaly_detection", "anomaly_detection",
"ml:settings", "ml:settings",
], ],
"renderItem": undefined,
"title": "Deeplink ml:settings", "title": "Deeplink ml:settings",
}, },
], ],
@ -320,7 +277,6 @@ Array [
"data_frame_analytics", "data_frame_analytics",
"ml:dataFrameAnalytics", "ml:dataFrameAnalytics",
], ],
"renderItem": undefined,
"title": "Jobs", "title": "Jobs",
}, },
Object { Object {
@ -340,7 +296,6 @@ Array [
"data_frame_analytics", "data_frame_analytics",
"ml:resultExplorer", "ml:resultExplorer",
], ],
"renderItem": undefined,
"title": "Deeplink ml:resultExplorer", "title": "Deeplink ml:resultExplorer",
}, },
Object { Object {
@ -360,7 +315,6 @@ Array [
"data_frame_analytics", "data_frame_analytics",
"ml:analyticsMap", "ml:analyticsMap",
], ],
"renderItem": undefined,
"title": "Deeplink ml:analyticsMap", "title": "Deeplink ml:analyticsMap",
}, },
], ],
@ -393,7 +347,6 @@ Array [
"model_management", "model_management",
"ml:nodesOverview", "ml:nodesOverview",
], ],
"renderItem": undefined,
"title": "Deeplink ml:nodesOverview", "title": "Deeplink ml:nodesOverview",
}, },
Object { Object {
@ -413,7 +366,6 @@ Array [
"model_management", "model_management",
"ml:nodes", "ml:nodes",
], ],
"renderItem": undefined,
"title": "Deeplink ml:nodes", "title": "Deeplink ml:nodes",
}, },
], ],
@ -446,7 +398,6 @@ Array [
"data_visualizer", "data_visualizer",
"ml:fileUpload", "ml:fileUpload",
], ],
"renderItem": undefined,
"title": "File", "title": "File",
}, },
Object { Object {
@ -466,7 +417,6 @@ Array [
"data_visualizer", "data_visualizer",
"ml:indexDataVisualizer", "ml:indexDataVisualizer",
], ],
"renderItem": undefined,
"title": "Data view", "title": "Data view",
}, },
Object { Object {
@ -486,7 +436,6 @@ Array [
"data_visualizer", "data_visualizer",
"ml:dataDrift", "ml:dataDrift",
], ],
"renderItem": undefined,
"title": "Data drift", "title": "Data drift",
}, },
], ],
@ -519,7 +468,6 @@ Array [
"aiops_labs", "aiops_labs",
"ml:logRateAnalysis", "ml:logRateAnalysis",
], ],
"renderItem": undefined,
"title": "Deeplink ml:logRateAnalysis", "title": "Deeplink ml:logRateAnalysis",
}, },
Object { Object {
@ -539,7 +487,6 @@ Array [
"aiops_labs", "aiops_labs",
"ml:logPatternAnalysis", "ml:logPatternAnalysis",
], ],
"renderItem": undefined,
"title": "Deeplink ml:logPatternAnalysis", "title": "Deeplink ml:logPatternAnalysis",
}, },
Object { Object {
@ -559,7 +506,6 @@ Array [
"aiops_labs", "aiops_labs",
"ml:changePointDetections", "ml:changePointDetections",
], ],
"renderItem": undefined,
"title": "Deeplink ml:changePointDetections", "title": "Deeplink ml:changePointDetections",
}, },
], ],
@ -608,65 +554,46 @@ Array [
"breadcrumbStatus": "hidden", "breadcrumbStatus": "hidden",
"children": Array [ "children": Array [
Object { Object {
"children": Array [ "children": undefined,
Object { "deepLink": Object {
"children": undefined, "baseUrl": "/mocked",
"deepLink": Object { "href": "http://mocked/management",
"baseUrl": "/mocked", "id": "management",
"href": "http://mocked/management", "title": "Deeplink management",
"id": "management", "url": "/mocked/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",
},
],
"deepLink": undefined,
"href": undefined, "href": undefined,
"id": "settings", "id": "management",
"isActive": false, "isActive": false,
"path": Array [ "path": Array [
"project_settings_project_nav", "project_settings_project_nav",
"settings", "management",
], ],
"title": "", "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",
"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",
"cloudLinkBilling",
],
"title": "Mock Billing & Subscriptions",
}, },
], ],
"deepLink": undefined, "deepLink": undefined,

View file

@ -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 (
<EuiFlexGroup gutterSize="m" alignItems="center" responsive={false}>
{iconType && (
<EuiFlexItem grow={false}>
<EuiIcon type={iconType} size="m" />
</EuiFlexItem>
)}
<EuiFlexItem>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiLink
color="text"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
navigateToUrl(href);
}}
href={href}
>
<EuiTitle size="xxs">
<TitleElement id={titleID} className="euiCollapsibleNavGroup__title">
{title}
</TitleElement>
</EuiTitle>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -40,12 +40,12 @@ describe('<Navigation />', () => {
const { findByTestId } = render( const { findByTestId } = render(
<NavigationProvider {...services} onProjectNavigationChange={onProjectNavigationChange}> <NavigationProvider {...services} onProjectNavigationChange={onProjectNavigationChange}>
<Navigation> <Navigation>
<Navigation.Group id="group1"> <Navigation.Group id="group1" defaultIsCollapsed={false}>
<Navigation.Item id="item1" title="Item 1" href="https://foo" /> <Navigation.Item id="item1" title="Item 1" href="https://foo" />
<Navigation.Item id="item2" title="Item 2" href="https://foo" /> <Navigation.Item id="item2" title="Item 2" href="https://foo" />
<Navigation.Group id="group1A" title="Group1A"> <Navigation.Group id="group1A" title="Group1A" defaultIsCollapsed={false}>
<Navigation.Item id="item1" title="Group 1A Item 1" href="https://foo" /> <Navigation.Item id="item1" title="Group 1A Item 1" href="https://foo" />
<Navigation.Group id="group1A_1" title="Group1A_1"> <Navigation.Group id="group1A_1" title="Group1A_1" defaultIsCollapsed={false}>
<Navigation.Item id="item1" title="Group 1A_1 Item 1" href="https://foo" /> <Navigation.Item id="item1" title="Group 1A_1 Item 1" href="https://foo" />
</Navigation.Group> </Navigation.Group>
</Navigation.Group> </Navigation.Group>
@ -62,10 +62,10 @@ describe('<Navigation />', () => {
expect(await findByTestId(/nav-item-group1.item2/)).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\s/)).toBeVisible();
expect(await findByTestId(/nav-item-group1.group1A.item1/)).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 // 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(); expect(await findByTestId(/nav-item-group1.group1A.group1A_1.item1/)).toBeVisible();
@ -76,56 +76,56 @@ describe('<Navigation />', () => {
expect(navTree.navigationTree).toEqual([ expect(navTree.navigationTree).toEqual([
{ {
id: 'group1',
path: ['group1'],
title: '',
isActive: false,
children: [ children: [
{ {
id: 'item1',
title: 'Item 1',
href: 'https://foo', href: 'https://foo',
id: 'item1',
isActive: false, isActive: false,
path: ['group1', 'item1'], path: ['group1', 'item1'],
title: 'Item 1',
}, },
{ {
id: 'item2',
title: 'Item 2',
href: 'https://foo', href: 'https://foo',
id: 'item2',
isActive: false, isActive: false,
path: ['group1', 'item2'], path: ['group1', 'item2'],
title: 'Item 2',
}, },
{ {
id: 'group1A',
title: 'Group1A',
isActive: false,
path: ['group1', 'group1A'],
children: [ children: [
{ {
id: 'item1',
href: 'https://foo', href: 'https://foo',
title: 'Group 1A Item 1', id: 'item1',
isActive: false, isActive: false,
path: ['group1', 'group1A', 'item1'], path: ['group1', 'group1A', 'item1'],
title: 'Group 1A Item 1',
}, },
{ {
id: 'group1A_1',
title: 'Group1A_1',
isActive: false,
path: ['group1', 'group1A', 'group1A_1'],
children: [ children: [
{ {
id: 'item1',
title: 'Group 1A_1 Item 1',
isActive: false,
href: 'https://foo', href: 'https://foo',
id: 'item1',
isActive: false,
path: ['group1', 'group1A', 'group1A_1', 'item1'], 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('<Navigation />', () => {
onProjectNavigationChange={onProjectNavigationChange} onProjectNavigationChange={onProjectNavigationChange}
> >
<Navigation> <Navigation>
<Navigation.Group id="root"> <Navigation.Group id="root" defaultIsCollapsed={false}>
<Navigation.Group id="group1"> <Navigation.Group id="group1" defaultIsCollapsed={false}>
{/* Title from deeplink */} {/* Title from deeplink */}
<Navigation.Item<any> id="item1" link="item1" /> <Navigation.Item<any> id="item1" link="item1" />
{/* Should not appear */} {/* Should not appear */}
@ -279,13 +279,13 @@ describe('<Navigation />', () => {
id: 'root', id: 'root',
path: ['root'], path: ['root'],
title: '', title: '',
isActive: false, isActive: true,
children: [ children: [
{ {
id: 'group1', id: 'group1',
path: ['root', 'group1'], path: ['root', 'group1'],
title: '', title: '',
isActive: false, isActive: true,
children: [ children: [
{ {
id: 'item1', id: 'item1',
@ -326,11 +326,11 @@ describe('<Navigation />', () => {
onProjectNavigationChange={onProjectNavigationChange} onProjectNavigationChange={onProjectNavigationChange}
> >
<Navigation> <Navigation>
<Navigation.Group id="root"> <Navigation.Group id="root" defaultIsCollapsed={false}>
<Navigation.Group id="group1"> <Navigation.Group id="group1" defaultIsCollapsed={false}>
<Navigation.Item<any> id="item1" link="notRegistered" /> <Navigation.Item<any> id="item1" link="notRegistered" />
</Navigation.Group> </Navigation.Group>
<Navigation.Group id="group2"> <Navigation.Group id="group2" defaultIsCollapsed={false}>
<Navigation.Item<any> id="item1" link="item1" /> <Navigation.Item<any> id="item1" link="item1" />
</Navigation.Group> </Navigation.Group>
</Navigation.Group> </Navigation.Group>
@ -352,128 +352,39 @@ describe('<Navigation />', () => {
expect(navTree.navigationTree).toEqual([ expect(navTree.navigationTree).toEqual([
{ {
id: 'root',
path: ['root'],
title: '',
isActive: false,
children: [ children: [
{ {
id: 'group1', id: 'group1',
isActive: true,
path: ['root', 'group1'], path: ['root', 'group1'],
title: '', title: '',
isActive: false,
}, },
{ {
id: 'group2',
path: ['root', 'group2'],
title: '',
isActive: false,
children: [ children: [
{ {
deepLink: {
baseUrl: '',
href: '',
id: 'item1',
title: 'Title from deeplink',
url: '',
},
id: 'item1', id: 'item1',
isActive: false,
path: ['root', 'group2', 'item1'], path: ['root', 'group2', 'item1'],
title: 'Title from deeplink', title: 'Title from deeplink',
isActive: false,
deepLink: {
id: 'item1',
title: 'Title from deeplink',
baseUrl: '',
url: '',
href: '',
},
}, },
], ],
id: 'group2',
isActive: true,
path: ['root', 'group2'],
title: '',
}, },
], ],
},
]);
});
test('should render custom react element', async () => {
const navLinks$: Observable<ChromeNavLink[]> = of([
{
id: 'item1',
title: 'Title from deeplink',
baseUrl: '',
url: '',
href: '',
},
]);
const onProjectNavigationChange = jest.fn();
const { findByTestId } = render(
<NavigationProvider
{...services}
navLinks$={navLinks$}
onProjectNavigationChange={onProjectNavigationChange}
>
<Navigation>
<Navigation.Group id="root">
<Navigation.Group id="group1">
<Navigation.Item<any> link="item1">
<div data-test-subj="my-custom-element">Custom element</div>
</Navigation.Item>
<Navigation.Item id="item2" title="Children prop" href="http://foo">
{(navNode) => <div data-test-subj="my-other-custom-element">{navNode.title}</div>}
</Navigation.Item>
</Navigation.Group>
</Navigation.Group>
</Navigation>
</NavigationProvider>
);
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', id: 'root',
isActive: true,
path: ['root'], path: ['root'],
title: '', 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',
isActive: false,
renderItem: expect.any(Function),
},
],
},
],
}, },
]); ]);
}); });
@ -649,11 +560,11 @@ describe('<Navigation />', () => {
</NavigationProvider> </NavigationProvider>
); );
expect(await findByTestId(/nav-item-group1.item1/)).toHaveClass( expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).toMatch(
'euiSideNavItemButton-isSelected' /nav-item-isActive/
); );
expect(await findByTestId(/nav-item-group1.item2/)).not.toHaveClass( expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).not.toMatch(
'euiSideNavItemButton-isSelected' /nav-item-isActive/
); );
await act(async () => { await act(async () => {
@ -673,11 +584,11 @@ describe('<Navigation />', () => {
]); ]);
}); });
expect(await findByTestId(/nav-item-group1.item1/)).not.toHaveClass( expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).not.toMatch(
'euiSideNavItemButton-isSelected' /nav-item-isActive/
); );
expect(await findByTestId(/nav-item-group1.item2/)).toHaveClass( expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).toMatch(
'euiSideNavItemButton-isSelected' /nav-item-isActive/
); );
}); });
@ -730,8 +641,8 @@ describe('<Navigation />', () => {
jest.advanceTimersByTime(SET_NAVIGATION_DELAY); jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
expect(await findByTestId(/nav-item-group1.item1/)).toHaveClass( expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).toMatch(
'euiSideNavItemButton-isSelected' /nav-item-isActive/
); );
}); });
}); });
@ -743,7 +654,7 @@ describe('<Navigation />', () => {
const { findByTestId } = render( const { findByTestId } = render(
<NavigationProvider {...services} onProjectNavigationChange={onProjectNavigationChange}> <NavigationProvider {...services} onProjectNavigationChange={onProjectNavigationChange}>
<Navigation> <Navigation>
<Navigation.Group id="group1"> <Navigation.Group id="group1" defaultIsCollapsed={false}>
<Navigation.Item id="cloudLink1" cloudLink="userAndRoles" /> <Navigation.Item id="cloudLink1" cloudLink="userAndRoles" />
<Navigation.Item id="cloudLink2" cloudLink="performance" /> <Navigation.Item id="cloudLink2" cloudLink="performance" />
<Navigation.Item id="cloudLink3" cloudLink="billingAndSub" /> <Navigation.Item id="cloudLink3" cloudLink="billingAndSub" />
@ -756,13 +667,13 @@ describe('<Navigation />', () => {
expect(await findByTestId(/nav-item-group1.cloudLink2/)).toBeVisible(); expect(await findByTestId(/nav-item-group1.cloudLink2/)).toBeVisible();
expect(await findByTestId(/nav-item-group1.cloudLink3/)).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' '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' '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' 'Mock Billing & SubscriptionsExternal link'
); );
}); });

View file

@ -85,7 +85,7 @@ function NavigationGroupInternalComp<
)} )}
{/* We render the children so they mount and can register themselves but {/* 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 visually they don't appear here in the DOM. They are rendered inside the
<EuiSideNav /> "items" prop (see <NavigationSectionUI />) */} <EuiCollapsibleNavItem /> "items" prop (see <NavigationSectionUI />) */}
{children} {children}
</> </>
); );

View file

@ -6,12 +6,12 @@
* Side Public License, v 1. * 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 { useNavigation as useNavigationServices } from '../../services';
import type { ChromeProjectNavigationNodeEnhanced, NodeProps } from '../types';
import { useInitNavNode } from '../hooks'; import { useInitNavNode } from '../hooks';
import type { NodeProps } from '../types';
import { useNavigation } from './navigation'; import { useNavigation } from './navigation';
export interface Props< export interface Props<
@ -22,10 +22,6 @@ export interface Props<
unstyled?: boolean; unstyled?: boolean;
} }
function isReactElement(element: ReactNode): element is ReactElement {
return React.isValidElement(element);
}
function NavigationItemComp< function NavigationItemComp<
LinkId extends AppDeepLinkId = AppDeepLinkId, LinkId extends AppDeepLinkId = AppDeepLinkId,
Id extends string = string, Id extends string = string,
@ -33,7 +29,7 @@ function NavigationItemComp<
>(props: Props<LinkId, Id, ChildrenId>) { >(props: Props<LinkId, Id, ChildrenId>) {
const { cloudLinks } = useNavigationServices(); const { cloudLinks } = useNavigationServices();
const navigationContext = useNavigation(); const navigationContext = useNavigation();
const navNodeRef = React.useRef<ChromeProjectNavigationNodeEnhanced | null>(null); const navNodeRef = React.useRef<ChromeProjectNavigationNode | null>(null);
const { children, node } = useMemo(() => { const { children, node } = useMemo(() => {
const { children: _children, ...rest } = props; const { children: _children, ...rest } = props;
@ -44,14 +40,7 @@ function NavigationItemComp<
}, [props]); }, [props]);
const unstyled = props.unstyled ?? navigationContext.unstyled; const unstyled = props.unstyled ?? navigationContext.unstyled;
let renderItem: (() => ReactElement) | undefined; const { navNode } = useInitNavNode({ ...node, children }, { cloudLinks });
if (!unstyled && children && (typeof children === 'function' || isReactElement(children))) {
renderItem =
typeof children === 'function' ? () => children(navNodeRef.current) : () => children;
}
const { navNode } = useInitNavNode({ ...node, children, renderItem }, { cloudLinks });
useEffect(() => { useEffect(() => {
navNodeRef.current = navNode; navNodeRef.current = navNode;

View file

@ -7,28 +7,22 @@
*/ */
import React, { FC, useEffect, useState } from 'react'; import React, { FC, useEffect, useState } from 'react';
import { import {
EuiCollapsibleNavGroup, EuiCollapsibleNavItem,
EuiIcon, EuiCollapsibleNavItemProps,
EuiLink, EuiCollapsibleNavSubItemGroupTitle,
EuiSideNav,
EuiSideNavItemType,
EuiText,
} from '@elastic/eui'; } from '@elastic/eui';
import { ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import classnames from 'classnames'; import classnames from 'classnames';
import type { BasePathService, NavigateToUrlFn } from '../../../types/internal'; import type { BasePathService, NavigateToUrlFn } from '../../../types/internal';
import { navigationStyles as styles } from '../../styles';
import { useNavigation as useServices } from '../../services'; import { useNavigation as useServices } from '../../services';
import { ChromeProjectNavigationNodeEnhanced } from '../types';
import { isAbsoluteLink } from '../../utils'; import { isAbsoluteLink } from '../../utils';
import { GroupAsLink } from './group_as_link';
type RenderItem = EuiSideNavItemType<unknown>['renderItem'];
const navigationNodeToEuiItem = ( const navigationNodeToEuiItem = (
item: ChromeProjectNavigationNodeEnhanced, item: ChromeProjectNavigationNode,
{ navigateToUrl, basePath }: { navigateToUrl: NavigateToUrlFn; basePath: BasePathService } { navigateToUrl, basePath }: { navigateToUrl: NavigateToUrlFn; basePath: BasePathService }
): EuiSideNavItemType<unknown> => { ): EuiCollapsibleNavSubItemGroupTitle | EuiCollapsibleNavItemProps => {
const href = item.deepLink?.url ?? item.href; const href = item.deepLink?.url ?? item.href;
const id = item.path ? item.path.join('.') : item.id; const id = item.path ? item.path.join('.') : item.id;
const isExternal = Boolean(href) && isAbsoluteLink(href!); const isExternal = Boolean(href) && isAbsoluteLink(href!);
@ -39,24 +33,16 @@ const navigationNodeToEuiItem = (
[`nav-item-isActive`]: isSelected, [`nav-item-isActive`]: isSelected,
}); });
const getRenderItem = (): RenderItem | undefined => {
if (!isExternal || item.renderItem) {
return item.renderItem;
}
return () => (
<div className="euiSideNavItemButton" data-test-subj={dataTestSubj}>
<EuiLink href={href} external color="text">
{item.title}
</EuiLink>
</div>
);
};
return { return {
id, id,
name: item.title, isGroupTitle: item.isGroupTitle,
title: item.title,
isSelected, isSelected,
accordionProps: {
...item.accordionProps,
initialIsOpen: true, // FIXME open state is controlled on component mount
},
linkProps: { external: isExternal },
onClick: onClick:
href !== undefined href !== undefined
? (event: React.MouseEvent) => { ? (event: React.MouseEvent) => {
@ -65,20 +51,18 @@ const navigationNodeToEuiItem = (
} }
: undefined, : undefined,
href, href,
renderItem: getRenderItem(),
items: item.children?.map((_item) => items: item.children?.map((_item) =>
navigationNodeToEuiItem(_item, { navigateToUrl, basePath }) navigationNodeToEuiItem(_item, { navigateToUrl, basePath })
), ),
['data-test-subj']: dataTestSubj, ['data-test-subj']: dataTestSubj,
...(item.icon && { icon: item.icon,
icon: <EuiIcon type={item.icon} size="s" />, iconProps: { size: 's' },
}),
}; };
}; };
interface Props { interface Props {
navNode: ChromeProjectNavigationNodeEnhanced; navNode: ChromeProjectNavigationNode;
items?: ChromeProjectNavigationNodeEnhanced[]; items?: ChromeProjectNavigationNode[];
} }
export const NavigationSectionUI: FC<Props> = ({ navNode, items = [] }) => { export const NavigationSectionUI: FC<Props> = ({ navNode, items = [] }) => {
@ -90,8 +74,12 @@ export const NavigationSectionUI: FC<Props> = ({ navNode, items = [] }) => {
const [doCollapseFromActiveState, setDoCollapseFromActiveState] = useState(true); const [doCollapseFromActiveState, setDoCollapseFromActiveState] = useState(true);
// If the item has no link and no cildren, we don't want to render it // 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); const hasLink = Boolean(item.deepLink) || Boolean(item.href);
if (isGroupTitle) {
return true;
}
if (hasLink) { if (hasLink) {
return true; return true;
} }
@ -128,49 +116,39 @@ export const NavigationSectionUI: FC<Props> = ({ navNode, items = [] }) => {
return null; return null;
} }
const propsForGroupAsLink = groupIsLink const propsForGroupAsLink: Partial<EuiCollapsibleNavItemProps> = groupIsLink
? { ? {
buttonElement: 'div' as const, linkProps: {
// If we don't force the state there is a little UI animation as if the href: groupHref,
// accordion was openin/closing. We don't want any animation when it is a link. onClick: (e: React.MouseEvent) => {
forceState: 'closed' as const, e.preventDefault();
buttonContent: ( e.stopPropagation();
<GroupAsLink navigateToUrl(groupHref);
title={title} },
iconType={icon} },
href={groupHref}
navigateToUrl={navigateToUrl}
/>
),
arrowProps: { style: { display: 'none' } },
} }
: {}; : {};
return ( return (
<EuiCollapsibleNavGroup <EuiCollapsibleNavItem
id={id} id={id}
title={title} title={title}
iconType={icon} icon={icon}
iconSize="m" iconProps={{ size: 'm' }}
isCollapsible accordionProps={{
initialIsOpen={isActive} initialIsOpen: isActive,
onToggle={(isOpen) => { forceState: isCollapsed ? 'closed' : 'open',
setIsCollapsed(!isOpen); onToggle: (isOpen) => {
setDoCollapseFromActiveState(false); setIsCollapsed(!isOpen);
setDoCollapseFromActiveState(false);
},
...navNode.accordionProps,
}} }}
forceState={isCollapsed ? 'closed' : 'open'}
data-test-subj={`nav-bucket-${id}`} data-test-subj={`nav-bucket-${id}`}
{...propsForGroupAsLink} {...propsForGroupAsLink}
> items={filteredItems.map((item) =>
<EuiText color="default"> navigationNodeToEuiItem(item, { navigateToUrl, basePath })
<EuiSideNav )}
mobileBreakpoints={/* turn off responsive behavior */ []} />
items={filteredItems.map((item) =>
navigationNodeToEuiItem(item, { navigateToUrl, basePath })
)}
css={styles.euiSideNavItems}
/>
</EuiText>
</EuiCollapsibleNavGroup>
); );
}; };

View file

@ -6,7 +6,7 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { EuiFlyoutBody, EuiFlyoutFooter } from '@elastic/eui';
import React, { FC } from 'react'; import React, { FC } from 'react';
interface Props { interface Props {
@ -21,17 +21,12 @@ export const NavigationUI: FC<Props> = ({ children, unstyled, footerChildren, da
{unstyled ? ( {unstyled ? (
<>{children}</> <>{children}</>
) : ( ) : (
<EuiFlexGroup <>
direction="column" <EuiFlyoutBody scrollableTabIndex={-1} data-test-subj={dataTestSubj}>
gutterSize="none" {children}
style={{ overflowY: 'auto' }} </EuiFlyoutBody>
justifyContent="spaceBetween" {footerChildren && <EuiFlyoutFooter>{footerChildren}</EuiFlyoutFooter>}
data-test-subj={dataTestSubj} </>
>
<EuiFlexItem grow={false}>{children}</EuiFlexItem>
{footerChildren && <EuiFlexItem grow={false}>{footerChildren}</EuiFlexItem>}
</EuiFlexGroup>
)} )}
</> </>
); );

View file

@ -6,14 +6,13 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import { EuiCollapsibleNavGroup, EuiSideNav, EuiSideNavItemType } from '@elastic/eui'; import { EuiCollapsibleNavItem } from '@elastic/eui';
import React, { FC } from 'react'; import React, { FC } from 'react';
import useObservable from 'react-use/lib/useObservable'; import useObservable from 'react-use/lib/useObservable';
import type { Observable } from 'rxjs'; import type { Observable } from 'rxjs';
import { RecentItem } from '../../../types/internal'; import { RecentItem } from '../../../types/internal';
import { useNavigation as useServices } from '../../services'; import { useNavigation as useServices } from '../../services';
import { navigationStyles as styles } from '../../styles';
import { getI18nStrings } from '../i18n_strings'; import { getI18nStrings } from '../i18n_strings';
@ -42,40 +41,31 @@ export const RecentlyAccessed: FC<Props> = ({
return null; return null;
} }
const navItems: Array<EuiSideNavItemType<unknown>> = [ const navItems = recentlyAccessed.map((recent) => {
{ const { id, label, link } = recent;
name: '', // no list header title const href = basePath.prepend(link);
id: 'recents_root',
items: recentlyAccessed.map((recent) => {
const { id, label, link } = recent;
const href = basePath.prepend(link);
return { return {
id, id,
name: label, title: label,
href, href,
onClick: (e: React.MouseEvent) => { onClick: (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
navigateToUrl(href); navigateToUrl(href);
}, },
}; };
}), });
},
];
return ( return (
<EuiCollapsibleNavGroup <EuiCollapsibleNavItem
title={strings.recentlyAccessed} title={strings.recentlyAccessed}
iconType="clock" icon="clock"
isCollapsible={true} iconProps={{ size: 'm' }}
initialIsOpen={!defaultIsCollapsed} accordionProps={{
initialIsOpen: !defaultIsCollapsed,
}}
data-test-subj={`nav-bucket-recentlyAccessed`} data-test-subj={`nav-bucket-recentlyAccessed`}
> items={navItems}
<EuiSideNav />
items={navItems}
css={styles.euiSideNavItems}
mobileBreakpoints={/* turn off responsive behavior */ []}
/>
</EuiCollapsibleNavGroup>
); );
}; };

View file

@ -80,7 +80,7 @@ describe('<DefaultNavigation />', () => {
}, },
]; ];
const { findByTestId } = render( const { findAllByTestId } = render(
<NavigationProvider {...services} onProjectNavigationChange={onProjectNavigationChange}> <NavigationProvider {...services} onProjectNavigationChange={onProjectNavigationChange}>
<DefaultNavigation navigationTree={{ body: navigationBody }} /> <DefaultNavigation navigationTree={{ body: navigationBody }} />
</NavigationProvider> </NavigationProvider>
@ -90,16 +90,8 @@ describe('<DefaultNavigation />', () => {
jest.advanceTimersByTime(SET_NAVIGATION_DELAY); 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 // Click the last group to expand and show the last depth
(await findByTestId(/nav-item-group1.group1A.group1A_1/)).click(); (await findAllByTestId(/nav-item-group1.group1A.group1A_1/))[0].click();
expect(await findByTestId(/nav-item-group1.group1A.group1A_1.item1/)).toBeVisible();
expect(onProjectNavigationChange).toHaveBeenCalled(); expect(onProjectNavigationChange).toHaveBeenCalled();
const lastCall = const lastCall =
@ -120,7 +112,6 @@ describe('<DefaultNavigation />', () => {
"group1", "group1",
"item1", "item1",
], ],
"renderItem": undefined,
"title": "Item 1", "title": "Item 1",
}, },
Object { Object {
@ -133,7 +124,6 @@ describe('<DefaultNavigation />', () => {
"group1", "group1",
"item2", "item2",
], ],
"renderItem": undefined,
"title": "Item 2", "title": "Item 2",
}, },
Object { Object {
@ -149,7 +139,6 @@ describe('<DefaultNavigation />', () => {
"group1A", "group1A",
"item1", "item1",
], ],
"renderItem": undefined,
"title": "Group 1A Item 1", "title": "Group 1A Item 1",
}, },
Object { Object {
@ -166,7 +155,6 @@ describe('<DefaultNavigation />', () => {
"group1A_1", "group1A_1",
"item1", "item1",
], ],
"renderItem": undefined,
"title": "Group 1A_1 Item 1", "title": "Group 1A_1 Item 1",
}, },
], ],
@ -291,7 +279,6 @@ describe('<DefaultNavigation />', () => {
"group1", "group1",
"item1", "item1",
], ],
"renderItem": undefined,
"title": "Title from deeplink", "title": "Title from deeplink",
}, },
Object { Object {
@ -311,7 +298,6 @@ describe('<DefaultNavigation />', () => {
"group1", "group1",
"item2", "item2",
], ],
"renderItem": undefined,
"title": "Overwrite deeplink title", "title": "Overwrite deeplink title",
}, },
], ],
@ -394,7 +380,6 @@ describe('<DefaultNavigation />', () => {
"group1", "group1",
"item1", "item1",
], ],
"renderItem": undefined,
"title": "Absolute link", "title": "Absolute link",
}, },
], ],
@ -556,11 +541,11 @@ describe('<DefaultNavigation />', () => {
jest.advanceTimersByTime(SET_NAVIGATION_DELAY); jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
}); });
expect(await findByTestId(/nav-item-group1.item1/)).toHaveClass( expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).toMatch(
'euiSideNavItemButton-isSelected' /nav-item-isActive/
); );
expect(await findByTestId(/nav-item-group1.item2/)).not.toHaveClass( expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).not.toMatch(
'euiSideNavItemButton-isSelected' /nav-item-isActive/
); );
}); });
@ -619,8 +604,8 @@ describe('<DefaultNavigation />', () => {
jest.advanceTimersByTime(SET_NAVIGATION_DELAY); jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
}); });
expect(await findByTestId(/nav-item-group1.item1/)).toHaveClass( expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).toMatch(
'euiSideNavItemButton-isSelected' /nav-item-isActive/
); );
}); });
}); });
@ -703,17 +688,12 @@ describe('<DefaultNavigation />', () => {
); );
expect( expect(
await ( (await findByTestId(/nav-item-project_settings_project_nav.cloudLinkUserAndRoles/))
await findByTestId( .textContent
/nav-item-project_settings_project_nav.settings.cloudLinkUserAndRoles/
)
).textContent
).toBe('Mock Users & RolesExternal link'); ).toBe('Mock Users & RolesExternal link');
expect( expect(
await ( (await findByTestId(/nav-item-project_settings_project_nav.cloudLinkBilling/)).textContent
await findByTestId(/nav-item-project_settings_project_nav.settings.cloudLinkBilling/)
).textContent
).toBe('Mock Billing & SubscriptionsExternal link'); ).toBe('Mock Billing & SubscriptionsExternal link');
}); });
}); });

View file

@ -72,23 +72,18 @@ const getDefaultNavigationTree = (
breadcrumbStatus: 'hidden', breadcrumbStatus: 'hidden',
children: [ children: [
{ {
id: 'settings', link: 'management',
children: [ title: i18n.translate('sharedUXPackages.chrome.sideNavigation.mngt', {
{ defaultMessage: 'Management',
link: 'management', }),
title: i18n.translate('sharedUXPackages.chrome.sideNavigation.mngt', { },
defaultMessage: 'Management', {
}), id: 'cloudLinkUserAndRoles',
}, cloudLink: 'userAndRoles',
{ },
id: 'cloudLinkUserAndRoles', {
cloudLink: 'userAndRoles', id: 'cloudLinkBilling',
}, cloudLink: 'billingAndSub',
{
id: 'cloudLinkBilling',
cloudLink: 'billingAndSub',
},
],
}, },
], ],
}, },

View file

@ -19,13 +19,7 @@ import { CloudLinks } from '../../cloud_links';
import { useNavigation as useNavigationServices } from '../../services'; import { useNavigation as useNavigationServices } from '../../services';
import { isAbsoluteLink } from '../../utils'; import { isAbsoluteLink } from '../../utils';
import { useNavigation } from '../components/navigation'; import { useNavigation } from '../components/navigation';
import { import { NodeProps, NodePropsEnhanced, RegisterFunction, UnRegisterFunction } from '../types';
ChromeProjectNavigationNodeEnhanced,
NodeProps,
NodePropsEnhanced,
RegisterFunction,
UnRegisterFunction,
} from '../types';
import { useRegisterTreeNode } from './use_register_tree_node'; import { useRegisterTreeNode } from './use_register_tree_node';
function getIdFromNavigationNode< function getIdFromNavigationNode<
@ -135,7 +129,7 @@ function createInternalNavNode<
path: string[] | null, path: string[] | null,
isActive: boolean, isActive: boolean,
{ cloudLinks }: { cloudLinks: CloudLinks } { cloudLinks }: { cloudLinks: CloudLinks }
): ChromeProjectNavigationNodeEnhanced | null { ): ChromeProjectNavigationNode | null {
validateNodeProps(_navNode); validateNodeProps(_navNode);
const { children, link, cloudLink, ...navNode } = _navNode; const { children, link, cloudLink, ...navNode } = _navNode;
@ -185,9 +179,9 @@ export const useInitNavNode = <
/** /**
* Map of children nodes * Map of children nodes
*/ */
const [childrenNodes, setChildrenNodes] = useState< const [childrenNodes, setChildrenNodes] = useState<Record<string, ChromeProjectNavigationNode>>(
Record<string, ChromeProjectNavigationNodeEnhanced> {}
>({}); );
const isMounted = useRef(false); const isMounted = useRef(false);

View file

@ -6,84 +6,61 @@
* Side Public License, v 1. * 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 { 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 { import {
EuiButton, EuiButton,
EuiButtonIcon, EuiCollapsibleNavBeta,
EuiCollapsibleNav, EuiCollapsibleNavBetaProps,
EuiFlexGroup, EuiFlexGroup,
EuiFlexItem, EuiFlexItem,
EuiHeader,
EuiHeaderSection,
EuiLink, EuiLink,
EuiPageTemplate,
EuiText, EuiText,
EuiThemeProvider,
EuiTitle, EuiTitle,
} from '@elastic/eui'; } from '@elastic/eui';
import { css } from '@emotion/react';
import type { ChromeNavLink, ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import { NavigationStorybookMock, navLinksMock } from '../../mocks'; import { NavigationStorybookMock, navLinksMock } from '../../mocks';
import mdx from '../../README.mdx'; import mdx from '../../README.mdx';
import { NavigationProvider } from '../services';
import { DefaultNavigation } from './default_navigation';
import type { NavigationServices } from '../../types'; import type { NavigationServices } from '../../types';
import { NavigationProvider } from '../services';
import { Navigation } from './components'; import { Navigation } from './components';
import type { NonEmptyArray, ProjectNavigationDefinition } from './types'; import { DefaultNavigation } from './default_navigation';
import { getPresets } from './nav_tree_presets'; import { getPresets } from './nav_tree_presets';
import type { GroupDefinition, NonEmptyArray, ProjectNavigationDefinition } from './types';
const storybookMock = new NavigationStorybookMock(); const storybookMock = new NavigationStorybookMock();
const SIZE_OPEN = 248; const NavigationWrapper: FC<
const SIZE_CLOSED = 40; PropsWithChildren<{ clickAction?: EventHandler<MouseEvent>; clickActionText?: string }> &
Partial<EuiCollapsibleNavBetaProps>
const NavigationWrapper: FC = ({ children }) => { > = (props) => {
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 (
<span css={buttonCSS}>
<EuiButtonIcon
iconType={isOpen ? 'menuLeft' : 'menuRight'}
color={isOpen ? 'ghost' : 'text'}
onClick={toggleOpen}
aria-label={isOpen ? 'Collapse navigation' : 'Expand navigation'}
/>
</span>
);
};
const toggleOpen = useCallback(() => {
setIsOpen(!isOpen);
}, [isOpen, setIsOpen]);
return ( return (
<EuiThemeProvider> <>
<EuiCollapsibleNav <EuiHeader position="fixed">
css={collabsibleNavCSS} <EuiHeaderSection side={props?.side}>
isOpen={true} <EuiCollapsibleNavBeta {...props} />
showButtonIfDocked={true} </EuiHeaderSection>
onClose={toggleOpen} </EuiHeader>
isDocked={true} <EuiPageTemplate>
size={isOpen ? SIZE_OPEN : SIZE_CLOSED} <EuiPageTemplate.Section>
hideCloseButton={false} {props.clickAction ? (
button={<CollapseButton />} <EuiButton color="text" onClick={props.clickAction}>
> {props.clickActionText ?? 'Click me'}
{isOpen && children} </EuiButton>
</EuiCollapsibleNav> ) : (
</EuiThemeProvider> <p>Hello world</p>
)}
</EuiPageTemplate.Section>
</EuiPageTemplate>
</>
); );
}; };
@ -123,30 +100,25 @@ const simpleNavigationDefinition: ProjectNavigationDefinition = {
defaultIsCollapsed: false, defaultIsCollapsed: false,
children: [ children: [
{ {
id: 'root', id: 'item1',
children: [ title: 'Get started',
{ },
id: 'item1', {
title: 'Get started', id: 'item2',
}, title: 'Alerts',
{ },
id: 'item2', {
title: 'Alerts', id: 'item3',
}, title: 'Dashboards',
{ },
id: 'item3', {
title: 'Dashboards', id: 'item4',
}, title: 'External link',
{ href: 'https://elastic.co',
id: 'item4', },
title: 'External link', {
href: 'https://elastic.co', id: 'item5',
}, title: 'Another link',
{
id: 'item5',
title: 'Another link',
},
],
}, },
{ {
id: 'group:settings', id: 'group:settings',
@ -205,21 +177,16 @@ const navigationDefinition: ProjectNavigationDefinition = {
defaultIsCollapsed: false, defaultIsCollapsed: false,
children: [ children: [
{ {
id: 'root', id: 'item1',
children: [ title: 'Get started',
{ },
id: 'item1', {
title: 'Get started', id: 'item2',
}, title: 'Alerts',
{ },
id: 'item2', {
title: 'Alerts', id: 'item3',
}, title: 'Some other node',
{
id: 'item3',
title: 'Some other node',
},
],
}, },
{ {
id: 'group:settings', id: 'group:settings',
@ -333,24 +300,22 @@ export const WithUIComponents = (args: NavigationServices) => {
icon="logoObservability" icon="logoObservability"
defaultIsCollapsed={false} defaultIsCollapsed={false}
> >
<Navigation.Group id="root"> <Navigation.Item<any> id="item1" link="item1" />
<Navigation.Item<any> id="item1" link="item1" /> <Navigation.Item id="item2" title="Alerts">
<Navigation.Item id="item2" title="Alerts"> {(navNode) => {
{(navNode) => { return (
return ( <div className="euiSideNavItemButton">
<div className="euiSideNavItemButton"> <EuiText size="s">{`Render prop: ${navNode.id} - ${navNode.title}`}</EuiText>
<EuiText size="s">{`Render prop: ${navNode.id} - ${navNode.title}`}</EuiText> </div>
</div> );
); }}
}} </Navigation.Item>
</Navigation.Item> <Navigation.Item id="item3" title="Title in ReactNode">
<Navigation.Item id="item3" title="Title in ReactNode"> <div className="euiSideNavItemButton">
<div className="euiSideNavItemButton"> <EuiLink>Title in ReactNode</EuiLink>
<EuiLink>Title in ReactNode</EuiLink> </div>
</div> </Navigation.Item>
</Navigation.Item> <Navigation.Item id="item4" title="External link" href="https://elastic.co" />
<Navigation.Item id="item4" title="External link" href="https://elastic.co" />
</Navigation.Group>
<Navigation.Group id="group:settings" title="Settings"> <Navigation.Group id="group:settings" title="Settings">
<Navigation.Item id="logs" title="Logs" /> <Navigation.Item id="logs" title="Logs" />
@ -370,12 +335,10 @@ export const WithUIComponents = (args: NavigationServices) => {
breadcrumbStatus="hidden" breadcrumbStatus="hidden"
icon="gear" icon="gear"
> >
<Navigation.Group id="settings"> <Navigation.Item link="management" title="Management" />
<Navigation.Item link="management" title="Management" /> <Navigation.Item id="cloudLinkUserAndRoles" cloudLink="userAndRoles" />
<Navigation.Item id="cloudLinkUserAndRoles" cloudLink="userAndRoles" /> <Navigation.Item id="cloudLinkPerformance" cloudLink="performance" />
<Navigation.Item id="cloudLinkPerformance" cloudLink="performance" /> <Navigation.Item id="cloudLinkBilling" cloudLink="billingAndSub" />
<Navigation.Item id="cloudLinkBilling" cloudLink="billingAndSub" />
</Navigation.Group>
</Navigation.Group> </Navigation.Group>
</Navigation.Footer> </Navigation.Footer>
</Navigation> </Navigation>
@ -551,3 +514,121 @@ export const CreativeUI = (args: NavigationServices) => {
</NavigationWrapper> </NavigationWrapper>
); );
}; };
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<number>(0);
const changeActiveNode = () => {
const value = (activeNodeIndex + 1) % 2; // toggle between 0 and 1
setActiveNodeIndex(value);
};
const activeNodes$ = new BehaviorSubject<ChromeProjectNavigationNode[][]>([]);
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 (
<NavigationWrapper clickAction={changeActiveNode} clickActionText="Change active node">
<NavigationProvider {...services}>
<DefaultNavigation
navigationTree={{
body: [simpleGroupDef],
}}
/>
</NavigationProvider>
</NavigationWrapper>
);
};

View file

@ -6,13 +6,14 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import type { ReactElement, ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { EuiAccordionProps } from '@elastic/eui';
import type { import type {
AppDeepLinkId, AppDeepLinkId,
ChromeProjectNavigationNode, ChromeProjectNavigationNode,
NodeDefinition, NodeDefinition,
} from '@kbn/core-chrome-browser'; } from '@kbn/core-chrome-browser';
import type { RecentlyAccessedProps } from './components'; import type { RecentlyAccessedProps } from './components';
export type NonEmptyArray<T> = [T, ...T[]]; export type NonEmptyArray<T> = [T, ...T[]];
@ -45,11 +46,6 @@ export interface NodePropsEnhanced<
Id extends string = string, Id extends string = string,
ChildrenId extends string = Id ChildrenId extends string = Id
> extends NodeProps<LinkId, Id, ChildrenId> { > extends NodeProps<LinkId, Id, ChildrenId> {
/**
* 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 * 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. * even if the URL does not match any of the nodes in the group.
@ -57,17 +53,6 @@ export interface NodePropsEnhanced<
isActive?: boolean; 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 */ /** The preset that can be pass to the NavigationBucket component */
export type NavigationGroupPreset = 'analytics' | 'devtools' | 'ml' | 'management'; 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. * `true`: the group will be collapsed event if any of its children nodes matches the current URL.
*/ */
defaultIsCollapsed?: boolean; defaultIsCollapsed?: boolean;
/*
* Pass props to the EUI accordion component used to represent a nav group
*/
accordionProps?: Partial<EuiAccordionProps>;
preset?: NavigationGroupPreset; preset?: NavigationGroupPreset;
} }
@ -172,7 +161,7 @@ export type UnRegisterFunction = (id: string) => void;
* *
* A function to register a navigation node on its parent. * 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. */ /** The function to unregister the node. */
unregister: UnRegisterFunction; unregister: UnRegisterFunction;
/** The full path of the node in the navigation tree. */ /** The full path of the node in the navigation tree. */

View file

@ -25,135 +25,134 @@ const navigationTree: NavigationTreeDefinition = {
title: 'Observability', title: 'Observability',
icon: 'logoObservability', icon: 'logoObservability',
defaultIsCollapsed: false, defaultIsCollapsed: false,
accordionProps: {
arrowProps: { css: { display: 'none' } },
},
breadcrumbStatus: 'hidden', breadcrumbStatus: 'hidden',
children: [ 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: [ children: [
{ {
title: i18n.translate('xpack.serverlessObservability.nav.logExplorer', { title: i18n.translate('xpack.serverlessObservability.nav.ml.jobs', {
defaultMessage: 'Log Explorer', defaultMessage: 'Anomaly detection',
}), }),
link: 'observability-log-explorer', link: 'ml:anomalyDetection',
}, },
{ {
title: i18n.translate('xpack.serverlessObservability.nav.dashboards', { title: i18n.translate('xpack.serverlessObservability.ml.logRateAnalysis', {
defaultMessage: 'Dashboards', defaultMessage: 'Log rate analysis',
}), }),
link: 'dashboards', link: 'ml:logRateAnalysis',
getIsActive: ({ pathNameSerialized, prepend }) => { getIsActive: ({ pathNameSerialized, prepend }) => {
return pathNameSerialized.startsWith(prepend('/app/dashboards')); return pathNameSerialized.includes(prepend('/app/ml/aiops/log_rate_analysis'));
}, },
}, },
{ {
link: 'observability-overview:alerts', title: i18n.translate('xpack.serverlessObservability.ml.changePointDetection', {
}, defaultMessage: 'Change point detection',
{
link: 'observability-overview:slos',
},
{
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',
},
],
},
],
},
{
id: 'applications',
children: [
{
id: 'apm',
title: i18n.translate('xpack.serverlessObservability.nav.applications', {
defaultMessage: 'Applications',
}), }),
children: [ link: 'ml:changePointDetections',
{
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: 'cases-vis',
children: [
{
link: 'observability-overview:cases',
},
{
title: i18n.translate('xpack.serverlessObservability.nav.visualizations', {
defaultMessage: 'Visualizations',
}),
link: 'visualize',
getIsActive: ({ pathNameSerialized, prepend }) => { getIsActive: ({ pathNameSerialized, prepend }) => {
return ( return pathNameSerialized.includes(prepend('/app/ml/aiops/change_point_detection'));
pathNameSerialized.startsWith(prepend('/app/visualize')) || },
pathNameSerialized.startsWith(prepend('/app/lens')) || },
pathNameSerialized.startsWith(prepend('/app/maps')) {
); title: i18n.translate('xpack.serverlessObservability.nav.ml.job.notifications', {
defaultMessage: 'Job notifications',
}),
link: 'ml:notifications',
},
],
},
{
id: 'groups-spacer-1',
isGroupTitle: true,
},
{
id: 'apm',
title: i18n.translate('xpack.serverlessObservability.nav.applications', {
defaultMessage: 'Applications',
}),
accordionProps: {
arrowProps: { css: { display: 'none' } },
},
children: [
{
link: 'apm:services',
getIsActive: ({ pathNameSerialized }) => {
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: 'on-boarding', id: 'groups-spacer-2',
children: [ isGroupTitle: true,
{ },
title: i18n.translate('xpack.serverlessObservability.nav.getStarted', { {
defaultMessage: 'Add data', link: 'observability-overview:cases',
}), },
link: 'observabilityOnboarding', {
}, 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', breadcrumbStatus: 'hidden',
children: [ children: [
{ {
id: 'settings', link: 'management',
children: [ title: i18n.translate('xpack.serverlessObservability.nav.mngt', {
{ defaultMessage: 'Management',
link: 'management', }),
title: i18n.translate('xpack.serverlessObservability.nav.mngt', { },
defaultMessage: 'Management', {
}), link: 'integrations',
}, },
{ {
link: 'integrations', link: 'fleet',
}, },
{ {
link: 'fleet', id: 'cloudLinkUserAndRoles',
}, cloudLink: 'userAndRoles',
{ },
id: 'cloudLinkUserAndRoles', {
cloudLink: 'userAndRoles', id: 'cloudLinkBilling',
}, cloudLink: 'billingAndSub',
{
id: 'cloudLinkBilling',
cloudLink: 'billingAndSub',
},
],
}, },
], ],
}, },

View file

@ -25,6 +25,9 @@ const navigationTree: NavigationTreeDefinition = {
title: 'Elasticsearch', title: 'Elasticsearch',
icon: 'logoElasticsearch', icon: 'logoElasticsearch',
defaultIsCollapsed: false, defaultIsCollapsed: false,
accordionProps: {
arrowProps: { css: { display: 'none' } },
},
breadcrumbStatus: 'hidden', breadcrumbStatus: 'hidden',
children: [ children: [
{ {
@ -39,77 +42,75 @@ const navigationTree: NavigationTreeDefinition = {
title: i18n.translate('xpack.serverlessSearch.nav.devTools', { title: i18n.translate('xpack.serverlessSearch.nav.devTools', {
defaultMessage: 'Dev Tools', defaultMessage: 'Dev Tools',
}), }),
children: [{ link: 'dev_tools:console' }, { link: 'dev_tools:searchprofiler' }], isGroupTitle: true,
}, },
{ link: 'dev_tools:console' },
{ link: 'dev_tools:searchprofiler' },
{ {
id: 'explore', id: 'explore',
title: i18n.translate('xpack.serverlessSearch.nav.explore', { title: i18n.translate('xpack.serverlessSearch.nav.explore', {
defaultMessage: 'Explore', defaultMessage: 'Explore',
}), }),
children: [ 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',
}),
},
],
}, },
{
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', id: 'content',
title: i18n.translate('xpack.serverlessSearch.nav.content', { title: i18n.translate('xpack.serverlessSearch.nav.content', {
defaultMessage: 'Content', defaultMessage: 'Content',
}), }),
children: [ 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 */,
},
],
}, },
{
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', id: 'security',
title: i18n.translate('xpack.serverlessSearch.nav.security', { title: i18n.translate('xpack.serverlessSearch.nav.security', {
defaultMessage: 'Security', defaultMessage: 'Security',
}), }),
children: [ isGroupTitle: true,
{ },
link: 'management:api_keys', {
breadcrumbStatus: link: 'management:api_keys',
'hidden' /* management sub-pages set their breadcrumbs themselves */, breadcrumbStatus: 'hidden' /* management sub-pages set their breadcrumbs themselves */,
},
],
}, },
], ],
}, },
@ -125,30 +126,25 @@ const navigationTree: NavigationTreeDefinition = {
breadcrumbStatus: 'hidden', breadcrumbStatus: 'hidden',
children: [ children: [
{ {
id: 'settings', link: 'management',
children: [ title: i18n.translate('xpack.serverlessSearch.nav.mngt', {
{ defaultMessage: 'Management',
link: 'management', }),
title: i18n.translate('xpack.serverlessSearch.nav.mngt', { },
defaultMessage: 'Management', {
}), id: 'cloudLinkDeployment',
}, cloudLink: 'deployment',
{ title: i18n.translate('xpack.serverlessSearch.nav.performance', {
id: 'cloudLinkDeployment', defaultMessage: 'Performance',
cloudLink: 'deployment', }),
title: i18n.translate('xpack.serverlessSearch.nav.performance', { },
defaultMessage: 'Performance', {
}), id: 'cloudLinkUserAndRoles',
}, cloudLink: 'userAndRoles',
{ },
id: 'cloudLinkUserAndRoles', {
cloudLink: 'userAndRoles', id: 'cloudLinkBilling',
}, cloudLink: 'billingAndSub',
{
id: 'cloudLinkBilling',
cloudLink: 'billingAndSub',
},
],
}, },
], ],
}, },

View file

@ -23,6 +23,7 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) {
const testSubjects = ctx.getService('testSubjects'); const testSubjects = ctx.getService('testSubjects');
const browser = ctx.getService('browser'); const browser = ctx.getService('browser');
const retry = ctx.getService('retry'); const retry = ctx.getService('retry');
const log = ctx.getService('log');
async function getByVisibleText( async function getByVisibleText(
selector: string | (() => Promise<WebElementWrapper[]>), selector: string | (() => Promise<WebElementWrapper[]>),
@ -93,16 +94,20 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) {
} }
}, },
async expectSectionExists(sectionId: NavigationId) { async expectSectionExists(sectionId: NavigationId) {
log.debug('ServerlessCommonNavigation.sidenav.expectSectionExists', sectionId);
await testSubjects.existOrFail(`~nav-bucket-${sectionId}`); await testSubjects.existOrFail(`~nav-bucket-${sectionId}`);
}, },
async isSectionOpen(sectionId: NavigationId) { async isSectionOpen(sectionId: NavigationId) {
await this.expectSectionExists(sectionId); await this.expectSectionExists(sectionId);
const section = await testSubjects.find(`~nav-bucket-${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'); const isExpanded = await collapseBtn.getAttribute('aria-expanded');
return isExpanded === 'true'; return isExpanded === 'true';
}, },
async expectSectionOpen(sectionId: NavigationId) { async expectSectionOpen(sectionId: NavigationId) {
log.debug('ServerlessCommonNavigation.sidenav.expectSectionOpen', sectionId);
await this.expectSectionExists(sectionId); await this.expectSectionExists(sectionId);
await retry.waitFor(`section ${sectionId} to be open`, async () => { await retry.waitFor(`section ${sectionId} to be open`, async () => {
const isOpen = await this.isSectionOpen(sectionId); const isOpen = await this.isSectionOpen(sectionId);
@ -117,11 +122,14 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) {
}); });
}, },
async openSection(sectionId: NavigationId) { async openSection(sectionId: NavigationId) {
log.debug('ServerlessCommonNavigation.sidenav.openSection', sectionId);
await this.expectSectionExists(sectionId); await this.expectSectionExists(sectionId);
const isOpen = await this.isSectionOpen(sectionId); const isOpen = await this.isSectionOpen(sectionId);
if (isOpen) return; if (isOpen) return;
const section = await testSubjects.find(`~nav-bucket-${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]`
);
await collapseBtn.click(); await collapseBtn.click();
await this.expectSectionOpen(sectionId); await this.expectSectionOpen(sectionId);
}, },
@ -130,7 +138,9 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) {
const isOpen = await this.isSectionOpen(sectionId); const isOpen = await this.isSectionOpen(sectionId);
if (!isOpen) return; if (!isOpen) return;
const section = await testSubjects.find(`~nav-bucket-${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]`
);
await collapseBtn.click(); await collapseBtn.click();
await this.expectSectionClosed(sectionId); await this.expectSectionClosed(sectionId);
}, },
@ -143,6 +153,10 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) {
await testSubjects.click('~breadcrumb-home'); await testSubjects.click('~breadcrumb-home');
}, },
async expectBreadcrumbExists(by: { deepLinkId: AppDeepLinkId } | { text: string }) { async expectBreadcrumbExists(by: { deepLinkId: AppDeepLinkId } | { text: string }) {
log.debug(
'ServerlessCommonNavigation.breadcrumbs.expectBreadcrumbExists',
JSON.stringify(by)
);
if ('deepLinkId' in by) { if ('deepLinkId' in by) {
await testSubjects.existOrFail(`~breadcrumb-deepLinkId-${by.deepLinkId}`); await testSubjects.existOrFail(`~breadcrumb-deepLinkId-${by.deepLinkId}`);
} else { } else {
@ -161,6 +175,10 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) {
} }
}, },
async expectBreadcrumbTexts(expectedBreadcrumbTexts: string[]) { async expectBreadcrumbTexts(expectedBreadcrumbTexts: string[]) {
log.debug(
'ServerlessCommonNavigation.breadcrumbs.expectBreadcrumbTexts',
JSON.stringify(expectedBreadcrumbTexts)
);
await retry.try(async () => { await retry.try(async () => {
const breadcrumbsContainer = await testSubjects.find('breadcrumbs'); const breadcrumbsContainer = await testSubjects.find('breadcrumbs');
const breadcrumbs = await breadcrumbsContainer.findAllByTestSubject('~breadcrumb'); const breadcrumbs = await breadcrumbsContainer.findAllByTestSubject('~breadcrumb');

View file

@ -48,9 +48,8 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
// navigate to discover // navigate to discover
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'discover' }); await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'discover' });
await svlCommonNavigation.sidenav.expectLinkActive({ deepLinkId: 'discover' }); await svlCommonNavigation.sidenav.expectLinkActive({ deepLinkId: 'discover' });
await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ text: `Explore` });
await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({ deepLinkId: 'discover' }); 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 // navigate to a different section
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'management:index_management' }); 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 () => { it("management apps from the sidenav hide the 'stack management' root from the breadcrumbs", async () => {
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'management:triggersActions' }); 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.sidenav.clickLink({ deepLinkId: 'management:index_management' });
await svlCommonNavigation.breadcrumbs.expectBreadcrumbTexts([ await svlCommonNavigation.breadcrumbs.expectBreadcrumbTexts(['Index Management', 'Indices']);
'Content',
'Index Management',
'Indices',
]);
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'management:ingest_pipelines' }); 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.sidenav.clickLink({ deepLinkId: 'management:api_keys' });
await svlCommonNavigation.breadcrumbs.expectBreadcrumbTexts(['Security', 'API keys']); await svlCommonNavigation.breadcrumbs.expectBreadcrumbTexts(['API keys']);
}); });
it('navigate management', async () => { it('navigate management', async () => {
@ -104,7 +99,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
await svlCommonNavigation.search.clickOnOption(0); await svlCommonNavigation.search.clickOnOption(0);
await svlCommonNavigation.search.hideSearch(); 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 () => { it('does not show cases in sidebar navigation', async () => {