mirror of
https://github.com/elastic/kibana.git
synced 2025-04-25 10:23:14 -04:00
[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:
parent
b93427b7b6
commit
5887c97d75
7 changed files with 253 additions and 25 deletions
|
@ -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 [];
|
||||
|
|
|
@ -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']
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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')
|
||||
const isTimeViz = data.dateRange && layers.every(l => l.xScaleType === 'time');
|
||||
|
||||
const xDomain = isTimeViz
|
||||
? {
|
||||
min: data.dateRange.fromDate.getTime(),
|
||||
max: data.dateRange.toDate.getTime(),
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue