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/ # For more info, see https://help.github.com/articles/about-codeowners/
# App # App
/x-pack/legacy/plugins/dashboard_enhanced/ @elastic/kibana-app
/x-pack/legacy/plugins/lens/ @elastic/kibana-app /x-pack/legacy/plugins/lens/ @elastic/kibana-app
/x-pack/legacy/plugins/graph/ @elastic/kibana-app /x-pack/legacy/plugins/graph/ @elastic/kibana-app
/src/legacy/server/url_shortening/ @elastic/kibana-app /src/legacy/server/url_shortening/ @elastic/kibana-app

View file

@ -46,7 +46,7 @@ export class UiActionExamplesPlugin
})); }));
uiActions.registerAction(helloWorldAction); uiActions.registerAction(helloWorldAction);
uiActions.addTriggerAction(helloWorldTrigger.id, helloWorldAction); uiActions.attachAction(helloWorldTrigger.id, helloWorldAction);
} }
public start() {} 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( setConfirmationText(
`You've successfully added a new action: ${dynamicAction.getDisplayName( `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(); const startServices = core.getStartServices();
deps.uiActions.addTriggerAction( deps.uiActions.attachAction(
USER_TRIGGER, USER_TRIGGER,
createPhoneUserAction(async () => (await startServices)[1].uiActions) createPhoneUserAction(async () => (await startServices)[1].uiActions)
); );
deps.uiActions.addTriggerAction( deps.uiActions.attachAction(
USER_TRIGGER, USER_TRIGGER,
createEditUserAction(async () => (await startServices)[0].overlays.openModal) createEditUserAction(async () => (await startServices)[0].overlays.openModal)
); );
deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, viewInMapsAction); deps.uiActions.attachAction(COUNTRY_TRIGGER, viewInMapsAction);
deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, lookUpWeatherAction); deps.uiActions.attachAction(COUNTRY_TRIGGER, lookUpWeatherAction);
deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, showcasePluggability); deps.uiActions.attachAction(COUNTRY_TRIGGER, showcasePluggability);
deps.uiActions.addTriggerAction(PHONE_TRIGGER, makePhoneCallAction); deps.uiActions.attachAction(PHONE_TRIGGER, makePhoneCallAction);
deps.uiActions.addTriggerAction(PHONE_TRIGGER, showcasePluggability); deps.uiActions.attachAction(PHONE_TRIGGER, showcasePluggability);
deps.uiActions.addTriggerAction(USER_TRIGGER, showcasePluggability); deps.uiActions.attachAction(USER_TRIGGER, showcasePluggability);
core.application.register({ core.application.register({
id: 'uiActionsExplorer', id: 'uiActionsExplorer',

View file

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

View file

@ -18,14 +18,12 @@
*/ */
export const storybookAliases = { export const storybookAliases = {
advanced_ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js',
apm: 'x-pack/legacy/plugins/apm/scripts/storybook.js', apm: 'x-pack/legacy/plugins/apm/scripts/storybook.js',
canvas: 'x-pack/legacy/plugins/canvas/scripts/storybook_new.js', canvas: 'x-pack/legacy/plugins/canvas/scripts/storybook_new.js',
codeeditor: 'src/plugins/kibana_react/public/code_editor/scripts/storybook.ts', 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', drilldowns: 'x-pack/plugins/drilldowns/scripts/storybook.js',
embeddable: 'src/plugins/embeddable/scripts/storybook.js', embeddable: 'src/plugins/embeddable/scripts/storybook.js',
infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js', infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js',
siem: 'x-pack/legacy/plugins/siem/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 { buildPipeline } from '../legacy/build_pipeline';
import { Vis } from '../vis'; import { Vis } from '../vis';
import { getExpressions, getUiActions } from '../services'; import { getExpressions, getUiActions } from '../services';
import { VisualizationsStartDeps } from '../plugin';
import { VIS_EVENT_TO_TRIGGER } from './events'; import { VIS_EVENT_TO_TRIGGER } from './events';
const getKeys = <T extends {}>(o: T): Array<keyof T> => Object.keys(o) as Array<keyof T>; 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; editable: boolean;
appState?: { save(): void }; appState?: { save(): void };
uiState?: PersistedState; uiState?: PersistedState;
uiActions?: VisualizationsStartDeps['uiActions'];
} }
export interface VisualizeInput extends EmbeddableInput { export interface VisualizeInput extends EmbeddableInput {
@ -96,7 +94,7 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut
constructor( constructor(
timefilter: TimefilterContract, timefilter: TimefilterContract,
{ vis, editUrl, indexPatterns, editable, uiActions }: VisualizeEmbeddableConfiguration, { vis, editUrl, indexPatterns, editable }: VisualizeEmbeddableConfiguration,
initialInput: VisualizeInput, initialInput: VisualizeInput,
parent?: Container parent?: Container
) { ) {
@ -109,8 +107,7 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut
editable, editable,
visTypeName: vis.type.name, visTypeName: vis.type.name,
}, },
parent, parent
{ uiActions }
); );
this.timefilter = timefilter; this.timefilter = timefilter;
this.vis = vis; this.vis = vis;
@ -268,7 +265,6 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut
timeFieldName: this.vis.data.indexPattern!.timeFieldName!, timeFieldName: this.vis.data.indexPattern!.timeFieldName!,
data: event.data, data: event.data,
}; };
getUiActions() getUiActions()
.getTrigger(triggerId) .getTrigger(triggerId)
.exec(context); .exec(context);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -49,7 +49,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => {
const editModeAction = createEditModeAction(); const editModeAction = createEditModeAction();
uiActionsSetup.registerAction(editModeAction); uiActionsSetup.registerAction(editModeAction);
uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); uiActionsSetup.attachAction(CONTEXT_MENU_TRIGGER, editModeAction);
setup.registerEmbeddableFactory( setup.registerEmbeddableFactory(
CONTACT_CARD_EMBEDDABLE, CONTACT_CARD_EMBEDDABLE,
new ContactCardEmbeddableFactory({} as any, (() => null) as any, {} as any) 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) createFilterAction(queryService.filterManager, queryService.timefilter.timefilter)
); );
uiActions.addTriggerAction( uiActions.attachAction(
SELECT_RANGE_TRIGGER, SELECT_RANGE_TRIGGER,
selectRangeAction(queryService.filterManager, queryService.timefilter.timefilter) selectRangeAction(queryService.filterManager, queryService.timefilter.timefilter)
); );
uiActions.addTriggerAction( uiActions.attachAction(
VALUE_CLICK_TRIGGER, VALUE_CLICK_TRIGGER,
valueClickAction(queryService.filterManager, queryService.timefilter.timefilter) valueClickAction(queryService.filterManager, queryService.timefilter.timefilter)
); );
@ -146,10 +146,7 @@ export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPubli
const search = this.searchService.start(core); const search = this.searchService.start(core);
setSearchService(search); setSearchService(search);
uiActions.addTriggerAction( uiActions.attachAction(APPLY_FILTER_TRIGGER, uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER));
APPLY_FILTER_TRIGGER,
uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER)
);
const dataServices = { const dataServices = {
actions: { actions: {

View file

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

View file

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

View file

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

View file

@ -17,20 +17,32 @@
* under the License. * under the License.
*/ */
import {
UiActionsAbstractActionStorage,
UiActionsSerializedEvent,
} from '../../../../ui_actions/public';
import { Embeddable } from '..'; import { Embeddable } from '..';
export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { /**
constructor(private readonly embbeddable: Embeddable) { * Below two interfaces are here temporarily, they will move to `ui_actions`
super(); * 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 input = this.embbeddable.getInput();
const events = (input.events || []) as UiActionsSerializedEvent[]; const events = (input.events || []) as SerializedEvent[];
const exists = !!events.find(({ eventId }) => eventId === event.eventId); const exists = !!events.find(({ eventId }) => eventId === event.eventId);
if (exists) { if (exists) {
@ -41,13 +53,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage {
} }
this.embbeddable.updateInput({ this.embbeddable.updateInput({
...input,
events: [...events, event], events: [...events, event],
}); });
} }
async update(event: UiActionsSerializedEvent) { async update(event: SerializedEvent) {
const input = this.embbeddable.getInput(); 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); const index = events.findIndex(({ eventId }) => eventId === event.eventId);
if (index === -1) { if (index === -1) {
@ -59,13 +72,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage {
} }
this.embbeddable.updateInput({ this.embbeddable.updateInput({
...input,
events: [...events.slice(0, index), event, ...events.slice(index + 1)], events: [...events.slice(0, index), event, ...events.slice(index + 1)],
}); });
} }
async remove(eventId: string) { async remove(eventId: string) {
const input = this.embbeddable.getInput(); 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); const index = events.findIndex(event => eventId === event.eventId);
if (index === -1) { if (index === -1) {
@ -77,13 +91,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage {
} }
this.embbeddable.updateInput({ this.embbeddable.updateInput({
...input,
events: [...events.slice(0, index), ...events.slice(index + 1)], 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 input = this.embbeddable.getInput();
const events = (input.events || []) as UiActionsSerializedEvent[]; const events = (input.events || []) as SerializedEvent[];
const event = events.find(ev => eventId === ev.eventId); const event = events.find(ev => eventId === ev.eventId);
if (!event) { if (!event) {
@ -98,10 +113,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage {
private __list() { private __list() {
const input = this.embbeddable.getInput(); 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(); return this.__list();
} }
} }

View file

@ -18,7 +18,6 @@
*/ */
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { UiActionsDynamicActionManager } from '../../../../../plugins/ui_actions/public';
import { Adapters } from '../types'; import { Adapters } from '../types';
import { IContainer } from '../containers/i_container'; import { IContainer } from '../containers/i_container';
import { ViewMode } from '../types'; import { ViewMode } from '../types';
@ -34,7 +33,7 @@ export interface EmbeddableInput {
/** /**
* Reserved key for `ui_actions` events. * Reserved key for `ui_actions` events.
*/ */
events?: Array<{ eventId: string }>; events?: unknown;
/** /**
* List of action IDs that this embeddable should not render. * List of action IDs that this embeddable should not render.
@ -83,19 +82,6 @@ export interface IEmbeddable<
**/ **/
readonly id: string; 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 * A functional representation of the isContainer variable, but helpful for typescript to
* know the shape if this returns true * know the shape if this returns true

View file

@ -44,7 +44,7 @@ import {
import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks'; import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks';
import { EuiBadge } from '@elastic/eui'; 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 triggerRegistry = new Map<string, Trigger>();
const embeddableFactories = new Map<string, EmbeddableFactory>(); const embeddableFactories = new Map<string, EmbeddableFactory>();
const getEmbeddableFactory = (id: string) => embeddableFactories.get(id); const getEmbeddableFactory = (id: string) => embeddableFactories.get(id);
@ -213,17 +213,13 @@ const renderInEditModeAndOpenContextMenu = async (
}; };
test('HelloWorldContainer in edit mode hides disabledActions', async () => { test('HelloWorldContainer in edit mode hides disabledActions', async () => {
const action = { const action: Action = {
id: 'FOO', id: 'FOO',
type: 'FOO' as ActionType, type: 'FOO' as ActionType,
getIconType: () => undefined, getIconType: () => undefined,
getDisplayName: () => 'foo', getDisplayName: () => 'foo',
isCompatible: async () => true, isCompatible: async () => true,
execute: async () => {}, execute: async () => {},
order: 10,
getHref: () => {
return undefined;
},
}; };
const getActions = () => Promise.resolve([action]); const getActions = () => Promise.resolve([action]);
@ -249,17 +245,13 @@ test('HelloWorldContainer in edit mode hides disabledActions', async () => {
}); });
test('HelloWorldContainer hides disabled badges', async () => { test('HelloWorldContainer hides disabled badges', async () => {
const action = { const action: Action = {
id: 'BAR', id: 'BAR',
type: 'BAR' as ActionType, type: 'BAR' as ActionType,
getIconType: () => undefined, getIconType: () => undefined,
getDisplayName: () => 'bar', getDisplayName: () => 'bar',
isCompatible: async () => true, isCompatible: async () => true,
execute: async () => {}, execute: async () => {},
order: 10,
getHref: () => {
return undefined;
},
}; };
const getActions = () => Promise.resolve([action]); 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 { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal';
import { EmbeddableStart } from '../../plugin'; 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 { interface Props {
embeddable: IEmbeddable<any, any>; embeddable: IEmbeddable<any, any>;
getActions: UiActionsService['getTriggerCompatibleActions']; getActions: UiActionsService['getTriggerCompatibleActions'];
@ -65,14 +57,12 @@ interface State {
hidePanelTitles: boolean; hidePanelTitles: boolean;
closeContextMenu: boolean; closeContextMenu: boolean;
badges: Array<Action<EmbeddableContext>>; badges: Array<Action<EmbeddableContext>>;
eventCount?: number;
} }
export class EmbeddablePanel extends React.Component<Props, State> { export class EmbeddablePanel extends React.Component<Props, State> {
private embeddableRoot: React.RefObject<HTMLDivElement>; private embeddableRoot: React.RefObject<HTMLDivElement>;
private parentSubscription?: Subscription; private parentSubscription?: Subscription;
private subscription?: Subscription; private subscription?: Subscription;
private eventCountSubscription?: Subscription;
private mounted: boolean = false; private mounted: boolean = false;
private generateId = htmlIdGenerator(); private generateId = htmlIdGenerator();
@ -146,9 +136,6 @@ export class EmbeddablePanel extends React.Component<Props, State> {
if (this.subscription) { if (this.subscription) {
this.subscription.unsubscribe(); this.subscription.unsubscribe();
} }
if (this.eventCountSubscription) {
this.eventCountSubscription.unsubscribe();
}
if (this.parentSubscription) { if (this.parentSubscription) {
this.parentSubscription.unsubscribe(); this.parentSubscription.unsubscribe();
} }
@ -190,7 +177,6 @@ export class EmbeddablePanel extends React.Component<Props, State> {
badges={this.state.badges} badges={this.state.badges}
embeddable={this.props.embeddable} embeddable={this.props.embeddable}
headerId={headerId} headerId={headerId}
eventCount={this.state.eventCount}
/> />
)} )}
<div className="embPanel__content" ref={this.embeddableRoot} /> <div className="embPanel__content" ref={this.embeddableRoot} />
@ -202,15 +188,6 @@ export class EmbeddablePanel extends React.Component<Props, State> {
if (this.embeddableRoot.current) { if (this.embeddableRoot.current) {
this.props.embeddable.render(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 = () => { closeMyContextMenuPanel = () => {
@ -224,14 +201,13 @@ export class EmbeddablePanel extends React.Component<Props, State> {
}; };
private getActionContextMenuPanel = async () => { 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, embeddable: this.props.embeddable,
}); });
const { disabledActions } = this.props.embeddable.getInput(); const { disabledActions } = this.props.embeddable.getInput();
if (disabledActions) { if (disabledActions) {
const removeDisabledActions = removeById(disabledActions); actions = actions.filter(action => disabledActions.indexOf(action.id) === -1);
regularActions = regularActions.filter(removeDisabledActions);
} }
const createGetUserData = (overlays: OverlayStart) => const createGetUserData = (overlays: OverlayStart) =>
@ -270,10 +246,16 @@ export class EmbeddablePanel extends React.Component<Props, State> {
new EditPanelAction(this.props.getEmbeddableFactory), 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({ return await buildContextMenuForActions({
actions: sortedActions, actions: sorted,
actionContext: { embeddable: this.props.embeddable }, actionContext: { embeddable: this.props.embeddable },
closeMenu: this.closeMyContextMenuPanel, closeMenu: this.closeMyContextMenuPanel,
}); });

View file

@ -33,13 +33,15 @@ interface ActionContext {
export class CustomizePanelTitleAction implements Action<ActionContext> { export class CustomizePanelTitleAction implements Action<ActionContext> {
public readonly type = ACTION_CUSTOMIZE_PANEL; public readonly type = ACTION_CUSTOMIZE_PANEL;
public id = 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() { public getDisplayName() {
return i18n.translate('embeddableApi.customizePanel.action.displayName', { 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> { export class InspectPanelAction implements Action<ActionContext> {
public readonly type = ACTION_INSPECT_PANEL; public readonly type = ACTION_INSPECT_PANEL;
public readonly id = ACTION_INSPECT_PANEL; public readonly id = ACTION_INSPECT_PANEL;
public order = 20; public order = 10;
constructor(private readonly inspector: InspectorStartContract) {} constructor(private readonly inspector: InspectorStartContract) {}

View file

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

View file

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

View file

@ -24,58 +24,15 @@ import { Comparator, Connect, StateContainer, UnboxState } from './types';
const { useContext, useLayoutEffect, useRef, createElement: h } = React; 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());
/**
* 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,
selector: (state: UnboxState<Container>) => Result,
comparator: Comparator<Result> = defaultComparator
): Result => {
const { state$, get } = container;
const lastValueRef = useRef<Result>(get());
const [value, setValue] = React.useState<Result>(() => {
const newValue = selector(get());
lastValueRef.current = newValue;
return newValue;
});
useLayoutEffect(() => {
const subscription = state$.subscribe((currentState: UnboxState<Container>) => {
const newValue = selector(currentState);
if (!comparator(lastValueRef.current, newValue)) {
lastValueRef.current = newValue;
setValue(newValue);
}
});
return () => subscription.unsubscribe();
}, [state$, comparator]);
return value;
};
export const createStateContainerReactHelpers = <Container extends StateContainer<any, any>>() => { export const createStateContainerReactHelpers = <Container extends StateContainer<any, any>>() => {
const context = React.createContext<Container>(null as any); const context = React.createContext<Container>(null as any);
const useContainer = (): Container => useContext(context); const useContainer = (): Container => useContext(context);
const useState = (): UnboxState<Container> => { const useState = (): UnboxState<Container> => {
const container = useContainer(); const { state$, get } = useContainer();
return useContainerState(container); const value = useObservable(state$, get());
return value;
}; };
const useTransitions: () => Container['transitions'] = () => useContainer().transitions; const useTransitions: () => Container['transitions'] = () => useContainer().transitions;
@ -84,8 +41,24 @@ export const createStateContainerReactHelpers = <Container extends StateContaine
selector: (state: UnboxState<Container>) => Result, selector: (state: UnboxState<Container>) => Result,
comparator: Comparator<Result> = defaultComparator comparator: Comparator<Result> = defaultComparator
): Result => { ): Result => {
const container = useContainer(); const { state$, get } = useContainer();
return useContainerSelector<Container, Result>(container, selector, comparator); const lastValueRef = useRef<Result>(get());
const [value, setValue] = React.useState<Result>(() => {
const newValue = selector(get());
lastValueRef.current = newValue;
return newValue;
});
useLayoutEffect(() => {
const subscription = state$.subscribe((currentState: UnboxState<Container>) => {
const newValue = selector(currentState);
if (!comparator(lastValueRef.current, newValue)) {
lastValueRef.current = newValue;
setValue(newValue);
}
});
return () => subscription.unsubscribe();
}, [state$, comparator]);
return value;
}; };
const connect: Connect<UnboxState<Container>> = mapStateToProp => component => props => const connect: Connect<UnboxState<Container>> = mapStateToProp => component => props =>

View file

@ -43,7 +43,7 @@ export interface BaseStateContainer<State extends BaseState> {
export interface StateContainer< export interface StateContainer<
State extends BaseState, State extends BaseState,
PureTransitions extends object = object, PureTransitions extends object,
PureSelectors extends object = {} PureSelectors extends object = {}
> extends BaseStateContainer<State> { > extends BaseStateContainer<State> {
transitions: Readonly<PureTransitionsToTransitions<PureTransitions>>; 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 { UiComponent } from 'src/plugins/kibana_utils/common';
import { ActionType, ActionContextMapping } from '../types'; import { ActionType, ActionContextMapping } from '../types';
import { Presentable } from '../util/presentable';
export type ActionByType<T extends ActionType> = Action<ActionContextMapping[T], T>; export type ActionByType<T extends ActionType> = Action<ActionContextMapping[T], T>;
export interface Action<Context extends {} = {}, T = ActionType> export interface Action<Context = {}, T = ActionType> {
extends Partial<Presentable<Context>> {
/** /**
* Determined the order when there is more than one action matched to a trigger. * Determined the order when there is more than one action matched to a trigger.
* Higher numbers are displayed first. * Higher numbers are displayed first.
@ -65,30 +63,12 @@ export interface Action<Context extends {} = {}, T = ActionType>
isCompatible(context: Context): Promise<boolean>; 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>; getHref?(context: Context): string | undefined;
}
/**
* 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;
/** /**
* Executes the action. * Executes the action.
*/ */
execute(context: Context): Promise<void>; 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 { UiComponent } from 'src/plugins/kibana_utils/common';
import { ActionType, ActionContextMapping } from '../types';
/** export interface ActionDefinition<T extends ActionType> {
* Represents something that can be displayed to user in UI.
*/
export interface Presentable<Context extends object = object> {
/** /**
* ID that uniquely identifies this object. * Determined the order when there is more than one action matched to a trigger.
* Higher numbers are displayed first.
*/ */
readonly id: string; order?: number;
/** /**
* Determines the display order in relation to other items. Higher numbers are * A unique identifier for this action instance.
* displayed first.
*/ */
readonly order: number; id?: string;
/** /**
* `UiComponent` to render when displaying this entity as a context menu item. * The action type is what determines the context shape.
* 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. * 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. * 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 * Returns a promise that resolves to true if this action is compatible given the context,
* the context and should be displayed to user, otherwise resolves to false. * 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. * under the License.
*/ */
import { ActionContextMapping } from '../types';
import { ActionByType } from './action'; import { ActionByType } from './action';
import { ActionType } from '../types'; import { ActionType } from '../types';
import { ActionDefinition } from './action'; import { ActionDefinition } from './action_definition';
interface ActionDefinitionByType<T extends ActionType> export function createAction<T extends ActionType>(action: ActionDefinition<T>): ActionByType<T> {
extends Omit<ActionDefinition<ActionContextMapping[T]>, 'id'> {
id?: string;
}
export function createAction<T extends ActionType>(
action: ActionDefinitionByType<T>
): ActionByType<T> {
return { return {
getIconType: () => undefined, getIconType: () => undefined,
order: 0, order: 0,
@ -38,5 +30,5 @@ export function createAction<T extends ActionType>(
getDisplayName: () => '', getDisplayName: () => '',
getHref: () => undefined, getHref: () => undefined,
...action, ...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';
export * from './action_internal';
export * from './action_factory_definition';
export * from './action_factory';
export * from './create_action'; export * from './create_action';
export * from './incompatible_action_error'; 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 { uiToReactComponent } from '../../../kibana_react/public';
import { Action } from '../actions'; import { Action } from '../actions';
export const defaultTitle = i18n.translate('uiActions.actionPanel.title', {
defaultMessage: 'Options',
});
/** /**
* Transforms an array of Actions to the shape EuiContextMenuPanel expects. * Transforms an array of Actions to the shape EuiContextMenuPanel expects.
*/ */
export async function buildContextMenuForActions<Context extends object>({ export async function buildContextMenuForActions<A>({
actions, actions,
actionContext, actionContext,
title = defaultTitle,
closeMenu, closeMenu,
}: { }: {
actions: Array<Action<Context>>; actions: Array<Action<A>>;
actionContext: Context; actionContext: A;
title?: string;
closeMenu: () => void; closeMenu: () => void;
}): Promise<EuiContextMenuPanelDescriptor> { }): Promise<EuiContextMenuPanelDescriptor> {
const menuItems = await buildEuiContextMenuPanelItems<Context>({ const menuItems = await buildEuiContextMenuPanelItems<A>({
actions, actions,
actionContext, actionContext,
closeMenu, closeMenu,
@ -50,7 +44,9 @@ export async function buildContextMenuForActions<Context extends object>({
return { return {
id: 'mainMenu', id: 'mainMenu',
title, title: i18n.translate('uiActions.actionPanel.title', {
defaultMessage: 'Options',
}),
items: menuItems, 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 * Transform an array of Actions into the shape needed to build an EUIContextMenu
*/ */
async function buildEuiContextMenuPanelItems<Context extends object>({ async function buildEuiContextMenuPanelItems<A>({
actions, actions,
actionContext, actionContext,
closeMenu, closeMenu,
}: { }: {
actions: Array<Action<Context>>; actions: Array<Action<A>>;
actionContext: Context; actionContext: A;
closeMenu: () => void; closeMenu: () => void;
}) { }) {
const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length); const items: EuiContextMenuPanelItemDescriptor[] = [];
const promises = actions.map(async (action, index) => { const promises = actions.map(async action => {
const isCompatible = await action.isCompatible(actionContext); const isCompatible = await action.isCompatible(actionContext);
if (!isCompatible) { if (!isCompatible) {
return; return;
} }
items[index] = convertPanelActionToContextMenuItem({ items.push(
action, convertPanelActionToContextMenuItem({
actionContext, action,
closeMenu, actionContext,
}); closeMenu,
})
);
}); });
await Promise.all(promises); 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, action,
actionContext, actionContext,
closeMenu, closeMenu,
}: { }: {
action: Action<Context>; action: Action<A>;
actionContext: Context; actionContext: A;
closeMenu: () => void; closeMenu: () => void;
}): EuiContextMenuPanelItemDescriptor { }): EuiContextMenuPanelItemDescriptor {
const menuPanelItem: EuiContextMenuPanelItemDescriptor = { const menuPanelItem: EuiContextMenuPanelItemDescriptor = {
@ -111,11 +115,8 @@ function convertPanelActionToContextMenuItem<Context extends object>({
closeMenu(); closeMenu();
}; };
if (action.getHref) { if (action.getHref && action.getHref(actionContext)) {
const href = action.getHref(actionContext); menuPanelItem.href = action.getHref(actionContext);
if (href) {
menuPanelItem.href = action.getHref(actionContext);
}
} }
return menuPanelItem; return menuPanelItem;

View file

@ -26,26 +26,8 @@ export function plugin(initializerContext: PluginInitializerContext) {
export { UiActionsSetup, UiActionsStart } from './plugin'; export { UiActionsSetup, UiActionsStart } from './plugin';
export { UiActionsServiceParams, UiActionsService } from './service'; export { UiActionsServiceParams, UiActionsService } from './service';
export { export { Action, createAction, IncompatibleActionError } from './actions';
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 { buildContextMenuForActions } from './context_menu'; export { buildContextMenuForActions } from './context_menu';
export {
Presentable as UiActionsPresentable,
Configurable as UiActionsConfigurable,
CollectConfigProps as UiActionsCollectConfigProps,
} from './util';
export { export {
Trigger, Trigger,
TriggerContext, TriggerContext,
@ -57,4 +39,4 @@ export {
applyFilterTrigger, applyFilterTrigger,
} from './triggers'; } from './triggers';
export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types'; 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 createSetupContract = (): Setup => {
const setupContract: Setup = { const setupContract: Setup = {
addTriggerAction: jest.fn(),
attachAction: jest.fn(), attachAction: jest.fn(),
detachAction: jest.fn(), detachAction: jest.fn(),
registerAction: jest.fn(), registerAction: jest.fn(),
registerActionFactory: jest.fn(),
registerTrigger: jest.fn(), registerTrigger: jest.fn(),
unregisterAction: jest.fn(),
}; };
return setupContract; return setupContract;
}; };
@ -42,21 +39,16 @@ const createSetupContract = (): Setup => {
const createStartContract = (): Start => { const createStartContract = (): Start => {
const startContract: Start = { const startContract: Start = {
attachAction: jest.fn(), attachAction: jest.fn(),
unregisterAction: jest.fn(), registerAction: jest.fn(),
addTriggerAction: jest.fn(), registerTrigger: jest.fn(),
clear: jest.fn(), getAction: jest.fn(),
detachAction: jest.fn(), detachAction: jest.fn(),
executeTriggerActions: jest.fn(), executeTriggerActions: jest.fn(),
fork: jest.fn(),
getAction: jest.fn(),
getActionFactories: jest.fn(),
getActionFactory: jest.fn(),
getTrigger: jest.fn(), getTrigger: jest.fn(),
getTriggerActions: jest.fn((id: TriggerId) => []), getTriggerActions: jest.fn((id: TriggerId) => []),
getTriggerCompatibleActions: jest.fn(), getTriggerCompatibleActions: jest.fn(),
registerAction: jest.fn(), clear: jest.fn(),
registerActionFactory: jest.fn(), fork: jest.fn(),
registerTrigger: jest.fn(),
}; };
return startContract; return startContract;

View file

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

View file

@ -18,13 +18,7 @@
*/ */
import { UiActionsService } from './ui_actions_service'; import { UiActionsService } from './ui_actions_service';
import { import { Action, createAction } from '../actions';
Action,
ActionInternal,
createAction,
ActionFactoryDefinition,
ActionFactory,
} from '../actions';
import { createHelloWorldAction } from '../tests/test_samples'; import { createHelloWorldAction } from '../tests/test_samples';
import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types'; import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types';
import { Trigger } from '../triggers'; import { Trigger } from '../triggers';
@ -108,21 +102,6 @@ describe('UiActionsService', () => {
type: 'test' as ActionType, 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()', () => { describe('.getTriggerActions()', () => {
@ -160,14 +139,13 @@ describe('UiActionsService', () => {
expect(list0).toHaveLength(0); expect(list0).toHaveLength(0);
service.addTriggerAction(FOO_TRIGGER, action1); service.attachAction(FOO_TRIGGER, action1);
const list1 = service.getTriggerActions(FOO_TRIGGER); const list1 = service.getTriggerActions(FOO_TRIGGER);
expect(list1).toHaveLength(1); expect(list1).toHaveLength(1);
expect(list1[0]).toBeInstanceOf(ActionInternal); expect(list1).toEqual([action1]);
expect(list1[0].id).toBe(action1.id);
service.addTriggerAction(FOO_TRIGGER, action2); service.attachAction(FOO_TRIGGER, action2);
const list2 = service.getTriggerActions(FOO_TRIGGER); const list2 = service.getTriggerActions(FOO_TRIGGER);
expect(list2).toHaveLength(2); expect(list2).toHaveLength(2);
@ -186,7 +164,7 @@ describe('UiActionsService', () => {
service.registerAction(helloWorldAction); service.registerAction(helloWorldAction);
expect(actions.size - length).toBe(1); 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 () => { test('getTriggerCompatibleActions returns attached actions', async () => {
@ -200,7 +178,7 @@ describe('UiActionsService', () => {
title: 'My trigger', title: 'My trigger',
}; };
service.registerTrigger(testTrigger); service.registerTrigger(testTrigger);
service.addTriggerAction(MY_TRIGGER, helloWorldAction); service.attachAction(MY_TRIGGER, helloWorldAction);
const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, { const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, {
hi: 'there', hi: 'there',
@ -226,7 +204,7 @@ describe('UiActionsService', () => {
}; };
service.registerTrigger(testTrigger); service.registerTrigger(testTrigger);
service.addTriggerAction(testTrigger.id, action); service.attachAction(testTrigger.id, action);
const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, { const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, {
accept: true, accept: true,
@ -310,7 +288,7 @@ describe('UiActionsService', () => {
id: FOO_TRIGGER, id: FOO_TRIGGER,
}); });
service1.registerAction(testAction1); service1.registerAction(testAction1);
service1.addTriggerAction(FOO_TRIGGER, testAction1); service1.attachAction(FOO_TRIGGER, testAction1);
const service2 = service1.fork(); const service2 = service1.fork();
@ -331,14 +309,14 @@ describe('UiActionsService', () => {
}); });
service1.registerAction(testAction1); service1.registerAction(testAction1);
service1.registerAction(testAction2); service1.registerAction(testAction2);
service1.addTriggerAction(FOO_TRIGGER, testAction1); service1.attachAction(FOO_TRIGGER, testAction1);
const service2 = service1.fork(); const service2 = service1.fork();
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.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(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
@ -352,14 +330,14 @@ describe('UiActionsService', () => {
}); });
service1.registerAction(testAction1); service1.registerAction(testAction1);
service1.registerAction(testAction2); service1.registerAction(testAction2);
service1.addTriggerAction(FOO_TRIGGER, testAction1); service1.attachAction(FOO_TRIGGER, testAction1);
const service2 = service1.fork(); const service2 = service1.fork();
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.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(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
@ -414,7 +392,7 @@ describe('UiActionsService', () => {
} as any; } as any;
service.registerTrigger(trigger); service.registerTrigger(trigger);
service.addTriggerAction(MY_TRIGGER, action); service.attachAction(MY_TRIGGER, action);
const actions = service.getTriggerActions(trigger.id); const actions = service.getTriggerActions(trigger.id);
@ -422,7 +400,7 @@ describe('UiActionsService', () => {
expect(actions[0].id).toBe(ACTION_HELLO_WORLD); 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 service = new UiActionsService();
const trigger: Trigger = { const trigger: Trigger = {
@ -435,7 +413,7 @@ describe('UiActionsService', () => {
service.registerTrigger(trigger); service.registerTrigger(trigger);
service.registerAction(action); service.registerAction(action);
service.addTriggerAction(trigger.id, action); service.attachAction(trigger.id, action);
service.detachAction(trigger.id, action.id); service.detachAction(trigger.id, action.id);
const actions2 = service.getTriggerActions(trigger.id); const actions2 = service.getTriggerActions(trigger.id);
@ -467,7 +445,7 @@ describe('UiActionsService', () => {
} as any; } as any;
service.registerAction(action); 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].' '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, TriggerId,
TriggerContextMapping, TriggerContextMapping,
ActionType, ActionType,
ActionFactoryRegistry,
} from '../types'; } from '../types';
import { import { Action, ActionByType } from '../actions';
ActionInternal,
Action,
ActionByType,
ActionFactory,
ActionDefinition,
ActionFactoryDefinition,
ActionContext,
} from '../actions';
import { Trigger, TriggerContext } from '../triggers/trigger'; import { Trigger, TriggerContext } from '../triggers/trigger';
import { TriggerInternal } from '../triggers/trigger_internal'; import { TriggerInternal } from '../triggers/trigger_internal';
import { TriggerContract } from '../triggers/trigger_contract'; 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`. * A 1-to-N mapping from `Trigger` to zero or more `Action`.
*/ */
readonly triggerToActions?: TriggerToActionsRegistry; readonly triggerToActions?: TriggerToActionsRegistry;
readonly actionFactories?: ActionFactoryRegistry;
} }
export class UiActionsService { export class UiActionsService {
protected readonly triggers: TriggerRegistry; protected readonly triggers: TriggerRegistry;
protected readonly actions: ActionRegistry; protected readonly actions: ActionRegistry;
protected readonly triggerToActions: TriggerToActionsRegistry; protected readonly triggerToActions: TriggerToActionsRegistry;
protected readonly actionFactories: ActionFactoryRegistry;
constructor({ constructor({
triggers = new Map(), triggers = new Map(),
actions = new Map(), actions = new Map(),
triggerToActions = new Map(), triggerToActions = new Map(),
actionFactories = new Map(),
}: UiActionsServiceParams = {}) { }: UiActionsServiceParams = {}) {
this.triggers = triggers; this.triggers = triggers;
this.actions = actions; this.actions = actions;
this.triggerToActions = triggerToActions; this.triggerToActions = triggerToActions;
this.actionFactories = actionFactories;
} }
public readonly registerTrigger = (trigger: Trigger) => { public readonly registerTrigger = (trigger: Trigger) => {
@ -89,44 +76,49 @@ export class UiActionsService {
return trigger.contract; return trigger.contract;
}; };
public readonly registerAction = <A extends ActionDefinition>( public readonly registerAction = <T extends ActionType>(action: ActionByType<T>) => {
definition: A if (this.actions.has(action.id)) {
): ActionInternal<A> => { throw new Error(`Action [action.id = ${action.id}] already registered.`);
if (this.actions.has(definition.id)) {
throw new Error(`Action [action.id = ${definition.id}] already registered.`);
} }
const action = new ActionInternal(definition);
this.actions.set(action.id, action); this.actions.set(action.id, action);
return action;
}; };
public readonly unregisterAction = (actionId: string): void => { public readonly getAction = <T extends ActionType>(id: string): ActionByType<T> => {
if (!this.actions.has(actionId)) { if (!this.actions.has(id)) {
throw new Error(`Action [action.id = ${actionId}] is not registered.`); 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>( public readonly attachAction = <TType extends TriggerId, AType extends ActionType>(
triggerId: TriggerId, triggerId: TType,
actionId: string // 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 => { ): 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); const trigger = this.triggers.get(triggerId);
if (!trigger) { if (!trigger) {
throw new Error( 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); const actionIds = this.triggerToActions.get(triggerId);
if (!actionIds!.find(id => id === actionId)) { if (!actionIds!.find(id => id === action.id)) {
this.triggerToActions.set(triggerId, [...actionIds!, actionId]); 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>( public readonly getTriggerActions = <T extends TriggerId>(
triggerId: T triggerId: T
): Array<Action<TriggerContextMapping[T]>> => { ): Array<Action<TriggerContextMapping[T]>> => {
@ -175,9 +147,9 @@ export class UiActionsService {
const actionIds = this.triggerToActions.get(triggerId); const actionIds = this.triggerToActions.get(triggerId);
const actions = actionIds! const actions = actionIds!.map(actionId => this.actions.get(actionId)).filter(Boolean) as Array<
.map(actionId => this.actions.get(actionId) as ActionInternal) Action<TriggerContextMapping[T]>
.filter(Boolean); >;
return actions as Array<Action<TriggerContext<T>>>; return actions as Array<Action<TriggerContext<T>>>;
}; };
@ -215,7 +187,6 @@ export class UiActionsService {
this.actions.clear(); this.actions.clear();
this.triggers.clear(); this.triggers.clear();
this.triggerToActions.clear(); this.triggerToActions.clear();
this.actionFactories.clear();
}; };
/** /**
@ -235,41 +206,4 @@ export class UiActionsService {
return new UiActionsService({ triggers, actions, triggerToActions }); 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); const action = createTestAction('test1', () => true);
setup.registerTrigger(trigger); setup.registerTrigger(trigger);
setup.addTriggerAction(trigger.id, action); setup.attachAction(trigger.id, action);
const context = {}; const context = {};
const start = doStart(); const start = doStart();
@ -109,7 +109,7 @@ test('does not execute an incompatible action', async () => {
); );
setup.registerTrigger(trigger); setup.registerTrigger(trigger);
setup.addTriggerAction(trigger.id, action); setup.attachAction(trigger.id, action);
const start = doStart(); const start = doStart();
const context = { 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); const action2 = createTestAction('test2', () => true);
setup.registerTrigger(trigger); setup.registerTrigger(trigger);
setup.addTriggerAction(trigger.id, action1); setup.attachAction(trigger.id, action1);
setup.addTriggerAction(trigger.id, action2); setup.attachAction(trigger.id, action2);
expect(openContextMenu).toHaveBeenCalledTimes(0); expect(openContextMenu).toHaveBeenCalledTimes(0);
@ -155,7 +155,7 @@ test('passes whole action context to isCompatible()', async () => {
}); });
setup.registerTrigger(trigger); setup.registerTrigger(trigger);
setup.addTriggerAction(trigger.id, action); setup.attachAction(trigger.id, action);
const start = doStart(); const start = doStart();

View file

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

View file

@ -37,7 +37,7 @@ beforeEach(() => {
id: 'trigger' as TriggerId, id: 'trigger' as TriggerId,
title: 'trigger', title: 'trigger',
}); });
uiActions.setup.addTriggerAction('trigger' as TriggerId, action); uiActions.setup.attachAction('trigger' as TriggerId, action);
}); });
test('can register action', async () => { test('can register action', async () => {
@ -58,7 +58,7 @@ test('getTriggerCompatibleActions returns attached actions', async () => {
title: 'My trigger', title: 'My trigger',
}; };
setup.registerTrigger(testTrigger); setup.registerTrigger(testTrigger);
setup.addTriggerAction('MY-TRIGGER' as TriggerId, helloWorldAction); setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction);
const start = doStart(); const start = doStart();
const actions = await start.getTriggerCompatibleActions('MY-TRIGGER' as TriggerId, {}); 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.registerTrigger(testTrigger);
setup.registerAction(action1); setup.registerAction(action1);
setup.addTriggerAction(testTrigger.id, action1); setup.attachAction(testTrigger.id, action1);
const start = doStart(); const start = doStart();
let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true }); let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true });

View file

@ -16,5 +16,4 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
export { createHelloWorldAction } from './hello_world_action'; 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 SELECT_RANGE_TRIGGER = 'SELECT_RANGE_TRIGGER';
export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = { export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = {
id: SELECT_RANGE_TRIGGER, id: SELECT_RANGE_TRIGGER,
title: '', title: 'Select range',
description: 'Applies a range filter', description: 'Applies a range filter',
}; };

View file

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

View file

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

View file

@ -17,17 +17,15 @@
* under the License. * under the License.
*/ */
import { ActionInternal } from './actions/action_internal'; import { ActionByType } from './actions/action';
import { TriggerInternal } from './triggers/trigger_internal'; import { TriggerInternal } from './triggers/trigger_internal';
import { ActionFactory } from './actions';
import { EmbeddableVisTriggerContext, IEmbeddable } from '../../embeddable/public'; import { EmbeddableVisTriggerContext, IEmbeddable } from '../../embeddable/public';
import { Filter } from '../../data/public'; import { Filter } from '../../data/public';
import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers';
export type TriggerRegistry = Map<TriggerId, TriggerInternal<any>>; 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 TriggerToActionsRegistry = Map<TriggerId, string[]>;
export type ActionFactoryRegistry = Map<string, ActionFactory>;
const DEFAULT_TRIGGER = ''; 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 sayHelloAction = new SayHelloAction(alert);
const sendMessageAction = createSendMessageAction(core.overlays); const sendMessageAction = createSendMessageAction(core.overlays);
plugins.uiActions.registerAction(helloWorldAction);
plugins.uiActions.registerAction(sayHelloAction); plugins.uiActions.registerAction(sayHelloAction);
plugins.uiActions.registerAction(sendMessageAction); plugins.uiActions.registerAction(sendMessageAction);
plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, helloWorldAction); plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction);
plugins.__LEGACY.onRenderComplete(() => { plugins.__LEGACY.onRenderComplete(() => {
const root = document.getElementById(REACT_ROOT_ID); const root = document.getElementById(REACT_ROOT_ID);

View file

@ -62,4 +62,5 @@ function createSamplePanelAction() {
} }
const action = 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(); 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.beatsManagement": "legacy/plugins/beats_management",
"xpack.canvas": "legacy/plugins/canvas", "xpack.canvas": "legacy/plugins/canvas",
"xpack.crossClusterReplication": "legacy/plugins/cross_cluster_replication", "xpack.crossClusterReplication": "legacy/plugins/cross_cluster_replication",
"xpack.dashboard": "plugins/dashboard_enhanced",
"xpack.dashboardMode": "legacy/plugins/dashboard_mode", "xpack.dashboardMode": "legacy/plugins/dashboard_mode",
"xpack.data": "plugins/data_enhanced", "xpack.data": "plugins/data_enhanced",
"xpack.drilldowns": "plugins/drilldowns", "xpack.drilldowns": "plugins/drilldowns",

View file

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

View file

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

View file

@ -8,14 +8,21 @@ import React from 'react';
import { cleanup, fireEvent, render } from '@testing-library/react/pure'; import { cleanup, fireEvent, render } from '@testing-library/react/pure';
import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global 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 { 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 // TODO: afterEach is not available for it globally during setup
// https://github.com/elastic/kibana/issues/59469 // https://github.com/elastic/kibana/issues/59469
afterEach(cleanup); afterEach(cleanup);
test('Pick and configure action', () => { 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 // check that all factories are displayed to pick
expect(screen.getAllByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).toHaveLength(2); 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', () => { 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 // check that no factories are displayed to pick from
expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument(); expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument();

View file

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

View file

@ -9,6 +9,6 @@ import { i18n } from '@kbn/i18n';
export const txtChangeButton = i18n.translate( export const txtChangeButton = i18n.translate(
'xpack.advancedUiActions.components.actionWizard.changeButton', '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. * 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,161 +6,124 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui';
import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; import { ActionFactory, ActionBaseConfig, ActionWizard } from './action_wizard';
import { ActionWizard } from './action_wizard';
import { ActionFactoryDefinition, ActionFactory } from '../../services';
import { CollectConfigProps } from '../../util';
type ActionBaseConfig = object;
export const dashboards = [ export const dashboards = [
{ id: 'dashboard1', title: 'Dashboard 1' }, { id: 'dashboard1', title: 'Dashboard 1' },
{ id: 'dashboard2', title: 'Dashboard 2' }, { id: 'dashboard2', title: 'Dashboard 2' },
]; ];
interface DashboardDrilldownConfig { export const dashboardDrilldownActionFactory: ActionFactory<{
dashboardId?: string; dashboardId?: string;
useCurrentFilters: boolean; useCurrentDashboardFilters: boolean;
useCurrentDateRange: boolean; useCurrentDashboardDataRange: boolean;
} }> = {
type: 'Dashboard',
function DashboardDrilldownCollectConfig(props: CollectConfigProps<DashboardDrilldownConfig>) { displayName: 'Go to Dashboard',
const config = props.config ?? { iconType: 'dashboardApp',
dashboardId: undefined,
useCurrentFilters: true,
useCurrentDateRange: true,
};
return (
<>
<EuiFormRow label="Choose destination dashboard:">
<EuiSelect
name="selectDashboard"
hasNoInitialSelection={true}
options={dashboards.map(({ id, title }) => ({ value: id, text: title }))}
value={config.dashboardId}
onChange={e => {
props.onConfig({ ...config, dashboardId: e.target.value });
}}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="useCurrentFilters"
label="Use current dashboard's filters"
checked={config.useCurrentFilters}
onChange={() =>
props.onConfig({
...config,
useCurrentFilters: !config.useCurrentFilters,
})
}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="useCurrentDateRange"
label="Use current dashboard's date range"
checked={config.useCurrentDateRange}
onChange={() =>
props.onConfig({
...config,
useCurrentDateRange: !config.useCurrentDateRange,
})
}
/>
</EuiFormRow>
</>
);
}
export const dashboardDrilldownActionFactory: ActionFactoryDefinition<
DashboardDrilldownConfig,
any,
any
> = {
id: 'Dashboard',
getDisplayName: () => 'Go to Dashboard',
getIconType: () => 'dashboardApp',
createConfig: () => { createConfig: () => {
return { return {
dashboardId: undefined, dashboardId: undefined,
useCurrentFilters: true, useCurrentDashboardDataRange: true,
useCurrentDateRange: true, useCurrentDashboardFilters: true,
}; };
}, },
isConfigValid: (config: DashboardDrilldownConfig): config is DashboardDrilldownConfig => { isValid: config => {
if (!config.dashboardId) return false; if (!config.dashboardId) return false;
return true; return true;
}, },
CollectConfig: reactToUiComponent(DashboardDrilldownCollectConfig), wizard: props => {
const config = props.config ?? {
isCompatible(context?: object): Promise<boolean> { dashboardId: undefined,
return Promise.resolve(true); useCurrentDashboardDataRange: true,
useCurrentDashboardFilters: true,
};
return (
<>
<EuiFormRow label="Choose destination dashboard:">
<EuiSelect
name="selectDashboard"
hasNoInitialSelection={true}
options={dashboards.map(({ id, title }) => ({ value: id, text: title }))}
value={config.dashboardId}
onChange={e => {
props.onConfig({ ...config, dashboardId: e.target.value });
}}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="useCurrentFilters"
label="Use current dashboard's filters"
checked={config.useCurrentDashboardFilters}
onChange={() =>
props.onConfig({
...config,
useCurrentDashboardFilters: !config.useCurrentDashboardFilters,
})
}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="useCurrentDateRange"
label="Use current dashboard's date range"
checked={config.useCurrentDashboardDataRange}
onChange={() =>
props.onConfig({
...config,
useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange,
})
}
/>
</EuiFormRow>
</>
);
}, },
order: 0,
create: () => ({
id: 'test',
execute: async () => alert('Navigate to dashboard!'),
}),
}; };
export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory); export const urlDrilldownActionFactory: ActionFactory<{ url: string; openInNewTab: boolean }> = {
type: 'Url',
interface UrlDrilldownConfig { displayName: 'Go to URL',
url: string; iconType: 'link',
openInNewTab: boolean;
}
function UrlDrilldownCollectConfig(props: CollectConfigProps<UrlDrilldownConfig>) {
const config = props.config ?? {
url: '',
openInNewTab: false,
};
return (
<>
<EuiFormRow label="Enter target URL">
<EuiFieldText
placeholder="Enter URL"
name="url"
value={config.url}
onChange={event => props.onConfig({ ...config, url: event.target.value })}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="openInNewTab"
label="Open in new tab?"
checked={config.openInNewTab}
onChange={() => props.onConfig({ ...config, openInNewTab: !config.openInNewTab })}
/>
</EuiFormRow>
</>
);
}
export const urlDrilldownActionFactory: ActionFactoryDefinition<UrlDrilldownConfig> = {
id: 'Url',
getDisplayName: () => 'Go to URL',
getIconType: () => 'link',
createConfig: () => { createConfig: () => {
return { return {
url: '', url: '',
openInNewTab: false, openInNewTab: false,
}; };
}, },
isConfigValid: (config: UrlDrilldownConfig): config is UrlDrilldownConfig => { isValid: config => {
if (!config.url) return false; if (!config.url) return false;
return true; return true;
}, },
CollectConfig: reactToUiComponent(UrlDrilldownCollectConfig), wizard: props => {
const config = props.config ?? {
order: 10, url: '',
isCompatible(context?: object): Promise<boolean> { openInNewTab: false,
return Promise.resolve(true); };
return (
<>
<EuiFormRow label="Enter target URL">
<EuiFieldText
placeholder="Enter URL"
name="url"
value={config.url}
onChange={event => props.onConfig({ ...config, url: event.target.value })}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="openInNewTab"
label="Open in new tab?"
checked={config.openInNewTab}
onChange={() => props.onConfig({ ...config, openInNewTab: !config.openInNewTab })}
/>
</EuiFormRow>
</>
);
}, },
create: () => null as any,
}; };
export const urlFactory = new ActionFactory(urlDrilldownActionFactory);
export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory<any>> }) { export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory<any>> }) {
const [state, setState] = useState<{ const [state, setState] = useState<{
currentActionFactory?: ActionFactory; currentActionFactory?: ActionFactory;
@ -194,15 +157,14 @@ export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory
changeActionFactory(newActionFactory); changeActionFactory(newActionFactory);
}} }}
currentActionFactory={state.currentActionFactory} currentActionFactory={state.currentActionFactory}
context={{}}
/> />
<div style={{ marginTop: '44px' }} /> <div style={{ marginTop: '44px' }} />
<hr /> <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>Action Factory Config: {JSON.stringify(state.config)}</div>
<div> <div>
Is config valid:{' '} Is config valid:{' '}
{JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)} {JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)}
</div> </div>
</> </>
); );

View file

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

View file

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