mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Stateful sidenav] Add telemetry (#189275)
This commit is contained in:
parent
a18d60c8c1
commit
93378d9e37
33 changed files with 660 additions and 41 deletions
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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') }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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[][]>;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
"@kbn/i18n",
|
||||
"@kbn/shared-ux-storybook-mock",
|
||||
"@kbn/core-http-browser",
|
||||
"@kbn/core-analytics-browser",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
|
|
9
src/plugins/navigation/public/analytics/index.ts
Normal file
9
src/plugins/navigation/public/analytics/index.ts
Normal 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';
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -18,5 +18,6 @@ export type {
|
|||
GetAllSpacesOptions,
|
||||
GetAllSpacesPurpose,
|
||||
GetSpaceResult,
|
||||
SolutionView,
|
||||
} from './types/latest';
|
||||
export { spaceV1 } from './types';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
80
x-pack/plugins/spaces/public/analytics/event_tracker.ts
Normal file
80
x-pack/plugins/spaces/public/analytics/event_tracker.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
12
x-pack/plugins/spaces/public/analytics/index.ts
Normal file
12
x-pack/plugins/spaces/public/analytics/index.ts
Normal 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';
|
|
@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -5,5 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
// @ts-ignore
|
||||
export { ManageSpacePage } from './manage_space_page';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
`);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
"@kbn/security-plugin-types-public",
|
||||
"@kbn/cloud-plugin",
|
||||
"@kbn/cloud-experiments-plugin",
|
||||
"@kbn/core-analytics-browser"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue