[Enterprise Search] [Behavioral analytics] Create dashboard details tables (#154790)

 Created logic for fetch explore items
 UI for tables
 Add tables 'Popular search terms', 'Worse performers', 'Top clicked
results', 'Top referrers'
<img width="1296" alt="image"
src="https://user-images.githubusercontent.com/17390745/231310342-2bc9b776-362e-4d8c-bd1c-0944e94216c9.png">
<img width="1296" alt="image"
src="https://user-images.githubusercontent.com/17390745/231310360-831d4f6c-feab-4b94-9d8c-67218d6b0557.png">
<img width="1296" alt="image"
src="https://user-images.githubusercontent.com/17390745/231310408-92bda734-6783-433d-a752-99025c30c446.png">
This commit is contained in:
Yan Savitski 2023-04-12 11:54:47 +02:00 committed by GitHub
parent 41170fe64b
commit 0790ec0da2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 1179 additions and 191 deletions

View file

@ -0,0 +1,66 @@
/*
* 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 { 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';
describe('AnalyticsCollectionExploreTable', () => {
const mockValues = {
activeTableId: 'search_terms',
analyticsCollection: {
events_datastream: 'analytics-events-example',
name: 'Analytics-Collection-1',
},
items: [],
searchFilter: 'searches',
};
const mockActions = {
findDataView: jest.fn(),
setSelectedTable: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(mockValues);
setMockActions(mockActions);
});
it('should call setSelectedTable with the correct table id when a tab is clicked', () => {
const wrapper = shallow(<AnalyticsCollectionExploreTable filterBy={FilterBy.Sessions} />);
const topReferrersTab = wrapper.find(EuiTab).at(0);
topReferrersTab.simulate('click');
expect(mockActions.setSelectedTable).toHaveBeenCalledTimes(1);
expect(mockActions.setSelectedTable).toHaveBeenCalledWith(ExploreTables.TopReferrers, {
direction: 'desc',
field: 'sessions',
});
});
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} />);
expect(wrapper.find(EuiBasicTable).prop('itemId')).toBe(ExploreTables.WorsePerformers);
});
});

View file

@ -0,0 +1,321 @@
/*
* 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, useMemo } from 'react';
import { useActions, useValues } from 'kea';
import {
EuiBasicTable,
EuiBasicTableColumn,
EuiButton,
EuiFlexGroup,
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 { 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 {
ExploreTableColumns,
ExploreTableItem,
ExploreTables,
SearchTermsTable,
TopClickedTable,
TopReferrersTable,
WorsePerformersTable,
} from './analytics_collection_explore_table_types';
const tabsByFilter: Record<FilterBy, Array<{ id: ExploreTables; name: string }>> = {
[FilterBy.Searches]: [
{
id: ExploreTables.SearchTerms,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTab.searchTerms',
{ defaultMessage: 'Popular search terms' }
),
},
],
[FilterBy.NoResults]: [
{
id: ExploreTables.WorsePerformers,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTab.worsePerformers',
{ defaultMessage: 'Worse performers' }
),
},
],
[FilterBy.Clicks]: [
{
id: ExploreTables.TopClicked,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTab.topClicked',
{ defaultMessage: 'Top clicked results' }
),
},
],
[FilterBy.Sessions]: [
{
id: ExploreTables.TopReferrers,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTab.topReferrers',
{ defaultMessage: 'Top referrers' }
),
},
],
};
interface TableSetting<T = ExploreTableItem, K = T> {
columns: Array<
EuiBasicTableColumn<T & K> & {
render?: (euiTheme: UseEuiTheme['euiTheme']) => EuiTableFieldDataColumnType<T & K>['render'];
}
>;
sorting: EuiTableSortingType<T>;
}
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' }
),
truncateText: true,
},
{
align: 'right',
field: ExploreTableColumns.count,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.count',
{ defaultMessage: 'Count' }
),
sortable: true,
truncateText: true,
},
],
sorting: {
readOnly: true,
sort: {
direction: 'desc',
field: ExploreTableColumns.count,
},
},
},
[ExploreTables.WorsePerformers]: {
columns: [
{
field: ExploreTableColumns.query,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.query',
{ defaultMessage: 'Query' }
),
truncateText: true,
},
{
align: 'right',
field: ExploreTableColumns.count,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.count',
{ defaultMessage: 'Count' }
),
sortable: true,
truncateText: true,
},
],
sorting: {
readOnly: true,
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>
),
truncateText: true,
},
{
align: 'right',
field: ExploreTableColumns.count,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.count',
{ defaultMessage: 'Count' }
),
sortable: true,
truncateText: true,
},
],
sorting: {
readOnly: true,
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>
),
truncateText: true,
},
{
align: 'right',
field: ExploreTableColumns.sessions,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.exploreTable.session',
{ defaultMessage: 'Session' }
),
sortable: true,
truncateText: true,
},
],
sorting: {
readOnly: true,
sort: {
direction: 'desc',
field: ExploreTableColumns.sessions,
},
},
},
};
interface AnalyticsCollectionExploreTableProps {
filterBy: FilterBy;
}
export const AnalyticsCollectionExploreTable: React.FC<AnalyticsCollectionExploreTableProps> = ({
filterBy,
}) => {
const { euiTheme } = useEuiTheme();
const { navigateToUrl } = useValues(KibanaLogic);
const { analyticsCollection } = useValues(FetchAnalyticsCollectionLogic);
const { findDataView, setSelectedTable, setSorting } = useActions(
AnalyticsCollectionExploreTableLogic
);
const { items, isLoading, selectedTable, sorting } = useValues(
AnalyticsCollectionExploreTableLogic
);
const tabs = tabsByFilter[filterBy];
useEffect(() => {
findDataView(analyticsCollection);
}, [analyticsCollection]);
useEffect(() => {
const firstTableInTabsId = tabs[0].id;
setSelectedTable(
firstTableInTabsId,
(tableSettings[firstTableInTabsId] as TableSetting)?.sorting?.sort
);
}, [tabs]);
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>
{useMemo(() => {
const table = selectedTable !== null && (tableSettings[selectedTable] as TableSetting);
return (
table && (
<EuiBasicTable
columns={
table.columns.map((column) => ({
...column,
render: column.render?.(euiTheme),
})) as TableSetting['columns']
}
itemId={selectedTable}
items={items}
loading={isLoading}
sorting={table.sorting}
onChange={({ sort }) => {
setSorting(sort);
}}
/>
)
);
}, [selectedTable, sorting, items, isLoading])}
<EuiFlexGroup>
<EuiButton
fill
onClick={() =>
navigateToUrl(
generateEncodedPath(COLLECTION_EXPLORER_PATH, {
name: analyticsCollection.name,
})
)
}
>
<FormattedMessage
id="xpack.enterpriseSearch.analytics.collections.collectionsView.list.exploreButton"
defaultMessage="Explore all"
/>
</EuiButton>
</EuiFlexGroup>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,149 @@
/*
* 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

@ -0,0 +1,333 @@
/*
* 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,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.
*/
export enum ExploreTables {
SearchTerms,
WorsePerformers,
TopClicked,
TopReferrers,
}
export enum ExploreTableColumns {
count = 'count',
searchTerms = 'searchTerms',
query = 'query',
page = 'page',
sessions = 'sessions',
}
export interface SearchTermsTable {
[ExploreTableColumns.count]: number;
[ExploreTableColumns.searchTerms]: string;
}
export interface WorsePerformersTable {
[ExploreTableColumns.count]: number;
[ExploreTableColumns.query]: string;
}
export interface TopClickedTable {
[ExploreTableColumns.count]: number;
[ExploreTableColumns.page]: string;
}
export interface TopReferrersTable {
[ExploreTableColumns.page]: string;
[ExploreTableColumns.sessions]: number;
}
export type ExploreTableItem =
| SearchTermsTable
| WorsePerformersTable
| TopClickedTable
| TopReferrersTable;

View file

@ -1,55 +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 React from 'react';
import { useActions, useValues } from 'kea';
import { i18n } from '@kbn/i18n';
import { AnalyticsCollection } from '../../../../../common/types/analytics';
import { EnterpriseSearchAnalyticsPageTemplate } from '../layout/page_template';
import { AnalyticsCollectionChartWithLens } from './analytics_collection_chart';
import { AnalyticsCollectionToolbar } from './analytics_collection_toolbar/analytics_collection_toolbar';
import { AnalyticsCollectionToolbarLogic } from './analytics_collection_toolbar/analytics_collection_toolbar_logic';
interface AnalyticsCollectionOverviewProps {
analyticsCollection: AnalyticsCollection;
}
export const AnalyticsCollectionOverview: React.FC<AnalyticsCollectionOverviewProps> = ({
analyticsCollection,
}) => {
const { setTimeRange } = useActions(AnalyticsCollectionToolbarLogic);
const { timeRange, searchSessionId } = useValues(AnalyticsCollectionToolbarLogic);
return (
<EnterpriseSearchAnalyticsPageTemplate
restrictWidth
pageChrome={[analyticsCollection?.name]}
analyticsName={analyticsCollection?.name}
pageViewTelemetry={`View Analytics Collection - Overview`}
pageHeader={{
bottomBorder: false,
pageTitle: i18n.translate('xpack.enterpriseSearch.analytics.collectionsView.title', {
defaultMessage: 'Overview',
}),
rightSideItems: [<AnalyticsCollectionToolbar />],
}}
>
<AnalyticsCollectionChartWithLens
id={'analytics-collection-chart-' + analyticsCollection.name}
dataViewQuery={analyticsCollection.events_datastream}
timeRange={timeRange}
setTimeRange={setTimeRange}
searchSessionId={searchSessionId}
/>
</EnterpriseSearchAnalyticsPageTemplate>
);
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { setMockValues } from '../../../__mocks__/kea_logic';
import { setMockValues } from '../../../../__mocks__/kea_logic';
import React from 'react';
@ -16,7 +16,7 @@ import moment from 'moment';
import { AreaSeries, Chart } from '@elastic/charts';
import { EuiLoadingChart } from '@elastic/eui';
import { FilterBy } from '../../utils/get_formula_by_filter';
import { FilterBy } from '../../../utils/get_formula_by_filter';
import { AnalyticsCollectionChart } from './analytics_collection_chart';
@ -42,6 +42,8 @@ describe('AnalyticsCollectionChart', () => {
dataViewQuery: mockedDataViewQuery,
id: 'mockedId',
isLoading: false,
selectedChart: FilterBy.Searches,
setSelectedChart: jest.fn(),
timeRange: mockedTimeRange,
};

View file

@ -33,12 +33,10 @@ import { DateHistogramIndexPatternColumn, TypedLensByValueInput } from '@kbn/len
import { euiThemeVars } from '@kbn/ui-theme';
import { KibanaLogic } from '../../../shared/kibana';
import { KibanaLogic } from '../../../../shared/kibana';
import { withLensData, WithLensDataInputProps } from '../../hoc/with_lens_data';
import { FilterBy as ChartIds, getFormulaByFilter } from '../../utils/get_formula_by_filter';
import { AnalyticsCollectionViewMetricWithLens } from './analytics_collection_metric';
import { withLensData, WithLensDataInputProps } from '../../../hoc/with_lens_data';
import { FilterBy, getFormulaByFilter } from '../../../utils/get_formula_by_filter';
const DEFAULT_STROKE_WIDTH = 1;
const HOVER_STROKE_WIDTH = 3;
@ -46,33 +44,33 @@ const CHART_HEIGHT = 490;
interface AnalyticsCollectionChartProps extends WithLensDataInputProps {
dataViewQuery: string;
selectedChart: FilterBy | null;
setSelectedChart(chart: FilterBy): void;
}
interface AnalyticsCollectionChartLensProps {
data: {
[key in ChartIds]?: Array<[number, number]>;
[key in FilterBy]?: Array<[number, number]>;
};
isLoading: boolean;
}
export const AnalyticsCollectionChart: React.FC<
AnalyticsCollectionChartProps & AnalyticsCollectionChartLensProps
> = ({ id: lensId, data, timeRange, dataViewQuery, isLoading, searchSessionId }) => {
> = ({ data, timeRange, isLoading, selectedChart, setSelectedChart }) => {
const [currentData, setCurrentData] = useState(data);
const [hoverChart, setHoverChart] = useState<ChartIds | null>(null);
const [selectedChart, setSelectedChart] = useState<ChartIds>(ChartIds.Searches);
const [hoverChart, setHoverChart] = useState<FilterBy | null>(null);
const { uiSettings, charts: chartSettings } = useValues(KibanaLogic);
const fromDateParsed = DateMath.parse(timeRange.from);
const toDataParsed = DateMath.parse(timeRange.to);
const chartTheme = chartSettings.theme.useChartsTheme();
const baseChartTheme = chartSettings.theme.useChartsBaseTheme();
const charts = useMemo(
() => [
{
chartColor: euiThemeVars.euiColorVis0,
data: currentData[ChartIds.Searches] || [],
id: ChartIds.Searches,
data: currentData[FilterBy.Searches] || [],
id: FilterBy.Searches,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.charts.searches',
{
@ -82,8 +80,8 @@ export const AnalyticsCollectionChart: React.FC<
},
{
chartColor: euiThemeVars.euiColorVis2,
data: currentData[ChartIds.NoResults] || [],
id: ChartIds.NoResults,
data: currentData[FilterBy.NoResults] || [],
id: FilterBy.NoResults,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.charts.noResults',
{
@ -93,8 +91,8 @@ export const AnalyticsCollectionChart: React.FC<
},
{
chartColor: euiThemeVars.euiColorVis3,
data: currentData[ChartIds.Clicks] || [],
id: ChartIds.Clicks,
data: currentData[FilterBy.Clicks] || [],
id: FilterBy.Clicks,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.charts.clicks',
{
@ -104,8 +102,8 @@ export const AnalyticsCollectionChart: React.FC<
},
{
chartColor: euiThemeVars.euiColorVis5,
data: currentData[ChartIds.Sessions] || [],
id: ChartIds.Sessions,
data: currentData[FilterBy.Sessions] || [],
id: FilterBy.Sessions,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.charts.sessions',
{
@ -123,111 +121,82 @@ export const AnalyticsCollectionChart: React.FC<
}
}, [data]);
return (
<EuiFlexGroup direction="column">
<EuiFlexGroup gutterSize="m">
{charts.map(({ name, id }) => (
<AnalyticsCollectionViewMetricWithLens
key={id}
id={`${lensId}-metric-${id}`}
isSelected={selectedChart === id}
name={name}
onClick={(event) => {
event.currentTarget?.blur();
setSelectedChart(id);
}}
searchSessionId={searchSessionId}
timeRange={timeRange}
dataViewQuery={dataViewQuery}
getFormula={getFormulaByFilter.bind(null, id)}
/>
))}
</EuiFlexGroup>
{isLoading && Object.keys(currentData).length === 0 ? (
<EuiFlexGroup alignItems="center" justifyContent="center" css={{ height: CHART_HEIGHT }}>
<EuiLoadingChart size="l" />
</EuiFlexGroup>
) : (
<Chart size={['100%', CHART_HEIGHT]}>
<Settings
theme={chartTheme}
baseTheme={baseChartTheme}
showLegend={false}
onElementClick={(elements) => {
const chartId = (elements as XYChartElementEvent[])[0][1]?.specId;
if (chartId) {
setSelectedChart(chartId as ChartIds);
}
}}
onElementOver={(elements) => {
const chartId = (elements as XYChartElementEvent[])[0][1]?.specId;
if (chartId) {
setHoverChart(chartId as ChartIds);
}
}}
onElementOut={() => setHoverChart(null)}
/>
{charts.map(({ data: chartData, id, name, chartColor }) => (
<AreaSeries
id={id}
key={id}
name={name}
data={chartData}
color={chartColor}
xAccessor={0}
yAccessors={[1]}
areaSeriesStyle={{
area: {
opacity: 0.2,
visible: selectedChart === id,
},
line: {
opacity: selectedChart === id ? 1 : 0.5,
strokeWidth: [hoverChart, selectedChart].includes(id)
? HOVER_STROKE_WIDTH
: DEFAULT_STROKE_WIDTH,
},
}}
yNice
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Sqrt}
curve={CurveType.CURVE_MONOTONE_X}
/>
))}
<Axis
id="bottom-axis"
position={Position.Bottom}
tickFormat={
fromDateParsed && toDataParsed
? niceTimeFormatter([fromDateParsed.valueOf(), toDataParsed.valueOf()])
: undefined
}
gridLine={{ visible: true }}
/>
<Axis
gridLine={{ dash: [], visible: true }}
hide
id="left-axis"
position={Position.Left}
/>
<Tooltip
headerFormatter={(tooltipData) =>
moment(tooltipData.value).format(uiSettings.get('dateFormat'))
}
maxTooltipItems={1}
type={TooltipType.VerticalCursor}
/>
</Chart>
)}
return isLoading && Object.keys(currentData).length === 0 ? (
<EuiFlexGroup alignItems="center" justifyContent="center" css={{ height: CHART_HEIGHT }}>
<EuiLoadingChart size="l" />
</EuiFlexGroup>
) : (
<Chart size={['100%', CHART_HEIGHT]}>
<Settings
theme={chartTheme}
baseTheme={baseChartTheme}
showLegend={false}
onElementClick={(elements) => {
const chartId = (elements as XYChartElementEvent[])[0][1]?.specId;
if (chartId) {
setSelectedChart(chartId as FilterBy);
}
}}
onElementOver={(elements) => {
const chartId = (elements as XYChartElementEvent[])[0][1]?.specId;
if (chartId) {
setHoverChart(chartId as FilterBy);
}
}}
onElementOut={() => setHoverChart(null)}
/>
{charts.map(({ data: chartData, id, name, chartColor }) => (
<AreaSeries
id={id}
key={id}
name={name}
data={chartData}
color={chartColor}
xAccessor={0}
yAccessors={[1]}
areaSeriesStyle={{
area: {
opacity: 0.2,
visible: selectedChart === id,
},
line: {
opacity: selectedChart === id ? 1 : 0.5,
strokeWidth: [hoverChart, selectedChart].includes(id)
? HOVER_STROKE_WIDTH
: DEFAULT_STROKE_WIDTH,
},
}}
yNice
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Sqrt}
curve={CurveType.CURVE_MONOTONE_X}
/>
))}
<Axis
id="bottom-axis"
position={Position.Bottom}
tickFormat={
fromDateParsed && toDataParsed
? niceTimeFormatter([fromDateParsed.valueOf(), toDataParsed.valueOf()])
: undefined
}
gridLine={{ visible: true }}
/>
<Axis gridLine={{ dash: [], visible: true }} hide id="left-axis" position={Position.Left} />
<Tooltip
headerFormatter={(tooltipData) =>
moment(tooltipData.value).format(uiSettings.get('dateFormat'))
}
maxTooltipItems={1}
type={TooltipType.VerticalCursor}
/>
</Chart>
);
};
@ -235,8 +204,8 @@ const initialValues: AnalyticsCollectionChartLensProps = {
data: {},
isLoading: true,
};
const LENS_LAYERS: Array<{ formula: string; id: ChartIds; x: string; y: string }> = Object.values(
ChartIds
const LENS_LAYERS: Array<{ formula: string; id: FilterBy; x: string; y: string }> = Object.values(
FilterBy
).map((id) => ({ formula: getFormulaByFilter(id), id, x: 'timeline', y: 'values' }));
export const AnalyticsCollectionChartWithLens = withLensData<

View file

@ -24,7 +24,7 @@ import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types';
import { i18n } from '@kbn/i18n';
import { euiThemeVars } from '@kbn/ui-theme';
import { withLensData } from '../../hoc/with_lens_data';
import { withLensData } from '../../../hoc/with_lens_data';
enum MetricStatus {
INCREASE = 'increase',

View file

@ -5,20 +5,22 @@
* 2.0.
*/
import '../../../__mocks__/shallow_useeffect.mock';
import '../../../../__mocks__/shallow_useeffect.mock';
import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic';
import { mockUseParams } from '../../../__mocks__/react_router';
import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsCollection } from '../../../../../common/types/analytics';
import { EnterpriseSearchAnalyticsPageTemplate } from '../layout/page_template';
import { AnalyticsCollection } from '../../../../../../common/types/analytics';
import { FilterBy } from '../../../utils/get_formula_by_filter';
import { EnterpriseSearchAnalyticsPageTemplate } from '../../layout/page_template';
import { AnalyticsCollectionChartWithLens } from './analytics_collection_chart';
import { AnalyticsCollectionViewMetricWithLens } from './analytics_collection_metric';
import { AnalyticsCollectionOverview } from './analytics_collection_overview';
const mockValues = {
@ -42,8 +44,6 @@ const mockActions = {
describe('AnalyticsOverView', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseParams.mockReturnValue({ name: '1', section: 'settings' });
});
it('renders with Data', async () => {
@ -89,6 +89,8 @@ describe('AnalyticsOverView', () => {
dataViewQuery: 'analytics-events-example',
id: 'analytics-collection-chart-Analytics-Collection-1',
searchSessionId: 'session-id',
selectedChart: 'Searches',
setSelectedChart: expect.any(Function),
setTimeRange: mockActions.setTimeRange,
timeRange: {
from: 'now-90d',
@ -96,4 +98,27 @@ describe('AnalyticsOverView', () => {
},
});
});
it('displays all filter options', () => {
const wrapper = shallow(
<AnalyticsCollectionOverview analyticsCollection={mockValues.analyticsCollection} />
);
const filterOptions = wrapper.find(AnalyticsCollectionViewMetricWithLens);
expect(filterOptions).toHaveLength(4);
expect(filterOptions.at(0).props().name).toEqual('Searches');
expect(filterOptions.at(1).props().name).toEqual('No results');
expect(filterOptions.at(2).props().name).toEqual('Click');
expect(filterOptions.at(3).props().name).toEqual('Sessions');
});
it('updates the selected chart when a filter option is clicked', () => {
const wrapper = shallow(
<AnalyticsCollectionOverview analyticsCollection={mockValues.analyticsCollection} />
);
const filterOption = wrapper.find(AnalyticsCollectionViewMetricWithLens).at(1);
filterOption.simulate('click', {});
expect(wrapper.find(AnalyticsCollectionChartWithLens).props().selectedChart).toEqual(
FilterBy.NoResults
);
});
});

View file

@ -0,0 +1,128 @@
/*
* 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, { useState } from 'react';
import { useActions, useValues } from 'kea';
import { EuiFlexGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AnalyticsCollection } from '../../../../../../common/types/analytics';
import { FilterBy, getFormulaByFilter } from '../../../utils/get_formula_by_filter';
import { EnterpriseSearchAnalyticsPageTemplate } from '../../layout/page_template';
import { AnalyticsCollectionExploreTable } from '../analytics_collection_explore_table/analytics_collection_explore_table';
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';
const filters = [
{
id: FilterBy.Searches,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.charts.searches',
{
defaultMessage: 'Searches',
}
),
},
{
id: FilterBy.NoResults,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.charts.noResults',
{
defaultMessage: 'No results',
}
),
},
{
id: FilterBy.Clicks,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.charts.clicks',
{
defaultMessage: 'Click',
}
),
},
{
id: FilterBy.Sessions,
name: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.collectionsView.charts.sessions',
{
defaultMessage: 'Sessions',
}
),
},
];
interface AnalyticsCollectionOverviewProps {
analyticsCollection: AnalyticsCollection;
}
export const AnalyticsCollectionOverview: React.FC<AnalyticsCollectionOverviewProps> = ({
analyticsCollection,
}) => {
const { setTimeRange } = useActions(AnalyticsCollectionToolbarLogic);
const { timeRange, searchSessionId } = useValues(AnalyticsCollectionToolbarLogic);
const [filterBy, setFilterBy] = useState<FilterBy>(FilterBy.Searches);
return (
<EnterpriseSearchAnalyticsPageTemplate
restrictWidth
pageChrome={[analyticsCollection?.name]}
analyticsName={analyticsCollection?.name}
pageViewTelemetry={`View Analytics Collection - Overview`}
pageHeader={{
bottomBorder: false,
pageTitle: i18n.translate('xpack.enterpriseSearch.analytics.collectionsView.title', {
defaultMessage: 'Overview',
}),
rightSideItems: [<AnalyticsCollectionToolbar />],
}}
>
<EuiFlexGroup direction="column">
<EuiFlexGroup gutterSize="m">
{filters.map(({ name, id }) => (
<AnalyticsCollectionViewMetricWithLens
key={id}
id={`analytics-collection-metric-${analyticsCollection.name}-${id}`}
isSelected={filterBy === id}
name={name}
onClick={(event) => {
event.currentTarget?.blur();
setFilterBy(id);
}}
dataViewQuery={analyticsCollection.events_datastream}
timeRange={timeRange}
searchSessionId={searchSessionId}
getFormula={getFormulaByFilter.bind(null, id)}
/>
))}
</EuiFlexGroup>
<AnalyticsCollectionChartWithLens
id={'analytics-collection-chart-' + analyticsCollection.name}
dataViewQuery={analyticsCollection.events_datastream}
timeRange={timeRange}
setTimeRange={setTimeRange}
searchSessionId={searchSessionId}
selectedChart={filterBy}
setSelectedChart={setFilterBy}
/>
<AnalyticsCollectionExploreTable filterBy={filterBy} />
</EuiFlexGroup>
</EnterpriseSearchAnalyticsPageTemplate>
);
};

View file

@ -9,9 +9,10 @@ import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Switch } from 'react-router-dom';
import { useActions, useValues } from 'kea';
import { useActions, useMountedLogic, useValues } from 'kea';
import { EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Route } from '@kbn/shared-ux-router';
@ -25,11 +26,13 @@ import { AddAnalyticsCollection } from '../add_analytics_collections/add_analyti
import { EnterpriseSearchAnalyticsPageTemplate } from '../layout/page_template';
import { AnalyticsCollectionIntegrateView } from './analytics_collection_integrate/analytics_collection_integrate_view';
import { AnalyticsCollectionOverview } from './analytics_collection_overview';
import { AnalyticsCollectionOverview } from './analytics_collection_overview/analytics_collection_overview';
import { AnalyticsCollectionToolbarLogic } from './analytics_collection_toolbar/analytics_collection_toolbar_logic';
import { FetchAnalyticsCollectionLogic } from './fetch_analytics_collection_logic';
export const AnalyticsCollectionView: React.FC = () => {
useMountedLogic(AnalyticsCollectionToolbarLogic);
const { fetchAnalyticsCollection } = useActions(FetchAnalyticsCollectionLogic);
const { analyticsCollection, isLoading } = useValues(FetchAnalyticsCollectionLogic);
const { name } = useParams<{ name: string }>();