[Visualizations] Enables multiple values filtering on tooltip actions (#148372)

## Summary

Enables tooltip actions on:
- Lens embeddables (as soon as the unified search bar exists)
- Lens
- Agg based (as they share the same renderer with Lens)

I am not enabling this in TSVB.


![lens](https://user-images.githubusercontent.com/17003240/210559313-3c36dab0-771d-415a-b9cd-fa2a8d48579e.gif)

Also the action is not enabled for Lens if:
- there are only series with metrics

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

Co-authored-by: Marco Vettorello <marco.vettorello@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Stratoula Kalafateli 2023-01-25 12:55:30 +02:00 committed by GitHub
parent f3741b7a61
commit 5ec4bde603
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 802 additions and 156 deletions

View file

@ -56,7 +56,7 @@ const cleanUpFilter = (filter: Filter) => {
export function buildCombinedFilter(
relation: BooleanRelation,
filters: Filter[],
indexPattern: DataViewBase,
indexPattern: Pick<DataViewBase, 'id'>,
disabled: FilterMeta['disabled'] = false,
negate: FilterMeta['negate'] = false,
alias?: FilterMeta['alias'],

View file

@ -557,6 +557,19 @@ exports[`XYChart component it renders area 1`] = `
<Chart
renderer="canvas"
>
<Tooltip
actions={
Array [
Object {
"disabled": [Function],
"label": [Function],
"onSelect": [Function],
},
]
}
headerFormatter={[Function]}
type="vertical"
/>
<Settings
allowBrushingLastHistogramBin={false}
ariaUseDefaultSummary={true}
@ -608,14 +621,6 @@ exports[`XYChart component it renders area 1`] = `
"markSizeRatio": undefined,
}
}
tooltip={
Object {
"boundary": undefined,
"customTooltip": undefined,
"headerFormatter": [Function],
"type": "vertical",
}
}
/>
<XYCurrentTime
enabled={false}
@ -1539,6 +1544,19 @@ exports[`XYChart component it renders bar 1`] = `
<Chart
renderer="canvas"
>
<Tooltip
actions={
Array [
Object {
"disabled": [Function],
"label": [Function],
"onSelect": [Function],
},
]
}
headerFormatter={[Function]}
type="vertical"
/>
<Settings
allowBrushingLastHistogramBin={false}
ariaUseDefaultSummary={true}
@ -1590,14 +1608,6 @@ exports[`XYChart component it renders bar 1`] = `
"markSizeRatio": undefined,
}
}
tooltip={
Object {
"boundary": undefined,
"customTooltip": undefined,
"headerFormatter": [Function],
"type": "vertical",
}
}
/>
<XYCurrentTime
enabled={false}
@ -2521,6 +2531,19 @@ exports[`XYChart component it renders horizontal bar 1`] = `
<Chart
renderer="canvas"
>
<Tooltip
actions={
Array [
Object {
"disabled": [Function],
"label": [Function],
"onSelect": [Function],
},
]
}
headerFormatter={[Function]}
type="vertical"
/>
<Settings
allowBrushingLastHistogramBin={false}
ariaUseDefaultSummary={true}
@ -2572,14 +2595,6 @@ exports[`XYChart component it renders horizontal bar 1`] = `
"markSizeRatio": undefined,
}
}
tooltip={
Object {
"boundary": undefined,
"customTooltip": undefined,
"headerFormatter": [Function],
"type": "vertical",
}
}
/>
<XYCurrentTime
enabled={false}
@ -3503,6 +3518,19 @@ exports[`XYChart component it renders line 1`] = `
<Chart
renderer="canvas"
>
<Tooltip
actions={
Array [
Object {
"disabled": [Function],
"label": [Function],
"onSelect": [Function],
},
]
}
headerFormatter={[Function]}
type="vertical"
/>
<Settings
allowBrushingLastHistogramBin={false}
ariaUseDefaultSummary={true}
@ -3554,14 +3582,6 @@ exports[`XYChart component it renders line 1`] = `
"markSizeRatio": undefined,
}
}
tooltip={
Object {
"boundary": undefined,
"customTooltip": undefined,
"headerFormatter": [Function],
"type": "vertical",
}
}
/>
<XYCurrentTime
enabled={false}
@ -4485,6 +4505,19 @@ exports[`XYChart component it renders stacked area 1`] = `
<Chart
renderer="canvas"
>
<Tooltip
actions={
Array [
Object {
"disabled": [Function],
"label": [Function],
"onSelect": [Function],
},
]
}
headerFormatter={[Function]}
type="vertical"
/>
<Settings
allowBrushingLastHistogramBin={false}
ariaUseDefaultSummary={true}
@ -4536,14 +4569,6 @@ exports[`XYChart component it renders stacked area 1`] = `
"markSizeRatio": undefined,
}
}
tooltip={
Object {
"boundary": undefined,
"customTooltip": undefined,
"headerFormatter": [Function],
"type": "vertical",
}
}
/>
<XYCurrentTime
enabled={false}
@ -5467,6 +5492,19 @@ exports[`XYChart component it renders stacked bar 1`] = `
<Chart
renderer="canvas"
>
<Tooltip
actions={
Array [
Object {
"disabled": [Function],
"label": [Function],
"onSelect": [Function],
},
]
}
headerFormatter={[Function]}
type="vertical"
/>
<Settings
allowBrushingLastHistogramBin={false}
ariaUseDefaultSummary={true}
@ -5518,14 +5556,6 @@ exports[`XYChart component it renders stacked bar 1`] = `
"markSizeRatio": undefined,
}
}
tooltip={
Object {
"boundary": undefined,
"customTooltip": undefined,
"headerFormatter": [Function],
"type": "vertical",
}
}
/>
<XYCurrentTime
enabled={false}
@ -6449,6 +6479,19 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = `
<Chart
renderer="canvas"
>
<Tooltip
actions={
Array [
Object {
"disabled": [Function],
"label": [Function],
"onSelect": [Function],
},
]
}
headerFormatter={[Function]}
type="vertical"
/>
<Settings
allowBrushingLastHistogramBin={false}
ariaUseDefaultSummary={true}
@ -6500,14 +6543,6 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = `
"markSizeRatio": undefined,
}
}
tooltip={
Object {
"boundary": undefined,
"customTooltip": undefined,
"headerFormatter": [Function],
"type": "vertical",
}
}
/>
<XYCurrentTime
enabled={false}
@ -7461,6 +7496,19 @@ exports[`XYChart component split chart should render split chart if both, splitR
<Chart
renderer="canvas"
>
<Tooltip
actions={
Array [
Object {
"disabled": [Function],
"label": [Function],
"onSelect": [Function],
},
]
}
headerFormatter={[Function]}
type="vertical"
/>
<Settings
allowBrushingLastHistogramBin={false}
ariaUseDefaultSummary={true}
@ -7512,14 +7560,6 @@ exports[`XYChart component split chart should render split chart if both, splitR
"markSizeRatio": undefined,
}
}
tooltip={
Object {
"boundary": undefined,
"customTooltip": undefined,
"headerFormatter": [Function],
"type": "vertical",
}
}
/>
<XYCurrentTime
enabled={false}
@ -8681,6 +8721,19 @@ exports[`XYChart component split chart should render split chart if splitColumnA
<Chart
renderer="canvas"
>
<Tooltip
actions={
Array [
Object {
"disabled": [Function],
"label": [Function],
"onSelect": [Function],
},
]
}
headerFormatter={[Function]}
type="vertical"
/>
<Settings
allowBrushingLastHistogramBin={false}
ariaUseDefaultSummary={true}
@ -8732,14 +8785,6 @@ exports[`XYChart component split chart should render split chart if splitColumnA
"markSizeRatio": undefined,
}
}
tooltip={
Object {
"boundary": undefined,
"customTooltip": undefined,
"headerFormatter": [Function],
"type": "vertical",
}
}
/>
<XYCurrentTime
enabled={false}
@ -9894,6 +9939,19 @@ exports[`XYChart component split chart should render split chart if splitRowAcce
<Chart
renderer="canvas"
>
<Tooltip
actions={
Array [
Object {
"disabled": [Function],
"label": [Function],
"onSelect": [Function],
},
]
}
headerFormatter={[Function]}
type="vertical"
/>
<Settings
allowBrushingLastHistogramBin={false}
ariaUseDefaultSummary={true}
@ -9945,14 +10003,6 @@ exports[`XYChart component split chart should render split chart if splitRowAcce
"markSizeRatio": undefined,
}
}
tooltip={
Object {
"boundary": undefined,
"customTooltip": undefined,
"headerFormatter": [Function],
"type": "vertical",
}
}
/>
<XYCurrentTime
enabled={false}

View file

@ -29,6 +29,7 @@ import {
SmallMultiples,
VerticalAlignment,
XYChartSeriesIdentifier,
Tooltip,
} from '@elastic/charts';
import { Datatable } from '@kbn/expressions-plugin/common';
import { EmptyPlaceholder } from '@kbn/charts-plugin/public';
@ -61,6 +62,7 @@ import { LegendSize } from '@kbn/visualizations-plugin/common';
import type { LayerCellValueActions } from '../types';
const onClickValue = jest.fn();
const onClickMultiValue = jest.fn();
const layerCellValueActions: LayerCellValueActions = [];
const onSelectRange = jest.fn();
@ -116,6 +118,7 @@ describe('XYChart component', () => {
paletteService,
minInterval: 50,
onClickValue,
onClickMultiValue,
layerCellValueActions,
onSelectRange,
syncColors: false,
@ -1102,6 +1105,126 @@ describe('XYChart component', () => {
expect(wrapper.find(Settings).at(0).prop('allowBrushingLastHistogramBin')).toEqual(true);
});
test('should not have tooltip actions for the detailed tooltip', () => {
const { args, data } = sampleArgs();
const wrapper = mountWithIntl(
<XYChart
{...defaultProps}
args={{
...args,
detailedTooltip: true,
layers: [
{
layerId: 'first',
type: 'dataLayer',
layerType: LayerTypes.DATA,
isHistogram: true,
seriesType: 'bar',
isStacked: true,
isHorizontal: false,
isPercentage: false,
showLines: true,
xAccessor: 'b',
xScaleType: 'time',
splitAccessors: ['b'],
accessors: ['d'],
columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}',
palette: mockPaletteOutput,
table: data,
},
],
}}
/>
);
const tooltip = wrapper.find(Tooltip);
const actions = tooltip.prop('actions');
expect(actions).toBeUndefined();
});
test('should not have tooltip actions for no split accessor', () => {
const { args, data } = sampleArgs();
const wrapper = mountWithIntl(
<XYChart
{...defaultProps}
args={{
...args,
layers: [
{
layerId: 'first',
type: 'dataLayer',
layerType: LayerTypes.DATA,
isHistogram: true,
seriesType: 'bar',
isStacked: true,
isHorizontal: false,
isPercentage: false,
showLines: true,
xAccessor: 'b',
xScaleType: 'time',
splitAccessors: undefined,
accessors: ['d'],
columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}',
palette: mockPaletteOutput,
table: data,
},
],
}}
/>
);
const tooltip = wrapper.find(Tooltip);
const actions = tooltip.prop('actions');
expect(actions).toBeUndefined();
});
test('should have tooltip actions for split accessor and default tooltip', () => {
const { args, data } = sampleArgs();
const wrapper = mountWithIntl(
<XYChart
{...defaultProps}
args={{
...args,
layers: [
{
layerId: 'first',
type: 'dataLayer',
layerType: LayerTypes.DATA,
isHistogram: true,
seriesType: 'bar',
isStacked: true,
isHorizontal: false,
isPercentage: false,
showLines: true,
xAccessor: 'b',
xScaleType: 'time',
splitAccessors: ['d'],
accessors: ['d'],
columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}',
palette: mockPaletteOutput,
table: data,
},
],
}}
/>
);
const tooltip = wrapper.find(Tooltip);
const actions = tooltip.prop('actions');
expect(actions?.length).toBe(1);
expect(actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
onSelect: expect.any(Function),
disabled: expect.any(Function),
}),
])
);
});
test('onElementClick returns correct context data', () => {
const geometry: GeometryValue = { x: 5, y: 1, accessor: 'y1', mark: null, datum: {} };
const series = {
@ -3284,14 +3407,11 @@ describe('XYChart component', () => {
}}
/>
);
const settings = component.find(Settings);
const tooltip = settings.prop('tooltip');
expect(tooltip).toEqual(
expect.objectContaining({
headerFormatter: undefined,
customTooltip: expect.any(Function),
})
);
const tooltip = component.find(Tooltip);
const customTooltip = tooltip.prop('customTooltip');
expect(customTooltip).not.toBeUndefined();
const headerFormatter = tooltip.prop('headerFormatter');
expect(headerFormatter).toBeUndefined();
});
it('should render default tooltip, if detailed tooltip is hidden', () => {
@ -3306,14 +3426,11 @@ describe('XYChart component', () => {
}}
/>
);
const settings = component.find(Settings);
const tooltip = settings.prop('tooltip');
expect(tooltip).toEqual(
expect.objectContaining({
headerFormatter: expect.any(Function),
customTooltip: undefined,
})
);
const tooltip = component.find(Tooltip);
const customTooltip = tooltip.prop('customTooltip');
expect(customTooltip).toBeUndefined();
const headerFormatter = tooltip.prop('headerFormatter');
expect(headerFormatter).not.toBeUndefined();
});
});
});

View file

@ -8,6 +8,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import {
Chart,
Settings,
@ -27,6 +28,9 @@ import {
Placement,
Direction,
XYChartElementEvent,
Tooltip,
XYChartSeriesIdentifier,
TooltipValue,
} from '@elastic/charts';
import { partition } from 'lodash';
import { IconType } from '@elastic/eui';
@ -47,7 +51,13 @@ import {
LegendSizeToPixels,
} from '@kbn/visualizations-plugin/common/constants';
import { PersistedState } from '@kbn/visualizations-plugin/public';
import type { FilterEvent, BrushEvent, FormatFactory, LayerCellValueActions } from '../types';
import type {
FilterEvent,
BrushEvent,
FormatFactory,
LayerCellValueActions,
MultiFilterEvent,
} from '../types';
import { isTimeChart } from '../../common/helpers';
import type {
CommonXYDataLayerConfig,
@ -94,7 +104,7 @@ import {
} from './annotations';
import { AxisExtentModes, SeriesTypes, ValueLabelModes, XScaleTypes } from '../../common/constants';
import { DataLayers } from './data_layers';
import { Tooltip } from './tooltip';
import { Tooltip as CustomTooltip } from './tooltip';
import { XYCurrentTime } from './xy_current_time';
import './xy_chart.scss';
@ -121,6 +131,7 @@ export type XYChartRenderProps = Omit<XYChartProps, 'canNavigateToLens'> & {
minInterval: number | undefined;
interactive?: boolean;
onClickValue: (data: FilterEvent['data']) => void;
onClickMultiValue: (data: MultiFilterEvent['data']) => void;
layerCellValueActions: LayerCellValueActions;
onSelectRange: (data: BrushEvent['data']) => void;
renderMode: RenderMode;
@ -133,6 +144,10 @@ export type XYChartRenderProps = Omit<XYChartProps, 'canNavigateToLens'> & {
timeFormat: string;
};
function nonNullable<T>(v: T): v is NonNullable<T> {
return v != null;
}
function getValueLabelsStyling(isHorizontal: boolean): {
displayValue: RecursivePartial<DisplayValueStyle>;
} {
@ -173,6 +188,9 @@ function createSplitPoint(
table: Datatable
) {
const splitPointRowIndex = rows.findIndex((row) => {
if (Array.isArray(value)) {
return value.includes(row[accessor]);
}
return row[accessor] === value;
});
if (splitPointRowIndex !== -1) {
@ -197,6 +215,7 @@ export function XYChart({
paletteService,
minInterval,
onClickValue,
onClickMultiValue,
layerCellValueActions,
onSelectRange,
interactive = true,
@ -534,6 +553,54 @@ export function XYChart({
valueLabels !== ValueLabelModes.HIDE &&
getValueLabelsStyling(shouldRotate);
const filterSelectedTooltipValues = (
tooltipSelectedValues: Array<
TooltipValue<Record<string, string | number>, XYChartSeriesIdentifier>
>
) => {
const layerIndexes: number[] = [];
tooltipSelectedValues.forEach((v) => {
const index = dataLayers.findIndex((l) =>
v.seriesIdentifier.seriesKeys.some((key: string | number) =>
l.accessors.some(
(accessor) => getAccessorByDimension(accessor, l.table.columns) === key.toString()
)
)
);
if (!layerIndexes.includes(index) && index !== -1) {
layerIndexes.push(index);
}
});
if (!layerIndexes.length) return;
layerIndexes.forEach((layerIndex) => {
const layer = dataLayers[layerIndex];
const { table } = layer;
if (layer.splitAccessors?.length !== 1) return;
const splitAccessor = getAccessorByDimension(layer.splitAccessors[0], table.columns);
const filterValues = tooltipSelectedValues
.map((v) => v.datum?.[splitAccessor])
.filter(nonNullable);
const splitPoints = filterValues
.map((v) =>
createSplitPoint(splitAccessor, v, formattedDatatables[layer.layerId].table.rows, table)
)
.filter(nonNullable);
if (splitPoints.length) {
onClickMultiValue({
data: {
column: splitPoints[0].column,
value: splitPoints.map(({ value }) => value),
table,
},
});
}
});
};
const clickHandler: ElementClickListener = ([elementEvent]) => {
// this cast is safe because we are rendering a cartesian chart
const [xyGeometry, xySeries] = elementEvent as XYChartElementEvent;
@ -722,6 +789,8 @@ export function XYChart({
overflowX: 'hidden',
position: uiState ? 'absolute' : 'relative',
});
// enable the tooltip actions only if there is at least one splitAccessor to the dataLayer
const hasTooltipActions = dataLayers.some((dataLayer) => dataLayer.splitAccessors) && interactive;
return (
<div css={chartContainerStyle}>
@ -745,6 +814,60 @@ export function XYChart({
}}
>
<Chart ref={chartRef}>
<Tooltip<Record<string, string | number>, XYChartSeriesIdentifier>
boundary={document.getElementById('app-fixed-viewport') ?? undefined}
headerFormatter={
!args.detailedTooltip
? ({ value }) => (
<TooltipHeader
value={value}
formatter={safeXAccessorLabelRenderer}
xDomain={rawXDomain}
/>
)
: undefined
}
actions={
!args.detailedTooltip && hasTooltipActions
? [
{
disabled: (selected) => selected.length < 1,
label: (selected) =>
selected.length === 0
? i18n.translate('expressionXY.tooltipActions.emptyFilterSelection', {
defaultMessage: 'Select at least one series to filter',
})
: i18n.translate('expressionXY.tooltipActions.filterValues', {
defaultMessage: 'Filter {seriesNumber} series',
values: { seriesNumber: selected.length },
}),
onSelect: filterSelectedTooltipValues,
},
]
: undefined
}
customTooltip={
args.detailedTooltip
? ({ header, values }) => (
<CustomTooltip
header={header}
values={values}
titles={titles}
fieldFormats={fieldFormats}
formatFactory={formatFactory}
formattedDatatables={formattedDatatables}
splitAccessors={{
splitColumnAccessor: splitColumnId,
splitRowAccessor: splitRowId,
}}
layers={dataLayers}
xDomain={isTimeViz ? rawXDomain : undefined}
/>
)
: undefined
}
type={args.showTooltip ? TooltipType.VerticalCursor : TooltipType.None}
/>
<Settings
noResults={
<EmptyPlaceholder
@ -789,37 +912,6 @@ export function XYChart({
markSizeRatio: args.markSizeRatio,
}}
baseTheme={chartBaseTheme}
tooltip={{
boundary: document.getElementById('app-fixed-viewport') ?? undefined,
headerFormatter: !args.detailedTooltip
? ({ value }) => (
<TooltipHeader
value={value}
formatter={safeXAccessorLabelRenderer}
xDomain={rawXDomain}
/>
)
: undefined,
customTooltip: args.detailedTooltip
? ({ header, values }) => (
<Tooltip
header={header}
values={values}
titles={titles}
fieldFormats={fieldFormats}
formatFactory={formatFactory}
formattedDatatables={formattedDatatables}
splitAccessors={{
splitColumnAccessor: splitColumnId,
splitRowAccessor: splitRowId,
}}
layers={dataLayers}
xDomain={isTimeViz ? rawXDomain : undefined}
/>
)
: undefined,
type: args.showTooltip ? TooltipType.VerticalCursor : TooltipType.None,
}}
allowBrushingLastHistogramBin={isTimeViz}
rotation={shouldRotate ? 90 : 0}
xDomain={xDomain}

View file

@ -31,7 +31,12 @@ import { extractContainerType, extractVisualizationType } from '@kbn/chart-expre
import type { getDataLayers } from '../helpers';
import { LayerTypes, SeriesTypes } from '../../common/constants';
import type { CommonXYDataLayerConfig, XYChartProps } from '../../common';
import type { BrushEvent, FilterEvent, GetCompatibleCellValueActions } from '../types';
import type {
BrushEvent,
FilterEvent,
GetCompatibleCellValueActions,
MultiFilterEvent,
} from '../types';
export type GetStartDepsFn = () => Promise<{
data: DataPublicPluginStart;
@ -207,6 +212,9 @@ export const getXyChartRenderer = ({
const onSelectRange = (data: BrushEvent['data']) => {
handlers.event({ name: 'brush', data });
};
const onClickMultiValue = (data: MultiFilterEvent['data']) => {
handlers.event({ name: 'multiFilter', data });
};
const layerCellValueActions = await getLayerCellValueActions(
getDataLayers(config.args.layers),
@ -260,6 +268,7 @@ export const getXyChartRenderer = ({
minInterval={calculateMinInterval(deps.data.datatableUtilities, config)}
interactive={handlers.isInteractive()}
onClickValue={onClickValue}
onClickMultiValue={onClickMultiValue}
layerCellValueActions={layerCellValueActions}
onSelectRange={onSelectRange}
renderMode={handlers.getRenderMode()}

View file

@ -15,6 +15,7 @@ import type {
CellValueContext,
RangeSelectContext,
ValueClickContext,
MultiValueClickContext,
} from '@kbn/embeddable-plugin/public';
import { ExpressionsServiceStart, ExpressionsSetup } from '@kbn/expressions-plugin/public';
@ -37,6 +38,11 @@ export interface FilterEvent {
data: ValueClickContext['data'];
}
export interface MultiFilterEvent {
name: 'multiFilter';
data: MultiValueClickContext['data'];
}
export interface BrushEvent {
name: 'brush';
data: RangeSelectContext['data'];

View file

@ -9,7 +9,11 @@
// TODO: https://github.com/elastic/kibana/issues/110891
/* eslint-disable @kbn/eslint/no_export_all */
import { RangeSelectContext, ValueClickContext } from '@kbn/embeddable-plugin/public';
import {
RangeSelectContext,
ValueClickContext,
MultiValueClickContext,
} from '@kbn/embeddable-plugin/public';
import { ChartsPlugin } from './plugin';
export const plugin = () => new ChartsPlugin();
@ -30,6 +34,11 @@ export interface BrushTriggerEvent {
data: RangeSelectContext['data'];
}
export interface MultiClickTriggerEvent {
name: 'multiFilter';
data: MultiValueClickContext['data'];
}
export type {
CustomPaletteArguments,
CustomPaletteState,

View file

@ -0,0 +1,101 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DataViewsContract } from '@kbn/data-views-plugin/common';
import { dataPluginMock } from '../../mocks';
import { setIndexPatterns, setSearchService } from '../../services';
import { createFiltersFromMultiValueClickAction } from './create_filters_from_multi_value_click';
import { FieldFormatsGetConfigFn, BytesFormat } from '@kbn/field-formats-plugin/common';
const mockField = {
name: 'bytes',
filterable: true,
};
describe('createFiltersFromMultiValueClickAction', () => {
let dataPoints: Parameters<typeof createFiltersFromMultiValueClickAction>[0]['data'];
beforeEach(() => {
dataPoints = {
table: {
columns: [
{
name: 'test',
id: '1-1',
meta: {
type: 'date',
source: 'esaggs',
sourceParams: {
indexPatternId: 'logstash-*',
type: 'histogram',
params: {
field: 'bytes',
interval: 30,
otherBucket: true,
},
},
},
},
],
rows: [
{
'1-1': '2048',
},
],
meta: {
source: 'dataview-1',
type: 'esaggs',
},
},
column: 0,
value: ['2048'],
};
const dataStart = dataPluginMock.createStartContract();
setSearchService(dataStart.search);
setIndexPatterns({
...dataStart.indexPatterns,
get: async () => ({
id: 'logstash-*',
fields: {
getByName: () => mockField,
filter: () => [mockField],
},
getFormatterForField: () => new BytesFormat({}, (() => {}) as FieldFormatsGetConfigFn),
}),
} as unknown as DataViewsContract);
});
test('ignores event when value for rows is not provided', async () => {
dataPoints.table.rows[0]['1-1'] = null;
const filters = await createFiltersFromMultiValueClickAction({ data: dataPoints });
expect(filters).toBeUndefined();
});
test('ignores event when dataview id is not provided', async () => {
dataPoints.table.meta = undefined;
const filters = await createFiltersFromMultiValueClickAction({ data: dataPoints });
expect(filters).toBeUndefined();
});
test('handles an event when aggregations type is a terms', async () => {
(dataPoints.table.columns[0].meta.sourceParams as any).type = 'terms';
const filters = await createFiltersFromMultiValueClickAction({ data: dataPoints });
expect(filters?.query?.match_phrase?.bytes).toEqual('2048');
});
test('handles an event when aggregations type is not terms', async () => {
const filters = await createFiltersFromMultiValueClickAction({ data: dataPoints });
expect(filters?.query?.range.bytes.gte).toEqual(2048);
expect(filters?.query?.range.bytes.lt).toEqual(2078);
});
});

View file

@ -0,0 +1,72 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { buildCombinedFilter, Filter, toggleFilterNegated, BooleanRelation } from '@kbn/es-query';
import { createFilter } from './create_filters_from_value_click';
import type { MultiValueClickContext } from '../multi_value_click_action';
type MultiValueClickDataContext = MultiValueClickContext['data'];
/** @public */
export const createFiltersFromMultiValueClickAction = async ({
data,
negate,
}: MultiValueClickDataContext) => {
const { table, column, value } = data;
const dataViewId = table?.meta?.source;
if (!dataViewId) return;
const columnId = table.columns[column].id;
const filters = (
await Promise.all(
value.map(async (v) => {
return (
await createFilter(
table,
column,
table.rows.findIndex((r) => r[columnId] === v)
)
)?.[0];
})
)
).filter(Boolean) as Filter[];
if (filters.length === 0) return;
// no need for combined filter in case of one filter
if (filters.length === 1) {
if (filters[0] && negate) {
return toggleFilterNegated(filters[0]);
}
return filters[0];
}
const filtersHaveAlias = filters.every((f) => f.meta.alias);
let alias = '';
if (filtersHaveAlias) {
filters.forEach((f, i) => {
if (i === filters.length - 1) {
alias += `${f.meta.alias}`;
} else {
alias += `${f.meta.alias} ${BooleanRelation.OR} `;
}
});
}
let filter: Filter = buildCombinedFilter(
BooleanRelation.OR,
filters,
{
id: dataViewId,
},
undefined,
undefined,
alias
);
if (filter && negate) {
filter = toggleFilterNegated(filter);
}
return filter;
};

View file

@ -71,7 +71,7 @@ const getOtherBucketFilterTerms = (
* @param {string} cellValue - value of the current cell
* @return {Filter[]|undefined} - list of filters to provide to queryFilter.addFilters()
*/
const createFilter = async (
export const createFilter = async (
table: Pick<Datatable, 'rows' | 'columns'>,
columnIndex: number,
rowIndex: number

View file

@ -8,5 +8,7 @@
export { createFiltersFromValueClickAction } from './filters/create_filters_from_value_click';
export { createFiltersFromRangeSelectAction } from './filters/create_filters_from_range_select';
export { createFiltersFromMultiValueClickAction } from './filters/create_filters_from_multi_value_click';
export * from './select_range_action';
export * from './value_click_action';
export * from './multi_value_click_action';

View file

@ -0,0 +1,49 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { Filter } from '@kbn/es-query';
import { Datatable } from '@kbn/expressions-plugin/public';
import { UiActionsActionDefinition } from '@kbn/ui-actions-plugin/public';
import { FilterManager } from '../query';
import { createFiltersFromMultiValueClickAction } from './filters/create_filters_from_multi_value_click';
export type MultiValueClickActionContext = MultiValueClickContext;
export const ACTION_MULTI_VALUE_CLICK = 'ACTION_MULTI_VALUE_CLICK';
export interface MultiValueClickContext {
// Need to make this unknown to prevent circular dependencies.
// Apps using this property will need to cast to `IEmbeddable`.
embeddable?: unknown;
data: {
data: {
table: Pick<Datatable, 'rows' | 'columns' | 'meta'>;
column: number;
value: any[];
};
timeFieldName?: string;
negate?: boolean;
};
}
export function createMultiValueClickActionDefinition(
getStartServices: () => { filterManager: FilterManager }
): UiActionsActionDefinition<MultiValueClickContext> {
return {
type: ACTION_MULTI_VALUE_CLICK,
id: ACTION_MULTI_VALUE_CLICK,
shouldAutoExecute: async () => true,
isCompatible: async (context: MultiValueClickContext) => {
const filters = await createFiltersFromMultiValueClickAction(context.data);
return Boolean(filters);
},
execute: async (context: MultiValueClickActionContext) => {
const filter = (await createFiltersFromMultiValueClickAction(context.data)) as Filter;
getStartServices().filterManager.addFilters(filter);
},
};
}

View file

@ -34,6 +34,7 @@ import {
import {
createFiltersFromValueClickAction,
createFiltersFromRangeSelectAction,
createMultiValueClickActionDefinition,
createValueClickActionDefinition,
createSelectRangeActionDefinition,
} from './actions';
@ -154,6 +155,13 @@ export class DataPublicPlugin
}))
);
uiActions.addTriggerAction(
'MULTI_VALUE_CLICK_TRIGGER',
createMultiValueClickActionDefinition(() => ({
filterManager: query.filterManager,
}))
);
const datatableUtilities = new DatatableUtilitiesService(search.aggs, dataViews, fieldFormats);
const dataServices = {
actions: {

View file

@ -9,6 +9,7 @@
import { UiActionsSetup } from '@kbn/ui-actions-plugin/public';
import {
contextMenuTrigger,
multiValueClickTrigger,
panelBadgeTrigger,
panelNotificationTrigger,
selectRangeTrigger,
@ -26,5 +27,6 @@ export const bootstrap = (uiActions: UiActionsSetup) => {
uiActions.registerTrigger(panelNotificationTrigger);
uiActions.registerTrigger(selectRangeTrigger);
uiActions.registerTrigger(valueClickTrigger);
uiActions.registerTrigger(multiValueClickTrigger);
uiActions.registerTrigger(cellValueTrigger);
};

View file

@ -29,6 +29,7 @@ export type {
EmbeddableInstanceConfiguration,
EmbeddableOutput,
ValueClickContext,
MultiValueClickContext,
CellValueContext,
RangeSelectContext,
IContainer,
@ -69,6 +70,7 @@ export {
PanelNotFoundError,
SELECT_RANGE_TRIGGER,
VALUE_CLICK_TRIGGER,
MULTI_VALUE_CLICK_TRIGGER,
CELL_VALUE_TRIGGER,
cellValueTrigger,
ViewMode,
@ -78,6 +80,7 @@ export {
isSavedObjectEmbeddableInput,
isRangeSelectTriggerContext,
isValueClickTriggerContext,
isMultiValueClickTriggerContext,
isRowClickTriggerContext,
isContextMenuTriggerContext,
EmbeddableStateTransfer,

View file

@ -29,6 +29,19 @@ export interface ValueClickContext<T extends IEmbeddable = IEmbeddable> {
};
}
export interface MultiValueClickContext<T extends IEmbeddable = IEmbeddable> {
embeddable?: T;
data: {
data: {
table: Pick<Datatable, 'rows' | 'columns'>;
column: number;
value: any[];
};
timeFieldName?: string;
negate?: boolean;
};
}
export interface CellValueContext<T extends IEmbeddable = IEmbeddable> {
embeddable: T;
data: Array<{
@ -50,6 +63,7 @@ export interface RangeSelectContext<T extends IEmbeddable = IEmbeddable> {
export type ChartActionContext<T extends IEmbeddable = IEmbeddable> =
| ValueClickContext<T>
| MultiValueClickContext<T>
| RangeSelectContext<T>
| RowClickContext;
@ -108,6 +122,17 @@ export const valueClickTrigger: Trigger = {
}),
};
export const MULTI_VALUE_CLICK_TRIGGER = 'MULTI_VALUE_CLICK_TRIGGER';
export const multiValueClickTrigger: Trigger = {
id: MULTI_VALUE_CLICK_TRIGGER,
title: i18n.translate('embeddableApi.multiValueClickTrigger.title', {
defaultMessage: 'Multi click',
}),
description: i18n.translate('embeddableApi.multiValueClickTrigger.description', {
defaultMessage: 'Selecting multiple values of a single dimension on the visualization',
}),
};
export const CELL_VALUE_TRIGGER = 'CELL_VALUE_TRIGGER';
export const cellValueTrigger: Trigger = {
id: CELL_VALUE_TRIGGER,
@ -123,6 +148,11 @@ export const isValueClickTriggerContext = (
context: ChartActionContext
): context is ValueClickContext => context.data && 'data' in context.data;
export const isMultiValueClickTriggerContext = (
context: ChartActionContext
): context is MultiValueClickContext =>
context.data && 'data' in context.data && !Array.isArray(context.data.data);
export const isRangeSelectTriggerContext = (
context: ChartActionContext
): context is RangeSelectContext => context.data && 'range' in context.data;

View file

@ -288,13 +288,17 @@ class FilterEditorComponent extends Component<FilterEditorProps, State> {
);
}
private hasCombinedFilterCustomType(filters: Filter[]) {
return filters.some((filter) => filter.meta.type === 'custom');
}
private renderFiltersBuilderEditor() {
const { selectedDataView, localFilter } = this.state;
const flattenedFilters = flattenFilters([localFilter]);
const shouldShowPreview =
selectedDataView &&
(flattenedFilters.length > 1 ||
((flattenedFilters.length > 1 && !this.hasCombinedFilterCustomType(flattenedFilters)) ||
(flattenedFilters.length === 1 &&
isFilterValid(
selectedDataView,
@ -386,7 +390,10 @@ class FilterEditorComponent extends Component<FilterEditorProps, State> {
};
private isUnknownFilterType() {
const { type } = this.props.filter.meta;
const { type, params } = this.props.filter.meta;
if (params && type === 'combined') {
return this.hasCombinedFilterCustomType(params);
}
return !!type && !['phrase', 'phrases', 'range', 'exists', 'combined'].includes(type);
}

View file

@ -8,12 +8,17 @@
import { ROW_CLICK_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public';
import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER } from '@kbn/embeddable-plugin/public';
import {
SELECT_RANGE_TRIGGER,
VALUE_CLICK_TRIGGER,
MULTI_VALUE_CLICK_TRIGGER,
} from '@kbn/embeddable-plugin/public';
export interface VisEventToTrigger {
['applyFilter']: typeof APPLY_FILTER_TRIGGER;
['brush']: typeof SELECT_RANGE_TRIGGER;
['filter']: typeof VALUE_CLICK_TRIGGER;
['multiFilter']: typeof MULTI_VALUE_CLICK_TRIGGER;
['tableRowContextMenuClick']: typeof ROW_CLICK_TRIGGER;
}
@ -21,5 +26,6 @@ export const VIS_EVENT_TO_TRIGGER: VisEventToTrigger = {
applyFilter: APPLY_FILTER_TRIGGER,
brush: SELECT_RANGE_TRIGGER,
filter: VALUE_CLICK_TRIGGER,
multiFilter: MULTI_VALUE_CLICK_TRIGGER,
tableRowContextMenuClick: ROW_CLICK_TRIGGER,
};

View file

@ -502,6 +502,40 @@ describe('workspace_panel', () => {
expect(trigger.exec).toHaveBeenCalledWith({ data: { ...eventData, timeFieldName: undefined } });
});
it('should execute a multi value click trigger on expression event', async () => {
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
mockDatasource.toExpression.mockReturnValue('datasource');
mockDatasource.getLayers.mockReturnValue(['first']);
const props = defaultProps;
const mounted = await mountWithProvider(
<WorkspacePanel
{...props}
datasourceMap={{
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
ExpressionRenderer={expressionRendererMock}
plugins={{ ...props.plugins, uiActions: uiActionsMock }}
/>
);
instance = mounted.instance;
const onEvent = expressionRendererMock.mock.calls[0][0].onEvent!;
const eventData = { myData: true, table: { rows: [], columns: [] }, column: 0 };
onEvent({ name: 'multiFilter', data: eventData });
expect(uiActionsMock.getTrigger).toHaveBeenCalledWith(VIS_EVENT_TO_TRIGGER.multiFilter);
expect(trigger.exec).toHaveBeenCalledWith({ data: { ...eventData, timeFieldName: undefined } });
});
it('should call getTriggerCompatibleActions on hasCompatibleActions call from within renderer', async () => {
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {

View file

@ -39,6 +39,7 @@ import {
FramePublicAPI,
isLensBrushEvent,
isLensFilterEvent,
isLensMultiFilterEvent,
isLensEditEvent,
VisualizationMap,
DatasourceMap,
@ -398,7 +399,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
},
});
}
if (isLensFilterEvent(event)) {
if (isLensFilterEvent(event) || isLensMultiFilterEvent(event)) {
plugins.uiActions.getTrigger(VIS_EVENT_TO_TRIGGER[event.name]).exec({
data: {
...event.data,
@ -424,7 +425,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
// ui actions not available, not handling event...
return false;
}
if (!isLensFilterEvent(event)) {
if (!isLensFilterEvent(event) && !isLensMultiFilterEvent(event)) {
return false;
}
return (

View file

@ -67,7 +67,12 @@ import type {
ThemeServiceStart,
} from '@kbn/core/public';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import { BrushTriggerEvent, ClickTriggerEvent, Warnings } from '@kbn/charts-plugin/public';
import {
BrushTriggerEvent,
ClickTriggerEvent,
Warnings,
MultiClickTriggerEvent,
} from '@kbn/charts-plugin/public';
import { DataViewSpec } from '@kbn/data-views-plugin/common';
import { getExecutionContextEvents, trackUiCounterEvents } from '../lens_ui_telemetry';
import { Document } from '../persistence';
@ -75,6 +80,7 @@ import { ExpressionWrapper, ExpressionWrapperProps } from './expression_wrapper'
import {
isLensBrushEvent,
isLensFilterEvent,
isLensMultiFilterEvent,
isLensEditEvent,
isLensTableRowContextMenuClickEvent,
LensTableRowContextMenuEvent,
@ -133,7 +139,7 @@ interface LensBaseEmbeddableInput extends EmbeddableInput {
noPadding?: boolean;
onBrushEnd?: (data: BrushTriggerEvent['data']) => void;
onLoad?: (isLoading: boolean, adapters?: Partial<DefaultInspectorAdapters>) => void;
onFilter?: (data: ClickTriggerEvent['data']) => void;
onFilter?: (data: ClickTriggerEvent['data'] | MultiClickTriggerEvent['data']) => void;
onTableRowClick?: (data: LensTableRowContextMenuEvent['data']) => void;
}
@ -896,7 +902,11 @@ export class Embeddable
private readonly hasCompatibleActions = async (
event: ExpressionRendererEvent
): Promise<boolean> => {
if (isLensTableRowContextMenuClickEvent(event) || isLensFilterEvent(event)) {
if (
isLensTableRowContextMenuClickEvent(event) ||
isLensMultiFilterEvent(event) ||
isLensFilterEvent(event)
) {
const { getTriggerCompatibleActions } = this.deps;
if (!getTriggerCompatibleActions) {
return false;
@ -992,7 +1002,7 @@ export class Embeddable
this.input.onBrushEnd(event.data);
}
}
if (isLensFilterEvent(event)) {
if (isLensFilterEvent(event) || isLensMultiFilterEvent(event)) {
this.deps.getTrigger(VIS_EVENT_TO_TRIGGER[event.name]).exec({
data: {
...event.data,

View file

@ -24,7 +24,11 @@ import type {
RowClickContext,
VisualizeFieldContext,
} from '@kbn/ui-actions-plugin/public';
import type { ClickTriggerEvent, BrushTriggerEvent } from '@kbn/charts-plugin/public';
import type {
ClickTriggerEvent,
BrushTriggerEvent,
MultiClickTriggerEvent,
} from '@kbn/charts-plugin/public';
import type { IndexPatternAggRestrictions } from '@kbn/data-plugin/public';
import type { FieldSpec, DataViewSpec, DataView } from '@kbn/data-views-plugin/common';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
@ -1340,6 +1344,12 @@ export function isLensFilterEvent(event: ExpressionRendererEvent): event is Clic
return event.name === 'filter';
}
export function isLensMultiFilterEvent(
event: ExpressionRendererEvent
): event is MultiClickTriggerEvent {
return event.name === 'multiFilter';
}
export function isLensBrushEvent(event: ExpressionRendererEvent): event is BrushTriggerEvent {
return event.name === 'brush';
}

View file

@ -13,7 +13,11 @@ import type { TimefilterContract } from '@kbn/data-plugin/public';
import type { IUiSettingsClient, SavedObjectReference } from '@kbn/core/public';
import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public';
import type { DatatableUtilitiesService } from '@kbn/data-plugin/common';
import { BrushTriggerEvent, ClickTriggerEvent } from '@kbn/charts-plugin/public';
import {
BrushTriggerEvent,
ClickTriggerEvent,
MultiClickTriggerEvent,
} from '@kbn/charts-plugin/public';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { ISearchStart } from '@kbn/data-plugin/public';
import type { Document } from './persistence/saved_object_store';
@ -209,7 +213,7 @@ export function getRemoveOperation(
export function inferTimeField(
datatableUtilities: DatatableUtilitiesService,
context: BrushTriggerEvent['data'] | ClickTriggerEvent['data']
context: BrushTriggerEvent['data'] | ClickTriggerEvent['data'] | MultiClickTriggerEvent['data']
) {
const tablesAndColumns =
'table' in context
@ -218,17 +222,19 @@ export function inferTimeField(
? context.data
: // if it's a negated filter, never respect bound time field
[];
return tablesAndColumns
.map(({ table, column }) => {
const tableColumn = table.columns[column];
const hasTimeRange = Boolean(
tableColumn && datatableUtilities.getDateHistogramMeta(tableColumn)?.timeRange
);
if (hasTimeRange) {
return tableColumn.meta.field;
}
})
.find(Boolean);
return !Array.isArray(tablesAndColumns)
? [tablesAndColumns]
: tablesAndColumns
.map(({ table, column }) => {
const tableColumn = table.columns[column];
const hasTimeRange = Boolean(
tableColumn && datatableUtilities.getDateHistogramMeta(tableColumn)?.timeRange
);
if (hasTimeRange) {
return tableColumn.meta.field;
}
})
.find(Boolean);
}
export function renewIDs<T = unknown>(

View file

@ -34,6 +34,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await browser.getActions().move({ x, y, origin: el._webElement }).click().perform();
}
async function rightClickInChart(x: number, y: number) {
const el = await elasticChart.getCanvas();
await browser.getActions().move({ x, y, origin: el._webElement }).contextClick().perform();
}
describe('lens dashboard tests', () => {
before(async () => {
await PageObjects.common.navigateToApp('dashboard');
@ -90,6 +95,23 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(hasIpFilter).to.be(true);
});
it('should be able to add filters by right clicking in XYChart', async () => {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.clickNewDashboard();
await dashboardAddPanel.clickOpenAddPanel();
await dashboardAddPanel.filterEmbeddableNames('lnsXYvis');
await find.clickByButtonText('lnsXYvis');
await dashboardAddPanel.closeAddPanel();
await PageObjects.lens.goToTimeRange();
await retry.try(async () => {
// show the tooltip actions
await rightClickInChart(30, 5); // hardcoded position of bar, depends heavy on data and charts implementation
await (await find.byCssSelector('.echTooltipActions__action')).click();
const hasIpFilter = await filterBar.hasFilter('ip', '97.220.3.248');
expect(hasIpFilter).to.be(true);
});
});
// Requires xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled
// setting set in kibana.yml to test (not enabled by default)
it('should hide old "explore underlying data" action', async () => {