[App Search] Analytics - add reusable Layout & Header + basic subroute view components (#88552)

* Add AnalyticsUnavailable component

* Add AnalyticsHeader component

+ update AnalyticsLogic to store allTags prop passed by API

+ convertTagsToSelectOptions util helper

* Add AnalyticsLayout that all subroutes will utilize

- Handles shared concerns of:
  - Loading state & unavailable state
  - Flash messages & log retention callout
  - Reusing the header component
  - Fetching data
  - Reloading data based on filter updates

* Add very basic subroute views that utilize AnalyticsLayout

* Update QueryDetail pages to set breadcrumbs

* Fix QueryDetail breadcrumbs to not 404 on the 'Query' crumb

Redirect to the /analytics overview since we don't actually have a /query_details overview

* [PR feedback] Enforce date range defaults on the client side

- instead of using coincidence to sync our client side default range & server default range

- tags remain ''/undefined as default

* [PR feedback] Add explicit query vs analytics view prop

- to help devs more quickly distinguish at a glance whether a view will fetch analytics data or query data

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Constance 2021-01-19 15:20:01 -08:00 committed by GitHub
parent 6d1c010607
commit 3168b7d851
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 913 additions and 17 deletions

View file

@ -0,0 +1,94 @@
/*
* 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 '../../../__mocks__/shallow_useeffect.mock';
import '../../../__mocks__/react_router_history.mock';
import { mockKibanaValues, setMockValues, setMockActions, rerender } from '../../../__mocks__';
import React from 'react';
import { useParams } from 'react-router-dom';
import { shallow } from 'enzyme';
import { Loading } from '../../../shared/loading';
import { FlashMessages } from '../../../shared/flash_messages';
import { LogRetentionCallout } from '../log_retention';
import { AnalyticsHeader, AnalyticsUnavailable } from './components';
import { AnalyticsLayout } from './analytics_layout';
describe('AnalyticsLayout', () => {
const { history } = mockKibanaValues;
const values = {
history,
dataLoading: false,
analyticsUnavailable: false,
};
const actions = {
loadAnalyticsData: jest.fn(),
loadQueryData: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
history.location.search = '';
setMockValues(values);
setMockActions(actions);
});
it('renders', () => {
const wrapper = shallow(
<AnalyticsLayout title="Hello">
<div data-test-subj="world">World!</div>
</AnalyticsLayout>
);
expect(wrapper.find(FlashMessages)).toHaveLength(1);
expect(wrapper.find(LogRetentionCallout)).toHaveLength(1);
expect(wrapper.find(AnalyticsHeader).prop('title')).toEqual('Hello');
expect(wrapper.find('[data-test-subj="world"]').text()).toEqual('World!');
});
it('renders a loading component if data is not done loading', () => {
setMockValues({ ...values, dataLoading: true });
const wrapper = shallow(<AnalyticsLayout title="" />);
expect(wrapper.type()).toEqual(Loading);
});
it('renders an unavailable component if analytics are unavailable', () => {
setMockValues({ ...values, analyticsUnavailable: true });
const wrapper = shallow(<AnalyticsLayout title="" />);
expect(wrapper.type()).toEqual(AnalyticsUnavailable);
});
describe('data loading', () => {
it('loads query data for query details pages', () => {
(useParams as jest.Mock).mockReturnValueOnce({ query: 'test' });
shallow(<AnalyticsLayout isQueryView title="" />);
expect(actions.loadQueryData).toHaveBeenCalledWith('test');
});
it('loads analytics data for non query details pages', () => {
shallow(<AnalyticsLayout isAnalyticsView title="" />);
expect(actions.loadAnalyticsData).toHaveBeenCalled();
});
it('reloads data when search params are updated (by our AnalyticsHeader filters)', () => {
const wrapper = shallow(<AnalyticsLayout isAnalyticsView title="" />);
expect(actions.loadAnalyticsData).toHaveBeenCalledTimes(1);
history.location.search = '?tag=some-filter';
rerender(wrapper);
expect(actions.loadAnalyticsData).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -0,0 +1,52 @@
/*
* 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, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useValues, useActions } from 'kea';
import { KibanaLogic } from '../../../shared/kibana';
import { FlashMessages } from '../../../shared/flash_messages';
import { Loading } from '../../../shared/loading';
import { LogRetentionCallout, LogRetentionOptions } from '../log_retention';
import { AnalyticsLogic } from './';
import { AnalyticsHeader, AnalyticsUnavailable } from './components';
interface Props {
title: string;
isQueryView?: boolean;
isAnalyticsView?: boolean;
}
export const AnalyticsLayout: React.FC<Props> = ({
title,
isQueryView,
isAnalyticsView,
children,
}) => {
const { history } = useValues(KibanaLogic);
const { query } = useParams() as { query: string };
const { dataLoading, analyticsUnavailable } = useValues(AnalyticsLogic);
const { loadAnalyticsData, loadQueryData } = useActions(AnalyticsLogic);
useEffect(() => {
if (isQueryView) loadQueryData(query);
if (isAnalyticsView) loadAnalyticsData();
}, [history.location.search]);
if (dataLoading) return <Loading />;
if (analyticsUnavailable) return <AnalyticsUnavailable />;
return (
<>
<AnalyticsHeader title={title} />
<FlashMessages />
<LogRetentionCallout type={LogRetentionOptions.Analytics} />
{children}
</>
);
};

View file

@ -16,6 +16,7 @@ jest.mock('../engine', () => ({
EngineLogic: { values: { engineName: 'test-engine' } },
}));
import { DEFAULT_START_DATE, DEFAULT_END_DATE } from './constants';
import { AnalyticsLogic } from './';
describe('AnalyticsLogic', () => {
@ -27,6 +28,7 @@ describe('AnalyticsLogic', () => {
const DEFAULT_VALUES = {
dataLoading: true,
analyticsUnavailable: false,
allTags: [],
};
const MOCK_TOP_QUERIES = [
@ -117,6 +119,7 @@ describe('AnalyticsLogic', () => {
...DEFAULT_VALUES,
dataLoading: false,
analyticsUnavailable: false,
allTags: ['some-tag'],
// TODO: more state will get set here in future PRs
});
});
@ -131,6 +134,7 @@ describe('AnalyticsLogic', () => {
...DEFAULT_VALUES,
dataLoading: false,
analyticsUnavailable: false,
allTags: ['some-tag'],
// TODO: more state will get set here in future PRs
});
});
@ -162,7 +166,11 @@ describe('AnalyticsLogic', () => {
expect(http.get).toHaveBeenCalledWith(
'/api/app_search/engines/test-engine/analytics/queries',
{
query: { size: 20 },
query: {
start: DEFAULT_START_DATE,
end: DEFAULT_END_DATE,
size: 20,
},
}
);
expect(AnalyticsLogic.actions.onAnalyticsDataLoad).toHaveBeenCalledWith(
@ -239,7 +247,12 @@ describe('AnalyticsLogic', () => {
expect(http.get).toHaveBeenCalledWith(
'/api/app_search/engines/test-engine/analytics/queries/some-query',
expect.any(Object) // empty query obj
{
query: {
start: DEFAULT_START_DATE,
end: DEFAULT_END_DATE,
},
}
);
expect(AnalyticsLogic.actions.onQueryDataLoad).toHaveBeenCalledWith(MOCK_QUERY_RESPONSE);
});

View file

@ -12,6 +12,7 @@ import { HttpLogic } from '../../../shared/http';
import { flashAPIErrors } from '../../../shared/flash_messages';
import { EngineLogic } from '../engine';
import { DEFAULT_START_DATE, DEFAULT_END_DATE } from './constants';
import { AnalyticsData, QueryDetails } from './types';
interface AnalyticsValues extends AnalyticsData, QueryDetails {
@ -54,6 +55,13 @@ export const AnalyticsLogic = kea<MakeLogicType<AnalyticsValues, AnalyticsAction
onQueryDataLoad: () => false,
},
],
allTags: [
[],
{
onAnalyticsDataLoad: (_, { allTags }) => allTags,
onQueryDataLoad: (_, { allTags }) => allTags,
},
],
}),
listeners: ({ actions }) => ({
loadAnalyticsData: async () => {
@ -63,7 +71,12 @@ export const AnalyticsLogic = kea<MakeLogicType<AnalyticsValues, AnalyticsAction
try {
const { start, end, tag } = queryString.parse(history.location.search);
const query = { start, end, tag, size: 20 };
const query = {
start: start || DEFAULT_START_DATE,
end: end || DEFAULT_END_DATE,
tag,
size: 20,
};
const url = `/api/app_search/engines/${engineName}/analytics/queries`;
const response = await http.get(url, { query });
@ -85,7 +98,11 @@ export const AnalyticsLogic = kea<MakeLogicType<AnalyticsValues, AnalyticsAction
try {
const { start, end, tag } = queryString.parse(history.location.search);
const queryParams = { start, end, tag };
const queryParams = {
start: start || DEFAULT_START_DATE,
end: end || DEFAULT_END_DATE,
tag,
};
const url = `/api/app_search/engines/${engineName}/analytics/queries/${query}`;
const response = await http.get(url, { query: queryParams });

View file

@ -16,6 +16,6 @@ describe('AnalyticsRouter', () => {
const wrapper = shallow(<AnalyticsRouter engineBreadcrumb={['Engines', 'some-engine']} />);
expect(wrapper.find(Switch)).toHaveLength(1);
expect(wrapper.find(Route)).toHaveLength(8);
expect(wrapper.find(Route)).toHaveLength(9);
});
});

View file

@ -5,12 +5,14 @@
*/
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { Route, Switch, Redirect } from 'react-router-dom';
import { APP_SEARCH_PLUGIN } from '../../../../../common/constants';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs';
import { NotFound } from '../../../shared/not_found';
import {
getEngineRoute,
ENGINE_PATH,
ENGINE_ANALYTICS_PATH,
ENGINE_ANALYTICS_TOP_QUERIES_PATH,
@ -18,6 +20,7 @@ import {
ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH,
ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH,
ENGINE_ANALYTICS_RECENT_QUERIES_PATH,
ENGINE_ANALYTICS_QUERY_DETAILS_PATH,
ENGINE_ANALYTICS_QUERY_DETAIL_PATH,
} from '../../routes';
import {
@ -29,40 +32,54 @@ import {
RECENT_QUERIES,
} from './constants';
import {
Analytics,
TopQueries,
TopQueriesNoResults,
TopQueriesNoClicks,
TopQueriesWithClicks,
RecentQueries,
QueryDetail,
} from './views';
interface Props {
engineBreadcrumb: string[];
engineBreadcrumb: BreadcrumbTrail;
}
export const AnalyticsRouter: React.FC<Props> = ({ engineBreadcrumb }) => {
const ANALYTICS_BREADCRUMB = [...engineBreadcrumb, ANALYTICS_TITLE];
const engineName = engineBreadcrumb[1];
return (
<Switch>
<Route exact path={ENGINE_PATH + ENGINE_ANALYTICS_PATH}>
<SetPageChrome trail={ANALYTICS_BREADCRUMB} />
TODO: Analytics overview
<Analytics />
</Route>
<Route exact path={ENGINE_PATH + ENGINE_ANALYTICS_TOP_QUERIES_PATH}>
<SetPageChrome trail={[...ANALYTICS_BREADCRUMB, TOP_QUERIES]} />
TODO: Top queries
<TopQueries />
</Route>
<Route exact path={ENGINE_PATH + ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH}>
<SetPageChrome trail={[...ANALYTICS_BREADCRUMB, TOP_QUERIES_NO_RESULTS]} />
TODO: Top queries with no results
<TopQueriesNoResults />
</Route>
<Route exact path={ENGINE_PATH + ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH}>
<SetPageChrome trail={[...ANALYTICS_BREADCRUMB, TOP_QUERIES_NO_CLICKS]} />
TODO: Top queries with no clicks
<TopQueriesNoClicks />
</Route>
<Route exact path={ENGINE_PATH + ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH}>
<SetPageChrome trail={[...ANALYTICS_BREADCRUMB, TOP_QUERIES_WITH_CLICKS]} />
TODO: Top queries with clicks
<TopQueriesWithClicks />
</Route>
<Route exact path={ENGINE_PATH + ENGINE_ANALYTICS_RECENT_QUERIES_PATH}>
<SetPageChrome trail={[...ANALYTICS_BREADCRUMB, RECENT_QUERIES]} />
TODO: Recent queries
<RecentQueries />
</Route>
<Route exact path={ENGINE_PATH + ENGINE_ANALYTICS_QUERY_DETAIL_PATH}>
TODO: Query detail page
<QueryDetail breadcrumbs={ANALYTICS_BREADCRUMB} />
</Route>
<Route exact path={ENGINE_PATH + ENGINE_ANALYTICS_QUERY_DETAILS_PATH}>
<Redirect to={getEngineRoute(engineName) + ENGINE_ANALYTICS_PATH} />
</Route>
<Route>
<NotFound breadcrumbs={ANALYTICS_BREADCRUMB} product={APP_SEARCH_PLUGIN} />

View file

@ -0,0 +1,168 @@
/*
* 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 { setMockValues, mockKibanaValues } from '../../../../__mocks__';
import React, { ReactElement } from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import moment, { Moment } from 'moment';
import { EuiPageHeader, EuiSelect, EuiDatePickerRange, EuiButton } from '@elastic/eui';
import { LogRetentionTooltip } from '../../log_retention';
import { DEFAULT_START_DATE, DEFAULT_END_DATE } from '../constants';
import { AnalyticsHeader } from './';
describe('AnalyticsHeader', () => {
const { history } = mockKibanaValues;
const values = {
allTags: ['All Analytics Tags'], // Comes from the server API
history,
};
const newStartDateMoment = moment('1970-01-30');
const newEndDateMoment = moment('1970-01-31');
let wrapper: ShallowWrapper;
const getTagsSelect = () => wrapper.find(EuiSelect);
const getDateRangePicker = () => wrapper.find(EuiDatePickerRange);
const getStartDatePicker = () => getDateRangePicker().prop('startDateControl') as ReactElement;
const getEndDatePicker = () => getDateRangePicker().prop('endDateControl') as ReactElement;
const getApplyButton = () => wrapper.find(EuiButton);
beforeEach(() => {
jest.clearAllMocks();
history.location.search = '';
setMockValues(values);
});
it('renders', () => {
wrapper = shallow(<AnalyticsHeader title="Hello world" />);
expect(wrapper.type()).toEqual(EuiPageHeader);
expect(wrapper.find('h1').text()).toEqual('Hello world');
expect(wrapper.find(LogRetentionTooltip)).toHaveLength(1);
expect(wrapper.find(EuiSelect)).toHaveLength(1);
expect(wrapper.find(EuiDatePickerRange)).toHaveLength(1);
});
it('renders tags & dates with default values when no search query params are present', () => {
wrapper = shallow(<AnalyticsHeader title="" />);
expect(getTagsSelect().prop('value')).toEqual('');
expect(getStartDatePicker().props.startDate._i).toEqual(DEFAULT_START_DATE);
expect(getEndDatePicker().props.endDate._i).toEqual(DEFAULT_END_DATE);
});
describe('tags select', () => {
beforeEach(() => {
history.location.search = '?tag=tag1';
const allTags = [...values.allTags, 'tag1', 'tag2', 'tag3'];
setMockValues({ ...values, allTags });
wrapper = shallow(<AnalyticsHeader title="" />);
});
it('renders the tags select with currentTag value and allTags options', () => {
const tagsSelect = getTagsSelect();
expect(tagsSelect.prop('value')).toEqual('tag1');
expect(tagsSelect.prop('options')).toEqual([
{ value: '', text: 'All analytics tags' },
{ value: 'tag1', text: 'tag1' },
{ value: 'tag2', text: 'tag2' },
{ value: 'tag3', text: 'tag3' },
]);
});
it('updates currentTag on new tag select', () => {
getTagsSelect().simulate('change', { target: { value: 'tag3' } });
expect(getTagsSelect().prop('value')).toEqual('tag3');
});
});
describe('date pickers', () => {
beforeEach(() => {
history.location.search = '?start=1970-01-01&end=1970-01-02';
wrapper = shallow(<AnalyticsHeader title="" />);
});
it('renders the start date picker', () => {
const startDatePicker = getStartDatePicker();
expect(startDatePicker.props.selected._i).toEqual('1970-01-01');
expect(startDatePicker.props.startDate._i).toEqual('1970-01-01');
});
it('renders the end date picker', () => {
const endDatePicker = getEndDatePicker();
expect(endDatePicker.props.selected._i).toEqual('1970-01-02');
expect(endDatePicker.props.endDate._i).toEqual('1970-01-02');
});
it('updates startDate on start date pick', () => {
getStartDatePicker().props.onChange(newStartDateMoment);
expect(getStartDatePicker().props.startDate._i).toEqual('1970-01-30');
});
it('updates endDate on start date pick', () => {
getEndDatePicker().props.onChange(newEndDateMoment);
expect(getEndDatePicker().props.endDate._i).toEqual('1970-01-31');
});
});
describe('invalid date ranges', () => {
beforeEach(() => {
history.location.search = '?start=1970-01-02&end=1970-01-01';
wrapper = shallow(<AnalyticsHeader title="" />);
});
it('renders the date pickers as invalid', () => {
expect(getStartDatePicker().props.isInvalid).toEqual(true);
expect(getEndDatePicker().props.isInvalid).toEqual(true);
});
it('disables the apply button', () => {
expect(getApplyButton().prop('isDisabled')).toEqual(true);
});
});
describe('applying filters', () => {
const updateState = ({ start, end, tag }: { start: Moment; end: Moment; tag: string }) => {
getTagsSelect().simulate('change', { target: { value: tag } });
getStartDatePicker().props.onChange(start);
getEndDatePicker().props.onChange(end);
};
beforeEach(() => {
wrapper = shallow(<AnalyticsHeader title="" />);
});
it('pushes up new tag & date state to the search query', () => {
updateState({ start: newStartDateMoment, end: newEndDateMoment, tag: 'tag2' });
getApplyButton().simulate('click');
expect(history.push).toHaveBeenCalledWith({
search: 'end=1970-01-31&start=1970-01-30&tag=tag2',
});
});
it('does not push up the tag param if empty (set to all tags)', () => {
updateState({ start: newStartDateMoment, end: newEndDateMoment, tag: '' });
getApplyButton().simulate('click');
expect(history.push).toHaveBeenCalledWith({
search: 'end=1970-01-31&start=1970-01-30',
});
});
});
});

View file

@ -0,0 +1,133 @@
/*
* 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 queryString from 'query-string';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import {
EuiPageHeader,
EuiPageHeaderSection,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiSelect,
EuiDatePickerRange,
EuiDatePicker,
EuiButton,
} from '@elastic/eui';
import { KibanaLogic } from '../../../../shared/kibana';
import { LogRetentionTooltip, LogRetentionOptions } from '../../log_retention';
import { AnalyticsLogic } from '../';
import { DEFAULT_START_DATE, DEFAULT_END_DATE, SERVER_DATE_FORMAT } from '../constants';
import { convertTagsToSelectOptions } from '../utils';
interface Props {
title: string;
}
export const AnalyticsHeader: React.FC<Props> = ({ title }) => {
const { allTags } = useValues(AnalyticsLogic);
const { history } = useValues(KibanaLogic);
// Parse out existing filters from URL query string
const { start, end, tag } = queryString.parse(history.location.search);
const [startDate, setStartDate] = useState(
start ? moment(start, SERVER_DATE_FORMAT) : moment(DEFAULT_START_DATE)
);
const [endDate, setEndDate] = useState(
end ? moment(end, SERVER_DATE_FORMAT) : moment(DEFAULT_END_DATE)
);
const [currentTag, setCurrentTag] = useState((tag as string) || '');
// Set the current URL query string on filter
const onApplyFilters = () => {
const search = queryString.stringify({
start: moment(startDate).format(SERVER_DATE_FORMAT),
end: moment(endDate).format(SERVER_DATE_FORMAT),
tag: currentTag || undefined,
});
history.push({ search });
};
const hasInvalidDateRange = startDate > endDate;
return (
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiFlexGroup alignItems="center" justifyContent="flexStart" responsive={false}>
<EuiFlexItem grow={false}>
<EuiTitle size="l">
<h1>{title}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LogRetentionTooltip type={LogRetentionOptions.Analytics} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageHeaderSection>
<EuiPageHeaderSection>
<EuiFlexGroup alignItems="center" justifyContent="flexEnd" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiSelect
options={convertTagsToSelectOptions(allTags)}
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
aria-label={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.filters.tagAriaLabel',
{ defaultMessage: 'Filter by analytics tag"' }
)}
fullWidth
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiDatePickerRange
startDateControl={
<EuiDatePicker
selected={startDate}
onChange={(date) => date && setStartDate(date)}
startDate={startDate}
endDate={endDate}
isInvalid={hasInvalidDateRange}
aria-label={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.filters.startDateAriaLabel',
{ defaultMessage: 'Filter by start date' }
)}
/>
}
endDateControl={
<EuiDatePicker
selected={endDate}
onChange={(date) => date && setEndDate(date)}
startDate={startDate}
endDate={endDate}
isInvalid={hasInvalidDateRange}
aria-label={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.filters.endDateAriaLabel',
{ defaultMessage: 'Filter by end date' }
)}
/>
}
fullWidth
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill isDisabled={hasInvalidDateRange} onClick={onApplyFilters}>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.filters.applyButtonLabel',
{ defaultMessage: 'Apply filters' }
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageHeaderSection>
</EuiPageHeader>
);
};

View file

@ -0,0 +1,22 @@
/*
* 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 { EuiEmptyPrompt } from '@elastic/eui';
import { FlashMessages } from '../../../../shared/flash_messages';
import { AnalyticsUnavailable } from './';
describe('AnalyticsUnavailable', () => {
it('renders', () => {
const wrapper = shallow(<AnalyticsUnavailable />);
expect(wrapper.find(FlashMessages)).toHaveLength(1);
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
});
});

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiEmptyPrompt } from '@elastic/eui';
import { FlashMessages } from '../../../../shared/flash_messages';
export const AnalyticsUnavailable: React.FC = () => (
<>
<FlashMessages />
<EuiEmptyPrompt
iconType="visLine"
title={
<h2>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.unavailable.title', {
defaultMessage: 'Analytics are currently unavailable',
})}
</h2>
}
body={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.unavailable.description',
{ defaultMessage: 'Please try again in a few minutes.' }
)}
/>
</>
);

View file

@ -5,3 +5,5 @@
*/
export { AnalyticsChart } from './analytics_chart';
export { AnalyticsHeader } from './analytics_header';
export { AnalyticsUnavailable } from './analytics_unavailable';

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import moment from 'moment';
import { i18n } from '@kbn/i18n';
export const ANALYTICS_TITLE = i18n.translate(
@ -51,7 +52,10 @@ export const RECENT_QUERIES = i18n.translate(
{ defaultMessage: 'Recent queries' }
);
// Moment date format conversions
// Date formats & dates
export const SERVER_DATE_FORMAT = 'YYYY-MM-DD';
export const TOOLTIP_DATE_FORMAT = 'MMMM D, YYYY';
export const X_AXIS_DATE_FORMAT = 'M/D';
export const DEFAULT_START_DATE = moment().subtract(6, 'days').format(SERVER_DATE_FORMAT);
export const DEFAULT_END_DATE = moment().format(SERVER_DATE_FORMAT);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { convertToChartData } from './utils';
import { convertToChartData, convertTagsToSelectOptions } from './utils';
describe('convertToChartData', () => {
it('converts server-side analytics data into an array of objects that Elastic Charts can consume', () => {
@ -22,3 +22,23 @@ describe('convertToChartData', () => {
]);
});
});
describe('convertTagsToSelectOptions', () => {
it('converts server-side tag data into an array of objects that EuiSelect can consume', () => {
expect(
convertTagsToSelectOptions([
'All Analytics Tags',
'lorem_ipsum',
'dolor_sit',
'amet',
'consectetur_adipiscing_elit',
])
).toEqual([
{ value: '', text: 'All analytics tags' },
{ value: 'lorem_ipsum', text: 'lorem_ipsum' },
{ value: 'dolor_sit', text: 'dolor_sit' },
{ value: 'amet', text: 'amet' },
{ value: 'consectetur_adipiscing_elit', text: 'consectetur_adipiscing_elit' },
]);
});
});

View file

@ -5,6 +5,8 @@
*/
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { EuiSelectProps } from '@elastic/eui';
import { SERVER_DATE_FORMAT } from './constants';
import { ChartData } from './components/analytics_chart';
@ -20,3 +22,25 @@ export const convertToChartData = ({ data, startDate }: ConvertToChartData): Cha
y,
}));
};
export const convertTagsToSelectOptions = (tags: string[]): EuiSelectProps['options'] => {
// Our server API returns an initial default tag for us, but we don't want to use it because
// it's not i18n'ed, and also setting the value to '' is nicer for select/param UX
tags = tags.slice(1);
const DEFAULT_OPTION = {
value: '',
text: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.allTagsDropDownOptionLabel',
{ defaultMessage: 'All analytics tags' }
),
};
return [
DEFAULT_OPTION,
...tags.map((tag: string) => ({
value: tag,
text: tag,
})),
];
};

View file

@ -0,0 +1,18 @@
/*
* 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 { Analytics } from './';
describe('Analytics overview', () => {
it('renders', () => {
const wrapper = shallow(<Analytics />);
expect(wrapper.isEmptyRender()).toBe(false); // TODO
});
});

View file

@ -0,0 +1,18 @@
/*
* 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 { ANALYTICS_TITLE } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
export const Analytics: React.FC = () => {
return (
<AnalyticsLayout isAnalyticsView title={ANALYTICS_TITLE}>
<p>TODO: Analytics overview</p>
</AnalyticsLayout>
);
};

View file

@ -0,0 +1,13 @@
/*
* 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 { Analytics } from './analytics';
export { TopQueries } from './top_queries';
export { TopQueriesNoResults } from './top_queries_no_results';
export { TopQueriesNoClicks } from './top_queries_no_clicks';
export { TopQueriesWithClicks } from './top_queries_with_clicks';
export { RecentQueries } from './recent_queries';
export { QueryDetail } from './query_detail';

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;
* you may not use this file except in compliance with the Elastic License.
*/
import '../../../../__mocks__/react_router_history.mock';
import React from 'react';
import { useParams } from 'react-router-dom';
import { shallow } from 'enzyme';
import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome';
import { QueryDetail } from './';
describe('QueryDetail', () => {
const mockBreadcrumbs = ['Engines', 'some-engine', 'Analytics'];
beforeEach(() => {
(useParams as jest.Mock).mockReturnValueOnce({ query: 'some-query' });
});
it('renders', () => {
const wrapper = shallow(<QueryDetail breadcrumbs={mockBreadcrumbs} />);
expect(wrapper.find(SetPageChrome).prop('trail')).toEqual([
'Engines',
'some-engine',
'Analytics',
'Query',
'some-query',
]);
});
});

View file

@ -0,0 +1,34 @@
/*
* 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 { useParams } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome';
import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs';
import { AnalyticsLayout } from '../analytics_layout';
const QUERY_DETAIL_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetail.title',
{ defaultMessage: 'Query' }
);
interface Props {
breadcrumbs: BreadcrumbTrail;
}
export const QueryDetail: React.FC<Props> = ({ breadcrumbs }) => {
const { query } = useParams() as { query: string };
return (
<AnalyticsLayout isQueryView title={`"${query}"`}>
<SetPageChrome trail={[...breadcrumbs, QUERY_DETAIL_TITLE, query]} />
<p>TODO: Query detail page</p>
</AnalyticsLayout>
);
};

View file

@ -0,0 +1,18 @@
/*
* 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 { RecentQueries } from './';
describe('RecentQueries', () => {
it('renders', () => {
const wrapper = shallow(<RecentQueries />);
expect(wrapper.isEmptyRender()).toBe(false); // TODO
});
});

View file

@ -0,0 +1,18 @@
/*
* 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 { RECENT_QUERIES } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
export const RecentQueries: React.FC = () => {
return (
<AnalyticsLayout isAnalyticsView title={RECENT_QUERIES}>
<p>TODO: Recent queries</p>
</AnalyticsLayout>
);
};

View file

@ -0,0 +1,18 @@
/*
* 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 { TopQueries } from './';
describe('TopQueries', () => {
it('renders', () => {
const wrapper = shallow(<TopQueries />);
expect(wrapper.isEmptyRender()).toBe(false); // TODO
});
});

View file

@ -0,0 +1,18 @@
/*
* 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 { TOP_QUERIES } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
export const TopQueries: React.FC = () => {
return (
<AnalyticsLayout isAnalyticsView title={TOP_QUERIES}>
<p>TODO: Top queries</p>
</AnalyticsLayout>
);
};

View file

@ -0,0 +1,18 @@
/*
* 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 { TopQueriesNoClicks } from './';
describe('TopQueriesNoClicks', () => {
it('renders', () => {
const wrapper = shallow(<TopQueriesNoClicks />);
expect(wrapper.isEmptyRender()).toBe(false); // TODO
});
});

View file

@ -0,0 +1,18 @@
/*
* 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 { TOP_QUERIES_NO_CLICKS } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
export const TopQueriesNoClicks: React.FC = () => {
return (
<AnalyticsLayout isAnalyticsView title={TOP_QUERIES_NO_CLICKS}>
<p>TODO: Top queries with no clicks</p>
</AnalyticsLayout>
);
};

View file

@ -0,0 +1,18 @@
/*
* 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 { TopQueriesNoResults } from './';
describe('TopQueriesNoResults', () => {
it('renders', () => {
const wrapper = shallow(<TopQueriesNoResults />);
expect(wrapper.isEmptyRender()).toBe(false); // TODO
});
});

View file

@ -0,0 +1,18 @@
/*
* 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 { TOP_QUERIES_NO_RESULTS } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
export const TopQueriesNoResults: React.FC = () => {
return (
<AnalyticsLayout isAnalyticsView title={TOP_QUERIES_NO_RESULTS}>
<p>TODO: Top queries with no results</p>
</AnalyticsLayout>
);
};

View file

@ -0,0 +1,18 @@
/*
* 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 { TopQueriesWithClicks } from './';
describe('TopQueriesWithClicks', () => {
it('renders', () => {
const wrapper = shallow(<TopQueriesWithClicks />);
expect(wrapper.isEmptyRender()).toBe(false); // TODO
});
});

View file

@ -0,0 +1,18 @@
/*
* 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 { TOP_QUERIES_WITH_CLICKS } from '../constants';
import { AnalyticsLayout } from '../analytics_layout';
export const TopQueriesWithClicks: React.FC = () => {
return (
<AnalyticsLayout isAnalyticsView title={TOP_QUERIES_WITH_CLICKS}>
<p>TODO: Top queries with clicks</p>
</AnalyticsLayout>
);
};

View file

@ -30,7 +30,8 @@ export const ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH = `${ENGINE_ANALYTICS_P
export const ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH = `${ENGINE_ANALYTICS_PATH}/top_queries_no_results`;
export const ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH = `${ENGINE_ANALYTICS_PATH}/top_queries_with_clicks`;
export const ENGINE_ANALYTICS_RECENT_QUERIES_PATH = `${ENGINE_ANALYTICS_PATH}/recent_queries`;
export const ENGINE_ANALYTICS_QUERY_DETAIL_PATH = `${ENGINE_ANALYTICS_PATH}/query_detail/:query`;
export const ENGINE_ANALYTICS_QUERY_DETAILS_PATH = `${ENGINE_ANALYTICS_PATH}/query_detail`;
export const ENGINE_ANALYTICS_QUERY_DETAIL_PATH = `${ENGINE_ANALYTICS_QUERY_DETAILS_PATH}/:query`;
export const ENGINE_DOCUMENTS_PATH = '/documents';
export const ENGINE_DOCUMENT_DETAIL_PATH = `${ENGINE_DOCUMENTS_PATH}/:documentId`;