Decouple actions from embeddables: step 1 (#44503)

* Decouple actions from embeddables: step 1

* prefer as any instead of is-ignore

* Remove unneccessary test, no more triggerContext to be null.

* Fix bug and fix the test that should have caught it.  Be more strict about checking isCompatible.
This commit is contained in:
Stacey Gammon 2019-09-05 10:17:42 -04:00 committed by GitHub
parent 1a15d5ba2c
commit 81d06d5378
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 231 additions and 262 deletions

View file

@ -21,7 +21,6 @@ import { i18n } from '@kbn/i18n';
import {
Action,
IEmbeddable,
ActionContext,
IncompatibleActionError,
} from '../../../../../../embeddable_api/public/np_ready/public';
import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable';
@ -40,7 +39,11 @@ function isExpanded(embeddable: IEmbeddable) {
return embeddable.id === embeddable.parent.getInput().expandedPanelId;
}
export class ExpandPanelAction extends Action {
interface ActionContext {
embeddable: IEmbeddable;
}
export class ExpandPanelAction extends Action<ActionContext> {
public readonly type = EXPAND_PANEL_ACTION;
constructor() {
@ -80,7 +83,7 @@ export class ExpandPanelAction extends Action {
return Boolean(embeddable.parent && isDashboard(embeddable.parent));
}
public execute({ embeddable }: ActionContext) {
public async execute({ embeddable }: ActionContext) {
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
throw new IncompatibleActionError();
}

View file

@ -18,9 +18,9 @@
*/
import { EmbeddableApiPure } from './types';
import { Action, ActionContext, buildContextMenuForActions, openContextMenu } from '../lib';
import { Action, buildContextMenuForActions, openContextMenu } from '../lib';
const executeSingleAction = async (action: Action, actionContext: ActionContext) => {
const executeSingleAction = async <A extends {} = {}>(action: Action<A>, actionContext: A) => {
const href = action.getHref(actionContext);
// TODO: Do we need a `getHref()` special case?

View file

@ -24,7 +24,6 @@ import {
EmbeddableFactory,
ExecuteTriggerActions,
GetEmbeddableFactories,
TriggerContext,
} from '../lib';
export interface EmbeddableApi {
@ -35,7 +34,7 @@ export interface EmbeddableApi {
getEmbeddableFactories: GetEmbeddableFactories;
getTrigger: (id: string) => Trigger;
getTriggerActions: (id: string) => Action[];
getTriggerCompatibleActions: (triggerId: string, context: TriggerContext) => Promise<Action[]>;
getTriggerCompatibleActions: <C>(triggerId: string, context: C) => Promise<Array<Action<C>>>;
registerAction: (action: Action) => void;
// TODO: Make `registerEmbeddableFactory` receive only `factory` argument.
registerEmbeddableFactory: (id: string, factory: EmbeddableFactory) => void;

View file

@ -26,7 +26,6 @@ export {
APPLY_FILTER_TRIGGER,
PANEL_BADGE_TRIGGER,
Action,
ActionContext,
Adapters,
AddPanelAction,
ApplyFilterAction,
@ -59,7 +58,6 @@ export {
PropertySpec,
SavedObjectMetaData,
Trigger,
TriggerContext,
ViewMode,
isErrorEmbeddable,
openAddPanelFlyout,

View file

@ -17,20 +17,7 @@
* under the License.
*/
import { IEmbeddable } from '../embeddables';
export interface ActionContext<
TEmbeddable extends IEmbeddable = IEmbeddable,
TTriggerContext extends {} = {}
> {
embeddable: TEmbeddable;
triggerContext?: TTriggerContext;
}
export abstract class Action<
TEmbeddable extends IEmbeddable = IEmbeddable,
TTriggerContext extends {} = {}
> {
export abstract class Action<ActionContext extends {} = {}> {
/**
* Determined the order when there is more than one action matched to a trigger.
* Higher numbers are displayed first.
@ -43,7 +30,7 @@ export abstract class Action<
/**
* Optional EUI icon type that can be displayed along with the title.
*/
public getIconType(context: ActionContext<TEmbeddable, TTriggerContext>): string | undefined {
public getIconType(context: ActionContext): string | undefined {
return undefined;
}
@ -51,27 +38,25 @@ export abstract class Action<
* Returns a title to be displayed to the user.
* @param context
*/
public abstract getDisplayName(context: ActionContext<TEmbeddable, TTriggerContext>): string;
public abstract getDisplayName(context: ActionContext): string;
/**
* Returns a promise that resolves to true if this action is compatible given the context,
* otherwise resolves to false.
*/
public async isCompatible(
context: ActionContext<TEmbeddable, TTriggerContext>
): Promise<boolean> {
public async isCompatible(context: ActionContext): Promise<boolean> {
return true;
}
/**
* If this returns something truthy, this is used in addition to the `execute` method when clicked.
*/
public getHref(context: ActionContext<TEmbeddable, TTriggerContext>): string | undefined {
public getHref(context: ActionContext): string | undefined {
return undefined;
}
/**
* Executes the action.
*/
public abstract execute(context: ActionContext<TEmbeddable, TTriggerContext>): void;
public abstract async execute(context: ActionContext): Promise<void>;
}

View file

@ -48,9 +48,7 @@ describe('isCompatible()', () => {
}),
}),
} as any,
triggerContext: {
filters: [],
},
filters: [],
});
expect(result).toBe(true);
});
@ -65,9 +63,7 @@ describe('isCompatible()', () => {
}),
}),
} as any,
triggerContext: {
filters: [],
},
filters: [],
});
expect(result).toBe(false);
});
@ -83,25 +79,8 @@ describe('isCompatible()', () => {
}),
}),
} as any,
triggerContext: {
// filters: [],
} as any,
});
} as any);
expect(result1).toBe(false);
const result2 = await action.isCompatible({
embeddable: {
getRoot: () => ({
getInput: () => ({
filters: [],
}),
}),
} as any,
// triggerContext: {
// filters: [],
// } as any
});
expect(result2).toBe(false);
});
});
@ -125,42 +104,41 @@ describe('execute()', () => {
const error = expectError(() =>
action.execute({
embeddable: getEmbeddable(),
triggerContext: {},
} as any)
);
expect(error).toBeInstanceOf(Error);
});
test('updates filter input on success', () => {
test('updates filter input on success', async done => {
const action = new ApplyFilterAction();
const [embeddable, root] = getEmbeddable();
action.execute({
await action.execute({
embeddable,
triggerContext: {
filters: ['FILTER' as any],
},
filters: ['FILTER' as any],
});
expect(root.updateInput).toHaveBeenCalledTimes(1);
expect(root.updateInput.mock.calls[0][0]).toMatchObject({
filters: ['FILTER'],
});
done();
});
test('checks if action isCompatible', () => {
test('checks if action isCompatible', async done => {
const action = new ApplyFilterAction();
const spy = jest.spyOn(action, 'isCompatible');
const [embeddable] = getEmbeddable();
action.execute({
await action.execute({
embeddable,
triggerContext: {
filters: ['FILTER' as any],
},
filters: ['FILTER' as any],
});
expect(spy).toHaveBeenCalledTimes(1);
done();
});
});
});

View file

@ -20,14 +20,18 @@
import { i18n } from '@kbn/i18n';
import { Filter } from '@kbn/es-query';
import { IEmbeddable, EmbeddableInput } from '../embeddables';
import { Action, ActionContext } from './action';
import { Action } from './action';
import { IncompatibleActionError } from '../errors';
export const APPLY_FILTER_ACTION = 'APPLY_FILTER_ACTION';
type RootEmbeddable = IEmbeddable<EmbeddableInput & { filters: Filter[] }>;
interface ActionContext {
embeddable: IEmbeddable;
filters: Filter[];
}
export class ApplyFilterAction extends Action<IEmbeddable, { filters: Filter[] }> {
export class ApplyFilterAction extends Action<ActionContext> {
public readonly type = APPLY_FILTER_ACTION;
constructor() {
@ -40,30 +44,27 @@ export class ApplyFilterAction extends Action<IEmbeddable, { filters: Filter[] }
});
}
public async isCompatible(context: ActionContext<IEmbeddable, { filters: Filter[] }>) {
public async isCompatible(context: ActionContext) {
if (context.embeddable === undefined) {
return false;
}
const root = context.embeddable.getRoot() as RootEmbeddable;
return Boolean(
root.getInput().filters !== undefined &&
context.triggerContext &&
context.triggerContext.filters !== undefined
);
return Boolean(root.getInput().filters !== undefined && context.filters !== undefined);
}
public execute({
embeddable,
triggerContext,
}: ActionContext<IEmbeddable, { filters: Filter[] }>) {
if (!triggerContext) {
throw new Error('Applying a filter requires a filter as context');
public async execute({ embeddable, filters }: ActionContext) {
if (!filters || !embeddable) {
throw new Error('Applying a filter requires a filter and embeddable as context');
}
const root = embeddable.getRoot() as RootEmbeddable;
if (!this.isCompatible({ triggerContext, embeddable })) {
if (!(await this.isCompatible({ embeddable, filters }))) {
throw new IncompatibleActionError();
}
const root = embeddable.getRoot() as RootEmbeddable;
root.updateInput({
filters: triggerContext.filters,
filters,
});
}
}

View file

@ -18,13 +18,18 @@
*/
import { i18n } from '@kbn/i18n';
import { Action, ActionContext } from './action';
import { Action } from './action';
import { GetEmbeddableFactory, ViewMode } from '../types';
import { EmbeddableFactoryNotFoundError } from '../errors';
import { IEmbeddable } from '../embeddables';
export const EDIT_PANEL_ACTION_ID = 'editPanel';
export class EditPanelAction extends Action {
interface ActionContext {
embeddable: IEmbeddable;
}
export class EditPanelAction extends Action<ActionContext> {
public readonly type = EDIT_PANEL_ACTION_ID;
constructor(private readonly getEmbeddableFactory: GetEmbeddableFactory) {
super(EDIT_PANEL_ACTION_ID);
@ -56,8 +61,8 @@ export class EditPanelAction extends Action {
return Boolean(canEditEmbeddable && inDashboardEditMode);
}
public execute() {
return undefined;
public async execute() {
return;
}
public getHref({ embeddable }: ActionContext): string {

View file

@ -17,6 +17,6 @@
* under the License.
*/
export { Action, ActionContext } from './action';
export { Action } from './action';
export * from './apply_filter_action';
export * from './edit_panel_action';

View file

@ -20,21 +20,21 @@
import { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor } from '@elastic/eui';
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
import { Action, ActionContext } from '../actions';
import { Action } from '../actions';
/**
* Transforms an array of Actions to the shape EuiContextMenuPanel expects.
*/
export async function buildContextMenuForActions({
export async function buildContextMenuForActions<A>({
actions,
actionContext,
closeMenu,
}: {
actions: Action[];
actionContext: ActionContext;
actions: Array<Action<A>>;
actionContext: A;
closeMenu: () => void;
}): Promise<EuiContextMenuPanelDescriptor> {
const menuItems = await buildEuiContextMenuPanelItems({
const menuItems = await buildEuiContextMenuPanelItems<A>({
actions,
actionContext,
closeMenu,
@ -52,13 +52,13 @@ export async function buildContextMenuForActions({
/**
* Transform an array of Actions into the shape needed to build an EUIContextMenu
*/
async function buildEuiContextMenuPanelItems({
async function buildEuiContextMenuPanelItems<A>({
actions,
actionContext,
closeMenu,
}: {
actions: Action[];
actionContext: ActionContext;
actions: Array<Action<A>>;
actionContext: A;
closeMenu: () => void;
}) {
const items: EuiContextMenuPanelItemDescriptor[] = [];
@ -88,13 +88,13 @@ async function buildEuiContextMenuPanelItems({
* @param {Embeddable} embeddable
* @return {EuiContextMenuPanelItemDescriptor}
*/
function convertPanelActionToContextMenuItem({
function convertPanelActionToContextMenuItem<A>({
action,
actionContext,
closeMenu,
}: {
action: Action;
actionContext: ActionContext;
action: Action<A>;
actionContext: A;
closeMenu: () => void;
}): EuiContextMenuPanelItemDescriptor {
const menuPanelItem: EuiContextMenuPanelItemDescriptor = {

View file

@ -37,7 +37,7 @@ import { AddPanelAction } from './panel_header/panel_actions/add_panel/add_panel
import { CustomizePanelTitleAction } from './panel_header/panel_actions/customize_title/customize_panel_action';
import { PanelHeader } from './panel_header/panel_header';
import { InspectPanelAction } from './panel_header/panel_actions/inspect_panel_action';
import { EditPanelAction, Action, ActionContext } from '../actions';
import { EditPanelAction, Action } from '../actions';
import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal';
import { Start as InspectorStartContract } from '../../../../../../../../plugins/inspector/public';
@ -192,7 +192,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
});
const createGetUserData = (overlays: CoreStart['overlays']) =>
async function getUserData(context: ActionContext) {
async function getUserData(context: { embeddable: IEmbeddable }) {
return new Promise<{ title: string | undefined }>(resolve => {
const session = overlays.openModal(
<CustomizePanelModal

View file

@ -89,9 +89,8 @@ test('Is not compatible when container is in view mode', async () => {
test('Is not compatible when embeddable is not a container', async () => {
expect(
await action.isCompatible({
embeddable,
})
// @ts-ignore
await action.isCompatible({ embeddable })
).toBe(false);
});

View file

@ -18,14 +18,19 @@
*/
import { i18n } from '@kbn/i18n';
import { ViewMode, GetEmbeddableFactory, GetEmbeddableFactories } from '../../../../types';
import { Action, ActionContext } from '../../../../actions';
import { Action } from '../../../../actions';
import { openAddPanelFlyout } from './open_add_panel_flyout';
import { NotificationsStart } from '../../../../../../../../../../../core/public';
import { KibanaReactOverlays } from '../../../../../../../../../../../plugins/kibana_react/public';
import { IContainer } from '../../../../containers';
export const ADD_PANEL_ACTION_ID = 'ADD_PANEL_ACTION_ID';
export class AddPanelAction extends Action {
interface ActionContext {
embeddable: IContainer;
}
export class AddPanelAction extends Action<ActionContext> {
public readonly type = ADD_PANEL_ACTION_ID;
constructor(

View file

@ -18,14 +18,19 @@
*/
import { i18n } from '@kbn/i18n';
import { Action, ActionContext } from '../../../../actions';
import { Action } from '../../../../actions';
import { ViewMode } from '../../../../types';
import { IEmbeddable } from '../../../../embeddables';
const CUSTOMIZE_PANEL_ACTION_ID = 'CUSTOMIZE_PANEL_ACTION_ID';
type GetUserData = (context: ActionContext) => Promise<{ title: string | undefined }>;
export class CustomizePanelTitleAction extends Action {
interface ActionContext {
embeddable: IEmbeddable;
}
export class CustomizePanelTitleAction extends Action<ActionContext> {
public readonly type = CUSTOMIZE_PANEL_ACTION_ID;
constructor(private readonly getDataFromUser: GetUserData) {

View file

@ -18,12 +18,17 @@
*/
import { i18n } from '@kbn/i18n';
import { Action, ActionContext } from '../../../actions';
import { Action } from '../../../actions';
import { Start as InspectorStartContract } from '../../../../../../../../../../plugins/inspector/public';
import { IEmbeddable } from '../../../embeddables';
export const INSPECT_PANEL_ACTION_ID = 'openInspector';
export class InspectPanelAction extends Action {
interface ActionContext {
embeddable: IEmbeddable;
}
export class InspectPanelAction extends Action<ActionContext> {
public readonly type = INSPECT_PANEL_ACTION_ID;
constructor(private readonly inspector: InspectorStartContract) {

View file

@ -28,7 +28,7 @@ import {
} from '../../../test_samples/embeddables/filterable_embeddable';
import { FilterableEmbeddableFactory } from '../../../test_samples/embeddables/filterable_embeddable_factory';
import { FilterableContainer } from '../../../test_samples/embeddables/filterable_container';
import { GetEmbeddableFactory } from '../../../types';
import { GetEmbeddableFactory, ViewMode } from '../../../types';
import { ContactCardEmbeddable } from '../../../test_samples/embeddables/contact_card/contact_card_embeddable';
const embeddableFactories = new Map<string, EmbeddableFactory>();
@ -45,7 +45,7 @@ beforeEach(async () => {
query: { match: {} },
};
container = new FilterableContainer(
{ id: 'hello', panels: {}, filters: [derivedFilter] },
{ id: 'hello', panels: {}, filters: [derivedFilter], viewMode: ViewMode.EDIT },
getFactory
);
@ -55,6 +55,7 @@ beforeEach(async () => {
FilterableEmbeddable
>(FILTERABLE_EMBEDDABLE, {
id: '123',
viewMode: ViewMode.EDIT,
});
if (isErrorEmbeddable(filterableEmbeddable)) {
@ -68,7 +69,7 @@ test('Removes the embeddable', async () => {
const removePanelAction = new RemovePanelAction();
expect(container.getChild(embeddable.id)).toBeDefined();
removePanelAction.execute({ embeddable });
await removePanelAction.execute({ embeddable });
expect(container.getChild(embeddable.id)).toBeUndefined();
});

View file

@ -19,8 +19,9 @@
import { i18n } from '@kbn/i18n';
import { ContainerInput, IContainer } from '../../../containers';
import { ViewMode } from '../../../types';
import { Action, ActionContext } from '../../../actions';
import { Action } from '../../../actions';
import { IncompatibleActionError } from '../../../errors';
import { IEmbeddable } from '../../../embeddables';
export const REMOVE_PANEL_ACTION = 'deletePanel';
@ -28,13 +29,17 @@ interface ExpandedPanelInput extends ContainerInput {
expandedPanelId: string;
}
interface ActionContext {
embeddable: IEmbeddable;
}
function hasExpandedPanelInput(
container: IContainer
): container is IContainer<{}, ExpandedPanelInput> {
return (container as IContainer<{}, ExpandedPanelInput>).getInput().expandedPanelId !== undefined;
}
export class RemovePanelAction extends Action {
export class RemovePanelAction extends Action<ActionContext> {
public readonly type = REMOVE_PANEL_ACTION;
constructor() {
super(REMOVE_PANEL_ACTION);
@ -63,8 +68,8 @@ export class RemovePanelAction extends Action {
);
}
public execute({ embeddable }: ActionContext) {
if (!embeddable.parent || !this.isCompatible({ embeddable })) {
public async execute({ embeddable }: ActionContext) {
if (!embeddable.parent || !(await this.isCompatible({ embeddable }))) {
throw new IncompatibleActionError();
}
embeddable.parent.removeEmbeddable(embeddable.id);

View file

@ -18,11 +18,15 @@
*/
import { ViewMode } from '../../types';
import { Action, ActionContext } from '../../actions';
import { Action } from '../../actions';
import { IEmbeddable } from '../../embeddables';
export const EDIT_MODE_ACTION = 'EDIT_MODE_ACTION';
export class EditModeAction extends Action {
interface ActionContext {
embeddable: IEmbeddable;
}
export class EditModeAction extends Action<ActionContext> {
public readonly type = EDIT_MODE_ACTION;
constructor() {
@ -37,7 +41,7 @@ export class EditModeAction extends Action {
return context.embeddable.getInput().viewMode === ViewMode.EDIT;
}
execute() {
async execute() {
return;
}
}

View file

@ -35,7 +35,7 @@ export class HelloWorldAction extends Action {
return 'Hello World Action!';
}
public execute() {
public async execute() {
const flyoutSession = this.overlays.openFlyout(
<EuiFlyout ownFocus onClose={() => flyoutSession && flyoutSession.close()}>
Hello World, I am a hello world action!

View file

@ -17,15 +17,15 @@
* under the License.
*/
import { Action, ActionContext } from '../../actions';
import { Action } from '../../actions';
export const RESTRICTED_ACTION = 'RESTRICTED_ACTION';
export class RestrictedAction extends Action {
export class RestrictedAction<A> extends Action<A> {
public readonly type = RESTRICTED_ACTION;
private isCompatibleFn: (context: ActionContext) => boolean;
constructor(isCompatible: (context: ActionContext) => boolean) {
private isCompatibleFn: (context: A) => boolean;
constructor(isCompatible: (context: A) => boolean) {
super(RESTRICTED_ACTION);
this.isCompatibleFn = isCompatible;
}
@ -34,9 +34,9 @@ export class RestrictedAction extends Action {
return `I am only sometimes compatible`;
}
async isCompatible(context: ActionContext) {
async isCompatible(context: A) {
return this.isCompatibleFn(context);
}
execute() {}
async execute() {}
}

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { Action, ActionContext } from '../../actions';
import { Action } from '../../actions';
import { EmbeddableInput, Embeddable, EmbeddableOutput, IEmbeddable } from '../../embeddables';
import { IncompatibleActionError } from '../../errors';
@ -36,7 +36,12 @@ export function hasFullNameOutput(
);
}
export class SayHelloAction extends Action {
interface ActionContext {
embeddable: Embeddable<EmbeddableInput, FullNameEmbeddableOutput>;
message?: string;
}
export class SayHelloAction extends Action<ActionContext> {
public readonly type = SAY_HELLO_ACTION;
private sayHello: (name: string) => void;
@ -53,9 +58,7 @@ export class SayHelloAction extends Action {
// Can use typescript generics to get compiler time warnings for immediate feedback if
// the context is not compatible.
async isCompatible(
context: ActionContext<Embeddable<EmbeddableInput, FullNameEmbeddableOutput>>
) {
async isCompatible(context: ActionContext) {
// Option 1: only compatible with Greeting Embeddables.
// return context.embeddable.type === CONTACT_CARD_EMBEDDABLE;
@ -63,20 +66,15 @@ export class SayHelloAction extends Action {
return hasFullNameOutput(context.embeddable);
}
async execute(
context: ActionContext<
Embeddable<EmbeddableInput, FullNameEmbeddableOutput>,
{ message?: string }
>
) {
async execute(context: ActionContext) {
if (!(await this.isCompatible(context))) {
throw new IncompatibleActionError();
}
const greeting = `Hello, ${context.embeddable.getOutput().fullName}`;
if (context.triggerContext && context.triggerContext.message) {
this.sayHello(`${greeting}. ${context.triggerContext.message}`);
if (context.message) {
this.sayHello(`${greeting}. ${context.message}`);
} else {
this.sayHello(greeting);
}

View file

@ -18,7 +18,7 @@
*/
import React from 'react';
import { EuiFlyoutBody } from '@elastic/eui';
import { Action, ActionContext, IncompatibleActionError } from '../..';
import { Action, IncompatibleActionError } from '../..';
import { Embeddable, EmbeddableInput } from '../../embeddables';
import { GetMessageModal } from './get_message_modal';
import { FullNameEmbeddableOutput, hasFullNameOutput } from './say_hello_action';
@ -26,6 +26,10 @@ import { CoreStart } from '../../../../../../../../../core/public';
export const SEND_MESSAGE_ACTION = 'SEND_MESSAGE_ACTION';
interface ActionContext {
embeddable: Embeddable<EmbeddableInput, FullNameEmbeddableOutput>;
message: string;
}
export class SendMessageAction extends Action {
public readonly type = SEND_MESSAGE_ACTION;
@ -37,28 +41,18 @@ export class SendMessageAction extends Action {
return 'Send message';
}
async isCompatible(
context: ActionContext<Embeddable<EmbeddableInput, FullNameEmbeddableOutput>>
) {
async isCompatible(context: ActionContext) {
return hasFullNameOutput(context.embeddable);
}
async sendMessage(
context: ActionContext<Embeddable<EmbeddableInput, FullNameEmbeddableOutput>>,
message: string
) {
async sendMessage(context: ActionContext, message: string) {
const greeting = `Hello, ${context.embeddable.getOutput().fullName}`;
const content = message ? `${greeting}. ${message}` : greeting;
this.overlays.openFlyout(<EuiFlyoutBody>{content}</EuiFlyoutBody>);
}
async execute(
context: ActionContext<
Embeddable<EmbeddableInput, FullNameEmbeddableOutput>,
{ message?: string }
>
) {
async execute(context: ActionContext) {
if (!(await this.isCompatible(context))) {
throw new IncompatibleActionError();
}

View file

@ -17,9 +17,7 @@
* under the License.
*/
import { Action, ActionContext } from './actions';
import { IEmbeddable } from './embeddables';
import { IContainer } from './containers';
import { Action } from './actions';
import { EmbeddableFactory } from './embeddables/embeddable_factory';
import { Adapters } from '../../../../../../../plugins/inspector/public';
@ -55,18 +53,10 @@ export interface SavedObjectMetaData<T> {
showSavedObject?(savedObject: any): any;
}
export interface TriggerContext {
embeddable: IEmbeddable;
container?: IContainer;
}
export type ExecuteTriggerActions = (
export type ExecuteTriggerActions = <A>(triggerId: string, actionContext: A) => Promise<void>;
export type GetActionsCompatibleWithTrigger = <C>(
triggerId: string,
actionContext: ActionContext
) => Promise<void>;
export type GetActionsCompatibleWithTrigger = (
triggerId: string,
context: TriggerContext
context: C
) => Promise<Action[]>;
export type GetEmbeddableFactory = (id: string) => EmbeddableFactory | undefined;
export type GetEmbeddableFactories = () => IterableIterator<EmbeddableFactory>;

View file

@ -85,7 +85,7 @@ test('ApplyFilterAction applies the filter to the root of the container tree', a
query: { match: { extension: { query: 'foo' } } },
};
applyFilterAction.execute({ embeddable, triggerContext: { filters: [filter] } });
await applyFilterAction.execute({ embeddable, filters: [filter] });
expect(root.getInput().filters.length).toBe(1);
expect(node1.getInput().filters.length).toBe(1);
expect(embeddable.getInput().filters.length).toBe(1);
@ -124,6 +124,7 @@ test('ApplyFilterAction is incompatible if the root container does not accept a
throw new Error();
}
// @ts-ignore
expect(await applyFilterAction.isCompatible({ embeddable })).toBe(false);
});
@ -160,6 +161,7 @@ test('trying to execute on incompatible context throws an error ', async () => {
}
async function check() {
// @ts-ignore
await applyFilterAction.execute({ embeddable });
}
await expect(check()).rejects.toThrow(Error);

View file

@ -19,7 +19,7 @@
import { testPlugin, TestPluginReturn } from './test_plugin';
import { of } from './helpers';
import { ActionContext, Action, openContextMenu } from '../lib';
import { Action, openContextMenu, IEmbeddable } from '../lib';
import {
ContactCardEmbeddable,
CONTACT_USER_TRIGGER,
@ -31,11 +31,11 @@ jest.mock('../lib/context_menu_actions');
const executeFn = jest.fn();
const openContextMenuSpy = (openContextMenu as any) as jest.SpyInstance;
class TestAction extends Action {
class TestAction<A> extends Action<A> {
public readonly type = 'testAction';
public checkCompatibility: (context: ActionContext) => boolean;
public checkCompatibility: (context: A) => boolean;
constructor(id: string, checkCompatibility: (context: ActionContext) => boolean) {
constructor(id: string, checkCompatibility: (context: A) => boolean) {
super(id);
this.checkCompatibility = checkCompatibility;
}
@ -44,11 +44,11 @@ class TestAction extends Action {
return 'test';
}
async isCompatible(context: ActionContext) {
async isCompatible(context: A) {
return this.checkCompatibility(context);
}
execute(context: ActionContext) {
async execute(context: unknown) {
executeFn(context);
}
}
@ -124,7 +124,10 @@ test('does not execute an incompatible action', async () => {
title: 'My trigger',
actionIds: ['test1'],
};
const action = new TestAction('test1', ({ embeddable }) => embeddable.id === 'executeme');
const action = new TestAction<{ embeddable: IEmbeddable }>(
'test1',
({ embeddable }) => embeddable.id === 'executeme'
);
const embeddable = new ContactCardEmbeddable(
{
id: 'executeme',
@ -187,7 +190,7 @@ test('passes whole action context to isCompatible()', async () => {
title: 'My trigger',
actionIds: ['test'],
};
const action = new TestAction('test', ({ triggerContext }) => {
const action = new TestAction<{ triggerContext: any }>('test', ({ triggerContext }) => {
expect(triggerContext).toEqual({
foo: 'bar',
});

View file

@ -22,7 +22,7 @@ import { HelloWorldAction } from '../lib/test_samples/actions/hello_world_action
import { SayHelloAction } from '../lib/test_samples/actions/say_hello_action';
import { RestrictedAction } from '../lib/test_samples/actions/restricted_action';
import { EmptyEmbeddable } from '../lib/test_samples/embeddables/empty_embeddable';
import { ActionContext, CONTEXT_MENU_TRIGGER } from '../lib';
import { CONTEXT_MENU_TRIGGER, IEmbeddable } from '../lib';
import { of } from './helpers';
let action: SayHelloAction;
@ -73,7 +73,7 @@ test('getTriggerCompatibleActions returns attached actions', async () => {
test('filters out actions not applicable based on the context', async () => {
const { setup, doStart } = embeddables;
const restrictedAction = new RestrictedAction((context: ActionContext) => {
const restrictedAction = new RestrictedAction<{ embeddable: IEmbeddable }>(context => {
return context.embeddable.id === 'accept';
});

View file

@ -17,6 +17,7 @@ if (timefield) {
class="fa fa-search-plus kbnDocTableRowFilterButton"
data-column="<%- column %>"
tooltip-append-to-body="1"
data-test-subj="docTableCellFilter"
tooltip="{{ ::'kbn.docTable.tableRow.filterForValueButtonTooltip' | i18n: {defaultMessage: 'Filter for value'} }}"
aria-label="{{ ::'kbn.docTable.tableRow.filterForValueButtonAriaLabel' | i18n: {defaultMessage: 'Filter for value'} }}"
></button>
@ -25,6 +26,7 @@ if (timefield) {
ng-click="inlineFilter($event, '-')"
class="fa fa-search-minus kbnDocTableRowFilterButton"
data-column="<%- column %>"
data-test-subj="docTableCellFilterNegate"
tooltip="{{ ::'kbn.docTable.tableRow.filterOutValueButtonTooltip' | i18n: {defaultMessage: 'Filter out value'} }}"
aria-label="{{ ::'kbn.docTable.tableRow.filterOutValueButtonAriaLabel' | i18n: {defaultMessage: 'Filter out value'} }}"
tooltip-append-to-body="1"

View file

@ -257,9 +257,7 @@ export class SearchEmbeddable extends Embeddable<SearchInput, SearchOutput>
await this.executeTriggerActions(APPLY_FILTER_TRIGGER, {
embeddable: this,
triggerContext: {
filters,
},
filters,
});
};
}

View file

@ -133,10 +133,10 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.dashboard.setTimepickerInDataRange();
});
it('are added when pie chart legend item is clicked', async function () {
await dashboardAddPanel.addVisualization('Rendering Test: pie');
it('are added when a cell magnifying glass is clicked', async function () {
await dashboardAddPanel.addSavedSearch('Rendering-Test:-saved-search');
await PageObjects.dashboard.waitForRenderComplete();
await pieChart.filterByLegendItem('4,886');
await testSubjects.click('docTableCellFilter');
const filterCount = await filterBar.getFilterCount();
expect(filterCount).to.equal(1);

View file

@ -22,12 +22,16 @@ import { npStart } from 'ui/new_platform';
import {
Action,
ActionContext,
CONTEXT_MENU_TRIGGER,
IEmbeddable,
} from '../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public';
import { setup } from '../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy';
class SamplePanelAction extends Action {
interface ActionContext {
embeddable: IEmbeddable;
}
class SamplePanelAction extends Action<ActionContext> {
public readonly type = 'samplePanelAction';
constructor() {
@ -38,7 +42,7 @@ class SamplePanelAction extends Action {
return 'Sample Panel Action';
}
public execute = ({ embeddable }: ActionContext) => {
public execute = async ({ embeddable }: ActionContext) => {
if (!embeddable) {
return;
}

View file

@ -33,7 +33,7 @@ class SamplePanelLink extends Action {
return 'Sample panel Link';
}
public execute() {
public async execute() {
return;
}

View file

@ -339,6 +339,7 @@ test('Attempting to execute on incompatible embeddable throws an error', async (
});
async function check() {
// @ts-ignore
await action.execute({ embeddable: child });
}
await expect(check()).rejects.toThrow(Error);

View file

@ -14,7 +14,6 @@ import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../../../src/legacy/core_
import {
Action,
IEmbeddable,
ActionContext,
IncompatibleActionError,
Embeddable,
EmbeddableInput,
@ -41,7 +40,11 @@ function isVisualizeEmbeddable(
return embeddable.type === VISUALIZE_EMBEDDABLE_TYPE;
}
export class CustomTimeRangeAction extends Action {
interface ActionContext {
embeddable: Embeddable<TimeRangeInput>;
}
export class CustomTimeRangeAction extends Action<ActionContext> {
public readonly type = CUSTOM_TIME_RANGE;
private openModal: OpenModal;
private dateFormat?: string;
@ -76,10 +79,11 @@ export class CustomTimeRangeAction extends Action {
public async isCompatible({ embeddable }: ActionContext) {
const isInputControl =
isVisualizeEmbeddable(embeddable) &&
embeddable.getOutput().visTypeName === 'input_control_vis';
(embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'input_control_vis';
const isMarkdown =
isVisualizeEmbeddable(embeddable) && embeddable.getOutput().visTypeName === 'markdown';
isVisualizeEmbeddable(embeddable) &&
(embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'markdown';
return Boolean(
embeddable &&
hasTimeRange(embeddable) &&

View file

@ -11,7 +11,6 @@ import { TimeRange } from '../../../../../../../src/plugins/data/public';
import {
Action,
IEmbeddable,
ActionContext,
IncompatibleActionError,
Embeddable,
EmbeddableInput,
@ -33,7 +32,11 @@ function hasTimeRange(
return (embeddable as Embeddable<TimeRangeInput>).getInput().timeRange !== undefined;
}
export class CustomTimeRangeBadge extends Action {
interface ActionContext {
embeddable: Embeddable<TimeRangeInput>;
}
export class CustomTimeRangeBadge extends Action<ActionContext> {
public readonly type = CUSTOM_TIME_RANGE_BADGE;
private openModal: OpenModal;
private dateFormat: string;
@ -55,7 +58,7 @@ export class CustomTimeRangeBadge extends Action {
this.commonlyUsedRanges = commonlyUsedRanges;
}
public getDisplayName({ embeddable }: ActionContext<Embeddable<TimeRangeInput>>) {
public getDisplayName({ embeddable }: ActionContext) {
return prettyDuration(
embeddable.getInput().timeRange.from,
embeddable.getInput().timeRange.to,

View file

@ -15,7 +15,6 @@ import { EuiIcon } from '@elastic/eui';
import {
Action,
ActionContext,
ViewMode,
IncompatibleActionError,
IEmbeddable,
@ -37,7 +36,12 @@ function isSavedSearchEmbeddable(
): embeddable is ISearchEmbeddable {
return embeddable.type === SEARCH_EMBEDDABLE_TYPE;
}
class GetCsvReportPanelAction extends Action<ISearchEmbeddable> {
interface ActionContext {
embeddable: ISearchEmbeddable;
}
class GetCsvReportPanelAction extends Action<ActionContext> {
private isDownloading: boolean;
public readonly type = CSV_REPORTING_ACTION;
@ -82,7 +86,7 @@ class GetCsvReportPanelAction extends Action<ISearchEmbeddable> {
return embeddable.getInput().viewMode !== ViewMode.EDIT && embeddable.type === 'search';
};
public execute = async (context: ActionContext<ISearchEmbeddable>) => {
public execute = async (context: ActionContext) => {
const { embeddable } = context;
if (!isSavedSearchEmbeddable(embeddable)) {

View file

@ -29,10 +29,6 @@ const isEmbeddable = (
return get('type', embeddable) != null;
};
const isTriggerContext = (triggerContext: unknown): triggerContext is { filters: Filter[] } => {
return typeof triggerContext === 'object';
};
describe('ApplySiemFilterAction', () => {
let applyFilterQueryFromKueryExpression: (expression: string) => void;
@ -57,7 +53,7 @@ describe('ApplySiemFilterAction', () => {
});
describe('#isCompatible', () => {
test('when embeddable type is MAP_SAVED_OBJECT_TYPE and triggerContext filters exist, returns true', async () => {
test('when embeddable type is MAP_SAVED_OBJECT_TYPE and filters exist, returns true', async () => {
const action = new ApplySiemFilterAction({ applyFilterQueryFromKueryExpression });
const embeddable = {
type: MAP_SAVED_OBJECT_TYPE,
@ -65,9 +61,7 @@ describe('ApplySiemFilterAction', () => {
if (isEmbeddable(embeddable)) {
const result = await action.isCompatible({
embeddable,
triggerContext: {
filters: [],
},
filters: [],
});
expect(result).toBe(true);
} else {
@ -75,7 +69,7 @@ describe('ApplySiemFilterAction', () => {
}
});
test('when embeddable type is MAP_SAVED_OBJECT_TYPE and triggerContext does not exist, returns false', async () => {
test('when embeddable type is MAP_SAVED_OBJECT_TYPE and filters do not exist, returns false', async () => {
const action = new ApplySiemFilterAction({ applyFilterQueryFromKueryExpression });
const embeddable = {
type: MAP_SAVED_OBJECT_TYPE,
@ -83,30 +77,14 @@ describe('ApplySiemFilterAction', () => {
if (isEmbeddable(embeddable)) {
const result = await action.isCompatible({
embeddable,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
expect(result).toBe(false);
} else {
throw new Error('Invalid embeddable in unit test');
}
});
test('when embeddable type is MAP_SAVED_OBJECT_TYPE and triggerContext filters do not exist, returns false', async () => {
const action = new ApplySiemFilterAction({ applyFilterQueryFromKueryExpression });
const embeddable = {
type: MAP_SAVED_OBJECT_TYPE,
};
const triggerContext = {};
if (isEmbeddable(embeddable) && isTriggerContext(triggerContext)) {
const result = await action.isCompatible({
embeddable,
triggerContext,
});
expect(result).toBe(false);
} else {
throw new Error('Invalid embeddable/triggerContext in unit test');
}
});
test('when embeddable type is not MAP_SAVED_OBJECT_TYPE, returns false', async () => {
const action = new ApplySiemFilterAction({ applyFilterQueryFromKueryExpression });
const embeddable = {
@ -115,9 +93,7 @@ describe('ApplySiemFilterAction', () => {
if (isEmbeddable(embeddable)) {
const result = await action.isCompatible({
embeddable,
triggerContext: {
filters: [],
},
filters: [],
});
expect(result).toBe(false);
} else {
@ -136,7 +112,8 @@ describe('ApplySiemFilterAction', () => {
const error = expectError(() =>
action.execute({
embeddable,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
);
expect(error).toBeInstanceOf(Error);
} else {
@ -152,24 +129,27 @@ describe('ApplySiemFilterAction', () => {
query: { query: '' },
}),
};
const triggerContext = {
filters: [
{
query: {
match: {
'host.name': {
query: 'zeek-newyork-sha-aa8df15',
type: 'phrase',
},
const filters: Filter[] = [
{
query: {
match: {
'host.name': {
query: 'zeek-newyork-sha-aa8df15',
type: 'phrase',
},
},
},
],
};
if (isEmbeddable(embeddable) && isTriggerContext(triggerContext)) {
meta: {
disabled: false,
negate: false,
alias: '',
},
},
];
if (isEmbeddable(embeddable)) {
await action.execute({
embeddable,
triggerContext,
filters,
});
expect(
@ -177,7 +157,7 @@ describe('ApplySiemFilterAction', () => {
.calls[0][0]
).toBe('host.name: "zeek-newyork-sha-aa8df15"');
} else {
throw new Error('Invalid embeddable/triggerContext in unit test');
throw new Error('Invalid embeddable in unit test');
}
});
});

View file

@ -9,15 +9,17 @@ import { getOr } from 'lodash/fp';
import { i18n } from '@kbn/i18n';
// @ts-ignore Missing type defs as maps moves to Typescript
import { MAP_SAVED_OBJECT_TYPE } from '../../../../../maps/common/constants';
import {
Action,
ActionContext,
} from '../../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/actions';
import { Action } from '../../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/actions';
import { IEmbeddable } from '../../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/lib/embeddables';
export const APPLY_SIEM_FILTER_ACTION_ID = 'APPLY_SIEM_FILTER_ACTION_ID';
export class ApplySiemFilterAction extends Action {
interface ActionContext {
embeddable: IEmbeddable;
filters: Filter[];
}
export class ApplySiemFilterAction extends Action<ActionContext> {
public readonly type = APPLY_SIEM_FILTER_ACTION_ID;
private readonly applyFilterQueryFromKueryExpression: (expression: string) => void;
@ -36,26 +38,17 @@ export class ApplySiemFilterAction extends Action {
});
}
public async isCompatible(
context: ActionContext<IEmbeddable, { filters: Filter[] }>
): Promise<boolean> {
return (
context.embeddable.type === MAP_SAVED_OBJECT_TYPE &&
context.triggerContext != null &&
context.triggerContext.filters !== undefined
);
public async isCompatible(context: ActionContext): Promise<boolean> {
return context.embeddable.type === MAP_SAVED_OBJECT_TYPE && context.filters !== undefined;
}
public execute({
embeddable,
triggerContext,
}: ActionContext<IEmbeddable, { filters: Filter[] }>) {
if (!triggerContext) {
public async execute({ embeddable, filters }: ActionContext) {
if (!filters) {
throw new Error('Applying a filter requires a filter as context');
}
// Parse queryExpression from queryDSL and apply to SIEM global KQL Bar via redux
const filterObject = getOr(null, 'filters[0].query.match', triggerContext);
const filterObject = getOr(null, '[0].query.match', filters);
if (filterObject != null) {
const filterQuery = getOr('', 'query.query', embeddable.getInput());