From 3ac5bc53236608fe598bdc7f45c226a91544b2ca Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Fri, 26 Jun 2020 18:33:32 +0200 Subject: [PATCH] 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 --- .../public/actions/action_internal.ts | 3 + .../public/service/ui_actions_service.test.ts | 2 +- .../public/service/ui_actions_service.ts | 1 - .../dashboard_to_url_drilldown/index.tsx | 2 + x-pack/plugins/licensing/public/mocks.ts | 14 +++- .../plugins/ui_actions_enhanced/kibana.json | 3 +- .../action_wizard/action_wizard.test.tsx | 26 +++++- .../action_wizard/action_wizard.tsx | 44 +++++++++-- .../components/action_wizard/test_data.tsx | 10 ++- .../connected_flyout_manage_drilldowns.tsx | 7 ++ .../i18n.ts | 20 +++++ .../flyout_list_manage_drilldowns.story.tsx | 2 +- .../form_drilldown_wizard.tsx | 27 ++++++- .../list_manage_drilldowns.test.tsx | 7 +- .../list_manage_drilldowns.tsx | 20 ++++- .../public/drilldowns/drilldown_definition.ts | 7 ++ .../dynamic_actions/action_factory.test.ts | 46 +++++++++++ .../public/dynamic_actions/action_factory.ts | 30 +++++-- .../action_factory_definition.ts | 11 ++- .../dynamic_action_manager.test.ts | 79 +++++++++++++++---- .../dynamic_actions/dynamic_action_manager.ts | 16 ++-- .../ui_actions_enhanced/public/mocks.ts | 2 + .../ui_actions_enhanced/public/plugin.ts | 25 +++++- .../ui_actions_service_enhancements.test.ts | 11 ++- .../ui_actions_service_enhancements.ts | 13 ++- 25 files changed, 370 insertions(+), 58 deletions(-) create mode 100644 x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts index aba1e22fe09ee2..10eb760b130898 100644 --- a/src/plugins/ui_actions/public/actions/action_internal.ts +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -24,6 +24,9 @@ import { Presentable } from '../util/presentable'; import { uiToReactComponent } from '../../../kibana_react/public'; import { ActionType } from '../types'; +/** + * @internal + */ export class ActionInternal implements Action>, Presentable> { constructor(public readonly definition: A) {} diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts index 45a1bdffa52adf..39502c3dd17fcd 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts @@ -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 diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index 760897f0287d82..11f5769a946483 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -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 }); }; } diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx index 4810fb2d6ad8da..5e4ba54864461b 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx @@ -39,6 +39,8 @@ export class DashboardToUrlDrilldown implements Drilldown public readonly order = 8; + readonly minimalLicense = 'gold'; // example of minimal license support + public readonly getDisplayName = () => 'Go to URL (example)'; public readonly euiIcon = 'link'; diff --git a/x-pack/plugins/licensing/public/mocks.ts b/x-pack/plugins/licensing/public/mocks.ts index 68b280c5341f26..8421a343d91ca8 100644 --- a/x-pack/plugins/licensing/public/mocks.ts +++ b/x-pack/plugins/licensing/public/mocks.ts @@ -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 = { + license$: new BehaviorSubject(license), + refresh: jest.fn(), + }; + mock.refresh.mockResolvedValue(license); + + return mock; +}; + export const licensingMock = { createSetup: createSetupMock, + createStart: createStartMock, ...licenseMock, }; diff --git a/x-pack/plugins/ui_actions_enhanced/kibana.json b/x-pack/plugins/ui_actions_enhanced/kibana.json index 027004f165c3b8..a813903d8b2124 100644 --- a/x-pack/plugins/ui_actions_enhanced/kibana.json +++ b/x-pack/plugins/ui_actions_enhanced/kibana.json @@ -4,7 +4,8 @@ "configPath": ["xpack", "ui_actions_enhanced"], "requiredPlugins": [ "embeddable", - "uiActions" + "uiActions", + "licensing" ], "server": false, "ui": true diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.test.tsx index 745b3c403afc66..78252dccd20d24 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.test.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.test.tsx @@ -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(); + + // 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(); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx index ccadf60426edf8..6769c8bab07327 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx @@ -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 = ({ 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 = ({ 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 ( - {[...actionFactories] - .sort((f1, f2) => f2.order - f1.order) - .map((actionFactory) => ( - + {ensureOrder(actionFactories).map((actionFactory) => ( + + + ) + } + > onActionFactorySelected(actionFactory)} + disabled={!actionFactory.isCompatibleLicence()} > {actionFactory.getIconType(context) && ( )} - - ))} + + + ))} ); }; diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx index 0a135e60126ca9..2672a086dca735 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx @@ -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 null as any, }; -export const urlFactory = new ActionFactory(urlDrilldownActionFactory); +export const urlFactory = new ActionFactory(urlDrilldownActionFactory, () => + licenseMock.createLicense() +); export function Demo({ actionFactories }: { actionFactories: Array> }) { const [state, setState] = useState<{ diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx index fbc72d04706351..20d15b4f4d2bd3 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx @@ -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, }; } diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/i18n.ts index e75ee2634aa43c..4b2be5db0c5584 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/i18n.ts @@ -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, + }, + } + ); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx index 0529f0451b16a0..603de39bc89081 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx @@ -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...' }, ]} /> diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx index 622ed58e3625d7..e7e7f72dbf58f4 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx @@ -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 = ({ ); + const hasNotCompatibleLicenseFactory = () => + actionFactories?.some((f) => !f.isCompatibleLicence()); + + const renderGetMoreActionsLink = () => ( + + + + + + ); + const actionWizard = ( 1 ? txtDrilldownAction : undefined} fullWidth={true} + labelAppend={ + !currentActionFactory && hasNotCompatibleLicenseFactory() && renderGetMoreActionsLink() + } > { @@ -67,3 +67,8 @@ test('Can delete drilldowns', () => { expect(fn).toBeCalledWith([drilldowns[1].id, drilldowns[2].id]); }); + +test('Error is displayed', () => { + const screen = render(); + expect(screen.getByLabelText('an error')).toBeInTheDocument(); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.tsx index cd41a3d6ec23a4..b828c4d7d076da 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.tsx @@ -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> = [ { - field: 'drilldownName', name: 'Name', truncateText: true, width: '50%', 'data-test-subj': 'drilldownListItemName', + render: (drilldown: DrilldownListItem) => ( +
+ {drilldown.drilldownName}{' '} + {drilldown.error && ( + + + + )} +
+ ), }, { name: 'Action', diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts index f01dd22c06bc54..a41ae851e185b0 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts @@ -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. diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts new file mode 100644 index 00000000000000..918c6422546f40 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts @@ -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); + }); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts index 262a5ef7d4561c..95b7941b48ed35 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts @@ -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, 'getHref'>, Configurable { constructor( - protected readonly def: ActionFactoryDefinition + protected readonly def: ActionFactoryDefinition, + 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, 'factoryId'> ): ActionDefinition { - return this.def.create(serializedAction); + const action = this.def.create(serializedAction); + return { + ...action, + isCompatible: async (context: ActionContext): Promise => { + if (!this.isCompatibleLicence()) return false; + if (!action.isCompatible) return true; + return action.isCompatible(context); + }, + }; } } diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts index d3751fe8116655..d63f69ba5ab729 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts @@ -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. diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.test.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.test.ts index 516b1f3cd27738..930f88ff08775c 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.test.ts @@ -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(); + 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); + }); }); diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.ts index 58344026079e79..4afefe3006a43c 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.ts @@ -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), + const actionDefinition: ActionDefinition = factory.create(action as SerializedAction); + uiActions.registerAction({ + ...actionDefinition, id: actionId, - isCompatible, - }; - - uiActions.registerAction(actionDefinition); + 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); } diff --git a/x-pack/plugins/ui_actions_enhanced/public/mocks.ts b/x-pack/plugins/ui_actions_enhanced/public/mocks.ts index 196b8f2c1d5c79..ff07d6e74a9c0b 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/mocks.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/mocks.ts @@ -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; export type Start = jest.Mocked; @@ -62,6 +63,7 @@ const createPlugin = ( return plugin.start(anotherCoreStart, { uiActions: uiActionsStart, embeddable: embeddableStart, + licensing: licensingMock.createStart(), }); }, }; diff --git a/x-pack/plugins/ui_actions_enhanced/public/plugin.ts b/x-pack/plugins/ui_actions_enhanced/public/plugin.ts index 04caef92f15a22..a625ea2e2118bc 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/plugin.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/plugin.ts @@ -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 { - private readonly enhancements = new UiActionsServiceEnhancements(); + readonly licenceInfo = new BehaviorSubject(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()); + } } diff --git a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.test.ts b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.test.ts index 3137e35a2fe476..4f2ddcf7e0491c 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.test.ts @@ -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); diff --git a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts index b7bdced228584a..bd05659d59e9d8 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts @@ -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(definition); + const actionFactory = new ActionFactory( + definition, + this.getLicenseInfo + ); this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory); }; @@ -72,9 +79,11 @@ export class UiActionsServiceEnhancements { euiIcon, execute, getHref, + minimalLicense, }: DrilldownDefinition): void => { const actionFactory: ActionFactoryDefinition = { id: factoryId, + minimalLicense, order, CollectConfig, createConfig,