Further improve type checking for actions and triggers (#58765) (#59319)

* wip

* review follow up

* make ACTION a prefix, not SUFFIX

* fix path

* add warnings about casting to ActionType

* Make context  an object in examples, not a string

* require object context, which seems to fix the partial requirement in type and thus the type issue

* mistake

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:
Stacey Gammon 2020-03-05 08:38:27 -05:00 committed by GitHub
parent fcc65c8359
commit 60f6131d45
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 565 additions and 396 deletions

View file

@ -22,7 +22,7 @@ import { OverlayStart } from '../../../src/core/public';
import { createAction } from '../../../src/plugins/ui_actions/public';
import { toMountPoint } from '../../../src/plugins/kibana_react/public';
export const HELLO_WORLD_ACTION_TYPE = 'HELLO_WORLD_ACTION_TYPE';
export const ACTION_HELLO_WORLD = 'ACTION_HELLO_WORLD';
interface StartServices {
openModal: OverlayStart['openModal'];
@ -30,7 +30,7 @@ interface StartServices {
export const createHelloWorldAction = (getStartServices: () => Promise<StartServices>) =>
createAction({
type: HELLO_WORLD_ACTION_TYPE,
type: ACTION_HELLO_WORLD,
getDisplayName: () => 'Hello World!',
execute: async () => {
const { openModal } = await getStartServices();

View file

@ -23,4 +23,4 @@ import { PluginInitializer } from '../../../src/core/public';
export const plugin: PluginInitializer<void, void> = () => new UiActionExamplesPlugin();
export { HELLO_WORLD_TRIGGER_ID } from './hello_world_trigger';
export { HELLO_WORLD_ACTION_TYPE } from './hello_world_action';
export { ACTION_HELLO_WORLD } from './hello_world_action';

View file

@ -19,7 +19,7 @@
import { Plugin, CoreSetup } from '../../../src/core/public';
import { UiActionsSetup } from '../../../src/plugins/ui_actions/public';
import { createHelloWorldAction } from './hello_world_action';
import { createHelloWorldAction, ACTION_HELLO_WORLD } from './hello_world_action';
import { helloWorldTrigger, HELLO_WORLD_TRIGGER_ID } from './hello_world_trigger';
interface UiActionExamplesSetupDependencies {
@ -28,7 +28,11 @@ interface UiActionExamplesSetupDependencies {
declare module '../../../src/plugins/ui_actions/public' {
export interface TriggerContextMapping {
[HELLO_WORLD_TRIGGER_ID]: undefined;
[HELLO_WORLD_TRIGGER_ID]: {};
}
export interface ActionContextMapping {
[ACTION_HELLO_WORLD]: {};
}
}
@ -42,7 +46,7 @@ export class UiActionExamplesPlugin
}));
uiActions.registerAction(helloWorldAction);
uiActions.attachAction(helloWorldTrigger.id, helloWorldAction.id);
uiActions.attachAction(helloWorldTrigger.id, helloWorldAction);
}
public start() {}

View file

@ -27,44 +27,48 @@ export const USER_TRIGGER = 'USER_TRIGGER';
export const COUNTRY_TRIGGER = 'COUNTRY_TRIGGER';
export const PHONE_TRIGGER = 'PHONE_TRIGGER';
export const VIEW_IN_MAPS_ACTION = 'VIEW_IN_MAPS_ACTION';
export const TRAVEL_GUIDE_ACTION = 'TRAVEL_GUIDE_ACTION';
export const CALL_PHONE_NUMBER_ACTION = 'CALL_PHONE_NUMBER_ACTION';
export const EDIT_USER_ACTION = 'EDIT_USER_ACTION';
export const PHONE_USER_ACTION = 'PHONE_USER_ACTION';
export const SHOWCASE_PLUGGABILITY_ACTION = 'SHOWCASE_PLUGGABILITY_ACTION';
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_SHOWCASE_PLUGGABILITY = 'ACTION_SHOWCASE_PLUGGABILITY';
export const showcasePluggability = createAction({
type: SHOWCASE_PLUGGABILITY_ACTION,
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?!"),
});
export type PhoneContext = string;
export interface PhoneContext {
phone: string;
}
export const makePhoneCallAction = createAction<PhoneContext>({
type: CALL_PHONE_NUMBER_ACTION,
export const makePhoneCallAction = createAction<typeof ACTION_CALL_PHONE_NUMBER>({
type: ACTION_CALL_PHONE_NUMBER,
getDisplayName: () => 'Call phone number',
execute: async phone => alert(`Pretend calling ${phone}...`),
execute: async context => alert(`Pretend calling ${context.phone}...`),
});
export const lookUpWeatherAction = createAction<{ country: string }>({
type: TRAVEL_GUIDE_ACTION,
export const lookUpWeatherAction = createAction<typeof ACTION_TRAVEL_GUIDE>({
type: ACTION_TRAVEL_GUIDE,
getIconType: () => 'popout',
getDisplayName: () => 'View travel guide',
execute: async ({ country }) => {
window.open(`https://www.worldtravelguide.net/?s=${country},`, '_blank');
execute: async context => {
window.open(`https://www.worldtravelguide.net/?s=${context.country}`, '_blank');
},
});
export type CountryContext = string;
export interface CountryContext {
country: string;
}
export const viewInMapsAction = createAction<CountryContext>({
type: VIEW_IN_MAPS_ACTION,
export const viewInMapsAction = createAction<typeof ACTION_VIEW_IN_MAPS>({
type: ACTION_VIEW_IN_MAPS,
getIconType: () => 'popout',
getDisplayName: () => 'View in maps',
execute: async country => {
window.open(`https://www.google.com/maps/place/${country}`, '_blank');
execute: async context => {
window.open(`https://www.google.com/maps/place/${context.country}`, '_blank');
},
});
@ -100,11 +104,8 @@ function EditUserModal({
}
export const createEditUserAction = (getOpenModal: () => Promise<OverlayStart['openModal']>) =>
createAction<{
user: User;
update: (user: User) => void;
}>({
type: EDIT_USER_ACTION,
createAction<typeof ACTION_EDIT_USER>({
type: ACTION_EDIT_USER,
getIconType: () => 'pencil',
getDisplayName: () => 'Edit user',
execute: async ({ user, update }) => {
@ -120,8 +121,8 @@ export interface UserContext {
}
export const createPhoneUserAction = (getUiActionsApi: () => Promise<UiActionsStart>) =>
createAction<UserContext>({
type: PHONE_USER_ACTION,
createAction<typeof ACTION_PHONE_USER>({
type: ACTION_PHONE_USER,
getDisplayName: () => 'Call phone number',
isCompatible: async ({ user }) => user.phone !== undefined,
execute: async ({ user }) => {
@ -133,7 +134,7 @@ export const createPhoneUserAction = (getUiActionsApi: () => Promise<UiActionsSt
// 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, user.phone);
(await getUiActionsApi()).executeTriggerActions(PHONE_TRIGGER, { phone: user.phone });
}
},
});

View file

@ -35,7 +35,7 @@ import { EuiModalBody } from '@elastic/eui';
import { toMountPoint } from '../../../src/plugins/kibana_react/public';
import { UiActionsStart, createAction } from '../../../src/plugins/ui_actions/public';
import { AppMountParameters, OverlayStart } from '../../../src/core/public';
import { HELLO_WORLD_TRIGGER_ID, HELLO_WORLD_ACTION_TYPE } from '../../ui_action_examples/public';
import { HELLO_WORLD_TRIGGER_ID, ACTION_HELLO_WORLD } from '../../ui_action_examples/public';
import { TriggerContextExample } from './trigger_context_example';
interface Props {
@ -60,7 +60,7 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => {
</EuiText>
<EuiButton
data-test-subj="emitHelloWorldTrigger"
onClick={() => uiActionsApi.executeTriggerActions(HELLO_WORLD_TRIGGER_ID, undefined)}
onClick={() => uiActionsApi.executeTriggerActions(HELLO_WORLD_TRIGGER_ID, {})}
>
Say hello world!
</EuiButton>
@ -76,8 +76,9 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => {
<EuiButton
data-test-subj="addDynamicAction"
onClick={() => {
const dynamicAction = createAction<{}>({
type: `${HELLO_WORLD_ACTION_TYPE}-${name}`,
const dynamicAction = createAction<typeof ACTION_HELLO_WORLD>({
id: `${ACTION_HELLO_WORLD}-${name}`,
type: ACTION_HELLO_WORLD,
getDisplayName: () => `Say hello to ${name}`,
execute: async () => {
const overlay = openModal(
@ -95,7 +96,7 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => {
},
});
uiActionsApi.registerAction(dynamicAction);
uiActionsApi.attachAction(HELLO_WORLD_TRIGGER_ID, dynamicAction.type);
uiActionsApi.attachAction(HELLO_WORLD_TRIGGER_ID, dynamicAction);
setConfirmationText(
`You've successfully added a new action: ${dynamicAction.getDisplayName(
{}

View file

@ -27,17 +27,17 @@ import {
lookUpWeatherAction,
viewInMapsAction,
createEditUserAction,
CALL_PHONE_NUMBER_ACTION,
VIEW_IN_MAPS_ACTION,
TRAVEL_GUIDE_ACTION,
PHONE_USER_ACTION,
EDIT_USER_ACTION,
makePhoneCallAction,
showcasePluggability,
SHOWCASE_PLUGGABILITY_ACTION,
UserContext,
CountryContext,
PhoneContext,
ACTION_EDIT_USER,
ACTION_SHOWCASE_PLUGGABILITY,
ACTION_CALL_PHONE_NUMBER,
ACTION_TRAVEL_GUIDE,
ACTION_VIEW_IN_MAPS,
ACTION_PHONE_USER,
} from './actions/actions';
interface StartDeps {
@ -54,6 +54,15 @@ declare module '../../../src/plugins/ui_actions/public' {
[COUNTRY_TRIGGER]: CountryContext;
[PHONE_TRIGGER]: PhoneContext;
}
export interface ActionContextMapping {
[ACTION_EDIT_USER]: UserContext;
[ACTION_SHOWCASE_PLUGGABILITY]: {};
[ACTION_CALL_PHONE_NUMBER]: PhoneContext;
[ACTION_TRAVEL_GUIDE]: CountryContext;
[ACTION_VIEW_IN_MAPS]: CountryContext;
[ACTION_PHONE_USER]: UserContext;
}
}
export class UiActionsExplorerPlugin implements Plugin<void, void, {}, StartDeps> {
@ -67,29 +76,24 @@ export class UiActionsExplorerPlugin implements Plugin<void, void, {}, StartDeps
deps.uiActions.registerTrigger({
id: USER_TRIGGER,
});
deps.uiActions.registerAction(lookUpWeatherAction);
deps.uiActions.registerAction(viewInMapsAction);
deps.uiActions.registerAction(makePhoneCallAction);
deps.uiActions.registerAction(showcasePluggability);
const startServices = core.getStartServices();
deps.uiActions.registerAction(
deps.uiActions.attachAction(
USER_TRIGGER,
createPhoneUserAction(async () => (await startServices)[1].uiActions)
);
deps.uiActions.registerAction(
deps.uiActions.attachAction(
USER_TRIGGER,
createEditUserAction(async () => (await startServices)[0].overlays.openModal)
);
deps.uiActions.attachAction(USER_TRIGGER, PHONE_USER_ACTION);
deps.uiActions.attachAction(USER_TRIGGER, EDIT_USER_ACTION);
// What's missing here is type analysis to ensure the context emitted by the trigger
// is the same context that the action requires.
deps.uiActions.attachAction(COUNTRY_TRIGGER, VIEW_IN_MAPS_ACTION);
deps.uiActions.attachAction(COUNTRY_TRIGGER, TRAVEL_GUIDE_ACTION);
deps.uiActions.attachAction(COUNTRY_TRIGGER, SHOWCASE_PLUGGABILITY_ACTION);
deps.uiActions.attachAction(PHONE_TRIGGER, CALL_PHONE_NUMBER_ACTION);
deps.uiActions.attachAction(PHONE_TRIGGER, SHOWCASE_PLUGGABILITY_ACTION);
deps.uiActions.attachAction(USER_TRIGGER, SHOWCASE_PLUGGABILITY_ACTION);
deps.uiActions.attachAction(COUNTRY_TRIGGER, viewInMapsAction);
deps.uiActions.attachAction(COUNTRY_TRIGGER, lookUpWeatherAction);
deps.uiActions.attachAction(COUNTRY_TRIGGER, showcasePluggability);
deps.uiActions.attachAction(PHONE_TRIGGER, makePhoneCallAction);
deps.uiActions.attachAction(PHONE_TRIGGER, showcasePluggability);
deps.uiActions.attachAction(USER_TRIGGER, showcasePluggability);
core.application.register({
id: 'uiActionsExplorer',

View file

@ -47,7 +47,7 @@ const createRowData = (
<Fragment>
<EuiButtonEmpty
onClick={() => {
uiActionsApi.executeTriggerActions(COUNTRY_TRIGGER, user.countryOfResidence);
uiActionsApi.executeTriggerActions(COUNTRY_TRIGGER, { country: user.countryOfResidence });
}}
>
{user.countryOfResidence}
@ -59,7 +59,7 @@ const createRowData = (
<EuiButtonEmpty
disabled={user.phone === undefined}
onClick={() => {
uiActionsApi.executeTriggerActions(PHONE_TRIGGER, user.phone!);
uiActionsApi.executeTriggerActions(PHONE_TRIGGER, { phone: user.phone! });
}}
>
{user.phone}

View file

@ -19,21 +19,21 @@
import { i18n } from '@kbn/i18n';
import {
Action,
createAction,
IncompatibleActionError,
ActionByType,
} from '../../../../../plugins/ui_actions/public';
import { onBrushEvent } from './filters/brush_event';
import { FilterManager, TimefilterContract, esFilters } from '../../../../../plugins/data/public';
export const SELECT_RANGE_ACTION = 'SELECT_RANGE_ACTION';
export const ACTION_SELECT_RANGE = 'ACTION_SELECT_RANGE';
interface ActionContext {
export interface SelectRangeActionContext {
data: any;
timeFieldName: string;
}
async function isCompatible(context: ActionContext) {
async function isCompatible(context: SelectRangeActionContext) {
try {
return Boolean(await onBrushEvent(context.data));
} catch {
@ -44,17 +44,17 @@ async function isCompatible(context: ActionContext) {
export function selectRangeAction(
filterManager: FilterManager,
timeFilter: TimefilterContract
): Action<ActionContext> {
return createAction<ActionContext>({
type: SELECT_RANGE_ACTION,
id: SELECT_RANGE_ACTION,
): ActionByType<typeof ACTION_SELECT_RANGE> {
return createAction<typeof ACTION_SELECT_RANGE>({
type: ACTION_SELECT_RANGE,
id: ACTION_SELECT_RANGE,
getDisplayName: () => {
return i18n.translate('data.filter.applyFilterActionTitle', {
defaultMessage: 'Apply filter to current view',
});
},
isCompatible,
execute: async ({ timeFieldName, data }: ActionContext) => {
execute: async ({ timeFieldName, data }: SelectRangeActionContext) => {
if (!(await isCompatible({ timeFieldName, data }))) {
throw new IncompatibleActionError();
}

View file

@ -20,7 +20,7 @@
import { i18n } from '@kbn/i18n';
import { toMountPoint } from '../../../../../plugins/kibana_react/public';
import {
Action,
ActionByType,
createAction,
IncompatibleActionError,
} from '../../../../../plugins/ui_actions/public';
@ -37,14 +37,14 @@ import {
esFilters,
} from '../../../../../plugins/data/public';
export const VALUE_CLICK_ACTION = 'VALUE_CLICK_ACTION';
export const ACTION_VALUE_CLICK = 'ACTION_VALUE_CLICK';
interface ActionContext {
export interface ValueClickActionContext {
data: any;
timeFieldName: string;
}
async function isCompatible(context: ActionContext) {
async function isCompatible(context: ValueClickActionContext) {
try {
const filters: Filter[] =
(await createFiltersFromEvent(context.data.data || [context.data], context.data.negate)) ||
@ -58,17 +58,17 @@ async function isCompatible(context: ActionContext) {
export function valueClickAction(
filterManager: FilterManager,
timeFilter: TimefilterContract
): Action<ActionContext> {
return createAction<ActionContext>({
type: VALUE_CLICK_ACTION,
id: VALUE_CLICK_ACTION,
): ActionByType<typeof ACTION_VALUE_CLICK> {
return createAction<typeof ACTION_VALUE_CLICK>({
type: ACTION_VALUE_CLICK,
id: ACTION_VALUE_CLICK,
getDisplayName: () => {
return i18n.translate('data.filter.applyFilterActionTitle', {
defaultMessage: 'Apply filter to current view',
});
},
isCompatible,
execute: async ({ timeFieldName, data }: ActionContext) => {
execute: async ({ timeFieldName, data }: ValueClickActionContext) => {
if (!(await isCompatible({ timeFieldName, data }))) {
throw new IncompatibleActionError();
}

View file

@ -37,8 +37,16 @@ import {
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../plugins/data/public/services';
import { setSearchServiceShim } from './services';
import { SELECT_RANGE_ACTION, selectRangeAction } from './actions/select_range_action';
import { VALUE_CLICK_ACTION, valueClickAction } from './actions/value_click_action';
import {
selectRangeAction,
SelectRangeActionContext,
ACTION_SELECT_RANGE,
} from './actions/select_range_action';
import {
valueClickAction,
ACTION_VALUE_CLICK,
ValueClickActionContext,
} from './actions/value_click_action';
import {
SELECT_RANGE_TRIGGER,
VALUE_CLICK_TRIGGER,
@ -76,6 +84,12 @@ export interface DataSetup {
export interface DataStart {
search: SearchStart;
}
declare module '../../../../plugins/ui_actions/public' {
export interface ActionContextMapping {
[ACTION_SELECT_RANGE]: SelectRangeActionContext;
[ACTION_VALUE_CLICK]: ValueClickActionContext;
}
}
/**
* Data Plugin - public
@ -100,10 +114,13 @@ export class DataPlugin
// This is to be deprecated once we switch to the new search service fully
addSearchStrategy(defaultSearchStrategy);
uiActions.registerAction(
uiActions.attachAction(
SELECT_RANGE_TRIGGER,
selectRangeAction(data.query.filterManager, data.query.timefilter.timefilter)
);
uiActions.registerAction(
uiActions.attachAction(
VALUE_CLICK_TRIGGER,
valueClickAction(data.query.filterManager, data.query.timefilter.timefilter)
);
@ -123,9 +140,6 @@ export class DataPlugin
setSearchService(data.search);
setOverlays(core.overlays);
uiActions.attachAction(SELECT_RANGE_TRIGGER, SELECT_RANGE_ACTION);
uiActions.attachAction(VALUE_CLICK_TRIGGER, VALUE_CLICK_ACTION);
return {
search,
};

View file

@ -19,10 +19,10 @@
import { i18n } from '@kbn/i18n';
import { IEmbeddable } from '../embeddable_plugin';
import { Action, IncompatibleActionError } from '../ui_actions_plugin';
import { ActionByType, IncompatibleActionError } from '../ui_actions_plugin';
import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable';
export const EXPAND_PANEL_ACTION = 'togglePanel';
export const ACTION_EXPAND_PANEL = 'togglePanel';
function isDashboard(embeddable: IEmbeddable): embeddable is DashboardContainer {
return embeddable.type === DASHBOARD_CONTAINER_TYPE;
@ -36,18 +36,18 @@ function isExpanded(embeddable: IEmbeddable) {
return embeddable.id === embeddable.parent.getInput().expandedPanelId;
}
interface ActionContext {
export interface ExpandPanelActionContext {
embeddable: IEmbeddable;
}
export class ExpandPanelAction implements Action<ActionContext> {
public readonly type = EXPAND_PANEL_ACTION;
public readonly id = EXPAND_PANEL_ACTION;
export class ExpandPanelAction implements ActionByType<typeof ACTION_EXPAND_PANEL> {
public readonly type = ACTION_EXPAND_PANEL;
public readonly id = ACTION_EXPAND_PANEL;
public order = 7;
constructor() {}
public getDisplayName({ embeddable }: ActionContext) {
public getDisplayName({ embeddable }: ExpandPanelActionContext) {
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
throw new IncompatibleActionError();
}
@ -67,7 +67,7 @@ export class ExpandPanelAction implements Action<ActionContext> {
);
}
public getIconType({ embeddable }: ActionContext) {
public getIconType({ embeddable }: ExpandPanelActionContext) {
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
throw new IncompatibleActionError();
}
@ -75,11 +75,11 @@ export class ExpandPanelAction implements Action<ActionContext> {
return isExpanded(embeddable) ? 'expand' : 'expand';
}
public async isCompatible({ embeddable }: ActionContext) {
public async isCompatible({ embeddable }: ExpandPanelActionContext) {
return Boolean(embeddable.parent && isDashboard(embeddable.parent));
}
public async execute({ embeddable }: ActionContext) {
public async execute({ embeddable }: ExpandPanelActionContext) {
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
throw new IncompatibleActionError();
}

View file

@ -17,5 +17,5 @@
* under the License.
*/
export { ExpandPanelAction, EXPAND_PANEL_ACTION } from './expand_panel_action';
export { ReplacePanelAction, REPLACE_PANEL_ACTION } from './replace_panel_action';
export { ExpandPanelAction, ACTION_EXPAND_PANEL } from './expand_panel_action';
export { ReplacePanelAction, ACTION_REPLACE_PANEL } from './replace_panel_action';

View file

@ -21,22 +21,22 @@ import { i18n } from '@kbn/i18n';
import { CoreStart } from '../../../../core/public';
import { IEmbeddable, ViewMode, IEmbeddableStart } from '../embeddable_plugin';
import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable';
import { Action, IncompatibleActionError } from '../ui_actions_plugin';
import { ActionByType, IncompatibleActionError } from '../ui_actions_plugin';
import { openReplacePanelFlyout } from './open_replace_panel_flyout';
export const REPLACE_PANEL_ACTION = 'replacePanel';
export const ACTION_REPLACE_PANEL = 'replacePanel';
function isDashboard(embeddable: IEmbeddable): embeddable is DashboardContainer {
return embeddable.type === DASHBOARD_CONTAINER_TYPE;
}
interface ActionContext {
export interface ReplacePanelActionContext {
embeddable: IEmbeddable;
}
export class ReplacePanelAction implements Action<ActionContext> {
public readonly type = REPLACE_PANEL_ACTION;
public readonly id = REPLACE_PANEL_ACTION;
export class ReplacePanelAction implements ActionByType<typeof ACTION_REPLACE_PANEL> {
public readonly type = ACTION_REPLACE_PANEL;
public readonly id = ACTION_REPLACE_PANEL;
public order = 11;
constructor(
@ -46,7 +46,7 @@ export class ReplacePanelAction implements Action<ActionContext> {
private getEmbeddableFactories: IEmbeddableStart['getEmbeddableFactories']
) {}
public getDisplayName({ embeddable }: ActionContext) {
public getDisplayName({ embeddable }: ReplacePanelActionContext) {
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
throw new IncompatibleActionError();
}
@ -55,14 +55,14 @@ export class ReplacePanelAction implements Action<ActionContext> {
});
}
public getIconType({ embeddable }: ActionContext) {
public getIconType({ embeddable }: ReplacePanelActionContext) {
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
throw new IncompatibleActionError();
}
return 'kqlOperand';
}
public async isCompatible({ embeddable }: ActionContext) {
public async isCompatible({ embeddable }: ReplacePanelActionContext) {
if (embeddable.getInput().viewMode) {
if (embeddable.getInput().viewMode === ViewMode.VIEW) {
return false;
@ -72,7 +72,7 @@ export class ReplacePanelAction implements Action<ActionContext> {
return Boolean(embeddable.parent && isDashboard(embeddable.parent));
}
public async execute({ embeddable }: ActionContext) {
public async execute({ embeddable }: ReplacePanelActionContext) {
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
throw new IncompatibleActionError();
}

View file

@ -31,6 +31,8 @@ import {
ExitFullScreenButton as ExitFullScreenButtonUi,
ExitFullScreenButtonProps,
} from '../../../plugins/kibana_react/public';
import { ExpandPanelActionContext, ACTION_EXPAND_PANEL } from './actions/expand_panel_action';
import { ReplacePanelActionContext, ACTION_REPLACE_PANEL } from './actions/replace_panel_action';
interface SetupDependencies {
embeddable: IEmbeddableSetup;
@ -46,6 +48,13 @@ interface StartDependencies {
export type Setup = void;
export type Start = void;
declare module '../../../plugins/ui_actions/public' {
export interface ActionContextMapping {
[ACTION_EXPAND_PANEL]: ExpandPanelActionContext;
[ACTION_REPLACE_PANEL]: ReplacePanelActionContext;
}
}
export class DashboardEmbeddableContainerPublicPlugin
implements Plugin<Setup, Start, SetupDependencies, StartDependencies> {
constructor(initializerContext: PluginInitializerContext) {}
@ -53,7 +62,7 @@ export class DashboardEmbeddableContainerPublicPlugin
public setup(core: CoreSetup, { embeddable, uiActions }: SetupDependencies): Setup {
const expandPanelAction = new ExpandPanelAction();
uiActions.registerAction(expandPanelAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction);
}
public start(core: CoreStart, plugins: StartDependencies): Start {
@ -81,7 +90,7 @@ export class DashboardEmbeddableContainerPublicPlugin
plugins.embeddable.getEmbeddableFactories
);
uiActions.registerAction(changeViewAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction.id);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction);
const factory = new DashboardContainerFactory({
application,

View file

@ -49,7 +49,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => {
const editModeAction = createEditModeAction();
uiActionsSetup.registerAction(editModeAction);
uiActionsSetup.attachAction(CONTEXT_MENU_TRIGGER, editModeAction.id);
uiActionsSetup.attachAction(CONTEXT_MENU_TRIGGER, editModeAction);
setup.registerEmbeddableFactory(
CONTACT_CARD_EMBEDDABLE,
new ContactCardEmbeddableFactory({} as any, (() => null) as any, {} as any)

View file

@ -19,36 +19,36 @@
import { i18n } from '@kbn/i18n';
import { toMountPoint } from '../../../kibana_react/public';
import { Action, createAction, IncompatibleActionError } from '../../../ui_actions/public';
import { ActionByType, createAction, IncompatibleActionError } from '../../../ui_actions/public';
import { getOverlays, getIndexPatterns } from '../services';
import { applyFiltersPopover } from '../ui/apply_filters';
import { Filter, FilterManager, TimefilterContract, esFilters } from '..';
export const GLOBAL_APPLY_FILTER_ACTION = 'GLOBAL_APPLY_FILTER_ACTION';
export const ACTION_GLOBAL_APPLY_FILTER = 'ACTION_GLOBAL_APPLY_FILTER';
interface ActionContext {
export interface ApplyGlobalFilterActionContext {
filters: Filter[];
timeFieldName?: string;
}
async function isCompatible(context: ActionContext) {
async function isCompatible(context: ApplyGlobalFilterActionContext) {
return context.filters !== undefined;
}
export function createFilterAction(
filterManager: FilterManager,
timeFilter: TimefilterContract
): Action<ActionContext> {
return createAction<ActionContext>({
type: GLOBAL_APPLY_FILTER_ACTION,
id: GLOBAL_APPLY_FILTER_ACTION,
): ActionByType<typeof ACTION_GLOBAL_APPLY_FILTER> {
return createAction<typeof ACTION_GLOBAL_APPLY_FILTER>({
type: ACTION_GLOBAL_APPLY_FILTER,
id: ACTION_GLOBAL_APPLY_FILTER,
getDisplayName: () => {
return i18n.translate('data.filter.applyFilterActionTitle', {
defaultMessage: 'Apply filter to current view',
});
},
isCompatible,
execute: async ({ filters, timeFieldName }: ActionContext) => {
execute: async ({ filters, timeFieldName }: ApplyGlobalFilterActionContext) => {
if (!filters) {
throw new Error('Applying a filter requires a filter');
}

View file

@ -17,4 +17,4 @@
* under the License.
*/
export { GLOBAL_APPLY_FILTER_ACTION, createFilterAction } from './apply_filter_action';
export { ACTION_GLOBAL_APPLY_FILTER, createFilterAction } from './apply_filter_action';

View file

@ -44,9 +44,16 @@ import {
setIndexPatterns,
setUiSettings,
} from './services';
import { createFilterAction, GLOBAL_APPLY_FILTER_ACTION } from './actions';
import { createFilterAction, ACTION_GLOBAL_APPLY_FILTER } from './actions';
import { APPLY_FILTER_TRIGGER } from '../../embeddable/public';
import { createSearchBar } from './ui/search_bar/create_search_bar';
import { ApplyGlobalFilterActionContext } from './actions/apply_filter_action';
declare module '../../ui_actions/public' {
export interface ActionContextMapping {
[ACTION_GLOBAL_APPLY_FILTER]: ApplyGlobalFilterActionContext;
}
}
export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPublicPluginStart> {
private readonly autocomplete = new AutocompleteService();
@ -93,7 +100,7 @@ export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPubli
const indexPatternsService = new IndexPatternsService(uiSettings, savedObjects.client, http);
setIndexPatterns(indexPatternsService);
uiActions.attachAction(APPLY_FILTER_TRIGGER, GLOBAL_APPLY_FILTER_ACTION);
uiActions.attachAction(APPLY_FILTER_TRIGGER, uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER));
const dataServices = {
autocomplete: this.autocomplete.start(),

View file

@ -33,6 +33,13 @@ import {
SELECT_RANGE_TRIGGER,
CONTEXT_MENU_TRIGGER,
PANEL_BADGE_TRIGGER,
ACTION_ADD_PANEL,
ACTION_CUSTOMIZE_PANEL,
ACTION_INSPECT_PANEL,
REMOVE_PANEL_ACTION,
ACTION_EDIT_PANEL,
FilterActionContext,
ACTION_APPLY_FILTER,
} from './lib';
declare module '../../ui_actions/public' {
@ -46,6 +53,15 @@ declare module '../../ui_actions/public' {
[CONTEXT_MENU_TRIGGER]: EmbeddableContext;
[PANEL_BADGE_TRIGGER]: EmbeddableContext;
}
export interface ActionContextMapping {
[ACTION_CUSTOMIZE_PANEL]: EmbeddableContext;
[ACTION_ADD_PANEL]: EmbeddableContext;
[ACTION_INSPECT_PANEL]: EmbeddableContext;
[REMOVE_PANEL_ACTION]: EmbeddableContext;
[ACTION_EDIT_PANEL]: EmbeddableContext;
[ACTION_APPLY_FILTER]: FilterActionContext;
}
}
/**

View file

@ -24,9 +24,9 @@ import { EmbeddablePublicPlugin } from './plugin';
export {
Adapters,
ADD_PANEL_ACTION_ID,
ACTION_ADD_PANEL,
AddPanelAction,
APPLY_FILTER_ACTION,
ACTION_APPLY_FILTER,
APPLY_FILTER_TRIGGER,
applyFilterTrigger,
Container,
@ -34,7 +34,7 @@ export {
ContainerOutput,
CONTEXT_MENU_TRIGGER,
contextMenuTrigger,
EDIT_PANEL_ACTION_ID,
ACTION_EDIT_PANEL,
EditPanelAction,
Embeddable,
EmbeddableChildPanel,

View file

@ -20,10 +20,10 @@
import { createFilterAction } from './apply_filter_action';
import { expectErrorAsync } from '../../tests/helpers';
test('has APPLY_FILTER_ACTION type and id', () => {
test('has ACTION_APPLY_FILTER type and id', () => {
const action = createFilterAction();
expect(action.id).toBe('APPLY_FILTER_ACTION');
expect(action.type).toBe('APPLY_FILTER_ACTION');
expect(action.id).toBe('ACTION_APPLY_FILTER');
expect(action.type).toBe('ACTION_APPLY_FILTER');
});
test('has expected display name', () => {

View file

@ -18,19 +18,19 @@
*/
import { i18n } from '@kbn/i18n';
import { Action, createAction, IncompatibleActionError } from '../ui_actions';
import { ActionByType, createAction, IncompatibleActionError } from '../ui_actions';
import { IEmbeddable, EmbeddableInput } from '../embeddables';
import { Filter } from '../../../../../plugins/data/public';
export const APPLY_FILTER_ACTION = 'APPLY_FILTER_ACTION';
export const ACTION_APPLY_FILTER = 'ACTION_APPLY_FILTER';
type RootEmbeddable = IEmbeddable<EmbeddableInput & { filters: Filter[] }>;
interface ActionContext {
export interface FilterActionContext {
embeddable: IEmbeddable;
filters: Filter[];
}
async function isCompatible(context: ActionContext) {
async function isCompatible(context: FilterActionContext) {
if (context.embeddable === undefined) {
return false;
}
@ -38,10 +38,10 @@ async function isCompatible(context: ActionContext) {
return Boolean(root.getInput().filters !== undefined && context.filters !== undefined);
}
export function createFilterAction(): Action<ActionContext> {
return createAction<ActionContext>({
type: APPLY_FILTER_ACTION,
id: APPLY_FILTER_ACTION,
export function createFilterAction(): ActionByType<typeof ACTION_APPLY_FILTER> {
return createAction<typeof ACTION_APPLY_FILTER>({
type: ACTION_APPLY_FILTER,
id: ACTION_APPLY_FILTER,
getDisplayName: () => {
return i18n.translate('embeddableApi.actions.applyFilterActionTitle', {
defaultMessage: 'Apply filter to current view',

View file

@ -23,15 +23,15 @@ import { GetEmbeddableFactory, ViewMode } from '../types';
import { EmbeddableFactoryNotFoundError } from '../errors';
import { IEmbeddable } from '../embeddables';
export const EDIT_PANEL_ACTION_ID = 'editPanel';
export const ACTION_EDIT_PANEL = 'editPanel';
interface ActionContext {
embeddable: IEmbeddable;
}
export class EditPanelAction implements Action<ActionContext> {
public readonly type = EDIT_PANEL_ACTION_ID;
public readonly id = EDIT_PANEL_ACTION_ID;
public readonly type = ACTION_EDIT_PANEL;
public readonly id = ACTION_EDIT_PANEL;
public order = 15;
constructor(private readonly getEmbeddableFactory: GetEmbeddableFactory) {}

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, UiActionsStart } from 'src/plugins/ui_actions/public';
import { Action, UiActionsStart, ActionType } from 'src/plugins/ui_actions/public';
import { Trigger, GetEmbeddableFactory, ViewMode } from '../types';
import { EmbeddableFactory, isErrorEmbeddable } from '../embeddables';
import { EmbeddablePanel } from './embeddable_panel';
@ -213,9 +213,9 @@ const renderInEditModeAndOpenContextMenu = async (
};
test('HelloWorldContainer in edit mode hides disabledActions', async () => {
const action = {
const action: Action = {
id: 'FOO',
type: 'FOO',
type: 'FOO' as ActionType,
getIconType: () => undefined,
getDisplayName: () => 'foo',
isCompatible: async () => true,
@ -245,9 +245,9 @@ test('HelloWorldContainer in edit mode hides disabledActions', async () => {
});
test('HelloWorldContainer hides disabled badges', async () => {
const action = {
const action: Action = {
id: 'BAR',
type: 'BAR',
type: 'BAR' as ActionType,
getIconType: () => undefined,
getDisplayName: () => 'bar',
isCompatible: async () => true,

View file

@ -17,5 +17,5 @@
* under the License.
*/
export { EmbeddablePanel } from './embeddable_panel';
export { ADD_PANEL_ACTION_ID, AddPanelAction, openAddPanelFlyout } from './panel_header';
export * from './embeddable_panel';
export * from './panel_header';

View file

@ -17,9 +17,4 @@
* under the License.
*/
export {
ADD_PANEL_ACTION_ID,
AddPanelAction,
RemovePanelAction,
openAddPanelFlyout,
} from './panel_actions';
export * from './panel_actions';

View file

@ -23,15 +23,15 @@ import { ViewMode, GetEmbeddableFactory, GetEmbeddableFactories } from '../../..
import { openAddPanelFlyout } from './open_add_panel_flyout';
import { IContainer } from '../../../../containers';
export const ADD_PANEL_ACTION_ID = 'ADD_PANEL_ACTION_ID';
export const ACTION_ADD_PANEL = 'ACTION_ADD_PANEL';
interface ActionContext {
embeddable: IContainer;
}
export class AddPanelAction implements Action<ActionContext> {
public readonly type = ADD_PANEL_ACTION_ID;
public readonly id = ADD_PANEL_ACTION_ID;
public readonly type = ACTION_ADD_PANEL;
public readonly id = ACTION_ADD_PANEL;
constructor(
private readonly getFactory: GetEmbeddableFactory,

View file

@ -22,7 +22,7 @@ import { Action } from 'src/plugins/ui_actions/public';
import { ViewMode } from '../../../../types';
import { IEmbeddable } from '../../../../embeddables';
const CUSTOMIZE_PANEL_ACTION_ID = 'CUSTOMIZE_PANEL_ACTION_ID';
export const ACTION_CUSTOMIZE_PANEL = 'ACTION_CUSTOMIZE_PANEL';
type GetUserData = (context: ActionContext) => Promise<{ title: string | undefined }>;
@ -31,8 +31,8 @@ interface ActionContext {
}
export class CustomizePanelTitleAction implements Action<ActionContext> {
public readonly type = CUSTOMIZE_PANEL_ACTION_ID;
public id = CUSTOMIZE_PANEL_ACTION_ID;
public readonly type = ACTION_CUSTOMIZE_PANEL;
public id = ACTION_CUSTOMIZE_PANEL;
public order = 10;
constructor(private readonly getDataFromUser: GetUserData) {

View file

@ -17,14 +17,4 @@
* under the License.
*/
import { Action, createAction } from '../../actions';
export const RESTRICTED_ACTION = 'RESTRICTED_ACTION';
export function createRestrictedAction<C>(isCompatibleIn: (context: C) => boolean): Action<C> {
return createAction<C>({
type: RESTRICTED_ACTION,
isCompatible: async context => isCompatibleIn(context),
execute: async () => {},
});
}
export * from './customize_panel_action';

View file

@ -17,6 +17,7 @@
* under the License.
*/
export { InspectPanelAction } from './inspect_panel_action';
export { ADD_PANEL_ACTION_ID, AddPanelAction, openAddPanelFlyout } from './add_panel';
export { RemovePanelAction } from './remove_panel_action';
export * from './inspect_panel_action';
export * from './add_panel';
export * from './remove_panel_action';
export * from './customize_title';

View file

@ -22,15 +22,15 @@ import { Action } from 'src/plugins/ui_actions/public';
import { Start as InspectorStartContract } from 'src/plugins/inspector/public';
import { IEmbeddable } from '../../../embeddables';
export const INSPECT_PANEL_ACTION_ID = 'openInspector';
export const ACTION_INSPECT_PANEL = 'openInspector';
interface ActionContext {
embeddable: IEmbeddable;
}
export class InspectPanelAction implements Action<ActionContext> {
public readonly type = INSPECT_PANEL_ACTION_ID;
public readonly id = INSPECT_PANEL_ACTION_ID;
public readonly type = ACTION_INSPECT_PANEL;
public readonly id = ACTION_INSPECT_PANEL;
public order = 10;
constructor(private readonly inspector: InspectorStartContract) {}

View file

@ -17,17 +17,20 @@
* under the License.
*/
import { createAction } from '../../ui_actions';
import { createAction, ActionType } from '../../ui_actions';
import { ViewMode } from '../../types';
import { EmbeddableContext } from '../../triggers';
import { IEmbeddable } from '../..';
export const EDIT_MODE_ACTION = 'EDIT_MODE_ACTION';
// Casting to ActionType is a hack - in a real situation use
// declare module and add this id to ActionContextMapping.
export const EDIT_MODE_ACTION = 'EDIT_MODE_ACTION' as ActionType;
export function createEditModeAction() {
return createAction<EmbeddableContext>({
return createAction<typeof EDIT_MODE_ACTION>({
type: EDIT_MODE_ACTION,
getDisplayName: () => 'I only show up in edit mode',
isCompatible: async context => context.embeddable.getInput().viewMode === ViewMode.EDIT,
isCompatible: async (context: { embeddable: IEmbeddable }) =>
context.embeddable.getInput().viewMode === ViewMode.EDIT,
execute: async () => {},
});
}

View file

@ -17,10 +17,12 @@
* under the License.
*/
import { Action, IncompatibleActionError } from '../../ui_actions';
import { ActionByType, IncompatibleActionError, ActionType } from '../../ui_actions';
import { EmbeddableInput, Embeddable, EmbeddableOutput, IEmbeddable } from '../../embeddables';
export const SAY_HELLO_ACTION = 'SAY_HELLO_ACTION';
// Casting to ActionType is a hack - in a real situation use
// declare module and add this id to ActionContextMapping.
export const SAY_HELLO_ACTION = 'SAY_HELLO_ACTION' as ActionType;
export interface FullNameEmbeddableOutput extends EmbeddableOutput {
fullName: string;
@ -35,12 +37,12 @@ export function hasFullNameOutput(
);
}
interface ActionContext {
export interface SayHelloActionContext {
embeddable: Embeddable<EmbeddableInput, FullNameEmbeddableOutput>;
message?: string;
}
export class SayHelloAction implements Action<ActionContext> {
export class SayHelloAction implements ActionByType<typeof SAY_HELLO_ACTION> {
public readonly type = SAY_HELLO_ACTION;
public readonly id = SAY_HELLO_ACTION;
@ -62,7 +64,7 @@ export class SayHelloAction implements Action<ActionContext> {
// Can use typescript generics to get compiler time warnings for immediate feedback if
// the context is not compatible.
async isCompatible(context: ActionContext) {
async isCompatible(context: SayHelloActionContext) {
// Option 1: only compatible with Greeting Embeddables.
// return context.embeddable.type === CONTACT_CARD_EMBEDDABLE;
@ -70,7 +72,7 @@ export class SayHelloAction implements Action<ActionContext> {
return hasFullNameOutput(context.embeddable);
}
async execute(context: ActionContext) {
async execute(context: SayHelloActionContext) {
if (!(await this.isCompatible(context))) {
throw new IncompatibleActionError();
}

View file

@ -18,14 +18,16 @@
*/
import React from 'react';
import { EuiFlyoutBody } from '@elastic/eui';
import { createAction, IncompatibleActionError } from '../../ui_actions';
import { createAction, IncompatibleActionError, ActionType } from '../../ui_actions';
import { CoreStart } from '../../../../../../core/public';
import { toMountPoint } from '../../../../../kibana_react/public';
import { Embeddable, EmbeddableInput } from '../../embeddables';
import { GetMessageModal } from './get_message_modal';
import { FullNameEmbeddableOutput, hasFullNameOutput } from './say_hello_action';
export const SEND_MESSAGE_ACTION = 'SEND_MESSAGE_ACTION';
// Casting to ActionType is a hack - in a real situation use
// declare module and add this id to ActionContextMapping.
export const ACTION_SEND_MESSAGE = 'ACTION_SEND_MESSAGE' as ActionType;
interface ActionContext {
embeddable: Embeddable<EmbeddableInput, FullNameEmbeddableOutput>;
@ -42,11 +44,11 @@ export function createSendMessageAction(overlays: CoreStart['overlays']) {
overlays.openFlyout(toMountPoint(<EuiFlyoutBody>{content}</EuiFlyoutBody>));
};
return createAction<ActionContext>({
type: SEND_MESSAGE_ACTION,
return createAction<typeof ACTION_SEND_MESSAGE>({
type: ACTION_SEND_MESSAGE,
getDisplayName: () => 'Send message',
isCompatible,
execute: async context => {
execute: async (context: ActionContext) => {
if (!(await isCompatible(context))) {
throw new IncompatibleActionError();
}

View file

@ -17,17 +17,23 @@
* under the License.
*/
import { createSayHelloAction } from '../tests/test_samples/say_hello_action';
import { createAction } from '../../../ui_actions/public';
import { ActionType } from '../types';
test('SayHelloAction is not compatible with not matching context', async () => {
const sayHelloAction = createSayHelloAction((() => {}) as any);
const sayHelloAction = createAction({
// Casting to ActionType is a hack - in a real situation use
// declare module and add this id to ActionContextMapping.
type: 'test' as ActionType,
isCompatible: ({ amICompatible }: { amICompatible: boolean }) => Promise.resolve(amICompatible),
execute: () => Promise.resolve(),
});
const isCompatible = await sayHelloAction.isCompatible({} as any);
test('action is not compatible based on context', async () => {
const isCompatible = await sayHelloAction.isCompatible({ amICompatible: false });
expect(isCompatible).toBe(false);
});
test('HelloWorldAction inherits isCompatible from base action', async () => {
const helloWorldAction = createSayHelloAction({} as any);
const isCompatible = await helloWorldAction.isCompatible({ name: 'Sue' });
test('action is compatible based on context', async () => {
const isCompatible = await sayHelloAction.isCompatible({ amICompatible: true });
expect(isCompatible).toBe(true);
});

View file

@ -18,17 +18,26 @@
*/
import { UiComponent } from 'src/plugins/kibana_utils/common';
import { ActionType, ActionContextMapping } from '../types';
export interface Action<Context = undefined> {
export type ActionByType<T extends ActionType> = Action<ActionContextMapping[T], T>;
export interface Action<Context = {}, T = ActionType> {
/**
* Determined the order when there is more than one action matched to a trigger.
* Higher numbers are displayed first.
*/
order?: number;
/**
* A unique identifier for this action instance.
*/
id: string;
readonly type: string;
/**
* The action type is what determines the context shape.
*/
readonly type: T;
/**
* Optional EUI icon type that can be displayed along with the title.

View file

@ -0,0 +1,72 @@
/*
* 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 { UiComponent } from 'src/plugins/kibana_utils/common';
import { ActionType, ActionContextMapping } from '../types';
export interface ActionDefinition<T extends ActionType> {
/**
* Determined the order when there is more than one action matched to a trigger.
* Higher numbers are displayed first.
*/
order?: number;
/**
* A unique identifier for this action instance.
*/
id?: string;
/**
* The action type is what determines the context shape.
*/
readonly type: T;
/**
* Optional EUI icon type that can be displayed along with the title.
*/
getIconType?(context: ActionContextMapping[T]): string;
/**
* Returns a title to be displayed to the user.
* @param context
*/
getDisplayName?(context: ActionContextMapping[T]): string;
/**
* `UiComponent` to render when displaying this action as a context menu item.
* If not provided, `getDisplayName` will be used instead.
*/
MenuItem?: UiComponent<{ context: ActionContextMapping[T] }>;
/**
* Returns a promise that resolves to true if this action is compatible given the context,
* otherwise resolves to false.
*/
isCompatible?(context: ActionContextMapping[T]): Promise<boolean>;
/**
* If this returns something truthy, this is used in addition to the `execute` method when clicked.
*/
getHref?(context: ActionContextMapping[T]): string | undefined;
/**
* Executes the action.
*/
execute(context: ActionContextMapping[T]): Promise<void>;
}

View file

@ -17,11 +17,11 @@
* under the License.
*/
import { Action } from './action';
import { ActionByType } from './action';
import { ActionType } from '../types';
import { ActionDefinition } from './action_definition';
export function createAction<Context = undefined>(
action: { type: string; execute: Action<Context>['execute'] } & Partial<Action<Context>>
): Action<Context> {
export function createAction<T extends ActionType>(action: ActionDefinition<T>): ActionByType<T> {
return {
getIconType: () => undefined,
order: 0,

View file

@ -29,4 +29,5 @@ export { UiActionsServiceParams, UiActionsService } from './service';
export { Action, createAction, IncompatibleActionError } from './actions';
export { buildContextMenuForActions } from './context_menu';
export { Trigger, TriggerContext } from './triggers';
export { TriggerContextMapping, TriggerId } from './types';
export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types';
export { ActionByType } from './actions';

View file

@ -41,6 +41,7 @@ const createStartContract = (): Start => {
attachAction: jest.fn(),
registerAction: jest.fn(),
registerTrigger: jest.fn(),
getAction: jest.fn(),
detachAction: jest.fn(),
executeTriggerActions: jest.fn(),
getTrigger: jest.fn(),

View file

@ -18,14 +18,13 @@
*/
import { UiActionsService } from './ui_actions_service';
import { Action } from '../actions';
import { createRestrictedAction, createHelloWorldAction } from '../tests/test_samples';
import { ActionRegistry, TriggerRegistry, TriggerId } from '../types';
import { Action, createAction } from '../actions';
import { createHelloWorldAction } from '../tests/test_samples';
import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types';
import { Trigger } from '../triggers';
// I tried redeclaring the module in here to extend the `TriggerContextMapping` but
// that seems to overwrite all other plugins extending it, I suspect because it's inside
// the main plugin.
// Casting to ActionType or TriggerId is a hack - in a real situation use
// declare module and add this id to the appropriate context mapping.
const FOO_TRIGGER: TriggerId = 'FOO_TRIGGER' as TriggerId;
const BAR_TRIGGER: TriggerId = 'BAR_TRIGGER' as TriggerId;
const MY_TRIGGER: TriggerId = 'MY_TRIGGER' as TriggerId;
@ -33,7 +32,7 @@ const MY_TRIGGER: TriggerId = 'MY_TRIGGER' as TriggerId;
const testAction1: Action = {
id: 'action1',
order: 1,
type: 'type1',
type: 'type1' as ActionType,
execute: async () => {},
getDisplayName: () => 'test1',
getIconType: () => '',
@ -43,7 +42,7 @@ const testAction1: Action = {
const testAction2: Action = {
id: 'action2',
order: 2,
type: 'type2',
type: 'type2' as ActionType,
execute: async () => {},
getDisplayName: () => 'test2',
getIconType: () => '',
@ -100,7 +99,7 @@ describe('UiActionsService', () => {
getDisplayName: () => 'test',
getIconType: () => '',
isCompatible: async () => true,
type: 'test',
type: 'test' as ActionType,
});
});
});
@ -109,7 +108,7 @@ describe('UiActionsService', () => {
const action1: Action = {
id: 'action1',
order: 1,
type: 'type1',
type: 'type1' as ActionType,
execute: async () => {},
getDisplayName: () => 'test',
getIconType: () => '',
@ -118,7 +117,7 @@ describe('UiActionsService', () => {
const action2: Action = {
id: 'action2',
order: 2,
type: 'type2',
type: 'type2' as ActionType,
execute: async () => {},
getDisplayName: () => 'test',
getIconType: () => '',
@ -140,13 +139,13 @@ describe('UiActionsService', () => {
expect(list0).toHaveLength(0);
service.attachAction(FOO_TRIGGER, 'action1');
service.attachAction(FOO_TRIGGER, action1);
const list1 = service.getTriggerActions(FOO_TRIGGER);
expect(list1).toHaveLength(1);
expect(list1).toEqual([action1]);
service.attachAction(FOO_TRIGGER, 'action2');
service.attachAction(FOO_TRIGGER, action2);
const list2 = service.getTriggerActions(FOO_TRIGGER);
expect(list2).toHaveLength(2);
@ -179,7 +178,7 @@ describe('UiActionsService', () => {
title: 'My trigger',
};
service.registerTrigger(testTrigger);
service.attachAction(MY_TRIGGER, helloWorldAction.id);
service.attachAction(MY_TRIGGER, helloWorldAction);
const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, {
hi: 'there',
@ -191,11 +190,13 @@ describe('UiActionsService', () => {
test('filters out actions not applicable based on the context', async () => {
const service = new UiActionsService();
const restrictedAction = createRestrictedAction<{ accept: boolean }>(context => {
return context.accept;
const action = createAction({
type: 'test' as ActionType,
isCompatible: ({ accept }: { accept: boolean }) => Promise.resolve(accept),
execute: () => Promise.resolve(),
});
service.registerAction(restrictedAction);
service.registerAction(action);
const testTrigger: Trigger = {
id: MY_TRIGGER,
@ -203,7 +204,7 @@ describe('UiActionsService', () => {
};
service.registerTrigger(testTrigger);
service.attachAction(testTrigger.id, restrictedAction.id);
service.attachAction(testTrigger.id, action);
const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, {
accept: true,
@ -287,7 +288,7 @@ describe('UiActionsService', () => {
id: FOO_TRIGGER,
});
service1.registerAction(testAction1);
service1.attachAction(FOO_TRIGGER, testAction1.id);
service1.attachAction(FOO_TRIGGER, testAction1);
const service2 = service1.fork();
@ -308,14 +309,14 @@ describe('UiActionsService', () => {
});
service1.registerAction(testAction1);
service1.registerAction(testAction2);
service1.attachAction(FOO_TRIGGER, testAction1.id);
service1.attachAction(FOO_TRIGGER, testAction1);
const service2 = service1.fork();
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
service2.attachAction(FOO_TRIGGER, testAction2.id);
service2.attachAction(FOO_TRIGGER, testAction2);
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
@ -329,14 +330,14 @@ describe('UiActionsService', () => {
});
service1.registerAction(testAction1);
service1.registerAction(testAction2);
service1.attachAction(FOO_TRIGGER, testAction1.id);
service1.attachAction(FOO_TRIGGER, testAction1);
const service2 = service1.fork();
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
service1.attachAction(FOO_TRIGGER, testAction2.id);
service1.attachAction(FOO_TRIGGER, testAction2);
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
@ -344,7 +345,7 @@ describe('UiActionsService', () => {
});
describe('registries', () => {
const HELLO_WORLD_ACTION_ID = 'HELLO_WORLD_ACTION_ID';
const ACTION_HELLO_WORLD = 'ACTION_HELLO_WORLD';
test('can register trigger', () => {
const triggers: TriggerRegistry = new Map();
@ -369,12 +370,12 @@ describe('UiActionsService', () => {
const service = new UiActionsService({ actions });
service.registerAction({
id: HELLO_WORLD_ACTION_ID,
id: ACTION_HELLO_WORLD,
order: 13,
} as any);
expect(actions.get(HELLO_WORLD_ACTION_ID)).toMatchObject({
id: HELLO_WORLD_ACTION_ID,
expect(actions.get(ACTION_HELLO_WORLD)).toMatchObject({
id: ACTION_HELLO_WORLD,
order: 13,
});
});
@ -386,18 +387,17 @@ describe('UiActionsService', () => {
id: MY_TRIGGER,
};
const action = {
id: HELLO_WORLD_ACTION_ID,
id: ACTION_HELLO_WORLD,
order: 25,
} as any;
service.registerTrigger(trigger);
service.registerAction(action);
service.attachAction(MY_TRIGGER, HELLO_WORLD_ACTION_ID);
service.attachAction(MY_TRIGGER, action);
const actions = service.getTriggerActions(trigger.id);
expect(actions.length).toBe(1);
expect(actions[0].id).toBe(HELLO_WORLD_ACTION_ID);
expect(actions[0].id).toBe(ACTION_HELLO_WORLD);
});
test('can detach an action to a trigger', () => {
@ -407,14 +407,14 @@ describe('UiActionsService', () => {
id: MY_TRIGGER,
};
const action = {
id: HELLO_WORLD_ACTION_ID,
id: ACTION_HELLO_WORLD,
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);
service.attachAction(trigger.id, action);
service.detachAction(trigger.id, action.id);
const actions2 = service.getTriggerActions(trigger.id);
expect(actions2).toEqual([]);
@ -424,15 +424,15 @@ describe('UiActionsService', () => {
const service = new UiActionsService();
const action = {
id: HELLO_WORLD_ACTION_ID,
id: ACTION_HELLO_WORLD,
order: 25,
} as any;
service.registerAction(action);
expect(() =>
service.detachAction('i do not exist' as TriggerId, HELLO_WORLD_ACTION_ID)
service.detachAction('i do not exist' as TriggerId, ACTION_HELLO_WORLD)
).toThrowError(
'No trigger [triggerId = i do not exist] exists, for detaching action [actionId = HELLO_WORLD_ACTION_ID].'
'No trigger [triggerId = i do not exist] exists, for detaching action [actionId = ACTION_HELLO_WORLD].'
);
});
@ -440,15 +440,13 @@ describe('UiActionsService', () => {
const service = new UiActionsService();
const action = {
id: HELLO_WORLD_ACTION_ID,
id: ACTION_HELLO_WORLD,
order: 25,
} as any;
service.registerAction(action);
expect(() =>
service.attachAction('i do not exist' as TriggerId, HELLO_WORLD_ACTION_ID)
).toThrowError(
'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = HELLO_WORLD_ACTION_ID].'
expect(() => service.attachAction('i do not exist' as TriggerId, action)).toThrowError(
'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = ACTION_HELLO_WORLD].'
);
});
@ -456,13 +454,13 @@ describe('UiActionsService', () => {
const service = new UiActionsService();
const action = {
id: HELLO_WORLD_ACTION_ID,
id: ACTION_HELLO_WORLD,
order: 25,
} as any;
service.registerAction(action);
expect(() => service.registerAction(action)).toThrowError(
'Action [action.id = HELLO_WORLD_ACTION_ID] already registered.'
'Action [action.id = ACTION_HELLO_WORLD] already registered.'
);
});

View file

@ -23,8 +23,9 @@ import {
TriggerToActionsRegistry,
TriggerId,
TriggerContextMapping,
ActionType,
} from '../types';
import { Action } from '../actions';
import { Action, ActionByType } from '../actions';
import { Trigger, TriggerContext } from '../triggers/trigger';
import { TriggerInternal } from '../triggers/trigger_internal';
import { TriggerContract } from '../triggers/trigger_contract';
@ -75,7 +76,7 @@ export class UiActionsService {
return trigger.contract;
};
public readonly registerAction = <Context>(action: Action<Context>) => {
public readonly registerAction = <T extends ActionType>(action: ActionByType<T>) => {
if (this.actions.has(action.id)) {
throw new Error(`Action [action.id = ${action.id}] already registered.`);
}
@ -83,22 +84,41 @@ export class UiActionsService {
this.actions.set(action.id, action);
};
// TODO: make this
// <T extends TriggerId>(triggerId: T, action: Action<TriggerContextMapping[T]>): \
// to get type checks here!
public readonly attachAction = <T extends TriggerId>(triggerId: T, actionId: string): void => {
public readonly getAction = <T extends ActionType>(id: string): ActionByType<T> => {
if (!this.actions.has(id)) {
throw new Error(`Action [action.id = ${id}] not registered.`);
}
return this.actions.get(id) as ActionByType<T>;
};
public readonly attachAction = <TType extends TriggerId, AType extends ActionType>(
triggerId: TType,
// 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: ActionByType<AType> & Action<TriggerContextMapping[TType]>
): void => {
if (!this.actions.has(action.id)) {
this.registerAction(action);
} else {
const registeredAction = this.actions.get(action.id);
if (registeredAction !== action) {
throw new Error(`A different action instance with this id is already registered.`);
}
}
const trigger = this.triggers.get(triggerId);
if (!trigger) {
throw new Error(
`No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].`
`No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${action.id}].`
);
}
const actionIds = this.triggerToActions.get(triggerId);
if (!actionIds!.find(id => id === actionId)) {
this.triggerToActions.set(triggerId, [...actionIds!, actionId]);
if (!actionIds!.find(id => id === action.id)) {
this.triggerToActions.set(triggerId, [...actionIds!, action.id]);
}
};

View file

@ -21,7 +21,7 @@ import { Action, createAction } from '../actions';
import { openContextMenu } from '../context_menu';
import { uiActionsPluginMock } from '../mocks';
import { Trigger } from '../triggers';
import { TriggerId } from '../types';
import { TriggerId, ActionType } from '../types';
jest.mock('../context_menu');
@ -30,11 +30,18 @@ const openContextMenuSpy = (openContextMenu as any) as jest.SpyInstance;
const CONTACT_USER_TRIGGER = 'CONTACT_USER_TRIGGER';
function createTestAction<A>(id: string, checkCompatibility: (context: A) => boolean): Action<A> {
return createAction<A>({
type: 'testAction',
id,
isCompatible: context => Promise.resolve(checkCompatibility(context)),
// Casting to ActionType is a hack - in a real situation use
// declare module and add this id to ActionContextMapping.
const TEST_ACTION_TYPE = 'TEST_ACTION_TYPE' as ActionType;
function createTestAction<C extends object>(
type: string,
checkCompatibility: (context: C) => boolean
): 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),
});
}
@ -46,7 +53,7 @@ const reset = () => {
uiActions.setup.registerTrigger({
id: CONTACT_USER_TRIGGER,
});
uiActions.setup.attachAction(CONTACT_USER_TRIGGER, 'SEND_MESSAGE_ACTION');
// uiActions.setup.attachAction(CONTACT_USER_TRIGGER, 'ACTION_SEND_MESSAGE');
executeFn.mockReset();
openContextMenuSpy.mockReset();
@ -62,8 +69,7 @@ test('executes a single action mapped to a trigger', async () => {
const action = createTestAction('test1', () => true);
setup.registerTrigger(trigger);
setup.registerAction(action);
setup.attachAction(trigger.id, 'test1');
setup.attachAction(trigger.id, action);
const context = {};
const start = doStart();
@ -81,7 +87,6 @@ test('throws an error if there are no compatible actions to execute', async () =
};
setup.registerTrigger(trigger);
setup.attachAction(trigger.id, 'testaction');
const context = {};
const start = doStart();
@ -98,11 +103,13 @@ test('does not execute an incompatible action', async () => {
id: 'MY-TRIGGER' as TriggerId,
title: 'My trigger',
};
const action = createTestAction<{ name: string }>('test1', ({ name }) => name === 'executeme');
const action = createTestAction<{ name: string }>(
'test1',
({ name }: { name: string }) => name === 'executeme'
);
setup.registerTrigger(trigger);
setup.registerAction(action);
setup.attachAction(trigger.id, 'test1');
setup.attachAction(trigger.id, action);
const start = doStart();
const context = {
@ -123,10 +130,8 @@ test('shows a context menu when more than one action is mapped to a trigger', as
const action2 = createTestAction('test2', () => true);
setup.registerTrigger(trigger);
setup.registerAction(action1);
setup.registerAction(action2);
setup.attachAction(trigger.id, 'test1');
setup.attachAction(trigger.id, 'test2');
setup.attachAction(trigger.id, action1);
setup.attachAction(trigger.id, action2);
expect(openContextMenu).toHaveBeenCalledTimes(0);
@ -150,8 +155,7 @@ test('passes whole action context to isCompatible()', async () => {
});
setup.registerTrigger(trigger);
setup.registerAction(action);
setup.attachAction(trigger.id, 'test');
setup.attachAction(trigger.id, action);
const start = doStart();

View file

@ -19,17 +19,17 @@
import { Action } from '../actions';
import { uiActionsPluginMock } from '../mocks';
import { TriggerId } from '../types';
import { TriggerId, ActionType } from '../types';
const action1: Action = {
id: 'action1',
order: 1,
type: 'type1',
type: 'type1' as ActionType,
} as any;
const action2: Action = {
id: 'action2',
order: 2,
type: 'type2',
type: 'type2' as ActionType,
} as any;
test('returns actions set on trigger', () => {
@ -47,13 +47,13 @@ test('returns actions set on trigger', () => {
expect(list0).toHaveLength(0);
setup.attachAction('trigger' as TriggerId, 'action1');
setup.attachAction('trigger' as TriggerId, action1);
const list1 = start.getTriggerActions('trigger' as TriggerId);
expect(list1).toHaveLength(1);
expect(list1).toEqual([action1]);
setup.attachAction('trigger' as TriggerId, 'action2');
setup.attachAction('trigger' as TriggerId, action2);
const list2 = start.getTriggerActions('trigger' as TriggerId);
expect(list2).toHaveLength(2);

View file

@ -17,25 +17,27 @@
* under the License.
*/
import { createSayHelloAction } from '../tests/test_samples/say_hello_action';
import { uiActionsPluginMock } from '../mocks';
import { createRestrictedAction, createHelloWorldAction } from '../tests/test_samples';
import { Action } from '../actions';
import { createHelloWorldAction } from '../tests/test_samples';
import { Action, createAction } from '../actions';
import { Trigger } from '../triggers';
import { TriggerId } from '../types';
import { TriggerId, ActionType } from '../types';
let action: Action<{ name: string }>;
let action: Action<{ name: string }, ActionType>;
let uiActions: ReturnType<typeof uiActionsPluginMock.createPlugin>;
beforeEach(() => {
uiActions = uiActionsPluginMock.createPlugin();
action = createSayHelloAction({} as any);
action = createAction({
type: 'test' as ActionType,
execute: () => Promise.resolve(),
});
uiActions.setup.registerAction(action);
uiActions.setup.registerTrigger({
id: 'trigger' as TriggerId,
title: 'trigger',
});
uiActions.setup.attachAction('trigger' as TriggerId, action.id);
uiActions.setup.attachAction('trigger' as TriggerId, action);
});
test('can register action', async () => {
@ -56,7 +58,7 @@ test('getTriggerCompatibleActions returns attached actions', async () => {
title: 'My trigger',
};
setup.registerTrigger(testTrigger);
setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction.id);
setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction);
const start = doStart();
const actions = await start.getTriggerCompatibleActions('MY-TRIGGER' as TriggerId, {});
@ -67,19 +69,22 @@ test('getTriggerCompatibleActions returns attached actions', async () => {
test('filters out actions not applicable based on the context', async () => {
const { setup, doStart } = uiActions;
const restrictedAction = createRestrictedAction<{ accept: boolean }>(context => {
return context.accept;
const action1 = createAction({
type: 'test1' as ActionType,
isCompatible: async (context: { accept: boolean }) => {
return Promise.resolve(context.accept);
},
execute: () => Promise.resolve(),
});
setup.registerAction(restrictedAction);
const testTrigger: Trigger = {
id: 'MY-TRIGGER' as TriggerId,
id: 'MY-TRIGGER2' as TriggerId,
title: 'My trigger',
};
setup.registerTrigger(testTrigger);
setup.attachAction(testTrigger.id, restrictedAction.id);
setup.registerAction(action1);
setup.attachAction(testTrigger.id, action1);
const start = doStart();
let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true });

View file

@ -20,8 +20,9 @@
import React from 'react';
import { EuiFlyout, EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui';
import { CoreStart } from 'src/core/public';
import { createAction, Action } from '../../actions';
import { createAction, ActionByType } from '../../actions';
import { toMountPoint, reactToUiComponent } from '../../../../kibana_react/public';
import { ActionType } from '../../types';
const ReactMenuItem: React.FC = () => {
return (
@ -36,11 +37,15 @@ const ReactMenuItem: React.FC = () => {
const UiMenuItem = reactToUiComponent(ReactMenuItem);
export const HELLO_WORLD_ACTION_ID = 'HELLO_WORLD_ACTION_ID';
// Casting to ActionType is a hack - in a real situation use
// declare module and add this id to ActionContextMapping.
export const ACTION_HELLO_WORLD = 'ACTION_HELLO_WORLD' as ActionType;
export function createHelloWorldAction(overlays: CoreStart['overlays']): Action {
return createAction({
type: HELLO_WORLD_ACTION_ID,
export function createHelloWorldAction(
overlays: CoreStart['overlays']
): ActionByType<typeof ACTION_HELLO_WORLD> {
return createAction<typeof ACTION_HELLO_WORLD>({
type: ACTION_HELLO_WORLD,
getIconType: () => 'lock',
MenuItem: UiMenuItem,
execute: async () => {

View file

@ -16,6 +16,4 @@
* specific language governing permissions and limitations
* under the License.
*/
export { createRestrictedAction } from './restricted_action';
export { createSayHelloAction } from './say_hello_action';
export { createHelloWorldAction } from './hello_world_action';

View file

@ -1,46 +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 React from 'react';
import { EuiFlyout } from '@elastic/eui';
import { CoreStart } from 'src/core/public';
import { Action, createAction } from '../../actions';
import { toMountPoint } from '../../../../kibana_react/public';
export const SAY_HELLO_ACTION = 'SAY_HELLO_ACTION';
export function createSayHelloAction(overlays: CoreStart['overlays']): Action<{ name: string }> {
return createAction<{ name: string }>({
type: SAY_HELLO_ACTION,
getDisplayName: ({ name }) => `Hello, ${name}`,
isCompatible: async ({ name }) => name !== undefined,
execute: async context => {
const flyoutSession = overlays.openFlyout(
toMountPoint(
<EuiFlyout ownFocus onClose={() => flyoutSession && flyoutSession.close()}>
this.getDisplayName(context)
</EuiFlyout>
),
{
'data-test-subj': 'sayHelloAction',
}
);
},
});
}

View file

@ -17,20 +17,27 @@
* under the License.
*/
import { Action } from './actions/action';
import { ActionByType } from './actions/action';
import { TriggerInternal } from './triggers/trigger_internal';
export type TriggerRegistry = Map<TriggerId, TriggerInternal<any>>;
export type ActionRegistry = Map<string, Action<any>>;
export type ActionRegistry = Map<string, ActionByType<any>>;
export type TriggerToActionsRegistry = Map<TriggerId, string[]>;
const DEFAULT_TRIGGER = '';
export type TriggerId = keyof TriggerContextMapping;
export type BaseContext = object;
export type TriggerContext = BaseContext;
export type BaseContext = object | undefined | string | number;
export interface TriggerContextMapping {
[DEFAULT_TRIGGER]: TriggerContext;
}
const DEFAULT_ACTION = '';
export type ActionType = keyof ActionContextMapping;
export interface ActionContextMapping {
[DEFAULT_ACTION]: BaseContext;
}

View file

@ -31,7 +31,7 @@ export default function({ getService }: PluginFunctionalProviderContext) {
it('Can create a new child', async () => {
await testSubjects.click('embeddablePanelToggleMenuIcon');
await testSubjects.click('embeddablePanelAction-ADD_PANEL_ACTION_ID');
await testSubjects.click('embeddablePanelAction-ACTION_ADD_PANEL');
await testSubjects.click('createNew');
await testSubjects.click('createNew-TODO_EMBEDDABLE');
await testSubjects.setValue('taskInputField', 'new task');

View file

@ -41,7 +41,7 @@ export default function({ getService }: PluginFunctionalProviderContext) {
await testSubjects.click('addDynamicAction');
await retry.try(async () => {
await testSubjects.click('emitHelloWorldTrigger');
await testSubjects.click('embeddablePanelAction-HELLO_WORLD_ACTION_TYPE-Waldo');
await testSubjects.click('embeddablePanelAction-ACTION_HELLO_WORLD-Waldo');
});
await retry.try(async () => {
const text = await testSubjects.getVisibleText('dynamicHelloWorldActionText');

View file

@ -21,7 +21,7 @@ const REMOVE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-deletePanel';
const EDIT_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-editPanel';
const REPLACE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-replacePanel';
const TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-togglePanel';
const CUSTOMIZE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-CUSTOMIZE_PANEL_ACTION_ID';
const CUSTOMIZE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-ACTION_CUSTOMIZE_PANEL';
const OPEN_CONTEXT_MENU_ICON_DATA_TEST_SUBJ = 'embeddablePanelToggleMenuIcon';
const OPEN_INSPECTOR_TEST_SUBJ = 'embeddablePanelAction-openInspector';

View file

@ -85,7 +85,7 @@ export class EmbeddableExplorerPublicPlugin
plugins.uiActions.registerAction(sayHelloAction);
plugins.uiActions.registerAction(sendMessageAction);
plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction.id);
plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction);
plugins.embeddable.registerEmbeddableFactory(
helloWorldEmbeddableFactory.type,

View file

@ -21,18 +21,22 @@ import React from 'react';
import { npStart, npSetup } from 'ui/new_platform';
import { CONTEXT_MENU_TRIGGER, IEmbeddable } from '../../../../../src/plugins/embeddable/public';
import { createAction } from '../../../../../src/plugins/ui_actions/public';
import { createAction, ActionType } from '../../../../../src/plugins/ui_actions/public';
import { toMountPoint } from '../../../../../src/plugins/kibana_react/public';
interface ActionContext {
// Casting to ActionType is a hack - in a real situation use
// declare module and add this id to ActionContextMapping.
export const SAMPLE_PANEL_ACTION = 'SAMPLE_PANEL_ACTION' as ActionType;
export interface SamplePanelActionContext {
embeddable: IEmbeddable;
}
function createSamplePanelAction() {
return createAction<ActionContext>({
type: 'samplePanelAction',
return createAction<typeof SAMPLE_PANEL_ACTION>({
type: SAMPLE_PANEL_ACTION,
getDisplayName: () => 'Sample Panel Action',
execute: async ({ embeddable }) => {
execute: async ({ embeddable }: SamplePanelActionContext) => {
if (!embeddable) {
return;
}
@ -59,4 +63,4 @@ function createSamplePanelAction() {
const action = createSamplePanelAction();
npSetup.plugins.uiActions.registerAction(action);
npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action.id);
npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action);

View file

@ -17,12 +17,16 @@
* under the License.
*/
import { npStart } from 'ui/new_platform';
import { Action, createAction } from '../../../../../src/plugins/ui_actions/public';
import { Action, createAction, ActionType } from '../../../../../src/plugins/ui_actions/public';
import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public';
// Casting to ActionType is a hack - in a real situation use
// declare module and add this id to ActionContextMapping.
export const SAMPLE_PANEL_LINK = 'samplePanelLink' as ActionType;
export const createSamplePanelLink = (): Action =>
createAction({
type: 'samplePanelLink',
createAction<typeof SAMPLE_PANEL_LINK>({
type: SAMPLE_PANEL_LINK,
getDisplayName: () => 'Sample panel Link',
execute: async () => {},
getHref: () => 'https://example.com/kibana/test',
@ -30,4 +34,4 @@ export const createSamplePanelLink = (): Action =>
const action = createSamplePanelLink();
npStart.plugins.uiActions.registerAction(action);
npStart.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action.id);
npStart.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action);

View file

@ -9,7 +9,10 @@ import { i18n } from '@kbn/i18n';
import moment from 'moment-timezone';
import { npSetup, npStart } from 'ui/new_platform';
import { Action, IncompatibleActionError } from '../../../../../../src/plugins/ui_actions/public';
import {
ActionByType,
IncompatibleActionError,
} from '../../../../../../src/plugins/ui_actions/public';
import {
ViewMode,
@ -29,11 +32,17 @@ function isSavedSearchEmbeddable(
return embeddable.type === SEARCH_EMBEDDABLE_TYPE;
}
interface ActionContext {
export interface CSVActionContext {
embeddable: ISearchEmbeddable;
}
class GetCsvReportPanelAction implements Action<ActionContext> {
declare module '../../../../../../src/plugins/ui_actions/public' {
export interface ActionContextMapping {
[CSV_REPORTING_ACTION]: CSVActionContext;
}
}
class GetCsvReportPanelAction implements ActionByType<typeof CSV_REPORTING_ACTION> {
private isDownloading: boolean;
public readonly type = CSV_REPORTING_ACTION;
public readonly id = CSV_REPORTING_ACTION;
@ -65,13 +74,13 @@ class GetCsvReportPanelAction implements Action<ActionContext> {
return searchEmbeddable.getSavedSearch().searchSource.getSearchRequestBody();
}
public isCompatible = async (context: ActionContext) => {
public isCompatible = async (context: CSVActionContext) => {
const { embeddable } = context;
return embeddable.getInput().viewMode !== ViewMode.EDIT && embeddable.type === 'search';
};
public execute = async (context: ActionContext) => {
public execute = async (context: CSVActionContext) => {
const { embeddable } = context;
if (!isSavedSearchEmbeddable(embeddable)) {
@ -167,4 +176,4 @@ class GetCsvReportPanelAction implements Action<ActionContext> {
const action = new GetCsvReportPanelAction();
npSetup.plugins.uiActions.registerAction(action);
npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action.id);
npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action);

View file

@ -7,12 +7,12 @@
import { i18n } from '@kbn/i18n';
import React from 'react';
import { IEmbeddable, Embeddable, EmbeddableInput } from 'src/plugins/embeddable/public';
import { Action, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public';
import { ActionByType, 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';
const CUSTOM_TIME_RANGE = 'CUSTOM_TIME_RANGE';
export const CUSTOM_TIME_RANGE = 'CUSTOM_TIME_RANGE';
const SEARCH_EMBEDDABLE_TYPE = 'search';
export interface TimeRangeInput extends EmbeddableInput {
@ -34,11 +34,11 @@ function isVisualizeEmbeddable(
return embeddable.type === VISUALIZE_EMBEDDABLE_TYPE;
}
interface ActionContext {
export interface TimeRangeActionContext {
embeddable: Embeddable<TimeRangeInput>;
}
export class CustomTimeRangeAction implements Action<ActionContext> {
export class CustomTimeRangeAction implements ActionByType<typeof CUSTOM_TIME_RANGE> {
public readonly type = CUSTOM_TIME_RANGE;
private openModal: OpenModal;
private dateFormat?: string;
@ -70,7 +70,7 @@ export class CustomTimeRangeAction implements Action<ActionContext> {
return 'calendar';
}
public async isCompatible({ embeddable }: ActionContext) {
public async isCompatible({ embeddable }: TimeRangeActionContext) {
const isInputControl =
isVisualizeEmbeddable(embeddable) &&
(embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'input_control_vis';
@ -89,7 +89,7 @@ export class CustomTimeRangeAction implements Action<ActionContext> {
);
}
public async execute({ embeddable }: ActionContext) {
public async execute({ embeddable }: TimeRangeActionContext) {
const isCompatible = await this.isCompatible({ embeddable });
if (!isCompatible) {
throw new IncompatibleActionError();

View file

@ -7,13 +7,13 @@
import React from 'react';
import { prettyDuration, commonDurationRanges } from '@elastic/eui';
import { IEmbeddable, Embeddable, EmbeddableInput } from 'src/plugins/embeddable/public';
import { Action, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public';
import { ActionByType, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public';
import { TimeRange } from '../../../../src/plugins/data/public';
import { CustomizeTimeRangeModal } from './customize_time_range_modal';
import { doesInheritTimeRange } from './does_inherit_time_range';
import { OpenModal, CommonlyUsedRange } from './types';
const CUSTOM_TIME_RANGE_BADGE = 'CUSTOM_TIME_RANGE_BADGE';
export const CUSTOM_TIME_RANGE_BADGE = 'CUSTOM_TIME_RANGE_BADGE';
export interface TimeRangeInput extends EmbeddableInput {
timeRange: TimeRange;
@ -25,11 +25,11 @@ function hasTimeRange(
return (embeddable as Embeddable<TimeRangeInput>).getInput().timeRange !== undefined;
}
interface ActionContext {
export interface TimeBadgeActionContext {
embeddable: Embeddable<TimeRangeInput>;
}
export class CustomTimeRangeBadge implements Action<ActionContext> {
export class CustomTimeRangeBadge implements ActionByType<typeof CUSTOM_TIME_RANGE_BADGE> {
public readonly type = CUSTOM_TIME_RANGE_BADGE;
public readonly id = CUSTOM_TIME_RANGE_BADGE;
public order = 7;
@ -51,7 +51,7 @@ export class CustomTimeRangeBadge implements Action<ActionContext> {
this.commonlyUsedRanges = commonlyUsedRanges;
}
public getDisplayName({ embeddable }: ActionContext) {
public getDisplayName({ embeddable }: TimeBadgeActionContext) {
return prettyDuration(
embeddable.getInput().timeRange.from,
embeddable.getInput().timeRange.to,
@ -64,11 +64,11 @@ export class CustomTimeRangeBadge implements Action<ActionContext> {
return 'calendar';
}
public async isCompatible({ embeddable }: ActionContext) {
public async isCompatible({ embeddable }: TimeBadgeActionContext) {
return Boolean(embeddable && hasTimeRange(embeddable) && !doesInheritTimeRange(embeddable));
}
public async execute({ embeddable }: ActionContext) {
public async execute({ embeddable }: TimeBadgeActionContext) {
const isCompatible = await this.isCompatible({ embeddable });
if (!isCompatible) {
throw new IncompatibleActionError();

View file

@ -18,9 +18,17 @@ import {
IEmbeddableSetup,
IEmbeddableStart,
} from '../../../../src/plugins/embeddable/public';
import { CustomTimeRangeAction } from './custom_time_range_action';
import {
CustomTimeRangeAction,
CUSTOM_TIME_RANGE,
TimeRangeActionContext,
} from './custom_time_range_action';
import { CustomTimeRangeBadge } from './custom_time_range_badge';
import {
CustomTimeRangeBadge,
CUSTOM_TIME_RANGE_BADGE,
TimeBadgeActionContext,
} from './custom_time_range_badge';
import { CommonlyUsedRange } from './types';
interface SetupDependencies {
@ -36,6 +44,13 @@ interface StartDependencies {
export type Setup = void;
export type Start = void;
declare module '../../../../src/plugins/ui_actions/public' {
export interface ActionContextMapping {
[CUSTOM_TIME_RANGE]: TimeRangeActionContext;
[CUSTOM_TIME_RANGE_BADGE]: TimeBadgeActionContext;
}
}
export class AdvancedUiActionsPublicPlugin
implements Plugin<Setup, Start, SetupDependencies, StartDependencies> {
constructor(initializerContext: PluginInitializerContext) {}
@ -52,7 +67,7 @@ export class AdvancedUiActionsPublicPlugin
commonlyUsedRanges,
});
uiActions.registerAction(timeRangeAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction.id);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction);
const timeRangeBadge = new CustomTimeRangeBadge({
openModal,
@ -60,7 +75,7 @@ export class AdvancedUiActionsPublicPlugin
commonlyUsedRanges,
});
uiActions.registerAction(timeRangeBadge);
uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge.id);
uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge);
}
public stop() {}

View file

@ -7,7 +7,7 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { CoreStart } from 'src/core/public';
import { Action } from '../../../../../../src/plugins/ui_actions/public';
import { ActionByType } from '../../../../../../src/plugins/ui_actions/public';
import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public';
import { FlyoutCreateDrilldown } from '../../components/flyout_create_drilldown';
@ -22,7 +22,7 @@ export interface OpenFlyoutAddDrilldownParams {
overlays: () => Promise<CoreStart['overlays']>;
}
export class FlyoutCreateDrilldownAction implements Action<FlyoutCreateDrilldownActionContext> {
export class FlyoutCreateDrilldownAction implements ActionByType<typeof OPEN_FLYOUT_ADD_DRILLDOWN> {
public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN;
public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN;
public order = 5;

View file

@ -7,6 +7,7 @@
import { CoreStart, CoreSetup, Plugin } from 'src/core/public';
import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public';
import { DrilldownService } from './service';
import { FlyoutCreateDrilldownActionContext, OPEN_FLYOUT_ADD_DRILLDOWN } from './actions';
export interface DrilldownsSetupDependencies {
uiActions: UiActionsSetup;
@ -21,6 +22,12 @@ export type DrilldownsSetupContract = Pick<DrilldownService, 'registerDrilldown'
// eslint-disable-next-line
export interface DrilldownsStartContract {}
declare module '../../../../src/plugins/ui_actions/public' {
export interface ActionContextMapping {
[OPEN_FLYOUT_ADD_DRILLDOWN]: FlyoutCreateDrilldownActionContext;
}
}
export class DrilldownsPlugin
implements
Plugin<

View file

@ -5,6 +5,7 @@
*/
import { CoreSetup } from 'src/core/public';
import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public';
import { FlyoutCreateDrilldownAction } from '../actions';
import { DrilldownsSetupDependencies } from '../plugin';
@ -15,7 +16,7 @@ export class DrilldownService {
});
uiActions.registerAction(actionFlyoutCreateDrilldown);
uiActions.attachAction('CONTEXT_MENU_TRIGGER', actionFlyoutCreateDrilldown.id);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown);
}
/**