From e97a7ffc990a08c98aff837bee90ea12309aecff Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 17 Sep 2020 16:24:56 +0200 Subject: [PATCH] [Drilldowns] {{event.points}} in URL drilldown for VALUE_CLICK_TRIGGER (#76771) (#77754) {{event.points}} in URL drilldown for VALUE_CLICK_TRIGGER Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- docs/user/dashboard/url-drilldown.asciidoc | 17 ++ .../public/triggers/value_click_trigger.ts | 2 +- .../url_drilldown/url_drilldown.test.ts | 2 - .../url_drilldown/url_drilldown.tsx | 16 +- .../url_drilldown/url_drilldown_scope.test.ts | 129 ++++++++++++ ...ldown_scope.tsx => url_drilldown_scope.ts} | 196 ++++-------------- .../embeddable_enhanced/public/plugin.ts | 1 - 7 files changed, 192 insertions(+), 171 deletions(-) create mode 100644 x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts rename x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/{url_drilldown_scope.tsx => url_drilldown_scope.ts} (51%) diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc index 16f82477756b7b..4919625340da28 100644 --- a/docs/user/dashboard/url-drilldown.asciidoc +++ b/docs/user/dashboard/url-drilldown.asciidoc @@ -197,6 +197,7 @@ context.panel.timeRange.indexPatternIds | ID of saved object behind a panel. | *Single click* + | event.value | Value behind clicked data point. @@ -208,6 +209,22 @@ context.panel.timeRange.indexPatternIds | event.negate | Boolean, indicating whether clicked data point resulted in negative filter. +| +| event.points +| Some visualizations have clickable points that emit more than one data point. Use list of data points in case a single value is insufficient. + + +Example: + +`{{json event.points}}` + +`{{event.points.[0].key}}` + +`{{event.points.[0].value}}` +`{{#each event.points}}key=value&{{/each}}` + +Note: + +`{{event.value}}` is a shorthand for `{{event.points.[0].value}}` + +`{{event.key}}` is a shorthand for `{{event.points.[0].key}}` + | *Range selection* | event.from + event.to diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts index e63ff28f42d965..f1aff6322522ae 100644 --- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts @@ -27,6 +27,6 @@ export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = { defaultMessage: 'Single click', }), description: i18n.translate('uiActions.triggers.valueClickDescription', { - defaultMessage: 'A single point on the visualization', + defaultMessage: 'A data point click on the visualization', }), }; diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts index 4906d0342be848..64af67aefa4be2 100644 --- a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts @@ -5,7 +5,6 @@ */ import { UrlDrilldown, ActionContext, Config } from './url_drilldown'; -import { coreMock } from '../../../../../../src/core/public/mocks'; import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public/lib/embeddables'; const mockDataPoints = [ @@ -52,7 +51,6 @@ const mockNavigateToUrl = jest.fn(() => Promise.resolve()); describe('UrlDrilldown', () => { const urlDrilldown = new UrlDrilldown({ getGlobalScope: () => ({ kibanaUrl: 'http://localhost:5601/' }), - getOpenModal: () => Promise.resolve(coreMock.createStart().overlays.openModal), getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs', getVariablesHelpDocsLink: () => 'http://localhost:5601/docs', navigateToUrl: mockNavigateToUrl, diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx index 80478e6490b8fa..04f60662d88a37 100644 --- a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx @@ -5,7 +5,6 @@ */ import React from 'react'; -import { OverlayStart } from 'kibana/public'; import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; import { ChartActionContext, IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public'; @@ -29,7 +28,6 @@ import { txtUrlDrilldownDisplayName } from './i18n'; interface UrlDrilldownDeps { getGlobalScope: () => UrlDrilldownGlobalScope; navigateToUrl: (url: string) => Promise; - getOpenModal: () => Promise; getSyntaxHelpDocsLink: () => string; getVariablesHelpDocsLink: () => string; } @@ -112,13 +110,10 @@ export class UrlDrilldown implements Drilldown - urlDrilldownCompileUrl(config.url.template, await this.buildRuntimeScope(context)); + urlDrilldownCompileUrl(config.url.template, this.buildRuntimeScope(context)); public readonly execute = async (config: Config, context: ActionContext) => { - const url = await urlDrilldownCompileUrl( - config.url.template, - await this.buildRuntimeScope(context, { allowPrompts: true }) - ); + const url = urlDrilldownCompileUrl(config.url.template, this.buildRuntimeScope(context)); if (config.openInNewTab) { window.open(url, '_blank', 'noopener'); } else { @@ -134,14 +129,11 @@ export class UrlDrilldown implements Drilldown { + private buildRuntimeScope = (context: ActionContext) => { return urlDrilldownBuildScope({ globalScope: this.deps.getGlobalScope(), contextScope: getContextScope(context), - eventScope: await getEventScope(context, this.deps, opts), + eventScope: getEventScope(context), }); }; } diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts new file mode 100644 index 00000000000000..bb1baf5b964285 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + getEventScope, + getMockEventScope, + ValueClickTriggerEventScope, +} from './url_drilldown_scope'; + +const createPoint = ({ + field, + value, +}: { + field: string; + value: string | null | number | boolean; +}) => ({ + table: { + columns: [ + { + name: field, + id: '1-1', + meta: { + type: 'histogram', + indexPatternId: 'logstash-*', + aggConfigParams: { + field, + interval: 30, + otherBucket: true, + }, + }, + }, + ], + rows: [ + { + '1-1': '2048', + }, + ], + }, + column: 0, + row: 0, + value, +}); + +describe('VALUE_CLICK_TRIGGER', () => { + describe('supports `points[]`', () => { + test('getEventScope()', () => { + const mockDataPoints = [ + createPoint({ field: 'field0', value: 'value0' }), + createPoint({ field: 'field1', value: 'value1' }), + createPoint({ field: 'field2', value: 'value2' }), + ]; + + const eventScope = getEventScope({ + data: { data: mockDataPoints }, + }) as ValueClickTriggerEventScope; + + expect(eventScope.key).toBe('field0'); + expect(eventScope.value).toBe('value0'); + expect(eventScope.points).toHaveLength(mockDataPoints.length); + expect(eventScope.points).toMatchInlineSnapshot(` + Array [ + Object { + "key": "field0", + "value": "value0", + }, + Object { + "key": "field1", + "value": "value1", + }, + Object { + "key": "field2", + "value": "value2", + }, + ] + `); + }); + + test('getMockEventScope()', () => { + const mockEventScope = getMockEventScope([ + 'VALUE_CLICK_TRIGGER', + ]) as ValueClickTriggerEventScope; + expect(mockEventScope.points.length).toBeGreaterThan(3); + expect(mockEventScope.points).toMatchInlineSnapshot(` + Array [ + Object { + "key": "event.points.0.key", + "value": "event.points.0.value", + }, + Object { + "key": "event.points.1.key", + "value": "event.points.1.value", + }, + Object { + "key": "event.points.2.key", + "value": "event.points.2.value", + }, + Object { + "key": "event.points.3.key", + "value": "event.points.3.value", + }, + ] + `); + }); + }); + + describe('handles undefined, null or missing values', () => { + test('undefined or missing values are removed from the result scope', () => { + const point = createPoint({ field: undefined } as any); + const eventScope = getEventScope({ + data: { data: [point] }, + }) as ValueClickTriggerEventScope; + + expect('key' in eventScope).toBeFalsy(); + expect('value' in eventScope).toBeFalsy(); + }); + + test('null value stays in the result scope', () => { + const point = createPoint({ field: 'field', value: null }); + const eventScope = getEventScope({ + data: { data: [point] }, + }) as ValueClickTriggerEventScope; + + expect(eventScope.value).toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts similarity index 51% rename from x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx rename to x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts index d3e3510f1b24e4..15a9a3ba77d882 100644 --- a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts @@ -9,19 +9,7 @@ * Please refer to ./README.md for explanation of different scope sources */ -import React from 'react'; -import { - EuiButton, - EuiButtonEmpty, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiRadioGroup, -} from '@elastic/eui'; -import uniqBy from 'lodash/uniqBy'; -import { FormattedMessage } from '@kbn/i18n/react'; -import type { Query, Filter, TimeRange } from '../../../../../../src/plugins/data/public'; +import type { Filter, Query, TimeRange } from '../../../../../../src/plugins/data/public'; import { IEmbeddable, isRangeSelectTriggerContext, @@ -31,8 +19,6 @@ import { } from '../../../../../../src/plugins/embeddable/public'; import type { ActionContext, ActionFactoryContext, UrlTrigger } from './url_drilldown'; import { SELECT_RANGE_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; -import { OverlayStart } from '../../../../../../src/core/public'; -import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; type ContextScopeInput = ActionContext | ActionFactoryContext; @@ -113,38 +99,35 @@ export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilld /** * URL drilldown event scope, - * available as: {{event.key}}, {{event.from}} + * available as {{event.$}} */ -type UrlDrilldownEventScope = ValueClickTriggerEventScope | RangeSelectTriggerEventScope; -type EventScopeInput = ActionContext; -interface ValueClickTriggerEventScope { +export type UrlDrilldownEventScope = ValueClickTriggerEventScope | RangeSelectTriggerEventScope; +export type EventScopeInput = ActionContext; +export interface ValueClickTriggerEventScope { key?: string; - value?: string | number | boolean; + value: Primitive; negate: boolean; + points: Array<{ key?: string; value: Primitive }>; } -interface RangeSelectTriggerEventScope { +export interface RangeSelectTriggerEventScope { key: string; from?: string | number; to?: string | number; } -export async function getEventScope( - eventScopeInput: EventScopeInput, - deps: { getOpenModal: () => Promise }, - opts: { allowPrompts: boolean } = { allowPrompts: false } -): Promise { +export function getEventScope(eventScopeInput: EventScopeInput): UrlDrilldownEventScope { if (isRangeSelectTriggerContext(eventScopeInput)) { return getEventScopeFromRangeSelectTriggerContext(eventScopeInput); } else if (isValueClickTriggerContext(eventScopeInput)) { - return getEventScopeFromValueClickTriggerContext(eventScopeInput, deps, opts); + return getEventScopeFromValueClickTriggerContext(eventScopeInput); } else { throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger"); } } -async function getEventScopeFromRangeSelectTriggerContext( +function getEventScopeFromRangeSelectTriggerContext( eventScopeInput: RangeSelectContext -): Promise { +): RangeSelectTriggerEventScope { const { table, column: columnIndex, range } = eventScopeInput.data; const column = table.columns[columnIndex]; return cleanEmptyKeys({ @@ -154,18 +137,23 @@ async function getEventScopeFromRangeSelectTriggerContext( }); } -async function getEventScopeFromValueClickTriggerContext( - eventScopeInput: ValueClickContext, - deps: { getOpenModal: () => Promise }, - opts: { allowPrompts: boolean } = { allowPrompts: false } -): Promise { +function getEventScopeFromValueClickTriggerContext( + eventScopeInput: ValueClickContext +): ValueClickTriggerEventScope { const negate = eventScopeInput.data.negate ?? false; - const point = await getSingleValue(eventScopeInput.data.data, deps, opts); - const { key, value } = getKeyValueFromPoint(point); + const points = eventScopeInput.data.data.map(({ table, value, column: columnIndex }) => { + const column = table.columns[columnIndex]; + return { + value: toPrimitiveOrUndefined(value) as Primitive, + key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string | undefined, + }; + }); + return cleanEmptyKeys({ - key, - value, + key: points[0]?.key, + value: points[0]?.value, negate, + points, }); } @@ -182,29 +170,28 @@ export function getMockEventScope([trigger]: UrlTrigger[]): UrlDrilldownEventSco to: new Date().toISOString(), }; } else { + // number of mock points to generate + // should be larger or equal of any possible data points length emitted by VALUE_CLICK_TRIGGER + const nPoints = 4; + const points = new Array(nPoints).fill(0).map((_, index) => ({ + key: `event.points.${index}.key`, + value: `event.points.${index}.value`, + })); return { - key: 'event.key', - value: 'event.value', + key: `event.key`, + value: `event.value`, negate: false, + points, }; } } -function getKeyValueFromPoint( - point: ValueClickContext['data']['data'][0] -): Pick { - const { table, column: columnIndex, value } = point; - const column = table.columns[columnIndex]; - return { - key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string | undefined, - value: toPrimitiveOrUndefined(value), - }; -} - -function toPrimitiveOrUndefined(v: unknown): string | number | boolean | undefined { - if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') return v; +type Primitive = string | number | boolean | null; +function toPrimitiveOrUndefined(v: unknown): Primitive | undefined { + if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string' || v === null) + return v; if (typeof v === 'object' && v instanceof Date) return v.toISOString(); - if (typeof v === 'undefined' || v === null) return undefined; + if (typeof v === 'undefined') return undefined; return String(v); } @@ -216,104 +203,3 @@ function cleanEmptyKeys>(obj: T): T { }); return obj; } - -/** - * VALUE_CLICK_TRIGGER could have multiple data points - * Prompt user which data point to use in a drilldown - */ -async function getSingleValue( - data: ValueClickContext['data']['data'], - deps: { getOpenModal: () => Promise }, - opts: { allowPrompts: boolean } = { allowPrompts: false } -): Promise { - data = uniqBy(data.filter(Boolean), (point) => { - const { key, value } = getKeyValueFromPoint(point); - return `${key}:${value}`; - }); - if (data.length === 0) - throw new Error(`[trigger = "VALUE_CLICK_TRIGGER"][getSingleValue] no value to pick from`); - if (data.length === 1) return Promise.resolve(data[0]); - if (!opts.allowPrompts) return Promise.resolve(data[0]); - return new Promise(async (resolve, reject) => { - const openModal = await deps.getOpenModal(); - const overlay = openModal( - toMountPoint( - overlay.close()} - onSubmit={(point) => { - if (point) { - resolve(point); - } - overlay.close(); - }} - data={data} - /> - ) - ); - overlay.onClose.then(() => reject()); - }); -} - -function GetSingleValuePopup({ - data, - onCancel, - onSubmit, -}: { - data: ValueClickContext['data']['data']; - onCancel: () => void; - onSubmit: (value: ValueClickContext['data']['data'][0]) => void; -}) { - const values = data - .map((point) => { - const { key, value } = getKeyValueFromPoint(point); - return { - point, - id: key ?? '', - label: `${key}:${value}`, - }; - }) - .filter((value) => Boolean(value.id)); - - const [selectedValueId, setSelectedValueId] = React.useState(values[0].id); - - return ( - - - - - - - - - setSelectedValueId(id)} - name="drilldownValues" - /> - - - - - - - onSubmit(values.find((v) => v.id === selectedValueId)?.point!)} - data-test-subj="applySingleValuePopoverButton" - fill - > - - - - - ); -} diff --git a/x-pack/plugins/embeddable_enhanced/public/plugin.ts b/x-pack/plugins/embeddable_enhanced/public/plugin.ts index 187db998e06eaa..2138a372523b78 100644 --- a/x-pack/plugins/embeddable_enhanced/public/plugin.ts +++ b/x-pack/plugins/embeddable_enhanced/public/plugin.ts @@ -74,7 +74,6 @@ export class EmbeddableEnhancedPlugin getGlobalScope: urlDrilldownGlobalScopeProvider({ core }), navigateToUrl: (url: string) => core.getStartServices().then(([{ application }]) => application.navigateToUrl(url)), - getOpenModal: () => core.getStartServices().then(([{ overlays }]) => overlays.openModal), getSyntaxHelpDocsLink: () => startServices().core.docLinks.links.dashboard.urlDrilldownTemplateSyntax, getVariablesHelpDocsLink: () =>