mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
603292ca11
commit
455b33bf87
23 changed files with 277 additions and 68 deletions
|
@ -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<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 {
|
||||
|
|
|
@ -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.`
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<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 },
|
||||
trigger: contextMenuTrigger,
|
||||
})),
|
||||
closeMenu: this.closeMyContextMenuPanel,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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<ActionContext> {
|
|||
return 'plusInCircleFilled';
|
||||
}
|
||||
|
||||
public async isCompatible({ embeddable }: ActionContext) {
|
||||
public async isCompatible(context: ActionExecutionContext<ActionContext>) {
|
||||
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<ActionContext>) {
|
||||
const { embeddable } = context;
|
||||
if (!embeddable.getIsContainer() || !(await this.isCompatible(context))) {
|
||||
throw new Error('Context is incompatible');
|
||||
}
|
||||
|
||||
|
|
|
@ -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<Action<EmbeddableContext>>, embeddable: IEmb
|
|||
<EuiBadge
|
||||
key={badge.id}
|
||||
className="embPanel__headerBadge"
|
||||
iconType={badge.getIconType({ embeddable })}
|
||||
onClick={() => 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 })}
|
||||
</EuiBadge>
|
||||
));
|
||||
}
|
||||
|
@ -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 })}
|
||||
</EuiNotificationBadge>
|
||||
);
|
||||
|
||||
if (notification.getDisplayNameTooltip) {
|
||||
const tooltip = notification.getDisplayNameTooltip(context);
|
||||
const tooltip = notification.getDisplayNameTooltip({
|
||||
...context,
|
||||
trigger: panelNotificationTrigger,
|
||||
});
|
||||
|
||||
if (tooltip) {
|
||||
badge = (
|
||||
|
|
|
@ -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<typeof SAY_HELLO_ACTION> {
|
||||
export class SayHelloAction implements ActionDefinitionByType<typeof SAY_HELLO_ACTION> {
|
||||
public readonly type = SAY_HELLO_ACTION;
|
||||
public readonly id = SAY_HELLO_ACTION;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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<T extends ActionType> = Action<ActionContextMapping[T], T>;
|
||||
export type ActionDefinitionByType<T extends ActionType> = ActionDefinition<
|
||||
ActionContextMapping[T]
|
||||
>;
|
||||
|
||||
export interface Action<Context extends {} = {}, T = ActionType>
|
||||
extends Partial<Presentable<Context>> {
|
||||
/**
|
||||
* 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 extends BaseContext = BaseContext> = Context &
|
||||
ActionExecutionMeta;
|
||||
|
||||
/**
|
||||
* Simplified action context for {@link ActionDefinition}
|
||||
* When defining action consumer may use either it's own Context
|
||||
* or an ActionExecutionContext<Context> to get access to {@link ActionExecutionMeta} params
|
||||
*/
|
||||
export type ActionDefinitionContext<Context extends BaseContext = BaseContext> =
|
||||
| Context
|
||||
| ActionExecutionContext<Context>;
|
||||
|
||||
export interface Action<Context extends BaseContext = {}, T = ActionType>
|
||||
extends Partial<Presentable<ActionExecutionContext<Context>>> {
|
||||
/**
|
||||
* 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<Context extends {} = {}, T = ActionType>
|
|||
/**
|
||||
* Optional EUI icon type that can be displayed along with the title.
|
||||
*/
|
||||
getIconType(context: Context): string | undefined;
|
||||
getIconType(context: ActionExecutionContext<Context>): string | undefined;
|
||||
|
||||
/**
|
||||
* Returns a title to be displayed to the user.
|
||||
* @param context
|
||||
*/
|
||||
getDisplayName(context: Context): string;
|
||||
getDisplayName(context: ActionExecutionContext<Context>): 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<Context> }>;
|
||||
|
||||
/**
|
||||
* 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: ActionExecutionContext<Context>): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Executes the action.
|
||||
*/
|
||||
execute(context: Context): Promise<void>;
|
||||
execute(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: ActionExecutionContext<Context>): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Determines if action should be executed automatically,
|
||||
* without first showing up in context menu.
|
||||
* false by default.
|
||||
*/
|
||||
shouldAutoExecute?(context: Context): Promise<boolean>;
|
||||
shouldAutoExecute?(context: ActionExecutionContext<Context>): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience interface used to register an action.
|
||||
*/
|
||||
export interface ActionDefinition<Context extends object = object>
|
||||
extends Partial<Presentable<Context>> {
|
||||
export interface ActionDefinition<Context extends BaseContext = {}>
|
||||
extends Partial<Presentable<ActionDefinitionContext<Context>>> {
|
||||
/**
|
||||
* ID of the action that uniquely identifies this action in the actions registry.
|
||||
*/
|
||||
|
@ -92,17 +129,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: ActionDefinitionContext<Context>): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Executes the action.
|
||||
*/
|
||||
execute(context: Context): Promise<void>;
|
||||
execute(context: ActionDefinitionContext<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: ActionDefinitionContext<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: ActionDefinitionContext<Context>): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
export type ActionContext<A> = A extends ActionDefinition<infer Context> ? Context : never;
|
||||
|
|
|
@ -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.
|
||||
|
@ -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<Context extends object>({
|
||||
action,
|
||||
actionContext,
|
||||
trigger,
|
||||
closeMenu,
|
||||
}: {
|
||||
action: Action<Context>;
|
||||
actionContext: Context;
|
||||
trigger: Trigger;
|
||||
closeMenu: () => void;
|
||||
}): Promise<EuiContextMenuPanelItemDescriptor> {
|
||||
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<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;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -46,7 +46,7 @@ export class UiActionsExecutionService {
|
|||
context: BaseContext;
|
||||
trigger: Trigger;
|
||||
}): Promise<void> {
|
||||
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());
|
||||
|
|
|
@ -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<TriggerContextMapping[T]>
|
||||
action: ActionDefinition<TriggerContextMapping[T]> | Action<TriggerContextMapping[T]> // 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<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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
27
src/plugins/ui_actions/public/triggers/default_trigger.ts
Normal file
27
src/plugins/ui_actions/public/triggers/default_trigger.ts
Normal file
|
@ -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.',
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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<TriggerId, TriggerInternal<any>>;
|
|||
export type ActionRegistry = Map<string, ActionInternal>;
|
||||
export type TriggerToActionsRegistry = Map<TriggerId, string[]>;
|
||||
|
||||
const DEFAULT_TRIGGER = '';
|
||||
|
||||
export type TriggerId = keyof TriggerContextMapping;
|
||||
|
||||
export type BaseContext = object;
|
||||
|
|
|
@ -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<Config, ActionContext>
|
|||
return config.url;
|
||||
};
|
||||
|
||||
public readonly execute = async (config: Config, context: ActionContext) => {
|
||||
public readonly execute = async (
|
||||
config: Config,
|
||||
context: ActionExecutionContext<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) {
|
||||
|
|
|
@ -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<ActionContext> {
|
||||
export class GetCsvReportPanelAction implements ActionDefinition<ActionContext> {
|
||||
private isDownloading: boolean;
|
||||
public readonly type = '';
|
||||
public readonly id = CSV_REPORTING_ACTION;
|
||||
|
|
|
@ -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<TimeRangeInput>;
|
||||
}
|
||||
|
||||
export class CustomTimeRangeAction implements ActionByType<typeof CUSTOM_TIME_RANGE> {
|
||||
export class CustomTimeRangeAction implements ActionDefinitionByType<typeof CUSTOM_TIME_RANGE> {
|
||||
public readonly type = CUSTOM_TIME_RANGE;
|
||||
private openModal: OpenModal;
|
||||
private dateFormat?: string;
|
||||
|
|
|
@ -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<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>;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue