[Lens] Allow user to drag and select a subset of the timeline in the chart (aka brush interaction) (#62636)

* feat: brushing basic example for time histogram

* test: added

* refactor: simplify the structure

* refactor: move to inline function

* refactor

* refactor

* Always use time field from index pattern

* types

* use the meta.aggConfigParams for timefieldName

* fix: test snapshot update

* Update embeddable.tsx

removing commented code

* fix: moment remov

* fix: corrections for adapting to timepicker on every timefield

* fix: fix single bar condition

* types

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Wylie Conlon <wylieconlon@gmail.com>
This commit is contained in:
Marta Bondyra 2020-04-30 20:36:50 +02:00 committed by GitHub
parent b93427b7b6
commit 5887c97d75
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 253 additions and 25 deletions

View file

@ -96,9 +96,7 @@ export class Embeddable extends AbstractEmbeddable<LensEmbeddableInput, LensEmbe
public supportedTriggers() {
switch (this.savedVis.visualizationType) {
case 'lnsXY':
// TODO: case 'lnsDatatable':
return [VIS_EVENT_TO_TRIGGER.filter];
return [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush];
case 'lnsMetric':
default:
return [];

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { createGetterSetter } from '../../../../../src/plugins/kibana_utils/public';
import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
import { createGetterSetter } from '../../../../src/plugins/kibana_utils/public';
import { UiActionsStart } from '../../../../src/plugins/ui_actions/public';
export const [getExecuteTriggerActions, setExecuteTriggerActions] = createGetterSetter<
UiActionsStart['executeTriggerActions']

View file

@ -6,6 +6,7 @@ exports[`xy_expression XYChart component it renders area 1`] = `
>
<Connect(spec)
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
rotation={0}
showLegend={false}
@ -73,6 +74,7 @@ exports[`xy_expression XYChart component it renders bar 1`] = `
>
<Connect(spec)
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
rotation={0}
showLegend={false}
@ -140,6 +142,7 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = `
>
<Connect(spec)
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
rotation={90}
showLegend={false}
@ -207,6 +210,7 @@ exports[`xy_expression XYChart component it renders line 1`] = `
>
<Connect(spec)
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
rotation={0}
showLegend={false}
@ -274,6 +278,7 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = `
>
<Connect(spec)
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
rotation={0}
showLegend={false}
@ -345,6 +350,7 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = `
>
<Connect(spec)
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
rotation={0}
showLegend={false}
@ -416,6 +422,7 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] =
>
<Connect(spec)
legendPosition="top"
onBrushEnd={[Function]}
onElementClick={[Function]}
rotation={90}
showLegend={false}

View file

@ -12,8 +12,8 @@ import { xyVisualization } from './xy_visualization';
import { xyChart, getXyChartRenderer } from './xy_expression';
import { legendConfig, xConfig, layerConfig } from './types';
import { EditorFrameSetup, FormatFactory } from '../types';
import { setExecuteTriggerActions } from '../services';
import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
import { setExecuteTriggerActions } from './services';
export interface XyVisualizationPluginSetupPlugins {
expressions: ExpressionsSetup;

View file

@ -142,7 +142,7 @@ export const buildExpression = (
.concat(layer.splitAccessor ? [layer.splitAccessor] : [])
.forEach(accessor => {
const operation = datasource.getOperationForColumnId(accessor);
if (operation && operation.label) {
if (operation?.label) {
columnToLabel[accessor] = operation.label;
}
});

View file

@ -27,6 +27,145 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers';
const executeTriggerActions = jest.fn();
const dateHistogramData: LensMultiTable = {
type: 'lens_multitable',
tables: {
timeLayer: {
type: 'kibana_datatable',
rows: [
{
xAccessorId: 1585758120000,
splitAccessorId: "Men's Clothing",
yAccessorId: 1,
},
{
xAccessorId: 1585758360000,
splitAccessorId: "Women's Accessories",
yAccessorId: 1,
},
{
xAccessorId: 1585758360000,
splitAccessorId: "Women's Clothing",
yAccessorId: 1,
},
{
xAccessorId: 1585759380000,
splitAccessorId: "Men's Clothing",
yAccessorId: 1,
},
{
xAccessorId: 1585759380000,
splitAccessorId: "Men's Shoes",
yAccessorId: 1,
},
{
xAccessorId: 1585759380000,
splitAccessorId: "Women's Clothing",
yAccessorId: 1,
},
{
xAccessorId: 1585760700000,
splitAccessorId: "Men's Clothing",
yAccessorId: 1,
},
{
xAccessorId: 1585760760000,
splitAccessorId: "Men's Clothing",
yAccessorId: 1,
},
{
xAccessorId: 1585760760000,
splitAccessorId: "Men's Shoes",
yAccessorId: 1,
},
{
xAccessorId: 1585761120000,
splitAccessorId: "Men's Shoes",
yAccessorId: 1,
},
],
columns: [
{
id: 'xAccessorId',
name: 'order_date per minute',
meta: {
type: 'date_histogram',
indexPatternId: 'indexPatternId',
aggConfigParams: {
field: 'order_date',
timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' },
useNormalizedEsInterval: true,
scaleMetricValues: false,
interval: '1m',
drop_partials: false,
min_doc_count: 0,
extended_bounds: {},
},
},
formatHint: { id: 'date', params: { pattern: 'HH:mm' } },
},
{
id: 'splitAccessorId',
name: 'Top values of category.keyword',
meta: {
type: 'terms',
indexPatternId: 'indexPatternId',
aggConfigParams: {
field: 'category.keyword',
orderBy: 'yAccessorId',
order: 'desc',
size: 3,
otherBucket: false,
otherBucketLabel: 'Other',
missingBucket: false,
missingBucketLabel: 'Missing',
},
},
formatHint: {
id: 'terms',
params: {
id: 'string',
otherBucketLabel: 'Other',
missingBucketLabel: 'Missing',
parsedUrl: {
origin: 'http://localhost:5601',
pathname: '/jiy/app/kibana',
basePath: '/jiy',
},
},
},
},
{
id: 'yAccessorId',
name: 'Count of records',
meta: {
type: 'count',
indexPatternId: 'indexPatternId',
aggConfigParams: {},
},
formatHint: { id: 'number' },
},
],
},
},
dateRange: {
fromDate: new Date('2020-04-01T16:14:16.246Z'),
toDate: new Date('2020-04-01T17:15:41.263Z'),
},
};
const dateHistogramLayer: LayerArgs = {
layerId: 'timeLayer',
hide: false,
xAccessor: 'xAccessorId',
yScaleType: 'linear',
xScaleType: 'time',
isHistogram: true,
splitAccessor: 'splitAccessorId',
seriesType: 'bar_stacked',
accessors: ['yAccessorId'],
};
const createSampleDatatableWithRows = (rows: KibanaDatatableRow[]): KibanaDatatable => ({
type: 'kibana_datatable',
columns: [
@ -284,7 +423,7 @@ describe('xy_expression', () => {
Object {
"max": 1546491600000,
"min": 1546405200000,
"minInterval": 1728000,
"minInterval": undefined,
}
`);
});
@ -449,6 +588,39 @@ describe('xy_expression', () => {
expect(component.find(Settings).prop('rotation')).toEqual(90);
});
test('onBrushEnd returns correct context data for date histogram data', () => {
const { args } = sampleArgs();
const wrapper = mountWithIntl(
<XYChart
data={dateHistogramData}
args={{
...args,
layers: [dateHistogramLayer],
}}
formatFactory={getFormatSpy}
timeZone="UTC"
chartTheme={{}}
histogramBarTarget={50}
executeTriggerActions={executeTriggerActions}
/>
);
wrapper
.find(Settings)
.first()
.prop('onBrushEnd')!(1585757732783, 1585758880838);
expect(executeTriggerActions).toHaveBeenCalledWith('SELECT_RANGE_TRIGGER', {
data: {
column: 0,
table: dateHistogramData.tables.timeLayer,
range: [1585757732783, 1585758880838],
},
timeFieldName: 'order_date',
});
});
test('onElementClick returns correct context data', () => {
const geometry: GeometryValue = { x: 5, y: 1, accessor: 'y1', mark: null };
const series = {

View file

@ -29,14 +29,17 @@ import {
import { EuiIcon, EuiText, IconType, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { ValueClickTriggerContext } from '../../../../../src/plugins/embeddable/public';
import {
ValueClickTriggerContext,
RangeSelectTriggerContext,
} from '../../../../../src/plugins/embeddable/public';
import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public';
import { LensMultiTable, FormatFactory } from '../types';
import { XYArgs, SeriesType, visualizationTypes } from './types';
import { VisualizationContainer } from '../visualization_container';
import { isHorizontalChart } from './state_helpers';
import { getExecuteTriggerActions } from '../services';
import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
import { getExecuteTriggerActions } from './services';
import { parseInterval } from '../../../../../src/plugins/data/common';
type InferPropType<T> = T extends React.FunctionComponent<infer P> ? P : T;
@ -218,8 +221,32 @@ export function XYChart({
const xTitle = (xAxisColumn && xAxisColumn.name) || args.xTitle;
function calculateMinInterval() {
// add minInterval only for single row value as it cannot be determined from dataset
if (data.dateRange && layers.every(layer => data.tables[layer.layerId].rows.length <= 1)) {
// check all the tables to see if all of the rows have the same timestamp
// that would mean that chart will draw a single bar
const isSingleTimestampInXDomain = () => {
const nonEmptyLayers = layers.filter(
layer => data.tables[layer.layerId].rows.length && layer.xAccessor
);
if (!nonEmptyLayers.length) {
return;
}
const firstRowValue =
data.tables[nonEmptyLayers[0].layerId].rows[0][nonEmptyLayers[0].xAccessor!];
for (const layer of nonEmptyLayers) {
if (
layer.xAccessor &&
data.tables[layer.layerId].rows.some(row => row[layer.xAccessor!] !== firstRowValue)
) {
return false;
}
}
return true;
};
// add minInterval only for single point in domain
if (data.dateRange && isSingleTimestampInXDomain()) {
if (xAxisColumn?.meta?.aggConfigParams?.interval !== 'auto')
return parseInterval(xAxisColumn?.meta?.aggConfigParams?.interval)?.asMilliseconds();
@ -231,14 +258,16 @@ export function XYChart({
return undefined;
}
const xDomain =
data.dateRange && layers.every(l => l.xScaleType === 'time')
? {
min: data.dateRange.fromDate.getTime(),
max: data.dateRange.toDate.getTime(),
minInterval: calculateMinInterval(),
}
: undefined;
const isTimeViz = data.dateRange && layers.every(l => l.xScaleType === 'time');
const xDomain = isTimeViz
? {
min: data.dateRange?.fromDate.getTime(),
max: data.dateRange?.toDate.getTime(),
minInterval: calculateMinInterval(),
}
: undefined;
return (
<Chart>
<Settings
@ -248,6 +277,31 @@ export function XYChart({
theme={chartTheme}
rotation={shouldRotate ? 90 : 0}
xDomain={xDomain}
onBrushEnd={(min: number, max: number) => {
// in the future we want to make it also for histogram
if (!xAxisColumn || !isTimeViz) {
return;
}
const firstLayerWithData =
layers[layers.findIndex(layer => data.tables[layer.layerId].rows.length)];
const table = data.tables[firstLayerWithData.layerId];
const xAxisColumnIndex = table.columns.findIndex(
el => el.id === firstLayerWithData.xAccessor
);
const timeFieldName = table.columns[xAxisColumnIndex]?.meta?.aggConfigParams?.field;
const context: RangeSelectTriggerContext = {
data: {
range: [min, max],
table,
column: xAxisColumnIndex,
},
timeFieldName,
};
executeTriggerActions(VIS_EVENT_TO_TRIGGER.brush, context);
}}
onElementClick={([[geometry, series]]) => {
// for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue
const xySeries = series as XYChartSeriesIdentifier;
@ -284,10 +338,8 @@ export function XYChart({
});
}
const xAxisFieldName: string | undefined = table.columns.find(
col => col.id === layer.xAccessor
)?.meta?.aggConfigParams?.field;
const xAxisFieldName = table.columns.find(el => el.id === layer.xAccessor)?.meta
?.aggConfigParams?.field;
const timeFieldName = xDomain && xAxisFieldName;
const context: ValueClickTriggerContext = {
@ -301,7 +353,6 @@ export function XYChart({
},
timeFieldName,
};
executeTriggerActions(VIS_EVENT_TO_TRIGGER.filter, context);
}}
/>