mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
Add telemetry for toasts (#166466)
## Summary Address #166459 This PR scaffolds telemetry into the notification service, to enable reporting when deduped toasts are dismissed. When a deduped toast is dismissed; the message recurrence count and the toast message would be emitted. Telemetry Event Definition; ```typescript { eventType: "global_toast_list_toast_dismissed", schema: { "toast_deduplication_count": { type: "long", _meta: { description: "toast message text" } }, "toast_message": { type: "keyword", _meta: { description: "recurrence count for particular toast message" } }, "toast_message_type": { type: "keyword", _meta: { description: "toast message type, accepted values are warning, danger, primary" } } } } ``` Testing; - Ensure you have an existing dashboard, if you don't installing any sample data would bootstrap a dashboard automatically - Search for `visualize library` from the global search bar, and navigate to visualize library. - Attempt to create a visualization, select the `TSVB` visualization type. At this step there typically wouldn't be an error - We can simulate an error, by open Dev tools and blocking the URL `/internal/metrics/vis/data` like so <img width="604" alt="Screenshot 2023-09-21 at 11 49 41" src="ad320569
-33b2-4335-8052-981d1761ea67"> on doing this, we then attempt refreshing the query, we would then be presented with an error toast, similar to the screenshot below; <img width="482" alt="Screenshot 2023-09-21 at 11 52 51" src="fef07e72
-625b-4457-abc3-9214d64f9e48"> click the refresh query button as much as you like to increase it's recurrence count. - From here on we verify that; clicking the dismiss button sends a telemetry event, also not interacting with the toast and it's display lifetime elapsing with the toast clearing itself, the telemetry gets sent still. In either case there should be a request calling the resource `kibana_browser` with a payload containing the telemetry event definition above. ### Checklist <!-- Delete any items that are not applicable to this PR. --> - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios <!-- - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) -> ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
d1155f3273
commit
ec20d615de
13 changed files with 532 additions and 253 deletions
|
@ -9,6 +9,7 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { Subscription } from 'rxjs';
|
||||
import type { AnalyticsServiceStart, AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
|
||||
import type { ThemeServiceStart } from '@kbn/core-theme-browser';
|
||||
import type { I18nStart } from '@kbn/core-i18n-browser';
|
||||
import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
|
||||
|
@ -16,8 +17,10 @@ import type { OverlayStart } from '@kbn/core-overlays-browser';
|
|||
import type { NotificationsSetup, NotificationsStart } from '@kbn/core-notifications-browser';
|
||||
import type { PublicMethodsOf } from '@kbn/utility-types';
|
||||
import { showErrorDialog, ToastsService } from './toasts';
|
||||
import { EventReporter, eventTypes } from './toasts/telemetry';
|
||||
|
||||
export interface SetupDeps {
|
||||
analytics: AnalyticsServiceSetup;
|
||||
uiSettings: IUiSettingsClient;
|
||||
}
|
||||
|
||||
|
@ -25,6 +28,7 @@ export interface StartDeps {
|
|||
i18n: I18nStart;
|
||||
overlays: OverlayStart;
|
||||
theme: ThemeServiceStart;
|
||||
analytics: AnalyticsServiceStart;
|
||||
targetDomElement: HTMLElement;
|
||||
}
|
||||
|
||||
|
@ -38,7 +42,11 @@ export class NotificationsService {
|
|||
this.toasts = new ToastsService();
|
||||
}
|
||||
|
||||
public setup({ uiSettings }: SetupDeps): NotificationsSetup {
|
||||
public setup({ uiSettings, analytics }: SetupDeps): NotificationsSetup {
|
||||
eventTypes.forEach((eventType) => {
|
||||
analytics.registerEventType(eventType);
|
||||
});
|
||||
|
||||
const notificationSetup = { toasts: this.toasts.setup({ uiSettings }) };
|
||||
|
||||
this.uiSettingsErrorSubscription = uiSettings.getUpdateErrors$().subscribe((error: Error) => {
|
||||
|
@ -54,6 +62,7 @@ export class NotificationsService {
|
|||
}
|
||||
|
||||
public start({
|
||||
analytics,
|
||||
i18n: i18nDep,
|
||||
overlays,
|
||||
theme,
|
||||
|
@ -63,8 +72,11 @@ export class NotificationsService {
|
|||
const toastsContainer = document.createElement('div');
|
||||
targetDomElement.appendChild(toastsContainer);
|
||||
|
||||
const eventReporter = new EventReporter({ analytics });
|
||||
|
||||
return {
|
||||
toasts: this.toasts.start({
|
||||
eventReporter,
|
||||
i18n: i18nDep,
|
||||
overlays,
|
||||
theme,
|
||||
|
|
|
@ -1,123 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`global_toast_list with duplicate elements renders the list with a single element 1`] = `
|
||||
<EuiGlobalToastList
|
||||
aria-label="Notification message list"
|
||||
data-test-subj="globalToastList"
|
||||
dismissToast={[Function]}
|
||||
toastLifeTimeMs={Infinity}
|
||||
toasts={
|
||||
Array [
|
||||
Object {
|
||||
"id": "0",
|
||||
"text": "You've got mail!",
|
||||
"title": <TitleWithBadge
|
||||
counter={4}
|
||||
title="AOL Notifications"
|
||||
/>,
|
||||
"toastLifeTimeMs": 5000,
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`global_toast_list with duplicate elements, using MountPoints renders the all separate elements element: euiToastList 1`] = `
|
||||
<EuiGlobalToastList
|
||||
aria-label="Notification message list"
|
||||
data-test-subj="globalToastList"
|
||||
dismissToast={[Function]}
|
||||
toastLifeTimeMs={Infinity}
|
||||
toasts={
|
||||
Array [
|
||||
Object {
|
||||
"id": "0",
|
||||
"text": "You've got mail!",
|
||||
"title": <MountWrapper
|
||||
mount={[Function]}
|
||||
/>,
|
||||
"toastLifeTimeMs": 5000,
|
||||
},
|
||||
Object {
|
||||
"id": "1",
|
||||
"text": "You've got mail!",
|
||||
"title": <MountWrapper
|
||||
mount={[Function]}
|
||||
/>,
|
||||
"toastLifeTimeMs": 5000,
|
||||
},
|
||||
Object {
|
||||
"id": "2",
|
||||
"text": "You've got mail!",
|
||||
"title": <MountWrapper
|
||||
mount={[Function]}
|
||||
/>,
|
||||
"toastLifeTimeMs": 5000,
|
||||
},
|
||||
Object {
|
||||
"id": "3",
|
||||
"text": "You've got mail!",
|
||||
"title": <MountWrapper
|
||||
mount={[Function]}
|
||||
/>,
|
||||
"toastLifeTimeMs": 5000,
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`global_toast_list with duplicate elements, using MountPoints renders the all separate elements element: globalToastList 1`] = `
|
||||
<EuiGlobalToastList
|
||||
aria-label="Notification message list"
|
||||
data-test-subj="globalToastList"
|
||||
dismissToast={[Function]}
|
||||
toastLifeTimeMs={Infinity}
|
||||
toasts={
|
||||
Array [
|
||||
Object {
|
||||
"id": "0",
|
||||
"text": "You've got mail!",
|
||||
"title": <MountWrapper
|
||||
mount={[Function]}
|
||||
/>,
|
||||
"toastLifeTimeMs": 5000,
|
||||
},
|
||||
Object {
|
||||
"id": "1",
|
||||
"text": "You've got mail!",
|
||||
"title": <MountWrapper
|
||||
mount={[Function]}
|
||||
/>,
|
||||
"toastLifeTimeMs": 5000,
|
||||
},
|
||||
Object {
|
||||
"id": "2",
|
||||
"text": "You've got mail!",
|
||||
"title": <MountWrapper
|
||||
mount={[Function]}
|
||||
/>,
|
||||
"toastLifeTimeMs": 5000,
|
||||
},
|
||||
Object {
|
||||
"id": "3",
|
||||
"text": "You've got mail!",
|
||||
"title": <MountWrapper
|
||||
mount={[Function]}
|
||||
/>,
|
||||
"toastLifeTimeMs": 5000,
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`renders matching snapshot 1`] = `
|
||||
<EuiGlobalToastList
|
||||
aria-label="Notification message list"
|
||||
data-test-subj="globalToastList"
|
||||
dismissToast={[Function]}
|
||||
toastLifeTimeMs={Infinity}
|
||||
toasts={Array []}
|
||||
/>
|
||||
`;
|
|
@ -19,6 +19,11 @@ Array [
|
|||
>
|
||||
<GlobalToastList
|
||||
dismissToast={[Function]}
|
||||
reportEvent={
|
||||
EventReporter {
|
||||
"reportEvent": [MockFunction],
|
||||
}
|
||||
}
|
||||
toasts$={
|
||||
Observable {
|
||||
"source": BehaviorSubject {
|
||||
|
|
|
@ -6,22 +6,37 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
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 React, { type ComponentProps } from 'react';
|
||||
import { Observable, from, EMPTY, BehaviorSubject } from 'rxjs';
|
||||
import { screen, render, fireEvent, act } from '@testing-library/react';
|
||||
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
|
||||
import { EuiToast } from '@elastic/eui';
|
||||
import { EventReporter } from './telemetry';
|
||||
|
||||
import { GlobalToastList } from './global_toast_list';
|
||||
|
||||
const mockDismissToast = jest.fn();
|
||||
const mockAnalytics = analyticsServiceMock.createAnalyticsServiceStart();
|
||||
|
||||
function render(props: Partial<GlobalToastList['props']> = {}) {
|
||||
return <GlobalToastList dismissToast={mockDismissToast} toasts$={EMPTY} {...props} />;
|
||||
const sharedProps = {
|
||||
toasts$: EMPTY,
|
||||
dismissToast: jest.fn(),
|
||||
reportEvent: new EventReporter({ analytics: mockAnalytics }),
|
||||
};
|
||||
|
||||
function RenderToastList(props: Partial<ComponentProps<typeof GlobalToastList>> = {}) {
|
||||
return <GlobalToastList {...sharedProps} {...props} />;
|
||||
}
|
||||
|
||||
it('renders matching snapshot', () => {
|
||||
expect(shallow(render())).toMatchSnapshot();
|
||||
const dummyToastText = `You've got mail!`;
|
||||
const dummyToastTitle = `AOL Notifications`;
|
||||
|
||||
const createMockToast = (id: any, type?: ComponentProps<typeof EuiToast>['color']): Toast => ({
|
||||
id: id.toString(),
|
||||
text: dummyToastText,
|
||||
title: dummyToastTitle,
|
||||
toastLifeTimeMs: 5000,
|
||||
color: type,
|
||||
});
|
||||
|
||||
it('subscribes to toasts$ on mount and unsubscribes on unmount', () => {
|
||||
|
@ -31,102 +46,291 @@ it('subscribes to toasts$ on mount and unsubscribes on unmount', () => {
|
|||
return unsubscribeSpy;
|
||||
});
|
||||
|
||||
const component = render({
|
||||
toasts$: new Observable<any>(subscribeSpy),
|
||||
});
|
||||
const { unmount } = render(<RenderToastList toasts$={new Observable<any>(subscribeSpy)} />);
|
||||
|
||||
expect(subscribeSpy).not.toHaveBeenCalled();
|
||||
|
||||
const el = shallow(component);
|
||||
expect(subscribeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(unsubscribeSpy).not.toHaveBeenCalled();
|
||||
|
||||
el.unmount();
|
||||
unmount();
|
||||
|
||||
expect(subscribeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(unsubscribeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes latest value from toasts$ to <EuiGlobalToastList />', () => {
|
||||
const el = shallow(
|
||||
render({
|
||||
toasts$: from([[], [{ id: '1' }], [{ id: '1' }, { id: '2' }]]) as any,
|
||||
})
|
||||
);
|
||||
it('uses the latest value from toasts$ passed to <EuiGlobalToastList /> to render the right number of toasts', () => {
|
||||
const toastObservable$ = new BehaviorSubject([{ id: '1' }, { id: '2' }]);
|
||||
|
||||
expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: '1' }, { id: '2' }]);
|
||||
render(<RenderToastList toasts$={toastObservable$.asObservable() as any} />);
|
||||
|
||||
expect(screen.getAllByLabelText('Notification')).toHaveLength(2);
|
||||
|
||||
act(() => {
|
||||
toastObservable$.next([...toastObservable$.getValue(), { id: '3' }]);
|
||||
});
|
||||
|
||||
expect(screen.getAllByLabelText('Notification')).toHaveLength(3);
|
||||
});
|
||||
|
||||
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 TOAST_DUPLICATE_COUNT = 4;
|
||||
|
||||
function ToastListWithDuplicates() {
|
||||
return (
|
||||
<RenderToastList
|
||||
toasts$={
|
||||
from([
|
||||
Array.from(new Array(TOAST_DUPLICATE_COUNT)).map((_, idx) => createMockToast(idx)),
|
||||
]) as any
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
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();
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders the single toast with the common text', () => {
|
||||
const firstRenderedToast = toastsProp![0];
|
||||
expect(firstRenderedToast.text).toBe(dummyText);
|
||||
it('renders the toast list with a single toast when toasts matching deduplication heuristics are passed', () => {
|
||||
render(<ToastListWithDuplicates />);
|
||||
|
||||
const { 0: firstToast, length: toastCount } = screen.getAllByLabelText('Notification');
|
||||
|
||||
expect(toastCount).toEqual(1);
|
||||
|
||||
expect(screen.getAllByText(dummyToastText)).toHaveLength(1);
|
||||
|
||||
expect(firstToast.querySelector('.euiNotificationBadge')?.innerHTML).toEqual('4');
|
||||
});
|
||||
|
||||
it(`calls all toast's dismiss when closed`, () => {
|
||||
const firstRenderedToast = toastsProp![0];
|
||||
const dismissToast = globalToastList.prop('dismissToast');
|
||||
dismissToast(firstRenderedToast);
|
||||
it('renders a single toast also when toast titles are mount points are used that match the deduplication heuristics', () => {
|
||||
const createMockToastWithMountPoints = (id: any): Toast => ({
|
||||
id: id.toString(),
|
||||
text: dummyToastText,
|
||||
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,
|
||||
});
|
||||
|
||||
expect(mockDismissToast).toHaveBeenCalledTimes(4);
|
||||
expect(mockDismissToast).toHaveBeenCalledWith('0');
|
||||
expect(mockDismissToast).toHaveBeenCalledWith('1');
|
||||
expect(mockDismissToast).toHaveBeenCalledWith('2');
|
||||
expect(mockDismissToast).toHaveBeenCalledWith('3');
|
||||
render(
|
||||
<RenderToastList
|
||||
toasts$={
|
||||
from([
|
||||
Array.from(new Array(TOAST_DUPLICATE_COUNT)).map((_, idx) =>
|
||||
createMockToastWithMountPoints(idx)
|
||||
),
|
||||
]) as any
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderedToasts = screen.getAllByText(dummyToastText);
|
||||
|
||||
expect(renderedToasts).toHaveLength(TOAST_DUPLICATE_COUNT);
|
||||
});
|
||||
|
||||
it(`when a represented toast is closed, the provided dismiss action is called for all its internal toasts`, () => {
|
||||
render(<ToastListWithDuplicates />);
|
||||
|
||||
const { 0: toastDismissButton, length: toastDismissButtonLength } =
|
||||
screen.getAllByLabelText('Dismiss toast');
|
||||
|
||||
expect(toastDismissButtonLength).toEqual(1);
|
||||
|
||||
fireEvent.click(toastDismissButton);
|
||||
|
||||
act(() => {
|
||||
// This is so that the toast fade out animation succesfully runs,
|
||||
// only after this is the dismiss method invoked
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(sharedProps.dismissToast).toHaveBeenCalledTimes(TOAST_DUPLICATE_COUNT);
|
||||
expect(sharedProps.dismissToast).toHaveBeenCalledWith('0');
|
||||
expect(sharedProps.dismissToast).toHaveBeenCalledWith('1');
|
||||
expect(sharedProps.dismissToast).toHaveBeenCalledWith('2');
|
||||
expect(sharedProps.dismissToast).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,
|
||||
describe('global_toast_list toast dismissal telemetry', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
const globalToastList = shallow(
|
||||
render({
|
||||
toasts$: from([[toast(0), toast(1), toast(2), toast(3)]]) as any,
|
||||
})
|
||||
);
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const euiToastList = globalToastList.find(EuiGlobalToastList);
|
||||
const toastsProp = euiToastList.prop('toasts');
|
||||
it('does not invoke the reportEvent method when there is no recurring toast', async () => {
|
||||
const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast');
|
||||
|
||||
it('renders the all separate elements element', () => {
|
||||
expect(toastsProp).toBeDefined();
|
||||
expect(toastsProp).toHaveLength(4);
|
||||
expect(euiToastList).toMatchSnapshot('euiToastList');
|
||||
expect(globalToastList).toMatchSnapshot('globalToastList');
|
||||
const toastObservable$ = new BehaviorSubject([createMockToast(1)]);
|
||||
|
||||
sharedProps.dismissToast.mockImplementation((toastId: string) =>
|
||||
act(() => {
|
||||
const toastList = toastObservable$.getValue();
|
||||
toastObservable$.next(toastList.filter((t) => t.id !== toastId));
|
||||
})
|
||||
);
|
||||
|
||||
render(<RenderToastList toasts$={toastObservable$.asObservable() as any} />);
|
||||
|
||||
const { 0: toastDismissButton, length: toastDismissButtonLength } =
|
||||
screen.getAllByLabelText('Dismiss toast');
|
||||
|
||||
expect(toastDismissButtonLength).toEqual(1);
|
||||
|
||||
fireEvent.click(toastDismissButton);
|
||||
|
||||
act(() => {
|
||||
// This is so that the toast fade out animation succesfully runs,
|
||||
// only after this is the dismiss method invoked
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(sharedProps.dismissToast).toHaveBeenCalled();
|
||||
expect(onDimissReporterSpy).not.toBeCalled();
|
||||
|
||||
expect(screen.queryByLabelText('Notification')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not invoke the reportEvent method for a recurring toast of the success type', () => {
|
||||
const REPEATED_TOAST_COUNT = 2;
|
||||
|
||||
const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast');
|
||||
|
||||
const toastObservable$ = new BehaviorSubject(
|
||||
Array.from(new Array(2)).map((_, idx) => createMockToast(idx, 'success'))
|
||||
);
|
||||
|
||||
sharedProps.dismissToast.mockImplementation((toastId: string) =>
|
||||
act(() => {
|
||||
const toastList = toastObservable$.getValue();
|
||||
toastObservable$.next(toastList.filter((t) => t.id !== toastId));
|
||||
})
|
||||
);
|
||||
|
||||
render(<RenderToastList toasts$={toastObservable$.asObservable() as any} />);
|
||||
|
||||
const { 0: toastDismissButton, length: toastDismissButtonLength } =
|
||||
screen.getAllByLabelText('Dismiss toast');
|
||||
|
||||
expect(toastDismissButtonLength).toEqual(1);
|
||||
|
||||
fireEvent.click(toastDismissButton);
|
||||
|
||||
act(() => {
|
||||
// This is so that the toast fade out animation succesfully runs,
|
||||
// only after this is the dismiss method invoked
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(sharedProps.dismissToast).toHaveBeenCalledTimes(REPEATED_TOAST_COUNT);
|
||||
expect(onDimissReporterSpy).not.toBeCalled();
|
||||
|
||||
expect(screen.queryByLabelText('Notification')).toBeNull();
|
||||
});
|
||||
|
||||
it('invokes the reportEvent method for a recurring toast of allowed type that is not success', () => {
|
||||
const REPEATED_TOAST_COUNT = 4;
|
||||
|
||||
const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast');
|
||||
|
||||
const toastObservable$ = new BehaviorSubject(
|
||||
Array.from(new Array(REPEATED_TOAST_COUNT)).map((_, idx) => createMockToast(idx, 'warning'))
|
||||
);
|
||||
|
||||
sharedProps.dismissToast.mockImplementation((toastId: string) =>
|
||||
act(() => {
|
||||
const toastList = toastObservable$.getValue();
|
||||
toastObservable$.next(toastList.filter((t) => t.id !== toastId));
|
||||
})
|
||||
);
|
||||
|
||||
render(<RenderToastList toasts$={toastObservable$.asObservable() as any} />);
|
||||
|
||||
const { 0: toastDismissButton, length: toastDismissButtonLength } =
|
||||
screen.getAllByLabelText('Dismiss toast');
|
||||
|
||||
expect(toastDismissButtonLength).toEqual(1);
|
||||
|
||||
fireEvent.click(toastDismissButton);
|
||||
|
||||
act(() => {
|
||||
// This is so that the toast fade out animation succesfully runs,
|
||||
// only after this is the dismiss method invoked
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(sharedProps.dismissToast).toHaveBeenCalledTimes(REPEATED_TOAST_COUNT);
|
||||
expect(onDimissReporterSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recurrenceCount: REPEATED_TOAST_COUNT,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('Notification')).toBeNull();
|
||||
});
|
||||
|
||||
it('invokes the reportEvent method when the clear all button is clicked', () => {
|
||||
const UNIQUE_TOASTS_COUNT = 4;
|
||||
const REPEATED_COUNT_PER_UNIQUE_TOAST = 2;
|
||||
|
||||
const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast');
|
||||
|
||||
const toastObservable$ = new BehaviorSubject<Toast[]>(
|
||||
Array.from(new Array(UNIQUE_TOASTS_COUNT)).reduce((acc, _, idx) => {
|
||||
return acc.concat(
|
||||
Array.from(new Array(REPEATED_COUNT_PER_UNIQUE_TOAST)).map(() => ({
|
||||
...createMockToast(idx, 'warning'),
|
||||
title: `${dummyToastTitle}_${idx}`,
|
||||
}))
|
||||
);
|
||||
}, [])
|
||||
);
|
||||
|
||||
sharedProps.dismissToast.mockImplementation((toastId: string) =>
|
||||
act(() => {
|
||||
const toastList = toastObservable$.getValue();
|
||||
toastObservable$.next(toastList.filter((t) => t.id !== toastId));
|
||||
})
|
||||
);
|
||||
|
||||
render(<RenderToastList toasts$={toastObservable$.asObservable() as any} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Clear all toast notifications'));
|
||||
|
||||
act(() => {
|
||||
// This is so that the toast fade out animation succesfully runs,
|
||||
// only after this is the dismiss method invoked
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(sharedProps.dismissToast).toHaveBeenCalledTimes(
|
||||
UNIQUE_TOASTS_COUNT * REPEATED_COUNT_PER_UNIQUE_TOAST
|
||||
);
|
||||
|
||||
expect(onDimissReporterSpy).toHaveBeenCalledTimes(UNIQUE_TOASTS_COUNT);
|
||||
|
||||
new Array(UNIQUE_TOASTS_COUNT).forEach((_, idx) => {
|
||||
expect(onDimissReporterSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toastMessage: `${dummyToastTitle}_${idx}`,
|
||||
recurrenceCount: REPEATED_COUNT_PER_UNIQUE_TOAST,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText('Notification')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,16 +7,19 @@
|
|||
*/
|
||||
|
||||
import { EuiGlobalToastList, EuiGlobalToastListToast as EuiToast } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { Observable, type Subscription } from 'rxjs';
|
||||
import React, { useEffect, useState, type FunctionComponent, useCallback } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { Observable } from 'rxjs';
|
||||
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';
|
||||
import { EventReporter } from './telemetry';
|
||||
|
||||
interface Props {
|
||||
toasts$: Observable<Toast[]>;
|
||||
reportEvent: EventReporter;
|
||||
dismissToast: (toastId: string) => void;
|
||||
}
|
||||
|
||||
|
@ -31,50 +34,77 @@ const convertToEui = (toast: ToastWithRichTitle): EuiToast => ({
|
|||
text: toast.text instanceof Function ? <MountWrapper mount={toast.text} /> : toast.text,
|
||||
});
|
||||
|
||||
export class GlobalToastList extends React.Component<Props, State> {
|
||||
public state: State = {
|
||||
toasts: [],
|
||||
idToToasts: {},
|
||||
};
|
||||
export const GlobalToastList: FunctionComponent<Props> = ({
|
||||
toasts$,
|
||||
dismissToast,
|
||||
reportEvent,
|
||||
}) => {
|
||||
const [toasts, setToasts] = useState<State['toasts']>([]);
|
||||
const [idToToasts, setIdToToasts] = useState<State['idToToasts']>({});
|
||||
|
||||
private subscription?: Subscription;
|
||||
const reportToastDismissal = useCallback(
|
||||
(representedToasts: State['idToToasts'][number]) => {
|
||||
// Select the first duplicate toast within the represented toast group
|
||||
// given it's identical to all other recurring ones within it's group
|
||||
const firstDuplicateToast = representedToasts[0];
|
||||
|
||||
public componentDidMount() {
|
||||
this.subscription = this.props.toasts$.subscribe((redundantToastList) => {
|
||||
const { toasts, idToToasts } = deduplicateToasts(redundantToastList);
|
||||
this.setState({ toasts, idToToasts });
|
||||
if (
|
||||
representedToasts.length > 1 &&
|
||||
firstDuplicateToast.color !== 'success' &&
|
||||
firstDuplicateToast.title
|
||||
) {
|
||||
reportEvent.onDismissToast({
|
||||
toastMessage:
|
||||
firstDuplicateToast.title instanceof Function
|
||||
? renderToStaticMarkup(<MountWrapper mount={firstDuplicateToast.title} />)
|
||||
: firstDuplicateToast.title,
|
||||
recurrenceCount: representedToasts.length,
|
||||
toastMessageType: firstDuplicateToast.color,
|
||||
});
|
||||
}
|
||||
},
|
||||
[reportEvent]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = toasts$.subscribe((redundantToastList) => {
|
||||
const { toasts: reducedToasts, idToToasts: reducedIdToasts } =
|
||||
deduplicateToasts(redundantToastList);
|
||||
|
||||
setIdToToasts(reducedIdToasts);
|
||||
setToasts(reducedToasts);
|
||||
});
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
return () => subscription.unsubscribe();
|
||||
}, [reportEvent, toasts$]);
|
||||
|
||||
private closeToastsRepresentedById(id: string) {
|
||||
const representedToasts = this.state.idToToasts[id];
|
||||
if (representedToasts) {
|
||||
representedToasts.forEach((toast) => this.props.dismissToast(toast.id));
|
||||
}
|
||||
}
|
||||
const closeToastsRepresentedById = useCallback(
|
||||
({ id }: EuiToast) => {
|
||||
const representedToasts = idToToasts[id];
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<EuiGlobalToastList
|
||||
aria-label={i18n.translate('core.notifications.globalToast.ariaLabel', {
|
||||
defaultMessage: 'Notification message list',
|
||||
})}
|
||||
data-test-subj="globalToastList"
|
||||
toasts={this.state.toasts.map(convertToEui)}
|
||||
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
|
||||
* provided in development.
|
||||
*/
|
||||
toastLifeTimeMs={Infinity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (representedToasts) {
|
||||
representedToasts.forEach((toast) => dismissToast(toast.id));
|
||||
|
||||
reportToastDismissal(representedToasts);
|
||||
}
|
||||
},
|
||||
[dismissToast, idToToasts, reportToastDismissal]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiGlobalToastList
|
||||
aria-label={i18n.translate('core.notifications.globalToast.ariaLabel', {
|
||||
defaultMessage: 'Notification message list',
|
||||
})}
|
||||
data-test-subj="globalToastList"
|
||||
toasts={toasts.map(convertToEui)}
|
||||
dismissToast={closeToastsRepresentedById}
|
||||
/**
|
||||
* This prop is overridden by the individual toasts that are added.
|
||||
* Use `Infinity` here so that it's obvious a timeout hasn't been
|
||||
* provided in development.
|
||||
*/
|
||||
toastLifeTimeMs={Infinity}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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 { ComponentProps } from 'react';
|
||||
import { EuiToast } from '@elastic/eui';
|
||||
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
|
||||
import { EventMetric, FieldType } from './event_types';
|
||||
|
||||
type ToastMessageType = Exclude<ComponentProps<typeof EuiToast>['color'], 'success'>;
|
||||
|
||||
interface EventPayload {
|
||||
[FieldType.RECURRENCE_COUNT]: number;
|
||||
[FieldType.TOAST_MESSAGE]: string;
|
||||
[FieldType.TOAST_MESSAGE_TYPE]: ToastMessageType;
|
||||
}
|
||||
|
||||
export class EventReporter {
|
||||
private reportEvent: AnalyticsServiceStart['reportEvent'];
|
||||
|
||||
constructor({ analytics }: { analytics: AnalyticsServiceStart }) {
|
||||
this.reportEvent = analytics.reportEvent;
|
||||
}
|
||||
|
||||
onDismissToast({
|
||||
recurrenceCount,
|
||||
toastMessage,
|
||||
toastMessageType,
|
||||
}: {
|
||||
toastMessage: string;
|
||||
recurrenceCount: number;
|
||||
toastMessageType: ToastMessageType;
|
||||
}) {
|
||||
this.reportEvent<EventPayload>(EventMetric.TOAST_DISMISSED, {
|
||||
[FieldType.RECURRENCE_COUNT]: recurrenceCount,
|
||||
[FieldType.TOAST_MESSAGE]: toastMessage,
|
||||
[FieldType.TOAST_MESSAGE_TYPE]: toastMessageType,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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 { type RootSchema, type EventTypeOpts } from '@kbn/analytics-client';
|
||||
|
||||
export enum EventMetric {
|
||||
TOAST_DISMISSED = 'global_toast_list_toast_dismissed',
|
||||
}
|
||||
|
||||
export enum FieldType {
|
||||
RECURRENCE_COUNT = 'toast_deduplication_count',
|
||||
TOAST_MESSAGE = 'toast_message',
|
||||
TOAST_MESSAGE_TYPE = 'toast_message_type',
|
||||
}
|
||||
|
||||
const fields: Record<FieldType, RootSchema<Record<string, unknown>>> = {
|
||||
[FieldType.TOAST_MESSAGE]: {
|
||||
[FieldType.TOAST_MESSAGE]: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'toast message text',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
[FieldType.RECURRENCE_COUNT]: {
|
||||
[FieldType.RECURRENCE_COUNT]: {
|
||||
type: 'long',
|
||||
_meta: {
|
||||
description: 'recurrence count for particular toast message',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
[FieldType.TOAST_MESSAGE_TYPE]: {
|
||||
[FieldType.TOAST_MESSAGE_TYPE]: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'toast message type',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const eventTypes: Array<EventTypeOpts<Record<string, unknown>>> = [
|
||||
{
|
||||
eventType: EventMetric.TOAST_DISMISSED,
|
||||
schema: {
|
||||
...fields[FieldType.TOAST_MESSAGE],
|
||||
...fields[FieldType.RECURRENCE_COUNT],
|
||||
...fields[FieldType.TOAST_MESSAGE_TYPE],
|
||||
},
|
||||
},
|
||||
];
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { EventReporter } from './event_reporter';
|
||||
export { eventTypes } from './event_types';
|
|
@ -13,6 +13,8 @@ import { ToastsApi } from './toasts_api';
|
|||
import { overlayServiceMock } from '@kbn/core-overlays-browser-mocks';
|
||||
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
|
||||
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
|
||||
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
|
||||
import { EventReporter } from './telemetry';
|
||||
|
||||
const mockI18n: any = {
|
||||
Context: function I18nContext() {
|
||||
|
@ -22,6 +24,9 @@ const mockI18n: any = {
|
|||
|
||||
const mockOverlays = overlayServiceMock.createStartContract();
|
||||
const mockTheme = themeServiceMock.createStartContract();
|
||||
const mockAnalytics = analyticsServiceMock.createAnalyticsServiceStart();
|
||||
|
||||
const eventReporter = new EventReporter({ analytics: mockAnalytics });
|
||||
|
||||
describe('#setup()', () => {
|
||||
it('returns a ToastsApi', () => {
|
||||
|
@ -41,7 +46,13 @@ describe('#start()', () => {
|
|||
|
||||
expect(mockReactDomRender).not.toHaveBeenCalled();
|
||||
toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() });
|
||||
toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays });
|
||||
toasts.start({
|
||||
i18n: mockI18n,
|
||||
theme: mockTheme,
|
||||
targetDomElement,
|
||||
overlays: mockOverlays,
|
||||
eventReporter,
|
||||
});
|
||||
expect(mockReactDomRender.mock.calls).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -53,7 +64,13 @@ describe('#start()', () => {
|
|||
toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() })
|
||||
).toBeInstanceOf(ToastsApi);
|
||||
expect(
|
||||
toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays })
|
||||
toasts.start({
|
||||
i18n: mockI18n,
|
||||
theme: mockTheme,
|
||||
targetDomElement,
|
||||
overlays: mockOverlays,
|
||||
eventReporter,
|
||||
})
|
||||
).toBeInstanceOf(ToastsApi);
|
||||
});
|
||||
});
|
||||
|
@ -65,7 +82,13 @@ describe('#stop()', () => {
|
|||
const toasts = new ToastsService();
|
||||
|
||||
toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() });
|
||||
toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays });
|
||||
toasts.start({
|
||||
i18n: mockI18n,
|
||||
theme: mockTheme,
|
||||
targetDomElement,
|
||||
overlays: mockOverlays,
|
||||
eventReporter,
|
||||
});
|
||||
|
||||
expect(mockReactDomUnmount).not.toHaveBeenCalled();
|
||||
toasts.stop();
|
||||
|
@ -84,7 +107,13 @@ describe('#stop()', () => {
|
|||
const toasts = new ToastsService();
|
||||
|
||||
toasts.setup({ uiSettings: uiSettingsServiceMock.createSetupContract() });
|
||||
toasts.start({ i18n: mockI18n, theme: mockTheme, targetDomElement, overlays: mockOverlays });
|
||||
toasts.start({
|
||||
i18n: mockI18n,
|
||||
theme: mockTheme,
|
||||
targetDomElement,
|
||||
overlays: mockOverlays,
|
||||
eventReporter,
|
||||
});
|
||||
toasts.stop();
|
||||
expect(targetDomElement.childNodes).toHaveLength(0);
|
||||
});
|
||||
|
|
|
@ -16,6 +16,7 @@ import type { OverlayStart } from '@kbn/core-overlays-browser';
|
|||
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
||||
import { GlobalToastList } from './global_toast_list';
|
||||
import { ToastsApi } from './toasts_api';
|
||||
import { EventReporter } from './telemetry';
|
||||
|
||||
interface SetupDeps {
|
||||
uiSettings: IUiSettingsClient;
|
||||
|
@ -25,6 +26,7 @@ interface StartDeps {
|
|||
i18n: I18nStart;
|
||||
overlays: OverlayStart;
|
||||
theme: ThemeServiceStart;
|
||||
eventReporter: EventReporter;
|
||||
targetDomElement: HTMLElement;
|
||||
}
|
||||
|
||||
|
@ -37,7 +39,7 @@ export class ToastsService {
|
|||
return this.api!;
|
||||
}
|
||||
|
||||
public start({ i18n, overlays, theme, targetDomElement }: StartDeps) {
|
||||
public start({ eventReporter, i18n, overlays, theme, targetDomElement }: StartDeps) {
|
||||
this.api!.start({ overlays, i18n, theme });
|
||||
this.targetDomElement = targetDomElement;
|
||||
|
||||
|
@ -46,6 +48,7 @@ export class ToastsService {
|
|||
<GlobalToastList
|
||||
dismissToast={(toastId: string) => this.api!.remove(toastId)}
|
||||
toasts$={this.api!.get$()}
|
||||
reportEvent={eventReporter}
|
||||
/>
|
||||
</KibanaRenderContextProvider>,
|
||||
targetDomElement
|
||||
|
|
|
@ -29,6 +29,9 @@
|
|||
"@kbn/core-theme-browser-mocks",
|
||||
"@kbn/core-mount-utils-browser",
|
||||
"@kbn/react-kibana-context-render",
|
||||
"@kbn/core-analytics-browser",
|
||||
"@kbn/core-analytics-browser-mocks",
|
||||
"@kbn/analytics-client",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -460,6 +460,7 @@ describe('#start()', () => {
|
|||
overlays: expect.any(Object),
|
||||
theme: expect.any(Object),
|
||||
targetDomElement: expect.any(HTMLElement),
|
||||
analytics: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -239,7 +239,7 @@ export class CoreSystem {
|
|||
this.chrome.setup({ analytics });
|
||||
const uiSettings = this.uiSettings.setup({ http, injectedMetadata });
|
||||
const settings = this.settings.setup({ http, injectedMetadata });
|
||||
const notifications = this.notifications.setup({ uiSettings });
|
||||
const notifications = this.notifications.setup({ uiSettings, analytics });
|
||||
const customBranding = this.customBranding.setup({ injectedMetadata });
|
||||
|
||||
const application = this.application.setup({ http, analytics });
|
||||
|
@ -305,6 +305,7 @@ export class CoreSystem {
|
|||
targetDomElement: overlayTargetDomElement,
|
||||
});
|
||||
const notifications = await this.notifications.start({
|
||||
analytics,
|
||||
i18n,
|
||||
overlays,
|
||||
theme,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue