[App Search] Add final Analytics table components (#89233)

* Add new AnalyticsSection component

* Update views that use AnalyticsSection

* [Setup] Update types + final API logic data

- export query types so that new table components can use them
- reorganize type keys by their (upcoming) table column order, remove unused tags from document obj

* [Setup] Migrate InlineTagsList component

- used for tags columns in all tables

* Create basic AnalyticsTable component

- there's a lot of logic separated out into constants.tsx right now, I promise it will make more sense when the one-off tables get added

* Update all views that use AnalyticsTable

+ add 'view all' button links to overview tables

* Add RecentQueriesTable component

- Why is the API for this specific table so different? who knows, but it do be that way

* Update views with RecentQueryTable

* Add QueryClicksTable component to QueryDetails view

* Create AnalyticsSearch bar for queries subpages

* [Polish] Add some space to the bottom of analytics pages

* [Design feedback] Tweak header + search form layout

- Have analytics filter form be on its own row separate from page title
- Change AnalyticsSearch to stretch to full width + add placeholder text + match header gutter + remain one line on mobile

* [PR feedback] Type clarification

* [PR feedback] Clear mocks

* [PR suggestion] File rename

constants.tsx -> shared_columns.tsx

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Constance 2021-01-29 11:42:37 -08:00 committed by GitHub
parent c5ad2ca5dd
commit 4f6de5a407
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1114 additions and 47 deletions

View file

@ -7,6 +7,7 @@
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useValues, useActions } from 'kea';
import { EuiSpacer } from '@elastic/eui';
import { KibanaLogic } from '../../../shared/kibana';
import { FlashMessages } from '../../../shared/flash_messages';
@ -47,6 +48,7 @@ export const AnalyticsLayout: React.FC<Props> = ({
<FlashMessages />
<LogRetentionCallout type={LogRetentionOptions.Analytics} />
{children}
<EuiSpacer />
</>
);
};

View file

@ -30,6 +30,11 @@ describe('AnalyticsLogic', () => {
dataLoading: true,
analyticsUnavailable: false,
allTags: [],
recentQueries: [],
topQueries: [],
topQueriesNoResults: [],
topQueriesNoClicks: [],
topQueriesWithClicks: [],
totalQueries: 0,
totalQueriesNoResults: 0,
totalClicks: 0,
@ -38,6 +43,7 @@ describe('AnalyticsLogic', () => {
queriesNoResultsPerDay: [],
clicksPerDay: [],
queriesPerDayForQuery: [],
topClicksForQuery: [],
startDate: '',
};
@ -130,16 +136,7 @@ describe('AnalyticsLogic', () => {
expect(AnalyticsLogic.values).toEqual({
...DEFAULT_VALUES,
dataLoading: false,
analyticsUnavailable: false,
allTags: ['some-tag'],
startDate: '1970-01-01',
totalClicks: 1000,
totalQueries: 5000,
totalQueriesNoResults: 500,
queriesPerDay: [10, 50, 100],
queriesNoResultsPerDay: [1, 2, 3],
clicksPerDay: [0, 10, 50],
// TODO: Replace this with ...MOCK_ANALYTICS_RESPONSE once all data is set
...MOCK_ANALYTICS_RESPONSE,
});
});
});
@ -152,12 +149,7 @@ describe('AnalyticsLogic', () => {
expect(AnalyticsLogic.values).toEqual({
...DEFAULT_VALUES,
dataLoading: false,
analyticsUnavailable: false,
allTags: ['some-tag'],
startDate: '1970-01-01',
totalQueriesForQuery: 50,
queriesPerDayForQuery: [25, 0, 25],
// TODO: Replace this with ...MOCK_QUERY_RESPONSE once all data is set
...MOCK_QUERY_RESPONSE,
});
});
});

View file

@ -62,6 +62,36 @@ export const AnalyticsLogic = kea<MakeLogicType<AnalyticsValues, AnalyticsAction
onQueryDataLoad: (_, { allTags }) => allTags,
},
],
recentQueries: [
[],
{
onAnalyticsDataLoad: (_, { recentQueries }) => recentQueries,
},
],
topQueries: [
[],
{
onAnalyticsDataLoad: (_, { topQueries }) => topQueries,
},
],
topQueriesNoResults: [
[],
{
onAnalyticsDataLoad: (_, { topQueriesNoResults }) => topQueriesNoResults,
},
],
topQueriesNoClicks: [
[],
{
onAnalyticsDataLoad: (_, { topQueriesNoClicks }) => topQueriesNoClicks,
},
],
topQueriesWithClicks: [
[],
{
onAnalyticsDataLoad: (_, { topQueriesWithClicks }) => topQueriesWithClicks,
},
],
totalQueries: [
0,
{
@ -110,6 +140,12 @@ export const AnalyticsLogic = kea<MakeLogicType<AnalyticsValues, AnalyticsAction
onQueryDataLoad: (_, { queriesPerDayForQuery }) => queriesPerDayForQuery,
},
],
topClicksForQuery: [
[],
{
onQueryDataLoad: (_, { topClicksForQuery }) => topClicksForQuery,
},
],
startDate: [
'',
{

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
.analyticsHeader {
flex-wrap: wrap;
&__filters.euiPageHeaderSection {
width: 100%;
margin: $euiSizeM 0;
}
}

View file

@ -30,6 +30,8 @@ import { AnalyticsLogic } from '../';
import { DEFAULT_START_DATE, DEFAULT_END_DATE, SERVER_DATE_FORMAT } from '../constants';
import { convertTagsToSelectOptions } from '../utils';
import './analytics_header.scss';
interface Props {
title: string;
}
@ -60,7 +62,7 @@ export const AnalyticsHeader: React.FC<Props> = ({ title }) => {
const hasInvalidDateRange = startDate > endDate;
return (
<EuiPageHeader>
<EuiPageHeader className="analyticsHeader">
<EuiPageHeaderSection>
<EuiFlexGroup alignItems="center" justifyContent="flexStart" responsive={false}>
<EuiFlexItem grow={false}>
@ -69,13 +71,13 @@ export const AnalyticsHeader: React.FC<Props> = ({ title }) => {
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LogRetentionTooltip type={LogRetentionOptions.Analytics} />
<LogRetentionTooltip type={LogRetentionOptions.Analytics} position="right" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageHeaderSection>
<EuiPageHeaderSection>
<EuiPageHeaderSection className="analyticsHeader__filters">
<EuiFlexGroup alignItems="center" justifyContent="flexEnd" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiFlexItem>
<EuiSelect
options={convertTagsToSelectOptions(allTags)}
value={currentTag}
@ -87,7 +89,7 @@ export const AnalyticsHeader: React.FC<Props> = ({ title }) => {
fullWidth
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexItem>
<EuiDatePickerRange
startDateControl={
<EuiDatePicker

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mockKibanaValues } from '../../../../__mocks__';
import '../../../__mocks__/engine_logic.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiFieldSearch } from '@elastic/eui';
import { AnalyticsSearch } from './';
describe('AnalyticsSearch', () => {
const { navigateToUrl } = mockKibanaValues;
const preventDefault = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
const wrapper = shallow(<AnalyticsSearch />);
const setSearchValue = (value: string) =>
wrapper.find(EuiFieldSearch).simulate('change', { target: { value } });
it('renders', () => {
expect(wrapper.find(EuiFieldSearch)).toHaveLength(1);
});
it('updates searchValue state on input change', () => {
expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual('');
setSearchValue('some-query');
expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual('some-query');
});
it('sends the user to the query detail page on search', () => {
wrapper.find('form').simulate('submit', { preventDefault });
expect(preventDefault).toHaveBeenCalled();
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/analytics/query_detail/some-query'
);
});
it('falls back to showing the "" query if searchValue is empty', () => {
setSearchValue('');
wrapper.find('form').simulate('submit', { preventDefault });
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/analytics/query_detail/%22%22' // "" gets encoded
);
});
});

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { useValues } from 'kea';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiButton, EuiSpacer } from '@elastic/eui';
import { KibanaLogic } from '../../../../shared/kibana';
import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH } from '../../../routes';
import { generateEnginePath } from '../../engine';
export const AnalyticsSearch: React.FC = () => {
const [searchValue, setSearchValue] = useState('');
const { navigateToUrl } = useValues(KibanaLogic);
const viewQueryDetails = (e: React.SyntheticEvent) => {
e.preventDefault();
const query = searchValue || '""';
navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query }));
};
return (
<form onSubmit={viewQueryDetails}>
<EuiFlexGroup alignItems="center" gutterSize="m" responsive={false}>
<EuiFlexItem>
<EuiFieldSearch
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetailSearchPlaceholder',
{ defaultMessage: 'Go to search term' }
)}
fullWidth
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton type="submit">
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetailSearchButtonLabel',
{ defaultMessage: 'View details' }
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
</form>
);
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsSection } from './';
describe('AnalyticsSection', () => {
it('renders', () => {
const wrapper = shallow(
<AnalyticsSection title="Lorem ipsum" subtitle="Dolor sit amet.">
<div data-test-subj="HelloWorld">Test</div>
</AnalyticsSection>
);
expect(wrapper.find('h2').text()).toEqual('Lorem ipsum');
expect(wrapper.find('p').text()).toEqual('Dolor sit amet.');
expect(wrapper.find('[data-test-subj="HelloWorld"]')).toHaveLength(1);
});
});

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiPageContentBody, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui';
interface Props {
title: string;
subtitle: string;
}
export const AnalyticsSection: React.FC<Props> = ({ title, subtitle, children }) => (
<section>
<header>
<EuiTitle size="m">
<h2>{title}</h2>
</EuiTitle>
<EuiText size="s" color="subdued">
<p>{subtitle}</p>
</EuiText>
</header>
<EuiSpacer size="m" />
<EuiPageContentBody>{children}</EuiPageContentBody>
</section>
);

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__';
import '../../../../__mocks__/engine_logic.mock';
import React from 'react';
import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui';
import { AnalyticsTable } from './';
describe('AnalyticsTable', () => {
const { navigateToUrl } = mockKibanaValues;
const items = [
{
key: 'some search',
tags: ['tagA'],
searches: { doc_count: 100 },
clicks: { doc_count: 10 },
},
{
key: 'another search',
tags: ['tagB'],
searches: { doc_count: 99 },
clicks: { doc_count: 9 },
},
{
key: '',
tags: ['tagA', 'tagB'],
searches: { doc_count: 1 },
clicks: { doc_count: 0 },
},
];
it('renders', () => {
const wrapper = mountWithIntl(<AnalyticsTable items={items} />);
const tableContent = wrapper.find(EuiBasicTable).text();
expect(tableContent).toContain('Search term');
expect(tableContent).toContain('some search');
expect(tableContent).toContain('another search');
expect(tableContent).toContain('""');
expect(tableContent).toContain('Analytics tags');
expect(tableContent).toContain('tagA');
expect(tableContent).toContain('tagB');
expect(wrapper.find(EuiBadge)).toHaveLength(4);
expect(tableContent).toContain('Queries');
expect(tableContent).toContain('100');
expect(tableContent).toContain('99');
expect(tableContent).toContain('1');
expect(tableContent).not.toContain('Clicks');
});
it('renders a clicks column if hasClicks is passed', () => {
const wrapper = mountWithIntl(<AnalyticsTable items={items} hasClicks />);
const tableContent = wrapper.find(EuiBasicTable).text();
expect(tableContent).toContain('Clicks');
expect(tableContent).toContain('10');
expect(tableContent).toContain('9');
expect(tableContent).toContain('0');
});
it('renders an action column', () => {
const wrapper = mountWithIntl(<AnalyticsTable items={items} />);
const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first();
const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first();
viewQuery.simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/analytics/query_detail/some%20search'
);
editQuery.simulate('click');
// TODO
});
it('renders an empty prompt if no items are passed', () => {
const wrapper = mountWithIntl(<AnalyticsTable items={[]} />);
const promptContent = wrapper.find(EuiEmptyPrompt).text();
expect(promptContent).toContain('No queries were performed during this time period.');
});
});

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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui';
import { Query } from '../../types';
import {
TERM_COLUMN_PROPS,
TAGS_COLUMN,
COUNT_COLUMN_PROPS,
ACTIONS_COLUMN,
} from './shared_columns';
interface Props {
items: Query[];
hasClicks?: boolean;
}
type Columns = Array<EuiBasicTableColumn<Query>>;
export const AnalyticsTable: React.FC<Props> = ({ items, hasClicks }) => {
const TERM_COLUMN = {
field: 'key',
...TERM_COLUMN_PROPS,
};
const COUNT_COLUMNS = [
{
field: 'searches.doc_count',
name: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.queriesColumn',
{ defaultMessage: 'Queries' }
),
...COUNT_COLUMN_PROPS,
},
];
if (hasClicks) {
COUNT_COLUMNS.push({
field: 'clicks.doc_count',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.clicksColumn', {
defaultMessage: 'Clicks',
}),
...COUNT_COLUMN_PROPS,
});
}
return (
<EuiBasicTable
columns={[TERM_COLUMN, TAGS_COLUMN, ...COUNT_COLUMNS, ACTIONS_COLUMN] as Columns}
items={items}
responsive
hasActions
noItemsMessage={
<EuiEmptyPrompt
iconType="visLine"
title={
<h4>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noQueriesTitle',
{ defaultMessage: 'No queries' }
)}
</h4>
}
body={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noQueriesDescription',
{ defaultMessage: 'No queries were performed during this time period.' }
)}
/>
}
/>
);
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { AnalyticsTable } from './analytics_table';
export { RecentQueriesTable } from './recent_queries_table';
export { QueryClicksTable } from './query_clicks_table';

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { EuiBadge, EuiToolTip } from '@elastic/eui';
import { InlineTagsList } from './inline_tags_list';
describe('InlineTagsList', () => {
it('renders', () => {
const wrapper = shallow(<InlineTagsList tags={['test']} />);
expect(wrapper.find(EuiBadge)).toHaveLength(1);
expect(wrapper.find(EuiBadge).prop('children')).toEqual('test');
});
it('renders >2 badges in a tooltip list', () => {
const wrapper = shallow(<InlineTagsList tags={['1', '2', '3', '4', '5']} />);
expect(wrapper.find(EuiBadge)).toHaveLength(3);
expect(wrapper.find(EuiToolTip)).toHaveLength(1);
expect(wrapper.find(EuiBadge).at(0).prop('children')).toEqual('1');
expect(wrapper.find(EuiBadge).at(1).prop('children')).toEqual('2');
expect(wrapper.find(EuiBadge).at(2).prop('children')).toEqual('and 3 more');
expect(wrapper.find(EuiToolTip).prop('content')).toEqual('3, 4, 5');
});
it('does not render with no tags', () => {
const wrapper = shallow(<InlineTagsList tags={[]} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
});

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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBadgeGroup, EuiBadge, EuiToolTip } from '@elastic/eui';
import { Query } from '../../types';
interface Props {
tags?: Query['tags'];
}
export const InlineTagsList: React.FC<Props> = ({ tags }) => {
if (!tags?.length) return null;
const displayedTags = tags.slice(0, 2);
const tooltipTags = tags.slice(2);
return (
<EuiBadgeGroup>
{displayedTags.map((tag: string) => (
<EuiBadge color="hollow" key={tag}>
{tag}
</EuiBadge>
))}
{tooltipTags.length > 0 && (
<EuiToolTip position="bottom" content={tooltipTags.join(', ')}>
<EuiBadge>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.moreTagsBadge',
{
defaultMessage: 'and {moreTagsCount} more',
values: { moreTagsCount: tooltipTags.length },
}
)}
</EuiBadge>
</EuiToolTip>
)}
</EuiBadgeGroup>
);
};

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mountWithIntl } from '../../../../../__mocks__';
import '../../../../__mocks__/engine_logic.mock';
import React from 'react';
import { EuiBasicTable, EuiLink, EuiBadge, EuiEmptyPrompt } from '@elastic/eui';
import { QueryClicksTable } from './';
describe('QueryClicksTable', () => {
const items = [
{
key: 'some-document',
document: {
engine: 'some-engine',
id: 'some-document',
},
tags: ['tagA'],
doc_count: 10,
},
{
key: 'another-document',
document: {
engine: 'another-engine',
id: 'another-document',
},
tags: ['tagB'],
doc_count: 5,
},
{
key: 'deleted-document',
tags: [],
doc_count: 1,
},
];
it('renders', () => {
const wrapper = mountWithIntl(<QueryClicksTable items={items} />);
const tableContent = wrapper.find(EuiBasicTable).text();
expect(tableContent).toContain('Documents');
expect(tableContent).toContain('some-document');
expect(tableContent).toContain('another-document');
expect(tableContent).toContain('deleted-document');
expect(wrapper.find(EuiLink).first().prop('href')).toEqual(
'/app/enterprise_search/engines/some-engine/documents/some-document'
);
expect(wrapper.find(EuiLink).last().prop('href')).toEqual(
'/app/enterprise_search/engines/another-engine/documents/another-document'
);
// deleted-document should not have a link
expect(tableContent).toContain('Analytics tags');
expect(tableContent).toContain('tagA');
expect(tableContent).toContain('tagB');
expect(wrapper.find(EuiBadge)).toHaveLength(2);
expect(tableContent).toContain('Clicks');
expect(tableContent).toContain('10');
expect(tableContent).toContain('5');
expect(tableContent).toContain('1');
});
it('renders an empty prompt if no items are passed', () => {
const wrapper = mountWithIntl(<QueryClicksTable items={[]} />);
const promptContent = wrapper.find(EuiEmptyPrompt).text();
expect(promptContent).toContain('No clicks');
expect(promptContent).toContain('No documents have been clicked from this query.');
});
});

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui';
import { EuiLinkTo } from '../../../../../shared/react_router_helpers';
import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../../../routes';
import { generateEnginePath } from '../../../engine';
import { DOCUMENTS_TITLE } from '../../../documents';
import { QueryClick } from '../../types';
import { FIRST_COLUMN_PROPS, TAGS_COLUMN, COUNT_COLUMN_PROPS } from './shared_columns';
interface Props {
items: QueryClick[];
}
type Columns = Array<EuiBasicTableColumn<QueryClick>>;
export const QueryClicksTable: React.FC<Props> = ({ items }) => {
const DOCUMENT_COLUMN = {
...FIRST_COLUMN_PROPS,
field: 'document',
name: DOCUMENTS_TITLE,
render: (document: QueryClick['document'], query: QueryClick) => {
return document ? (
<EuiLinkTo
to={generateEnginePath(ENGINE_DOCUMENT_DETAIL_PATH, {
engineName: document.engine,
documentId: document.id,
})}
>
{document.id}
</EuiLinkTo>
) : (
query.key
);
},
};
const CLICKS_COLUMN = {
...COUNT_COLUMN_PROPS,
field: 'doc_count',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.clicksColumn', {
defaultMessage: 'Clicks',
}),
};
return (
<EuiBasicTable
columns={[DOCUMENT_COLUMN, TAGS_COLUMN, CLICKS_COLUMN] as Columns}
items={items}
responsive
noItemsMessage={
<EuiEmptyPrompt
iconType="visLine"
title={
<h4>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noClicksTitle',
{ defaultMessage: 'No clicks' }
)}
</h4>
}
body={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noClicksDescription',
{ defaultMessage: 'No documents have been clicked from this query.' }
)}
/>
}
/>
);
};

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__';
import '../../../../__mocks__/engine_logic.mock';
import React from 'react';
import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui';
import { RecentQueriesTable } from './';
describe('RecentQueriesTable', () => {
const { navigateToUrl } = mockKibanaValues;
const items = [
{
query_string: 'some search',
timestamp: '1970-01-03T12:00:00Z',
tags: ['tagA'],
document_ids: ['documentA', 'documentB'],
},
{
query_string: 'another search',
timestamp: '1970-01-02T12:00:00Z',
tags: ['tagB'],
document_ids: ['documentC'],
},
{
query_string: '',
timestamp: '1970-01-01T12:00:00Z',
tags: ['tagA', 'tagB'],
document_ids: ['documentA', 'documentB', 'documentC'],
},
];
it('renders', () => {
const wrapper = mountWithIntl(<RecentQueriesTable items={items} />);
const tableContent = wrapper.find(EuiBasicTable).text();
expect(tableContent).toContain('Search term');
expect(tableContent).toContain('some search');
expect(tableContent).toContain('another search');
expect(tableContent).toContain('""');
expect(tableContent).toContain('Time');
expect(tableContent).toContain('1/3/1970');
expect(tableContent).toContain('1/2/1970');
expect(tableContent).toContain('1/1/1970');
expect(tableContent).toContain('Analytics tags');
expect(tableContent).toContain('tagA');
expect(tableContent).toContain('tagB');
expect(wrapper.find(EuiBadge)).toHaveLength(4);
expect(tableContent).toContain('Results');
expect(tableContent).toContain('2');
expect(tableContent).toContain('1');
expect(tableContent).toContain('3');
});
it('renders an action column', () => {
const wrapper = mountWithIntl(<RecentQueriesTable items={items} />);
const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first();
const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first();
viewQuery.simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/analytics/query_detail/some%20search'
);
editQuery.simulate('click');
// TODO
});
it('renders an empty prompt if no items are passed', () => {
const wrapper = mountWithIntl(<RecentQueriesTable items={[]} />);
const promptContent = wrapper.find(EuiEmptyPrompt).text();
expect(promptContent).toContain('No recent queries');
expect(promptContent).toContain('Queries will appear here as they are received.');
});
});

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedDate, FormattedTime } from '@kbn/i18n/react';
import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui';
import { RecentQuery } from '../../types';
import {
TERM_COLUMN_PROPS,
TAGS_COLUMN,
COUNT_COLUMN_PROPS,
ACTIONS_COLUMN,
} from './shared_columns';
interface Props {
items: RecentQuery[];
}
type Columns = Array<EuiBasicTableColumn<RecentQuery>>;
export const RecentQueriesTable: React.FC<Props> = ({ items }) => {
const TERM_COLUMN = {
...TERM_COLUMN_PROPS,
field: 'query_string',
};
const TIME_COLUMN = {
field: 'timestamp',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.timeColumn', {
defaultMessage: 'Time',
}),
render: (timestamp: RecentQuery['timestamp']) => {
const date = new Date(timestamp);
return (
<>
<FormattedDate value={date} /> <FormattedTime value={date} />
</>
);
},
width: '175px',
};
const RESULTS_COLUMN = {
...COUNT_COLUMN_PROPS,
field: 'document_ids',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.resultsColumn', {
defaultMessage: 'Results',
}),
render: (documents: RecentQuery['document_ids']) => documents.length,
};
return (
<EuiBasicTable
columns={[TERM_COLUMN, TIME_COLUMN, TAGS_COLUMN, RESULTS_COLUMN, ACTIONS_COLUMN] as Columns}
items={items}
responsive
hasActions
noItemsMessage={
<EuiEmptyPrompt
iconType="visLine"
title={
<h4>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noRecentQueriesTitle',
{ defaultMessage: 'No recent queries' }
)}
</h4>
}
body={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noRecentQueriesDescription',
{ defaultMessage: 'Queries will appear here as they are received.' }
)}
/>
}
/>
);
};

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiLinkTo } from '../../../../../shared/react_router_helpers';
import { KibanaLogic } from '../../../../../shared/kibana';
import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH } from '../../../../routes';
import { generateEnginePath } from '../../../engine';
import { Query, RecentQuery } from '../../types';
import { InlineTagsList } from './inline_tags_list';
/**
* Shared columns / column properties between separate analytics tables
*/
export const FIRST_COLUMN_PROPS = {
truncateText: true,
width: '25%',
mobileOptions: {
enlarge: true,
width: '100%',
},
};
export const TERM_COLUMN_PROPS = {
// Field key changes per-table
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.termColumn', {
defaultMessage: 'Search term',
}),
render: (query: Query['key']) => {
if (!query) query = '""';
return (
<EuiLinkTo to={generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query })}>
{query}
</EuiLinkTo>
);
},
...FIRST_COLUMN_PROPS,
};
export const ACTIONS_COLUMN = {
width: '120px',
actions: [
{
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.viewAction', {
defaultMessage: 'View',
}),
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.viewTooltip',
{ defaultMessage: 'View query analytics' }
),
type: 'icon',
icon: 'popout',
color: 'primary',
onClick: (item: Query | RecentQuery) => {
const { navigateToUrl } = KibanaLogic.values;
const query = (item as Query).key || (item as RecentQuery).query_string;
navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query }));
},
'data-test-subj': 'AnalyticsTableViewQueryButton',
},
{
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.editAction', {
defaultMessage: 'Edit',
}),
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.editTooltip',
{ defaultMessage: 'Edit query analytics' }
),
type: 'icon',
icon: 'pencil',
onClick: () => {
// TODO: CurationsLogic
},
'data-test-subj': 'AnalyticsTableEditQueryButton',
},
],
};
export const TAGS_COLUMN = {
field: 'tags',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.tagsColumn', {
defaultMessage: 'Analytics tags',
}),
truncateText: true,
render: (tags: Query['tags']) => <InlineTagsList tags={tags} />,
};
export const COUNT_COLUMN_PROPS = {
dataType: 'number',
width: '100px',
};

View file

@ -7,4 +7,7 @@
export { AnalyticsCards } from './analytics_cards';
export { AnalyticsChart } from './analytics_chart';
export { AnalyticsHeader } from './analytics_header';
export { AnalyticsSection } from './analytics_section';
export { AnalyticsSearch } from './analytics_search';
export { AnalyticsTable, RecentQueriesTable, QueryClicksTable } from './analytics_tables';
export { AnalyticsUnavailable } from './analytics_unavailable';

View file

@ -4,27 +4,25 @@
* you may not use this file except in compliance with the Elastic License.
*/
interface Query {
doc_count: number;
export interface Query {
key: string;
clicks?: { doc_count: number };
searches?: { doc_count: number };
tags?: string[];
searches?: { doc_count: number };
clicks?: { doc_count: number };
}
interface QueryClick extends Query {
export interface QueryClick extends Query {
document?: {
id: string;
engine: string;
tags?: string[];
};
}
interface RecentQuery {
document_ids: string[];
export interface RecentQuery {
query_string: string;
tags: string[];
timestamp: string;
tags: string[];
document_ids: string[];
}
/**

View file

@ -5,12 +5,19 @@
*/
import { setMockValues } from '../../../../__mocks__';
import '../../../__mocks__/engine_logic.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsCards, AnalyticsChart } from '../components';
import { Analytics } from './';
import {
AnalyticsCards,
AnalyticsChart,
AnalyticsSection,
AnalyticsTable,
RecentQueriesTable,
} from '../components';
import { Analytics, ViewAllButton } from './analytics';
describe('Analytics overview', () => {
it('renders', () => {
@ -22,10 +29,27 @@ describe('Analytics overview', () => {
queriesNoResultsPerDay: [1, 2, 3],
clicksPerDay: [0, 1, 5],
startDate: '1970-01-01',
topQueries: [],
topQueriesNoResults: [],
topQueriesNoClicks: [],
topQueriesWithClicks: [],
recentQueries: [],
});
const wrapper = shallow(<Analytics />);
expect(wrapper.find(AnalyticsCards)).toHaveLength(1);
expect(wrapper.find(AnalyticsChart)).toHaveLength(1);
expect(wrapper.find(AnalyticsSection)).toHaveLength(3);
expect(wrapper.find(AnalyticsTable)).toHaveLength(4);
expect(wrapper.find(RecentQueriesTable)).toHaveLength(1);
});
describe('ViewAllButton', () => {
it('renders', () => {
const to = '/analytics/top_queries';
const wrapper = shallow(<ViewAllButton to={to} />);
expect(wrapper.prop('to')).toEqual(to);
});
});
});

View file

@ -7,15 +7,32 @@
import React from 'react';
import { useValues } from 'kea';
import { EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiTitle } from '@elastic/eui';
import { EuiButtonTo } from '../../../../shared/react_router_helpers';
import {
ENGINE_ANALYTICS_TOP_QUERIES_PATH,
ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH,
ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH,
ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH,
ENGINE_ANALYTICS_RECENT_QUERIES_PATH,
} from '../../../routes';
import { generateEnginePath } from '../../engine';
import {
ANALYTICS_TITLE,
TOTAL_QUERIES,
TOTAL_QUERIES_NO_RESULTS,
TOTAL_CLICKS,
TOP_QUERIES,
TOP_QUERIES_NO_RESULTS,
TOP_QUERIES_WITH_CLICKS,
TOP_QUERIES_NO_CLICKS,
RECENT_QUERIES,
} from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSection, AnalyticsTable, RecentQueriesTable } from '../components';
import { AnalyticsLogic, AnalyticsCards, AnalyticsChart, convertToChartData } from '../';
export const Analytics: React.FC = () => {
@ -27,6 +44,11 @@ export const Analytics: React.FC = () => {
queriesNoResultsPerDay,
clicksPerDay,
startDate,
topQueries,
topQueriesNoResults,
topQueriesWithClicks,
topQueriesNoClicks,
recentQueries,
} = useValues(AnalyticsLogic);
return (
@ -72,7 +94,77 @@ export const Analytics: React.FC = () => {
/>
<EuiSpacer />
<p>TODO: Analytics overview</p>
<AnalyticsSection
title={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryTablesTitle',
{ defaultMessage: 'Query analytics' }
)}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryTablesDescription',
{
defaultMessage:
'Gain insight into the most frequent queries, and which queries returned no results.',
}
)}
>
<EuiTitle size="s">
<h3>{TOP_QUERIES}</h3>
</EuiTitle>
<AnalyticsTable items={topQueries.slice(0, 10)} hasClicks />
<ViewAllButton to={generateEnginePath(ENGINE_ANALYTICS_TOP_QUERIES_PATH)} />
<EuiSpacer />
<EuiTitle size="s">
<h3>{TOP_QUERIES_NO_RESULTS}</h3>
</EuiTitle>
<AnalyticsTable items={topQueriesNoResults.slice(0, 10)} />
<ViewAllButton to={generateEnginePath(ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH)} />
</AnalyticsSection>
<EuiSpacer size="xl" />
<AnalyticsSection
title={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.clickTablesTitle',
{ defaultMessage: 'Click analytics' }
)}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.clickTablesDescription',
{
defaultMessage: 'Discover which queries generated the most and least amount of clicks.',
}
)}
>
<EuiTitle size="s">
<h3>{TOP_QUERIES_WITH_CLICKS}</h3>
</EuiTitle>
<AnalyticsTable items={topQueriesWithClicks.slice(0, 10)} hasClicks />
<ViewAllButton to={generateEnginePath(ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH)} />
<EuiSpacer />
<EuiTitle size="s">
<h3>{TOP_QUERIES_NO_CLICKS}</h3>
</EuiTitle>
<AnalyticsTable items={topQueriesNoClicks.slice(0, 10)} />
<ViewAllButton to={generateEnginePath(ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH)} />
</AnalyticsSection>
<EuiSpacer size="xl" />
<AnalyticsSection
title={RECENT_QUERIES}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.recentQueriesDescription',
{ defaultMessage: 'A view into queries happening right now.' }
)}
>
<RecentQueriesTable items={recentQueries.slice(0, 10)} />
<ViewAllButton to={generateEnginePath(ENGINE_ANALYTICS_RECENT_QUERIES_PATH)} />
</AnalyticsSection>
</AnalyticsLayout>
);
};
export const ViewAllButton: React.FC<{ to: string }> = ({ to }) => (
<EuiButtonTo to={to} size="s" fullWidth>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.viewAllButtonLabel', {
defaultMessage: 'View all',
})}
</EuiButtonTo>
);

View file

@ -13,7 +13,7 @@ import { shallow } from 'enzyme';
import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome';
import { AnalyticsCards, AnalyticsChart } from '../components';
import { AnalyticsCards, AnalyticsChart, QueryClicksTable } from '../components';
import { QueryDetail } from './';
describe('QueryDetail', () => {
@ -41,5 +41,6 @@ describe('QueryDetail', () => {
expect(wrapper.find(AnalyticsCards)).toHaveLength(1);
expect(wrapper.find(AnalyticsChart)).toHaveLength(1);
expect(wrapper.find(QueryClicksTable)).toHaveLength(1);
});
});

View file

@ -15,6 +15,7 @@ import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_c
import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSection, QueryClicksTable } from '../components';
import { AnalyticsLogic, AnalyticsCards, AnalyticsChart, convertToChartData } from '../';
const QUERY_DETAIL_TITLE = i18n.translate(
@ -28,7 +29,9 @@ interface Props {
export const QueryDetail: React.FC<Props> = ({ breadcrumbs }) => {
const { query } = useParams() as { query: string };
const { totalQueriesForQuery, queriesPerDayForQuery, startDate } = useValues(AnalyticsLogic);
const { totalQueriesForQuery, queriesPerDayForQuery, startDate, topClicksForQuery } = useValues(
AnalyticsLogic
);
return (
<AnalyticsLayout isQueryView title={`"${query}"`}>
@ -63,7 +66,18 @@ export const QueryDetail: React.FC<Props> = ({ breadcrumbs }) => {
/>
<EuiSpacer />
<p>TODO: Query detail page</p>
<AnalyticsSection
title={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetail.tableTitle',
{ defaultMessage: 'Top clicks' }
)}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetail.tableDescription',
{ defaultMessage: 'The documents with the most clicks resulting from this query.' }
)}
>
<QueryClicksTable items={topClicksForQuery} />
</AnalyticsSection>
</AnalyticsLayout>
);
};

View file

@ -4,15 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { setMockValues } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { RecentQueriesTable } from '../components';
import { RecentQueries } from './';
describe('RecentQueries', () => {
it('renders', () => {
setMockValues({ recentQueries: [] });
const wrapper = shallow(<RecentQueries />);
expect(wrapper.isEmptyRender()).toBe(false); // TODO
expect(wrapper.find(RecentQueriesTable)).toHaveLength(1);
});
});

View file

@ -5,14 +5,20 @@
*/
import React from 'react';
import { useValues } from 'kea';
import { RECENT_QUERIES } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSearch, RecentQueriesTable } from '../components';
import { AnalyticsLogic } from '../';
export const RecentQueries: React.FC = () => {
const { recentQueries } = useValues(AnalyticsLogic);
return (
<AnalyticsLayout isAnalyticsView title={RECENT_QUERIES}>
<p>TODO: Recent queries</p>
<AnalyticsSearch />
<RecentQueriesTable items={recentQueries} />
</AnalyticsLayout>
);
};

View file

@ -4,15 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { setMockValues } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsTable } from '../components';
import { TopQueries } from './';
describe('TopQueries', () => {
it('renders', () => {
setMockValues({ topQueries: [] });
const wrapper = shallow(<TopQueries />);
expect(wrapper.isEmptyRender()).toBe(false); // TODO
expect(wrapper.find(AnalyticsTable)).toHaveLength(1);
});
});

View file

@ -5,14 +5,20 @@
*/
import React from 'react';
import { useValues } from 'kea';
import { TOP_QUERIES } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSearch, AnalyticsTable } from '../components';
import { AnalyticsLogic } from '../';
export const TopQueries: React.FC = () => {
const { topQueries } = useValues(AnalyticsLogic);
return (
<AnalyticsLayout isAnalyticsView title={TOP_QUERIES}>
<p>TODO: Top queries</p>
<AnalyticsSearch />
<AnalyticsTable items={topQueries} hasClicks />
</AnalyticsLayout>
);
};

View file

@ -4,15 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { setMockValues } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsTable } from '../components';
import { TopQueriesNoClicks } from './';
describe('TopQueriesNoClicks', () => {
it('renders', () => {
setMockValues({ topQueriesNoClicks: [] });
const wrapper = shallow(<TopQueriesNoClicks />);
expect(wrapper.isEmptyRender()).toBe(false); // TODO
expect(wrapper.find(AnalyticsTable)).toHaveLength(1);
});
});

View file

@ -5,14 +5,20 @@
*/
import React from 'react';
import { useValues } from 'kea';
import { TOP_QUERIES_NO_CLICKS } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSearch, AnalyticsTable } from '../components';
import { AnalyticsLogic } from '../';
export const TopQueriesNoClicks: React.FC = () => {
const { topQueriesNoClicks } = useValues(AnalyticsLogic);
return (
<AnalyticsLayout isAnalyticsView title={TOP_QUERIES_NO_CLICKS}>
<p>TODO: Top queries with no clicks</p>
<AnalyticsSearch />
<AnalyticsTable items={topQueriesNoClicks} hasClicks />
</AnalyticsLayout>
);
};

View file

@ -4,15 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { setMockValues } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsTable } from '../components';
import { TopQueriesNoResults } from './';
describe('TopQueriesNoResults', () => {
it('renders', () => {
setMockValues({ topQueriesNoResults: [] });
const wrapper = shallow(<TopQueriesNoResults />);
expect(wrapper.isEmptyRender()).toBe(false); // TODO
expect(wrapper.find(AnalyticsTable)).toHaveLength(1);
});
});

View file

@ -5,14 +5,20 @@
*/
import React from 'react';
import { useValues } from 'kea';
import { TOP_QUERIES_NO_RESULTS } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSearch, AnalyticsTable } from '../components';
import { AnalyticsLogic } from '../';
export const TopQueriesNoResults: React.FC = () => {
const { topQueriesNoResults } = useValues(AnalyticsLogic);
return (
<AnalyticsLayout isAnalyticsView title={TOP_QUERIES_NO_RESULTS}>
<p>TODO: Top queries with no results</p>
<AnalyticsSearch />
<AnalyticsTable items={topQueriesNoResults} hasClicks />
</AnalyticsLayout>
);
};

View file

@ -4,15 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { setMockValues } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsTable } from '../components';
import { TopQueriesWithClicks } from './';
describe('TopQueriesWithClicks', () => {
it('renders', () => {
setMockValues({ topQueriesWithClicks: [] });
const wrapper = shallow(<TopQueriesWithClicks />);
expect(wrapper.isEmptyRender()).toBe(false); // TODO
expect(wrapper.find(AnalyticsTable)).toHaveLength(1);
});
});

View file

@ -5,14 +5,20 @@
*/
import React from 'react';
import { useValues } from 'kea';
import { TOP_QUERIES_WITH_CLICKS } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSearch, AnalyticsTable } from '../components';
import { AnalyticsLogic } from '../';
export const TopQueriesWithClicks: React.FC = () => {
const { topQueriesWithClicks } = useValues(AnalyticsLogic);
return (
<AnalyticsLayout isAnalyticsView title={TOP_QUERIES_WITH_CLICKS}>
<p>TODO: Top queries with clicks</p>
<AnalyticsSearch />
<AnalyticsTable items={topQueriesWithClicks} hasClicks />
</AnalyticsLayout>
);
};