Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[uiActions] notify action usage #76294

Merged
merged 5 commits into from
Sep 2, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class DashboardToUrlDrilldown implements Drilldown<Config, UrlTrigger> {
public readonly order = 8;

readonly minimalLicense = 'gold'; // example of minimal license support
readonly licenseFeatureName = 'Sample URL Drilldown';

public readonly getDisplayName = () => 'Go to URL (example)';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const createSetupMock = (): jest.Mocked<FeatureUsageServiceSetup> => {
register: jest.fn(),
};

mock.register.mockImplementation(() => Promise.resolve());

return mock;
};

Expand All @@ -23,6 +25,8 @@ const createStartMock = (): jest.Mocked<FeatureUsageServiceStart> => {
notifyUsage: jest.fn(),
};

mock.notifyUsage.mockImplementation(() => Promise.resolve());

return mock;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
urlDrilldownActionFactory,
} from './test_data';
import { ActionFactory } from '../../dynamic_actions';
import { licenseMock } from '../../../../licensing/common/licensing.mock';
import { licensingMock } from '../../../../licensing/public/mocks';

// TODO: afterEach is not available for it globally during setup
// https://github.com/elastic/kibana/issues/59469
Expand Down Expand Up @@ -68,8 +68,12 @@ test('If not enough license, button is disabled', () => {
{
...urlDrilldownActionFactory,
minimalLicense: 'gold',
licenseFeatureName: 'Url Drilldown',
},
() => licenseMock.createLicense()
{
getLicense: () => licensingMock.createLicense(),
getFeatureUsageStart: () => licensingMock.createStart().featureUsage,
}
);
const screen = render(<Demo actionFactories={[dashboardFactory, urlWithGoldLicense]} />);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
if (
!currentActionFactory &&
actionFactories.length === 1 &&
actionFactories[0].isCompatibleLicence()
actionFactories[0].isCompatibleLicense()
) {
onActionFactoryChange(actionFactories[0]);
}
Expand Down Expand Up @@ -314,8 +314,8 @@ const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({
* 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());
const compatibleLicense = factories.filter((f) => f.isCompatibleLicense());
const notCompatibleLicense = factories.filter((f) => !f.isCompatibleLicense());
return [
...compatibleLicense.sort((f1, f2) => f2.order - f1.order),
...notCompatibleLicense.sort((f1, f2) => f2.order - f1.order),
Expand All @@ -328,7 +328,7 @@ const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({
<EuiFlexItem grow={false} key={actionFactory.id}>
<EuiToolTip
content={
!actionFactory.isCompatibleLicence() && (
!actionFactory.isCompatibleLicense() && (
<FormattedMessage
defaultMessage="Insufficient license level"
id="xpack.uiActionsEnhanced.components.actionWizard.insufficientLicenseLevelTooltip"
Expand All @@ -341,7 +341,7 @@ const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({
label={actionFactory.getDisplayName(context)}
data-test-subj={`${TEST_SUBJ_ACTION_FACTORY_ITEM}-${actionFactory.id}`}
onClick={() => onActionFactorySelected(actionFactory)}
disabled={!actionFactory.isCompatibleLicence()}
disabled={!actionFactory.isCompatibleLicense()}
>
{actionFactory.getIconType(context) && (
<EuiIcon type={actionFactory.getIconType(context)!} size="m" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/p
import { ActionWizard } from './action_wizard';
import { ActionFactory, ActionFactoryDefinition } from '../../dynamic_actions';
import { CollectConfigProps } from '../../../../../../src/plugins/kibana_utils/public';
import { licenseMock } from '../../../../licensing/common/licensing.mock';
import { licensingMock } from '../../../../licensing/public/mocks';
import {
APPLY_FILTER_TRIGGER,
SELECT_RANGE_TRIGGER,
Expand Down Expand Up @@ -116,9 +116,10 @@ export const dashboardDrilldownActionFactory: ActionFactoryDefinition<
},
};

export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory, () =>
licenseMock.createLicense()
);
export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory, {
getLicense: () => licensingMock.createLicense(),
getFeatureUsageStart: () => licensingMock.createStart().featureUsage,
});

interface UrlDrilldownConfig {
url: string;
Expand Down Expand Up @@ -176,9 +177,10 @@ export const urlDrilldownActionFactory: ActionFactoryDefinition<UrlDrilldownConf
},
};

export const urlFactory = new ActionFactory(urlDrilldownActionFactory, () =>
licenseMock.createLicense()
);
export const urlFactory = new ActionFactory(urlDrilldownActionFactory, {
getLicense: () => licensingMock.createLicense(),
getFeatureUsageStart: () => licensingMock.createStart().featureUsage,
});

export const mockSupportedTriggers: TriggerId[] = [
VALUE_CLICK_TRIGGER,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export function createFlyoutManageDrilldowns({
icon: actionFactory?.getIconType(drilldownFactoryContext),
error: !actionFactory
? invalidDrilldownType(drilldown.action.factoryId) // this shouldn't happen for the end user, but useful during development
: !actionFactory.isCompatibleLicence()
: !actionFactory.isCompatibleLicense()
? insufficientLicenseLevel
: undefined,
triggers: drilldown.triggers.map((trigger) => getTrigger(trigger as TriggerId)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const FormDrilldownWizard: React.FC<FormDrilldownWizardProps> = ({
);

const hasNotCompatibleLicenseFactory = () =>
actionFactories?.some((f) => !f.isCompatibleLicence());
actionFactories?.some((f) => !f.isCompatibleLicense());

const renderGetMoreActionsLink = () => (
<EuiText size="s">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,18 @@ export interface DrilldownDefinition<
id: string;

/**
* Minimal licence level
* Minimal license level
* Empty means no restrictions
*/
minimalLicense?: LicenseType;

/**
* Required when `minimalLicense` is used.
* Is a user-facing string. Has to be unique. Doesn't need i18n.
* The feature's name will be displayed to Cloud end-users when they're billed based on their feature usage.
*/
licenseFeatureName?: string;

/**
* Determines the display order of the drilldowns in the flyout picker.
* Higher numbers are displayed first.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { ActionFactory } from './action_factory';
import { ActionFactoryDefinition } from './action_factory_definition';
import { licensingMock } from '../../../licensing/public/mocks';
import { PublicLicense } from '../../../licensing/public';

const def: ActionFactoryDefinition = {
id: 'ACTION_FACTORY_1',
Expand All @@ -22,34 +23,94 @@ const def: ActionFactoryDefinition = {
supportedTriggers: () => [],
};

const featureUsage = licensingMock.createStart().featureUsage;

const createActionFactory = (
defOverride: Partial<ActionFactoryDefinition> = {},
license?: Partial<PublicLicense>
) => {
return new ActionFactory(
{ ...def, ...defOverride },
{
getLicense: () => licensingMock.createLicense({ license }),
getFeatureUsageStart: () => featureUsage,
}
);
};

describe('License & ActionFactory', () => {
test('no license requirements', async () => {
const factory = new ActionFactory(def, () => licensingMock.createLicense());
const factory = createActionFactory();
expect(await factory.isCompatible({ triggers: [] })).toBe(true);
expect(factory.isCompatibleLicence()).toBe(true);
expect(factory.isCompatibleLicense()).toBe(true);
});

test('not enough license level', async () => {
const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () =>
licensingMock.createLicense()
);
const factory = createActionFactory({ minimalLicense: 'gold', licenseFeatureName: 'Feature' });
expect(await factory.isCompatible({ triggers: [] })).toBe(true);
expect(factory.isCompatibleLicence()).toBe(false);
expect(factory.isCompatibleLicense()).toBe(false);
});

test('licence has expired', async () => {
const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () =>
licensingMock.createLicense({ license: { type: 'gold', status: 'expired' } })
test('license has expired', async () => {
const factory = createActionFactory(
{ minimalLicense: 'gold', licenseFeatureName: 'Feature' },
{ type: 'gold', status: 'expired' }
);
expect(await factory.isCompatible({ triggers: [] })).toBe(true);
expect(factory.isCompatibleLicence()).toBe(false);
expect(factory.isCompatibleLicense()).toBe(false);
});

test('enough license level', async () => {
const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () =>
licensingMock.createLicense({ license: { type: 'gold' } })
const factory = createActionFactory(
{ minimalLicense: 'gold', licenseFeatureName: 'Feature' },
{ type: 'gold' }
);

expect(await factory.isCompatible({ triggers: [] })).toBe(true);
expect(factory.isCompatibleLicence()).toBe(true);
expect(factory.isCompatibleLicense()).toBe(true);
});

describe('licenseFeatureName', () => {
test('licenseFeatureName is required, if minimalLicense is provided', () => {
expect(() => {
createActionFactory();
}).not.toThrow();

expect(() => {
createActionFactory({ minimalLicense: 'gold', licenseFeatureName: 'feature' });
}).not.toThrow();

expect(() => {
createActionFactory({ minimalLicense: 'gold' });
}).toThrow();
});

test('"licenseFeatureName"', () => {
expect(
createActionFactory({ minimalLicense: 'gold', licenseFeatureName: 'feature' })
.licenseFeatureName
).toBe('feature');
expect(createActionFactory().licenseFeatureName).toBeUndefined();
});
});

describe('notifyFeatureUsage', () => {
const spy = jest.spyOn(featureUsage, 'notifyUsage');
beforeEach(() => {
spy.mockClear();
});
test('is not called if no license requirements', async () => {
const action = createActionFactory().create({ name: 'fake', config: {} });
await action.execute({});
expect(spy).not.toBeCalled();
});
test('is called if has license requirements', async () => {
const action = createActionFactory({
minimalLicense: 'gold',
licenseFeatureName: 'feature',
}).create({ name: 'fake', config: {} });
await action.execute({});
expect(spy).toBeCalledWith('feature');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ import {
import { ActionFactoryDefinition } from './action_factory_definition';
import { Configurable } from '../../../../../src/plugins/kibana_utils/public';
import { BaseActionFactoryContext, SerializedAction } from './types';
import { ILicense } from '../../../licensing/public';
import { ILicense, LicensingPluginStart } from '../../../licensing/public';
import { UiActionsActionDefinition as ActionDefinition } from '../../../../../src/plugins/ui_actions/public';

export interface ActionFactoryDeps {
readonly getLicense: () => ILicense;
readonly getFeatureUsageStart: () => LicensingPluginStart['featureUsage'];
}

export class ActionFactory<
Config extends object = object,
SupportedTriggers extends TriggerId = TriggerId,
Expand All @@ -31,11 +36,18 @@ export class ActionFactory<
FactoryContext,
ActionContext
>,
protected readonly getLicence: () => ILicense
) {}
protected readonly deps: ActionFactoryDeps
) {
if (def.minimalLicense && !def.licenseFeatureName) {
throw new Error(
`ActionFactory [actionFactory.id = ${def.id}] "licenseFeatureName" is required, if "minimalLicense" is provided`
);
}
}

public readonly id = this.def.id;
public readonly minimalLicense = this.def.minimalLicense;
public readonly licenseFeatureName = this.def.licenseFeatureName;
public readonly order = this.def.order || 0;
public readonly MenuItem? = this.def.MenuItem;
public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined;
Expand Down Expand Up @@ -65,13 +77,13 @@ export class ActionFactory<
}

/**
* Does this action factory licence requirements
* Does this action factory license requirements
* compatible with current license?
*/
public isCompatibleLicence() {
public isCompatibleLicense() {
if (!this.minimalLicense) return true;
const licence = this.getLicence();
return licence.isAvailable && licence.isActive && licence.hasAtLeast(this.minimalLicense);
const license = this.deps.getLicense();
return license.isAvailable && license.isActive && license.hasAtLeast(this.minimalLicense);
}

public create(
Expand All @@ -81,14 +93,31 @@ export class ActionFactory<
return {
...action,
isCompatible: async (context: ActionContext): Promise<boolean> => {
if (!this.isCompatibleLicence()) return false;
if (!this.isCompatibleLicense()) return false;
if (!action.isCompatible) return true;
return action.isCompatible(context);
},
execute: async (context: ActionContext): Promise<void> => {
this.notifyFeatureUsage();
return action.execute(context);
},
};
}

public supportedTriggers(): SupportedTriggers[] {
return this.def.supportedTriggers();
}

private notifyFeatureUsage(): void {
if (!this.minimalLicense || !this.licenseFeatureName) return;
this.deps
.getFeatureUsageStart()
.notifyUsage(this.licenseFeatureName)
.catch(() => {
// eslint-disable-next-line no-console
console.warn(
`ActionFactory [actionFactory.id = ${this.def.id}] fail notify feature usage.`
);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,18 @@ export interface ActionFactoryDefinition<
id: string;

/**
* Minimal licence level
* Empty means no licence restrictions
* Minimal license level
* Empty means no license restrictions
*/
readonly minimalLicense?: LicenseType;

/**
* Required when `minimalLicense` is used.
* Is a user-facing string. Has to be unique. Doesn't need i18n.
* The feature's name will be displayed to Cloud end-users when they're billed based on their feature usage.
*/
licenseFeatureName?: string;

/**
* This method should return a definition of a new action, normally used to
* register it in `ui_actions` registry.
Expand Down
Loading