diff --git a/package.json b/package.json index a0ae80509a70..cb8cad93ed35 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ }, "dependencies": { "@elastic/datemath": "4.0.2", - "@elastic/eui": "0.0.19", + "@elastic/eui": "0.0.18", "@elastic/filesaver": "1.1.2", "@elastic/numeral": "2.3.0", "@elastic/test-subj-selector": "0.2.1", diff --git a/src/ui/public/notify/toasts/__snapshots__/global_toast_list.test.js.snap b/src/ui/public/notify/toasts/__snapshots__/global_toast_list.test.js.snap new file mode 100644 index 000000000000..111a9dbf5b16 --- /dev/null +++ b/src/ui/public/notify/toasts/__snapshots__/global_toast_list.test.js.snap @@ -0,0 +1,146 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GlobalToastList is rendered 1`] = ` +
+`; + +exports[`GlobalToastList props toasts is rendered 1`] = ` +
+
+
+ + + A + +
+ +
+ a +
+
+
+
+ + + B + +
+ +
+ b +
+
+
+`; diff --git a/src/ui/public/notify/toasts/global_toast_list.js b/src/ui/public/notify/toasts/global_toast_list.js index b65f1fa6b553..d08f07a3f903 100644 --- a/src/ui/public/notify/toasts/global_toast_list.js +++ b/src/ui/public/notify/toasts/global_toast_list.js @@ -5,37 +5,126 @@ import PropTypes from 'prop-types'; import { EuiGlobalToastList, + EuiGlobalToastListItem, + EuiToast, } from '@elastic/eui'; +export const TOAST_FADE_OUT_MS = 250; + export class GlobalToastList extends Component { constructor(props) { super(props); + this.state = { + toastIdToDismissedMap: {} + }; + + this.timeoutIds = []; + this.toastIdToScheduledForDismissalMap = {}; + if (this.props.subscribe) { this.props.subscribe(() => this.forceUpdate()); } } static propTypes = { - subscribe: PropTypes.func, toasts: PropTypes.array, + subscribe: PropTypes.func, dismissToast: PropTypes.func.isRequired, toastLifeTimeMs: PropTypes.number.isRequired, }; + static defaultProps = { + toasts: [], + }; + + scheduleAllToastsForDismissal = () => { + this.props.toasts.forEach(toast => { + if (!this.toastIdToScheduledForDismissalMap[toast.id]) { + this.scheduleToastForDismissal(toast); + } + }); + }; + + scheduleToastForDismissal = (toast, isImmediate = false) => { + this.toastIdToScheduledForDismissalMap[toast.id] = true; + const toastLifeTimeMs = isImmediate ? 0 : this.props.toastLifeTimeMs; + + // Start fading the toast out once its lifetime elapses. + this.timeoutIds.push(setTimeout(() => { + this.startDismissingToast(toast); + }, toastLifeTimeMs)); + + // Remove the toast after it's done fading out. + this.timeoutIds.push(setTimeout(() => { + this.props.dismissToast(toast); + this.setState(prevState => { + const toastIdToDismissedMap = { ...prevState.toastIdToDismissedMap }; + delete toastIdToDismissedMap[toast.id]; + delete this.toastIdToScheduledForDismissalMap[toast.id]; + + return { + toastIdToDismissedMap, + }; + }); + }, toastLifeTimeMs + TOAST_FADE_OUT_MS)); + }; + + startDismissingToast(toast) { + this.setState(prevState => { + const toastIdToDismissedMap = { + ...prevState.toastIdToDismissedMap, + [toast.id]: true, + }; + + return { + toastIdToDismissedMap, + }; + }); + } + + componentDidMount() { + this.scheduleAllToastsForDismissal(); + } + + componentWillUnmount() { + this.timeoutIds.forEach(clearTimeout); + } + + componentDidUpdate() { + this.scheduleAllToastsForDismissal(); + } + render() { const { toasts, - dismissToast, - toastLifeTimeMs, } = this.props; + const renderedToasts = toasts.map(toast => { + const { + text, + ...rest + } = toast; + + return ( + + + {text} + + + ); + }); + return ( - + + {renderedToasts} + ); } } diff --git a/src/ui/public/notify/toasts/global_toast_list.test.js b/src/ui/public/notify/toasts/global_toast_list.test.js new file mode 100644 index 000000000000..4bdd6cfd15f3 --- /dev/null +++ b/src/ui/public/notify/toasts/global_toast_list.test.js @@ -0,0 +1,103 @@ +import React from 'react'; +import { render, mount } from 'enzyme'; +import sinon from 'sinon'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +import { + GlobalToastList, + TOAST_FADE_OUT_MS, +} from './global_toast_list'; + +describe('GlobalToastList', () => { + test('is rendered', () => { + const component = render( + {}} + toastLifeTimeMs={5} + /> + ); + + expect(component) + .toMatchSnapshot(); + }); + + describe('props', () => { + describe('toasts', () => { + test('is rendered', () => { + const toasts = [{ + title: 'A', + text: 'a', + color: 'success', + iconType: 'check', + 'data-test-subj': 'a', + id: 'a', + }, { + title: 'B', + text: 'b', + color: 'danger', + iconType: 'alert', + 'data-test-subj': 'b', + id: 'b', + }]; + + const component = render( + {}} + toastLifeTimeMs={5} + /> + ); + + expect(component) + .toMatchSnapshot(); + }); + }); + + describe('dismissToast', () => { + test('is called when a toast is clicked', done => { + const dismissToastSpy = sinon.spy(); + const component = mount( + + ); + + const toastB = findTestSubject(component, 'b'); + const closeButton = findTestSubject(toastB, 'toastCloseButton'); + closeButton.simulate('click'); + + // The callback is invoked once the toast fades from view. + setTimeout(() => { + expect(dismissToastSpy.called).toBe(true); + done(); + }, TOAST_FADE_OUT_MS + 1); + }); + + test('is called when the toast lifetime elapses', done => { + const TOAST_LIFE_TIME_MS = 5; + const dismissToastSpy = sinon.spy(); + mount( + + ); + + // The callback is invoked once the toast fades from view. + setTimeout(() => { + expect(dismissToastSpy.called).toBe(true); + done(); + }, TOAST_LIFE_TIME_MS + TOAST_FADE_OUT_MS); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 9c016484a1a1..5493a1b5b0e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -87,9 +87,9 @@ version "0.0.0" uid "" -"@elastic/eui@0.0.19": - version "0.0.19" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-0.0.19.tgz#f5e57151c20eb9ef0544155b09a1b9c5d8bfa6ef" +"@elastic/eui@0.0.18": + version "0.0.18" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-0.0.18.tgz#eeae6e93ead3bd014f401f577f06af75fff60898" dependencies: brace "^0.10.0" classnames "^2.2.5"