From 96df7a93c870af69e1b6ed7651e150bb7a90f1c8 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 19 Aug 2020 14:11:26 +0200 Subject: [PATCH 01/14] add `setHeaderActionMenu` to AppMountParameters --- .../application/application_service.mock.ts | 2 + .../application/application_service.tsx | 42 ++++- .../application_service.test.tsx | 149 ++++++++++++++++++ .../integration_tests/router.test.tsx | 1 + src/core/public/application/types.ts | 21 +++ .../application/ui/app_container.test.tsx | 4 + .../public/application/ui/app_container.tsx | 17 +- src/core/public/application/ui/app_router.tsx | 7 +- src/core/public/mocks.ts | 1 + .../ui/public/new_platform/new_platform.ts | 1 + src/plugins/dev_tools/public/application.tsx | 1 + x-pack/plugins/ml/public/plugin.ts | 1 + .../account_management_app.test.ts | 1 + .../access_agreement_app.test.ts | 1 + .../capture_url/capture_url_app.test.ts | 1 + .../logged_out/logged_out_app.test.ts | 1 + .../authentication/login/login_app.test.ts | 1 + .../authentication/logout/logout_app.test.ts | 1 + .../overwritten_session_app.test.ts | 1 + 19 files changed, 245 insertions(+), 9 deletions(-) diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index 47a8a01d917eb..2bdf56ee34211 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -20,6 +20,7 @@ import { History } from 'history'; import { BehaviorSubject, Subject } from 'rxjs'; +import type { MountPoint } from '../types'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; import { ApplicationSetup, @@ -87,6 +88,7 @@ const createInternalStartContractMock = (): jest.Mocked>(new Map()), capabilities: capabilitiesServiceMock.createStartContract().capabilities, currentAppId$: currentAppId$.asObservable(), + currentActionMenu$: new BehaviorSubject(undefined), getComponent: jest.fn(), getUrlForApp: jest.fn(), navigateToApp: jest.fn().mockImplementation((appId) => currentAppId$.next(appId)), diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index d7f15decb255d..7a220972f0393 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -22,6 +22,7 @@ import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { map, shareReplay, takeUntil, distinctUntilChanged, filter } from 'rxjs/operators'; import { createBrowserHistory, History } from 'history'; +import { MountPoint } from '../types'; import { InjectedMetadataSetup } from '../injected_metadata'; import { HttpSetup, HttpStart } from '../http'; import { OverlayStart } from '../overlays'; @@ -90,6 +91,11 @@ interface AppUpdaterWrapper { updater: AppUpdater; } +interface AppInternalState { + leaveHandler?: AppLeaveHandler; + actionMenu?: MountPoint; +} + /** * Service that is responsible for registering new applications. * @internal @@ -98,8 +104,9 @@ export class ApplicationService { private readonly apps = new Map | LegacyApp>(); private readonly mounters = new Map(); private readonly capabilities = new CapabilitiesService(); - private readonly appLeaveHandlers = new Map(); + private readonly appInternalStates = new Map(); private currentAppId$ = new BehaviorSubject(undefined); + private currentActionMenu$ = new BehaviorSubject(undefined); private readonly statusUpdaters$ = new BehaviorSubject>(new Map()); private readonly subscriptions: Subscription[] = []; private stop$ = new Subject(); @@ -293,12 +300,14 @@ export class ApplicationService { if (path === undefined) { path = applications$.value.get(appId)?.defaultPath; } - this.appLeaveHandlers.delete(this.currentAppId$.value!); + this.appInternalStates.delete(this.currentAppId$.value!); this.navigate!(getAppUrl(availableMounters, appId, path), state, replace); this.currentAppId$.next(appId); } }; + this.currentAppId$.subscribe(() => this.refreshCurrentActionMenu()); + return { applications$: applications$.pipe( map((apps) => new Map([...apps.entries()].map(([id, app]) => [id, getAppInfo(app)]))), @@ -310,6 +319,10 @@ export class ApplicationService { distinctUntilChanged(), takeUntil(this.stop$) ), + currentActionMenu$: this.currentActionMenu$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), history: this.history, registerMountContext: this.mountContext.registerContext, getUrlForApp: ( @@ -338,6 +351,7 @@ export class ApplicationService { mounters={availableMounters} appStatuses$={applicationStatuses$} setAppLeaveHandler={this.setAppLeaveHandler} + setAppActionMenu={this.setAppActionMenu} setIsMounting={(isMounting) => httpLoadingCount$.next(isMounting ? 1 : 0)} /> ); @@ -346,7 +360,24 @@ export class ApplicationService { } private setAppLeaveHandler = (appId: string, handler: AppLeaveHandler) => { - this.appLeaveHandlers.set(appId, handler); + this.appInternalStates.set(appId, { + ...(this.appInternalStates.get(appId) ?? {}), + leaveHandler: handler, + }); + }; + + private setAppActionMenu = (appId: string, mount: MountPoint) => { + this.appInternalStates.set(appId, { + ...(this.appInternalStates.get(appId) ?? {}), + actionMenu: mount, + }); + this.refreshCurrentActionMenu(); + }; + + private refreshCurrentActionMenu = () => { + const appId = this.currentAppId$.getValue(); + const currentActionMenu = appId ? this.appInternalStates.get(appId)?.actionMenu : undefined; + this.currentActionMenu$.next(currentActionMenu); }; private async shouldNavigate(overlays: OverlayStart): Promise { @@ -354,7 +385,7 @@ export class ApplicationService { if (currentAppId === undefined) { return true; } - const action = getLeaveAction(this.appLeaveHandlers.get(currentAppId)); + const action = getLeaveAction(this.appInternalStates.get(currentAppId)?.leaveHandler); if (isConfirmAction(action)) { const confirmed = await overlays.openConfirm(action.text, { title: action.title, @@ -372,7 +403,7 @@ export class ApplicationService { if (currentAppId === undefined) { return; } - const action = getLeaveAction(this.appLeaveHandlers.get(currentAppId)); + const action = getLeaveAction(this.appInternalStates.get(currentAppId)?.leaveHandler); if (isConfirmAction(action)) { event.preventDefault(); // some browsers accept a string return value being the message displayed @@ -383,6 +414,7 @@ export class ApplicationService { public stop() { this.stop$.next(); this.currentAppId$.complete(); + this.currentActionMenu$.complete(); this.statusUpdaters$.complete(); this.subscriptions.forEach((sub) => sub.unsubscribe()); window.removeEventListener('beforeunload', this.onBeforeUnload); diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx index b0419d276dfa1..0f434a951af30 100644 --- a/src/core/public/application/integration_tests/application_service.test.tsx +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -30,6 +30,8 @@ import { MockLifecycle } from '../test_types'; import { overlayServiceMock } from '../../overlays/overlay_service.mock'; import { AppMountParameters } from '../types'; import { ScopedHistory } from '../scoped_history'; +import { Observable } from 'rxjs'; +import { MountPoint } from 'kibana/public'; const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); @@ -309,4 +311,151 @@ describe('ApplicationService', () => { expect(history.entries[1].pathname).toEqual('/app/app1'); }); }); + + describe('registering action menus', () => { + const getValue = (obs: Observable): Promise => { + return obs.pipe(take(1)).toPromise(); + }; + + const mounter1: MountPoint = () => () => undefined; + const mounter2: MountPoint = () => () => undefined; + + it('updates the observable value when an application is mounted', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + setHeaderActionMenu(mounter1); + return () => undefined; + }, + }); + + const { navigateToApp, getComponent, currentActionMenu$ } = await service.start(startDeps); + update = createRenderer(getComponent()); + + expect(await getValue(currentActionMenu$)).toBeUndefined(); + + await act(async () => { + await navigateToApp('app1'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter1); + }); + + it('updates the observable value when switching application', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + setHeaderActionMenu(mounter1); + return () => undefined; + }, + }); + register(Symbol(), { + id: 'app2', + title: 'App2', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + setHeaderActionMenu(mounter2); + return () => undefined; + }, + }); + + const { navigateToApp, getComponent, currentActionMenu$ } = await service.start(startDeps); + update = createRenderer(getComponent()); + + await act(async () => { + await navigateToApp('app1'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter1); + + await act(async () => { + await navigateToApp('app2'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter2); + }); + + it('updates the observable value to undefined when switching to an application without action menu', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + setHeaderActionMenu(mounter1); + return () => undefined; + }, + }); + register(Symbol(), { + id: 'app2', + title: 'App2', + mount: async ({}: AppMountParameters) => { + return () => undefined; + }, + }); + + const { navigateToApp, getComponent, currentActionMenu$ } = await service.start(startDeps); + update = createRenderer(getComponent()); + + await act(async () => { + await navigateToApp('app1'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter1); + + await act(async () => { + await navigateToApp('app2'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBeUndefined(); + }); + + it('allow applications to call `setHeaderActionMenu` multiple times', async () => { + const { register } = service.setup(setupDeps); + + let resolveMount: () => void; + const promise = new Promise((resolve) => { + resolveMount = resolve; + }); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + setHeaderActionMenu(mounter1); + promise.then(() => { + setHeaderActionMenu(mounter2); + }); + return () => undefined; + }, + }); + + const { navigateToApp, getComponent, currentActionMenu$ } = await service.start(startDeps); + update = createRenderer(getComponent()); + + await act(async () => { + await navigateToApp('app1'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter1); + + await act(async () => { + resolveMount(); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter2); + }); + }); }); diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index f992e121437a9..6408b8123365e 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -59,6 +59,7 @@ describe('AppRouter', () => { mounters={mockMountersToMounters()} appStatuses$={mountersToAppStatus$()} setAppLeaveHandler={noop} + setAppActionMenu={noop} setIsMounting={noop} /> ); diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 0fe97431b1569..991bbd200591f 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -21,6 +21,7 @@ import { Observable } from 'rxjs'; import { History } from 'history'; import { RecursiveReadonly } from '@kbn/utility-types'; +import { MountPoint } from '../types'; import { Capabilities } from './capabilities'; import { ChromeStart } from '../chrome'; import { IContextProvider } from '../context'; @@ -495,6 +496,18 @@ export interface AppMountParameters { * ``` */ onAppLeave: (handler: AppLeaveHandler) => void; + + /** + * A function that can be used to set the mount point used to populate the application action container + * in the chrome header. + * + * Calling this multiple time will erase the current content of the action menu with the mount from the latest call. + * + * Calling this after the application has been unmounted will have no effect. + * + * @param actionMount + */ + setHeaderActionMenu: (menuMount: MountPoint) => void; } /** @@ -820,6 +833,14 @@ export interface InternalApplicationStart extends Omit; + /** * The global history instance, exposed only to Core. Undefined when rendering a legacy application. * @internal diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index a94313dd53abb..e26fe7e59fd04 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -29,6 +29,7 @@ import { ScopedHistory } from '../scoped_history'; describe('AppContainer', () => { const appId = 'someApp'; const setAppLeaveHandler = jest.fn(); + const setAppActionMenu = jest.fn(); const setIsMounting = jest.fn(); beforeEach(() => { @@ -76,6 +77,7 @@ describe('AppContainer', () => { appStatus={AppStatus.inaccessible} mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} + setAppActionMenu={setAppActionMenu} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location @@ -116,6 +118,7 @@ describe('AppContainer', () => { appStatus={AppStatus.accessible} mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} + setAppActionMenu={setAppActionMenu} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location @@ -158,6 +161,7 @@ describe('AppContainer', () => { appStatus={AppStatus.accessible} mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} + setAppActionMenu={setAppActionMenu} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index 332c31c64b6ba..17f16e027365a 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -25,8 +25,9 @@ import React, { useState, MutableRefObject, } from 'react'; - import { EuiLoadingSpinner } from '@elastic/eui'; + +import type { MountPoint } from '../../types'; import { AppLeaveHandler, AppStatus, AppUnmount, Mounter } from '../types'; import { AppNotFound } from './app_not_found_screen'; import { ScopedHistory } from '../scoped_history'; @@ -39,6 +40,7 @@ interface Props { mounter?: Mounter; appStatus: AppStatus; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; + setAppActionMenu: (appId: string, mount: MountPoint) => void; createScopedHistory: (appUrl: string) => ScopedHistory; setIsMounting: (isMounting: boolean) => void; } @@ -48,6 +50,7 @@ export const AppContainer: FunctionComponent = ({ appId, appPath, setAppLeaveHandler, + setAppActionMenu, createScopedHistory, appStatus, setIsMounting, @@ -84,6 +87,7 @@ export const AppContainer: FunctionComponent = ({ history: createScopedHistory(appPath), element: elementRef.current!, onAppLeave: (handler) => setAppLeaveHandler(appId, handler), + setHeaderActionMenu: (menuMount) => setAppActionMenu(appId, menuMount), })) || null; } catch (e) { // TODO: add error UI @@ -98,7 +102,16 @@ export const AppContainer: FunctionComponent = ({ mount(); return unmount; - }, [appId, appStatus, mounter, createScopedHistory, setAppLeaveHandler, appPath, setIsMounting]); + }, [ + appId, + appStatus, + mounter, + createScopedHistory, + setAppLeaveHandler, + setAppActionMenu, + appPath, + setIsMounting, + ]); return ( diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index f2d2d1e6587ac..0a198dc451159 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -23,6 +23,7 @@ import { History } from 'history'; import { Observable } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; +import type { MountPoint } from '../../types'; import { AppLeaveHandler, AppStatus, Mounter } from '../types'; import { AppContainer } from './app_container'; import { ScopedHistory } from '../scoped_history'; @@ -32,6 +33,7 @@ interface Props { history: History; appStatuses$: Observable>; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; + setAppActionMenu: (appId: string, mount: MountPoint) => void; setIsMounting: (isMounting: boolean) => void; } @@ -43,6 +45,7 @@ export const AppRouter: FunctionComponent = ({ history, mounters, setAppLeaveHandler, + setAppActionMenu, appStatuses$, setIsMounting, }) => { @@ -69,7 +72,7 @@ export const AppRouter: FunctionComponent = ({ appPath={url} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ appId, mounter, setAppLeaveHandler, setIsMounting }} + {...{ appId, mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting }} /> )} /> @@ -94,7 +97,7 @@ export const AppRouter: FunctionComponent = ({ appId={id} appStatus={appStatuses.get(id) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ mounter, setAppLeaveHandler, setIsMounting }} + {...{ mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting }} /> ); }} diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 2f7f6fae94436..aefcb830d40bf 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -166,6 +166,7 @@ function createAppMountParametersMock(appBasePath = '') { element: document.createElement('div'), history, onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), }; return params; diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index 37787ffbde4fc..3bc9b2a526e27 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -191,6 +191,7 @@ export const legacyAppRegister = (app: App) => { appRoute ), onAppLeave: () => undefined, + setHeaderActionMenu: () => undefined, }; const unmount = isAppMountDeprecated(app.mount) ? await app.mount({ core: npStart.core }, params) diff --git a/src/plugins/dev_tools/public/application.tsx b/src/plugins/dev_tools/public/application.tsx index 46f09a8ebb879..d3a54627b0240 100644 --- a/src/plugins/dev_tools/public/application.tsx +++ b/src/plugins/dev_tools/public/application.tsx @@ -90,6 +90,7 @@ function DevToolsWrapper({ devTools, activeDevTool, updateRoute }: DevToolsWrapp element, appBasePath: '', onAppLeave: () => undefined, + setHeaderActionMenu: () => undefined, // TODO: adapt to use Core's ScopedHistory history: {} as any, }; diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index ff59d46de758d..8ca3cfaadd672 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -100,6 +100,7 @@ export class MlPlugin implements Plugin { element: params.element, appBasePath: params.appBasePath, onAppLeave: params.onAppLeave, + setHeaderActionMenu: params.setHeaderActionMenu, history: params.history, } ); diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts index 37b97a8472310..c41bd43872bee 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.ts +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.ts @@ -54,6 +54,7 @@ describe('accountManagementApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), }); diff --git a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts index 0e262e9089842..eafad74d2f0d8 100644 --- a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts +++ b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts @@ -48,6 +48,7 @@ describe('accessAgreementApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), }); diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts index c5b9245414630..e6723085460f8 100644 --- a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts @@ -54,6 +54,7 @@ describe('captureURLApp', () => { element: document.createElement('div'), appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: (scopedHistoryMock.create() as unknown) as ScopedHistory, }); diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts index 15d55136b405d..86a5d21f1b233 100644 --- a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts @@ -46,6 +46,7 @@ describe('loggedOutApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), }); diff --git a/x-pack/plugins/security/public/authentication/login/login_app.test.ts b/x-pack/plugins/security/public/authentication/login/login_app.test.ts index a6e5a321ef6ec..5ae8afab9de23 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.test.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts @@ -51,6 +51,7 @@ describe('loginApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), }); diff --git a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts index 46b1083a2ed14..b7bfdf492305e 100644 --- a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts @@ -52,6 +52,7 @@ describe('logoutApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), }); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts index 0eed1382c270b..6e0e06dd3dc44 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts @@ -53,6 +53,7 @@ describe('overwrittenSessionApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), history: scopedHistoryMock.create(), }); From 45567720beae73dfc5b438a13875ed932a6c5965 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 19 Aug 2020 14:31:55 +0200 Subject: [PATCH 02/14] allow to remove the current menu by calling handler with undefined --- .../application/application_service.tsx | 2 +- .../application_service.test.tsx | 38 +++++++++++++++++++ src/core/public/application/types.ts | 27 +++++++++++-- .../public/application/ui/app_container.tsx | 2 +- src/core/public/application/ui/app_router.tsx | 2 +- 5 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 7a220972f0393..df0f74c1914e9 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -366,7 +366,7 @@ export class ApplicationService { }); }; - private setAppActionMenu = (appId: string, mount: MountPoint) => { + private setAppActionMenu = (appId: string, mount: MountPoint | undefined) => { this.appInternalStates.set(appId, { ...(this.appInternalStates.get(appId) ?? {}), actionMenu: mount, diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx index 0f434a951af30..9eafddd6a61fe 100644 --- a/src/core/public/application/integration_tests/application_service.test.tsx +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -457,5 +457,43 @@ describe('ApplicationService', () => { expect(await getValue(currentActionMenu$)).toBe(mounter2); }); + + it('allow applications to unset the current menu', async () => { + const { register } = service.setup(setupDeps); + + let resolveMount: () => void; + const promise = new Promise((resolve) => { + resolveMount = resolve; + }); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: async ({ setHeaderActionMenu }: AppMountParameters) => { + setHeaderActionMenu(mounter1); + promise.then(() => { + setHeaderActionMenu(undefined); + }); + return () => undefined; + }, + }); + + const { navigateToApp, getComponent, currentActionMenu$ } = await service.start(startDeps); + update = createRenderer(getComponent()); + + await act(async () => { + await navigateToApp('app1'); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBe(mounter1); + + await act(async () => { + resolveMount(); + await flushPromises(); + }); + + expect(await getValue(currentActionMenu$)).toBeUndefined(); + }); }); }); diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 991bbd200591f..320416a8c2379 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -501,13 +501,32 @@ export interface AppMountParameters { * A function that can be used to set the mount point used to populate the application action container * in the chrome header. * - * Calling this multiple time will erase the current content of the action menu with the mount from the latest call. + * Calling the handler multiple time will erase the current content of the action menu with the mount from the latest call. + * Calling the handler with `undefined` will unmount the current mount point. + * Calling the handler after the application has been unmounted will have no effect. * - * Calling this after the application has been unmounted will have no effect. + * @example + * + * ```ts + * // application.tsx + * import React from 'react'; + * import ReactDOM from 'react-dom'; + * import { BrowserRouter, Route } from 'react-router-dom'; * - * @param actionMount + * import { CoreStart, AppMountParameters } from 'src/core/public'; + * import { MyPluginDepsStart } from './plugin'; + * + * export renderApp = ({ element, history, setHeaderActionMenu }: AppMountParameters) => { + * const { renderApp } = await import('./application'); + * const { renderActionMenu } = await import('./action_menu'); + * setHeaderActionMenu((element) => { + * return renderActionMenu(element); + * }) + * return renderApp({ element, history }); + * } + * ``` */ - setHeaderActionMenu: (menuMount: MountPoint) => void; + setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; } /** diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index 17f16e027365a..f668cf851da55 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -40,7 +40,7 @@ interface Props { mounter?: Mounter; appStatus: AppStatus; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; - setAppActionMenu: (appId: string, mount: MountPoint) => void; + setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void; createScopedHistory: (appUrl: string) => ScopedHistory; setIsMounting: (isMounting: boolean) => void; } diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 0a198dc451159..6919fb7344aa6 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -33,7 +33,7 @@ interface Props { history: History; appStatuses$: Observable>; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; - setAppActionMenu: (appId: string, mount: MountPoint) => void; + setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void; setIsMounting: (isMounting: boolean) => void; } From 96d3494658417253fd601a12d43d5c07d0f420f3 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 19 Aug 2020 14:36:54 +0200 Subject: [PATCH 03/14] update generated doc --- ...a-plugin-core-public.appmountparameters.md | 1 + ....appmountparameters.setheaderactionmenu.md | 39 +++++++++++++++++++ ...kibana-plugin-core-public.doclinksstart.md | 2 +- src/core/public/public.api.md | 1 + 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountparameters.md b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.md index de79fc8281c45..f6c57603bedde 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appmountparameters.md +++ b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.md @@ -19,4 +19,5 @@ export interface AppMountParameters | [element](./kibana-plugin-core-public.appmountparameters.element.md) | HTMLElement | The container element to render the application into. | | [history](./kibana-plugin-core-public.appmountparameters.history.md) | ScopedHistory<HistoryLocationState> | A scoped history instance for your application. Should be used to wire up your applications Router. | | [onAppLeave](./kibana-plugin-core-public.appmountparameters.onappleave.md) | (handler: AppLeaveHandler) => void | A function that can be used to register a handler that will be called when the user is leaving the current application, allowing to prompt a confirmation message before actually changing the page.This will be called either when the user goes to another application, or when trying to close the tab or manually changing the url. | +| [setHeaderActionMenu](./kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md) | (menuMount: MountPoint | undefined) => void | A function that can be used to set the mount point used to populate the application action container in the chrome header.Calling the handler multiple time will erase the current content of the action menu with the mount from the latest call. Calling the handler with undefined will unmount the current mount point. Calling the handler after the application has been unmounted will have no effect. | diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md new file mode 100644 index 0000000000000..ca9cee64bb1f9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md @@ -0,0 +1,39 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) > [setHeaderActionMenu](./kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md) + +## AppMountParameters.setHeaderActionMenu property + +A function that can be used to set the mount point used to populate the application action container in the chrome header. + +Calling the handler multiple time will erase the current content of the action menu with the mount from the latest call. Calling the handler with `undefined` will unmount the current mount point. Calling the handler after the application has been unmounted will have no effect. + +Signature: + +```typescript +setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; +``` + +## Example + + +```ts +// application.tsx +import React from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter, Route } from 'react-router-dom'; + +import { CoreStart, AppMountParameters } from 'src/core/public'; +import { MyPluginDepsStart } from './plugin'; + +export renderApp = ({ element, history, setHeaderActionMenu }: AppMountParameters) => { + const { renderApp } = await import('./application'); + const { renderActionMenu } = await import('./action_menu'); + setHeaderActionMenu((element) => { + return renderActionMenu(element); + }) + return renderApp({ element, history }); +} + +``` + diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index fa2d9090e3159..4644dc432bc9a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly drilldowns: string;
};
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly drilldowns: string;
};
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
readonly visualize: Record<string, string>;
} | | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 17626418cbeeb..fcd56b45b56b9 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -165,6 +165,7 @@ export interface AppMountParameters { element: HTMLElement; history: ScopedHistory; onAppLeave: (handler: AppLeaveHandler) => void; + setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; } // @public From eb70d9ce6fa91ca0dbe2804f40d96d979c0777da Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 19 Aug 2020 14:59:42 +0200 Subject: [PATCH 04/14] updating snapshots --- .../application_service.test.ts.snap | 1 + .../header/__snapshots__/header.test.tsx.snap | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/core/public/application/__snapshots__/application_service.test.ts.snap b/src/core/public/application/__snapshots__/application_service.test.ts.snap index c63a22170c4f6..a6c9eb27e338a 100644 --- a/src/core/public/application/__snapshots__/application_service.test.ts.snap +++ b/src/core/public/application/__snapshots__/application_service.test.ts.snap @@ -80,6 +80,7 @@ exports[`#start() getComponent returns renderable JSX tree 1`] = ` } } mounters={Map {}} + setAppActionMenu={[Function]} setAppLeaveHandler={[Function]} setIsMounting={[Function]} /> diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index a1920154d9f71..1d5a017702491 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -29,6 +29,15 @@ exports[`Header renders 1`] = ` "management": Object {}, "navLinks": Object {}, }, + "currentActionMenu$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, "currentAppId$": Observable { "_isScalar": false, "source": Subject { @@ -641,6 +650,15 @@ exports[`Header renders 2`] = ` "management": Object {}, "navLinks": Object {}, }, + "currentActionMenu$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, "currentAppId$": Observable { "_isScalar": false, "source": Subject { @@ -4852,6 +4870,15 @@ exports[`Header renders 3`] = ` "management": Object {}, "navLinks": Object {}, }, + "currentActionMenu$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, "currentAppId$": Observable { "_isScalar": false, "source": Subject { @@ -9833,6 +9860,15 @@ exports[`Header renders 4`] = ` "management": Object {}, "navLinks": Object {}, }, + "currentActionMenu$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, "currentAppId$": Observable { "_isScalar": false, "source": Subject { From e595519e0c5c1f17a749ef2e01bc121c0f9367f3 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 19 Aug 2020 15:18:15 +0200 Subject: [PATCH 05/14] fix legacy tests --- src/legacy/ui/public/new_platform/new_platform.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/legacy/ui/public/new_platform/new_platform.test.ts b/src/legacy/ui/public/new_platform/new_platform.test.ts index d515c348ca440..a192ade4d9c89 100644 --- a/src/legacy/ui/public/new_platform/new_platform.test.ts +++ b/src/legacy/ui/public/new_platform/new_platform.test.ts @@ -66,6 +66,7 @@ describe('ui/new_platform', () => { element: expect.any(HTMLElement), appBasePath: '/test/base/path/app/test', onAppLeave: expect.any(Function), + setHeaderActionMenu: expect.any(Function), history: historyMock, }); }); @@ -100,6 +101,7 @@ describe('ui/new_platform', () => { element: expect.any(HTMLElement), appBasePath: '/test/base/path/app/test', onAppLeave: expect.any(Function), + setHeaderActionMenu: expect.any(Function), history: historyMock, }); }); From d3a2c8ee7849d93a8dc52df3841fa9b93d02abb3 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Thu, 20 Aug 2020 11:18:57 +0200 Subject: [PATCH 06/14] call renderApp with params --- x-pack/plugins/ml/public/plugin.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 8ca3cfaadd672..bb37bdbd71edf 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -96,13 +96,7 @@ export class MlPlugin implements Plugin { uiActions: pluginsStart.uiActions, kibanaVersion, }, - { - element: params.element, - appBasePath: params.appBasePath, - onAppLeave: params.onAppLeave, - setHeaderActionMenu: params.setHeaderActionMenu, - history: params.history, - } + params ); }, }); From ab75d276d84936358d3a1bbeafc6cf5a1ff60f27 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Mon, 24 Aug 2020 09:20:24 +0200 Subject: [PATCH 07/14] rename toMountPoint component file for consistency --- src/plugins/kibana_react/public/util/index.ts | 2 +- .../public/util/{react_mount.tsx => to_mount_point.tsx} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/plugins/kibana_react/public/util/{react_mount.tsx => to_mount_point.tsx} (100%) diff --git a/src/plugins/kibana_react/public/util/index.ts b/src/plugins/kibana_react/public/util/index.ts index 71a281dbdaad3..947194f00d53d 100644 --- a/src/plugins/kibana_react/public/util/index.ts +++ b/src/plugins/kibana_react/public/util/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from './react_mount'; +export * from './to_mount_point'; diff --git a/src/plugins/kibana_react/public/util/react_mount.tsx b/src/plugins/kibana_react/public/util/to_mount_point.tsx similarity index 100% rename from src/plugins/kibana_react/public/util/react_mount.tsx rename to src/plugins/kibana_react/public/util/to_mount_point.tsx From 20685b314f6c55c2a593e00b7ec902de86c95e77 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Mon, 24 Aug 2020 10:30:57 +0200 Subject: [PATCH 08/14] add the MountPointPortal utility component --- src/plugins/kibana_react/public/index.ts | 2 +- src/plugins/kibana_react/public/util/index.ts | 3 +- .../public/util/mount_point_portal.test.tsx | 190 ++++++++++++++++++ .../public/util/mount_point_portal.tsx | 57 ++++++ 4 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 src/plugins/kibana_react/public/util/mount_point_portal.test.tsx create mode 100644 src/plugins/kibana_react/public/util/mount_point_portal.tsx diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index 34140703fd8ae..9a9486da892e4 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -32,7 +32,7 @@ export * from './notifications'; export { Markdown, MarkdownSimple } from './markdown'; export { reactToUiComponent, uiToReactComponent } from './adapters'; export { useUrlTracker } from './use_url_tracker'; -export { toMountPoint } from './util'; +export { toMountPoint, MountPointPortal } from './util'; export { RedirectAppLinks } from './app_links'; /** dummy plugin, we just want kibanaReact to have its own bundle */ diff --git a/src/plugins/kibana_react/public/util/index.ts b/src/plugins/kibana_react/public/util/index.ts index 947194f00d53d..a6f3f87535f46 100644 --- a/src/plugins/kibana_react/public/util/index.ts +++ b/src/plugins/kibana_react/public/util/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export * from './to_mount_point'; +export { toMountPoint } from './to_mount_point'; +export { MountPointPortal } from './mount_point_portal'; diff --git a/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx b/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx new file mode 100644 index 0000000000000..4cafaf912889a --- /dev/null +++ b/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx @@ -0,0 +1,190 @@ +/* + * 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 React, { FC } from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { MountPoint, UnmountCallback } from 'kibana/public'; +import { MountPointPortal } from './mount_point_portal'; +import { act } from 'react-dom/test-utils'; + +describe('MountPointPortal', () => { + let portalTarget: HTMLElement; + let mountPoint: MountPoint; + let setMountPoint: jest.Mock<(mountPoint: MountPoint) => void>; + let dom: ReactWrapper; + + const refresh = () => { + new Promise(async (resolve) => { + if (dom) { + act(() => { + dom.update(); + }); + } + setImmediate(() => resolve(dom)); // flushes any pending promises + }); + }; + + beforeEach(() => { + portalTarget = document.createElement('div'); + document.body.append(portalTarget); + setMountPoint = jest.fn().mockImplementation((mp) => (mountPoint = mp)); + }); + + afterEach(() => { + if (portalTarget) { + portalTarget.remove(); + } + }); + + it('calls the provided `setMountPoint` during render', async () => { + dom = mount( + + portal content + + ); + + await refresh(); + + expect(setMountPoint).toHaveBeenCalledTimes(1); + }); + + it('renders the portal content when calling the mountPoint ', async () => { + dom = mount( + + portal content + + ); + + await refresh(); + + expect(mountPoint).toBeDefined(); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(portalTarget.textContent).toBe('portal content'); + }); + + it('cleanup the portal content when the component is unmounted', async () => { + dom = mount( + + portal content + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(portalTarget.textContent).toBe('portal content'); + + dom.unmount(); + + await refresh(); + + expect(portalTarget.textContent).toBe(''); + }); + + it('cleanup the portal content when unmounting the MountPoint from outside', async () => { + dom = mount( + + portal content + + ); + + let unmount: UnmountCallback; + act(() => { + unmount = mountPoint(portalTarget); + }); + + await refresh(); + + expect(portalTarget.textContent).toBe('portal content'); + + act(() => { + unmount(); + }); + + await refresh(); + + expect(portalTarget.textContent).toBe(''); + }); + + it('updates the content of the portal element when the content of MountPointPortal changes', async () => { + const Wrapper: FC<{ + setMount: (mountPoint: MountPoint) => void; + portalContent: string; + }> = ({ setMount, portalContent }) => { + return ( + +
{portalContent}
+
+ ); + }; + + dom = mount(); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(portalTarget.textContent).toBe('before'); + + dom.setProps({ + portalContent: 'after', + }); + + await refresh(); + + expect(portalTarget.textContent).toBe('after'); + }); + + it('cleanup the previous portal content when setMountPoint changes', async () => { + dom = mount( + + portal content + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(portalTarget.textContent).toBe('portal content'); + + const newSetMountPoint = jest.fn(); + + dom.setProps({ + setMountPoint: newSetMountPoint, + }); + + await refresh(); + + expect(portalTarget.textContent).toBe(''); + }); +}); diff --git a/src/plugins/kibana_react/public/util/mount_point_portal.tsx b/src/plugins/kibana_react/public/util/mount_point_portal.tsx new file mode 100644 index 0000000000000..2cab376db0248 --- /dev/null +++ b/src/plugins/kibana_react/public/util/mount_point_portal.tsx @@ -0,0 +1,57 @@ +/* + * 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 React, { useRef, useEffect, useState } from 'react'; +import ReactDOM from 'react-dom'; +import { MountPoint } from 'kibana/public'; + +interface MountPointPortalProps { + setMountPoint: (mountPoint: MountPoint) => void; +} + +/** + * Utility component to portal a part of a react application into the provided `MountPoint`. + */ +export const MountPointPortal: React.FC = ({ children, setMountPoint }) => { + // state used to force re-renders when the element changes + const [shouldRender, setShouldRender] = useState(false); + const el = useRef(); + + useEffect(() => { + setMountPoint((element) => { + el.current = element; + setShouldRender(true); + return () => { + setShouldRender(false); + el.current = undefined; + }; + }); + + return () => { + setShouldRender(false); + el.current = undefined; + }; + }, [setMountPoint]); + + if (shouldRender && el.current) { + return ReactDOM.createPortal(children, el.current); + } else { + return null; + } +}; From 6405ab5e937dc2ff198bd357d629601fe75768cd Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Mon, 24 Aug 2020 11:08:36 +0200 Subject: [PATCH 09/14] adapt TopNavMenu to add optional `setMenuMountPoint` prop --- .../public/top_nav_menu/_index.scss | 16 ++--- .../public/top_nav_menu/top_nav_menu.test.tsx | 60 ++++++++++++++++++- .../public/top_nav_menu/top_nav_menu.tsx | 35 +++++++++-- 3 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index a6ddf7a8b4264..283a0b45d2331 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,15 +1,15 @@ .kbnTopNavMenu__wrapper { z-index: 5; +} - .kbnTopNavMenu { - padding: $euiSizeS 0; +.kbnTopNavMenu { + padding: $euiSizeS 0; - .kbnTopNavItemEmphasized { - padding: 0 $euiSizeS; - } + .kbnTopNavItemEmphasized { + padding: 0 $euiSizeS; } +} - .kbnTopNavMenu-isFullScreen { - padding: 0; - } +.kbnTopNavMenu-isFullScreen { + padding: 0; } diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 46384fb3f27d5..f21e5680e8f61 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -18,9 +18,12 @@ */ import React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { MountPoint } from 'kibana/public'; import { TopNavMenu } from './top_nav_menu'; import { TopNavMenuData } from './top_nav_menu_data'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; const dataShim = { ui: { @@ -109,4 +112,59 @@ describe('TopNavMenu', () => { expect(component.find('.kbnTopNavMenu').length).toBe(1); expect(component.find('.myCoolClass').length).toBeTruthy(); }); + + describe('when setMenuMountPoint is provided', () => { + let portalTarget: HTMLElement; + let mountPoint: MountPoint; + let setMountPoint: jest.Mock<(mountPoint: MountPoint) => void>; + let dom: ReactWrapper; + + const refresh = () => { + new Promise(async (resolve) => { + if (dom) { + act(() => { + dom.update(); + }); + } + setImmediate(() => resolve(dom)); // flushes any pending promises + }); + }; + + beforeEach(() => { + portalTarget = document.createElement('div'); + document.body.append(portalTarget); + setMountPoint = jest.fn().mockImplementation((mp) => (mountPoint = mp)); + }); + + afterEach(() => { + if (portalTarget) { + portalTarget.remove(); + } + }); + + it('mounts the menu inside the provided mountPoint', async () => { + const component = mountWithIntl( + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(component.find(WRAPPER_SELECTOR).length).toBe(1); + expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(1); + + // menu is rendered outside of the component + expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); + expect(portalTarget.getElementsByTagName('BUTTON').length).toBe(menuItems.length); + }); + }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 2cfca332effb0..b09b111cebe10 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -18,13 +18,14 @@ */ import React, { ReactElement } from 'react'; - import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; - import classNames from 'classnames'; + +import { MountPoint } from '../../../../core/public'; +import { MountPointPortal } from '../../../kibana_react/public'; +import { StatefulSearchBarProps, DataPublicPluginStart } from '../../../data/public'; import { TopNavMenuData } from './top_nav_menu_data'; import { TopNavMenuItem } from './top_nav_menu_item'; -import { StatefulSearchBarProps, DataPublicPluginStart } from '../../../data/public'; export type TopNavMenuProps = StatefulSearchBarProps & { config?: TopNavMenuData[]; @@ -35,6 +36,25 @@ export type TopNavMenuProps = StatefulSearchBarProps & { showFilterBar?: boolean; data?: DataPublicPluginStart; className?: string; + /** + * If provided, the menu part of the component will be rendered as a portal inside the given mount point. + * + * This is meant to be used with the `setHeaderActionMenu` core API. + * + * @example + * ```ts + * export renderApp = ({ element, history, setHeaderActionMenu }: AppMountParameters) => { + * const topNavConfig = ...; // TopNavMenuProps + * return ( + * + * + * + * + * ) + * } + * ``` + */ + setMenuMountPoint?: (menuMount: MountPoint | undefined) => void; }; /* @@ -92,10 +112,17 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { } function renderLayout() { + const { setMenuMountPoint } = props; const className = classNames('kbnTopNavMenu', props.className); return ( - {renderMenu(className)} + {setMenuMountPoint ? ( + + {renderMenu(className)} + + ) : ( + renderMenu(className) + )} {renderSearchBar()} ); From 70dad1d54efef2cb3e05a93c2de5808980850d53 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Mon, 24 Aug 2020 11:21:45 +0200 Subject: [PATCH 10/14] add kibanaReact as required bundle. --- src/plugins/navigation/kibana.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/plugins/navigation/kibana.json b/src/plugins/navigation/kibana.json index 000d5acf2635f..85d2049a34be0 100644 --- a/src/plugins/navigation/kibana.json +++ b/src/plugins/navigation/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": ["data"] -} \ No newline at end of file + "requiredPlugins": ["data"], + "requiredBundles": ["kibanaReact"] +} From 0d02a2c25fbb4be3ed7c6bfccd4d86b2ab75e086 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 26 Aug 2020 10:00:56 +0200 Subject: [PATCH 11/14] use innerHTML instead of textContent for portal tests --- .../public/util/mount_point_portal.test.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx b/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx index 4cafaf912889a..55a0e305e9c1f 100644 --- a/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx +++ b/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx @@ -81,7 +81,7 @@ describe('MountPointPortal', () => { await refresh(); - expect(portalTarget.textContent).toBe('portal content'); + expect(portalTarget.innerHTML).toBe('portal content'); }); it('cleanup the portal content when the component is unmounted', async () => { @@ -97,13 +97,13 @@ describe('MountPointPortal', () => { await refresh(); - expect(portalTarget.textContent).toBe('portal content'); + expect(portalTarget.innerHTML).toBe('portal content'); dom.unmount(); await refresh(); - expect(portalTarget.textContent).toBe(''); + expect(portalTarget.innerHTML).toBe(''); }); it('cleanup the portal content when unmounting the MountPoint from outside', async () => { @@ -120,7 +120,7 @@ describe('MountPointPortal', () => { await refresh(); - expect(portalTarget.textContent).toBe('portal content'); + expect(portalTarget.innerHTML).toBe('portal content'); act(() => { unmount(); @@ -128,7 +128,7 @@ describe('MountPointPortal', () => { await refresh(); - expect(portalTarget.textContent).toBe(''); + expect(portalTarget.innerHTML).toBe(''); }); it('updates the content of the portal element when the content of MountPointPortal changes', async () => { @@ -151,7 +151,7 @@ describe('MountPointPortal', () => { await refresh(); - expect(portalTarget.textContent).toBe('before'); + expect(portalTarget.innerHTML).toBe('
before
'); dom.setProps({ portalContent: 'after', @@ -159,7 +159,7 @@ describe('MountPointPortal', () => { await refresh(); - expect(portalTarget.textContent).toBe('after'); + expect(portalTarget.innerHTML).toBe('
after
'); }); it('cleanup the previous portal content when setMountPoint changes', async () => { @@ -175,7 +175,7 @@ describe('MountPointPortal', () => { await refresh(); - expect(portalTarget.textContent).toBe('portal content'); + expect(portalTarget.innerHTML).toBe('portal content'); const newSetMountPoint = jest.fn(); @@ -185,6 +185,6 @@ describe('MountPointPortal', () => { await refresh(); - expect(portalTarget.textContent).toBe(''); + expect(portalTarget.innerHTML).toBe(''); }); }); From 530e03144924fd39d0139dd610e61a4c353ec4d3 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 26 Aug 2020 10:39:04 +0200 Subject: [PATCH 12/14] add error boundaries to portal component --- .../public/util/mount_point_portal.test.tsx | 20 +++++++++++ .../public/util/mount_point_portal.tsx | 35 +++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx b/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx index 55a0e305e9c1f..c13b8eae26221 100644 --- a/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx +++ b/src/plugins/kibana_react/public/util/mount_point_portal.test.tsx @@ -187,4 +187,24 @@ describe('MountPointPortal', () => { expect(portalTarget.innerHTML).toBe(''); }); + + it('intercepts errors and display an error message', async () => { + const CrashTest = () => { + throw new Error('crash'); + }; + + dom = mount( + + + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + expect(portalTarget.innerHTML).toBe('

Error rendering portal content

'); + }); }); diff --git a/src/plugins/kibana_react/public/util/mount_point_portal.tsx b/src/plugins/kibana_react/public/util/mount_point_portal.tsx index 2cab376db0248..b762fba88791e 100644 --- a/src/plugins/kibana_react/public/util/mount_point_portal.tsx +++ b/src/plugins/kibana_react/public/util/mount_point_portal.tsx @@ -17,7 +17,8 @@ * under the License. */ -import React, { useRef, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import React, { useRef, useEffect, useState, Component } from 'react'; import ReactDOM from 'react-dom'; import { MountPoint } from 'kibana/public'; @@ -50,8 +51,38 @@ export const MountPointPortal: React.FC = ({ children, se }, [setMountPoint]); if (shouldRender && el.current) { - return ReactDOM.createPortal(children, el.current); + return ReactDOM.createPortal( + {children}, + el.current + ); } else { return null; } }; + +class MountPointPortalErrorBoundary extends Component<{}, { error?: any }> { + state = { + error: undefined, + }; + + static getDerivedStateFromError(error: any) { + return { error }; + } + + componentDidCatch() { + // nothing, will just rerender to display the error message + } + + render() { + if (this.state.error) { + return ( +

+ {i18n.translate('kibana-react.mountPointPortal.errorMessage', { + defaultMessage: 'Error rendering portal content', + })} +

+ ); + } + return this.props.children; + } +} From e34f402e868cebe011bca7b0116ae85f3593d9ae Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 26 Aug 2020 10:57:30 +0200 Subject: [PATCH 13/14] improve renderLayout readability --- .../public/top_nav_menu/top_nav_menu.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index b09b111cebe10..d2ca800b06618 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -113,19 +113,25 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { function renderLayout() { const { setMenuMountPoint } = props; - const className = classNames('kbnTopNavMenu', props.className); - return ( - - {setMenuMountPoint ? ( + const menuClassName = classNames('kbnTopNavMenu', props.className); + const wrapperClassName = 'kbnTopNavMenu__wrapper'; + if (setMenuMountPoint) { + return ( + - {renderMenu(className)} + {renderMenu(menuClassName)} - ) : ( - renderMenu(className) - )} - {renderSearchBar()} - - ); + {renderSearchBar()} + + ); + } else { + return ( + + {renderMenu(menuClassName)} + {renderSearchBar()} + + ); + } } return renderLayout(); From 7ee6b10690592db8f6817b580c750f2f1dcc42c0 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 26 Aug 2020 21:49:24 +0200 Subject: [PATCH 14/14] duplicate wrapper in portal mode to avoid altering styles --- .../navigation/public/top_nav_menu/_index.scss | 16 ++++++++-------- .../public/top_nav_menu/top_nav_menu.tsx | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index 283a0b45d2331..a6ddf7a8b4264 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,15 +1,15 @@ .kbnTopNavMenu__wrapper { z-index: 5; -} -.kbnTopNavMenu { - padding: $euiSizeS 0; + .kbnTopNavMenu { + padding: $euiSizeS 0; - .kbnTopNavItemEmphasized { - padding: 0 $euiSizeS; + .kbnTopNavItemEmphasized { + padding: 0 $euiSizeS; + } } -} -.kbnTopNavMenu-isFullScreen { - padding: 0; + .kbnTopNavMenu-isFullScreen { + padding: 0; + } } diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index d2ca800b06618..a1a40b49cc8f0 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -117,12 +117,12 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { const wrapperClassName = 'kbnTopNavMenu__wrapper'; if (setMenuMountPoint) { return ( - + <> - {renderMenu(menuClassName)} + {renderMenu(menuClassName)} - {renderSearchBar()} - + {renderSearchBar()} + ); } else { return (