[Behavioral Analytics] Change events state check (#154744)

## https://github.com/elastic/enterprise-search-team/issues/4270
### Description
In order to improve user experience, it's required to introduce a
Callout when a datastream doesn't have any events.

This PR is dedicated to:
1. Introducing the `EuiCallout` element that redirects to the
Integration page, when clicking on "Learn how" button
2. Changing the way how we check if datastream has any events

### Screenshots
<img width="1341" alt="Screenshot 2023-04-11 at 18 19 23"
src="https://user-images.githubusercontent.com/5709507/231226098-947cc214-e738-496e-84bc-d18168967c9f.png">
This commit is contained in:
Klim Markelov 2023-04-17 15:28:13 +02:00 committed by GitHub
parent 035d28e757
commit f3e5759a73
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 270 additions and 119 deletions

View file

@ -10,8 +10,8 @@ export interface AnalyticsCollection {
name: string;
}
export interface AnalyticsEventsIndexExists {
exists: boolean;
export interface AnalyticsEventsExist {
exist: boolean;
}
export interface AnalyticsCollectionDataViewId {

View file

@ -9,9 +9,9 @@ import { mockHttpValues } from '../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { checkAnalyticsEventsIndexExists } from './check_analytics_events_index_api_logic';
import { checkAnalyticsEventsExist } from './check_analytics_events_exist_api_logic';
describe('FetchAnalyticsCollectionApiLogic', () => {
describe('AnalyticsEventsExistApiLogic', () => {
const { http } = mockHttpValues;
beforeEach(() => {
jest.clearAllMocks();
@ -22,10 +22,10 @@ describe('FetchAnalyticsCollectionApiLogic', () => {
const promise = Promise.resolve({ exists: true });
const indexName = 'eventsIndex';
http.get.mockReturnValue(promise);
const result = checkAnalyticsEventsIndexExists({ indexName });
const result = checkAnalyticsEventsExist({ indexName });
await nextTick();
expect(http.get).toHaveBeenCalledWith(
`/internal/enterprise_search/analytics/events/${indexName}/exists`
`/internal/enterprise_search/analytics/collection/${indexName}/events/exist`
);
await expect(result).resolves.toEqual({ exists: true });
});

View file

@ -0,0 +1,31 @@
/*
* 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 { AnalyticsEventsExist } from '../../../../../common/types/analytics';
import { createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export interface AnalyticsEventsExistApiLogicArgs {
indexName: string;
}
export type AnalyticsEventsExistApiLogicResponse = AnalyticsEventsExist;
export const checkAnalyticsEventsExist = async ({
indexName,
}: AnalyticsEventsExistApiLogicArgs): Promise<AnalyticsEventsExistApiLogicResponse> => {
const { http } = HttpLogic.values;
const route = `/internal/enterprise_search/analytics/collection/${indexName}/events/exist`;
const response = await http.get<AnalyticsEventsExist>(route);
return response;
};
export const AnalyticsEventsExistAPILogic = createApiLogic(
['analytics', 'analytics_events_exist_api_logic'],
checkAnalyticsEventsExist
);

View file

@ -1,31 +0,0 @@
/*
* 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 { AnalyticsEventsIndexExists } from '../../../../../common/types/analytics';
import { createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export interface AnalyticsEventsIndexExistsApiLogicArgs {
indexName: string;
}
export type AnalyticsEventsIndexExistsApiLogicResponse = AnalyticsEventsIndexExists;
export const checkAnalyticsEventsIndexExists = async ({
indexName,
}: AnalyticsEventsIndexExistsApiLogicArgs): Promise<AnalyticsEventsIndexExistsApiLogicResponse> => {
const { http } = HttpLogic.values;
const route = `/internal/enterprise_search/analytics/events/${indexName}/exists`;
const response = await http.get<AnalyticsEventsIndexExists>(route);
return response;
};
export const AnalyticsEventsIndexExistsAPILogic = createApiLogic(
['analytics', 'analytics_events_index_exists_api_logic'],
checkAnalyticsEventsIndexExists
);

View file

@ -0,0 +1,61 @@
/*
* 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 '../../../../__mocks__/shallow_useeffect.mock';
import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsCollection } from '../../../../../../common/types/analytics';
import { AnalyticsCollectionNoEventsCallout } from './analytics_collection_no_events_callout';
const mockValues = {
analyticsCollection: {
events_datastream: 'analytics-events-example',
name: 'Analytics-Collection-1',
} as AnalyticsCollection,
hasEvents: true,
};
const mockActions = {
fetchAnalyticsCollection: jest.fn(),
fetchAnalyticsCollectionDataViewId: jest.fn(),
analyticsEventsExist: jest.fn(),
setTimeRange: jest.fn(),
};
describe('AnalyticsCollectionNoEventsCallout', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders no events Callout when the collection has no events', () => {
setMockValues({ ...mockValues, hasEvents: false });
setMockActions(mockActions);
const wrapper = shallow(
<AnalyticsCollectionNoEventsCallout analyticsCollection={mockValues.analyticsCollection} />
);
expect(wrapper.find('EuiCallOut')).toHaveLength(1);
});
it('does not render events Callout when the collection has events', () => {
setMockValues(mockValues);
setMockActions(mockActions);
const wrapper = shallow(
<AnalyticsCollectionNoEventsCallout analyticsCollection={mockValues.analyticsCollection} />
);
expect(wrapper.find('EuiCallOut')).toHaveLength(0);
});
});

View file

@ -0,0 +1,78 @@
/*
* 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 React, { useEffect } from 'react';
import { useValues, useActions } from 'kea';
import { EuiButton, EuiCallOut, EuiSpacer, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AnalyticsCollection } from '../../../../../../common/types/analytics';
import { generateEncodedPath } from '../../../../shared/encode_path_params';
import { KibanaLogic } from '../../../../shared/kibana';
import { COLLECTION_INTEGRATE_PATH } from '../../../routes';
import { AnalyticsCollectionNoEventsCalloutLogic } from './analytics_collection_no_events_callout_logic';
interface AnalyticsCollectionNoEventsCalloutProps {
analyticsCollection: AnalyticsCollection;
}
export const AnalyticsCollectionNoEventsCallout: React.FC<
AnalyticsCollectionNoEventsCalloutProps
> = ({ analyticsCollection }) => {
const { navigateToUrl } = useValues(KibanaLogic);
const { analyticsEventsExist } = useActions(AnalyticsCollectionNoEventsCalloutLogic);
const { hasEvents } = useValues(AnalyticsCollectionNoEventsCalloutLogic);
useEffect(() => {
analyticsEventsExist(analyticsCollection.name);
}, []);
return hasEvents ? null : (
<EuiCallOut
color="primary"
iconType="download"
title={i18n.translate(
'xpack.enterpriseSearch.analytics.collectionsView.noEventsCallout.title',
{
defaultMessage: 'Install our tracker',
}
)}
>
<EuiText>
{i18n.translate(
'xpack.enterpriseSearch.analytics.collectionsView.noEventsCallout.description',
{
defaultMessage:
'Start receiving metric data in this Collection by installing our tracker in your search application.',
}
)}
</EuiText>
<EuiSpacer />
<EuiButton
fill
type="submit"
onClick={() =>
navigateToUrl(
generateEncodedPath(COLLECTION_INTEGRATE_PATH, {
name: analyticsCollection.name,
})
)
}
>
{i18n.translate('xpack.enterpriseSearch.analytics.collectionsView.noEventsCallout.button', {
defaultMessage: 'Learn how',
})}
</EuiButton>
</EuiCallOut>
);
};

View file

@ -5,14 +5,14 @@
* 2.0.
*/
import { LogicMounter } from '../../../__mocks__/kea_logic';
import { LogicMounter } from '../../../../__mocks__/kea_logic';
import { Status } from '../../../../../common/types/api';
import { Status } from '../../../../../../common/types/api';
import { AnalyticsEventsIndexExistsLogic } from './analytics_events_index_exists_logic';
import { AnalyticsCollectionNoEventsCalloutLogic } from './analytics_collection_no_events_callout_logic';
describe('analyticsEventsIndexExistsLogic', () => {
const { mount } = new LogicMounter(AnalyticsEventsIndexExistsLogic);
describe('analyticsEventsExistLogic', () => {
const { mount } = new LogicMounter(AnalyticsCollectionNoEventsCalloutLogic);
const indexName = true;
beforeEach(() => {
@ -23,25 +23,23 @@ describe('analyticsEventsIndexExistsLogic', () => {
const DEFAULT_VALUES = {
data: undefined,
isLoading: true,
isPresent: false,
hasEvents: false,
status: Status.IDLE,
};
it('has expected default values', () => {
expect(AnalyticsEventsIndexExistsLogic.values).toEqual(DEFAULT_VALUES);
expect(AnalyticsCollectionNoEventsCalloutLogic.values).toEqual(DEFAULT_VALUES);
});
describe('selectors', () => {
it('updates when apiSuccess listener triggered', () => {
AnalyticsEventsIndexExistsLogic.actions.apiSuccess({ exists: indexName });
AnalyticsCollectionNoEventsCalloutLogic.actions.apiSuccess({ exist: indexName });
expect(AnalyticsEventsIndexExistsLogic.values).toEqual({
expect(AnalyticsCollectionNoEventsCalloutLogic.values).toEqual({
...DEFAULT_VALUES,
isLoading: false,
isPresent: true,
hasEvents: true,
status: Status.SUCCESS,
data: { exists: indexName },
data: { exist: indexName },
});
});
});

View file

@ -0,0 +1,47 @@
/*
* 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 { kea, MakeLogicType } from 'kea';
import { Status } from '../../../../../../common/types/api';
import { Actions } from '../../../../shared/api_logic/create_api_logic';
import {
AnalyticsEventsExistAPILogic,
AnalyticsEventsExistApiLogicResponse,
} from '../../../api/check_analytics_events/check_analytics_events_exist_api_logic';
export interface AnalyticsCollectionNoEventsCalloutActions {
apiSuccess: Actions<{}, AnalyticsEventsExistApiLogicResponse>['apiSuccess'];
analyticsEventsExist(indexName: string): { indexName: string };
makeRequest: Actions<{}, AnalyticsEventsExistApiLogicResponse>['makeRequest'];
}
export interface AnalyticsCollectionNoEventsCalloutValues {
hasEvents: boolean;
status: Status;
data: typeof AnalyticsEventsExistAPILogic.values.data;
}
export const AnalyticsCollectionNoEventsCalloutLogic = kea<
MakeLogicType<AnalyticsCollectionNoEventsCalloutValues, AnalyticsCollectionNoEventsCalloutActions>
>({
actions: {
analyticsEventsExist: (indexName) => ({ indexName }),
},
connect: {
actions: [AnalyticsEventsExistAPILogic, ['makeRequest', 'apiSuccess', 'apiError']],
values: [AnalyticsEventsExistAPILogic, ['status', 'data']],
},
listeners: ({ actions }) => ({
analyticsEventsExist: ({ indexName }) => {
actions.makeRequest({ indexName });
},
}),
path: ['enterprise_search', 'analytics', 'events_exist'],
selectors: ({ selectors }) => ({
hasEvents: [() => [selectors.data], (data) => data?.exist === true],
}),
});

View file

@ -18,6 +18,8 @@ import { FilterBy } from '../../../utils/get_formula_by_filter';
import { EnterpriseSearchAnalyticsPageTemplate } from '../../layout/page_template';
import { AnalyticsCollectionNoEventsCallout } from '../analytics_collection_no_events_callout/analytics_collection_no_events_callout';
import { AnalyticsCollectionChartWithLens } from './analytics_collection_chart';
import { AnalyticsCollectionViewMetricWithLens } from './analytics_collection_metric';
@ -38,6 +40,7 @@ const mockValues = {
const mockActions = {
fetchAnalyticsCollection: jest.fn(),
fetchAnalyticsCollectionDataViewId: jest.fn(),
analyticsEventsExist: jest.fn(),
setTimeRange: jest.fn(),
};
@ -47,6 +50,9 @@ describe('AnalyticsOverView', () => {
});
it('renders with Data', async () => {
setMockValues(mockValues);
setMockActions(mockActions);
const wrapper = shallow(
<AnalyticsCollectionOverview analyticsCollection={mockValues.analyticsCollection} />
);
@ -121,4 +127,15 @@ describe('AnalyticsOverView', () => {
FilterBy.NoResults
);
});
it('renders no events AnalyticsCollectionNoEventsCallout with collection', () => {
const wrapper = shallow(
<AnalyticsCollectionOverview analyticsCollection={mockValues.analyticsCollection} />
);
expect(wrapper.find(AnalyticsCollectionNoEventsCallout)).toHaveLength(1);
expect(wrapper?.find(AnalyticsCollectionNoEventsCallout).props()).toEqual({
analyticsCollection: mockValues.analyticsCollection,
});
});
});

View file

@ -9,7 +9,7 @@ import React, { useState } from 'react';
import { useActions, useValues } from 'kea';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiFlexGroup, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -20,6 +20,7 @@ import { EnterpriseSearchAnalyticsPageTemplate } from '../../layout/page_templat
import { AnalyticsCollectionExploreTable } from '../analytics_collection_explore_table/analytics_collection_explore_table';
import { AnalyticsCollectionNoEventsCallout } from '../analytics_collection_no_events_callout/analytics_collection_no_events_callout';
import { AnalyticsCollectionToolbar } from '../analytics_collection_toolbar/analytics_collection_toolbar';
import { AnalyticsCollectionToolbarLogic } from '../analytics_collection_toolbar/analytics_collection_toolbar_logic';
@ -90,6 +91,9 @@ export const AnalyticsCollectionOverview: React.FC<AnalyticsCollectionOverviewPr
rightSideItems: [<AnalyticsCollectionToolbar />],
}}
>
<AnalyticsCollectionNoEventsCallout analyticsCollection={analyticsCollection} />
<EuiSpacer />
<EuiFlexGroup direction="column">
<EuiFlexGroup gutterSize="m">
{filters.map(({ name, id }) => (

View file

@ -1,52 +0,0 @@
/*
* 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 { kea, MakeLogicType } from 'kea';
import { Status } from '../../../../../common/types/api';
import { Actions } from '../../../shared/api_logic/create_api_logic';
import {
AnalyticsEventsIndexExistsAPILogic,
AnalyticsEventsIndexExistsApiLogicResponse,
} from '../../api/check_analytics_events_index/check_analytics_events_index_api_logic';
export interface AnalyticsEventsIndexExistsActions {
apiSuccess: Actions<{}, AnalyticsEventsIndexExistsApiLogicResponse>['apiSuccess'];
analyticsEventsIndexExists(indexName: string): { indexName: string };
makeRequest: Actions<{}, AnalyticsEventsIndexExistsApiLogicResponse>['makeRequest'];
}
export interface AnalyticsEventsIndexExistsValues {
isLoading: boolean;
isPresent: boolean;
status: Status;
data: typeof AnalyticsEventsIndexExistsAPILogic.values.data;
}
export const AnalyticsEventsIndexExistsLogic = kea<
MakeLogicType<AnalyticsEventsIndexExistsValues, AnalyticsEventsIndexExistsActions>
>({
actions: {
analyticsEventsIndexExists: (indexName) => ({ indexName }),
},
connect: {
actions: [AnalyticsEventsIndexExistsAPILogic, ['makeRequest', 'apiSuccess', 'apiError']],
values: [AnalyticsEventsIndexExistsAPILogic, ['status', 'data']],
},
listeners: ({ actions }) => ({
analyticsEventsIndexExists: ({ indexName }) => {
actions.makeRequest({ indexName });
},
}),
path: ['enterprise_search', 'analytics', 'events_index'],
selectors: ({ selectors }) => ({
isLoading: [
() => [selectors.status],
(status) => [Status.LOADING, Status.IDLE].includes(status),
],
isPresent: [() => [selectors.data], (data) => data?.exists === true],
}),
});

View file

@ -7,14 +7,12 @@
import { IScopedClusterClient } from '@kbn/core-elasticsearch-server';
import { analyticsEventsIndexExists } from './analytics_events_index_exists';
import { analyticsEventsExist } from './analytics_events_exist';
describe('analytics collection events exists function', () => {
const mockClient = {
asCurrentUser: {
indices: {
getDataStream: jest.fn(),
},
count: jest.fn(),
},
};
@ -24,14 +22,14 @@ describe('analytics collection events exists function', () => {
describe('checking if analytics events index exists', () => {
it('should call exists endpoint', async () => {
mockClient.asCurrentUser.indices.getDataStream.mockImplementationOnce(() => ({
data_streams: [{ name: 'example' }],
mockClient.asCurrentUser.count.mockImplementationOnce(() => ({
count: 1,
}));
await expect(
analyticsEventsIndexExists(mockClient as unknown as IScopedClusterClient, 'example')
analyticsEventsExist(mockClient as unknown as IScopedClusterClient, 'example')
).resolves.toEqual(true);
expect(mockClient.asCurrentUser.indices.getDataStream).toHaveBeenCalledWith({
name: 'example',
expect(mockClient.asCurrentUser.count).toHaveBeenCalledWith({
index: 'example',
});
});
});

View file

@ -9,15 +9,15 @@ import { IScopedClusterClient } from '@kbn/core-elasticsearch-server';
import { isIndexNotFoundException } from '../../utils/identify_exceptions';
export const analyticsEventsIndexExists = async (
export const analyticsEventsExist = async (
client: IScopedClusterClient,
datastreamName: string
): Promise<boolean> => {
try {
const response = await client.asCurrentUser.indices.getDataStream({
name: datastreamName,
const response = await client.asCurrentUser.count({
index: datastreamName,
});
return response.data_streams.length > 0;
return response.count > 0;
} catch (error) {
if (isIndexNotFoundException(error)) {
return false;

View file

@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n';
import { ErrorCode } from '../../../common/types/error_codes';
import { addAnalyticsCollection } from '../../lib/analytics/add_analytics_collection';
import { analyticsEventsIndexExists } from '../../lib/analytics/analytics_events_index_exists';
import { analyticsEventsExist } from '../../lib/analytics/analytics_events_exist';
import { createApiKey } from '../../lib/analytics/create_api_key';
import { deleteAnalyticsCollectionById } from '../../lib/analytics/delete_analytics_collection';
import { fetchAnalyticsCollections } from '../../lib/analytics/fetch_analytics_collection';
@ -181,7 +181,7 @@ export function registerAnalyticsRoutes({
router.get(
{
path: '/internal/enterprise_search/analytics/events/{name}/exists',
path: '/internal/enterprise_search/analytics/collection/{name}/events/exist',
validate: {
params: schema.object({
name: schema.string(),
@ -191,7 +191,7 @@ export function registerAnalyticsRoutes({
elasticsearchErrorHandler(log, async (context, request, response) => {
const { client } = (await context.core).elasticsearch;
const eventsIndexExists = await analyticsEventsIndexExists(client, request.params.name);
const eventsIndexExists = await analyticsEventsExist(client, request.params.name);
if (!eventsIndexExists) {
return response.ok({ body: { exists: false } });