[Chrome] Expose api to to set custom component for project left nav (#156205)

In this PR I've exposed a new api from the Chrome service to set a
custom React component for the side navigation.
Calling this API does not have any effect if Kibana is not started in
"project" ChromeStyle.

## Api

```ts
chrome.project.setSideNavComponent(/* component */);
```

## Example

```ts
// my_nav.tsx
import React, { type FC } from 'react';
import type { SideNavComponent } from '@kbn/core-chrome-browser';

// SideNavComponent will receive in its props Chrome service state (the current active route, the navTree...) 
export const MyNav: SideNavComponent = (props) => {
  return <div>Custom navigation...</div>;
};

-------------------

// serverless plugin.ts (public)
import { MyNav } from './my_nav';
...

public start(core: CoreStart) {
  core.chrome.project.setSideNavComponent(MyNav);
  return {};
}
```
This commit is contained in:
Sébastien Loix 2023-05-02 19:07:30 +01:00 committed by GitHub
parent 41f0b75f55
commit 06054cd4ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 284 additions and 43 deletions

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { shallow } from 'enzyme';
import { shallow, mount } from 'enzyme';
import React from 'react';
import * as Rx from 'rxjs';
import { toArray } from 'rxjs/operators';
@ -19,6 +19,7 @@ import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
import { customBrandingServiceMock } from '@kbn/core-custom-branding-browser-mocks';
import { getAppInfo } from '@kbn/core-application-browser-internal';
import { findTestSubject } from '@kbn/test-jest-helpers';
import { ChromeService } from './chrome_service';
class FakeApp implements App {
@ -176,6 +177,41 @@ describe('start', () => {
// Don't capture the snapshot because it's 600+ lines long.
expect(shallow(React.createElement(() => chrome.getHeaderComponent()))).toBeDefined();
});
it('renders the default project side navigation', async () => {
const { chrome } = await start();
chrome.setChromeStyle('project');
const component = mount(chrome.getHeaderComponent());
const projectHeader = findTestSubject(component, 'kibanaProjectHeader');
expect(projectHeader.length).toBe(1);
const defaultProjectSideNav = findTestSubject(component, 'defaultProjectSideNav');
expect(defaultProjectSideNav.length).toBe(1);
});
it('renders the custom project side navigation', async () => {
const { chrome } = await start();
const MyNav = function MyNav() {
return <div data-test-subj="customProjectSideNav">HELLO</div>;
};
chrome.setChromeStyle('project');
chrome.project.setSideNavComponent(MyNav);
const component = mount(chrome.getHeaderComponent());
const projectHeader = findTestSubject(component, 'kibanaProjectHeader');
expect(projectHeader.length).toBe(1);
const defaultProjectSideNav = findTestSubject(component, 'defaultProjectSideNav');
expect(defaultProjectSideNav.length).toBe(0); // Default side nav not mounted
const customProjectSideNav = findTestSubject(component, 'customProjectSideNav');
expect(customProjectSideNav.text()).toBe('HELLO');
});
});
describe('visibility', () => {

View file

@ -12,6 +12,7 @@ import { BehaviorSubject, combineLatest, merge, type Observable, of, ReplaySubje
import { flatMap, map, takeUntil } from 'rxjs/operators';
import { parse } from 'url';
import { EuiLink } from '@elastic/eui';
import useObservable from 'react-use/lib/useObservable';
import type { InternalInjectedMetadataStart } from '@kbn/core-injected-metadata-browser-internal';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import type { HttpStart } from '@kbn/core-http-browser';
@ -27,14 +28,17 @@ import type {
ChromeHelpExtension,
ChromeUserBanner,
ChromeStyle,
ChromeProjectNavigation,
} from '@kbn/core-chrome-browser';
import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser';
import type { SideNavComponent as ISideNavComponent } from '@kbn/core-chrome-browser';
import { KIBANA_ASK_ELASTIC_LINK } from './constants';
import { DocTitleService } from './doc_title';
import { NavControlsService } from './nav_controls';
import { NavLinksService } from './nav_links';
import { ProjectNavigationService } from './project_navigation';
import { RecentlyAccessedService } from './recently_accessed';
import { Header, ProjectHeader } from './ui';
import { Header, ProjectHeader, ProjectSideNavigation } from './ui';
import type { InternalChromeStart } from './types';
const IS_LOCKED_KEY = 'core.chrome.isLocked';
@ -63,6 +67,7 @@ export class ChromeService {
private readonly navLinks = new NavLinksService();
private readonly recentlyAccessed = new RecentlyAccessedService();
private readonly docTitle = new DocTitleService();
private readonly projectNavigation = new ProjectNavigationService();
constructor(private readonly params: ConstructorParams) {}
@ -147,6 +152,7 @@ export class ChromeService {
const navControls = this.navControls.start();
const navLinks = this.navLinks.start({ application, http });
const projectNavigation = this.projectNavigation.start({ application, navLinks });
const recentlyAccessed = await this.recentlyAccessed.start({ http });
const docTitle = this.docTitle.start({ document: window.document });
const { customBranding$ } = customBranding;
@ -170,6 +176,14 @@ export class ChromeService {
chromeStyle$.next(style);
};
const setProjectSideNavComponent = (component: ISideNavComponent | null) => {
projectNavigation.setProjectSideNavComponent(component);
};
const setProjectNavigation = (config: ChromeProjectNavigation) => {
projectNavigation.setProjectNavigation(config);
};
const isIE = () => {
const ua = window.navigator.userAgent;
const msie = ua.indexOf('MSIE '); // IE 10 or older
@ -211,8 +225,28 @@ export class ChromeService {
}
const getHeaderComponent = () => {
const Component = ({ style$ }: { style$: typeof chromeStyle$ }) => {
if (style$.getValue() === 'project') {
if (chromeStyle$.getValue() === 'project') {
// const projectNavigationConfig = projectNavigation.getProjectNavigation$();
// TODO: Uncommented when we support the project navigation config
// if (!projectNavigationConfig) {
// throw new Erorr(`Project navigation config must be provided for project.`);
// }
const projectNavigationComponent$ = projectNavigation.getProjectSideNavComponent$();
const ProjectHeaderWithNavigation = () => {
const CustomSideNavComponent = useObservable(projectNavigationComponent$, undefined);
let SideNavComponent: ISideNavComponent = () => null;
if (CustomSideNavComponent !== undefined) {
// We have the state from the Observable
SideNavComponent =
CustomSideNavComponent.current !== null
? CustomSideNavComponent.current
: ProjectSideNavigation;
}
return (
<ProjectHeader
{...{
@ -226,41 +260,45 @@ export class ChromeService {
navControlsRight$={navControls.getRight$()}
kibanaDocLink={docLinks.links.kibana.guide}
kibanaVersion={injectedMetadata.getKibanaVersion()}
/>
>
{/* TODO: pass down the SideNavCompProps once they are defined */}
<SideNavComponent />
</ProjectHeader>
);
}
};
return (
<Header
loadingCount$={http.getLoadingCount$()}
application={application}
headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))}
badge$={badge$.pipe(takeUntil(this.stop$))}
basePath={http.basePath}
breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))}
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$))}
customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))}
kibanaDocLink={docLinks.links.kibana.guide}
forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()}
globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$}
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
homeHref={http.basePath.prepend('/app/home')}
isVisible$={this.isVisible$}
kibanaVersion={injectedMetadata.getKibanaVersion()}
navLinks$={navLinks.getNavLinks$()}
recentlyAccessed$={recentlyAccessed.get$()}
navControlsLeft$={navControls.getLeft$()}
navControlsCenter$={navControls.getCenter$()}
navControlsRight$={navControls.getRight$()}
navControlsExtension$={navControls.getExtension$()}
onIsLockedUpdate={setIsNavDrawerLocked}
isLocked$={getIsNavDrawerLocked$}
customBranding$={customBranding$}
/>
);
};
return <Component {...{ style$: chromeStyle$ }} />;
return <ProjectHeaderWithNavigation />;
}
return (
<Header
loadingCount$={http.getLoadingCount$()}
application={application}
headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))}
badge$={badge$.pipe(takeUntil(this.stop$))}
basePath={http.basePath}
breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))}
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$))}
customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))}
kibanaDocLink={docLinks.links.kibana.guide}
forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()}
globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$}
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
homeHref={http.basePath.prepend('/app/home')}
isVisible$={this.isVisible$}
kibanaVersion={injectedMetadata.getKibanaVersion()}
navLinks$={navLinks.getNavLinks$()}
recentlyAccessed$={recentlyAccessed.get$()}
navControlsLeft$={navControls.getLeft$()}
navControlsCenter$={navControls.getCenter$()}
navControlsRight$={navControls.getRight$()}
navControlsExtension$={navControls.getExtension$()}
onIsLockedUpdate={setIsNavDrawerLocked}
isLocked$={getIsNavDrawerLocked$}
customBranding$={customBranding$}
/>
);
};
return {
@ -335,6 +373,10 @@ export class ChromeService {
getBodyClasses$: () => bodyClasses$.pipe(takeUntil(this.stop$)),
setChromeStyle,
getChromeStyle$: () => chromeStyle$.pipe(takeUntil(this.stop$)),
project: {
setNavigation: setProjectNavigation,
setSideNavComponent: setProjectSideNavComponent,
},
};
}

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { ProjectNavigationService } from './project_navigation_service';

View file

@ -0,0 +1,49 @@
/*
* 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 { InternalApplicationStart } from '@kbn/core-application-browser-internal';
import {
ChromeNavLinks,
ChromeProjectNavigation,
SideNavComponent,
} from '@kbn/core-chrome-browser';
import { BehaviorSubject } from 'rxjs';
interface StartDeps {
application: InternalApplicationStart;
navLinks: ChromeNavLinks;
}
export class ProjectNavigationService {
private customProjectSideNavComponent$ = new BehaviorSubject<{
current: SideNavComponent | null;
}>({ current: null });
private projectNavigation$ = new BehaviorSubject<ChromeProjectNavigation | undefined>(undefined);
public start({ application, navLinks }: StartDeps) {
// TODO: use application, navLink and projectNavigation$ to:
// 1. validate projectNavigation$ against navLinks,
// 2. filter disabled/missing links from projectNavigation
// 3. keep track of currently active link / path (path will be used to highlight the link in the sidenav and display part of the breadcrumbs)
return {
setProjectNavigation: (projectNavigation: ChromeProjectNavigation) => {
this.projectNavigation$.next(projectNavigation);
},
getProjectNavigation$: () => {
return this.projectNavigation$.asObservable();
},
setProjectSideNavComponent: (component: SideNavComponent | null) => {
this.customProjectSideNavComponent$.next({ current: component });
},
getProjectSideNavComponent$: () => {
return this.customProjectSideNavComponent$.asObservable();
},
};
}
}

View file

@ -7,6 +7,6 @@
*/
export { Header } from './header';
export { ProjectHeader } from './project';
export { ProjectHeader, SideNavigation as ProjectSideNavigation } from './project';
export { LoadingIndicator } from './loading_indicator';
export type { NavType } from './header';

View file

@ -34,12 +34,14 @@ interface Props {
kibanaVersion: string;
application: InternalApplicationStart;
navControlsRight$: Observable<ChromeNavControl[]>;
children: React.ReactNode;
}
export const ProjectHeader = ({
application,
kibanaDocLink,
kibanaVersion,
children,
...observables
}: Props) => {
const renderLogo = () => (
@ -53,7 +55,7 @@ export const ProjectHeader = ({
return (
<>
<EuiHeader position="fixed">
<EuiHeader position="fixed" data-test-subj="kibanaProjectHeader">
<EuiHeaderSection grow={false}>
<EuiHeaderSectionItem border="right">{renderLogo()}</EuiHeaderSectionItem>
<EuiHeaderSectionItem>
@ -81,9 +83,7 @@ export const ProjectHeader = ({
</EuiHeaderSection>
</EuiHeader>
<Router history={application.history}>
<ProjectNavigation>
<span />
</ProjectNavigation>
<ProjectNavigation>{children}</ProjectNavigation>
</Router>
</>
);

View file

@ -7,3 +7,4 @@
*/
export { ProjectHeader } from './header';
export { SideNavigation } from './side_navigation';

View file

@ -0,0 +1,18 @@
/*
* 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 { EuiText } from '@elastic/eui';
import type { SideNavComponent } from '@kbn/core-chrome-browser';
export const SideNavigation: SideNavComponent = () => {
return (
<div data-test-subj="defaultProjectSideNav">
<EuiText color="white">TODO - Build navigation from config</EuiText>
</div>
);
};

View file

@ -63,6 +63,10 @@ const createStartContractMock = () => {
getBodyClasses$: jest.fn(),
getChromeStyle$: jest.fn(),
setChromeStyle: jest.fn(),
project: {
setNavigation: jest.fn(),
setSideNavComponent: jest.fn(),
},
};
startContract.navLinks.getAll.mockReturnValue([]);
startContract.getIsVisible$.mockReturnValue(new BehaviorSubject(false));

View file

@ -29,4 +29,9 @@ export type {
ChromeStart,
ChromeStyle,
ChromeUserBanner,
ChromeProjectNavigationLink,
ChromeProjectNavigation,
ChromeProjectNavigationNode,
SideNavCompProps,
SideNavComponent,
} from './src';

View file

@ -14,7 +14,8 @@ import type { ChromeNavControls } from './nav_controls';
import type { ChromeHelpExtension } from './help_extension';
import type { ChromeBreadcrumb, ChromeBreadcrumbsAppendExtension } from './breadcrumb';
import type { ChromeBadge, ChromeStyle, ChromeUserBanner } from './types';
import { ChromeGlobalHelpExtensionMenuLink } from './help_extension';
import type { ChromeGlobalHelpExtensionMenuLink } from './help_extension';
import type { ChromeProjectNavigation, SideNavComponent } from './project_navigation';
/**
* ChromeStart allows plugins to customize the global chrome header UI and
@ -161,4 +162,26 @@ export interface ChromeStart {
* Get an observable of the current style type of the chrome.
*/
getChromeStyle$(): Observable<ChromeStyle>;
/**
* Configuration for serverless projects
*/
project: {
/**
* Sets the project navigation config to be used for rendering project navigation.
* It is used for default project sidenav, project breadcrumbs, tracking active deep link.
* @param projectNavigation The project navigation config
*
* @remarks Has no effect if the chrome style is not `project`.
*/
setNavigation(projectNavigation: ChromeProjectNavigation): void;
/**
* Set custom project sidenav component to be used instead of the default project sidenav.
* @param getter A function returning a CustomNavigationComponent.
* This component will receive Chrome navigation state as props (not yet implemented)
*
* @remarks Has no effect if the chrome style is not `project`.
*/
setSideNavComponent(component: SideNavComponent | null): void;
};
}

View file

@ -27,3 +27,11 @@ export type {
ChromeRecentlyAccessedHistoryItem,
} from './recently_accessed';
export type { ChromeBadge, ChromeUserBanner, ChromeStyle } from './types';
export type {
ChromeProjectNavigation,
ChromeProjectNavigationNode,
ChromeProjectNavigationLink,
SideNavCompProps,
SideNavComponent,
} from './project_navigation';

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { ComponentType } from 'react';
/** @internal */
type AppId = string;
/** @internal */
type DeepLinkId = string;
/** @internal */
export type AppDeepLinkId = `${AppId}:${DeepLinkId}`;
/** @public */
export type ChromeProjectNavigationLink = AppId | AppDeepLinkId;
/** @public */
export interface ChromeProjectNavigationNode {
id?: string;
link?: ChromeProjectNavigationLink;
children?: ChromeProjectNavigationNode[];
title?: string;
icon?: string;
}
/** @public */
export interface ChromeProjectNavigation {
navigationTree: ChromeProjectNavigationNode[];
}
/** @public */
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SideNavCompProps {
// TODO: provide the Chrome state to the component through props
// e.g. "navTree", "activeRoute", "recentItems"...
}
/** @public */
export type SideNavComponent = ComponentType<SideNavCompProps>;