diff --git a/packages/core/notifications/core-notifications-browser-internal/src/notifications_service.ts b/packages/core/notifications/core-notifications-browser-internal/src/notifications_service.ts index b7babd0cb433d..3df6ac89e03d3 100644 --- a/packages/core/notifications/core-notifications-browser-internal/src/notifications_service.ts +++ b/packages/core/notifications/core-notifications-browser-internal/src/notifications_service.ts @@ -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, 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 deleted file mode 100644 index d9dc9f6c7b13d..0000000000000 --- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/global_toast_list.test.tsx.snap +++ /dev/null @@ -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`] = ` -, - "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`] = ` - -`; diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap index fbb22caac4cd8..fa435d05d72c7 100644 --- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/__snapshots__/toasts_service.test.tsx.snap @@ -19,6 +19,11 @@ Array [ > = {}) { - return ; +const sharedProps = { + toasts$: EMPTY, + dismissToast: jest.fn(), + reportEvent: new EventReporter({ analytics: mockAnalytics }), +}; + +function RenderToastList(props: Partial> = {}) { + return ; } -it('renders matching snapshot', () => { - expect(shallow(render())).toMatchSnapshot(); +const dummyToastText = `You've got mail!`; +const dummyToastTitle = `AOL Notifications`; + +const createMockToast = (id: any, type?: ComponentProps['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(subscribeSpy), - }); + const { unmount } = render((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 ', () => { - const el = shallow( - render({ - toasts$: from([[], [{ id: '1' }], [{ id: '1' }, { id: '2' }]]) as any, - }) - ); +it('uses the latest value from toasts$ passed to to render the right number of toasts', () => { + const toastObservable$ = new BehaviorSubject([{ id: '1' }, { id: '2' }]); + + render(); + + expect(screen.getAllByLabelText('Notification')).toHaveLength(2); + + act(() => { + toastObservable$.next([...toastObservable$.getValue(), { id: '3' }]); + }); - expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: '1' }, { id: '2' }]); + 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 ( + createMockToast(idx)), + ]) as any + } + /> + ); + } + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); }); - const globalToastList = shallow( - render({ - toasts$: from([[toast(0), toast(1), toast(2), toast(3)]]) as any, - }) - ); + it('renders the toast list with a single toast when toasts matching deduplication heuristics are passed', () => { + render(); - const euiToastList = globalToastList.find(EuiGlobalToastList); - const toastsProp = euiToastList.prop('toasts'); + const { 0: firstToast, length: toastCount } = screen.getAllByLabelText('Notification'); - it('renders the list with a single element', () => { - expect(toastsProp).toBeDefined(); - expect(toastsProp).toHaveLength(1); - expect(euiToastList).toMatchSnapshot(); + expect(toastCount).toEqual(1); + + expect(screen.getAllByText(dummyToastText)).toHaveLength(1); + + expect(firstToast.querySelector('.euiNotificationBadge')?.innerHTML).toEqual('4'); }); - it('renders the single toast with the common text', () => { - const firstRenderedToast = toastsProp![0]; - expect(firstRenderedToast.text).toBe(dummyText); + 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, + }); + + render( + + createMockToastWithMountPoints(idx) + ), + ]) as any + } + /> + ); + + const renderedToasts = screen.getAllByText(dummyToastText); + + expect(renderedToasts).toHaveLength(TOAST_DUPLICATE_COUNT); }); - it(`calls all toast's dismiss when closed`, () => { - const firstRenderedToast = toastsProp![0]; - const dismissToast = globalToastList.prop('dismissToast'); - dismissToast(firstRenderedToast); + it(`when a represented toast is closed, the provided dismiss action is called for all its internal toasts`, () => { + render(); + + 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(mockDismissToast).toHaveBeenCalledTimes(4); - expect(mockDismissToast).toHaveBeenCalledWith('0'); - expect(mockDismissToast).toHaveBeenCalledWith('1'); - expect(mockDismissToast).toHaveBeenCalledWith('2'); - expect(mockDismissToast).toHaveBeenCalledWith('3'); + 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(); + }); + + it('does not invoke the reportEvent method when there is no recurring toast', async () => { + const onDimissReporterSpy = jest.spyOn(sharedProps.reportEvent, 'onDismissToast'); + + 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(); + + 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(); + + 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(); + + 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( + 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(); + + 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); - const euiToastList = globalToastList.find(EuiGlobalToastList); - const toastsProp = euiToastList.prop('toasts'); + new Array(UNIQUE_TOASTS_COUNT).forEach((_, idx) => { + expect(onDimissReporterSpy).toHaveBeenCalledWith( + expect.objectContaining({ + toastMessage: `${dummyToastTitle}_${idx}`, + recurrenceCount: REPEATED_COUNT_PER_UNIQUE_TOAST, + }) + ); + }); - it('renders the all separate elements element', () => { - expect(toastsProp).toBeDefined(); - expect(toastsProp).toHaveLength(4); - expect(euiToastList).toMatchSnapshot('euiToastList'); - expect(globalToastList).toMatchSnapshot('globalToastList'); + expect(screen.queryByLabelText('Notification')).toBeNull(); }); }); 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 e0a5d631d3776..5218afd7c6209 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 @@ -7,16 +7,18 @@ */ 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 { 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; + reportEvent: EventReporter; dismissToast: (toastId: string) => void; } @@ -31,50 +33,73 @@ const convertToEui = (toast: ToastWithRichTitle): EuiToast => ({ text: toast.text instanceof Function ? : toast.text, }); -export class GlobalToastList extends React.Component { - public state: State = { - toasts: [], - idToToasts: {}, - }; +export const GlobalToastList: FunctionComponent = ({ + toasts$, + dismissToast, + reportEvent, +}) => { + const [toasts, setToasts] = useState([]); + const [idToToasts, setIdToToasts] = useState({}); - 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({ + 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 ( - 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 ( + + ); +}; diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_reporter.ts b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_reporter.ts new file mode 100644 index 0000000000000..135dcc5741fb8 --- /dev/null +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_reporter.ts @@ -0,0 +1,40 @@ +/* + * 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['color'], 'success'>; + +interface EventPayload { + [FieldType.RECURRENCE_COUNT]: number; + [FieldType.TOAST_MESSAGE_TYPE]: ToastMessageType; +} + +export class EventReporter { + private reportEvent: AnalyticsServiceStart['reportEvent']; + + constructor({ analytics }: { analytics: AnalyticsServiceStart }) { + this.reportEvent = analytics.reportEvent; + } + + onDismissToast({ + recurrenceCount, + toastMessageType, + }: { + recurrenceCount: number; + toastMessageType: ToastMessageType; + }) { + this.reportEvent(EventMetric.TOAST_DISMISSED, { + [FieldType.RECURRENCE_COUNT]: recurrenceCount, + [FieldType.TOAST_MESSAGE_TYPE]: toastMessageType, + }); + } +} diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_types.ts b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_types.ts new file mode 100644 index 0000000000000..739d3d6b0b9f0 --- /dev/null +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/event_types.ts @@ -0,0 +1,49 @@ +/* + * 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_TYPE = 'toast_message_type', +} + +const fields: Record>> = { + [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>> = [ + { + eventType: EventMetric.TOAST_DISMISSED, + schema: { + ...fields[FieldType.RECURRENCE_COUNT], + ...fields[FieldType.TOAST_MESSAGE_TYPE], + }, + }, +]; diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/index.ts b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/index.ts new file mode 100644 index 0000000000000..dad98bcbd54be --- /dev/null +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/telemetry/index.ts @@ -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'; diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.test.tsx b/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.test.tsx index 4e4ef668e9778..6509c75b84533 100644 --- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.test.tsx +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.test.tsx @@ -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); }); diff --git a/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.tsx b/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.tsx index 9656b6764b715..63ede7bcb906e 100644 --- a/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.tsx +++ b/packages/core/notifications/core-notifications-browser-internal/src/toasts/toasts_service.tsx @@ -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 { this.api!.remove(toastId)} toasts$={this.api!.get$()} + reportEvent={eventReporter} /> , targetDomElement diff --git a/packages/core/notifications/core-notifications-browser-internal/tsconfig.json b/packages/core/notifications/core-notifications-browser-internal/tsconfig.json index 09392c1805f8f..0250d4b80488b 100644 --- a/packages/core/notifications/core-notifications-browser-internal/tsconfig.json +++ b/packages/core/notifications/core-notifications-browser-internal/tsconfig.json @@ -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/**/*", diff --git a/packages/core/root/core-root-browser-internal/src/core_system.test.ts b/packages/core/root/core-root-browser-internal/src/core_system.test.ts index d77ccaedb5279..b4cdd4b1d965b 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.test.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.test.ts @@ -460,6 +460,7 @@ describe('#start()', () => { overlays: expect.any(Object), theme: expect.any(Object), targetDomElement: expect.any(HTMLElement), + analytics: expect.any(Object), }); }); diff --git a/packages/core/root/core-root-browser-internal/src/core_system.ts b/packages/core/root/core-root-browser-internal/src/core_system.ts index cf9a172479b31..0980c84aab89f 100644 --- a/packages/core/root/core-root-browser-internal/src/core_system.ts +++ b/packages/core/root/core-root-browser-internal/src/core_system.ts @@ -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,