Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[uiActions] pass trigger into action handlers #71675

Closed
wants to merge 11 commits into from
9 changes: 7 additions & 2 deletions examples/ui_actions_explorer/public/actions/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,7 +41,8 @@ export const ACTION_SHOWCASE_PLUGGABILITY = 'ACTION_SHOWCASE_PLUGGABILITY';
export const showcasePluggability = createAction<typeof ACTION_SHOWCASE_PLUGGABILITY>({
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 {
Expand Down
5 changes: 4 additions & 1 deletion src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,10 @@ export class EmbeddablePanel extends React.Component<Props, State> {
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,
});
};
Expand Down
50 changes: 43 additions & 7 deletions src/plugins/ui_actions/public/actions/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends ActionType> = Action<ActionContextMapping[T], T>;

export interface Action<Context extends {} = {}, T = ActionType>
/**
* 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 extends BaseContext = BaseContext> = Context &
ActionExecutionMeta;

export interface Action<Context extends BaseContext = {}, T = ActionType>
extends Partial<Presentable<Context>> {
/**
* Determined the order when there is more than one action matched to a trigger.
Expand Down Expand Up @@ -62,12 +78,19 @@ export interface Action<Context extends {} = {}, T = ActionType>
* Returns a promise that resolves to true if this action is compatible given the context,
* otherwise resolves to false.
*/
isCompatible(context: Context): Promise<boolean>;
isCompatible(context: Context | ActionExecutionContext<Context>): Promise<boolean>;

/**
* Executes the action.
*/
execute(context: Context): Promise<void>;
execute(context: Context | ActionExecutionContext<Context>): Promise<void>;

/**
* 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<Context>): Promise<string | undefined>;

/**
* Determines if action should be executed automatically,
Expand All @@ -80,7 +103,7 @@ export interface Action<Context extends {} = {}, T = ActionType>
/**
* A convenience interface used to register an action.
*/
export interface ActionDefinition<Context extends object = object>
export interface ActionDefinition<Context extends BaseContext = {}>
extends Partial<Presentable<Context>> {
/**
* ID of the action that uniquely identifies this action in the actions registry.
Expand All @@ -92,17 +115,30 @@ export interface ActionDefinition<Context extends object = object>
*/
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<Context>): Promise<boolean>;

/**
* Executes the action.
*/
execute(context: Context): Promise<void>;
execute(context: Context | ActionExecutionContext<Context>): Promise<void>;

/**
* Determines if action should be executed automatically,
* without first showing up in context menu.
* false by default.
*/
shouldAutoExecute?(context: Context): Promise<boolean>;
shouldAutoExecute?(context: Context | ActionExecutionContext<Context>): Promise<boolean>;

/**
* 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<Context>): Promise<string | undefined>;
}

export type ActionContext<A> = A extends ActionDefinition<infer Context> ? Context : never;
Original file line number Diff line number Diff line change
Expand Up @@ -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<Context extends BaseContext = BaseContext> = [Action<Context>, Context];
interface ActionWithContext<Context extends BaseContext = BaseContext> {
action: Action<Context>;
context: Context;

/**
* Trigger that caused this action
*/
trigger?: Trigger;
}

/**
* Transforms an array of Actions to the shape EuiContextMenuPanel expects.
Expand Down Expand Up @@ -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,
});
});
Expand All @@ -87,10 +99,12 @@ async function buildEuiContextMenuPanelItems({
async function convertPanelActionToContextMenuItem<Context extends object>({
action,
actionContext,
trigger,
closeMenu,
}: {
action: Action<Context>;
actionContext: Context;
trigger?: Trigger;
closeMenu: () => void;
}): Promise<EuiContextMenuPanelItemDescriptor> {
const menuPanelItem: EuiContextMenuPanelItemDescriptor = {
Expand All @@ -114,20 +128,29 @@ async function convertPanelActionToContextMenuItem<Context extends object>({
!(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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/ui_actions/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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());
Expand Down
9 changes: 8 additions & 1 deletion src/plugins/ui_actions/public/service/ui_actions_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,14 @@ export class UiActionsService {
context: TriggerContextMapping[T]
): Promise<Array<Action<TriggerContextMapping[T]>>> => {
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<TriggerContextMapping[T]>>, action, i) =>
isCompatibles[i] ? [...acc, action] : acc,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -101,7 +102,15 @@ export class DashboardToUrlDrilldown implements Drilldown<Config, ActionContext>
return config.url;
};

public readonly execute = async (config: Config, context: ActionContext) => {
public readonly execute = async (
config: Config,
context: ActionExecutionContext<ActionContext>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want tot use trigger, we "specify" special type here which is an extension around regular ActionContext

) => {
// 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ExecutionContext>
): void;

/**
* A link where drilldown should navigate on middle click or Ctrl + click.
*/
getHref?(config: Config, context: ExecutionContext): Promise<string | undefined>;
getHref?(
config: Config,
context: ExecutionContext | ActionExecutionContext<ExecutionContext>
): Promise<string | undefined>;
}