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';
interface AppMenuBarProps {
isOpen: boolean;
headerActionMenuMounter: { mount: MountPoint<HTMLElement> | undefined };
}
export const AppMenuBar = ({ headerActionMenuMounter }: AppMenuBarProps) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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(
<NavigationProvider {...services} onProjectNavigationChange={onProjectNavigationChange}>
<Navigation>
<Navigation.Group id="group1">
<Navigation.Group id="group1" defaultIsCollapsed={false}>
<Navigation.Item id="item1" title="Item 1" 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.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.Group>
</Navigation.Group>
@ -62,10 +62,10 @@ describe('<Navigation />', () => {
expect(await findByTestId(/nav-item-group1.item2/)).toBeVisible();
expect(await findByTestId(/nav-item-group1.group1A\s/)).toBeVisible();
expect(await findByTestId(/nav-item-group1.group1A.item1/)).toBeVisible();
expect(await findByTestId(/nav-item-group1.group1A.group1A_1/)).toBeVisible();
expect(await findByTestId(/nav-item-group1.group1A.group1A_1\s/)).toBeVisible();
// Click the last group to expand and show the last depth
(await findByTestId(/nav-item-group1.group1A.group1A_1/)).click();
(await findByTestId(/nav-item-group1.group1A.group1A_1\s/)).click();
expect(await findByTestId(/nav-item-group1.group1A.group1A_1.item1/)).toBeVisible();
@ -76,56 +76,56 @@ describe('<Navigation />', () => {
expect(navTree.navigationTree).toEqual([
{
id: 'group1',
path: ['group1'],
title: '',
isActive: false,
children: [
{
id: 'item1',
title: 'Item 1',
href: 'https://foo',
id: 'item1',
isActive: false,
path: ['group1', 'item1'],
title: 'Item 1',
},
{
id: 'item2',
title: 'Item 2',
href: 'https://foo',
id: 'item2',
isActive: false,
path: ['group1', 'item2'],
title: 'Item 2',
},
{
id: 'group1A',
title: 'Group1A',
isActive: false,
path: ['group1', 'group1A'],
children: [
{
id: 'item1',
href: 'https://foo',
title: 'Group 1A Item 1',
id: 'item1',
isActive: false,
path: ['group1', 'group1A', 'item1'],
title: 'Group 1A Item 1',
},
{
id: 'group1A_1',
title: 'Group1A_1',
isActive: false,
path: ['group1', 'group1A', 'group1A_1'],
children: [
{
id: 'item1',
title: 'Group 1A_1 Item 1',
isActive: false,
href: 'https://foo',
id: 'item1',
isActive: false,
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}
>
<Navigation>
<Navigation.Group id="root">
<Navigation.Group id="group1">
<Navigation.Group id="root" defaultIsCollapsed={false}>
<Navigation.Group id="group1" defaultIsCollapsed={false}>
{/* Title from deeplink */}
<Navigation.Item<any> id="item1" link="item1" />
{/* Should not appear */}
@ -279,13 +279,13 @@ describe('<Navigation />', () => {
id: 'root',
path: ['root'],
title: '',
isActive: false,
isActive: true,
children: [
{
id: 'group1',
path: ['root', 'group1'],
title: '',
isActive: false,
isActive: true,
children: [
{
id: 'item1',
@ -326,11 +326,11 @@ describe('<Navigation />', () => {
onProjectNavigationChange={onProjectNavigationChange}
>
<Navigation>
<Navigation.Group id="root">
<Navigation.Group id="group1">
<Navigation.Group id="root" defaultIsCollapsed={false}>
<Navigation.Group id="group1" defaultIsCollapsed={false}>
<Navigation.Item<any> id="item1" link="notRegistered" />
</Navigation.Group>
<Navigation.Group id="group2">
<Navigation.Group id="group2" defaultIsCollapsed={false}>
<Navigation.Item<any> id="item1" link="item1" />
</Navigation.Group>
</Navigation.Group>
@ -352,128 +352,39 @@ describe('<Navigation />', () => {
expect(navTree.navigationTree).toEqual([
{
id: 'root',
path: ['root'],
title: '',
isActive: false,
children: [
{
id: 'group1',
isActive: true,
path: ['root', 'group1'],
title: '',
isActive: false,
},
{
id: 'group2',
path: ['root', 'group2'],
title: '',
isActive: false,
children: [
{
deepLink: {
baseUrl: '',
href: '',
id: 'item1',
title: 'Title from deeplink',
url: '',
},
id: 'item1',
isActive: false,
path: ['root', 'group2', 'item1'],
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',
isActive: true,
path: ['root'],
title: '',
isActive: false,
children: [
{
id: 'group1',
path: ['root', 'group1'],
title: '',
isActive: false,
children: [
{
id: 'item1',
path: ['root', 'group1', 'item1'],
title: 'Title from deeplink',
renderItem: expect.any(Function),
isActive: false,
deepLink: {
id: 'item1',
title: 'Title from deeplink',
baseUrl: '',
url: '',
href: '',
},
},
{
id: 'item2',
href: 'http://foo',
path: ['root', 'group1', 'item2'],
title: 'Children prop',
isActive: false,
renderItem: expect.any(Function),
},
],
},
],
},
]);
});
@ -649,11 +560,11 @@ describe('<Navigation />', () => {
</NavigationProvider>
);
expect(await findByTestId(/nav-item-group1.item1/)).toHaveClass(
'euiSideNavItemButton-isSelected'
expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).toMatch(
/nav-item-isActive/
);
expect(await findByTestId(/nav-item-group1.item2/)).not.toHaveClass(
'euiSideNavItemButton-isSelected'
expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).not.toMatch(
/nav-item-isActive/
);
await act(async () => {
@ -673,11 +584,11 @@ describe('<Navigation />', () => {
]);
});
expect(await findByTestId(/nav-item-group1.item1/)).not.toHaveClass(
'euiSideNavItemButton-isSelected'
expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).not.toMatch(
/nav-item-isActive/
);
expect(await findByTestId(/nav-item-group1.item2/)).toHaveClass(
'euiSideNavItemButton-isSelected'
expect((await findByTestId(/nav-item-group1.item2/)).dataset.testSubj).toMatch(
/nav-item-isActive/
);
});
@ -730,8 +641,8 @@ describe('<Navigation />', () => {
jest.advanceTimersByTime(SET_NAVIGATION_DELAY);
expect(await findByTestId(/nav-item-group1.item1/)).toHaveClass(
'euiSideNavItemButton-isSelected'
expect((await findByTestId(/nav-item-group1.item1/)).dataset.testSubj).toMatch(
/nav-item-isActive/
);
});
});
@ -743,7 +654,7 @@ describe('<Navigation />', () => {
const { findByTestId } = render(
<NavigationProvider {...services} onProjectNavigationChange={onProjectNavigationChange}>
<Navigation>
<Navigation.Group id="group1">
<Navigation.Group id="group1" defaultIsCollapsed={false}>
<Navigation.Item id="cloudLink1" cloudLink="userAndRoles" />
<Navigation.Item id="cloudLink2" cloudLink="performance" />
<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.cloudLink3/)).toBeVisible();
expect(await (await findByTestId(/nav-item-group1.cloudLink1/)).textContent).toBe(
expect((await findByTestId(/nav-item-group1.cloudLink1/)).textContent).toBe(
'Mock Users & RolesExternal link'
);
expect(await (await findByTestId(/nav-item-group1.cloudLink2/)).textContent).toBe(
expect((await findByTestId(/nav-item-group1.cloudLink2/)).textContent).toBe(
'Mock PerformanceExternal link'
);
expect(await (await findByTestId(/nav-item-group1.cloudLink3/)).textContent).toBe(
expect((await findByTestId(/nav-item-group1.cloudLink3/)).textContent).toBe(
'Mock Billing & SubscriptionsExternal link'
);
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,84 +6,61 @@
* Side Public License, v 1.
*/
import React, { FC, useCallback, useState } from 'react';
import { of } from 'rxjs';
import { ComponentMeta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import type { ChromeNavLink } from '@kbn/core-chrome-browser';
import { useState } from '@storybook/addons';
import { ComponentMeta } from '@storybook/react';
import React, { EventHandler, FC, PropsWithChildren, MouseEvent } from 'react';
import { BehaviorSubject, of } from 'rxjs';
import {
EuiButton,
EuiButtonIcon,
EuiCollapsibleNav,
EuiCollapsibleNavBeta,
EuiCollapsibleNavBetaProps,
EuiFlexGroup,
EuiFlexItem,
EuiHeader,
EuiHeaderSection,
EuiLink,
EuiPageTemplate,
EuiText,
EuiThemeProvider,
EuiTitle,
} from '@elastic/eui';
import { css } from '@emotion/react';
import type { ChromeNavLink, ChromeProjectNavigationNode } from '@kbn/core-chrome-browser';
import { NavigationStorybookMock, navLinksMock } from '../../mocks';
import mdx from '../../README.mdx';
import { NavigationProvider } from '../services';
import { DefaultNavigation } from './default_navigation';
import type { NavigationServices } from '../../types';
import { NavigationProvider } from '../services';
import { Navigation } from './components';
import type { NonEmptyArray, ProjectNavigationDefinition } from './types';
import { DefaultNavigation } from './default_navigation';
import { getPresets } from './nav_tree_presets';
import type { GroupDefinition, NonEmptyArray, ProjectNavigationDefinition } from './types';
const storybookMock = new NavigationStorybookMock();
const SIZE_OPEN = 248;
const SIZE_CLOSED = 40;
const NavigationWrapper: FC = ({ children }) => {
const [isOpen, setIsOpen] = useState(true);
const collabsibleNavCSS = css`
border-inline-end-width: 1,
display: flex,
flex-direction: row,
`;
const CollapseButton = () => {
const buttonCSS = css`
margin-left: -32px;
position: fixed;
z-index: 1000;
`;
return (
<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]);
const NavigationWrapper: FC<
PropsWithChildren<{ clickAction?: EventHandler<MouseEvent>; clickActionText?: string }> &
Partial<EuiCollapsibleNavBetaProps>
> = (props) => {
return (
<EuiThemeProvider>
<EuiCollapsibleNav
css={collabsibleNavCSS}
isOpen={true}
showButtonIfDocked={true}
onClose={toggleOpen}
isDocked={true}
size={isOpen ? SIZE_OPEN : SIZE_CLOSED}
hideCloseButton={false}
button={<CollapseButton />}
>
{isOpen && children}
</EuiCollapsibleNav>
</EuiThemeProvider>
<>
<EuiHeader position="fixed">
<EuiHeaderSection side={props?.side}>
<EuiCollapsibleNavBeta {...props} />
</EuiHeaderSection>
</EuiHeader>
<EuiPageTemplate>
<EuiPageTemplate.Section>
{props.clickAction ? (
<EuiButton color="text" onClick={props.clickAction}>
{props.clickActionText ?? 'Click me'}
</EuiButton>
) : (
<p>Hello world</p>
)}
</EuiPageTemplate.Section>
</EuiPageTemplate>
</>
);
};
@ -123,30 +100,25 @@ const simpleNavigationDefinition: ProjectNavigationDefinition = {
defaultIsCollapsed: false,
children: [
{
id: 'root',
children: [
{
id: 'item1',
title: 'Get started',
},
{
id: 'item2',
title: 'Alerts',
},
{
id: 'item3',
title: 'Dashboards',
},
{
id: 'item4',
title: 'External link',
href: 'https://elastic.co',
},
{
id: 'item5',
title: 'Another link',
},
],
id: 'item1',
title: 'Get started',
},
{
id: 'item2',
title: 'Alerts',
},
{
id: 'item3',
title: 'Dashboards',
},
{
id: 'item4',
title: 'External link',
href: 'https://elastic.co',
},
{
id: 'item5',
title: 'Another link',
},
{
id: 'group:settings',
@ -205,21 +177,16 @@ const navigationDefinition: ProjectNavigationDefinition = {
defaultIsCollapsed: false,
children: [
{
id: 'root',
children: [
{
id: 'item1',
title: 'Get started',
},
{
id: 'item2',
title: 'Alerts',
},
{
id: 'item3',
title: 'Some other node',
},
],
id: 'item1',
title: 'Get started',
},
{
id: 'item2',
title: 'Alerts',
},
{
id: 'item3',
title: 'Some other node',
},
{
id: 'group:settings',
@ -333,24 +300,22 @@ export const WithUIComponents = (args: NavigationServices) => {
icon="logoObservability"
defaultIsCollapsed={false}
>
<Navigation.Group id="root">
<Navigation.Item<any> id="item1" link="item1" />
<Navigation.Item id="item2" title="Alerts">
{(navNode) => {
return (
<div className="euiSideNavItemButton">
<EuiText size="s">{`Render prop: ${navNode.id} - ${navNode.title}`}</EuiText>
</div>
);
}}
</Navigation.Item>
<Navigation.Item id="item3" title="Title in ReactNode">
<div className="euiSideNavItemButton">
<EuiLink>Title in ReactNode</EuiLink>
</div>
</Navigation.Item>
<Navigation.Item id="item4" title="External link" href="https://elastic.co" />
</Navigation.Group>
<Navigation.Item<any> id="item1" link="item1" />
<Navigation.Item id="item2" title="Alerts">
{(navNode) => {
return (
<div className="euiSideNavItemButton">
<EuiText size="s">{`Render prop: ${navNode.id} - ${navNode.title}`}</EuiText>
</div>
);
}}
</Navigation.Item>
<Navigation.Item id="item3" title="Title in ReactNode">
<div className="euiSideNavItemButton">
<EuiLink>Title in ReactNode</EuiLink>
</div>
</Navigation.Item>
<Navigation.Item id="item4" title="External link" href="https://elastic.co" />
<Navigation.Group id="group:settings" title="Settings">
<Navigation.Item id="logs" title="Logs" />
@ -370,12 +335,10 @@ export const WithUIComponents = (args: NavigationServices) => {
breadcrumbStatus="hidden"
icon="gear"
>
<Navigation.Group id="settings">
<Navigation.Item link="management" title="Management" />
<Navigation.Item id="cloudLinkUserAndRoles" cloudLink="userAndRoles" />
<Navigation.Item id="cloudLinkPerformance" cloudLink="performance" />
<Navigation.Item id="cloudLinkBilling" cloudLink="billingAndSub" />
</Navigation.Group>
<Navigation.Item link="management" title="Management" />
<Navigation.Item id="cloudLinkUserAndRoles" cloudLink="userAndRoles" />
<Navigation.Item id="cloudLinkPerformance" cloudLink="performance" />
<Navigation.Item id="cloudLinkBilling" cloudLink="billingAndSub" />
</Navigation.Group>
</Navigation.Footer>
</Navigation>
@ -551,3 +514,121 @@ export const CreativeUI = (args: NavigationServices) => {
</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.
*/
import type { ReactElement, ReactNode } from 'react';
import type { ReactNode } from 'react';
import type { EuiAccordionProps } from '@elastic/eui';
import type {
AppDeepLinkId,
ChromeProjectNavigationNode,
NodeDefinition,
} from '@kbn/core-chrome-browser';
import type { RecentlyAccessedProps } from './components';
export type NonEmptyArray<T> = [T, ...T[]];
@ -45,11 +46,6 @@ export interface NodePropsEnhanced<
Id extends string = string,
ChildrenId extends string = Id
> 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
* even if the URL does not match any of the nodes in the group.
@ -57,17 +53,6 @@ export interface NodePropsEnhanced<
isActive?: boolean;
}
/**
* @internal
*/
export interface ChromeProjectNavigationNodeEnhanced extends ChromeProjectNavigationNode {
/**
* This function correspond to the same "itemRender" function that can be passed to
* the EuiSideNavItemType (see navigation_section_ui.tsx)
*/
renderItem?: () => ReactElement;
}
/** The preset that can be pass to the NavigationBucket component */
export type NavigationGroupPreset = 'analytics' | 'devtools' | 'ml' | 'management';
@ -101,6 +86,10 @@ export interface GroupDefinition<
* `true`: the group will be collapsed event if any of its children nodes matches the current URL.
*/
defaultIsCollapsed?: boolean;
/*
* Pass props to the EUI accordion component used to represent a nav group
*/
accordionProps?: Partial<EuiAccordionProps>;
preset?: NavigationGroupPreset;
}
@ -172,7 +161,7 @@ export type UnRegisterFunction = (id: string) => void;
*
* A function to register a navigation node on its parent.
*/
export type RegisterFunction = (navNode: ChromeProjectNavigationNodeEnhanced) => {
export type RegisterFunction = (navNode: ChromeProjectNavigationNode) => {
/** The function to unregister the node. */
unregister: UnRegisterFunction;
/** The full path of the node in the navigation tree. */

View file

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

View file

@ -25,6 +25,9 @@ const navigationTree: NavigationTreeDefinition = {
title: 'Elasticsearch',
icon: 'logoElasticsearch',
defaultIsCollapsed: false,
accordionProps: {
arrowProps: { css: { display: 'none' } },
},
breadcrumbStatus: 'hidden',
children: [
{
@ -39,77 +42,75 @@ const navigationTree: NavigationTreeDefinition = {
title: i18n.translate('xpack.serverlessSearch.nav.devTools', {
defaultMessage: 'Dev Tools',
}),
children: [{ link: 'dev_tools:console' }, { link: 'dev_tools:searchprofiler' }],
isGroupTitle: true,
},
{ link: 'dev_tools:console' },
{ link: 'dev_tools:searchprofiler' },
{
id: 'explore',
title: i18n.translate('xpack.serverlessSearch.nav.explore', {
defaultMessage: 'Explore',
}),
children: [
{
link: 'discover',
},
{
link: 'dashboards',
getIsActive: ({ pathNameSerialized, prepend }) => {
return pathNameSerialized.startsWith(prepend('/app/dashboards'));
},
},
{
link: 'visualize',
getIsActive: ({ pathNameSerialized, prepend }) => {
return (
pathNameSerialized.startsWith(prepend('/app/visualize')) ||
pathNameSerialized.startsWith(prepend('/app/lens')) ||
pathNameSerialized.startsWith(prepend('/app/maps'))
);
},
},
{
link: 'management:triggersActions',
title: i18n.translate('xpack.serverlessSearch.nav.alerts', {
defaultMessage: 'Alerts',
}),
},
],
isGroupTitle: true,
},
{
link: 'discover',
},
{
link: 'dashboards',
getIsActive: ({ pathNameSerialized, prepend }) => {
return pathNameSerialized.startsWith(prepend('/app/dashboards'));
},
},
{
link: 'visualize',
getIsActive: ({ pathNameSerialized, prepend }) => {
return (
pathNameSerialized.startsWith(prepend('/app/visualize')) ||
pathNameSerialized.startsWith(prepend('/app/lens')) ||
pathNameSerialized.startsWith(prepend('/app/maps'))
);
},
},
{
link: 'management:triggersActions',
title: i18n.translate('xpack.serverlessSearch.nav.alerts', {
defaultMessage: 'Alerts',
}),
},
{
id: 'content',
title: i18n.translate('xpack.serverlessSearch.nav.content', {
defaultMessage: 'Content',
}),
children: [
{
title: i18n.translate('xpack.serverlessSearch.nav.content.indices', {
defaultMessage: 'Index Management',
}),
link: 'management:index_management',
breadcrumbStatus:
'hidden' /* management sub-pages set their breadcrumbs themselves */,
},
{
title: i18n.translate('xpack.serverlessSearch.nav.content.pipelines', {
defaultMessage: 'Pipelines',
}),
link: 'management:ingest_pipelines',
breadcrumbStatus:
'hidden' /* management sub-pages set their breadcrumbs themselves */,
},
],
isGroupTitle: true,
},
{
title: i18n.translate('xpack.serverlessSearch.nav.content.indices', {
defaultMessage: 'Index Management',
}),
link: 'management:index_management',
breadcrumbStatus: 'hidden' /* management sub-pages set their breadcrumbs themselves */,
},
{
title: i18n.translate('xpack.serverlessSearch.nav.content.pipelines', {
defaultMessage: 'Pipelines',
}),
link: 'management:ingest_pipelines',
breadcrumbStatus: 'hidden' /* management sub-pages set their breadcrumbs themselves */,
},
{
id: 'security',
title: i18n.translate('xpack.serverlessSearch.nav.security', {
defaultMessage: 'Security',
}),
children: [
{
link: 'management:api_keys',
breadcrumbStatus:
'hidden' /* management sub-pages set their breadcrumbs themselves */,
},
],
isGroupTitle: true,
},
{
link: 'management:api_keys',
breadcrumbStatus: 'hidden' /* management sub-pages set their breadcrumbs themselves */,
},
],
},
@ -125,30 +126,25 @@ const navigationTree: NavigationTreeDefinition = {
breadcrumbStatus: 'hidden',
children: [
{
id: 'settings',
children: [
{
link: 'management',
title: i18n.translate('xpack.serverlessSearch.nav.mngt', {
defaultMessage: 'Management',
}),
},
{
id: 'cloudLinkDeployment',
cloudLink: 'deployment',
title: i18n.translate('xpack.serverlessSearch.nav.performance', {
defaultMessage: 'Performance',
}),
},
{
id: 'cloudLinkUserAndRoles',
cloudLink: 'userAndRoles',
},
{
id: 'cloudLinkBilling',
cloudLink: 'billingAndSub',
},
],
link: 'management',
title: i18n.translate('xpack.serverlessSearch.nav.mngt', {
defaultMessage: 'Management',
}),
},
{
id: 'cloudLinkDeployment',
cloudLink: 'deployment',
title: i18n.translate('xpack.serverlessSearch.nav.performance', {
defaultMessage: 'Performance',
}),
},
{
id: 'cloudLinkUserAndRoles',
cloudLink: 'userAndRoles',
},
{
id: 'cloudLinkBilling',
cloudLink: 'billingAndSub',
},
],
},

View file

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

View file

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