diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index d3846c11835b9..8793ef6754ad0 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -20,6 +20,7 @@ import { FatalErrorsService } from './fatal_errors'; import { InjectedMetadataService } from './injected_metadata'; import { LegacyPlatformService } from './legacy_platform'; +import { NotificationsService } from './notifications'; const MockLegacyPlatformService = jest.fn( function _MockLegacyPlatformService(this: any) { @@ -52,6 +53,18 @@ jest.mock('./fatal_errors', () => ({ FatalErrorsService: MockFatalErrorsService, })); +const mockNotificationStartContract = {}; +const MockNotificationsService = jest.fn(function _MockNotificationsService( + this: any +) { + this.start = jest.fn().mockReturnValue(mockNotificationStartContract); + this.add = jest.fn(); + this.stop = jest.fn(); +}); +jest.mock('./notifications', () => ({ + NotificationsService: MockNotificationsService, +})); + import { CoreSystem } from './core_system'; jest.spyOn(CoreSystem.prototype, 'stop'); @@ -74,6 +87,8 @@ describe('constructor', () => { expect(MockInjectedMetadataService).toHaveBeenCalledTimes(1); expect(MockLegacyPlatformService).toHaveBeenCalledTimes(1); + expect(MockFatalErrorsService).toHaveBeenCalledTimes(1); + expect(MockNotificationsService).toHaveBeenCalledTimes(1); }); it('passes injectedMetadata param to InjectedMetadataService', () => { @@ -92,14 +107,12 @@ describe('constructor', () => { }); it('passes requireLegacyFiles, useLegacyTestHarness, and a dom element to LegacyPlatformService', () => { - const rootDomElement = document.createElement('div'); const requireLegacyFiles = { requireLegacyFiles: true } as any; const useLegacyTestHarness = { useLegacyTestHarness: true } as any; // tslint:disable no-unused-expression new CoreSystem({ ...defaultCoreSystemParams, - rootDomElement, requireLegacyFiles, useLegacyTestHarness, }); @@ -112,6 +125,18 @@ describe('constructor', () => { }); }); + it('passes a dom element to NotificationsService', () => { + // tslint:disable no-unused-expression + new CoreSystem({ + ...defaultCoreSystemParams, + }); + + expect(MockNotificationsService).toHaveBeenCalledTimes(1); + expect(MockNotificationsService).toHaveBeenCalledWith({ + targetDomElement: expect.any(HTMLElement), + }); + }); + it('passes injectedMetadata, rootDomElement, and a stopCoreSystem function to FatalErrorsService', () => { const rootDomElement = document.createElement('div'); const injectedMetadata = { injectedMetadata: true } as any; @@ -161,11 +186,11 @@ describe('#start()', () => { core.start(); } - it('clears the children of the rootDomElement and appends container for legacyPlatform', () => { + it('clears the children of the rootDomElement and appends container for legacyPlatform and notifications', () => { const root = document.createElement('div'); root.innerHTML = '

foo bar

'; startCore(root); - expect(root.innerHTML).toBe('
'); + expect(root.innerHTML).toBe('
'); }); it('calls injectedMetadata#start()', () => { @@ -181,6 +206,13 @@ describe('#start()', () => { expect(mockInstance.start).toHaveBeenCalledTimes(1); expect(mockInstance.start).toHaveBeenCalledWith(); }); + + it('calls notifications#start()', () => { + startCore(); + const [mockInstance] = MockNotificationsService.mock.instances; + expect(mockInstance.start).toHaveBeenCalledTimes(1); + expect(mockInstance.start).toHaveBeenCalledWith(); + }); }); describe('LegacyPlatform targetDomElement', () => { @@ -207,3 +239,28 @@ describe('LegacyPlatform targetDomElement', () => { expect(targetDomElementParentInStart!).toBe(rootDomElement); }); }); + +describe('Notifications targetDomElement', () => { + it('only mounts the element when started, before starting the notificationsService', () => { + const rootDomElement = document.createElement('div'); + const core = new CoreSystem({ + ...defaultCoreSystemParams, + rootDomElement, + }); + + const [notifications] = MockNotificationsService.mock.instances; + + let targetDomElementParentInStart: HTMLElement; + (notifications as any).start.mockImplementation(() => { + targetDomElementParentInStart = targetDomElement.parentElement; + }); + + // targetDomElement should not have a parent element when the LegacyPlatformService is constructed + const [[{ targetDomElement }]] = MockNotificationsService.mock.calls; + expect(targetDomElement).toHaveProperty('parentElement', null); + + // starting the core system should mount the targetDomElement as a child of the rootDomElement + core.start(); + expect(targetDomElementParentInStart!).toBe(rootDomElement); + }); +}); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index dcbf194a07c7d..775edd6c8325e 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -21,6 +21,7 @@ import './core.css'; import { FatalErrorsService } from './fatal_errors'; import { InjectedMetadataParams, InjectedMetadataService } from './injected_metadata'; import { LegacyPlatformParams, LegacyPlatformService } from './legacy_platform'; +import { NotificationsService } from './notifications'; interface Params { rootDomElement: HTMLElement; @@ -39,8 +40,10 @@ export class CoreSystem { private readonly fatalErrors: FatalErrorsService; private readonly injectedMetadata: InjectedMetadataService; private readonly legacyPlatform: LegacyPlatformService; + private readonly notifications: NotificationsService; private readonly rootDomElement: HTMLElement; + private readonly notificationsTargetDomElement: HTMLDivElement; private readonly legacyPlatformTargetDomElement: HTMLDivElement; constructor(params: Params) { @@ -60,6 +63,11 @@ export class CoreSystem { }, }); + this.notificationsTargetDomElement = document.createElement('div'); + this.notifications = new NotificationsService({ + targetDomElement: this.notificationsTargetDomElement, + }); + this.legacyPlatformTargetDomElement = document.createElement('div'); this.legacyPlatform = new LegacyPlatformService({ targetDomElement: this.legacyPlatformTargetDomElement, @@ -73,11 +81,13 @@ export class CoreSystem { // ensure the rootDomElement is empty this.rootDomElement.textContent = ''; this.rootDomElement.classList.add('coreSystemRootDomElement'); + this.rootDomElement.appendChild(this.notificationsTargetDomElement); this.rootDomElement.appendChild(this.legacyPlatformTargetDomElement); + const notifications = this.notifications.start(); const injectedMetadata = this.injectedMetadata.start(); const fatalErrors = this.fatalErrors.start(); - this.legacyPlatform.start({ injectedMetadata, fatalErrors }); + this.legacyPlatform.start({ injectedMetadata, fatalErrors, notifications }); } catch (error) { this.fatalErrors.add(error); } @@ -85,6 +95,7 @@ export class CoreSystem { public stop() { this.legacyPlatform.stop(); + this.notifications.stop(); this.rootDomElement.textContent = ''; } } diff --git a/src/core/public/fatal_errors/fatal_errors_screen.test.tsx b/src/core/public/fatal_errors/fatal_errors_screen.test.tsx index 184c0b544e265..3165162db8519 100644 --- a/src/core/public/fatal_errors/fatal_errors_screen.test.tsx +++ b/src/core/public/fatal_errors/fatal_errors_screen.test.tsx @@ -17,7 +17,6 @@ * under the License. */ -// @ts-ignore EuiCallOut not available until we upgrade to EUI 3.1.0 import { EuiCallOut } from '@elastic/eui'; import testSubjSelector from '@kbn/test-subj-selector'; import { mount, shallow } from 'enzyme'; diff --git a/src/core/public/fatal_errors/fatal_errors_screen.tsx b/src/core/public/fatal_errors/fatal_errors_screen.tsx index a217fc7ee1575..ccd87fdf8e6c2 100644 --- a/src/core/public/fatal_errors/fatal_errors_screen.tsx +++ b/src/core/public/fatal_errors/fatal_errors_screen.tsx @@ -20,11 +20,8 @@ import { EuiButton, EuiButtonEmpty, - // @ts-ignore EuiCallOut not available until we upgrade to EUI 3.1.0 EuiCallOut, - // @ts-ignore EuiCodeBlock not available until we upgrade to EUI 3.1.0 EuiCodeBlock, - // @ts-ignore EuiEmptyPrompt not available until we upgrade to EUI 3.1.0 EuiEmptyPrompt, EuiPage, EuiPageBody, diff --git a/src/core/public/legacy_platform/legacy_platform_service.test.ts b/src/core/public/legacy_platform/legacy_platform_service.test.ts index f6b64f2bf28c8..3a5d98140a753 100644 --- a/src/core/public/legacy_platform/legacy_platform_service.test.ts +++ b/src/core/public/legacy_platform/legacy_platform_service.test.ts @@ -53,9 +53,20 @@ jest.mock('ui/notify/fatal_error', () => { }; }); +const mockNotifyToastsInit = jest.fn(); +jest.mock('ui/notify/toasts', () => { + mockLoadOrder.push('ui/notify/toasts'); + return { + __newPlatformInit__: mockNotifyToastsInit, + }; +}); + import { LegacyPlatformService } from './legacy_platform_service'; const fatalErrorsStartContract = {} as any; +const notificationsStartContract = { + toasts: {}, +} as any; const injectedMetadataStartContract = { getLegacyMetadata: jest.fn(), @@ -88,6 +99,7 @@ describe('#start()', () => { legacyPlatform.start({ fatalErrors: fatalErrorsStartContract, injectedMetadata: injectedMetadataStartContract, + notifications: notificationsStartContract, }); expect(mockUiMetadataInit).toHaveBeenCalledTimes(1); @@ -102,12 +114,28 @@ describe('#start()', () => { legacyPlatform.start({ fatalErrors: fatalErrorsStartContract, injectedMetadata: injectedMetadataStartContract, + notifications: notificationsStartContract, }); expect(mockFatalErrorInit).toHaveBeenCalledTimes(1); expect(mockFatalErrorInit).toHaveBeenCalledWith(fatalErrorsStartContract); }); + it('passes toasts service to ui/notify/toasts', () => { + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + }); + + legacyPlatform.start({ + fatalErrors: fatalErrorsStartContract, + injectedMetadata: injectedMetadataStartContract, + notifications: notificationsStartContract, + }); + + expect(mockNotifyToastsInit).toHaveBeenCalledTimes(1); + expect(mockNotifyToastsInit).toHaveBeenCalledWith(notificationsStartContract.toasts); + }); + describe('useLegacyTestHarness = false', () => { it('passes the targetDomElement to ui/chrome', () => { const legacyPlatform = new LegacyPlatformService({ @@ -117,6 +145,7 @@ describe('#start()', () => { legacyPlatform.start({ fatalErrors: fatalErrorsStartContract, injectedMetadata: injectedMetadataStartContract, + notifications: notificationsStartContract, }); expect(mockUiTestHarnessBootstrap).not.toHaveBeenCalled(); @@ -134,6 +163,7 @@ describe('#start()', () => { legacyPlatform.start({ fatalErrors: fatalErrorsStartContract, injectedMetadata: injectedMetadataStartContract, + notifications: notificationsStartContract, }); expect(mockUiChromeBootstrap).not.toHaveBeenCalled(); @@ -155,11 +185,13 @@ describe('#start()', () => { legacyPlatform.start({ fatalErrors: fatalErrorsStartContract, injectedMetadata: injectedMetadataStartContract, + notifications: notificationsStartContract, }); expect(mockLoadOrder).toEqual([ 'ui/metadata', 'ui/notify/fatal_error', + 'ui/notify/toasts', 'ui/chrome', 'legacy files', ]); @@ -178,11 +210,13 @@ describe('#start()', () => { legacyPlatform.start({ fatalErrors: fatalErrorsStartContract, injectedMetadata: injectedMetadataStartContract, + notifications: notificationsStartContract, }); expect(mockLoadOrder).toEqual([ 'ui/metadata', 'ui/notify/fatal_error', + 'ui/notify/toasts', 'ui/test_harness', 'legacy files', ]); diff --git a/src/core/public/legacy_platform/legacy_platform_service.ts b/src/core/public/legacy_platform/legacy_platform_service.ts index 61d3ef95ca777..9ecc7a1ef81fb 100644 --- a/src/core/public/legacy_platform/legacy_platform_service.ts +++ b/src/core/public/legacy_platform/legacy_platform_service.ts @@ -20,10 +20,12 @@ import angular from 'angular'; import { FatalErrorsStartContract } from '../fatal_errors'; import { InjectedMetadataStartContract } from '../injected_metadata'; +import { NotificationsStartContract } from '../notifications'; interface Deps { injectedMetadata: InjectedMetadataStartContract; fatalErrors: FatalErrorsStartContract; + notifications: NotificationsStartContract; } export interface LegacyPlatformParams { @@ -42,11 +44,12 @@ export interface LegacyPlatformParams { export class LegacyPlatformService { constructor(private readonly params: LegacyPlatformParams) {} - public start({ injectedMetadata, fatalErrors }: Deps) { + public start({ injectedMetadata, fatalErrors, notifications }: Deps) { // Inject parts of the new platform into parts of the legacy platform // so that legacy APIs/modules can mimic their new platform counterparts require('ui/metadata').__newPlatformInit__(injectedMetadata.getLegacyMetadata()); require('ui/notify/fatal_error').__newPlatformInit__(fatalErrors); + require('ui/notify/toasts').__newPlatformInit__(notifications.toasts); // Load the bootstrap module before loading the legacy platform files so that // the bootstrap module can modify the environment a bit first diff --git a/src/ui/public/notify/toasts/index.js b/src/core/public/notifications/index.ts similarity index 83% rename from src/ui/public/notify/toasts/index.js rename to src/core/public/notifications/index.ts index 9bd6190b91cad..0654ffffbcb28 100644 --- a/src/ui/public/notify/toasts/index.js +++ b/src/core/public/notifications/index.ts @@ -17,5 +17,5 @@ * under the License. */ -export { GlobalToastList } from './global_toast_list'; -export { toastNotifications } from './toast_notifications'; +export { Toast, ToastInput, ToastsStartContract } from './toasts'; +export { NotificationsService, NotificationsStartContract } from './notifications_service'; diff --git a/src/core/public/notifications/notifications_service.ts b/src/core/public/notifications/notifications_service.ts new file mode 100644 index 0000000000000..b1f50497aec70 --- /dev/null +++ b/src/core/public/notifications/notifications_service.ts @@ -0,0 +1,53 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ToastsService } from './toasts'; + +interface Params { + targetDomElement: HTMLElement; +} + +export class NotificationsService { + private readonly toasts: ToastsService; + + private readonly toastsContainer: HTMLElement; + + constructor(private readonly params: Params) { + this.toastsContainer = document.createElement('div'); + this.toasts = new ToastsService({ + targetDomElement: this.toastsContainer, + }); + } + + public start() { + this.params.targetDomElement.appendChild(this.toastsContainer); + + return { + toasts: this.toasts.start(), + }; + } + + public stop() { + this.toasts.stop(); + + this.params.targetDomElement.textContent = ''; + } +} + +export type NotificationsStartContract = ReturnType; diff --git a/src/core/public/notifications/toasts/__snapshots__/global_toast_list.test.tsx.snap b/src/core/public/notifications/toasts/__snapshots__/global_toast_list.test.tsx.snap new file mode 100644 index 0000000000000..afa2f7b911979 --- /dev/null +++ b/src/core/public/notifications/toasts/__snapshots__/global_toast_list.test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders matching snapshot 1`] = ` + +`; diff --git a/src/core/public/notifications/toasts/__snapshots__/toasts_service.test.tsx.snap b/src/core/public/notifications/toasts/__snapshots__/toasts_service.test.tsx.snap new file mode 100644 index 0000000000000..f33bff56f5a63 --- /dev/null +++ b/src/core/public/notifications/toasts/__snapshots__/toasts_service.test.tsx.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#start() renders the GlobalToastList into the targetDomElement param 1`] = ` +Array [ + Array [ + , +
, + ], +] +`; + +exports[`#stop() unmounts the GlobalToastList from the targetDomElement 1`] = ` +Array [ + Array [ +
, + ], +] +`; diff --git a/src/core/public/notifications/toasts/global_toast_list.test.tsx b/src/core/public/notifications/toasts/global_toast_list.test.tsx new file mode 100644 index 0000000000000..c6c127acbb033 --- /dev/null +++ b/src/core/public/notifications/toasts/global_toast_list.test.tsx @@ -0,0 +1,65 @@ +/* + * 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 { EuiGlobalToastList } from '@elastic/eui'; +import { shallow } from 'enzyme'; +import React from 'react'; +import * as Rx from 'rxjs'; + +import { GlobalToastList } from './global_toast_list'; + +function render(props: Partial = {}) { + return ; +} + +it('renders matching snapshot', () => { + expect(shallow(render())).toMatchSnapshot(); +}); + +it('subscribes to toasts$ on mount and unsubscribes on unmount', () => { + const unsubscribeSpy = jest.fn(); + const subscribeSpy = jest.fn(observer => { + observer.next([]); + return unsubscribeSpy; + }); + + const component = render({ + toasts$: new Rx.Observable(subscribeSpy), + }); + + expect(subscribeSpy).not.toHaveBeenCalled(); + + const el = shallow(component); + expect(subscribeSpy).toHaveBeenCalledTimes(1); + expect(unsubscribeSpy).not.toHaveBeenCalled(); + + el.unmount(); + expect(subscribeSpy).toHaveBeenCalledTimes(1); + expect(unsubscribeSpy).toHaveBeenCalledTimes(1); +}); + +it('passes latest value from toasts$ to ', () => { + const el = shallow( + render({ + toasts$: Rx.from([[], [1], [1, 2]]) as any, + }) + ); + + expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([1, 2]); +}); diff --git a/src/ui/public/notify/toasts/global_toast_list.js b/src/core/public/notifications/toasts/global_toast_list.tsx similarity index 51% rename from src/ui/public/notify/toasts/global_toast_list.js rename to src/core/public/notifications/toasts/global_toast_list.tsx index b6d02172ddd8f..4832b700d1da9 100644 --- a/src/ui/public/notify/toasts/global_toast_list.js +++ b/src/core/public/notifications/toasts/global_toast_list.tsx @@ -17,47 +17,46 @@ * under the License. */ -import React, { - Component, -} from 'react'; -import PropTypes from 'prop-types'; - -import { - EuiGlobalToastList, - EuiPortal, -} from '@elastic/eui'; - -export class GlobalToastList extends Component { - constructor(props) { - super(props); - - if (this.props.subscribe) { - this.props.subscribe(() => this.forceUpdate()); - } - } +import { EuiGlobalToastList, Toast } from '@elastic/eui'; + +import React from 'react'; +import * as Rx from 'rxjs'; + +interface Props { + toasts$: Rx.Observable; + dismissToast: (t: Toast) => void; +} + +interface State { + toasts: Toast[]; +} - static propTypes = { - subscribe: PropTypes.func, - toasts: PropTypes.array, - dismissToast: PropTypes.func.isRequired, - toastLifeTimeMs: PropTypes.number.isRequired, +export class GlobalToastList extends React.Component { + public state: State = { + toasts: [], }; - render() { - const { - toasts, - dismissToast, - toastLifeTimeMs, - } = this.props; + private subscription?: Rx.Subscription; + + public componentDidMount() { + this.subscription = this.props.toasts$.subscribe(toasts => { + this.setState({ toasts }); + }); + } + + public componentWillUnmount() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + public render() { return ( - - - + ); } } diff --git a/src/core/public/notifications/toasts/index.ts b/src/core/public/notifications/toasts/index.ts new file mode 100644 index 0000000000000..5ae4c0dc4629b --- /dev/null +++ b/src/core/public/notifications/toasts/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { ToastsService } from './toasts_service'; +export { ToastsStartContract, ToastInput } from './toasts_start_contract'; +export { Toast } from '@elastic/eui'; diff --git a/src/core/public/notifications/toasts/toasts_service.test.tsx b/src/core/public/notifications/toasts/toasts_service.test.tsx new file mode 100644 index 0000000000000..68f2eae6093af --- /dev/null +++ b/src/core/public/notifications/toasts/toasts_service.test.tsx @@ -0,0 +1,80 @@ +/* + * 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. + */ + +const mockReactDomRender = jest.fn(); +const mockReactDomUnmount = jest.fn(); +jest.mock('react-dom', () => ({ + render: mockReactDomRender, + unmountComponentAtNode: mockReactDomUnmount, +})); + +import { ToastsService } from './toasts_service'; +import { ToastsStartContract } from './toasts_start_contract'; + +describe('#start()', () => { + it('renders the GlobalToastList into the targetDomElement param', async () => { + const targetDomElement = document.createElement('div'); + targetDomElement.setAttribute('test', 'target-dom-element'); + const toasts = new ToastsService({ targetDomElement }); + + expect(mockReactDomRender).not.toHaveBeenCalled(); + toasts.start(); + expect(mockReactDomRender.mock.calls).toMatchSnapshot(); + }); + + it('returns a ToastsStartContract', () => { + const toasts = new ToastsService({ + targetDomElement: document.createElement('div'), + }); + + expect(toasts.start()).toBeInstanceOf(ToastsStartContract); + }); +}); + +describe('#stop()', () => { + it('unmounts the GlobalToastList from the targetDomElement', () => { + const targetDomElement = document.createElement('div'); + targetDomElement.setAttribute('test', 'target-dom-element'); + const toasts = new ToastsService({ targetDomElement }); + + toasts.start(); + + expect(mockReactDomUnmount).not.toHaveBeenCalled(); + toasts.stop(); + expect(mockReactDomUnmount.mock.calls).toMatchSnapshot(); + }); + + it('does not fail if start() was never called', () => { + const targetDomElement = document.createElement('div'); + targetDomElement.setAttribute('test', 'target-dom-element'); + const toasts = new ToastsService({ targetDomElement }); + expect(() => { + toasts.stop(); + }).not.toThrowError(); + }); + + it('empties the content of the targetDomElement', () => { + const targetDomElement = document.createElement('div'); + const toasts = new ToastsService({ targetDomElement }); + + targetDomElement.appendChild(document.createTextNode('foo bar')); + toasts.stop(); + expect(targetDomElement.childNodes).toHaveLength(0); + }); +}); diff --git a/src/core/public/notifications/toasts/toasts_service.tsx b/src/core/public/notifications/toasts/toasts_service.tsx new file mode 100644 index 0000000000000..fd7f7411b863e --- /dev/null +++ b/src/core/public/notifications/toasts/toasts_service.tsx @@ -0,0 +1,53 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; + +import { Toast } from '@elastic/eui'; +import { GlobalToastList } from './global_toast_list'; +import { ToastsStartContract } from './toasts_start_contract'; + +interface Params { + targetDomElement: HTMLElement; +} + +export class ToastsService { + constructor(private readonly params: Params) {} + + public start() { + const toasts = new ToastsStartContract(); + + render( + toasts.remove(toast)} + toasts$={toasts.get$()} + />, + this.params.targetDomElement + ); + + return toasts; + } + + public stop() { + unmountComponentAtNode(this.params.targetDomElement); + + this.params.targetDomElement.textContent = ''; + } +} diff --git a/src/core/public/notifications/toasts/toasts_start_contract.test.ts b/src/core/public/notifications/toasts/toasts_start_contract.test.ts new file mode 100644 index 0000000000000..2810f57129791 --- /dev/null +++ b/src/core/public/notifications/toasts/toasts_start_contract.test.ts @@ -0,0 +1,159 @@ +/* + * 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 { take } from 'rxjs/operators'; + +import { ToastsStartContract } from './toasts_start_contract'; + +async function getCurrentToasts(toasts: ToastsStartContract) { + return await toasts + .get$() + .pipe(take(1)) + .toPromise(); +} + +describe('#get$()', () => { + it('returns observable that emits NEW toast list when something added or removed', () => { + const toasts = new ToastsStartContract(); + const onToasts = jest.fn(); + + toasts.get$().subscribe(onToasts); + const foo = toasts.add('foo'); + const bar = toasts.add('bar'); + toasts.remove(foo); + + expect(onToasts).toHaveBeenCalledTimes(4); + + const initial = onToasts.mock.calls[0][0]; + expect(initial).toEqual([]); + + const afterFoo = onToasts.mock.calls[1][0]; + expect(afterFoo).not.toBe(initial); + expect(afterFoo).toEqual([foo]); + + const afterFooAndBar = onToasts.mock.calls[2][0]; + expect(afterFooAndBar).not.toBe(afterFoo); + expect(afterFooAndBar).toEqual([foo, bar]); + + const afterRemoveFoo = onToasts.mock.calls[3][0]; + expect(afterRemoveFoo).not.toBe(afterFooAndBar); + expect(afterRemoveFoo).toEqual([bar]); + }); + + it('does not emit a new toast list when unknown toast is passed to remove()', () => { + const toasts = new ToastsStartContract(); + const onToasts = jest.fn(); + + toasts.get$().subscribe(onToasts); + toasts.add('foo'); + onToasts.mockClear(); + + toasts.remove({ id: 'bar' }); + expect(onToasts).not.toHaveBeenCalled(); + }); +}); + +describe('#add()', () => { + it('returns toast objects with auto assigned id', () => { + const toasts = new ToastsStartContract(); + const toast = toasts.add({ title: 'foo' }); + expect(toast).toHaveProperty('id'); + expect(toast).toHaveProperty('title', 'foo'); + }); + + it('adds the toast to toasts list', async () => { + const toasts = new ToastsStartContract(); + const toast = toasts.add({}); + + const currentToasts = await getCurrentToasts(toasts); + expect(currentToasts).toHaveLength(1); + expect(currentToasts[0]).toBe(toast); + }); + + it('increments the toast ID for each additional toast', () => { + const toasts = new ToastsStartContract(); + expect(toasts.add({})).toHaveProperty('id', '0'); + expect(toasts.add({})).toHaveProperty('id', '1'); + expect(toasts.add({})).toHaveProperty('id', '2'); + }); + + it('accepts a string, uses it as the title', async () => { + const toasts = new ToastsStartContract(); + expect(toasts.add('foo')).toHaveProperty('title', 'foo'); + }); +}); + +describe('#remove()', () => { + it('removes a toast', async () => { + const toasts = new ToastsStartContract(); + toasts.remove(toasts.add('Test')); + expect(await getCurrentToasts(toasts)).toHaveLength(0); + }); + + it('ignores unknown toast', async () => { + const toasts = new ToastsStartContract(); + toasts.add('Test'); + toasts.remove({ id: 'foo' }); + + const currentToasts = await getCurrentToasts(toasts); + expect(currentToasts).toHaveLength(1); + }); +}); + +describe('#addSuccess()', () => { + it('adds a success toast', async () => { + const toasts = new ToastsStartContract(); + expect(toasts.addSuccess({})).toHaveProperty('color', 'success'); + }); + + it('returns the created toast', async () => { + const toasts = new ToastsStartContract(); + const toast = toasts.addSuccess({}); + const currentToasts = await getCurrentToasts(toasts); + expect(currentToasts[0]).toBe(toast); + }); +}); + +describe('#addWarning()', () => { + it('adds a warning toast', async () => { + const toasts = new ToastsStartContract(); + expect(toasts.addWarning({})).toHaveProperty('color', 'warning'); + }); + + it('returns the created toast', async () => { + const toasts = new ToastsStartContract(); + const toast = toasts.addWarning({}); + const currentToasts = await getCurrentToasts(toasts); + expect(currentToasts[0]).toBe(toast); + }); +}); + +describe('#addDanger()', () => { + it('adds a danger toast', async () => { + const toasts = new ToastsStartContract(); + expect(toasts.addDanger({})).toHaveProperty('color', 'danger'); + }); + + it('returns the created toast', async () => { + const toasts = new ToastsStartContract(); + const toast = toasts.addDanger({}); + const currentToasts = await getCurrentToasts(toasts); + expect(currentToasts[0]).toBe(toast); + }); +}); diff --git a/src/ui/public/notify/toasts/toast_notifications.js b/src/core/public/notifications/toasts/toasts_start_contract.tsx similarity index 59% rename from src/ui/public/notify/toasts/toast_notifications.js rename to src/core/public/notifications/toasts/toasts_start_contract.tsx index 9057a598c6290..3f2bbe0425beb 100644 --- a/src/ui/public/notify/toasts/toast_notifications.js +++ b/src/core/public/notifications/toasts/toasts_start_contract.tsx @@ -17,7 +17,12 @@ * under the License. */ -const normalizeToast = toastOrTitle => { +import { Toast } from '@elastic/eui'; +import * as Rx from 'rxjs'; + +export type ToastInput = string | Pick>; + +const normalizeToast = (toastOrTitle: ToastInput) => { if (typeof toastOrTitle === 'string') { return { title: toastOrTitle, @@ -27,67 +32,54 @@ const normalizeToast = toastOrTitle => { return toastOrTitle; }; -export class ToastNotifications { - constructor() { - this.list = []; - this.idCounter = 0; - this.onChangeCallback = null; - } +export class ToastsStartContract { + private toasts$ = new Rx.BehaviorSubject([]); + private idCounter = 0; - _changed = () => { - if (this.onChangeCallback) { - this.onChangeCallback(); - } + public get$() { + return this.toasts$.asObservable(); } - onChange = callback => { - this.onChangeCallback = callback; - }; - - add = toastOrTitle => { - const toast = { - id: this.idCounter++, + public add(toastOrTitle: ToastInput) { + const toast: Toast = { + id: String(this.idCounter++), ...normalizeToast(toastOrTitle), }; - this.list.push(toast); - this._changed(); + this.toasts$.next([...this.toasts$.getValue(), toast]); return toast; - }; - - remove = toast => { - const index = this.list.indexOf(toast); + } - if (index !== -1) { - this.list.splice(index, 1); - this._changed(); + public remove(toast: Toast) { + const list = this.toasts$.getValue(); + const listWithoutToast = list.filter(t => t !== toast); + if (listWithoutToast.length !== list.length) { + this.toasts$.next(listWithoutToast); } - }; + } - addSuccess = toastOrTitle => { + public addSuccess(toastOrTitle: ToastInput) { return this.add({ color: 'success', iconType: 'check', ...normalizeToast(toastOrTitle), }); - }; + } - addWarning = toastOrTitle => { + public addWarning(toastOrTitle: ToastInput) { return this.add({ color: 'warning', iconType: 'help', ...normalizeToast(toastOrTitle), }); - }; + } - addDanger = toastOrTitle => { + public addDanger(toastOrTitle: ToastInput) { return this.add({ color: 'danger', iconType: 'alert', ...normalizeToast(toastOrTitle), }); - }; + } } - -export const toastNotifications = new ToastNotifications(); diff --git a/src/ui/public/chrome/directives/kbn_chrome.html b/src/ui/public/chrome/directives/kbn_chrome.html index cf88b866e31ae..1c993506dab5e 100644 --- a/src/ui/public/chrome/directives/kbn_chrome.html +++ b/src/ui/public/chrome/directives/kbn_chrome.html @@ -15,7 +15,6 @@ >
-
diff --git a/src/ui/public/chrome/directives/kbn_chrome.js b/src/ui/public/chrome/directives/kbn_chrome.js index 1f1b03df53bdd..31bdf62646a7f 100644 --- a/src/ui/public/chrome/directives/kbn_chrome.js +++ b/src/ui/public/chrome/directives/kbn_chrome.js @@ -29,8 +29,6 @@ import { } from '../../state_management/state_hashing'; import { notify, - GlobalToastList, - toastNotifications, GlobalBannerList, banners, } from '../../notify'; @@ -99,17 +97,6 @@ export function kbnChromeProvider(chrome, internals) { document.getElementById('globalBannerList') ); - // Toast Notifications - ReactDOM.render( - , - document.getElementById('globalToastList') - ); - return chrome; } }; diff --git a/src/ui/public/notify/index.d.ts b/src/ui/public/notify/index.d.ts index 0a6f8392c0d74..829cf131f5a0a 100644 --- a/src/ui/public/notify/index.d.ts +++ b/src/ui/public/notify/index.d.ts @@ -17,4 +17,4 @@ * under the License. */ -export { toastNotifications, ToastNotifications } from './toasts'; +export { toastNotifications, Toast, ToastInput } from './toasts'; diff --git a/src/ui/public/notify/index.js b/src/ui/public/notify/index.js index 7b2d704cc2424..078b4807430dd 100644 --- a/src/ui/public/notify/index.js +++ b/src/ui/public/notify/index.js @@ -20,6 +20,6 @@ export { notify } from './notify'; export { Notifier } from './notifier'; export { fatalError, addFatalErrorCallback } from './fatal_error'; -export { GlobalToastList, toastNotifications } from './toasts'; +export { toastNotifications } from './toasts'; export { GlobalBannerList, banners } from './banners'; export { addAppRedirectMessageToUrl, showAppRedirectNotification } from './app_redirect'; diff --git a/src/ui/public/notify/toasts/index.d.ts b/src/ui/public/notify/toasts/index.ts similarity index 86% rename from src/ui/public/notify/toasts/index.d.ts rename to src/ui/public/notify/toasts/index.ts index b841aa1c8334a..9ba1a1667b61b 100644 --- a/src/ui/public/notify/toasts/index.d.ts +++ b/src/ui/public/notify/toasts/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export { toastNotifications, ToastNotifications } from './toast_notifications'; +export { toastNotifications, __newPlatformInit__ } from './toasts'; +export { Toast, ToastInput } from './toast_notifications'; diff --git a/src/ui/public/notify/toasts/toast_notifications.test.js b/src/ui/public/notify/toasts/toast_notifications.test.ts similarity index 60% rename from src/ui/public/notify/toasts/toast_notifications.test.js rename to src/ui/public/notify/toasts/toast_notifications.test.ts index 05b4549023ee2..910002b2ce190 100644 --- a/src/ui/public/notify/toasts/toast_notifications.test.js +++ b/src/ui/public/notify/toasts/toast_notifications.test.ts @@ -18,98 +18,111 @@ */ import sinon from 'sinon'; +import { ToastsStartContract } from '../../../../core/public/notifications'; -import { - ToastNotifications, -} from './toast_notifications'; +import { ToastNotifications } from './toast_notifications'; describe('ToastNotifications', () => { describe('interface', () => { - let toastNotifications; - - beforeEach(() => { - toastNotifications = new ToastNotifications(); - }); + function setup() { + return { + toastNotifications: new ToastNotifications(new ToastsStartContract()), + }; + } describe('add method', () => { test('adds a toast', () => { + const { toastNotifications } = setup(); toastNotifications.add({}); - expect(toastNotifications.list.length).toBe(1); + expect(toastNotifications.list).toHaveLength(1); }); test('adds a toast with an ID property', () => { + const { toastNotifications } = setup(); toastNotifications.add({}); - expect(toastNotifications.list[0].id).toBe(0); + expect(toastNotifications.list[0]).toHaveProperty('id', '0'); }); test('increments the toast ID', () => { + const { toastNotifications } = setup(); toastNotifications.add({}); toastNotifications.add({}); - expect(toastNotifications.list[1].id).toBe(1); + expect(toastNotifications.list[1]).toHaveProperty('id', '1'); }); test('accepts a string', () => { + const { toastNotifications } = setup(); toastNotifications.add('New toast'); - expect(toastNotifications.list[0].title).toBe('New toast'); + expect(toastNotifications.list[0]).toHaveProperty('title', 'New toast'); }); }); describe('remove method', () => { test('removes a toast', () => { + const { toastNotifications } = setup(); const toast = toastNotifications.add('Test'); toastNotifications.remove(toast); - expect(toastNotifications.list.length).toBe(0); + expect(toastNotifications.list).toHaveLength(0); }); test('ignores unknown toast', () => { - toastNotifications.add('Test'); - toastNotifications.remove({}); - expect(toastNotifications.list.length).toBe(1); + const { toastNotifications } = setup(); + const toast = toastNotifications.add('Test'); + toastNotifications.remove({ + id: `not ${toast.id}`, + }); + expect(toastNotifications.list).toHaveLength(1); }); }); describe('onChange method', () => { test('callback is called when a toast is added', () => { + const { toastNotifications } = setup(); const onChangeSpy = sinon.spy(); toastNotifications.onChange(onChangeSpy); toastNotifications.add({}); - expect(onChangeSpy.callCount).toBe(1); + sinon.assert.calledOnce(onChangeSpy); }); test('callback is called when a toast is removed', () => { + const { toastNotifications } = setup(); const onChangeSpy = sinon.spy(); toastNotifications.onChange(onChangeSpy); const toast = toastNotifications.add({}); toastNotifications.remove(toast); - expect(onChangeSpy.callCount).toBe(2); + sinon.assert.calledTwice(onChangeSpy); }); test('callback is not called when remove is ignored', () => { + const { toastNotifications } = setup(); const onChangeSpy = sinon.spy(); toastNotifications.onChange(onChangeSpy); - toastNotifications.remove({}); - expect(onChangeSpy.callCount).toBe(0); + toastNotifications.remove({ id: 'foo' }); + sinon.assert.notCalled(onChangeSpy); }); }); describe('addSuccess method', () => { test('adds a success toast', () => { + const { toastNotifications } = setup(); toastNotifications.addSuccess({}); - expect(toastNotifications.list[0].color).toBe('success'); + expect(toastNotifications.list[0]).toHaveProperty('color', 'success'); }); }); describe('addWarning method', () => { test('adds a warning toast', () => { + const { toastNotifications } = setup(); toastNotifications.addWarning({}); - expect(toastNotifications.list[0].color).toBe('warning'); + expect(toastNotifications.list[0]).toHaveProperty('color', 'warning'); }); }); describe('addDanger method', () => { test('adds a danger toast', () => { + const { toastNotifications } = setup(); toastNotifications.addDanger({}); - expect(toastNotifications.list[0].color).toBe('danger'); + expect(toastNotifications.list[0]).toHaveProperty('color', 'danger'); }); }); }); diff --git a/src/ui/public/notify/toasts/toast_notifications.ts b/src/ui/public/notify/toasts/toast_notifications.ts new file mode 100644 index 0000000000000..7e4ea11edf3a7 --- /dev/null +++ b/src/ui/public/notify/toasts/toast_notifications.ts @@ -0,0 +1,48 @@ +/* + * 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 { Toast, ToastInput, ToastsStartContract } from '../../../../core/public/notifications'; + +export { Toast, ToastInput }; + +export class ToastNotifications { + public list: Toast[] = []; + + private onChangeCallback?: () => void; + + constructor(private readonly toasts: ToastsStartContract) { + toasts.get$().subscribe(list => { + this.list = list; + + if (this.onChangeCallback) { + this.onChangeCallback(); + } + }); + } + + public onChange = (callback: () => void) => { + this.onChangeCallback = callback; + }; + + public add = (toastOrTitle: ToastInput) => this.toasts.add(toastOrTitle); + public remove = (toast: Toast) => this.toasts.remove(toast); + public addSuccess = (toastOrTitle: ToastInput) => this.toasts.addSuccess(toastOrTitle); + public addWarning = (toastOrTitle: ToastInput) => this.toasts.addWarning(toastOrTitle); + public addDanger = (toastOrTitle: ToastInput) => this.toasts.addDanger(toastOrTitle); +} diff --git a/src/ui/public/notify/toasts/toast_notifications.d.ts b/src/ui/public/notify/toasts/toasts.ts similarity index 58% rename from src/ui/public/notify/toasts/toast_notifications.d.ts rename to src/ui/public/notify/toasts/toasts.ts index 565c5c4ad725c..cc3a8f18fc101 100644 --- a/src/ui/public/notify/toasts/toast_notifications.d.ts +++ b/src/ui/public/notify/toasts/toasts.ts @@ -17,25 +17,15 @@ * under the License. */ -interface Toast extends ToastDescription { - id: number; -} +import { ToastsStartContract } from '../../../../core/public/notifications'; +import { ToastNotifications } from './toast_notifications'; -interface ToastDescription { - title: string; - color?: string; - iconType?: string; - text?: string; - 'data-test-subj'?: string; -} +export let toastNotifications: ToastNotifications; -export interface ToastNotifications { - onChange(changeCallback: () => void): void; - remove(toast: Toast): void; - add(toast: ToastDescription | string): Toast; - addSuccess(toast: ToastDescription | string): Toast; - addWarning(toast: ToastDescription | string): Toast; - addDanger(toast: ToastDescription | string): Toast; -} +export function __newPlatformInit__(toasts: ToastsStartContract) { + if (toastNotifications) { + throw new Error('ui/notify/toasts already initialized with new platform apis'); + } -export const toastNotifications: ToastNotifications; + toastNotifications = new ToastNotifications(toasts); +}