mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
cbf2844d64
commit
0cde5fd456
7 changed files with 192 additions and 171 deletions
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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: () =>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue