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/**/*",