From d1ece89f01ce5ba8fc03d3735a734457b980a2f0 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 20 Nov 2020 19:18:23 -0700 Subject: [PATCH] [maps] support URL drilldowns (#83732) (#83997) * 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> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/user/dashboard/drilldowns.asciidoc | 42 ++++++++--------- .../maps/public/components/action_select.tsx | 3 +- .../map_container/map_container.tsx | 5 +- .../features_tooltip/feature_properties.js | 47 +++++++++++++------ .../features_tooltip/features_tooltip.js | 1 + .../connected_components/mb_map/mb_map.js | 1 + .../mb_map/tooltip_control/tooltip_control.js | 1 + .../mb_map/tooltip_control/tooltip_popover.js | 1 + .../maps/public/embeddable/map_embeddable.tsx | 36 +++++++++++++- .../public/trigger_actions/trigger_utils.ts | 45 ++++++++++++++++++ .../maps/embeddable/tooltip_filter_actions.js | 21 +++++---- .../es_archives/maps/kibana/data.json | 11 +++-- 12 files changed, 163 insertions(+), 51 deletions(-) create mode 100644 x-pack/plugins/maps/public/trigger_actions/trigger_utils.ts diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index ca788020d92862..1b9896d7dea565 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -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 <> you use to create a <> depends on the trigger you choose. URL drilldowns support these types of triggers: * *Single click* — A single data point in the visualization. * *Range selection* — 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. + diff --git a/x-pack/plugins/maps/public/components/action_select.tsx b/x-pack/plugins/maps/public/components/action_select.tsx index ad61a6a129974c..8ea9334bba7533 100644 --- a/x-pack/plugins/maps/public/components/action_select.tsx +++ b/x-pack/plugins/maps/public/components/action_select.tsx @@ -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 { } const actions = await this.props.getFilterActions(); if (this._isMounted) { - this.setState({ actions }); + this.setState({ actions: actions.filter((action) => !isUrlDrilldown(action)) }); } } diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index 955a229d421a34..9a5110a0c24d24 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -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; 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 { addFilters, getFilterActions, getActionContext, + onSingleValueTrigger, flyoutDisplay, isFullScreen, exitFullScreen, @@ -250,6 +252,7 @@ export class MapContainer extends Component { addFilters={addFilters} getFilterActions={getFilterActions} getActionContext={getActionContext} + onSingleValueTrigger={onSingleValueTrigger} geoFields={this.state.geoFields} renderTooltipContent={renderTooltipContent} /> diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js index edd501f2666907..97b47358ec089c 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/feature_properties.js @@ -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 ? : 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 ? : 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 ( diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js index 8547219b42e30d..60d9e57d15e23a 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/features_tooltip/features_tooltip.js @@ -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)} diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.js b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.js index 04c376a093623b..0ea40f6e3182f4 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.js @@ -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} /> diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.js b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.js index b178eef6fa5d39..c5c3ad4d78f7e7 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.js @@ -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} diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.js b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.js index ca4864f79940ee..4983e394ed93cc 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.js +++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_popover.js @@ -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, diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index caf21431145d56..7aaabc427790af 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -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 { - return [APPLY_FILTER_TRIGGER]; + return [APPLY_FILTER_TRIGGER, VALUE_CLICK_TRIGGER]; } setRenderTooltipContent = (renderTooltipContent: RenderToolTipContent) => { @@ -290,6 +293,7 @@ export class MapEmbeddable { + 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 = () => { diff --git a/x-pack/plugins/maps/public/trigger_actions/trigger_utils.ts b/x-pack/plugins/maps/public/trigger_actions/trigger_utils.ts new file mode 100644 index 00000000000000..3505588a9c0497 --- /dev/null +++ b/x-pack/plugins/maps/public/trigger_actions/trigger_utils.ts @@ -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, + }, + ]; +} diff --git a/x-pack/test/functional/apps/maps/embeddable/tooltip_filter_actions.js b/x-pack/test/functional/apps/maps/embeddable/tooltip_filter_actions.js index 10754d20118e9b..d612a3776d2115 100644 --- a/x-pack/test/functional/apps/maps/embeddable/tooltip_filter_actions.js +++ b/x-pack/test/functional/apps/maps/embeddable/tooltip_filter_actions.js @@ -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); + }); }); }); } diff --git a/x-pack/test/functional/es_archives/maps/kibana/data.json b/x-pack/test/functional/es_archives/maps/kibana/data.json index 79e8c14cc39827..71b4a85d63f089 100644 --- a/x-pack/test/functional/es_archives/maps/kibana/data.json +++ b/x-pack/test/functional/es_archives/maps/kibana/data.json @@ -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" } } }