Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SharedUX] Merge similar toast messages in case of a toast-flood/storm #161738

Merged
merged 20 commits into from
Jul 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3948f10
feat(notifications): Collect similar toast messages to single instanc…
delanni Jul 12, 2023
34d5319
Merge branch 'main' into sharedux-deduplicate-toaststorms
kibanamachine Jul 18, 2023
1a35ac6
feat(notifications): Tidy up deduplication code
delanni Jul 13, 2023
7fabc6e
feat(notifications): fix race conditions related to deduplication, ad…
delanni Jul 18, 2023
5fd0c29
Merge branch 'main' into sharedux-deduplicate-toaststorms
kibanamachine Jul 18, 2023
9006e47
feat(notifications): Remove testing code
delanni Jul 18, 2023
1d87243
feat(notifications): keep the collated toasts visible for as the firs…
delanni Jul 18, 2023
15dd131
test(notifications): add more test cases to deduplicate_toasts tests
delanni Jul 18, 2023
6310ea3
feat(notifications): fix race conditions, manage represented toasts i…
delanni Jul 19, 2023
cb1184c
feat(notifications): handle MountPoints as titles, add tests, tidy up…
delanni Jul 20, 2023
2a43fb5
Merge branch 'main' into sharedux-deduplicate-toaststorms
kibanamachine Jul 20, 2023
cbaf904
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Jul 20, 2023
dbed104
feat: remove react from the API signature, use domain type variant fo…
delanni Jul 25, 2023
71d5261
feat: move notification counter to top right
delanni Jul 25, 2023
a231d52
test: fix type and snapshot in deduplicate toasts tests
delanni Jul 25, 2023
02d1b96
Merge branch 'main' into sharedux-deduplicate-toaststorms
kibanamachine Jul 25, 2023
f227247
feat: use 'm' sized notification badges instead of scaling
delanni Jul 26, 2023
5b6079d
feat: only collapse if title+text are strings or missing
delanni Jul 26, 2023
9f13cd6
feat: also merge toasts with the same texts and similar text mount fu…
delanni Jul 27, 2023
5e9adf7
Merge branch 'main' into sharedux-deduplicate-toaststorms
kibanamachine Jul 27, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* 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 from 'react';
import { mount, render, shallow } from 'enzyme';
import { ReactElement, ReactNode } from 'react';

import { deduplicateToasts, TitleWithBadge, ToastWithRichTitle } from './deduplicate_toasts';
import { Toast } from '@kbn/core-notifications-browser';
import { MountPoint } from '@kbn/core-mount-utils-browser';

function toast(title: string | MountPoint, text?: string | MountPoint, id = Math.random()): Toast {
return {
id: id.toString(),
title,
text,
};
}

const fakeMountPoint = () => () => {};

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 = <TitleWithBadge title={title} counter={5} />;
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();
}
Original file line number Diff line number Diff line change
@@ -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<Toast, 'title'> & {
title?: MountPoint | ReactNode;
};

export interface DeduplicateResult {
toasts: ToastWithRichTitle[];
idToToasts: Record<string, Toast[]>;
}

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<string, Toast[]> = {};
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: <TitleWithBadge title={title} counter={toastGroup.length} />,
});
}
}

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<string, Toast[]> = {};
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 (
<React.Fragment>
{title}{' '}
<EuiNotificationBadge color="subdued" size="m" className={floatTopRight}>
{counter}
</EuiNotificationBadge>
</React.Fragment>
);
}
Loading