mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
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:
parent
100a5fd18b
commit
3ac5bc5323
25 changed files with 371 additions and 59 deletions
|
@ -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) {}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -4,7 +4,8 @@
|
|||
"configPath": ["xpack", "ui_actions_enhanced"],
|
||||
"requiredPlugins": [
|
||||
"embeddable",
|
||||
"uiActions"
|
||||
"uiActions",
|
||||
"licensing"
|
||||
],
|
||||
"server": false,
|
||||
"ui": true
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<{
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue