diff --git a/docs/development/core/public/kibana-plugin-public.chromenavcontrol.md b/docs/development/core/public/kibana-plugin-public.chromenavcontrol.md index c6920fc30d4ee..fdf56012e4729 100644 --- a/docs/development/core/public/kibana-plugin-public.chromenavcontrol.md +++ b/docs/development/core/public/kibana-plugin-public.chromenavcontrol.md @@ -15,11 +15,6 @@ export interface ChromeNavControl | Property | Type | Description | | --- | --- | --- | +| [mount](./kibana-plugin-public.chromenavcontrol.mount.md) | MountPoint | | | [order](./kibana-plugin-public.chromenavcontrol.order.md) | number | | -## Methods - -| Method | Description | -| --- | --- | -| [mount(targetDomElement)](./kibana-plugin-public.chromenavcontrol.mount.md) | | - diff --git a/docs/development/core/public/kibana-plugin-public.chromenavcontrol.mount.md b/docs/development/core/public/kibana-plugin-public.chromenavcontrol.mount.md index 6ce5d7f0d5c4d..3e1f5a1f78f89 100644 --- a/docs/development/core/public/kibana-plugin-public.chromenavcontrol.mount.md +++ b/docs/development/core/public/kibana-plugin-public.chromenavcontrol.mount.md @@ -2,21 +2,10 @@ [Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ChromeNavControl](./kibana-plugin-public.chromenavcontrol.md) > [mount](./kibana-plugin-public.chromenavcontrol.mount.md) -## ChromeNavControl.mount() method +## ChromeNavControl.mount property Signature: ```typescript -mount(targetDomElement: HTMLElement): () => void; +mount: MountPoint; ``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| targetDomElement | HTMLElement | | - -Returns: - -`() => void` - diff --git a/docs/development/core/public/kibana-plugin-public.mountpoint.md b/docs/development/core/public/kibana-plugin-public.mountpoint.md index 58f407904a576..928d22f00ed00 100644 --- a/docs/development/core/public/kibana-plugin-public.mountpoint.md +++ b/docs/development/core/public/kibana-plugin-public.mountpoint.md @@ -9,5 +9,5 @@ A function that should mount DOM content inside the provided container element a Signature: ```typescript -export declare type MountPoint = (element: HTMLElement) => UnmountCallback; +export declare type MountPoint = (element: T) => UnmountCallback; ``` diff --git a/docs/development/core/public/kibana-plugin-public.overlaystart.md b/docs/development/core/public/kibana-plugin-public.overlaystart.md index 6bcf0a581df80..8b6f11bd819f8 100644 --- a/docs/development/core/public/kibana-plugin-public.overlaystart.md +++ b/docs/development/core/public/kibana-plugin-public.overlaystart.md @@ -16,6 +16,6 @@ export interface OverlayStart | Property | Type | Description | | --- | --- | --- | | [banners](./kibana-plugin-public.overlaystart.banners.md) | OverlayBannersStart | [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) | -| [openFlyout](./kibana-plugin-public.overlaystart.openflyout.md) | (flyoutChildren: React.ReactNode, flyoutProps?: {
closeButtonAriaLabel?: string;
'data-test-subj'?: string;
}) => OverlayRef | | -| [openModal](./kibana-plugin-public.overlaystart.openmodal.md) | (modalChildren: React.ReactNode, modalProps?: {
className?: string;
closeButtonAriaLabel?: string;
'data-test-subj'?: string;
}) => OverlayRef | | +| [openFlyout](./kibana-plugin-public.overlaystart.openflyout.md) | OverlayFlyoutStart['open'] | | +| [openModal](./kibana-plugin-public.overlaystart.openmodal.md) | OverlayModalStart['open'] | | diff --git a/docs/development/core/public/kibana-plugin-public.overlaystart.openflyout.md b/docs/development/core/public/kibana-plugin-public.overlaystart.openflyout.md index 6d015d6a34382..ad3351fb4d098 100644 --- a/docs/development/core/public/kibana-plugin-public.overlaystart.openflyout.md +++ b/docs/development/core/public/kibana-plugin-public.overlaystart.openflyout.md @@ -4,11 +4,9 @@ ## OverlayStart.openFlyout property + Signature: ```typescript -openFlyout: (flyoutChildren: React.ReactNode, flyoutProps?: { - closeButtonAriaLabel?: string; - 'data-test-subj'?: string; - }) => OverlayRef; +openFlyout: OverlayFlyoutStart['open']; ``` diff --git a/docs/development/core/public/kibana-plugin-public.overlaystart.openmodal.md b/docs/development/core/public/kibana-plugin-public.overlaystart.openmodal.md index a4569e178f17d..2c983d6151f4c 100644 --- a/docs/development/core/public/kibana-plugin-public.overlaystart.openmodal.md +++ b/docs/development/core/public/kibana-plugin-public.overlaystart.openmodal.md @@ -4,12 +4,9 @@ ## OverlayStart.openModal property + Signature: ```typescript -openModal: (modalChildren: React.ReactNode, modalProps?: { - className?: string; - closeButtonAriaLabel?: string; - 'data-test-subj'?: string; - }) => OverlayRef; +openModal: OverlayModalStart['open']; ``` diff --git a/src/core/public/chrome/nav_controls/nav_controls_service.ts b/src/core/public/chrome/nav_controls/nav_controls_service.ts index 0088ef68aaeb8..7f9c75595a4ce 100644 --- a/src/core/public/chrome/nav_controls/nav_controls_service.ts +++ b/src/core/public/chrome/nav_controls/nav_controls_service.ts @@ -20,11 +20,12 @@ import { sortBy } from 'lodash'; import { BehaviorSubject, ReplaySubject, Observable } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; +import { MountPoint } from '../../types'; /** @public */ export interface ChromeNavControl { order?: number; - mount(targetDomElement: HTMLElement): () => void; + mount: MountPoint; } /** diff --git a/src/core/public/chrome/ui/header/header_extension.tsx b/src/core/public/chrome/ui/header/header_extension.tsx index 90e7907b0068c..76413a0ea0317 100644 --- a/src/core/public/chrome/ui/header/header_extension.tsx +++ b/src/core/public/chrome/ui/header/header_extension.tsx @@ -18,9 +18,10 @@ */ import React from 'react'; +import { MountPoint } from '../../../types'; interface Props { - extension?: (el: HTMLDivElement) => () => void; + extension?: MountPoint; } export class HeaderExtension extends React.Component { diff --git a/src/core/public/legacy/legacy_service.ts b/src/core/public/legacy/legacy_service.ts index 79fa32040b14c..22e315f9e1b03 100644 --- a/src/core/public/legacy/legacy_service.ts +++ b/src/core/public/legacy/legacy_service.ts @@ -19,7 +19,7 @@ import angular from 'angular'; import { InternalCoreSetup, InternalCoreStart } from '../core_system'; -import { LegacyCoreSetup, LegacyCoreStart } from '../'; +import { LegacyCoreSetup, LegacyCoreStart, MountPoint } from '../'; /** @internal */ export interface LegacyPlatformParams { @@ -40,7 +40,7 @@ interface StartDeps { } interface BootstrapModule { - bootstrap: (targetDomElement: HTMLElement) => void; + bootstrap: MountPoint; } /** diff --git a/src/core/public/notifications/toasts/error_toast.test.tsx b/src/core/public/notifications/toasts/error_toast.test.tsx index b72b2de85340a..b497be526093d 100644 --- a/src/core/public/notifications/toasts/error_toast.test.tsx +++ b/src/core/public/notifications/toasts/error_toast.test.tsx @@ -40,6 +40,7 @@ function render(props: ErrorToastProps = {}) { error={props.error || new Error('error message')} title={props.title || 'An error occured'} toastMessage={props.toastMessage || 'This is the toast message'} + i18nContext={() => ({ children }) => {children}} /> ); } diff --git a/src/core/public/notifications/toasts/error_toast.tsx b/src/core/public/notifications/toasts/error_toast.tsx index 10bc51559644b..6b53719839b0f 100644 --- a/src/core/public/notifications/toasts/error_toast.tsx +++ b/src/core/public/notifications/toasts/error_toast.tsx @@ -18,6 +18,7 @@ */ import React from 'react'; +import ReactDOM from 'react-dom'; import { EuiButton, @@ -32,12 +33,14 @@ import { EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { OverlayStart } from '../../overlays'; +import { I18nStart } from '../../i18n'; interface ErrorToastProps { title: string; error: Error; toastMessage: string; openModal: OverlayStart['openModal']; + i18nContext: () => I18nStart['Context']; } /** @@ -50,33 +53,48 @@ function showErrorDialog({ title, error, openModal, -}: Pick) { + i18nContext, +}: Pick) { + const I18nContext = i18nContext(); const modal = openModal( - - - {title} - - - - {error.stack && ( - - - - {error.stack} - - - )} - - - modal.close()} fill> - - - - + mount( + + + + {title} + + + + {error.stack && ( + + + + {error.stack} + + + )} + + + modal.close()} fill> + + + + + + ) ); } -export function ErrorToast({ title, error, toastMessage, openModal }: ErrorToastProps) { +export function ErrorToast({ + title, + error, + toastMessage, + openModal, + i18nContext, +}: ErrorToastProps) { return (

{toastMessage}

@@ -84,7 +102,7 @@ export function ErrorToast({ title, error, toastMessage, openModal }: ErrorToast showErrorDialog({ title, error, openModal })} + onClick={() => showErrorDialog({ title, error, openModal, i18nContext })} > ); } + +const mount = (component: React.ReactElement) => (container: HTMLElement) => { + ReactDOM.render(component, container); + return () => ReactDOM.unmountComponentAtNode(container); +}; diff --git a/src/core/public/notifications/toasts/toasts_api.test.ts b/src/core/public/notifications/toasts/toasts_api.test.ts index f99a28617aa5c..a0e419e989657 100644 --- a/src/core/public/notifications/toasts/toasts_api.test.ts +++ b/src/core/public/notifications/toasts/toasts_api.test.ts @@ -51,10 +51,13 @@ function uiSettingsMock() { function toastDeps() { return { uiSettings: uiSettingsMock(), - i18n: i18nServiceMock.createStartContract(), }; } +function startDeps() { + return { overlays: {} as any, i18n: i18nServiceMock.createStartContract() }; +} + describe('#get$()', () => { it('returns observable that emits NEW toast list when something added or removed', () => { const toasts = new ToastsApi(toastDeps()); @@ -188,6 +191,7 @@ describe('#addDanger()', () => { describe('#addError', () => { it('adds an error toast', async () => { const toasts = new ToastsApi(toastDeps()); + toasts.start(startDeps()); const toast = toasts.addError(new Error('unexpected error'), { title: 'Something went wrong' }); expect(toast).toHaveProperty('color', 'danger'); expect(toast).toHaveProperty('title', 'Something went wrong'); @@ -195,6 +199,7 @@ describe('#addError', () => { it('returns the created toast', async () => { const toasts = new ToastsApi(toastDeps()); + toasts.start(startDeps()); const toast = toasts.addError(new Error('unexpected error'), { title: 'Something went wrong' }); const currentToasts = await getCurrentToasts(toasts); expect(currentToasts[0]).toBe(toast); diff --git a/src/core/public/notifications/toasts/toasts_api.tsx b/src/core/public/notifications/toasts/toasts_api.tsx index b49bafda5b26e..a21b727b02d73 100644 --- a/src/core/public/notifications/toasts/toasts_api.tsx +++ b/src/core/public/notifications/toasts/toasts_api.tsx @@ -26,6 +26,7 @@ import { MountPoint } from '../../types'; import { mountReactNode } from '../../utils'; import { UiSettingsClientContract } from '../../ui_settings'; import { OverlayStart } from '../../overlays'; +import { I18nStart } from '../../i18n'; /** * Allowed fields for {@link ToastInput}. @@ -96,14 +97,16 @@ export class ToastsApi implements IToasts { private uiSettings: UiSettingsClientContract; private overlays?: OverlayStart; + private i18n?: I18nStart; constructor(deps: { uiSettings: UiSettingsClientContract }) { this.uiSettings = deps.uiSettings; } /** @internal */ - public registerOverlays(overlays: OverlayStart) { + public start({ overlays, i18n }: { overlays: OverlayStart; i18n: I18nStart }) { this.overlays = overlays; + this.i18n = i18n; } /** Observable of the toast messages to show to the user. */ @@ -206,6 +209,7 @@ export class ToastsApi implements IToasts { error={error} title={options.title} toastMessage={message} + i18nContext={() => this.i18n!.Context} /> ), }); diff --git a/src/core/public/notifications/toasts/toasts_service.tsx b/src/core/public/notifications/toasts/toasts_service.tsx index 47d8c14f9af8b..81d23afc4f4d3 100644 --- a/src/core/public/notifications/toasts/toasts_service.tsx +++ b/src/core/public/notifications/toasts/toasts_service.tsx @@ -58,7 +58,7 @@ export class ToastsService { } public start({ i18n, overlays, targetDomElement }: StartDeps) { - this.api!.registerOverlays(overlays); + this.api!.start({ overlays, i18n }); this.targetDomElement = targetDomElement; render( diff --git a/src/core/public/overlays/__snapshots__/flyout.test.tsx.snap b/src/core/public/overlays/__snapshots__/flyout.test.tsx.snap deleted file mode 100644 index 0c19c6312a672..0000000000000 --- a/src/core/public/overlays/__snapshots__/flyout.test.tsx.snap +++ /dev/null @@ -1,70 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FlyoutService FlyoutRef#close() can be called multiple times on the same FlyoutRef 1`] = ` -Array [ - Array [ -
, - ], -] -`; - -exports[`FlyoutService openFlyout() renders a flyout to the DOM 1`] = ` -Array [ - Array [ - - - - Flyout content - - - , -
, - ], -] -`; - -exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 1`] = ` -Array [ - Array [ - - - - Flyout content 1 - - - , -
, - ], - Array [ - - - - Flyout content 2 - - - , -
, - ], -] -`; diff --git a/src/core/public/overlays/__snapshots__/modal.test.tsx.snap b/src/core/public/overlays/__snapshots__/modal.test.tsx.snap deleted file mode 100644 index a4e6f5d6f72b8..0000000000000 --- a/src/core/public/overlays/__snapshots__/modal.test.tsx.snap +++ /dev/null @@ -1,64 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ModalService ModalRef#close() can be called multiple times on the same ModalRef 1`] = ` -Array [ - Array [ -
, - ], -] -`; - -exports[`ModalService openModal() renders a modal to the DOM 1`] = ` -Array [ - Array [ - - - - - Modal content - - - - , -
, - ], -] -`; - -exports[`ModalService openModal() with a currently active modal replaces the current modal with a new one 1`] = ` -Array [ - Array [ - - - - - Modal content 1 - - - - , -
, - ], - Array [ - - - - - Flyout content 2 - - - - , -
, - ], -] -`; diff --git a/src/core/public/overlays/_index.scss b/src/core/public/overlays/_index.scss index fe86883aedcf9..368dc9b644ff9 100644 --- a/src/core/public/overlays/_index.scss +++ b/src/core/public/overlays/_index.scss @@ -1 +1,2 @@ @import './banners/index'; +@import './mount_wrapper'; diff --git a/src/core/public/overlays/_mount_wrapper.scss b/src/core/public/overlays/_mount_wrapper.scss new file mode 100644 index 0000000000000..aafcc4bbe87db --- /dev/null +++ b/src/core/public/overlays/_mount_wrapper.scss @@ -0,0 +1,5 @@ +.kbnOverlayMountWrapper { + display: flex; + flex-direction: column; + height: 100%; +} diff --git a/src/core/public/overlays/banners/banners_service.tsx b/src/core/public/overlays/banners/banners_service.tsx index 31d49b5952e87..a26a7c71bc61f 100644 --- a/src/core/public/overlays/banners/banners_service.tsx +++ b/src/core/public/overlays/banners/banners_service.tsx @@ -97,9 +97,7 @@ export class OverlayBannersService { if (!banners$.value.has(id)) { return false; } - banners$.next(banners$.value.remove(id)); - return true; }, @@ -107,10 +105,8 @@ export class OverlayBannersService { if (!id || !banners$.value.has(id)) { return this.add(mount, priority); } - const nextId = genId(); const nextBanner = { id: nextId, mount, priority }; - banners$.next(banners$.value.remove(id).add(nextId, nextBanner)); return nextId; }, diff --git a/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap b/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap new file mode 100644 index 0000000000000..94c11f0185427 --- /dev/null +++ b/src/core/public/overlays/flyout/__snapshots__/flyout_service.test.tsx.snap @@ -0,0 +1,77 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FlyoutService FlyoutRef#close() can be called multiple times on the same FlyoutRef 1`] = ` +Array [ + Array [ +
, + ], +] +`; + +exports[`FlyoutService openFlyout() renders a flyout to the DOM 1`] = ` +Array [ + Array [ + + + + + , +
, + ], +] +`; + +exports[`FlyoutService openFlyout() renders a flyout to the DOM 2`] = `"
Flyout content
"`; + +exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 1`] = ` +Array [ + Array [ + + + + + , +
, + ], + Array [ + + + + + , +
, + ], +] +`; + +exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
Flyout content 2
"`; diff --git a/src/core/public/overlays/flyout/flyout_service.mock.ts b/src/core/public/overlays/flyout/flyout_service.mock.ts new file mode 100644 index 0000000000000..91544500713d6 --- /dev/null +++ b/src/core/public/overlays/flyout/flyout_service.mock.ts @@ -0,0 +1,43 @@ +/* + * 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, OverlayFlyoutStart } from './flyout_service'; + +const createStartContractMock = () => { + const startContract: jest.Mocked = { + open: jest.fn().mockReturnValue({ + close: jest.fn(), + onClose: Promise.resolve(), + }), + }; + return startContract; +}; + +const createMock = () => { + const mocked: jest.Mocked> = { + start: jest.fn(), + }; + mocked.start.mockReturnValue(createStartContractMock()); + return mocked; +}; + +export const overlayFlyoutServiceMock = { + create: createMock, + createStartContract: createStartContractMock, +}; diff --git a/src/core/public/overlays/flyout.test.tsx b/src/core/public/overlays/flyout/flyout_service.test.tsx similarity index 65% rename from src/core/public/overlays/flyout.test.tsx rename to src/core/public/overlays/flyout/flyout_service.test.tsx index afe37fc50776b..25ba94f577993 100644 --- a/src/core/public/overlays/flyout.test.tsx +++ b/src/core/public/overlays/flyout/flyout_service.test.tsx @@ -16,11 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import { mockReactDomRender, mockReactDomUnmount } from './flyout.test.mocks'; +import { mockReactDomRender, mockReactDomUnmount } from '../overlay.test.mocks'; -import React from 'react'; -import { i18nServiceMock } from '../i18n/i18n_service.mock'; -import { FlyoutRef, FlyoutService } from './flyout'; +import { mount } from 'enzyme'; +import { i18nServiceMock } from '../../i18n/i18n_service.mock'; +import { FlyoutService, OverlayFlyoutStart } from './flyout_service'; +import { OverlayRef } from '../types'; const i18nMock = i18nServiceMock.createStartContract(); @@ -29,35 +30,50 @@ beforeEach(() => { mockReactDomUnmount.mockClear(); }); +const mountText = (text: string) => (container: HTMLElement) => { + const content = document.createElement('span'); + content.textContent = text; + container.append(content); + return () => {}; +}; + +const getServiceStart = () => { + const service = new FlyoutService(); + return service.start({ i18n: i18nMock, targetDomElement: document.createElement('div') }); +}; + describe('FlyoutService', () => { + let flyouts: OverlayFlyoutStart; + beforeEach(() => { + flyouts = getServiceStart(); + }); + 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, Flyout content); + flyouts.open(mountText('Flyout content')); expect(mockReactDomRender.mock.calls).toMatchSnapshot(); + const modalContent = mount(mockReactDomRender.mock.calls[0][0]); + expect(modalContent.html()).toMatchSnapshot(); }); describe('with a currently active flyout', () => { - let target: HTMLElement; - let flyoutService: FlyoutService; - let ref1: FlyoutRef; + let ref1: OverlayRef; beforeEach(() => { - target = document.createElement('div'); - flyoutService = new FlyoutService(target); - ref1 = flyoutService.openFlyout(i18nMock, Flyout content 1); + ref1 = flyouts.open(mountText('Flyout content')); }); it('replaces the current flyout with a new one', () => { - flyoutService.openFlyout(i18nMock, Flyout content 2); + flyouts.open(mountText('Flyout content 2')); expect(mockReactDomRender.mock.calls).toMatchSnapshot(); expect(mockReactDomUnmount).toHaveBeenCalledTimes(1); + const modalContent = mount(mockReactDomRender.mock.calls[1][0]); + expect(modalContent.html()).toMatchSnapshot(); 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, Flyout content 2); + flyouts.open(mountText('Flyout content 2')); await ref1.onClose; expect(onCloseComplete).toBeCalledTimes(1); }); @@ -65,9 +81,7 @@ describe('FlyoutService', () => { }); describe('FlyoutRef#close()', () => { it('resolves the onClose Promise', async () => { - const target = document.createElement('div'); - const flyoutService = new FlyoutService(target); - const ref = flyoutService.openFlyout(i18nMock, Flyout content); + const ref = flyouts.open(mountText('Flyout content')); const onCloseComplete = jest.fn(); ref.onClose.then(onCloseComplete); @@ -76,9 +90,7 @@ describe('FlyoutService', () => { 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, Flyout content); + const ref = flyouts.open(mountText('Flyout content')); expect(mockReactDomUnmount).not.toHaveBeenCalled(); await ref.close(); expect(mockReactDomUnmount.mock.calls).toMatchSnapshot(); @@ -86,10 +98,8 @@ describe('FlyoutService', () => { 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, Flyout content 1); - const ref2 = flyoutService.openFlyout(i18nMock, Flyout content 2); + const ref1 = flyouts.open(mountText('Flyout content 1')); + const ref2 = flyouts.open(mountText('Flyout content 2')); const onCloseComplete = jest.fn(); ref2.onClose.then(onCloseComplete); mockReactDomUnmount.mockClear(); diff --git a/src/core/public/overlays/flyout.tsx b/src/core/public/overlays/flyout/flyout_service.tsx similarity index 57% rename from src/core/public/overlays/flyout.tsx rename to src/core/public/overlays/flyout/flyout_service.tsx index c8ba9c6b284d3..b609b2ce1d741 100644 --- a/src/core/public/overlays/flyout.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -23,8 +23,10 @@ import { EuiFlyout } from '@elastic/eui'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Subject } from 'rxjs'; -import { I18nStart } from '../i18n'; -import { OverlayRef } from './overlay_service'; +import { I18nStart } from '../../i18n'; +import { MountPoint } from '../../types'; +import { OverlayRef } from '../types'; +import { MountWrapper } from '../../utils'; /** * A FlyoutRef is a reference to an opened flyout panel. It offers methods to @@ -37,7 +39,7 @@ import { OverlayRef } from './overlay_service'; * * @public */ -export class FlyoutRef implements OverlayRef { +class FlyoutRef implements OverlayRef { /** * An Promise that will resolve once this flyout is closed. * @@ -66,55 +68,77 @@ export class FlyoutRef implements OverlayRef { } } +/** + * APIs to open and manage fly-out dialogs. + * + * @public + */ +export interface OverlayFlyoutStart { + /** + * Opens a flyout panel with the given mount point inside. You can use + * `close()` on the returned FlyoutRef to close the flyout. + * + * @param mount {@link MountPoint} - Mounts the children inside a flyout panel + * @param options {@link OverlayFlyoutOpenOptions} - options for the flyout + * @return {@link OverlayRef} A reference to the opened flyout panel. + */ + open(mount: MountPoint, options?: OverlayFlyoutOpenOptions): OverlayRef; +} + +/** + * @public + */ +export interface OverlayFlyoutOpenOptions { + className?: string; + closeButtonAriaLabel?: string; + 'data-test-subj'?: string; +} + +interface StartDeps { + i18n: I18nStart; + targetDomElement: Element; +} + /** @internal */ export class FlyoutService { private activeFlyout: FlyoutRef | null = null; + private targetDomElement: Element | null = null; - constructor(private readonly targetDomElement: Element) {} + public start({ i18n, targetDomElement }: StartDeps): OverlayFlyoutStart { + this.targetDomElement = targetDomElement; - /** - * 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: I18nStart, - 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(); - } + return { + open: (mount: MountPoint, options: OverlayFlyoutOpenOptions = {}): OverlayRef => { + // 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(); + 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(); - } - }); + // 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; + this.activeFlyout = flyout; - render( - - flyout.close()}> - {flyoutChildren} - - , - this.targetDomElement - ); + render( + + flyout.close()}> + + + , + this.targetDomElement + ); - return flyout; - }; + return flyout; + }, + }; + } /** * Using React.Render to re-render into a target DOM element will replace @@ -124,8 +148,10 @@ export class FlyoutService { * depend on unmounting for cleanup behaviour. */ private cleanupDom(): void { - unmountComponentAtNode(this.targetDomElement); - this.targetDomElement.innerHTML = ''; + if (this.targetDomElement != null) { + unmountComponentAtNode(this.targetDomElement); + this.targetDomElement.innerHTML = ''; + } this.activeFlyout = null; } } diff --git a/src/core/public/overlays/flyout/index.ts b/src/core/public/overlays/flyout/index.ts new file mode 100644 index 0000000000000..b01cc3af5fa38 --- /dev/null +++ b/src/core/public/overlays/flyout/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { FlyoutService, OverlayFlyoutStart, OverlayFlyoutOpenOptions } from './flyout_service'; diff --git a/src/core/public/overlays/index.ts b/src/core/public/overlays/index.ts index ff03e5dffb2ca..417486f78f719 100644 --- a/src/core/public/overlays/index.ts +++ b/src/core/public/overlays/index.ts @@ -17,5 +17,8 @@ * under the License. */ +export { OverlayRef } from './types'; export { OverlayBannersStart } from './banners'; -export { OverlayService, OverlayStart, OverlayRef } from './overlay_service'; +export { OverlayFlyoutStart, OverlayFlyoutOpenOptions } from './flyout'; +export { OverlayModalStart, OverlayModalOpenOptions } from './modal'; +export { OverlayService, OverlayStart } from './overlay_service'; diff --git a/src/core/public/overlays/modal.tsx b/src/core/public/overlays/modal.tsx deleted file mode 100644 index 6f94788b84d71..0000000000000 --- a/src/core/public/overlays/modal.tsx +++ /dev/null @@ -1,122 +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. - */ - -/* eslint-disable max-classes-per-file */ - -import { EuiModal, EuiOverlayMask } from '@elastic/eui'; -import React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import { Subject } from 'rxjs'; -import { I18nStart } from '../i18n'; -import { OverlayRef } from './overlay_service'; - -/** - * A ModalRef is a reference to an opened modal. It offers methods to - * close the modal. - * - * @public - */ -export class ModalRef implements OverlayRef { - public readonly onClose: Promise; - - private closeSubject = new Subject(); - - constructor() { - this.onClose = this.closeSubject.toPromise(); - } - - /** - * Closes the referenced modal if it's still open which in turn will - * resolve the `onClose` Promise. If the modal had already been - * closed this method does nothing. - */ - public close(): Promise { - if (!this.closeSubject.closed) { - this.closeSubject.next(); - this.closeSubject.complete(); - } - return this.onClose; - } -} - -/** @internal */ -export class ModalService { - private activeModal: ModalRef | 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 openModal = ( - i18n: I18nStart, - modalChildren: React.ReactNode, - modalProps: { - closeButtonAriaLabel?: string; - 'data-test-subj'?: string; - } = {} - ): ModalRef => { - // If there is an active flyout session close it before opening a new one. - if (this.activeModal) { - this.activeModal.close(); - this.cleanupDom(); - } - - const modal = new ModalRef(); - - // If a modal gets closed through it's ModalRef, remove it from the dom - modal.onClose.then(() => { - if (this.activeModal === modal) { - this.cleanupDom(); - } - }); - - this.activeModal = modal; - - render( - - - modal.close()}> - {modalChildren} - - - , - this.targetDomElement - ); - - return modal; - }; - - /** - * 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.activeModal = null; - } -} diff --git a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap new file mode 100644 index 0000000000000..3928c54f90179 --- /dev/null +++ b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ModalService ModalRef#close() can be called multiple times on the same ModalRef 1`] = ` +Array [ + Array [ +
, + ], +] +`; + +exports[`ModalService openModal() renders a modal to the DOM 1`] = ` +Array [ + Array [ + + + + + + + , +
, + ], +] +`; + +exports[`ModalService openModal() renders a modal to the DOM 2`] = `"
Modal content
"`; + +exports[`ModalService openModal() with a currently active modal replaces the current modal with a new one 1`] = ` +Array [ + Array [ + + + + + + + , +
, + ], + Array [ + + + + + + + , +
, + ], +] +`; diff --git a/src/core/public/overlays/modal/index.ts b/src/core/public/overlays/modal/index.ts new file mode 100644 index 0000000000000..9ef4492af3a3a --- /dev/null +++ b/src/core/public/overlays/modal/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { ModalService, OverlayModalStart, OverlayModalOpenOptions } from './modal_service'; diff --git a/src/core/public/overlays/modal/modal_service.mock.ts b/src/core/public/overlays/modal/modal_service.mock.ts new file mode 100644 index 0000000000000..726209b8f277c --- /dev/null +++ b/src/core/public/overlays/modal/modal_service.mock.ts @@ -0,0 +1,43 @@ +/* + * 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 { ModalService, OverlayModalStart } from './modal_service'; + +const createStartContractMock = () => { + const startContract: jest.Mocked = { + open: jest.fn().mockReturnValue({ + close: jest.fn(), + onClose: Promise.resolve(), + }), + }; + return startContract; +}; + +const createMock = () => { + const mocked: jest.Mocked> = { + start: jest.fn(), + }; + mocked.start.mockReturnValue(createStartContractMock()); + return mocked; +}; + +export const overlayModalServiceMock = { + create: createMock, + createStartContract: createStartContractMock, +}; diff --git a/src/core/public/overlays/modal.test.tsx b/src/core/public/overlays/modal/modal_service.test.tsx similarity index 65% rename from src/core/public/overlays/modal.test.tsx rename to src/core/public/overlays/modal/modal_service.test.tsx index ee8bba5d61112..582c2697aef30 100644 --- a/src/core/public/overlays/modal.test.tsx +++ b/src/core/public/overlays/modal/modal_service.test.tsx @@ -16,11 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { mockReactDomRender, mockReactDomUnmount } from './flyout.test.mocks'; +import { mockReactDomRender, mockReactDomUnmount } from '../overlay.test.mocks'; import React from 'react'; -import { i18nServiceMock } from '../i18n/i18n_service.mock'; -import { ModalService, ModalRef } from './modal'; +import { mount } from 'enzyme'; +import { i18nServiceMock } from '../../i18n/i18n_service.mock'; +import { ModalService, OverlayModalStart } from './modal_service'; +import { mountReactNode } from '../../utils'; +import { OverlayRef } from '../types'; const i18nMock = i18nServiceMock.createStartContract(); @@ -29,45 +32,59 @@ beforeEach(() => { mockReactDomUnmount.mockClear(); }); +const getServiceStart = () => { + const service = new ModalService(); + return service.start({ i18n: i18nMock, targetDomElement: document.createElement('div') }); +}; + describe('ModalService', () => { + let modals: OverlayModalStart; + beforeEach(() => { + modals = getServiceStart(); + }); + describe('openModal()', () => { it('renders a modal to the DOM', () => { - const target = document.createElement('div'); - const modalService = new ModalService(target); expect(mockReactDomRender).not.toHaveBeenCalled(); - modalService.openModal(i18nMock, Modal content); + modals.open(container => { + const content = document.createElement('span'); + content.textContent = 'Modal content'; + container.append(content); + return () => {}; + }); expect(mockReactDomRender.mock.calls).toMatchSnapshot(); + const modalContent = mount(mockReactDomRender.mock.calls[0][0]); + expect(modalContent.html()).toMatchSnapshot(); }); + describe('with a currently active modal', () => { - let target: HTMLElement; - let modalService: ModalService; - let ref1: ModalRef; + let ref1: OverlayRef; + beforeEach(() => { - target = document.createElement('div'); - modalService = new ModalService(target); - ref1 = modalService.openModal(i18nMock, Modal content 1); + ref1 = modals.open(mountReactNode(Modal content 1)); }); + it('replaces the current modal with a new one', () => { - modalService.openModal(i18nMock, Flyout content 2); + modals.open(mountReactNode(Flyout content 2)); 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); - modalService.openModal(i18nMock, Flyout content 2); + modals.open(mountReactNode(Flyout content 2)); await ref1.onClose; expect(onCloseComplete).toBeCalledTimes(1); }); }); }); + describe('ModalRef#close()', () => { it('resolves the onClose Promise', async () => { - const target = document.createElement('div'); - const modalService = new ModalService(target); - const ref = modalService.openModal(i18nMock, Flyout content); + const ref = modals.open(mountReactNode(Flyout content)); const onCloseComplete = jest.fn(); ref.onClose.then(onCloseComplete); @@ -75,21 +92,19 @@ describe('ModalService', () => { await ref.close(); expect(onCloseComplete).toHaveBeenCalledTimes(1); }); + it('can be called multiple times on the same ModalRef', async () => { - const target = document.createElement('div'); - const modalService = new ModalService(target); - const ref = modalService.openModal(i18nMock, Flyout content); + const ref = modals.open(mountReactNode(Flyout content)); expect(mockReactDomUnmount).not.toHaveBeenCalled(); await ref.close(); expect(mockReactDomUnmount.mock.calls).toMatchSnapshot(); await ref.close(); expect(mockReactDomUnmount).toHaveBeenCalledTimes(1); }); + it("on a stale ModalRef doesn't affect the active flyout", async () => { - const target = document.createElement('div'); - const modalService = new ModalService(target); - const ref1 = modalService.openModal(i18nMock, Modal content 1); - const ref2 = modalService.openModal(i18nMock, Modal content 2); + const ref1 = modals.open(mountReactNode(Modal content 1)); + const ref2 = modals.open(mountReactNode(Modal content 2)); const onCloseComplete = jest.fn(); ref2.onClose.then(onCloseComplete); mockReactDomUnmount.mockClear(); diff --git a/src/core/public/overlays/modal/modal_service.tsx b/src/core/public/overlays/modal/modal_service.tsx new file mode 100644 index 0000000000000..cb77c2ec4c88c --- /dev/null +++ b/src/core/public/overlays/modal/modal_service.tsx @@ -0,0 +1,148 @@ +/* + * 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 { EuiModal, EuiOverlayMask } from '@elastic/eui'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { Subject } from 'rxjs'; +import { I18nStart } from '../../i18n'; +import { MountPoint } from '../../types'; +import { OverlayRef } from '../types'; +import { MountWrapper } from '../../utils'; + +/** + * A ModalRef is a reference to an opened modal. It offers methods to + * close the modal. + * + * @public + */ +class ModalRef implements OverlayRef { + public readonly onClose: Promise; + + private closeSubject = new Subject(); + + constructor() { + this.onClose = this.closeSubject.toPromise(); + } + + /** + * Closes the referenced modal if it's still open which in turn will + * resolve the `onClose` Promise. If the modal had already been + * closed this method does nothing. + */ + public close(): Promise { + if (!this.closeSubject.closed) { + this.closeSubject.next(); + this.closeSubject.complete(); + } + return this.onClose; + } +} + +/** + * APIs to open and manage modal dialogs. + * + * @public + */ +export interface OverlayModalStart { + /** + * Opens a modal panel with the given mount point inside. You can use + * `close()` on the returned OverlayRef to close the modal. + * + * @param mount {@link MountPoint} - Mounts the children inside the modal + * @param options {@link OverlayModalOpenOptions} - options for the modal + * @return {@link OverlayRef} A reference to the opened modal. + */ + open(mount: MountPoint, options?: OverlayModalOpenOptions): OverlayRef; +} + +/** + * @public + */ +export interface OverlayModalOpenOptions { + className?: string; + closeButtonAriaLabel?: string; + 'data-test-subj'?: string; +} + +interface StartDeps { + i18n: I18nStart; + targetDomElement: Element; +} + +/** @internal */ +export class ModalService { + private activeModal: ModalRef | null = null; + private targetDomElement: Element | null = null; + + public start({ i18n, targetDomElement }: StartDeps): OverlayModalStart { + this.targetDomElement = targetDomElement; + + return { + open: (mount: MountPoint, options: OverlayModalOpenOptions = {}): OverlayRef => { + // If there is an active flyout session close it before opening a new one. + if (this.activeModal) { + this.activeModal.close(); + this.cleanupDom(); + } + + const modal = new ModalRef(); + + // If a modal gets closed through it's ModalRef, remove it from the dom + modal.onClose.then(() => { + if (this.activeModal === modal) { + this.cleanupDom(); + } + }); + + this.activeModal = modal; + + render( + + + modal.close()}> + + + + , + targetDomElement + ); + + return modal; + }, + }; + } + + /** + * 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 { + if (this.targetDomElement != null) { + unmountComponentAtNode(this.targetDomElement); + this.targetDomElement.innerHTML = ''; + } + this.activeModal = null; + } +} diff --git a/src/core/public/overlays/flyout.test.mocks.ts b/src/core/public/overlays/overlay.test.mocks.ts similarity index 88% rename from src/core/public/overlays/flyout.test.mocks.ts rename to src/core/public/overlays/overlay.test.mocks.ts index 35a046e960077..563f414a0ae99 100644 --- a/src/core/public/overlays/flyout.test.mocks.ts +++ b/src/core/public/overlays/overlay.test.mocks.ts @@ -19,7 +19,9 @@ export const mockReactDomRender = jest.fn(); export const mockReactDomUnmount = jest.fn(); +export const mockReactDomCreatePortal = jest.fn().mockImplementation(component => component); jest.doMock('react-dom', () => ({ render: mockReactDomRender, + createPortal: mockReactDomCreatePortal, unmountComponentAtNode: mockReactDomUnmount, })); diff --git a/src/core/public/overlays/overlay_service.mock.ts b/src/core/public/overlays/overlay_service.mock.ts index 39abc6f765b97..2937ec89bfc74 100644 --- a/src/core/public/overlays/overlay_service.mock.ts +++ b/src/core/public/overlays/overlay_service.mock.ts @@ -18,17 +18,15 @@ */ import { OverlayService, OverlayStart } from './overlay_service'; import { overlayBannersServiceMock } from './banners/banners_service.mock'; +import { overlayFlyoutServiceMock } from './flyout/flyout_service.mock'; +import { overlayModalServiceMock } from './modal/modal_service.mock'; const createStartContractMock = () => { const startContract: DeeplyMockedKeys = { - openFlyout: jest.fn(), - openModal: jest.fn(), + openFlyout: overlayFlyoutServiceMock.createStartContract().open, + openModal: overlayModalServiceMock.createStartContract().open, banners: overlayBannersServiceMock.createStartContract(), }; - startContract.openModal.mockReturnValue({ - close: jest.fn(), - onClose: Promise.resolve(), - }); return startContract; }; diff --git a/src/core/public/overlays/overlay_service.ts b/src/core/public/overlays/overlay_service.ts index 1a72bb5dbe435..82fe753d6f283 100644 --- a/src/core/public/overlays/overlay_service.ts +++ b/src/core/public/overlays/overlay_service.ts @@ -17,34 +17,11 @@ * under the License. */ -import React from 'react'; - -import { FlyoutService } from './flyout'; -import { ModalService } from './modal'; import { I18nStart } from '../i18n'; -import { OverlayBannersStart, OverlayBannersService } from './banners'; import { UiSettingsClientContract } from '../ui_settings'; - -/** - * Returned by {@link OverlayStart} methods for closing a mounted overlay. - * @public - */ -export interface OverlayRef { - /** - * A Promise that will resolve once this overlay is closed. - * - * Overlays can close from user interaction, calling `close()` on the overlay - * reference or another overlay replacing yours via `openModal` or `openFlyout`. - */ - onClose: Promise; - - /** - * Closes the referenced overlay if it's still open which in turn will - * resolve the `onClose` Promise. If the overlay had already been - * closed this method does nothing. - */ - close(): Promise; -} +import { OverlayBannersStart, OverlayBannersService } from './banners'; +import { FlyoutService, OverlayFlyoutStart } from './flyout'; +import { ModalService, OverlayModalStart } from './modal'; interface StartDeps { i18n: I18nStart; @@ -54,19 +31,25 @@ interface StartDeps { /** @internal */ export class OverlayService { + private bannersService = new OverlayBannersService(); + private modalService = new ModalService(); + private flyoutService = new FlyoutService(); + public start({ i18n, targetDomElement, uiSettings }: StartDeps): OverlayStart { const flyoutElement = document.createElement('div'); - const modalElement = document.createElement('div'); targetDomElement.appendChild(flyoutElement); + const flyouts = this.flyoutService.start({ i18n, targetDomElement: flyoutElement }); + + const banners = this.bannersService.start({ i18n, uiSettings }); + + const modalElement = document.createElement('div'); targetDomElement.appendChild(modalElement); - const flyoutService = new FlyoutService(flyoutElement); - const modalService = new ModalService(modalElement); - const bannersService = new OverlayBannersService(); + const modals = this.modalService.start({ i18n, targetDomElement: modalElement }); return { - banners: bannersService.start({ i18n, uiSettings }), - openFlyout: flyoutService.openFlyout.bind(flyoutService, i18n), - openModal: modalService.openModal.bind(modalService, i18n), + banners, + openFlyout: flyouts.open.bind(flyouts), + openModal: modals.open.bind(modals), }; } } @@ -75,19 +58,8 @@ export class OverlayService { export interface OverlayStart { /** {@link OverlayBannersStart} */ banners: OverlayBannersStart; - openFlyout: ( - flyoutChildren: React.ReactNode, - flyoutProps?: { - closeButtonAriaLabel?: string; - 'data-test-subj'?: string; - } - ) => OverlayRef; - openModal: ( - modalChildren: React.ReactNode, - modalProps?: { - className?: string; - closeButtonAriaLabel?: string; - 'data-test-subj'?: string; - } - ) => OverlayRef; + /** {@link OverlayFlyoutStart#open} */ + openFlyout: OverlayFlyoutStart['open']; + /** {@link OverlayModalStart#open} */ + openModal: OverlayModalStart['open']; } diff --git a/src/core/public/overlays/types.ts b/src/core/public/overlays/types.ts new file mode 100644 index 0000000000000..d5bd01c672d1f --- /dev/null +++ b/src/core/public/overlays/types.ts @@ -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. + */ + +/** + * Returned by {@link OverlayStart} methods for closing a mounted overlay. + * @public + */ +export interface OverlayRef { + /** + * A Promise that will resolve once this overlay is closed. + * + * Overlays can close from user interaction, calling `close()` on the overlay + * reference or another overlay replacing yours via `openModal` or `openFlyout`. + */ + onClose: Promise; + + /** + * Closes the referenced overlay if it's still open which in turn will + * resolve the `onClose` Promise. If the overlay had already been + * closed this method does nothing. + */ + close(): Promise; +} diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 1e97d8e066d09..7abbbcd32fbb8 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -124,7 +124,7 @@ export type ChromeHelpExtension = (element: HTMLDivElement) => () => void; // @public (undocumented) export interface ChromeNavControl { // (undocumented) - mount(targetDomElement: HTMLElement): () => void; + mount: MountPoint; // (undocumented) order?: number; } @@ -620,7 +620,7 @@ export interface LegacyNavLink { } // @public -export type MountPoint = (element: HTMLElement) => UnmountCallback; +export type MountPoint = (element: T) => UnmountCallback; // @public (undocumented) export interface NotificationsSetup { @@ -657,17 +657,14 @@ export interface OverlayRef { export interface OverlayStart { // (undocumented) banners: OverlayBannersStart; + // Warning: (ae-forgotten-export) The symbol "OverlayFlyoutStart" needs to be exported by the entry point index.d.ts + // // (undocumented) - openFlyout: (flyoutChildren: React.ReactNode, flyoutProps?: { - closeButtonAriaLabel?: string; - 'data-test-subj'?: string; - }) => OverlayRef; + openFlyout: OverlayFlyoutStart['open']; + // Warning: (ae-forgotten-export) The symbol "OverlayModalStart" needs to be exported by the entry point index.d.ts + // // (undocumented) - openModal: (modalChildren: React.ReactNode, modalProps?: { - className?: string; - closeButtonAriaLabel?: string; - 'data-test-subj'?: string; - }) => OverlayRef; + openModal: OverlayModalStart['open']; } // @public (undocumented) @@ -941,9 +938,12 @@ export class ToastsApi implements IToasts { addSuccess(toastOrTitle: ToastInput): Toast; addWarning(toastOrTitle: ToastInput): Toast; get$(): Rx.Observable; - // @internal (undocumented) - registerOverlays(overlays: OverlayStart): void; remove(toastOrId: Toast | string): void; + // @internal (undocumented) + start({ overlays, i18n }: { + overlays: OverlayStart; + i18n: I18nStart; + }): void; } // @public (undocumented) diff --git a/src/core/public/types.ts b/src/core/public/types.ts index 4b12d5bc6da51..5abbb3c55813a 100644 --- a/src/core/public/types.ts +++ b/src/core/public/types.ts @@ -26,7 +26,7 @@ * * @public */ -export type MountPoint = (element: HTMLElement) => UnmountCallback; +export type MountPoint = (element: T) => UnmountCallback; /** * A function that will unmount the element previously mounted by diff --git a/src/core/public/utils/mount.test.tsx b/src/core/public/utils/mount.test.tsx new file mode 100644 index 0000000000000..1cacb7d6a796c --- /dev/null +++ b/src/core/public/utils/mount.test.tsx @@ -0,0 +1,79 @@ +/* + * 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 { mount } from 'enzyme'; +import { MountWrapper, mountReactNode } from './mount'; + +describe('MountWrapper', () => { + it('renders an html element in react tree', () => { + const mountPoint = (container: HTMLElement) => { + const el = document.createElement('p'); + el.textContent = 'hello'; + el.className = 'bar'; + container.append(el); + return () => {}; + }; + const wrapper = ; + const container = mount(wrapper); + expect(container.html()).toMatchInlineSnapshot( + `"

hello

"` + ); + }); + + it('updates the react tree when the mounted element changes', () => { + const el = document.createElement('p'); + el.textContent = 'initial'; + + const mountPoint = (container: HTMLElement) => { + container.append(el); + return () => {}; + }; + + const wrapper = ; + const container = mount(wrapper); + expect(container.html()).toMatchInlineSnapshot( + `"

initial

"` + ); + + el.textContent = 'changed'; + container.update(); + expect(container.html()).toMatchInlineSnapshot( + `"

changed

"` + ); + }); + + it('can render a detached react component', () => { + const mountPoint = mountReactNode(detached); + const wrapper = ; + const container = mount(wrapper); + expect(container.html()).toMatchInlineSnapshot( + `"
detached
"` + ); + }); + + it('accepts a className prop to override default className', () => { + const mountPoint = mountReactNode(detached); + const wrapper = ; + const container = mount(wrapper); + expect(container.html()).toMatchInlineSnapshot( + `"
detached
"` + ); + }); +}); diff --git a/src/core/public/utils/mount.tsx b/src/core/public/utils/mount.tsx index dbd7d5da435a6..0fee67a6e7fbc 100644 --- a/src/core/public/utils/mount.tsx +++ b/src/core/public/utils/mount.tsx @@ -22,23 +22,26 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { MountPoint } from '../types'; +const defaultWrapperClass = 'kbnMountWrapper'; + /** * MountWrapper is a react component to mount a {@link MountPoint} inside a react tree. */ -export const MountWrapper: React.FunctionComponent<{ mount: MountPoint }> = ({ mount }) => { +export const MountWrapper: React.FunctionComponent<{ mount: MountPoint; className?: string }> = ({ + mount, + className = defaultWrapperClass, +}) => { const element = useRef(null); useEffect(() => mount(element.current!), [mount]); - return
; + return
; }; /** - * Mount converter for react components. + * Mount converter for react node. * - * @param component to get a mount for + * @param node to get a mount for */ -export const mountReactNode = (component: React.ReactNode): MountPoint => ( - element: HTMLElement -) => { - render({component}, element); +export const mountReactNode = (node: React.ReactNode): MountPoint => (element: HTMLElement) => { + render({node}, element); return () => unmountComponentAtNode(element); }; diff --git a/src/legacy/core_plugins/data/public/filter/action/apply_filter_action.ts b/src/legacy/core_plugins/data/public/filter/action/apply_filter_action.ts index abe9ec6d6e873..39ec1f78b65f0 100644 --- a/src/legacy/core_plugins/data/public/filter/action/apply_filter_action.ts +++ b/src/legacy/core_plugins/data/public/filter/action/apply_filter_action.ts @@ -19,6 +19,7 @@ import { i18n } from '@kbn/i18n'; import { CoreStart } from 'src/core/public'; +import { toMountPoint } from '../../../../../../plugins/kibana_react/public'; import { IAction, createAction, @@ -79,17 +80,19 @@ export function createFilterAction( const filterSelectionPromise: Promise = new Promise(resolve => { const overlay = overlays.openModal( - applyFiltersPopover( - filters, - indexPatterns, - () => { - overlay.close(); - resolve([]); - }, - (filterSelection: esFilters.Filter[]) => { - overlay.close(); - resolve(filterSelection); - } + toMountPoint( + applyFiltersPopover( + filters, + indexPatterns, + () => { + overlay.close(); + resolve([]); + }, + (filterSelection: esFilters.Filter[]) => { + overlay.close(); + resolve(filterSelection); + } + ) ), { 'data-test-subj': 'test', diff --git a/src/legacy/ui/public/courier/fetch/components/shard_failure_open_modal_button.tsx b/src/legacy/ui/public/courier/fetch/components/shard_failure_open_modal_button.tsx index 5e53477b8ec04..b02344ce6dd72 100644 --- a/src/legacy/ui/public/courier/fetch/components/shard_failure_open_modal_button.tsx +++ b/src/legacy/ui/public/courier/fetch/components/shard_failure_open_modal_button.tsx @@ -22,6 +22,7 @@ import { npStart } from 'ui/new_platform'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiTextAlign } from '@elastic/eui'; +import { toMountPoint } from '../../../../../../plugins/kibana_react/public'; import { ShardFailureModal } from './shard_failure_modal'; import { ResponseWithShardFailure, Request } from './shard_failure_types'; @@ -34,12 +35,14 @@ interface Props { export function ShardFailureOpenModalButton({ request, response, title }: Props) { function onClick() { const modal = npStart.core.overlays.openModal( - modal.close()} - />, + toMountPoint( + modal.close()} + /> + ), { className: 'shardFailureModal', } diff --git a/src/plugins/dashboard_embeddable_container/public/actions/open_replace_panel_flyout.tsx b/src/plugins/dashboard_embeddable_container/public/actions/open_replace_panel_flyout.tsx index cb354375e7a68..b30733760bbdf 100644 --- a/src/plugins/dashboard_embeddable_container/public/actions/open_replace_panel_flyout.tsx +++ b/src/plugins/dashboard_embeddable_container/public/actions/open_replace_panel_flyout.tsx @@ -18,6 +18,7 @@ */ import React from 'react'; import { CoreStart } from '../../../../core/public'; +import { toMountPoint } from '../../../../plugins/kibana_react/public'; import { ReplacePanelFlyout } from './replace_panel_flyout'; import { IEmbeddable, @@ -44,18 +45,20 @@ export async function openReplacePanelFlyout(options: { getEmbeddableFactories, } = options; const flyoutSession = core.overlays.openFlyout( - { - if (flyoutSession) { - flyoutSession.close(); - } - }} - panelToRemove={panelToRemove} - savedObjectsFinder={savedObjectFinder} - notifications={notifications} - getEmbeddableFactories={getEmbeddableFactories} - />, + toMountPoint( + { + if (flyoutSession) { + flyoutSession.close(); + } + }} + panelToRemove={panelToRemove} + savedObjectsFinder={savedObjectFinder} + notifications={notifications} + getEmbeddableFactories={getEmbeddableFactories} + /> + ), { 'data-test-subj': 'replacePanelFlyout', } diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 8a8a2f44b7fd8..33be01529c631 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -25,7 +25,8 @@ import { TGetActionsCompatibleWithTrigger, IAction, } from '../ui_actions'; -import { CoreStart } from '../../../../../core/public'; +import { CoreStart, OverlayStart } from '../../../../../core/public'; +import { toMountPoint } from '../../../../kibana_react/public'; import { Start as InspectorStartContract } from '../inspector'; import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER } from '../triggers'; @@ -200,17 +201,19 @@ export class EmbeddablePanel extends React.Component { embeddable: this.props.embeddable, }); - const createGetUserData = (overlays: CoreStart['overlays']) => + const createGetUserData = (overlays: OverlayStart) => async function getUserData(context: { embeddable: IEmbeddable }) { return new Promise<{ title: string | undefined }>(resolve => { const session = overlays.openModal( - { - session.close(); - resolve({ title }); - }} - />, + toMountPoint( + { + session.close(); + resolve({ title }); + }} + /> + ), { 'data-test-subj': 'customizePanel', } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts index 3ca3a0864d9f1..9ecc4686c21b6 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts @@ -18,8 +18,7 @@ */ import { i18n } from '@kbn/i18n'; import { IAction } from 'src/plugins/ui_actions/public'; -import { NotificationsStart } from 'src/core/public'; -import { KibanaReactOverlays } from 'src/plugins/kibana_react/public'; +import { NotificationsStart, OverlayStart } from 'src/core/public'; import { ViewMode, GetEmbeddableFactory, GetEmbeddableFactories } from '../../../../types'; import { openAddPanelFlyout } from './open_add_panel_flyout'; import { IContainer } from '../../../../containers'; @@ -37,7 +36,7 @@ export class AddPanelAction implements IAction { constructor( private readonly getFactory: GetEmbeddableFactory, private readonly getAllFactories: GetEmbeddableFactories, - private readonly overlays: KibanaReactOverlays, + private readonly overlays: OverlayStart, private readonly notifications: NotificationsStart, private readonly SavedObjectFinder: React.ComponentType ) {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx index bfa4f6e31d84e..481693501066c 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/open_add_panel_flyout.tsx @@ -17,8 +17,8 @@ * under the License. */ import React from 'react'; -import { NotificationsStart } from 'src/core/public'; -import { KibanaReactOverlays } from 'src/plugins/kibana_react/public'; +import { NotificationsStart, OverlayStart } from 'src/core/public'; +import { toMountPoint } from '../../../../../../../kibana_react/public'; import { IContainer } from '../../../../containers'; import { AddPanelFlyout } from './add_panel_flyout'; import { GetEmbeddableFactory, GetEmbeddableFactories } from '../../../../types'; @@ -27,7 +27,7 @@ export async function openAddPanelFlyout(options: { embeddable: IContainer; getFactory: GetEmbeddableFactory; getAllFactories: GetEmbeddableFactories; - overlays: KibanaReactOverlays; + overlays: OverlayStart; notifications: NotificationsStart; SavedObjectFinder: React.ComponentType; }) { @@ -40,18 +40,20 @@ export async function openAddPanelFlyout(options: { SavedObjectFinder, } = options; const flyoutSession = overlays.openFlyout( - { - if (flyoutSession) { - flyoutSession.close(); - } - }} - getFactory={getFactory} - getAllFactories={getAllFactories} - notifications={notifications} - SavedObjectFinder={SavedObjectFinder} - />, + toMountPoint( + { + if (flyoutSession) { + flyoutSession.close(); + } + }} + getFactory={getFactory} + getAllFactories={getAllFactories} + notifications={notifications} + SavedObjectFinder={SavedObjectFinder} + /> + ), { 'data-test-subj': 'addPanelFlyout', } diff --git a/src/plugins/embeddable/public/lib/test_samples/actions/send_message_action.tsx b/src/plugins/embeddable/public/lib/test_samples/actions/send_message_action.tsx index fc20a99987484..502269d7ac193 100644 --- a/src/plugins/embeddable/public/lib/test_samples/actions/send_message_action.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/actions/send_message_action.tsx @@ -20,6 +20,7 @@ import React from 'react'; import { EuiFlyoutBody } from '@elastic/eui'; import { createAction, IncompatibleActionError } from '../../ui_actions'; import { CoreStart } from '../../../../../../core/public'; +import { toMountPoint } from '../../../../../kibana_react/public'; import { Embeddable, EmbeddableInput } from '../../embeddables'; import { GetMessageModal } from './get_message_modal'; import { FullNameEmbeddableOutput, hasFullNameOutput } from './say_hello_action'; @@ -38,7 +39,7 @@ export function createSendMessageAction(overlays: CoreStart['overlays']) { const greeting = `Hello, ${context.embeddable.getOutput().fullName}`; const content = message ? `${greeting}. ${message}` : greeting; - overlays.openFlyout({content}); + overlays.openFlyout(toMountPoint({content})); }; return createAction({ @@ -51,13 +52,15 @@ export function createSendMessageAction(overlays: CoreStart['overlays']) { } const modal = overlays.openModal( - modal.close()} - onDone={message => { - modal.close(); - sendMessage(context, message); - }} - /> + toMountPoint( + modal.close()} + onDone={message => { + modal.close(); + sendMessage(context, message); + }} + /> + ) ); }, }); diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx index 962cddfa3735f..d1eea5d67fb41 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx @@ -22,6 +22,7 @@ import { i18n } from '@kbn/i18n'; import { TExecuteTriggerActions } from 'src/plugins/ui_actions/public'; import { CoreStart } from 'src/core/public'; +import { toMountPoint } from '../../../../../../kibana_react/public'; import { EmbeddableFactory } from '../../../embeddables'; import { Container } from '../../../containers'; import { ContactCardEmbeddable, ContactCardEmbeddableInput } from './contact_card_embeddable'; @@ -54,16 +55,18 @@ export class ContactCardEmbeddableFactory extends EmbeddableFactory> { return new Promise(resolve => { const modalSession = this.overlays.openModal( - { - modalSession.close(); - resolve(undefined); - }} - onCreate={(input: { firstName: string; lastName?: string }) => { - modalSession.close(); - resolve(input); - }} - />, + toMountPoint( + { + modalSession.close(); + resolve(undefined); + }} + onCreate={(input: { firstName: string; lastName?: string }) => { + modalSession.close(); + resolve(input); + }} + /> + ), { 'data-test-subj': 'createContactCardEmbeddable', } diff --git a/src/plugins/inspector/public/plugin.tsx b/src/plugins/inspector/public/plugin.tsx index 00714c5a8205c..cc9f2404d802f 100644 --- a/src/plugins/inspector/public/plugin.tsx +++ b/src/plugins/inspector/public/plugin.tsx @@ -20,6 +20,7 @@ import { i18n } from '@kbn/i18n'; import * as React from 'react'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { toMountPoint } from '../../kibana_react/public'; import { InspectorViewRegistry } from './view_registry'; import { Adapters, InspectorOptions, InspectorSession } from './types'; import { InspectorPanel } from './ui/inspector_panel'; @@ -99,7 +100,7 @@ export class InspectorPublicPlugin implements Plugin { } return core.overlays.openFlyout( - , + toMountPoint(), { 'data-test-subj': 'inspectorPanel', closeButtonAriaLabel: closeButtonLabel, diff --git a/src/plugins/kibana_react/public/overlays/create_react_overlays.test.tsx b/src/plugins/kibana_react/public/overlays/create_react_overlays.test.tsx index b10cbbc87be7c..c4981c72f1a78 100644 --- a/src/plugins/kibana_react/public/overlays/create_react_overlays.test.tsx +++ b/src/plugins/kibana_react/public/overlays/create_react_overlays.test.tsx @@ -48,13 +48,11 @@ test('can open flyout with React element', () => { overlays.openFlyout(
foo
); expect(coreOverlays.openFlyout).toHaveBeenCalledTimes(1); - expect(coreOverlays.openFlyout.mock.calls[0][0]).toMatchInlineSnapshot(` - -
- foo -
-
- `); + + const container = document.createElement('div'); + const mount = coreOverlays.openFlyout.mock.calls[0][0]; + mount(container); + expect(container.innerHTML).toMatchInlineSnapshot(`"
foo
"`); }); test('can open modal with React element', () => { @@ -68,13 +66,10 @@ test('can open modal with React element', () => { overlays.openModal(
bar
); expect(coreOverlays.openModal).toHaveBeenCalledTimes(1); - expect(coreOverlays.openModal.mock.calls[0][0]).toMatchInlineSnapshot(` - -
- bar -
-
- `); + const container = document.createElement('div'); + const mount = coreOverlays.openModal.mock.calls[0][0]; + mount(container); + expect(container.innerHTML).toMatchInlineSnapshot(`"
bar
"`); }); test('passes through flyout options when opening flyout', () => { diff --git a/src/plugins/kibana_react/public/overlays/create_react_overlays.tsx b/src/plugins/kibana_react/public/overlays/create_react_overlays.tsx index a62c0970cf525..6d7b34128f3fe 100644 --- a/src/plugins/kibana_react/public/overlays/create_react_overlays.tsx +++ b/src/plugins/kibana_react/public/overlays/create_react_overlays.tsx @@ -20,6 +20,7 @@ import * as React from 'react'; import { KibanaServices } from '../context/types'; import { KibanaReactOverlays } from './types'; +import { toMountPoint } from '../util'; export const createReactOverlays = (services: KibanaServices): KibanaReactOverlays => { const checkCoreService = () => { @@ -30,12 +31,12 @@ export const createReactOverlays = (services: KibanaServices): KibanaReactOverla const openFlyout: KibanaReactOverlays['openFlyout'] = (node, options?) => { checkCoreService(); - return services.overlays!.openFlyout(<>{node}, options); + return services.overlays!.openFlyout(toMountPoint(<>{node}), options); }; const openModal: KibanaReactOverlays['openModal'] = (node, options?) => { checkCoreService(); - return services.overlays!.openModal(<>{node}, options); + return services.overlays!.openModal(toMountPoint(<>{node}), options); }; const overlays: KibanaReactOverlays = { diff --git a/src/plugins/kibana_react/public/overlays/types.ts b/src/plugins/kibana_react/public/overlays/types.ts index 0108d8eaba6cc..9822e80376d94 100644 --- a/src/plugins/kibana_react/public/overlays/types.ts +++ b/src/plugins/kibana_react/public/overlays/types.ts @@ -27,6 +27,6 @@ export interface KibanaReactOverlays { ) => ReturnType; openModal: ( node: React.ReactNode, - options?: Parameters['1'] + options?: Parameters['1'] ) => ReturnType; } diff --git a/src/plugins/ui_actions/public/tests/test_samples/hello_world_action.tsx b/src/plugins/ui_actions/public/tests/test_samples/hello_world_action.tsx index 88a0c4ca08145..9e3d206c9a6dc 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/hello_world_action.tsx +++ b/src/plugins/ui_actions/public/tests/test_samples/hello_world_action.tsx @@ -21,6 +21,7 @@ import React from 'react'; import { EuiFlyout } from '@elastic/eui'; import { CoreStart } from 'src/core/public'; import { createAction, IAction } from '../../actions'; +import { toMountPoint } from '../../../../kibana_react/public'; export const HELLO_WORLD_ACTION_ID = 'HELLO_WORLD_ACTION_ID'; @@ -29,9 +30,11 @@ export function createHelloWorldAction(overlays: CoreStart['overlays']): IAction type: HELLO_WORLD_ACTION_ID, execute: async () => { const flyoutSession = overlays.openFlyout( - flyoutSession && flyoutSession.close()}> - Hello World, I am a hello world action! - , + toMountPoint( + flyoutSession && flyoutSession.close()}> + Hello World, I am a hello world action! + + ), { 'data-test-subj': 'helloWorldAction', } diff --git a/src/plugins/ui_actions/public/tests/test_samples/say_hello_action.tsx b/src/plugins/ui_actions/public/tests/test_samples/say_hello_action.tsx index 3c4ecfb6e7c8a..e984dd8fb64cc 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/say_hello_action.tsx +++ b/src/plugins/ui_actions/public/tests/test_samples/say_hello_action.tsx @@ -21,6 +21,7 @@ import React from 'react'; import { EuiFlyout } from '@elastic/eui'; import { CoreStart } from 'src/core/public'; import { IAction, createAction } from '../../actions'; +import { toMountPoint } from '../../../../kibana_react/public'; export const SAY_HELLO_ACTION = 'SAY_HELLO_ACTION'; @@ -31,9 +32,11 @@ export function createSayHelloAction(overlays: CoreStart['overlays']): IAction<{ isCompatible: async ({ name }) => name !== undefined, execute: async context => { const flyoutSession = overlays.openFlyout( - flyoutSession && flyoutSession.close()}> - this.getDisplayName(context) - , + toMountPoint( + flyoutSession && flyoutSession.close()}> + this.getDisplayName(context) + + ), { 'data-test-subj': 'sayHelloAction', } diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx index 71724595d462a..4ce748e2c7118 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx @@ -22,6 +22,7 @@ import { npStart, npSetup } from 'ui/new_platform'; import { CONTEXT_MENU_TRIGGER, IEmbeddable } from '../../../../../src/plugins/embeddable/public'; import { createAction } from '../../../../../src/plugins/ui_actions/public'; +import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; interface ActionContext { embeddable: IEmbeddable; @@ -36,16 +37,18 @@ function createSamplePanelAction() { return; } npStart.core.overlays.openFlyout( - - - -

{embeddable.getTitle()}

-
-
- -

This is a sample action

-
-
, + toMountPoint( + + + +

{embeddable.getTitle()}

+
+
+ +

This is a sample action

+
+
+ ), { 'data-test-subj': 'samplePanelActionFlyout', } diff --git a/x-pack/legacy/plugins/graph/public/app.js b/x-pack/legacy/plugins/graph/public/app.js index b60f6b267ad84..aa08841e03f52 100644 --- a/x-pack/legacy/plugins/graph/public/app.js +++ b/x-pack/legacy/plugins/graph/public/app.js @@ -11,6 +11,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import { isColorDark, hexToRgb } from '@elastic/eui'; +import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { showSaveModal } from 'ui/saved_objects/show_saved_object_save_modal'; import { addAppRedirectMessageToUrl } from 'ui/notify'; @@ -551,9 +552,11 @@ export function initGraphApp(angularModule, deps) { canEditDrillDownUrls: canEditDrillDownUrls }), $scope.$digest.bind($scope)); coreStart.overlays.openFlyout( - - - , { + toMountPoint( + + + + ), { size: 'm', closeButtonAriaLabel: i18n.translate('xpack.graph.settings.closeLabel', { defaultMessage: 'Close' }), 'data-test-subj': 'graphSettingsFlyout', diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx index 369963fb46097..8dede207b803c 100644 --- a/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx @@ -80,7 +80,8 @@ function GuidancePanelComponent(props: GuidancePanelProps) { } = props; const kibana = useKibana(); - const { overlays, savedObjects, uiSettings, chrome, application } = kibana.services; + const { services, overlays } = kibana; + const { savedObjects, uiSettings, chrome, application } = services; if (!overlays || !chrome || !application) return null; const onOpenDatasourcePicker = () => { diff --git a/x-pack/legacy/plugins/graph/public/components/search_bar.tsx b/x-pack/legacy/plugins/graph/public/components/search_bar.tsx index 293219cca9876..b6200d831b248 100644 --- a/x-pack/legacy/plugins/graph/public/components/search_bar.tsx +++ b/x-pack/legacy/plugins/graph/public/components/search_bar.tsx @@ -86,7 +86,8 @@ export function SearchBarComponent(props: SearchBarProps) { }, [currentDatasource]); const kibana = useKibana(); - const { overlays, savedObjects, uiSettings } = kibana.services; + const { services, overlays } = kibana; + const { savedObjects, uiSettings } = services; if (!overlays) return null; return ( diff --git a/x-pack/legacy/plugins/graph/public/services/source_modal.tsx b/x-pack/legacy/plugins/graph/public/services/source_modal.tsx index c985271f4dfe0..20a5b6d0786bd 100644 --- a/x-pack/legacy/plugins/graph/public/services/source_modal.tsx +++ b/x-pack/legacy/plugins/graph/public/services/source_modal.tsx @@ -6,6 +6,7 @@ import { CoreStart } from 'src/core/public'; import React from 'react'; +import { KibanaReactOverlays } from 'src/plugins/kibana_react/public'; import { SourceModal } from '../components/source_modal'; import { IndexPatternSavedObject } from '../types'; @@ -15,7 +16,7 @@ export function openSourceModal( savedObjects, uiSettings, }: { - overlays: CoreStart['overlays']; + overlays: KibanaReactOverlays; savedObjects: CoreStart['savedObjects']; uiSettings: CoreStart['uiSettings']; }, diff --git a/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.tsx b/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.tsx index 28bb9b2687913..c79bf52a86642 100644 --- a/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.tsx +++ b/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.tsx @@ -19,6 +19,7 @@ import { import { i18n } from '@kbn/i18n'; import { npStart } from 'ui/new_platform'; +import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; const MAX_SIMPLE_MESSAGE_LENGTH = 140; @@ -43,27 +44,29 @@ export const ToastNotificationText: FC<{ text: any }> = ({ text }) => { const openModal = () => { const modal = npStart.core.overlays.openModal( - modal.close()}> - - - {i18n.translate('xpack.transform.toastText.modalTitle', { - defaultMessage: 'Error details', - })} - - - - - {formattedText} - - - - modal.close()}> - {i18n.translate('xpack.transform.toastText.closeModalButtonText', { - defaultMessage: 'Close', - })} - - - + toMountPoint( + modal.close()}> + + + {i18n.translate('xpack.transform.toastText.modalTitle', { + defaultMessage: 'Error details', + })} + + + + + {formattedText} + + + + modal.close()}> + {i18n.translate('xpack.transform.toastText.closeModalButtonText', { + defaultMessage: 'Close', + })} + + + + ) ); }; diff --git a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.test.ts b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.test.ts index bbdcf99495288..55e913e0f31da 100644 --- a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.test.ts +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.test.ts @@ -14,7 +14,6 @@ import { EmbeddableFactory } from '../../../../src/plugins/embeddable/public'; import { TimeRangeEmbeddable, TimeRangeContainer, TIME_RANGE_EMBEDDABLE } from './test_helpers'; import { TimeRangeEmbeddableFactory } from './test_helpers/time_range_embeddable_factory'; import { CustomTimeRangeAction } from './custom_time_range_action'; -import { coreMock } from '../../../../src/core/public/mocks'; /* eslint-disable */ import { HelloWorldEmbeddableFactory, @@ -29,6 +28,12 @@ import { ReactElement } from 'react'; jest.mock('ui/new_platform'); +const createOpenModalMock = () => { + const mock = jest.fn(); + mock.mockReturnValue({ close: jest.fn() }); + return mock; +}; + test('Custom time range action prevents embeddable from using container time', async done => { const embeddableFactories = new Map(); embeddableFactories.set(TIME_RANGE_EMBEDDABLE, new TimeRangeEmbeddableFactory()); @@ -66,11 +71,10 @@ test('Custom time range action prevents embeddable from using container time', a expect(child2).toBeDefined(); expect(child2.getInput().timeRange).toEqual({ from: 'now-15m', to: 'now' }); - const start = coreMock.createStart(); - const overlayMock = start.overlays; - overlayMock.openModal.mockClear(); + const openModalMock = createOpenModalMock(); + new CustomTimeRangeAction({ - openModal: start.overlays.openModal, + openModal: openModalMock, commonlyUsedRanges: [], dateFormat: 'MM YYY', }).execute({ @@ -78,7 +82,7 @@ test('Custom time range action prevents embeddable from using container time', a }); await nextTick(); - const openModal = overlayMock.openModal.mock.calls[0][0] as ReactElement; + const openModal = openModalMock.mock.calls[0][0] as ReactElement; const wrapper = mount(openModal); wrapper.setState({ timeRange: { from: 'now-30days', to: 'now-29days' } }); @@ -129,11 +133,9 @@ test('Removing custom time range action resets embeddable back to container time const child1 = container.getChild('1'); const child2 = container.getChild('2'); - const start = coreMock.createStart(); - const overlayMock = start.overlays; - overlayMock.openModal.mockClear(); + const openModalMock = createOpenModalMock(); new CustomTimeRangeAction({ - openModal: start.overlays.openModal, + openModal: openModalMock, commonlyUsedRanges: [], dateFormat: 'MM YYY', }).execute({ @@ -141,7 +143,7 @@ test('Removing custom time range action resets embeddable back to container time }); await nextTick(); - const openModal = overlayMock.openModal.mock.calls[0][0] as ReactElement; + const openModal = openModalMock.mock.calls[0][0] as ReactElement; const wrapper = mount(openModal); wrapper.setState({ timeRange: { from: 'now-30days', to: 'now-29days' } }); @@ -151,7 +153,7 @@ test('Removing custom time range action resets embeddable back to container time container.updateInput({ timeRange: { from: 'now-30m', to: 'now-1m' } }); new CustomTimeRangeAction({ - openModal: start.overlays.openModal, + openModal: openModalMock, commonlyUsedRanges: [], dateFormat: 'MM YYY', }).execute({ @@ -159,7 +161,7 @@ test('Removing custom time range action resets embeddable back to container time }); await nextTick(); - const openModal2 = (overlayMock.openModal as any).mock.calls[1][0]; + const openModal2 = openModalMock.mock.calls[1][0]; const wrapper2 = mount(openModal2); findTestSubject(wrapper2, 'removePerPanelTimeRangeButton').simulate('click'); @@ -209,11 +211,9 @@ test('Cancelling custom time range action leaves state alone', async done => { const child1 = container.getChild('1'); const child2 = container.getChild('2'); - const start = coreMock.createStart(); - const overlayMock = start.overlays; - overlayMock.openModal.mockClear(); + const openModalMock = createOpenModalMock(); new CustomTimeRangeAction({ - openModal: start.overlays.openModal, + openModal: openModalMock, commonlyUsedRanges: [], dateFormat: 'MM YYY', }).execute({ @@ -221,7 +221,7 @@ test('Cancelling custom time range action leaves state alone', async done => { }); await nextTick(); - const openModal = overlayMock.openModal.mock.calls[0][0] as ReactElement; + const openModal = openModalMock.mock.calls[0][0] as ReactElement; const wrapper = mount(openModal); wrapper.setState({ timeRange: { from: 'now-300m', to: 'now-400m' } }); @@ -263,9 +263,9 @@ test(`badge is compatible with embeddable that inherits from parent`, async () = const child = container.getChild('1'); - const start = coreMock.createStart(); + const openModalMock = createOpenModalMock(); const compatible = await new CustomTimeRangeAction({ - openModal: start.overlays.openModal, + openModal: openModalMock, commonlyUsedRanges: [], dateFormat: 'MM YYY', }).isCompatible({ @@ -333,9 +333,9 @@ test('Attempting to execute on incompatible embeddable throws an error', async ( const child = container.getChild('1'); - const start = coreMock.createStart(); + const openModalMock = createOpenModalMock(); const action = await new CustomTimeRangeAction({ - openModal: start.overlays.openModal, + openModal: openModalMock, dateFormat: 'MM YYYY', commonlyUsedRanges: [], }); diff --git a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.test.ts b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.test.ts index c6046c02f0833..d2b9fa9ac1655 100644 --- a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.test.ts +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.test.ts @@ -13,7 +13,6 @@ import { EmbeddableFactory } from '../../../../src/plugins/embeddable/public'; import { TimeRangeEmbeddable, TimeRangeContainer, TIME_RANGE_EMBEDDABLE } from './test_helpers'; import { TimeRangeEmbeddableFactory } from './test_helpers/time_range_embeddable_factory'; import { CustomTimeRangeBadge } from './custom_time_range_badge'; -import { coreMock } from '../../../../src/core/public/mocks'; import { ReactElement } from 'react'; import { nextTick } from 'test_utils/enzyme_helpers'; @@ -50,11 +49,11 @@ test('Removing custom time range from badge resets embeddable back to container const child1 = container.getChild('1'); const child2 = container.getChild('2'); - const start = coreMock.createStart(); - const overlayMock = start.overlays; - overlayMock.openModal.mockClear(); + const openModalMock = jest.fn(); + openModalMock.mockReturnValue({ close: jest.fn() }); + new CustomTimeRangeBadge({ - openModal: start.overlays.openModal, + openModal: openModalMock, dateFormat: 'MM YYYY', commonlyUsedRanges: [], }).execute({ @@ -62,7 +61,7 @@ test('Removing custom time range from badge resets embeddable back to container }); await nextTick(); - const openModal = overlayMock.openModal.mock.calls[0][0] as ReactElement; + const openModal = openModalMock.mock.calls[0][0] as ReactElement; const wrapper = mount(openModal); findTestSubject(wrapper, 'removePerPanelTimeRangeButton').simulate('click'); @@ -102,9 +101,9 @@ test(`badge is not compatible with embeddable that inherits from parent`, async const child = container.getChild('1'); - const start = coreMock.createStart(); + const openModalMock = jest.fn(); const compatible = await new CustomTimeRangeBadge({ - openModal: start.overlays.openModal, + openModal: openModalMock, dateFormat: 'MM YYYY', commonlyUsedRanges: [], }).isCompatible({ @@ -137,9 +136,9 @@ test(`badge is compatible with embeddable that has custom time range`, async () const child = container.getChild('1'); - const start = coreMock.createStart(); + const openModalMock = jest.fn(); const compatible = await new CustomTimeRangeBadge({ - openModal: start.overlays.openModal, + openModal: openModalMock, dateFormat: 'MM YYYY', commonlyUsedRanges: [], }).isCompatible({ @@ -171,9 +170,9 @@ test('Attempting to execute on incompatible embeddable throws an error', async ( const child = container.getChild('1'); - const start = coreMock.createStart(); + const openModalMock = jest.fn(); const badge = await new CustomTimeRangeBadge({ - openModal: start.overlays.openModal, + openModal: openModalMock, dateFormat: 'MM YYYY', commonlyUsedRanges: [], }); diff --git a/x-pack/plugins/advanced_ui_actions/public/plugin.ts b/x-pack/plugins/advanced_ui_actions/public/plugin.ts index fc106cc8ec26b..e2d1892b1355e 100644 --- a/x-pack/plugins/advanced_ui_actions/public/plugin.ts +++ b/x-pack/plugins/advanced_ui_actions/public/plugin.ts @@ -10,6 +10,7 @@ import { CoreStart, Plugin, } from '../../../../src/core/public'; +import { createReactOverlays } from '../../../../src/plugins/kibana_react/public'; import { IUiActionsStart, IUiActionsSetup } from '../../../../src/plugins/ui_actions/public'; import { CONTEXT_MENU_TRIGGER, @@ -44,8 +45,9 @@ export class AdvancedUiActionsPublicPlugin public start(core: CoreStart, { uiActions }: StartDependencies): Start { const dateFormat = core.uiSettings.get('dateFormat') as string; const commonlyUsedRanges = core.uiSettings.get('timepicker:quickRanges') as CommonlyUsedRange[]; + const { openModal } = createReactOverlays(core); const timeRangeAction = new CustomTimeRangeAction({ - openModal: core.overlays.openModal, + openModal, dateFormat, commonlyUsedRanges, }); @@ -53,7 +55,7 @@ export class AdvancedUiActionsPublicPlugin uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction.id); const timeRangeBadge = new CustomTimeRangeBadge({ - openModal: core.overlays.openModal, + openModal, dateFormat, commonlyUsedRanges, }); diff --git a/x-pack/plugins/advanced_ui_actions/public/types.ts b/x-pack/plugins/advanced_ui_actions/public/types.ts index bbd7c5528276f..313b09535b196 100644 --- a/x-pack/plugins/advanced_ui_actions/public/types.ts +++ b/x-pack/plugins/advanced_ui_actions/public/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { OverlayRef } from '../../../../src/core/public'; +import { KibanaReactOverlays } from '../../../../src/plugins/kibana_react/public'; export interface CommonlyUsedRange { from: string; @@ -12,10 +12,4 @@ export interface CommonlyUsedRange { display: string; } -export type OpenModal = ( - modalChildren: React.ReactNode, - modalProps?: { - closeButtonAriaLabel?: string; - 'data-test-subj'?: string; - } -) => OverlayRef; +export type OpenModal = KibanaReactOverlays['openModal'];