diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index caaa588c2c9f8..f555f6a1282e6 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -244,6 +244,29 @@ Array [ ], Array [], ] +`); + }); + }); + + describe('help extension', () => { + it('updates/emits the current help extension', async () => { + const service = new ChromeService(); + const start = service.start(); + const promise = start + .getHelpExtension$() + .pipe(toArray()) + .toPromise(); + + start.setHelpExtension(() => () => undefined); + start.setHelpExtension(undefined); + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` +Array [ + undefined, + [Function], + undefined, +] `); }); }); @@ -258,7 +281,8 @@ describe('stop', () => { start.getApplicationClasses$(), start.getIsCollapsed$(), start.getBreadcrumbs$(), - start.getIsVisible$() + start.getIsVisible$(), + start.getHelpExtension$() ).toPromise(); service.stop(); @@ -276,7 +300,8 @@ describe('stop', () => { start.getApplicationClasses$(), start.getIsCollapsed$(), start.getBreadcrumbs$(), - start.getIsVisible$() + start.getIsVisible$(), + start.getHelpExtension$() ).toPromise() ).resolves.toBe(undefined); }); diff --git a/src/core/public/chrome/chrome_service.ts b/src/core/public/chrome/chrome_service.ts index 766da07ebbf9b..033c4a0505328 100644 --- a/src/core/public/chrome/chrome_service.ts +++ b/src/core/public/chrome/chrome_service.ts @@ -40,6 +40,8 @@ export interface Breadcrumb { 'data-test-subj'?: string; } +export type HelpExtension = (element: HTMLDivElement) => (() => void); + export class ChromeService { private readonly stop$ = new Rx.ReplaySubject(1); @@ -50,6 +52,7 @@ export class ChromeService { const isVisible$ = new Rx.BehaviorSubject(true); const isCollapsed$ = new Rx.BehaviorSubject(!!localStorage.getItem(IS_COLLAPSED_KEY)); const applicationClasses$ = new Rx.BehaviorSubject>(new Set()); + const helpExtension$ = new Rx.BehaviorSubject(undefined); const breadcrumbs$ = new Rx.BehaviorSubject([]); return { @@ -154,6 +157,18 @@ export class ChromeService { setBreadcrumbs: (newBreadcrumbs: Breadcrumb[]) => { breadcrumbs$.next(newBreadcrumbs); }, + + /** + * Get an observable of the current custom help conttent + */ + getHelpExtension$: () => helpExtension$.pipe(takeUntil(this.stop$)), + + /** + * Override the current set of breadcrumbs + */ + setHelpExtension: (helpExtension?: HelpExtension) => { + helpExtension$.next(helpExtension); + }, }; } diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index ac54469e20bd4..1a7e34b9d30c7 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -17,4 +17,10 @@ * under the License. */ -export { Breadcrumb, ChromeService, ChromeStartContract, Brand } from './chrome_service'; +export { + Breadcrumb, + ChromeService, + ChromeStartContract, + Brand, + HelpExtension, +} from './chrome_service'; diff --git a/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap b/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap index a1474127605dd..6ccc0ee1d2c25 100644 --- a/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap +++ b/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap @@ -10,6 +10,7 @@ Array [ "ui/chrome/api/ui_settings", "ui/chrome/api/injected_vars", "ui/chrome/api/controls", + "ui/chrome/api/help_extension", "ui/chrome/api/theme", "ui/chrome/api/breadcrumbs", "ui/chrome/services/global_nav_state", @@ -28,6 +29,7 @@ Array [ "ui/chrome/api/ui_settings", "ui/chrome/api/injected_vars", "ui/chrome/api/controls", + "ui/chrome/api/help_extension", "ui/chrome/api/theme", "ui/chrome/api/breadcrumbs", "ui/chrome/services/global_nav_state", diff --git a/src/core/public/legacy_platform/legacy_platform_service.test.ts b/src/core/public/legacy_platform/legacy_platform_service.test.ts index 957731fb7bcbf..1c559169ea3e8 100644 --- a/src/core/public/legacy_platform/legacy_platform_service.test.ts +++ b/src/core/public/legacy_platform/legacy_platform_service.test.ts @@ -102,6 +102,14 @@ jest.mock('ui/chrome/api/controls', () => { }; }); +const mockChromeHelpExtensionInit = jest.fn(); +jest.mock('ui/chrome/api/help_extension', () => { + mockLoadOrder.push('ui/chrome/api/help_extension'); + return { + __newPlatformInit__: mockChromeHelpExtensionInit, + }; +}); + const mockChromeThemeInit = jest.fn(); jest.mock('ui/chrome/api/theme', () => { mockLoadOrder.push('ui/chrome/api/theme'); @@ -269,6 +277,17 @@ describe('#start()', () => { expect(mockChromeControlsInit).toHaveBeenCalledWith(chromeStartContract); }); + it('passes chrome service to ui/chrome/api/help_extension', () => { + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + }); + + legacyPlatform.start(defaultStartDeps); + + expect(mockChromeHelpExtensionInit).toHaveBeenCalledTimes(1); + expect(mockChromeHelpExtensionInit).toHaveBeenCalledWith(chromeStartContract); + }); + it('passes chrome service to ui/chrome/api/theme', () => { const legacyPlatform = new LegacyPlatformService({ ...defaultParams, diff --git a/src/core/public/legacy_platform/legacy_platform_service.ts b/src/core/public/legacy_platform/legacy_platform_service.ts index 54bb912614cb2..8c2fb6cd21d42 100644 --- a/src/core/public/legacy_platform/legacy_platform_service.ts +++ b/src/core/public/legacy_platform/legacy_platform_service.ts @@ -71,6 +71,7 @@ export class LegacyPlatformService { require('ui/chrome/api/ui_settings').__newPlatformInit__(uiSettings); require('ui/chrome/api/injected_vars').__newPlatformInit__(injectedMetadata); require('ui/chrome/api/controls').__newPlatformInit__(chrome); + require('ui/chrome/api/help_extension').__newPlatformInit__(chrome); require('ui/chrome/api/theme').__newPlatformInit__(chrome); require('ui/chrome/api/breadcrumbs').__newPlatformInit__(chrome); require('ui/chrome/services/global_nav_state').__newPlatformInit__(chrome); diff --git a/src/ui/public/chrome/api/angular.js b/src/ui/public/chrome/api/angular.js index 56e1fea1186e3..410964beb5909 100644 --- a/src/ui/public/chrome/api/angular.js +++ b/src/ui/public/chrome/api/angular.js @@ -70,6 +70,7 @@ export function initAngularApi(chrome, internals) { }) .run(internals.capture$httpLoadingCount) .run(internals.$setupBreadcrumbsAutoClear) + .run(internals.$setupHelpExtensionAutoClear) .run(internals.$initNavLinksDeepWatch) .run(($location, $rootScope, Private, config) => { chrome.getFirstPathSegment = () => { diff --git a/src/ui/public/chrome/api/help_extension.ts b/src/ui/public/chrome/api/help_extension.ts new file mode 100644 index 0000000000000..2408c3457ab1e --- /dev/null +++ b/src/ui/public/chrome/api/help_extension.ts @@ -0,0 +1,97 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IRootScopeService } from 'angular'; + +import { ChromeStartContract, HelpExtension } from '../../../../core/public/chrome'; + +let newPlatformChrome: ChromeStartContract; +export function __newPlatformInit__(instance: ChromeStartContract) { + if (newPlatformChrome) { + throw new Error('ui/chrome/api/help_extension is already initialized'); + } + + newPlatformChrome = instance; +} + +export type HelpExtensionApi = ReturnType['helpExtension']; +export { HelpExtension }; + +function createHelpExtensionApi() { + /** + * reset helpExtensionSetSinceRouteChange any time the helpExtension changes, even + * if it was done directly through the new platform + */ + let helpExtensionSetSinceRouteChange = false; + newPlatformChrome.getHelpExtension$().subscribe({ + next() { + helpExtensionSetSinceRouteChange = true; + }, + }); + + return { + helpExtension: { + /** + * Set the custom help extension, or clear it by passing undefined. This + * will be rendered within the help popover in the header + */ + set: (helpExtension: HelpExtension | undefined) => { + newPlatformChrome.setHelpExtension(helpExtension); + }, + + /** + * Get the current help extension that should be rendered in the header + */ + get$: () => newPlatformChrome.getHelpExtension$(), + }, + + /** + * internal angular run function that will be called when angular bootstraps and + * lets us integrate with the angular router so that we can automatically clear + * the helpExtension if we switch to a Kibana app that does not set its own + * helpExtension + */ + $setupHelpExtensionAutoClear: ($rootScope: IRootScopeService, $injector: any) => { + const $route = $injector.has('$route') ? $injector.get('$route') : {}; + + $rootScope.$on('$routeChangeStart', () => { + helpExtensionSetSinceRouteChange = false; + }); + + $rootScope.$on('$routeChangeSuccess', () => { + const current = $route.current || {}; + + if (helpExtensionSetSinceRouteChange || (current.$$route && current.$$route.redirectTo)) { + return; + } + + newPlatformChrome.setHelpExtension(current.helpExtension); + }); + }, + }; +} + +export function initHelpExtensionApi( + chrome: { [key: string]: any }, + internal: { [key: string]: any } +) { + const { helpExtension, $setupHelpExtensionAutoClear } = createHelpExtensionApi(); + chrome.helpExtension = helpExtension; + internal.$setupHelpExtensionAutoClear = $setupHelpExtensionAutoClear; +} diff --git a/src/ui/public/chrome/chrome.js b/src/ui/public/chrome/chrome.js index 7eba951076803..541ccb76c1fe6 100644 --- a/src/ui/public/chrome/chrome.js +++ b/src/ui/public/chrome/chrome.js @@ -44,6 +44,7 @@ import { initLoadingCountApi } from './api/loading_count'; import { initSavedObjectClient } from './api/saved_object_client'; import { initChromeBasePathApi } from './api/base_path'; import { initChromeInjectedVarsApi } from './api/injected_vars'; +import { initHelpExtensionApi } from './api/help_extension'; export const chrome = {}; const internals = _.defaults( @@ -70,6 +71,7 @@ initChromeInjectedVarsApi(chrome); initChromeNavApi(chrome, internals); initBreadcrumbsApi(chrome, internals); initLoadingCountApi(chrome, internals); +initHelpExtensionApi(chrome, internals); initAngularApi(chrome, internals); initChromeControlsApi(chrome); templateApi(chrome, internals); diff --git a/src/ui/public/chrome/directives/header_global_nav/components/header.tsx b/src/ui/public/chrome/directives/header_global_nav/components/header.tsx index 834bc36f7c3f2..1ab12b4c5a501 100644 --- a/src/ui/public/chrome/directives/header_global_nav/components/header.tsx +++ b/src/ui/public/chrome/directives/header_global_nav/components/header.tsx @@ -30,7 +30,6 @@ import { EuiHeaderSection, // @ts-ignore EuiHeaderSectionItem, - EuiLink, } from '@elastic/eui'; import { HeaderAppMenu } from './header_app_menu'; @@ -39,6 +38,7 @@ import { HeaderHelpMenu } from './header_help_menu'; import { HeaderNavControls } from './header_nav_controls'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import { HelpExtension } from 'ui/chrome'; import { ChromeHeaderNavControlsRegistry } from 'ui/registry/chrome_header_nav_controls'; import { NavControlSide, NavLink } from '../'; import { Breadcrumb } from '../../../../../../core/public/chrome'; @@ -49,6 +49,7 @@ interface Props { homeHref: string; isVisible: boolean; navLinks$: Rx.Observable; + helpExtension$: Rx.Observable; navControls: ChromeHeaderNavControlsRegistry; intl: InjectedIntl; } @@ -70,7 +71,14 @@ class HeaderUI extends Component { } public render() { - const { appTitle, breadcrumbs$, isVisible, navControls, navLinks$ } = this.props; + const { + appTitle, + breadcrumbs$, + isVisible, + navControls, + navLinks$, + helpExtension$, + } = this.props; if (!isVisible) { return null; @@ -91,7 +99,7 @@ class HeaderUI extends Component { - Give APM Feedback} /> + diff --git a/src/ui/public/chrome/directives/header_global_nav/components/header_nav_control.tsx b/src/ui/public/chrome/directives/header_global_nav/components/header_extension.tsx similarity index 69% rename from src/ui/public/chrome/directives/header_global_nav/components/header_nav_control.tsx rename to src/ui/public/chrome/directives/header_global_nav/components/header_extension.tsx index 53791bf771c9e..da00d63444202 100644 --- a/src/ui/public/chrome/directives/header_global_nav/components/header_nav_control.tsx +++ b/src/ui/public/chrome/directives/header_global_nav/components/header_extension.tsx @@ -18,47 +18,50 @@ */ import React from 'react'; -import { NavControl } from '../'; interface Props { - navControl: NavControl; + extension?: (el: HTMLDivElement) => (() => void); } -export class HeaderNavControl extends React.Component { +export class HeaderExtension extends React.Component { private readonly ref = React.createRef(); private unrender?: () => void; public componentDidMount() { - if (!this.ref.current) { - throw new Error(' mounted without ref'); - } - - this.unrender = this.props.navControl.render(this.ref.current) || undefined; + this.renderExtension(); } public componentDidUpdate(prevProps: Props) { - if (this.props.navControl.render === prevProps.navControl.render) { + if (this.props.extension === prevProps.extension) { return; } + this.unrenderExtension(); + this.renderExtension(); + } + + public componentWillUnmount() { + this.unrenderExtension(); + } + + public render() { + return
; + } + + private renderExtension() { if (!this.ref.current) { - throw new Error(' updated without ref'); + throw new Error(' mounted without ref'); } - if (this.unrender) { - this.unrender(); + if (this.props.extension) { + this.unrender = this.props.extension(this.ref.current); } - - this.unrender = this.props.navControl.render(this.ref.current) || undefined; } - public componentWillUnmount() { + private unrenderExtension() { if (this.unrender) { this.unrender(); + this.unrender = undefined; } } - - public render() { - return
; - } } diff --git a/src/ui/public/chrome/directives/header_global_nav/components/header_help_menu.tsx b/src/ui/public/chrome/directives/header_global_nav/components/header_help_menu.tsx index ed92586f57dc0..eafb72692ee92 100644 --- a/src/ui/public/chrome/directives/header_global_nav/components/header_help_menu.tsx +++ b/src/ui/public/chrome/directives/header_global_nav/components/header_help_menu.tsx @@ -18,7 +18,7 @@ */ import React, { Component, Fragment } from 'react'; -// import * as Rx from 'rxjs'; +import * as Rx from 'rxjs'; import { // TODO: add type annotations @@ -36,12 +36,13 @@ import { EuiSpacer, EuiText, } from '@elastic/eui'; - import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; -import { NavLink } from '../'; +import { HelpExtension } from 'ui/chrome'; + +import { HeaderExtension } from './header_extension'; interface Props { - customContent$?: React.ReactNode; + helpExtension$: Rx.Observable; intl: InjectedIntl; useDefaultContent?: boolean; documentationLink?: string; @@ -49,45 +50,46 @@ interface Props { interface State { isOpen: boolean; - customContent: NavLink[]; + helpExtension?: HelpExtension; } class HeaderHelpMenuUI extends Component { - // private subscription?: Rx.Subscription; + private subscription?: Rx.Subscription; constructor(props: Props) { super(props); this.state = { isOpen: false, - customContent: [], + helpExtension: undefined, }; } public componentDidMount() { - // this.subscription = this.props.customContent$.subscribe({ - // next: customContent => { - // this.setState({ customContent }); - // }, - // }); + this.subscription = this.props.helpExtension$.subscribe({ + next: helpExtension => { + this.setState({ + helpExtension, + }); + }, + }); } public componentWillUnmount() { - // if (this.subscription) { - // this.subscription.unsubscribe(); - // this.subscription = undefined; - // } + if (this.subscription) { + this.subscription.unsubscribe(); + this.subscription = undefined; + } } public render() { - const { intl, useDefaultContent, customContent$, documentationLink } = this.props; + const { intl, useDefaultContent, documentationLink } = this.props; + const { helpExtension } = this.state; const defaultContent = useDefaultContent ? ( -

- Get updates, information, and answers in our documentation. -

+

Get updates, information, and answers in our documentation.

@@ -132,8 +134,8 @@ class HeaderHelpMenuUI extends Component {
{defaultContent} - {defaultContent && customContent$ && } - {customContent$} + {defaultContent && helpExtension && } + {helpExtension && }
); diff --git a/src/ui/public/chrome/directives/header_global_nav/components/header_nav_control.test.tsx b/src/ui/public/chrome/directives/header_global_nav/components/header_nav_control.test.tsx index 2642ebd430e35..3d5678b8bb7ef 100644 --- a/src/ui/public/chrome/directives/header_global_nav/components/header_nav_control.test.tsx +++ b/src/ui/public/chrome/directives/header_global_nav/components/header_nav_control.test.tsx @@ -19,17 +19,12 @@ import { mount } from 'enzyme'; import React from 'react'; -import { NavControl, NavControlSide } from '../'; -import { HeaderNavControl } from './header_nav_control'; - -describe('HeaderNavControl', () => { - const defaultNavControl = { name: '', order: 1, side: NavControlSide.Right }; +import { HeaderExtension } from './header_extension'; +describe('HeaderExtension', () => { it('calls navControl.render with div node', () => { const renderSpy = jest.fn(); - const navControl = { ...defaultNavControl, render: renderSpy } as NavControl; - - mount(); + mount(); expect(renderSpy.mock.calls.length).toEqual(1); @@ -40,9 +35,8 @@ describe('HeaderNavControl', () => { it('calls unrender callback when unmounted', () => { const unrenderSpy = jest.fn(); const render = () => unrenderSpy; - const navControl = { ...defaultNavControl, render } as NavControl; - const wrapper = mount(); + const wrapper = mount(); wrapper.unmount(); expect(unrenderSpy.mock.calls.length).toEqual(1); diff --git a/src/ui/public/chrome/directives/header_global_nav/components/header_nav_controls.tsx b/src/ui/public/chrome/directives/header_global_nav/components/header_nav_controls.tsx index 2f9206935be00..3940b2f475a72 100644 --- a/src/ui/public/chrome/directives/header_global_nav/components/header_nav_controls.tsx +++ b/src/ui/public/chrome/directives/header_global_nav/components/header_nav_controls.tsx @@ -25,7 +25,7 @@ import { } from '@elastic/eui'; import { NavControl } from '../'; -import { HeaderNavControl } from './header_nav_control'; +import { HeaderExtension } from './header_extension'; interface Props { navControls: NavControl[]; @@ -47,7 +47,7 @@ export class HeaderNavControls extends Component { key={navControl.name} border={navControl.side === 'left' ? 'right' : 'left'} > - + ); } diff --git a/src/ui/public/chrome/directives/header_global_nav/header_global_nav.js b/src/ui/public/chrome/directives/header_global_nav/header_global_nav.js index 8da834b1fa9f2..7e16cd2934824 100644 --- a/src/ui/public/chrome/directives/header_global_nav/header_global_nav.js +++ b/src/ui/public/chrome/directives/header_global_nav/header_global_nav.js @@ -38,6 +38,7 @@ module.directive('headerGlobalNav', (reactDirective, chrome, Private) => { // angular injected React props { breadcrumbs$: chrome.breadcrumbs.get$(), + helpExtension$: chrome.helpExtension.get$(), navLinks$: chrome.getNavLinks$(), navControls, homeHref diff --git a/src/ui/public/chrome/directives/header_global_nav/index.ts b/src/ui/public/chrome/directives/header_global_nav/index.ts index ba8fbf66e736c..0cf351ee0090b 100644 --- a/src/ui/public/chrome/directives/header_global_nav/index.ts +++ b/src/ui/public/chrome/directives/header_global_nav/index.ts @@ -29,7 +29,7 @@ export interface NavControl { name: string; order: number; side: NavControlSide; - render: (targetDomElement: HTMLDivElement) => (() => void) | void; + render: (targetDomElement: HTMLDivElement) => (() => void); } export interface NavLink { diff --git a/src/ui/public/chrome/index.d.ts b/src/ui/public/chrome/index.d.ts index a30bd94f8fc4d..4403bfb946f13 100644 --- a/src/ui/public/chrome/index.d.ts +++ b/src/ui/public/chrome/index.d.ts @@ -19,7 +19,7 @@ import { Brand } from '../../../core/public/chrome'; import { BreadcrumbsApi } from './api/breadcrumbs'; -export { Breadcrumb } from './api/breadcrumbs'; +import { HelpExtensionApi } from './api/help_extension'; interface IInjector { get(injectable: string): T; @@ -27,6 +27,7 @@ interface IInjector { declare interface Chrome { breadcrumbs: BreadcrumbsApi; + helpExtension: HelpExtensionApi; addBasePath(path: T): T; dangerouslyGetActiveInjector(): Promise; getBasePath(): string; @@ -46,3 +47,5 @@ declare interface Chrome { declare const chrome: Chrome; export default chrome; +export { Breadcrumb } from './api/breadcrumbs'; +export { HelpExtension } from './api/help_extension';