Revert "Drilldowns (#59632)" (#61136)

This reverts commit 5abb2c8c7d.
This commit is contained in:
Matthew Kime 2020-03-24 16:22:11 -05:00 committed by GitHub
parent e251310000
commit 498abb4152
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
164 changed files with 897 additions and 5367 deletions

1
.github/CODEOWNERS vendored
View file

@ -3,7 +3,6 @@
# For more info, see https://help.github.com/articles/about-codeowners/
# App
/x-pack/legacy/plugins/dashboard_enhanced/ @elastic/kibana-app
/x-pack/legacy/plugins/lens/ @elastic/kibana-app
/x-pack/legacy/plugins/graph/ @elastic/kibana-app
/src/legacy/server/url_shortening/ @elastic/kibana-app

View file

@ -46,7 +46,7 @@ export class UiActionExamplesPlugin
}));
uiActions.registerAction(helloWorldAction);
uiActions.addTriggerAction(helloWorldTrigger.id, helloWorldAction);
uiActions.attachAction(helloWorldTrigger.id, helloWorldAction);
}
public start() {}

View file

@ -95,7 +95,8 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => {
);
},
});
uiActionsApi.addTriggerAction(HELLO_WORLD_TRIGGER_ID, dynamicAction);
uiActionsApi.registerAction(dynamicAction);
uiActionsApi.attachAction(HELLO_WORLD_TRIGGER_ID, dynamicAction);
setConfirmationText(
`You've successfully added a new action: ${dynamicAction.getDisplayName(
{}

View file

@ -79,21 +79,21 @@ export class UiActionsExplorerPlugin implements Plugin<void, void, {}, StartDeps
const startServices = core.getStartServices();
deps.uiActions.addTriggerAction(
deps.uiActions.attachAction(
USER_TRIGGER,
createPhoneUserAction(async () => (await startServices)[1].uiActions)
);
deps.uiActions.addTriggerAction(
deps.uiActions.attachAction(
USER_TRIGGER,
createEditUserAction(async () => (await startServices)[0].overlays.openModal)
);
deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, viewInMapsAction);
deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, lookUpWeatherAction);
deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, showcasePluggability);
deps.uiActions.addTriggerAction(PHONE_TRIGGER, makePhoneCallAction);
deps.uiActions.addTriggerAction(PHONE_TRIGGER, showcasePluggability);
deps.uiActions.addTriggerAction(USER_TRIGGER, showcasePluggability);
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

@ -91,7 +91,6 @@ export interface OverlayFlyoutStart {
export interface OverlayFlyoutOpenOptions {
className?: string;
closeButtonAriaLabel?: string;
ownFocus?: boolean;
'data-test-subj'?: string;
}

View file

@ -18,14 +18,12 @@
*/
export const storybookAliases = {
advanced_ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js',
apm: 'x-pack/legacy/plugins/apm/scripts/storybook.js',
canvas: 'x-pack/legacy/plugins/canvas/scripts/storybook_new.js',
codeeditor: 'src/plugins/kibana_react/public/code_editor/scripts/storybook.ts',
dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/scripts/storybook.js',
drilldowns: 'x-pack/plugins/drilldowns/scripts/storybook.js',
embeddable: 'src/plugins/embeddable/scripts/storybook.js',
infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js',
siem: 'x-pack/legacy/plugins/siem/scripts/storybook.js',
ui_actions: 'src/plugins/ui_actions/scripts/storybook.js',
ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js',
};

View file

@ -45,7 +45,6 @@ import { PersistedState } from '../../../../../../../plugins/visualizations/publ
import { buildPipeline } from '../legacy/build_pipeline';
import { Vis } from '../vis';
import { getExpressions, getUiActions } from '../services';
import { VisualizationsStartDeps } from '../plugin';
import { VIS_EVENT_TO_TRIGGER } from './events';
const getKeys = <T extends {}>(o: T): Array<keyof T> => Object.keys(o) as Array<keyof T>;
@ -57,7 +56,6 @@ export interface VisualizeEmbeddableConfiguration {
editable: boolean;
appState?: { save(): void };
uiState?: PersistedState;
uiActions?: VisualizationsStartDeps['uiActions'];
}
export interface VisualizeInput extends EmbeddableInput {
@ -96,7 +94,7 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut
constructor(
timefilter: TimefilterContract,
{ vis, editUrl, indexPatterns, editable, uiActions }: VisualizeEmbeddableConfiguration,
{ vis, editUrl, indexPatterns, editable }: VisualizeEmbeddableConfiguration,
initialInput: VisualizeInput,
parent?: Container
) {
@ -109,8 +107,7 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut
editable,
visTypeName: vis.type.name,
},
parent,
{ uiActions }
parent
);
this.timefilter = timefilter;
this.vis = vis;
@ -268,7 +265,6 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut
timeFieldName: this.vis.data.indexPattern!.timeFieldName!,
data: event.data,
};
getUiActions()
.getTrigger(triggerId)
.exec(context);

View file

@ -38,7 +38,6 @@ import {
getTimeFilter,
} from '../services';
import { showNewVisModal } from '../wizard';
import { VisualizationsStartDeps } from '../plugin';
import { convertToSerializedVis } from '../saved_visualizations/_saved_vis';
interface VisualizationAttributes extends SavedObjectAttributes {
@ -53,11 +52,7 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory<
> {
public readonly type = VISUALIZE_EMBEDDABLE_TYPE;
constructor(
private readonly getUiActions: () => Promise<
Pick<VisualizationsStartDeps, 'uiActions'>['uiActions']
>
) {
constructor() {
super({
savedObjectMetaData: {
name: i18n.translate('visualizations.savedObjectName', { defaultMessage: 'Visualization' }),
@ -119,8 +114,6 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory<
const indexPattern = vis.data.indexPattern;
const indexPatterns = indexPattern ? [indexPattern] : [];
const uiActions = await this.getUiActions();
const editable = await this.isEditable();
return new VisualizeEmbeddable(
getTimeFilter(),
@ -131,7 +124,6 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory<
editable,
appState: input.appState,
uiState: input.uiState,
uiActions,
},
input,
parent

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { CoreSetup, PluginInitializerContext } from '../../../../../../core/public';
import { PluginInitializerContext } from '../../../../../../core/public';
import { VisualizationsSetup, VisualizationsStart } from './';
import { VisualizationsPlugin } from './plugin';
import { coreMock } from '../../../../../../core/public/mocks';
@ -26,7 +26,6 @@ import { expressionsPluginMock } from '../../../../../../plugins/expressions/pub
import { dataPluginMock } from '../../../../../../plugins/data/public/mocks';
import { usageCollectionPluginMock } from '../../../../../../plugins/usage_collection/public/mocks';
import { uiActionsPluginMock } from '../../../../../../plugins/ui_actions/public/mocks';
import { VisualizationsStartDeps } from './plugin';
const createSetupContract = (): VisualizationsSetup => ({
createBaseVisualization: jest.fn(),
@ -49,7 +48,7 @@ const createStartContract = (): VisualizationsStart => ({
const createInstance = async () => {
const plugin = new VisualizationsPlugin({} as PluginInitializerContext);
const setup = plugin.setup(coreMock.createSetup() as CoreSetup<VisualizationsStartDeps>, {
const setup = plugin.setup(coreMock.createSetup(), {
data: dataPluginMock.createSetupContract(),
expressions: expressionsPluginMock.createSetupContract(),
embeddable: embeddablePluginMock.createSetupContract(),

View file

@ -111,7 +111,7 @@ export class VisualizationsPlugin
constructor(initializerContext: PluginInitializerContext) {}
public setup(
core: CoreSetup<VisualizationsStartDeps>,
core: CoreSetup,
{ expressions, embeddable, usageCollection, data }: VisualizationsSetupDeps
): VisualizationsSetup {
setUISettings(core.uiSettings);
@ -120,9 +120,7 @@ export class VisualizationsPlugin
expressions.registerFunction(visualizationFunction);
expressions.registerRenderer(visualizationRenderer);
const embeddableFactory = new VisualizeEmbeddableFactory(
async () => (await core.getStartServices())[1].uiActions
);
const embeddableFactory = new VisualizeEmbeddableFactory();
embeddable.registerEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE, embeddableFactory);
return {

View file

@ -37,7 +37,7 @@ export interface ReplacePanelActionContext {
export class ReplacePanelAction implements ActionByType<typeof ACTION_REPLACE_PANEL> {
public readonly type = ACTION_REPLACE_PANEL;
public readonly id = ACTION_REPLACE_PANEL;
public order = 3;
public order = 11;
constructor(
private core: CoreStart,

View file

@ -87,7 +87,7 @@ export class DashboardEmbeddableContainerPublicPlugin
): Setup {
const expandPanelAction = new ExpandPanelAction();
uiActions.registerAction(expandPanelAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction);
const startServices = core.getStartServices();
if (share) {
@ -146,7 +146,7 @@ export class DashboardEmbeddableContainerPublicPlugin
plugins.embeddable.getEmbeddableFactories
);
uiActions.registerAction(changeViewAction);
uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, changeViewAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction);
const savedDashboardLoader = createSavedDashboardLoader({
savedObjectsClient: core.savedObjects.client,
indexPatterns,

View file

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

View file

@ -109,12 +109,12 @@ export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPubli
createFilterAction(queryService.filterManager, queryService.timefilter.timefilter)
);
uiActions.addTriggerAction(
uiActions.attachAction(
SELECT_RANGE_TRIGGER,
selectRangeAction(queryService.filterManager, queryService.timefilter.timefilter)
);
uiActions.addTriggerAction(
uiActions.attachAction(
VALUE_CLICK_TRIGGER,
valueClickAction(queryService.filterManager, queryService.timefilter.timefilter)
);
@ -146,10 +146,7 @@ export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPubli
const search = this.searchService.start(core);
setSearchService(search);
uiActions.addTriggerAction(
APPLY_FILTER_TRIGGER,
uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER)
);
uiActions.attachAction(APPLY_FILTER_TRIGGER, uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER));
const dataServices = {
actions: {

View file

@ -33,7 +33,7 @@ interface ActionContext {
export class EditPanelAction implements Action<ActionContext> {
public readonly type = ACTION_EDIT_PANEL;
public readonly id = ACTION_EDIT_PANEL;
public order = 50;
public order = 15;
constructor(private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']) {}

View file

@ -16,35 +16,23 @@
* specific language governing permissions and limitations
* under the License.
*/
import { cloneDeep, isEqual } from 'lodash';
import { isEqual, cloneDeep } from 'lodash';
import * as Rx from 'rxjs';
import { Adapters, ViewMode } from '../types';
import { Adapters } from '../types';
import { IContainer } from '../containers';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable';
import { IEmbeddable, EmbeddableInput, EmbeddableOutput } from './i_embeddable';
import { ViewMode } from '../types';
import { TriggerContextMapping } from '../ui_actions';
import { EmbeddableActionStorage } from './embeddable_action_storage';
import {
UiActionsDynamicActionManager,
UiActionsStart,
} from '../../../../../plugins/ui_actions/public';
import { EmbeddableContext } from '../triggers';
function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) {
return input.hidePanelTitles ? '' : input.title === undefined ? output.defaultTitle : input.title;
}
export interface EmbeddableParams {
uiActions?: UiActionsStart;
}
export abstract class Embeddable<
TEmbeddableInput extends EmbeddableInput = EmbeddableInput,
TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput
> implements IEmbeddable<TEmbeddableInput, TEmbeddableOutput> {
static runtimeId: number = 0;
public readonly runtimeId = Embeddable.runtimeId++;
public readonly parent?: IContainer;
public readonly isContainer: boolean = false;
public abstract readonly type: string;
@ -60,34 +48,15 @@ export abstract class Embeddable<
// to update input when the parent changes.
private parentSubscription?: Rx.Subscription;
private storageSubscription?: Rx.Subscription;
// TODO: Rename to destroyed.
private destoyed: boolean = false;
private storage = new EmbeddableActionStorage((this as unknown) as Embeddable);
private cachedDynamicActions?: UiActionsDynamicActionManager;
public get dynamicActions(): UiActionsDynamicActionManager | undefined {
if (!this.params.uiActions) return undefined;
if (!this.cachedDynamicActions) {
this.cachedDynamicActions = new UiActionsDynamicActionManager({
isCompatible: async (context: unknown) =>
(context as EmbeddableContext).embeddable.runtimeId === this.runtimeId,
storage: this.storage,
uiActions: this.params.uiActions,
});
private __actionStorage?: EmbeddableActionStorage;
public get actionStorage(): EmbeddableActionStorage {
return this.__actionStorage || (this.__actionStorage = new EmbeddableActionStorage(this));
}
return this.cachedDynamicActions;
}
constructor(
input: TEmbeddableInput,
output: TEmbeddableOutput,
parent?: IContainer,
public readonly params: EmbeddableParams = {}
) {
constructor(input: TEmbeddableInput, output: TEmbeddableOutput, parent?: IContainer) {
this.id = input.id;
this.output = {
title: getPanelTitle(input, output),
@ -111,18 +80,6 @@ export abstract class Embeddable<
this.onResetInput(newInput);
});
}
if (this.dynamicActions) {
this.dynamicActions.start().catch(error => {
/* eslint-disable */
console.log('Failed to start embeddable dynamic actions', this);
console.error(error);
/* eslint-enable */
});
this.storageSubscription = this.input$.subscribe(() => {
this.storage.reload$.next();
});
}
}
public getIsContainer(): this is IContainer {
@ -201,20 +158,6 @@ export abstract class Embeddable<
*/
public destroy(): void {
this.destoyed = true;
if (this.dynamicActions) {
this.dynamicActions.stop().catch(error => {
/* eslint-disable */
console.log('Failed to stop embeddable dynamic actions', this);
console.error(error);
/* eslint-enable */
});
}
if (this.storageSubscription) {
this.storageSubscription.unsubscribe();
}
if (this.parentSubscription) {
this.parentSubscription.unsubscribe();
}

View file

@ -20,8 +20,7 @@
import { Embeddable } from './embeddable';
import { EmbeddableInput } from './i_embeddable';
import { ViewMode } from '../types';
import { EmbeddableActionStorage } from './embeddable_action_storage';
import { UiActionsSerializedEvent } from '../../../../ui_actions/public';
import { EmbeddableActionStorage, SerializedEvent } from './embeddable_action_storage';
import { of } from '../../../../kibana_utils/common';
class TestEmbeddable extends Embeddable<EmbeddableInput> {
@ -43,9 +42,9 @@ describe('EmbeddableActionStorage', () => {
test('can add event to embeddable', async () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event: UiActionsSerializedEvent = {
const event: SerializedEvent = {
eventId: 'EVENT_ID',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {} as any,
};
@ -58,40 +57,23 @@ describe('EmbeddableActionStorage', () => {
expect(events2).toEqual([event]);
});
test('does not merge .getInput() into .updateInput()', async () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event: UiActionsSerializedEvent = {
eventId: 'EVENT_ID',
triggers: ['TRIGGER-ID'],
action: {} as any,
};
const spy = jest.spyOn(embeddable, 'updateInput');
await storage.create(event);
expect(spy.mock.calls[0][0].id).toBe(undefined);
expect(spy.mock.calls[0][0].viewMode).toBe(undefined);
});
test('can create multiple events', async () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event1: UiActionsSerializedEvent = {
const event1: SerializedEvent = {
eventId: 'EVENT_ID1',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {} as any,
};
const event2: UiActionsSerializedEvent = {
const event2: SerializedEvent = {
eventId: 'EVENT_ID2',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {} as any,
};
const event3: UiActionsSerializedEvent = {
const event3: SerializedEvent = {
eventId: 'EVENT_ID3',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {} as any,
};
@ -113,9 +95,9 @@ describe('EmbeddableActionStorage', () => {
test('throws when creating an event with the same ID', async () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event: UiActionsSerializedEvent = {
const event: SerializedEvent = {
eventId: 'EVENT_ID',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {} as any,
};
@ -140,16 +122,16 @@ describe('EmbeddableActionStorage', () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event1: UiActionsSerializedEvent = {
const event1: SerializedEvent = {
eventId: 'EVENT_ID',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {
name: 'foo',
} as any,
};
const event2: UiActionsSerializedEvent = {
const event2: SerializedEvent = {
eventId: 'EVENT_ID',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {
name: 'bar',
} as any,
@ -166,30 +148,30 @@ describe('EmbeddableActionStorage', () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event1: UiActionsSerializedEvent = {
const event1: SerializedEvent = {
eventId: 'EVENT_ID1',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {
name: 'foo',
} as any,
};
const event2: UiActionsSerializedEvent = {
const event2: SerializedEvent = {
eventId: 'EVENT_ID2',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {
name: 'bar',
} as any,
};
const event22: UiActionsSerializedEvent = {
const event22: SerializedEvent = {
eventId: 'EVENT_ID2',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {
name: 'baz',
} as any,
};
const event3: UiActionsSerializedEvent = {
const event3: SerializedEvent = {
eventId: 'EVENT_ID3',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {
name: 'qux',
} as any,
@ -217,9 +199,9 @@ describe('EmbeddableActionStorage', () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event: UiActionsSerializedEvent = {
const event: SerializedEvent = {
eventId: 'EVENT_ID',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {} as any,
};
@ -235,14 +217,14 @@ describe('EmbeddableActionStorage', () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event1: UiActionsSerializedEvent = {
const event1: SerializedEvent = {
eventId: 'EVENT_ID1',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {} as any,
};
const event2: UiActionsSerializedEvent = {
const event2: SerializedEvent = {
eventId: 'EVENT_ID2',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {} as any,
};
@ -267,9 +249,9 @@ describe('EmbeddableActionStorage', () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event: UiActionsSerializedEvent = {
const event: SerializedEvent = {
eventId: 'EVENT_ID',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {} as any,
};
@ -284,23 +266,23 @@ describe('EmbeddableActionStorage', () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event1: UiActionsSerializedEvent = {
const event1: SerializedEvent = {
eventId: 'EVENT_ID1',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {
name: 'foo',
} as any,
};
const event2: UiActionsSerializedEvent = {
const event2: SerializedEvent = {
eventId: 'EVENT_ID2',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {
name: 'bar',
} as any,
};
const event3: UiActionsSerializedEvent = {
const event3: SerializedEvent = {
eventId: 'EVENT_ID3',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {
name: 'qux',
} as any,
@ -345,9 +327,9 @@ describe('EmbeddableActionStorage', () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event: UiActionsSerializedEvent = {
const event: SerializedEvent = {
eventId: 'EVENT_ID',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {} as any,
};
@ -373,9 +355,9 @@ describe('EmbeddableActionStorage', () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event: UiActionsSerializedEvent = {
const event: SerializedEvent = {
eventId: 'EVENT_ID',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {} as any,
};
@ -401,9 +383,9 @@ describe('EmbeddableActionStorage', () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event: UiActionsSerializedEvent = {
const event: SerializedEvent = {
eventId: 'EVENT_ID',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID',
action: {} as any,
};
@ -420,19 +402,19 @@ describe('EmbeddableActionStorage', () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event1: UiActionsSerializedEvent = {
const event1: SerializedEvent = {
eventId: 'EVENT_ID1',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID1',
action: {} as any,
};
const event2: UiActionsSerializedEvent = {
const event2: SerializedEvent = {
eventId: 'EVENT_ID2',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID2',
action: {} as any,
};
const event3: UiActionsSerializedEvent = {
const event3: SerializedEvent = {
eventId: 'EVENT_ID3',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID3',
action: {} as any,
};
@ -476,7 +458,7 @@ describe('EmbeddableActionStorage', () => {
await storage.create({
eventId: 'EVENT_ID1',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID1',
action: {} as any,
});
@ -484,7 +466,7 @@ describe('EmbeddableActionStorage', () => {
await storage.create({
eventId: 'EVENT_ID2',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID1',
action: {} as any,
});
@ -520,15 +502,15 @@ describe('EmbeddableActionStorage', () => {
const embeddable = new TestEmbeddable();
const storage = new EmbeddableActionStorage(embeddable);
const event1: UiActionsSerializedEvent = {
const event1: SerializedEvent = {
eventId: 'EVENT_ID1',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID1',
action: {} as any,
};
const event2: UiActionsSerializedEvent = {
const event2: SerializedEvent = {
eventId: 'EVENT_ID2',
triggers: ['TRIGGER-ID'],
triggerId: 'TRIGGER-ID1',
action: {} as any,
};

View file

@ -17,20 +17,32 @@
* under the License.
*/
import {
UiActionsAbstractActionStorage,
UiActionsSerializedEvent,
} from '../../../../ui_actions/public';
import { Embeddable } from '..';
export class EmbeddableActionStorage extends UiActionsAbstractActionStorage {
constructor(private readonly embbeddable: Embeddable) {
super();
/**
* Below two interfaces are here temporarily, they will move to `ui_actions`
* plugin once #58216 is merged.
*/
export interface SerializedEvent {
eventId: string;
triggerId: string;
action: unknown;
}
export interface ActionStorage {
create(event: SerializedEvent): Promise<void>;
update(event: SerializedEvent): Promise<void>;
remove(eventId: string): Promise<void>;
read(eventId: string): Promise<SerializedEvent>;
count(): Promise<number>;
list(): Promise<SerializedEvent[]>;
}
async create(event: UiActionsSerializedEvent) {
export class EmbeddableActionStorage implements ActionStorage {
constructor(private readonly embbeddable: Embeddable<any, any>) {}
async create(event: SerializedEvent) {
const input = this.embbeddable.getInput();
const events = (input.events || []) as UiActionsSerializedEvent[];
const events = (input.events || []) as SerializedEvent[];
const exists = !!events.find(({ eventId }) => eventId === event.eventId);
if (exists) {
@ -41,13 +53,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage {
}
this.embbeddable.updateInput({
...input,
events: [...events, event],
});
}
async update(event: UiActionsSerializedEvent) {
async update(event: SerializedEvent) {
const input = this.embbeddable.getInput();
const events = (input.events || []) as UiActionsSerializedEvent[];
const events = (input.events || []) as SerializedEvent[];
const index = events.findIndex(({ eventId }) => eventId === event.eventId);
if (index === -1) {
@ -59,13 +72,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage {
}
this.embbeddable.updateInput({
...input,
events: [...events.slice(0, index), event, ...events.slice(index + 1)],
});
}
async remove(eventId: string) {
const input = this.embbeddable.getInput();
const events = (input.events || []) as UiActionsSerializedEvent[];
const events = (input.events || []) as SerializedEvent[];
const index = events.findIndex(event => eventId === event.eventId);
if (index === -1) {
@ -77,13 +91,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage {
}
this.embbeddable.updateInput({
...input,
events: [...events.slice(0, index), ...events.slice(index + 1)],
});
}
async read(eventId: string): Promise<UiActionsSerializedEvent> {
async read(eventId: string): Promise<SerializedEvent> {
const input = this.embbeddable.getInput();
const events = (input.events || []) as UiActionsSerializedEvent[];
const events = (input.events || []) as SerializedEvent[];
const event = events.find(ev => eventId === ev.eventId);
if (!event) {
@ -98,10 +113,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage {
private __list() {
const input = this.embbeddable.getInput();
return (input.events || []) as UiActionsSerializedEvent[];
return (input.events || []) as SerializedEvent[];
}
async list(): Promise<UiActionsSerializedEvent[]> {
async count(): Promise<number> {
return this.__list().length;
}
async list(): Promise<SerializedEvent[]> {
return this.__list();
}
}

View file

@ -18,7 +18,6 @@
*/
import { Observable } from 'rxjs';
import { UiActionsDynamicActionManager } from '../../../../../plugins/ui_actions/public';
import { Adapters } from '../types';
import { IContainer } from '../containers/i_container';
import { ViewMode } from '../types';
@ -34,7 +33,7 @@ export interface EmbeddableInput {
/**
* Reserved key for `ui_actions` events.
*/
events?: Array<{ eventId: string }>;
events?: unknown;
/**
* List of action IDs that this embeddable should not render.
@ -83,19 +82,6 @@ export interface IEmbeddable<
**/
readonly id: string;
/**
* Unique ID an embeddable is assigned each time it is initialized. This ID
* is different for different instances of the same embeddable. For example,
* if the same dashboard is rendered twice on the screen, all embeddable
* instances will have a unique `runtimeId`.
*/
readonly runtimeId?: number;
/**
* Default implementation of dynamic action API for embeddables.
*/
dynamicActions?: UiActionsDynamicActionManager;
/**
* A functional representation of the isContainer variable, but helpful for typescript to
* know the shape if this returns true

View file

@ -44,7 +44,7 @@ import {
import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks';
import { EuiBadge } from '@elastic/eui';
const actionRegistry = new Map<string, Action>();
const actionRegistry = new Map<string, Action<object | undefined | string | number>>();
const triggerRegistry = new Map<string, Trigger>();
const embeddableFactories = new Map<string, EmbeddableFactory>();
const getEmbeddableFactory = (id: string) => embeddableFactories.get(id);
@ -213,17 +213,13 @@ const renderInEditModeAndOpenContextMenu = async (
};
test('HelloWorldContainer in edit mode hides disabledActions', async () => {
const action = {
const action: Action = {
id: 'FOO',
type: 'FOO' as ActionType,
getIconType: () => undefined,
getDisplayName: () => 'foo',
isCompatible: async () => true,
execute: async () => {},
order: 10,
getHref: () => {
return undefined;
},
};
const getActions = () => Promise.resolve([action]);
@ -249,17 +245,13 @@ test('HelloWorldContainer in edit mode hides disabledActions', async () => {
});
test('HelloWorldContainer hides disabled badges', async () => {
const action = {
const action: Action = {
id: 'BAR',
type: 'BAR' as ActionType,
getIconType: () => undefined,
getDisplayName: () => 'bar',
isCompatible: async () => true,
execute: async () => {},
order: 10,
getHref: () => {
return undefined;
},
};
const getActions = () => Promise.resolve([action]);

View file

@ -38,14 +38,6 @@ import { EditPanelAction } from '../actions';
import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal';
import { EmbeddableStart } from '../../plugin';
const sortByOrderField = (
{ order: orderA }: { order?: number },
{ order: orderB }: { order?: number }
) => (orderB || 0) - (orderA || 0);
const removeById = (disabledActions: string[]) => ({ id }: { id: string }) =>
disabledActions.indexOf(id) === -1;
interface Props {
embeddable: IEmbeddable<any, any>;
getActions: UiActionsService['getTriggerCompatibleActions'];
@ -65,14 +57,12 @@ interface State {
hidePanelTitles: boolean;
closeContextMenu: boolean;
badges: Array<Action<EmbeddableContext>>;
eventCount?: number;
}
export class EmbeddablePanel extends React.Component<Props, State> {
private embeddableRoot: React.RefObject<HTMLDivElement>;
private parentSubscription?: Subscription;
private subscription?: Subscription;
private eventCountSubscription?: Subscription;
private mounted: boolean = false;
private generateId = htmlIdGenerator();
@ -146,9 +136,6 @@ export class EmbeddablePanel extends React.Component<Props, State> {
if (this.subscription) {
this.subscription.unsubscribe();
}
if (this.eventCountSubscription) {
this.eventCountSubscription.unsubscribe();
}
if (this.parentSubscription) {
this.parentSubscription.unsubscribe();
}
@ -190,7 +177,6 @@ export class EmbeddablePanel extends React.Component<Props, State> {
badges={this.state.badges}
embeddable={this.props.embeddable}
headerId={headerId}
eventCount={this.state.eventCount}
/>
)}
<div className="embPanel__content" ref={this.embeddableRoot} />
@ -202,15 +188,6 @@ export class EmbeddablePanel extends React.Component<Props, State> {
if (this.embeddableRoot.current) {
this.props.embeddable.render(this.embeddableRoot.current);
}
const dynamicActions = this.props.embeddable.dynamicActions;
if (dynamicActions) {
this.setState({ eventCount: dynamicActions.state.get().events.length });
this.eventCountSubscription = dynamicActions.state.state$.subscribe(({ events }) => {
if (!this.mounted) return;
this.setState({ eventCount: events.length });
});
}
}
closeMyContextMenuPanel = () => {
@ -224,14 +201,13 @@ export class EmbeddablePanel extends React.Component<Props, State> {
};
private getActionContextMenuPanel = async () => {
let regularActions = await this.props.getActions(CONTEXT_MENU_TRIGGER, {
let actions = await this.props.getActions(CONTEXT_MENU_TRIGGER, {
embeddable: this.props.embeddable,
});
const { disabledActions } = this.props.embeddable.getInput();
if (disabledActions) {
const removeDisabledActions = removeById(disabledActions);
regularActions = regularActions.filter(removeDisabledActions);
actions = actions.filter(action => disabledActions.indexOf(action.id) === -1);
}
const createGetUserData = (overlays: OverlayStart) =>
@ -270,10 +246,16 @@ export class EmbeddablePanel extends React.Component<Props, State> {
new EditPanelAction(this.props.getEmbeddableFactory),
];
const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField);
const sorted = actions
.concat(extraActions)
.sort((a: Action<EmbeddableContext>, b: Action<EmbeddableContext>) => {
const bOrder = b.order || 0;
const aOrder = a.order || 0;
return bOrder - aOrder;
});
return await buildContextMenuForActions({
actions: sortedActions,
actions: sorted,
actionContext: { embeddable: this.props.embeddable },
closeMenu: this.closeMyContextMenuPanel,
});

View file

@ -33,13 +33,15 @@ interface ActionContext {
export class CustomizePanelTitleAction implements Action<ActionContext> {
public readonly type = ACTION_CUSTOMIZE_PANEL;
public id = ACTION_CUSTOMIZE_PANEL;
public order = 40;
public order = 10;
constructor(private readonly getDataFromUser: GetUserData) {}
constructor(private readonly getDataFromUser: GetUserData) {
this.order = 10;
}
public getDisplayName() {
return i18n.translate('embeddableApi.customizePanel.action.displayName', {
defaultMessage: 'Edit panel title',
defaultMessage: 'Customize panel',
});
}

View file

@ -31,7 +31,7 @@ interface ActionContext {
export class InspectPanelAction implements Action<ActionContext> {
public readonly type = ACTION_INSPECT_PANEL;
public readonly id = ACTION_INSPECT_PANEL;
public order = 20;
public order = 10;
constructor(private readonly inspector: InspectorStartContract) {}

View file

@ -41,7 +41,7 @@ function hasExpandedPanelInput(
export class RemovePanelAction implements Action<ActionContext> {
public readonly type = REMOVE_PANEL_ACTION;
public readonly id = REMOVE_PANEL_ACTION;
public order = 1;
public order = 5;
constructor() {}

View file

@ -23,7 +23,6 @@ import {
EuiIcon,
EuiToolTip,
EuiScreenReaderOnly,
EuiNotificationBadge,
} from '@elastic/eui';
import classNames from 'classnames';
import React from 'react';
@ -41,7 +40,6 @@ export interface PanelHeaderProps {
badges: Array<Action<EmbeddableContext>>;
embeddable: IEmbeddable;
headerId?: string;
eventCount?: number;
}
function renderBadges(badges: Array<Action<EmbeddableContext>>, embeddable: IEmbeddable) {
@ -92,7 +90,6 @@ export function PanelHeader({
badges,
embeddable,
headerId,
eventCount,
}: PanelHeaderProps) {
const viewDescription = getViewDescription(embeddable);
const showTitle = !isViewMode || (title && !hidePanelTitles) || viewDescription !== '';
@ -150,11 +147,7 @@ export function PanelHeader({
)}
{renderBadges(badges, embeddable)}
</h2>
{!isViewMode && !!eventCount && (
<EuiNotificationBadge style={{ marginTop: '4px', marginRight: '4px' }}>
{eventCount}
</EuiNotificationBadge>
)}
<PanelOptionsMenu
isViewMode={isViewMode}
getActionContextMenuPanel={getActionContextMenuPanel}

View file

@ -24,31 +24,24 @@ import { Comparator, Connect, StateContainer, UnboxState } from './types';
const { useContext, useLayoutEffect, useRef, createElement: h } = React;
/**
* Returns the latest state of a state container.
*
* @param container State container which state to track.
*/
export const useContainerState = <Container extends StateContainer<any, any>>(
container: Container
): UnboxState<Container> => useObservable(container.state$, container.get());
export const createStateContainerReactHelpers = <Container extends StateContainer<any, any>>() => {
const context = React.createContext<Container>(null as any);
/**
* Apply selector to state container to extract only needed information. Will
* re-render your component only when the section changes.
*
* @param container State container which state to track.
* @param selector Function used to pick parts of state.
* @param comparator Comparator function used to memoize previous result, to not
* re-render React component if state did not change. By default uses
* `fast-deep-equal` package.
*/
export const useContainerSelector = <Container extends StateContainer<any, any>, Result>(
container: Container,
const useContainer = (): Container => useContext(context);
const useState = (): UnboxState<Container> => {
const { state$, get } = useContainer();
const value = useObservable(state$, get());
return value;
};
const useTransitions: () => Container['transitions'] = () => useContainer().transitions;
const useSelector = <Result>(
selector: (state: UnboxState<Container>) => Result,
comparator: Comparator<Result> = defaultComparator
): Result => {
const { state$, get } = container;
const { state$, get } = useContainer();
const lastValueRef = useRef<Result>(get());
const [value, setValue] = React.useState<Result>(() => {
const newValue = selector(get());
@ -68,26 +61,6 @@ export const useContainerSelector = <Container extends StateContainer<any, any>,
return value;
};
export const createStateContainerReactHelpers = <Container extends StateContainer<any, any>>() => {
const context = React.createContext<Container>(null as any);
const useContainer = (): Container => useContext(context);
const useState = (): UnboxState<Container> => {
const container = useContainer();
return useContainerState(container);
};
const useTransitions: () => Container['transitions'] = () => useContainer().transitions;
const useSelector = <Result>(
selector: (state: UnboxState<Container>) => Result,
comparator: Comparator<Result> = defaultComparator
): Result => {
const container = useContainer();
return useContainerSelector<Container, Result>(container, selector, comparator);
};
const connect: Connect<UnboxState<Container>> = mapStateToProp => component => props =>
h(component, { ...useSelector(mapStateToProp), ...props } as any);

View file

@ -43,7 +43,7 @@ export interface BaseStateContainer<State extends BaseState> {
export interface StateContainer<
State extends BaseState,
PureTransitions extends object = object,
PureTransitions extends object,
PureSelectors extends object = {}
> extends BaseStateContainer<State> {
transitions: Readonly<PureTransitionsToTransitions<PureTransitions>>;

View file

@ -1,20 +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.
*/
export { createStateContainer, StateContainer, of } from './common';

View file

@ -19,12 +19,10 @@
import { UiComponent } from 'src/plugins/kibana_utils/common';
import { ActionType, ActionContextMapping } from '../types';
import { Presentable } from '../util/presentable';
export type ActionByType<T extends ActionType> = Action<ActionContextMapping[T], T>;
export interface Action<Context extends {} = {}, T = ActionType>
extends Partial<Presentable<Context>> {
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.
@ -65,30 +63,12 @@ export interface Action<Context extends {} = {}, T = ActionType>
isCompatible(context: Context): Promise<boolean>;
/**
* Executes the action.
* If this returns something truthy, this is used in addition to the `execute` method when clicked.
*/
execute(context: Context): Promise<void>;
}
/**
* A convenience interface used to register an action.
*/
export interface ActionDefinition<Context extends object = object>
extends Partial<Presentable<Context>> {
/**
* ID of the action that uniquely identifies this action in the actions registry.
*/
readonly id: string;
/**
* ID of the factory for this action. Used to construct dynamic actions.
*/
readonly type?: ActionType;
getHref?(context: Context): string | undefined;
/**
* Executes the action.
*/
execute(context: Context): Promise<void>;
}
export type ActionContext<A> = A extends ActionDefinition<infer Context> ? Context : never;

View file

@ -18,46 +18,55 @@
*/
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;
/**
* Represents something that can be displayed to user in UI.
* A unique identifier for this action instance.
*/
export interface Presentable<Context extends object = object> {
/**
* ID that uniquely identifies this object.
*/
readonly id: string;
id?: string;
/**
* Determines the display order in relation to other items. Higher numbers are
* displayed first.
* The action type is what determines the context shape.
*/
readonly order: number;
/**
* `UiComponent` to render when displaying this entity as a context menu item.
* If not provided, `getDisplayName` will be used instead.
*/
readonly MenuItem?: UiComponent<{ context: Context }>;
readonly type: T;
/**
* Optional EUI icon type that can be displayed along with the title.
*/
getIconType(context: Context): string | undefined;
getIconType?(context: ActionContextMapping[T]): string;
/**
* Returns a title to be displayed to the user.
* @param context
*/
getDisplayName(context: Context): string;
getDisplayName?(context: ActionContextMapping[T]): string;
/**
* This method should return a link if this item can be clicked on.
* `UiComponent` to render when displaying this action as a context menu item.
* If not provided, `getDisplayName` will be used instead.
*/
getHref?(context: Context): string | undefined;
MenuItem?: UiComponent<{ context: ActionContextMapping[T] }>;
/**
* Returns a promise that resolves to true if this item is compatible given
* the context and should be displayed to user, otherwise resolves to false.
* Returns a promise that resolves to true if this action is compatible given the context,
* otherwise resolves to false.
*/
isCompatible(context: Context): Promise<boolean>;
isCompatible?(context: 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

@ -1,71 +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 { uiToReactComponent } from '../../../kibana_react/public';
import { Presentable } from '../util/presentable';
import { ActionDefinition } from './action';
import { ActionFactoryDefinition } from './action_factory_definition';
import { Configurable } from '../util';
import { SerializedAction } from './types';
export class ActionFactory<
Config extends object = object,
FactoryContext extends object = object,
ActionContext extends object = object
> implements Presentable<FactoryContext>, Configurable<Config, FactoryContext> {
constructor(
protected readonly def: ActionFactoryDefinition<Config, FactoryContext, ActionContext>
) {}
public readonly id = this.def.id;
public readonly order = this.def.order || 0;
public readonly MenuItem? = this.def.MenuItem;
public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined;
public readonly CollectConfig = this.def.CollectConfig;
public readonly ReactCollectConfig = uiToReactComponent(this.CollectConfig);
public readonly createConfig = this.def.createConfig;
public readonly isConfigValid = this.def.isConfigValid;
public getIconType(context: FactoryContext): string | undefined {
if (!this.def.getIconType) return undefined;
return this.def.getIconType(context);
}
public getDisplayName(context: FactoryContext): string {
if (!this.def.getDisplayName) return '';
return this.def.getDisplayName(context);
}
public async isCompatible(context: FactoryContext): Promise<boolean> {
if (!this.def.isCompatible) return true;
return await this.def.isCompatible(context);
}
public getHref(context: FactoryContext): string | undefined {
if (!this.def.getHref) return undefined;
return this.def.getHref(context);
}
public create(
serializedAction: Omit<SerializedAction<Config>, 'factoryId'>
): ActionDefinition<ActionContext> {
return this.def.create(serializedAction);
}
}

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 { ActionDefinition } from './action';
import { Presentable, Configurable } from '../util';
import { SerializedAction } from './types';
/**
* This is a convenience interface for registering new action factories.
*/
export interface ActionFactoryDefinition<
Config extends object = object,
FactoryContext extends object = object,
ActionContext extends object = object
> extends Partial<Presentable<FactoryContext>>, Configurable<Config, FactoryContext> {
/**
* Unique ID of the action factory. This ID is used to identify this action
* factory in the registry as well as to construct actions of this type and
* identify this action factory when presenting it to the user in UI.
*/
id: string;
/**
* This method should return a definition of a new action, normally used to
* register it in `ui_actions` registry.
*/
create(
serializedAction: Omit<SerializedAction<Config>, 'factoryId'>
): ActionDefinition<ActionContext>;
}

View file

@ -1,33 +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 { ActionDefinition } from './action';
import { ActionInternal } from './action_internal';
const defaultActionDef: ActionDefinition = {
id: 'test-action',
execute: jest.fn(),
};
describe('ActionInternal', () => {
test('can instantiate from action definition', () => {
const action = new ActionInternal(defaultActionDef);
expect(action.id).toBe('test-action');
});
});

View file

@ -1,58 +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 { Action, ActionContext as Context, ActionDefinition } from './action';
import { Presentable } from '../util/presentable';
import { uiToReactComponent } from '../../../kibana_react/public';
import { ActionType } from '../types';
export class ActionInternal<A extends ActionDefinition = ActionDefinition>
implements Action<Context<A>>, Presentable<Context<A>> {
constructor(public readonly definition: A) {}
public readonly id: string = this.definition.id;
public readonly type: ActionType = this.definition.type || '';
public readonly order: number = this.definition.order || 0;
public readonly MenuItem? = this.definition.MenuItem;
public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined;
public execute(context: Context<A>) {
return this.definition.execute(context);
}
public getIconType(context: Context<A>): string | undefined {
if (!this.definition.getIconType) return undefined;
return this.definition.getIconType(context);
}
public getDisplayName(context: Context<A>): string {
if (!this.definition.getDisplayName) return `Action: ${this.id}`;
return this.definition.getDisplayName(context);
}
public async isCompatible(context: Context<A>): Promise<boolean> {
if (!this.definition.isCompatible) return true;
return await this.definition.isCompatible(context);
}
public getHref(context: Context<A>): string | undefined {
if (!this.definition.getHref) return undefined;
return this.definition.getHref(context);
}
}

View file

@ -17,19 +17,11 @@
* under the License.
*/
import { ActionContextMapping } from '../types';
import { ActionByType } from './action';
import { ActionType } from '../types';
import { ActionDefinition } from './action';
import { ActionDefinition } from './action_definition';
interface ActionDefinitionByType<T extends ActionType>
extends Omit<ActionDefinition<ActionContextMapping[T]>, 'id'> {
id?: string;
}
export function createAction<T extends ActionType>(
action: ActionDefinitionByType<T>
): ActionByType<T> {
export function createAction<T extends ActionType>(action: ActionDefinition<T>): ActionByType<T> {
return {
getIconType: () => undefined,
order: 0,
@ -38,5 +30,5 @@ export function createAction<T extends ActionType>(
getDisplayName: () => '',
getHref: () => undefined,
...action,
} as ActionByType<T>;
};
}

View file

@ -1,646 +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 { DynamicActionManager } from './dynamic_action_manager';
import { ActionStorage, MemoryActionStorage, SerializedEvent } from './dynamic_action_storage';
import { UiActionsService } from '../service';
import { ActionFactoryDefinition } from './action_factory_definition';
import { ActionRegistry } from '../types';
import { SerializedAction } from './types';
import { of } from '../../../kibana_utils';
const actionFactoryDefinition1: ActionFactoryDefinition = {
id: 'ACTION_FACTORY_1',
CollectConfig: {} as any,
createConfig: () => ({}),
isConfigValid: (() => true) as any,
create: ({ name }) => ({
id: '',
execute: async () => {},
getDisplayName: () => name,
}),
};
const actionFactoryDefinition2: ActionFactoryDefinition = {
id: 'ACTION_FACTORY_2',
CollectConfig: {} as any,
createConfig: () => ({}),
isConfigValid: (() => true) as any,
create: ({ name }) => ({
id: '',
execute: async () => {},
getDisplayName: () => name,
}),
};
const event1: SerializedEvent = {
eventId: 'EVENT_ID_1',
triggers: ['VALUE_CLICK_TRIGGER'],
action: {
factoryId: actionFactoryDefinition1.id,
name: 'Action 1',
config: {},
},
};
const event2: SerializedEvent = {
eventId: 'EVENT_ID_2',
triggers: ['VALUE_CLICK_TRIGGER'],
action: {
factoryId: actionFactoryDefinition1.id,
name: 'Action 2',
config: {},
},
};
const event3: SerializedEvent = {
eventId: 'EVENT_ID_3',
triggers: ['VALUE_CLICK_TRIGGER'],
action: {
factoryId: actionFactoryDefinition2.id,
name: 'Action 3',
config: {},
},
};
const setup = (events: readonly SerializedEvent[] = []) => {
const isCompatible = async () => true;
const storage: ActionStorage = new MemoryActionStorage(events);
const actions: ActionRegistry = new Map();
const uiActions = new UiActionsService({
actions,
});
const manager = new DynamicActionManager({
isCompatible,
storage,
uiActions,
});
uiActions.registerTrigger({
id: 'VALUE_CLICK_TRIGGER',
});
return {
isCompatible,
actions,
storage,
uiActions,
manager,
};
};
describe('DynamicActionManager', () => {
test('can instantiate', () => {
const { manager } = setup([event1]);
expect(manager).toBeInstanceOf(DynamicActionManager);
});
describe('.start()', () => {
test('instantiates stored events', async () => {
const { manager, actions, uiActions } = setup([event1]);
const create1 = jest.fn();
const create2 = jest.fn();
uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 });
uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 });
expect(create1).toHaveBeenCalledTimes(0);
expect(create2).toHaveBeenCalledTimes(0);
expect(actions.size).toBe(0);
await manager.start();
expect(create1).toHaveBeenCalledTimes(1);
expect(create2).toHaveBeenCalledTimes(0);
expect(actions.size).toBe(1);
});
test('does nothing when no events stored', async () => {
const { manager, actions, uiActions } = setup();
const create1 = jest.fn();
const create2 = jest.fn();
uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 });
uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 });
expect(create1).toHaveBeenCalledTimes(0);
expect(create2).toHaveBeenCalledTimes(0);
expect(actions.size).toBe(0);
await manager.start();
expect(create1).toHaveBeenCalledTimes(0);
expect(create2).toHaveBeenCalledTimes(0);
expect(actions.size).toBe(0);
});
test('UI state is empty before manager starts', async () => {
const { manager } = setup([event1]);
expect(manager.state.get()).toMatchObject({
events: [],
isFetchingEvents: false,
fetchCount: 0,
});
});
test('loads events into UI state', async () => {
const { manager, uiActions } = setup([event1, event2, event3]);
uiActions.registerActionFactory(actionFactoryDefinition1);
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
expect(manager.state.get()).toMatchObject({
events: [event1, event2, event3],
isFetchingEvents: false,
fetchCount: 1,
});
});
test('sets isFetchingEvents to true while fetching events', async () => {
const { manager, uiActions } = setup([event1, event2, event3]);
uiActions.registerActionFactory(actionFactoryDefinition1);
uiActions.registerActionFactory(actionFactoryDefinition2);
const promise = manager.start().catch(() => {});
expect(manager.state.get().isFetchingEvents).toBe(true);
await promise;
expect(manager.state.get().isFetchingEvents).toBe(false);
});
test('throws if storage threw', async () => {
const { manager, storage } = setup([event1]);
storage.list = async () => {
throw new Error('baz');
};
const [, error] = await of(manager.start());
expect(error).toEqual(new Error('baz'));
});
test('sets UI state error if error happened during initial fetch', async () => {
const { manager, storage } = setup([event1]);
storage.list = async () => {
throw new Error('baz');
};
await of(manager.start());
expect(manager.state.get().fetchError!.message).toBe('baz');
});
});
describe('.stop()', () => {
test('removes events from UI actions registry', async () => {
const { manager, actions, uiActions } = setup([event1, event2]);
const create1 = jest.fn();
const create2 = jest.fn();
uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 });
uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 });
expect(actions.size).toBe(0);
await manager.start();
expect(actions.size).toBe(2);
await manager.stop();
expect(actions.size).toBe(0);
});
});
describe('.createEvent()', () => {
describe('when storage succeeds', () => {
test('stores new event in storage', async () => {
const { manager, storage, uiActions } = setup([]);
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
expect(await storage.count()).toBe(0);
await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']);
expect(await storage.count()).toBe(1);
const [event] = await storage.list();
expect(event).toMatchObject({
eventId: expect.any(String),
triggers: ['VALUE_CLICK_TRIGGER'],
action: {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
},
});
});
test('adds event to UI state', async () => {
const { manager, uiActions } = setup([]);
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(manager.state.get().events.length).toBe(0);
await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']);
expect(manager.state.get().events.length).toBe(1);
});
test('optimistically adds event to UI state', async () => {
const { manager, uiActions } = setup([]);
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(manager.state.get().events.length).toBe(0);
const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e);
expect(manager.state.get().events.length).toBe(1);
await promise;
expect(manager.state.get().events.length).toBe(1);
});
test('instantiates event in actions service', async () => {
const { manager, uiActions, actions } = setup([]);
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(actions.size).toBe(0);
await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']);
expect(actions.size).toBe(1);
});
});
describe('when storage fails', () => {
test('throws an error', async () => {
const { manager, storage, uiActions } = setup([]);
storage.create = async () => {
throw new Error('foo');
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
const [, error] = await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER']));
expect(error).toEqual(new Error('foo'));
});
test('does not add even to UI state', async () => {
const { manager, storage, uiActions } = setup([]);
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
storage.create = async () => {
throw new Error('foo');
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER']));
expect(manager.state.get().events.length).toBe(0);
});
test('optimistically adds event to UI state and then removes it', async () => {
const { manager, storage, uiActions } = setup([]);
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
storage.create = async () => {
throw new Error('foo');
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(manager.state.get().events.length).toBe(0);
const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e);
expect(manager.state.get().events.length).toBe(1);
await promise;
expect(manager.state.get().events.length).toBe(0);
});
test('does not instantiate event in actions service', async () => {
const { manager, storage, uiActions, actions } = setup([]);
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
storage.create = async () => {
throw new Error('foo');
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(actions.size).toBe(0);
await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER']));
expect(actions.size).toBe(0);
});
});
});
describe('.updateEvent()', () => {
describe('when storage succeeds', () => {
test('un-registers old event from ui actions service and registers the new one', async () => {
const { manager, actions, uiActions } = setup([event3]);
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
expect(actions.size).toBe(1);
const registeredAction1 = actions.values().next().value;
expect(registeredAction1.getDisplayName()).toBe('Action 3');
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']);
expect(actions.size).toBe(1);
const registeredAction2 = actions.values().next().value;
expect(registeredAction2.getDisplayName()).toBe('foo');
});
test('updates event in storage', async () => {
const { manager, storage, uiActions } = setup([event3]);
const storageUpdateSpy = jest.spyOn(storage, 'update');
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
expect(storageUpdateSpy).toHaveBeenCalledTimes(0);
await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']);
expect(storageUpdateSpy).toHaveBeenCalledTimes(1);
expect(storageUpdateSpy.mock.calls[0][0]).toMatchObject({
eventId: expect.any(String),
triggers: ['VALUE_CLICK_TRIGGER'],
action: {
factoryId: actionFactoryDefinition2.id,
},
});
});
test('updates event in UI state', async () => {
const { manager, uiActions } = setup([event3]);
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
expect(manager.state.get().events[0].action.name).toBe('Action 3');
await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']);
expect(manager.state.get().events[0].action.name).toBe('foo');
});
test('optimistically updates event in UI state', async () => {
const { manager, uiActions } = setup([event3]);
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
expect(manager.state.get().events[0].action.name).toBe('Action 3');
const promise = manager
.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])
.catch(e => e);
expect(manager.state.get().events[0].action.name).toBe('foo');
await promise;
});
});
describe('when storage fails', () => {
test('throws error', async () => {
const { manager, storage, uiActions } = setup([event3]);
storage.update = () => {
throw new Error('bar');
};
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
const [, error] = await of(
manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])
);
expect(error).toEqual(new Error('bar'));
});
test('keeps the old action in actions registry', async () => {
const { manager, storage, actions, uiActions } = setup([event3]);
storage.update = () => {
throw new Error('bar');
};
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
expect(actions.size).toBe(1);
const registeredAction1 = actions.values().next().value;
expect(registeredAction1.getDisplayName()).toBe('Action 3');
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']));
expect(actions.size).toBe(1);
const registeredAction2 = actions.values().next().value;
expect(registeredAction2.getDisplayName()).toBe('Action 3');
});
test('keeps old event in UI state', async () => {
const { manager, storage, uiActions } = setup([event3]);
storage.update = () => {
throw new Error('bar');
};
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
expect(manager.state.get().events[0].action.name).toBe('Action 3');
await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']));
expect(manager.state.get().events[0].action.name).toBe('Action 3');
});
});
});
describe('.deleteEvents()', () => {
describe('when storage succeeds', () => {
test('removes all actions from uiActions service', async () => {
const { manager, actions, uiActions } = setup([event2, event1]);
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(actions.size).toBe(2);
await manager.deleteEvents([event1.eventId, event2.eventId]);
expect(actions.size).toBe(0);
});
test('removes all events from storage', async () => {
const { manager, uiActions, storage } = setup([event2, event1]);
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(await storage.list()).toEqual([event2, event1]);
await manager.deleteEvents([event1.eventId, event2.eventId]);
expect(await storage.list()).toEqual([]);
});
test('removes all events from UI state', async () => {
const { manager, uiActions } = setup([event2, event1]);
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(manager.state.get().events).toEqual([event2, event1]);
await manager.deleteEvents([event1.eventId, event2.eventId]);
expect(manager.state.get().events).toEqual([]);
});
});
});
});

View file

@ -1,284 +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 { v4 as uuidv4 } from 'uuid';
import { Subscription } from 'rxjs';
import { ActionStorage, SerializedEvent } from './dynamic_action_storage';
import { UiActionsService } from '../service';
import { SerializedAction } from './types';
import { TriggerContextMapping } from '../types';
import { ActionDefinition } from './action';
import { defaultState, transitions, selectors, State } from './dynamic_action_manager_state';
import { StateContainer, createStateContainer } from '../../../kibana_utils';
const compareEvents = (
a: ReadonlyArray<{ eventId: string }>,
b: ReadonlyArray<{ eventId: string }>
) => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i].eventId !== b[i].eventId) return false;
return true;
};
export type DynamicActionManagerState = State;
export interface DynamicActionManagerParams {
storage: ActionStorage;
uiActions: Pick<
UiActionsService,
'registerAction' | 'attachAction' | 'unregisterAction' | 'detachAction' | 'getActionFactory'
>;
isCompatible: <C = unknown>(context: C) => Promise<boolean>;
}
export class DynamicActionManager {
static idPrefixCounter = 0;
private readonly idPrefix = `D_ACTION_${DynamicActionManager.idPrefixCounter++}_`;
private stopped: boolean = false;
private reloadSubscription?: Subscription;
/**
* UI State of the dynamic action manager.
*/
protected readonly ui = createStateContainer(defaultState, transitions, selectors);
constructor(protected readonly params: DynamicActionManagerParams) {}
protected getEvent(eventId: string): SerializedEvent {
const oldEvent = this.ui.selectors.getEvent(eventId);
if (!oldEvent) throw new Error(`Could not find event [eventId = ${eventId}].`);
return oldEvent;
}
/**
* We prefix action IDs with a unique `.idPrefix`, so we can render the
* same dashboard twice on the screen.
*/
protected generateActionId(eventId: string): string {
return this.idPrefix + eventId;
}
protected reviveAction(event: SerializedEvent) {
const { eventId, triggers, action } = event;
const { uiActions, isCompatible } = this.params;
const actionId = this.generateActionId(eventId);
const factory = uiActions.getActionFactory(event.action.factoryId);
const actionDefinition: ActionDefinition = {
...factory.create(action as SerializedAction<object>),
id: actionId,
isCompatible,
};
uiActions.registerAction(actionDefinition);
for (const trigger of triggers) uiActions.attachAction(trigger as any, actionId);
}
protected killAction({ eventId, triggers }: SerializedEvent) {
const { uiActions } = this.params;
const actionId = this.generateActionId(eventId);
for (const trigger of triggers) uiActions.detachAction(trigger as any, actionId);
uiActions.unregisterAction(actionId);
}
private syncId = 0;
/**
* This function is called every time stored events might have changed not by
* us. For example, when in edit mode on dashboard user presses "back" button
* in the browser, then contents of storage changes.
*/
private onSync = () => {
if (this.stopped) return;
(async () => {
const syncId = ++this.syncId;
const events = await this.params.storage.list();
if (this.stopped) return;
if (syncId !== this.syncId) return;
if (compareEvents(events, this.ui.get().events)) return;
for (const event of this.ui.get().events) this.killAction(event);
for (const event of events) this.reviveAction(event);
this.ui.transitions.finishFetching(events);
})().catch(error => {
/* eslint-disable */
console.log('Dynamic action manager storage reload failed.');
console.error(error);
/* eslint-enable */
});
};
// Public API: ---------------------------------------------------------------
/**
* Read-only state container of dynamic action manager. Use it to perform all
* *read* operations.
*/
public readonly state: StateContainer<State> = this.ui;
/**
* 1. Loads all events from @type {DynamicActionStorage} storage.
* 2. Creates actions for each event in `ui_actions` registry.
* 3. Adds events to UI state.
* 4. Does nothing if dynamic action manager was stopped or if event fetching
* is already taking place.
*/
public async start() {
if (this.stopped) return;
if (this.ui.get().isFetchingEvents) return;
this.ui.transitions.startFetching();
try {
const events = await this.params.storage.list();
for (const event of events) this.reviveAction(event);
this.ui.transitions.finishFetching(events);
} catch (error) {
this.ui.transitions.failFetching(error instanceof Error ? error : { message: String(error) });
throw error;
}
if (this.params.storage.reload$) {
this.reloadSubscription = this.params.storage.reload$.subscribe(this.onSync);
}
}
/**
* 1. Removes all events from `ui_actions` registry.
* 2. Puts dynamic action manager is stopped state.
*/
public async stop() {
this.stopped = true;
const events = await this.params.storage.list();
for (const event of events) {
this.killAction(event);
}
if (this.reloadSubscription) {
this.reloadSubscription.unsubscribe();
}
}
/**
* Creates a new event.
*
* 1. Stores event in @type {DynamicActionStorage} storage.
* 2. Optimistically adds it to UI state, and rolls back on failure.
* 3. Adds action to `ui_actions` registry.
*
* @param action Dynamic action for which to create an event.
* @param triggers List of triggers to which action should react.
*/
public async createEvent(
action: SerializedAction<unknown>,
triggers: Array<keyof TriggerContextMapping>
) {
const event: SerializedEvent = {
eventId: uuidv4(),
triggers,
action,
};
this.ui.transitions.addEvent(event);
try {
await this.params.storage.create(event);
this.reviveAction(event);
} catch (error) {
this.ui.transitions.removeEvent(event.eventId);
throw error;
}
}
/**
* Updates an existing event. Fails if event with given `eventId` does not
* exit.
*
* 1. Updates the event in @type {DynamicActionStorage} storage.
* 2. Optimistically replaces the old event by the new one in UI state, and
* rolls back on failure.
* 3. Replaces action in `ui_actions` registry with the new event.
*
*
* @param eventId ID of the event to replace.
* @param action New action for which to create the event.
* @param triggers List of triggers to which action should react.
*/
public async updateEvent(
eventId: string,
action: SerializedAction<unknown>,
triggers: Array<keyof TriggerContextMapping>
) {
const event: SerializedEvent = {
eventId,
triggers,
action,
};
const oldEvent = this.getEvent(eventId);
this.killAction(oldEvent);
this.reviveAction(event);
this.ui.transitions.replaceEvent(event);
try {
await this.params.storage.update(event);
} catch (error) {
this.killAction(event);
this.reviveAction(oldEvent);
this.ui.transitions.replaceEvent(oldEvent);
throw error;
}
}
/**
* Removes existing event. Throws if event does not exist.
*
* 1. Removes the event from @type {DynamicActionStorage} storage.
* 2. Optimistically removes event from UI state, and puts it back on failure.
* 3. Removes associated action from `ui_actions` registry.
*
* @param eventId ID of the event to remove.
*/
public async deleteEvent(eventId: string) {
const event = this.getEvent(eventId);
this.killAction(event);
this.ui.transitions.removeEvent(eventId);
try {
await this.params.storage.remove(eventId);
} catch (error) {
this.reviveAction(event);
this.ui.transitions.addEvent(event);
throw error;
}
}
/**
* Deletes multiple events at once.
*
* @param eventIds List of event IDs.
*/
public async deleteEvents(eventIds: string[]) {
await Promise.all(eventIds.map(this.deleteEvent.bind(this)));
}
}

View file

@ -1,111 +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 { SerializedEvent } from './dynamic_action_storage';
/**
* This interface represents the state of @type {DynamicActionManager} at any
* point in time.
*/
export interface State {
/**
* Whether dynamic action manager is currently in process of fetching events
* from storage.
*/
readonly isFetchingEvents: boolean;
/**
* Number of times event fetching has been completed.
*/
readonly fetchCount: number;
/**
* Error received last time when fetching events.
*/
readonly fetchError?: {
message: string;
};
/**
* List of all fetched events.
*/
readonly events: readonly SerializedEvent[];
}
export interface Transitions {
startFetching: (state: State) => () => State;
finishFetching: (state: State) => (events: SerializedEvent[]) => State;
failFetching: (state: State) => (error: { message: string }) => State;
addEvent: (state: State) => (event: SerializedEvent) => State;
removeEvent: (state: State) => (eventId: string) => State;
replaceEvent: (state: State) => (event: SerializedEvent) => State;
}
export interface Selectors {
getEvent: (state: State) => (eventId: string) => SerializedEvent | null;
}
export const defaultState: State = {
isFetchingEvents: false,
fetchCount: 0,
events: [],
};
export const transitions: Transitions = {
startFetching: state => () => ({ ...state, isFetchingEvents: true }),
finishFetching: state => events => ({
...state,
isFetchingEvents: false,
fetchCount: state.fetchCount + 1,
fetchError: undefined,
events,
}),
failFetching: state => ({ message }) => ({
...state,
isFetchingEvents: false,
fetchCount: state.fetchCount + 1,
fetchError: { message },
}),
addEvent: state => (event: SerializedEvent) => ({
...state,
events: [...state.events, event],
}),
removeEvent: state => (eventId: string) => ({
...state,
events: state.events ? state.events.filter(event => event.eventId !== eventId) : state.events,
}),
replaceEvent: state => event => {
const index = state.events.findIndex(({ eventId }) => eventId === event.eventId);
if (index === -1) return state;
return {
...state,
events: [...state.events.slice(0, index), event, ...state.events.slice(index + 1)],
};
},
};
export const selectors: Selectors = {
getEvent: state => eventId => state.events.find(event => event.eventId === eventId) || null,
};

View file

@ -1,102 +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.
*/
/* eslint-disable max-classes-per-file */
import { Observable, Subject } from 'rxjs';
import { SerializedAction } from './types';
/**
* Serialized representation of event-action pair, used to persist in storage.
*/
export interface SerializedEvent {
eventId: string;
triggers: string[];
action: SerializedAction<unknown>;
}
/**
* This CRUD interface needs to be implemented by dynamic action users if they
* want to persist the dynamic actions. It has a default implementation in
* Embeddables, however one can use the dynamic actions without Embeddables,
* in that case they have to implement this interface.
*/
export interface ActionStorage {
create(event: SerializedEvent): Promise<void>;
update(event: SerializedEvent): Promise<void>;
remove(eventId: string): Promise<void>;
read(eventId: string): Promise<SerializedEvent>;
count(): Promise<number>;
list(): Promise<SerializedEvent[]>;
/**
* Triggered every time events changed in storage and should be re-loaded.
*/
readonly reload$?: Observable<void>;
}
export abstract class AbstractActionStorage implements ActionStorage {
public readonly reload$: Observable<void> & Pick<Subject<void>, 'next'> = new Subject<void>();
public async count(): Promise<number> {
return (await this.list()).length;
}
public async read(eventId: string): Promise<SerializedEvent> {
const events = await this.list();
const event = events.find(ev => ev.eventId === eventId);
if (!event) throw new Error(`Event [eventId = ${eventId}] not found.`);
return event;
}
abstract create(event: SerializedEvent): Promise<void>;
abstract update(event: SerializedEvent): Promise<void>;
abstract remove(eventId: string): Promise<void>;
abstract list(): Promise<SerializedEvent[]>;
}
/**
* This is an in-memory implementation of ActionStorage. It is used in testing,
* but can also be used production code to store events in memory.
*/
export class MemoryActionStorage extends AbstractActionStorage {
constructor(public events: readonly SerializedEvent[] = []) {
super();
}
public async list() {
return this.events.map(event => ({ ...event }));
}
public async create(event: SerializedEvent) {
this.events = [...this.events, { ...event }];
}
public async update(event: SerializedEvent) {
const index = this.events.findIndex(({ eventId }) => eventId === event.eventId);
if (index < 0) throw new Error(`Event [eventId = ${event.eventId}] not found`);
this.events = [...this.events.slice(0, index), { ...event }, ...this.events.slice(index + 1)];
}
public async remove(eventId: string) {
const index = this.events.findIndex(ev => eventId === ev.eventId);
if (index < 0) throw new Error(`Event [eventId = ${eventId}] not found`);
this.events = [...this.events.slice(0, index), ...this.events.slice(index + 1)];
}
}

View file

@ -18,11 +18,5 @@
*/
export * from './action';
export * from './action_internal';
export * from './action_factory_definition';
export * from './action_factory';
export * from './create_action';
export * from './incompatible_action_error';
export * from './dynamic_action_storage';
export * from './dynamic_action_manager';
export * from './types';

View file

@ -1,24 +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.
*/
export interface SerializedAction<Config> {
readonly factoryId: string;
readonly name: string;
readonly config: Config;
}

View file

@ -24,25 +24,19 @@ import { i18n } from '@kbn/i18n';
import { uiToReactComponent } from '../../../kibana_react/public';
import { Action } from '../actions';
export const defaultTitle = i18n.translate('uiActions.actionPanel.title', {
defaultMessage: 'Options',
});
/**
* Transforms an array of Actions to the shape EuiContextMenuPanel expects.
*/
export async function buildContextMenuForActions<Context extends object>({
export async function buildContextMenuForActions<A>({
actions,
actionContext,
title = defaultTitle,
closeMenu,
}: {
actions: Array<Action<Context>>;
actionContext: Context;
title?: string;
actions: Array<Action<A>>;
actionContext: A;
closeMenu: () => void;
}): Promise<EuiContextMenuPanelDescriptor> {
const menuItems = await buildEuiContextMenuPanelItems<Context>({
const menuItems = await buildEuiContextMenuPanelItems<A>({
actions,
actionContext,
closeMenu,
@ -50,7 +44,9 @@ export async function buildContextMenuForActions<Context extends object>({
return {
id: 'mainMenu',
title,
title: i18n.translate('uiActions.actionPanel.title', {
defaultMessage: 'Options',
}),
items: menuItems,
};
}
@ -58,41 +54,49 @@ export async function buildContextMenuForActions<Context extends object>({
/**
* Transform an array of Actions into the shape needed to build an EUIContextMenu
*/
async function buildEuiContextMenuPanelItems<Context extends object>({
async function buildEuiContextMenuPanelItems<A>({
actions,
actionContext,
closeMenu,
}: {
actions: Array<Action<Context>>;
actionContext: Context;
actions: Array<Action<A>>;
actionContext: A;
closeMenu: () => void;
}) {
const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length);
const promises = actions.map(async (action, index) => {
const items: EuiContextMenuPanelItemDescriptor[] = [];
const promises = actions.map(async action => {
const isCompatible = await action.isCompatible(actionContext);
if (!isCompatible) {
return;
}
items[index] = convertPanelActionToContextMenuItem({
items.push(
convertPanelActionToContextMenuItem({
action,
actionContext,
closeMenu,
});
})
);
});
await Promise.all(promises);
return items.filter(Boolean);
return items;
}
function convertPanelActionToContextMenuItem<Context extends object>({
/**
*
* @param {ContextMenuAction} action
* @param {Embeddable} embeddable
* @return {EuiContextMenuPanelItemDescriptor}
*/
function convertPanelActionToContextMenuItem<A>({
action,
actionContext,
closeMenu,
}: {
action: Action<Context>;
actionContext: Context;
action: Action<A>;
actionContext: A;
closeMenu: () => void;
}): EuiContextMenuPanelItemDescriptor {
const menuPanelItem: EuiContextMenuPanelItemDescriptor = {
@ -111,12 +115,9 @@ function convertPanelActionToContextMenuItem<Context extends object>({
closeMenu();
};
if (action.getHref) {
const href = action.getHref(actionContext);
if (href) {
if (action.getHref && action.getHref(actionContext)) {
menuPanelItem.href = action.getHref(actionContext);
}
}
return menuPanelItem;
}

View file

@ -26,26 +26,8 @@ export function plugin(initializerContext: PluginInitializerContext) {
export { UiActionsSetup, UiActionsStart } from './plugin';
export { UiActionsServiceParams, UiActionsService } from './service';
export {
Action,
ActionDefinition as UiActionsActionDefinition,
ActionFactoryDefinition as UiActionsActionFactoryDefinition,
ActionInternal as UiActionsActionInternal,
ActionStorage as UiActionsActionStorage,
AbstractActionStorage as UiActionsAbstractActionStorage,
createAction,
DynamicActionManager,
DynamicActionManagerState,
IncompatibleActionError,
SerializedAction as UiActionsSerializedAction,
SerializedEvent as UiActionsSerializedEvent,
} from './actions';
export { Action, createAction, IncompatibleActionError } from './actions';
export { buildContextMenuForActions } from './context_menu';
export {
Presentable as UiActionsPresentable,
Configurable as UiActionsConfigurable,
CollectConfigProps as UiActionsCollectConfigProps,
} from './util';
export {
Trigger,
TriggerContext,
@ -57,4 +39,4 @@ export {
applyFilterTrigger,
} from './triggers';
export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types';
export { ActionByType, DynamicActionManager as UiActionsDynamicActionManager } from './actions';
export { ActionByType } from './actions';

View file

@ -28,13 +28,10 @@ export type Start = jest.Mocked<UiActionsStart>;
const createSetupContract = (): Setup => {
const setupContract: Setup = {
addTriggerAction: jest.fn(),
attachAction: jest.fn(),
detachAction: jest.fn(),
registerAction: jest.fn(),
registerActionFactory: jest.fn(),
registerTrigger: jest.fn(),
unregisterAction: jest.fn(),
};
return setupContract;
};
@ -42,21 +39,16 @@ const createSetupContract = (): Setup => {
const createStartContract = (): Start => {
const startContract: Start = {
attachAction: jest.fn(),
unregisterAction: jest.fn(),
addTriggerAction: jest.fn(),
clear: jest.fn(),
registerAction: jest.fn(),
registerTrigger: jest.fn(),
getAction: jest.fn(),
detachAction: jest.fn(),
executeTriggerActions: jest.fn(),
fork: jest.fn(),
getAction: jest.fn(),
getActionFactories: jest.fn(),
getActionFactory: jest.fn(),
getTrigger: jest.fn(),
getTriggerActions: jest.fn((id: TriggerId) => []),
getTriggerCompatibleActions: jest.fn(),
registerAction: jest.fn(),
registerActionFactory: jest.fn(),
registerTrigger: jest.fn(),
clear: jest.fn(),
fork: jest.fn(),
};
return startContract;

View file

@ -23,13 +23,7 @@ import { selectRangeTrigger, valueClickTrigger, applyFilterTrigger } from './tri
export type UiActionsSetup = Pick<
UiActionsService,
| 'addTriggerAction'
| 'attachAction'
| 'detachAction'
| 'registerAction'
| 'registerActionFactory'
| 'registerTrigger'
| 'unregisterAction'
'attachAction' | 'detachAction' | 'registerAction' | 'registerTrigger'
>;
export type UiActionsStart = PublicMethodsOf<UiActionsService>;

View file

@ -18,13 +18,7 @@
*/
import { UiActionsService } from './ui_actions_service';
import {
Action,
ActionInternal,
createAction,
ActionFactoryDefinition,
ActionFactory,
} from '../actions';
import { Action, createAction } from '../actions';
import { createHelloWorldAction } from '../tests/test_samples';
import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types';
import { Trigger } from '../triggers';
@ -108,21 +102,6 @@ describe('UiActionsService', () => {
type: 'test' as ActionType,
});
});
test('return action instance', () => {
const service = new UiActionsService();
const action = service.registerAction({
id: 'test',
execute: async () => {},
getDisplayName: () => 'test',
getIconType: () => '',
isCompatible: async () => true,
type: 'test' as ActionType,
});
expect(action).toBeInstanceOf(ActionInternal);
expect(action.id).toBe('test');
});
});
describe('.getTriggerActions()', () => {
@ -160,14 +139,13 @@ describe('UiActionsService', () => {
expect(list0).toHaveLength(0);
service.addTriggerAction(FOO_TRIGGER, action1);
service.attachAction(FOO_TRIGGER, action1);
const list1 = service.getTriggerActions(FOO_TRIGGER);
expect(list1).toHaveLength(1);
expect(list1[0]).toBeInstanceOf(ActionInternal);
expect(list1[0].id).toBe(action1.id);
expect(list1).toEqual([action1]);
service.addTriggerAction(FOO_TRIGGER, action2);
service.attachAction(FOO_TRIGGER, action2);
const list2 = service.getTriggerActions(FOO_TRIGGER);
expect(list2).toHaveLength(2);
@ -186,7 +164,7 @@ describe('UiActionsService', () => {
service.registerAction(helloWorldAction);
expect(actions.size - length).toBe(1);
expect(actions.get(helloWorldAction.id)!.id).toBe(helloWorldAction.id);
expect(actions.get(helloWorldAction.id)).toBe(helloWorldAction);
});
test('getTriggerCompatibleActions returns attached actions', async () => {
@ -200,7 +178,7 @@ describe('UiActionsService', () => {
title: 'My trigger',
};
service.registerTrigger(testTrigger);
service.addTriggerAction(MY_TRIGGER, helloWorldAction);
service.attachAction(MY_TRIGGER, helloWorldAction);
const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, {
hi: 'there',
@ -226,7 +204,7 @@ describe('UiActionsService', () => {
};
service.registerTrigger(testTrigger);
service.addTriggerAction(testTrigger.id, action);
service.attachAction(testTrigger.id, action);
const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, {
accept: true,
@ -310,7 +288,7 @@ describe('UiActionsService', () => {
id: FOO_TRIGGER,
});
service1.registerAction(testAction1);
service1.addTriggerAction(FOO_TRIGGER, testAction1);
service1.attachAction(FOO_TRIGGER, testAction1);
const service2 = service1.fork();
@ -331,14 +309,14 @@ describe('UiActionsService', () => {
});
service1.registerAction(testAction1);
service1.registerAction(testAction2);
service1.addTriggerAction(FOO_TRIGGER, testAction1);
service1.attachAction(FOO_TRIGGER, testAction1);
const service2 = service1.fork();
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
service2.addTriggerAction(FOO_TRIGGER, testAction2);
service2.attachAction(FOO_TRIGGER, testAction2);
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
@ -352,14 +330,14 @@ describe('UiActionsService', () => {
});
service1.registerAction(testAction1);
service1.registerAction(testAction2);
service1.addTriggerAction(FOO_TRIGGER, testAction1);
service1.attachAction(FOO_TRIGGER, testAction1);
const service2 = service1.fork();
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
service1.addTriggerAction(FOO_TRIGGER, testAction2);
service1.attachAction(FOO_TRIGGER, testAction2);
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
@ -414,7 +392,7 @@ describe('UiActionsService', () => {
} as any;
service.registerTrigger(trigger);
service.addTriggerAction(MY_TRIGGER, action);
service.attachAction(MY_TRIGGER, action);
const actions = service.getTriggerActions(trigger.id);
@ -422,7 +400,7 @@ describe('UiActionsService', () => {
expect(actions[0].id).toBe(ACTION_HELLO_WORLD);
});
test('can detach an action from a trigger', () => {
test('can detach an action to a trigger', () => {
const service = new UiActionsService();
const trigger: Trigger = {
@ -435,7 +413,7 @@ describe('UiActionsService', () => {
service.registerTrigger(trigger);
service.registerAction(action);
service.addTriggerAction(trigger.id, action);
service.attachAction(trigger.id, action);
service.detachAction(trigger.id, action.id);
const actions2 = service.getTriggerActions(trigger.id);
@ -467,7 +445,7 @@ describe('UiActionsService', () => {
} as any;
service.registerAction(action);
expect(() => service.addTriggerAction('i do not exist' as TriggerId, action)).toThrowError(
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].'
);
});
@ -497,64 +475,4 @@ describe('UiActionsService', () => {
);
});
});
describe('action factories', () => {
const factoryDefinition1: ActionFactoryDefinition = {
id: 'test-factory-1',
CollectConfig: {} as any,
createConfig: () => ({}),
isConfigValid: () => true,
create: () => ({} as any),
};
const factoryDefinition2: ActionFactoryDefinition = {
id: 'test-factory-2',
CollectConfig: {} as any,
createConfig: () => ({}),
isConfigValid: () => true,
create: () => ({} as any),
};
test('.getActionFactories() returns empty array if no action factories registered', () => {
const service = new UiActionsService();
const factories = service.getActionFactories();
expect(factories).toEqual([]);
});
test('can register and retrieve an action factory', () => {
const service = new UiActionsService();
service.registerActionFactory(factoryDefinition1);
const factory = service.getActionFactory(factoryDefinition1.id);
expect(factory).toBeInstanceOf(ActionFactory);
expect(factory.id).toBe(factoryDefinition1.id);
});
test('can retrieve all action factories', () => {
const service = new UiActionsService();
service.registerActionFactory(factoryDefinition1);
service.registerActionFactory(factoryDefinition2);
const factories = service.getActionFactories();
const factoriesSorted = [...factories].sort((f1, f2) => (f1.id > f2.id ? 1 : -1));
expect(factoriesSorted.length).toBe(2);
expect(factoriesSorted[0].id).toBe(factoryDefinition1.id);
expect(factoriesSorted[1].id).toBe(factoryDefinition2.id);
});
test('throws when retrieving action factory that does not exist', () => {
const service = new UiActionsService();
service.registerActionFactory(factoryDefinition1);
expect(() => service.getActionFactory('UNKNOWN_ID')).toThrowError(
'Action factory [actionFactoryId = UNKNOWN_ID] does not exist.'
);
});
});
});

View file

@ -24,17 +24,8 @@ import {
TriggerId,
TriggerContextMapping,
ActionType,
ActionFactoryRegistry,
} from '../types';
import {
ActionInternal,
Action,
ActionByType,
ActionFactory,
ActionDefinition,
ActionFactoryDefinition,
ActionContext,
} 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';
@ -47,25 +38,21 @@ export interface UiActionsServiceParams {
* A 1-to-N mapping from `Trigger` to zero or more `Action`.
*/
readonly triggerToActions?: TriggerToActionsRegistry;
readonly actionFactories?: ActionFactoryRegistry;
}
export class UiActionsService {
protected readonly triggers: TriggerRegistry;
protected readonly actions: ActionRegistry;
protected readonly triggerToActions: TriggerToActionsRegistry;
protected readonly actionFactories: ActionFactoryRegistry;
constructor({
triggers = new Map(),
actions = new Map(),
triggerToActions = new Map(),
actionFactories = new Map(),
}: UiActionsServiceParams = {}) {
this.triggers = triggers;
this.actions = actions;
this.triggerToActions = triggerToActions;
this.actionFactories = actionFactories;
}
public readonly registerTrigger = (trigger: Trigger) => {
@ -89,44 +76,49 @@ export class UiActionsService {
return trigger.contract;
};
public readonly registerAction = <A extends ActionDefinition>(
definition: A
): ActionInternal<A> => {
if (this.actions.has(definition.id)) {
throw new Error(`Action [action.id = ${definition.id}] already registered.`);
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.`);
}
const action = new ActionInternal(definition);
this.actions.set(action.id, action);
return action;
};
public readonly unregisterAction = (actionId: string): void => {
if (!this.actions.has(actionId)) {
throw new Error(`Action [action.id = ${actionId}] is not registered.`);
public readonly getAction = <T extends ActionType>(id: string): ActionByType<T> => {
if (!this.actions.has(id)) {
throw new Error(`Action [action.id = ${id}] not registered.`);
}
this.actions.delete(actionId);
return this.actions.get(id) as ActionByType<T>;
};
public readonly attachAction = <TriggerId extends keyof TriggerContextMapping>(
triggerId: TriggerId,
actionId: string
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]);
}
};
@ -147,26 +139,6 @@ export class UiActionsService {
);
};
public readonly addTriggerAction = <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);
this.attachAction(triggerId, action.id);
};
public readonly getAction = <T extends ActionDefinition>(
id: string
): Action<ActionContext<T>> => {
if (!this.actions.has(id)) {
throw new Error(`Action [action.id = ${id}] not registered.`);
}
return this.actions.get(id) as ActionInternal<T>;
};
public readonly getTriggerActions = <T extends TriggerId>(
triggerId: T
): Array<Action<TriggerContextMapping[T]>> => {
@ -175,9 +147,9 @@ export class UiActionsService {
const actionIds = this.triggerToActions.get(triggerId);
const actions = actionIds!
.map(actionId => this.actions.get(actionId) as ActionInternal)
.filter(Boolean);
const actions = actionIds!.map(actionId => this.actions.get(actionId)).filter(Boolean) as Array<
Action<TriggerContextMapping[T]>
>;
return actions as Array<Action<TriggerContext<T>>>;
};
@ -215,7 +187,6 @@ export class UiActionsService {
this.actions.clear();
this.triggers.clear();
this.triggerToActions.clear();
this.actionFactories.clear();
};
/**
@ -235,41 +206,4 @@ export class UiActionsService {
return new UiActionsService({ triggers, actions, triggerToActions });
};
/**
* Register an action factory. Action factories are used to configure and
* serialize/deserialize dynamic actions.
*/
public readonly registerActionFactory = <
Config extends object = object,
FactoryContext extends object = object,
ActionContext extends object = object
>(
definition: ActionFactoryDefinition<Config, FactoryContext, ActionContext>
) => {
if (this.actionFactories.has(definition.id)) {
throw new Error(`ActionFactory [actionFactory.id = ${definition.id}] already registered.`);
}
const actionFactory = new ActionFactory<Config, FactoryContext, ActionContext>(definition);
this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory<any, any, any>);
};
public readonly getActionFactory = (actionFactoryId: string): ActionFactory => {
const actionFactory = this.actionFactories.get(actionFactoryId);
if (!actionFactory) {
throw new Error(`Action factory [actionFactoryId = ${actionFactoryId}] does not exist.`);
}
return actionFactory;
};
/**
* Returns an array of all action factories.
*/
public readonly getActionFactories = (): ActionFactory[] => {
return [...this.actionFactories.values()];
};
}

View file

@ -69,7 +69,7 @@ test('executes a single action mapped to a trigger', async () => {
const action = createTestAction('test1', () => true);
setup.registerTrigger(trigger);
setup.addTriggerAction(trigger.id, action);
setup.attachAction(trigger.id, action);
const context = {};
const start = doStart();
@ -109,7 +109,7 @@ test('does not execute an incompatible action', async () => {
);
setup.registerTrigger(trigger);
setup.addTriggerAction(trigger.id, action);
setup.attachAction(trigger.id, action);
const start = doStart();
const context = {
@ -130,8 +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.addTriggerAction(trigger.id, action1);
setup.addTriggerAction(trigger.id, action2);
setup.attachAction(trigger.id, action1);
setup.attachAction(trigger.id, action2);
expect(openContextMenu).toHaveBeenCalledTimes(0);
@ -155,7 +155,7 @@ test('passes whole action context to isCompatible()', async () => {
});
setup.registerTrigger(trigger);
setup.addTriggerAction(trigger.id, action);
setup.attachAction(trigger.id, action);
const start = doStart();

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { ActionInternal, Action } from '../actions';
import { Action } from '../actions';
import { uiActionsPluginMock } from '../mocks';
import { TriggerId, ActionType } from '../types';
@ -47,14 +47,13 @@ test('returns actions set on trigger', () => {
expect(list0).toHaveLength(0);
setup.addTriggerAction('trigger' as TriggerId, action1);
setup.attachAction('trigger' as TriggerId, action1);
const list1 = start.getTriggerActions('trigger' as TriggerId);
expect(list1).toHaveLength(1);
expect(list1[0]).toBeInstanceOf(ActionInternal);
expect(list1[0].id).toBe(action1.id);
expect(list1).toEqual([action1]);
setup.addTriggerAction('trigger' as TriggerId, action2);
setup.attachAction('trigger' as TriggerId, action2);
const list2 = start.getTriggerActions('trigger' as TriggerId);
expect(list2).toHaveLength(2);

View file

@ -37,7 +37,7 @@ beforeEach(() => {
id: 'trigger' as TriggerId,
title: 'trigger',
});
uiActions.setup.addTriggerAction('trigger' as TriggerId, action);
uiActions.setup.attachAction('trigger' as TriggerId, action);
});
test('can register action', async () => {
@ -58,7 +58,7 @@ test('getTriggerCompatibleActions returns attached actions', async () => {
title: 'My trigger',
};
setup.registerTrigger(testTrigger);
setup.addTriggerAction('MY-TRIGGER' as TriggerId, helloWorldAction);
setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction);
const start = doStart();
const actions = await start.getTriggerCompatibleActions('MY-TRIGGER' as TriggerId, {});
@ -84,7 +84,7 @@ test('filters out actions not applicable based on the context', async () => {
setup.registerTrigger(testTrigger);
setup.registerAction(action1);
setup.addTriggerAction(testTrigger.id, action1);
setup.attachAction(testTrigger.id, action1);
const start = doStart();
let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true });

View file

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

View file

@ -22,6 +22,6 @@ import { Trigger } from '.';
export const SELECT_RANGE_TRIGGER = 'SELECT_RANGE_TRIGGER';
export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = {
id: SELECT_RANGE_TRIGGER,
title: '',
title: 'Select range',
description: 'Applies a range filter',
};

View file

@ -72,7 +72,6 @@ export class TriggerInternal<T extends TriggerId> {
const panel = await buildContextMenuForActions({
actions,
actionContext: context,
title: this.trigger.title,
closeMenu: () => session.close(),
});
const session = openContextMenu([panel]);

View file

@ -22,6 +22,6 @@ import { Trigger } from '.';
export const VALUE_CLICK_TRIGGER = 'VALUE_CLICK_TRIGGER';
export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = {
id: VALUE_CLICK_TRIGGER,
title: '',
title: 'Value clicked',
description: 'Value was clicked',
};

View file

@ -17,17 +17,15 @@
* under the License.
*/
import { ActionInternal } from './actions/action_internal';
import { ActionByType } from './actions/action';
import { TriggerInternal } from './triggers/trigger_internal';
import { ActionFactory } from './actions';
import { EmbeddableVisTriggerContext, IEmbeddable } from '../../embeddable/public';
import { Filter } from '../../data/public';
import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers';
export type TriggerRegistry = Map<TriggerId, TriggerInternal<any>>;
export type ActionRegistry = Map<string, ActionInternal>;
export type ActionRegistry = Map<string, ActionByType<any>>;
export type TriggerToActionsRegistry = Map<TriggerId, string[]>;
export type ActionFactoryRegistry = Map<string, ActionFactory>;
const DEFAULT_TRIGGER = '';

View file

@ -1,60 +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 { UiComponent } from 'src/plugins/kibana_utils/common';
/**
* Represents something that can be configured by user using UI.
*/
export interface Configurable<Config extends object = object, Context = object> {
/**
* Create default config for this item, used when item is created for the first time.
*/
readonly createConfig: () => Config;
/**
* Is this config valid. Used to validate user's input before saving.
*/
readonly isConfigValid: (config: Config) => boolean;
/**
* `UiComponent` to be rendered when collecting configuration for this item.
*/
readonly CollectConfig: UiComponent<CollectConfigProps<Config, Context>>;
}
/**
* Props provided to `CollectConfig` component on every re-render.
*/
export interface CollectConfigProps<Config extends object = object, Context = object> {
/**
* Current (latest) config of the item.
*/
config: Config;
/**
* Callback called when user updates the config in UI.
*/
onConfig: (config: Config) => void;
/**
* Context information about where component is being rendered.
*/
context: Context;
}

View file

@ -1,21 +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.
*/
export * from './presentable';
export * from './configurable';

View file

@ -1,26 +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 { join } from 'path';
// eslint-disable-next-line
require('@kbn/storybook').runStorybookCli({
name: 'ui_actions',
storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')],
});

View file

@ -70,10 +70,11 @@ export class EmbeddableExplorerPublicPlugin
const sayHelloAction = new SayHelloAction(alert);
const sendMessageAction = createSendMessageAction(core.overlays);
plugins.uiActions.registerAction(helloWorldAction);
plugins.uiActions.registerAction(sayHelloAction);
plugins.uiActions.registerAction(sendMessageAction);
plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, helloWorldAction);
plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction);
plugins.__LEGACY.onRenderComplete(() => {
const root = document.getElementById(REACT_ROOT_ID);

View file

@ -62,4 +62,5 @@ function createSamplePanelAction() {
}
const action = createSamplePanelAction();
npSetup.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action);
npSetup.plugins.uiActions.registerAction(action);
npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action);

View file

@ -33,4 +33,5 @@ export const createSamplePanelLink = (): Action =>
});
const action = createSamplePanelLink();
npStart.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action);
npStart.plugins.uiActions.registerAction(action);
npStart.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action);

View file

@ -9,7 +9,6 @@
"xpack.beatsManagement": "legacy/plugins/beats_management",
"xpack.canvas": "legacy/plugins/canvas",
"xpack.crossClusterReplication": "legacy/plugins/cross_cluster_replication",
"xpack.dashboard": "plugins/dashboard_enhanced",
"xpack.dashboardMode": "legacy/plugins/dashboard_mode",
"xpack.data": "plugins/data_enhanced",
"xpack.drilldowns": "plugins/drilldowns",

View file

@ -1,3 +1,8 @@
.auaActionWizard__selectedActionFactoryContainer {
background-color: $euiColorLightestShade;
padding: $euiSize;
}
.auaActionWizard__actionFactoryItem {
.euiKeyPadMenuItem__label {
height: #{$euiSizeXL};

View file

@ -6,26 +6,28 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { Demo, dashboardFactory, urlFactory } from './test_data';
import { dashboardDrilldownActionFactory, Demo, urlDrilldownActionFactory } from './test_data';
storiesOf('components/ActionWizard', module)
.add('default', () => <Demo actionFactories={[dashboardFactory, urlFactory]} />)
.add('default', () => (
<Demo actionFactories={[dashboardDrilldownActionFactory, urlDrilldownActionFactory]} />
))
.add('Only one factory is available', () => (
// to make sure layout doesn't break
<Demo actionFactories={[dashboardFactory]} />
<Demo actionFactories={[dashboardDrilldownActionFactory]} />
))
.add('Long list of action factories', () => (
// to make sure layout doesn't break
<Demo
actionFactories={[
dashboardFactory,
urlFactory,
dashboardFactory,
urlFactory,
dashboardFactory,
urlFactory,
dashboardFactory,
urlFactory,
dashboardDrilldownActionFactory,
urlDrilldownActionFactory,
dashboardDrilldownActionFactory,
urlDrilldownActionFactory,
dashboardDrilldownActionFactory,
urlDrilldownActionFactory,
dashboardDrilldownActionFactory,
urlDrilldownActionFactory,
]}
/>
));

View file

@ -8,14 +8,21 @@ import React from 'react';
import { cleanup, fireEvent, render } from '@testing-library/react/pure';
import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global
import { TEST_SUBJ_ACTION_FACTORY_ITEM, TEST_SUBJ_SELECTED_ACTION_FACTORY } from './action_wizard';
import { dashboardFactory, dashboards, Demo, urlFactory } from './test_data';
import {
dashboardDrilldownActionFactory,
dashboards,
Demo,
urlDrilldownActionFactory,
} from './test_data';
// TODO: afterEach is not available for it globally during setup
// https://github.com/elastic/kibana/issues/59469
afterEach(cleanup);
test('Pick and configure action', () => {
const screen = render(<Demo actionFactories={[dashboardFactory, urlFactory]} />);
const screen = render(
<Demo actionFactories={[dashboardDrilldownActionFactory, urlDrilldownActionFactory]} />
);
// check that all factories are displayed to pick
expect(screen.getAllByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).toHaveLength(2);
@ -40,7 +47,7 @@ test('Pick and configure action', () => {
});
test('If only one actions factory is available then actionFactory selection is emitted without user input', () => {
const screen = render(<Demo actionFactories={[urlFactory]} />);
const screen = render(<Demo actionFactories={[urlDrilldownActionFactory]} />);
// check that no factories are displayed to pick from
expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument();

View file

@ -16,23 +16,40 @@ import {
} from '@elastic/eui';
import { txtChangeButton } from './i18n';
import './action_wizard.scss';
import { ActionFactory } from '../../services';
type ActionBaseConfig = object;
type ActionFactoryBaseContext = object;
// TODO: this interface is temporary for just moving forward with the component
// and it will be imported from the ../ui_actions when implemented properly
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type ActionBaseConfig = {};
export interface ActionFactory<Config extends ActionBaseConfig = ActionBaseConfig> {
type: string; // TODO: type should be tied to Action and ActionByType
displayName: string;
iconType?: string;
wizard: React.FC<ActionFactoryWizardProps<Config>>;
createConfig: () => Config;
isValid: (config: Config) => boolean;
}
export interface ActionFactoryWizardProps<Config extends ActionBaseConfig> {
config?: Config;
/**
* Callback called when user updates the config in UI.
*/
onConfig: (config: Config) => void;
}
export interface ActionWizardProps {
/**
* List of available action factories
*/
actionFactories: ActionFactory[];
actionFactories: Array<ActionFactory<any>>; // any here to be able to pass array of ActionFactory<Config> with different configs
/**
* Currently selected action factory
* undefined - is allowed and means that non is selected
*/
currentActionFactory?: ActionFactory;
/**
* Action factory selected changed
* null - means user click "change" and removed action factory selection
@ -48,11 +65,6 @@ export interface ActionWizardProps {
* config changed
*/
onConfigChange: (config: ActionBaseConfig) => void;
/**
* Context will be passed into ActionFactory's methods
*/
context: ActionFactoryBaseContext;
}
export const ActionWizard: React.FC<ActionWizardProps> = ({
@ -61,7 +73,6 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
onActionFactoryChange,
onConfigChange,
config,
context,
}) => {
// auto pick action factory if there is only 1 available
if (!currentActionFactory && actionFactories.length === 1) {
@ -76,7 +87,6 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
onDeselect={() => {
onActionFactoryChange(null);
}}
context={context}
config={config}
onConfigChange={newConfig => {
onConfigChange(newConfig);
@ -87,7 +97,6 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
return (
<ActionFactorySelector
context={context}
actionFactories={actionFactories}
onActionFactorySelected={actionFactory => {
onActionFactoryChange(actionFactory);
@ -96,11 +105,10 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
);
};
interface SelectedActionFactoryProps {
actionFactory: ActionFactory;
config: ActionBaseConfig;
context: ActionFactoryBaseContext;
onConfigChange: (config: ActionBaseConfig) => void;
interface SelectedActionFactoryProps<Config extends ActionBaseConfig = ActionBaseConfig> {
actionFactory: ActionFactory<Config>;
config: Config;
onConfigChange: (config: Config) => void;
showDeselect: boolean;
onDeselect: () => void;
}
@ -113,28 +121,28 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
showDeselect,
onConfigChange,
config,
context,
}) => {
return (
<div
className="auaActionWizard__selectedActionFactoryContainer"
data-test-subj={TEST_SUBJ_SELECTED_ACTION_FACTORY}
data-testid={TEST_SUBJ_SELECTED_ACTION_FACTORY}
>
<header>
<EuiFlexGroup alignItems="center" gutterSize="s">
{actionFactory.getIconType(context) && (
{actionFactory.iconType && (
<EuiFlexItem grow={false}>
<EuiIcon type={actionFactory.getIconType(context)!} size="m" />
<EuiIcon type={actionFactory.iconType} size="m" />
</EuiFlexItem>
)}
<EuiFlexItem grow={true}>
<EuiText>
<h4>{actionFactory.getDisplayName(context)}</h4>
<h4>{actionFactory.displayName}</h4>
</EuiText>
</EuiFlexItem>
{showDeselect && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="xs" onClick={() => onDeselect()}>
<EuiButtonEmpty size="s" onClick={() => onDeselect()}>
{txtChangeButton}
</EuiButtonEmpty>
</EuiFlexItem>
@ -143,11 +151,10 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
</header>
<EuiSpacer size="m" />
<div>
<actionFactory.ReactCollectConfig
config={config}
onConfig={onConfigChange}
context={context}
/>
{actionFactory.wizard({
config,
onConfig: onConfigChange,
})}
</div>
</div>
);
@ -155,7 +162,6 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
interface ActionFactorySelectorProps {
actionFactories: ActionFactory[];
context: ActionFactoryBaseContext;
onActionFactorySelected: (actionFactory: ActionFactory) => void;
}
@ -164,7 +170,6 @@ export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'action-factory-item';
const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({
actionFactories,
onActionFactorySelected,
context,
}) => {
if (actionFactories.length === 0) {
// this is not user facing, as it would be impossible to get into this state
@ -173,22 +178,18 @@ const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({
}
return (
<EuiFlexGroup gutterSize="m" wrap={true}>
{[...actionFactories]
.sort((f1, f2) => f1.order - f2.order)
.map(actionFactory => (
<EuiFlexItem grow={false} key={actionFactory.id}>
<EuiFlexGroup wrap>
{actionFactories.map(actionFactory => (
<EuiKeyPadMenuItemButton
className="auaActionWizard__actionFactoryItem"
label={actionFactory.getDisplayName(context)}
key={actionFactory.type}
label={actionFactory.displayName}
data-testid={TEST_SUBJ_ACTION_FACTORY_ITEM}
data-test-subj={TEST_SUBJ_ACTION_FACTORY_ITEM}
onClick={() => onActionFactorySelected(actionFactory)}
>
{actionFactory.getIconType(context) && (
<EuiIcon type={actionFactory.getIconType(context)!} size="m" />
)}
{actionFactory.iconType && <EuiIcon type={actionFactory.iconType} size="m" />}
</EuiKeyPadMenuItemButton>
</EuiFlexItem>
))}
</EuiFlexGroup>
);

View file

@ -9,6 +9,6 @@ import { i18n } from '@kbn/i18n';
export const txtChangeButton = i18n.translate(
'xpack.advancedUiActions.components.actionWizard.changeButton',
{
defaultMessage: 'Change',
defaultMessage: 'change',
}
);

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { ActionWizard } from './action_wizard';
export { ActionFactory, ActionWizard } from './action_wizard';

View file

@ -6,29 +6,37 @@
import React, { useState } from 'react';
import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui';
import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public';
import { ActionWizard } from './action_wizard';
import { ActionFactoryDefinition, ActionFactory } from '../../services';
import { CollectConfigProps } from '../../util';
type ActionBaseConfig = object;
import { ActionFactory, ActionBaseConfig, ActionWizard } from './action_wizard';
export const dashboards = [
{ id: 'dashboard1', title: 'Dashboard 1' },
{ id: 'dashboard2', title: 'Dashboard 2' },
];
interface DashboardDrilldownConfig {
export const dashboardDrilldownActionFactory: ActionFactory<{
dashboardId?: string;
useCurrentFilters: boolean;
useCurrentDateRange: boolean;
}
function DashboardDrilldownCollectConfig(props: CollectConfigProps<DashboardDrilldownConfig>) {
useCurrentDashboardFilters: boolean;
useCurrentDashboardDataRange: boolean;
}> = {
type: 'Dashboard',
displayName: 'Go to Dashboard',
iconType: 'dashboardApp',
createConfig: () => {
return {
dashboardId: undefined,
useCurrentDashboardDataRange: true,
useCurrentDashboardFilters: true,
};
},
isValid: config => {
if (!config.dashboardId) return false;
return true;
},
wizard: props => {
const config = props.config ?? {
dashboardId: undefined,
useCurrentFilters: true,
useCurrentDateRange: true,
useCurrentDashboardDataRange: true,
useCurrentDashboardFilters: true,
};
return (
<>
@ -47,11 +55,11 @@ function DashboardDrilldownCollectConfig(props: CollectConfigProps<DashboardDril
<EuiSwitch
name="useCurrentFilters"
label="Use current dashboard's filters"
checked={config.useCurrentFilters}
checked={config.useCurrentDashboardFilters}
onChange={() =>
props.onConfig({
...config,
useCurrentFilters: !config.useCurrentFilters,
useCurrentDashboardFilters: !config.useCurrentDashboardFilters,
})
}
/>
@ -60,57 +68,35 @@ function DashboardDrilldownCollectConfig(props: CollectConfigProps<DashboardDril
<EuiSwitch
name="useCurrentDateRange"
label="Use current dashboard's date range"
checked={config.useCurrentDateRange}
checked={config.useCurrentDashboardDataRange}
onChange={() =>
props.onConfig({
...config,
useCurrentDateRange: !config.useCurrentDateRange,
useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange,
})
}
/>
</EuiFormRow>
</>
);
}
},
};
export const dashboardDrilldownActionFactory: ActionFactoryDefinition<
DashboardDrilldownConfig,
any,
any
> = {
id: 'Dashboard',
getDisplayName: () => 'Go to Dashboard',
getIconType: () => 'dashboardApp',
export const urlDrilldownActionFactory: ActionFactory<{ url: string; openInNewTab: boolean }> = {
type: 'Url',
displayName: 'Go to URL',
iconType: 'link',
createConfig: () => {
return {
dashboardId: undefined,
useCurrentFilters: true,
useCurrentDateRange: true,
url: '',
openInNewTab: false,
};
},
isConfigValid: (config: DashboardDrilldownConfig): config is DashboardDrilldownConfig => {
if (!config.dashboardId) return false;
isValid: config => {
if (!config.url) return false;
return true;
},
CollectConfig: reactToUiComponent(DashboardDrilldownCollectConfig),
isCompatible(context?: object): Promise<boolean> {
return Promise.resolve(true);
},
order: 0,
create: () => ({
id: 'test',
execute: async () => alert('Navigate to dashboard!'),
}),
};
export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory);
interface UrlDrilldownConfig {
url: string;
openInNewTab: boolean;
}
function UrlDrilldownCollectConfig(props: CollectConfigProps<UrlDrilldownConfig>) {
wizard: props => {
const config = props.config ?? {
url: '',
openInNewTab: false,
@ -135,31 +121,8 @@ function UrlDrilldownCollectConfig(props: CollectConfigProps<UrlDrilldownConfig>
</EuiFormRow>
</>
);
}
export const urlDrilldownActionFactory: ActionFactoryDefinition<UrlDrilldownConfig> = {
id: 'Url',
getDisplayName: () => 'Go to URL',
getIconType: () => 'link',
createConfig: () => {
return {
url: '',
openInNewTab: false,
},
};
},
isConfigValid: (config: UrlDrilldownConfig): config is UrlDrilldownConfig => {
if (!config.url) return false;
return true;
},
CollectConfig: reactToUiComponent(UrlDrilldownCollectConfig),
order: 10,
isCompatible(context?: object): Promise<boolean> {
return Promise.resolve(true);
},
create: () => null as any,
};
export const urlFactory = new ActionFactory(urlDrilldownActionFactory);
export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory<any>> }) {
const [state, setState] = useState<{
@ -194,15 +157,14 @@ export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory
changeActionFactory(newActionFactory);
}}
currentActionFactory={state.currentActionFactory}
context={{}}
/>
<div style={{ marginTop: '44px' }} />
<hr />
<div>Action Factory Id: {state.currentActionFactory?.id}</div>
<div>Action Factory Type: {state.currentActionFactory?.type}</div>
<div>Action Factory Config: {JSON.stringify(state.config)}</div>
<div>
Is config valid:{' '}
{JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)}
{JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)}
</div>
</>
);

View file

@ -44,7 +44,7 @@ export class CustomTimeRangeAction implements ActionByType<typeof CUSTOM_TIME_RA
private dateFormat?: string;
private commonlyUsedRanges: CommonlyUsedRange[];
public readonly id = CUSTOM_TIME_RANGE;
public order = 30;
public order = 7;
constructor({
openModal,

View file

@ -12,17 +12,3 @@ export function plugin(initializerContext: PluginInitializerContext) {
}
export { AdvancedUiActionsPublicPlugin as Plugin };
export {
SetupContract as AdvancedUiActionsSetup,
StartContract as AdvancedUiActionsStart,
} from './plugin';
export { ActionWizard } from './components';
export {
ActionFactoryDefinition as AdvancedUiActionsActionFactoryDefinition,
ActionFactory as AdvancedUiActionsActionFactory,
} from './services/action_factory_service';
export {
Configurable as AdvancedUiActionsConfigurable,
CollectConfigProps as AdvancedUiActionsCollectConfigProps,
} from './util';

View file

@ -11,7 +11,7 @@ import {
Plugin,
} from '../../../../src/core/public';
import { createReactOverlays } from '../../../../src/plugins/kibana_react/public';
import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public';
import { UiActionsStart, UiActionsSetup } from '../../../../src/plugins/ui_actions/public';
import {
CONTEXT_MENU_TRIGGER,
PANEL_BADGE_TRIGGER,
@ -41,10 +41,8 @@ interface StartDependencies {
uiActions: UiActionsStart;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SetupContract extends UiActionsSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface StartContract extends UiActionsStart {}
export type Setup = void;
export type Start = void;
declare module '../../../../src/plugins/ui_actions/public' {
export interface ActionContextMapping {
@ -54,16 +52,12 @@ declare module '../../../../src/plugins/ui_actions/public' {
}
export class AdvancedUiActionsPublicPlugin
implements Plugin<SetupContract, StartContract, SetupDependencies, StartDependencies> {
implements Plugin<Setup, Start, SetupDependencies, StartDependencies> {
constructor(initializerContext: PluginInitializerContext) {}
public setup(core: CoreSetup, { uiActions }: SetupDependencies): SetupContract {
return {
...uiActions,
};
}
public setup(core: CoreSetup, { uiActions }: SetupDependencies): Setup {}
public start(core: CoreStart, { uiActions }: StartDependencies): StartContract {
public start(core: CoreStart, { uiActions }: StartDependencies): Start {
const dateFormat = core.uiSettings.get('dateFormat') as string;
const commonlyUsedRanges = core.uiSettings.get('timepicker:quickRanges') as CommonlyUsedRange[];
const { openModal } = createReactOverlays(core);
@ -72,18 +66,16 @@ export class AdvancedUiActionsPublicPlugin
dateFormat,
commonlyUsedRanges,
});
uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, timeRangeAction);
uiActions.registerAction(timeRangeAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction);
const timeRangeBadge = new CustomTimeRangeBadge({
openModal,
dateFormat,
commonlyUsedRanges,
});
uiActions.addTriggerAction(PANEL_BADGE_TRIGGER, timeRangeBadge);
return {
...uiActions,
};
uiActions.registerAction(timeRangeBadge);
uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge);
}
public stop() {}

View file

@ -1,11 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable */
export {
ActionFactory
} from '../../../../../../src/plugins/ui_actions/public/actions/action_factory';

View file

@ -1,11 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable */
export {
ActionFactoryDefinition
} from '../../../../../../src/plugins/ui_actions/public/actions/action_factory_definition';

View file

@ -1,8 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './action_factory_definition';
export * from './action_factory';

View file

@ -1,10 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export {
UiActionsConfigurable as Configurable,
UiActionsCollectConfigProps as CollectConfigProps,
} from '../../../../../src/plugins/ui_actions/public';

View file

@ -1 +0,0 @@
# X-Pack part of Dashboard app

View file

@ -1,8 +0,0 @@
{
"id": "dashboardEnhanced",
"version": "kibana",
"server": true,
"ui": true,
"requiredPlugins": ["uiActions", "embeddable", "dashboard", "drilldowns"],
"configPath": ["xpack", "dashboardEnhanced"]
}

View file

@ -1,5 +0,0 @@
# Presentation React components
Here we keep reusable *presentation* (aka *dumb*) React components&mdash;these
components should not be connected to state and ideally should not know anything
about Kibana.

View file

@ -1,54 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable no-console */
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { DashboardDrilldownConfig } from '.';
export const dashboards = [
{ id: 'dashboard1', title: 'Dashboard 1' },
{ id: 'dashboard2', title: 'Dashboard 2' },
{ id: 'dashboard3', title: 'Dashboard 3' },
];
const InteractiveDemo: React.FC = () => {
const [activeDashboardId, setActiveDashboardId] = React.useState('dashboard1');
const [currentFilters, setCurrentFilters] = React.useState(false);
const [keepRange, setKeepRange] = React.useState(false);
return (
<DashboardDrilldownConfig
activeDashboardId={activeDashboardId}
dashboards={dashboards}
currentFilters={currentFilters}
keepRange={keepRange}
onDashboardSelect={id => setActiveDashboardId(id)}
onCurrentFiltersToggle={() => setCurrentFilters(old => !old)}
onKeepRangeToggle={() => setKeepRange(old => !old)}
/>
);
};
storiesOf('components/DashboardDrilldownConfig', module)
.add('default', () => (
<DashboardDrilldownConfig
activeDashboardId={'dashboard2'}
dashboards={dashboards}
onDashboardSelect={e => console.log('onDashboardSelect', e)}
/>
))
.add('with switches', () => (
<DashboardDrilldownConfig
activeDashboardId={'dashboard2'}
dashboards={dashboards}
onDashboardSelect={e => console.log('onDashboardSelect', e)}
onCurrentFiltersToggle={() => console.log('onCurrentFiltersToggle')}
onKeepRangeToggle={() => console.log('onKeepRangeToggle')}
/>
))
.add('interactive demo', () => <InteractiveDemo />);

View file

@ -1,11 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
test.todo('renders list of dashboards');
test.todo('renders correct selected dashboard');
test.todo('can change dashboard');
test.todo('can toggle "use current filters" switch');
test.todo('can toggle "date range" switch');

View file

@ -1,69 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui';
import { txtChooseDestinationDashboard } from './i18n';
export interface DashboardItem {
id: string;
title: string;
}
export interface DashboardDrilldownConfigProps {
activeDashboardId?: string;
dashboards: DashboardItem[];
currentFilters?: boolean;
keepRange?: boolean;
onDashboardSelect: (dashboardId: string) => void;
onCurrentFiltersToggle?: () => void;
onKeepRangeToggle?: () => void;
}
export const DashboardDrilldownConfig: React.FC<DashboardDrilldownConfigProps> = ({
activeDashboardId,
dashboards,
currentFilters,
keepRange,
onDashboardSelect,
onCurrentFiltersToggle,
onKeepRangeToggle,
}) => {
// TODO: use i18n below.
return (
<>
<EuiFormRow label={txtChooseDestinationDashboard}>
<EuiSelect
name="selectDashboard"
hasNoInitialSelection={true}
options={dashboards.map(({ id, title }) => ({ value: id, text: title }))}
value={activeDashboardId}
onChange={e => onDashboardSelect(e.target.value)}
/>
</EuiFormRow>
{!!onCurrentFiltersToggle && (
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="useCurrentFilters"
label="Use current dashboard's filters"
checked={!!currentFilters}
onChange={onCurrentFiltersToggle}
/>
</EuiFormRow>
)}
{!!onKeepRangeToggle && (
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="useCurrentDateRange"
label="Use current dashboard's date range"
checked={!!keepRange}
onChange={onKeepRangeToggle}
/>
</EuiFormRow>
)}
</>
);
};

View file

@ -1,14 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const txtChooseDestinationDashboard = i18n.translate(
'xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard',
{
defaultMessage: 'Choose destination dashboard',
}
);

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './dashboard_drilldown_config';

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './dashboard_drilldown_config';

View file

@ -1,19 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { PluginInitializerContext } from 'src/core/public';
import { DashboardEnhancedPlugin } from './plugin';
export {
SetupContract as DashboardEnhancedSetupContract,
SetupDependencies as DashboardEnhancedSetupDependencies,
StartContract as DashboardEnhancedStartContract,
StartDependencies as DashboardEnhancedStartDependencies,
} from './plugin';
export function plugin(context: PluginInitializerContext) {
return new DashboardEnhancedPlugin(context);
}

View file

@ -1,27 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { DashboardEnhancedSetupContract, DashboardEnhancedStartContract } from '.';
export type Setup = jest.Mocked<DashboardEnhancedSetupContract>;
export type Start = jest.Mocked<DashboardEnhancedStartContract>;
const createSetupContract = (): Setup => {
const setupContract: Setup = {};
return setupContract;
};
const createStartContract = (): Start => {
const startContract: Start = {};
return startContract;
};
export const dashboardEnhancedPluginMock = {
createSetupContract,
createStartContract,
};

View file

@ -1,50 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public';
import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public';
import { DashboardDrilldownsService } from './services';
import { DrilldownsSetup, DrilldownsStart } from '../../drilldowns/public';
export interface SetupDependencies {
uiActions: UiActionsSetup;
drilldowns: DrilldownsSetup;
}
export interface StartDependencies {
uiActions: UiActionsStart;
drilldowns: DrilldownsStart;
}
// eslint-disable-next-line
export interface SetupContract {}
// eslint-disable-next-line
export interface StartContract {}
export class DashboardEnhancedPlugin
implements Plugin<SetupContract, StartContract, SetupDependencies, StartDependencies> {
public readonly drilldowns = new DashboardDrilldownsService();
public readonly config: { drilldowns: { enabled: boolean } };
constructor(protected readonly context: PluginInitializerContext) {
this.config = context.config.get();
}
public setup(core: CoreSetup<StartDependencies>, plugins: SetupDependencies): SetupContract {
this.drilldowns.bootstrap(core, plugins, {
enableDrilldowns: this.config.drilldowns.enabled,
});
return {};
}
public start(core: CoreStart, plugins: StartDependencies): StartContract {
return {};
}
public stop() {}
}

View file

@ -1,124 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
FlyoutCreateDrilldownAction,
OpenFlyoutAddDrilldownParams,
} from './flyout_create_drilldown';
import { coreMock } from '../../../../../../../../src/core/public/mocks';
import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks';
import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public';
import { uiActionsPluginMock } from '../../../../../../../../src/plugins/ui_actions/public/mocks';
import { TriggerContextMapping } from '../../../../../../../../src/plugins/ui_actions/public';
import { MockEmbeddable } from '../test_helpers';
const overlays = coreMock.createStart().overlays;
const drilldowns = drilldownsPluginMock.createStartContract();
const uiActions = uiActionsPluginMock.createStartContract();
const actionParams: OpenFlyoutAddDrilldownParams = {
drilldowns: () => Promise.resolve(drilldowns),
overlays: () => Promise.resolve(overlays),
};
test('should create', () => {
expect(() => new FlyoutCreateDrilldownAction(actionParams)).not.toThrow();
});
test('title is a string', () => {
expect(typeof new FlyoutCreateDrilldownAction(actionParams).getDisplayName() === 'string').toBe(
true
);
});
test('icon exists', () => {
expect(typeof new FlyoutCreateDrilldownAction(actionParams).getIconType() === 'string').toBe(
true
);
});
describe('isCompatible', () => {
const drilldownAction = new FlyoutCreateDrilldownAction(actionParams);
function checkCompatibility(params: {
isEdit: boolean;
withUiActions: boolean;
isValueClickTriggerSupported: boolean;
}): Promise<boolean> {
return drilldownAction.isCompatible({
embeddable: new MockEmbeddable(
{ id: '', viewMode: params.isEdit ? ViewMode.EDIT : ViewMode.VIEW },
{
supportedTriggers: (params.isValueClickTriggerSupported
? ['VALUE_CLICK_TRIGGER']
: []) as Array<keyof TriggerContextMapping>,
uiActions: params.withUiActions ? uiActions : undefined, // dynamic actions support
}
),
});
}
test("compatible if dynamicUiActions enabled, 'VALUE_CLICK_TRIGGER' is supported, in edit mode", async () => {
expect(
await checkCompatibility({
withUiActions: true,
isEdit: true,
isValueClickTriggerSupported: true,
})
).toBe(true);
});
test('not compatible if dynamicUiActions disabled', async () => {
expect(
await checkCompatibility({
withUiActions: false,
isEdit: true,
isValueClickTriggerSupported: true,
})
).toBe(false);
});
test("not compatible if 'VALUE_CLICK_TRIGGER' is not supported", async () => {
expect(
await checkCompatibility({
withUiActions: true,
isEdit: true,
isValueClickTriggerSupported: false,
})
).toBe(false);
});
test('not compatible if in view mode', async () => {
expect(
await checkCompatibility({
withUiActions: true,
isEdit: false,
isValueClickTriggerSupported: true,
})
).toBe(false);
});
});
describe('execute', () => {
const drilldownAction = new FlyoutCreateDrilldownAction(actionParams);
test('throws error if no dynamicUiActions', async () => {
await expect(
drilldownAction.execute({
embeddable: new MockEmbeddable({ id: '' }, {}),
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Can't execute FlyoutCreateDrilldownAction without dynamicActionsManager"`
);
});
test('should open flyout', async () => {
const spy = jest.spyOn(overlays, 'openFlyout');
await drilldownAction.execute({
embeddable: new MockEmbeddable({ id: '' }, { uiActions }),
});
expect(spy).toBeCalled();
});
});

View file

@ -1,74 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { CoreStart } from 'src/core/public';
import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public';
import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public';
import { DrilldownsStart } from '../../../../../../drilldowns/public';
import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public';
export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN';
export interface OpenFlyoutAddDrilldownParams {
overlays: () => Promise<CoreStart['overlays']>;
drilldowns: () => Promise<DrilldownsStart>;
}
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 = 12;
constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {}
public getDisplayName() {
return i18n.translate('xpack.dashboard.FlyoutCreateDrilldownAction.displayName', {
defaultMessage: 'Create drilldown',
});
}
public getIconType() {
return 'plusInCircle';
}
private isEmbeddableCompatible(context: EmbeddableContext) {
if (!context.embeddable.dynamicActions) return false;
const supportedTriggers = context.embeddable.supportedTriggers();
if (!supportedTriggers || !supportedTriggers.length) return false;
return supportedTriggers.indexOf('VALUE_CLICK_TRIGGER') > -1;
}
public async isCompatible(context: EmbeddableContext) {
const isEditMode = context.embeddable.getInput().viewMode === 'edit';
return isEditMode && this.isEmbeddableCompatible(context);
}
public async execute(context: EmbeddableContext) {
const overlays = await this.params.overlays();
const drilldowns = await this.params.drilldowns();
const dynamicActionManager = context.embeddable.dynamicActions;
if (!dynamicActionManager) {
throw new Error(`Can't execute FlyoutCreateDrilldownAction without dynamicActionsManager`);
}
const handle = overlays.openFlyout(
toMountPoint(
<drilldowns.FlyoutManageDrilldowns
onClose={() => handle.close()}
placeContext={context}
viewMode={'create'}
dynamicActionManager={dynamicActionManager}
/>
),
{
ownFocus: true,
}
);
}
}

View file

@ -1,11 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export {
FlyoutCreateDrilldownAction,
OpenFlyoutAddDrilldownParams,
OPEN_FLYOUT_ADD_DRILLDOWN,
} from './flyout_create_drilldown';

View file

@ -1,102 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FlyoutEditDrilldownAction, FlyoutEditDrilldownParams } from './flyout_edit_drilldown';
import { coreMock } from '../../../../../../../../src/core/public/mocks';
import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks';
import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public';
import { uiActionsPluginMock } from '../../../../../../../../src/plugins/ui_actions/public/mocks';
import { MockEmbeddable } from '../test_helpers';
const overlays = coreMock.createStart().overlays;
const drilldowns = drilldownsPluginMock.createStartContract();
const uiActions = uiActionsPluginMock.createStartContract();
const actionParams: FlyoutEditDrilldownParams = {
drilldowns: () => Promise.resolve(drilldowns),
overlays: () => Promise.resolve(overlays),
};
test('should create', () => {
expect(() => new FlyoutEditDrilldownAction(actionParams)).not.toThrow();
});
test('title is a string', () => {
expect(typeof new FlyoutEditDrilldownAction(actionParams).getDisplayName() === 'string').toBe(
true
);
});
test('icon exists', () => {
expect(typeof new FlyoutEditDrilldownAction(actionParams).getIconType() === 'string').toBe(true);
});
test('MenuItem exists', () => {
expect(new FlyoutEditDrilldownAction(actionParams).MenuItem).toBeDefined();
});
describe('isCompatible', () => {
const drilldownAction = new FlyoutEditDrilldownAction(actionParams);
function checkCompatibility(params: {
isEdit: boolean;
withUiActions: boolean;
}): Promise<boolean> {
return drilldownAction.isCompatible({
embeddable: new MockEmbeddable(
{
id: '',
viewMode: params.isEdit ? ViewMode.EDIT : ViewMode.VIEW,
},
{
uiActions: params.withUiActions ? uiActions : undefined, // dynamic actions support
}
),
});
}
// TODO: need proper DynamicActionsMock and ActionFactory mock
test.todo('compatible if dynamicUiActions enabled, in edit view, and have at least 1 drilldown');
test('not compatible if dynamicUiActions disabled', async () => {
expect(
await checkCompatibility({
withUiActions: false,
isEdit: true,
})
).toBe(false);
});
test('not compatible if no drilldowns', async () => {
expect(
await checkCompatibility({
withUiActions: true,
isEdit: true,
})
).toBe(false);
});
});
describe('execute', () => {
const drilldownAction = new FlyoutEditDrilldownAction(actionParams);
test('throws error if no dynamicUiActions', async () => {
await expect(
drilldownAction.execute({
embeddable: new MockEmbeddable({ id: '' }, {}),
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Can't execute FlyoutEditDrilldownAction without dynamicActionsManager"`
);
});
test('should open flyout', async () => {
const spy = jest.spyOn(overlays, 'openFlyout');
await drilldownAction.execute({
embeddable: new MockEmbeddable({ id: '' }, { uiActions }),
});
expect(spy).toBeCalled();
});
});

View file

@ -1,71 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { CoreStart } from 'src/core/public';
import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public';
import {
reactToUiComponent,
toMountPoint,
} from '../../../../../../../../src/plugins/kibana_react/public';
import { EmbeddableContext, ViewMode } from '../../../../../../../../src/plugins/embeddable/public';
import { DrilldownsStart } from '../../../../../../drilldowns/public';
import { txtDisplayName } from './i18n';
import { MenuItem } from './menu_item';
export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN';
export interface FlyoutEditDrilldownParams {
overlays: () => Promise<CoreStart['overlays']>;
drilldowns: () => Promise<DrilldownsStart>;
}
export class FlyoutEditDrilldownAction implements ActionByType<typeof OPEN_FLYOUT_EDIT_DRILLDOWN> {
public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN;
public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN;
public order = 10;
constructor(protected readonly params: FlyoutEditDrilldownParams) {}
public getDisplayName() {
return txtDisplayName;
}
public getIconType() {
return 'list';
}
MenuItem = reactToUiComponent(MenuItem);
public async isCompatible({ embeddable }: EmbeddableContext) {
if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false;
if (!embeddable.dynamicActions) return false;
return embeddable.dynamicActions.state.get().events.length > 0;
}
public async execute(context: EmbeddableContext) {
const overlays = await this.params.overlays();
const drilldowns = await this.params.drilldowns();
const dynamicActionManager = context.embeddable.dynamicActions;
if (!dynamicActionManager) {
throw new Error(`Can't execute FlyoutEditDrilldownAction without dynamicActionsManager`);
}
const handle = overlays.openFlyout(
toMountPoint(
<drilldowns.FlyoutManageDrilldowns
onClose={() => handle.close()}
placeContext={context}
viewMode={'manage'}
dynamicActionManager={dynamicActionManager}
/>
),
{
ownFocus: true,
}
);
}
}

View file

@ -1,11 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export {
FlyoutEditDrilldownAction,
FlyoutEditDrilldownParams,
OPEN_FLYOUT_EDIT_DRILLDOWN,
} from './flyout_edit_drilldown';

View file

@ -1,37 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { render, cleanup, act } from '@testing-library/react/pure';
import { MenuItem } from './menu_item';
import { createStateContainer } from '../../../../../../../../src/plugins/kibana_utils/common';
import { DynamicActionManager } from '../../../../../../../../src/plugins/ui_actions/public';
import { IEmbeddable } from '../../../../../../../../src/plugins/embeddable/public/lib/embeddables';
import '@testing-library/jest-dom';
afterEach(cleanup);
test('<MenuItem/>', () => {
const state = createStateContainer<{ events: object[] }>({ events: [] });
const { getByText, queryByText } = render(
<MenuItem
context={{
embeddable: ({
dynamicActions: ({ state } as unknown) as DynamicActionManager,
} as unknown) as IEmbeddable,
}}
/>
);
expect(getByText(/manage drilldowns/i)).toBeInTheDocument();
expect(queryByText('0')).not.toBeInTheDocument();
act(() => {
state.set({ events: [{}] });
});
expect(queryByText('1')).toBeInTheDocument();
});

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiNotificationBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public';
import { txtDisplayName } from './i18n';
import { useContainerState } from '../../../../../../../../src/plugins/kibana_utils/common';
export const MenuItem: React.FC<{ context: EmbeddableContext }> = ({ context }) => {
if (!context.embeddable.dynamicActions)
throw new Error('Flyout edit drillldown context menu item requires `dynamicActions`');
const { events } = useContainerState(context.embeddable.dynamicActions.state);
const count = events.length;
return (
<EuiFlexGroup alignItems={'center'}>
<EuiFlexItem grow={true}>{txtDisplayName}</EuiFlexItem>
{count > 0 && (
<EuiFlexItem grow={false}>
<EuiNotificationBadge>{count}</EuiNotificationBadge>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -1,28 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Embeddable, EmbeddableInput } from '../../../../../../../src/plugins/embeddable/public/';
import {
TriggerContextMapping,
UiActionsStart,
} from '../../../../../../../src/plugins/ui_actions/public';
export class MockEmbeddable extends Embeddable {
public readonly type = 'mock';
private readonly triggers: Array<keyof TriggerContextMapping> = [];
constructor(
initialInput: EmbeddableInput,
params: { uiActions?: UiActionsStart; supportedTriggers?: Array<keyof TriggerContextMapping> }
) {
super(initialInput, {}, undefined, params);
this.triggers = params.supportedTriggers ?? [];
}
public render(node: HTMLElement) {}
public reload() {}
public supportedTriggers(): Array<keyof TriggerContextMapping> {
return this.triggers;
}
}

View file

@ -1,60 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CoreSetup } from 'src/core/public';
import { SetupDependencies } from '../../plugin';
import {
CONTEXT_MENU_TRIGGER,
EmbeddableContext,
} from '../../../../../../src/plugins/embeddable/public';
import {
FlyoutCreateDrilldownAction,
FlyoutEditDrilldownAction,
OPEN_FLYOUT_ADD_DRILLDOWN,
OPEN_FLYOUT_EDIT_DRILLDOWN,
} from './actions';
import { DrilldownsStart } from '../../../../drilldowns/public';
import { DashboardToDashboardDrilldown } from './dashboard_to_dashboard_drilldown';
declare module '../../../../../../src/plugins/ui_actions/public' {
export interface ActionContextMapping {
[OPEN_FLYOUT_ADD_DRILLDOWN]: EmbeddableContext;
[OPEN_FLYOUT_EDIT_DRILLDOWN]: EmbeddableContext;
}
}
interface BootstrapParams {
enableDrilldowns: boolean;
}
export class DashboardDrilldownsService {
bootstrap(
core: CoreSetup<{ drilldowns: DrilldownsStart }>,
plugins: SetupDependencies,
{ enableDrilldowns }: BootstrapParams
) {
if (enableDrilldowns) {
this.setupDrilldowns(core, plugins);
}
}
setupDrilldowns(core: CoreSetup<{ drilldowns: DrilldownsStart }>, plugins: SetupDependencies) {
const overlays = async () => (await core.getStartServices())[0].overlays;
const drilldowns = async () => (await core.getStartServices())[1].drilldowns;
const savedObjects = async () => (await core.getStartServices())[0].savedObjects.client;
const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays, drilldowns });
plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown);
const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays, drilldowns });
plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown);
const dashboardToDashboardDrilldown = new DashboardToDashboardDrilldown({
savedObjects,
});
plugins.drilldowns.registerDrilldown(dashboardToDashboardDrilldown);
}
}

View file

@ -1,9 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
test.todo('displays all dashboard in a list');
test.todo('does not display dashboard on which drilldown is being created');
test.todo('updates config object correctly');

View file

@ -1,55 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useEffect } from 'react';
import { CollectConfigProps } from './types';
import { DashboardDrilldownConfig } from '../../../components/dashboard_drilldown_config';
import { Params } from './drilldown';
export interface CollectConfigContainerProps extends CollectConfigProps {
params: Params;
}
export const CollectConfigContainer: React.FC<CollectConfigContainerProps> = ({
config,
onConfig,
params: { savedObjects },
}) => {
const [dashboards] = useState([
{ id: 'dashboard1', title: 'Dashboard 1' },
{ id: 'dashboard2', title: 'Dashboard 2' },
{ id: 'dashboard3', title: 'Dashboard 3' },
{ id: 'dashboard4', title: 'Dashboard 4' },
]);
useEffect(() => {
// TODO: Load dashboards...
}, [savedObjects]);
return (
<DashboardDrilldownConfig
activeDashboardId={config.dashboardId}
dashboards={dashboards}
currentFilters={config.useCurrentFilters}
keepRange={config.useCurrentDateRange}
onDashboardSelect={dashboardId => {
onConfig({ ...config, dashboardId });
}}
onCurrentFiltersToggle={() =>
onConfig({
...config,
useCurrentFilters: !config.useCurrentFilters,
})
}
onKeepRangeToggle={() =>
onConfig({
...config,
useCurrentDateRange: !config.useCurrentDateRange,
})
}
/>
);
};

Some files were not shown because too many files have changed in this diff Show more