diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/deduplicate_toasts.test.tsx.snap b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/deduplicate_toasts.test.tsx.snap
new file mode 100644
index 0000000000000..b940ae7a6a178
--- /dev/null
+++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/deduplicate_toasts.test.tsx.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TitleWithBadge component renders with string titles 1`] = `
+
+ Welcome!
+
+
+ 5
+
+
+`;
diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/global_toast_list.test.tsx.snap b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/global_toast_list.test.tsx.snap
index 558f0f19080e6..d9dc9f6c7b13d 100644
--- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/global_toast_list.test.tsx.snap
+++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/global_toast_list.test.tsx.snap
@@ -1,5 +1,117 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`global_toast_list with duplicate elements renders the list with a single element 1`] = `
+,
+ "toastLifeTimeMs": 5000,
+ },
+ ]
+ }
+/>
+`;
+
+exports[`global_toast_list with duplicate elements, using MountPoints renders the all separate elements element: euiToastList 1`] = `
+,
+ "toastLifeTimeMs": 5000,
+ },
+ Object {
+ "id": "1",
+ "text": "You've got mail!",
+ "title": ,
+ "toastLifeTimeMs": 5000,
+ },
+ Object {
+ "id": "2",
+ "text": "You've got mail!",
+ "title": ,
+ "toastLifeTimeMs": 5000,
+ },
+ Object {
+ "id": "3",
+ "text": "You've got mail!",
+ "title": ,
+ "toastLifeTimeMs": 5000,
+ },
+ ]
+ }
+/>
+`;
+
+exports[`global_toast_list with duplicate elements, using MountPoints renders the all separate elements element: globalToastList 1`] = `
+,
+ "toastLifeTimeMs": 5000,
+ },
+ Object {
+ "id": "1",
+ "text": "You've got mail!",
+ "title": ,
+ "toastLifeTimeMs": 5000,
+ },
+ Object {
+ "id": "2",
+ "text": "You've got mail!",
+ "title": ,
+ "toastLifeTimeMs": 5000,
+ },
+ Object {
+ "id": "3",
+ "text": "You've got mail!",
+ "title": ,
+ "toastLifeTimeMs": 5000,
+ },
+ ]
+ }
+/>
+`;
+
exports[`renders matching snapshot 1`] = `
() => {};
+
+describe('deduplicate toasts', () => {
+ it('returns an empty list for an empty input', () => {
+ const toasts: Toast[] = [];
+
+ const { toasts: deduplicatedToastList } = deduplicateToasts(toasts);
+
+ expect(deduplicatedToastList).toHaveLength(0);
+ });
+
+ it(`doesn't affect singular notifications`, () => {
+ const toasts: Toast[] = [
+ toast('A', 'B'), // single toast
+ toast('X', 'Y'), // single toast
+ ];
+
+ const { toasts: deduplicatedToastList } = deduplicateToasts(toasts);
+
+ expect(deduplicatedToastList).toHaveLength(toasts.length);
+ verifyTextAndTitle(deduplicatedToastList[0], 'A', 'B');
+ verifyTextAndTitle(deduplicatedToastList[1], 'X', 'Y');
+ });
+
+ it(`doesn't group notifications with MountPoints for title`, () => {
+ const toasts: Toast[] = [
+ toast('A', 'B'),
+ toast(fakeMountPoint, 'B'),
+ toast(fakeMountPoint, 'B'),
+ toast(fakeMountPoint, fakeMountPoint),
+ toast(fakeMountPoint, fakeMountPoint),
+ ];
+
+ const { toasts: deduplicatedToastList } = deduplicateToasts(toasts);
+
+ expect(deduplicatedToastList).toHaveLength(toasts.length);
+ });
+
+ it('groups toasts based on title + text', () => {
+ const toasts: Toast[] = [
+ toast('A', 'B'), // 2 of these
+ toast('X', 'Y'), // 3 of these
+ toast('A', 'B'),
+ toast('X', 'Y'),
+ toast('A', 'C'), // 1 of these
+ toast('X', 'Y'),
+ ];
+
+ const { toasts: deduplicatedToastList } = deduplicateToasts(toasts);
+
+ expect(deduplicatedToastList).toHaveLength(3);
+ verifyTextAndTitle(deduplicatedToastList[0], 'A 2', 'B');
+ verifyTextAndTitle(deduplicatedToastList[1], 'X 3', 'Y');
+ verifyTextAndTitle(deduplicatedToastList[2], 'A', 'C');
+ });
+
+ it('groups toasts based on title, when text is not available', () => {
+ const toasts: Toast[] = [
+ toast('A', 'B'), // 2 of these
+ toast('A', fakeMountPoint), // 2 of these
+ toast('A', 'C'), // 1 of this
+ toast('A', 'B'),
+ toast('A', fakeMountPoint),
+ toast('A'), // but it doesn't group functions with missing texts
+ ];
+
+ const { toasts: deduplicatedToastList } = deduplicateToasts(toasts);
+
+ expect(deduplicatedToastList).toHaveLength(4);
+ verifyTextAndTitle(deduplicatedToastList[0], 'A 2', 'B');
+ verifyTextAndTitle(deduplicatedToastList[1], 'A 2', expect.any(Function));
+ verifyTextAndTitle(deduplicatedToastList[2], 'A', 'C');
+ verifyTextAndTitle(deduplicatedToastList[3], 'A', undefined);
+ });
+});
+
+describe('TitleWithBadge component', () => {
+ it('renders with string titles', () => {
+ const title = 'Welcome!';
+
+ const titleComponent = ;
+ const shallowRender = shallow(titleComponent);
+ const fullRender = mount(titleComponent);
+
+ expect(fullRender.text()).toBe('Welcome! 5');
+ expect(shallowRender).toMatchSnapshot();
+ });
+});
+
+function verifyTextAndTitle(
+ { text, title }: ToastWithRichTitle,
+ expectedTitle?: string,
+ expectedText?: string
+) {
+ expect(getNodeText(title)).toEqual(expectedTitle);
+ expect(text).toEqual(expectedText);
+}
+
+function getNodeText(node: ReactNode) {
+ const rendered = render(node as ReactElement);
+ return rendered.text();
+}
diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/deduplicate_toasts.tsx b/packages/core/notifications/core-notifications-browser-internal/src/toasts/deduplicate_toasts.tsx
new file mode 100644
index 0000000000000..51a3a8989b7b8
--- /dev/null
+++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/deduplicate_toasts.tsx
@@ -0,0 +1,138 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React, { ReactNode } from 'react';
+import { css } from '@emotion/css';
+
+import { EuiNotificationBadge } from '@elastic/eui';
+import { Toast } from '@kbn/core-notifications-browser';
+import { MountPoint } from '@kbn/core-mount-utils-browser';
+
+/**
+ * We can introduce this type within this domain, to allow for react-managed titles
+ */
+export type ToastWithRichTitle = Omit & {
+ title?: MountPoint | ReactNode;
+};
+
+export interface DeduplicateResult {
+ toasts: ToastWithRichTitle[];
+ idToToasts: Record;
+}
+
+interface TitleWithBadgeProps {
+ title: string | undefined;
+ counter: number;
+}
+
+/**
+ * Collects toast messages to groups based on the `getKeyOf` function,
+ * then represents every group of message with a single toast
+ * @param allToasts
+ * @return the deduplicated list of toasts, and a lookup to find toasts represented by their first toast's ID
+ */
+export function deduplicateToasts(allToasts: Toast[]): DeduplicateResult {
+ const toastGroups = groupByKey(allToasts);
+
+ const distinctToasts: ToastWithRichTitle[] = [];
+ const idToToasts: Record = {};
+ for (const toastGroup of Object.values(toastGroups)) {
+ const firstElement = toastGroup[0];
+ idToToasts[firstElement.id] = toastGroup;
+ if (toastGroup.length === 1) {
+ distinctToasts.push(firstElement);
+ } else {
+ // Grouping will only happen for toasts whose titles are strings (or missing)
+ const title = firstElement.title as string | undefined;
+ distinctToasts.push({
+ ...firstElement,
+ title: ,
+ });
+ }
+ }
+
+ return { toasts: distinctToasts, idToToasts };
+}
+
+/**
+ * Derives a key from a toast object
+ * these keys decide what makes between different toasts, and which ones should be merged
+ * These toasts will be merged:
+ * - where title and text are strings, and the same
+ * - where titles are the same, and texts are missing
+ * - where titles are the same, and the text's mount function is the same string
+ * - where titles are missing, but the texts are the same string
+ * @param toast The toast whose key we're deriving
+ */
+function getKeyOf(toast: Toast): string {
+ if (isString(toast.title) && isString(toast.text)) {
+ return toast.title + ' ' + toast.text;
+ } else if (isString(toast.title) && !toast.text) {
+ return toast.title;
+ } else if (isString(toast.title) && typeof toast.text === 'function') {
+ return toast.title + ' ' + djb2Hash(toast.text.toString());
+ } else if (isString(toast.text) && !toast.title) {
+ return toast.text;
+ } else {
+ // Either toast or text is a mount function, or both missing
+ return 'KEY_' + toast.id.toString();
+ }
+}
+
+function isString(a: string | any): a is string {
+ return typeof a === 'string';
+}
+
+// Based on: https://gist.github.com/eplawless/52813b1d8ad9af510d85
+function djb2Hash(str: string): number {
+ const len = str.length;
+ let hash = 5381;
+
+ for (let i = 0; i < len; i++) {
+ // eslint-disable-next-line no-bitwise
+ hash = (hash * 33) ^ str.charCodeAt(i);
+ }
+ // eslint-disable-next-line no-bitwise
+ return hash >>> 0;
+}
+
+function groupByKey(allToasts: Toast[]) {
+ const toastGroups: Record = {};
+ for (const toast of allToasts) {
+ const key = getKeyOf(toast);
+
+ if (!toastGroups[key]) {
+ toastGroups[key] = [toast];
+ } else {
+ toastGroups[key].push(toast);
+ }
+ }
+ return toastGroups;
+}
+
+const floatTopRight = css`
+ position: absolute;
+ top: -8px;
+ right: -8px;
+`;
+
+/**
+ * A component that renders a title with a floating counter
+ * @param title {string} The title string
+ * @param counter {number} The count of notifications represented
+ */
+export function TitleWithBadge({ title, counter }: TitleWithBadgeProps) {
+ return (
+
+ {title}{' '}
+
+ {counter}
+
+
+ );
+}
diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.test.tsx b/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.test.tsx
index 77430aa951b11..3835dfd24e899 100644
--- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.test.tsx
+++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.test.tsx
@@ -7,14 +7,17 @@
*/
import { EuiGlobalToastList } from '@elastic/eui';
+import { Toast } from '@kbn/core-notifications-browser/src/types';
import { shallow } from 'enzyme';
import React from 'react';
import { Observable, from, EMPTY } from 'rxjs';
import { GlobalToastList } from './global_toast_list';
+const mockDismissToast = jest.fn();
+
function render(props: Partial = {}) {
- return ;
+ return ;
}
it('renders matching snapshot', () => {
@@ -52,3 +55,78 @@ it('passes latest value from toasts$ to ', () => {
expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: '1' }, { id: '2' }]);
});
+
+describe('global_toast_list with duplicate elements', () => {
+ const dummyText = `You've got mail!`;
+ const dummyTitle = `AOL Notifications`;
+ const toast = (id: any): Toast => ({
+ id: id.toString(),
+ text: dummyText,
+ title: dummyTitle,
+ toastLifeTimeMs: 5000,
+ });
+
+ const globalToastList = shallow(
+ render({
+ toasts$: from([[toast(0), toast(1), toast(2), toast(3)]]) as any,
+ })
+ );
+
+ const euiToastList = globalToastList.find(EuiGlobalToastList);
+ const toastsProp = euiToastList.prop('toasts');
+
+ it('renders the list with a single element', () => {
+ expect(toastsProp).toBeDefined();
+ expect(toastsProp).toHaveLength(1);
+ expect(euiToastList).toMatchSnapshot();
+ });
+
+ it('renders the single toast with the common text', () => {
+ const firstRenderedToast = toastsProp![0];
+ expect(firstRenderedToast.text).toBe(dummyText);
+ });
+
+ it(`calls all toast's dismiss when closed`, () => {
+ const firstRenderedToast = toastsProp![0];
+ const dismissToast = globalToastList.prop('dismissToast');
+ dismissToast(firstRenderedToast);
+
+ expect(mockDismissToast).toHaveBeenCalledTimes(4);
+ expect(mockDismissToast).toHaveBeenCalledWith('0');
+ expect(mockDismissToast).toHaveBeenCalledWith('1');
+ expect(mockDismissToast).toHaveBeenCalledWith('2');
+ expect(mockDismissToast).toHaveBeenCalledWith('3');
+ });
+});
+
+describe('global_toast_list with duplicate elements, using MountPoints', () => {
+ const dummyText = `You've got mail!`;
+ const toast = (id: any): Toast => ({
+ id: id.toString(),
+ text: dummyText,
+ title: (element) => {
+ const a = document.createElement('a');
+ a.innerText = 'Click me!';
+ a.href = 'https://elastic.co';
+ element.appendChild(a);
+ return () => element.removeChild(a);
+ },
+ toastLifeTimeMs: 5000,
+ });
+
+ const globalToastList = shallow(
+ render({
+ toasts$: from([[toast(0), toast(1), toast(2), toast(3)]]) as any,
+ })
+ );
+
+ const euiToastList = globalToastList.find(EuiGlobalToastList);
+ const toastsProp = euiToastList.prop('toasts');
+
+ it('renders the all separate elements element', () => {
+ expect(toastsProp).toBeDefined();
+ expect(toastsProp).toHaveLength(4);
+ expect(euiToastList).toMatchSnapshot('euiToastList');
+ expect(globalToastList).toMatchSnapshot('globalToastList');
+ });
+});
diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.tsx b/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.tsx
index db700da12cf0a..e0a5d631d3776 100644
--- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.tsx
+++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/global_toast_list.tsx
@@ -13,6 +13,7 @@ import { i18n } from '@kbn/i18n';
import type { Toast } from '@kbn/core-notifications-browser';
import { MountWrapper } from '@kbn/core-mount-utils-browser-internal';
+import { deduplicateToasts, ToastWithRichTitle } from './deduplicate_toasts';
interface Props {
toasts$: Observable;
@@ -20,25 +21,28 @@ interface Props {
}
interface State {
- toasts: Toast[];
+ toasts: ToastWithRichTitle[];
+ idToToasts: Record;
}
-const convertToEui = (toast: Toast): EuiToast => ({
+const convertToEui = (toast: ToastWithRichTitle): EuiToast => ({
...toast,
- title: typeof toast.title === 'function' ? : toast.title,
- text: typeof toast.text === 'function' ? : toast.text,
+ title: toast.title instanceof Function ? : toast.title,
+ text: toast.text instanceof Function ? : toast.text,
});
export class GlobalToastList extends React.Component {
public state: State = {
toasts: [],
+ idToToasts: {},
};
private subscription?: Subscription;
public componentDidMount() {
- this.subscription = this.props.toasts$.subscribe((toasts) => {
- this.setState({ toasts });
+ this.subscription = this.props.toasts$.subscribe((redundantToastList) => {
+ const { toasts, idToToasts } = deduplicateToasts(redundantToastList);
+ this.setState({ toasts, idToToasts });
});
}
@@ -48,6 +52,13 @@ export class GlobalToastList extends React.Component {
}
}
+ private closeToastsRepresentedById(id: string) {
+ const representedToasts = this.state.idToToasts[id];
+ if (representedToasts) {
+ representedToasts.forEach((toast) => this.props.dismissToast(toast.id));
+ }
+ }
+
public render() {
return (
{
})}
data-test-subj="globalToastList"
toasts={this.state.toasts.map(convertToEui)}
- dismissToast={({ id }) => this.props.dismissToast(id)}
+ dismissToast={({ id }) => this.closeToastsRepresentedById(id)}
/**
* This prop is overridden by the individual toasts that are added.
* Use `Infinity` here so that it's obvious a timeout hasn't been
diff --git a/packages/core/notifications/core-notifications-browser-internal/tsconfig.json b/packages/core/notifications/core-notifications-browser-internal/tsconfig.json
index f2828768aa26b..a2cdcd20e4889 100644
--- a/packages/core/notifications/core-notifications-browser-internal/tsconfig.json
+++ b/packages/core/notifications/core-notifications-browser-internal/tsconfig.json
@@ -28,6 +28,7 @@
"@kbn/test-jest-helpers",
"@kbn/core-overlays-browser-mocks",
"@kbn/core-theme-browser-mocks",
+ "@kbn/core-mount-utils-browser",
],
"exclude": [
"target/**/*",