Overlay core service (#34261) (#34717)

* Move ui/flyout to overlay core service

* Remove onClose in parameter (use FlyoutSession instead)

* Fix tests

* Remove old inspector tests

* Proper TODO message

* Convert flyout service to class

* Use correct i18n

* Resolving weird merge conflicts

* Fix panel plugin test

* Change new platform access

* Add more tests

* Remove commented tests

* Revert test fix (core is actually not fixed yet)

* Fix tests

* Expose onClose as Observable

* Use jest.doMock

* Fix typos

* Core start() -> setup()

* Remove @extends EventEmitter docs

* Refactor and test flyoutservice

* Fix comments: promise -> observable

* Fix tests

* Explicitly define OverlaySetup

* Fix OverlaySetup type signature

* Update Core API review file and docs

* Remove redudant if case

* Change FlyoutRef.onClose into a promise

* Remove redundante cleanup

* Use promise.finally

* Remove targetDomElement from openFlyout()

There's no need to support multiple targetDomElements per FlyoutService
and the current implementation handled this use case incorrectly.

Instead of adding complexity to try to support it, remove this from the
function signature.

* Fix + test to ensure child components are unmounted when a new flyover is displayed

* Wrap flyover in i18n Context component

* TSlint -> ESlint + test improvements
This commit is contained in:
Rudolf Meijering 2019-04-08 16:43:58 +02:00 committed by GitHub
parent 73735ab0e4
commit 8dbff4f1ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 602 additions and 191 deletions

View file

@ -21,5 +21,6 @@ export interface CoreSetup
| [i18n](./kibana-plugin-public.coresetup.i18n.md) | <code>I18nSetup</code> | |
| [injectedMetadata](./kibana-plugin-public.coresetup.injectedmetadata.md) | <code>InjectedMetadataSetup</code> | |
| [notifications](./kibana-plugin-public.coresetup.notifications.md) | <code>NotificationsSetup</code> | |
| [overlays](./kibana-plugin-public.coresetup.overlays.md) | <code>OverlaySetup</code> | |
| [uiSettings](./kibana-plugin-public.coresetup.uisettings.md) | <code>UiSettingsSetup</code> | |

View file

@ -0,0 +1,9 @@
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [CoreSetup](./kibana-plugin-public.coresetup.md) &gt; [overlays](./kibana-plugin-public.coresetup.overlays.md)
## CoreSetup.overlays property
<b>Signature:</b>
```typescript
overlays: OverlaySetup;
```

View file

@ -0,0 +1,15 @@
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [FlyoutRef](./kibana-plugin-public.flyoutref.md) &gt; [close](./kibana-plugin-public.flyoutref.close.md)
## FlyoutRef.close() method
Closes the referenced flyout if it's still open which in turn will resolve the `onClose` Promise. If the flyout had already been closed this method does nothing.
<b>Signature:</b>
```typescript
close(): Promise<void>;
```
<b>Returns:</b>
`Promise<void>`

View file

@ -0,0 +1,24 @@
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [FlyoutRef](./kibana-plugin-public.flyoutref.md)
## FlyoutRef class
A FlyoutRef is a reference to an opened flyout panel. It offers methods to close the flyout panel again. If you open a flyout panel you should make sure you call `close()` when it should be closed. Since a flyout could also be closed by a user or from another flyout being opened, you must bind to the `onClose` Promise on the FlyoutRef instance. The Promise will resolve whenever the flyout was closed at which point you should discard the FlyoutRef.
<b>Signature:</b>
```typescript
export declare class FlyoutRef
```
## Properties
| Property | Modifiers | Type | Description |
| --- | --- | --- | --- |
| [onClose](./kibana-plugin-public.flyoutref.onclose.md) | | <code>Promise&lt;void&gt;</code> | An Promise that will resolve once this flyout is closed.<!-- -->Flyouts can close from user interaction, calling <code>close()</code> on the flyout reference or another call to <code>openFlyout()</code> replacing your flyout. |
## Methods
| Method | Modifiers | Description |
| --- | --- | --- |
| [close()](./kibana-plugin-public.flyoutref.close.md) | | Closes the referenced flyout if it's still open which in turn will resolve the <code>onClose</code> Promise. If the flyout had already been closed this method does nothing. |

View file

@ -0,0 +1,13 @@
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [FlyoutRef](./kibana-plugin-public.flyoutref.md) &gt; [onClose](./kibana-plugin-public.flyoutref.onclose.md)
## FlyoutRef.onClose property
An Promise that will resolve once this flyout is closed.
Flyouts can close from user interaction, calling `close()` on the flyout reference or another call to `openFlyout()` replacing your flyout.
<b>Signature:</b>
```typescript
readonly onClose: Promise<void>;
```

View file

@ -6,6 +6,7 @@
| Class | Description |
| --- | --- |
| [FlyoutRef](./kibana-plugin-public.flyoutref.md) | A FlyoutRef is a reference to an opened flyout panel. It offers methods to close the flyout panel again. If you open a flyout panel you should make sure you call <code>close()</code> when it should be closed. Since a flyout could also be closed by a user or from another flyout being opened, you must bind to the <code>onClose</code> Promise on the FlyoutRef instance. The Promise will resolve whenever the flyout was closed at which point you should discard the FlyoutRef. |
| [ToastsSetup](./kibana-plugin-public.toastssetup.md) | |
| [UiSettingsClient](./kibana-plugin-public.uisettingsclient.md) | |
@ -16,6 +17,7 @@
| [ChromeBrand](./kibana-plugin-public.chromebrand.md) | |
| [ChromeBreadcrumb](./kibana-plugin-public.chromebreadcrumb.md) | |
| [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the start lifecycle |
| [OverlaySetup](./kibana-plugin-public.overlaysetup.md) | |
| [Plugin](./kibana-plugin-public.plugin.md) | The interface that should be returned by a <code>PluginInitializer</code>. |
| [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) | The available core services passed to a <code>PluginInitializer</code> |
| [PluginSetupContext](./kibana-plugin-public.pluginsetupcontext.md) | The available core services passed to a plugin's <code>Plugin#setup</code> method. |

View file

@ -0,0 +1,17 @@
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [OverlaySetup](./kibana-plugin-public.overlaysetup.md)
## OverlaySetup interface
<b>Signature:</b>
```typescript
export interface OverlaySetup
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [openFlyout](./kibana-plugin-public.overlaysetup.openflyout.md) | <code>(flyoutChildren: React.ReactNode, flyoutProps?: {`<p/>` closeButtonAriaLabel?: string;`<p/>` 'data-test-subj'?: string;`<p/>` }) =&gt; FlyoutRef</code> | |

View file

@ -0,0 +1,12 @@
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [OverlaySetup](./kibana-plugin-public.overlaysetup.md) &gt; [openFlyout](./kibana-plugin-public.overlaysetup.openflyout.md)
## OverlaySetup.openFlyout property
<b>Signature:</b>
```typescript
openFlyout: (flyoutChildren: React.ReactNode, flyoutProps?: {
closeButtonAriaLabel?: string;
'data-test-subj'?: string;
}) => FlyoutRef;
```

View file

@ -25,6 +25,7 @@ import { i18nServiceMock } from './i18n/i18n_service.mock';
import { injectedMetadataServiceMock } from './injected_metadata/injected_metadata_service.mock';
import { legacyPlatformServiceMock } from './legacy/legacy_service.mock';
import { notificationServiceMock } from './notifications/notifications_service.mock';
import { overlayServiceMock } from './overlays/overlay_service.mock';
import { pluginsServiceMock } from './plugins/plugins_service.mock';
import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock';
@ -92,6 +93,12 @@ jest.doMock('./chrome', () => ({
ChromeService: ChromeServiceConstructor,
}));
export const MockOverlayService = overlayServiceMock.create();
export const OverlayServiceConstructor = jest.fn().mockImplementation(() => MockOverlayService);
jest.doMock('./overlays', () => ({
OverlayService: OverlayServiceConstructor,
}));
export const MockPluginsService = pluginsServiceMock.create();
export const PluginsServiceConstructor = jest.fn().mockImplementation(() => MockPluginsService);
jest.doMock('./plugins', () => ({

View file

@ -36,9 +36,11 @@ import {
MockInjectedMetadataService,
MockLegacyPlatformService,
MockNotificationsService,
MockOverlayService,
MockPluginsService,
MockUiSettingsService,
NotificationServiceConstructor,
OverlayServiceConstructor,
UiSettingsServiceConstructor,
} from './core_system.test.mocks';
@ -81,6 +83,7 @@ describe('constructor', () => {
expect(BasePathServiceConstructor).toHaveBeenCalledTimes(1);
expect(UiSettingsServiceConstructor).toHaveBeenCalledTimes(1);
expect(ChromeServiceConstructor).toHaveBeenCalledTimes(1);
expect(OverlayServiceConstructor).toHaveBeenCalledTimes(1);
});
it('passes injectedMetadata param to InjectedMetadataService', () => {
@ -229,7 +232,7 @@ describe('#setup()', () => {
const root = document.createElement('div');
root.innerHTML = '<p>foo bar</p>';
await setupCore(root);
expect(root.innerHTML).toBe('<div></div><div></div>');
expect(root.innerHTML).toBe('<div></div><div></div><div></div>');
});
it('calls injectedMetadata#setup()', async () => {
@ -272,6 +275,11 @@ describe('#setup()', () => {
expect(MockChromeService.setup).toHaveBeenCalledTimes(1);
});
it('calls overlays#setup()', () => {
setupCore();
expect(MockOverlayService.setup).toHaveBeenCalledTimes(1);
});
it('calls plugin#setup()', async () => {
await setupCore();
expect(MockPluginsService.setup).toHaveBeenCalledTimes(1);

View file

@ -30,6 +30,7 @@ import { I18nService } from './i18n';
import { InjectedMetadataParams, InjectedMetadataService } from './injected_metadata';
import { LegacyPlatformParams, LegacyPlatformService } from './legacy';
import { NotificationsService } from './notifications';
import { OverlayService } from './overlays';
import { PluginsService } from './plugins';
import { UiSettingsService } from './ui_settings';
@ -63,11 +64,13 @@ export class CoreSystem {
private readonly basePath: BasePathService;
private readonly chrome: ChromeService;
private readonly i18n: I18nService;
private readonly overlay: OverlayService;
private readonly plugins: PluginsService;
private readonly rootDomElement: HTMLElement;
private readonly notificationsTargetDomElement$: Subject<HTMLDivElement>;
private readonly legacyPlatformTargetDomElement: HTMLDivElement;
private readonly overlayTargetDomElement: HTMLDivElement;
constructor(params: Params) {
const {
@ -101,6 +104,8 @@ export class CoreSystem {
this.http = new HttpService();
this.basePath = new BasePathService();
this.uiSettings = new UiSettingsService();
this.overlayTargetDomElement = document.createElement('div');
this.overlay = new OverlayService(this.overlayTargetDomElement);
this.chrome = new ChromeService({ browserSupportsCsp });
const core: CoreContext = {};
@ -121,6 +126,7 @@ export class CoreSystem {
const injectedMetadata = this.injectedMetadata.setup();
const fatalErrors = this.fatalErrors.setup({ i18n });
const http = this.http.setup({ fatalErrors });
const overlays = this.overlay.setup({ i18n });
const basePath = this.basePath.setup({ injectedMetadata });
const uiSettings = this.uiSettings.setup({
notifications,
@ -142,6 +148,7 @@ export class CoreSystem {
injectedMetadata,
notifications,
uiSettings,
overlays,
};
await this.plugins.setup(core);
@ -153,6 +160,7 @@ export class CoreSystem {
const notificationsTargetDomElement = document.createElement('div');
this.rootDomElement.appendChild(notificationsTargetDomElement);
this.rootDomElement.appendChild(this.legacyPlatformTargetDomElement);
this.rootDomElement.appendChild(this.overlayTargetDomElement);
// Only provide the DOM element to notifications once it's attached to the page.
// This prevents notifications from timing out before being displayed.

View file

@ -16,11 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { I18nService, I18nSetup } from './i18n_service';
const PassThroughComponent = ({ children }: { children: React.ReactNode }) => children;
const createSetupContractMock = () => {
const setupContract: jest.Mocked<I18nSetup> = {
Context: jest.fn(),
// By default mock the Context component so it simply renders all children
Context: jest.fn().mockImplementation(PassThroughComponent),
};
return setupContract;
};

View file

@ -24,6 +24,7 @@ import { HttpSetup } from './http';
import { I18nSetup } from './i18n';
import { InjectedMetadataParams, InjectedMetadataSetup } from './injected_metadata';
import { NotificationsSetup, Toast, ToastInput, ToastsSetup } from './notifications';
import { FlyoutRef, OverlaySetup } from './overlays';
import { Plugin, PluginInitializer, PluginInitializerContext, PluginSetupContext } from './plugins';
import { UiSettingsClient, UiSettingsSetup, UiSettingsState } from './ui_settings';
@ -44,6 +45,7 @@ export interface CoreSetup {
basePath: BasePathSetup;
uiSettings: UiSettingsSetup;
chrome: ChromeSetup;
overlays: OverlaySetup;
}
export {
@ -62,6 +64,8 @@ export {
PluginInitializerContext,
PluginSetupContext,
NotificationsSetup,
OverlaySetup,
FlyoutRef,
Toast,
ToastInput,
ToastsSetup,

View file

@ -4,8 +4,10 @@
```ts
import * as CSS from 'csstype';
import { default } from 'react';
import { Observable } from 'rxjs';
import * as PropTypes from 'prop-types';
import * as Rx from 'rxjs';
import { Toast } from '@elastic/eui';
@ -63,6 +65,8 @@ export interface CoreSetup {
// (undocumented)
notifications: NotificationsSetup;
// (undocumented)
overlays: OverlaySetup;
// (undocumented)
uiSettings: UiSettingsSetup;
}
@ -93,6 +97,14 @@ export class CoreSystem {
// @public (undocumented)
export type FatalErrorsSetup = ReturnType<FatalErrorsService['setup']>;
// @public
export class FlyoutRef {
// (undocumented)
constructor();
close(): Promise<void>;
readonly onClose: Promise<void>;
}
// Warning: (ae-forgotten-export) The symbol "HttpService" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
@ -152,6 +164,17 @@ export type InjectedMetadataSetup = ReturnType<InjectedMetadataService['setup']>
// @public (undocumented)
export type NotificationsSetup = ReturnType<NotificationsService['setup']>;
// @public (undocumented)
export interface OverlaySetup {
// Warning: (ae-forgotten-export) The symbol "React" needs to be exported by the entry point index.d.ts
//
// (undocumented)
openFlyout: (flyoutChildren: React.ReactNode, flyoutProps?: {
closeButtonAriaLabel?: string;
'data-test-subj'?: string;
}) => FlyoutRef;
}
// @public
export interface Plugin<TSetup, TDependencies extends Record<string, unknown> = {}> {
// (undocumented)

View file

@ -148,6 +148,7 @@ import { httpServiceMock } from '../http/http_service.mock';
import { i18nServiceMock } from '../i18n/i18n_service.mock';
import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock';
import { notificationServiceMock } from '../notifications/notifications_service.mock';
import { overlayServiceMock } from '../overlays/overlay_service.mock';
import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock';
import { LegacyPlatformService } from './legacy_service';
@ -159,6 +160,7 @@ const i18nSetup = i18nServiceMock.createSetupContract();
const injectedMetadataSetup = injectedMetadataServiceMock.createSetupContract();
const notificationsSetup = notificationServiceMock.createSetupContract();
const uiSettingsSetup = uiSettingsServiceMock.createSetupContract();
const overlaySetup = overlayServiceMock.createSetupContract();
const defaultParams = {
targetDomElement: document.createElement('div'),
@ -176,6 +178,7 @@ const defaultSetupDeps = {
basePath: basePathSetup,
uiSettings: uiSettingsSetup,
chrome: chromeSetup,
overlays: overlaySetup,
};
afterEach(() => {

View file

@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FlyoutService FlyoutRef#close() can be called multiple times on the same FlyoutRef 1`] = `
Array [
Array [
<div />,
],
]
`;
exports[`FlyoutService openFlyout() renders a flyout to the DOM 1`] = `
Array [
Array [
<mockConstructor>
<EuiFlyout
closeButtonAriaLabel="Closes this dialog"
hideCloseButton={false}
maxWidth={false}
onClose={[Function]}
ownFocus={false}
size="m"
>
<span>
Flyout content
</span>
</EuiFlyout>
</mockConstructor>,
<div />,
],
]
`;
exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 1`] = `
Array [
Array [
<mockConstructor>
<EuiFlyout
closeButtonAriaLabel="Closes this dialog"
hideCloseButton={false}
maxWidth={false}
onClose={[Function]}
ownFocus={false}
size="m"
>
<span>
Flyout content 1
</span>
</EuiFlyout>
</mockConstructor>,
<div />,
],
Array [
<mockConstructor>
<EuiFlyout
closeButtonAriaLabel="Closes this dialog"
hideCloseButton={false}
maxWidth={false}
onClose={[Function]}
ownFocus={false}
size="m"
>
<span>
Flyout content 2
</span>
</EuiFlyout>
</mockConstructor>,
<div />,
],
]
`;

View file

@ -0,0 +1,25 @@
/*
* 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.
*/
export const mockReactDomRender = jest.fn();
export const mockReactDomUnmount = jest.fn();
jest.doMock('react-dom', () => ({
render: mockReactDomRender,
unmountComponentAtNode: mockReactDomUnmount,
}));

View file

@ -0,0 +1,99 @@
/*
* 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 { mockReactDomRender, mockReactDomUnmount } from './flyout.test.mocks';
import React from 'react';
import { i18nServiceMock } from '../i18n/i18n_service.mock';
import { FlyoutRef, FlyoutService } from './flyout';
const i18nMock = i18nServiceMock.createSetupContract();
beforeEach(() => {
mockReactDomRender.mockClear();
mockReactDomUnmount.mockClear();
});
describe('FlyoutService', () => {
describe('openFlyout()', () => {
it('renders a flyout to the DOM', () => {
const target = document.createElement('div');
const flyoutService = new FlyoutService(target);
expect(mockReactDomRender).not.toHaveBeenCalled();
flyoutService.openFlyout(i18nMock, <span>Flyout content</span>);
expect(mockReactDomRender.mock.calls).toMatchSnapshot();
});
describe('with a currently active flyout', () => {
let target: HTMLElement, flyoutService: FlyoutService, ref1: FlyoutRef;
beforeEach(() => {
target = document.createElement('div');
flyoutService = new FlyoutService(target);
ref1 = flyoutService.openFlyout(i18nMock, <span>Flyout content 1</span>);
});
it('replaces the current flyout with a new one', () => {
flyoutService.openFlyout(i18nMock, <span>Flyout content 2</span>);
expect(mockReactDomRender.mock.calls).toMatchSnapshot();
expect(mockReactDomUnmount).toHaveBeenCalledTimes(1);
expect(() => ref1.close()).not.toThrowError();
expect(mockReactDomUnmount).toHaveBeenCalledTimes(1);
});
it('resolves onClose on the previous ref', async () => {
const onCloseComplete = jest.fn();
ref1.onClose.then(onCloseComplete);
flyoutService.openFlyout(i18nMock, <span>Flyout content 2</span>);
await ref1.onClose;
expect(onCloseComplete).toBeCalledTimes(1);
});
});
});
describe('FlyoutRef#close()', () => {
it('resolves the onClose Promise', async () => {
const target = document.createElement('div');
const flyoutService = new FlyoutService(target);
const ref = flyoutService.openFlyout(i18nMock, <span>Flyout content</span>);
const onCloseComplete = jest.fn();
ref.onClose.then(onCloseComplete);
await ref.close();
await ref.close();
expect(onCloseComplete).toHaveBeenCalledTimes(1);
});
it('can be called multiple times on the same FlyoutRef', async () => {
const target = document.createElement('div');
const flyoutService = new FlyoutService(target);
const ref = flyoutService.openFlyout(i18nMock, <span>Flyout content</span>);
expect(mockReactDomUnmount).not.toHaveBeenCalled();
await ref.close();
expect(mockReactDomUnmount.mock.calls).toMatchSnapshot();
await ref.close();
expect(mockReactDomUnmount).toHaveBeenCalledTimes(1);
});
it("on a stale FlyoutRef doesn't affect the active flyout", async () => {
const target = document.createElement('div');
const flyoutService = new FlyoutService(target);
const ref1 = flyoutService.openFlyout(i18nMock, <span>Flyout content 1</span>);
const ref2 = flyoutService.openFlyout(i18nMock, <span>Flyout content 2</span>);
const onCloseComplete = jest.fn();
ref2.onClose.then(onCloseComplete);
mockReactDomUnmount.mockClear();
await ref1.close();
expect(mockReactDomUnmount).toBeCalledTimes(0);
expect(onCloseComplete).toBeCalledTimes(0);
});
});
});

View file

@ -0,0 +1,130 @@
/*
* 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.
*/
/* eslint-disable max-classes-per-file */
import { EuiFlyout } from '@elastic/eui';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Subject } from 'rxjs';
import { I18nSetup } from '../i18n';
/**
* A FlyoutRef is a reference to an opened flyout panel. It offers methods to
* close the flyout panel again. If you open a flyout panel you should make
* sure you call `close()` when it should be closed.
* Since a flyout could also be closed by a user or from another flyout being
* opened, you must bind to the `onClose` Promise on the FlyoutRef instance.
* The Promise will resolve whenever the flyout was closed at which point you
* should discard the FlyoutRef.
*
* @public
*/
export class FlyoutRef {
/**
* An Promise that will resolve once this flyout is closed.
*
* Flyouts can close from user interaction, calling `close()` on the flyout
* reference or another call to `openFlyout()` replacing your flyout.
*/
public readonly onClose: Promise<void>;
private closeSubject = new Subject<void>();
constructor() {
this.onClose = this.closeSubject.toPromise();
}
/**
* Closes the referenced flyout if it's still open which in turn will
* resolve the `onClose` Promise. If the flyout had already been
* closed this method does nothing.
*/
public close(): Promise<void> {
if (!this.closeSubject.closed) {
this.closeSubject.next();
this.closeSubject.complete();
}
return this.onClose;
}
}
/** @internal */
export class FlyoutService {
private activeFlyout: FlyoutRef | null = null;
constructor(private readonly targetDomElement: Element) {}
/**
* Opens a flyout panel with the given component inside. You can use
* `close()` on the returned FlyoutRef to close the flyout.
*
* @param flyoutChildren - Mounts the children inside a flyout panel
* @return {FlyoutRef} A reference to the opened flyout panel.
*/
public openFlyout = (
i18n: I18nSetup,
flyoutChildren: React.ReactNode,
flyoutProps: {
closeButtonAriaLabel?: string;
'data-test-subj'?: string;
} = {}
): FlyoutRef => {
// If there is an active flyout session close it before opening a new one.
if (this.activeFlyout) {
this.activeFlyout.close();
this.cleanupDom();
}
const flyout = new FlyoutRef();
// If a flyout gets closed through it's FlyoutRef, remove it from the dom
flyout.onClose.then(() => {
if (this.activeFlyout === flyout) {
this.cleanupDom();
}
});
this.activeFlyout = flyout;
render(
<i18n.Context>
<EuiFlyout {...flyoutProps} onClose={() => flyout.close()}>
{flyoutChildren}
</EuiFlyout>
</i18n.Context>,
this.targetDomElement
);
return flyout;
};
/**
* Using React.Render to re-render into a target DOM element will replace
* the content of the target but won't call unmountComponent on any
* components inside the target or any of their children. So we properly
* cleanup the DOM here to prevent subtle bugs in child components which
* depend on unmounting for cleanup behaviour.
*/
private cleanupDom(): void {
unmountComponentAtNode(this.targetDomElement);
this.targetDomElement.innerHTML = '';
this.activeFlyout = null;
}
}

View file

@ -17,4 +17,5 @@
* under the License.
*/
export * from './flyout_session';
export { OverlayService, OverlaySetup } from './overlay_service';
export { FlyoutRef } from './flyout';

View file

@ -0,0 +1,39 @@
/*
* 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 { OverlayService, OverlaySetup } from './overlay_service';
const createSetupContractMock = () => {
const setupContract: jest.Mocked<PublicMethodsOf<OverlaySetup>> = {
openFlyout: jest.fn(),
};
return setupContract;
};
const createMock = () => {
const mocked: jest.Mocked<PublicMethodsOf<OverlayService>> = {
setup: jest.fn(),
};
mocked.setup.mockReturnValue(createSetupContractMock());
return mocked;
};
export const overlayServiceMock = {
create: createMock,
createSetupContract: createSetupContractMock,
};

View file

@ -0,0 +1,53 @@
/*
* 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 { FlyoutService } from './flyout';
import { FlyoutRef } from '..';
import { I18nSetup } from '../i18n';
interface Deps {
i18n: I18nSetup;
}
/** @internal */
export class OverlayService {
private flyoutService: FlyoutService;
constructor(targetDomElement: HTMLElement) {
this.flyoutService = new FlyoutService(targetDomElement);
}
public setup({ i18n }: Deps): OverlaySetup {
return {
openFlyout: this.flyoutService.openFlyout.bind(this.flyoutService, i18n),
};
}
}
/** @public */
export interface OverlaySetup {
openFlyout: (
flyoutChildren: React.ReactNode,
flyoutProps?: {
closeButtonAriaLabel?: string;
'data-test-subj'?: string;
}
) => FlyoutRef;
}

View file

@ -17,11 +17,9 @@
* under the License.
*/
import React from 'react';
import { EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ContextMenuAction } from 'ui/embeddable';
import { Inspector } from 'ui/inspector';
@ -78,7 +76,7 @@ export function getInspectorPanelAction({
}
};
// In case the inspector gets closed (otherwise), restore the original destroy function
session.on('closed', () => {
session.onClose.finally(() => {
embeddable.destroy = originalDestroy;
});
},

View file

@ -233,7 +233,11 @@ function VisEditor(
return !vis.hasInspector || !vis.hasInspector();
},
run() {
vis.openInspector().bindToAngularScope($scope);
const inspectorSession = vis.openInspector();
// Close the inspector if this scope is destroyed (e.g. because the user navigates away).
const removeWatch = $scope.$on('$destroy', () => inspectorSession.close());
// Remove that watch in case the user closes the inspector session herself.
inspectorSession.onClose.finally(removeWatch);
},
tooltip() {
if (!vis.hasInspector || !vis.hasInspector()) {

View file

@ -1,119 +0,0 @@
/*
* 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 from 'react';
import { EuiFlyout } from '@elastic/eui';
import { EventEmitter } from 'events';
import ReactDOM from 'react-dom';
import { I18nContext } from 'ui/i18n';
let activeSession: FlyoutSession | null = null;
const CONTAINER_ID = 'flyout-container';
function getOrCreateContainerElement() {
let container = document.getElementById(CONTAINER_ID);
if (!container) {
container = document.createElement('div');
container.id = CONTAINER_ID;
document.body.appendChild(container);
}
return container;
}
/**
* A FlyoutSession describes the session of one opened flyout panel. It offers
* methods to close the flyout panel again. If you open a flyout panel you should make
* sure you call {@link FlyoutSession#close} when it should be closed.
* Since a flyout could also be closed without calling this method (e.g. because
* the user closes it), you must listen to the "closed" event on this instance.
* It will be emitted whenever the flyout will be closed and you should throw
* away your reference to this instance whenever you receive that event.
* @extends EventEmitter
*/
class FlyoutSession extends EventEmitter {
/**
* Binds the current flyout session to an Angular scope, meaning this flyout
* session will be closed as soon as the Angular scope gets destroyed.
* @param {object} scope - An angular scope object to bind to.
*/
public bindToAngularScope(scope: ng.IScope): void {
const removeWatch = scope.$on('$destroy', () => this.close());
this.on('closed', () => removeWatch());
}
/**
* Closes the opened flyout as long as it's still the open one.
* If this is not the active session anymore, this method won't do anything.
* If this session was still active and a flyout was closed, the 'closed'
* event will be emitted on this FlyoutSession instance.
*/
public close(): void {
if (activeSession === this) {
const container = document.getElementById(CONTAINER_ID);
if (container) {
ReactDOM.unmountComponentAtNode(container);
this.emit('closed');
}
}
}
}
/**
* Opens a flyout panel with the given component inside. You can use
* {@link FlyoutSession#close} on the return value to close the flyout.
*
* @param flyoutChildren - Mounts the children inside a fly out panel
* @return {FlyoutSession} The session instance for the opened flyout panel.
*/
export function openFlyout(
flyoutChildren: React.ReactNode,
flyoutProps: {
closeButtonAriaLabel?: string;
onClose?: () => void;
'data-test-subj'?: string;
} = {}
): FlyoutSession {
// If there is an active inspector session close it before opening a new one.
if (activeSession) {
activeSession.close();
}
const container = getOrCreateContainerElement();
const session = (activeSession = new FlyoutSession());
const onClose = () => {
if (flyoutProps.onClose) {
flyoutProps.onClose();
}
session.close();
};
ReactDOM.render(
<I18nContext>
<EuiFlyout {...flyoutProps} onClose={onClose}>
{flyoutChildren}
</EuiFlyout>
</I18nContext>,
container
);
return session;
}
export { FlyoutSession };

View file

@ -28,6 +28,18 @@ jest.mock('./ui/inspector_panel', () => ({
}));
jest.mock('ui/i18n', () => ({ I18nContext: ({ children }) => children }));
jest.mock('ui/new_platform', () => ({
getNewPlatform: () => ({
start: {
core: {
overlay: {
openFlyout: jest.fn(),
},
}
}
}),
}));
import { viewRegistry } from './view_registry';
function setViews(views) {
@ -52,60 +64,5 @@ describe('Inspector', () => {
setViews([]);
expect(() => Inspector.open({})).toThrow();
});
describe('return value', () => {
beforeEach(() => {
setViews([{}]);
});
it('should be an object with a close function', () => {
const session = Inspector.open({});
expect(typeof session.close).toBe('function');
});
it('should emit the "closed" event if another inspector opens', () => {
const session = Inspector.open({});
const spy = jest.fn();
session.on('closed', spy);
Inspector.open({});
expect(spy).toHaveBeenCalled();
});
it('should emit the "closed" event if you call close', () => {
const session = Inspector.open({});
const spy = jest.fn();
session.on('closed', spy);
session.close();
expect(spy).toHaveBeenCalled();
});
it('can be bound to an angular scope', () => {
const session = Inspector.open({});
const spy = jest.fn();
session.on('closed', spy);
const scope = {
$on: jest.fn(() => () => {})
};
session.bindToAngularScope(scope);
expect(scope.$on).toHaveBeenCalled();
const onCall = scope.$on.mock.calls[0];
expect(onCall[0]).toBe('$destroy');
expect(typeof onCall[1]).toBe('function');
// Call $destroy callback, as angular would when the scope gets destroyed
onCall[1]();
expect(spy).toHaveBeenCalled();
});
it('will remove from angular scope when closed', () => {
const session = Inspector.open({});
const unwatchSpy = jest.fn();
const scope = {
$on: jest.fn(() => unwatchSpy)
};
session.bindToAngularScope(scope);
session.close();
expect(unwatchSpy).toHaveBeenCalled();
});
});
});
});

View file

@ -19,7 +19,8 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FlyoutSession, openFlyout } from 'ui/flyout';
import { FlyoutRef } from '../../../../core/public';
import { getNewPlatform } from '../new_platform';
import { Adapters } from './types';
import { InspectorPanel } from './ui/inspector_panel';
import { viewRegistry } from './view_registry';
@ -49,7 +50,7 @@ interface InspectorOptions {
title?: string;
}
export type InspectorSession = FlyoutSession;
export type InspectorSession = FlyoutRef;
/**
* Opens the inspector panel for the given adapters and close any previously opened
@ -72,10 +73,13 @@ function open(adapters: Adapters, options: InspectorOptions = {}): InspectorSess
if an inspector can be shown.`);
}
return openFlyout(<InspectorPanel views={views} adapters={adapters} title={options.title} />, {
'data-test-subj': 'inspectorPanel',
closeButtonAriaLabel: closeButtonLabel,
});
return getNewPlatform().setup.core.overlays.openFlyout(
<InspectorPanel views={views} adapters={adapters} title={options.title} />,
{
'data-test-subj': 'inspectorPanel',
closeButtonAriaLabel: closeButtonLabel,
}
);
}
const Inspector = {

View file

@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { CoreSetup } from '../../../../core/public';
const runtimeContext = {

View file

@ -18,7 +18,7 @@
*/
import { EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
import React from 'react';
import { openFlyout } from 'ui/flyout';
import { getNewPlatform } from 'ui/new_platform';
import {
ContextMenuAction,
@ -38,7 +38,7 @@ class SamplePanelAction extends ContextMenuAction {
if (!embeddable) {
return;
}
openFlyout(
getNewPlatform().setup.core.overlays.openFlyout(
<React.Fragment>
<EuiFlyoutHeader>
<EuiTitle size="s" data-test-subj="samplePanelActionTitle">