From ae2e7f6b8a63f519ed3a8969ea1ae1d6c9b2a6cb Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Fri, 21 Aug 2020 13:17:42 +0200 Subject: [PATCH 01/12] url drilldown mvp --- .../public/triggers/select_range_trigger.ts | 2 +- .../public/triggers/value_click_trigger.ts | 2 +- .../dashboard_to_url_drilldown/index.tsx | 141 -------- .../public/plugin.ts | 2 - .../flyout_create_drilldown.tsx | 1 + .../flyout_edit_drilldown.tsx | 1 + .../plugins/embeddable_enhanced/kibana.json | 3 +- .../public/drilldowns/url_drilldown/README.md | 21 ++ .../url_drilldown/url_drilldown.test.ts | 166 +++++++++ .../url_drilldown/url_drilldown.tsx | 156 +++++++++ .../url_drilldown/url_drilldown_scope.tsx | 326 ++++++++++++++++++ .../embeddable_enhanced/public/plugin.ts | 15 +- .../action_wizard/action_wizard.tsx | 17 +- .../public/components/action_wizard/i18n.ts | 11 +- .../public/drilldowns/url_drilldown/README.md | 1 + .../url_drilldown/components/index.ts | 7 + .../__tests__/demo.tsx | 47 +++ .../url_drilldown_collect_config/i18n.ts | 59 ++++ .../url_drilldown_collect_config/index.scss | 5 + .../url_drilldown_collect_config.story.tsx | 11 + .../url_drilldown_collect_config.test.tsx | 45 +++ .../url_drilldown_collect_config.tsx | 196 +++++++++++ .../public/drilldowns/url_drilldown/index.ts | 18 + .../public/drilldowns/url_drilldown/types.ts | 30 ++ .../url_drilldown_global_scope.ts | 20 ++ .../url_drilldown/url_drilldown_scope.test.ts | 52 +++ .../url_drilldown/url_drilldown_scope.ts | 31 ++ .../url_drilldown/url_template.test.ts | 133 +++++++ .../drilldowns/url_drilldown/url_template.ts | 75 ++++ .../url_drilldown/url_validation.test.ts | 87 +++++ .../url_drilldown/url_validation.ts | 71 ++++ .../ui_actions_enhanced/public/index.ts | 1 + .../ui_actions_enhanced/scripts/storybook.js | 5 +- 33 files changed, 1600 insertions(+), 158 deletions(-) delete mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx create mode 100644 x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/README.md create mode 100644 x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts create mode 100644 x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx create mode 100644 x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/README.md create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/__tests__/demo.tsx create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.scss create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.story.tsx create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_global_scope.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts diff --git a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts index f6d5547f62481..312e75314bd92 100644 --- a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts @@ -27,6 +27,6 @@ export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = { defaultMessage: 'Range selection', }), description: i18n.translate('uiActions.triggers.selectRangeDescription', { - defaultMessage: 'Select a group of values', + defaultMessage: 'A range of values on the visualization', }), }; 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 e1e7b6507d82b..e63ff28f42d96 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 clicked on a visualization', + defaultMessage: 'A single point on the visualization', }), }; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx deleted file mode 100644 index 7d915ea23c66f..0000000000000 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/* - * 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 React from 'react'; -import { EuiCallOut, EuiFieldText, EuiFormRow, EuiSpacer, EuiSwitch } from '@elastic/eui'; -import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; -import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; -import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; -import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; -import { - SELECT_RANGE_TRIGGER, - VALUE_CLICK_TRIGGER, -} from '../../../../../src/plugins/ui_actions/public'; -import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public'; - -function isValidUrl(url: string) { - try { - new URL(url); - return true; - } catch { - return false; - } -} - -export type ActionContext = ChartActionContext; - -export interface Config { - url: string; - openInNewTab: boolean; -} - -type UrlTrigger = typeof VALUE_CLICK_TRIGGER | typeof SELECT_RANGE_TRIGGER; - -export type CollectConfigProps = CollectConfigPropsBase; - -const SAMPLE_DASHBOARD_TO_URL_DRILLDOWN = 'SAMPLE_DASHBOARD_TO_URL_DRILLDOWN'; - -export class DashboardToUrlDrilldown implements Drilldown { - public readonly id = SAMPLE_DASHBOARD_TO_URL_DRILLDOWN; - - public readonly order = 8; - - readonly minimalLicense = 'gold'; // example of minimal license support - - public readonly getDisplayName = () => 'Go to URL (example)'; - - public readonly euiIcon = 'link'; - - supportedTriggers(): UrlTrigger[] { - return [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER]; - } - - private readonly ReactCollectConfig: React.FC = ({ - config, - onConfig, - context, - }) => ( - <> - -

- This is an example drilldown. It is meant as a starting point for developers, so they can - grab this code and get started. It does not provide a complete working functionality but - serves as a getting started example. -

-

- Implementation of the actual Go to URL drilldown is tracked in{' '} - #55324 -

-
- - - onConfig({ ...config, url: event.target.value })} - onBlur={() => { - if (!config.url) return; - if (/https?:\/\//.test(config.url)) return; - onConfig({ ...config, url: 'https://' + config.url }); - }} - /> - - - onConfig({ ...config, openInNewTab: !config.openInNewTab })} - /> - - - - {/* just demo how can access selected triggers*/} -

Will be attached to triggers: {JSON.stringify(context.triggers)}

-
- - ); - - public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); - - public readonly createConfig = () => ({ - url: '', - openInNewTab: false, - }); - - public readonly isConfigValid = (config: Config): config is Config => { - if (!config.url) return false; - return isValidUrl(config.url); - }; - - /** - * `getHref` is need to support mouse middle-click and Cmd + Click behavior - * to open a link in new tab. - */ - public readonly getHref = async (config: Config, context: ActionContext) => { - return config.url; - }; - - public readonly execute = async ( - config: Config, - context: ActionExecutionContext - ) => { - // Just for showcasing: - // we can get trigger a which caused this drilldown execution - // eslint-disable-next-line no-console - console.log(context.trigger?.id); - - const url = await this.getHref(config, context); - - if (config.openInNewTab) { - window.open(url, '_blank'); - } else { - window.location.href = url; - } - }; -} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts index 7f2c9a9b3bbc8..3f0b64a2ac9ed 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts @@ -11,7 +11,6 @@ import { AdvancedUiActionsStart, } from '../../../../x-pack/plugins/ui_actions_enhanced/public'; import { DashboardHelloWorldDrilldown } from './dashboard_hello_world_drilldown'; -import { DashboardToUrlDrilldown } from './dashboard_to_url_drilldown'; import { DashboardToDiscoverDrilldown } from './dashboard_to_discover_drilldown'; import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; import { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public'; @@ -39,7 +38,6 @@ export class UiActionsEnhancedExamplesPlugin uiActions.registerDrilldown(new DashboardHelloWorldDrilldown()); uiActions.registerDrilldown(new DashboardHelloWorldOnlyRangeSelectDrilldown()); - uiActions.registerDrilldown(new DashboardToUrlDrilldown()); uiActions.registerDrilldown(new DashboardToDiscoverDrilldown({ start })); } diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx index b5e5e248eaeb1..b5bbda64a62e8 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -80,6 +80,7 @@ export class FlyoutCreateDrilldownAction implements ActionByType ), { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx index 6dfda93db7155..2bafa9269f24d 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -64,6 +64,7 @@ export class FlyoutEditDrilldownAction implements ActionByType ), { diff --git a/x-pack/plugins/embeddable_enhanced/kibana.json b/x-pack/plugins/embeddable_enhanced/kibana.json index 5663671de7bd9..acada946fe0d1 100644 --- a/x-pack/plugins/embeddable_enhanced/kibana.json +++ b/x-pack/plugins/embeddable_enhanced/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": ["embeddable", "uiActionsEnhanced"] + "requiredPlugins": ["embeddable", "kibanaReact", "uiActions", "uiActionsEnhanced"], + "requiredBundles": ["kibanaUtils"] } diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/README.md b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/README.md new file mode 100644 index 0000000000000..1b0b173973112 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/README.md @@ -0,0 +1,21 @@ +# Basic url drilldown implementation + +Url drilldown allows navigating to external URL or to internal kibana URL. +By using variables in url template result url can be dynamic and depend on user's interaction. + +URL drilldown has 3 sources for variables: + +- Global static variables like, for example, `kibanaUrl`. Such variables won’t change depending on a place where url drilldown is used. +- Context variables are dynamic and different depending on where drilldown is created and used. For example: +- Event variables depend on a trigger context. These variables are dynamically extracted from the action context when drilldown is executed. + +In current implementation url drilldown has to be used inside the embeddable and with `ValueClickTrigger` or `RangeSelectTrigger`. + +- `context` variables extracted from `embeddable` +- `event` variables extracted from `trigger` context + +In future this basic url drilldown implementation would allow injecting more variables into `context` (e.g. `dashboard` app specific variables) and would allow providing support for new trigger types from outside. +This extensibility improvements are tracked here: https://github.com/elastic/kibana/issues/55324 + +In case a solution app has a use case for url drilldown that has to be different from current basic implementation and +just extending variables list is not enough, then recommendation is to create own custom url drilldown and reuse building blocks from `ui_actions_enhanced`. 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 new file mode 100644 index 0000000000000..6a11663ea6c3d --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts @@ -0,0 +1,166 @@ +/* + * 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 { UrlDrilldown, ActionContext, Config } from './url_drilldown'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public/lib/embeddables'; + +const mockDataPoints = [ + { + table: { + columns: [ + { + name: 'test', + id: '1-1', + meta: { + type: 'histogram', + indexPatternId: 'logstash-*', + aggConfigParams: { + field: 'bytes', + interval: 30, + otherBucket: true, + }, + }, + }, + ], + rows: [ + { + '1-1': '2048', + }, + ], + }, + column: 0, + row: 0, + value: 'test', + }, +]; + +const mockEmbeddable = ({ + getInput: () => ({ + filters: [], + timeRange: { from: 'now-15m', to: 'now' }, + query: { query: 'test', language: 'kuery' }, + }), + getOutput: () => ({}), +} as unknown) as IEmbeddable; + +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', + navigateToUrl: mockNavigateToUrl, + }); + + test('license', () => { + expect(urlDrilldown.minimalLicense).toBe('gold'); + }); + + describe('isCompatible', () => { + test('throws if no embeddable', async () => { + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + }; + + await expect(urlDrilldown.isCompatible(config, context)).rejects.toThrowError(); + }); + + test('compatible if url is valid', async () => { + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(true); + }); + + test('not compatible if url is invalid', async () => { + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.somethingFake}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(false); + }); + }); + + describe('getHref & execute', () => { + beforeEach(() => { + mockNavigateToUrl.mockReset(); + }); + + test('valid url', async () => { + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + const url = await urlDrilldown.getHref(config, context); + expect(url).toMatchInlineSnapshot(`"https://elasti.co/?test&(language:kuery,query:test)"`); + + await urlDrilldown.execute(config, context); + expect(mockNavigateToUrl).toBeCalledWith(url); + }); + + test('invalid url', async () => { + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.invalid}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + await expect(urlDrilldown.getHref(config, context)).rejects.toThrowError(); + await expect(urlDrilldown.execute(config, context)).rejects.toThrowError(); + expect(mockNavigateToUrl).not.toBeCalled(); + }); + }); +}); 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 new file mode 100644 index 0000000000000..6786236040f29 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx @@ -0,0 +1,156 @@ +/* + * 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 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'; +import { + SELECT_RANGE_TRIGGER, + VALUE_CLICK_TRIGGER, +} from '../../../../../../src/plugins/ui_actions/public'; +import { + UiActionsEnhancedDrilldownDefinition as Drilldown, + UrlDrilldownGlobalScope, + UrlDrilldownConfig, + UrlDrilldownCollectConfig, + urlDrilldownValidateUrlTemplate, + urlDrilldownBuildScope, + urlDrilldownCompileUrl, + UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, +} from '../../../../ui_actions_enhanced/public'; +import { getContextScope, getEventScope, getMockEventScope } from './url_drilldown_scope'; + +interface UrlDrilldownDeps { + getGlobalScope: () => UrlDrilldownGlobalScope; + navigateToUrl: (url: string) => Promise; + getOpenModal: () => Promise; + getSyntaxHelpDocsLink: () => string; +} + +export type ActionContext = ChartActionContext; +export type Config = UrlDrilldownConfig; +export type UrlTrigger = typeof VALUE_CLICK_TRIGGER | typeof SELECT_RANGE_TRIGGER; +export interface ActionFactoryContext extends BaseActionFactoryContext { + embeddable?: IEmbeddable; +} +export type CollectConfigProps = CollectConfigPropsBase; + +const URL_DRILLDOWN = 'URL_DRILLDOWN'; + +export class UrlDrilldown implements Drilldown { + public readonly id = URL_DRILLDOWN; + + constructor(private deps: UrlDrilldownDeps) {} + + public readonly order = 8; + + readonly minimalLicense = 'gold'; + + public readonly getDisplayName = () => 'Go to URL'; + + public readonly euiIcon = 'link'; + + supportedTriggers(): UrlTrigger[] { + return [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER]; + } + + private readonly ReactCollectConfig: React.FC = ({ + config, + onConfig, + context, + }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const scope = React.useMemo( + () => + urlDrilldownBuildScope({ + globalScope: this.deps.getGlobalScope(), + contextScope: getContextScope(context), + eventScope: getMockEventScope(context.triggers), + }), + [context] + ); + + return ( + + ); + }; + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + url: { template: '' }, + openInNewTab: false, + }); + + public readonly isConfigValid = ( + config: Config, + context: ActionFactoryContext + ): config is Config => { + const { isValid } = urlDrilldownValidateUrlTemplate( + config.url, + urlDrilldownBuildScope({ + globalScope: this.deps.getGlobalScope(), + contextScope: getContextScope(context), + eventScope: getMockEventScope(context.triggers), + }) + ); + return isValid; + }; + + public readonly isCompatible = async (config: Config, context: ActionContext) => { + const { isValid, error } = urlDrilldownValidateUrlTemplate( + config.url, + urlDrilldownBuildScope({ + globalScope: this.deps.getGlobalScope(), + contextScope: getContextScope(context), + eventScope: await getEventScope(context, this.deps), + }) + ); + + if (!isValid) { + // eslint-disable-next-line no-console + console.warn( + `UrlDrilldown [${config.url.template}] is not valid. Error [${error}]. Skipping execution.` + ); + } + + return Promise.resolve(isValid); + }; + + public readonly getHref = async (config: Config, context: ActionContext) => { + return urlDrilldownCompileUrl( + config.url.template, + urlDrilldownBuildScope({ + globalScope: this.deps.getGlobalScope(), + contextScope: getContextScope(context), + eventScope: await getEventScope(context, this.deps), + }) + ); + }; + + public readonly execute = async (config: Config, context: ActionContext) => { + const url = await urlDrilldownCompileUrl( + config.url.template, + urlDrilldownBuildScope({ + globalScope: this.deps.getGlobalScope(), + contextScope: getContextScope(context), + eventScope: await getEventScope(context, this.deps, { allowPrompts: true }), + }) + ); + if (config.openInNewTab) { + window.open(url, '_blank', 'noopener'); + } else { + await this.deps.navigateToUrl(url); + } + }; +} 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.tsx new file mode 100644 index 0000000000000..66d07dcb82c39 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx @@ -0,0 +1,326 @@ +/* + * 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. + */ + +/** + * This file contains all the logic for mapping from trigger's context and environment context to variables for URL drilldown scope + * URL drilldown has 3 sources for variables: + * + * - Global static variables like, for example, `kibanaUrl`. Such variables won’t change depending on a place where url drilldown is used. + * - Context variables are dynamic and different depending on where drilldown is created and used. For example: + * - Event variables depend on a trigger context. These variables are dynamically extracted from the action context when drilldown is executed. + * + * At this point context variables are extracted from `embeddable` and event variables are extracted from trigger's context. + * Only 2 triggers are supported at this point: VALUE_CLICK_TRIGGER and RANGE_SELECT_TRIGGER + * + * In future URL drilldown implementation will provide extension points + * for injecting more variables into context and to support more triggers + * https://github.com/elastic/kibana/issues/55324 + * + * For now possible variables and logic for extracting those are hardcoded and all contained in this file. + */ + +import React from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiRadioGroup, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { Query, Filter, TimeRange } from '../../../../../../src/plugins/data/public'; +import { + IEmbeddable, + isRangeSelectTriggerContext, + isValueClickTriggerContext, + RangeSelectContext, + ValueClickContext, +} 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; + +/** + * Part of context scope extracted from an embeddable + * Expose on the scope as: `{{context.panel.id}}` `{{context.panel.filters.[0]}}` + */ +interface EmbeddableUrlDrilldownContextScope { + id: string; + title?: string; + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; + savedObjectId?: string; + /** + * In case panel supports only 1 index patterns + */ + indexPatternId?: string; + /** + * In case panel supports more then 1 index patterns + */ + indexPatternIds?: string[]; +} + +/** + * Url drilldown context scope + * `{{context.$}}` + */ +interface UrlDrilldownContextScope { + panel?: EmbeddableUrlDrilldownContextScope; +} + +export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilldownContextScope { + if (!hasEmbeddable(contextScopeInput)) + throw new Error( + "UrlDrilldown [getContextScope] can't build scope because embeddable object is missing in context" + ); + + const embeddable = contextScopeInput.embeddable; + const input = embeddable.getInput(); + const output = embeddable.getOutput(); + function isSavedObjectId(savedObjectId: unknown): savedObjectId is string { + return typeof savedObjectId === 'string'; + } + function getIndexPatternIds(): string[] { + function hasIndexPatterns( + _output: Record + ): _output is { indexPatterns: Array<{ id?: string }> } { + return ( + 'indexPatterns' in _output && + Array.isArray(_output.indexPatterns) && + _output.indexPatterns.length > 0 + ); + } + return hasIndexPatterns(output) + ? (output.indexPatterns.map((ip) => ip.id).filter(Boolean) as string[]) + : []; + } + const indexPatternsIds = getIndexPatternIds(); + return { + panel: cleanEmptyKeys({ + id: input.id, + title: output.title ?? input.title, + savedObjectId: + output.savedObjectId ?? + (isSavedObjectId(input.savedObjectId) ? input.savedObjectId : undefined), + query: input.query, + timeRange: input.timeRange, + filters: input.filters, + indexPatternIds: indexPatternsIds.length > 1 ? indexPatternsIds : undefined, + indexPatternId: indexPatternsIds.length === 1 ? indexPatternsIds[0] : undefined, + }), + }; +} + +function hasEmbeddable(val: unknown): val is { embeddable: IEmbeddable } { + if (val && typeof val === 'object' && 'embeddable' in val) return true; + return false; +} + +/** + * URL drilldown event scope, + * available as: {{event.key}}, {{event.from}} + */ +type UrlDrilldownEventScope = ValueClickTriggerEventScope | RangeSelectTriggerEventScope; +type EventScopeInput = ActionContext; +interface ValueClickTriggerEventScope { + key: string; + value: string; + negate: boolean; +} + +interface RangeSelectTriggerEventScope { + key: string; + from: string; + to: string; +} + +export async function getEventScope( + eventScopeInput: EventScopeInput, + deps: { getOpenModal: () => Promise }, + opts: { allowPrompts: boolean } = { allowPrompts: false } +): Promise { + if (isRangeSelectTriggerContext(eventScopeInput)) { + return getEventScopeFromRangeSelectTriggerContext(eventScopeInput); + } else if (isValueClickTriggerContext(eventScopeInput)) { + return getEventScopeFromValueClickTriggerContext(eventScopeInput, deps, opts); + } else { + throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger"); + } +} + +async function getEventScopeFromRangeSelectTriggerContext( + eventScopeInput: RangeSelectContext +): Promise { + const { table, column: columnIndex, range } = eventScopeInput.data; + const column = table.columns[columnIndex]; + return cleanEmptyKeys({ + key: toStringOrUndefined(column?.meta?.aggConfigParams?.field)!, + from: toStringOrUndefined(range[0])!, + to: toStringOrUndefined(range[range.length - 1])!, + }); +} + +async function getEventScopeFromValueClickTriggerContext( + eventScopeInput: ValueClickContext, + deps: { getOpenModal: () => Promise }, + opts: { allowPrompts: boolean } = { allowPrompts: false } +): Promise { + const negate = eventScopeInput.data.negate ?? false; + const { table, column: columnIndex, value } = await getSingleValue( + eventScopeInput.data.data, + deps, + opts + ); + const column = table.columns[columnIndex]; + return cleanEmptyKeys({ + key: toStringOrUndefined(column?.meta?.aggConfigParams?.field)!, + value: toStringOrUndefined(value)!, + negate, + }); +} + +/** + * @remarks + * Difference between `event` and `context` variables, is that real `context` variables are available during drilldown creation (e.g. embeddable panel) + * `event` variables are mapped from trigger context. Since there is no trigger context during drilldown creation, we have to provide some _mock_ variables for validating and previewing the URL + */ +export function getMockEventScope([trigger]: UrlTrigger[]): UrlDrilldownEventScope { + if (trigger === SELECT_RANGE_TRIGGER) { + return { + key: '__testKey__', + from: new Date(Date.now() - 15 * 60 * 1000).toISOString(), // 15 minutes ago + to: new Date().toISOString(), + }; + } else { + return { + key: '__testKey__', + value: '__testValue__', + negate: false, + }; + } +} + +function toStringOrUndefined(v: unknown): string | undefined { + if (typeof v === 'string') return v; + if (typeof v === 'object' && v instanceof Date) return v.toISOString(); + return typeof v === 'undefined' ? v : String(v); +} + +function cleanEmptyKeys>(obj: T): T { + Object.keys(obj).forEach((key) => { + if (obj[key] === undefined) { + delete obj[key]; + } + }); + 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 = data.filter(Boolean); + 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 { table, column: columnIndex, value } = point; + const column = table.columns[columnIndex]; + return { + point, + id: toStringOrUndefined(column?.meta?.aggConfigParams?.field)!, + label: `${toStringOrUndefined(column?.meta?.aggConfigParams?.field)!}:${toStringOrUndefined( + 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 fd0bcc2023269..82e40fac73cc1 100644 --- a/x-pack/plugins/embeddable_enhanced/public/plugin.ts +++ b/x-pack/plugins/embeddable_enhanced/public/plugin.ts @@ -28,8 +28,11 @@ import { UiActionsEnhancedDynamicActionManager as DynamicActionManager, AdvancedUiActionsSetup, AdvancedUiActionsStart, + urlDrilldownGlobalScopeProvider, } from '../../ui_actions_enhanced/public'; import { PanelNotificationsAction, ACTION_PANEL_NOTIFICATIONS } from './actions'; +import { UrlDrilldown } from './drilldowns/url_drilldown/url_drilldown'; +import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; declare module '../../../../src/plugins/ui_actions/public' { export interface ActionContextMapping { @@ -61,11 +64,21 @@ export class EmbeddableEnhancedPlugin public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { this.setCustomEmbeddableFactoryProvider(plugins); - + const startServices = createStartServicesGetter(core.getStartServices); const panelNotificationAction = new PanelNotificationsAction(); plugins.uiActionsEnhanced.registerAction(panelNotificationAction); plugins.uiActionsEnhanced.attachAction(PANEL_NOTIFICATION_TRIGGER, panelNotificationAction.id); + plugins.uiActionsEnhanced.registerDrilldown( + new UrlDrilldown({ + 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.drilldowns, // TODO: replace with docs https://github.com/elastic/kibana/issues/69414 + }) + ); + return {}; } diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx index 7e4fe1de8be8d..dad885e2596d9 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx @@ -21,7 +21,12 @@ import { EuiLink, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { txtChangeButton, txtTriggerPickerHelpText, txtTriggerPickerLabel } from './i18n'; +import { + txtChangeButton, + txtTriggerPickerHelpText, + txtTriggerPickerLabel, + txtTriggerPickerHelpTooltip, +} from './i18n'; import './action_wizard.scss'; import { ActionFactory, BaseActionFactoryContext } from '../../dynamic_actions'; import { Trigger, TriggerId } from '../../../../../../src/plugins/ui_actions/public'; @@ -162,9 +167,11 @@ const TriggerPicker: React.FC = ({
{txtTriggerPickerLabel}{' '} - - {txtTriggerPickerHelpText} - + + + {txtTriggerPickerHelpText} + +
), @@ -271,7 +278,7 @@ const SelectedActionFactory: React.FC = ({ /> )} - +
{ + const [config, onConfig] = React.useState({ + openInNewTab: false, + url: { template: '' }, + }); + + const fakeScope: UrlDrilldownScope = { + kibanaUrl: 'http://localhost:5601/', + context: { + filters: [ + { + query: { match: { extension: { query: 'jpg', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + }, + { + query: { match: { '@tags': { query: 'info', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + }, + { + query: { match: { _type: { query: 'nginx', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + }, + ], + }, + event: { + key: 'fakeKey', + value: 'fakeValue', + }, + }; + + return ( + <> + + {JSON.stringify(config)} + + ); +}; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts new file mode 100644 index 0000000000000..84352e2c500f3 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts @@ -0,0 +1,59 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const txtUrlTemplatePlaceholder = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplatePlaceholderText', + { + defaultMessage: 'Example: {exampleUrl}', + values: { + exampleUrl: 'https://www.my-url.com/?{{event.key}}={{event.value}}', + }, + } +); + +export const txtAddVariableButtonTitle = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle', + { + defaultMessage: 'Add variable', + } +); + +export const txtUrlTemplateLabel = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel', + { + defaultMessage: 'Enter URL template:', + } +); + +export const txtUrlTemplateHelpLinkText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateHelpLinkText', + { + defaultMessage: 'Syntax help', + } +); + +export const txtUrlTemplatePreviewLabel = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel', + { + defaultMessage: 'URL preview:', + } +); + +export const txtUrlTemplatePreviewLinkText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLinkText', + { + defaultMessage: 'Preview', + } +); + +export const txtUrlTemplateOpenInNewTab = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.openInNewTabLabel', + { + defaultMessage: 'Open in new tab', + } +); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.scss b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.scss new file mode 100644 index 0000000000000..475c3f2a915ea --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.scss @@ -0,0 +1,5 @@ +.uaeUrlDrilldownCollectConfig__urlTemplateFormRow { + .euiFormRow__label { + align-self: flex-end; + } +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.story.tsx new file mode 100644 index 0000000000000..bc5b370bef2d6 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.story.tsx @@ -0,0 +1,11 @@ +/* + * 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 React from 'react'; +import { storiesOf } from '@storybook/react'; +import { Demo } from './__tests__/demo'; + +storiesOf('UrlDrilldownCollectConfig', module).add('default', () => ); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx new file mode 100644 index 0000000000000..d49021f661a94 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx @@ -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 { Demo } from './__tests__/demo'; +import { cleanup, fireEvent, render } from '@testing-library/react/pure'; +import React from 'react'; + +afterEach(cleanup); + +test('configure valid URL template', () => { + const screen = render(); + + const urlTemplate = 'https://elastic.co/?{{event.key}}={{event.value}}'; + fireEvent.change(screen.getByLabelText(/Enter URL template/i), { + target: { value: urlTemplate }, + }); + + const preview = screen.getByLabelText(/URL preview/i) as HTMLTextAreaElement; + expect(preview.value).toMatchInlineSnapshot(`"https://elastic.co/?fakeKey=fakeValue"`); + expect(preview.disabled).toEqual(true); + const previewLink = screen.getByText('Preview') as HTMLAnchorElement; + expect(previewLink.href).toMatchInlineSnapshot(`"https://elastic.co/?fakeKey=fakeValue"`); + expect(previewLink.target).toMatchInlineSnapshot(`"_blank"`); +}); + +test('configure invalid URL template', () => { + const screen = render(); + + const urlTemplate = 'https://elastic.co/?{{event.wrongKey}}={{event.wrongValue}}'; + fireEvent.change(screen.getByLabelText(/Enter URL template/i), { + target: { value: urlTemplate }, + }); + + const previewTextArea = screen.getByLabelText(/URL preview/i) as HTMLTextAreaElement; + expect(previewTextArea.disabled).toEqual(true); + expect(previewTextArea.value).toEqual(urlTemplate); + expect(screen.getByText(/invalid format/i)).toBeInTheDocument(); // check that error is shown + + const previewLink = screen.getByText('Preview') as HTMLAnchorElement; + expect(previewLink.href).toEqual(urlTemplate); + expect(previewLink.target).toMatchInlineSnapshot(`"_blank"`); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx new file mode 100644 index 0000000000000..7b7164d67e12a --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx @@ -0,0 +1,196 @@ +/* + * 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 React, { useRef, useState } from 'react'; +import { + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFormRow, + EuiLink, + EuiPopover, + EuiSwitch, + EuiText, + EuiTextArea, +} from '@elastic/eui'; +import { UrlDrilldownConfig, UrlDrilldownScope } from '../../types'; +import { compile } from '../../url_template'; +import { validateUrlTemplate } from '../../url_validation'; +import { buildScopeSuggestions } from '../../url_drilldown_scope'; +import './index.scss'; +import { + txtAddVariableButtonTitle, + txtUrlTemplateHelpLinkText, + txtUrlTemplateLabel, + txtUrlTemplateOpenInNewTab, + txtUrlTemplatePlaceholder, + txtUrlTemplatePreviewLabel, + txtUrlTemplatePreviewLinkText, +} from './i18n'; + +export interface UrlDrilldownCollectConfig { + config: UrlDrilldownConfig; + onConfig: (newConfig: UrlDrilldownConfig) => void; + scope: UrlDrilldownScope; + syntaxHelpDocsLink?: string; +} + +export const UrlDrilldownCollectConfig: React.FC = ({ + config, + onConfig, + scope, + syntaxHelpDocsLink, +}: UrlDrilldownCollectConfig) => { + const textAreaRef = useRef(null); + const urlTemplate = config.url.template ?? ''; + const compiledUrl = React.useMemo(() => { + try { + return compile(urlTemplate, scope); + } catch { + return urlTemplate; + } + }, [urlTemplate, scope]); + const scopeVariables = React.useMemo(() => buildScopeSuggestions(scope), [scope]); + + function updateUrlTemplate(newUrlTemplate: string) { + if (config.url.template !== newUrlTemplate) { + onConfig({ + ...config, + url: { + ...config.url, + template: newUrlTemplate, + }, + }); + } + } + const { error, isValid } = React.useMemo( + () => validateUrlTemplate({ template: urlTemplate }, scope), + [urlTemplate, scope] + ); + const isEmpty = !urlTemplate; + const isInvalid = !isValid && !isEmpty; + return ( + <> + + {txtUrlTemplateHelpLinkText} + + ) + } + labelAppend={ + { + if (textAreaRef.current) { + updateUrlTemplate( + urlTemplate.substr(0, textAreaRef.current!.selectionStart) + + `{{${variable}}}` + + urlTemplate.substr(textAreaRef.current!.selectionEnd) + ); + } else { + updateUrlTemplate(urlTemplate + `{{${variable}}}`); + } + }} + /> + } + > + updateUrlTemplate(event.target.value)} + rows={3} + inputRef={textAreaRef} + /> + + + + {txtUrlTemplatePreviewLinkText} + + + } + > + + + + onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + + + ); +}; + +function AddVariableButton({ + variables, + onSelect, +}: { + variables: string[]; + onSelect: (variable: string) => void; +}) { + const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState(false); + + const renderVariables = () => + variables.map((variable: string) => ( + { + onSelect(variable); + setIsVariablesPopoverOpen(false); + }} + > + {`{{${variable}}}`} + + )); + + return ( + { + setIsVariablesPopoverOpen(true); + }} + iconType="indexOpen" + title={txtAddVariableButtonTitle} + aria-label={txtAddVariableButtonTitle} + /> + } + isOpen={isVariablesPopoverOpen} + closePopover={() => setIsVariablesPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downLeft" + withTitle + > + + + ); +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts new file mode 100644 index 0000000000000..7b7a850050c41 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export { UrlDrilldownConfig, UrlDrilldownGlobalScope, UrlDrilldownScope } from './types'; +export { UrlDrilldownCollectConfig } from './components'; +export { + validateUrlTemplate as urlDrilldownValidateUrlTemplate, + validateUrl as urlDrilldownValidateUrl, +} from './url_validation'; +export { compile as urlDrilldownCompileUrl } from './url_template'; +export { globalScopeProvider as urlDrilldownGlobalScopeProvider } from './url_drilldown_global_scope'; +export { + buildScope as urlDrilldownBuildScope, + buildScopeSuggestions as urlDrilldownBuildScopeSuggestions, +} from './url_drilldown_scope'; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts new file mode 100644 index 0000000000000..a73f236331f49 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts @@ -0,0 +1,30 @@ +/* + * 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. + */ + +export interface UrlDrilldownConfig { + url: { format?: 'mustache_v1'; template: string }; + openInNewTab: boolean; +} + +/** + * URL drilldown has 3 sources for variables: global, context and event variables + 1. Global - static variables like, for example, kibanaInstanceId or kibanaBaseUrl. Such variables won’t change depending on a place where url drilldown is used. + 2. Context - variables are dynamic and different depending on where drilldown is created and used. For example: + Variables from embeddable. Like embeddableId, embeddableTitle, embeddableType, filters, query, etc… + Variables from dashboard (if used inside dashboard app). Like dashboardId or dashboardTitle. + 3. Event - variables depend on a trigger context. These variables are dynamically extracted from the trigger context when drilldown is executed. + */ +export interface UrlDrilldownScope< + ContextScope extends object = object, + EventScope extends object = object +> extends UrlDrilldownGlobalScope { + context?: ContextScope; + event?: EventScope; +} + +export interface UrlDrilldownGlobalScope { + kibanaUrl: string; +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_global_scope.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_global_scope.ts new file mode 100644 index 0000000000000..afc7fa590a2f0 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_global_scope.ts @@ -0,0 +1,20 @@ +/* + * 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 { CoreSetup } from 'kibana/public'; +import { UrlDrilldownGlobalScope } from './types'; + +interface UrlDrilldownGlobalScopeDeps { + core: CoreSetup; +} + +export function globalScopeProvider({ + core, +}: UrlDrilldownGlobalScopeDeps): () => UrlDrilldownGlobalScope { + return () => ({ + kibanaUrl: window.location.origin + core.http.basePath.get(), + }); +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts new file mode 100644 index 0000000000000..9d3df521ab8e5 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts @@ -0,0 +1,52 @@ +/* + * 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 { buildScope, buildScopeSuggestions } from './url_drilldown_scope'; + +test('buildScopeSuggestions', () => { + expect( + buildScopeSuggestions( + buildScope({ + globalScope: { + kibanaUrl: 'http://localhost:5061/', + }, + eventScope: { + key: '__testKey__', + value: '__testValue__', + }, + contextScope: { + filters: [ + { + query: { match: { extension: { query: 'jpg', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + }, + { + query: { match: { '@tags': { query: 'info', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + }, + { + query: { match: { _type: { query: 'nginx', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + }, + ], + query: { + query: '', + language: 'kquery', + }, + }, + }) + ) + ).toMatchInlineSnapshot(` + Array [ + "kibanaUrl", + "context.filters", + "context.query.query", + "context.query.language", + "event.key", + "event.value", + ] + `); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts new file mode 100644 index 0000000000000..ba792c868919c --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts @@ -0,0 +1,31 @@ +/* + * 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 { UrlDrilldownGlobalScope, UrlDrilldownScope } from './types'; +import { getFlattenedObject } from '../../../../../../src/core/public'; + +export function buildScope< + ContextScope extends object = object, + EventScope extends object = object +>({ + globalScope, + contextScope, + eventScope, +}: { + globalScope: UrlDrilldownGlobalScope; + contextScope?: ContextScope; + eventScope?: EventScope; +}): UrlDrilldownScope { + return { + ...globalScope, + context: contextScope, + event: eventScope, + }; +} + +export function buildScopeSuggestions(scope: UrlDrilldownGlobalScope): string[] { + return Object.keys(getFlattenedObject(scope)); +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts new file mode 100644 index 0000000000000..67255b6f4cfb3 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts @@ -0,0 +1,133 @@ +/* + * 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 { compile } from './url_template'; +import moment from 'moment-timezone'; + +test('should compile url without variables', () => { + const url = 'https://elastic.co'; + expect(compile(url, {})).toBe(url); +}); + +test('should fail on unknown syntax', () => { + const url = 'https://elastic.co/{{}'; + expect(() => compile(url, {})).toThrowError(); +}); + +test('should fail on not existing variable', () => { + const url = 'https://elastic.co/{{fake}}'; + expect(() => compile(url, {})).toThrowError(); +}); + +test('should fail on not existing nested variable', () => { + const url = 'https://elastic.co/{{fake.fake}}'; + expect(() => compile(url, { fake: {} })).toThrowError(); +}); + +test('should replace existing variable', () => { + const url = 'https://elastic.co/{{foo}}'; + expect(compile(url, { foo: 'bar' })).toMatchInlineSnapshot(`"https://elastic.co/bar"`); +}); + +test('should fail on unknown helper', () => { + const url = 'https://elastic.co/{{fake foo}}'; + expect(() => compile(url, { foo: 'bar' })).toThrowError(); +}); + +describe('json helper', () => { + test('should replace with json', () => { + const url = 'https://elastic.co/{{json foo bar}}'; + expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( + `"https://elastic.co/%5B%7B%22foo%22:%22bar%22%7D,%7B%22bar%22:%22foo%22%7D%5D"` + ); + }); + test('should replace with json and skip encoding', () => { + const url = 'https://elastic.co/{{{json foo bar}}}'; + expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( + `"https://elastic.co/%5B%7B%22foo%22:%22bar%22%7D,%7B%22bar%22:%22foo%22%7D%5D"` + ); + }); + test('should throw on unknown key', () => { + const url = 'https://elastic.co/{{{json fake}}}'; + expect(() => compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toThrowError(); + }); +}); + +describe('rison helper', () => { + test('should replace with rison', () => { + const url = 'https://elastic.co/{{rison foo bar}}'; + expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( + `"https://elastic.co/!((foo:bar),(bar:foo))"` + ); + }); + test('should replace with rison and skip encoding', () => { + const url = 'https://elastic.co/{{{rison foo bar}}}'; + expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( + `"https://elastic.co/!((foo:bar),(bar:foo))"` + ); + }); + test('should throw on unknown key', () => { + const url = 'https://elastic.co/{{{rison fake}}}'; + expect(() => compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toThrowError(); + }); +}); + +describe('date helper', () => { + let spy: jest.SpyInstance; + const date = new Date('2020-08-18T14:45:00.000Z'); + beforeAll(() => { + spy = jest.spyOn(global.Date, 'now').mockImplementation(() => date.valueOf()); + moment.tz.setDefault('UTC'); + }); + afterAll(() => { + spy.mockRestore(); + moment.tz.setDefault('Browser'); + }); + + test('uses datemath', () => { + const url = 'https://elastic.co/{{date time}}'; + expect(compile(url, { time: 'now' })).toMatchInlineSnapshot( + `"https://elastic.co/2020-08-18T14:45:00.000Z"` + ); + }); + + test('can use format', () => { + const url = 'https://elastic.co/{{date time "dddd, MMMM Do YYYY, h:mm:ss a"}}'; + expect(compile(url, { time: 'now' })).toMatchInlineSnapshot( + `"https://elastic.co/Tuesday,%20August%2018th%202020,%202:45:00%20pm"` + ); + }); + + test('throws if missing variable', () => { + const url = 'https://elastic.co/{{date time}}'; + expect(() => compile(url, {})).toThrowError(); + }); + + test("doesn't throw if non valid date", () => { + const url = 'https://elastic.co/{{date time}}'; + expect(compile(url, { time: 'fake' })).toMatchInlineSnapshot(`"https://elastic.co/fake"`); + }); + + test('works with ISO string', () => { + const url = 'https://elastic.co/{{date time}}'; + expect(compile(url, { time: date.toISOString() })).toMatchInlineSnapshot( + `"https://elastic.co/2020-08-18T14:45:00.000Z"` + ); + }); + + test('works with ts', () => { + const url = 'https://elastic.co/{{date time}}'; + expect(compile(url, { time: date.valueOf() })).toMatchInlineSnapshot( + `"https://elastic.co/2020-08-18T14:45:00.000Z"` + ); + }); + test('works with ts string', () => { + const url = 'https://elastic.co/{{date time}}'; + expect(compile(url, { time: String(date.valueOf()) })).toMatchInlineSnapshot( + `"https://elastic.co/2020-08-18T14:45:00.000Z"` + ); + }); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.ts new file mode 100644 index 0000000000000..2c3537636b9da --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.ts @@ -0,0 +1,75 @@ +/* + * 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 { create as createHandlebars, HelperDelegate, HelperOptions } from 'handlebars'; +import { encode, RisonValue } from 'rison-node'; +import dateMath from '@elastic/datemath'; +import moment, { Moment } from 'moment'; + +const handlebars = createHandlebars(); + +function createSerializationHelper( + fnName: string, + serializeFn: (value: unknown) => string +): HelperDelegate { + return (...args) => { + const { hash } = args.slice(-1)[0] as HelperOptions; + const hasHash = Object.keys(hash).length > 0; + const hasValues = args.length > 1; + if (hasHash && hasValues) { + throw new Error(`[${fnName}]: both value list and hash are not supported`); + } + if (hasHash) { + if (Object.values(hash).some((v) => typeof v === 'undefined')) + throw new Error(`[${fnName}]: unknown variable`); + return serializeFn(hash); + } else { + const values = args.slice(0, -1) as unknown[]; + if (values.some((value) => typeof value === 'undefined')) + throw new Error(`[${fnName}]: unknown variable`); + if (values.length === 0) throw new Error(`[${fnName}]: unknown variable`); + if (values.length === 1) return serializeFn(values[0]); + return serializeFn(values); + } + }; +} + +handlebars.registerHelper('json', createSerializationHelper('json', JSON.stringify)); +handlebars.registerHelper( + 'rison', + createSerializationHelper('rison', (v) => encode(v as RisonValue)) +); + +handlebars.registerHelper('date', (...args) => { + const values = args.slice(0, -1) as [string | Date, string | undefined]; + // eslint-disable-next-line prefer-const + let [date, format] = values; + if (typeof date === 'undefined') throw new Error(`[date]: unknown variable`); + let momentDate: Moment | undefined; + if (typeof date === 'string') { + momentDate = dateMath.parse(date); + if (!momentDate || !momentDate.isValid()) { + const ts = Number(date); + if (!Number.isNaN(ts)) { + momentDate = moment(ts); + } + } + } else { + momentDate = moment(date); + } + + if (!momentDate || !momentDate.isValid()) { + // do not throw error here, because it could be that in preview `__testValue__` is not parsable, + // but in runtime it is + return date; + } + return format ? momentDate.format(format) : momentDate.toISOString(); +}); + +export function compile(url: string, context: object): string { + const template = handlebars.compile(url, { strict: true, noEscape: true }); + return encodeURI(template(context)); +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts new file mode 100644 index 0000000000000..cb6f4a28402d1 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts @@ -0,0 +1,87 @@ +/* + * 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 { validateUrl, validateUrlTemplate } from './url_validation'; + +describe('validateUrl', () => { + describe('unsafe urls', () => { + const unsafeUrls = [ + // eslint-disable-next-line no-script-url + 'javascript:evil()', + // eslint-disable-next-line no-script-url + 'JavaScript:abc', + 'evilNewProtocol:abc', + ' \n Java\n Script:abc', + 'javascript:', + 'javascript:', + 'j avascript:', + 'javascript:', + 'javascript:', + 'jav ascript:alert();', + // 'jav\u0000ascript:alert();', CI fails on this one + 'data:;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', + 'data:,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', + 'data:iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', + 'data:text/javascript;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', + 'data:application/x-msdownload;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', + ]; + + for (const url of unsafeUrls) { + test(`unsafe ${url}`, () => { + expect(validateUrl(url).isValid).toBe(false); + }); + } + }); + + describe('invalid urls', () => { + const invalidUrls = ['elastic.co', 'www.elastic.co', 'test', '', ' ', 'https://']; + for (const url of invalidUrls) { + test(`invalid ${url}`, () => { + expect(validateUrl(url).isValid).toBe(false); + }); + } + }); + + describe('valid urls', () => { + const validUrls = [ + 'https://elastic.co', + 'https://www.elastic.co', + 'http://elastic', + 'mailto:someone', + ]; + for (const url of validUrls) { + test(`valid ${url}`, () => { + expect(validateUrl(url).isValid).toBe(true); + }); + } + }); +}); + +describe('validateUrlTemplate', () => { + test('domain in variable is allowed', () => { + expect( + validateUrlTemplate( + { template: '{{kibanaUrl}}/test' }, + { kibanaUrl: 'http://localhost:5601/app' } + ).isValid + ).toBe(true); + }); + + test('unsafe domain in variable is not allowed', () => { + expect( + // eslint-disable-next-line no-script-url + validateUrlTemplate({ template: '{{kibanaUrl}}/test' }, { kibanaUrl: 'javascript:evil()' }) + .isValid + ).toBe(false); + }); + + test('if missing variable then invalid', () => { + expect( + validateUrlTemplate({ template: '{{url}}/test' }, { kibanaUrl: 'http://localhost:5601/app' }) + .isValid + ).toBe(false); + }); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts new file mode 100644 index 0000000000000..b32f5d84c6775 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts @@ -0,0 +1,71 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { UrlDrilldownConfig, UrlDrilldownScope } from './types'; +import { compile } from './url_template'; + +const generalFormatError = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage', + { + defaultMessage: 'Invalid format. Example: {exampleUrl}', + values: { + exampleUrl: 'https://www.my-url.com/?{{event.key}}={{event.value}}', + }, + } +); + +const formatError = (message: string) => + i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage', + { + defaultMessage: 'Invalid format: {message}', + values: { + message, + }, + } + ); + +const SAFE_URL_PATTERN = /^(?:(?:https?|mailto):|[^&:/?#]*(?:[/?#]|$))/gi; +export function validateUrl(url: string): { isValid: boolean; error?: string } { + if (!url) + return { + isValid: false, + error: generalFormatError, + }; + + try { + new URL(url); + if (!url.match(SAFE_URL_PATTERN)) throw new Error(); + return { isValid: true }; + } catch (e) { + return { + isValid: false, + error: generalFormatError, + }; + } +} + +export function validateUrlTemplate( + urlTemplate: UrlDrilldownConfig['url'], + scope: UrlDrilldownScope +): { isValid: boolean; error?: string } { + if (!urlTemplate.template) + return { + isValid: false, + error: generalFormatError, + }; + + try { + const compiledUrl = compile(urlTemplate.template, scope); + return validateUrl(compiledUrl); + } catch (e) { + return { + isValid: false, + error: formatError(e.message), + }; + } +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/index.ts b/x-pack/plugins/ui_actions_enhanced/public/index.ts index a255bc28f5c68..4a899b24852a9 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/index.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/index.ts @@ -32,3 +32,4 @@ export { } from './dynamic_actions'; export { DrilldownDefinition as UiActionsEnhancedDrilldownDefinition } from './drilldowns'; +export * from './drilldowns/url_drilldown'; diff --git a/x-pack/plugins/ui_actions_enhanced/scripts/storybook.js b/x-pack/plugins/ui_actions_enhanced/scripts/storybook.js index 1e3ab0d96b81c..bf43167a3ae51 100644 --- a/x-pack/plugins/ui_actions_enhanced/scripts/storybook.js +++ b/x-pack/plugins/ui_actions_enhanced/scripts/storybook.js @@ -9,8 +9,5 @@ import { join } from 'path'; // eslint-disable-next-line require('@kbn/storybook').runStorybookCli({ name: 'ui_actions_enhanced', - storyGlobs: [ - join(__dirname, '..', 'public', 'components', '**', '*.story.tsx'), - join(__dirname, '..', 'public', 'drilldowns', 'components', '**', '*.story.tsx'), - ], + storyGlobs: [join(__dirname, '..', 'public', '**', '*.story.tsx')], }); From 20ba75d5b547a5be5a7d00804201f40cdc1954de Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Fri, 21 Aug 2020 15:24:55 +0200 Subject: [PATCH 02/12] Add functional test --- .../action_wizard/action_wizard.tsx | 1 + ...ts => dashboard_to_dashboard_drilldown.ts} | 2 +- .../drilldowns/dashboard_to_url_drilldown.ts | 96 +++++++++++++++++++ .../apps/dashboard/drilldowns/index.ts | 3 +- .../services/dashboard/drilldowns_manage.ts | 36 +++++++ 5 files changed, 136 insertions(+), 2 deletions(-) rename x-pack/test/functional/apps/dashboard/drilldowns/{dashboard_drilldowns.ts => dashboard_to_dashboard_drilldown.ts} (99%) create mode 100644 x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_url_drilldown.ts diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx index dad885e2596d9..6c474b94f696e 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx @@ -162,6 +162,7 @@ const TriggerPicker: React.FC = ({ const selectedTrigger = selectedTriggers ? selectedTriggers[0] : undefined; return ( diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_drilldowns.ts b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts similarity index 99% rename from x-pack/test/functional/apps/dashboard/drilldowns/dashboard_drilldowns.ts rename to x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts index 29ead0db1c634..c300412c393bc 100644 --- a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_drilldowns.ts +++ b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts @@ -22,7 +22,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); - describe('Dashboard Drilldowns', function () { + describe('Dashboard to dashboard drilldown', function () { before(async () => { log.debug('Dashboard Drilldowns:initTests'); await PageObjects.common.navigateToApp('dashboard'); diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_url_drilldown.ts b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_url_drilldown.ts new file mode 100644 index 0000000000000..12de29c4fde10 --- /dev/null +++ b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_url_drilldown.ts @@ -0,0 +1,96 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +const DRILLDOWN_TO_DISCOVER_URL = 'Go to discover'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const dashboardPanelActions = getService('dashboardPanelActions'); + const dashboardDrilldownPanelActions = getService('dashboardDrilldownPanelActions'); + const dashboardDrilldownsManage = getService('dashboardDrilldownsManage'); + const PageObjects = getPageObjects(['dashboard', 'common', 'header', 'timePicker', 'discover']); + const log = getService('log'); + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + + describe('Dashboard to URL drilldown', function () { + before(async () => { + log.debug('Dashboard to URL:initTests'); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + }); + + it('should create dashboard to URL drilldown and use it to navigate to discover', async () => { + await PageObjects.dashboard.gotoDashboardEditMode( + dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME + ); + + // create drilldown + await dashboardPanelActions.openContextMenu(); + await dashboardDrilldownPanelActions.expectExistsCreateDrilldownAction(); + await dashboardDrilldownPanelActions.clickCreateDrilldown(); + await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutOpen(); + + const urlTemplate = `{{kibanaUrl}}/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:'{{event.from}}',to:'{{event.to}}'))&_a=(columns:!(_source),filters:{{rison context.panel.filters}},index:'{{context.panel.indexPatternId}}',interval:auto,query:(language:{{context.panel.query.language}},query:'{{context.panel.query.query}}'),sort:!())`; + + await dashboardDrilldownsManage.fillInDashboardToURLDrilldownWizard({ + drilldownName: DRILLDOWN_TO_DISCOVER_URL, + destinationURLTemplate: urlTemplate, + trigger: 'SELECT_RANGE_TRIGGER', + }); + await dashboardDrilldownsManage.saveChanges(); + await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutClose(); + + // check that drilldown notification badge is shown + expect(await PageObjects.dashboard.getPanelDrilldownCount()).to.be(2); + + // save dashboard, navigate to view mode + await PageObjects.dashboard.saveDashboard( + dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME, + { + saveAsNew: false, + waitDialogIsClosed: true, + } + ); + + const originalTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); + + await brushAreaChart(); + await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened(); + await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_DISCOVER_URL); + + await PageObjects.discover.waitForDiscoverAppOnScreen(); + + // check that new time range duration was applied + const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); + expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours); + }); + }); + + // utils which shouldn't be a part of test flow, but also too specific to be moved to pageobject or service + async function brushAreaChart() { + const areaChart = await testSubjects.find('visualizationLoader'); + expect(await areaChart.getAttribute('data-title')).to.be('Visualization漢字 AreaChart'); + await browser.dragAndDrop( + { + location: areaChart, + offset: { + x: -100, + y: 0, + }, + }, + { + location: areaChart, + offset: { + x: 100, + y: 0, + }, + } + ); + } +} diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/index.ts b/x-pack/test/functional/apps/dashboard/drilldowns/index.ts index ff604b18e1d51..57454f50266da 100644 --- a/x-pack/test/functional/apps/dashboard/drilldowns/index.ts +++ b/x-pack/test/functional/apps/dashboard/drilldowns/index.ts @@ -22,7 +22,8 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { await esArchiver.unload('dashboard/drilldowns'); }); - loadTestFile(require.resolve('./dashboard_drilldowns')); + loadTestFile(require.resolve('./dashboard_to_dashboard_drilldown')); + loadTestFile(require.resolve('./dashboard_to_url_drilldown')); loadTestFile(require.resolve('./explore_data_panel_action')); // Disabled for now as it requires xpack.discoverEnhanced.actions.exploreDataInChart.enabled diff --git a/x-pack/test/functional/services/dashboard/drilldowns_manage.ts b/x-pack/test/functional/services/dashboard/drilldowns_manage.ts index a01fde3a5233f..7b66591fcf764 100644 --- a/x-pack/test/functional/services/dashboard/drilldowns_manage.ts +++ b/x-pack/test/functional/services/dashboard/drilldowns_manage.ts @@ -12,6 +12,8 @@ const DASHBOARD_TO_DASHBOARD_ACTION_LIST_ITEM = 'actionFactoryItem-DASHBOARD_TO_DASHBOARD_DRILLDOWN'; const DASHBOARD_TO_DASHBOARD_ACTION_WIZARD = 'selectedActionFactory-DASHBOARD_TO_DASHBOARD_DRILLDOWN'; +const DASHBOARD_TO_URL_ACTION_LIST_ITEM = 'actionFactoryItem-URL_DRILLDOWN'; +const DASHBOARD_TO_URL_ACTION_WIZARD = 'selectedActionFactory-URL_DRILLDOWN'; const DESTINATION_DASHBOARD_SELECT = 'dashboardDrilldownSelectDashboard'; const DRILLDOWN_WIZARD_SUBMIT = 'drilldownWizardSubmit'; @@ -68,10 +70,32 @@ export function DashboardDrilldownsManageProvider({ getService }: FtrProviderCon await this.selectDestinationDashboard(destinationDashboardTitle); } + async fillInDashboardToURLDrilldownWizard({ + drilldownName, + destinationURLTemplate, + trigger, + }: { + drilldownName: string; + destinationURLTemplate: string; + trigger: 'VALUE_CLICK_TRIGGER' | 'SELECT_RANGE_TRIGGER'; + }) { + await this.fillInDrilldownName(drilldownName); + await this.selectDashboardToURLActionIfNeeded(); + await this.selectTriggerIfNeeded(trigger); + await this.fillInURLTemplate(destinationURLTemplate); + } + async fillInDrilldownName(name: string) { await testSubjects.setValue('drilldownNameInput', name); } + async selectDashboardToURLActionIfNeeded() { + if (await testSubjects.exists(DASHBOARD_TO_URL_ACTION_LIST_ITEM)) { + await testSubjects.click(DASHBOARD_TO_URL_ACTION_LIST_ITEM); + } + await testSubjects.existOrFail(DASHBOARD_TO_URL_ACTION_WIZARD); + } + async selectDashboardToDashboardActionIfNeeded() { if (await testSubjects.exists(DASHBOARD_TO_DASHBOARD_ACTION_LIST_ITEM)) { await testSubjects.click(DASHBOARD_TO_DASHBOARD_ACTION_LIST_ITEM); @@ -83,6 +107,18 @@ export function DashboardDrilldownsManageProvider({ getService }: FtrProviderCon await comboBox.set(DESTINATION_DASHBOARD_SELECT, title); } + async selectTriggerIfNeeded(trigger: 'VALUE_CLICK_TRIGGER' | 'SELECT_RANGE_TRIGGER') { + if (await testSubjects.exists(`triggerPicker`)) { + const container = await testSubjects.find(`triggerPicker-${trigger}`); + const radio = await container.findByCssSelector('input[type=radio]'); + await radio.click(); + } + } + + async fillInURLTemplate(destinationURLTemplate: string) { + await testSubjects.setValue('urlInput', destinationURLTemplate); + } + async saveChanges() { await testSubjects.click(DRILLDOWN_WIZARD_SUBMIT); } From cdfd8f4c934ef9cbf22d09a26e2b16be4294070b Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Fri, 21 Aug 2020 16:23:41 +0200 Subject: [PATCH 03/12] rename test folder --- .../{__tests__ => test_samples}/demo.tsx | 0 .../url_drilldown_collect_config.story.tsx | 2 +- .../url_drilldown_collect_config.test.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/{__tests__ => test_samples}/demo.tsx (100%) diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/__tests__/demo.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx similarity index 100% rename from x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/__tests__/demo.tsx rename to x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.story.tsx index bc5b370bef2d6..244ea9bd2a97e 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.story.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.story.tsx @@ -6,6 +6,6 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { Demo } from './__tests__/demo'; +import { Demo } from './test_samples/demo'; storiesOf('UrlDrilldownCollectConfig', module).add('default', () => ); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx index d49021f661a94..f55818379ef3f 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Demo } from './__tests__/demo'; +import { Demo } from './test_samples/demo'; import { cleanup, fireEvent, render } from '@testing-library/react/pure'; import React from 'react'; From 9b723d197a3a073957091bf93c47684b4fc65f1d Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 25 Aug 2020 11:08:13 +0200 Subject: [PATCH 04/12] @mdefazio review --- .../url_drilldown_collect_config.tsx | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx index 7b7164d67e12a..7b6763522bc76 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx @@ -6,13 +6,13 @@ import React, { useRef, useState } from 'react'; import { - EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiFormRow, + EuiIcon, EuiLink, EuiPopover, - EuiSwitch, + EuiCheckbox, EuiText, EuiTextArea, } from '@elastic/eui'; @@ -137,7 +137,8 @@ export const UrlDrilldownCollectConfig: React.FC = ({ /> - { - setIsVariablesPopoverOpen(true); - }} - iconType="indexOpen" - title={txtAddVariableButtonTitle} - aria-label={txtAddVariableButtonTitle} - /> + + setIsVariablesPopoverOpen(true)}> + {txtAddVariableButtonTitle} + + } isOpen={isVariablesPopoverOpen} closePopover={() => setIsVariablesPopoverOpen(false)} From 8e40b6883210dcaef8be7e1170df0ec71e97662d Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 25 Aug 2020 12:40:01 +0200 Subject: [PATCH 05/12] fix ts, improve types --- .../url_drilldown/url_drilldown_scope.tsx | 50 +++++++++---------- .../url_drilldown/url_template.test.ts | 8 +++ 2 files changed, 31 insertions(+), 27 deletions(-) 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.tsx index 66d07dcb82c39..eff07446edbad 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.tsx @@ -78,6 +78,10 @@ interface UrlDrilldownContextScope { } export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilldownContextScope { + function hasEmbeddable(val: unknown): val is { embeddable: IEmbeddable } { + if (val && typeof val === 'object' && 'embeddable' in val) return true; + return false; + } if (!hasEmbeddable(contextScopeInput)) throw new Error( "UrlDrilldown [getContextScope] can't build scope because embeddable object is missing in context" @@ -86,8 +90,8 @@ export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilld const embeddable = contextScopeInput.embeddable; const input = embeddable.getInput(); const output = embeddable.getOutput(); - function isSavedObjectId(savedObjectId: unknown): savedObjectId is string { - return typeof savedObjectId === 'string'; + function hasSavedObjectId(obj: Record): obj is { savedObjectId: string } { + return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string'; } function getIndexPatternIds(): string[] { function hasIndexPatterns( @@ -109,8 +113,7 @@ export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilld id: input.id, title: output.title ?? input.title, savedObjectId: - output.savedObjectId ?? - (isSavedObjectId(input.savedObjectId) ? input.savedObjectId : undefined), + output.savedObjectId ?? (hasSavedObjectId(input) ? input.savedObjectId : undefined), query: input.query, timeRange: input.timeRange, filters: input.filters, @@ -120,11 +123,6 @@ export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilld }; } -function hasEmbeddable(val: unknown): val is { embeddable: IEmbeddable } { - if (val && typeof val === 'object' && 'embeddable' in val) return true; - return false; -} - /** * URL drilldown event scope, * available as: {{event.key}}, {{event.from}} @@ -132,15 +130,14 @@ function hasEmbeddable(val: unknown): val is { embeddable: IEmbeddable } { type UrlDrilldownEventScope = ValueClickTriggerEventScope | RangeSelectTriggerEventScope; type EventScopeInput = ActionContext; interface ValueClickTriggerEventScope { - key: string; - value: string; + key?: string; + value?: string | number | boolean; negate: boolean; } - interface RangeSelectTriggerEventScope { - key: string; - from: string; - to: string; + key?: string; + from?: string | number; + to?: string | number; } export async function getEventScope( @@ -163,9 +160,9 @@ async function getEventScopeFromRangeSelectTriggerContext( const { table, column: columnIndex, range } = eventScopeInput.data; const column = table.columns[columnIndex]; return cleanEmptyKeys({ - key: toStringOrUndefined(column?.meta?.aggConfigParams?.field)!, - from: toStringOrUndefined(range[0])!, - to: toStringOrUndefined(range[range.length - 1])!, + key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string | undefined, + from: toPrimitiveOrUndefined(range[0]) as string | number | undefined, + to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined, }); } @@ -182,8 +179,8 @@ async function getEventScopeFromValueClickTriggerContext( ); const column = table.columns[columnIndex]; return cleanEmptyKeys({ - key: toStringOrUndefined(column?.meta?.aggConfigParams?.field)!, - value: toStringOrUndefined(value)!, + key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string | undefined, + value: toPrimitiveOrUndefined(value) as string | number | boolean, negate, }); } @@ -209,10 +206,11 @@ export function getMockEventScope([trigger]: UrlTrigger[]): UrlDrilldownEventSco } } -function toStringOrUndefined(v: unknown): string | undefined { - if (typeof v === 'string') return v; +function toPrimitiveOrUndefined(v: unknown): string | number | boolean | undefined { + if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') return v; if (typeof v === 'object' && v instanceof Date) return v.toISOString(); - return typeof v === 'undefined' ? v : String(v); + if (typeof v === 'undefined' || v === null) return undefined; + return String(v); } function cleanEmptyKeys>(obj: T): T { @@ -273,10 +271,8 @@ function GetSingleValuePopup({ const column = table.columns[columnIndex]; return { point, - id: toStringOrUndefined(column?.meta?.aggConfigParams?.field)!, - label: `${toStringOrUndefined(column?.meta?.aggConfigParams?.field)!}:${toStringOrUndefined( - value - )!}`, + id: column?.meta?.aggConfigParams?.field ?? '', + label: `${column?.meta?.aggConfigParams?.field ?? ''}:${toPrimitiveOrUndefined(value)}`, }; }) .filter((value) => Boolean(value.id)); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts index 67255b6f4cfb3..64b8cc49292b3 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts @@ -111,6 +111,14 @@ describe('date helper', () => { expect(compile(url, { time: 'fake' })).toMatchInlineSnapshot(`"https://elastic.co/fake"`); }); + test("doesn't throw on boolean or number", () => { + const url = 'https://elastic.co/{{date time}}'; + expect(compile(url, { time: false })).toMatchInlineSnapshot(`"https://elastic.co/false"`); + expect(compile(url, { time: 24 })).toMatchInlineSnapshot( + `"https://elastic.co/1970-01-01T00:00:00.024Z"` + ); + }); + test('works with ISO string', () => { const url = 'https://elastic.co/{{date time}}'; expect(compile(url, { time: date.toISOString() })).toMatchInlineSnapshot( From 1c4d09919f46749d6f24c10478ab6c6566f2a9f3 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 25 Aug 2020 12:41:21 +0200 Subject: [PATCH 06/12] improve --- .../plugins/embeddable_enhanced/public/drilldowns/index.ts | 7 +++++++ .../public/drilldowns/url_drilldown/index.ts | 7 +++++++ x-pack/plugins/embeddable_enhanced/public/plugin.ts | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/embeddable_enhanced/public/drilldowns/index.ts create mode 100644 x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/index.ts diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/index.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/index.ts new file mode 100644 index 0000000000000..a8d5a179dbac1 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export * from './url_drilldown'; diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/index.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/index.ts new file mode 100644 index 0000000000000..61406f7d84318 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { UrlDrilldown } from './url_drilldown'; diff --git a/x-pack/plugins/embeddable_enhanced/public/plugin.ts b/x-pack/plugins/embeddable_enhanced/public/plugin.ts index 82e40fac73cc1..37e102b40131d 100644 --- a/x-pack/plugins/embeddable_enhanced/public/plugin.ts +++ b/x-pack/plugins/embeddable_enhanced/public/plugin.ts @@ -31,7 +31,7 @@ import { urlDrilldownGlobalScopeProvider, } from '../../ui_actions_enhanced/public'; import { PanelNotificationsAction, ACTION_PANEL_NOTIFICATIONS } from './actions'; -import { UrlDrilldown } from './drilldowns/url_drilldown/url_drilldown'; +import { UrlDrilldown } from './drilldowns'; import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; declare module '../../../../src/plugins/ui_actions/public' { From 404f403fdb02a7ae8d235408ed269d32f222027c Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 26 Aug 2020 13:56:19 +0200 Subject: [PATCH 07/12] @streamich review --- .../flyout_create_drilldown.tsx | 2 +- .../flyout_edit_drilldown.tsx | 2 +- .../public/drilldowns/url_drilldown/README.md | 5 +- .../public/drilldowns/url_drilldown/i18n.ts | 14 ++++ .../url_drilldown/url_drilldown.tsx | 66 ++++++++----------- .../url_drilldown/url_drilldown_scope.tsx | 19 +----- .../connected_flyout_manage_drilldowns.tsx | 12 ++-- .../flyout_drilldown_wizard.tsx | 12 ++-- .../public/drilldowns/components/types.ts | 2 +- .../url_drilldown_collect_config.tsx | 2 +- .../public/drilldowns/url_drilldown/types.ts | 19 ++++-- 11 files changed, 77 insertions(+), 78 deletions(-) create mode 100644 x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/i18n.ts diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx index b5bbda64a62e8..607a11a76a066 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -80,7 +80,7 @@ export class FlyoutCreateDrilldownAction implements ActionByType ), { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx index 2bafa9269f24d..30de62d0d28dd 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -64,7 +64,7 @@ export class FlyoutEditDrilldownAction implements ActionByType ), { diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/README.md b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/README.md index 1b0b173973112..996723ccb914d 100644 --- a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/README.md +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/README.md @@ -6,9 +6,12 @@ By using variables in url template result url can be dynamic and depend on user' URL drilldown has 3 sources for variables: - Global static variables like, for example, `kibanaUrl`. Such variables won’t change depending on a place where url drilldown is used. -- Context variables are dynamic and different depending on where drilldown is created and used. For example: +- Context variables are dynamic and different depending on where drilldown is created and used. - Event variables depend on a trigger context. These variables are dynamically extracted from the action context when drilldown is executed. +Difference between `event` and `context` variables, is that real `context` variables are available during drilldown creation (e.g. embeddable panel), +but `event` variables mapped from trigger context. Since there is no trigger context during drilldown creation, we have to provide some _mock_ variables for validating and previewing the URL. + In current implementation url drilldown has to be used inside the embeddable and with `ValueClickTrigger` or `RangeSelectTrigger`. - `context` variables extracted from `embeddable` diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/i18n.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/i18n.ts new file mode 100644 index 0000000000000..748f6f4cecedd --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/i18n.ts @@ -0,0 +1,14 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const txtUrlDrilldownDisplayName = i18n.translate( + 'xpack.embeddableEnhanced.drilldowns.urlDrilldownDisplayName', + { + defaultMessage: 'Go to URL', + } +); 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 6786236040f29..fedfd67d632a8 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 @@ -24,6 +24,7 @@ import { UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, } from '../../../../ui_actions_enhanced/public'; import { getContextScope, getEventScope, getMockEventScope } from './url_drilldown_scope'; +import { txtUrlDrilldownDisplayName } from './i18n'; interface UrlDrilldownDeps { getGlobalScope: () => UrlDrilldownGlobalScope; @@ -51,7 +52,7 @@ export class UrlDrilldown implements Drilldown 'Go to URL'; + public readonly getDisplayName = () => txtUrlDrilldownDisplayName; public readonly euiIcon = 'link'; @@ -65,16 +66,7 @@ export class UrlDrilldown implements Drilldown { // eslint-disable-next-line react-hooks/rules-of-hooks - const scope = React.useMemo( - () => - urlDrilldownBuildScope({ - globalScope: this.deps.getGlobalScope(), - contextScope: getContextScope(context), - eventScope: getMockEventScope(context.triggers), - }), - [context] - ); - + const scope = React.useMemo(() => this.buildEditorScope(context), [context]); return ( { - const { isValid } = urlDrilldownValidateUrlTemplate( - config.url, - urlDrilldownBuildScope({ - globalScope: this.deps.getGlobalScope(), - contextScope: getContextScope(context), - eventScope: getMockEventScope(context.triggers), - }) - ); + const { isValid } = urlDrilldownValidateUrlTemplate(config.url, this.buildEditorScope(context)); return isValid; }; public readonly isCompatible = async (config: Config, context: ActionContext) => { const { isValid, error } = urlDrilldownValidateUrlTemplate( config.url, - urlDrilldownBuildScope({ - globalScope: this.deps.getGlobalScope(), - contextScope: getContextScope(context), - eventScope: await getEventScope(context, this.deps), - }) + await this.buildRuntimeScope(context) ); if (!isValid) { @@ -127,25 +108,13 @@ export class UrlDrilldown implements Drilldown { - return urlDrilldownCompileUrl( - config.url.template, - urlDrilldownBuildScope({ - globalScope: this.deps.getGlobalScope(), - contextScope: getContextScope(context), - eventScope: await getEventScope(context, this.deps), - }) - ); - }; + public readonly getHref = async (config: Config, context: ActionContext) => + urlDrilldownCompileUrl(config.url.template, await this.buildRuntimeScope(context)); public readonly execute = async (config: Config, context: ActionContext) => { const url = await urlDrilldownCompileUrl( config.url.template, - urlDrilldownBuildScope({ - globalScope: this.deps.getGlobalScope(), - contextScope: getContextScope(context), - eventScope: await getEventScope(context, this.deps, { allowPrompts: true }), - }) + await this.buildRuntimeScope(context, { allowPrompts: true }) ); if (config.openInNewTab) { window.open(url, '_blank', 'noopener'); @@ -153,4 +122,23 @@ export class UrlDrilldown implements Drilldown { + return urlDrilldownBuildScope({ + globalScope: this.deps.getGlobalScope(), + contextScope: getContextScope(context), + eventScope: getMockEventScope(context.triggers), + }); + }; + + private buildRuntimeScope = async ( + context: ActionContext, + opts: { allowPrompts: boolean } = { allowPrompts: false } + ) => { + return urlDrilldownBuildScope({ + globalScope: this.deps.getGlobalScope(), + contextScope: getContextScope(context), + eventScope: await getEventScope(context, this.deps, opts), + }); + }; } 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.tsx index eff07446edbad..161ccc6444cdb 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.tsx @@ -5,21 +5,8 @@ */ /** - * This file contains all the logic for mapping from trigger's context and environment context to variables for URL drilldown scope - * URL drilldown has 3 sources for variables: - * - * - Global static variables like, for example, `kibanaUrl`. Such variables won’t change depending on a place where url drilldown is used. - * - Context variables are dynamic and different depending on where drilldown is created and used. For example: - * - Event variables depend on a trigger context. These variables are dynamically extracted from the action context when drilldown is executed. - * - * At this point context variables are extracted from `embeddable` and event variables are extracted from trigger's context. - * Only 2 triggers are supported at this point: VALUE_CLICK_TRIGGER and RANGE_SELECT_TRIGGER - * - * In future URL drilldown implementation will provide extension points - * for injecting more variables into context and to support more triggers - * https://github.com/elastic/kibana/issues/55324 - * - * For now possible variables and logic for extracting those are hardcoded and all contained in this file. + * This file contains all the logic for mapping from trigger's context and action factory context to variables for URL drilldown scope, + * Please refer to ./README.md for explanation of different scope sources */ import React from 'react'; @@ -50,7 +37,7 @@ type ContextScopeInput = ActionContext | ActionFactoryContext; /** * Part of context scope extracted from an embeddable - * Expose on the scope as: `{{context.panel.id}}` `{{context.panel.filters.[0]}}` + * Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}` */ interface EmbeddableUrlDrilldownContextScope { id: string; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx index 9fca785ec9072..4deef8db1487b 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx @@ -30,7 +30,7 @@ import { SerializedAction, SerializedEvent, } from '../../../dynamic_actions'; -import { ExtraActionFactoryContext } from '../types'; +import { ActionFactoryPlaceContext } from '../types'; interface ConnectedFlyoutManageDrilldownsProps< ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext @@ -47,7 +47,7 @@ interface ConnectedFlyoutManageDrilldownsProps< /** * Extra action factory context passed into action factories CollectConfig, getIconType, getDisplayName and etc... */ - extraContext?: ExtraActionFactoryContext; + placeContext?: ActionFactoryPlaceContext; } /** @@ -81,8 +81,8 @@ export function createFlyoutManageDrilldowns({ const isCreateOnly = props.viewMode === 'create'; const factoryContext: BaseActionFactoryContext = useMemo( - () => ({ ...props.extraContext, triggers: props.supportedTriggers }), - [props.extraContext, props.supportedTriggers] + () => ({ ...props.placeContext, triggers: props.supportedTriggers }), + [props.placeContext, props.supportedTriggers] ); const actionFactories = useCompatibleActionFactoriesForCurrentContext( allActionFactories, @@ -137,7 +137,7 @@ export function createFlyoutManageDrilldowns({ function mapToDrilldownToDrilldownListItem(drilldown: SerializedEvent): DrilldownListItem { const actionFactory = allActionFactoriesById[drilldown.action.factoryId]; const drilldownFactoryContext: BaseActionFactoryContext = { - ...props.extraContext, + ...props.placeContext, triggers: drilldown.triggers as TriggerId[], }; return { @@ -204,7 +204,7 @@ export function createFlyoutManageDrilldowns({ setRoute(Routes.Manage); setCurrentEditId(null); }} - extraActionFactoryContext={props.extraContext} + actionFactoryPlaceContext={props.placeContext} initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()} supportedTriggers={props.supportedTriggers} getTrigger={getTrigger} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx index a908d53bf6ae7..c8e3f454bd53d 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx @@ -18,7 +18,7 @@ import { import { DrilldownHelloBar } from '../drilldown_hello_bar'; import { ActionFactory, BaseActionFactoryContext } from '../../../dynamic_actions'; import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public'; -import { ExtraActionFactoryContext } from '../types'; +import { ActionFactoryPlaceContext } from '../types'; export interface DrilldownWizardConfig { name: string; @@ -44,7 +44,7 @@ export interface FlyoutDrilldownWizardProps< showWelcomeMessage?: boolean; onWelcomeHideClick?: () => void; - extraActionFactoryContext?: ExtraActionFactoryContext; + actionFactoryPlaceContext?: ActionFactoryPlaceContext; docsLink?: string; @@ -143,7 +143,7 @@ export function FlyoutDrilldownWizard ({ - ...extraActionFactoryContext, + ...actionFactoryPlaceContext, triggers: wizardConfig.selectedTriggers ?? [], }), - [extraActionFactoryContext, wizardConfig.selectedTriggers] + [actionFactoryPlaceContext, wizardConfig.selectedTriggers] ); const isActionValid = ( diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/types.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/types.ts index 870b55c24fb58..811680bf380f7 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/types.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/types.ts @@ -10,6 +10,6 @@ import { BaseActionFactoryContext } from '../../dynamic_actions'; * Interface used as piece of ActionFactoryContext that is passed in from drilldown wizard component to action factories * Omitted values are added inside the wizard and then full {@link BaseActionFactoryContext} passed into action factory methods */ -export type ExtraActionFactoryContext< +export type ActionFactoryPlaceContext< ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext > = Omit; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx index 7b6763522bc76..4387c11a5d0f2 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx @@ -43,7 +43,7 @@ export const UrlDrilldownCollectConfig: React.FC = ({ onConfig, scope, syntaxHelpDocsLink, -}: UrlDrilldownCollectConfig) => { +}) => { const textAreaRef = useRef(null); const urlTemplate = config.url.template ?? ''; const compiledUrl = React.useMemo(() => { diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts index a73f236331f49..31c7481c9d63e 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts @@ -5,26 +5,33 @@ */ export interface UrlDrilldownConfig { - url: { format?: 'mustache_v1'; template: string }; + url: { format?: 'handlebars_v1'; template: string }; openInNewTab: boolean; } /** * URL drilldown has 3 sources for variables: global, context and event variables - 1. Global - static variables like, for example, kibanaInstanceId or kibanaBaseUrl. Such variables won’t change depending on a place where url drilldown is used. - 2. Context - variables are dynamic and different depending on where drilldown is created and used. For example: - Variables from embeddable. Like embeddableId, embeddableTitle, embeddableType, filters, query, etc… - Variables from dashboard (if used inside dashboard app). Like dashboardId or dashboardTitle. - 3. Event - variables depend on a trigger context. These variables are dynamically extracted from the trigger context when drilldown is executed. */ export interface UrlDrilldownScope< ContextScope extends object = object, EventScope extends object = object > extends UrlDrilldownGlobalScope { + /** + * Dynamic variables that are differ depending on where drilldown is created and used, + * For example: variables extracted from embeddable panel + */ context?: ContextScope; + + /** + * Variables extracted from trigger context + */ event?: EventScope; } +/** + * Global static variables like, for example, `kibanaUrl` + * Such variables won’t change depending on a place where url drilldown is used. + */ export interface UrlDrilldownGlobalScope { kibanaUrl: string; } From 0ad40f8768493e56021e80051dc7c8fd235e2e8c Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 27 Aug 2020 12:46:26 +0200 Subject: [PATCH 08/12] improve --- .../drilldowns/url_drilldown/url_drilldown_scope.tsx | 10 +++++----- .../url_drilldown/url_drilldown_scope.test.ts | 8 ++++---- .../drilldowns/url_drilldown/url_drilldown_scope.ts | 10 +++++++++- 3 files changed, 18 insertions(+), 10 deletions(-) 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.tsx index 161ccc6444cdb..e12604791f085 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.tsx @@ -122,7 +122,7 @@ interface ValueClickTriggerEventScope { negate: boolean; } interface RangeSelectTriggerEventScope { - key?: string; + key: string; from?: string | number; to?: string | number; } @@ -147,7 +147,7 @@ async function getEventScopeFromRangeSelectTriggerContext( const { table, column: columnIndex, range } = eventScopeInput.data; const column = table.columns[columnIndex]; return cleanEmptyKeys({ - key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string | undefined, + key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string, from: toPrimitiveOrUndefined(range[0]) as string | number | undefined, to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined, }); @@ -180,14 +180,14 @@ async function getEventScopeFromValueClickTriggerContext( export function getMockEventScope([trigger]: UrlTrigger[]): UrlDrilldownEventScope { if (trigger === SELECT_RANGE_TRIGGER) { return { - key: '__testKey__', + key: 'event.key', from: new Date(Date.now() - 15 * 60 * 1000).toISOString(), // 15 minutes ago to: new Date().toISOString(), }; } else { return { - key: '__testKey__', - value: '__testValue__', + key: 'event.key', + value: 'event.value', negate: false, }; } diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts index 9d3df521ab8e5..f95fc5e70ae00 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts @@ -41,12 +41,12 @@ test('buildScopeSuggestions', () => { ) ).toMatchInlineSnapshot(` Array [ - "kibanaUrl", - "context.filters", - "context.query.query", - "context.query.language", "event.key", "event.value", + "context.filters", + "context.query.language", + "context.query.query", + "kibanaUrl", ] `); }); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts index ba792c868919c..d499812a9d5ae 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import partition from 'lodash/partition'; import { UrlDrilldownGlobalScope, UrlDrilldownScope } from './types'; import { getFlattenedObject } from '../../../../../../src/core/public'; @@ -26,6 +27,13 @@ export function buildScope< }; } +/** + * Builds list of variables for suggestion from scope + * keys sorted alphabetically, except {{event.$}} variables are pulled to the top + * @param scope + */ export function buildScopeSuggestions(scope: UrlDrilldownGlobalScope): string[] { - return Object.keys(getFlattenedObject(scope)); + const allKeys = Object.keys(getFlattenedObject(scope)).sort(); + const [eventKeys, otherKeys] = partition(allKeys, (key) => key.startsWith('event')); + return [...eventKeys, ...otherKeys]; } From 7772a89de766f005901f25fcdd1769fe7faeea02 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 27 Aug 2020 13:37:01 +0200 Subject: [PATCH 09/12] improve --- .../url_drilldown/url_drilldown_scope.tsx | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) 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.tsx index e12604791f085..d3e3510f1b24e 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.tsx @@ -19,6 +19,7 @@ import { 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 { @@ -159,15 +160,11 @@ async function getEventScopeFromValueClickTriggerContext( opts: { allowPrompts: boolean } = { allowPrompts: false } ): Promise { const negate = eventScopeInput.data.negate ?? false; - const { table, column: columnIndex, value } = await getSingleValue( - eventScopeInput.data.data, - deps, - opts - ); - const column = table.columns[columnIndex]; + const point = await getSingleValue(eventScopeInput.data.data, deps, opts); + const { key, value } = getKeyValueFromPoint(point); return cleanEmptyKeys({ - key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string | undefined, - value: toPrimitiveOrUndefined(value) as string | number | boolean, + key, + value, negate, }); } @@ -193,6 +190,17 @@ export function getMockEventScope([trigger]: UrlTrigger[]): UrlDrilldownEventSco } } +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; if (typeof v === 'object' && v instanceof Date) return v.toISOString(); @@ -218,7 +226,10 @@ async function getSingleValue( deps: { getOpenModal: () => Promise }, opts: { allowPrompts: boolean } = { allowPrompts: false } ): Promise { - data = data.filter(Boolean); + 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]); @@ -254,12 +265,11 @@ function GetSingleValuePopup({ }) { const values = data .map((point) => { - const { table, column: columnIndex, value } = point; - const column = table.columns[columnIndex]; + const { key, value } = getKeyValueFromPoint(point); return { point, - id: column?.meta?.aggConfigParams?.field ?? '', - label: `${column?.meta?.aggConfigParams?.field ?? ''}:${toPrimitiveOrUndefined(value)}`, + id: key ?? '', + label: `${key}:${value}`, }; }) .filter((value) => Boolean(value.id)); From 9d0ef8fab30a4fa70500ddfdc08392870fb43a89 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 1 Sep 2020 13:11:24 +0200 Subject: [PATCH 10/12] add a note about dummy values --- .../components/url_drilldown_collect_config/i18n.ts | 7 +++++++ .../url_drilldown_collect_config.tsx | 2 ++ 2 files changed, 9 insertions(+) diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts index 84352e2c500f3..fc0ebea3342e4 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts @@ -16,6 +16,13 @@ export const txtUrlTemplatePlaceholder = i18n.translate( } ); +export const txtUrlPreviewHelpText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewHelpText', + { + defaultMessage: 'Please note that \\{\\{event.*\\}\\} variables replaced by dummy values.', + } +); + export const txtAddVariableButtonTitle = i18n.translate( 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle', { diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx index 4387c11a5d0f2..df067cda169da 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx @@ -23,6 +23,7 @@ import { buildScopeSuggestions } from '../../url_drilldown_scope'; import './index.scss'; import { txtAddVariableButtonTitle, + txtUrlPreviewHelpText, txtUrlTemplateHelpLinkText, txtUrlTemplateLabel, txtUrlTemplateOpenInNewTab, @@ -126,6 +127,7 @@ export const UrlDrilldownCollectConfig: React.FC = ({ } + helpText={txtUrlPreviewHelpText} > Date: Wed, 2 Sep 2020 16:57:41 +0200 Subject: [PATCH 11/12] integrate with licenseFeatureName --- .../public/drilldowns/url_drilldown/url_drilldown.tsx | 1 + 1 file changed, 1 insertion(+) 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 fedfd67d632a8..d5ab095fdd287 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 @@ -51,6 +51,7 @@ export class UrlDrilldown implements Drilldown txtUrlDrilldownDisplayName; From eeb7a7fbd508d6d7e46891b9356cb8a1cc74119b Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 3 Sep 2020 13:15:45 +0200 Subject: [PATCH 12/12] =?UTF-8?q?better=20=E2=80=9Cadd=20variable=E2=80=9D?= =?UTF-8?q?=20popup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../url_drilldown_collect_config/i18n.ts | 18 ++++- .../url_drilldown_collect_config.tsx | 71 +++++++++++++------ 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts index fc0ebea3342e4..78f7218dce22e 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts @@ -37,13 +37,27 @@ export const txtUrlTemplateLabel = i18n.translate( } ); -export const txtUrlTemplateHelpLinkText = i18n.translate( - 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateHelpLinkText', +export const txtUrlTemplateSyntaxHelpLinkText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxHelpLinkText', { defaultMessage: 'Syntax help', } ); +export const txtUrlTemplateVariablesHelpLinkText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesHelpLinkText', + { + defaultMessage: 'Help', + } +); + +export const txtUrlTemplateVariablesFilterPlaceholderText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesFilterPlaceholderText', + { + defaultMessage: 'Filter variables', + } +); + export const txtUrlTemplatePreviewLabel = i18n.translate( 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel', { diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx index df067cda169da..dabf09e4b6e9f 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx @@ -6,15 +6,17 @@ import React, { useRef, useState } from 'react'; import { - EuiContextMenuItem, - EuiContextMenuPanel, + EuiCheckbox, EuiFormRow, EuiIcon, EuiLink, EuiPopover, - EuiCheckbox, + EuiPopoverFooter, + EuiPopoverTitle, + EuiSelectable, EuiText, EuiTextArea, + EuiSelectableOption, } from '@elastic/eui'; import { UrlDrilldownConfig, UrlDrilldownScope } from '../../types'; import { compile } from '../../url_template'; @@ -24,7 +26,9 @@ import './index.scss'; import { txtAddVariableButtonTitle, txtUrlPreviewHelpText, - txtUrlTemplateHelpLinkText, + txtUrlTemplateSyntaxHelpLinkText, + txtUrlTemplateVariablesHelpLinkText, + txtUrlTemplateVariablesFilterPlaceholderText, txtUrlTemplateLabel, txtUrlTemplateOpenInNewTab, txtUrlTemplatePlaceholder, @@ -84,13 +88,14 @@ export const UrlDrilldownCollectConfig: React.FC = ({ helpText={ syntaxHelpDocsLink && ( - {txtUrlTemplateHelpLinkText} + {txtUrlTemplateSyntaxHelpLinkText} ) } labelAppend={ { if (textAreaRef.current) { updateUrlTemplate( @@ -154,28 +159,23 @@ export const UrlDrilldownCollectConfig: React.FC = ({ function AddVariableButton({ variables, onSelect, + variablesHelpLink, }: { variables: string[]; onSelect: (variable: string) => void; + variablesHelpLink?: string; }) { const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState(false); + const closePopover = () => setIsVariablesPopoverOpen(false); - const renderVariables = () => - variables.map((variable: string) => ( - { - onSelect(variable); - setIsVariablesPopoverOpen(false); - }} - > - {`{{${variable}}}`} - - )); + const options: EuiSelectableOption[] = variables.map((variable: string) => ({ + key: variable, + label: variable, + })); return ( setIsVariablesPopoverOpen(true)}> @@ -184,12 +184,43 @@ function AddVariableButton({ } isOpen={isVariablesPopoverOpen} - closePopover={() => setIsVariablesPopoverOpen(false)} + closePopover={closePopover} panelPaddingSize="none" anchorPosition="downLeft" withTitle > - + { + const selected = newOptions.find((o) => o.checked === 'on'); + if (!selected) return; + onSelect(selected.key!); + closePopover(); + }} + listProps={{ + showIcons: false, + }} + > + {(list, search) => ( +
+ {search} + {list} + {variablesHelpLink && ( + + + {txtUrlTemplateVariablesHelpLinkText} + + + )} +
+ )} +
); }