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
4 changes: 3 additions & 1 deletion src/plugins/ui_actions/public/actions/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@

import { createAction } from '../../../ui_actions/public';
import { ActionType } from '../types';
import { ActionExecutionContext } from './action';

const sayHelloAction = createAction({
// Casting to ActionType is a hack - in a real situation use
// declare module and add this id to ActionContextMapping.
type: 'test' as ActionType,
isCompatible: ({ amICompatible }: { amICompatible: boolean }) => Promise.resolve(amICompatible),
isCompatible: ({ amICompatible }: ActionExecutionContext<{ amICompatible: boolean }>) =>
Promise.resolve(amICompatible),
execute: () => Promise.resolve(),
});

Expand Down
48 changes: 42 additions & 6 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
* Since action could be trigger witout
*/
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,18 +78,25 @@ 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>;
}

/**
* 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 @@ -85,10 +108,23 @@ 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>;

/**
* 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,6 +23,7 @@ import _ from 'lodash';
import { i18n } from '@kbn/i18n';
import { uiToReactComponent } from '../../../kibana_react/public';
import { Action } from '../actions';
import { Trigger } from '../triggers';

export const defaultTitle = i18n.translate('uiActions.actionPanel.title', {
defaultMessage: 'Options',
Expand All @@ -34,17 +35,20 @@ export const defaultTitle = i18n.translate('uiActions.actionPanel.title', {
export async function buildContextMenuForActions<Context extends object>({
actions,
actionContext,
trigger,
title = defaultTitle,
closeMenu,
}: {
actions: Array<Action<Context>>;
actionContext: Context;
trigger?: Trigger;
title?: string;
closeMenu: () => void;
}): Promise<EuiContextMenuPanelDescriptor> {
const menuItems = await buildEuiContextMenuPanelItems<Context>({
actions,
actionContext,
trigger,
closeMenu,
});

Expand All @@ -61,22 +65,28 @@ export async function buildContextMenuForActions<Context extends object>({
async function buildEuiContextMenuPanelItems<Context extends object>({
actions,
actionContext,
trigger,
closeMenu,
}: {
actions: Array<Action<Context>>;
actionContext: Context;
trigger?: Trigger;
closeMenu: () => void;
}) {
const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length);
const promises = actions.map(async (action, index) => {
const isCompatible = await action.isCompatible(actionContext);
const isCompatible = await action.isCompatible({
...actionContext,
trigger,
});
if (!isCompatible) {
return;
}

items[index] = await convertPanelActionToContextMenuItem({
action,
actionContext,
trigger,
closeMenu,
});
});
Expand All @@ -89,10 +99,12 @@ async function buildEuiContextMenuPanelItems<Context extends object>({
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 @@ -116,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';
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 @@ -176,7 +176,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
6 changes: 5 additions & 1 deletion src/plugins/ui_actions/public/triggers/trigger_internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ export class TriggerInternal<T extends TriggerId> {
action: Action<TriggerContextMapping[T]>,
context: TriggerContextMapping[T]
) {
await action.execute(context);
await action.execute({
...context,
trigger: this.trigger,
});
}

private async executeMultipleActions(
Expand All @@ -67,6 +70,7 @@ export class TriggerInternal<T extends TriggerId> {
actionContext: context,
title: this.trigger.title,
closeMenu: () => session.close(),
trigger: this.trigger,
});
const session = openContextMenu([panel], {
'data-test-subj': 'multipleActionsContextMenu',
Expand Down
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>;
}