[Enterprise Search] [Behavioral analytics] Add Explorer page (#155077)

Implement Explorer page that consists of:

- ✔️ Tabs [Search terms, Top clicked results, No results, Referrers]
- ✔️ Search bar for searching the data
- ✔️ Data representation for top results as a table with paginations,
sorting, page size
- ✔️ Callout "Need a deeper analysis"
<img width="1168" alt="image"
src="https://user-images.githubusercontent.com/17390745/232572642-ab6374bc-a798-4d6a-93db-36acf141f931.png">

---------

Co-authored-by: Klim Markelov <klim.markelov@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yan Savitski 2023-04-19 19:04:25 +02:00 committed by GitHub
parent c3c55e7aa8
commit 0225747610
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1667 additions and 662 deletions

View file

@ -0,0 +1,75 @@
/*
* 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 { LogicMounter } from '../../../__mocks__/kea_logic';
import { DataView } from '@kbn/data-views-plugin/common';
import { AnalyticsCollection } from '../../../../../common/types/analytics';
import { findOrCreateDataView } from '../../utils/find_or_create_data_view';
import { AnalyticsCollectionDataViewLogic } from './analytics_collection_data_view_logic';
import { FetchAnalyticsCollectionLogic } from './fetch_analytics_collection_logic';
jest.mock('../../utils/find_or_create_data_view', () => {
return {
findOrCreateDataView: jest.fn(),
};
});
describe('AnalyticsCollectionDataViewLogic', () => {
const { mount } = new LogicMounter(AnalyticsCollectionDataViewLogic);
beforeEach(() => {
jest.clearAllMocks();
mount();
});
const defaultProps = {
dataView: null,
};
it('initializes with default values', () => {
expect(AnalyticsCollectionDataViewLogic.values).toEqual(defaultProps);
});
describe('reducers', () => {
it('should handle set dataView', () => {
const dataView = { id: 'test' } as DataView;
AnalyticsCollectionDataViewLogic.actions.setDataView(dataView);
expect(AnalyticsCollectionDataViewLogic.values.dataView).toBe(dataView);
});
});
describe('listeners', () => {
it('should find and set dataView when analytics collection fetched', async () => {
const dataView = { id: 'test' } as DataView;
(findOrCreateDataView as jest.Mock).mockResolvedValue(dataView);
await FetchAnalyticsCollectionLogic.actions.apiSuccess({
events_datastream: 'events1',
name: 'collection1',
} as AnalyticsCollection);
expect(AnalyticsCollectionDataViewLogic.values.dataView).toEqual(dataView);
});
it('should create, save and set dataView when analytics collection fetched but dataView is not found', async () => {
const dataView = { id: 'test' } as DataView;
(findOrCreateDataView as jest.Mock).mockResolvedValue(dataView);
await FetchAnalyticsCollectionLogic.actions.apiSuccess({
events_datastream: 'events1',
name: 'collection1',
} as AnalyticsCollection);
expect(AnalyticsCollectionDataViewLogic.values.dataView).toEqual(dataView);
});
});
});

View file

@ -0,0 +1,46 @@
/*
* 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 { DataView } from '@kbn/data-views-plugin/common';
import { findOrCreateDataView } from '../../utils/find_or_create_data_view';
import {
FetchAnalyticsCollectionActions,
FetchAnalyticsCollectionLogic,
} from './fetch_analytics_collection_logic';
interface AnalyticsCollectionDataViewLogicValues {
dataView: DataView | null;
}
interface AnalyticsCollectionDataViewLogicActions {
fetchedAnalyticsCollection: FetchAnalyticsCollectionActions['apiSuccess'];
setDataView(dataView: DataView): { dataView: DataView };
}
export const AnalyticsCollectionDataViewLogic = kea<
MakeLogicType<AnalyticsCollectionDataViewLogicValues, AnalyticsCollectionDataViewLogicActions>
>({
actions: {
setDataView: (dataView) => ({ dataView }),
},
connect: {
actions: [FetchAnalyticsCollectionLogic, ['apiSuccess as fetchedAnalyticsCollection']],
},
listeners: ({ actions }) => ({
fetchedAnalyticsCollection: async (collection) => {
actions.setDataView(await findOrCreateDataView(collection));
},
}),
path: ['enterprise_search', 'analytics', 'collections', 'dataView'],
reducers: () => ({
dataView: [null, { setDataView: (_, { dataView }) => dataView }],
}),
});

View file

@ -1,149 +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 { LogicMounter } from '../../../../__mocks__/kea_logic';
import { DataView } from '@kbn/data-views-plugin/common';
import { AnalyticsCollection } from '../../../../../../common/types/analytics';
import { KibanaLogic } from '../../../../shared/kibana/kibana_logic';
import { AnalyticsCollectionToolbarLogic } from '../analytics_collection_toolbar/analytics_collection_toolbar_logic';
import {
AnalyticsCollectionExploreTableLogic,
Sorting,
} from './analytics_collection_explore_table_logic';
import { ExploreTableColumns, ExploreTables } from './analytics_collection_explore_table_types';
jest.mock('../../../../shared/kibana/kibana_logic', () => ({
KibanaLogic: {
values: {
data: {
dataViews: {
find: jest.fn(() => Promise.resolve([{ id: 'some-data-view-id' }])),
},
search: {
search: jest.fn().mockReturnValue({ subscribe: jest.fn() }),
},
},
},
},
}));
describe('AnalyticsCollectionExplorerTablesLogic', () => {
const { mount } = new LogicMounter(AnalyticsCollectionExploreTableLogic);
beforeEach(() => {
jest.clearAllMocks();
mount();
});
const defaultProps = {
dataView: null,
isLoading: false,
items: [],
selectedTable: null,
sorting: null,
};
it('initializes with default values', () => {
expect(AnalyticsCollectionExploreTableLogic.values).toEqual(defaultProps);
});
describe('reducers', () => {
it('should handle set dataView', () => {
const dataView = { id: 'test' } as DataView;
AnalyticsCollectionExploreTableLogic.actions.setDataView(dataView);
expect(AnalyticsCollectionExploreTableLogic.values.dataView).toBe(dataView);
});
it('should handle set items', () => {
const items = [
{ count: 1, query: 'test' },
{ count: 2, query: 'test2' },
];
AnalyticsCollectionExploreTableLogic.actions.setItems(items);
expect(AnalyticsCollectionExploreTableLogic.values.items).toEqual(items);
});
it('should handle set selectedTable', () => {
const id = ExploreTables.WorsePerformers;
const sorting = { direction: 'desc', field: ExploreTableColumns.count } as Sorting;
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(id, sorting);
expect(AnalyticsCollectionExploreTableLogic.values.selectedTable).toEqual(id);
expect(AnalyticsCollectionExploreTableLogic.values.sorting).toEqual(sorting);
});
it('should handle set sorting', () => {
const sorting = { direction: 'asc', field: ExploreTableColumns.sessions } as Sorting;
AnalyticsCollectionExploreTableLogic.actions.setSorting(sorting);
expect(AnalyticsCollectionExploreTableLogic.values.sorting).toEqual(sorting);
});
it('should handle isLoading', () => {
expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(false);
AnalyticsCollectionExploreTableLogic.actions.setItems([]);
expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(false);
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.WorsePerformers);
expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(true);
AnalyticsCollectionToolbarLogic.actions.setTimeRange({ from: 'now-7d', to: 'now' });
expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(true);
AnalyticsCollectionToolbarLogic.actions.setSearchSessionId('12345');
expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(true);
});
});
describe('listeners', () => {
it('should fetch items when selectedTable changes', () => {
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers);
expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), {
indexPattern: undefined,
sessionId: undefined,
});
});
it('should fetch items when timeRange changes', () => {
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.WorsePerformers);
(KibanaLogic.values.data.search.search as jest.Mock).mockClear();
AnalyticsCollectionToolbarLogic.actions.setTimeRange({ from: 'now-7d', to: 'now' });
expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), {
indexPattern: undefined,
sessionId: undefined,
});
});
it('should fetch items when searchSessionId changes', () => {
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.WorsePerformers);
(KibanaLogic.values.data.search.search as jest.Mock).mockClear();
AnalyticsCollectionToolbarLogic.actions.setSearchSessionId('1234');
expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), {
indexPattern: undefined,
sessionId: '1234',
});
});
it('should find and set dataView when findDataView is called', async () => {
const dataView = { id: 'test' } as DataView;
jest.spyOn(KibanaLogic.values.data.dataViews, 'find').mockResolvedValue([dataView]);
await AnalyticsCollectionExploreTableLogic.actions.findDataView({
events_datastream: 'events1',
name: 'collection1',
} as AnalyticsCollection);
expect(AnalyticsCollectionExploreTableLogic.values.dataView).toEqual(dataView);
});
});
});

View file

@ -1,333 +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 {
IKibanaSearchRequest,
IKibanaSearchResponse,
isCompleteResponse,
TimeRange,
} from '@kbn/data-plugin/common';
import { DataView } from '@kbn/data-views-plugin/common';
import { AnalyticsCollection } from '../../../../../../common/types/analytics';
import { KibanaLogic } from '../../../../shared/kibana/kibana_logic';
import { AnalyticsCollectionToolbarLogic } from '../analytics_collection_toolbar/analytics_collection_toolbar_logic';
import {
ExploreTableColumns,
ExploreTableItem,
ExploreTables,
SearchTermsTable,
TopClickedTable,
TopReferrersTable,
WorsePerformersTable,
} from './analytics_collection_explore_table_types';
const BASE_PAGE_SIZE = 10;
export interface Sorting<T extends ExploreTableItem = ExploreTableItem> {
direction: 'asc' | 'desc';
field: keyof T;
}
interface TableParams<T extends ExploreTableItem = ExploreTableItem> {
parseResponseToItems(response: IKibanaSearchResponse): T[];
requestParams(timeRange: TimeRange, sorting: Sorting<T> | null): IKibanaSearchRequest;
}
const tablesParams: {
[ExploreTables.SearchTerms]: TableParams<SearchTermsTable>;
[ExploreTables.TopClicked]: TableParams<TopClickedTable>;
[ExploreTables.TopReferrers]: TableParams<TopReferrersTable>;
[ExploreTables.WorsePerformers]: TableParams<WorsePerformersTable>;
} = {
[ExploreTables.SearchTerms]: {
parseResponseToItems: (
response: IKibanaSearchResponse<{
aggregations: { searches: { buckets: Array<{ doc_count: number; key: string }> } };
}>
) =>
response.rawResponse.aggregations.searches.buckets.map((bucket) => ({
[ExploreTableColumns.count]: bucket.doc_count,
[ExploreTableColumns.searchTerms]: bucket.key,
})),
requestParams: (timeRange, sorting) => ({
params: {
aggs: {
searches: {
terms: {
field: 'search.query',
order: sorting
? {
[sorting.field === ExploreTableColumns.count ? '_count' : '_key']:
sorting.direction,
}
: undefined,
size: BASE_PAGE_SIZE,
},
},
},
query: {
range: {
'@timestamp': {
gte: timeRange.from,
lt: timeRange.to,
},
},
},
size: 0,
track_total_hits: false,
},
}),
},
[ExploreTables.WorsePerformers]: {
parseResponseToItems: (
response: IKibanaSearchResponse<{
aggregations: {
formula: { searches: { buckets: Array<{ doc_count: number; key: string }> } };
};
}>
) =>
response.rawResponse.aggregations.formula.searches.buckets.map((bucket) => ({
[ExploreTableColumns.count]: bucket.doc_count,
[ExploreTableColumns.query]: bucket.key,
})),
requestParams: (timeRange, sorting) => ({
params: {
aggs: {
formula: {
aggs: {
searches: {
terms: {
field: 'search.query',
order: sorting
? {
[sorting?.field === ExploreTableColumns.count ? '_count' : '_key']:
sorting?.direction,
}
: undefined,
size: BASE_PAGE_SIZE,
},
},
},
filter: { term: { 'search.results.total_results': '0' } },
},
},
query: {
range: {
'@timestamp': {
gte: timeRange.from,
lt: timeRange.to,
},
},
},
size: 0,
track_total_hits: false,
},
}),
},
[ExploreTables.TopClicked]: {
parseResponseToItems: (
response: IKibanaSearchResponse<{
aggregations: {
formula: { searches: { buckets: Array<{ doc_count: number; key: string }> } };
};
}>
) =>
response.rawResponse.aggregations.formula.searches.buckets.map((bucket) => ({
[ExploreTableColumns.count]: bucket.doc_count,
[ExploreTableColumns.page]: bucket.key,
})),
requestParams: (timeRange, sorting) => ({
params: {
aggs: {
formula: {
aggs: {
searches: {
terms: {
field: 'search.results.items.page.url',
order: sorting
? {
[sorting.field === ExploreTableColumns.count ? '_count' : '_key']:
sorting.direction,
}
: undefined,
size: BASE_PAGE_SIZE,
},
},
},
filter: { term: { 'event.action': 'search_click' } },
},
},
query: {
range: {
'@timestamp': {
gte: timeRange.from,
lt: timeRange.to,
},
},
},
size: 0,
track_total_hits: false,
},
}),
},
[ExploreTables.TopReferrers]: {
parseResponseToItems: (
response: IKibanaSearchResponse<{
aggregations: {
formula: { searches: { buckets: Array<{ doc_count: number; key: string }> } };
};
}>
) =>
response.rawResponse.aggregations.formula.searches.buckets.map((bucket) => ({
[ExploreTableColumns.sessions]: bucket.doc_count,
[ExploreTableColumns.page]: bucket.key,
})),
requestParams: (timeRange, sorting) => ({
params: {
aggs: {
formula: {
aggs: {
searches: {
terms: {
field: 'page.referrer',
order: sorting
? {
[sorting?.field === ExploreTableColumns.sessions ? '_count' : '_key']:
sorting?.direction,
}
: undefined,
size: BASE_PAGE_SIZE,
},
},
},
filter: { term: { 'event.action': 'page_view' } },
},
},
query: {
range: {
'@timestamp': {
gte: timeRange.from,
lt: timeRange.to,
},
},
},
size: 0,
track_total_hits: false,
},
}),
},
};
export interface AnalyticsCollectionExploreTableLogicValues {
dataView: DataView | null;
isLoading: boolean;
items: ExploreTableItem[];
selectedTable: ExploreTables | null;
sorting: Sorting | null;
}
export interface AnalyticsCollectionExploreTableLogicActions {
findDataView(collection: AnalyticsCollection): { collection: AnalyticsCollection };
setDataView(dataView: DataView): { dataView: DataView };
setItems(items: ExploreTableItem[]): { items: ExploreTableItem[] };
setSelectedTable(
id: ExploreTables | null,
sorting?: Sorting
): { id: ExploreTables | null; sorting?: Sorting };
setSorting(sorting?: Sorting): { sorting?: Sorting };
}
export const AnalyticsCollectionExploreTableLogic = kea<
MakeLogicType<
AnalyticsCollectionExploreTableLogicValues,
AnalyticsCollectionExploreTableLogicActions
>
>({
actions: {
findDataView: (collection) => ({ collection }),
setDataView: (dataView) => ({ dataView }),
setItems: (items) => ({ items }),
setSelectedTable: (id, sorting) => ({ id, sorting }),
setSorting: (sorting) => ({ sorting }),
},
listeners: ({ actions, values }) => {
const fetchItems = () => {
if (values.selectedTable === null || !(values.selectedTable in tablesParams)) {
return;
}
const { requestParams, parseResponseToItems } = tablesParams[
values.selectedTable
] as TableParams;
const timeRange = AnalyticsCollectionToolbarLogic.values.timeRange;
const search$ = KibanaLogic.values.data.search
.search(requestParams(timeRange, values.sorting), {
indexPattern: values.dataView || undefined,
sessionId: AnalyticsCollectionToolbarLogic.values.searchSessionId,
})
.subscribe({
error: (e) => {
KibanaLogic.values.data.search.showError(e);
},
next: (response) => {
if (isCompleteResponse(response)) {
actions.setItems(parseResponseToItems(response));
search$.unsubscribe();
}
},
});
};
return {
findDataView: async ({ collection }) => {
const dataView = (
await KibanaLogic.values.data.dataViews.find(collection.events_datastream, 1)
)?.[0];
if (dataView) {
actions.setDataView(dataView);
}
},
setSelectedTable: () => {
fetchItems();
},
[AnalyticsCollectionToolbarLogic.actionTypes.setTimeRange]: () => {
fetchItems();
},
[AnalyticsCollectionToolbarLogic.actionTypes.setSearchSessionId]: () => {
fetchItems();
},
};
},
path: ['enterprise_search', 'analytics', 'collections', 'explore', 'table'],
reducers: () => ({
dataView: [null, { setDataView: (_, { dataView }) => dataView }],
isLoading: [
false,
{
setItems: () => false,
setSelectedTable: () => true,
[AnalyticsCollectionToolbarLogic.actionTypes.setTimeRange]: () => true,
[AnalyticsCollectionToolbarLogic.actionTypes.setSearchSessionId]: () => true,
},
],
items: [[], { setItems: (_, { items }) => items }],
selectedTable: [null, { setSelectedTable: (_, { id }) => id }],
sorting: [
null,
{
setSelectedTable: (_, { sorting = null }) => sorting,
setSorting: (_, { sorting = null }) => sorting,
},
],
}),
});

View file

@ -0,0 +1,71 @@
/*
* 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 { IKibanaSearchRequest, TimeRange } from '@kbn/data-plugin/common';
const getSearchQueryRequestParams = (field: string, search: string): { regexp: {} } => {
const createRegexQuery = (queryString: string) => {
const query = queryString.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
return `.*${query}.*`;
};
return {
regexp: {
[field]: {
value: createRegexQuery(search),
},
},
};
};
export const getTotalCountRequestParams = (field: string) => ({
totalCount: {
cardinality: {
field,
},
},
});
export const getPaginationRequestSizeParams = (pageIndex: number, pageSize: number) => ({
size: (pageIndex + 1) * pageSize,
});
export const getPaginationRequestParams = (pageIndex: number, pageSize: number) => ({
aggs: {
sort: {
bucket_sort: {
from: pageIndex * pageSize,
size: pageSize,
},
},
},
});
export const getBaseSearchTemplate = (
aggregationFieldName: string,
{ search, timeRange }: { search: string; timeRange: TimeRange },
aggs: IKibanaSearchRequest['params']['aggs']
): IKibanaSearchRequest => ({
params: {
aggs,
query: {
bool: {
must: [
{
range: {
'@timestamp': {
gte: timeRange.from,
lt: timeRange.to,
},
},
},
...(search ? [getSearchQueryRequestParams(aggregationFieldName, search)] : []),
],
},
},
size: 0,
track_total_hits: false,
},
});

View file

@ -0,0 +1,255 @@
/*
* 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 { LogicMounter } from '../../../__mocks__/kea_logic';
import { KibanaLogic } from '../../../shared/kibana/kibana_logic';
import {
AnalyticsCollectionExploreTableLogic,
Sorting,
} from './analytics_collection_explore_table_logic';
import { ExploreTableColumns, ExploreTables } from './analytics_collection_explore_table_types';
import { AnalyticsCollectionToolbarLogic } from './analytics_collection_toolbar/analytics_collection_toolbar_logic';
jest.mock('../../../shared/kibana/kibana_logic', () => ({
KibanaLogic: {
values: {
data: {
search: {
search: jest.fn().mockReturnValue({ subscribe: jest.fn() }),
},
},
},
},
}));
describe('AnalyticsCollectionExplorerTablesLogic', () => {
const { mount } = new LogicMounter(AnalyticsCollectionExploreTableLogic);
beforeEach(() => {
jest.clearAllMocks();
mount();
});
const defaultProps = {
isLoading: false,
items: [],
pageIndex: 0,
pageSize: 10,
search: '',
selectedTable: null,
sorting: null,
totalItemsCount: 0,
};
it('initializes with default values', () => {
expect(AnalyticsCollectionExploreTableLogic.values).toEqual(defaultProps);
});
describe('reducers', () => {
it('should handle set items', () => {
const items = [
{ count: 1, query: 'test' },
{ count: 2, query: 'test2' },
];
AnalyticsCollectionExploreTableLogic.actions.setItems(items);
expect(AnalyticsCollectionExploreTableLogic.values.items).toEqual(items);
});
it('should handle set selectedTable', () => {
const id = ExploreTables.WorsePerformers;
const sorting = { direction: 'desc', field: ExploreTableColumns.count } as Sorting;
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(id, sorting);
expect(AnalyticsCollectionExploreTableLogic.values.selectedTable).toEqual(id);
expect(AnalyticsCollectionExploreTableLogic.values.sorting).toEqual(sorting);
});
it('should handle set sorting', () => {
const sorting = { direction: 'asc', field: ExploreTableColumns.sessions } as Sorting;
AnalyticsCollectionExploreTableLogic.actions.onTableChange({
sort: sorting,
});
expect(AnalyticsCollectionExploreTableLogic.values.sorting).toEqual(sorting);
});
describe('isLoading', () => {
it('should handle onTableChange', () => {
AnalyticsCollectionExploreTableLogic.actions.onTableChange({
page: { index: 2, size: 10 },
sort: {
direction: 'asc',
field: ExploreTableColumns.sessions,
} as Sorting,
});
expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(true);
});
it('should handle setSearch', () => {
AnalyticsCollectionExploreTableLogic.actions.setSearch('test');
expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(true);
});
it('should handle setItems', () => {
AnalyticsCollectionExploreTableLogic.actions.setItems([]);
expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(false);
});
it('should handle setSelectedTable', () => {
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers);
expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(true);
});
it('should handle setTimeRange', () => {
AnalyticsCollectionToolbarLogic.actions.setTimeRange({ from: 'now-7d', to: 'now' });
expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(true);
});
it('should handle setSearchSessionId', () => {
AnalyticsCollectionToolbarLogic.actions.setSearchSessionId('12345');
expect(AnalyticsCollectionExploreTableLogic.values.isLoading).toEqual(true);
});
});
describe('pageIndex', () => {
it('should handle setPageIndex', () => {
AnalyticsCollectionExploreTableLogic.actions.onTableChange({
page: { index: 2, size: 10 },
});
expect(AnalyticsCollectionExploreTableLogic.values.pageIndex).toEqual(2);
});
it('should handle setSelectedTable', () => {
AnalyticsCollectionExploreTableLogic.actions.onTableChange({
page: { index: 2, size: 10 },
});
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers);
expect(AnalyticsCollectionExploreTableLogic.values.pageIndex).toEqual(0);
});
it('should handle reset', () => {
AnalyticsCollectionExploreTableLogic.actions.onTableChange({
page: { index: 2, size: 10 },
});
AnalyticsCollectionExploreTableLogic.actions.reset();
expect(AnalyticsCollectionExploreTableLogic.values.pageIndex).toEqual(0);
});
it('should handle setSearch', () => {
AnalyticsCollectionExploreTableLogic.actions.onTableChange({
page: { index: 2, size: 10 },
});
AnalyticsCollectionExploreTableLogic.actions.setSearch('');
expect(AnalyticsCollectionExploreTableLogic.values.pageIndex).toEqual(0);
});
});
describe('pageSize', () => {
it('should handle setPageSize', () => {
AnalyticsCollectionExploreTableLogic.actions.onTableChange({
page: { index: 2, size: 10 },
});
expect(AnalyticsCollectionExploreTableLogic.values.pageSize).toEqual(10);
});
it('should handle setSelectedTable', () => {
AnalyticsCollectionExploreTableLogic.actions.onTableChange({
page: { index: 2, size: 10 },
});
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers);
expect(AnalyticsCollectionExploreTableLogic.values.pageSize).toEqual(10);
});
it('should handle reset', () => {
AnalyticsCollectionExploreTableLogic.actions.onTableChange({
page: { index: 2, size: 10 },
});
AnalyticsCollectionExploreTableLogic.actions.reset();
expect(AnalyticsCollectionExploreTableLogic.values.pageSize).toEqual(10);
});
});
describe('search', () => {
it('should handle setSearch', () => {
AnalyticsCollectionExploreTableLogic.actions.setSearch('test');
expect(AnalyticsCollectionExploreTableLogic.values.search).toEqual('test');
});
it('should handle setSelectedTable', () => {
AnalyticsCollectionExploreTableLogic.actions.setSearch('test');
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers);
expect(AnalyticsCollectionExploreTableLogic.values.search).toEqual('');
});
it('should handle reset', () => {
AnalyticsCollectionExploreTableLogic.actions.setSearch('test');
AnalyticsCollectionExploreTableLogic.actions.reset();
expect(AnalyticsCollectionExploreTableLogic.values.search).toEqual('');
});
});
it('should handle totalItemsCount', () => {
AnalyticsCollectionExploreTableLogic.actions.setTotalItemsCount(100);
expect(AnalyticsCollectionExploreTableLogic.values.totalItemsCount).toEqual(100);
});
});
describe('listeners', () => {
it('should fetch items when selectedTable changes', () => {
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.TopReferrers);
expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), {
indexPattern: undefined,
sessionId: undefined,
});
});
it('should fetch items when timeRange changes', () => {
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.WorsePerformers);
(KibanaLogic.values.data.search.search as jest.Mock).mockClear();
AnalyticsCollectionToolbarLogic.actions.setTimeRange({ from: 'now-7d', to: 'now' });
expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), {
indexPattern: undefined,
sessionId: undefined,
});
});
it('should fetch items when searchSessionId changes', () => {
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.WorsePerformers);
(KibanaLogic.values.data.search.search as jest.Mock).mockClear();
AnalyticsCollectionToolbarLogic.actions.setSearchSessionId('1234');
expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), {
indexPattern: undefined,
sessionId: '1234',
});
});
it('should fetch items when onTableChange called', () => {
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.WorsePerformers);
(KibanaLogic.values.data.search.search as jest.Mock).mockClear();
AnalyticsCollectionExploreTableLogic.actions.onTableChange({});
expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), {
indexPattern: undefined,
sessionId: undefined,
});
});
it('should fetch items when search changes', () => {
AnalyticsCollectionExploreTableLogic.actions.setSelectedTable(ExploreTables.WorsePerformers);
(KibanaLogic.values.data.search.search as jest.Mock).mockClear();
AnalyticsCollectionExploreTableLogic.actions.setSearch('test');
expect(KibanaLogic.values.data.search.search).toHaveBeenCalledWith(expect.any(Object), {
indexPattern: undefined,
sessionId: undefined,
});
});
});
});

View file

@ -0,0 +1,377 @@
/*
* 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 {
IKibanaSearchRequest,
IKibanaSearchResponse,
isCompleteResponse,
TimeRange,
} from '@kbn/data-plugin/common';
import { KibanaLogic } from '../../../shared/kibana/kibana_logic';
import { AnalyticsCollectionDataViewLogic } from './analytics_collection_data_view_logic';
import {
getBaseSearchTemplate,
getPaginationRequestParams,
getPaginationRequestSizeParams,
getTotalCountRequestParams,
} from './analytics_collection_explore_table_formulas';
import {
ExploreTableColumns,
ExploreTableItem,
ExploreTables,
SearchTermsTable,
TopClickedTable,
TopReferrersTable,
WorsePerformersTable,
} from './analytics_collection_explore_table_types';
import { AnalyticsCollectionToolbarLogic } from './analytics_collection_toolbar/analytics_collection_toolbar_logic';
const BASE_PAGE_SIZE = 10;
export interface Sorting<T extends ExploreTableItem = ExploreTableItem> {
direction: 'asc' | 'desc';
field: keyof T;
}
interface TableParams<T extends ExploreTableItem = ExploreTableItem> {
parseResponse(response: IKibanaSearchResponse): { items: T[]; totalCount: number };
requestParams(props: {
pageIndex: number;
pageSize: number;
search: string;
sorting: Sorting<T> | null;
timeRange: TimeRange;
}): IKibanaSearchRequest;
}
const tablesParams: {
[ExploreTables.SearchTerms]: TableParams<SearchTermsTable>;
[ExploreTables.TopClicked]: TableParams<TopClickedTable>;
[ExploreTables.TopReferrers]: TableParams<TopReferrersTable>;
[ExploreTables.WorsePerformers]: TableParams<WorsePerformersTable>;
} = {
[ExploreTables.SearchTerms]: {
parseResponse: (
response: IKibanaSearchResponse<{
aggregations: {
searches: { buckets: Array<{ doc_count: number; key: string }> };
totalCount: { value: number };
};
}>
) => ({
items: response.rawResponse.aggregations.searches.buckets.map((bucket) => ({
[ExploreTableColumns.count]: bucket.doc_count,
[ExploreTableColumns.searchTerms]: bucket.key,
})),
totalCount: response.rawResponse.aggregations.totalCount.value,
}),
requestParams: (
{ timeRange, sorting, pageIndex, pageSize, search },
aggregationFieldName = 'search.query'
) =>
getBaseSearchTemplate(
aggregationFieldName,
{ search, timeRange },
{
searches: {
terms: {
...getPaginationRequestSizeParams(pageIndex, pageSize),
field: aggregationFieldName,
order: sorting
? {
[sorting.field === ExploreTableColumns.count ? '_count' : '_key']:
sorting.direction,
}
: undefined,
},
...getPaginationRequestParams(pageIndex, pageSize),
},
...getTotalCountRequestParams(aggregationFieldName),
}
),
},
[ExploreTables.WorsePerformers]: {
parseResponse: (
response: IKibanaSearchResponse<{
aggregations: {
formula: {
searches: { buckets: Array<{ doc_count: number; key: string }> };
totalCount: { value: number };
};
};
}>
) => ({
items: response.rawResponse.aggregations.formula.searches.buckets.map((bucket) => ({
[ExploreTableColumns.count]: bucket.doc_count,
[ExploreTableColumns.query]: bucket.key,
})),
totalCount: response.rawResponse.aggregations.formula.totalCount.value,
}),
requestParams: (
{ timeRange, sorting, pageIndex, pageSize, search },
aggregationFieldName = 'search.query'
) =>
getBaseSearchTemplate(
aggregationFieldName,
{ search, timeRange },
{
formula: {
aggs: {
...getTotalCountRequestParams(aggregationFieldName),
searches: {
terms: {
...getPaginationRequestSizeParams(pageIndex, pageSize),
field: aggregationFieldName,
order: sorting
? {
[sorting?.field === ExploreTableColumns.count ? '_count' : '_key']:
sorting?.direction,
}
: undefined,
},
...getPaginationRequestParams(pageIndex, pageSize),
},
},
filter: { term: { 'search.results.total_results': '0' } },
},
}
),
},
[ExploreTables.TopClicked]: {
parseResponse: (
response: IKibanaSearchResponse<{
aggregations: {
formula: {
searches: { buckets: Array<{ doc_count: number; key: string }> };
totalCount: { value: number };
};
};
}>
) => ({
items: response.rawResponse.aggregations.formula.searches.buckets.map((bucket) => ({
[ExploreTableColumns.count]: bucket.doc_count,
[ExploreTableColumns.page]: bucket.key,
})),
totalCount: response.rawResponse.aggregations.formula.totalCount.value,
}),
requestParams: (
{ timeRange, sorting, pageIndex, pageSize, search },
aggregationFieldName = 'search.results.items.page.url'
) =>
getBaseSearchTemplate(
aggregationFieldName,
{ search, timeRange },
{
formula: {
aggs: {
...getTotalCountRequestParams(aggregationFieldName),
searches: {
terms: {
...getPaginationRequestSizeParams(pageIndex, pageSize),
field: aggregationFieldName,
order: sorting
? {
[sorting.field === ExploreTableColumns.count ? '_count' : '_key']:
sorting.direction,
}
: undefined,
},
...getPaginationRequestParams(pageIndex, pageSize),
},
},
filter: { term: { 'event.action': 'search_click' } },
},
}
),
},
[ExploreTables.TopReferrers]: {
parseResponse: (
response: IKibanaSearchResponse<{
aggregations: {
formula: {
searches: { buckets: Array<{ doc_count: number; key: string }> };
totalCount: { value: number };
};
};
}>
) => ({
items: response.rawResponse.aggregations.formula.searches.buckets.map((bucket) => ({
[ExploreTableColumns.sessions]: bucket.doc_count,
[ExploreTableColumns.page]: bucket.key,
})),
totalCount: response.rawResponse.aggregations.formula.totalCount.value,
}),
requestParams: (
{ timeRange, sorting, pageIndex, pageSize, search },
aggregationFieldName = 'page.referrer'
) =>
getBaseSearchTemplate(
aggregationFieldName,
{ search, timeRange },
{
formula: {
aggs: {
...getTotalCountRequestParams(aggregationFieldName),
searches: {
terms: {
...getPaginationRequestSizeParams(pageIndex, pageSize),
field: aggregationFieldName,
order: sorting
? {
[sorting?.field === ExploreTableColumns.sessions ? '_count' : '_key']:
sorting?.direction,
}
: undefined,
},
...getPaginationRequestParams(pageIndex, pageSize),
},
},
filter: { term: { 'event.action': 'page_view' } },
},
}
),
},
};
export interface AnalyticsCollectionExploreTableLogicValues {
isLoading: boolean;
items: ExploreTableItem[];
pageIndex: number;
pageSize: number;
search: string;
selectedTable: ExploreTables | null;
sorting: Sorting | null;
totalItemsCount: number;
}
export interface AnalyticsCollectionExploreTableLogicActions {
onTableChange(state: { page?: { index: number; size: number }; sort?: Sorting }): {
page?: { index: number; size: number };
sort?: Sorting;
};
reset(): void;
setItems(items: ExploreTableItem[]): { items: ExploreTableItem[] };
setSearch(search: string): { search: string };
setSelectedTable(
id: ExploreTables | null,
sorting?: Sorting
): { id: ExploreTables | null; sorting?: Sorting };
setTotalItemsCount(count: number): { count: number };
}
export const AnalyticsCollectionExploreTableLogic = kea<
MakeLogicType<
AnalyticsCollectionExploreTableLogicValues,
AnalyticsCollectionExploreTableLogicActions
>
>({
actions: {
onTableChange: ({ page, sort }) => ({ page, sort }),
reset: true,
setItems: (items) => ({ items }),
setSearch: (search) => ({ search }),
setSelectedTable: (id, sorting) => ({ id, sorting }),
setTotalItemsCount: (count) => ({ count }),
},
listeners: ({ actions, values }) => {
const fetchItems = () => {
if (values.selectedTable === null || !(values.selectedTable in tablesParams)) {
return;
}
const { requestParams, parseResponse } = tablesParams[values.selectedTable] as TableParams;
const timeRange = AnalyticsCollectionToolbarLogic.values.timeRange;
const search$ = KibanaLogic.values.data.search
.search(
requestParams({
pageIndex: values.pageIndex,
pageSize: values.pageSize,
search: values.search,
sorting: values.sorting,
timeRange,
}),
{
indexPattern: AnalyticsCollectionDataViewLogic.values.dataView || undefined,
sessionId: AnalyticsCollectionToolbarLogic.values.searchSessionId,
}
)
.subscribe({
error: (e) => {
KibanaLogic.values.data.search.showError(e);
},
next: (response) => {
if (isCompleteResponse(response)) {
const { items, totalCount } = parseResponse(response);
actions.setItems(items);
actions.setTotalItemsCount(totalCount);
search$.unsubscribe();
}
},
});
};
return {
onTableChange: fetchItems,
setSearch: fetchItems,
setSelectedTable: fetchItems,
[AnalyticsCollectionToolbarLogic.actionTypes.setTimeRange]: fetchItems,
[AnalyticsCollectionToolbarLogic.actionTypes.setSearchSessionId]: fetchItems,
};
},
path: ['enterprise_search', 'analytics', 'collections', 'explore', 'table'],
reducers: () => ({
isLoading: [
false,
{
onTableChange: () => true,
setItems: () => false,
setSearch: () => true,
setSelectedTable: () => true,
setTableState: () => true,
[AnalyticsCollectionToolbarLogic.actionTypes.setTimeRange]: () => true,
[AnalyticsCollectionToolbarLogic.actionTypes.setSearchSessionId]: () => true,
},
],
items: [[], { setItems: (_, { items }) => items }],
pageIndex: [
0,
{
onTableChange: (_, { page }) => page?.index || 0,
reset: () => 0,
setSearch: () => 0,
setSelectedTable: () => 0,
},
],
pageSize: [
BASE_PAGE_SIZE,
{
onTableChange: (_, { page }) => page?.size || BASE_PAGE_SIZE,
reset: () => BASE_PAGE_SIZE,
},
],
search: [
'',
{ reset: () => '', setSearch: (_, { search }) => search, setSelectedTable: () => '' },
],
selectedTable: [null, { setSelectedTable: (_, { id }) => id }],
sorting: [
null,
{
onTableChange: (_, { sort = null }) => sort,
setSelectedTable: (_, { sorting = null }) => sorting,
},
],
totalItemsCount: [0, { setTotalItemsCount: (_, { count }) => count }],
}),
});

View file

@ -0,0 +1,63 @@
/*
* 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 { setMockActions, setMockValues } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { EnterpriseSearchAnalyticsPageTemplate } from '../../layout/page_template';
import { AnalyticsCollectionToolbar } from '../analytics_collection_toolbar/analytics_collection_toolbar';
import { AnalyticsCollectionExplorer } from './analytics_collection_explorer';
import { AnalyticsCollectionExplorerTable } from './analytics_collection_explorer_table';
describe('AnalyticsCollectionExplorer', () => {
const mockValues = {
analyticsCollection: { event_data_stream: 'test_data_stream', name: 'Mock Collection' },
refreshInterval: { pause: false, value: 1000 },
timeRange: { from: 'now-15m', to: 'now' },
};
const mockActions = { reset: jest.fn() };
beforeAll(() => {
jest.clearAllMocks();
setMockValues(mockValues);
setMockActions(mockActions);
});
afterAll(() => {
jest.resetAllMocks();
});
it('renders the AnalyticsCollectionExplorerTable', () => {
const wrapper = shallow(<AnalyticsCollectionExplorer />);
expect(wrapper.find(AnalyticsCollectionExplorerTable)).toHaveLength(1);
});
it('renders the EnterpriseSearchAnalyticsPageTemplate', () => {
const wrapper = shallow(<AnalyticsCollectionExplorer />);
expect(wrapper.find(EnterpriseSearchAnalyticsPageTemplate)).toHaveLength(1);
});
it('passes the expected props to EnterpriseSearchAnalyticsPageTemplate', () => {
const wrapper = shallow(<AnalyticsCollectionExplorer />).find(
EnterpriseSearchAnalyticsPageTemplate
);
expect(wrapper.prop('pageChrome')).toEqual([mockValues.analyticsCollection.name]);
expect(wrapper.prop('analyticsName')).toEqual(mockValues.analyticsCollection.name);
expect(wrapper.prop('pageHeader')).toEqual({
bottomBorder: false,
pageTitle: 'Explorer',
rightSideItems: [<AnalyticsCollectionToolbar />],
});
});
});

View file

@ -0,0 +1,51 @@
/*
* 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 { useActions, useValues } from 'kea';
import { i18n } from '@kbn/i18n';
import { EnterpriseSearchAnalyticsPageTemplate } from '../../layout/page_template';
import { AnalyticsCollectionExploreTableLogic } from '../analytics_collection_explore_table_logic';
import { AnalyticsCollectionToolbar } from '../analytics_collection_toolbar/analytics_collection_toolbar';
import { FetchAnalyticsCollectionLogic } from '../fetch_analytics_collection_logic';
import { AnalyticsCollectionExplorerTable } from './analytics_collection_explorer_table';
export const AnalyticsCollectionExplorer: React.FC = ({}) => {
const { analyticsCollection } = useValues(FetchAnalyticsCollectionLogic);
const { reset } = useActions(AnalyticsCollectionExploreTableLogic);
useEffect(() => {
return () => {
reset();
};
}, []);
return (
<EnterpriseSearchAnalyticsPageTemplate
restrictWidth
pageChrome={[analyticsCollection?.name]}
analyticsName={analyticsCollection?.name}
pageViewTelemetry={`View Analytics Collection - explorer`}
pageHeader={{
bottomBorder: false,
pageTitle: i18n.translate(
'xpack.enterpriseSearch.analytics.collectionsView.explorerView.title',
{
defaultMessage: 'Explorer',
}
),
rightSideItems: [<AnalyticsCollectionToolbar />],
}}
>
<AnalyticsCollectionExplorerTable />
</EnterpriseSearchAnalyticsPageTemplate>
);
};

View file

@ -0,0 +1,54 @@
/*
* 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 from 'react';
import { useValues } from 'kea';
import { EuiButton, EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { KibanaLogic } from '../../../../shared/kibana';
import { useDiscoverLink } from '../use_discover_link';
export const AnalyticsCollectionExplorerCallout: React.FC = () => {
const { application } = useValues(KibanaLogic);
const discoverLink = useDiscoverLink();
return discoverLink ? (
<EuiCallOut
title={i18n.translate(
'xpack.enterpriseSearch.analytics.collectionsView.explorer.callout.title',
{ defaultMessage: 'Need a deeper analysis?' }
)}
iconType="inspect"
>
<p>
<FormattedMessage
id="xpack.enterpriseSearch.analytics.collectionsView.explorer.callout.description"
defaultMessage="Review your event logs in Discover to get more insights about your application metrics."
/>
</p>
<RedirectAppLinks coreStart={{ application }}>
<EuiButton
fill
href={discoverLink}
data-telemetry-id="entSearch-analytics-explorer-callout-exploreLink"
>
<FormattedMessage
id="xpack.enterpriseSearch.analytics.collectionsView.explorer.callout.button"
defaultMessage="Explore"
/>
</EuiButton>
</RedirectAppLinks>
</EuiCallOut>
) : null;
};

View file

@ -0,0 +1,70 @@
/*
* 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 { setMockActions, setMockValues } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { mount, shallow } from 'enzyme';
import { ExploreTables } from '../analytics_collection_explore_table_types';
import { AnalyticsCollectionExplorerTable } from './analytics_collection_explorer_table';
describe('AnalyticsCollectionExplorerTable', () => {
const mockActions = {
onTableChange: jest.fn(),
setPageIndex: jest.fn(),
setPageSize: jest.fn(),
setSearch: jest.fn(),
setSelectedTable: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues({ items: [], selectedTable: ExploreTables.TopClicked });
setMockActions(mockActions);
});
it('should set default selectedTable', () => {
setMockValues({ items: [], selectedTable: null });
const wrapper = mount(<AnalyticsCollectionExplorerTable />);
wrapper.update();
expect(mockActions.setSelectedTable).toHaveBeenCalledWith(ExploreTables.SearchTerms, {
direction: 'desc',
field: 'count',
});
});
it('should call setSelectedTable when click on a tab', () => {
const tabs = shallow(<AnalyticsCollectionExplorerTable />).find('EuiTab');
expect(tabs.length).toBe(4);
tabs.at(2).simulate('click');
expect(mockActions.setSelectedTable).toHaveBeenCalledWith(ExploreTables.WorsePerformers, {
direction: 'desc',
field: 'count',
});
});
it('should call onTableChange when table called onChange', () => {
const table = shallow(<AnalyticsCollectionExplorerTable />).find('EuiBasicTable');
table.simulate('change', {
page: { index: 23, size: 44 },
sort: { direction: 'asc', field: 'test' },
});
expect(mockActions.onTableChange).toHaveBeenCalledWith({
page: { index: 23, size: 44 },
sort: { direction: 'asc', field: 'test' },
});
});
});

View file

@ -0,0 +1,327 @@
/*
* 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 { useActions, useValues } from 'kea';
import {
Criteria,
EuiBasicTable,
EuiBasicTableColumn,
EuiFieldSearch,
EuiFlexGroup,
EuiHorizontalRule,
EuiSpacer,
EuiTab,
EuiTabs,
EuiText,
useEuiTheme,
} from '@elastic/eui';
import {
EuiTableFieldDataColumnType,
EuiTableSortingType,
} from '@elastic/eui/src/components/basic_table/table_types';
import { UseEuiTheme } from '@elastic/eui/src/services/theme/hooks';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { AnalyticsCollectionExploreTableLogic } from '../analytics_collection_explore_table_logic';
import {
ExploreTableColumns,
ExploreTableItem,
ExploreTables,
SearchTermsTable,
TopClickedTable,
TopReferrersTable,
WorsePerformersTable,
} from '../analytics_collection_explore_table_types';
import { AnalyticsCollectionExplorerCallout } from './analytics_collection_explorer_callout';
interface TableSetting<T = ExploreTableItem, K = T> {
columns: Array<
EuiBasicTableColumn<T & K> & {
render?: (euiTheme: UseEuiTheme['euiTheme']) => EuiTableFieldDataColumnType<T & K>['render'];
}
>;
sorting: EuiTableSortingType<T>;
}
const tabs: Array<{ id: ExploreTables; name: string }> = [
{
id: ExploreTables.SearchTerms,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.explorer.searchTermsTab',
{ defaultMessage: 'Search terms' }
),
},
{
id: ExploreTables.TopClicked,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.explorer.topClickedTab',
{ defaultMessage: 'Top clicked results' }
),
},
{
id: ExploreTables.WorsePerformers,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.explorer.noResultsTab',
{ defaultMessage: 'No results' }
),
},
{
id: ExploreTables.TopReferrers,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.explorer.referrersTab',
{ defaultMessage: 'Referrers' }
),
},
];
const tableSettings: {
[ExploreTables.SearchTerms]: TableSetting<SearchTermsTable>;
[ExploreTables.TopClicked]: TableSetting<TopClickedTable>;
[ExploreTables.TopReferrers]: TableSetting<TopReferrersTable>;
[ExploreTables.WorsePerformers]: TableSetting<WorsePerformersTable>;
} = {
[ExploreTables.SearchTerms]: {
columns: [
{
field: ExploreTableColumns.searchTerms,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.searchTerms',
{ defaultMessage: 'Search Terms' }
),
sortable: true,
truncateText: true,
},
{
align: 'right',
field: ExploreTableColumns.count,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.count',
{ defaultMessage: 'Count' }
),
sortable: true,
truncateText: true,
},
],
sorting: {
sort: {
direction: 'desc',
field: ExploreTableColumns.count,
},
},
},
[ExploreTables.WorsePerformers]: {
columns: [
{
field: ExploreTableColumns.query,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.query',
{ defaultMessage: 'Query' }
),
sortable: true,
truncateText: true,
},
{
align: 'right',
field: ExploreTableColumns.count,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.count',
{ defaultMessage: 'Count' }
),
sortable: true,
truncateText: true,
},
],
sorting: {
sort: {
direction: 'desc',
field: ExploreTableColumns.count,
},
},
},
[ExploreTables.TopClicked]: {
columns: [
{
field: ExploreTableColumns.page,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.page',
{ defaultMessage: 'Page' }
),
render: (euiTheme: UseEuiTheme['euiTheme']) => (value: string) =>
(
<EuiText size="s" color={euiTheme.colors.primaryText}>
<p>{value}</p>
</EuiText>
),
sortable: true,
truncateText: true,
},
{
align: 'right',
field: ExploreTableColumns.count,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.count',
{ defaultMessage: 'Count' }
),
sortable: true,
truncateText: true,
},
],
sorting: {
sort: {
direction: 'desc',
field: ExploreTableColumns.count,
},
},
},
[ExploreTables.TopReferrers]: {
columns: [
{
field: ExploreTableColumns.page,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.page',
{ defaultMessage: 'Page' }
),
render: (euiTheme: UseEuiTheme['euiTheme']) => (value: string) =>
(
<EuiText size="s" color={euiTheme.colors.primaryText}>
<p>{value}</p>
</EuiText>
),
sortable: true,
truncateText: true,
},
{
align: 'right',
field: ExploreTableColumns.sessions,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.session',
{ defaultMessage: 'Session' }
),
sortable: true,
truncateText: true,
},
],
sorting: {
sort: {
direction: 'desc',
field: ExploreTableColumns.sessions,
},
},
},
};
export const AnalyticsCollectionExplorerTable = () => {
const { euiTheme } = useEuiTheme();
const { onTableChange, setSelectedTable, setSearch } = useActions(
AnalyticsCollectionExploreTableLogic
);
const { items, isLoading, pageIndex, pageSize, search, selectedTable, sorting, totalItemsCount } =
useValues(AnalyticsCollectionExploreTableLogic);
let table = selectedTable !== null && (tableSettings[selectedTable] as TableSetting);
if (table) {
table = {
...table,
columns: table.columns.map((column) => ({
...column,
render: column.render?.(euiTheme),
})) as TableSetting['columns'],
sorting: { ...table.sorting, sort: sorting || undefined },
};
}
const handleTableChange = ({ sort, page }: Criteria<ExploreTableItem>) => {
onTableChange({ page, sort });
};
useEffect(() => {
if (!selectedTable) {
const firstTabId = tabs[0].id;
setSelectedTable(firstTabId, (tableSettings[firstTabId] as TableSetting)?.sorting?.sort);
}
}, []);
return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiTabs>
{tabs?.map(({ id, name }) => (
<EuiTab
key={id}
onClick={() => setSelectedTable(id, (tableSettings[id] as TableSetting)?.sorting?.sort)}
isSelected={id === selectedTable}
>
{name}
</EuiTab>
))}
</EuiTabs>
{table && (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFieldSearch
placeholder={i18n.translate(
'xpack.enterpriseSearch.analytics.collectionsView.explorer.searchPlaceholder',
{
defaultMessage: 'Search',
}
)}
value={search}
onChange={(event) => setSearch(event.target.value)}
isClearable
incremental
fullWidth
/>
<EuiSpacer size="xl" />
<EuiText size="xs">
<FormattedMessage
id="xpack.enterpriseSearch.analytics.collectionsView.explorer.tableSummary"
defaultMessage="Showing {items} of {totalItemsCount}"
values={{
items: (
<strong>
{pageSize * pageIndex + Number(!!items.length)}-
{pageSize * pageIndex + items.length}
</strong>
),
totalItemsCount,
}}
/>
</EuiText>
<EuiSpacer size="m" />
<EuiHorizontalRule margin="none" />
<EuiBasicTable
columns={table.columns}
itemId={selectedTable || undefined}
items={items}
loading={isLoading}
sorting={table.sorting}
pagination={{
pageIndex,
pageSize,
pageSizeOptions: [10, 20, 50],
showPerPageOptions: true,
totalItemCount: totalItemsCount,
}}
onChange={handleTableChange}
/>
</EuiFlexGroup>
)}
<AnalyticsCollectionExplorerCallout />
</EuiFlexGroup>
);
};

View file

@ -35,12 +35,9 @@ describe('AnalyticsCollectionChart', () => {
from: moment().subtract(7, 'days').toISOString(),
to: moment().toISOString(),
};
const mockedDataViewQuery = 'mockedDataViewQuery';
const defaultProps = {
data: {},
dataViewQuery: mockedDataViewQuery,
id: 'mockedId',
isLoading: false,
selectedChart: FilterBy.Searches,
setSelectedChart: jest.fn(),

View file

@ -42,8 +42,7 @@ const DEFAULT_STROKE_WIDTH = 1;
const HOVER_STROKE_WIDTH = 3;
const CHART_HEIGHT = 490;
interface AnalyticsCollectionChartProps extends WithLensDataInputProps {
dataViewQuery: string;
interface AnalyticsCollectionChartProps extends Pick<WithLensDataInputProps, 'timeRange'> {
selectedChart: FilterBy | null;
setSelectedChart(chart: FilterBy): void;
}
@ -303,6 +302,5 @@ export const AnalyticsCollectionChartWithLens = withLensData<
visualizationType: 'lnsXY',
};
},
getDataViewQuery: (props) => props.dataViewQuery,
initialValues,
});

View file

@ -54,7 +54,6 @@ const getMetricStatus = (metric: number): MetricStatus => {
};
interface AnalyticsCollectionViewMetricProps {
dataViewQuery: string;
getFormula: (shift?: string) => string;
isSelected?: boolean;
name: string;
@ -230,6 +229,5 @@ export const AnalyticsCollectionViewMetricWithLens = withLensData<
visualizationType: 'lnsMetric',
};
},
getDataViewQuery: (props) => props.dataViewQuery,
initialValues,
});

View file

@ -38,9 +38,9 @@ const mockValues = {
};
const mockActions = {
analyticsEventsExist: jest.fn(),
fetchAnalyticsCollection: jest.fn(),
fetchAnalyticsCollectionDataViewId: jest.fn(),
analyticsEventsExist: jest.fn(),
setTimeRange: jest.fn(),
};
@ -92,7 +92,7 @@ describe('AnalyticsOverView', () => {
);
expect(wrapper?.find(AnalyticsCollectionChartWithLens)).toHaveLength(1);
expect(wrapper?.find(AnalyticsCollectionChartWithLens).props()).toEqual({
dataViewQuery: 'analytics-events-example',
collection: mockValues.analyticsCollection,
id: 'analytics-collection-chart-Analytics-Collection-1',
searchSessionId: 'session-id',
selectedChart: 'Searches',

View file

@ -18,14 +18,13 @@ import { FilterBy, getFormulaByFilter } from '../../../utils/get_formula_by_filt
import { EnterpriseSearchAnalyticsPageTemplate } from '../../layout/page_template';
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';
import { AnalyticsCollectionChartWithLens } from './analytics_collection_chart';
import { AnalyticsCollectionViewMetricWithLens } from './analytics_collection_metric';
import { AnalyticsCollectionOverviewTable } from './analytics_collection_overview_table';
const filters = [
{
@ -107,7 +106,7 @@ export const AnalyticsCollectionOverview: React.FC<AnalyticsCollectionOverviewPr
setFilterBy(id);
}}
dataViewQuery={analyticsCollection.events_datastream}
collection={analyticsCollection}
timeRange={timeRange}
searchSessionId={searchSessionId}
getFormula={getFormulaByFilter.bind(null, id)}
@ -117,7 +116,7 @@ export const AnalyticsCollectionOverview: React.FC<AnalyticsCollectionOverviewPr
<AnalyticsCollectionChartWithLens
id={'analytics-collection-chart-' + analyticsCollection.name}
dataViewQuery={analyticsCollection.events_datastream}
collection={analyticsCollection}
timeRange={timeRange}
setTimeRange={setTimeRange}
searchSessionId={searchSessionId}
@ -125,7 +124,7 @@ export const AnalyticsCollectionOverview: React.FC<AnalyticsCollectionOverviewPr
setSelectedChart={setFilterBy}
/>
<AnalyticsCollectionExploreTable filterBy={filterBy} />
<AnalyticsCollectionOverviewTable filterBy={filterBy} />
</EuiFlexGroup>
</EnterpriseSearchAnalyticsPageTemplate>
);

View file

@ -15,10 +15,11 @@ import { EuiBasicTable, EuiTab } from '@elastic/eui';
import { FilterBy } from '../../../utils/get_formula_by_filter';
import { AnalyticsCollectionExploreTable } from './analytics_collection_explore_table';
import { ExploreTables } from './analytics_collection_explore_table_types';
import { ExploreTables } from '../analytics_collection_explore_table_types';
describe('AnalyticsCollectionExploreTable', () => {
import { AnalyticsCollectionOverviewTable } from './analytics_collection_overview_table';
describe('AnalyticsCollectionOverviewTable', () => {
const mockValues = {
activeTableId: 'search_terms',
analyticsCollection: {
@ -41,7 +42,7 @@ describe('AnalyticsCollectionExploreTable', () => {
});
it('should call setSelectedTable with the correct table id when a tab is clicked', () => {
const wrapper = shallow(<AnalyticsCollectionExploreTable filterBy={FilterBy.Sessions} />);
const wrapper = shallow(<AnalyticsCollectionOverviewTable filterBy={FilterBy.Sessions} />);
const topReferrersTab = wrapper.find(EuiTab).at(0);
topReferrersTab.simulate('click');
@ -53,14 +54,9 @@ describe('AnalyticsCollectionExploreTable', () => {
});
});
it('should call findDataView with the active table ID and search filter when mounted', () => {
mount(<AnalyticsCollectionExploreTable filterBy={FilterBy.Sessions} />);
expect(mockActions.findDataView).toHaveBeenCalledWith(mockValues.analyticsCollection);
});
it('should render a table with the selectedTable', () => {
setMockValues({ ...mockValues, selectedTable: ExploreTables.WorsePerformers });
const wrapper = mount(<AnalyticsCollectionExploreTable filterBy={FilterBy.Sessions} />);
const wrapper = mount(<AnalyticsCollectionOverviewTable filterBy={FilterBy.Sessions} />);
expect(wrapper.find(EuiBasicTable).prop('itemId')).toBe(ExploreTables.WorsePerformers);
});
});

View file

@ -33,9 +33,8 @@ import { generateEncodedPath } from '../../../../shared/encode_path_params';
import { KibanaLogic } from '../../../../shared/kibana';
import { COLLECTION_EXPLORER_PATH } from '../../../routes';
import { FilterBy } from '../../../utils/get_formula_by_filter';
import { FetchAnalyticsCollectionLogic } from '../fetch_analytics_collection_logic';
import { AnalyticsCollectionExploreTableLogic } from './analytics_collection_explore_table_logic';
import { AnalyticsCollectionExploreTableLogic } from '../analytics_collection_explore_table_logic';
import {
ExploreTableColumns,
ExploreTableItem,
@ -44,7 +43,8 @@ import {
TopClickedTable,
TopReferrersTable,
WorsePerformersTable,
} from './analytics_collection_explore_table_types';
} from '../analytics_collection_explore_table_types';
import { FetchAnalyticsCollectionLogic } from '../fetch_analytics_collection_logic';
const tabsByFilter: Record<FilterBy, Array<{ id: ExploreTables; name: string }>> = {
[FilterBy.Searches]: [
@ -230,28 +230,22 @@ const tableSettings: {
},
};
interface AnalyticsCollectionExploreTableProps {
interface AnalyticsCollectionOverviewTableProps {
filterBy: FilterBy;
}
export const AnalyticsCollectionExploreTable: React.FC<AnalyticsCollectionExploreTableProps> = ({
export const AnalyticsCollectionOverviewTable: React.FC<AnalyticsCollectionOverviewTableProps> = ({
filterBy,
}) => {
const { euiTheme } = useEuiTheme();
const { navigateToUrl } = useValues(KibanaLogic);
const { analyticsCollection } = useValues(FetchAnalyticsCollectionLogic);
const { findDataView, setSelectedTable, setSorting } = useActions(
AnalyticsCollectionExploreTableLogic
);
const { onTableChange, setSelectedTable } = useActions(AnalyticsCollectionExploreTableLogic);
const { items, isLoading, selectedTable, sorting } = useValues(
AnalyticsCollectionExploreTableLogic
);
const tabs = tabsByFilter[filterBy];
useEffect(() => {
findDataView(analyticsCollection);
}, [analyticsCollection]);
useEffect(() => {
const firstTableInTabsId = tabs[0].id;
@ -292,7 +286,7 @@ export const AnalyticsCollectionExploreTable: React.FC<AnalyticsCollectionExplor
loading={isLoading}
sorting={table.sorting}
onChange={({ sort }) => {
setSorting(sort);
onTableChange({ sort });
}}
/>
)

View file

@ -37,7 +37,7 @@ describe('AnalyticsCollectionToolbar', () => {
events_datastream: 'test-events',
name: 'test',
} as AnalyticsCollection,
dataViewId: 'data-view-test',
dataView: { id: 'data-view-test' },
isLoading: false,
refreshInterval: { pause: false, value: 10000 },
timeRange: { from: 'now-90d', to: 'now' },
@ -93,7 +93,9 @@ describe('AnalyticsCollectionToolbar', () => {
expect(exploreInDiscoverItem).toHaveLength(1);
expect(exploreInDiscoverItem.prop('href')).toBe("/app/discover#/?_a=(index:'data-view-test')");
expect(exploreInDiscoverItem.prop('href')).toBe(
"/app/discover#/?_a=(index:'data-view-test')&_g=(filters:!(),refreshInterval:(pause:!f,value:10000),time:(from:now-90d,to:now))"
);
});
it('should correct link to the manage datastream link', () => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useActions, useValues } from 'kea';
@ -23,10 +23,12 @@ import {
import { OnTimeChangeProps } from '@elastic/eui/src/components/date_picker/super_date_picker/super_date_picker';
import { OnRefreshChangeProps } from '@elastic/eui/src/components/date_picker/types';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { RedirectAppLinks } from '@kbn/kibana-react-plugin/public';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { generateEncodedPath } from '../../../../shared/encode_path_params';
@ -34,6 +36,7 @@ import { KibanaLogic } from '../../../../shared/kibana';
import { COLLECTION_INTEGRATE_PATH } from '../../../routes';
import { DeleteAnalyticsCollectionLogic } from '../delete_analytics_collection_logic';
import { FetchAnalyticsCollectionLogic } from '../fetch_analytics_collection_logic';
import { useDiscoverLink } from '../use_discover_link';
import { AnalyticsCollectionToolbarLogic } from './analytics_collection_toolbar_logic';
@ -76,18 +79,16 @@ const defaultQuickRanges: EuiSuperDatePickerCommonRange[] = [
];
export const AnalyticsCollectionToolbar: React.FC = () => {
const discoverLink = useDiscoverLink();
const [isPopoverOpen, setPopover] = useState(false);
const { application, navigateToUrl } = useValues(KibanaLogic);
const { analyticsCollection } = useValues(FetchAnalyticsCollectionLogic);
const { setTimeRange, setRefreshInterval, findDataViewId, onTimeRefresh } = useActions(
const { setTimeRange, setRefreshInterval, onTimeRefresh } = useActions(
AnalyticsCollectionToolbarLogic
);
const { refreshInterval, timeRange, dataViewId } = useValues(AnalyticsCollectionToolbarLogic);
const { refreshInterval, timeRange } = useValues(AnalyticsCollectionToolbarLogic);
const { deleteAnalyticsCollection } = useActions(DeleteAnalyticsCollectionLogic);
const { isLoading } = useValues(DeleteAnalyticsCollectionLogic);
const discoverUrl = application.getUrlForApp('discover', {
path: `#/?_a=(index:'${dataViewId}')`,
});
const manageDatastreamUrl = application.getUrlForApp('management', {
path: '/data/index_management/data_streams/' + analyticsCollection.events_datastream,
});
@ -104,10 +105,6 @@ export const AnalyticsCollectionToolbar: React.FC = () => {
setRefreshInterval({ pause, value });
};
useEffect(() => {
if (analyticsCollection) findDataViewId(analyticsCollection);
}, [analyticsCollection]);
return (
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={false}>
@ -126,41 +123,41 @@ export const AnalyticsCollectionToolbar: React.FC = () => {
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButton iconType="arrowDown" iconSide="right" onClick={togglePopover}>
<FormattedMessage
id="xpack.enterpriseSearch.analytics.collectionsView.manageButton"
defaultMessage="Manage"
/>
</EuiButton>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
anchorPosition="downRight"
panelPaddingSize="none"
>
<EuiContextMenuPanel>
<EuiContextMenuItem
icon="link"
size="s"
data-telemetry-id={'entSearch-analytics-overview-toolbar-integrate-tracker-link'}
onClick={() =>
navigateToUrl(
generateEncodedPath(COLLECTION_INTEGRATE_PATH, {
name: analyticsCollection.name,
})
)
}
>
<FormattedMessage
id="xpack.enterpriseSearch.analytics.collectionsView.integrateTracker"
defaultMessage="Integrate JS tracker"
/>
</EuiContextMenuItem>
<RedirectAppLinks coreStart={{ application }}>
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButton iconType="arrowDown" iconSide="right" onClick={togglePopover}>
<FormattedMessage
id="xpack.enterpriseSearch.analytics.collectionsView.manageButton"
defaultMessage="Manage"
/>
</EuiButton>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
anchorPosition="downRight"
panelPaddingSize="none"
>
<EuiContextMenuPanel>
<EuiContextMenuItem
icon="link"
size="s"
data-telemetry-id={'entSearch-analytics-overview-toolbar-integrate-tracker-link'}
onClick={() =>
navigateToUrl(
generateEncodedPath(COLLECTION_INTEGRATE_PATH, {
name: analyticsCollection.name,
})
)
}
>
<FormattedMessage
id="xpack.enterpriseSearch.analytics.collectionsView.integrateTracker"
defaultMessage="Integrate JS tracker"
/>
</EuiContextMenuItem>
<RedirectAppLinks application={application}>
<EuiContextMenuItem
icon="database"
size="s"
@ -173,41 +170,45 @@ export const AnalyticsCollectionToolbar: React.FC = () => {
/>
</EuiContextMenuItem>
<EuiContextMenuItem
icon="visArea"
href={discoverUrl}
size="s"
data-telemetry-id={'entSearch-analytics-overview-toolbar-manage-discover-link'}
>
<FormattedMessage
id="xpack.enterpriseSearch.analytics.collectionsView.openInDiscover"
defaultMessage="Create dashboards in Discover"
/>
</EuiContextMenuItem>
</RedirectAppLinks>
{discoverLink && (
<EuiContextMenuItem
icon="visArea"
href={discoverLink}
size="s"
data-telemetry-id={'entSearch-analytics-overview-toolbar-manage-discover-link'}
>
<FormattedMessage
id="xpack.enterpriseSearch.analytics.collectionsView.openInDiscover"
defaultMessage="Create dashboards in Discover"
/>
</EuiContextMenuItem>
)}
<EuiPopoverFooter paddingSize="m">
<EuiButton
type="submit"
color="danger"
fullWidth
isLoading={!isLoading}
disabled={!isLoading}
data-telemetry-id={'entSearch-analytics-overview-toolbar-delete-collection-button'}
size="s"
onClick={() => {
deleteAnalyticsCollection(analyticsCollection.name);
}}
>
<FormattedMessage
id="xpack.enterpriseSearch.analytics.collections.collectionsView.delete.buttonTitle"
defaultMessage="Delete collection"
/>
</EuiButton>
</EuiPopoverFooter>
</EuiContextMenuPanel>
</EuiPopover>
</EuiFlexItem>
<EuiPopoverFooter paddingSize="m">
<EuiButton
type="submit"
color="danger"
fullWidth
isLoading={!isLoading}
disabled={!isLoading}
data-telemetry-id={
'entSearch-analytics-overview-toolbar-delete-collection-button'
}
size="s"
onClick={() => {
deleteAnalyticsCollection(analyticsCollection.name);
}}
>
<FormattedMessage
id="xpack.enterpriseSearch.analytics.collections.collectionsView.delete.buttonTitle"
defaultMessage="Delete collection"
/>
</EuiButton>
</EuiPopoverFooter>
</EuiContextMenuPanel>
</EuiPopover>
</EuiFlexItem>
</RedirectAppLinks>
</EuiFlexGroup>
);
};

View file

@ -44,7 +44,6 @@ describe('AnalyticsCollectionToolbarLogic', () => {
});
const defaultProps: AnalyticsCollectionToolbarLogicValues = {
_searchSessionId: null,
dataViewId: null,
refreshInterval: { pause: true, value: 10000 },
searchSessionId: undefined,
timeRange: { from: 'now-7d', to: 'now' },
@ -62,11 +61,6 @@ describe('AnalyticsCollectionToolbarLogic', () => {
);
});
it('sets dataViewId', () => {
AnalyticsCollectionToolbarLogic.actions.setDataViewId('sample_data_view_id');
expect(AnalyticsCollectionToolbarLogic.values.dataViewId).toEqual('sample_data_view_id');
});
it('sets refreshInterval', () => {
const refreshInterval: RefreshInterval = { pause: false, value: 5000 };
AnalyticsCollectionToolbarLogic.actions.setRefreshInterval(refreshInterval);
@ -81,14 +75,6 @@ describe('AnalyticsCollectionToolbarLogic', () => {
});
describe('listeners', () => {
it('should set dataViewId when findDataViewId called', async () => {
await AnalyticsCollectionToolbarLogic.actions.findDataViewId({
events_datastream: 'some-collection',
name: 'some-collection-name',
});
expect(AnalyticsCollectionToolbarLogic.values.dataViewId).toBe('some-data-view-id');
});
it('should set searchSessionId when onTimeRefresh called', () => {
jest.spyOn(AnalyticsCollectionToolbarLogic.actions, 'setSearchSessionId');

View file

@ -11,11 +11,9 @@ import { RefreshInterval } from '@kbn/data-plugin/common';
import { TimeRange } from '@kbn/es-query';
import { AnalyticsCollection } from '../../../../../../common/types/analytics';
import { KibanaLogic } from '../../../../shared/kibana/kibana_logic';
export interface AnalyticsCollectionToolbarLogicActions {
findDataViewId(collection: AnalyticsCollection): { collection: AnalyticsCollection };
onTimeRefresh(): void;
setDataViewId(id: string): { id: string };
setRefreshInterval(refreshInterval: RefreshInterval): RefreshInterval;
@ -27,7 +25,6 @@ export interface AnalyticsCollectionToolbarLogicActions {
export interface AnalyticsCollectionToolbarLogicValues {
// kea forbid to set undefined as a value
_searchSessionId: string | null;
dataViewId: string | null;
refreshInterval: RefreshInterval;
searchSessionId: string | undefined;
timeRange: TimeRange;
@ -40,23 +37,12 @@ export const AnalyticsCollectionToolbarLogic = kea<
MakeLogicType<AnalyticsCollectionToolbarLogicValues, AnalyticsCollectionToolbarLogicActions>
>({
actions: {
findDataViewId: (collection) => ({ collection }),
onTimeRefresh: true,
setDataViewId: (id) => ({ id }),
setRefreshInterval: ({ pause, value }) => ({ pause, value }),
setSearchSessionId: (searchSessionId) => ({ searchSessionId }),
setTimeRange: ({ from, to }) => ({ from, to }),
},
listeners: ({ actions }) => ({
findDataViewId: async ({ collection }) => {
const dataViewId = (
await KibanaLogic.values.data.dataViews.find(collection.events_datastream, 1)
)?.[0]?.id;
if (dataViewId) {
actions.setDataViewId(dataViewId);
}
},
onTimeRefresh() {
actions.setSearchSessionId(KibanaLogic.values.data.search.session.start());
},
@ -75,7 +61,6 @@ export const AnalyticsCollectionToolbarLogic = kea<
null,
{ setSearchSessionId: (state, { searchSessionId }) => searchSessionId },
],
dataViewId: [null, { setDataViewId: (_, { id }) => id }],
refreshInterval: [
DEFAULT_REFRESH_INTERVAL,
{

View file

@ -25,6 +25,10 @@ import { AddAnalyticsCollection } from '../add_analytics_collections/add_analyti
import { EnterpriseSearchAnalyticsPageTemplate } from '../layout/page_template';
import { AnalyticsCollectionDataViewLogic } from './analytics_collection_data_view_logic';
import { AnalyticsCollectionExplorer } from './analytics_collection_explorer/analytics_collection_explorer';
import { AnalyticsCollectionIntegrateView } from './analytics_collection_integrate/analytics_collection_integrate_view';
import { AnalyticsCollectionOverview } from './analytics_collection_overview/analytics_collection_overview';
import { AnalyticsCollectionToolbarLogic } from './analytics_collection_toolbar/analytics_collection_toolbar_logic';
@ -33,6 +37,7 @@ import { FetchAnalyticsCollectionLogic } from './fetch_analytics_collection_logi
export const AnalyticsCollectionView: React.FC = () => {
useMountedLogic(AnalyticsCollectionToolbarLogic);
useMountedLogic(AnalyticsCollectionDataViewLogic);
const { fetchAnalyticsCollection } = useActions(FetchAnalyticsCollectionLogic);
const { analyticsCollection, isLoading } = useValues(FetchAnalyticsCollectionLogic);
const { name } = useParams<{ name: string }>();
@ -52,7 +57,9 @@ export const AnalyticsCollectionView: React.FC = () => {
<AnalyticsCollectionIntegrateView analyticsCollection={analyticsCollection} />
</Route>
<Route exact path={COLLECTION_EXPLORER_PATH} />
<Route exact path={COLLECTION_EXPLORER_PATH}>
<AnalyticsCollectionExplorer />
</Route>
</Switch>
);
}

View file

@ -16,6 +16,7 @@ import {
} from '../../api/fetch_analytics_collection/fetch_analytics_collection_api_logic';
export interface FetchAnalyticsCollectionActions {
apiSuccess: Actions<{}, FetchAnalyticsCollectionApiLogicResponse>['apiSuccess'];
fetchAnalyticsCollection(name: string): AnalyticsCollection;
makeRequest: Actions<{}, FetchAnalyticsCollectionApiLogicResponse>['makeRequest'];
}
@ -33,7 +34,7 @@ export const FetchAnalyticsCollectionLogic = kea<
fetchAnalyticsCollection: (name) => ({ name }),
},
connect: {
actions: [FetchAnalyticsCollectionAPILogic, ['makeRequest']],
actions: [FetchAnalyticsCollectionAPILogic, ['makeRequest', 'apiSuccess']],
values: [FetchAnalyticsCollectionAPILogic, ['data', 'status']],
},
listeners: ({ actions }) => ({

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 { useValues } from 'kea';
import { KibanaLogic } from '../../../shared/kibana';
import { AnalyticsCollectionDataViewLogic } from './analytics_collection_data_view_logic';
import { AnalyticsCollectionToolbarLogic } from './analytics_collection_toolbar/analytics_collection_toolbar_logic';
export const useDiscoverLink = (): string | null => {
const { application } = useValues(KibanaLogic);
const { dataView } = useValues(AnalyticsCollectionDataViewLogic);
const { refreshInterval, timeRange } = useValues(AnalyticsCollectionToolbarLogic);
return dataView
? application.getUrlForApp('discover', {
path: `#/?_a=(index:'${
dataView.id
}')&_g=(filters:!(),refreshInterval:(pause:!${refreshInterval.pause
.toString()
.charAt(0)},value:${refreshInterval.value}),time:(from:${timeRange.from},to:${
timeRange.to
}))`,
})
: null;
};

View file

@ -93,7 +93,6 @@ const getChartStatus = (metric: number | null): ChartStatus => {
if (metric && metric < 0) return ChartStatus.DECREASE;
return ChartStatus.CONSTANT;
};
export const AnalyticsCollectionCard: React.FC<
AnalyticsCollectionCardProps & AnalyticsCollectionCardLensProps
> = ({ collection, isLoading, isCreatedByEngine, subtitle, data, metric, secondaryMetric }) => {
@ -332,6 +331,5 @@ export const AnalyticsCollectionCardWithLens = withLensData<
visualizationType: 'lnsMetric',
};
},
getDataViewQuery: ({ collection }) => collection.events_datastream,
initialValues,
});

View file

@ -13,10 +13,10 @@ import { mount, shallow } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { DataView } from '@kbn/data-views-plugin/common';
import { FormulaPublicApi } from '@kbn/lens-plugin/public';
import { findOrCreateDataView } from '../utils/find_or_create_data_view';
import { withLensData } from './with_lens_data';
interface MockComponentProps {
@ -29,6 +29,20 @@ interface MockComponentLensProps {
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
const mockCollection = {
event_retention_day_length: 180,
events_datastream: 'analytics-events-example2',
id: 'example2',
name: 'example2',
};
const mockDataView = { id: 'test-data-view-id' };
jest.mock('../utils/find_or_create_data_view', () => {
return {
findOrCreateDataView: jest.fn(),
};
});
describe('withLensData', () => {
const MockComponent: React.FC<MockComponentProps> = ({ name }) => <div>{name}</div>;
@ -48,85 +62,91 @@ describe('withLensData', () => {
return { data: 'initial data' };
}),
getAttributes: jest.fn(),
getDataViewQuery: jest.fn(),
initialValues: { data: 'initial data' },
}
);
const props = { name: 'John Doe' };
const props = { collection: mockCollection, name: 'John Doe' };
const wrapper = shallow(
<WrappedComponent id={'id'} timeRange={{ from: 'now-10d', to: 'now' }} {...props} />
);
expect(wrapper.find(MockComponent).prop('data')).toEqual('initial data');
});
it('should call getDataViewQuery with props', async () => {
const getDataViewQuery = jest.fn();
getDataViewQuery.mockReturnValue('title-collection');
const findMock = jest.spyOn(mockKibanaValues.data.dataViews, 'find').mockResolvedValueOnce([]);
it('should call findOrCreateDataView with collection', async () => {
const WrappedComponent = withLensData<MockComponentProps, MockComponentLensProps>(
MockComponent,
{
dataLoadTransform: jest.fn(),
getAttributes: jest.fn(),
getDataViewQuery,
initialValues: { data: 'initial data' },
}
);
const props = { id: 'id', name: 'John Doe', timeRange: { from: 'now-10d', to: 'now' } };
const props = {
collection: mockCollection,
id: 'id',
name: 'John Doe',
timeRange: { from: 'now-10d', to: 'now' },
};
mount(<WrappedComponent {...props} />);
await act(async () => {
await flushPromises();
});
expect(getDataViewQuery).toHaveBeenCalledWith(props);
expect(findMock).toHaveBeenCalledWith('title-collection', 1);
expect(findOrCreateDataView).toHaveBeenCalledWith(mockCollection);
});
it('should call getAttributes with the correct arguments when dataView and formula are available', async () => {
const getAttributes = jest.fn();
const dataView = {} as DataView;
const formula = {} as FormulaPublicApi;
mockKibanaValues.lens.stateHelperApi = jest.fn().mockResolvedValueOnce({ formula });
jest.spyOn(mockKibanaValues.data.dataViews, 'find').mockResolvedValueOnce([dataView]);
(findOrCreateDataView as jest.Mock).mockResolvedValueOnce(mockDataView);
const WrappedComponent = withLensData<MockComponentProps, MockComponentLensProps>(
MockComponent,
{
dataLoadTransform: jest.fn(),
getAttributes,
getDataViewQuery: jest.fn(),
initialValues: { data: 'initial data' },
}
);
const props = { id: 'id', name: 'John Doe', timeRange: { from: 'now-10d', to: 'now' } };
const props = {
collection: mockCollection,
id: 'id',
name: 'John Doe',
timeRange: { from: 'now-10d', to: 'now' },
};
mount(<WrappedComponent {...props} />);
await act(async () => {
await flushPromises();
});
expect(getAttributes).toHaveBeenCalledWith(dataView, formula, props);
expect(getAttributes).toHaveBeenCalledWith(mockDataView, formula, props);
});
it('should not call getAttributes when dataView is not available', async () => {
const getAttributes = jest.fn();
const formula = {} as FormulaPublicApi;
mockKibanaValues.lens.stateHelperApi = jest.fn().mockResolvedValueOnce({ formula });
jest.spyOn(mockKibanaValues.data.dataViews, 'find').mockResolvedValueOnce([]);
(findOrCreateDataView as jest.Mock).mockResolvedValueOnce(undefined);
const WrappedComponent = withLensData<MockComponentProps, MockComponentLensProps>(
MockComponent,
{
dataLoadTransform: jest.fn(),
getAttributes,
getDataViewQuery: jest.fn(),
initialValues: { data: 'initial data' },
}
);
const props = { id: 'id', name: 'John Doe', timeRange: { from: 'now-10d', to: 'now' } };
const props = {
collection: mockCollection,
id: 'id',
name: 'John Doe',
timeRange: { from: 'now-10d', to: 'now' },
};
mount(<WrappedComponent {...props} />);
await act(async () => {
await flushPromises();

View file

@ -17,9 +17,13 @@ import { TimeRange } from '@kbn/es-query';
import { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common';
import { FormulaPublicApi, TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { AnalyticsCollection } from '../../../../common/types/analytics';
import { KibanaLogic } from '../../shared/kibana';
import { findOrCreateDataView } from '../utils/find_or_create_data_view';
export interface WithLensDataInputProps {
collection: AnalyticsCollection;
id: string;
searchSessionId?: string;
setTimeRange?(timeRange: TimeRange): void;
@ -36,7 +40,6 @@ interface WithLensDataParams<Props, OutputState> {
formulaApi: FormulaPublicApi,
props: Props
) => TypedLensByValueInput['attributes'];
getDataViewQuery: (props: Props) => string;
initialValues: OutputState;
}
@ -45,14 +48,12 @@ export const withLensData = <T extends {} = {}, OutputState extends {} = {}>(
{
dataLoadTransform,
getAttributes,
getDataViewQuery,
initialValues,
}: WithLensDataParams<Omit<T, keyof OutputState>, OutputState>
) => {
const ComponentWithLensData: React.FC<T & WithLensDataInputProps> = (props) => {
const {
lens: { EmbeddableComponent, stateHelperApi },
data: { dataViews },
} = useValues(KibanaLogic);
const [dataView, setDataView] = useState<DataView | null>(null);
const [data, setData] = useState<OutputState>(initialValues);
@ -73,11 +74,7 @@ export const withLensData = <T extends {} = {}, OutputState extends {} = {}>(
useEffect(() => {
(async () => {
const [target] = await dataViews.find(getDataViewQuery(props), 1);
if (target) {
setDataView(target);
}
setDataView(await findOrCreateDataView(props.collection));
})();
}, [props]);
useEffect(() => {

View file

@ -0,0 +1,58 @@
/*
* 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 { DataView } from '@kbn/data-views-plugin/common';
import { AnalyticsCollection } from '../../../../common/types/analytics';
import { KibanaLogic } from '../../shared/kibana/kibana_logic';
import { findOrCreateDataView } from './find_or_create_data_view';
jest.mock('../../shared/kibana/kibana_logic', () => ({
KibanaLogic: {
values: {
data: {
dataViews: {
createAndSave: jest.fn(),
find: jest.fn(() => Promise.resolve([])),
},
},
},
},
}));
describe('findOrCreateDataView', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should find and set dataView when analytics collection fetched', async () => {
const dataView = { id: 'test', title: 'events1' } as DataView;
jest.spyOn(KibanaLogic.values.data.dataViews, 'find').mockResolvedValueOnce([dataView]);
expect(
await findOrCreateDataView({
events_datastream: 'events1',
name: 'collection1',
} as AnalyticsCollection)
).toEqual(dataView);
expect(KibanaLogic.values.data.dataViews.createAndSave).not.toHaveBeenCalled();
});
it('should create, save and set dataView when analytics collection fetched but dataView is not found', async () => {
const dataView = { id: 'test21' } as DataView;
jest.spyOn(KibanaLogic.values.data.dataViews, 'createAndSave').mockResolvedValueOnce(dataView);
expect(
await findOrCreateDataView({
events_datastream: 'events1',
name: 'collection1',
} as AnalyticsCollection)
).toEqual(dataView);
expect(KibanaLogic.values.data.dataViews.createAndSave).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,29 @@
/*
* 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 { AnalyticsCollection } from '../../../../common/types/analytics';
import { KibanaLogic } from '../../shared/kibana/kibana_logic';
export const findOrCreateDataView = async (collection: AnalyticsCollection) => {
const dataView = (
await KibanaLogic.values.data.dataViews.find(collection.events_datastream, 1)
).find((result) => result.title === collection.events_datastream);
if (dataView) {
return dataView;
}
return await KibanaLogic.values.data.dataViews.createAndSave(
{
allowNoIndex: true,
name: `behavioral_analytics.events-${collection.name}`,
timeFieldName: '@timestamp',
title: collection.events_datastream,
},
true
);
};

View file

@ -58,5 +58,6 @@
"@kbn/react-field",
"@kbn/field-types",
"@kbn/core-elasticsearch-server-mocks",
"@kbn/shared-ux-link-redirect-app",
]
}