[Drilldowns] {{event.points}} in URL drilldown for VALUE_CLICK_TRIGGER (#76771)

{{event.points}} in URL drilldown for VALUE_CLICK_TRIGGER

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Anton Dosov 2020-09-17 14:29:03 +02:00 committed by GitHub
parent cbf2844d64
commit 0cde5fd456
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 192 additions and 171 deletions

View file

@ -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

View file

@ -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',
}),
};

View file

@ -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,

View file

@ -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<void>;
getOpenModal: () => Promise<OverlayStart['openModal']>;
getSyntaxHelpDocsLink: () => string;
getVariablesHelpDocsLink: () => string;
}
@ -112,13 +110,10 @@ export class UrlDrilldown implements Drilldown<Config, UrlTrigger, ActionFactory
};
public readonly getHref = async (config: Config, context: ActionContext) =>
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<Config, UrlTrigger, ActionFactory
});
};
private buildRuntimeScope = async (
context: ActionContext,
opts: { allowPrompts: boolean } = { allowPrompts: false }
) => {
private buildRuntimeScope = (context: ActionContext) => {
return urlDrilldownBuildScope({
globalScope: this.deps.getGlobalScope(),
contextScope: getContextScope(context),
eventScope: await getEventScope(context, this.deps, opts),
eventScope: getEventScope(context),
});
};
}

View file

@ -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();
});
});
});

View file

@ -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<OverlayStart['openModal']> },
opts: { allowPrompts: boolean } = { allowPrompts: false }
): Promise<UrlDrilldownEventScope> {
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> {
): 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<OverlayStart['openModal']> },
opts: { allowPrompts: boolean } = { allowPrompts: false }
): Promise<ValueClickTriggerEventScope> {
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<ValueClickTriggerEventScope, 'key' | 'value'> {
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<T extends Record<string, any>>(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<OverlayStart['openModal']> },
opts: { allowPrompts: boolean } = { allowPrompts: false }
): Promise<ValueClickContext['data']['data'][0]> {
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(
<GetSingleValuePopup
onCancel={() => 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 (
<React.Fragment>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
id="xpack.embeddableEnhanced.drilldowns.pickSingleValuePopup.popupHeader"
defaultMessage="Select a value to drill down into"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiRadioGroup
options={values}
idSelected={selectedValueId}
onChange={(id) => setSelectedValueId(id)}
name="drilldownValues"
/>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel}>
<FormattedMessage
id="xpack.embeddableEnhanced.drilldowns.pickSingleValuePopup.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<EuiButton
onClick={() => onSubmit(values.find((v) => v.id === selectedValueId)?.point!)}
data-test-subj="applySingleValuePopoverButton"
fill
>
<FormattedMessage
id="xpack.embeddableEnhanced.drilldowns.pickSingleValuePopup.applyButtonLabel"
defaultMessage="Apply"
/>
</EuiButton>
</EuiModalFooter>
</React.Fragment>
);
}

View file

@ -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: () =>