[toasts] migrate toastNotifications to the new platform (#21772)

Fixes #20698

As part of the transition of APIs necessary for migrating the Chrome to the new platform, this moves the core logic for the toastNotifications out of `ui/notify` and into the new platform as the `core.notifications.toasts` service. I chose to use the `notifications` namespace here as I plan for the [`banners` service](494c267cd9/src/ui/public/notify/banners/banners.js) from `ui/notify` to eventually live at `core.notifications.banners`. If you disagree with this strategy and would prefer that we use something like `core.toastNotifications` let me know.

For the most part this service just does the same thing as the ui service did, so functionality should be exactly the same. To test the notifications I suggest using the testbed like so: https://gist.github.com/spalger/81097177c88dee142700fab25de88932
This commit is contained in:
Spencer 2018-08-14 09:27:12 -07:00 committed by GitHub
parent 1e6fb80be2
commit c01c2f95e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 751 additions and 142 deletions

View file

@ -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<LegacyPlatformService>(
function _MockLegacyPlatformService(this: any) {
@ -52,6 +53,18 @@ jest.mock('./fatal_errors', () => ({
FatalErrorsService: MockFatalErrorsService,
}));
const mockNotificationStartContract = {};
const MockNotificationsService = jest.fn<NotificationsService>(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 = '<p>foo bar</p>';
startCore(root);
expect(root.innerHTML).toBe('<div></div>');
expect(root.innerHTML).toBe('<div></div><div></div>');
});
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);
});
});

View file

@ -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 = '';
}
}

View file

@ -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';

View file

@ -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,

View file

@ -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',
]);

View file

@ -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

View file

@ -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';

View file

@ -0,0 +1,53 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { 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<NotificationsService['start']>;

View file

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders matching snapshot 1`] = `
<EuiGlobalToastList
dismissToast={[MockFunction]}
toastLifeTimeMs={6000}
toasts={Array []}
/>
`;

View file

@ -0,0 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#start() renders the GlobalToastList into the targetDomElement param 1`] = `
Array [
Array [
<GlobalToastList
dismissToast={[Function]}
toasts$={
Observable {
"_isScalar": false,
"source": BehaviorSubject {
"_isScalar": false,
"_value": Array [],
"closed": false,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
/>,
<div
test="target-dom-element"
/>,
],
]
`;
exports[`#stop() unmounts the GlobalToastList from the targetDomElement 1`] = `
Array [
Array [
<div
test="target-dom-element"
/>,
],
]
`;

View file

@ -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<GlobalToastList['props']> = {}) {
return <GlobalToastList dismissToast={jest.fn()} toasts$={Rx.EMPTY} {...props} />;
}
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<any>(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 <EuiGlobalToastList />', () => {
const el = shallow(
render({
toasts$: Rx.from([[], [1], [1, 2]]) as any,
})
);
expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([1, 2]);
});

View file

@ -17,47 +17,46 @@
* under the License.
*/
import React, {
Component,
} from 'react';
import PropTypes from 'prop-types';
import { EuiGlobalToastList, Toast } from '@elastic/eui';
import {
EuiGlobalToastList,
EuiPortal,
} from '@elastic/eui';
import React from 'react';
import * as Rx from 'rxjs';
export class GlobalToastList extends Component {
constructor(props) {
super(props);
interface Props {
toasts$: Rx.Observable<Toast[]>;
dismissToast: (t: Toast) => void;
}
if (this.props.subscribe) {
this.props.subscribe(() => this.forceUpdate());
interface State {
toasts: Toast[];
}
export class GlobalToastList extends React.Component<Props, State> {
public state: State = {
toasts: [],
};
private subscription?: Rx.Subscription;
public componentDidMount() {
this.subscription = this.props.toasts$.subscribe(toasts => {
this.setState({ toasts });
});
}
public componentWillUnmount() {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
static propTypes = {
subscribe: PropTypes.func,
toasts: PropTypes.array,
dismissToast: PropTypes.func.isRequired,
toastLifeTimeMs: PropTypes.number.isRequired,
};
render() {
const {
toasts,
dismissToast,
toastLifeTimeMs,
} = this.props;
public render() {
return (
<EuiPortal>
<EuiGlobalToastList
toasts={toasts}
dismissToast={dismissToast}
toastLifeTimeMs={toastLifeTimeMs}
/>
</EuiPortal>
<EuiGlobalToastList
toasts={this.state.toasts}
dismissToast={this.props.dismissToast}
toastLifeTimeMs={6000}
/>
);
}
}

View file

@ -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';

View file

@ -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);
});
});

View file

@ -0,0 +1,53 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import 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(
<GlobalToastList
dismissToast={(toast: Toast) => toasts.remove(toast)}
toasts$={toasts.get$()}
/>,
this.params.targetDomElement
);
return toasts;
}
public stop() {
unmountComponentAtNode(this.params.targetDomElement);
this.params.targetDomElement.textContent = '';
}
}

View file

@ -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);
});
});

View file

@ -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<Toast, Exclude<keyof Toast, 'id'>>;
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<Toast[]>([]);
private idCounter = 0;
public get$() {
return this.toasts$.asObservable();
}
_changed = () => {
if (this.onChangeCallback) {
this.onChangeCallback();
}
}
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();

View file

@ -15,7 +15,6 @@
></kbn-notifications>
<div id="globalBannerList"></div>
<div id="globalToastList"></div>
<kbn-loading-indicator></kbn-loading-indicator>

View file

@ -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(
<GlobalToastList
toasts={toastNotifications.list}
dismissToast={toastNotifications.remove}
toastLifeTimeMs={6000}
subscribe={toastNotifications.onChange}
/>,
document.getElementById('globalToastList')
);
return chrome;
}
};

View file

@ -17,4 +17,4 @@
* under the License.
*/
export { toastNotifications, ToastNotifications } from './toasts';
export { toastNotifications, Toast, ToastInput } from './toasts';

View file

@ -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';

View file

@ -17,4 +17,5 @@
* under the License.
*/
export { toastNotifications, ToastNotifications } from './toast_notifications';
export { toastNotifications, __newPlatformInit__ } from './toasts';
export { Toast, ToastInput } from './toast_notifications';

View file

@ -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');
});
});
});

View file

@ -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);
}

View file

@ -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);
}