[Stateful sidenav] Add telemetry (#189275)

This commit is contained in:
Sébastien Loix 2024-08-01 15:10:00 +01:00 committed by GitHub
parent a18d60c8c1
commit 93378d9e37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 660 additions and 41 deletions

View file

@ -12,6 +12,7 @@ import type {
ChromeProjectNavigationNode,
} from '@kbn/core-chrome-browser';
import { EventTracker } from '../src/analytics';
import { renderNavigation } from './utils';
describe('builds navigation tree', () => {
@ -135,6 +136,43 @@ describe('builds navigation tree', () => {
}
});
test('should track click event', async () => {
const navigateToUrl = jest.fn();
const reportEvent = jest.fn();
const node: ChromeProjectNavigationNode = {
id: 'group1',
title: 'Group 1',
path: 'group1',
defaultIsCollapsed: false,
children: [
{
id: 'item1',
title: 'Item 1',
href: 'https://foo',
path: 'group1.item1',
},
],
};
const { findByTestId } = renderNavigation({
navTreeDef: of({
body: [node],
}),
services: { navigateToUrl, eventTracker: new EventTracker({ reportEvent }) },
});
const navItem = await findByTestId(/nav-item-group1.item1\s/);
navItem.click();
expect(navigateToUrl).toHaveBeenCalled();
expect(reportEvent).toHaveBeenCalledWith('solutionNav_click_navlink', {
href: undefined,
href_prev: undefined,
id: 'item1',
});
});
test('should allow custom onClick handler for links', async () => {
const navigateToUrl = jest.fn();
const onClick = jest.fn();

View file

@ -19,13 +19,15 @@ import { NavigationProvider } from '../src/services';
import { Navigation } from '../src/ui/navigation';
import type { PanelContentProvider } from '../src/ui';
import { NavigationServices } from '../src/types';
import { EventTracker } from '../src/analytics';
const activeNodes: ChromeProjectNavigationNode[][] = [];
export const getServicesMock = (): NavigationServices => {
const navigateToUrl = jest.fn().mockResolvedValue(undefined);
const basePath = { prepend: jest.fn((path: string) => `/base${path}`) };
const basePath = { prepend: jest.fn((path: string) => `/base${path}`), remove: jest.fn() };
const recentlyAccessed$ = new BehaviorSubject([]);
const eventTracker = new EventTracker({ reportEvent: jest.fn() });
return {
basePath,
@ -34,6 +36,7 @@ export const getServicesMock = (): NavigationServices => {
navigateToUrl,
activeNodes$: of(activeNodes),
isSideNavCollapsed: false,
eventTracker,
};
};

View file

@ -9,6 +9,8 @@
export { NavigationKibanaProvider, NavigationProvider } from './src/services';
export { Navigation } from './src/ui';
export { EventType, FieldType } from './src/analytics';
export type { NavigationProps } from './src/ui';
export type { PanelComponentProps, PanelContent, PanelContentProvider } from './src/ui';

View file

@ -9,6 +9,7 @@
import { AbstractStorybookMock } from '@kbn/shared-ux-storybook-mock';
import { action } from '@storybook/addon-actions';
import { BehaviorSubject } from 'rxjs';
import { EventTracker } from '../src/analytics';
import { NavigationServices } from '../src/types';
type Arguments = NavigationServices;
@ -35,11 +36,12 @@ export class StorybookMock extends AbstractStorybookMock<{}, NavigationServices>
return {
...params,
basePath: { prepend: (suffix: string) => `/basepath${suffix}` },
basePath: { prepend: (suffix: string) => `/basepath${suffix}`, remove: () => '' },
navigateToUrl,
recentlyAccessed$: params.recentlyAccessed$ ?? new BehaviorSubject([]),
activeNodes$: params.activeNodes$ ?? new BehaviorSubject([]),
isSideNavCollapsed: true,
eventTracker: new EventTracker({ reportEvent: action('Report event') }),
};
}

View file

@ -0,0 +1,43 @@
/*
* 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 { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
export enum EventType {
CLICK_NAVLINK = 'solutionNav_click_navlink',
}
export enum FieldType {
ID = 'id',
HREF = 'href',
HREF_PREV = 'href_prev',
}
export class EventTracker {
constructor(private analytics: Pick<AnalyticsServiceStart, 'reportEvent'>) {}
private track(eventType: string, eventFields: object) {
try {
this.analytics.reportEvent(eventType, eventFields);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`Navigation EventTracker error: ${err.toString()}`);
}
}
/*
* Track whenever a user clicks on a navigation link in the side nav
*/
public clickNavLink({ id, href, hrefPrev }: { id: string; href?: string; hrefPrev?: string }) {
this.track(EventType.CLICK_NAVLINK, {
[FieldType.ID]: id,
[FieldType.HREF]: href,
[FieldType.HREF_PREV]: hrefPrev,
});
}
}

View file

@ -0,0 +1,9 @@
/*
* 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 { EventTracker, EventType, FieldType } from './event_tracker';

View file

@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
import React, { FC, PropsWithChildren, useContext } from 'react';
import React, { FC, PropsWithChildren, useContext, useMemo } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { EventTracker } from './analytics';
import { NavigationKibanaDependencies, NavigationServices } from './types';
@ -31,19 +32,30 @@ export const NavigationKibanaProvider: FC<PropsWithChildren<NavigationKibanaDepe
...dependencies
}) => {
const { core, activeNodes$ } = dependencies;
const { chrome, http } = core;
const { chrome, http, analytics } = core;
const { basePath } = http;
const { navigateToUrl } = core.application;
const isSideNavCollapsed = useObservable(chrome.getIsSideNavCollapsed$(), true);
const value: NavigationServices = {
basePath,
recentlyAccessed$: chrome.recentlyAccessed.get$(),
navigateToUrl,
navIsOpen: true,
activeNodes$,
isSideNavCollapsed,
};
const value: NavigationServices = useMemo(
() => ({
basePath,
recentlyAccessed$: chrome.recentlyAccessed.get$(),
navigateToUrl,
navIsOpen: true,
activeNodes$,
isSideNavCollapsed,
eventTracker: new EventTracker({ reportEvent: analytics.reportEvent }),
}),
[
activeNodes$,
analytics.reportEvent,
basePath,
chrome.recentlyAccessed,
isSideNavCollapsed,
navigateToUrl,
]
);
return <Context.Provider value={value}>{children}</Context.Provider>;
};

View file

@ -15,8 +15,9 @@ import type {
ChromeProjectNavigationNode,
ChromeRecentlyAccessedHistoryItem,
} from '@kbn/core-chrome-browser';
import { EventTracker } from './analytics';
type BasePathService = Pick<IBasePath, 'prepend'>;
export type BasePathService = Pick<IBasePath, 'prepend' | 'remove'>;
/**
* @internal
@ -35,6 +36,7 @@ export interface NavigationServices {
navigateToUrl: NavigateToUrlFn;
activeNodes$: Observable<ChromeProjectNavigationNode[][]>;
isSideNavCollapsed: boolean;
eventTracker: EventTracker;
}
/**
@ -56,6 +58,9 @@ export interface NavigationKibanaDependencies {
basePath: BasePathService;
getLoadingCount$(): Observable<number>;
};
analytics: {
reportEvent: (eventType: string, eventData: object) => void;
};
};
activeNodes$: Observable<ChromeProjectNavigationNode[][]>;
}

View file

@ -21,8 +21,9 @@ import type { EuiThemeSize, RenderAs } from '@kbn/core-chrome-browser/src/projec
import { useNavigation as useServices } from '../../services';
import { isAbsoluteLink, isActiveFromUrl, isAccordionNode } from '../../utils';
import type { NavigateToUrlFn } from '../../types';
import type { BasePathService, NavigateToUrlFn } from '../../types';
import { useNavigation } from '../navigation';
import { EventTracker } from '../../analytics';
import { useAccordionState } from '../hooks';
import {
DEFAULT_IS_COLLAPSIBLE,
@ -183,6 +184,8 @@ const getEuiProps = (
treeDepth: number;
getIsCollapsed: (path: string) => boolean;
activeNodes: ChromeProjectNavigationNode[][];
eventTracker: EventTracker;
basePath: BasePathService;
}
): {
navNode: ChromeProjectNavigationNode;
@ -192,7 +195,15 @@ const getEuiProps = (
dataTestSubj: string;
spaceBefore?: EuiThemeSize | null;
} & Pick<EuiCollapsibleNavItemProps, 'linkProps' | 'onClick'> => {
const { navigateToUrl, closePanel, treeDepth, getIsCollapsed, activeNodes } = deps;
const {
navigateToUrl,
closePanel,
treeDepth,
getIsCollapsed,
activeNodes,
eventTracker,
basePath,
} = deps;
const { navNode, isItem, hasChildren, hasLink } = serializeNavNode(_navNode);
const { path, href, onClick: customOnClick, isCollapsible = DEFAULT_IS_COLLAPSIBLE } = navNode;
@ -239,6 +250,14 @@ const getEuiProps = (
href,
external: isExternal,
onClick: (e) => {
if (href) {
eventTracker.clickNavLink({
href: basePath.remove(href),
id: navNode.id,
hrefPrev: basePath.remove(window.location.pathname),
});
}
if (customOnClick) {
customOnClick(e);
return;
@ -253,6 +272,14 @@ const getEuiProps = (
: undefined;
const onClick = (e: React.MouseEvent<HTMLElement | HTMLButtonElement>) => {
if (href) {
eventTracker.clickNavLink({
href: basePath.remove(href),
id: navNode.id,
hrefPrev: basePath.remove(window.location.pathname),
});
}
if (customOnClick) {
customOnClick(e);
return;
@ -293,6 +320,8 @@ function nodeToEuiCollapsibleNavProps(
treeDepth: number;
getIsCollapsed: (path: string) => boolean;
activeNodes: ChromeProjectNavigationNode[][];
eventTracker: EventTracker;
basePath: BasePathService;
}
): {
items: Array<EuiCollapsibleNavItemProps | EuiCollapsibleNavSubItemPropsEnhanced>;
@ -369,7 +398,7 @@ interface Props {
export const NavigationSectionUI: FC<Props> = React.memo(({ navNode: _navNode }) => {
const { activeNodes } = useNavigation();
const { navigateToUrl } = useServices();
const { navigateToUrl, eventTracker, basePath } = useServices();
const [items, setItems] = useState<EuiCollapsibleNavSubItemProps[] | undefined>();
const { navNode } = useMemo(
@ -394,8 +423,10 @@ export const NavigationSectionUI: FC<Props> = React.memo(({ navNode: _navNode })
treeDepth: 0,
getIsCollapsed,
activeNodes,
eventTracker,
basePath,
});
}, [navNode, navigateToUrl, closePanel, getIsCollapsed, activeNodes]);
}, [navNode, navigateToUrl, closePanel, getIsCollapsed, activeNodes, eventTracker, basePath]);
const { items: topLevelItems } = props;

View file

@ -22,6 +22,7 @@
"@kbn/i18n",
"@kbn/shared-ux-storybook-mock",
"@kbn/core-http-browser",
"@kbn/core-analytics-browser",
],
"exclude": [
"target/**/*"

View file

@ -0,0 +1,9 @@
/*
* 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 { registerNavigationEventTypes } from './register_event_types';

View file

@ -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 { CoreSetup, EventTypeOpts, RootSchema } from '@kbn/core/public';
import {
FieldType as NavigationFieldType,
EventType as NavigationEventType,
} from '@kbn/shared-ux-chrome-navigation';
const fields: Record<NavigationFieldType, RootSchema<Record<string, unknown>>> = {
[NavigationFieldType.ID]: {
[NavigationFieldType.ID]: {
type: 'keyword',
_meta: {
description: 'The ID of navigation node.',
},
},
},
[NavigationFieldType.HREF]: {
[NavigationFieldType.HREF]: {
type: 'keyword',
_meta: {
description: 'The href of the navigation node.',
optional: true,
},
},
},
[NavigationFieldType.HREF_PREV]: {
[NavigationFieldType.HREF_PREV]: {
type: 'keyword',
_meta: {
description: 'The previous href before clicking on a navigation node.',
optional: true,
},
},
},
};
const eventTypes: Array<EventTypeOpts<Record<string, unknown>>> = [
{
eventType: NavigationEventType.CLICK_NAVLINK,
schema: {
...fields[NavigationFieldType.ID],
...fields[NavigationFieldType.HREF],
...fields[NavigationFieldType.HREF_PREV],
},
},
];
export function registerNavigationEventTypes(core: CoreSetup) {
const { analytics } = core;
for (const eventType of eventTypes) {
analytics.registerEventType(eventType);
}
}

View file

@ -33,6 +33,7 @@ import type {
import { TopNavMenuExtensionsRegistry, createTopNav } from './top_nav_menu';
import { RegisteredTopNavMenuData } from './top_nav_menu/top_nav_menu_data';
import { SideNavComponent } from './side_navigation';
import { registerNavigationEventTypes } from './analytics';
export class NavigationPublicPlugin
implements
@ -52,7 +53,9 @@ export class NavigationPublicPlugin
constructor(private initializerContext: PluginInitializerContext) {}
public setup(_core: CoreSetup): NavigationPublicSetup {
public setup(core: CoreSetup): NavigationPublicSetup {
registerNavigationEventTypes(core);
return {
registerMenuItem: this.topNavMenuExtensionsRegistry.register.bind(
this.topNavMenuExtensionsRegistry

View file

@ -18,5 +18,6 @@ export type {
GetAllSpacesOptions,
GetAllSpacesPurpose,
GetSpaceResult,
SolutionView,
} from './types/latest';
export { spaceV1 } from './types';

View file

@ -7,6 +7,8 @@
import type { OnBoardingDefaultSolution } from '@kbn/cloud-plugin/common';
export type SolutionView = OnBoardingDefaultSolution | 'classic';
/**
* A Space.
*/
@ -64,7 +66,7 @@ export interface Space {
/**
* Solution selected for this space.
*/
solution?: OnBoardingDefaultSolution | 'classic';
solution?: SolutionView;
}
/**

View file

@ -0,0 +1,80 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AnalyticsServiceStart } from '@kbn/core/public';
import type { SolutionView } from '../../common';
export enum EventType {
SPACE_SOLUTION_CHANGED = 'space_solution_changed',
SPACE_CHANGED = 'space_changed',
}
export enum FieldType {
ACTION = 'action',
SPACE_ID = 'space_id',
SPACE_ID_PREV = 'space_id_prev',
SOLUTION = 'solution',
SOLUTION_PREV = 'solution_prev',
}
export class EventTracker {
constructor(private analytics: Pick<AnalyticsServiceStart, 'reportEvent'>) {}
private track(eventType: string, eventFields: object) {
try {
this.analytics.reportEvent(eventType, eventFields);
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
}
}
/**
* Track whenever the space "solution" is changed.
*/
public spaceSolutionChanged({
spaceId,
action,
solution,
solutionPrev,
}: {
spaceId: string;
action: 'create' | 'edit';
solution: SolutionView;
solutionPrev?: SolutionView;
}) {
this.track(EventType.SPACE_SOLUTION_CHANGED, {
[FieldType.SPACE_ID]: spaceId,
[FieldType.SOLUTION]: solution,
[FieldType.SOLUTION_PREV]: solutionPrev,
[FieldType.ACTION]: action,
});
}
/**
* Track whenever the user changes space.
*/
public changeSpace({
prevSpaceId,
prevSolution,
nextSpaceId,
nextSolution,
}: {
prevSpaceId: string;
prevSolution?: SolutionView;
nextSpaceId: string;
nextSolution?: SolutionView;
}) {
this.track(EventType.SPACE_CHANGED, {
[FieldType.SPACE_ID]: nextSpaceId,
[FieldType.SPACE_ID_PREV]: prevSpaceId,
[FieldType.SOLUTION]: nextSolution,
[FieldType.SOLUTION_PREV]: prevSolution,
});
}
}

View file

@ -0,0 +1,12 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { registerAnalyticsContext } from './register_analytics_context';
export { EventTracker } from './event_tracker';
export { registerSpacesEventTypes } from './register_event_types';

View file

@ -0,0 +1,33 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Observable } from 'rxjs';
import { map } from 'rxjs';
import type { AnalyticsClient } from '@kbn/core-analytics-browser';
import type { SolutionView, Space } from '../../common';
export interface SpaceMetadata {
spaceSolutionView?: SolutionView;
}
export function registerAnalyticsContext(
analytics: Pick<AnalyticsClient, 'registerContextProvider'>,
activeSpace: Observable<Space>
) {
analytics.registerContextProvider({
name: 'Spaces Metadata',
context$: activeSpace.pipe(map((space) => ({ spaceSolution: space.solution }))),
schema: {
spaceSolution: {
type: 'keyword',
_meta: { description: 'The Space solution view', optional: true },
},
},
});
}

View file

@ -0,0 +1,82 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreSetup, EventTypeOpts, RootSchema } from '@kbn/core/public';
import { EventType, FieldType } from './event_tracker';
const fields: Record<FieldType, RootSchema<Record<string, unknown>>> = {
[FieldType.SPACE_ID]: {
[FieldType.SPACE_ID]: {
type: 'keyword',
_meta: {
description: 'The ID of the space.',
},
},
},
[FieldType.SPACE_ID_PREV]: {
[FieldType.SPACE_ID_PREV]: {
type: 'keyword',
_meta: {
description: 'The previous ID of the space (before switching space).',
},
},
},
[FieldType.SOLUTION]: {
[FieldType.SOLUTION]: {
type: 'keyword',
_meta: {
description: 'The solution set for the space.',
},
},
},
[FieldType.SOLUTION_PREV]: {
[FieldType.SOLUTION_PREV]: {
type: 'keyword',
_meta: {
description: 'The previous solution value before editing the space.',
optional: true,
},
},
},
[FieldType.ACTION]: {
[FieldType.ACTION]: {
type: 'keyword',
_meta: {
description: 'The user action, either create or edit a space.',
},
},
},
};
const eventTypes: Array<EventTypeOpts<Record<string, unknown>>> = [
{
eventType: EventType.SPACE_SOLUTION_CHANGED,
schema: {
...fields[FieldType.SPACE_ID],
...fields[FieldType.SOLUTION_PREV],
...fields[FieldType.SOLUTION],
...fields[FieldType.ACTION],
},
},
{
eventType: EventType.SPACE_CHANGED,
schema: {
...fields[FieldType.SPACE_ID],
...fields[FieldType.SPACE_ID_PREV],
...fields[FieldType.SOLUTION_PREV],
...fields[FieldType.SOLUTION],
},
},
];
export function registerSpacesEventTypes(core: CoreSetup) {
const { analytics } = core;
for (const eventType of eventTypes) {
analytics.registerEventType(eventType);
}
}

View file

@ -5,5 +5,4 @@
* 2.0.
*/
// @ts-ignore
export { ManageSpacePage } from './manage_space_page';

View file

@ -10,6 +10,7 @@ import { EuiButton } from '@elastic/eui';
import { waitFor } from '@testing-library/react';
import type { ReactWrapper } from 'enzyme';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/public';
import { notificationServiceMock, scopedHistoryMock } from '@kbn/core/public/mocks';
@ -20,6 +21,8 @@ import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers';
import { ConfirmAlterActiveSpaceModal } from './confirm_alter_active_space_modal';
import { EnabledFeatures } from './enabled_features';
import { ManageSpacePage } from './manage_space_page';
import type { SolutionView, Space } from '../../../common/types/latest';
import { EventTracker } from '../../analytics';
import type { SpacesManager } from '../../spaces_manager';
import { spacesManagerMock } from '../../spaces_manager/mocks';
@ -31,7 +34,7 @@ jest.mock('@elastic/eui/lib/components/overlay_mask', () => {
};
});
const space = {
const space: Space = {
id: 'my-space',
name: 'My Space',
disabledFeatures: [],
@ -48,6 +51,9 @@ featuresStart.getFeatures.mockResolvedValue([
}),
]);
const reportEvent = jest.fn();
const eventTracker = new EventTracker({ reportEvent });
describe('ManageSpacePage', () => {
beforeAll(() => {
Object.defineProperty(window, 'location', {
@ -75,6 +81,7 @@ describe('ManageSpacePage', () => {
catalogue: {},
spaces: { manage: true },
}}
eventTracker={eventTracker}
allowFeatureVisibility
/>
);
@ -124,6 +131,7 @@ describe('ManageSpacePage', () => {
}}
allowFeatureVisibility
solutionNavExperiment={Promise.resolve(true)}
eventTracker={eventTracker}
/>
);
@ -154,6 +162,7 @@ describe('ManageSpacePage', () => {
spaces: { manage: true },
}}
allowFeatureVisibility
eventTracker={eventTracker}
/>
);
@ -180,6 +189,7 @@ describe('ManageSpacePage', () => {
}}
allowFeatureVisibility
solutionNavExperiment={Promise.resolve(false)}
eventTracker={eventTracker}
/>
);
@ -209,6 +219,7 @@ describe('ManageSpacePage', () => {
catalogue: {},
spaces: { manage: true },
}}
eventTracker={eventTracker}
allowFeatureVisibility
/>
);
@ -238,6 +249,7 @@ describe('ManageSpacePage', () => {
catalogue: {},
spaces: { manage: true },
}}
eventTracker={eventTracker}
allowFeatureVisibility={false}
/>
);
@ -258,6 +270,7 @@ describe('ManageSpacePage', () => {
color: '#aabbcc',
initials: 'AB',
disabledFeatures: [],
solution: 'es',
};
const spacesManager = spacesManagerMock.create();
@ -282,7 +295,9 @@ describe('ManageSpacePage', () => {
catalogue: {},
spaces: { manage: true },
}}
eventTracker={eventTracker}
allowFeatureVisibility
solutionNavExperiment={Promise.resolve(true)}
/>
);
@ -299,7 +314,7 @@ describe('ManageSpacePage', () => {
wrapper.update();
updateSpace(wrapper);
updateSpace(wrapper, true, 'oblt');
await clickSaveButton(wrapper);
@ -311,6 +326,14 @@ describe('ManageSpacePage', () => {
initials: 'AB',
imageUrl: '',
disabledFeatures: ['feature-1'],
solution: 'oblt', // solution has been changed
});
expect(reportEvent).toHaveBeenCalledWith('space_solution_changed', {
action: 'edit',
solution: 'oblt',
solution_prev: 'es',
space_id: 'existing-space',
});
});
@ -350,6 +373,7 @@ describe('ManageSpacePage', () => {
catalogue: {},
spaces: { manage: true },
}}
eventTracker={eventTracker}
allowFeatureVisibility
/>
);
@ -399,6 +423,7 @@ describe('ManageSpacePage', () => {
catalogue: {},
spaces: { manage: true },
}}
eventTracker={eventTracker}
allowFeatureVisibility
/>
);
@ -436,6 +461,7 @@ describe('ManageSpacePage', () => {
catalogue: {},
spaces: { manage: true },
}}
eventTracker={eventTracker}
allowFeatureVisibility
/>
);
@ -497,6 +523,7 @@ describe('ManageSpacePage', () => {
catalogue: {},
spaces: { manage: true },
}}
eventTracker={eventTracker}
allowFeatureVisibility
/>
);
@ -521,7 +548,11 @@ describe('ManageSpacePage', () => {
});
});
function updateSpace(wrapper: ReactWrapper<any, any>, updateFeature = true) {
function updateSpace(
wrapper: ReactWrapper<any, any>,
updateFeature = true,
solution?: SolutionView
) {
const nameInput = wrapper.find('input[name="name"]');
const descriptionInput = wrapper.find('textarea[name="description"]');
@ -531,6 +562,16 @@ function updateSpace(wrapper: ReactWrapper<any, any>, updateFeature = true) {
if (updateFeature) {
toggleFeature(wrapper);
}
if (solution) {
act(() => {
findTestSubject(wrapper, `solutionViewSelect`).simulate('click');
});
wrapper.update();
findTestSubject(wrapper, `solutionView${capitalizeFirstLetter(solution)}Option`).simulate(
'click'
);
}
}
function toggleFeature(wrapper: ReactWrapper<any, any>) {
@ -552,3 +593,7 @@ async function clickSaveButton(wrapper: ReactWrapper<any, any>) {
wrapper.update();
}
function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}

View file

@ -33,6 +33,7 @@ import { EnabledFeatures } from './enabled_features';
import { SolutionView } from './solution_view';
import type { Space } from '../../../common';
import { isReservedSpace } from '../../../common';
import type { EventTracker } from '../../analytics';
import { getSpacesFeatureDescription } from '../../constants';
import { getSpaceColor, getSpaceInitials } from '../../space_avatar';
import type { SpacesManager } from '../../spaces_manager';
@ -57,6 +58,7 @@ interface Props {
history: ScopedHistory;
allowFeatureVisibility: boolean;
solutionNavExperiment?: Promise<boolean>;
eventTracker: EventTracker;
}
interface State {
@ -454,14 +456,30 @@ export class ManageSpacePage extends Component<Props, State> {
};
let action;
if (this.editingExistingSpace()) {
action = this.props.spacesManager.updateSpace(params);
const isEditing = this.editingExistingSpace();
const { spacesManager, eventTracker } = this.props;
if (isEditing) {
action = spacesManager.updateSpace(params);
} else {
action = this.props.spacesManager.createSpace(params);
action = spacesManager.createSpace(params);
}
this.setState({ saveInProgress: true });
const trackSpaceSolutionChange = () => {
const hasChangedSolution = this.state.originalSpace?.solution !== solution;
if (!hasChangedSolution || solution === undefined) return;
eventTracker.spaceSolutionChanged({
spaceId: id,
solution,
solutionPrev: this.state.originalSpace?.solution,
action: isEditing ? 'edit' : 'create',
});
};
action
.then(() => {
this.props.notifications.toasts.addSuccess(
@ -474,11 +492,15 @@ export class ManageSpacePage extends Component<Props, State> {
)
);
trackSpaceSolutionChange();
this.backToSpacesList();
if (requireRefresh) {
setTimeout(() => {
window.location.reload();
const flushAnalyticsEvents = window.__kbnAnalytics?.flush ?? (() => Promise.resolve());
flushAnalyticsEvents().then(() => {
setTimeout(() => {
window.location.reload();
});
});
}
})

View file

@ -12,10 +12,13 @@ import { managementPluginMock } from '@kbn/management-plugin/public/mocks';
import { ManagementService } from './management_service';
import { getRolesAPIClientMock } from './roles_api_client.mock';
import { EventTracker } from '../analytics';
import type { ConfigType } from '../config';
import type { PluginsStart } from '../plugin';
import { spacesManagerMock } from '../spaces_manager/mocks';
const eventTracker = new EventTracker({ reportEvent: jest.fn() });
describe('ManagementService', () => {
const config: ConfigType = {
maxSpaces: 1000,
@ -39,6 +42,7 @@ describe('ManagementService', () => {
config,
getRolesAPIClient: getRolesAPIClientMock,
solutionNavExperiment: Promise.resolve(false),
eventTracker,
});
expect(mockKibanaSection.registerApp).toHaveBeenCalledTimes(1);
@ -60,6 +64,7 @@ describe('ManagementService', () => {
config,
getRolesAPIClient: getRolesAPIClientMock,
solutionNavExperiment: Promise.resolve(false),
eventTracker,
});
});
});
@ -82,6 +87,7 @@ describe('ManagementService', () => {
config,
getRolesAPIClient: jest.fn(),
solutionNavExperiment: Promise.resolve(false),
eventTracker,
});
service.stop();

View file

@ -10,6 +10,7 @@ import type { ManagementApp, ManagementSetup } from '@kbn/management-plugin/publ
import type { RolesAPIClient } from '@kbn/security-plugin-types-public';
import { spacesManagementApp } from './spaces_management_app';
import type { EventTracker } from '../analytics';
import type { ConfigType } from '../config';
import type { PluginsStart } from '../plugin';
import type { SpacesManager } from '../spaces_manager';
@ -21,6 +22,7 @@ interface SetupDeps {
config: ConfigType;
getRolesAPIClient: () => Promise<RolesAPIClient>;
solutionNavExperiment: Promise<boolean>;
eventTracker: EventTracker;
}
export class ManagementService {
@ -33,6 +35,7 @@ export class ManagementService {
config,
getRolesAPIClient,
solutionNavExperiment,
eventTracker,
}: SetupDeps) {
this.registeredSpacesManagementApp = management.sections.section.kibana.registerApp(
spacesManagementApp.create({
@ -41,6 +44,7 @@ export class ManagementService {
config,
getRolesAPIClient,
solutionNavExperiment,
eventTracker,
})
);
}

View file

@ -22,6 +22,7 @@ import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/
import { featuresPluginMock } from '@kbn/features-plugin/public/mocks';
import { spacesManagementApp } from './spaces_management_app';
import { EventTracker } from '../analytics';
import type { ConfigType } from '../config';
import type { PluginsStart } from '../plugin';
import { spacesManagerMock } from '../spaces_manager/mocks';
@ -31,6 +32,8 @@ const config: ConfigType = {
allowFeatureVisibility: true,
};
const eventTracker = new EventTracker({ reportEvent: jest.fn() });
async function mountApp(basePath: string, pathname: string, spaceId?: string) {
const container = document.createElement('div');
const setBreadcrumbs = jest.fn();
@ -54,6 +57,7 @@ async function mountApp(basePath: string, pathname: string, spaceId?: string) {
config,
getRolesAPIClient: jest.fn(),
solutionNavExperiment: Promise.resolve(false),
eventTracker,
})
.mount({
basePath,
@ -76,6 +80,7 @@ describe('spacesManagementApp', () => {
config,
getRolesAPIClient: jest.fn(),
solutionNavExperiment: Promise.resolve(false),
eventTracker,
})
).toMatchInlineSnapshot(`
Object {
@ -127,7 +132,7 @@ describe('spacesManagementApp', () => {
css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)."
data-test-subj="kbnRedirectAppLink"
>
Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/create","search":"","hash":""}},"allowFeatureVisibility":true,"solutionNavExperiment":{}}
Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/create","search":"","hash":""}},"allowFeatureVisibility":true,"solutionNavExperiment":{},"eventTracker":{"analytics":{}}}
</div>
</div>
`);
@ -160,7 +165,7 @@ describe('spacesManagementApp', () => {
css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)."
data-test-subj="kbnRedirectAppLink"
>
Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"spaceId":"some-space","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/some-space","search":"","hash":""}},"allowFeatureVisibility":true,"solutionNavExperiment":{}}
Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"spaceId":"some-space","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/some-space","search":"","hash":""}},"allowFeatureVisibility":true,"solutionNavExperiment":{},"eventTracker":{"analytics":{}}}
</div>
</div>
`);

View file

@ -19,6 +19,7 @@ import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { Route, Router, Routes } from '@kbn/shared-ux-router';
import type { Space } from '../../common';
import type { EventTracker } from '../analytics';
import type { ConfigType } from '../config';
import type { PluginsStart } from '../plugin';
import type { SpacesManager } from '../spaces_manager';
@ -29,11 +30,18 @@ interface CreateParams {
config: ConfigType;
getRolesAPIClient: () => Promise<RolesAPIClient>;
solutionNavExperiment: Promise<boolean>;
eventTracker: EventTracker;
}
export const spacesManagementApp = Object.freeze({
id: 'spaces',
create({ getStartServices, spacesManager, config, solutionNavExperiment }: CreateParams) {
create({
getStartServices,
spacesManager,
config,
solutionNavExperiment,
eventTracker,
}: CreateParams) {
const title = i18n.translate('xpack.spaces.displayName', {
defaultMessage: 'Spaces',
});
@ -90,6 +98,7 @@ export const spacesManagementApp = Object.freeze({
history={history}
allowFeatureVisibility={config.allowFeatureVisibility}
solutionNavExperiment={solutionNavExperiment}
eventTracker={eventTracker}
/>
);
};
@ -117,6 +126,7 @@ export const spacesManagementApp = Object.freeze({
history={history}
allowFeatureVisibility={config.allowFeatureVisibility}
solutionNavExperiment={solutionNavExperiment}
eventTracker={eventTracker}
/>
);
};

View file

@ -30,6 +30,7 @@ import { FormattedMessage, injectI18n } from '@kbn/i18n-react';
import { ManageSpacesButton } from './manage_spaces_button';
import type { Space } from '../../../common';
import { addSpaceIdToPath, ENTER_SPACE_PATH, SPACE_SEARCH_COUNT_THRESHOLD } from '../../../common';
import type { EventTracker } from '../../analytics';
import { getSpaceAvatarComponent } from '../../space_avatar';
import { SpaceSolutionBadge } from '../../space_solution_badge';
@ -48,6 +49,7 @@ interface Props {
navigateToUrl: ApplicationStart['navigateToUrl'];
readonly activeSpace: Space | null;
isSolutionNavEnabled: boolean;
eventTracker: EventTracker;
}
class SpacesMenuUI extends Component<Props> {
public render() {
@ -95,7 +97,7 @@ class SpacesMenuUI extends Component<Props> {
id={this.props.id}
className={'spcMenu'}
title={i18n.translate('xpack.spaces.navControl.spacesMenu.changeCurrentSpaceTitle', {
defaultMessage: 'Change current space',
defaultMessage: 'Change current space xx',
})}
{...searchableProps}
noMatchesMessage={noSpacesMessage}
@ -148,16 +150,36 @@ class SpacesMenuUI extends Component<Props> {
});
};
private spaceSelectionChange = (
private getSpaceDetails = (id: string): Space | undefined => {
return this.props.spaces.find((space) => space.id === id);
};
private spaceSelectionChange = async (
newOptions: EuiSelectableOption[],
event: EuiSelectableOnChangeEvent
) => {
const selectedSpaceItem = newOptions.filter((item) => item.checked === 'on')[0];
const trackSpaceChange = (nextId?: string) => {
if (!nextId) return;
const nextSpace = this.getSpaceDetails(nextId);
const currentSpace = this.props.activeSpace;
if (!nextSpace || !currentSpace) return;
this.props.eventTracker.changeSpace({
nextSpaceId: nextSpace.id,
nextSolution: nextSpace.solution,
prevSpaceId: currentSpace.id,
prevSolution: currentSpace.solution,
});
};
if (!!selectedSpaceItem) {
const spaceId = selectedSpaceItem.key; // the key is the unique space id
const urlToSelectedSpace = addSpaceIdToPath(
this.props.serverBasePath,
selectedSpaceItem.key, // the key is the unique space id
spaceId,
ENTER_SPACE_PATH
);
@ -169,15 +191,23 @@ class SpacesMenuUI extends Component<Props> {
if (event.shiftKey) {
// Open in new window, shift is given priority over other modifiers
this.props.toggleSpaceSelector();
trackSpaceChange(spaceId);
window.open(urlToSelectedSpace);
} else if (event.ctrlKey || event.metaKey || middleClick) {
// Open in new tab - either a ctrl click or middle mouse button
trackSpaceChange(spaceId);
window.open(urlToSelectedSpace, '_blank');
} else {
// Force full page reload (usually not a good idea, but we need to in order to change spaces)
// If the selected space is already the active space, gracefully close the popover
if (this.props.activeSpace?.id === selectedSpaceItem.key) this.props.toggleSpaceSelector();
else this.props.navigateToUrl(urlToSelectedSpace);
if (this.props.activeSpace?.id === selectedSpaceItem.key) {
this.props.toggleSpaceSelector();
} else {
trackSpaceChange(spaceId);
const flushAnalyticsEvents = window.__kbnAnalytics?.flush ?? (() => Promise.resolve());
await flushAnalyticsEvents();
this.props.navigateToUrl(urlToSelectedSpace);
}
}
}
};

View file

@ -12,12 +12,14 @@ import ReactDOM from 'react-dom';
import type { CoreStart } from '@kbn/core/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import type { EventTracker } from '../analytics';
import type { SpacesManager } from '../spaces_manager';
export function initSpacesNavControl(
spacesManager: SpacesManager,
core: CoreStart,
solutionNavExperiment: Promise<boolean>
solutionNavExperiment: Promise<boolean>,
eventTracker: EventTracker
) {
core.chrome.navControls.registerLeft({
order: 1000,
@ -43,6 +45,7 @@ export function initSpacesNavControl(
navigateToApp={core.application.navigateToApp}
navigateToUrl={core.application.navigateToUrl}
solutionNavExperiment={solutionNavExperiment}
eventTracker={eventTracker}
/>
</Suspense>
</KibanaRenderContextProvider>,

View file

@ -16,10 +16,11 @@ import { act, render, waitFor } from '@testing-library/react';
import React from 'react';
import * as Rx from 'rxjs';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers';
import { NavControlPopover } from './nav_control_popover';
import type { Space } from '../../common';
import { EventTracker } from '../analytics';
import { SpaceAvatarInternal } from '../space_avatar/space_avatar_internal';
import { SpaceSolutionBadge } from '../space_solution_badge';
import type { SpacesManager } from '../spaces_manager';
@ -44,11 +45,19 @@ const mockSpaces = [
},
];
const reportEvent = jest.fn();
const eventTracker = new EventTracker({ reportEvent });
describe('NavControlPopover', () => {
async function setup(spaces: Space[], isSolutionNavEnabled = false) {
async function setup(spaces: Space[], isSolutionNavEnabled = false, activeSpace?: Space) {
const spacesManager = spacesManagerMock.create();
spacesManager.getSpaces = jest.fn().mockResolvedValue(spaces);
if (activeSpace) {
// @ts-ignore readonly check
spacesManager.onActiveSpaceChange$ = Rx.of(activeSpace);
}
const wrapper = mountWithIntl(
<NavControlPopover
spacesManager={spacesManager as unknown as SpacesManager}
@ -58,6 +67,7 @@ describe('NavControlPopover', () => {
navigateToApp={jest.fn()}
navigateToUrl={jest.fn()}
solutionNavExperiment={Promise.resolve(isSolutionNavEnabled)}
eventTracker={eventTracker}
/>
);
@ -80,6 +90,7 @@ describe('NavControlPopover', () => {
navigateToApp={jest.fn()}
navigateToUrl={jest.fn()}
solutionNavExperiment={Promise.resolve(false)}
eventTracker={eventTracker}
/>
);
expect(baseElement).toMatchSnapshot();
@ -105,6 +116,7 @@ describe('NavControlPopover', () => {
navigateToApp={jest.fn()}
navigateToUrl={jest.fn()}
solutionNavExperiment={Promise.resolve(false)}
eventTracker={eventTracker}
/>
);
@ -253,4 +265,40 @@ describe('NavControlPopover', () => {
expect(wrapper.find(SpaceSolutionBadge)).toHaveLength(2);
});
it('should report event when switching space', async () => {
const spaces: Space[] = [
{
id: 'space-1',
name: 'Space-1',
disabledFeatures: [],
solution: 'classic',
},
{
id: 'space-2',
name: 'Space 2',
disabledFeatures: [],
solution: 'security',
},
];
const activeSpace = spaces[0];
const wrapper = await setup(spaces, true /** isSolutionEnabled **/, activeSpace);
await act(async () => {
wrapper.find(EuiHeaderSectionItemButton).find('button').simulate('click');
});
wrapper.update();
expect(reportEvent).not.toHaveBeenCalled();
findTestSubject(wrapper, 'space-2-selectableSpaceItem').simulate('click');
expect(reportEvent).toHaveBeenCalledWith('space_changed', {
solution: 'security',
solution_prev: 'classic',
space_id: 'space-2',
space_id_prev: 'space-1',
});
});
});

View file

@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n';
import { SpacesDescription } from './components/spaces_description';
import { SpacesMenu } from './components/spaces_menu';
import type { Space } from '../../common';
import type { EventTracker } from '../analytics';
import { getSpaceAvatarComponent } from '../space_avatar';
import type { SpacesManager } from '../spaces_manager';
@ -38,6 +39,7 @@ interface Props {
serverBasePath: string;
theme: WithEuiThemeProps['theme'];
solutionNavExperiment: Promise<boolean>;
eventTracker: EventTracker;
}
interface State {
@ -109,6 +111,7 @@ class NavControlPopoverUI extends Component<Props, State> {
navigateToUrl={this.props.navigateToUrl}
activeSpace={this.state.activeSpace}
isSolutionNavEnabled={this.state.isSolutionNavEnabled}
eventTracker={this.props.eventTracker}
/>
);
}

View file

@ -13,6 +13,7 @@ import type { HomePublicPluginSetup } from '@kbn/home-plugin/public';
import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public';
import type { SecurityPluginStart } from '@kbn/security-plugin-types-public';
import { EventTracker, registerAnalyticsContext, registerSpacesEventTypes } from './analytics';
import type { ConfigType } from './config';
import { createSpacesFeatureCatalogueEntry } from './create_feature_catalogue_entry';
import { isSolutionNavEnabled } from './experiments';
@ -49,6 +50,7 @@ export type SpacesPluginStart = ReturnType<SpacesPlugin['start']>;
export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart> {
private spacesManager!: SpacesManager;
private spacesApi!: SpacesApi;
private eventTracker!: EventTracker;
private managementService?: ManagementService;
private readonly config: ConfigType;
@ -73,6 +75,9 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
hasOnlyDefaultSpace,
};
registerSpacesEventTypes(core);
this.eventTracker = new EventTracker(core.analytics);
this.solutionNavExperiment = core
.getStartServices()
.then(([, { cloud, cloudExperiments }]) => isSolutionNavEnabled(cloud, cloudExperiments))
@ -109,6 +114,7 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
config: this.config,
getRolesAPIClient,
solutionNavExperiment: this.solutionNavExperiment,
eventTracker: this.eventTracker,
});
}
@ -119,13 +125,15 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
});
}
registerAnalyticsContext(core.analytics, this.spacesManager.onActiveSpaceChange$);
return { hasOnlyDefaultSpace };
}
public start(core: CoreStart) {
// Only skip spaces navigation if serverless and only one space is allowed
if (!(this.isServerless && this.config.maxSpaces === 1)) {
initSpacesNavControl(this.spacesManager, core, this.solutionNavExperiment);
initSpacesNavControl(this.spacesManager, core, this.solutionNavExperiment, this.eventTracker);
}
return this.spacesApi;

View file

@ -13,6 +13,7 @@ import type {
UsageCollectionSetup,
} from '@kbn/usage-collection-plugin/server';
import type { SolutionView } from '../../common';
import type { PluginsSetup } from '../plugin';
import type { UsageStats, UsageStatsServiceSetup } from '../usage_stats';
@ -46,7 +47,13 @@ async function getSpacesUsage(
}
const knownFeatureIds = features.getKibanaFeatures().map((feature) => feature.id);
const knownSolutions = ['classic', 'es', 'oblt', 'security', 'unset'];
const knownSolutions: Array<SolutionView | 'unset'> = [
'classic',
'es',
'oblt',
'security',
'unset',
];
const resp = (await esClient.search({
index: kibanaIndex,

View file

@ -38,6 +38,7 @@
"@kbn/security-plugin-types-public",
"@kbn/cloud-plugin",
"@kbn/cloud-experiments-plugin",
"@kbn/core-analytics-browser"
],
"exclude": [
"target/**/*",