[uiActions] Support emitting nested triggers and actions (#70602)

* Introduce automatically executed actions
* Introduce batching of emitted triggers to be execute on the macro task
This commit is contained in:
Anton Dosov 2020-07-15 16:44:11 +02:00 committed by GitHub
parent 0173ef3528
commit 1ac56d7bfc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 368 additions and 215 deletions

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) &gt; [embeddable](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md)
## ApplyGlobalFilterActionContext.embeddable property
<b>Signature:</b>
```typescript
embeddable?: IEmbeddable;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) &gt; [filters](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md)
## ApplyGlobalFilterActionContext.filters property
<b>Signature:</b>
```typescript
filters: Filter[];
```

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md)
## ApplyGlobalFilterActionContext interface
<b>Signature:</b>
```typescript
export interface ApplyGlobalFilterActionContext
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [embeddable](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md) | <code>IEmbeddable</code> | |
| [filters](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md) | <code>Filter[]</code> | |
| [timeFieldName](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md) | <code>string</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) &gt; [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) &gt; [timeFieldName](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md)
## ApplyGlobalFilterActionContext.timeFieldName property
<b>Signature:</b>
```typescript
timeFieldName?: string;
```

View file

@ -48,6 +48,7 @@
| Interface | Description |
| --- | --- |
| [AggParamOption](./kibana-plugin-plugins-data-public.aggparamoption.md) | |
| [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) | |
| [DataPublicPluginSetup](./kibana-plugin-plugins-data-public.datapublicpluginsetup.md) | |
| [DataPublicPluginStart](./kibana-plugin-plugins-data-public.datapublicpluginstart.md) | |
| [EsQueryConfig](./kibana-plugin-plugins-data-public.esqueryconfig.md) | |

View file

@ -7,14 +7,14 @@
<b>Signature:</b>
```typescript
setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup;
setup(core: CoreSetup<DataStartDependencies, DataPublicPluginStart>, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| core | <code>CoreSetup</code> | |
| core | <code>CoreSetup&lt;DataStartDependencies, DataPublicPluginStart&gt;</code> | |
| { expressions, uiActions, usageCollection } | <code>DataSetupDependencies</code> | |
<b>Returns:</b>

View file

@ -31,7 +31,7 @@ export const ACTION_VIEW_IN_MAPS = 'ACTION_VIEW_IN_MAPS';
export const ACTION_TRAVEL_GUIDE = 'ACTION_TRAVEL_GUIDE';
export const ACTION_CALL_PHONE_NUMBER = 'ACTION_CALL_PHONE_NUMBER';
export const ACTION_EDIT_USER = 'ACTION_EDIT_USER';
export const ACTION_PHONE_USER = 'ACTION_PHONE_USER';
export const ACTION_TRIGGER_PHONE_USER = 'ACTION_TRIGGER_PHONE_USER';
export const ACTION_SHOWCASE_PLUGGABILITY = 'ACTION_SHOWCASE_PLUGGABILITY';
export const showcasePluggability = createAction<typeof ACTION_SHOWCASE_PLUGGABILITY>({
@ -120,19 +120,13 @@ export interface UserContext {
update: (user: User) => void;
}
export const createPhoneUserAction = (getUiActionsApi: () => Promise<UiActionsStart>) =>
createAction<typeof ACTION_PHONE_USER>({
type: ACTION_PHONE_USER,
export const createTriggerPhoneTriggerAction = (getUiActionsApi: () => Promise<UiActionsStart>) =>
createAction<typeof ACTION_TRIGGER_PHONE_USER>({
type: ACTION_TRIGGER_PHONE_USER,
getDisplayName: () => 'Call phone number',
shouldAutoExecute: async () => true,
isCompatible: async ({ user }) => user.phone !== undefined,
execute: async ({ user }) => {
// One option - execute the more specific action directly.
// makePhoneCallAction.execute({ phone: user.phone });
// Another option - emit the trigger and automatically get *all* the actions attached
// to the phone number trigger.
// TODO: we need to figure out the best way to handle these nested actions however, since
// we don't want multiple context menu's to pop up.
if (user.phone !== undefined) {
(await getUiActionsApi()).executeTriggerActions(PHONE_TRIGGER, { phone: user.phone });
}

View file

@ -23,7 +23,6 @@ import {
PHONE_TRIGGER,
USER_TRIGGER,
COUNTRY_TRIGGER,
createPhoneUserAction,
lookUpWeatherAction,
viewInMapsAction,
createEditUserAction,
@ -37,7 +36,8 @@ import {
ACTION_CALL_PHONE_NUMBER,
ACTION_TRAVEL_GUIDE,
ACTION_VIEW_IN_MAPS,
ACTION_PHONE_USER,
ACTION_TRIGGER_PHONE_USER,
createTriggerPhoneTriggerAction,
} from './actions/actions';
import { DeveloperExamplesSetup } from '../../developer_examples/public';
import image from './ui_actions.png';
@ -64,7 +64,7 @@ declare module '../../../src/plugins/ui_actions/public' {
[ACTION_CALL_PHONE_NUMBER]: PhoneContext;
[ACTION_TRAVEL_GUIDE]: CountryContext;
[ACTION_VIEW_IN_MAPS]: CountryContext;
[ACTION_PHONE_USER]: UserContext;
[ACTION_TRIGGER_PHONE_USER]: UserContext;
}
}
@ -84,7 +84,7 @@ export class UiActionsExplorerPlugin implements Plugin<void, void, {}, StartDeps
deps.uiActions.addTriggerAction(
USER_TRIGGER,
createPhoneUserAction(async () => (await startServices)[1].uiActions)
createTriggerPhoneTriggerAction(async () => (await startServices)[1].uiActions)
);
deps.uiActions.addTriggerAction(
USER_TRIGGER,

View file

@ -22,6 +22,7 @@ import { toMountPoint } from '../../../kibana_react/public';
import { ActionByType, createAction, IncompatibleActionError } from '../../../ui_actions/public';
import { getOverlays, getIndexPatterns } from '../services';
import { applyFiltersPopover } from '../ui/apply_filters';
import type { IEmbeddable } from '../../../embeddable/public';
import { Filter, FilterManager, TimefilterContract, esFilters } from '..';
export const ACTION_GLOBAL_APPLY_FILTER = 'ACTION_GLOBAL_APPLY_FILTER';
@ -29,6 +30,7 @@ export const ACTION_GLOBAL_APPLY_FILTER = 'ACTION_GLOBAL_APPLY_FILTER';
export interface ApplyGlobalFilterActionContext {
filters: Filter[];
timeFieldName?: string;
embeddable?: IEmbeddable;
}
async function isCompatible(context: ApplyGlobalFilterActionContext) {

View file

@ -22,7 +22,7 @@ import moment from 'moment';
import { esFilters, IFieldType, RangeFilterParams } from '../../../public';
import { getIndexPatterns } from '../../../public/services';
import { deserializeAggConfig } from '../../search/expressions/utils';
import { RangeSelectContext } from '../../../../embeddable/public';
import type { RangeSelectContext } from '../../../../embeddable/public';
export async function createFiltersFromRangeSelectAction(event: RangeSelectContext['data']) {
const column: Record<string, any> = event.table.columns[event.column];

View file

@ -21,7 +21,7 @@ import { KibanaDatatable } from '../../../../../plugins/expressions/public';
import { deserializeAggConfig } from '../../search/expressions';
import { esFilters, Filter } from '../../../public';
import { getIndexPatterns } from '../../../public/services';
import { ValueClickContext } from '../../../../embeddable/public';
import type { ValueClickContext } from '../../../../embeddable/public';
/**
* For terms aggregations on `__other__` buckets, this assembles a list of applicable filter

View file

@ -17,8 +17,12 @@
* under the License.
*/
export { ACTION_GLOBAL_APPLY_FILTER, createFilterAction } from './apply_filter_action';
export {
ACTION_GLOBAL_APPLY_FILTER,
createFilterAction,
ApplyGlobalFilterActionContext,
} from './apply_filter_action';
export { createFiltersFromValueClickAction } from './filters/create_filters_from_value_click';
export { createFiltersFromRangeSelectAction } from './filters/create_filters_from_range_select';
export { selectRangeAction } from './select_range_action';
export { valueClickAction } from './value_click_action';
export * from './select_range_action';
export * from './value_click_action';

View file

@ -17,60 +17,39 @@
* under the License.
*/
import { i18n } from '@kbn/i18n';
import {
createAction,
IncompatibleActionError,
ActionByType,
APPLY_FILTER_TRIGGER,
createAction,
UiActionsStart,
} from '../../../../plugins/ui_actions/public';
import { createFiltersFromRangeSelectAction } from './filters/create_filters_from_range_select';
import { RangeSelectContext } from '../../../embeddable/public';
import { FilterManager, TimefilterContract, esFilters } from '..';
export const ACTION_SELECT_RANGE = 'ACTION_SELECT_RANGE';
import type { RangeSelectContext } from '../../../embeddable/public';
export type SelectRangeActionContext = RangeSelectContext;
async function isCompatible(context: SelectRangeActionContext) {
try {
return Boolean(await createFiltersFromRangeSelectAction(context.data));
} catch {
return false;
}
}
export const ACTION_SELECT_RANGE = 'ACTION_SELECT_RANGE';
export function selectRangeAction(
filterManager: FilterManager,
timeFilter: TimefilterContract
export function createSelectRangeAction(
getStartServices: () => { uiActions: UiActionsStart }
): ActionByType<typeof ACTION_SELECT_RANGE> {
return createAction<typeof ACTION_SELECT_RANGE>({
type: ACTION_SELECT_RANGE,
id: ACTION_SELECT_RANGE,
getIconType: () => 'filter',
getDisplayName: () => {
return i18n.translate('data.filter.applyFilterActionTitle', {
defaultMessage: 'Apply filter to current view',
});
},
isCompatible,
execute: async ({ data }: SelectRangeActionContext) => {
if (!(await isCompatible({ data }))) {
throw new IncompatibleActionError();
}
const selectedFilters = await createFiltersFromRangeSelectAction(data);
if (data.timeFieldName) {
const { timeRangeFilter, restOfFilters } = esFilters.extractTimeFilter(
data.timeFieldName,
selectedFilters
);
filterManager.addFilters(restOfFilters);
if (timeRangeFilter) {
esFilters.changeTimeFilter(timeFilter, timeRangeFilter);
shouldAutoExecute: async () => true,
execute: async (context: SelectRangeActionContext) => {
try {
const filters = await createFiltersFromRangeSelectAction(context.data);
if (filters.length > 0) {
await getStartServices().uiActions.getTrigger(APPLY_FILTER_TRIGGER).exec({
filters,
embeddable: context.embeddable,
timeFieldName: context.data.timeFieldName,
});
}
} else {
filterManager.addFilters(selectedFilters);
} catch (e) {
// eslint-disable-next-line no-console
console.warn(`Error [ACTION_SELECT_RANGE]: can\'t extract filters from action context`);
}
},
});

View file

@ -17,98 +17,41 @@
* under the License.
*/
import { i18n } from '@kbn/i18n';
import { toMountPoint } from '../../../../plugins/kibana_react/public';
import {
ActionByType,
APPLY_FILTER_TRIGGER,
createAction,
IncompatibleActionError,
UiActionsStart,
} from '../../../../plugins/ui_actions/public';
import { getOverlays, getIndexPatterns } from '../services';
import { applyFiltersPopover } from '../ui/apply_filters';
import { createFiltersFromValueClickAction } from './filters/create_filters_from_value_click';
import { ValueClickContext } from '../../../embeddable/public';
import { Filter, FilterManager, TimefilterContract, esFilters } from '..';
export const ACTION_VALUE_CLICK = 'ACTION_VALUE_CLICK';
import type { Filter } from '../../common/es_query/filters';
import type { ValueClickContext } from '../../../embeddable/public';
export type ValueClickActionContext = ValueClickContext;
export const ACTION_VALUE_CLICK = 'ACTION_VALUE_CLICK';
async function isCompatible(context: ValueClickActionContext) {
try {
const filters: Filter[] = await createFiltersFromValueClickAction(context.data);
return filters.length > 0;
} catch {
return false;
}
}
export function valueClickAction(
filterManager: FilterManager,
timeFilter: TimefilterContract
export function createValueClickAction(
getStartServices: () => { uiActions: UiActionsStart }
): ActionByType<typeof ACTION_VALUE_CLICK> {
return createAction<typeof ACTION_VALUE_CLICK>({
type: ACTION_VALUE_CLICK,
id: ACTION_VALUE_CLICK,
getIconType: () => 'filter',
getDisplayName: () => {
return i18n.translate('data.filter.applyFilterActionTitle', {
defaultMessage: 'Apply filter to current view',
});
},
isCompatible,
execute: async ({ data }: ValueClickActionContext) => {
if (!(await isCompatible({ data }))) {
throw new IncompatibleActionError();
}
const filters: Filter[] = await createFiltersFromValueClickAction(data);
let selectedFilters = filters;
if (filters.length > 1) {
const indexPatterns = await Promise.all(
filters.map((filter) => {
return getIndexPatterns().get(filter.meta.index!);
})
);
const filterSelectionPromise: Promise<Filter[]> = new Promise((resolve) => {
const overlay = getOverlays().openModal(
toMountPoint(
applyFiltersPopover(
filters,
indexPatterns,
() => {
overlay.close();
resolve([]);
},
(filterSelection: Filter[]) => {
overlay.close();
resolve(filterSelection);
}
)
),
{
'data-test-subj': 'selectFilterOverlay',
}
);
});
selectedFilters = await filterSelectionPromise;
}
if (data.timeFieldName) {
const { timeRangeFilter, restOfFilters } = esFilters.extractTimeFilter(
data.timeFieldName,
selectedFilters
);
filterManager.addFilters(restOfFilters);
if (timeRangeFilter) {
esFilters.changeTimeFilter(timeFilter, timeRangeFilter);
shouldAutoExecute: async () => true,
execute: async (context: ValueClickActionContext) => {
try {
const filters: Filter[] = await createFiltersFromValueClickAction(context.data);
if (filters.length > 0) {
await getStartServices().uiActions.getTrigger(APPLY_FILTER_TRIGGER).exec({
filters,
embeddable: context.embeddable,
timeFieldName: context.data.timeFieldName,
});
}
} else {
filterManager.addFilters(selectedFilters);
} catch (e) {
// eslint-disable-next-line no-console
console.warn(
`Error [ACTION_EMIT_APPLY_FILTER_TRIGGER]: can\'t extract filters from action context`
);
}
},
});

View file

@ -438,6 +438,8 @@ export {
export { isTimeRange, isQuery, isFilter, isFilters } from '../common';
export { ApplyGlobalFilterActionContext } from './actions';
export * from '../common/field_mapping';
/*

View file

@ -69,18 +69,15 @@ import {
createFilterAction,
createFiltersFromValueClickAction,
createFiltersFromRangeSelectAction,
} from './actions';
import { ApplyGlobalFilterActionContext } from './actions/apply_filter_action';
import {
selectRangeAction,
SelectRangeActionContext,
ApplyGlobalFilterActionContext,
ACTION_SELECT_RANGE,
} from './actions/select_range_action';
import {
valueClickAction,
ACTION_VALUE_CLICK,
SelectRangeActionContext,
ValueClickActionContext,
} from './actions/value_click_action';
createValueClickAction,
createSelectRangeAction,
} from './actions';
import { SavedObjectsClientPublicToCommon } from './index_patterns';
import { indexPatternLoad } from './index_patterns/expressions/load_index_pattern';
@ -92,7 +89,14 @@ declare module '../../ui_actions/public' {
}
}
export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPublicPluginStart> {
export class DataPublicPlugin
implements
Plugin<
DataPublicPluginSetup,
DataPublicPluginStart,
DataSetupDependencies,
DataStartDependencies
> {
private readonly autocomplete: AutocompleteService;
private readonly searchService: SearchService;
private readonly fieldFormatsService: FieldFormatsService;
@ -110,13 +114,13 @@ export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPubli
}
public setup(
core: CoreSetup,
core: CoreSetup<DataStartDependencies, DataPublicPluginStart>,
{ expressions, uiActions, usageCollection }: DataSetupDependencies
): DataPublicPluginSetup {
const startServices = createStartServicesGetter(core.getStartServices);
const getInternalStartServices = (): InternalStartServices => {
const { core: coreStart, self }: any = startServices();
const { core: coreStart, self } = startServices();
return {
fieldFormats: self.fieldFormats,
notifications: coreStart.notifications,
@ -140,12 +144,16 @@ export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPubli
uiActions.addTriggerAction(
SELECT_RANGE_TRIGGER,
selectRangeAction(queryService.filterManager, queryService.timefilter.timefilter)
createSelectRangeAction(() => ({
uiActions: startServices().plugins.uiActions,
}))
);
uiActions.addTriggerAction(
VALUE_CLICK_TRIGGER,
valueClickAction(queryService.filterManager, queryService.timefilter.timefilter)
createValueClickAction(() => ({
uiActions: startServices().plugins.uiActions,
}))
);
return {

View file

@ -250,6 +250,20 @@ export class AggParamType<TAggConfig extends IAggConfig = IAggConfig> extends Ba
makeAgg: (agg: TAggConfig, state?: AggConfigSerialized) => TAggConfig;
}
// Warning: (ae-missing-release-tag) "ApplyGlobalFilterActionContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface ApplyGlobalFilterActionContext {
// Warning: (ae-forgotten-export) The symbol "IEmbeddable" needs to be exported by the entry point index.d.ts
//
// (undocumented)
embeddable?: IEmbeddable;
// (undocumented)
filters: Filter[];
// (undocumented)
timeFieldName?: string;
}
// Warning: (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "DateFormat" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "baseFormattersPublic" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@ -1443,18 +1457,16 @@ export type PhrasesFilter = Filter & {
meta: PhrasesFilterMeta;
};
// Warning: (ae-forgotten-export) The symbol "DataSetupDependencies" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "DataStartDependencies" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "DataPublicPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export class Plugin implements Plugin_2<DataPublicPluginSetup, DataPublicPluginStart> {
export class Plugin implements Plugin_2<DataPublicPluginSetup, DataPublicPluginStart, DataSetupDependencies, DataStartDependencies> {
// Warning: (ae-forgotten-export) The symbol "ConfigSchema" needs to be exported by the entry point index.d.ts
constructor(initializerContext: PluginInitializerContext_2<ConfigSchema>);
// Warning: (ae-forgotten-export) The symbol "DataSetupDependencies" needs to be exported by the entry point index.d.ts
//
// (undocumented)
setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup;
// Warning: (ae-forgotten-export) The symbol "DataStartDependencies" needs to be exported by the entry point index.d.ts
//
setup(core: CoreSetup<DataStartDependencies, DataPublicPluginStart>, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup;
// (undocumented)
start(core: CoreStart_2, { uiActions }: DataStartDependencies): DataPublicPluginStart;
// (undocumented)

View file

@ -311,8 +311,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField);
return await buildContextMenuForActions({
actions: sortedActions,
actionContext: { embeddable: this.props.embeddable },
actions: sortedActions.map((action) => [action, { embeddable: this.props.embeddable }]),
closeMenu: this.closeMyContextMenuPanel,
});
};

View file

@ -7,6 +7,7 @@
"public/tests/test_samples"
],
"requiredBundles": [
"kibanaUtils",
"kibanaReact"
]
}

View file

@ -68,6 +68,13 @@ export interface Action<Context extends {} = {}, T = ActionType>
* Executes the action.
*/
execute(context: 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>;
}
/**
@ -89,6 +96,13 @@ export interface ActionDefinition<Context extends object = object>
* Executes the action.
*/
execute(context: 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>;
}
export type ActionContext<A> = A extends ActionDefinition<infer Context> ? Context : never;

View file

@ -65,4 +65,9 @@ export class ActionInternal<A extends ActionDefinition = ActionDefinition>
if (!this.definition.getHref) return undefined;
return await this.definition.getHref(context);
}
public async shouldAutoExecute(context: Context<A>): Promise<boolean> {
if (!this.definition.shouldAutoExecute) return false;
return this.definition.shouldAutoExecute(context);
}
}

View file

@ -23,28 +23,28 @@ import _ from 'lodash';
import { i18n } from '@kbn/i18n';
import { uiToReactComponent } from '../../../kibana_react/public';
import { Action } from '../actions';
import { BaseContext } from '../types';
export const defaultTitle = i18n.translate('uiActions.actionPanel.title', {
defaultMessage: 'Options',
});
type ActionWithContext<Context extends BaseContext = BaseContext> = [Action<Context>, Context];
/**
* Transforms an array of Actions to the shape EuiContextMenuPanel expects.
*/
export async function buildContextMenuForActions<Context extends object>({
export async function buildContextMenuForActions({
actions,
actionContext,
title = defaultTitle,
closeMenu,
}: {
actions: Array<Action<Context>>;
actionContext: Context;
actions: ActionWithContext[];
title?: string;
closeMenu: () => void;
}): Promise<EuiContextMenuPanelDescriptor> {
const menuItems = await buildEuiContextMenuPanelItems<Context>({
const menuItems = await buildEuiContextMenuPanelItems({
actions,
actionContext,
closeMenu,
});
@ -58,17 +58,15 @@ export async function buildContextMenuForActions<Context extends object>({
/**
* Transform an array of Actions into the shape needed to build an EUIContextMenu
*/
async function buildEuiContextMenuPanelItems<Context extends object>({
async function buildEuiContextMenuPanelItems({
actions,
actionContext,
closeMenu,
}: {
actions: Array<Action<Context>>;
actionContext: Context;
actions: ActionWithContext[];
closeMenu: () => void;
}) {
const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length);
const promises = actions.map(async (action, index) => {
const promises = actions.map(async ([action, actionContext], index) => {
const isCompatible = await action.isCompatible(actionContext);
if (!isCompatible) {
return;

View file

@ -0,0 +1,121 @@
/*
* 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 { uniqBy } from 'lodash';
import { Action } from '../actions';
import { BaseContext } from '../types';
import { defer as createDefer, Defer } from '../../../kibana_utils/public';
import { buildContextMenuForActions, openContextMenu } from '../context_menu';
import { Trigger } from '../triggers';
interface ExecuteActionTask {
action: Action;
context: BaseContext;
trigger: Trigger;
defer: Defer<void>;
}
export class UiActionsExecutionService {
private readonly batchingQueue: ExecuteActionTask[] = [];
private readonly pendingTasks = new Set<ExecuteActionTask>();
constructor() {}
async execute({
action,
context,
trigger,
}: {
action: Action<BaseContext>;
context: BaseContext;
trigger: Trigger;
}): Promise<void> {
const shouldBatch = !(await action.shouldAutoExecute?.(context)) ?? false;
const task: ExecuteActionTask = {
action,
context,
trigger,
defer: createDefer(),
};
if (shouldBatch) {
this.batchingQueue.push(task);
} else {
this.pendingTasks.add(task);
try {
await action.execute(context);
this.pendingTasks.delete(task);
} catch (e) {
this.pendingTasks.delete(task);
throw new Error(e);
}
}
this.scheduleFlush();
return task.defer.promise;
}
private scheduleFlush() {
/**
* Have to delay at least until next macro task
* Otherwise chain:
* Trigger -> await action.execute() -> trigger -> action
* isn't batched
*
* This basically needed to support a chain of scheduled micro tasks (async/awaits) within uiActions code
*/
setTimeout(() => {
if (this.pendingTasks.size === 0) {
const tasks = uniqBy(this.batchingQueue, (t) => t.action.id);
if (tasks.length === 1) {
this.executeSingleTask(tasks[0]);
}
if (tasks.length > 1) {
this.executeMultipleActions(tasks);
}
this.batchingQueue.splice(0, this.batchingQueue.length);
}
}, 0);
}
private async executeSingleTask({ context, action, defer }: ExecuteActionTask) {
try {
await action.execute(context);
defer.resolve();
} catch (e) {
defer.reject(e);
}
}
private async executeMultipleActions(tasks: ExecuteActionTask[]) {
const panel = await buildContextMenuForActions({
actions: tasks.map(({ action, context }) => [action, context]),
title: tasks[0].trigger.title, // title of context menu is title of trigger which originated the chain
closeMenu: () => {
tasks.forEach((t) => t.defer.resolve());
session.close();
},
});
const session = openContextMenu([panel], {
'data-test-subj': 'multipleActionsContextMenu',
});
}
}

View file

@ -28,6 +28,7 @@ import { ActionInternal, Action, ActionDefinition, ActionContext } from '../acti
import { Trigger, TriggerContext } from '../triggers/trigger';
import { TriggerInternal } from '../triggers/trigger_internal';
import { TriggerContract } from '../triggers/trigger_contract';
import { UiActionsExecutionService } from './ui_actions_execution_service';
export interface UiActionsServiceParams {
readonly triggers?: TriggerRegistry;
@ -40,6 +41,7 @@ export interface UiActionsServiceParams {
}
export class UiActionsService {
public readonly executionService = new UiActionsExecutionService();
protected readonly triggers: TriggerRegistry;
protected readonly actions: ActionRegistry;
protected readonly triggerToActions: TriggerToActionsRegistry;

View file

@ -22,6 +22,7 @@ import { openContextMenu } from '../context_menu';
import { uiActionsPluginMock } from '../mocks';
import { Trigger } from '../triggers';
import { TriggerId, ActionType } from '../types';
import { wait } from '@testing-library/dom';
jest.mock('../context_menu');
@ -36,13 +37,15 @@ const TEST_ACTION_TYPE = 'TEST_ACTION_TYPE' as ActionType;
function createTestAction<C extends object>(
type: string,
checkCompatibility: (context: C) => boolean
checkCompatibility: (context: C) => boolean,
autoExecutable = false
): Action<object> {
return createAction<typeof TEST_ACTION_TYPE>({
type: type as ActionType,
id: type,
isCompatible: (context: C) => Promise.resolve(checkCompatibility(context)),
execute: (context) => executeFn(context),
shouldAutoExecute: () => Promise.resolve(autoExecutable),
});
}
@ -57,6 +60,7 @@ const reset = () => {
executeFn.mockReset();
openContextMenuSpy.mockReset();
jest.useFakeTimers();
};
beforeEach(reset);
@ -75,6 +79,8 @@ test('executes a single action mapped to a trigger', async () => {
const start = doStart();
await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context);
jest.runAllTimers();
expect(executeFn).toBeCalledTimes(1);
expect(executeFn).toBeCalledWith(context);
});
@ -117,6 +123,8 @@ test('does not execute an incompatible action', async () => {
};
await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context);
jest.runAllTimers();
expect(executeFn).toBeCalledTimes(1);
});
@ -139,8 +147,12 @@ test('shows a context menu when more than one action is mapped to a trigger', as
const context = {};
await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context);
expect(executeFn).toBeCalledTimes(0);
expect(openContextMenu).toHaveBeenCalledTimes(1);
jest.runAllTimers();
await wait(() => {
expect(executeFn).toBeCalledTimes(0);
expect(openContextMenu).toHaveBeenCalledTimes(1);
});
});
test('passes whole action context to isCompatible()', async () => {
@ -161,4 +173,32 @@ test('passes whole action context to isCompatible()', async () => {
const context = { foo: 'bar' };
await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context);
jest.runAllTimers();
});
test("doesn't show a context menu for auto executable actions", async () => {
const { setup, doStart } = uiActions;
const trigger: Trigger = {
id: 'MY-TRIGGER' as TriggerId,
title: 'My trigger',
};
const action1 = createTestAction('test1', () => true, true);
const action2 = createTestAction('test2', () => true, false);
setup.registerTrigger(trigger);
setup.addTriggerAction(trigger.id, action1);
setup.addTriggerAction(trigger.id, action2);
expect(openContextMenu).toHaveBeenCalledTimes(0);
const start = doStart();
const context = {};
await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context);
jest.runAllTimers();
await wait(() => {
expect(executeFn).toBeCalledTimes(2);
expect(openContextMenu).toHaveBeenCalledTimes(0);
});
});

View file

@ -20,8 +20,6 @@
import { Trigger } from './trigger';
import { TriggerContract } from './trigger_contract';
import { UiActionsService } from '../service';
import { Action } from '../actions';
import { buildContextMenuForActions, openContextMenu } from '../context_menu';
import { TriggerId, TriggerContextMapping } from '../types';
/**
@ -43,33 +41,14 @@ export class TriggerInternal<T extends TriggerId> {
);
}
if (actions.length === 1) {
await this.executeSingleAction(actions[0], context);
return;
}
await this.executeMultipleActions(actions, context);
}
private async executeSingleAction(
action: Action<TriggerContextMapping[T]>,
context: TriggerContextMapping[T]
) {
await action.execute(context);
}
private async executeMultipleActions(
actions: Array<Action<TriggerContextMapping[T]>>,
context: TriggerContextMapping[T]
) {
const panel = await buildContextMenuForActions({
actions,
actionContext: context,
title: this.trigger.title,
closeMenu: () => session.close(),
});
const session = openContextMenu([panel], {
'data-test-subj': 'multipleActionsContextMenu',
});
await Promise.all([
actions.map((action) =>
this.service.executionService.execute({
action,
context,
trigger: this.trigger,
})
),
]);
}
}

View file

@ -19,10 +19,9 @@
import { ActionInternal } from './actions/action_internal';
import { TriggerInternal } from './triggers/trigger_internal';
import { Filter } from '../../data/public';
import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers';
import { IEmbeddable } from '../../embeddable/public';
import { RangeSelectContext, ValueClickContext } from '../../embeddable/public';
import type { RangeSelectContext, ValueClickContext } from '../../embeddable/public';
import type { ApplyGlobalFilterActionContext } from '../../data/public';
export type TriggerRegistry = Map<TriggerId, TriggerInternal<any>>;
export type ActionRegistry = Map<string, ActionInternal>;
@ -39,10 +38,7 @@ export interface TriggerContextMapping {
[DEFAULT_TRIGGER]: TriggerContext;
[SELECT_RANGE_TRIGGER]: RangeSelectContext;
[VALUE_CLICK_TRIGGER]: ValueClickContext;
[APPLY_FILTER_TRIGGER]: {
embeddable: IEmbeddable;
filters: Filter[];
};
[APPLY_FILTER_TRIGGER]: ApplyGlobalFilterActionContext;
}
const DEFAULT_ACTION = '';