Skip to content

Commit

Permalink
migrate openModal / modalService
Browse files Browse the repository at this point in the history
Signed-off-by: pgayvallet <pierre.gayvallet@elastic.co>
  • Loading branch information
pgayvallet committed Oct 16, 2019
1 parent 5f63498 commit b360cb7
Show file tree
Hide file tree
Showing 26 changed files with 328 additions and 138 deletions.
55 changes: 33 additions & 22 deletions src/core/public/notifications/toasts/error_toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import React from 'react';
import ReactDOM from 'react-dom';

import {
EuiButton,
Expand All @@ -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';

Expand All @@ -40,6 +41,11 @@ interface ErrorToastProps {
openModal: OverlayStart['openModal'];
}

const mount = (component: React.ReactElement) => (element: HTMLElement) => {
ReactDOM.render(<I18nProvider>{component}</I18nProvider>, 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
Expand All @@ -52,27 +58,32 @@ function showErrorDialog({
openModal,
}: Pick<ErrorToastProps, 'error' | 'title' | 'openModal'>) {
const modal = openModal(
<React.Fragment>
<EuiModalHeader>
<EuiModalHeaderTitle>{title}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiCallOut size="s" color="danger" iconType="alert" title={error.message} />
{error.stack && (
<React.Fragment>
<EuiSpacer size="s" />
<EuiCodeBlock isCopyable={true} paddingSize="s">
{error.stack}
</EuiCodeBlock>
</React.Fragment>
)}
</EuiModalBody>
<EuiModalFooter>
<EuiButton onClick={() => modal.close()} fill>
<FormattedMessage id="core.notifications.errorToast.closeModal" defaultMessage="Close" />
</EuiButton>
</EuiModalFooter>
</React.Fragment>
mount(
<React.Fragment>
<EuiModalHeader>
<EuiModalHeaderTitle>{title}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiCallOut size="s" color="danger" iconType="alert" title={error.message} />
{error.stack && (
<React.Fragment>
<EuiSpacer size="s" />
<EuiCodeBlock isCopyable={true} paddingSize="s">
{error.stack}
</EuiCodeBlock>
</React.Fragment>
)}
</EuiModalBody>
<EuiModalFooter>
<EuiButton onClick={() => modal.close()} fill>
<FormattedMessage
id="core.notifications.errorToast.closeModal"
defaultMessage="Close"
/>
</EuiButton>
</EuiModalFooter>
</React.Fragment>
)
);
}

Expand Down
20 changes: 11 additions & 9 deletions src/core/public/overlays/__snapshots__/modal.test.tsx.snap

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

2 changes: 2 additions & 0 deletions src/core/public/overlays/flyout.test.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
38 changes: 30 additions & 8 deletions src/core/public/overlays/modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -35,61 +37,81 @@ describe('ModalService', () => {
const target = document.createElement('div');
const modalService = new ModalService(target);
expect(mockReactDomRender).not.toHaveBeenCalled();
modalService.openModal(i18nMock, <span>Modal content</span>);
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, <span>Modal content 1</span>);
ref1 = modalService.openModal(i18nMock, mountForComponent(<span>Modal content 1</span>));
});

it('replaces the current modal with a new one', () => {
modalService.openModal(i18nMock, <span>Flyout content 2</span>);
modalService.openModal(i18nMock, mountForComponent(<span>Flyout content 2</span>));
expect(mockReactDomRender.mock.calls).toMatchSnapshot();
expect(mockReactDomUnmount).toHaveBeenCalledTimes(1);
expect(() => ref1.close()).not.toThrowError();
expect(mockReactDomUnmount).toHaveBeenCalledTimes(1);
});

it('resolves onClose on the previous ref', async () => {
const onCloseComplete = jest.fn();
ref1.onClose.then(onCloseComplete);
modalService.openModal(i18nMock, <span>Flyout content 2</span>);
modalService.openModal(i18nMock, mountForComponent(<span>Flyout content 2</span>));
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, <span>Flyout content</span>);
const ref = modalService.openModal(i18nMock, mountForComponent(<span>Flyout content</span>));

const onCloseComplete = jest.fn();
ref.onClose.then(onCloseComplete);
await ref.close();
await ref.close();
expect(onCloseComplete).toHaveBeenCalledTimes(1);
});

it('can be called multiple times on the same ModalRef', async () => {
const target = document.createElement('div');
const modalService = new ModalService(target);
const ref = modalService.openModal(i18nMock, <span>Flyout content</span>);
const ref = modalService.openModal(i18nMock, mountForComponent(<span>Flyout content</span>));
expect(mockReactDomUnmount).not.toHaveBeenCalled();
await ref.close();
expect(mockReactDomUnmount.mock.calls).toMatchSnapshot();
await ref.close();
expect(mockReactDomUnmount).toHaveBeenCalledTimes(1);
});

it("on a stale ModalRef doesn't affect the active flyout", async () => {
const target = document.createElement('div');
const modalService = new ModalService(target);
const ref1 = modalService.openModal(i18nMock, <span>Modal content 1</span>);
const ref2 = modalService.openModal(i18nMock, <span>Modal content 2</span>);
const ref1 = modalService.openModal(
i18nMock,
mountForComponent(<span>Modal content 1</span>)
);
const ref2 = modalService.openModal(
i18nMock,
mountForComponent(<span>Modal content 2</span>)
);
const onCloseComplete = jest.fn();
ref2.onClose.then(onCloseComplete);
mockReactDomUnmount.mockClear();
Expand Down
10 changes: 6 additions & 4 deletions src/core/public/overlays/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -97,7 +99,7 @@ export class ModalService {
<EuiOverlayMask>
<i18n.Context>
<EuiModal {...modalProps} onClose={() => modal.close()}>
{modalChildren}
<MountWrapper mount={modalMount} />
</EuiModal>
</i18n.Context>
</EuiOverlayMask>,
Expand Down
3 changes: 2 additions & 1 deletion src/core/public/overlays/overlay_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -83,7 +84,7 @@ export interface OverlayStart {
}
) => OverlayRef;
openModal: (
modalChildren: React.ReactNode,
modalChildren: MountPoint,
modalProps?: {
className?: string;
closeButtonAriaLabel?: string;
Expand Down
62 changes: 62 additions & 0 deletions src/core/public/overlays/utils.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = <MountWrapper mount={mountPoint} />;
const container = mount(wrapper);
expect(container.html()).toMatchInlineSnapshot(`"<div><p class=\\"bar\\">hello</p></div>"`);
});

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 = <MountWrapper mount={mountPoint} />;
const container = mount(wrapper);
expect(container.html()).toMatchInlineSnapshot(`"<div><p>initial</p></div>"`);

el.textContent = 'changed';
container.update();
expect(container.html()).toMatchInlineSnapshot(`"<div><p>changed</p></div>"`);
});

it('can render a detached react component', () => {
const mountPoint = mountForComponent(<span>detached</span>);
const wrapper = <MountWrapper mount={mountPoint} />;
const container = mount(wrapper);
expect(container.html()).toMatchInlineSnapshot(`"<div><span>detached</span></div>"`);
});
});
Loading

0 comments on commit b360cb7

Please sign in to comment.