diff --git a/src/core/public/notifications/toasts/error_toast.tsx b/src/core/public/notifications/toasts/error_toast.tsx index 10bc51559644b2..956e56999849ef 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, @@ -29,7 +30,7 @@ import { EuiModalHeaderTitle, } from '@elastic/eui'; import { EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { OverlayStart } from '../../overlays'; @@ -40,6 +41,11 @@ interface ErrorToastProps { openModal: OverlayStart['openModal']; } +const mount = (component: React.ReactElement) => (element: HTMLElement) => { + ReactDOM.render({component}, element); + return () => ReactDOM.unmountComponentAtNode(element); +}; + /** * This should instead be replaced by the overlay service once it's available. * This does not use React portals so that if the parent toast times out, this modal @@ -52,27 +58,32 @@ function showErrorDialog({ openModal, }: Pick) { const modal = openModal( - - - {title} - - - - {error.stack && ( - - - - {error.stack} - - - )} - - - modal.close()} fill> - - - - + mount( + + + {title} + + + + {error.stack && ( + + + + {error.stack} + + + )} + + + modal.close()} fill> + + + + + ) ); } diff --git a/src/core/public/overlays/__snapshots__/modal.test.tsx.snap b/src/core/public/overlays/__snapshots__/modal.test.tsx.snap index a4e6f5d6f72b8f..27932e313bef4b 100644 --- a/src/core/public/overlays/__snapshots__/modal.test.tsx.snap +++ b/src/core/public/overlays/__snapshots__/modal.test.tsx.snap @@ -17,9 +17,9 @@ Array [ maxWidth={true} onClose={[Function]} > - - Modal content - + , @@ -28,6 +28,8 @@ 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 [ @@ -37,9 +39,9 @@ Array [ maxWidth={true} onClose={[Function]} > - - Modal content 1 - + , @@ -52,9 +54,9 @@ Array [ maxWidth={true} onClose={[Function]} > - - Flyout content 2 - + , diff --git a/src/core/public/overlays/flyout.test.mocks.ts b/src/core/public/overlays/flyout.test.mocks.ts index 35a046e9600774..563f414a0ae99d 100644 --- a/src/core/public/overlays/flyout.test.mocks.ts +++ b/src/core/public/overlays/flyout.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/modal.test.tsx b/src/core/public/overlays/modal.test.tsx index ee8bba5d611129..8b712cb6638083 100644 --- a/src/core/public/overlays/modal.test.tsx +++ b/src/core/public/overlays/modal.test.tsx @@ -19,8 +19,10 @@ import { mockReactDomRender, mockReactDomUnmount } from './flyout.test.mocks'; import React from 'react'; +import { mount } from 'enzyme'; import { i18nServiceMock } from '../i18n/i18n_service.mock'; import { ModalService, ModalRef } from './modal'; +import { mountForComponent } from './utils'; const i18nMock = i18nServiceMock.createStartContract(); @@ -35,39 +37,51 @@ describe('ModalService', () => { const target = document.createElement('div'); const modalService = new ModalService(target); expect(mockReactDomRender).not.toHaveBeenCalled(); - modalService.openModal(i18nMock, Modal content); + modalService.openModal(i18nMock, 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; + beforeEach(() => { target = document.createElement('div'); modalService = new ModalService(target); - ref1 = modalService.openModal(i18nMock, Modal content 1); + ref1 = modalService.openModal(i18nMock, mountForComponent(Modal content 1)); }); + it('replaces the current modal with a new one', () => { - modalService.openModal(i18nMock, Flyout content 2); + modalService.openModal(i18nMock, mountForComponent(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); + modalService.openModal(i18nMock, mountForComponent(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 = modalService.openModal(i18nMock, mountForComponent(Flyout content)); const onCloseComplete = jest.fn(); ref.onClose.then(onCloseComplete); @@ -75,21 +89,29 @@ 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 = modalService.openModal(i18nMock, mountForComponent(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 = modalService.openModal( + i18nMock, + mountForComponent(Modal content 1) + ); + const ref2 = modalService.openModal( + i18nMock, + mountForComponent(Modal content 2) + ); const onCloseComplete = jest.fn(); ref2.onClose.then(onCloseComplete); mockReactDomUnmount.mockClear(); diff --git a/src/core/public/overlays/modal.tsx b/src/core/public/overlays/modal.tsx index 6f94788b84d717..200d4903694bb9 100644 --- a/src/core/public/overlays/modal.tsx +++ b/src/core/public/overlays/modal.tsx @@ -25,6 +25,8 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { Subject } from 'rxjs'; import { I18nStart } from '../i18n'; import { OverlayRef } from './overlay_service'; +import { MountPoint } from './types'; +import { MountWrapper } from './utils'; /** * A ModalRef is a reference to an opened modal. It offers methods to @@ -65,12 +67,12 @@ export class ModalService { * 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. + * @param modalMount - Mounts the children inside the modal + * @return {ModalRef} A reference to the opened modal. */ public openModal = ( i18n: I18nStart, - modalChildren: React.ReactNode, + modalMount: MountPoint, modalProps: { closeButtonAriaLabel?: string; 'data-test-subj'?: string; @@ -97,7 +99,7 @@ export class ModalService { modal.close()}> - {modalChildren} + , diff --git a/src/core/public/overlays/overlay_service.ts b/src/core/public/overlays/overlay_service.ts index 1a72bb5dbe435a..62b41db0581bdf 100644 --- a/src/core/public/overlays/overlay_service.ts +++ b/src/core/public/overlays/overlay_service.ts @@ -23,6 +23,7 @@ import { FlyoutService } from './flyout'; import { ModalService } from './modal'; import { I18nStart } from '../i18n'; import { OverlayBannersStart, OverlayBannersService } from './banners'; +import { MountPoint } from './types'; import { UiSettingsClientContract } from '../ui_settings'; /** @@ -83,7 +84,7 @@ export interface OverlayStart { } ) => OverlayRef; openModal: ( - modalChildren: React.ReactNode, + modalChildren: MountPoint, modalProps?: { className?: string; closeButtonAriaLabel?: string; diff --git a/src/core/public/overlays/utils.test.tsx b/src/core/public/overlays/utils.test.tsx new file mode 100644 index 00000000000000..caec542f9c4b2f --- /dev/null +++ b/src/core/public/overlays/utils.test.tsx @@ -0,0 +1,62 @@ +/* + * 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, mountForComponent } from './utils'; + +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 = mountForComponent(detached); + const wrapper = ; + const container = mount(wrapper); + expect(container.html()).toMatchInlineSnapshot(`"
detached
"`); + }); +}); diff --git a/src/core/public/overlays/utils.tsx b/src/core/public/overlays/utils.tsx new file mode 100644 index 00000000000000..e0029f394e307f --- /dev/null +++ b/src/core/public/overlays/utils.tsx @@ -0,0 +1,46 @@ +/* + * 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, { useEffect, useRef } from 'react'; +import ReactDOM from 'react-dom'; +import { MountPoint } from 'kibana/public'; +import { I18nProvider } from '@kbn/i18n/src/react'; + +/** + * Mount converter for react components. + * + * @param component to get a mount for + */ +export const mountForComponent = (component: React.ReactElement): MountPoint => ( + element: HTMLElement +) => { + ReactDOM.render({component}, element); + return () => ReactDOM.unmountComponentAtNode(element); +}; + +/** + * + * @param mount + * @constructor + */ +export const MountWrapper: React.FunctionComponent<{ mount: MountPoint }> = ({ mount }) => { + const element = useRef(null); + useEffect(() => mount(element.current!), [mount]); + return
; +}; 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 5e53477b8ec04e..a5b28229279199 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 { mountForComponent } 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()} - />, + mountForComponent( + modal.close()} + /> + ), { className: 'shardFailureModal', } diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 8a8a2f44b7fd8a..c92599d44bf7a3 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 { mountForComponent } 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 }); - }} - />, + mountForComponent( + { + 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 3ca3a0864d9f14..9ecc4686c21b6e 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 bfa4f6e31d84e7..50ebebfe8ffeff 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,7 @@ * 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 { IContainer } from '../../../../containers'; import { AddPanelFlyout } from './add_panel_flyout'; import { GetEmbeddableFactory, GetEmbeddableFactories } from '../../../../types'; @@ -27,7 +26,7 @@ export async function openAddPanelFlyout(options: { embeddable: IContainer; getFactory: GetEmbeddableFactory; getAllFactories: GetEmbeddableFactories; - overlays: KibanaReactOverlays; + overlays: OverlayStart; notifications: NotificationsStart; SavedObjectFinder: React.ComponentType; }) { 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 fc20a99987484d..ac540bff4a610c 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 { mountForComponent } from '../../../../../kibana_react/public'; import { Embeddable, EmbeddableInput } from '../../embeddables'; import { GetMessageModal } from './get_message_modal'; import { FullNameEmbeddableOutput, hasFullNameOutput } from './say_hello_action'; @@ -51,13 +52,15 @@ export function createSendMessageAction(overlays: CoreStart['overlays']) { } const modal = overlays.openModal( - modal.close()} - onDone={message => { - modal.close(); - sendMessage(context, message); - }} - /> + mountForComponent( + 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 962cddfa3735f8..8e7352b24e87b2 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 { mountForComponent } 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); - }} - />, + mountForComponent( + { + modalSession.close(); + resolve(undefined); + }} + onCreate={(input: { firstName: string; lastName?: string }) => { + modalSession.close(); + resolve(input); + }} + /> + ), { 'data-test-subj': 'createContactCardEmbeddable', } 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 b10cbbc87be7c6..1882d138ad5cd6 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 @@ -68,13 +68,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 a62c0970cf5256..d645ed3356da85 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 { mountForComponent } from './mount_for_component'; export const createReactOverlays = (services: KibanaServices): KibanaReactOverlays => { const checkCoreService = () => { @@ -35,7 +36,7 @@ export const createReactOverlays = (services: KibanaServices): KibanaReactOverla const openModal: KibanaReactOverlays['openModal'] = (node, options?) => { checkCoreService(); - return services.overlays!.openModal(<>{node}, options); + return services.overlays!.openModal(mountForComponent(<>{node}), options); }; const overlays: KibanaReactOverlays = { diff --git a/src/plugins/kibana_react/public/overlays/index.tsx b/src/plugins/kibana_react/public/overlays/index.tsx index 844f617ceafdbe..2096d7e8586901 100644 --- a/src/plugins/kibana_react/public/overlays/index.tsx +++ b/src/plugins/kibana_react/public/overlays/index.tsx @@ -19,3 +19,4 @@ export * from './types'; export * from './create_react_overlays'; +export * from './mount_for_component'; diff --git a/src/plugins/kibana_react/public/overlays/mount_for_component.tsx b/src/plugins/kibana_react/public/overlays/mount_for_component.tsx new file mode 100644 index 00000000000000..6ac6002c1b0609 --- /dev/null +++ b/src/plugins/kibana_react/public/overlays/mount_for_component.tsx @@ -0,0 +1,35 @@ +/* + * 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 ReactDOM from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { MountPoint } from 'kibana/public'; + +/** + * Mount converter for react components. + * + * @param component to get a mount for + */ +export const mountForComponent = (component: React.ReactElement): MountPoint => ( + element: HTMLElement +) => { + ReactDOM.render({component}, element); + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/src/plugins/kibana_react/public/overlays/types.ts b/src/plugins/kibana_react/public/overlays/types.ts index 6a1fb25ca14835..f1a82113a26eee 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/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 369963fb460977..8dede207b803c7 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 4fd1a162105f16..59c991760bce85 100644 --- a/x-pack/legacy/plugins/graph/public/components/search_bar.tsx +++ b/x-pack/legacy/plugins/graph/public/components/search_bar.tsx @@ -85,7 +85,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 c985271f4dfe09..20a5b6d0786bd3 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/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 bbdcf99495288b..55e913e0f31da4 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 c6046c02f0833d..d2b9fa9ac16555 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 fc106cc8ec26b9..e2d1892b1355e0 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 bbd7c5528276f1..313b09535b196a 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'];