[App Search] Add AnalyticsChart component to EnginesOverview (#87752)

* Set up Kibana charts plugin dependency

- required for using shared colors/themes/etc.
- see https://github.com/elastic/kibana/tree/master/src/plugins/charts

* Add reusable AnalyticsChart component

+ util for converting data from our server API to data that Elastic Charts can use

* Update EngineOverview to use AnalyticsChart

+ remove now-unnecessary endDate value (we don't really need it just IMO)

* [PR feedback] Return type

* [Self feedback] naming - remove pluralization
This commit is contained in:
Constance 2021-01-12 11:43:01 -08:00 committed by GitHub
parent a1c2422261
commit 04739d851c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 229 additions and 34 deletions

View file

@ -2,7 +2,7 @@
"id": "enterpriseSearch",
"version": "kibana",
"kibanaVersion": "kibana",
"requiredPlugins": ["features", "licensing"],
"requiredPlugins": ["features", "licensing", "charts"],
"configPath": ["enterpriseSearch"],
"optionalPlugins": ["usageCollection", "security", "home", "spaces", "cloud"],
"server": true,

View file

@ -4,15 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { chartPluginMock } from '../../../../../../src/plugins/charts/public/mocks';
import { mockHistory } from './';
export const mockKibanaValues = {
config: { host: 'http://localhost:3002' },
history: mockHistory,
charts: chartPluginMock.createStartContract(),
cloud: {
isCloudEnabled: false,
cloudDeploymentUrl: 'https://cloud.elastic.co/deployments/some-id',
},
history: mockHistory,
navigateToUrl: jest.fn(),
setBreadcrumbs: jest.fn(),
setDocTitle: jest.fn(),

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mockKibanaValues } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { Chart, Settings, LineSeries, Axis } from '@elastic/charts';
import { AnalyticsChart } from './';
describe('AnalyticsChart', () => {
const MOCK_DATA = [
{ x: '1970-01-01', y: 0 },
{ x: '1970-01-02', y: 1 },
{ x: '1970-01-03', y: 5 },
{ x: '1970-01-04', y: 50 },
{ x: '1970-01-05', y: 25 },
];
beforeAll(() => {
jest.clearAllMocks();
});
it('renders an Elastic line chart', () => {
const wrapper = shallow(
<AnalyticsChart height={300} lines={[{ id: 'test', data: MOCK_DATA }]} />
);
expect(wrapper.find(Chart).prop('size')).toEqual({ height: 300 });
expect(wrapper.find(Axis)).toHaveLength(2);
expect(mockKibanaValues.charts.theme.useChartsTheme).toHaveBeenCalled();
expect(mockKibanaValues.charts.theme.useChartsBaseTheme).toHaveBeenCalled();
expect(wrapper.find(LineSeries)).toHaveLength(1);
expect(wrapper.find(LineSeries).prop('id')).toEqual('test');
expect(wrapper.find(LineSeries).prop('data')).toEqual(MOCK_DATA);
});
it('renders multiple lines', () => {
const wrapper = shallow(
<AnalyticsChart
lines={[
{ id: 'line 1', data: MOCK_DATA },
{ id: 'line 2', data: MOCK_DATA },
{ id: 'line 3', data: MOCK_DATA },
]}
/>
);
expect(wrapper.find(LineSeries)).toHaveLength(3);
});
it('formats x-axis dates correctly', () => {
const wrapper = shallow(<AnalyticsChart lines={[{ id: 'test', data: MOCK_DATA }]} />);
const dateFormatter: Function = wrapper.find('#bottom-axis').prop('tickFormat');
expect(dateFormatter('1970-02-28')).toEqual('2/28');
});
it('formats tooltip dates correctly', () => {
const wrapper = shallow(<AnalyticsChart lines={[{ id: 'test', data: MOCK_DATA }]} />);
const dateFormatter: Function = (wrapper.find(Settings).prop('tooltip') as any).headerFormatter;
expect(dateFormatter({ value: '1970-12-03' })).toEqual('December 3, 1970');
});
});

View file

@ -0,0 +1,61 @@
/*
* 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 { useValues } from 'kea';
import moment from 'moment';
import { Chart, Settings, LineSeries, CurveType, Axis } from '@elastic/charts';
import { KibanaLogic } from '../../../../shared/kibana';
import { X_AXIS_DATE_FORMAT, TOOLTIP_DATE_FORMAT } from '../constants';
interface ChartPoint {
x: string; // Date string
y: number; // # of clicks, queries, etc.
}
export type ChartData = ChartPoint[];
interface Props {
height?: number;
lines: Array<{
id: string;
data: ChartData;
}>;
}
export const AnalyticsChart: React.FC<Props> = ({ height = 300, lines }) => {
const { charts } = useValues(KibanaLogic);
return (
<Chart size={{ height }}>
<Settings
theme={charts.theme.useChartsTheme()}
baseTheme={charts.theme.useChartsBaseTheme()}
tooltip={{
headerFormatter: (tooltip) => moment(tooltip.value).format(TOOLTIP_DATE_FORMAT),
}}
/>
{lines.map(({ id, data }) => (
<LineSeries
key={id}
id={id}
data={data}
xAccessor={'x'}
yAccessors={['y']}
curve={CurveType.CURVE_MONOTONE_X}
/>
))}
<Axis
id="bottom-axis"
position="bottom"
tickFormat={(d) => moment(d).format(X_AXIS_DATE_FORMAT)}
showGridLines
/>
<Axis id="left-axis" position="left" ticks={4} showGridLines />
</Chart>
);
};

View file

@ -0,0 +1,7 @@
/*
* 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 { AnalyticsChart } from './analytics_chart';

View file

@ -30,3 +30,8 @@ export const TOTAL_CLICKS = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.totalClicks',
{ defaultMessage: 'Total clicks' }
);
// Moment date format conversions
export const SERVER_DATE_FORMAT = 'YYYY-MM-DD';
export const TOOLTIP_DATE_FORMAT = 'MMMM D, YYYY';
export const X_AXIS_DATE_FORMAT = 'M/D';

View file

@ -5,3 +5,5 @@
*/
export { ANALYTICS_TITLE } from './constants';
export { AnalyticsChart } from './components';
export { convertToChartData } from './utils';

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 { convertToChartData } from './utils';
describe('convertToChartData', () => {
it('converts server-side analytics data into an array of objects that Elastic Charts can consume', () => {
expect(
convertToChartData({
startDate: '1970-01-01',
data: [0, 1, 5, 50, 25],
})
).toEqual([
{ x: '1970-01-01', y: 0 },
{ x: '1970-01-02', y: 1 },
{ x: '1970-01-03', y: 5 },
{ x: '1970-01-04', y: 50 },
{ x: '1970-01-05', y: 25 },
]);
});
});

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 moment from 'moment';
import { SERVER_DATE_FORMAT } from './constants';
import { ChartData } from './components/analytics_chart';
interface ConvertToChartData {
data: number[];
startDate: string;
}
export const convertToChartData = ({ data, startDate }: ConvertToChartData): ChartData => {
const date = moment(startDate, SERVER_DATE_FORMAT);
return data.map((y, index) => ({
x: moment(date).add(index, 'days').format(SERVER_DATE_FORMAT),
y,
}));
};

View file

@ -10,6 +10,7 @@ import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { EuiButtonTo } from '../../../../shared/react_router_helpers';
import { AnalyticsChart } from '../../analytics';
import { TotalCharts } from './total_charts';
@ -21,7 +22,6 @@ describe('TotalCharts', () => {
setMockValues({
engineName: 'some-engine',
startDate: '1970-01-01',
endDate: '1970-01-08',
queriesPerDay: [0, 1, 2, 3, 5, 10, 50],
operationsPerDay: [0, 0, 0, 0, 0, 0, 0],
});
@ -33,7 +33,7 @@ describe('TotalCharts', () => {
expect(chart.find('h2').text()).toEqual('Total queries');
expect(chart.find(EuiButtonTo).prop('to')).toEqual('/engines/some-engine/analytics');
// TODO: find chart component
expect(chart.find(AnalyticsChart)).toHaveLength(1);
});
it('renders the total API operations chart', () => {
@ -41,6 +41,6 @@ describe('TotalCharts', () => {
expect(chart.find('h2').text()).toEqual('Total API operations');
expect(chart.find(EuiButtonTo).prop('to')).toEqual('/engines/some-engine/api-logs');
// TODO: find chart component
expect(chart.find(AnalyticsChart)).toHaveLength(1);
});
});

View file

@ -24,6 +24,8 @@ import { ENGINE_ANALYTICS_PATH, ENGINE_API_LOGS_PATH, getEngineRoute } from '../
import { TOTAL_QUERIES, TOTAL_API_OPERATIONS } from '../../analytics/constants';
import { VIEW_ANALYTICS, VIEW_API_LOGS, LAST_7_DAYS } from '../constants';
import { AnalyticsChart, convertToChartData } from '../../analytics';
import { EngineLogic } from '../../engine';
import { EngineOverviewLogic } from '../';
@ -31,12 +33,7 @@ export const TotalCharts: React.FC = () => {
const { engineName } = useValues(EngineLogic);
const engineRoute = getEngineRoute(engineName);
const {
// startDate,
// endDate,
// queriesPerDay,
// operationsPerDay,
} = useValues(EngineOverviewLogic);
const { startDate, queriesPerDay, operationsPerDay } = useValues(EngineOverviewLogic);
return (
<EuiFlexGroup>
@ -58,12 +55,14 @@ export const TotalCharts: React.FC = () => {
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>
TODO: Analytics chart
{/* <EngineAnalytics
data={[queriesPerDay]}
startDate={new Date(startDate)}
endDate={new Date(endDate)}
/> */}
<AnalyticsChart
lines={[
{
id: TOTAL_QUERIES,
data: convertToChartData({ startDate, data: queriesPerDay }),
},
]}
/>
</EuiPageContentBody>
</EuiPageContent>
</EuiFlexItem>
@ -85,12 +84,14 @@ export const TotalCharts: React.FC = () => {
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>
TODO: API Logs chart
{/* <EngineAnalytics
data={[operationsPerDay]}
startDate={new Date(startDate)}
endDate={new Date(endDate)}
/> */}
<AnalyticsChart
lines={[
{
id: TOTAL_API_OPERATIONS,
data: convertToChartData({ startDate, data: operationsPerDay }),
},
]}
/>
</EuiPageContentBody>
</EuiPageContent>
</EuiFlexItem>

View file

@ -28,7 +28,6 @@ describe('EngineOverviewLogic', () => {
apiLogsUnavailable: true,
documentCount: 10,
startDate: '1970-01-30',
endDate: '1970-01-31',
operationsPerDay: [0, 0, 0, 0, 0, 0, 0],
queriesPerDay: [0, 0, 0, 0, 0, 25, 50],
totalClicks: 50,
@ -40,7 +39,6 @@ describe('EngineOverviewLogic', () => {
apiLogsUnavailable: false,
documentCount: 0,
startDate: '',
endDate: '',
operationsPerDay: [],
queriesPerDay: [],
totalClicks: 0,

View file

@ -16,7 +16,6 @@ interface EngineOverviewApiData {
apiLogsUnavailable: boolean;
documentCount: number;
startDate: string;
endDate: string;
operationsPerDay: number[];
queriesPerDay: number[];
totalClicks: number;
@ -61,12 +60,6 @@ export const EngineOverviewLogic = kea<MakeLogicType<EngineOverviewValues, Engin
setPolledData: (_, { startDate }) => startDate,
},
],
endDate: [
'',
{
setPolledData: (_, { endDate }) => endDate,
},
],
queriesPerDay: [
[],
{

View file

@ -9,6 +9,7 @@ import { getContext } from 'kea';
import { coreMock } from 'src/core/public/mocks';
import { licensingMock } from '../../../licensing/public/mocks';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
import { renderApp, renderHeaderActions } from './';
import { EnterpriseSearch } from './enterprise_search';
@ -20,7 +21,10 @@ describe('renderApp', () => {
const kibanaDeps = {
params: coreMock.createAppMountParamters(),
core: coreMock.createStart(),
plugins: { licensing: licensingMock.createStart() },
plugins: {
licensing: licensingMock.createStart(),
charts: chartPluginMock.createStartContract(),
},
} as any;
const pluginData = {
config: {},

View file

@ -41,6 +41,7 @@ export const renderApp = (
const unmountKibanaLogic = mountKibanaLogic({
config,
charts: plugins.charts,
cloud: plugins.cloud || {},
history: params.history,
navigateToUrl: core.application.navigateToUrl,

View file

@ -9,6 +9,7 @@ import { kea, MakeLogicType } from 'kea';
import { FC } from 'react';
import { History } from 'history';
import { ApplicationStart, ChromeBreadcrumb } from 'src/core/public';
import { ChartsPluginStart } from 'src/plugins/charts/public';
import { CloudSetup } from '../../../../../cloud/public';
import { HttpLogic } from '../http';
@ -18,6 +19,7 @@ interface KibanaLogicProps {
config: { host?: string };
history: History;
cloud: Partial<CloudSetup>;
charts: ChartsPluginStart;
navigateToUrl: ApplicationStart['navigateToUrl'];
setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void;
setDocTitle(title: string): void;
@ -31,8 +33,9 @@ export const KibanaLogic = kea<MakeLogicType<KibanaValues>>({
path: ['enterprise_search', 'kibana_logic'],
reducers: ({ props }) => ({
config: [props.config || {}, {}],
history: [props.history, {}],
charts: [props.charts, {}],
cloud: [props.cloud || {}, {}],
history: [props.history, {}],
navigateToUrl: [
(url: string, options?: CreateHrefOptions) => {
const deps = { history: props.history, http: HttpLogic.values.http };

View file

@ -18,6 +18,7 @@ import {
} from '../../../../src/plugins/home/public';
import { CloudSetup } from '../../cloud/public';
import { LicensingPluginStart } from '../../licensing/public';
import { ChartsPluginStart } from '../../../../src/plugins/charts/public';
import {
APP_SEARCH_PLUGIN,
@ -41,6 +42,7 @@ interface PluginsSetup {
export interface PluginsStart {
cloud?: CloudSetup;
licensing: LicensingPluginStart;
charts: ChartsPluginStart;
}
export class EnterpriseSearchPlugin implements Plugin {