diff --git a/examples/ui_actions_explorer/public/actions/actions.tsx b/examples/ui_actions_explorer/public/actions/actions.tsx index 6d83362e998bc..777bcd9c18119 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/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index cb02ffc470e95..359cc3108e22e 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -311,7 +311,10 @@ 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 }, + })), closeMenu: this.closeMyContextMenuPanel, }); }; diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index bc5f36acb8f0c..22f0d79dcef11 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -18,12 +18,28 @@ */ 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 interface Action +/** + * During action execution we can provide additional information, + * for example, trigger, that caused the action execution + */ +export interface ActionExecutionMeta { + /** + * Trigger that executed the action + * Optional - since action could be executed without a trigger + */ + trigger?: Trigger; +} + +export type ActionExecutionContext = Context & + ActionExecutionMeta; + +export interface Action extends Partial> { /** * Determined the order when there is more than one action matched to a trigger. @@ -62,12 +78,19 @@ export interface Action * 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: Context | ActionExecutionContext): Promise; /** * Executes the action. */ - execute(context: Context): Promise; + execute(context: 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: Context | ActionExecutionContext): Promise; /** * Determines if action should be executed automatically, @@ -80,7 +103,7 @@ export interface Action /** * A convenience interface used to register an action. */ -export interface ActionDefinition +export interface ActionDefinition extends Partial> { /** * ID of the action that uniquely identifies this action in the actions registry. @@ -92,17 +115,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: Context | ActionExecutionContext): Promise; + /** * Executes the action. */ - execute(context: Context): Promise; + execute(context: 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: 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: Context | ActionExecutionContext): 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 7b87a5992a7f5..d47f591ffefc1 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,18 @@ 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, closeMenu, }); }); @@ -87,10 +99,12 @@ async function buildEuiContextMenuPanelItems({ async function convertPanelActionToContextMenuItem({ action, actionContext, + trigger, closeMenu, }: { action: Action; actionContext: Context; + trigger?: Trigger; closeMenu: () => void; }): Promise { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { @@ -114,20 +128,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 a9b413fb36542..34b6fc3ba0771 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -45,4 +45,4 @@ export { applyFilterTrigger, } from './triggers'; export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types'; -export { ActionByType } from './actions'; +export { ActionByType, 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 7393989672e9d..44326e6aa1e76 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 @@ -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 08efffbb6b5a8..c3094b7dc5c07 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -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 9af46f25b4fec..81120990001e3 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/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 037e017097e53..67599687dd881 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/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts index a41ae851e185b..756bdf9e672aa 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; }