mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Enterprise Search] [Behavioral analytics] Add Collection Dashboard UI (#153509)
- ✅ The following elements should be implemented: 1. Searches 2. No results 3. Clicks 4. Sessions - ✅ The percentage should be calculated based on the current period and the same period of time before the start date. E.g. last 7 days and 7 days before that. - ✅ The percentage and numbers color should be green, orange, or grey for positive, negative, and neutral numbers - ✅ When hovering the element, highlight the corresponding chart line - ✅ When clicking on the element, highlight the corresponding chart line and area under the chart line https://user-images.githubusercontent.com/17390745/227809069-2430101a-b8b4-4cf7-ac68-45cc663ed96b.mov --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
29f038071e
commit
58b235650a
20 changed files with 1225 additions and 427 deletions
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { setMockValues } from '../../../__mocks__/kea_logic';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import moment from 'moment';
|
||||
|
||||
import { AreaSeries, Chart } from '@elastic/charts';
|
||||
import { EuiLoadingChart } from '@elastic/eui';
|
||||
|
||||
import { FilterBy } from '../../utils/get_formula_by_filter';
|
||||
|
||||
import { AnalyticsCollectionChart } from './analytics_collection_chart';
|
||||
|
||||
describe('AnalyticsCollectionChart', () => {
|
||||
const mockedData = Object.values(FilterBy).reduce(
|
||||
(result, id) => ({
|
||||
...result,
|
||||
[id]: [
|
||||
[100, 200],
|
||||
[200, 300],
|
||||
],
|
||||
}),
|
||||
{}
|
||||
);
|
||||
const mockedTimeRange = {
|
||||
from: moment().subtract(7, 'days').toISOString(),
|
||||
to: moment().toISOString(),
|
||||
};
|
||||
const mockedDataViewQuery = 'mockedDataViewQuery';
|
||||
|
||||
const defaultProps = {
|
||||
data: mockedData,
|
||||
dataViewQuery: mockedDataViewQuery,
|
||||
id: 'mockedId',
|
||||
isLoading: false,
|
||||
timeRange: mockedTimeRange,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setMockValues({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render chart and metrics for each chart', () => {
|
||||
const component = shallow(<AnalyticsCollectionChart {...defaultProps} />);
|
||||
expect(component.find(Chart)).toHaveLength(1);
|
||||
expect(component.find(AreaSeries)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should render a loading indicator if loading', () => {
|
||||
const component = shallow(<AnalyticsCollectionChart {...defaultProps} isLoading />);
|
||||
expect(component.find(EuiLoadingChart).exists()).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,331 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { useValues } from 'kea';
|
||||
import moment from 'moment';
|
||||
|
||||
import {
|
||||
AreaSeries,
|
||||
Axis,
|
||||
Chart,
|
||||
CurveType,
|
||||
Position,
|
||||
ScaleType,
|
||||
Settings,
|
||||
Tooltip,
|
||||
TooltipType,
|
||||
} from '@elastic/charts';
|
||||
|
||||
import { XYChartElementEvent } from '@elastic/charts/dist/specs/settings';
|
||||
import { niceTimeFormatter } from '@elastic/charts/dist/utils/data/formatters';
|
||||
import { EuiFlexGroup, EuiLoadingChart } from '@elastic/eui';
|
||||
|
||||
import DateMath from '@kbn/datemath';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { DateHistogramIndexPatternColumn, TypedLensByValueInput } from '@kbn/lens-plugin/public';
|
||||
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
|
||||
import { KibanaLogic } from '../../../shared/kibana';
|
||||
|
||||
import { withLensData, WithLensDataInputProps } from '../../hoc/with_lens_data';
|
||||
import { FilterBy as ChartIds, getFormulaByFilter } from '../../utils/get_formula_by_filter';
|
||||
|
||||
import { AnalyticsCollectionViewMetricWithLens } from './analytics_collection_metric';
|
||||
|
||||
const DEFAULT_STROKE_WIDTH = 1;
|
||||
const HOVER_STROKE_WIDTH = 3;
|
||||
const CHART_HEIGHT = 490;
|
||||
|
||||
interface AnalyticsCollectionChartProps extends WithLensDataInputProps {
|
||||
dataViewQuery: string;
|
||||
}
|
||||
|
||||
interface AnalyticsCollectionChartLensProps {
|
||||
data: {
|
||||
[key in ChartIds]?: Array<[number, number]>;
|
||||
};
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const AnalyticsCollectionChart: React.FC<
|
||||
AnalyticsCollectionChartProps & AnalyticsCollectionChartLensProps
|
||||
> = ({ id: lensId, data, timeRange, dataViewQuery, isLoading }) => {
|
||||
const [hoverChart, setHoverChart] = useState<ChartIds | null>(null);
|
||||
const [selectedChart, setSelectedChart] = useState<ChartIds>(ChartIds.Searches);
|
||||
const { uiSettings, charts: chartSettings } = useValues(KibanaLogic);
|
||||
const fromDateParsed = DateMath.parse(timeRange.from);
|
||||
const toDataParsed = DateMath.parse(timeRange.to);
|
||||
const chartTheme = chartSettings.theme.useChartsTheme();
|
||||
const baseChartTheme = chartSettings.theme.useChartsBaseTheme();
|
||||
|
||||
const charts = useMemo(
|
||||
() => [
|
||||
{
|
||||
chartColor: euiThemeVars.euiColorVis0,
|
||||
data: data[ChartIds.Searches] || [],
|
||||
id: ChartIds.Searches,
|
||||
name: i18n.translate(
|
||||
'xpack.enterpriseSearch.analytics.collections.collectionsView.charts.searches',
|
||||
{
|
||||
defaultMessage: 'Searches',
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
chartColor: euiThemeVars.euiColorVis2,
|
||||
data: data[ChartIds.NoResults] || [],
|
||||
id: ChartIds.NoResults,
|
||||
name: i18n.translate(
|
||||
'xpack.enterpriseSearch.analytics.collections.collectionsView.charts.noResults',
|
||||
{
|
||||
defaultMessage: 'No results',
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
chartColor: euiThemeVars.euiColorVis3,
|
||||
data: data[ChartIds.Clicks] || [],
|
||||
id: ChartIds.Clicks,
|
||||
name: i18n.translate(
|
||||
'xpack.enterpriseSearch.analytics.collections.collectionsView.charts.clicks',
|
||||
{
|
||||
defaultMessage: 'Click',
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
chartColor: euiThemeVars.euiColorVis5,
|
||||
data: data[ChartIds.Sessions] || [],
|
||||
id: ChartIds.Sessions,
|
||||
name: i18n.translate(
|
||||
'xpack.enterpriseSearch.analytics.collections.collectionsView.charts.sessions',
|
||||
{
|
||||
defaultMessage: 'Sessions',
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexGroup gutterSize="m">
|
||||
{charts.map(({ name, id }) => (
|
||||
<AnalyticsCollectionViewMetricWithLens
|
||||
key={id}
|
||||
id={`${lensId}-metric-${id}`}
|
||||
isSelected={selectedChart === id}
|
||||
name={name}
|
||||
onClick={(event) => {
|
||||
event.currentTarget?.blur();
|
||||
|
||||
setSelectedChart(id);
|
||||
}}
|
||||
timeRange={timeRange}
|
||||
dataViewQuery={dataViewQuery}
|
||||
getFormula={getFormulaByFilter.bind(null, id)}
|
||||
/>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
|
||||
{isLoading ? (
|
||||
<EuiFlexGroup alignItems="center" justifyContent="center" css={{ height: CHART_HEIGHT }}>
|
||||
<EuiLoadingChart size="l" />
|
||||
</EuiFlexGroup>
|
||||
) : (
|
||||
<Chart size={['100%', CHART_HEIGHT]}>
|
||||
<Settings
|
||||
theme={chartTheme}
|
||||
baseTheme={baseChartTheme}
|
||||
showLegend={false}
|
||||
onElementClick={(elements) => {
|
||||
const chartId = (elements as XYChartElementEvent[])[0][1]?.specId;
|
||||
|
||||
if (chartId) {
|
||||
setSelectedChart(chartId as ChartIds);
|
||||
}
|
||||
}}
|
||||
onElementOver={(elements) => {
|
||||
const chartId = (elements as XYChartElementEvent[])[0][1]?.specId;
|
||||
|
||||
if (chartId) {
|
||||
setHoverChart(chartId as ChartIds);
|
||||
}
|
||||
}}
|
||||
onElementOut={() => setHoverChart(null)}
|
||||
/>
|
||||
|
||||
{charts.map(({ data: chartData, id, name, chartColor }) => (
|
||||
<AreaSeries
|
||||
id={id}
|
||||
key={id}
|
||||
name={name}
|
||||
data={chartData}
|
||||
color={chartColor}
|
||||
xAccessor={0}
|
||||
yAccessors={[1]}
|
||||
areaSeriesStyle={{
|
||||
area: {
|
||||
opacity: 0.2,
|
||||
visible: selectedChart === id,
|
||||
},
|
||||
line: {
|
||||
opacity: selectedChart === id ? 1 : 0.5,
|
||||
strokeWidth: [hoverChart, selectedChart].includes(id)
|
||||
? HOVER_STROKE_WIDTH
|
||||
: DEFAULT_STROKE_WIDTH,
|
||||
},
|
||||
}}
|
||||
yNice
|
||||
xScaleType={ScaleType.Time}
|
||||
yScaleType={ScaleType.Sqrt}
|
||||
curve={CurveType.CURVE_MONOTONE_X}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Axis
|
||||
id="bottom-axis"
|
||||
position={Position.Bottom}
|
||||
tickFormat={
|
||||
fromDateParsed && toDataParsed
|
||||
? niceTimeFormatter([fromDateParsed.valueOf(), toDataParsed.valueOf()])
|
||||
: undefined
|
||||
}
|
||||
gridLine={{ visible: true }}
|
||||
/>
|
||||
|
||||
<Axis
|
||||
gridLine={{ dash: [], visible: true }}
|
||||
hide
|
||||
id="left-axis"
|
||||
position={Position.Left}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
headerFormatter={(tooltipData) =>
|
||||
moment(tooltipData.value).format(uiSettings.get('dateFormat'))
|
||||
}
|
||||
maxTooltipItems={1}
|
||||
type={TooltipType.VerticalCursor}
|
||||
/>
|
||||
</Chart>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const initialValues: AnalyticsCollectionChartLensProps = {
|
||||
data: {},
|
||||
isLoading: true,
|
||||
};
|
||||
const LENS_LAYERS: Array<{ formula: string; id: ChartIds; x: string; y: string }> = Object.values(
|
||||
ChartIds
|
||||
).map((id) => ({ formula: getFormulaByFilter(id), id, x: 'timeline', y: 'values' }));
|
||||
|
||||
export const AnalyticsCollectionChartWithLens = withLensData<
|
||||
AnalyticsCollectionChartProps,
|
||||
AnalyticsCollectionChartLensProps
|
||||
>(AnalyticsCollectionChart, {
|
||||
dataLoadTransform: (isLoading, adapters) =>
|
||||
isLoading || !adapters
|
||||
? initialValues
|
||||
: {
|
||||
data: LENS_LAYERS.reduce(
|
||||
(results, { id, x, y }) => ({
|
||||
...results,
|
||||
[id]:
|
||||
(adapters.tables?.tables[id]?.rows?.map((row) => [
|
||||
row[x] as number,
|
||||
row[y] as number,
|
||||
]) as Array<[number, number]>) || [],
|
||||
}),
|
||||
{}
|
||||
),
|
||||
isLoading: false,
|
||||
},
|
||||
getAttributes: (dataView, formulaApi): TypedLensByValueInput['attributes'] => {
|
||||
return {
|
||||
references: [
|
||||
{
|
||||
id: dataView.id!,
|
||||
name: 'indexpattern-datasource-current-indexpattern',
|
||||
type: 'index-pattern',
|
||||
},
|
||||
...LENS_LAYERS.map(({ id }) => ({
|
||||
id: dataView.id!,
|
||||
name: `indexpattern-datasource-layer-${id}`,
|
||||
type: 'index-pattern',
|
||||
})),
|
||||
],
|
||||
state: {
|
||||
datasourceStates: {
|
||||
formBased: {
|
||||
layers: LENS_LAYERS.reduce(
|
||||
(results, { id, x, y, formula }) => ({
|
||||
...results,
|
||||
[id]: formulaApi.insertOrReplaceFormulaColumn(
|
||||
y,
|
||||
{
|
||||
formula,
|
||||
},
|
||||
{
|
||||
columnOrder: [],
|
||||
columns: {
|
||||
[x]: {
|
||||
dataType: 'date',
|
||||
isBucketed: false,
|
||||
label: 'Timestamp',
|
||||
operationType: 'date_histogram',
|
||||
params: { includeEmptyRows: true, interval: 'auto' },
|
||||
scale: 'ordinal',
|
||||
sourceField: dataView?.timeFieldName!,
|
||||
} as DateHistogramIndexPatternColumn,
|
||||
},
|
||||
},
|
||||
dataView!
|
||||
)!,
|
||||
}),
|
||||
{}
|
||||
),
|
||||
},
|
||||
},
|
||||
filters: [],
|
||||
query: { language: 'kuery', query: '' },
|
||||
visualization: {
|
||||
axisTitlesVisibilitySettings: { x: false, yLeft: false, yRight: false },
|
||||
curveType: 'CURVE_MONOTONE_X',
|
||||
fittingFunction: 'None',
|
||||
gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true },
|
||||
layers: LENS_LAYERS.map(({ id, x, y }) => ({
|
||||
accessors: [y],
|
||||
layerId: [id],
|
||||
layerType: 'data',
|
||||
seriesType: 'area',
|
||||
xAccessor: x,
|
||||
yConfig: [
|
||||
{
|
||||
forAccessor: y,
|
||||
},
|
||||
],
|
||||
})),
|
||||
legend: { isVisible: false },
|
||||
preferredSeriesType: 'area',
|
||||
valueLabels: 'hide',
|
||||
},
|
||||
},
|
||||
title: '',
|
||||
visualizationType: 'lnsXY',
|
||||
};
|
||||
},
|
||||
getDataViewQuery: (props) => props.dataViewQuery,
|
||||
initialValues,
|
||||
});
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { EuiI18nNumber } from '@elastic/eui';
|
||||
|
||||
import { AnalyticsCollectionViewMetric } from './analytics_collection_metric';
|
||||
|
||||
const mockProps = {
|
||||
dataViewQuery: 'test',
|
||||
getFormula: jest.fn(),
|
||||
isLoading: false,
|
||||
isSelected: false,
|
||||
metric: 100,
|
||||
name: 'Test metric',
|
||||
onClick: jest.fn(),
|
||||
secondaryMetric: 50,
|
||||
};
|
||||
|
||||
describe('AnalyticsCollectionViewMetric', () => {
|
||||
it('should render component without issues', () => {
|
||||
const wrapper = shallow(<AnalyticsCollectionViewMetric {...mockProps} />);
|
||||
expect(wrapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('should show N/A if metric is null', () => {
|
||||
const wrapper = shallow(
|
||||
<AnalyticsCollectionViewMetric {...mockProps} metric={null} secondaryMetric={null} />
|
||||
);
|
||||
expect(wrapper.find(EuiI18nNumber)).toHaveLength(0);
|
||||
expect(wrapper.find('h2').text()).toContain('N/A');
|
||||
});
|
||||
|
||||
it('should show the metric value if it is not null', () => {
|
||||
const wrapper = shallow(<AnalyticsCollectionViewMetric {...mockProps} />);
|
||||
expect(wrapper.find(EuiI18nNumber)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiI18nNumber).prop('value')).toEqual(mockProps.metric);
|
||||
});
|
||||
|
||||
it('should show N/A if secondary metric is null', () => {
|
||||
const wrapper = shallow(
|
||||
<AnalyticsCollectionViewMetric {...mockProps} secondaryMetric={null} />
|
||||
);
|
||||
expect(wrapper.find('span').text()).toContain('N/A');
|
||||
});
|
||||
|
||||
it('should show the secondary metric value if it is not null', () => {
|
||||
const wrapper = shallow(<AnalyticsCollectionViewMetric {...mockProps} />);
|
||||
expect(wrapper.find('span').text()).toContain(`${mockProps.secondaryMetric}%`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,220 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiI18nNumber,
|
||||
EuiIcon,
|
||||
EuiPanel,
|
||||
EuiProgress,
|
||||
EuiText,
|
||||
EuiSkeletonRectangle,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
|
||||
import { withLensData } from '../../hoc/with_lens_data';
|
||||
|
||||
enum MetricStatus {
|
||||
INCREASE = 'increase',
|
||||
DECREASE = 'decrease',
|
||||
CONSTANT = 'constant',
|
||||
}
|
||||
const getMetricTheme = (euiTheme: EuiThemeComputed, status: MetricStatus) =>
|
||||
({
|
||||
[MetricStatus.DECREASE]: {
|
||||
color: euiThemeVars.euiColorVis7,
|
||||
icon: 'sortDown',
|
||||
},
|
||||
[MetricStatus.CONSTANT]: {
|
||||
color: euiTheme.colors.darkShade,
|
||||
icon: 'minus',
|
||||
},
|
||||
[MetricStatus.INCREASE]: {
|
||||
color: euiThemeVars.euiColorVis0,
|
||||
icon: 'sortUp',
|
||||
},
|
||||
}[status]);
|
||||
|
||||
const getMetricStatus = (metric: number): MetricStatus => {
|
||||
if (metric > 0) return MetricStatus.INCREASE;
|
||||
if (metric < 0) return MetricStatus.DECREASE;
|
||||
return MetricStatus.CONSTANT;
|
||||
};
|
||||
|
||||
interface AnalyticsCollectionViewMetricProps {
|
||||
dataViewQuery: string;
|
||||
getFormula: (shift?: string) => string;
|
||||
isSelected?: boolean;
|
||||
name: string;
|
||||
onClick(event: React.MouseEvent<HTMLButtonElement>): void;
|
||||
}
|
||||
|
||||
interface AnalyticsCollectionViewMetricLensProps {
|
||||
isLoading: boolean;
|
||||
metric: number | null;
|
||||
secondaryMetric: number | null;
|
||||
}
|
||||
|
||||
export const AnalyticsCollectionViewMetric: React.FC<
|
||||
AnalyticsCollectionViewMetricProps & AnalyticsCollectionViewMetricLensProps
|
||||
> = ({ isLoading, isSelected, metric, name, onClick, secondaryMetric }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const { icon, color } = getMetricTheme(euiTheme, getMetricStatus(secondaryMetric || 0));
|
||||
|
||||
return (
|
||||
<EuiPanel
|
||||
grow
|
||||
hasBorder
|
||||
hasShadow={false}
|
||||
onClick={onClick}
|
||||
color={isSelected ? 'primary' : 'plain'}
|
||||
css={
|
||||
isSelected
|
||||
? css`
|
||||
border: 1px solid ${euiTheme.colors.primary};
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
`
|
||||
: css`
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
`
|
||||
}
|
||||
>
|
||||
{isLoading && <EuiProgress size="xs" color="success" position="absolute" />}
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexGroup gutterSize="s" justifyContent="spaceBetween">
|
||||
<EuiText size="s">
|
||||
<p>{name}</p>
|
||||
</EuiText>
|
||||
<EuiText size="s" color={color}>
|
||||
<span>
|
||||
{secondaryMetric === null ? (
|
||||
i18n.translate('xpack.enterpriseSearch.analytics.collection.notAvailableLabel', {
|
||||
defaultMessage: 'N/A',
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<EuiIcon type={icon} />
|
||||
{secondaryMetric + '%'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</EuiText>
|
||||
</EuiFlexGroup>
|
||||
{isLoading ? (
|
||||
<EuiSkeletonRectangle height={euiTheme.size.xxl} width="100%" />
|
||||
) : (
|
||||
<EuiText color={isSelected ? euiTheme.colors.primaryText : color}>
|
||||
<h2>
|
||||
{metric === null ? (
|
||||
i18n.translate('xpack.enterpriseSearch.analytics.collection.notAvailableLabel', {
|
||||
defaultMessage: 'N/A',
|
||||
})
|
||||
) : (
|
||||
<EuiI18nNumber value={metric} />
|
||||
)}
|
||||
</h2>
|
||||
</EuiText>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
const LENS_LAYERS = {
|
||||
metrics: {
|
||||
hitsTotal: 'hitsTotal',
|
||||
id: 'metrics',
|
||||
percentage: 'percentage',
|
||||
},
|
||||
};
|
||||
const initialValues = { isLoading: true, metric: null, secondaryMetric: null };
|
||||
|
||||
export const AnalyticsCollectionViewMetricWithLens = withLensData<
|
||||
AnalyticsCollectionViewMetricProps,
|
||||
AnalyticsCollectionViewMetricLensProps
|
||||
>(AnalyticsCollectionViewMetric, {
|
||||
dataLoadTransform: (isLoading, adapters) =>
|
||||
isLoading || !adapters
|
||||
? initialValues
|
||||
: {
|
||||
isLoading,
|
||||
metric:
|
||||
adapters.tables?.tables[LENS_LAYERS.metrics.id]?.rows?.[0]?.[
|
||||
LENS_LAYERS.metrics.hitsTotal
|
||||
] ?? null,
|
||||
secondaryMetric:
|
||||
adapters.tables?.tables[LENS_LAYERS.metrics.id]?.rows?.[0]?.[
|
||||
LENS_LAYERS.metrics.percentage
|
||||
] ?? null,
|
||||
},
|
||||
getAttributes: (dataView, formulaApi, props) => {
|
||||
let metric = formulaApi.insertOrReplaceFormulaColumn(
|
||||
LENS_LAYERS.metrics.percentage,
|
||||
{
|
||||
formula: `round((${props.getFormula()}/${props.getFormula('previous')}-1) * 100)`,
|
||||
},
|
||||
{
|
||||
columnOrder: [],
|
||||
columns: {},
|
||||
},
|
||||
dataView
|
||||
)!;
|
||||
metric = formulaApi.insertOrReplaceFormulaColumn(
|
||||
LENS_LAYERS.metrics.hitsTotal,
|
||||
{ formula: props.getFormula() },
|
||||
metric,
|
||||
dataView
|
||||
)!;
|
||||
|
||||
return {
|
||||
references: [
|
||||
{
|
||||
id: dataView.id!,
|
||||
name: 'indexpattern-datasource-current-indexpattern',
|
||||
type: 'index-pattern',
|
||||
},
|
||||
{
|
||||
id: dataView.id!,
|
||||
name: `indexpattern-datasource-layer-${LENS_LAYERS.metrics.id}`,
|
||||
type: 'index-pattern',
|
||||
},
|
||||
],
|
||||
state: {
|
||||
datasourceStates: {
|
||||
formBased: {
|
||||
layers: {
|
||||
[LENS_LAYERS.metrics.id]: metric,
|
||||
},
|
||||
},
|
||||
},
|
||||
filters: [],
|
||||
query: { language: 'kuery', query: '' },
|
||||
visualization: {
|
||||
layerId: [LENS_LAYERS.metrics.id],
|
||||
layerType: 'data',
|
||||
layers: [],
|
||||
metricAccessor: LENS_LAYERS.metrics.hitsTotal,
|
||||
secondaryMetricAccessor: LENS_LAYERS.metrics.percentage,
|
||||
},
|
||||
},
|
||||
title: '',
|
||||
visualizationType: 'lnsMetric',
|
||||
};
|
||||
},
|
||||
getDataViewQuery: (props) => props.dataViewQuery,
|
||||
initialValues,
|
||||
});
|
|
@ -17,6 +17,8 @@ import { shallow } from 'enzyme';
|
|||
import { AnalyticsCollection } from '../../../../../common/types/analytics';
|
||||
import { EnterpriseSearchAnalyticsPageTemplate } from '../layout/page_template';
|
||||
|
||||
import { AnalyticsCollectionChartWithLens } from './analytics_collection_chart';
|
||||
|
||||
import { AnalyticsCollectionIntegrate } from './analytics_collection_integrate/analytics_collection_integrate';
|
||||
import { AnalyticsCollectionSettings } from './analytics_collection_settings';
|
||||
|
||||
|
@ -24,7 +26,8 @@ import { AnalyticsCollectionView } from './analytics_collection_view';
|
|||
|
||||
const mockValues = {
|
||||
analyticsCollection: {
|
||||
name: 'Analytics Collection 1',
|
||||
events_datastream: 'analytics-events-example',
|
||||
name: 'Analytics-Collection-1',
|
||||
} as AnalyticsCollection,
|
||||
dataViewId: '1234-1234-1234',
|
||||
};
|
||||
|
@ -41,64 +44,77 @@ describe('AnalyticsOverview', () => {
|
|||
mockUseParams.mockReturnValue({ name: '1', section: 'settings' });
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('renders when analytics collection is empty on inital query', () => {
|
||||
setMockValues({
|
||||
...mockValues,
|
||||
analyticsCollection: null,
|
||||
});
|
||||
setMockActions(mockActions);
|
||||
const wrapper = shallow(<AnalyticsCollectionView />);
|
||||
|
||||
expect(mockActions.fetchAnalyticsCollection).toHaveBeenCalled();
|
||||
|
||||
expect(wrapper.find(AnalyticsCollectionSettings)).toHaveLength(0);
|
||||
expect(wrapper.find(AnalyticsCollectionIntegrate)).toHaveLength(0);
|
||||
it('renders when analytics collection is empty on initial query', () => {
|
||||
setMockValues({
|
||||
...mockValues,
|
||||
analyticsCollection: null,
|
||||
});
|
||||
setMockActions(mockActions);
|
||||
const wrapper = shallow(<AnalyticsCollectionView />);
|
||||
|
||||
it('renders with Data', async () => {
|
||||
setMockValues(mockValues);
|
||||
setMockActions(mockActions);
|
||||
expect(mockActions.fetchAnalyticsCollection).toHaveBeenCalled();
|
||||
|
||||
const wrapper = shallow(<AnalyticsCollectionView />);
|
||||
expect(wrapper.find(AnalyticsCollectionSettings)).toHaveLength(0);
|
||||
expect(wrapper.find(AnalyticsCollectionIntegrate)).toHaveLength(0);
|
||||
});
|
||||
|
||||
expect(wrapper.find(AnalyticsCollectionSettings)).toHaveLength(1);
|
||||
expect(mockActions.fetchAnalyticsCollection).toHaveBeenCalled();
|
||||
});
|
||||
it('renders with Data', async () => {
|
||||
setMockValues(mockValues);
|
||||
setMockActions(mockActions);
|
||||
|
||||
it('sends correct telemetry page name for selected tab', async () => {
|
||||
setMockValues(mockValues);
|
||||
setMockActions(mockActions);
|
||||
shallow(<AnalyticsCollectionView />);
|
||||
|
||||
const wrapper = shallow(<AnalyticsCollectionView />);
|
||||
expect(mockActions.fetchAnalyticsCollection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(wrapper.prop('pageViewTelemetry')).toBe('View Analytics Collection - settings');
|
||||
});
|
||||
it('sends correct telemetry page name for selected tab', async () => {
|
||||
setMockValues(mockValues);
|
||||
setMockActions(mockActions);
|
||||
|
||||
it('send correct pageHeader rightSideItems when dataViewId exists', async () => {
|
||||
setMockValues(mockValues);
|
||||
setMockActions(mockActions);
|
||||
const wrapper = shallow(<AnalyticsCollectionView />);
|
||||
|
||||
const rightSideItems = shallow(<AnalyticsCollectionView />)
|
||||
?.find(EnterpriseSearchAnalyticsPageTemplate)
|
||||
?.prop('pageHeader')?.rightSideItems;
|
||||
expect(wrapper.prop('pageViewTelemetry')).toBe('View Analytics Collection - settings');
|
||||
});
|
||||
|
||||
expect(rightSideItems).toHaveLength(1);
|
||||
it('send correct pageHeader rightSideItems when dataViewId exists', async () => {
|
||||
setMockValues(mockValues);
|
||||
setMockActions(mockActions);
|
||||
|
||||
expect((rightSideItems?.[0] as ReactElement).props?.children?.props?.href).toBe(
|
||||
"/app/discover#/?_a=(index:'1234-1234-1234')"
|
||||
);
|
||||
});
|
||||
const rightSideItems = shallow(<AnalyticsCollectionView />)
|
||||
?.find(EnterpriseSearchAnalyticsPageTemplate)
|
||||
?.prop('pageHeader')?.rightSideItems;
|
||||
|
||||
it('hide pageHeader rightSideItems when dataViewId not exists', async () => {
|
||||
setMockValues({ ...mockValues, dataViewId: null });
|
||||
setMockActions(mockActions);
|
||||
expect(rightSideItems).toHaveLength(1);
|
||||
|
||||
const wrapper = shallow(<AnalyticsCollectionView />);
|
||||
expect((rightSideItems?.[0] as ReactElement).props?.children?.props?.href).toBe(
|
||||
"/app/discover#/?_a=(index:'1234-1234-1234')"
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
wrapper?.find(EnterpriseSearchAnalyticsPageTemplate)?.prop('pageHeader')?.rightSideItems
|
||||
).toBeUndefined();
|
||||
it('hide pageHeader rightSideItems when dataViewId not exists', async () => {
|
||||
setMockValues({ ...mockValues, dataViewId: null });
|
||||
setMockActions(mockActions);
|
||||
|
||||
const wrapper = shallow(<AnalyticsCollectionView />);
|
||||
|
||||
expect(
|
||||
wrapper?.find(EnterpriseSearchAnalyticsPageTemplate)?.prop('pageHeader')?.rightSideItems
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('render AnalyticsCollectionChartWithLens with collection', () => {
|
||||
setMockValues(mockValues);
|
||||
setMockActions(mockActions);
|
||||
|
||||
const wrapper = shallow(<AnalyticsCollectionView />);
|
||||
expect(wrapper?.find(AnalyticsCollectionChartWithLens)).toHaveLength(1);
|
||||
expect(wrapper?.find(AnalyticsCollectionChartWithLens).props()).toEqual({
|
||||
dataViewQuery: 'analytics-events-example',
|
||||
id: 'analytics-collection-chart-Analytics-Collection-1',
|
||||
timeRange: {
|
||||
from: 'now-90d',
|
||||
to: 'now',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,33 +10,20 @@ import { useParams } from 'react-router-dom';
|
|||
|
||||
import { useActions, useValues } from 'kea';
|
||||
|
||||
import {
|
||||
EuiEmptyPrompt,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIconTip,
|
||||
EuiLink,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { EuiEmptyPrompt, EuiIconTip, EuiLink } from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { RedirectAppLinks } from '@kbn/kibana-react-plugin/public';
|
||||
|
||||
import { generateEncodedPath } from '../../../shared/encode_path_params';
|
||||
import { KibanaLogic } from '../../../shared/kibana';
|
||||
import { COLLECTION_VIEW_PATH } from '../../routes';
|
||||
import { AddAnalyticsCollection } from '../add_analytics_collections/add_analytics_collection';
|
||||
|
||||
import { EnterpriseSearchAnalyticsPageTemplate } from '../layout/page_template';
|
||||
|
||||
import { AnalyticsCollectionChartWithLens } from './analytics_collection_chart';
|
||||
import { AnalyticsCollectionDataViewIdLogic } from './analytics_collection_data_view_id_logic';
|
||||
|
||||
import { AnalyticsCollectionEvents } from './analytics_collection_events';
|
||||
import { AnalyticsCollectionIntegrate } from './analytics_collection_integrate/analytics_collection_integrate';
|
||||
import { AnalyticsCollectionSettings } from './analytics_collection_settings';
|
||||
|
||||
import { FetchAnalyticsCollectionLogic } from './fetch_analytics_collection_logic';
|
||||
|
||||
export const collectionViewBreadcrumbs = [
|
||||
|
@ -51,51 +38,7 @@ export const AnalyticsCollectionView: React.FC = () => {
|
|||
const { analyticsCollection, isLoading } = useValues(FetchAnalyticsCollectionLogic);
|
||||
const { dataViewId } = useValues(AnalyticsCollectionDataViewIdLogic);
|
||||
const { name, section } = useParams<{ name: string; section: string }>();
|
||||
const { navigateToUrl, application } = useValues(KibanaLogic);
|
||||
const collectionViewTabs = [
|
||||
{
|
||||
id: 'events',
|
||||
label: i18n.translate('xpack.enterpriseSearch.analytics.collectionsView.tabs.eventsName', {
|
||||
defaultMessage: 'Events',
|
||||
}),
|
||||
onClick: () =>
|
||||
navigateToUrl(
|
||||
generateEncodedPath(COLLECTION_VIEW_PATH, {
|
||||
name: analyticsCollection.name,
|
||||
section: 'events',
|
||||
})
|
||||
),
|
||||
isSelected: section === 'events',
|
||||
},
|
||||
{
|
||||
id: 'integrate',
|
||||
label: i18n.translate('xpack.enterpriseSearch.analytics.collectionsView.tabs.integrateName', {
|
||||
defaultMessage: 'Integrate',
|
||||
}),
|
||||
onClick: () =>
|
||||
navigateToUrl(
|
||||
generateEncodedPath(COLLECTION_VIEW_PATH, {
|
||||
name: analyticsCollection?.name,
|
||||
section: 'integrate',
|
||||
})
|
||||
),
|
||||
isSelected: section === 'integrate',
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: i18n.translate('xpack.enterpriseSearch.analytics.collectionsView.tabs.settingsName', {
|
||||
defaultMessage: 'Settings',
|
||||
}),
|
||||
onClick: () =>
|
||||
navigateToUrl(
|
||||
generateEncodedPath(COLLECTION_VIEW_PATH, {
|
||||
name: analyticsCollection?.name,
|
||||
section: 'settings',
|
||||
})
|
||||
),
|
||||
isSelected: section === 'settings',
|
||||
},
|
||||
];
|
||||
const { application } = useValues(KibanaLogic);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalyticsCollection(name);
|
||||
|
@ -109,14 +52,10 @@ export const AnalyticsCollectionView: React.FC = () => {
|
|||
pageChrome={[...collectionViewBreadcrumbs]}
|
||||
pageViewTelemetry={`View Analytics Collection - ${section}`}
|
||||
pageHeader={{
|
||||
description: i18n.translate(
|
||||
'xpack.enterpriseSearch.analytics.collectionsView.pageDescription',
|
||||
{
|
||||
defaultMessage:
|
||||
'Dashboards and tools for visualizing end-user behavior and measuring the performance of your search applications. Track trends over time, identify and investigate anomalies, and make optimizations.',
|
||||
}
|
||||
),
|
||||
pageTitle: analyticsCollection?.name,
|
||||
bottomBorder: false,
|
||||
pageTitle: i18n.translate('xpack.enterpriseSearch.analytics.collectionsView.title', {
|
||||
defaultMessage: 'Overview',
|
||||
}),
|
||||
rightSideItems: dataViewId
|
||||
? [
|
||||
<RedirectAppLinks application={application}>
|
||||
|
@ -139,40 +78,17 @@ export const AnalyticsCollectionView: React.FC = () => {
|
|||
</RedirectAppLinks>,
|
||||
]
|
||||
: undefined,
|
||||
tabs: [...collectionViewTabs],
|
||||
}}
|
||||
>
|
||||
{!analyticsCollection && (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.analytics.collections.collectionsView.headingTitle',
|
||||
{
|
||||
defaultMessage: 'Collections',
|
||||
}
|
||||
)}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<AddAnalyticsCollection />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
|
||||
<EuiSpacer size="l" />
|
||||
{analyticsCollection ? (
|
||||
<>
|
||||
{section === 'settings' && (
|
||||
<AnalyticsCollectionSettings collection={analyticsCollection} />
|
||||
)}
|
||||
{section === 'integrate' && (
|
||||
<AnalyticsCollectionIntegrate collection={analyticsCollection} />
|
||||
)}
|
||||
{section === 'events' && <AnalyticsCollectionEvents collection={analyticsCollection} />}
|
||||
</>
|
||||
<AnalyticsCollectionChartWithLens
|
||||
id={'analytics-collection-chart-' + analyticsCollection.name}
|
||||
dataViewQuery={analyticsCollection.events_datastream}
|
||||
timeRange={{
|
||||
from: 'now-90d',
|
||||
to: 'now',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EuiEmptyPrompt
|
||||
iconType="search"
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
import { euiTextTruncate } from '@elastic/eui';
|
||||
import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types';
|
||||
|
||||
export const AnalyticsCollectionCardStyles = (euiTheme: EuiThemeComputed) => ({
|
||||
|
@ -48,9 +51,7 @@ export const AnalyticsCollectionCardStyles = (euiTheme: EuiThemeComputed) => ({
|
|||
position: 'relative' as 'relative',
|
||||
zIndex: 1,
|
||||
},
|
||||
title: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
},
|
||||
title: css`
|
||||
${euiTextTruncate()}
|
||||
`,
|
||||
});
|
||||
|
|
|
@ -14,6 +14,8 @@ import { shallow } from 'enzyme';
|
|||
import { Chart } from '@elastic/charts';
|
||||
import { EuiCard, EuiFlexGroup, EuiLoadingChart } from '@elastic/eui';
|
||||
|
||||
import { FilterBy } from '../../../utils/get_formula_by_filter';
|
||||
|
||||
import { AnalyticsCollectionCard } from './analytics_collection_card';
|
||||
|
||||
const mockCollection = {
|
||||
|
@ -36,6 +38,7 @@ describe('AnalyticsCollectionCard', () => {
|
|||
metric={null}
|
||||
secondaryMetric={null}
|
||||
data={[]}
|
||||
filterBy={FilterBy.Searches}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -58,7 +61,8 @@ describe('AnalyticsCollectionCard', () => {
|
|||
isLoading={false}
|
||||
metric={mockMetric}
|
||||
secondaryMetric={secondaryMetric}
|
||||
data={[[0, 0]]}
|
||||
data={[[0, 23]]}
|
||||
filterBy={FilterBy.Searches}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -78,6 +82,7 @@ describe('AnalyticsCollectionCard', () => {
|
|||
metric={mockMetric}
|
||||
secondaryMetric={secondaryMetric}
|
||||
data={[]}
|
||||
filterBy={FilterBy.Searches}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -26,21 +26,20 @@ import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types';
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { DateHistogramIndexPatternColumn } from '@kbn/lens-plugin/public';
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
|
||||
import { AnalyticsCollection } from '../../../../../../common/types/analytics';
|
||||
|
||||
import { generateEncodedPath } from '../../../../shared/encode_path_params';
|
||||
|
||||
import { KibanaLogic } from '../../../../shared/kibana';
|
||||
import { withLensData } from '../../../hoc/with_lens_data';
|
||||
import { COLLECTION_VIEW_PATH } from '../../../routes';
|
||||
|
||||
import { AnalyticsCollectionCardStyles } from './analytics_collection_card.styles';
|
||||
import {
|
||||
withLensData,
|
||||
WithLensDataInputProps,
|
||||
WithLensDataLogicOutputProps,
|
||||
} from './with_lens_data';
|
||||
import { FilterBy, getFormulaByFilter } from '../../../utils/get_formula_by_filter';
|
||||
|
||||
import './analytics_collection_card.styles';
|
||||
import { AnalyticsCollectionCardStyles } from './analytics_collection_card.styles';
|
||||
|
||||
enum ChartStatus {
|
||||
INCREASE = 'increase',
|
||||
|
@ -50,10 +49,10 @@ enum ChartStatus {
|
|||
|
||||
const getCardTheme = (euiTheme: EuiThemeComputed) => ({
|
||||
[ChartStatus.DECREASE]: {
|
||||
area: '#F5A35C',
|
||||
area: euiThemeVars.euiColorVis7_behindText,
|
||||
areaOpacity: 0.2,
|
||||
icon: 'sortDown',
|
||||
line: '#DA8B45',
|
||||
line: euiThemeVars.euiColorVis7,
|
||||
lineOpacity: 0.5,
|
||||
text: '#996130',
|
||||
},
|
||||
|
@ -66,37 +65,38 @@ const getCardTheme = (euiTheme: EuiThemeComputed) => ({
|
|||
text: euiTheme.colors.darkShade,
|
||||
},
|
||||
[ChartStatus.INCREASE]: {
|
||||
area: '#6DCCB1',
|
||||
area: euiThemeVars.euiColorVis0_behindText,
|
||||
areaOpacity: 0.2,
|
||||
icon: 'sortUp',
|
||||
line: '#54b399',
|
||||
line: euiThemeVars.euiColorVis0,
|
||||
lineOpacity: 0.5,
|
||||
text: '#387765',
|
||||
},
|
||||
});
|
||||
|
||||
interface AnalyticsCollectionCardProps extends WithLensDataLogicOutputProps {
|
||||
interface AnalyticsCollectionCardProps {
|
||||
collection: AnalyticsCollection;
|
||||
filterBy: FilterBy;
|
||||
isCreatedByEngine?: boolean;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
interface AnalyticsCollectionCardLensProps {
|
||||
data: Array<[number, number]>;
|
||||
isLoading: boolean;
|
||||
metric: number | null;
|
||||
secondaryMetric: number | null;
|
||||
}
|
||||
|
||||
const getChartStatus = (metric: number | null): ChartStatus => {
|
||||
if (metric && metric > 0) return ChartStatus.INCREASE;
|
||||
if (metric && metric < 0) return ChartStatus.DECREASE;
|
||||
return ChartStatus.CONSTANT;
|
||||
};
|
||||
|
||||
export const AnalyticsCollectionCard: React.FC<AnalyticsCollectionCardProps> = ({
|
||||
collection,
|
||||
isLoading,
|
||||
isCreatedByEngine,
|
||||
subtitle,
|
||||
data,
|
||||
metric,
|
||||
secondaryMetric,
|
||||
children,
|
||||
}) => {
|
||||
export const AnalyticsCollectionCard: React.FC<
|
||||
AnalyticsCollectionCardProps & AnalyticsCollectionCardLensProps
|
||||
> = ({ collection, isLoading, isCreatedByEngine, subtitle, data, metric, secondaryMetric }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const { history, navigateToUrl } = useValues(KibanaLogic);
|
||||
const cardStyles = AnalyticsCollectionCardStyles(euiTheme);
|
||||
|
@ -174,7 +174,7 @@ export const AnalyticsCollectionCard: React.FC<AnalyticsCollectionCardProps> = (
|
|||
)
|
||||
}
|
||||
>
|
||||
{!!data?.length && !isLoading && (
|
||||
{!isLoading && data?.some(([, y]) => y && y !== 0) && (
|
||||
<Chart size={['100%', 130]} css={cardStyles.chart}>
|
||||
<Settings
|
||||
theme={{
|
||||
|
@ -206,10 +206,133 @@ export const AnalyticsCollectionCard: React.FC<AnalyticsCollectionCardProps> = (
|
|||
/>
|
||||
</Chart>
|
||||
)}
|
||||
{children}
|
||||
</EuiCard>
|
||||
);
|
||||
};
|
||||
|
||||
const LENS_LAYERS = {
|
||||
metrics: {
|
||||
hitsTotal: 'hitsTotal',
|
||||
id: 'metrics',
|
||||
percentage: 'percentage',
|
||||
},
|
||||
trend: {
|
||||
id: 'trend',
|
||||
x: 'timeline',
|
||||
y: 'values',
|
||||
},
|
||||
};
|
||||
const initialValues = { data: [], isLoading: true, metric: null, secondaryMetric: null };
|
||||
|
||||
export const AnalyticsCollectionCardWithLens = withLensData<
|
||||
AnalyticsCollectionCardProps & WithLensDataInputProps
|
||||
>(AnalyticsCollectionCard);
|
||||
AnalyticsCollectionCardProps,
|
||||
AnalyticsCollectionCardLensProps
|
||||
>(AnalyticsCollectionCard, {
|
||||
dataLoadTransform: (isLoading, adapters) =>
|
||||
isLoading || !adapters
|
||||
? initialValues
|
||||
: {
|
||||
data:
|
||||
(adapters.tables?.tables[LENS_LAYERS.trend.id]?.rows?.map((row) => [
|
||||
row[LENS_LAYERS.trend.x] as number,
|
||||
row[LENS_LAYERS.trend.y] as number,
|
||||
]) as Array<[number, number]>) || [],
|
||||
isLoading: false,
|
||||
metric:
|
||||
adapters.tables?.tables[LENS_LAYERS.metrics.id]?.rows?.[0]?.[
|
||||
LENS_LAYERS.metrics.hitsTotal
|
||||
] ?? null,
|
||||
secondaryMetric:
|
||||
adapters.tables?.tables[LENS_LAYERS.metrics.id]?.rows?.[0]?.[
|
||||
LENS_LAYERS.metrics.percentage
|
||||
] ?? null,
|
||||
},
|
||||
getAttributes: (dataView, formulaApi, { filterBy }) => {
|
||||
let metric = formulaApi.insertOrReplaceFormulaColumn(
|
||||
LENS_LAYERS.metrics.percentage,
|
||||
{
|
||||
formula: `round(((${getFormulaByFilter(filterBy)}/${getFormulaByFilter(
|
||||
filterBy,
|
||||
'previous'
|
||||
)})-1) * 100)`,
|
||||
label: ' ',
|
||||
},
|
||||
{
|
||||
columnOrder: [],
|
||||
columns: {},
|
||||
},
|
||||
dataView
|
||||
)!;
|
||||
metric = formulaApi.insertOrReplaceFormulaColumn(
|
||||
LENS_LAYERS.metrics.hitsTotal,
|
||||
{ formula: getFormulaByFilter(filterBy), label: ' ' },
|
||||
metric,
|
||||
dataView
|
||||
)!;
|
||||
|
||||
return {
|
||||
references: [
|
||||
{
|
||||
id: dataView.id!,
|
||||
name: 'indexpattern-datasource-current-indexpattern',
|
||||
type: 'index-pattern',
|
||||
},
|
||||
{
|
||||
id: dataView.id!,
|
||||
name: `indexpattern-datasource-layer-${LENS_LAYERS.trend.id}`,
|
||||
type: 'index-pattern',
|
||||
},
|
||||
{
|
||||
id: dataView.id!,
|
||||
name: `indexpattern-datasource-layer-${LENS_LAYERS.metrics.id}`,
|
||||
type: 'index-pattern',
|
||||
},
|
||||
],
|
||||
state: {
|
||||
datasourceStates: {
|
||||
formBased: {
|
||||
layers: {
|
||||
[LENS_LAYERS.trend.id]: formulaApi.insertOrReplaceFormulaColumn(
|
||||
LENS_LAYERS.trend.y,
|
||||
{
|
||||
formula: getFormulaByFilter(filterBy),
|
||||
},
|
||||
{
|
||||
columnOrder: [],
|
||||
columns: {
|
||||
[LENS_LAYERS.trend.x]: {
|
||||
dataType: 'date',
|
||||
isBucketed: false,
|
||||
label: 'Timestamp',
|
||||
operationType: 'date_histogram',
|
||||
params: { includeEmptyRows: true, interval: 'auto' },
|
||||
scale: 'ordinal',
|
||||
sourceField: dataView?.timeFieldName!,
|
||||
} as DateHistogramIndexPatternColumn,
|
||||
},
|
||||
},
|
||||
dataView!
|
||||
)!,
|
||||
[LENS_LAYERS.metrics.id]: metric,
|
||||
},
|
||||
},
|
||||
},
|
||||
filters: [],
|
||||
query: { language: 'kuery', query: '' },
|
||||
visualization: {
|
||||
layerId: [LENS_LAYERS.metrics.id],
|
||||
layerType: 'data',
|
||||
metricAccessor: LENS_LAYERS.metrics.hitsTotal,
|
||||
secondaryMetricAccessor: LENS_LAYERS.metrics.percentage,
|
||||
trendlineLayerId: LENS_LAYERS.trend.id,
|
||||
trendlineMetricAccessor: LENS_LAYERS.trend.y,
|
||||
trendlineTimeAccessor: LENS_LAYERS.trend.x,
|
||||
},
|
||||
},
|
||||
title: '',
|
||||
visualizationType: 'lnsMetric',
|
||||
};
|
||||
},
|
||||
getDataViewQuery: ({ collection }) => collection.events_datastream,
|
||||
initialValues,
|
||||
});
|
||||
|
|
|
@ -1,232 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
|
||||
import { useValues } from 'kea';
|
||||
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
|
||||
import { TimeRange } from '@kbn/es-query';
|
||||
import {
|
||||
DateHistogramIndexPatternColumn,
|
||||
FormulaPublicApi,
|
||||
TypedLensByValueInput,
|
||||
} from '@kbn/lens-plugin/public';
|
||||
|
||||
import { LensByReferenceInput } from '@kbn/lens-plugin/public/embeddable';
|
||||
|
||||
import { AnalyticsCollection } from '../../../../../../common/types/analytics';
|
||||
|
||||
import { KibanaLogic } from '../../../../shared/kibana';
|
||||
|
||||
export enum FilterBy {
|
||||
Searches = 'Searches',
|
||||
NoResults = 'NoResults',
|
||||
}
|
||||
|
||||
const getFilterFormulaByFilter = (filter: string, shift?: string | null): string => {
|
||||
const mapFilterByToFormula: { [key: string]: string } = {
|
||||
[FilterBy.Searches]: "count(kql='event.action: search'",
|
||||
[FilterBy.NoResults]: "count(kql='event.customer_data.totalResults : 0'",
|
||||
};
|
||||
const formula = mapFilterByToFormula[filter] || 'count(';
|
||||
|
||||
return formula + (shift ? `, shift='${shift}'` : '') + ')';
|
||||
};
|
||||
|
||||
const LENS_LAYERS = {
|
||||
metrics: {
|
||||
hitsTotal: 'hitsTotal',
|
||||
id: 'metrics',
|
||||
percentage: 'percentage',
|
||||
},
|
||||
trend: {
|
||||
id: 'trend',
|
||||
x: 'timeline',
|
||||
y: 'values',
|
||||
},
|
||||
};
|
||||
const getLensAttributes = (
|
||||
dataView: DataView,
|
||||
formulaApi: FormulaPublicApi,
|
||||
filterBy: string
|
||||
): TypedLensByValueInput['attributes'] => {
|
||||
let metric = formulaApi.insertOrReplaceFormulaColumn(
|
||||
LENS_LAYERS.metrics.percentage,
|
||||
{
|
||||
formula: `round(((${getFilterFormulaByFilter(filterBy)}/${getFilterFormulaByFilter(
|
||||
filterBy,
|
||||
'previous'
|
||||
)})-1) * 100)`,
|
||||
label: ' ',
|
||||
},
|
||||
{
|
||||
columnOrder: [],
|
||||
columns: {},
|
||||
},
|
||||
dataView
|
||||
)!;
|
||||
metric = formulaApi.insertOrReplaceFormulaColumn(
|
||||
LENS_LAYERS.metrics.hitsTotal,
|
||||
{ formula: getFilterFormulaByFilter(filterBy), label: ' ' },
|
||||
metric,
|
||||
dataView
|
||||
)!;
|
||||
|
||||
return {
|
||||
references: [
|
||||
{
|
||||
id: dataView.id!,
|
||||
name: 'indexpattern-datasource-current-indexpattern',
|
||||
type: 'index-pattern',
|
||||
},
|
||||
{
|
||||
id: dataView.id!,
|
||||
name: `indexpattern-datasource-layer-${LENS_LAYERS.trend.id}`,
|
||||
type: 'index-pattern',
|
||||
},
|
||||
{
|
||||
id: dataView.id!,
|
||||
name: `indexpattern-datasource-layer-${LENS_LAYERS.metrics.id}`,
|
||||
type: 'index-pattern',
|
||||
},
|
||||
],
|
||||
state: {
|
||||
datasourceStates: {
|
||||
formBased: {
|
||||
layers: {
|
||||
[LENS_LAYERS.trend.id]: formulaApi.insertOrReplaceFormulaColumn(
|
||||
LENS_LAYERS.trend.y,
|
||||
{
|
||||
formula: getFilterFormulaByFilter(filterBy),
|
||||
},
|
||||
{
|
||||
columnOrder: [],
|
||||
columns: {
|
||||
[LENS_LAYERS.trend.x]: {
|
||||
dataType: 'date',
|
||||
isBucketed: false,
|
||||
label: 'Timestamp',
|
||||
operationType: 'date_histogram',
|
||||
params: { interval: 'auto' },
|
||||
scale: 'ratio',
|
||||
sourceField: dataView?.timeFieldName!,
|
||||
} as DateHistogramIndexPatternColumn,
|
||||
},
|
||||
},
|
||||
dataView!
|
||||
)!,
|
||||
[LENS_LAYERS.metrics.id]: metric,
|
||||
},
|
||||
},
|
||||
},
|
||||
filters: [],
|
||||
query: { language: 'kuery', query: '' },
|
||||
visualization: {
|
||||
layerId: [LENS_LAYERS.metrics.id],
|
||||
layerType: 'data',
|
||||
metricAccessor: LENS_LAYERS.metrics.hitsTotal,
|
||||
secondaryMetricAccessor: LENS_LAYERS.metrics.percentage,
|
||||
trendlineLayerId: LENS_LAYERS.trend.id,
|
||||
trendlineMetricAccessor: LENS_LAYERS.trend.y,
|
||||
trendlineTimeAccessor: LENS_LAYERS.trend.x,
|
||||
},
|
||||
},
|
||||
title: '',
|
||||
visualizationType: 'lnsMetric',
|
||||
};
|
||||
};
|
||||
|
||||
export interface WithLensDataInputProps {
|
||||
collection: AnalyticsCollection;
|
||||
filterBy: string;
|
||||
timeRange: TimeRange;
|
||||
}
|
||||
|
||||
export interface WithLensDataLogicOutputProps {
|
||||
data: Array<[number, number]>;
|
||||
isLoading: boolean;
|
||||
metric: number | null;
|
||||
secondaryMetric: number | null;
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
data: [],
|
||||
isLoading: true,
|
||||
metric: null,
|
||||
secondaryMetric: null,
|
||||
};
|
||||
|
||||
export const withLensData = <T extends WithLensDataInputProps>(Component: React.FC<T>) => {
|
||||
const ComponentWithLensData = (props: Omit<T, keyof WithLensDataLogicOutputProps>) => {
|
||||
const {
|
||||
lens: { EmbeddableComponent, stateHelperApi },
|
||||
data: { dataViews },
|
||||
} = useValues(KibanaLogic);
|
||||
const [dataView, setDataView] = useState<DataView | null>(null);
|
||||
const [formula, setFormula] = useState<FormulaPublicApi | null>(null);
|
||||
const [lensData, setLensData] = useState<WithLensDataLogicOutputProps>(initialValues);
|
||||
const attributes = useMemo(
|
||||
() => dataView && formula && getLensAttributes(dataView, formula, props.filterBy),
|
||||
[props.filterBy, dataView, formula]
|
||||
);
|
||||
const onDataLoad: LensByReferenceInput['onLoad'] = (isLoading, adapters) => {
|
||||
if (isLoading) {
|
||||
setLensData(initialValues);
|
||||
} else if (adapters) {
|
||||
setLensData({
|
||||
data:
|
||||
(adapters.tables?.tables[LENS_LAYERS.trend.id]?.rows?.map((row) => [
|
||||
row[LENS_LAYERS.trend.x] as number,
|
||||
row[LENS_LAYERS.trend.y] as number,
|
||||
]) as Array<[number, number]>) || [],
|
||||
isLoading: false,
|
||||
metric:
|
||||
adapters.tables?.tables[LENS_LAYERS.metrics.id]?.rows?.[0]?.[
|
||||
LENS_LAYERS.metrics.hitsTotal
|
||||
] ?? null,
|
||||
secondaryMetric:
|
||||
adapters.tables?.tables[LENS_LAYERS.metrics.id]?.rows?.[0]?.[
|
||||
LENS_LAYERS.metrics.percentage
|
||||
] ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dataViews.find(props.collection.events_datastream, 1).then(([targetDataView]) => {
|
||||
if (targetDataView) {
|
||||
setDataView(targetDataView);
|
||||
}
|
||||
});
|
||||
}, [props.collection.events_datastream]);
|
||||
useEffect(() => {
|
||||
stateHelperApi().then((helper) => {
|
||||
setFormula(helper.formula);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Component {...(props as T)} {...lensData}>
|
||||
{dataView && attributes && (
|
||||
<div css={{ display: 'none' }}>
|
||||
<EmbeddableComponent
|
||||
id={props.collection.name}
|
||||
timeRange={props.timeRange}
|
||||
attributes={attributes}
|
||||
onLoad={onDataLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
ComponentWithLensData.displayName = `withLensDataHOC(${Component.displayName || Component.name})`;
|
||||
|
||||
return ComponentWithLensData;
|
||||
};
|
|
@ -49,7 +49,7 @@ describe('AnalyticsCollectionTable', () => {
|
|||
).find(EuiButtonGroup);
|
||||
|
||||
expect(buttonGroup).toHaveLength(1);
|
||||
expect(buttonGroup.prop('options')).toHaveLength(2);
|
||||
expect(buttonGroup.prop('options')).toHaveLength(4);
|
||||
expect(buttonGroup.prop('idSelected')).toEqual('Searches');
|
||||
});
|
||||
|
||||
|
|
|
@ -25,11 +25,11 @@ import { OnTimeChangeProps } from '@elastic/eui/src/components/date_picker/super
|
|||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { AnalyticsCollection } from '../../../../../common/types/analytics';
|
||||
import { FilterBy } from '../../utils/get_formula_by_filter';
|
||||
import { AddAnalyticsCollection } from '../add_analytics_collections/add_analytics_collection';
|
||||
|
||||
import { AnalyticsCollectionCardWithLens } from './analytics_collection_card/analytics_collection_card';
|
||||
|
||||
import { FilterBy } from './analytics_collection_card/with_lens_data';
|
||||
import { AnalyticsCollectionTableStyles } from './analytics_collection_table.styles';
|
||||
|
||||
const defaultQuickRanges: EuiSuperDatePickerCommonRange[] = [
|
||||
|
@ -95,10 +95,24 @@ export const AnalyticsCollectionTable: React.FC<AnalyticsCollectionTableProps> =
|
|||
defaultMessage: 'No results',
|
||||
}),
|
||||
},
|
||||
{
|
||||
css: [analyticsCollectionTableStyles.button],
|
||||
id: FilterBy.Clicks,
|
||||
label: i18n.translate('xpack.enterpriseSearch.analytics.filtering.clicks', {
|
||||
defaultMessage: 'Clicks',
|
||||
}),
|
||||
},
|
||||
{
|
||||
css: [analyticsCollectionTableStyles.button],
|
||||
id: FilterBy.NoResults,
|
||||
label: i18n.translate('xpack.enterpriseSearch.analytics.filtering.sessions', {
|
||||
defaultMessage: 'Sessions',
|
||||
}),
|
||||
},
|
||||
],
|
||||
[analyticsCollectionTableStyles.button]
|
||||
);
|
||||
const [filterId, setFilterId] = useState<string>(filterOptions[0].id);
|
||||
const [filterId, setFilterId] = useState<FilterBy>(filterOptions[0].id);
|
||||
const [timeRange, setTimeRange] = useState<{ from: string; to: string }>({
|
||||
from: defaultQuickRanges[0].start,
|
||||
to: defaultQuickRanges[0].end,
|
||||
|
@ -127,7 +141,7 @@ export const AnalyticsCollectionTable: React.FC<AnalyticsCollectionTableProps> =
|
|||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonGroup
|
||||
css={analyticsCollectionTableStyles.buttonGroup}
|
||||
onChange={setFilterId}
|
||||
onChange={(newFilterId) => setFilterId(newFilterId as FilterBy)}
|
||||
color="primary"
|
||||
buttonSize="compressed"
|
||||
idSelected={filterId}
|
||||
|
@ -156,6 +170,7 @@ export const AnalyticsCollectionTable: React.FC<AnalyticsCollectionTableProps> =
|
|||
{collections.map((collection) => (
|
||||
<AnalyticsCollectionCardWithLens
|
||||
key={collection.name}
|
||||
id={`collection-card-${collection.name}`}
|
||||
collection={collection}
|
||||
subtitle={selectedFilterLabel}
|
||||
filterBy={filterId}
|
||||
|
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { mockKibanaValues, setMockValues } from '../../__mocks__/kea_logic';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { mount, shallow } from 'enzyme';
|
||||
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
|
||||
import { FormulaPublicApi } from '@kbn/lens-plugin/public';
|
||||
|
||||
import { withLensData } from './with_lens_data';
|
||||
|
||||
interface MockComponentProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface MockComponentLensProps {
|
||||
data: string;
|
||||
}
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
describe('withLensData', () => {
|
||||
const MockComponent: React.FC<MockComponentProps> = ({ name }) => <div>{name}</div>;
|
||||
|
||||
beforeEach(() => {
|
||||
setMockValues({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the wrapped component with the data prop', () => {
|
||||
const WrappedComponent = withLensData<MockComponentProps, MockComponentLensProps>(
|
||||
MockComponent,
|
||||
{
|
||||
dataLoadTransform: jest.fn(() => {
|
||||
return { data: 'initial data' };
|
||||
}),
|
||||
getAttributes: jest.fn(),
|
||||
getDataViewQuery: jest.fn(),
|
||||
initialValues: { data: 'initial data' },
|
||||
}
|
||||
);
|
||||
|
||||
const props = { name: 'John Doe' };
|
||||
const wrapper = shallow(
|
||||
<WrappedComponent id={'id'} timeRange={{ from: 'now-10d', to: 'now' }} {...props} />
|
||||
);
|
||||
expect(wrapper.find(MockComponent).prop('data')).toEqual('initial data');
|
||||
});
|
||||
|
||||
it('should call getDataViewQuery with props', async () => {
|
||||
const getDataViewQuery = jest.fn();
|
||||
getDataViewQuery.mockReturnValue('title-collection');
|
||||
const findMock = jest.spyOn(mockKibanaValues.data.dataViews, 'find').mockResolvedValueOnce([]);
|
||||
const WrappedComponent = withLensData<MockComponentProps, MockComponentLensProps>(
|
||||
MockComponent,
|
||||
{
|
||||
dataLoadTransform: jest.fn(),
|
||||
getAttributes: jest.fn(),
|
||||
getDataViewQuery,
|
||||
initialValues: { data: 'initial data' },
|
||||
}
|
||||
);
|
||||
|
||||
const props = { id: 'id', name: 'John Doe', timeRange: { from: 'now-10d', to: 'now' } };
|
||||
mount(<WrappedComponent {...props} />);
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(getDataViewQuery).toHaveBeenCalledWith(props);
|
||||
expect(findMock).toHaveBeenCalledWith('title-collection', 1);
|
||||
});
|
||||
|
||||
it('should call getAttributes with the correct arguments when dataView and formula are available', async () => {
|
||||
const getAttributes = jest.fn();
|
||||
const dataView = {} as DataView;
|
||||
const formula = {} as FormulaPublicApi;
|
||||
mockKibanaValues.lens.stateHelperApi = jest.fn().mockResolvedValueOnce({ formula });
|
||||
jest.spyOn(mockKibanaValues.data.dataViews, 'find').mockResolvedValueOnce([dataView]);
|
||||
|
||||
const WrappedComponent = withLensData<MockComponentProps, MockComponentLensProps>(
|
||||
MockComponent,
|
||||
{
|
||||
dataLoadTransform: jest.fn(),
|
||||
getAttributes,
|
||||
getDataViewQuery: jest.fn(),
|
||||
initialValues: { data: 'initial data' },
|
||||
}
|
||||
);
|
||||
|
||||
const props = { id: 'id', name: 'John Doe', timeRange: { from: 'now-10d', to: 'now' } };
|
||||
mount(<WrappedComponent {...props} />);
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(getAttributes).toHaveBeenCalledWith(dataView, formula, props);
|
||||
});
|
||||
|
||||
it('should not call getAttributes when dataView is not available', async () => {
|
||||
const getAttributes = jest.fn();
|
||||
const formula = {} as FormulaPublicApi;
|
||||
mockKibanaValues.lens.stateHelperApi = jest.fn().mockResolvedValueOnce({ formula });
|
||||
jest.spyOn(mockKibanaValues.data.dataViews, 'find').mockResolvedValueOnce([]);
|
||||
|
||||
const WrappedComponent = withLensData<MockComponentProps, MockComponentLensProps>(
|
||||
MockComponent,
|
||||
{
|
||||
dataLoadTransform: jest.fn(),
|
||||
getAttributes,
|
||||
getDataViewQuery: jest.fn(),
|
||||
initialValues: { data: 'initial data' },
|
||||
}
|
||||
);
|
||||
|
||||
const props = { id: 'id', name: 'John Doe', timeRange: { from: 'now-10d', to: 'now' } };
|
||||
mount(<WrappedComponent {...props} />);
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(getAttributes).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
import { useValues } from 'kea';
|
||||
|
||||
import { EuiFlexItem } from '@elastic/eui';
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
|
||||
import { TimeRange } from '@kbn/es-query';
|
||||
import { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common';
|
||||
import { FormulaPublicApi, TypedLensByValueInput } from '@kbn/lens-plugin/public';
|
||||
|
||||
import { KibanaLogic } from '../../shared/kibana';
|
||||
|
||||
export interface WithLensDataInputProps {
|
||||
id: string;
|
||||
timeRange: TimeRange;
|
||||
}
|
||||
|
||||
interface WithLensDataParams<Props, OutputState> {
|
||||
dataLoadTransform: (
|
||||
isLoading: boolean,
|
||||
adapters?: Partial<DefaultInspectorAdapters>
|
||||
) => OutputState;
|
||||
getAttributes: (
|
||||
dataView: DataView,
|
||||
formulaApi: FormulaPublicApi,
|
||||
props: Props
|
||||
) => TypedLensByValueInput['attributes'];
|
||||
getDataViewQuery: (props: Props) => string;
|
||||
initialValues: OutputState;
|
||||
}
|
||||
|
||||
export const withLensData = <T extends {} = {}, OutputState extends {} = {}>(
|
||||
Component: React.FC<T & OutputState>,
|
||||
{
|
||||
dataLoadTransform,
|
||||
getAttributes,
|
||||
getDataViewQuery,
|
||||
initialValues,
|
||||
}: WithLensDataParams<Omit<T, keyof OutputState>, OutputState>
|
||||
) => {
|
||||
const ComponentWithLensData: React.FC<T & WithLensDataInputProps> = (props) => {
|
||||
const {
|
||||
lens: { EmbeddableComponent, stateHelperApi },
|
||||
data: { dataViews },
|
||||
} = useValues(KibanaLogic);
|
||||
const [dataView, setDataView] = useState<DataView | null>(null);
|
||||
const [data, setData] = useState<OutputState>(initialValues);
|
||||
const [formula, setFormula] = useState<FormulaPublicApi | null>(null);
|
||||
const attributes = useMemo(
|
||||
() => dataView && formula && getAttributes(dataView, formula, props),
|
||||
[dataView, formula, props]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [target] = await dataViews.find(getDataViewQuery(props), 1);
|
||||
|
||||
if (target) {
|
||||
setDataView(target);
|
||||
}
|
||||
})();
|
||||
}, [props]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const helper = await stateHelperApi();
|
||||
|
||||
setFormula(helper.formula);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Component {...(props as T)} {...data} />
|
||||
{attributes && (
|
||||
<EuiFlexItem css={{ display: 'none' }}>
|
||||
<EmbeddableComponent
|
||||
id={props.id}
|
||||
timeRange={props.timeRange}
|
||||
attributes={attributes}
|
||||
onLoad={(...args) => {
|
||||
if (dataLoadTransform) {
|
||||
setData(dataLoadTransform(...args));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
ComponentWithLensData.displayName = `withLensDataHOC(${Component.displayName || Component.name})`;
|
||||
|
||||
return ComponentWithLensData;
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { FilterBy, getFormulaByFilter } from './get_formula_by_filter';
|
||||
|
||||
describe('getFormulaByFilter', () => {
|
||||
test('should return the correct formula for Searches filter without shift', () => {
|
||||
const formula = getFormulaByFilter(FilterBy.Searches);
|
||||
expect(formula).toBe('count(search.query)');
|
||||
});
|
||||
|
||||
test('should return the correct formula for NoResults filter with shift', () => {
|
||||
const formula = getFormulaByFilter(FilterBy.NoResults, '1d');
|
||||
expect(formula).toBe("count(kql='search.results.total_results : 0', shift='1d')");
|
||||
});
|
||||
|
||||
test('should return the correct formula for Clicks filter without shift', () => {
|
||||
const formula = getFormulaByFilter(FilterBy.Clicks);
|
||||
expect(formula).toBe("count(kql='event.action: search_click')");
|
||||
});
|
||||
|
||||
test('should return the correct formula for Sessions filter with shift', () => {
|
||||
const formula = getFormulaByFilter(FilterBy.Sessions, '7d');
|
||||
expect(formula).toBe("unique_count(session.id, shift='7d')");
|
||||
});
|
||||
});
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export enum FilterBy {
|
||||
Searches = 'Searches',
|
||||
NoResults = 'NoResults',
|
||||
Clicks = 'Clicks',
|
||||
Sessions = 'Sessions',
|
||||
}
|
||||
export const getFormulaByFilter = (filter: FilterBy, shift?: string): string => {
|
||||
const mapFilterByToFormula: { [key in FilterBy]: string } = {
|
||||
[FilterBy.Searches]: 'count(search.query',
|
||||
[FilterBy.NoResults]: "count(kql='search.results.total_results : 0'",
|
||||
[FilterBy.Clicks]: "count(kql='event.action: search_click'",
|
||||
[FilterBy.Sessions]: 'unique_count(session.id',
|
||||
};
|
||||
|
||||
return mapFilterByToFormula[filter] + (shift ? `, shift='${shift}'` : '') + ')';
|
||||
};
|
|
@ -52,5 +52,7 @@
|
|||
"@kbn/shared-ux-router",
|
||||
"@kbn/core-http-browser",
|
||||
"@kbn/es-query",
|
||||
"@kbn/datemath",
|
||||
"@kbn/expressions-plugin",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -11430,7 +11430,6 @@
|
|||
"xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.actions": "Afficher les instructions de l'intégration",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.body": "Commencer à suivre les événements en ajoutant le client d'analyse comportementale à chaque page de votre site web ou de l'application que vous souhaitez suivre",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.footer": "Visiter la documentation relative à l'analyse comportementale",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.headingTitle": "Collections",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.description": "Une fois que vous avez appelé createTracker, vous pouvez utiliser les méthodes de suivi telles que trackPageView pour envoyer les événements vers Behavioral Analytics.",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionThree": "Vous pouvez également déployer des événements personnalisés dans Behavioral Analytics en appelant la méthode trackEvent.",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionTwo": "Une fois initialisé, vous aurez la possibilité de suivre les vues de page dans votre application.",
|
||||
|
@ -11476,10 +11475,6 @@
|
|||
"xpack.enterpriseSearch.analytics.collectionsCreate.form.title": "Créer une collection d'analyses",
|
||||
"xpack.enterpriseSearch.analytics.collectionsDelete.action.successMessage": "La collection a été supprimée avec succès",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.breadcrumb": "Afficher la collection",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.pageDescription": "Tableaux de bord et outils permettant de visualiser le comportement des utilisateurs finaux et de mesurer les performances de vos applications de recherche. Suivez les tendances sur la durée, identifier et examinez les anomalies, et effectuez des optimisations.",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.tabs.eventsName": "Événements",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.tabs.integrateName": "Intégrer",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.tabs.settingsName": "Paramètres",
|
||||
"xpack.enterpriseSearch.analytics.productCardDescription": "Tableaux de bord et outils permettant de visualiser le comportement des utilisateurs finaux et de mesurer les performances de vos applications de recherche.",
|
||||
"xpack.enterpriseSearch.analytics.productDescription": "Tableaux de bord et outils permettant de visualiser le comportement des utilisateurs finaux et de mesurer les performances de vos applications de recherche.",
|
||||
"xpack.enterpriseSearch.analytics.productName": "Behavioral Analytics",
|
||||
|
|
|
@ -11429,7 +11429,6 @@
|
|||
"xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.actions": "統合手順を表示",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.body": "追跡したいWebサイトやアプリケーションの各ページに行動分析クライアントを追加して、イベントの追跡を開始します。",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.footer": "行動分析ドキュメントを表示",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.headingTitle": "コレクション",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.description": "createTrackerを呼び出したら、trackPageViewなどのtrackerメソッドを使って、Behavioral Analyticsにイベントを送ることができます。",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionThree": "また、trackEventメソッドを呼び出すことで、Behavioral Analyticsにカスタムイベントをディスパッチすることもできます。",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionTwo": "初期化すると、アプリケーションのページビューを追跡することができるようになります。",
|
||||
|
@ -11475,10 +11474,6 @@
|
|||
"xpack.enterpriseSearch.analytics.collectionsCreate.form.title": "分析コレクションを作成",
|
||||
"xpack.enterpriseSearch.analytics.collectionsDelete.action.successMessage": "コレクションが正常に削除されました",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.breadcrumb": "コレクションを表示",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.pageDescription": "エンドユーザーの行動を可視化し、検索アプリケーションのパフォーマンスを測定するためのダッシュボードとツール。経時的な傾向を追跡し、異常を特定して調査し、最適化を行います。",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.tabs.eventsName": "イベント",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.tabs.integrateName": "統合",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.tabs.settingsName": "設定",
|
||||
"xpack.enterpriseSearch.analytics.productCardDescription": "エンドユーザーの行動を可視化し、検索アプリケーションのパフォーマンスを測定するためのダッシュボードとツール。",
|
||||
"xpack.enterpriseSearch.analytics.productDescription": "エンドユーザーの行動を可視化し、検索アプリケーションのパフォーマンスを測定するためのダッシュボードとツール。",
|
||||
"xpack.enterpriseSearch.analytics.productName": "Behavioral Analytics",
|
||||
|
|
|
@ -11430,7 +11430,6 @@
|
|||
"xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.actions": "查看集成说明",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.body": "通过将行为分析客户端添加到您要跟踪的每个网站页面或应用程序来启动事件跟踪",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.eventsTab.emptyState.footer": "访问行为分析文档",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.headingTitle": "集合",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.description": "调用 createTracker 后,可以使用跟踪器方法(如 trackPageView)将事件发送到行为分析。",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionThree": "还可以通过调用 trackEvent 方法来向行为分析分派定制事件。",
|
||||
"xpack.enterpriseSearch.analytics.collections.collectionsView.integrateTab.javascriptClientEmbed.stepFour.descriptionTwo": "完成初始化后,您将能够跟踪您应用程序中的页面视图。",
|
||||
|
@ -11476,10 +11475,6 @@
|
|||
"xpack.enterpriseSearch.analytics.collectionsCreate.form.title": "创建分析集合",
|
||||
"xpack.enterpriseSearch.analytics.collectionsDelete.action.successMessage": "已成功删除此集合",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.breadcrumb": "查看集合",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.pageDescription": "用于对最终用户行为进行可视化并评估搜索应用程序性能的仪表板和工具。跟踪一段时间的趋势,识别和调查异常,并进行优化。",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.tabs.eventsName": "事件",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.tabs.integrateName": "集成",
|
||||
"xpack.enterpriseSearch.analytics.collectionsView.tabs.settingsName": "设置",
|
||||
"xpack.enterpriseSearch.analytics.productCardDescription": "用于对最终用户行为进行可视化并评估搜索应用程序性能的仪表板和工具。",
|
||||
"xpack.enterpriseSearch.analytics.productDescription": "用于对最终用户行为进行可视化并评估搜索应用程序性能的仪表板和工具。",
|
||||
"xpack.enterpriseSearch.analytics.productName": "行为分析",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue