Skip to content

Commit

Permalink
Dynamic uiActions & license support (#68507)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
Dosant and elasticmachine committed Jun 26, 2020
1 parent 100a5fd commit 3ac5bc5
Show file tree
Hide file tree
Showing 25 changed files with 370 additions and 58 deletions.
3 changes: 3 additions & 0 deletions src/plugins/ui_actions/public/actions/action_internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
14 changes: 13 additions & 1 deletion x-pack/plugins/licensing/public/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -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,
};
3 changes: 2 additions & 1 deletion x-pack/plugins/ui_actions_enhanced/kibana.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"configPath": ["xpack", "ui_actions_enhanced"],
"requiredPlugins": [
"embeddable",
"uiActions"
"uiActions",
"licensing"
],
"server": false,
"ui": true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]);
}

Expand Down Expand Up @@ -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>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
EuiIcon,
EuiSpacer,
EuiTextColor,
EuiToolTip,
} from '@elastic/eui';
import React, { useState } from 'react';
import {
Expand All @@ -28,6 +29,7 @@ export interface DrilldownListItem {
actionName: string;
drilldownName: string;
icon?: string;
error?: string;
}

export interface ListManageDrilldownsProps {
Expand All @@ -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',
Expand Down
Loading

0 comments on commit 3ac5bc5

Please sign in to comment.