[Enterprise Search] [Analytics] Analytics Collection list view (#139554)

Implements the analytics collection list view.

Co-authored-by: Casey Zumwalt <casey.zumwalt@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Joseph McElroy 2022-08-31 13:11:25 +01:00 committed by GitHub
parent dc43193d73
commit 48ad65f992
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 968 additions and 3 deletions

View file

@ -135,6 +135,7 @@ export const applicationUsageSchema = {
canvas: commonSchema,
enterpriseSearch: commonSchema,
enterpriseSearchContent: commonSchema,
enterpriseSearchAnalytics: commonSchema,
elasticsearch: commonSchema,
appSearch: commonSchema,
workplaceSearch: commonSchema,

View file

@ -2354,6 +2354,137 @@
}
}
},
"enterpriseSearchAnalytics": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "Always `main`"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 90 days"
}
},
"views": {
"type": "array",
"items": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "The application view being tracked"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application sub view since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application sub view is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 90 days"
}
}
}
}
}
}
},
"elasticsearch": {
"properties": {
"appId": {

View file

@ -39,6 +39,23 @@ export const ENTERPRISE_SEARCH_CONTENT_PLUGIN = {
SUPPORT_URL: 'https://discuss.elastic.co/c/enterprise-search/',
};
export const ANALYTICS_PLUGIN = {
ID: 'enterpriseSearchAnalytics',
NAME: i18n.translate('xpack.enterpriseSearch.analytics.productName', {
defaultMessage: 'Analytics',
}),
DESCRIPTION: i18n.translate('xpack.enterpriseSearch.analytics.productDescription', {
defaultMessage:
'Dashboards and tools for visualizing end-user behavior and measuring the performance of your search applications.',
}),
CARD_DESCRIPTION: i18n.translate('xpack.enterpriseSearch.analytics.productCardDescription', {
defaultMessage:
'Dashboards and tools for visualizing end-user behavior and measuring the performance of your search applications.',
}),
URL: '/app/enterprise_search/analytics',
SUPPORT_URL: 'https://discuss.elastic.co/c/enterprise-search/',
};
export const ELASTICSEARCH_PLUGIN = {
ID: 'elasticsearch',
NAME: i18n.translate('xpack.enterpriseSearch.elasticsearch.productName', {

View file

@ -0,0 +1,30 @@
/*
* 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 { mockHttpValues } from '../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { fetchAnalyticsCollections } from './fetch_analytics_collections_api_logic';
describe('FetchAnalyticsCollectionsApiLogic', () => {
const { http } = mockHttpValues;
beforeEach(() => {
jest.clearAllMocks();
});
describe('FetchAnalyticsCollectionsApiLogic', () => {
it('calls the analytics collections list api', async () => {
const promise = Promise.resolve([{ name: 'result' }]);
http.get.mockReturnValue(promise);
const result = fetchAnalyticsCollections();
await nextTick();
expect(http.get).toHaveBeenCalledWith('/internal/enterprise_search/analytics/collections');
await expect(result).resolves.toEqual([{ name: 'result' }]);
});
});
});

View file

@ -0,0 +1,24 @@
/*
* 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 { createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export const fetchAnalyticsCollections = async () => {
const { http } = HttpLogic.values;
const route = '/internal/enterprise_search/analytics/collections';
const response = await http.get<AnalyticsCollection[]>(route);
return response;
};
export const FetchAnalyticsCollectionsAPILogic = createApiLogic(
['analytics', 'analytics_collections_api_logic'],
fetchAnalyticsCollections
);

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import '../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiBasicTable } from '@elastic/eui';
import { AnalyticsCollection } from '../../../../../common/types/analytics';
import { EuiLinkTo } from '../../../shared/react_router_helpers';
import { AnalyticsCollectionTable } from './analytics_collection_table';
describe('AnalyticsCollectionTable', () => {
const analyticsCollections: AnalyticsCollection[] = [
{ event_retention_day_length: 180, id: '1', name: 'example' },
];
beforeEach(() => {
jest.clearAllMocks();
});
it('renders and provides navigation to the view detail pages', () => {
const wrapper = shallow(
<AnalyticsCollectionTable collections={analyticsCollections} isLoading={false} />
);
expect(wrapper.find(EuiBasicTable)).toHaveLength(1);
const rows = wrapper.find(EuiBasicTable).prop('items');
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject(analyticsCollections[0]);
expect(wrapper.dive().find(EuiLinkTo).first().prop('to')).toBe('/collections/example');
});
});

View file

@ -0,0 +1,76 @@
/*
* 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 { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AnalyticsCollection } from '../../../../../common/types/analytics';
import { generateEncodedPath } from '../../../shared/encode_path_params';
import { KibanaLogic } from '../../../shared/kibana';
import { EuiLinkTo } from '../../../shared/react_router_helpers';
import { COLLECTION_VIEW_PATH } from '../../routes';
interface AnalyticsCollectionTableProps {
collections: AnalyticsCollection[];
isLoading: boolean;
}
export const AnalyticsCollectionTable: React.FC<AnalyticsCollectionTableProps> = ({
collections,
isLoading,
}) => {
const { navigateToUrl } = useValues(KibanaLogic);
const columns: Array<EuiBasicTableColumn<AnalyticsCollection>> = [
{
field: 'name',
name: 'Name',
render: (name: string) => (
<EuiLinkTo
to={generateEncodedPath(COLLECTION_VIEW_PATH, {
name,
})}
>
{name}
</EuiLinkTo>
),
width: '60%',
},
{
actions: [
{
description: 'View this analytics collection',
icon: 'eye',
isPrimary: true,
name: (collection) => `View ${collection.name}`,
onClick: (collection) =>
navigateToUrl(
generateEncodedPath(COLLECTION_VIEW_PATH, {
name: collection.name,
})
),
type: 'icon',
},
],
align: 'right',
name: i18n.translate('xpack.enterpriseSearch.analytics.collections.actions.columnTitle', {
defaultMessage: 'Actions',
}),
width: '40%',
},
];
return (
<EuiBasicTable items={collections} columns={columns} tableLayout="fixed" loading={isLoading} />
);
};

View file

@ -0,0 +1,107 @@
/*
* 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, mockFlashMessageHelpers } from '../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { HttpError, Status } from '../../../../../common/types/api';
import { AnalyticsCollectionsLogic } from './analytics_collections_logic';
describe('analyticsCollectionsLogic', () => {
const { mount } = new LogicMounter(AnalyticsCollectionsLogic);
beforeEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
mount();
});
const DEFAULT_VALUES = {
analyticsCollections: [],
data: undefined,
hasNoAnalyticsCollections: false,
isLoading: true,
status: Status.IDLE,
};
it('has expected default values', () => {
expect(AnalyticsCollectionsLogic.values).toEqual(DEFAULT_VALUES);
});
describe('reducers', () => {
describe('hasNoAnalyticsCollections', () => {
it('updates to true when apiSuccess returns empty analytics collections array', () => {
AnalyticsCollectionsLogic.actions.apiSuccess([]);
expect(AnalyticsCollectionsLogic.values.hasNoAnalyticsCollections).toBe(true);
expect(AnalyticsCollectionsLogic.values).toEqual({
...DEFAULT_VALUES,
analyticsCollections: [],
hasNoAnalyticsCollections: true,
data: [],
isLoading: false,
status: Status.SUCCESS,
});
});
it('updates to false when apiSuccess returns analytics collections array', () => {
const collections = [
{ event_retention_day_length: 19, id: 'collection1', name: 'collection1' },
];
AnalyticsCollectionsLogic.actions.apiSuccess(collections);
expect(AnalyticsCollectionsLogic.values.hasNoAnalyticsCollections).toBe(false);
expect(AnalyticsCollectionsLogic.values).toEqual({
...DEFAULT_VALUES,
analyticsCollections: collections,
data: collections,
isLoading: false,
status: Status.SUCCESS,
});
});
});
});
describe('listeners', () => {
it('calls clearFlashMessages on new makeRequest', () => {
AnalyticsCollectionsLogic.actions.makeRequest({});
expect(mockFlashMessageHelpers.clearFlashMessages).toHaveBeenCalledTimes(1);
});
it('calls flashAPIErrors on apiError', () => {
AnalyticsCollectionsLogic.actions.apiError({} as HttpError);
expect(mockFlashMessageHelpers.flashAPIErrors).toHaveBeenCalledTimes(1);
expect(mockFlashMessageHelpers.flashAPIErrors).toHaveBeenCalledWith({});
});
it('calls makeRequest on fetchAnalyticsCollections', async () => {
jest.useFakeTimers();
AnalyticsCollectionsLogic.actions.makeRequest = jest.fn();
AnalyticsCollectionsLogic.actions.fetchAnalyticsCollections();
jest.advanceTimersByTime(150);
await nextTick();
expect(AnalyticsCollectionsLogic.actions.makeRequest).toHaveBeenCalledWith({});
});
});
describe('selectors', () => {
describe('analyticsCollections', () => {
it('updates when apiSuccess listener triggered', () => {
AnalyticsCollectionsLogic.actions.apiSuccess([]);
expect(AnalyticsCollectionsLogic.values).toEqual({
...DEFAULT_VALUES,
analyticsCollections: [],
data: [],
hasNoAnalyticsCollections: true,
isLoading: false,
status: Status.SUCCESS,
});
});
});
});
});

View file

@ -0,0 +1,55 @@
/*
* 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 { AnalyticsCollection } from '../../../../../common/types/analytics';
import { HttpError, Status } from '../../../../../common/types/api';
import { flashAPIErrors, clearFlashMessages } from '../../../shared/flash_messages';
import { FetchAnalyticsCollectionsAPILogic } from '../../api/index/fetch_analytics_collections_api_logic';
export interface AnalyticsCollectionsActions {
apiError(error: HttpError): HttpError;
apiSuccess(collections: AnalyticsCollection[]): AnalyticsCollection[];
fetchAnalyticsCollections(): void;
makeRequest: typeof FetchAnalyticsCollectionsAPILogic.actions.makeRequest;
}
export interface AnalyticsCollectionsValues {
analyticsCollections: AnalyticsCollection[];
data: typeof FetchAnalyticsCollectionsAPILogic.values.data;
hasNoAnalyticsCollections: boolean;
isLoading: boolean;
status: typeof FetchAnalyticsCollectionsAPILogic.values.status;
}
export const AnalyticsCollectionsLogic = kea<
MakeLogicType<AnalyticsCollectionsValues, AnalyticsCollectionsActions>
>({
actions: {
fetchAnalyticsCollections: () => {},
},
connect: {
actions: [FetchAnalyticsCollectionsAPILogic, ['makeRequest', 'apiSuccess', 'apiError']],
values: [FetchAnalyticsCollectionsAPILogic, ['data', 'status']],
},
listeners: ({ actions }) => ({
apiError: (e) => flashAPIErrors(e),
fetchAnalyticsCollections: () => {
actions.makeRequest({});
},
makeRequest: () => clearFlashMessages(),
}),
path: ['enterprise_search', 'analytics', 'collections'],
selectors: ({ selectors }) => ({
analyticsCollections: [() => [selectors.data], (data) => data || []],
hasNoAnalyticsCollections: [() => [selectors.data], (data) => data?.length === 0],
isLoading: [
() => [selectors.status],
(status) => [Status.LOADING, Status.IDLE].includes(status),
],
}),
});

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import '../../../__mocks__/shallow_useeffect.mock';
import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsCollection } from '../../../../../common/types/analytics';
import { AnalyticsCollectionTable } from './analytics_collection_table';
import { AnalyticsOverview } from './analytics_overview';
const mockValues = {
analyticsCollections: [
{
event_retention_day_length: 180,
id: '1',
name: 'Analytics Collection 1',
},
] as AnalyticsCollection[],
hasNoAnalyticsCollections: false,
};
const mockActions = {
fetchAnalyticsCollections: jest.fn(),
};
describe('AnalyticsOverview', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('empty state', () => {
it('renders when analytics collections are empty on inital query', () => {
setMockValues({
...mockValues,
analyticsCollections: [],
hasNoAnalyticsCollections: true,
});
setMockActions(mockActions);
const wrapper = shallow(<AnalyticsOverview />);
expect(mockActions.fetchAnalyticsCollections).toHaveBeenCalled();
expect(wrapper.find(AnalyticsCollectionTable)).toHaveLength(0);
});
it('renders with Data', async () => {
setMockValues(mockValues);
setMockActions(mockActions);
const wrapper = shallow(<AnalyticsOverview />);
expect(wrapper.find(AnalyticsCollectionTable)).toHaveLength(1);
expect(mockActions.fetchAnalyticsCollections).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,116 @@
/*
* 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 { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiButtonTo } from '../../../shared/react_router_helpers';
import { COLLECTION_CREATION_PATH } from '../../routes';
import { EnterpriseSearchAnalyticsPageTemplate } from '../layout/page_template';
import { AnalyticsCollectionTable } from './analytics_collection_table';
import { AnalyticsCollectionsLogic } from './analytics_collections_logic';
export const baseBreadcrumbs = [
i18n.translate('xpack.enterpriseSearch.analytics.collections.breadcrumb', {
defaultMessage: 'Analytics collections',
}),
];
export const AnalyticsOverview: React.FC = () => {
const { fetchAnalyticsCollections } = useActions(AnalyticsCollectionsLogic);
const { analyticsCollections, isLoading, hasNoAnalyticsCollections } =
useValues(AnalyticsCollectionsLogic);
useEffect(() => {
fetchAnalyticsCollections();
}, []);
return (
<EnterpriseSearchAnalyticsPageTemplate
pageChrome={baseBreadcrumbs}
restrictWidth
isLoading={isLoading}
pageViewTelemetry="Analytics Collections Overview"
pageHeader={{
description: i18n.translate(
'xpack.enterpriseSearch.analytics.collections.pageDescription',
{
defaultMessage:
'Dashboards and tools for visualizing end-user behavior and measuring the performance of your search applications. Track trends over time, identify and investigate anomalies, and make optimizations.',
}
),
pageTitle: i18n.translate('xpack.enterpriseSearch.analytics.collections.pageTitle', {
defaultMessage: 'Behaviorial Analytics',
}),
}}
>
{!hasNoAnalyticsCollections && (
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle>
<h2>
{i18n.translate('xpack.enterpriseSearch.analytics.collections.headingTitle', {
defaultMessage: 'Collections',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonTo fill iconType="plusInCircle" to={COLLECTION_CREATION_PATH}>
{i18n.translate('xpack.enterpriseSearch.analytics.collections.create.buttonTitle', {
defaultMessage: 'Create new collection',
})}
</EuiButtonTo>
</EuiFlexItem>
</EuiFlexGroup>
)}
<EuiSpacer size="l" />
{hasNoAnalyticsCollections ? (
<EuiEmptyPrompt
iconType="search"
title={
<h2>
{i18n.translate(
'xpack.enterpriseSearch.analytics.collections.emptyState.headingTitle',
{
defaultMessage: 'You dont have any collections yet',
}
)}
</h2>
}
body={
<p>
{i18n.translate(
'xpack.enterpriseSearch.analytics.collections.emptyState.subHeading',
{
defaultMessage:
'An analytics collection provides a place to store the analytics events for any given search application you are building. Create a new collection to get started.',
}
)}
</p>
}
actions={[
<EuiButtonTo fill iconType="plusInCircle" to={COLLECTION_CREATION_PATH}>
{i18n.translate('xpack.enterpriseSearch.analytics.collections.create.buttonTitle', {
defaultMessage: 'Create new collection',
})}
</EuiButtonTo>,
]}
/>
) : (
<AnalyticsCollectionTable collections={analyticsCollections} isLoading={isLoading} />
)}
</EnterpriseSearchAnalyticsPageTemplate>
);
};

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 React from 'react';
jest.mock('../../../shared/layout/nav', () => ({
useEnterpriseSearchNav: () => [],
}));
import { shallow } from 'enzyme';
import { SetAnalyticsChrome } from '../../../shared/kibana_chrome';
import { EnterpriseSearchPageTemplateWrapper } from '../../../shared/layout';
import { SendEnterpriseSearchTelemetry } from '../../../shared/telemetry';
import { EnterpriseSearchAnalyticsPageTemplate } from './page_template';
describe('EnterpriseSearchAnalyticsPageTemplate', () => {
it('renders', () => {
const wrapper = shallow(
<EnterpriseSearchAnalyticsPageTemplate>
<div className="hello">world</div>
</EnterpriseSearchAnalyticsPageTemplate>
);
expect(wrapper.type()).toEqual(EnterpriseSearchPageTemplateWrapper);
expect(wrapper.prop('solutionNav')).toEqual({ name: 'Enterprise Search', items: [] });
expect(wrapper.find('.hello').text()).toEqual('world');
});
describe('page chrome', () => {
it('takes a breadcrumb array & renders a product-specific page chrome', () => {
const wrapper = shallow(<EnterpriseSearchAnalyticsPageTemplate pageChrome={['Some page']} />);
const setPageChrome = wrapper
.find(EnterpriseSearchPageTemplateWrapper)
.prop('setPageChrome') as any;
expect(setPageChrome.type).toEqual(SetAnalyticsChrome);
expect(setPageChrome.props.trail).toEqual(['Some page']);
});
});
describe('page telemetry', () => {
it('takes a metric & renders product-specific telemetry viewed event', () => {
const wrapper = shallow(
<EnterpriseSearchAnalyticsPageTemplate pageViewTelemetry="some_page" />
);
expect(wrapper.find(SendEnterpriseSearchTelemetry).prop('action')).toEqual('viewed');
expect(wrapper.find(SendEnterpriseSearchTelemetry).prop('metric')).toEqual('some_page');
});
});
describe('props', () => {
it('passes down any ...pageTemplateProps that EnterpriseSearchPageTemplateWrapper accepts', () => {
const wrapper = shallow(
<EnterpriseSearchAnalyticsPageTemplate
pageHeader={{ pageTitle: 'hello world' }}
isLoading={false}
emptyState={<div />}
/>
);
expect(
wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('pageHeader')!.pageTitle
).toEqual('hello world');
expect(wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('isLoading')).toEqual(false);
expect(wrapper.find(EnterpriseSearchPageTemplateWrapper).prop('emptyState')).toEqual(<div />);
});
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 { ENTERPRISE_SEARCH_CONTENT_PLUGIN } from '../../../../../common/constants';
import { SetAnalyticsChrome } from '../../../shared/kibana_chrome';
import {
EnterpriseSearchPageTemplateWrapper,
PageTemplateProps,
useEnterpriseSearchNav,
} from '../../../shared/layout';
import { SendEnterpriseSearchTelemetry } from '../../../shared/telemetry';
export const EnterpriseSearchAnalyticsPageTemplate: React.FC<PageTemplateProps> = ({
children,
pageChrome,
pageViewTelemetry,
...pageTemplateProps
}) => {
return (
<EnterpriseSearchPageTemplateWrapper
{...pageTemplateProps}
solutionNav={{
items: useEnterpriseSearchNav(),
name: ENTERPRISE_SEARCH_CONTENT_PLUGIN.NAME,
}}
setPageChrome={pageChrome && <SetAnalyticsChrome trail={pageChrome} />}
>
{pageViewTelemetry && (
<SendEnterpriseSearchTelemetry action="viewed" metric={pageViewTelemetry} />
)}
{children}
</EnterpriseSearchPageTemplateWrapper>
);
};

View file

@ -0,0 +1,35 @@
/*
* 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 '../../../common/__mocks__';
import '../__mocks__/kea_logic';
import '../__mocks__/shallow_useeffect.mock';
import '../__mocks__/enterprise_search_url.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { VersionMismatchPage } from '../shared/version_mismatch';
import { AnalyticsOverview } from './components/analytics_overview/analytics_overview';
import { Analytics } from '.';
describe('EnterpriseSearchAnalytics', () => {
it('always renders the overview', () => {
const wrapper = shallow(<Analytics />);
expect(wrapper.find(AnalyticsOverview)).toHaveLength(1);
});
it('renders VersionMismatchPage when there are mismatching versions', () => {
const wrapper = shallow(<Analytics enterpriseSearchVersion="7.15.0" kibanaVersion="7.16.0" />);
expect(wrapper.find(VersionMismatchPage)).toHaveLength(1);
});
});

View file

@ -0,0 +1,37 @@
/*
* 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 { Route, Switch } from 'react-router-dom';
import { isVersionMismatch } from '../../../common/is_version_mismatch';
import { InitialAppData } from '../../../common/types';
import { VersionMismatchPage } from '../shared/version_mismatch';
import { AnalyticsOverview } from './components/analytics_overview/analytics_overview';
import { ROOT_PATH } from './routes';
export const Analytics: React.FC<InitialAppData> = (props) => {
const { enterpriseSearchVersion, kibanaVersion } = props;
const incompatibleVersions = isVersionMismatch(enterpriseSearchVersion, kibanaVersion);
return (
<Switch>
<Route exact path={ROOT_PATH}>
{incompatibleVersions ? (
<VersionMismatchPage
enterpriseSearchVersion={enterpriseSearchVersion}
kibanaVersion={kibanaVersion}
/>
) : (
<AnalyticsOverview />
)}
</Route>
</Switch>
);
};

View file

@ -0,0 +1,11 @@
/*
* 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 const ROOT_PATH = '/';
export const COLLECTIONS_PATH = '/collections';
export const COLLECTION_CREATION_PATH = `${COLLECTIONS_PATH}/new`;
export const COLLECTION_VIEW_PATH = `${COLLECTIONS_PATH}/:name`;

View file

@ -11,6 +11,7 @@ import { EuiBreadcrumb } from '@elastic/eui';
import {
ENTERPRISE_SEARCH_OVERVIEW_PLUGIN,
ANALYTICS_PLUGIN,
APP_SEARCH_PLUGIN,
WORKPLACE_SEARCH_PLUGIN,
ENTERPRISE_SEARCH_CONTENT_PLUGIN,
@ -105,6 +106,9 @@ export const useEnterpriseSearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) =>
...breadcrumbs,
]);
export const useAnalyticsBreadcrumbs = (breadcrumbs: Breadcrumbs = []) =>
useEnterpriseSearchBreadcrumbs([{ text: ANALYTICS_PLUGIN.NAME, path: '/' }, ...breadcrumbs]);
export const useElasticsearchBreadcrumbs = (breadcrumbs: Breadcrumbs = []) =>
useEnterpriseSearchBreadcrumbs([
{ text: 'Getting started with Elasticsearch', path: '/' },

View file

@ -7,6 +7,7 @@
import {
ENTERPRISE_SEARCH_OVERVIEW_PLUGIN,
ANALYTICS_PLUGIN,
APP_SEARCH_PLUGIN,
WORKPLACE_SEARCH_PLUGIN,
} from '../../../../common/constants';
@ -32,6 +33,8 @@ export const generateTitle = (pages: Title) => pages.join(' - ');
export const enterpriseSearchTitle = (page: Title = []) =>
generateTitle([...page, ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.NAME]);
export const analyticsTitle = (page: Title = []) => generateTitle([...page, ANALYTICS_PLUGIN.NAME]);
export const elasticsearchTitle = (page: Title = []) =>
generateTitle([...page, 'Getting started with Elasticsearch']);

View file

@ -7,6 +7,7 @@
export {
SetEnterpriseSearchChrome,
SetAnalyticsChrome,
SetEnterpriseSearchContentChrome,
SetElasticsearchChrome,
SetAppSearchChrome,

View file

@ -14,6 +14,7 @@ import { KibanaLogic } from '../kibana';
import {
useGenerateBreadcrumbs,
useEnterpriseSearchBreadcrumbs,
useAnalyticsBreadcrumbs,
useEnterpriseSearchContentBreadcrumbs,
useElasticsearchBreadcrumbs,
useAppSearchBreadcrumbs,
@ -22,6 +23,7 @@ import {
} from './generate_breadcrumbs';
import {
enterpriseSearchTitle,
analyticsTitle,
elasticsearchTitle,
appSearchTitle,
workplaceSearchTitle,
@ -63,6 +65,23 @@ export const SetEnterpriseSearchChrome: React.FC<SetChromeProps> = ({ trail = []
return null;
};
export const SetAnalyticsChrome: React.FC<SetChromeProps> = ({ trail = [] }) => {
const { setBreadcrumbs, setDocTitle } = useValues(KibanaLogic);
const title = reverseArray(trail);
const docTitle = analyticsTitle(title);
const crumbs = useGenerateBreadcrumbs(trail);
const breadcrumbs = useAnalyticsBreadcrumbs(crumbs);
useEffect(() => {
setBreadcrumbs(breadcrumbs);
setDocTitle(docTitle);
}, [trail]);
return null;
};
export const SetElasticsearchChrome: React.FC<SetChromeProps> = ({ trail = [] }) => {
const { setBreadcrumbs, setDocTitle } = useValues(KibanaLogic);

View file

@ -44,6 +44,17 @@ describe('useEnterpriseSearchContentNav', () => {
],
name: 'Content',
},
{
id: 'enterpriseSearchAnalytics',
name: 'Analytics',
items: [
{
href: '/app/enterprise_search/analytics',
id: 'analytics_collections',
name: 'Collections',
},
],
},
{
id: 'search',
items: [
@ -76,7 +87,7 @@ describe('useEnterpriseSearchContentNav', () => {
setMockValues({ productAccess: noProductAccess });
expect(useEnterpriseSearchNav()[2]).toEqual({
expect(useEnterpriseSearchNav()[3]).toEqual({
id: 'search',
items: [
{
@ -97,7 +108,7 @@ describe('useEnterpriseSearchContentNav', () => {
setMockValues({ productAccess: workplaceSearchProductAccess });
expect(useEnterpriseSearchNav()[2]).toEqual({
expect(useEnterpriseSearchNav()[3]).toEqual({
id: 'search',
items: [
{
@ -123,7 +134,7 @@ describe('useEnterpriseSearchContentNav', () => {
setMockValues({ productAccess: appSearchProductAccess });
expect(useEnterpriseSearchNav()[2]).toEqual({
expect(useEnterpriseSearchNav()[3]).toEqual({
id: 'search',
items: [
{

View file

@ -11,6 +11,7 @@ import { EuiSideNavItemType } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
ANALYTICS_PLUGIN,
APP_SEARCH_PLUGIN,
ELASTICSEARCH_PLUGIN,
ENTERPRISE_SEARCH_CONTENT_PLUGIN,
@ -55,6 +56,25 @@ export const useEnterpriseSearchNav = () => {
defaultMessage: 'Content',
}),
},
{
id: 'enterpriseSearchAnalytics',
items: [
{
id: 'analytics_collections',
name: i18n.translate('xpack.enterpriseSearch.nav.analyticsCollectionsTitle', {
defaultMessage: 'Collections',
}),
...generateNavLink({
shouldNotCreateHref: true,
shouldShowActiveForSubroutes: true,
to: ANALYTICS_PLUGIN.URL,
}),
},
],
name: i18n.translate('xpack.enterpriseSearch.nav.analyticsTitle', {
defaultMessage: 'Analytics',
}),
},
{
id: 'search',
items: [

View file

@ -22,6 +22,7 @@ import { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import {
ANALYTICS_PLUGIN,
APP_SEARCH_PLUGIN,
ELASTICSEARCH_PLUGIN,
ENTERPRISE_SEARCH_CONTENT_PLUGIN,
@ -113,6 +114,27 @@ export class EnterpriseSearchPlugin implements Plugin {
},
});
core.application.register({
id: ANALYTICS_PLUGIN.ID,
title: ANALYTICS_PLUGIN.NAME,
euiIconType: ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.LOGO,
appRoute: ANALYTICS_PLUGIN.URL,
category: DEFAULT_APP_CATEGORIES.enterpriseSearch,
mount: async (params: AppMountParameters) => {
const kibanaDeps = await this.getKibanaDeps(core, params, cloud);
const { chrome, http } = kibanaDeps.core;
chrome.docTitle.change(ANALYTICS_PLUGIN.NAME);
await this.getInitialData(http);
const pluginData = this.getPluginData();
const { renderApp } = await import('./applications');
const { Analytics } = await import('./applications/analytics');
return renderApp(Analytics, kibanaDeps, pluginData);
},
});
core.application.register({
id: ELASTICSEARCH_PLUGIN.ID,
title: ELASTICSEARCH_PLUGIN.NAME,
@ -189,6 +211,16 @@ export class EnterpriseSearchPlugin implements Plugin {
order: 100,
});
plugins.home.featureCatalogue.register({
id: ANALYTICS_PLUGIN.ID,
title: ANALYTICS_PLUGIN.NAME,
icon: 'appAnalytics',
description: ANALYTICS_PLUGIN.DESCRIPTION,
path: ANALYTICS_PLUGIN.URL,
category: 'data',
showOnHomePage: false,
});
plugins.home.featureCatalogue.register({
id: APP_SEARCH_PLUGIN.ID,
title: APP_SEARCH_PLUGIN.NAME,

View file

@ -26,6 +26,7 @@ import {
ENTERPRISE_SEARCH_OVERVIEW_PLUGIN,
ENTERPRISE_SEARCH_CONTENT_PLUGIN,
ELASTICSEARCH_PLUGIN,
ANALYTICS_PLUGIN,
APP_SEARCH_PLUGIN,
WORKPLACE_SEARCH_PLUGIN,
ENTERPRISE_SEARCH_RELEVANCE_LOGS_SOURCE_ID,
@ -100,6 +101,7 @@ export class EnterpriseSearchPlugin implements Plugin {
ENTERPRISE_SEARCH_OVERVIEW_PLUGIN.ID,
ENTERPRISE_SEARCH_CONTENT_PLUGIN.ID,
ELASTICSEARCH_PLUGIN.ID,
ANALYTICS_PLUGIN.ID,
APP_SEARCH_PLUGIN.ID,
WORKPLACE_SEARCH_PLUGIN.ID,
];
@ -141,6 +143,7 @@ export class EnterpriseSearchPlugin implements Plugin {
navLinks: {
enterpriseSearch: showEnterpriseSearch,
enterpriseSearchContent: showEnterpriseSearch,
enterpriseSearchAnalytics: showEnterpriseSearch,
elasticsearch: showEnterpriseSearch,
appSearch: hasAppSearchAccess,
workplaceSearch: hasWorkplaceSearchAccess,
@ -148,6 +151,7 @@ export class EnterpriseSearchPlugin implements Plugin {
catalogue: {
enterpriseSearch: showEnterpriseSearch,
enterpriseSearchContent: showEnterpriseSearch,
enterpriseSearchAnalytics: showEnterpriseSearch,
elasticsearch: showEnterpriseSearch,
appSearch: hasAppSearchAccess,
workplaceSearch: hasWorkplaceSearchAccess,

View file

@ -64,6 +64,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) {
'monitoring',
'enterpriseSearch',
'enterpriseSearchContent',
'enterpriseSearchAnalytics',
'elasticsearch',
'appSearch',
'workplaceSearch',
@ -89,6 +90,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) {
'monitoring',
'enterpriseSearch',
'enterpriseSearchContent',
'enterpriseSearchAnalytics',
'elasticsearch',
'appSearch',
'workplaceSearch',

View file

@ -54,6 +54,7 @@ export default function navLinksTests({ getService }: FtrProviderContext) {
'monitoring',
'enterpriseSearch',
'enterpriseSearchContent',
'enterpriseSearchAnalytics',
'appSearch',
'workplaceSearch'
)

View file

@ -28,6 +28,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) {
// enterprise_search plugin is loaded but disabled because security isn't enabled in ES. That means the following capabilities are disabled
'enterpriseSearch',
'enterpriseSearchContent',
'enterpriseSearchAnalytics',
'elasticsearch',
'appSearch',
'workplaceSearch',

View file

@ -20,6 +20,7 @@ export default function navLinksTests({ getService }: FtrProviderContext) {
// enterprise_search plugin is loaded but disabled because security isn't enabled in ES. That means the following capabilities are disabled
'enterpriseSearch',
'enterpriseSearchContent',
'enterpriseSearchAnalytics',
'appSearch',
'workplaceSearch',
];