Dynamic uiActions & license support (#68507)

This pr adds convenient license support to dynamic uiActions in x-pack.
Works for actions created with action factories & drilldowns.

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Anton Dosov 2020-06-26 18:33:32 +02:00 committed by GitHub
parent 100a5fd18b
commit 3ac5bc5323
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 371 additions and 59 deletions

View file

@ -24,6 +24,9 @@ import { Presentable } from '../util/presentable';
import { uiToReactComponent } from '../../../kibana_react/public';
import { ActionType } from '../types';
/**
* @internal
*/
export class ActionInternal<A extends ActionDefinition = ActionDefinition>
implements Action<Context<A>>, Presentable<Context<A>> {
constructor(public readonly definition: A) {}

View file

@ -20,7 +20,7 @@
import { UiActionsService } from './ui_actions_service';
import { Action, ActionInternal, createAction } from '../actions';
import { createHelloWorldAction } from '../tests/test_samples';
import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types';
import { TriggerRegistry, TriggerId, ActionType, ActionRegistry } from '../types';
import { Trigger } from '../triggers';
// Casting to ActionType or TriggerId is a hack - in a real situation use

View file

@ -220,7 +220,6 @@ export class UiActionsService {
for (const [key, value] of this.actions.entries()) actions.set(key, value);
for (const [key, value] of this.triggerToActions.entries())
triggerToActions.set(key, [...value]);
return new UiActionsService({ triggers, actions, triggerToActions });
};
}

View file

@ -39,6 +39,8 @@ export class DashboardToUrlDrilldown implements Drilldown<Config, ActionContext>
public readonly order = 8;
readonly minimalLicense = 'gold'; // example of minimal license support
public readonly getDisplayName = () => 'Go to URL (example)';
public readonly euiIcon = 'link';

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { BehaviorSubject } from 'rxjs';
import { LicensingPluginSetup } from './types';
import { LicensingPluginSetup, LicensingPluginStart } from './types';
import { licenseMock } from '../common/licensing.mock';
const createSetupMock = () => {
@ -18,7 +18,19 @@ const createSetupMock = () => {
return mock;
};
const createStartMock = () => {
const license = licenseMock.createLicense();
const mock: jest.Mocked<LicensingPluginStart> = {
license$: new BehaviorSubject(license),
refresh: jest.fn(),
};
mock.refresh.mockResolvedValue(license);
return mock;
};
export const licensingMock = {
createSetup: createSetupMock,
createStart: createStartMock,
...licenseMock,
};

View file

@ -4,7 +4,8 @@
"configPath": ["xpack", "ui_actions_enhanced"],
"requiredPlugins": [
"embeddable",
"uiActions"
"uiActions",
"licensing"
],
"server": false,
"ui": true

View file

@ -7,7 +7,15 @@
import React from 'react';
import { cleanup, fireEvent, render } from '@testing-library/react/pure';
import { TEST_SUBJ_ACTION_FACTORY_ITEM, TEST_SUBJ_SELECTED_ACTION_FACTORY } from './action_wizard';
import { dashboardFactory, dashboards, Demo, urlFactory } from './test_data';
import {
dashboardFactory,
dashboards,
Demo,
urlFactory,
urlDrilldownActionFactory,
} from './test_data';
import { ActionFactory } from '../../dynamic_actions';
import { licenseMock } from '../../../../licensing/common/licensing.mock';
// TODO: afterEach is not available for it globally during setup
// https://github.com/elastic/kibana/issues/59469
@ -54,3 +62,19 @@ test('If only one actions factory is available then actionFactory selection is e
// check that can't change to action factory type
expect(screen.queryByTestId(/change/i)).not.toBeInTheDocument();
});
test('If not enough license, button is disabled', () => {
const urlWithGoldLicense = new ActionFactory(
{
...urlDrilldownActionFactory,
minimalLicense: 'gold',
},
() => licenseMock.createLicense()
);
const screen = render(<Demo actionFactories={[dashboardFactory, urlWithGoldLicense]} />);
// check that all factories are displayed to pick
expect(screen.getAllByTestId(new RegExp(TEST_SUBJ_ACTION_FACTORY_ITEM))).toHaveLength(2);
expect(screen.getByText(/Go to URL/i)).toBeDisabled();
});

View file

@ -10,10 +10,12 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiKeyPadMenuItem,
EuiSpacer,
EuiText,
EuiKeyPadMenuItem,
EuiToolTip,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { txtChangeButton } from './i18n';
import './action_wizard.scss';
import { ActionFactory } from '../../dynamic_actions';
@ -61,7 +63,11 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
context,
}) => {
// auto pick action factory if there is only 1 available
if (!currentActionFactory && actionFactories.length === 1) {
if (
!currentActionFactory &&
actionFactories.length === 1 &&
actionFactories[0].isCompatibleLicence()
) {
onActionFactoryChange(actionFactories[0]);
}
@ -175,24 +181,46 @@ const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({
willChange: 'opacity',
};
/**
* make sure not compatible factories are in the end
*/
const ensureOrder = (factories: ActionFactory[]) => {
const compatibleLicense = factories.filter((f) => f.isCompatibleLicence());
const notCompatibleLicense = factories.filter((f) => !f.isCompatibleLicence());
return [
...compatibleLicense.sort((f1, f2) => f2.order - f1.order),
...notCompatibleLicense.sort((f1, f2) => f2.order - f1.order),
];
};
return (
<EuiFlexGroup gutterSize="m" wrap={true} style={firefoxBugFix}>
{[...actionFactories]
.sort((f1, f2) => f2.order - f1.order)
.map((actionFactory) => (
<EuiFlexItem grow={false} key={actionFactory.id}>
{ensureOrder(actionFactories).map((actionFactory) => (
<EuiFlexItem grow={false} key={actionFactory.id}>
<EuiToolTip
content={
!actionFactory.isCompatibleLicence() && (
<FormattedMessage
defaultMessage="Insufficient license level"
id="xpack.uiActionsEnhanced.components.actionWizard.insufficientLicenseLevelTooltip"
/>
)
}
>
<EuiKeyPadMenuItem
className="auaActionWizard__actionFactoryItem"
label={actionFactory.getDisplayName(context)}
data-test-subj={`${TEST_SUBJ_ACTION_FACTORY_ITEM}-${actionFactory.id}`}
onClick={() => onActionFactorySelected(actionFactory)}
disabled={!actionFactory.isCompatibleLicence()}
>
{actionFactory.getIconType(context) && (
<EuiIcon type={actionFactory.getIconType(context)!} size="m" />
)}
</EuiKeyPadMenuItem>
</EuiFlexItem>
))}
</EuiToolTip>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};

View file

@ -10,6 +10,7 @@ import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/p
import { ActionWizard } from './action_wizard';
import { ActionFactoryDefinition, ActionFactory } from '../../dynamic_actions';
import { CollectConfigProps } from '../../../../../../src/plugins/kibana_utils/public';
import { licenseMock } from '../../../../licensing/common/licensing.mock';
type ActionBaseConfig = object;
@ -101,10 +102,13 @@ export const dashboardDrilldownActionFactory: ActionFactoryDefinition<
create: () => ({
id: 'test',
execute: async () => alert('Navigate to dashboard!'),
enhancements: {},
}),
};
export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory);
export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory, () =>
licenseMock.createLicense()
);
interface UrlDrilldownConfig {
url: string;
@ -159,7 +163,9 @@ export const urlDrilldownActionFactory: ActionFactoryDefinition<UrlDrilldownConf
create: () => null as any,
};
export const urlFactory = new ActionFactory(urlDrilldownActionFactory);
export const urlFactory = new ActionFactory(urlDrilldownActionFactory, () =>
licenseMock.createLicense()
);
export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory<any>> }) {
const [state, setState] = useState<{

View file

@ -18,6 +18,8 @@ import {
import { useContainerState } from '../../../../../../../src/plugins/kibana_utils/public';
import { DrilldownListItem } from '../list_manage_drilldowns';
import {
insufficientLicenseLevel,
invalidDrilldownType,
toastDrilldownCreated,
toastDrilldownDeleted,
toastDrilldownEdited,
@ -133,6 +135,11 @@ export function createFlyoutManageDrilldowns({
drilldownName: drilldown.action.name,
actionName: actionFactory?.getDisplayName(factoryContext) ?? drilldown.action.factoryId,
icon: actionFactory?.getIconType(factoryContext),
error: !actionFactory
? invalidDrilldownType(drilldown.action.factoryId) // this shouldn't happen for the end user, but useful during development
: !actionFactory.isCompatibleLicence()
? insufficientLicenseLevel
: undefined,
};
}

View file

@ -86,3 +86,23 @@ export const toastDrilldownsCRUDError = i18n.translate(
description: 'Title for generic error toast when persisting drilldown updates failed',
}
);
export const insufficientLicenseLevel = i18n.translate(
'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.insufficientLicenseLevelError',
{
defaultMessage: 'Insufficient license level',
description:
'User created drilldown with higher license type, but then downgraded the license. This error is shown in the list near created drilldown',
}
);
export const invalidDrilldownType = (type: string) =>
i18n.translate(
'xpack.uiActionsEnhanced.drilldowns.components.flyoutDrilldownWizard.invalidDrilldownType',
{
defaultMessage: "Drilldown type {type} doesn't exist",
values: {
type,
},
}
);

View file

@ -15,7 +15,7 @@ storiesOf('components/FlyoutListManageDrilldowns', module).add('default', () =>
drilldowns={[
{ id: '1', actionName: 'Dashboard', drilldownName: 'Drilldown 1' },
{ id: '2', actionName: 'Dashboard', drilldownName: 'Drilldown 2' },
{ id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3' },
{ id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3', error: 'Some error...' },
]}
/>
</EuiFlyout>

View file

@ -5,11 +5,14 @@
*/
import React from 'react';
import { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer } from '@elastic/eui';
import { EuiFieldText, EuiForm, EuiFormRow, EuiLink, EuiSpacer, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { txtDrilldownAction, txtNameOfDrilldown, txtUntitledDrilldown } from './i18n';
import { ActionFactory } from '../../../dynamic_actions';
import { ActionWizard } from '../../../components/action_wizard';
const GET_MORE_ACTIONS_LINK = 'https://www.elastic.co/subscriptions';
const noopFn = () => {};
export interface FormDrilldownWizardProps {
@ -49,10 +52,32 @@ export const FormDrilldownWizard: React.FC<FormDrilldownWizardProps> = ({
</EuiFormRow>
);
const hasNotCompatibleLicenseFactory = () =>
actionFactories?.some((f) => !f.isCompatibleLicence());
const renderGetMoreActionsLink = () => (
<EuiText size="s">
<EuiLink
href={GET_MORE_ACTIONS_LINK}
target="_blank"
external
data-test-subj={'getMoreActionsLink'}
>
<FormattedMessage
id="xpack.uiActionsEnhanced.drilldowns.components.FormDrilldownWizard.getMoreActionsLinkLabel"
defaultMessage="Get more actions"
/>
</EuiLink>
</EuiText>
);
const actionWizard = (
<EuiFormRow
label={actionFactories?.length > 1 ? txtDrilldownAction : undefined}
fullWidth={true}
labelAppend={
!currentActionFactory && hasNotCompatibleLicenseFactory() && renderGetMoreActionsLink()
}
>
<ActionWizard
actionFactories={actionFactories}

View file

@ -19,7 +19,7 @@ afterEach(cleanup);
const drilldowns: DrilldownListItem[] = [
{ id: '1', actionName: 'Dashboard', drilldownName: 'Drilldown 1' },
{ id: '2', actionName: 'Dashboard', drilldownName: 'Drilldown 2' },
{ id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3' },
{ id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3', error: 'an error' },
];
test('Render list of drilldowns', () => {
@ -67,3 +67,8 @@ test('Can delete drilldowns', () => {
expect(fn).toBeCalledWith([drilldowns[1].id, drilldowns[2].id]);
});
test('Error is displayed', () => {
const screen = render(<ListManageDrilldowns drilldowns={drilldowns} />);
expect(screen.getByLabelText('an error')).toBeInTheDocument();
});

View file

@ -14,6 +14,7 @@ import {
EuiIcon,
EuiSpacer,
EuiTextColor,
EuiToolTip,
} from '@elastic/eui';
import React, { useState } from 'react';
import {
@ -28,6 +29,7 @@ export interface DrilldownListItem {
actionName: string;
drilldownName: string;
icon?: string;
error?: string;
}
export interface ListManageDrilldownsProps {
@ -52,11 +54,27 @@ export function ListManageDrilldowns({
const columns: Array<EuiBasicTableColumn<DrilldownListItem>> = [
{
field: 'drilldownName',
name: 'Name',
truncateText: true,
width: '50%',
'data-test-subj': 'drilldownListItemName',
render: (drilldown: DrilldownListItem) => (
<div>
{drilldown.drilldownName}{' '}
{drilldown.error && (
<EuiToolTip id={`drilldownError-${drilldown.id}`} content={drilldown.error}>
<EuiIcon
type="alert"
color="danger"
title={drilldown.error}
aria-label={drilldown.error}
data-test-subj={`drilldownError-${drilldown.id}`}
style={{ marginLeft: '4px' }} /* a bit of spacing from text */
/>
</EuiToolTip>
)}
</div>
),
},
{
name: 'Action',

View file

@ -5,6 +5,7 @@
*/
import { ActionFactoryDefinition } from '../dynamic_actions';
import { LicenseType } from '../../../licensing/public';
/**
* This is a convenience interface to register a drilldown. Drilldown has
@ -28,6 +29,12 @@ export interface DrilldownDefinition<
*/
id: string;
/**
* Minimal licence level
* Empty means no restrictions
*/
minimalLicense?: LicenseType;
/**
* Determines the display order of the drilldowns in the flyout picker.
* Higher numbers are displayed first.

View file

@ -0,0 +1,46 @@
/*
* 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 { ActionFactory } from './action_factory';
import { ActionFactoryDefinition } from './action_factory_definition';
import { licensingMock } from '../../../licensing/public/mocks';
const def: ActionFactoryDefinition = {
id: 'ACTION_FACTORY_1',
CollectConfig: {} as any,
createConfig: () => ({}),
isConfigValid: (() => true) as any,
create: ({ name }) => ({
id: '',
execute: async () => {},
getDisplayName: () => name,
enhancements: {},
}),
};
describe('License & ActionFactory', () => {
test('no license requirements', async () => {
const factory = new ActionFactory(def, () => licensingMock.createLicense());
expect(await factory.isCompatible({})).toBe(true);
expect(factory.isCompatibleLicence()).toBe(true);
});
test('not enough license level', async () => {
const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () =>
licensingMock.createLicense()
);
expect(await factory.isCompatible({})).toBe(true);
expect(factory.isCompatibleLicence()).toBe(false);
});
test('enough license level', async () => {
const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () =>
licensingMock.createLicense({ license: { type: 'gold' } })
);
expect(await factory.isCompatible({})).toBe(true);
expect(factory.isCompatibleLicence()).toBe(true);
});
});

View file

@ -5,13 +5,12 @@
*/
import { uiToReactComponent } from '../../../../../src/plugins/kibana_react/public';
import {
UiActionsActionDefinition as ActionDefinition,
UiActionsPresentable as Presentable,
} from '../../../../../src/plugins/ui_actions/public';
import { UiActionsPresentable as Presentable } from '../../../../../src/plugins/ui_actions/public';
import { ActionFactoryDefinition } from './action_factory_definition';
import { Configurable } from '../../../../../src/plugins/kibana_utils/public';
import { SerializedAction } from './types';
import { ILicense } from '../../../licensing/public';
import { UiActionsActionDefinition as ActionDefinition } from '../../../../../src/plugins/ui_actions/public';
export class ActionFactory<
Config extends object = object,
@ -19,10 +18,12 @@ export class ActionFactory<
ActionContext extends object = object
> implements Omit<Presentable<FactoryContext>, 'getHref'>, Configurable<Config, FactoryContext> {
constructor(
protected readonly def: ActionFactoryDefinition<Config, FactoryContext, ActionContext>
protected readonly def: ActionFactoryDefinition<Config, FactoryContext, ActionContext>,
protected readonly getLicence: () => ILicense
) {}
public readonly id = this.def.id;
public readonly minimalLicense = this.def.minimalLicense;
public readonly order = this.def.order || 0;
public readonly MenuItem? = this.def.MenuItem;
public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined;
@ -51,9 +52,26 @@ export class ActionFactory<
return await this.def.isCompatible(context);
}
/**
* Does this action factory licence requirements
* compatible with current license?
*/
public isCompatibleLicence() {
if (!this.minimalLicense) return true;
return this.getLicence().hasAtLeast(this.minimalLicense);
}
public create(
serializedAction: Omit<SerializedAction<Config>, 'factoryId'>
): ActionDefinition<ActionContext> {
return this.def.create(serializedAction);
const action = this.def.create(serializedAction);
return {
...action,
isCompatible: async (context: ActionContext): Promise<boolean> => {
if (!this.isCompatibleLicence()) return false;
if (!action.isCompatible) return true;
return action.isCompatible(context);
},
};
}
}

View file

@ -4,12 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Configurable } from '../../../../../src/plugins/kibana_utils/public';
import { SerializedAction } from './types';
import { LicenseType } from '../../../licensing/public';
import {
UiActionsActionDefinition as ActionDefinition,
UiActionsPresentable as Presentable,
} from '../../../../../src/plugins/ui_actions/public';
import { Configurable } from '../../../../../src/plugins/kibana_utils/public';
import { SerializedAction } from './types';
/**
* This is a convenience interface for registering new action factories.
@ -28,6 +29,12 @@ export interface ActionFactoryDefinition<
*/
id: string;
/**
* Minimal licence level
* Empty means no licence restrictions
*/
readonly minimalLicense?: LicenseType;
/**
* This method should return a definition of a new action, normally used to
* register it in `ui_actions` registry.

View file

@ -7,11 +7,12 @@
import { DynamicActionManager } from './dynamic_action_manager';
import { ActionStorage, MemoryActionStorage } from './dynamic_action_storage';
import { UiActionsService } from '../../../../../src/plugins/ui_actions/public';
import { ActionInternal } from '../../../../../src/plugins/ui_actions/public/actions';
import { ActionRegistry } from '../../../../../src/plugins/ui_actions/public/types';
import { of } from '../../../../../src/plugins/kibana_utils';
import { UiActionsServiceEnhancements } from '../services';
import { ActionFactoryDefinition } from './action_factory_definition';
import { SerializedAction, SerializedEvent } from './types';
import { licensingMock } from '../../../licensing/public/mocks';
const actionFactoryDefinition1: ActionFactoryDefinition = {
id: 'ACTION_FACTORY_1',
@ -67,14 +68,21 @@ const event3: SerializedEvent = {
},
};
const setup = (events: readonly SerializedEvent[] = []) => {
const setup = (
events: readonly SerializedEvent[] = [],
{ getLicenseInfo = () => licensingMock.createLicense() } = {
getLicenseInfo: () => licensingMock.createLicense(),
}
) => {
const isCompatible = async () => true;
const storage: ActionStorage = new MemoryActionStorage(events);
const actions = new Map<string, ActionInternal>();
const actions: ActionRegistry = new Map();
const uiActions = new UiActionsService({
actions,
});
const uiActionsEnhancements = new UiActionsServiceEnhancements();
const uiActionsEnhancements = new UiActionsServiceEnhancements({
getLicenseInfo,
});
const manager = new DynamicActionManager({
isCompatible,
storage,
@ -95,6 +103,9 @@ const setup = (events: readonly SerializedEvent[] = []) => {
};
describe('DynamicActionManager', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('can instantiate', () => {
const { manager } = setup([event1]);
expect(manager).toBeInstanceOf(DynamicActionManager);
@ -103,11 +114,11 @@ describe('DynamicActionManager', () => {
describe('.start()', () => {
test('instantiates stored events', async () => {
const { manager, actions, uiActions } = setup([event1]);
const create1 = jest.fn();
const create2 = jest.fn();
const create1 = jest.spyOn(actionFactoryDefinition1, 'create');
const create2 = jest.spyOn(actionFactoryDefinition2, 'create');
uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 });
uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 });
uiActions.registerActionFactory(actionFactoryDefinition1);
uiActions.registerActionFactory(actionFactoryDefinition2);
expect(create1).toHaveBeenCalledTimes(0);
expect(create2).toHaveBeenCalledTimes(0);
@ -122,11 +133,11 @@ describe('DynamicActionManager', () => {
test('does nothing when no events stored', async () => {
const { manager, actions, uiActions } = setup();
const create1 = jest.fn();
const create2 = jest.fn();
const create1 = jest.spyOn(actionFactoryDefinition1, 'create');
const create2 = jest.spyOn(actionFactoryDefinition2, 'create');
uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 });
uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 });
uiActions.registerActionFactory(actionFactoryDefinition1);
uiActions.registerActionFactory(actionFactoryDefinition2);
expect(create1).toHaveBeenCalledTimes(0);
expect(create2).toHaveBeenCalledTimes(0);
@ -207,11 +218,9 @@ describe('DynamicActionManager', () => {
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 });
uiActions.registerActionFactory(actionFactoryDefinition1);
uiActions.registerActionFactory(actionFactoryDefinition2);
expect(actions.size).toBe(0);
@ -632,4 +641,42 @@ describe('DynamicActionManager', () => {
});
});
});
test('revived actions incompatible when license is not enough', async () => {
const getLicenseInfo = jest.fn(() =>
licensingMock.createLicense({ license: { type: 'basic' } })
);
const { manager, uiActions } = setup([event1, event3], { getLicenseInfo });
const basicActionFactory: ActionFactoryDefinition = {
...actionFactoryDefinition1,
minimalLicense: 'basic',
};
const goldActionFactory: ActionFactoryDefinition = {
...actionFactoryDefinition2,
minimalLicense: 'gold',
};
uiActions.registerActionFactory(basicActionFactory);
uiActions.registerActionFactory(goldActionFactory);
await manager.start();
const basicActions = await uiActions.getTriggerCompatibleActions(
'VALUE_CLICK_TRIGGER',
{} as any
);
expect(basicActions).toHaveLength(1);
getLicenseInfo.mockImplementation(() =>
licensingMock.createLicense({ license: { type: 'gold' } })
);
const basicAndGoldActions = await uiActions.getTriggerCompatibleActions(
'VALUE_CLICK_TRIGGER',
{} as any
);
expect(basicAndGoldActions).toHaveLength(2);
});
});

View file

@ -72,14 +72,18 @@ export class DynamicActionManager {
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);
const factory = uiActions.getActionFactory(event.action.factoryId);
const actionDefinition: ActionDefinition = factory.create(action as SerializedAction<object>);
uiActions.registerAction({
...actionDefinition,
id: actionId,
isCompatible: async (context) => {
if (!(await isCompatible(context))) return false;
if (!actionDefinition.isCompatible) return true;
return actionDefinition.isCompatible(context);
},
});
for (const trigger of triggers) uiActions.attachAction(trigger as any, actionId);
}

View file

@ -10,6 +10,7 @@ import { uiActionsPluginMock } from '../../../../src/plugins/ui_actions/public/m
import { embeddablePluginMock } from '../../../../src/plugins/embeddable/public/mocks';
import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '.';
import { plugin as pluginInitializer } from '.';
import { licensingMock } from '../../licensing/public/mocks';
export type Setup = jest.Mocked<AdvancedUiActionsSetup>;
export type Start = jest.Mocked<AdvancedUiActionsStart>;
@ -62,6 +63,7 @@ const createPlugin = (
return plugin.start(anotherCoreStart, {
uiActions: uiActionsStart,
embeddable: embeddableStart,
licensing: licensingMock.createStart(),
});
},
};

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { BehaviorSubject, Subscription } from 'rxjs';
import {
PluginInitializerContext,
CoreSetup,
@ -31,6 +32,7 @@ import {
} from './custom_time_range_badge';
import { CommonlyUsedRange } from './types';
import { UiActionsServiceEnhancements } from './services';
import { ILicense, LicensingPluginStart } from '../../licensing/public';
import { createFlyoutManageDrilldowns } from './drilldowns';
import { Storage } from '../../../../src/plugins/kibana_utils/public';
@ -42,6 +44,7 @@ interface SetupDependencies {
interface StartDependencies {
embeddable: EmbeddableStart;
uiActions: UiActionsStart;
licensing: LicensingPluginStart;
}
export interface SetupContract
@ -63,7 +66,19 @@ declare module '../../../../src/plugins/ui_actions/public' {
export class AdvancedUiActionsPublicPlugin
implements Plugin<SetupContract, StartContract, SetupDependencies, StartDependencies> {
private readonly enhancements = new UiActionsServiceEnhancements();
readonly licenceInfo = new BehaviorSubject<ILicense | undefined>(undefined);
private getLicenseInfo(): ILicense {
if (!this.licenceInfo.getValue()) {
throw new Error(
'AdvancedUiActionsPublicPlugin: Licence is not ready! Licence becomes available only after setup.'
);
}
return this.licenceInfo.getValue()!;
}
private readonly enhancements = new UiActionsServiceEnhancements({
getLicenseInfo: () => this.getLicenseInfo(),
});
private subs: Subscription[] = [];
constructor(initializerContext: PluginInitializerContext) {}
@ -74,7 +89,9 @@ export class AdvancedUiActionsPublicPlugin
};
}
public start(core: CoreStart, { uiActions }: StartDependencies): StartContract {
public start(core: CoreStart, { uiActions, licensing }: StartDependencies): StartContract {
this.subs.push(licensing.license$.subscribe(this.licenceInfo));
const dateFormat = core.uiSettings.get('dateFormat') as string;
const commonlyUsedRanges = core.uiSettings.get(
UI_SETTINGS.TIMEPICKER_QUICK_RANGES
@ -106,5 +123,7 @@ export class AdvancedUiActionsPublicPlugin
};
}
public stop() {}
public stop() {
this.subs.forEach((s) => s.unsubscribe());
}
}

View file

@ -6,6 +6,9 @@
import { UiActionsServiceEnhancements } from './ui_actions_service_enhancements';
import { ActionFactoryDefinition, ActionFactory } from '../dynamic_actions';
import { licensingMock } from '../../../licensing/public/mocks';
const getLicenseInfo = () => licensingMock.createLicense();
describe('UiActionsService', () => {
describe('action factories', () => {
@ -25,7 +28,7 @@ describe('UiActionsService', () => {
};
test('.getActionFactories() returns empty array if no action factories registered', () => {
const service = new UiActionsServiceEnhancements();
const service = new UiActionsServiceEnhancements({ getLicenseInfo });
const factories = service.getActionFactories();
@ -33,7 +36,7 @@ describe('UiActionsService', () => {
});
test('can register and retrieve an action factory', () => {
const service = new UiActionsServiceEnhancements();
const service = new UiActionsServiceEnhancements({ getLicenseInfo });
service.registerActionFactory(factoryDefinition1);
@ -44,7 +47,7 @@ describe('UiActionsService', () => {
});
test('can retrieve all action factories', () => {
const service = new UiActionsServiceEnhancements();
const service = new UiActionsServiceEnhancements({ getLicenseInfo });
service.registerActionFactory(factoryDefinition1);
service.registerActionFactory(factoryDefinition2);
@ -58,7 +61,7 @@ describe('UiActionsService', () => {
});
test('throws when retrieving action factory that does not exist', () => {
const service = new UiActionsServiceEnhancements();
const service = new UiActionsServiceEnhancements({ getLicenseInfo });
service.registerActionFactory(factoryDefinition1);

View file

@ -7,16 +7,20 @@
import { ActionFactoryRegistry } from '../types';
import { ActionFactory, ActionFactoryDefinition } from '../dynamic_actions';
import { DrilldownDefinition } from '../drilldowns';
import { ILicense } from '../../../licensing/common/types';
export interface UiActionsServiceEnhancementsParams {
readonly actionFactories?: ActionFactoryRegistry;
readonly getLicenseInfo: () => ILicense;
}
export class UiActionsServiceEnhancements {
protected readonly actionFactories: ActionFactoryRegistry;
protected readonly getLicenseInfo: () => ILicense;
constructor({ actionFactories = new Map() }: UiActionsServiceEnhancementsParams = {}) {
constructor({ actionFactories = new Map(), getLicenseInfo }: UiActionsServiceEnhancementsParams) {
this.actionFactories = actionFactories;
this.getLicenseInfo = getLicenseInfo;
}
/**
@ -34,7 +38,10 @@ export class UiActionsServiceEnhancements {
throw new Error(`ActionFactory [actionFactory.id = ${definition.id}] already registered.`);
}
const actionFactory = new ActionFactory<Config, FactoryContext, ActionContext>(definition);
const actionFactory = new ActionFactory<Config, FactoryContext, ActionContext>(
definition,
this.getLicenseInfo
);
this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory<any, any, any>);
};
@ -72,9 +79,11 @@ export class UiActionsServiceEnhancements {
euiIcon,
execute,
getHref,
minimalLicense,
}: DrilldownDefinition<Config, ExecutionContext>): void => {
const actionFactory: ActionFactoryDefinition<Config, object, ExecutionContext> = {
id: factoryId,
minimalLicense,
order,
CollectConfig,
createConfig,