[maps] support URL drilldowns (#83732)

* url drilldowns

* onSingleValueTrigger

* cleanup

* tslint

* revert changes to ui_actions_service

* remove unused method added to es_tooltip_property

* remove unused file

* update drilldown docs to reflect maps supports URL drilldowns

* add functional test case for URL drilldown

* do not show URL drilldowns in geometry filter action selection

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2020-11-20 15:35:55 -07:00 committed by GitHub
parent 21995e6c5f
commit 658ecb6c8a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 163 additions and 51 deletions

View file

@ -3,7 +3,7 @@
== Create custom dashboard actions
Custom dashboard actions, also known as drilldowns, allow you to create
workflows for analyzing and troubleshooting your data. Drilldowns apply only to the panel that you created the drilldown from, and are not shared across all of the panels. Each panel can have multiple drilldowns.
workflows for analyzing and troubleshooting your data. Drilldowns apply only to the panel that you created the drilldown from, and are not shared across all of the panels. Each panel can have multiple drilldowns.
Third-party developers can create drilldowns. To learn how to code drilldowns, refer to {kib-repo}blob/{branch}/x-pack/examples/ui_actions_enhanced_examples[this example plugin].
@ -28,7 +28,7 @@ Dashboard drilldowns enable you to open a dashboard from another dashboard,
taking the time range, filters, and other parameters with you,
so the context remains the same. Dashboard drilldowns help you to continue your analysis from a new perspective.
For example, if you have a dashboard that shows the overall status of multiple data center,
For example, if you have a dashboard that shows the overall status of multiple data center,
you can create a drilldown that navigates from the overall status dashboard to a dashboard
that shows a single data center or server.
@ -41,14 +41,14 @@ Destination URLs can be dynamic, depending on the dashboard context or user inte
For example, if you have a dashboard that shows data from a Github repository, you can create a URL drilldown
that opens Github from the dashboard.
Some panels support multiple interactions, also known as triggers.
Some panels support multiple interactions, also known as triggers.
The <<variables,variables>> you use to create a <<url_templating, URL template>> depends on the trigger you choose. URL drilldowns support these types of triggers:
* *Single click* &mdash; A single data point in the visualization.
* *Range selection* &mdash; A range of values in a visualization.
For example, *Single click* has `{{event.value}}` and *Range selection* has `{{event.from}}` and `{{event.to}}`.
For example, *Single click* has `{{event.value}}` and *Range selection* has `{{event.from}}` and `{{event.to}}`.
To disable URL drilldowns on your {kib} instance, disable the plugin:
@ -77,20 +77,20 @@ The following panels support dashboard and URL drilldowns.
^| X
| Controls
^|
^|
^|
^|
| Data Table
^| X
^| X
| Gauge
^|
^|
^|
^|
| Goal
^|
^|
^|
^|
| Heat map
^| X
@ -106,15 +106,15 @@ The following panels support dashboard and URL drilldowns.
| Maps
^| X
^|
^| X
| Markdown
^|
^|
^|
^|
| Metric
^|
^|
^|
^|
| Pie
^| X
@ -122,7 +122,7 @@ The following panels support dashboard and URL drilldowns.
| TSVB
^| X
^|
^|
| Tag Cloud
^| X
@ -130,11 +130,11 @@ The following panels support dashboard and URL drilldowns.
| Timelion
^| X
^|
^|
| Vega
^| X
^|
^|
| Vertical Bar
^| X
@ -192,7 +192,7 @@ image::images/drilldown_create.png[Create drilldown with entries for drilldown n
. Click *Create drilldown*.
+
The drilldown is stored as dashboard metadata.
The drilldown is stored as dashboard metadata.
. Save the dashboard.
+
@ -226,7 +226,7 @@ image:images/url_drilldown_go_to_github.gif[Drilldown on pie chart that navigate
.. Select *Go to URL*.
.. Enter the URL template:
.. Enter the URL template:
+
[source, bash]
----
@ -240,7 +240,7 @@ image:images/url_drilldown_url_template.png[URL template input]
.. Click *Create drilldown*.
+
The drilldown is stored as dashboard metadata.
The drilldown is stored as dashboard metadata.
. Save the dashboard.
+

View file

@ -8,6 +8,7 @@ import React, { Component } from 'react';
import { EuiFormRow, EuiSuperSelect, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public';
import { isUrlDrilldown } from '../trigger_actions/trigger_utils';
interface Props {
value?: string;
@ -41,7 +42,7 @@ export class ActionSelect extends Component<Props, State> {
}
const actions = await this.props.getFilterActions();
if (this._isMounted) {
this.setState({ actions });
this.setState({ actions: actions.filter((action) => !isUrlDrilldown(action)) });
}
}

View file

@ -23,7 +23,7 @@ import { LayerPanel } from '../layer_panel';
import { AddLayerPanel } from '../add_layer_panel';
import { ExitFullScreenButton } from '../../../../../../src/plugins/kibana_react/public';
import { getIndexPatternsFromIds } from '../../index_pattern_util';
import { ES_GEO_FIELD_TYPE } from '../../../common/constants';
import { ES_GEO_FIELD_TYPE, RawValue } from '../../../common/constants';
import { indexPatterns as indexPatternsUtils } from '../../../../../../src/plugins/data/public';
import { FLYOUT_STATE } from '../../reducers/ui';
import { MapSettingsPanel } from '../map_settings_panel';
@ -40,6 +40,7 @@ interface Props {
backgroundColor: string;
getFilterActions?: () => Promise<Action[]>;
getActionContext?: () => ActionExecutionContext;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void;
areLayersLoaded: boolean;
cancelAllInFlightRequests: () => void;
exitFullScreen: () => void;
@ -191,6 +192,7 @@ export class MapContainer extends Component<Props, State> {
addFilters,
getFilterActions,
getActionContext,
onSingleValueTrigger,
flyoutDisplay,
isFullScreen,
exitFullScreen,
@ -250,6 +252,7 @@ export class MapContainer extends Component<Props, State> {
addFilters={addFilters}
getFilterActions={getFilterActions}
getActionContext={getActionContext}
onSingleValueTrigger={onSingleValueTrigger}
geoFields={this.state.geoFields}
renderTooltipContent={renderTooltipContent}
/>

View file

@ -15,6 +15,7 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../../../src/plugins/data/public';
import { isUrlDrilldown } from '../../../trigger_actions/trigger_utils';
export class FeatureProperties extends React.Component {
state = {
@ -114,21 +115,37 @@ export class FeatureProperties extends React.Component {
_renderFilterActions(tooltipProperty) {
const panel = {
id: 0,
items: this.state.actions.map((action) => {
const actionContext = this.props.getActionContext();
const iconType = action.getIconType(actionContext);
const name = action.getDisplayName(actionContext);
return {
name,
icon: iconType ? <EuiIcon type={iconType} /> : null,
onClick: async () => {
this.props.onCloseTooltip();
const filters = await tooltipProperty.getESFilters();
this.props.addFilters(filters, action.id);
},
['data-test-subj']: `mapFilterActionButton__${name}`,
};
}),
items: this.state.actions
.filter((action) => {
if (isUrlDrilldown(action)) {
return !!this.props.onSingleValueTrigger;
}
return true;
})
.map((action) => {
const actionContext = this.props.getActionContext();
const iconType = action.getIconType(actionContext);
const name = action.getDisplayName(actionContext);
return {
name: name ? name : action.id,
icon: iconType ? <EuiIcon type={iconType} /> : null,
onClick: async () => {
this.props.onCloseTooltip();
if (isUrlDrilldown(action)) {
this.props.onSingleValueTrigger(
action.id,
tooltipProperty.getPropertyKey(),
tooltipProperty.getRawValue()
);
} else {
const filters = await tooltipProperty.getESFilters();
this.props.addFilters(filters, action.id);
}
},
['data-test-subj']: `mapFilterActionButton__${name}`,
};
}),
};
return (

View file

@ -183,6 +183,7 @@ export class FeaturesTooltip extends Component {
addFilters={this.props.addFilters}
getFilterActions={this.props.getFilterActions}
getActionContext={this.props.getActionContext}
onSingleValueTrigger={this.props.onSingleValueTrigger}
showFilterActions={this._showFilterActionsView}
/>
{this._renderActions(geoFields)}

View file

@ -323,6 +323,7 @@ export class MBMap extends React.Component {
addFilters={this.props.addFilters}
getFilterActions={this.props.getFilterActions}
getActionContext={this.props.getActionContext}
onSingleValueTrigger={this.props.onSingleValueTrigger}
geoFields={this.props.geoFields}
renderTooltipContent={this.props.renderTooltipContent}
/>

View file

@ -201,6 +201,7 @@ export class TooltipControl extends React.Component {
addFilters={this.props.addFilters}
getFilterActions={this.props.getFilterActions}
getActionContext={this.props.getActionContext}
onSingleValueTrigger={this.props.onSingleValueTrigger}
renderTooltipContent={this.props.renderTooltipContent}
geoFields={this.props.geoFields}
features={features}

View file

@ -119,6 +119,7 @@ export class TooltipPopover extends Component {
addFilters: this.props.addFilters,
getFilterActions: this.props.getFilterActions,
getActionContext: this.props.getActionContext,
onSingleValueTrigger: this.props.onSingleValueTrigger,
closeTooltip: this.props.closeTooltip,
features: this.props.features,
isLocked: this.props.isLocked,

View file

@ -18,6 +18,7 @@ import {
import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../src/plugins/data/public';
import {
APPLY_FILTER_TRIGGER,
VALUE_CLICK_TRIGGER,
ActionExecutionContext,
TriggerContextMapping,
} from '../../../../../src/plugins/ui_actions/public';
@ -57,6 +58,7 @@ import {
getExistingMapPath,
MAP_SAVED_OBJECT_TYPE,
MAP_PATH,
RawValue,
} from '../../common/constants';
import { RenderToolTipContent } from '../classes/tooltips/tooltip_property';
import { getUiActions, getCoreI18n, getHttp } from '../kibana_services';
@ -65,6 +67,7 @@ import { MapContainer } from '../connected_components/map_container';
import { SavedMap } from '../routes/map_page';
import { getIndexPatternsFromIds } from '../index_pattern_util';
import { getMapAttributeService } from '../map_attribute_service';
import { isUrlDrilldown, toValueClickDataFormat } from '../trigger_actions/trigger_utils';
import {
MapByValueInput,
@ -202,7 +205,7 @@ export class MapEmbeddable
}
public supportedTriggers(): Array<keyof TriggerContextMapping> {
return [APPLY_FILTER_TRIGGER];
return [APPLY_FILTER_TRIGGER, VALUE_CLICK_TRIGGER];
}
setRenderTooltipContent = (renderTooltipContent: RenderToolTipContent) => {
@ -290,6 +293,7 @@ export class MapEmbeddable
<Provider store={this._savedMap.getStore()}>
<I18nContext>
<MapContainer
onSingleValueTrigger={this.onSingleValueTrigger}
addFilters={this.input.hideFilterActions ? null : this.addFilters}
getFilterActions={this.getFilterActions}
getActionContext={this.getActionContext}
@ -320,6 +324,20 @@ export class MapEmbeddable
return await getIndexPatternsFromIds(queryableIndexPatternIds);
}
onSingleValueTrigger = (actionId: string, key: string, value: RawValue) => {
const action = getUiActions().getAction(actionId);
if (!action) {
throw new Error('Unable to apply action, could not locate action');
}
const executeContext = {
...this.getActionContext(),
data: {
data: toValueClickDataFormat(key, value),
},
};
action.execute(executeContext);
};
addFilters = async (filters: Filter[], actionId: string = ACTION_GLOBAL_APPLY_FILTER) => {
const executeContext = {
...this.getActionContext(),
@ -333,10 +351,24 @@ export class MapEmbeddable
};
getFilterActions = async () => {
return await getUiActions().getTriggerCompatibleActions(APPLY_FILTER_TRIGGER, {
const filterActions = await getUiActions().getTriggerCompatibleActions(APPLY_FILTER_TRIGGER, {
embeddable: this,
filters: [],
});
const valueClickActions = await getUiActions().getTriggerCompatibleActions(
VALUE_CLICK_TRIGGER,
{
embeddable: this,
data: {
// uiActions.getTriggerCompatibleActions validates action with provided context
// so if event.key and event.value are used in the URL template but can not be parsed from context
// then the action is filtered out.
// To prevent filtering out actions, provide dummy context when initially fetching actions.
data: toValueClickDataFormat('anyfield', 'anyvalue'),
},
}
);
return [...filterActions, ...valueClickActions.filter(isUrlDrilldown)];
};
getActionContext = () => {

View file

@ -0,0 +1,45 @@
/*
* 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 { Action } from 'src/plugins/ui_actions/public';
import { RawValue } from '../../common/constants';
import { DatatableColumnType } from '../../../../../src/plugins/expressions';
export function isUrlDrilldown(action: Action) {
// @ts-expect-error
return action.type === 'URL_DRILLDOWN';
}
// VALUE_CLICK_TRIGGER is coupled with expressions and Datatable type
// URL drilldown parses event scope from Datatable
// https://github.com/elastic/kibana/blob/7.10/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts#L140
// In order to use URL drilldown, maps has to package its data in Datatable compatiable format.
export function toValueClickDataFormat(key: string, value: RawValue) {
return [
{
table: {
columns: [
{
id: key,
meta: {
type: 'unknown' as DatatableColumnType, // type is not used by URL drilldown to parse event but is required by DatatableColumnMeta
field: key,
},
name: key,
},
],
rows: [
{
[key]: value,
},
],
},
column: 0,
row: 0,
value,
},
];
}

View file

@ -7,7 +7,7 @@
import expect from '@kbn/expect';
export default function ({ getPageObjects, getService }) {
const PageObjects = getPageObjects(['common', 'dashboard', 'maps']);
const PageObjects = getPageObjects(['common', 'dashboard', 'discover', 'maps']);
const kibanaServer = getService('kibanaServer');
const testSubjects = getService('testSubjects');
const filterBar = getService('filterBar');
@ -48,16 +48,11 @@ export default function ({ getPageObjects, getService }) {
});
describe('panel actions', () => {
before(async () => {
beforeEach(async () => {
await loadDashboardAndOpenTooltip();
});
it('should display more actions button when tooltip is locked', async () => {
const exists = await testSubjects.exists('mapTooltipMoreActionsButton');
expect(exists).to.be(true);
});
it('should trigger drilldown action when clicked', async () => {
it('should trigger dashboard drilldown action when clicked', async () => {
await testSubjects.click('mapTooltipMoreActionsButton');
await testSubjects.click('mapFilterActionButton__drilldown1');
@ -69,6 +64,16 @@ export default function ({ getPageObjects, getService }) {
const hasJoinFilter = await filterBar.hasFilter('shape_name', 'charlie');
expect(hasJoinFilter).to.be(true);
});
it('should trigger url drilldown action when clicked', async () => {
await testSubjects.click('mapTooltipMoreActionsButton');
await testSubjects.click('mapFilterActionButton__urlDrilldownToDiscover');
// Assert on discover with filter from action
await PageObjects.discover.waitForDiscoverAppOnScreen();
const hasFilter = await filterBar.hasFilter('name', 'charlie');
expect(hasFilter).to.be(true);
});
});
});
}

View file

@ -1113,7 +1113,7 @@
"title" : "dash for tooltip filter action test",
"hits" : 0,
"description" : "Zoomed in so entire screen is covered by filter so click to open tooltip can not miss.",
"panelsJSON" : "[{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":26,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"mapCenter\":{\"lat\":-1.31919,\"lon\":59.53306,\"zoom\":9.67},\"isLayerTOCOpen\":false,\"openTOCDetails\":[\"n1t6f\"],\"hiddenLayers\":[],\"enhancements\":{\"dynamicActions\":{\"events\":[{\"eventId\":\"669a3521-1215-4228-9ced-77e2edf5ad17\",\"triggers\":[\"FILTER_TRIGGER\"],\"action\":{\"name\":\"drilldown1\",\"config\":{\"dashboardId\":\"19906970-2e40-11e9-85cb-6965aae20f13\",\"useCurrentFilters\":true,\"useCurrentDateRange\":true},\"factoryId\":\"DASHBOARD_TO_DASHBOARD_DRILLDOWN\"}}]}}},\"panelRefName\":\"panel_0\"}]",
"panelsJSON" : "[{\"version\":\"8.0.0\",\"gridData\":{\"x\":0,\"y\":0,\"w\":48,\"h\":26,\"i\":\"1\"},\"panelIndex\":\"1\",\"embeddableConfig\":{\"mapCenter\":{\"lat\":-1.31919,\"lon\":59.53306,\"zoom\":9.67},\"isLayerTOCOpen\":false,\"openTOCDetails\":[\"n1t6f\"],\"hiddenLayers\":[],\"enhancements\":{\"dynamicActions\":{\"events\":[{\"eventId\":\"669a3521-1215-4228-9ced-77e2edf5ad17\",\"triggers\":[\"FILTER_TRIGGER\"],\"action\":{\"name\":\"drilldown1\",\"config\":{\"useCurrentFilters\":true,\"useCurrentDateRange\":true},\"factoryId\":\"DASHBOARD_TO_DASHBOARD_DRILLDOWN\"}},{\"eventId\":\"b9c20d96-03ce-4dcc-8823-e3503311172e\",\"triggers\":[\"VALUE_CLICK_TRIGGER\"],\"action\":{\"name\":\"urlDrilldownToDiscover\",\"config\":{\"url\":{\"template\":\"{{kibanaUrl}}/app/discover#/?_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'561253e0-f731-11e8-8487-11b9dd924f96',key:{{event.key}},negate:!f,params:(query:{{event.value}}),type:phrase),query:(match_phrase:({{event.key}}:{{event.value}})))),index:'561253e0-f731-11e8-8487-11b9dd924f96',interval:auto,query:(language:kuery,query:''),sort:!())\"},\"openInNewTab\":false},\"factoryId\":\"URL_DRILLDOWN\"}}]}}},\"panelRefName\":\"panel_0\"}]",
"optionsJSON" : "{\"useMargins\":true,\"hidePanelTitles\":false}",
"version" : 1,
"timeRestore" : true,
@ -1129,6 +1129,11 @@
},
"type" : "dashboard",
"references" : [
{
"name" : "drilldown:DASHBOARD_TO_DASHBOARD_DRILLDOWN:669a3521-1215-4228-9ced-77e2edf5ad17:dashboardId",
"type" : "dashboard",
"id" : "19906970-2e40-11e9-85cb-6965aae20f13"
},
{
"name" : "panel_0",
"type" : "map",
@ -1136,9 +1141,9 @@
}
],
"migrationVersion" : {
"dashboard" : "7.3.0"
"dashboard" : "7.11.0"
},
"updated_at" : "2020-08-26T14:32:27.854Z"
"updated_at" : "2020-11-19T15:12:25.703Z"
}
}
}