Skip to content

Commit

Permalink
Add setHeaderActionMenu API to AppMountParameters (elastic#75422)
Browse files Browse the repository at this point in the history
* add `setHeaderActionMenu` to AppMountParameters

* allow to remove the current menu by calling handler with undefined

* update generated doc

* updating snapshots

* fix legacy tests

* call renderApp with params

* rename toMountPoint component file for consistency

* add the MountPointPortal utility component

* adapt TopNavMenu to add optional `setMenuMountPoint` prop

* add kibanaReact as required bundle.

* use innerHTML instead of textContent for portal tests

* add error boundaries to portal component

* improve renderLayout readability

* duplicate wrapper in portal mode to avoid altering styles

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
  • Loading branch information
pgayvallet and elasticmachine committed Sep 1, 2020
1 parent 9fed654 commit 92b97f3
Show file tree
Hide file tree
Showing 31 changed files with 785 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ export interface AppMountParameters<HistoryLocationState = unknown>
| [element](./kibana-plugin-core-public.appmountparameters.element.md) | <code>HTMLElement</code> | The container element to render the application into. |
| [history](./kibana-plugin-core-public.appmountparameters.history.md) | <code>ScopedHistory&lt;HistoryLocationState&gt;</code> | A scoped history instance for your application. Should be used to wire up your applications Router. |
| [onAppLeave](./kibana-plugin-core-public.appmountparameters.onappleave.md) | <code>(handler: AppLeaveHandler) =&gt; void</code> | 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) | <code>(menuMount: MountPoint &#124; undefined) =&gt; void</code> | 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 <code>undefined</code> will unmount the current mount point. Calling the handler after the application has been unmounted will have no effect. |

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) &gt; [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.

<b>Signature:</b>

```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 });
}

```

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/core/public/application/application_service.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -87,6 +88,7 @@ const createInternalStartContractMock = (): jest.Mocked<InternalApplicationStart
applications$: new BehaviorSubject<Map<string, PublicAppInfo | PublicLegacyAppInfo>>(new Map()),
capabilities: capabilitiesServiceMock.createStartContract().capabilities,
currentAppId$: currentAppId$.asObservable(),
currentActionMenu$: new BehaviorSubject<MountPoint | undefined>(undefined),
getComponent: jest.fn(),
getUrlForApp: jest.fn(),
navigateToApp: jest.fn().mockImplementation((appId) => currentAppId$.next(appId)),
Expand Down
42 changes: 37 additions & 5 deletions src/core/public/application/application_service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -90,6 +91,11 @@ interface AppUpdaterWrapper {
updater: AppUpdater;
}

interface AppInternalState {
leaveHandler?: AppLeaveHandler;
actionMenu?: MountPoint;
}

/**
* Service that is responsible for registering new applications.
* @internal
Expand All @@ -98,8 +104,9 @@ export class ApplicationService {
private readonly apps = new Map<string, App<any> | LegacyApp>();
private readonly mounters = new Map<string, Mounter>();
private readonly capabilities = new CapabilitiesService();
private readonly appLeaveHandlers = new Map<string, AppLeaveHandler>();
private readonly appInternalStates = new Map<string, AppInternalState>();
private currentAppId$ = new BehaviorSubject<string | undefined>(undefined);
private currentActionMenu$ = new BehaviorSubject<MountPoint | undefined>(undefined);
private readonly statusUpdaters$ = new BehaviorSubject<Map<symbol, AppUpdaterWrapper>>(new Map());
private readonly subscriptions: Subscription[] = [];
private stop$ = new Subject();
Expand Down Expand Up @@ -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)]))),
Expand All @@ -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: (
Expand Down Expand Up @@ -338,6 +351,7 @@ export class ApplicationService {
mounters={availableMounters}
appStatuses$={applicationStatuses$}
setAppLeaveHandler={this.setAppLeaveHandler}
setAppActionMenu={this.setAppActionMenu}
setIsMounting={(isMounting) => httpLoadingCount$.next(isMounting ? 1 : 0)}
/>
);
Expand All @@ -346,15 +360,32 @@ 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 | undefined) => {
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<boolean> {
const currentAppId = this.currentAppId$.value;
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,
Expand All @@ -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
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -309,4 +311,189 @@ describe('ApplicationService', () => {
expect(history.entries[1].pathname).toEqual('/app/app1');
});
});

describe('registering action menus', () => {
const getValue = (obs: Observable<MountPoint | undefined>): Promise<MountPoint | undefined> => {
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);
});

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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe('AppRouter', () => {
mounters={mockMountersToMounters()}
appStatuses$={mountersToAppStatus$()}
setAppLeaveHandler={noop}
setAppActionMenu={noop}
setIsMounting={noop}
/>
);
Expand Down
Loading

0 comments on commit 92b97f3

Please sign in to comment.