Local actions (#57451)

* feat: 🎸 create UiActionsService

* feat: 🎸 add UiActionsServvice.fork() method

* feat: 🎸 instantiate UiActionsService in plugin

* feat: 🎸 add UiActionsService.registerTrigger(), remove old

* feat: 🎸 move attach/detachAction() methods to UiActionsService

* refactor: 💡 move remaining actions API to UiActionsService

* chore: 🤖 clean up /trigger folder

* test: 💍 move registry tests into UiActiosnService tests

* fix: 🐛 fix TypeScript typecheck errors

* test: 💍 add .fork() trigger tests

* feat: 🎸 remove actionIds from ui_actions Trigger interface

* fix: 🐛 remove usage of actionIds

* fix: 🐛 attach hello world action to trigger in plugin lifecycle

* feat: 🎸 fork also trigger to action attachments

* fix: 🐛 clear mapping registry in .clear(), improve type
This commit is contained in:
Vadim Dalecky 2020-02-17 14:59:47 +01:00 committed by GitHub
parent 9388ff7b43
commit ca5e25c139
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 764 additions and 734 deletions

View file

@ -18,11 +18,9 @@
*/
import { Trigger } from '../../../src/plugins/ui_actions/public';
import { HELLO_WORLD_ACTION_TYPE } from './hello_world_action';
export const HELLO_WORLD_TRIGGER_ID = 'HELLO_WORLD_TRIGGER_ID';
export const helloWorldTrigger: Trigger = {
id: HELLO_WORLD_TRIGGER_ID,
actionIds: [HELLO_WORLD_ACTION_TYPE],
};

View file

@ -19,7 +19,7 @@
import { Plugin, CoreSetup, CoreStart } from '../../../src/core/public';
import { UiActionsSetup, UiActionsStart } from '../../../src/plugins/ui_actions/public';
import { createHelloWorldAction } from './hello_world_action';
import { createHelloWorldAction, HELLO_WORLD_ACTION_TYPE } from './hello_world_action';
import { helloWorldTrigger } from './hello_world_trigger';
interface UiActionExamplesSetupDependencies {
@ -33,8 +33,9 @@ interface UiActionExamplesStartDependencies {
export class UiActionExamplesPlugin
implements
Plugin<void, void, UiActionExamplesSetupDependencies, UiActionExamplesStartDependencies> {
public setup(core: CoreSetup, deps: UiActionExamplesSetupDependencies) {
deps.uiActions.registerTrigger(helloWorldTrigger);
public setup(core: CoreSetup, { uiActions }: UiActionExamplesSetupDependencies) {
uiActions.registerTrigger(helloWorldTrigger);
uiActions.attachAction(helloWorldTrigger.id, HELLO_WORLD_ACTION_TYPE);
}
public start(coreStart: CoreStart, deps: UiActionExamplesStartDependencies) {

View file

@ -56,15 +56,12 @@ export class UiActionsExplorerPlugin implements Plugin<void, void, {}, StartDeps
public setup(core: CoreSetup<{ uiActions: UiActionsStart }>, deps: SetupDeps) {
deps.uiActions.registerTrigger({
id: COUNTRY_TRIGGER,
actionIds: [],
});
deps.uiActions.registerTrigger({
id: PHONE_TRIGGER,
actionIds: [],
});
deps.uiActions.registerTrigger({
id: USER_TRIGGER,
actionIds: [],
});
deps.uiActions.registerAction(lookUpWeatherAction);
deps.uiActions.registerAction(viewInMapsAction);

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { UiActionsSetup } from 'src/plugins/ui_actions/public';
import { UiActionsSetup, Trigger } from 'src/plugins/ui_actions/public';
import {
CONTEXT_MENU_TRIGGER,
APPLY_FILTER_TRIGGER,
@ -34,35 +34,30 @@ import {
* @param api
*/
export const bootstrap = (uiActions: UiActionsSetup) => {
const triggerContext = {
const triggerContext: Trigger = {
id: CONTEXT_MENU_TRIGGER,
title: 'Context menu',
description: 'Triggered on top-right corner context-menu select.',
actionIds: [],
};
const triggerFilter = {
const triggerFilter: Trigger = {
id: APPLY_FILTER_TRIGGER,
title: 'Filter click',
description: 'Triggered when user applies filter to an embeddable.',
actionIds: [],
};
const triggerBadge = {
const triggerBadge: Trigger = {
id: PANEL_BADGE_TRIGGER,
title: 'Panel badges',
description: 'Actions appear in title bar when an embeddable loads in a panel',
actionIds: [],
};
const selectRangeTrigger = {
const selectRangeTrigger: Trigger = {
id: SELECT_RANGE_TRIGGER,
title: 'Select range',
description: 'Applies a range filter',
actionIds: [],
};
const valueClickTrigger = {
const valueClickTrigger: Trigger = {
id: VALUE_CLICK_TRIGGER,
title: 'Value clicked',
description: 'Value was clicked',
actionIds: [],
};
const actionApplyFilter = createFilterAction();

View file

@ -25,7 +25,7 @@ import { nextTick } from 'test_utils/enzyme_helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { I18nProvider } from '@kbn/i18n/react';
import { CONTEXT_MENU_TRIGGER } from '../triggers';
import { Action, UiActionsApi } from 'src/plugins/ui_actions/public';
import { Action, UiActionsStart } from 'src/plugins/ui_actions/public';
import { Trigger, GetEmbeddableFactory, ViewMode } from '../types';
import { EmbeddableFactory, isErrorEmbeddable } from '../embeddables';
import { EmbeddablePanel } from './embeddable_panel';
@ -52,7 +52,6 @@ const getEmbeddableFactory: GetEmbeddableFactory = (id: string) => embeddableFac
const editModeAction = createEditModeAction();
const trigger: Trigger = {
id: CONTEXT_MENU_TRIGGER,
actionIds: [editModeAction.id],
};
const embeddableFactory = new ContactCardEmbeddableFactory(
{} as any,
@ -177,7 +176,7 @@ test('HelloWorldContainer in view mode hides edit mode actions', async () => {
const renderInEditModeAndOpenContextMenu = async (
embeddableInputs: any,
getActions: UiActionsApi['getTriggerCompatibleActions'] = () => Promise.resolve([])
getActions: UiActionsStart['getTriggerCompatibleActions'] = () => Promise.resolve([])
) => {
const inspector = inspectorPluginMock.createStartContract();

View file

@ -24,7 +24,6 @@ export interface Trigger {
id: string;
title?: string;
description?: string;
actionIds: string[];
}
export interface PropertySpec {

View file

@ -19,8 +19,8 @@
import { CoreSetup, CoreStart } from 'src/core/public';
// eslint-disable-next-line
import { uiActionsTestPlugin } from 'src/plugins/ui_actions/public/tests';
import { UiActionsApi } from 'src/plugins/ui_actions/public';
import { uiActionsPluginMock } from 'src/plugins/ui_actions/public/mocks';
import { UiActionsStart } from 'src/plugins/ui_actions/public';
import { coreMock } from '../../../../core/public/mocks';
import { EmbeddablePublicPlugin, IEmbeddableSetup, IEmbeddableStart } from '../plugin';
@ -30,14 +30,14 @@ export interface TestPluginReturn {
coreStart: CoreStart;
setup: IEmbeddableSetup;
doStart: (anotherCoreStart?: CoreStart) => IEmbeddableStart;
uiActions: UiActionsApi;
uiActions: UiActionsStart;
}
export const testPlugin = (
coreSetup: CoreSetup = coreMock.createSetup(),
coreStart: CoreStart = coreMock.createStart()
): TestPluginReturn => {
const uiActions = uiActionsTestPlugin(coreSetup, coreStart);
const uiActions = uiActionsPluginMock.createPlugin(coreSetup, coreStart);
const initializerContext = {} as any;
const plugin = new EmbeddablePublicPlugin(initializerContext);
const setup = plugin.setup(coreSetup, { uiActions: uiActions.setup });

View file

@ -1,10 +1,10 @@
# UI Actions
An API for:
- creating custom functionality (`actions`)
- creating custom user interaction events (`triggers`)
- attaching and detaching `actions` to `triggers`.
- emitting `trigger` events
- executing `actions` attached to a given `trigger`.
- exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger.
An API for:
- creating custom functionality (`actions`)
- creating custom user interaction events (`triggers`)
- attaching and detaching `actions` to `triggers`.
- emitting `trigger` events
- executing `actions` attached to a given `trigger`.
- exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger.

View file

@ -17,5 +17,6 @@
* under the License.
*/
export { Action } from './action';
export { createAction } from './create_action';
export * from './action';
export * from './create_action';
export * from './incompatible_action_error';

View file

@ -1,28 +0,0 @@
/*
* 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 { UiActionsApiPure } from '../types';
export const registerAction: UiActionsApiPure['registerAction'] = ({ actions }) => action => {
if (actions.has(action.id)) {
throw new Error(`Action [action.id = ${action.id}] already registered.`);
}
actions.set(action.id, action);
};

View file

@ -1,55 +0,0 @@
/*
* 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 {
UiActionsApi,
UiActionsDependenciesInternal,
UiActionsDependencies,
UiActionsApiPure,
} from './types';
import { attachAction } from './triggers/attach_action';
import { detachAction } from './triggers/detach_action';
import { executeTriggerActions } from './triggers/execute_trigger_actions';
import { getTrigger } from './triggers/get_trigger';
import { getTriggerActions } from './triggers/get_trigger_actions';
import { getTriggerCompatibleActions } from './triggers/get_trigger_compatible_actions';
import { registerAction } from './actions/register_action';
import { registerTrigger } from './triggers/register_trigger';
export const pureApi: UiActionsApiPure = {
attachAction,
detachAction,
executeTriggerActions,
getTrigger,
getTriggerActions,
getTriggerCompatibleActions,
registerAction,
registerTrigger,
};
export const createApi = (deps: UiActionsDependencies) => {
const partialApi: Partial<UiActionsApi> = {};
const depsInternal: UiActionsDependenciesInternal = { ...deps, api: partialApi };
for (const [key, fn] of Object.entries(pureApi)) {
(partialApi as any)[key] = fn(depsInternal);
}
Object.freeze(partialApi);
const api = partialApi as UiActionsApi;
return { api, depsInternal };
};

View file

@ -19,19 +19,30 @@
import { PluginInitializerContext } from '../../../core/public';
import { UiActionsPlugin } from './plugin';
import { UiActionsService } from './service';
export function plugin(initializerContext: PluginInitializerContext) {
return new UiActionsPlugin(initializerContext);
}
export { UiActionsSetup, UiActionsStart } from './plugin';
export {
Action,
Trigger,
UiActionsApi,
GetActionsCompatibleWithTrigger,
ExecuteTriggerActions,
} from './types';
export { createAction } from './actions';
export { UiActionsServiceParams, UiActionsService } from './service';
export { Action, createAction, IncompatibleActionError } from './actions';
export { buildContextMenuForActions } from './context_menu';
export { IncompatibleActionError } from './triggers';
export { Trigger } from './triggers';
/**
* @deprecated
*
* Use `UiActionsStart['getTriggerCompatibleActions']` or
* `UiActionsService['getTriggerCompatibleActions']` instead.
*/
export type GetActionsCompatibleWithTrigger = UiActionsService['getTriggerCompatibleActions'];
/**
* @deprecated
*
* Use `UiActionsStart['executeTriggerActions']` or
* `UiActionsService['executeTriggerActions']` instead.
*/
export type ExecuteTriggerActions = UiActionsService['executeTriggerActions'];

View file

@ -17,9 +17,9 @@
* under the License.
*/
import { CoreSetup, CoreStart } from 'src/core/public';
import { UiActionsSetup, UiActionsStart } from '.';
import { plugin as pluginInitializer } from '.';
// eslint-disable-next-line
import { coreMock } from '../../../core/public/mocks';
export type Setup = jest.Mocked<UiActionsSetup>;
@ -45,17 +45,20 @@ const createStartContract = (): Start => {
getTrigger: jest.fn(),
getTriggerActions: jest.fn((id: string) => []),
getTriggerCompatibleActions: jest.fn(),
clear: jest.fn(),
fork: jest.fn(),
};
return startContract;
};
const createPlugin = async () => {
const createPlugin = (
coreSetup: CoreSetup = coreMock.createSetup(),
coreStart: CoreStart = coreMock.createStart()
) => {
const pluginInitializerContext = coreMock.createPluginInitializerContext();
const coreSetup = coreMock.createSetup();
const coreStart = coreMock.createStart();
const plugin = pluginInitializer(pluginInitializerContext);
const setup = await plugin.setup(coreSetup);
const setup = plugin.setup(coreSetup);
return {
pluginInitializerContext,
@ -63,7 +66,7 @@ const createPlugin = async () => {
coreStart,
plugin,
setup,
doStart: async () => await plugin.start(coreStart),
doStart: (anotherCoreStart: CoreStart = coreStart) => plugin.start(anotherCoreStart),
};
};

View file

@ -17,43 +17,30 @@
* under the License.
*/
import { CoreStart, PluginInitializerContext, CoreSetup, Plugin } from 'src/core/public';
import { UiActionsApi, ActionRegistry, TriggerRegistry } from './types';
import { createApi } from './api';
import { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public';
import { UiActionsService } from './service';
export interface UiActionsSetup {
attachAction: UiActionsApi['attachAction'];
detachAction: UiActionsApi['detachAction'];
registerAction: UiActionsApi['registerAction'];
registerTrigger: UiActionsApi['registerTrigger'];
}
export type UiActionsSetup = Pick<
UiActionsService,
'attachAction' | 'detachAction' | 'registerAction' | 'registerTrigger'
>;
export type UiActionsStart = UiActionsApi;
export type UiActionsStart = PublicMethodsOf<UiActionsService>;
export class UiActionsPlugin implements Plugin<UiActionsSetup, UiActionsStart> {
private readonly triggers: TriggerRegistry = new Map();
private readonly actions: ActionRegistry = new Map();
private api!: UiActionsApi;
private readonly service = new UiActionsService();
constructor(initializerContext: PluginInitializerContext) {
this.api = createApi({ triggers: this.triggers, actions: this.actions }).api;
}
constructor(initializerContext: PluginInitializerContext) {}
public setup(core: CoreSetup): UiActionsSetup {
return {
registerTrigger: this.api.registerTrigger,
registerAction: this.api.registerAction,
attachAction: this.api.attachAction,
detachAction: this.api.detachAction,
};
return this.service;
}
public start(core: CoreStart): UiActionsStart {
return this.api;
return this.service;
}
public stop() {
this.actions.clear();
this.triggers.clear();
this.service.clear();
}
}

View file

@ -17,12 +17,4 @@
* under the License.
*/
import { UiActionsDependencies } from '../types';
export const createDeps = (): UiActionsDependencies => {
const deps: UiActionsDependencies = {
actions: new Map<any, any>(),
triggers: new Map<any, any>(),
};
return deps;
};
export * from './ui_actions_service';

View file

@ -0,0 +1,465 @@
/*
* 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 { UiActionsService } from './ui_actions_service';
import { Action } from '../actions';
import { createRestrictedAction, createHelloWorldAction } from '../tests/test_samples';
import { ActionRegistry, TriggerRegistry } from '../types';
import { Trigger } from '../triggers';
const testAction1: Action = {
id: 'action1',
order: 1,
type: 'type1',
execute: async () => {},
getDisplayName: () => 'test1',
getIconType: () => '',
isCompatible: async () => true,
};
const testAction2: Action = {
id: 'action2',
order: 2,
type: 'type2',
execute: async () => {},
getDisplayName: () => 'test2',
getIconType: () => '',
isCompatible: async () => true,
};
describe('UiActionsService', () => {
test('can instantiate', () => {
new UiActionsService();
});
describe('.registerTrigger()', () => {
test('can register a trigger', () => {
const service = new UiActionsService();
service.registerTrigger({
id: 'test',
});
});
});
describe('.getTrigger()', () => {
test('can get Trigger from registry', () => {
const service = new UiActionsService();
service.registerTrigger({
description: 'foo',
id: 'bar',
title: 'baz',
});
const trigger = service.getTrigger('bar');
expect(trigger).toEqual({
description: 'foo',
id: 'bar',
title: 'baz',
});
});
test('throws if trigger does not exist', () => {
const service = new UiActionsService();
expect(() => service.getTrigger('foo')).toThrowError(
'Trigger [triggerId = foo] does not exist.'
);
});
});
describe('.registerAction()', () => {
test('can register an action', () => {
const service = new UiActionsService();
service.registerAction({
id: 'test',
execute: async () => {},
getDisplayName: () => 'test',
getIconType: () => '',
isCompatible: async () => true,
type: 'test',
});
});
});
describe('.getTriggerActions()', () => {
const action1: Action = {
id: 'action1',
order: 1,
type: 'type1',
execute: async () => {},
getDisplayName: () => 'test',
getIconType: () => '',
isCompatible: async () => true,
};
const action2: Action = {
id: 'action2',
order: 2,
type: 'type2',
execute: async () => {},
getDisplayName: () => 'test',
getIconType: () => '',
isCompatible: async () => true,
};
test('returns actions set on trigger', () => {
const service = new UiActionsService();
service.registerAction(action1);
service.registerAction(action2);
service.registerTrigger({
description: 'foo',
id: 'trigger',
title: 'baz',
});
const list0 = service.getTriggerActions('trigger');
expect(list0).toHaveLength(0);
service.attachAction('trigger', 'action1');
const list1 = service.getTriggerActions('trigger');
expect(list1).toHaveLength(1);
expect(list1).toEqual([action1]);
service.attachAction('trigger', 'action2');
const list2 = service.getTriggerActions('trigger');
expect(list2).toHaveLength(2);
expect(!!list2.find(({ id }: any) => id === 'action1')).toBe(true);
expect(!!list2.find(({ id }: any) => id === 'action2')).toBe(true);
});
});
describe('.getTriggerCompatibleActions()', () => {
test('can register and get actions', async () => {
const actions: ActionRegistry = new Map();
const service = new UiActionsService({ actions });
const helloWorldAction = createHelloWorldAction({} as any);
const length = actions.size;
service.registerAction(helloWorldAction);
expect(actions.size - length).toBe(1);
expect(actions.get(helloWorldAction.id)).toBe(helloWorldAction);
});
test('getTriggerCompatibleActions returns attached actions', async () => {
const service = new UiActionsService();
const helloWorldAction = createHelloWorldAction({} as any);
service.registerAction(helloWorldAction);
const testTrigger: Trigger = {
id: 'MY-TRIGGER',
title: 'My trigger',
};
service.registerTrigger(testTrigger);
service.attachAction('MY-TRIGGER', helloWorldAction.id);
const compatibleActions = await service.getTriggerCompatibleActions('MY-TRIGGER', {});
expect(compatibleActions.length).toBe(1);
expect(compatibleActions[0].id).toBe(helloWorldAction.id);
});
test('filters out actions not applicable based on the context', async () => {
const service = new UiActionsService();
const restrictedAction = createRestrictedAction<{ accept: boolean }>(context => {
return context.accept;
});
service.registerAction(restrictedAction);
const testTrigger: Trigger = {
id: 'MY-TRIGGER',
title: 'My trigger',
};
service.registerTrigger(testTrigger);
service.attachAction(testTrigger.id, restrictedAction.id);
const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, {
accept: true,
});
expect(compatibleActions1.length).toBe(1);
const compatibleActions2 = await service.getTriggerCompatibleActions(testTrigger.id, {
accept: false,
});
expect(compatibleActions2.length).toBe(0);
});
test(`throws an error with an invalid trigger ID`, async () => {
const service = new UiActionsService();
await expect(service.getTriggerCompatibleActions('I do not exist', {})).rejects.toMatchObject(
new Error('Trigger [triggerId = I do not exist] does not exist.')
);
});
test('returns empty list if trigger not attached to any action', async () => {
const service = new UiActionsService();
const testTrigger: Trigger = {
id: '123',
title: '123',
};
service.registerTrigger(testTrigger);
const actions = await service.getTriggerCompatibleActions(testTrigger.id, {});
expect(actions).toEqual([]);
});
});
describe('.fork()', () => {
test('returns a new instance of the service', () => {
const service1 = new UiActionsService();
const service2 = service1.fork();
expect(service1).not.toBe(service2);
expect(service2).toBeInstanceOf(UiActionsService);
});
test('triggers registered in original service are available in original an forked services', () => {
const service1 = new UiActionsService();
service1.registerTrigger({
id: 'foo',
});
const service2 = service1.fork();
const trigger1 = service1.getTrigger('foo');
const trigger2 = service2.getTrigger('foo');
expect(trigger1.id).toBe('foo');
expect(trigger2.id).toBe('foo');
});
test('triggers registered in forked service are not available in original service', () => {
const service1 = new UiActionsService();
const service2 = service1.fork();
service2.registerTrigger({
id: 'foo',
});
expect(() => service1.getTrigger('foo')).toThrowErrorMatchingInlineSnapshot(
`"Trigger [triggerId = foo] does not exist."`
);
const trigger2 = service2.getTrigger('foo');
expect(trigger2.id).toBe('foo');
});
test('forked service preserves trigger-to-actions mapping', () => {
const service1 = new UiActionsService();
service1.registerTrigger({
id: 'foo',
});
service1.registerAction(testAction1);
service1.attachAction('foo', testAction1.id);
const service2 = service1.fork();
const actions1 = service1.getTriggerActions('foo');
const actions2 = service2.getTriggerActions('foo');
expect(actions1).toHaveLength(1);
expect(actions2).toHaveLength(1);
expect(actions1[0].id).toBe(testAction1.id);
expect(actions2[0].id).toBe(testAction1.id);
});
test('new attachments in fork do not appear in original service', () => {
const service1 = new UiActionsService();
service1.registerTrigger({
id: 'foo',
});
service1.registerAction(testAction1);
service1.registerAction(testAction2);
service1.attachAction('foo', testAction1.id);
const service2 = service1.fork();
expect(service1.getTriggerActions('foo')).toHaveLength(1);
expect(service2.getTriggerActions('foo')).toHaveLength(1);
service2.attachAction('foo', testAction2.id);
expect(service1.getTriggerActions('foo')).toHaveLength(1);
expect(service2.getTriggerActions('foo')).toHaveLength(2);
});
test('new attachments in original service do not appear in fork', () => {
const service1 = new UiActionsService();
service1.registerTrigger({
id: 'foo',
});
service1.registerAction(testAction1);
service1.registerAction(testAction2);
service1.attachAction('foo', testAction1.id);
const service2 = service1.fork();
expect(service1.getTriggerActions('foo')).toHaveLength(1);
expect(service2.getTriggerActions('foo')).toHaveLength(1);
service1.attachAction('foo', testAction2.id);
expect(service1.getTriggerActions('foo')).toHaveLength(2);
expect(service2.getTriggerActions('foo')).toHaveLength(1);
});
});
describe('registries', () => {
const HELLO_WORLD_ACTION_ID = 'HELLO_WORLD_ACTION_ID';
test('can register trigger', () => {
const triggers: TriggerRegistry = new Map();
const service = new UiActionsService({ triggers });
service.registerTrigger({
description: 'foo',
id: 'bar',
title: 'baz',
});
expect(triggers.get('bar')).toEqual({
description: 'foo',
id: 'bar',
title: 'baz',
});
});
test('can register action', () => {
const actions: ActionRegistry = new Map();
const service = new UiActionsService({ actions });
service.registerAction({
id: HELLO_WORLD_ACTION_ID,
order: 13,
} as any);
expect(actions.get(HELLO_WORLD_ACTION_ID)).toMatchObject({
id: HELLO_WORLD_ACTION_ID,
order: 13,
});
});
test('can attach an action to a trigger', () => {
const service = new UiActionsService();
const trigger: Trigger = {
id: 'MY-TRIGGER',
};
const action = {
id: HELLO_WORLD_ACTION_ID,
order: 25,
} as any;
service.registerTrigger(trigger);
service.registerAction(action);
service.attachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID);
const actions = service.getTriggerActions(trigger.id);
expect(actions.length).toBe(1);
expect(actions[0].id).toBe(HELLO_WORLD_ACTION_ID);
});
test('can detach an action to a trigger', () => {
const service = new UiActionsService();
const trigger: Trigger = {
id: 'MY-TRIGGER',
};
const action = {
id: HELLO_WORLD_ACTION_ID,
order: 25,
} as any;
service.registerTrigger(trigger);
service.registerAction(action);
service.attachAction(trigger.id, HELLO_WORLD_ACTION_ID);
service.detachAction(trigger.id, HELLO_WORLD_ACTION_ID);
const actions2 = service.getTriggerActions(trigger.id);
expect(actions2).toEqual([]);
});
test('detaching an invalid action from a trigger throws an error', async () => {
const service = new UiActionsService();
const action = {
id: HELLO_WORLD_ACTION_ID,
order: 25,
} as any;
service.registerAction(action);
expect(() => service.detachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError(
'No trigger [triggerId = i do not exist] exists, for detaching action [actionId = HELLO_WORLD_ACTION_ID].'
);
});
test('attaching an invalid action to a trigger throws an error', async () => {
const service = new UiActionsService();
const action = {
id: HELLO_WORLD_ACTION_ID,
order: 25,
} as any;
service.registerAction(action);
expect(() => service.attachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError(
'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = HELLO_WORLD_ACTION_ID].'
);
});
test('cannot register another action with the same ID', async () => {
const service = new UiActionsService();
const action = {
id: HELLO_WORLD_ACTION_ID,
order: 25,
} as any;
service.registerAction(action);
expect(() => service.registerAction(action)).toThrowError(
'Action [action.id = HELLO_WORLD_ACTION_ID] already registered.'
);
});
test('cannot register another trigger with the same ID', async () => {
const service = new UiActionsService();
const trigger = { id: 'MY-TRIGGER' } as any;
service.registerTrigger(trigger);
expect(() => service.registerTrigger(trigger)).toThrowError(
'Trigger [trigger.id = MY-TRIGGER] already registered.'
);
});
});
});

View file

@ -0,0 +1,194 @@
/*
* 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 { TriggerRegistry, ActionRegistry, TriggerToActionsRegistry } from '../types';
import { Action } from '../actions';
import { Trigger } from '../triggers/trigger';
import { buildContextMenuForActions, openContextMenu } from '../context_menu';
export interface UiActionsServiceParams {
readonly triggers?: TriggerRegistry;
readonly actions?: ActionRegistry;
/**
* A 1-to-N mapping from `Trigger` to zero or more `Action`.
*/
readonly triggerToActions?: TriggerToActionsRegistry;
}
export class UiActionsService {
protected readonly triggers: TriggerRegistry;
protected readonly actions: ActionRegistry;
protected readonly triggerToActions: TriggerToActionsRegistry;
constructor({
triggers = new Map(),
actions = new Map(),
triggerToActions = new Map(),
}: UiActionsServiceParams = {}) {
this.triggers = triggers;
this.actions = actions;
this.triggerToActions = triggerToActions;
}
public readonly registerTrigger = (trigger: Trigger) => {
if (this.triggers.has(trigger.id)) {
throw new Error(`Trigger [trigger.id = ${trigger.id}] already registered.`);
}
this.triggers.set(trigger.id, trigger);
this.triggerToActions.set(trigger.id, []);
};
public readonly getTrigger = (id: string) => {
const trigger = this.triggers.get(id);
if (!trigger) {
throw new Error(`Trigger [triggerId = ${id}] does not exist.`);
}
return trigger;
};
public readonly registerAction = (action: Action) => {
if (this.actions.has(action.id)) {
throw new Error(`Action [action.id = ${action.id}] already registered.`);
}
this.actions.set(action.id, action);
};
public readonly attachAction = (triggerId: string, actionId: string): void => {
const trigger = this.triggers.get(triggerId);
if (!trigger) {
throw new Error(
`No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].`
);
}
const actionIds = this.triggerToActions.get(triggerId);
if (!actionIds!.find(id => id === actionId)) {
this.triggerToActions.set(triggerId, [...actionIds!, actionId]);
}
};
public readonly detachAction = (triggerId: string, actionId: string) => {
const trigger = this.triggers.get(triggerId);
if (!trigger) {
throw new Error(
`No trigger [triggerId = ${triggerId}] exists, for detaching action [actionId = ${actionId}].`
);
}
const actionIds = this.triggerToActions.get(triggerId);
this.triggerToActions.set(
triggerId,
actionIds!.filter(id => id !== actionId)
);
};
public readonly getTriggerActions = (triggerId: string) => {
// This line checks if trigger exists, otherwise throws.
this.getTrigger!(triggerId);
const actionIds = this.triggerToActions.get(triggerId);
const actions = actionIds!
.map(actionId => this.actions.get(actionId))
.filter(Boolean) as Action[];
return actions;
};
public readonly getTriggerCompatibleActions = async <C>(triggerId: string, context: C) => {
const actions = this.getTriggerActions!(triggerId);
const isCompatibles = await Promise.all(actions.map(action => action.isCompatible(context)));
return actions.reduce<Action[]>(
(acc, action, i) => (isCompatibles[i] ? [...acc, action] : acc),
[]
);
};
private async executeSingleAction<A>(action: Action<A>, actionContext: A) {
const href = action.getHref && action.getHref(actionContext);
if (href) {
window.location.href = href;
return;
}
await action.execute(actionContext);
}
private async executeMultipleActions<C>(actions: Action[], actionContext: C) {
const panel = await buildContextMenuForActions({
actions,
actionContext,
closeMenu: () => session.close(),
});
const session = openContextMenu([panel]);
}
public readonly executeTriggerActions = async <C>(triggerId: string, actionContext: C) => {
const actions = await this.getTriggerCompatibleActions!(triggerId, actionContext);
if (!actions.length) {
throw new Error(
`No compatible actions found to execute for trigger [triggerId = ${triggerId}].`
);
}
if (actions.length === 1) {
await this.executeSingleAction(actions[0], actionContext);
return;
}
await this.executeMultipleActions(actions, actionContext);
};
/**
* Removes all registered triggers and actions.
*/
public readonly clear = () => {
this.actions.clear();
this.triggers.clear();
this.triggerToActions.clear();
};
/**
* "Fork" a separate instance of `UiActionsService` that inherits all existing
* triggers and actions, but going forward all new triggers and actions added
* to this instance of `UiActionsService` are only available within this instance.
*/
public readonly fork = (): UiActionsService => {
const triggers: TriggerRegistry = new Map();
const actions: ActionRegistry = new Map();
const triggerToActions: TriggerToActionsRegistry = new Map();
for (const [key, value] of this.triggers.entries()) triggers.set(key, value);
for (const [key, value] of this.actions.entries()) actions.set(key, value);
for (const [key, value] of this.triggerToActions.entries())
triggerToActions.set(key, [...value]);
return new UiActionsService({ triggers, actions, triggerToActions });
};
}

View file

@ -0,0 +1,2 @@
This folder contains integration tests for the `ui_actions` plugin and
`test_samples` that other plugins can use in their tests.

View file

@ -19,7 +19,8 @@
import { Action, createAction } from '../actions';
import { openContextMenu } from '../context_menu';
import { UiActionsTestPluginReturn, uiActionsTestPlugin } from '../tests/test_plugin';
import { uiActionsPluginMock } from '../mocks';
import { Trigger } from '../triggers';
jest.mock('../context_menu');
@ -37,14 +38,14 @@ function createTestAction<A>(id: string, checkCompatibility: (context: A) => boo
});
}
let uiActions: UiActionsTestPluginReturn;
let uiActions: ReturnType<typeof uiActionsPluginMock.createPlugin>;
const reset = () => {
uiActions = uiActionsTestPlugin();
uiActions = uiActionsPluginMock.createPlugin();
uiActions.setup.registerTrigger({
id: CONTACT_USER_TRIGGER,
actionIds: ['SEND_MESSAGE_ACTION'],
});
uiActions.setup.attachAction(CONTACT_USER_TRIGGER, 'SEND_MESSAGE_ACTION');
executeFn.mockReset();
openContextMenuSpy.mockReset();
@ -53,14 +54,15 @@ beforeEach(reset);
test('executes a single action mapped to a trigger', async () => {
const { setup, doStart } = uiActions;
const trigger = {
const trigger: Trigger = {
id: 'MY-TRIGGER',
title: 'My trigger',
actionIds: ['test1'],
};
const action = createTestAction('test1', () => true);
setup.registerTrigger(trigger);
setup.registerAction(action);
setup.attachAction(trigger.id, 'test1');
const context = {};
const start = doStart();
@ -72,12 +74,13 @@ test('executes a single action mapped to a trigger', async () => {
test('throws an error if there are no compatible actions to execute', async () => {
const { setup, doStart } = uiActions;
const trigger = {
const trigger: Trigger = {
id: 'MY-TRIGGER',
title: 'My trigger',
actionIds: ['testaction'],
};
setup.registerTrigger(trigger);
setup.attachAction(trigger.id, 'testaction');
const context = {};
const start = doStart();
@ -88,14 +91,15 @@ test('throws an error if there are no compatible actions to execute', async () =
test('does not execute an incompatible action', async () => {
const { setup, doStart } = uiActions;
const trigger = {
const trigger: Trigger = {
id: 'MY-TRIGGER',
title: 'My trigger',
actionIds: ['test1'],
};
const action = createTestAction<{ name: string }>('test1', ({ name }) => name === 'executeme');
setup.registerTrigger(trigger);
setup.registerAction(action);
setup.attachAction(trigger.id, 'test1');
const start = doStart();
const context = {
@ -108,16 +112,18 @@ test('does not execute an incompatible action', async () => {
test('shows a context menu when more than one action is mapped to a trigger', async () => {
const { setup, doStart } = uiActions;
const trigger = {
const trigger: Trigger = {
id: 'MY-TRIGGER',
title: 'My trigger',
actionIds: ['test1', 'test2'],
};
const action1 = createTestAction('test1', () => true);
const action2 = createTestAction('test2', () => true);
setup.registerTrigger(trigger);
setup.registerAction(action1);
setup.registerAction(action2);
setup.attachAction(trigger.id, 'test1');
setup.attachAction(trigger.id, 'test2');
expect(openContextMenu).toHaveBeenCalledTimes(0);
@ -134,7 +140,6 @@ test('passes whole action context to isCompatible()', async () => {
const trigger = {
id: 'MY-TRIGGER',
title: 'My trigger',
actionIds: ['test'],
};
const action = createTestAction<{ foo: string }>('test', ({ foo }) => {
expect(foo).toEqual('bar');
@ -143,6 +148,8 @@ test('passes whole action context to isCompatible()', async () => {
setup.registerTrigger(trigger);
setup.registerAction(action);
setup.attachAction(trigger.id, 'test');
const start = doStart();
const context = { foo: 'bar' };

View file

@ -18,7 +18,7 @@
*/
import { Action } from '../actions';
import { uiActionsTestPlugin } from '../tests/test_plugin';
import { uiActionsPluginMock } from '../mocks';
const action1: Action = {
id: 'action1',
@ -32,11 +32,10 @@ const action2: Action = {
} as any;
test('returns actions set on trigger', () => {
const { setup, doStart } = uiActionsTestPlugin();
const { setup, doStart } = uiActionsPluginMock.createPlugin();
setup.registerAction(action1);
setup.registerAction(action2);
setup.registerTrigger({
actionIds: [],
description: 'foo',
id: 'trigger',
title: 'baz',

View file

@ -18,35 +18,30 @@
*/
import { createSayHelloAction } from '../tests/test_samples/say_hello_action';
import { UiActionsTestPluginReturn, uiActionsTestPlugin } from '../tests/test_plugin';
import { uiActionsPluginMock } from '../mocks';
import { createRestrictedAction, createHelloWorldAction } from '../tests/test_samples';
import { Action } from '../actions';
import { Trigger } from '../triggers';
let action: Action<{ name: string }>;
let uiActions: UiActionsTestPluginReturn;
let uiActions: ReturnType<typeof uiActionsPluginMock.createPlugin>;
beforeEach(() => {
uiActions = uiActionsTestPlugin();
uiActions = uiActionsPluginMock.createPlugin();
action = createSayHelloAction({} as any);
uiActions.setup.registerAction(action);
uiActions.setup.registerTrigger({
id: 'trigger',
title: 'trigger',
actionIds: [],
});
uiActions.setup.attachAction('trigger', action.id);
});
test('can register and get actions', async () => {
const { setup, plugin } = uiActions;
test('can register action', async () => {
const { setup } = uiActions;
const helloWorldAction = createHelloWorldAction({} as any);
const length = (plugin as any).actions.size;
setup.registerAction(helloWorldAction);
expect((plugin as any).actions.size - length).toBe(1);
expect((plugin as any).actions.get(action.id)).toBe(action);
expect((plugin as any).actions.get(helloWorldAction.id)).toBe(helloWorldAction);
});
test('getTriggerCompatibleActions returns attached actions', async () => {
@ -55,10 +50,9 @@ test('getTriggerCompatibleActions returns attached actions', async () => {
setup.registerAction(helloWorldAction);
const testTrigger = {
const testTrigger: Trigger = {
id: 'MY-TRIGGER',
title: 'My trigger',
actionIds: [],
};
setup.registerTrigger(testTrigger);
setup.attachAction('MY-TRIGGER', helloWorldAction.id);
@ -78,13 +72,13 @@ test('filters out actions not applicable based on the context', async () => {
setup.registerAction(restrictedAction);
const testTrigger = {
const testTrigger: Trigger = {
id: 'MY-TRIGGER',
title: 'My trigger',
actionIds: [restrictedAction.id],
};
setup.registerTrigger(testTrigger);
setup.attachAction(testTrigger.id, restrictedAction.id);
const start = doStart();
let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true });
@ -107,10 +101,9 @@ test(`throws an error with an invalid trigger ID`, async () => {
test(`with a trigger mapping that maps to an non-existing action returns empty list`, async () => {
const { setup, doStart } = uiActions;
const testTrigger = {
const testTrigger: Trigger = {
id: '123',
title: '123',
actionIds: ['I do not exist'],
};
setup.registerTrigger(testTrigger);

View file

@ -17,4 +17,4 @@
* under the License.
*/
export { uiActionsTestPlugin } from './test_plugin';
export * from './test_samples';

View file

@ -1,49 +0,0 @@
/*
* 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 { CoreSetup, CoreStart } from 'src/core/public';
import { UiActionsPlugin, UiActionsSetup, UiActionsStart } from '../plugin';
export interface UiActionsTestPluginReturn {
plugin: UiActionsPlugin;
coreSetup: CoreSetup;
coreStart: CoreStart;
setup: UiActionsSetup;
doStart: (anotherCoreStart?: CoreStart) => UiActionsStart;
}
export const uiActionsTestPlugin = (
coreSetup: CoreSetup = {} as CoreSetup,
coreStart: CoreStart = {} as CoreStart
): UiActionsTestPluginReturn => {
const initializerContext = {} as any;
const plugin = new UiActionsPlugin(initializerContext);
const setup = plugin.setup(coreSetup);
return {
plugin,
coreSetup,
coreStart,
setup,
doStart: (anotherCoreStart: CoreStart = coreStart) => {
const start = plugin.start(anotherCoreStart);
return start;
},
};
};

View file

@ -1,37 +0,0 @@
/*
* 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 { UiActionsApiPure } from '../types';
export const attachAction: UiActionsApiPure['attachAction'] = ({ triggers }) => (
triggerId,
actionId
) => {
const trigger = triggers.get(triggerId);
if (!trigger) {
throw new Error(
`No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].`
);
}
if (!trigger.actionIds.find(id => id === actionId)) {
trigger.actionIds.push(actionId);
}
};

View file

@ -1,35 +0,0 @@
/*
* 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 { UiActionsApiPure } from '../types';
export const detachAction: UiActionsApiPure['detachAction'] = ({ triggers }) => (
triggerId,
actionId
) => {
const trigger = triggers.get(triggerId);
if (!trigger) {
throw new Error(
`No trigger [triggerId = ${triggerId}] exists, for detaching action [actionId = ${actionId}].`
);
}
trigger.actionIds = trigger.actionIds.filter(id => id !== actionId);
};

View file

@ -1,59 +0,0 @@
/*
* 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 { UiActionsApiPure } from '../types';
import { buildContextMenuForActions, openContextMenu } from '../context_menu';
import { Action } from '../actions';
const executeSingleAction = async <A extends {} = {}>(action: Action<A>, actionContext: A) => {
const href = action.getHref && action.getHref(actionContext);
// TODO: Do we need a `getHref()` special case?
if (href) {
window.location.href = href;
return;
}
await action.execute(actionContext);
};
export const executeTriggerActions: UiActionsApiPure['executeTriggerActions'] = ({ api }) => async (
triggerId,
actionContext
) => {
const actions = await api.getTriggerCompatibleActions!(triggerId, actionContext);
if (!actions.length) {
throw new Error(
`No compatible actions found to execute for trigger [triggerId = ${triggerId}].`
);
}
if (actions.length === 1) {
await executeSingleAction(actions[0], actionContext);
return;
}
const panel = await buildContextMenuForActions({
actions,
actionContext,
closeMenu: () => session.close(),
});
const session = openContextMenu([panel]);
};

View file

@ -1,48 +0,0 @@
/*
* 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 { createApi } from '../api';
import { createDeps } from '../tests/helpers';
test('can get Trigger from registry', () => {
const deps = createDeps();
const { api } = createApi(deps);
api.registerTrigger({
actionIds: [],
description: 'foo',
id: 'bar',
title: 'baz',
});
const trigger = api.getTrigger('bar');
expect(trigger).toEqual({
actionIds: [],
description: 'foo',
id: 'bar',
title: 'baz',
});
});
test('throws if trigger does not exist', () => {
const deps = createDeps();
const { api } = createApi(deps);
expect(() => api.getTrigger('foo')).toThrowError('Trigger [triggerId = foo] does not exist.');
});

View file

@ -1,30 +0,0 @@
/*
* 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 { UiActionsApiPure } from '../types';
export const getTrigger: UiActionsApiPure['getTrigger'] = ({ triggers }) => id => {
const trigger = triggers.get(id);
if (!trigger) {
throw new Error(`Trigger [triggerId = ${id}] does not exist.`);
}
return trigger;
};

View file

@ -1,29 +0,0 @@
/*
* 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 { UiActionsApiPure } from '../types';
import { Action } from '../actions';
export const getTriggerActions: UiActionsApiPure['getTriggerActions'] = ({
api,
actions,
}) => id => {
const trigger = api.getTrigger!(id);
return trigger.actionIds.map(actionId => actions.get(actionId)).filter(Boolean) as Action[];
};

View file

@ -1,32 +0,0 @@
/*
* 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 { UiActionsApiPure } from '../types';
import { Action } from '../actions/action';
export const getTriggerCompatibleActions: UiActionsApiPure['getTriggerCompatibleActions'] = ({
api,
}) => async (triggerId, context) => {
const actions = api.getTriggerActions!(triggerId);
const isCompatibles = await Promise.all(actions.map(action => action.isCompatible(context)));
return actions.reduce<Action[]>(
(acc, action, i) => (isCompatibles[i] ? [...acc, action] : acc),
[]
);
};

View file

@ -17,4 +17,4 @@
* under the License.
*/
export { IncompatibleActionError } from './incompatible_action_error';
export { Trigger } from './trigger';

View file

@ -1,28 +0,0 @@
/*
* 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 { UiActionsApiPure } from '../types';
export const registerTrigger: UiActionsApiPure['registerTrigger'] = ({ triggers }) => trigger => {
if (triggers.has(trigger.id)) {
throw new Error(`Trigger [trigger.id = ${trigger.id}] already registered.`);
}
triggers.set(trigger.id, trigger);
};

View file

@ -1,149 +0,0 @@
/*
* 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 { createApi } from '../api';
import { createDeps } from '../tests/helpers';
const HELLO_WORLD_ACTION_ID = 'HELLO_WORLD_ACTION_ID';
test('can register trigger', () => {
const deps = createDeps();
const { api } = createApi(deps);
api.registerTrigger({
actionIds: [],
description: 'foo',
id: 'bar',
title: 'baz',
});
expect(deps.triggers.get('bar')).toEqual({
actionIds: [],
description: 'foo',
id: 'bar',
title: 'baz',
});
});
test('can register action', () => {
const deps = createDeps();
const { api } = createApi(deps);
api.registerAction({
id: HELLO_WORLD_ACTION_ID,
order: 13,
} as any);
expect(deps.actions.get(HELLO_WORLD_ACTION_ID)).toMatchObject({
id: HELLO_WORLD_ACTION_ID,
order: 13,
});
});
test('can attach an action to a trigger', () => {
const deps = createDeps();
const { api } = createApi(deps);
const trigger = {
id: 'MY-TRIGGER',
actionIds: [],
};
const action = {
id: HELLO_WORLD_ACTION_ID,
order: 25,
} as any;
expect(trigger.actionIds).toEqual([]);
api.registerTrigger(trigger);
api.registerAction(action);
api.attachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID);
expect(trigger.actionIds).toEqual([HELLO_WORLD_ACTION_ID]);
});
test('can detach an action to a trigger', () => {
const deps = createDeps();
const { api } = createApi(deps);
const trigger = {
id: 'MY-TRIGGER',
actionIds: [],
};
const action = {
id: HELLO_WORLD_ACTION_ID,
order: 25,
} as any;
expect(trigger.actionIds).toEqual([]);
api.registerTrigger(trigger);
api.registerAction(action);
api.attachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID);
api.detachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID);
expect(trigger.actionIds).toEqual([]);
});
test('detaching an invalid action from a trigger throws an error', async () => {
const { api } = createApi({ actions: new Map<any, any>(), triggers: new Map<any, any>() });
const action = {
id: HELLO_WORLD_ACTION_ID,
order: 25,
} as any;
api.registerAction(action);
expect(() => api.detachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError(
'No trigger [triggerId = i do not exist] exists, for detaching action [actionId = HELLO_WORLD_ACTION_ID].'
);
});
test('attaching an invalid action to a trigger throws an error', async () => {
const { api } = createApi({ actions: new Map<any, any>(), triggers: new Map<any, any>() });
const action = {
id: HELLO_WORLD_ACTION_ID,
order: 25,
} as any;
api.registerAction(action);
expect(() => api.attachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError(
'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = HELLO_WORLD_ACTION_ID].'
);
});
test('cannot register another action with the same ID', async () => {
const { api } = createApi({ actions: new Map<any, any>(), triggers: new Map<any, any>() });
const action = {
id: HELLO_WORLD_ACTION_ID,
order: 25,
} as any;
api.registerAction(action);
expect(() => api.registerAction(action)).toThrowError(
'Action [action.id = HELLO_WORLD_ACTION_ID] already registered.'
);
});
test('cannot register another trigger with the same ID', async () => {
const { api } = createApi({ actions: new Map<any, any>(), triggers: new Map<any, any>() });
const trigger = { id: 'MY-TRIGGER' } as any;
api.registerTrigger(trigger);
expect(() => api.registerTrigger(trigger)).toThrowError(
'Trigger [trigger.id = MY-TRIGGER] already registered.'
);
});

View file

@ -21,5 +21,4 @@ export interface Trigger {
id: string;
title?: string;
description?: string;
actionIds: string[];
}

View file

@ -20,39 +20,6 @@
import { Action } from './actions/action';
import { Trigger } from './triggers/trigger';
export { Action } from './actions';
export { Trigger } from './triggers/trigger';
export type ExecuteTriggerActions = <A>(triggerId: string, actionContext: A) => Promise<void>;
export type GetActionsCompatibleWithTrigger = <C>(
triggerId: string,
context: C
) => Promise<Action[]>;
export interface UiActionsApi {
attachAction: (triggerId: string, actionId: string) => void;
detachAction: (triggerId: string, actionId: string) => void;
executeTriggerActions: ExecuteTriggerActions;
getTrigger: (id: string) => Trigger;
getTriggerActions: (id: string) => Action[];
getTriggerCompatibleActions: <C>(triggerId: string, context: C) => Promise<Array<Action<C>>>;
registerAction: (action: Action) => void;
registerTrigger: (trigger: Trigger) => void;
}
export interface UiActionsDependencies {
actions: ActionRegistry;
triggers: TriggerRegistry;
}
export interface UiActionsDependenciesInternal extends UiActionsDependencies {
api: Readonly<Partial<UiActionsApi>>;
}
export type UiActionsApiPure = {
[K in keyof UiActionsApi]: (deps: UiActionsDependenciesInternal) => UiActionsApi[K];
};
export type TriggerRegistry = Map<string, Trigger>;
export type ActionRegistry = Map<string, Action>;
export type TriggerToActionsRegistry = Map<string, string[]>;