From b494234b716171ac270b99081eea947fe9f79a6e Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Fri, 14 Aug 2020 12:07:04 +0200 Subject: [PATCH] [UiActions] pass trigger into action execution context (#74363) Co-authored-by: Elastic Machine --- .../public/actions/actions.tsx | 9 ++- examples/ui_actions_explorer/public/app.tsx | 6 +- .../lib/actions/apply_filter_action.test.ts | 4 + .../public/lib/panel/embeddable_panel.tsx | 7 +- .../add_panel/add_panel_action.test.tsx | 9 ++- .../add_panel/add_panel_action.ts | 10 ++- .../lib/panel/panel_header/panel_header.tsx | 19 +++-- .../test_samples/actions/say_hello_action.tsx | 4 +- .../public/tests/apply_filter_action.test.ts | 3 +- .../ui_actions/public/actions/action.test.ts | 13 +++- .../ui_actions/public/actions/action.ts | 76 +++++++++++++++---- .../build_eui_context_menu_panels.tsx | 53 ++++++++++--- src/plugins/ui_actions/public/index.ts | 7 +- .../service/ui_actions_execution_service.ts | 17 +++-- .../public/service/ui_actions_service.ts | 11 ++- .../tests/execute_trigger_actions.test.ts | 24 +++++- .../public/triggers/default_trigger.ts | 27 +++++++ .../ui_actions/public/triggers/index.ts | 1 + src/plugins/ui_actions/public/types.ts | 9 ++- .../dashboard_to_url_drilldown/index.tsx | 11 ++- .../panel_actions/get_csv_panel_action.tsx | 7 +- .../public/custom_time_range_action.tsx | 7 +- .../public/drilldowns/drilldown_definition.ts | 11 ++- 23 files changed, 277 insertions(+), 68 deletions(-) create mode 100644 src/plugins/ui_actions/public/triggers/default_trigger.ts diff --git a/examples/ui_actions_explorer/public/actions/actions.tsx b/examples/ui_actions_explorer/public/actions/actions.tsx index 6d83362e998bc6..777bcd9c181192 100644 --- a/examples/ui_actions_explorer/public/actions/actions.tsx +++ b/examples/ui_actions_explorer/public/actions/actions.tsx @@ -21,7 +21,11 @@ import { OverlayStart } from 'kibana/public'; import { EuiFieldText, EuiModalBody, EuiButton } from '@elastic/eui'; import { useState } from 'react'; import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; -import { createAction, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; +import { + ActionExecutionContext, + createAction, + UiActionsStart, +} from '../../../../src/plugins/ui_actions/public'; export const USER_TRIGGER = 'USER_TRIGGER'; export const COUNTRY_TRIGGER = 'COUNTRY_TRIGGER'; @@ -37,7 +41,8 @@ export const ACTION_SHOWCASE_PLUGGABILITY = 'ACTION_SHOWCASE_PLUGGABILITY'; export const showcasePluggability = createAction({ type: ACTION_SHOWCASE_PLUGGABILITY, getDisplayName: () => 'This is pluggable! Any plugin can inject their actions here.', - execute: async () => alert("Isn't that cool?!"), + execute: async (context: ActionExecutionContext) => + alert(`Isn't that cool?! Triggered by ${context.trigger?.id} trigger`), }); export interface PhoneContext { diff --git a/examples/ui_actions_explorer/public/app.tsx b/examples/ui_actions_explorer/public/app.tsx index 1b0667962a3c2f..d59309f0068385 100644 --- a/examples/ui_actions_explorer/public/app.tsx +++ b/examples/ui_actions_explorer/public/app.tsx @@ -97,9 +97,9 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => { }); uiActionsApi.addTriggerAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); setConfirmationText( - `You've successfully added a new action: ${dynamicAction.getDisplayName( - {} - )}. Refresh the page to reset state. It's up to the user of the system to persist state like this.` + `You've successfully added a new action: ${dynamicAction.getDisplayName({ + trigger: uiActionsApi.getTrigger(HELLO_WORLD_TRIGGER_ID), + })}. Refresh the page to reset state. It's up to the user of the system to persist state like this.` ); }} > diff --git a/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts b/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts index 636ce3e623c5bf..88c1a5917e609c 100644 --- a/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts +++ b/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts @@ -19,6 +19,7 @@ import { createFilterAction } from './apply_filter_action'; import { expectErrorAsync } from '../../tests/helpers'; +import { defaultTrigger } from '../../../../ui_actions/public/triggers'; test('has ACTION_APPLY_FILTER type and id', () => { const action = createFilterAction(); @@ -51,6 +52,7 @@ describe('isCompatible()', () => { }), } as any, filters: [], + trigger: defaultTrigger, }); expect(result).toBe(true); }); @@ -66,6 +68,7 @@ describe('isCompatible()', () => { }), } as any, filters: [], + trigger: defaultTrigger, }); expect(result).toBe(false); }); @@ -119,6 +122,7 @@ describe('execute()', () => { await action.execute({ embeddable, filters: ['FILTER' as any], + trigger: defaultTrigger, }); expect(root.updateInput).toHaveBeenCalledTimes(1); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index cb02ffc470e95c..d8659680dceb95 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -30,6 +30,7 @@ import { PANEL_BADGE_TRIGGER, PANEL_NOTIFICATION_TRIGGER, EmbeddableContext, + contextMenuTrigger, } from '../triggers'; import { IEmbeddable, EmbeddableOutput, EmbeddableError } from '../embeddables/i_embeddable'; import { ViewMode } from '../types'; @@ -311,7 +312,11 @@ export class EmbeddablePanel extends React.Component { const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField); return await buildContextMenuForActions({ - actions: sortedActions.map((action) => [action, { embeddable: this.props.embeddable }]), + actions: sortedActions.map((action) => ({ + action, + context: { embeddable: this.props.embeddable }, + trigger: contextMenuTrigger, + })), closeMenu: this.closeMyContextMenuPanel, }); }; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.test.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.test.tsx index d8def3147e52c9..0361939fd07e6a 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.test.tsx @@ -31,6 +31,7 @@ import { ContactCardEmbeddable } from '../../../../test_samples'; import { esFilters, Filter } from '../../../../../../../../plugins/data/public'; import { EmbeddableStart } from '../../../../../plugin'; import { embeddablePluginMock } from '../../../../../mocks'; +import { defaultTrigger } from '../../../../../../../ui_actions/public/triggers'; const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory(FILTERABLE_EMBEDDABLE, new FilterableEmbeddableFactory()); @@ -85,7 +86,9 @@ test('Is not compatible when container is in view mode', async () => { () => null ); container.updateInput({ viewMode: ViewMode.VIEW }); - expect(await addPanelAction.isCompatible({ embeddable: container })).toBe(false); + expect( + await addPanelAction.isCompatible({ embeddable: container, trigger: defaultTrigger }) + ).toBe(false); }); test('Is not compatible when embeddable is not a container', async () => { @@ -94,7 +97,7 @@ test('Is not compatible when embeddable is not a container', async () => { test('Is compatible when embeddable is a parent and in edit mode', async () => { container.updateInput({ viewMode: ViewMode.EDIT }); - expect(await action.isCompatible({ embeddable: container })).toBe(true); + expect(await action.isCompatible({ embeddable: container, trigger: defaultTrigger })).toBe(true); }); test('Execute throws an error when called with an embeddable that is not a container', async () => { @@ -108,6 +111,7 @@ test('Execute throws an error when called with an embeddable that is not a conta }, {} as any ), + trigger: defaultTrigger, } as any); } await expect(check()).rejects.toThrow(Error); @@ -116,6 +120,7 @@ test('Execute does not throw an error when called with a compatible container', container.updateInput({ viewMode: ViewMode.EDIT }); await action.execute({ embeddable: container, + trigger: defaultTrigger, }); }); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts index f3a483bb4bda4b..63575273bbf626 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts @@ -17,7 +17,7 @@ * under the License. */ import { i18n } from '@kbn/i18n'; -import { Action } from 'src/plugins/ui_actions/public'; +import { Action, ActionExecutionContext } from 'src/plugins/ui_actions/public'; import { NotificationsStart, OverlayStart } from 'src/core/public'; import { EmbeddableStart } from 'src/plugins/embeddable/public/plugin'; import { ViewMode } from '../../../../types'; @@ -52,12 +52,14 @@ export class AddPanelAction implements Action { return 'plusInCircleFilled'; } - public async isCompatible({ embeddable }: ActionContext) { + public async isCompatible(context: ActionExecutionContext) { + const { embeddable } = context; return embeddable.getIsContainer() && embeddable.getInput().viewMode === ViewMode.EDIT; } - public async execute({ embeddable }: ActionContext) { - if (!embeddable.getIsContainer() || !(await this.isCompatible({ embeddable }))) { + public async execute(context: ActionExecutionContext) { + const { embeddable } = context; + if (!embeddable.getIsContainer() || !(await this.isCompatible(context))) { throw new Error('Context is incompatible'); } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 2f086a3fb2c0cf..5d7daaa7217ed7 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -30,7 +30,7 @@ import React from 'react'; import { Action } from 'src/plugins/ui_actions/public'; import { PanelOptionsMenu } from './panel_options_menu'; import { IEmbeddable } from '../../embeddables'; -import { EmbeddableContext } from '../../triggers'; +import { EmbeddableContext, panelBadgeTrigger, panelNotificationTrigger } from '../../triggers'; export interface PanelHeaderProps { title?: string; @@ -49,11 +49,11 @@ function renderBadges(badges: Array>, embeddable: IEmb badge.execute({ embeddable })} - onClickAriaLabel={badge.getDisplayName({ embeddable })} + iconType={badge.getIconType({ embeddable, trigger: panelBadgeTrigger })} + onClick={() => badge.execute({ embeddable, trigger: panelBadgeTrigger })} + onClickAriaLabel={badge.getDisplayName({ embeddable, trigger: panelBadgeTrigger })} > - {badge.getDisplayName({ embeddable })} + {badge.getDisplayName({ embeddable, trigger: panelBadgeTrigger })} )); } @@ -70,14 +70,17 @@ function renderNotifications( data-test-subj={`embeddablePanelNotification-${notification.id}`} key={notification.id} style={{ marginTop: '4px', marginRight: '4px' }} - onClick={() => notification.execute(context)} + onClick={() => notification.execute({ ...context, trigger: panelNotificationTrigger })} > - {notification.getDisplayName(context)} + {notification.getDisplayName({ ...context, trigger: panelNotificationTrigger })} ); if (notification.getDisplayNameTooltip) { - const tooltip = notification.getDisplayNameTooltip(context); + const tooltip = notification.getDisplayNameTooltip({ + ...context, + trigger: panelNotificationTrigger, + }); if (tooltip) { badge = ( diff --git a/src/plugins/embeddable/public/lib/test_samples/actions/say_hello_action.tsx b/src/plugins/embeddable/public/lib/test_samples/actions/say_hello_action.tsx index 0612b838a6ce70..968caf67b18262 100644 --- a/src/plugins/embeddable/public/lib/test_samples/actions/say_hello_action.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/actions/say_hello_action.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { ActionByType, IncompatibleActionError, ActionType } from '../../ui_actions'; +import { IncompatibleActionError, ActionType, ActionDefinitionByType } from '../../ui_actions'; import { EmbeddableInput, Embeddable, EmbeddableOutput, IEmbeddable } from '../../embeddables'; // Casting to ActionType is a hack - in a real situation use @@ -42,7 +42,7 @@ export interface SayHelloActionContext { message?: string; } -export class SayHelloAction implements ActionByType { +export class SayHelloAction implements ActionDefinitionByType { public readonly type = SAY_HELLO_ACTION; public readonly id = SAY_HELLO_ACTION; diff --git a/src/plugins/embeddable/public/tests/apply_filter_action.test.ts b/src/plugins/embeddable/public/tests/apply_filter_action.test.ts index 9d765c99064436..f8c4a4a7e4b72f 100644 --- a/src/plugins/embeddable/public/tests/apply_filter_action.test.ts +++ b/src/plugins/embeddable/public/tests/apply_filter_action.test.ts @@ -31,6 +31,7 @@ import { FilterableEmbeddableInput, } from '../lib/test_samples'; import { esFilters } from '../../../data/public'; +import { applyFilterTrigger } from '../../../ui_actions/public'; test('ApplyFilterAction applies the filter to the root of the container tree', async () => { const { doStart, setup } = testPlugin(); @@ -85,7 +86,7 @@ test('ApplyFilterAction applies the filter to the root of the container tree', a query: { match: { extension: { query: 'foo' } } }, }; - await applyFilterAction.execute({ embeddable, filters: [filter] }); + await applyFilterAction.execute({ embeddable, filters: [filter], trigger: applyFilterTrigger }); expect(root.getInput().filters.length).toBe(1); expect(node1.getInput().filters.length).toBe(1); expect(embeddable.getInput().filters.length).toBe(1); diff --git a/src/plugins/ui_actions/public/actions/action.test.ts b/src/plugins/ui_actions/public/actions/action.test.ts index f9d696d3ddb5f4..1f76223a0d7c4b 100644 --- a/src/plugins/ui_actions/public/actions/action.test.ts +++ b/src/plugins/ui_actions/public/actions/action.test.ts @@ -17,8 +17,9 @@ * under the License. */ -import { createAction } from '../../../ui_actions/public'; +import { ActionExecutionContext, createAction } from '../../../ui_actions/public'; import { ActionType } from '../types'; +import { defaultTrigger } from '../triggers'; const sayHelloAction = createAction({ // Casting to ActionType is a hack - in a real situation use @@ -29,11 +30,17 @@ const sayHelloAction = createAction({ }); test('action is not compatible based on context', async () => { - const isCompatible = await sayHelloAction.isCompatible({ amICompatible: false }); + const isCompatible = await sayHelloAction.isCompatible({ + amICompatible: false, + trigger: defaultTrigger, + } as ActionExecutionContext); expect(isCompatible).toBe(false); }); test('action is compatible based on context', async () => { - const isCompatible = await sayHelloAction.isCompatible({ amICompatible: true }); + const isCompatible = await sayHelloAction.isCompatible({ + amICompatible: true, + trigger: defaultTrigger, + } as ActionExecutionContext); expect(isCompatible).toBe(true); }); diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index bc5f36acb8f0c6..8005dadd8f5ef3 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -18,13 +18,43 @@ */ import { UiComponent } from 'src/plugins/kibana_utils/public'; -import { ActionType, ActionContextMapping } from '../types'; +import { ActionType, ActionContextMapping, BaseContext } from '../types'; import { Presentable } from '../util/presentable'; +import { Trigger } from '../triggers'; export type ActionByType = Action; +export type ActionDefinitionByType = ActionDefinition< + ActionContextMapping[T] +>; -export interface Action - extends Partial> { +/** + * During action execution we can provide additional information, + * for example, trigger, that caused the action execution + */ +export interface ActionExecutionMeta { + /** + * Trigger that executed the action + */ + trigger: Trigger; +} + +/** + * Action methods are executed with Context from trigger + {@link ActionExecutionMeta} + */ +export type ActionExecutionContext = Context & + ActionExecutionMeta; + +/** + * Simplified action context for {@link ActionDefinition} + * When defining action consumer may use either it's own Context + * or an ActionExecutionContext to get access to {@link ActionExecutionMeta} params + */ +export type ActionDefinitionContext = + | Context + | ActionExecutionContext; + +export interface Action + extends Partial>> { /** * Determined the order when there is more than one action matched to a trigger. * Higher numbers are displayed first. @@ -44,44 +74,51 @@ export interface Action /** * Optional EUI icon type that can be displayed along with the title. */ - getIconType(context: Context): string | undefined; + getIconType(context: ActionExecutionContext): string | undefined; /** * Returns a title to be displayed to the user. * @param context */ - getDisplayName(context: Context): string; + getDisplayName(context: ActionExecutionContext): string; /** * `UiComponent` to render when displaying this action as a context menu item. * If not provided, `getDisplayName` will be used instead. */ - MenuItem?: UiComponent<{ context: Context }>; + MenuItem?: UiComponent<{ context: ActionExecutionContext }>; /** * Returns a promise that resolves to true if this action is compatible given the context, * otherwise resolves to false. */ - isCompatible(context: Context): Promise; + isCompatible(context: ActionExecutionContext): Promise; /** * Executes the action. */ - execute(context: Context): Promise; + execute(context: ActionExecutionContext): Promise; + + /** + * This method should return a link if this item can be clicked on. The link + * is used to navigate user if user middle-clicks it or Ctrl + clicks or + * right-clicks and selects "Open in new tab". + */ + getHref?(context: ActionExecutionContext): Promise; /** * Determines if action should be executed automatically, * without first showing up in context menu. * false by default. */ - shouldAutoExecute?(context: Context): Promise; + shouldAutoExecute?(context: ActionExecutionContext): Promise; } /** * A convenience interface used to register an action. */ -export interface ActionDefinition - extends Partial> { +export interface ActionDefinition + extends Partial>> { /** * ID of the action that uniquely identifies this action in the actions registry. */ @@ -92,17 +129,30 @@ export interface ActionDefinition */ readonly type?: ActionType; + /** + * Returns a promise that resolves to true if this item is compatible given + * the context and should be displayed to user, otherwise resolves to false. + */ + isCompatible?(context: ActionDefinitionContext): Promise; + /** * Executes the action. */ - execute(context: Context): Promise; + execute(context: ActionDefinitionContext): Promise; /** * Determines if action should be executed automatically, * without first showing up in context menu. * false by default. */ - shouldAutoExecute?(context: Context): Promise; + shouldAutoExecute?(context: ActionDefinitionContext): Promise; + + /** + * This method should return a link if this item can be clicked on. The link + * is used to navigate user if user middle-clicks it or Ctrl + clicks or + * right-clicks and selects "Open in new tab". + */ + getHref?(context: ActionDefinitionContext): Promise; } export type ActionContext = A extends ActionDefinition ? Context : never; diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 7b87a5992a7f50..b44a07273f4a92 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -23,13 +23,22 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { uiToReactComponent } from '../../../kibana_react/public'; import { Action } from '../actions'; +import { Trigger } from '../triggers'; import { BaseContext } from '../types'; export const defaultTitle = i18n.translate('uiActions.actionPanel.title', { defaultMessage: 'Options', }); -type ActionWithContext = [Action, Context]; +interface ActionWithContext { + action: Action; + context: Context; + + /** + * Trigger that caused this action + */ + trigger: Trigger; +} /** * Transforms an array of Actions to the shape EuiContextMenuPanel expects. @@ -66,15 +75,19 @@ async function buildEuiContextMenuPanelItems({ closeMenu: () => void; }) { const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length); - const promises = actions.map(async ([action, actionContext], index) => { - const isCompatible = await action.isCompatible(actionContext); + const promises = actions.map(async ({ action, context, trigger }, index) => { + const isCompatible = await action.isCompatible({ + ...context, + trigger, + }); if (!isCompatible) { return; } items[index] = await convertPanelActionToContextMenuItem({ action, - actionContext, + actionContext: context, + trigger, closeMenu, }); }); @@ -87,19 +100,30 @@ async function buildEuiContextMenuPanelItems({ async function convertPanelActionToContextMenuItem({ action, actionContext, + trigger, closeMenu, }: { action: Action; actionContext: Context; + trigger: Trigger; closeMenu: () => void; }): Promise { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { name: action.MenuItem ? React.createElement(uiToReactComponent(action.MenuItem), { - context: actionContext, + context: { + ...actionContext, + trigger, + }, }) - : action.getDisplayName(actionContext), - icon: action.getIconType(actionContext), + : action.getDisplayName({ + ...actionContext, + trigger, + }), + icon: action.getIconType({ + ...actionContext, + trigger, + }), panel: _.get(action, 'childContextMenuPanel.id'), 'data-test-subj': `embeddablePanelAction-${action.id}`, }; @@ -114,20 +138,29 @@ async function convertPanelActionToContextMenuItem({ !(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) // ignore clicks with modifier keys ) { event.preventDefault(); - action.execute(actionContext); + action.execute({ + ...actionContext, + trigger, + }); } else { // let browser handle navigation } } else { // not a link - action.execute(actionContext); + action.execute({ + ...actionContext, + trigger, + }); } closeMenu(); }; if (action.getHref) { - const href = await action.getHref(actionContext); + const href = await action.getHref({ + ...actionContext, + trigger, + }); if (href) { menuPanelItem.href = href; } diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index a9b413fb36542d..d76ca124ead2c6 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -45,4 +45,9 @@ export { applyFilterTrigger, } from './triggers'; export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types'; -export { ActionByType } from './actions'; +export { + ActionByType, + ActionDefinitionByType, + ActionExecutionContext, + ActionExecutionMeta, +} from './actions'; diff --git a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts index 7393989672e9de..df89c9c2f70e9d 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts @@ -46,7 +46,7 @@ export class UiActionsExecutionService { context: BaseContext; trigger: Trigger; }): Promise { - const shouldBatch = !(await action.shouldAutoExecute?.(context)) ?? false; + const shouldBatch = !(await action.shouldAutoExecute?.({ ...context, trigger })) ?? false; const task: ExecuteActionTask = { action, context, @@ -59,7 +59,7 @@ export class UiActionsExecutionService { } else { this.pendingTasks.add(task); try { - await action.execute(context); + await action.execute({ ...context, trigger }); this.pendingTasks.delete(task); } catch (e) { this.pendingTasks.delete(task); @@ -96,9 +96,12 @@ export class UiActionsExecutionService { }, 0); } - private async executeSingleTask({ context, action, defer }: ExecuteActionTask) { + private async executeSingleTask({ context, action, defer, trigger }: ExecuteActionTask) { try { - await action.execute(context); + await action.execute({ + ...context, + trigger, + }); defer.resolve(); } catch (e) { defer.reject(e); @@ -107,7 +110,11 @@ export class UiActionsExecutionService { private async executeMultipleActions(tasks: ExecuteActionTask[]) { const panel = await buildContextMenuForActions({ - actions: tasks.map(({ action, context }) => [action, context]), + actions: tasks.map(({ action, context, trigger }) => ({ + action, + context, + trigger, + })), title: tasks[0].trigger.title, // title of context menu is title of trigger which originated the chain closeMenu: () => { tasks.forEach((t) => t.defer.resolve()); diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index 08efffbb6b5a8a..6028177964fb77 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -142,7 +142,7 @@ export class UiActionsService { triggerId: T, // The action can accept partial or no context, but if it needs context not provided // by this type of trigger, typescript will complain. yay! - action: Action + action: ActionDefinition | Action // TODO: remove `Action` https://github.com/elastic/kibana/issues/74501 ): void => { if (!this.actions.has(action.id)) this.registerAction(action); this.attachAction(triggerId, action.id); @@ -178,7 +178,14 @@ export class UiActionsService { context: TriggerContextMapping[T] ): Promise>> => { const actions = this.getTriggerActions!(triggerId); - const isCompatibles = await Promise.all(actions.map((action) => action.isCompatible(context))); + const isCompatibles = await Promise.all( + actions.map((action) => + action.isCompatible({ + ...context, + trigger: this.getTrigger(triggerId), + }) + ) + ); return actions.reduce( (acc: Array>, action, i) => isCompatibles[i] ? [...acc, action] : acc, diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index 9af46f25b4fec4..81120990001e34 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -82,7 +82,7 @@ test('executes a single action mapped to a trigger', async () => { jest.runAllTimers(); expect(executeFn).toBeCalledTimes(1); - expect(executeFn).toBeCalledWith(context); + expect(executeFn).toBeCalledWith(expect.objectContaining(context)); }); test('throws an error if there are no compatible actions to execute', async () => { @@ -202,3 +202,25 @@ test("doesn't show a context menu for auto executable actions", async () => { expect(openContextMenu).toHaveBeenCalledTimes(0); }); }); + +test('passes trigger into execute', async () => { + const { setup, doStart } = uiActions; + const trigger = { + id: 'MY-TRIGGER' as TriggerId, + title: 'My trigger', + }; + const action = createTestAction<{ foo: string }>('test', () => true); + + setup.registerTrigger(trigger); + setup.addTriggerAction(trigger.id, action); + + const start = doStart(); + + const context = { foo: 'bar' }; + await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); + jest.runAllTimers(); + expect(executeFn).toBeCalledWith({ + ...context, + trigger, + }); +}); diff --git a/src/plugins/ui_actions/public/triggers/default_trigger.ts b/src/plugins/ui_actions/public/triggers/default_trigger.ts new file mode 100644 index 00000000000000..74be0243bdac58 --- /dev/null +++ b/src/plugins/ui_actions/public/triggers/default_trigger.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Trigger } from '.'; + +export const DEFAULT_TRIGGER = ''; +export const defaultTrigger: Trigger<''> = { + id: DEFAULT_TRIGGER, + title: 'Unknown', + description: 'Unknown trigger.', +}; diff --git a/src/plugins/ui_actions/public/triggers/index.ts b/src/plugins/ui_actions/public/triggers/index.ts index a5bf9e1822941d..dbc54163c5af56 100644 --- a/src/plugins/ui_actions/public/triggers/index.ts +++ b/src/plugins/ui_actions/public/triggers/index.ts @@ -23,3 +23,4 @@ export * from './trigger_internal'; export * from './select_range_trigger'; export * from './value_click_trigger'; export * from './apply_filter_trigger'; +export * from './default_trigger'; diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index 5631441cf9a1bf..dcf0bfb14d5385 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -19,7 +19,12 @@ import { ActionInternal } from './actions/action_internal'; import { TriggerInternal } from './triggers/trigger_internal'; -import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; +import { + SELECT_RANGE_TRIGGER, + VALUE_CLICK_TRIGGER, + APPLY_FILTER_TRIGGER, + DEFAULT_TRIGGER, +} from './triggers'; import type { RangeSelectContext, ValueClickContext } from '../../embeddable/public'; import type { ApplyGlobalFilterActionContext } from '../../data/public'; @@ -27,8 +32,6 @@ export type TriggerRegistry = Map>; export type ActionRegistry = Map; export type TriggerToActionsRegistry = Map; -const DEFAULT_TRIGGER = ''; - export type TriggerId = keyof TriggerContextMapping; export type BaseContext = object; 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 index 037e017097e533..67599687dd881b 100644 --- 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 @@ -10,6 +10,7 @@ import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/publ 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 { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public'; function isValidUrl(url: string) { try { @@ -101,7 +102,15 @@ export class DashboardToUrlDrilldown implements Drilldown return config.url; }; - public readonly execute = async (config: Config, context: ActionContext) => { + 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) { diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index 50f4c26739a862..423fa5512fd91c 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -9,7 +9,10 @@ import { i18n } from '@kbn/i18n'; import _ from 'lodash'; import moment from 'moment-timezone'; import { CoreSetup } from 'src/core/public'; -import { Action, IncompatibleActionError } from '../../../../../src/plugins/ui_actions/public'; +import { + UiActionsActionDefinition as ActionDefinition, + IncompatibleActionError, +} from '../../../../../src/plugins/ui_actions/public'; import { LicensingPluginSetup } from '../../../licensing/public'; import { checkLicense } from '../lib/license_check'; @@ -31,7 +34,7 @@ interface ActionContext { embeddable: ISearchEmbeddable; } -export class GetCsvReportPanelAction implements Action { +export class GetCsvReportPanelAction implements ActionDefinition { private isDownloading: boolean; public readonly type = ''; public readonly id = CSV_REPORTING_ACTION; diff --git a/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_action.tsx b/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_action.tsx index 5d9804d2a5c33c..259fe5c774c4b9 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_action.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_action.tsx @@ -7,7 +7,10 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { IEmbeddable, Embeddable, EmbeddableInput } from 'src/plugins/embeddable/public'; -import { ActionByType, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; +import { + ActionDefinitionByType, + IncompatibleActionError, +} from '../../../../src/plugins/ui_actions/public'; import { TimeRange } from '../../../../src/plugins/data/public'; import { CustomizeTimeRangeModal } from './customize_time_range_modal'; import { OpenModal, CommonlyUsedRange } from './types'; @@ -38,7 +41,7 @@ export interface TimeRangeActionContext { embeddable: Embeddable; } -export class CustomTimeRangeAction implements ActionByType { +export class CustomTimeRangeAction implements ActionDefinitionByType { public readonly type = CUSTOM_TIME_RANGE; private openModal: OpenModal; private dateFormat?: string; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts index a41ae851e185b0..756bdf9e672aac 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts @@ -6,6 +6,7 @@ import { ActionFactoryDefinition } from '../dynamic_actions'; import { LicenseType } from '../../../licensing/public'; +import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public'; /** * This is a convenience interface to register a drilldown. Drilldown has @@ -93,10 +94,16 @@ export interface DrilldownDefinition< * @param context Object that represents context in which the underlying * `UIAction` of this drilldown is being executed in. */ - execute(config: Config, context: ExecutionContext): void; + execute( + config: Config, + context: ExecutionContext | ActionExecutionContext + ): void; /** * A link where drilldown should navigate on middle click or Ctrl + click. */ - getHref?(config: Config, context: ExecutionContext): Promise; + getHref?( + config: Config, + context: ExecutionContext | ActionExecutionContext + ): Promise; }