[Drilldowns] Trigger picker (#74751)

Drilldowns now support trigger picker. It allows to create a drilldown and specify which trigger it should be attached to.

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Anton Dosov 2020-08-17 14:47:25 +02:00 committed by GitHub
parent fcb1a2848a
commit 3c5f2e7e7b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 942 additions and 150 deletions

View file

@ -115,7 +115,7 @@ export class UiActionsExecutionService {
context,
trigger,
})),
title: tasks[0].trigger.title, // title of context menu is title of trigger which originated the chain
title: '', // intentionally don't have any title
closeMenu: () => {
tasks.forEach((t) => t.defer.resolve());
session.close();

View file

@ -17,11 +17,16 @@
* under the License.
*/
import { i18n } from '@kbn/i18n';
import { Trigger } from '.';
export const APPLY_FILTER_TRIGGER = 'FILTER_TRIGGER';
export const applyFilterTrigger: Trigger<'FILTER_TRIGGER'> = {
id: APPLY_FILTER_TRIGGER,
title: 'Apply filter',
description: 'Triggered when user applies filter to an embeddable.',
title: i18n.translate('uiActions.triggers.applyFilterTitle', {
defaultMessage: 'Apply filter',
}),
description: i18n.translate('uiActions.triggers.applyFilterDescription', {
defaultMessage: 'When kibana filter is applied. Could be a single value or a range filter.',
}),
};

View file

@ -17,13 +17,16 @@
* under the License.
*/
import { i18n } from '@kbn/i18n';
import { Trigger } from '.';
export const SELECT_RANGE_TRIGGER = 'SELECT_RANGE_TRIGGER';
export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = {
id: SELECT_RANGE_TRIGGER,
// This is empty string to hide title of ui_actions context menu that appears
// when this trigger is executed.
title: '',
description: 'Applies a range filter',
title: i18n.translate('uiActions.triggers.selectRangeTitle', {
defaultMessage: 'Range selection',
}),
description: i18n.translate('uiActions.triggers.selectRangeDescription', {
defaultMessage: 'Select a group of values',
}),
};

View file

@ -17,13 +17,16 @@
* under the License.
*/
import { i18n } from '@kbn/i18n';
import { Trigger } from '.';
export const VALUE_CLICK_TRIGGER = 'VALUE_CLICK_TRIGGER';
export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = {
id: VALUE_CLICK_TRIGGER,
// This is empty string to hide title of ui_actions context menu that appears
// when this trigger is executed.
title: '',
description: 'Value was clicked',
title: i18n.translate('uiActions.triggers.valueClickTitle', {
defaultMessage: 'Single click',
}),
description: i18n.translate('uiActions.triggers.valueClickDescription', {
defaultMessage: 'A single point clicked on a visualization',
}),
};

View file

@ -47,6 +47,7 @@ import { Vis } from '../vis';
import { getExpressions, getUiActions } from '../services';
import { VIS_EVENT_TO_TRIGGER } from './events';
import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory';
import { TriggerId } from '../../../ui_actions/public';
const getKeys = <T extends {}>(o: T): Array<keyof T> => Object.keys(o) as Array<keyof T>;
@ -402,7 +403,7 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut
});
};
public supportedTriggers() {
public supportedTriggers(): TriggerId[] {
return this.vis.type.getSupportedTriggers?.() ?? [];
}
}

View file

@ -5,7 +5,7 @@
"configPath": ["ui_actions_enhanced_examples"],
"server": false,
"ui": true,
"requiredPlugins": ["uiActionsEnhanced", "data", "discover"],
"requiredPlugins": ["uiActions","uiActionsEnhanced", "data", "discover"],
"optionalPlugins": [],
"requiredBundles": [
"kibanaUtils",

View file

@ -10,6 +10,10 @@ import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/publ
import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public';
import { ChartActionContext } from '../../../../../src/plugins/embeddable/public';
import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/public';
import {
SELECT_RANGE_TRIGGER,
VALUE_CLICK_TRIGGER,
} from '../../../../../src/plugins/ui_actions/public';
export type ActionContext = ChartActionContext;
@ -19,7 +23,8 @@ export interface Config {
const SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN = 'SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN';
export class DashboardHelloWorldDrilldown implements Drilldown<Config, ActionContext> {
export class DashboardHelloWorldDrilldown
implements Drilldown<Config, typeof VALUE_CLICK_TRIGGER | typeof SELECT_RANGE_TRIGGER> {
public readonly id = SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN;
public readonly order = 6;
@ -28,9 +33,14 @@ export class DashboardHelloWorldDrilldown implements Drilldown<Config, ActionCon
public readonly euiIcon = 'cheer';
supportedTriggers(): Array<typeof VALUE_CLICK_TRIGGER | typeof SELECT_RANGE_TRIGGER> {
return [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER];
}
private readonly ReactCollectConfig: React.FC<CollectConfigProps<Config>> = ({
config,
onConfig,
context,
}) => (
<EuiFormRow label="Enter your name" fullWidth>
<EuiFieldText

View file

@ -0,0 +1 @@
This folder contains a one-file example of the most basic drilldown implementation which support only RANGE_SELECT_TRIGGER.

View file

@ -0,0 +1,62 @@
/*
* 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 React from 'react';
import { EuiFormRow, EuiFieldText } from '@elastic/eui';
import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public';
import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public';
import { RangeSelectContext } from '../../../../../src/plugins/embeddable/public';
import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/public';
import { SELECT_RANGE_TRIGGER } from '../../../../../src/plugins/ui_actions/public';
export interface Config {
name: string;
}
const SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN_ONLY_RANGE_SELECT =
'SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN_ONLY_RANGE_SELECT';
export class DashboardHelloWorldOnlyRangeSelectDrilldown
implements Drilldown<Config, typeof SELECT_RANGE_TRIGGER> {
public readonly id = SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN_ONLY_RANGE_SELECT;
public readonly order = 7;
public readonly getDisplayName = () => 'Say hello only for range select';
public readonly euiIcon = 'cheer';
supportedTriggers(): Array<typeof SELECT_RANGE_TRIGGER> {
return [SELECT_RANGE_TRIGGER];
}
private readonly ReactCollectConfig: React.FC<CollectConfigProps<Config>> = ({
config,
onConfig,
}) => (
<EuiFormRow label="Enter your name" fullWidth>
<EuiFieldText
fullWidth
value={config.name}
onChange={(event) => onConfig({ ...config, name: event.target.value })}
/>
</EuiFormRow>
);
public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig);
public readonly createConfig = () => ({
name: '',
});
public readonly isConfigValid = (config: Config): config is Config => {
return !!config.name;
};
public readonly execute = async (config: Config, context: RangeSelectContext) => {
alert(`Hello, ${config.name}, your selected range: ${JSON.stringify(context.data.range)}`);
};
}

View file

@ -13,6 +13,7 @@ import { CollectConfigContainer } from './collect_config_container';
import { SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN } from './constants';
import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public';
import { txtGoToDiscover } from './i18n';
import { APPLY_FILTER_TRIGGER } from '../../../../../src/plugins/ui_actions/public';
const isOutputWithIndexPatterns = (
output: unknown
@ -25,7 +26,8 @@ export interface Params {
start: StartServicesGetter<Pick<Start, 'data' | 'discover'>>;
}
export class DashboardToDiscoverDrilldown implements Drilldown<Config, ActionContext> {
export class DashboardToDiscoverDrilldown
implements Drilldown<Config, typeof APPLY_FILTER_TRIGGER> {
constructor(protected readonly params: Params) {}
public readonly id = SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN;
@ -36,6 +38,10 @@ export class DashboardToDiscoverDrilldown implements Drilldown<Config, ActionCon
public readonly euiIcon = 'discoverApp';
public supportedTriggers(): Array<typeof APPLY_FILTER_TRIGGER> {
return [APPLY_FILTER_TRIGGER];
}
private readonly ReactCollectConfig: React.FC<CollectConfigProps> = (props) => (
<CollectConfigContainer {...props} params={this.params} />
);

View file

@ -4,10 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ChartActionContext } from '../../../../../src/plugins/embeddable/public';
import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public';
import { ApplyGlobalFilterActionContext } from '../../../../../src/plugins/data/public';
export type ActionContext = ChartActionContext;
export type ActionContext = ApplyGlobalFilterActionContext;
export interface Config {
/**

View file

@ -5,11 +5,15 @@
*/
import React from 'react';
import { EuiFormRow, EuiSwitch, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui';
import { EuiCallOut, EuiFieldText, EuiFormRow, EuiSpacer, EuiSwitch } from '@elastic/eui';
import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public';
import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public';
import { ChartActionContext } from '../../../../../src/plugins/embeddable/public';
import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public';
import {
SELECT_RANGE_TRIGGER,
VALUE_CLICK_TRIGGER,
} from '../../../../../src/plugins/ui_actions/public';
import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public';
function isValidUrl(url: string) {
@ -28,11 +32,13 @@ export interface Config {
openInNewTab: boolean;
}
export type CollectConfigProps = CollectConfigPropsBase<Config>;
type UrlTrigger = typeof VALUE_CLICK_TRIGGER | typeof SELECT_RANGE_TRIGGER;
export type CollectConfigProps = CollectConfigPropsBase<Config, { triggers: UrlTrigger[] }>;
const SAMPLE_DASHBOARD_TO_URL_DRILLDOWN = 'SAMPLE_DASHBOARD_TO_URL_DRILLDOWN';
export class DashboardToUrlDrilldown implements Drilldown<Config, ActionContext> {
export class DashboardToUrlDrilldown implements Drilldown<Config, UrlTrigger> {
public readonly id = SAMPLE_DASHBOARD_TO_URL_DRILLDOWN;
public readonly order = 8;
@ -43,7 +49,15 @@ export class DashboardToUrlDrilldown implements Drilldown<Config, ActionContext>
public readonly euiIcon = 'link';
private readonly ReactCollectConfig: React.FC<CollectConfigProps> = ({ config, onConfig }) => (
supportedTriggers(): UrlTrigger[] {
return [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER];
}
private readonly ReactCollectConfig: React.FC<CollectConfigProps> = ({
config,
onConfig,
context,
}) => (
<>
<EuiCallOut title="Example warning!" color="warning" iconType="help">
<p>
@ -79,6 +93,11 @@ export class DashboardToUrlDrilldown implements Drilldown<Config, ActionContext>
onChange={() => onConfig({ ...config, openInNewTab: !config.openInNewTab })}
/>
</EuiFormRow>
<EuiSpacer size="xl" />
<EuiCallOut>
{/* just demo how can access selected triggers*/}
<p>Will be attached to triggers: {JSON.stringify(context.triggers)}</p>
</EuiCallOut>
</>
);

View file

@ -15,6 +15,7 @@ import { DashboardToUrlDrilldown } from './dashboard_to_url_drilldown';
import { DashboardToDiscoverDrilldown } from './dashboard_to_discover_drilldown';
import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public';
import { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public';
import { DashboardHelloWorldOnlyRangeSelectDrilldown } from './dashboard_hello_world_only_range_select_drilldown';
export interface SetupDependencies {
data: DataPublicPluginSetup;
@ -37,6 +38,7 @@ export class UiActionsEnhancedExamplesPlugin
const start = createStartServicesGetter(core.getStartServices);
uiActions.registerDrilldown(new DashboardHelloWorldDrilldown());
uiActions.registerDrilldown(new DashboardHelloWorldOnlyRangeSelectDrilldown());
uiActions.registerDrilldown(new DashboardToUrlDrilldown());
uiActions.registerDrilldown(new DashboardToDiscoverDrilldown({ start }));
}

View file

@ -0,0 +1,32 @@
/*
* 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 {
APPLY_FILTER_TRIGGER,
SELECT_RANGE_TRIGGER,
TriggerId,
VALUE_CLICK_TRIGGER,
} from '../../../../../../../src/plugins/ui_actions/public';
/**
* We know that VALUE_CLICK_TRIGGER and SELECT_RANGE_TRIGGER are also triggering APPLY_FILTER_TRIGGER
* This function appends APPLY_FILTER_TRIGGER to list of triggers if VALUE_CLICK_TRIGGER or SELECT_RANGE_TRIGGER
*
* TODO: this probably should be part of uiActions infrastructure,
* but dynamic implementation of nested trigger doesn't allow to statically express such relations
*
* @param triggers
*/
export function ensureNestedTriggers(triggers: TriggerId[]): TriggerId[] {
if (
!triggers.includes(APPLY_FILTER_TRIGGER) &&
(triggers.includes(VALUE_CLICK_TRIGGER) || triggers.includes(SELECT_RANGE_TRIGGER))
) {
return [...triggers, APPLY_FILTER_TRIGGER];
}
return triggers;
}

View file

@ -10,9 +10,13 @@ import {
} from './flyout_create_drilldown';
import { coreMock } from '../../../../../../../../src/core/public/mocks';
import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public';
import { TriggerContextMapping } from '../../../../../../../../src/plugins/ui_actions/public';
import {
TriggerContextMapping,
TriggerId,
} from '../../../../../../../../src/plugins/ui_actions/public';
import { MockEmbeddable, enhanceEmbeddable } from '../test_helpers';
import { uiActionsEnhancedPluginMock } from '../../../../../../ui_actions_enhanced/public/mocks';
import { UiActionsEnhancedActionFactory } from '../../../../../../ui_actions_enhanced/public/';
const overlays = coreMock.createStart().overlays;
const uiActionsEnhanced = uiActionsEnhancedPluginMock.createStartContract();
@ -50,6 +54,7 @@ interface CompatibilityParams {
isValueClickTriggerSupported?: boolean;
isEmbeddableEnhanced?: boolean;
rootType?: string;
actionFactoriesTriggers?: TriggerId[];
}
describe('isCompatible', () => {
@ -61,9 +66,16 @@ describe('isCompatible', () => {
isValueClickTriggerSupported = true,
isEmbeddableEnhanced = true,
rootType = 'dashboard',
actionFactoriesTriggers = ['VALUE_CLICK_TRIGGER'],
}: CompatibilityParams,
expectedResult: boolean = true
): Promise<void> {
uiActionsEnhanced.getActionFactories.mockImplementation(() => [
({
supportedTriggers: () => actionFactoriesTriggers,
} as unknown) as UiActionsEnhancedActionFactory,
]);
let embeddable = new MockEmbeddable(
{ id: '', viewMode: isEdit ? ViewMode.EDIT : ViewMode.VIEW },
{
@ -116,6 +128,15 @@ describe('isCompatible', () => {
rootType: 'visualization',
});
});
test('not compatible if no triggers intersection', async () => {
await assertNonCompatibility({
actionFactoriesTriggers: [],
});
await assertNonCompatibility({
actionFactoriesTriggers: ['SELECT_RANGE_TRIGGER'],
});
});
});
describe('execute', () => {

View file

@ -6,17 +6,13 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import {
ActionByType,
APPLY_FILTER_TRIGGER,
SELECT_RANGE_TRIGGER,
VALUE_CLICK_TRIGGER,
} from '../../../../../../../../src/plugins/ui_actions/public';
import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public';
import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public';
import { isEnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public';
import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public';
import { StartDependencies } from '../../../../plugin';
import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_utils/public';
import { ensureNestedTriggers } from '../drilldown_shared';
export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN';
@ -47,8 +43,18 @@ export class FlyoutCreateDrilldownAction implements ActionByType<typeof OPEN_FLY
if (!supportedTriggers || !supportedTriggers.length) return false;
if (context.embeddable.getRoot().type !== 'dashboard') return false;
return supportedTriggers.some((trigger) =>
[VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER, APPLY_FILTER_TRIGGER].includes(trigger)
/**
* Check if there is an intersection between all registered drilldowns possible triggers that they could be attached to
* and triggers that current embeddable supports
*/
const allPossibleTriggers = this.params
.start()
.plugins.uiActionsEnhanced.getActionFactories()
.map((factory) => factory.supportedTriggers())
.reduce((res, next) => res.concat(next), []);
return ensureNestedTriggers(supportedTriggers).some((trigger) =>
allPossibleTriggers.includes(trigger)
);
}
@ -73,6 +79,7 @@ export class FlyoutCreateDrilldownAction implements ActionByType<typeof OPEN_FLY
onClose={() => handle.close()}
viewMode={'create'}
dynamicActionManager={embeddable.enhancements.dynamicActions}
supportedTriggers={ensureNestedTriggers(embeddable.supportedTriggers())}
/>
),
{

View file

@ -22,6 +22,9 @@ uiActionsPlugin.setup.registerDrilldown({
isConfigValid: () => true,
execute: async () => {},
getDisplayName: () => 'test',
supportedTriggers() {
return ['VALUE_CLICK_TRIGGER'];
},
});
const actionParams: FlyoutEditDrilldownParams = {

View file

@ -16,6 +16,7 @@ import { MenuItem } from './menu_item';
import { isEnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public';
import { StartDependencies } from '../../../../plugin';
import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_utils/public';
import { ensureNestedTriggers } from '../drilldown_shared';
export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN';
@ -62,6 +63,7 @@ export class FlyoutEditDrilldownAction implements ActionByType<typeof OPEN_FLYOU
onClose={() => handle.close()}
viewMode={'manage'}
dynamicActionManager={embeddable.enhancements.dynamicActions}
supportedTriggers={ensureNestedTriggers(embeddable.supportedTriggers())}
/>
),
{

View file

@ -11,7 +11,7 @@ import { SimpleSavedObject } from '../../../../../../../../src/core/public';
import { DashboardDrilldownConfig } from './dashboard_drilldown_config';
import { txtDestinationDashboardNotFound } from './i18n';
import { CollectConfigProps } from '../../../../../../../../src/plugins/kibana_utils/public';
import { Config } from '../types';
import { Config, FactoryContext } from '../types';
import { Params } from '../drilldown';
const mergeDashboards = (
@ -34,7 +34,7 @@ const dashboardSavedObjectToMenuItem = (
label: savedObject.attributes.title,
});
interface DashboardDrilldownCollectConfigProps extends CollectConfigProps<Config> {
interface DashboardDrilldownCollectConfigProps extends CollectConfigProps<Config, FactoryContext> {
params: Params;
}

View file

@ -6,6 +6,7 @@
import React from 'react';
import { reactToUiComponent } from '../../../../../../../src/plugins/kibana_react/public';
import { APPLY_FILTER_TRIGGER } from '../../../../../../../src/plugins/ui_actions/public';
import {
DashboardUrlGenerator,
DashboardUrlGeneratorState,
@ -23,7 +24,7 @@ import {
} from '../../../../../../../src/plugins/data/public';
import { StartServicesGetter } from '../../../../../../../src/plugins/kibana_utils/public';
import { StartDependencies } from '../../../plugin';
import { Config } from './types';
import { Config, FactoryContext } from './types';
export interface Params {
start: StartServicesGetter<Pick<StartDependencies, 'data' | 'uiActionsEnhanced'>>;
@ -31,7 +32,7 @@ export interface Params {
}
export class DashboardToDashboardDrilldown
implements Drilldown<Config, ApplyGlobalFilterActionContext> {
implements Drilldown<Config, typeof APPLY_FILTER_TRIGGER, FactoryContext> {
constructor(protected readonly params: Params) {}
public readonly id = DASHBOARD_TO_DASHBOARD_DRILLDOWN;
@ -59,6 +60,10 @@ export class DashboardToDashboardDrilldown
return true;
};
public supportedTriggers(): Array<typeof APPLY_FILTER_TRIGGER> {
return [APPLY_FILTER_TRIGGER];
}
public readonly getHref = async (
config: Config,
context: ApplyGlobalFilterActionContext

View file

@ -4,8 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { UiActionsEnhancedBaseActionFactoryContext } from '../../../../../ui_actions_enhanced/public';
import { APPLY_FILTER_TRIGGER } from '../../../../../../../src/plugins/ui_actions/public';
export interface Config {
dashboardId?: string;
useCurrentFilters: boolean;
useCurrentDateRange: boolean;
}
export type FactoryContext = UiActionsEnhancedBaseActionFactoryContext<typeof APPLY_FILTER_TRIGGER>;

View file

@ -14,13 +14,21 @@ import {
EuiSpacer,
EuiText,
EuiToolTip,
EuiFormFieldset,
EuiCheckableCard,
EuiTextColor,
EuiTitle,
EuiLink,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { txtChangeButton } from './i18n';
import { txtChangeButton, txtTriggerPickerHelpText, txtTriggerPickerLabel } from './i18n';
import './action_wizard.scss';
import { ActionFactory } from '../../dynamic_actions';
import { ActionFactory, BaseActionFactoryContext } from '../../dynamic_actions';
import { Trigger, TriggerId } from '../../../../../../src/plugins/ui_actions/public';
export interface ActionWizardProps {
export interface ActionWizardProps<
ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext
> {
/**
* List of available action factories
*/
@ -51,7 +59,22 @@ export interface ActionWizardProps {
/**
* Context will be passed into ActionFactory's methods
*/
context: object;
context: ActionFactoryContext;
/**
* Trigger selection has changed
* @param triggers
*/
onSelectedTriggersChange: (triggers?: TriggerId[]) => void;
getTriggerInfo: (triggerId: TriggerId) => Trigger;
/**
* List of possible triggers in current context
*/
supportedTriggers: TriggerId[];
triggerPickerDocsLink?: string;
}
export const ActionWizard: React.FC<ActionWizardProps> = ({
@ -61,6 +84,10 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
onConfigChange,
config,
context,
onSelectedTriggersChange,
getTriggerInfo,
supportedTriggers,
triggerPickerDocsLink,
}) => {
// auto pick action factory if there is only 1 available
if (
@ -71,7 +98,16 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
onActionFactoryChange(actionFactories[0]);
}
// auto pick selected trigger if none is picked
if (currentActionFactory && !((context.triggers?.length ?? 0) > 0)) {
const triggers = getTriggersForActionFactory(currentActionFactory, supportedTriggers);
if (triggers.length > 0) {
onSelectedTriggersChange([triggers[0]]);
}
}
if (currentActionFactory && config) {
const allTriggers = getTriggersForActionFactory(currentActionFactory, supportedTriggers);
return (
<SelectedActionFactory
actionFactory={currentActionFactory}
@ -84,6 +120,10 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
onConfigChange={(newConfig) => {
onConfigChange(newConfig);
}}
allTriggers={allTriggers}
getTriggerInfo={getTriggerInfo}
onSelectedTriggersChange={onSelectedTriggersChange}
triggerPickerDocsLink={triggerPickerDocsLink}
/>
);
}
@ -99,13 +139,84 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
);
};
interface SelectedActionFactoryProps {
interface TriggerPickerProps {
triggers: TriggerId[];
selectedTriggers?: TriggerId[];
getTriggerInfo: (triggerId: TriggerId) => Trigger;
onSelectedTriggersChange: (triggers?: TriggerId[]) => void;
triggerPickerDocsLink?: string;
}
const TriggerPicker: React.FC<TriggerPickerProps> = ({
triggers,
selectedTriggers,
getTriggerInfo,
onSelectedTriggersChange,
triggerPickerDocsLink,
}) => {
const selectedTrigger = selectedTriggers ? selectedTriggers[0] : undefined;
return (
<EuiFormFieldset
legend={{
children: (
<EuiText size="s">
<h5>
<span>{txtTriggerPickerLabel}</span>{' '}
<EuiLink href={triggerPickerDocsLink} target={'blank'} external>
{txtTriggerPickerHelpText}
</EuiLink>
</h5>
</EuiText>
),
}}
style={{ maxWidth: `80%` }}
>
{triggers.map((trigger) => (
<React.Fragment key={trigger}>
<EuiCheckableCard
id={trigger}
label={
<>
<EuiTitle size={'xxs'}>
<span>{getTriggerInfo(trigger)?.title ?? 'Unknown'}</span>
</EuiTitle>
{getTriggerInfo(trigger)?.description && (
<div>
<EuiText size={'s'}>
<EuiTextColor color={'subdued'}>
{getTriggerInfo(trigger)?.description}
</EuiTextColor>
</EuiText>
</div>
)}
</>
}
name={trigger}
value={trigger}
checked={selectedTrigger === trigger}
onChange={() => onSelectedTriggersChange([trigger])}
data-test-subj={`triggerPicker-${trigger}`}
/>
<EuiSpacer size={'s'} />
</React.Fragment>
))}
</EuiFormFieldset>
);
};
interface SelectedActionFactoryProps<
ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext
> {
actionFactory: ActionFactory;
config: object;
context: object;
context: ActionFactoryContext;
onConfigChange: (config: object) => void;
showDeselect: boolean;
onDeselect: () => void;
allTriggers: TriggerId[];
getTriggerInfo: (triggerId: TriggerId) => Trigger;
onSelectedTriggersChange: (triggers?: TriggerId[]) => void;
triggerPickerDocsLink?: string;
}
export const TEST_SUBJ_SELECTED_ACTION_FACTORY = 'selectedActionFactory';
@ -117,6 +228,10 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
onConfigChange,
config,
context,
allTriggers,
getTriggerInfo,
onSelectedTriggersChange,
triggerPickerDocsLink,
}) => {
return (
<div
@ -144,7 +259,19 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
)}
</EuiFlexGroup>
</header>
<EuiSpacer size="m" />
{allTriggers.length > 1 && (
<>
<EuiSpacer size="l" />
<TriggerPicker
triggers={allTriggers}
getTriggerInfo={getTriggerInfo}
selectedTriggers={context.triggers}
onSelectedTriggersChange={onSelectedTriggersChange}
triggerPickerDocsLink={triggerPickerDocsLink}
/>
</>
)}
<EuiSpacer size="l" />
<div>
<actionFactory.ReactCollectConfig
config={config}
@ -156,9 +283,11 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
);
};
interface ActionFactorySelectorProps {
interface ActionFactorySelectorProps<
ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext
> {
actionFactories: ActionFactory[];
context: object;
context: ActionFactoryContext;
onActionFactorySelected: (actionFactory: ActionFactory) => void;
}
@ -224,3 +353,10 @@ const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({
</EuiFlexGroup>
);
};
function getTriggersForActionFactory(
actionFactory: ActionFactory,
allTriggers: TriggerId[]
): TriggerId[] {
return actionFactory.supportedTriggers().filter((trigger) => allTriggers.includes(trigger));
}

View file

@ -12,3 +12,17 @@ export const txtChangeButton = i18n.translate(
defaultMessage: 'Change',
}
);
export const txtTriggerPickerLabel = i18n.translate(
'xpack.uiActionsEnhanced.components.actionWizard.triggerPickerLabel',
{
defaultMessage: 'Pick a trigger:',
}
);
export const txtTriggerPickerHelpText = i18n.translate(
'xpack.uiActionsEnhanced.components.actionWizard.helpText',
{
defaultMessage: "What's this?",
}
);

View file

@ -8,9 +8,16 @@ import React, { useState } from 'react';
import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui';
import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public';
import { ActionWizard } from './action_wizard';
import { ActionFactoryDefinition, ActionFactory } from '../../dynamic_actions';
import { ActionFactory, ActionFactoryDefinition } from '../../dynamic_actions';
import { CollectConfigProps } from '../../../../../../src/plugins/kibana_utils/public';
import { licenseMock } from '../../../../licensing/common/licensing.mock';
import {
APPLY_FILTER_TRIGGER,
SELECT_RANGE_TRIGGER,
Trigger,
TriggerId,
VALUE_CLICK_TRIGGER,
} from '../../../../../../src/plugins/ui_actions/public';
type ActionBaseConfig = object;
@ -104,6 +111,9 @@ export const dashboardDrilldownActionFactory: ActionFactoryDefinition<
execute: async () => alert('Navigate to dashboard!'),
enhancements: {},
}),
supportedTriggers(): any[] {
return [APPLY_FILTER_TRIGGER];
},
};
export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory, () =>
@ -161,16 +171,45 @@ export const urlDrilldownActionFactory: ActionFactoryDefinition<UrlDrilldownConf
return Promise.resolve(true);
},
create: () => null as any,
supportedTriggers(): any[] {
return [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER];
},
};
export const urlFactory = new ActionFactory(urlDrilldownActionFactory, () =>
licenseMock.createLicense()
);
export const mockSupportedTriggers: TriggerId[] = [
VALUE_CLICK_TRIGGER,
SELECT_RANGE_TRIGGER,
APPLY_FILTER_TRIGGER,
];
export const mockGetTriggerInfo = (triggerId: TriggerId): Trigger => {
const titleMap = {
[VALUE_CLICK_TRIGGER]: 'Single click',
[SELECT_RANGE_TRIGGER]: 'Range selection',
[APPLY_FILTER_TRIGGER]: 'Apply filter',
} as Record<any, string>;
const descriptionMap = {
[VALUE_CLICK_TRIGGER]: 'A single point clicked on a visualization',
[SELECT_RANGE_TRIGGER]: 'Select a group of values',
[APPLY_FILTER_TRIGGER]: 'Apply filter description...',
} as Record<any, string>;
return {
id: triggerId,
title: titleMap[triggerId] ?? 'Unknown',
description: descriptionMap[triggerId] ?? 'Unknown description',
};
};
export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory<any>> }) {
const [state, setState] = useState<{
currentActionFactory?: ActionFactory;
config?: ActionBaseConfig;
selectedTriggers?: TriggerId[];
}>({});
function changeActionFactory(newActionFactory?: ActionFactory) {
@ -200,7 +239,15 @@ export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory
changeActionFactory(newActionFactory);
}}
currentActionFactory={state.currentActionFactory}
context={{}}
context={{ triggers: state.selectedTriggers ?? [] }}
onSelectedTriggersChange={(triggers) => {
setState({
...state,
selectedTriggers: triggers,
});
}}
getTriggerInfo={mockGetTriggerInfo}
supportedTriggers={[VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER, SELECT_RANGE_TRIGGER]}
/>
<div style={{ marginTop: '44px' }} />
<hr />
@ -210,6 +257,7 @@ export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory
Is config valid:{' '}
{JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)}
</div>
<div>Picked trigger: {state.selectedTriggers?.[0]}</div>
</>
);
}

View file

@ -25,10 +25,25 @@ const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({
alert(JSON.stringify(args));
},
} as any,
getTrigger: (triggerId) => ({
id: triggerId,
}),
});
storiesOf('components/FlyoutManageDrilldowns', module).add('default', () => (
<EuiFlyout onClose={() => {}}>
<FlyoutManageDrilldowns dynamicActionManager={mockDynamicActionManager} />
</EuiFlyout>
));
storiesOf('components/FlyoutManageDrilldowns', module)
.add('default (3 triggers)', () => (
<EuiFlyout onClose={() => {}}>
<FlyoutManageDrilldowns
dynamicActionManager={mockDynamicActionManager}
supportedTriggers={['VALUE_CLICK_TRIGGER', 'SELECT_RANGE_TRIGGER', 'FILTER_TRIGGER']}
/>
</EuiFlyout>
))
.add('Only filter is supported', () => (
<EuiFlyout onClose={() => {}}>
<FlyoutManageDrilldowns
dynamicActionManager={mockDynamicActionManager}
supportedTriggers={['FILTER_TRIGGER']}
/>
</EuiFlyout>
));

View file

@ -7,7 +7,12 @@
import React from 'react';
import { cleanup, fireEvent, render, wait } from '@testing-library/react/pure';
import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns';
import { dashboardFactory, urlFactory } from '../../../components/action_wizard/test_data';
import {
dashboardFactory,
mockGetTriggerInfo,
mockSupportedTriggers,
urlFactory,
} from '../../../components/action_wizard/test_data';
import { StubBrowserStorage } from '../../../../../../../src/test_utils/public/stub_browser_storage';
import { Storage } from '../../../../../../../src/plugins/kibana_utils/public';
import { mockDynamicActionManager } from './test_data';
@ -24,6 +29,7 @@ const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({
actionFactories: [dashboardFactory as ActionFactory, urlFactory as ActionFactory],
storage: new Storage(new StubBrowserStorage()),
toastService: toasts,
getTrigger: mockGetTriggerInfo,
});
// https://github.com/elastic/kibana/issues/59469
@ -31,12 +37,18 @@ afterEach(cleanup);
beforeEach(() => {
storage.clear();
mockDynamicActionManager.state.set({ ...mockDynamicActionManager.state.get(), events: [] });
(toasts as jest.Mocked<NotificationsStart['toasts']>).addSuccess.mockClear();
(toasts as jest.Mocked<NotificationsStart['toasts']>).addError.mockClear();
});
test('Allows to manage drilldowns', async () => {
const screen = render(<FlyoutManageDrilldowns dynamicActionManager={mockDynamicActionManager} />);
const screen = render(
<FlyoutManageDrilldowns
dynamicActionManager={mockDynamicActionManager}
supportedTriggers={mockSupportedTriggers}
/>
);
// wait for initial render. It is async because resolving compatible action factories is async
await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible());
@ -103,7 +115,12 @@ test('Allows to manage drilldowns', async () => {
});
test('Can delete multiple drilldowns', async () => {
const screen = render(<FlyoutManageDrilldowns dynamicActionManager={mockDynamicActionManager} />);
const screen = render(
<FlyoutManageDrilldowns
dynamicActionManager={mockDynamicActionManager}
supportedTriggers={mockSupportedTriggers}
/>
);
// wait for initial render. It is async because resolving compatible action factories is async
await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible());
@ -143,6 +160,7 @@ test('Create only mode', async () => {
dynamicActionManager={mockDynamicActionManager}
viewMode={'create'}
onClose={onClose}
supportedTriggers={mockSupportedTriggers}
/>
);
// wait for initial render. It is async because resolving compatible action factories is async
@ -163,7 +181,11 @@ test('Create only mode', async () => {
test('After switching between action factories state is restored', async () => {
const screen = render(
<FlyoutManageDrilldowns dynamicActionManager={mockDynamicActionManager} viewMode={'create'} />
<FlyoutManageDrilldowns
dynamicActionManager={mockDynamicActionManager}
viewMode={'create'}
supportedTriggers={mockSupportedTriggers}
/>
);
// wait for initial render. It is async because resolving compatible action factories is async
await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0));
@ -200,7 +222,12 @@ test("Error when can't save drilldown changes", async () => {
jest.spyOn(mockDynamicActionManager, 'createEvent').mockImplementationOnce(async () => {
throw error;
});
const screen = render(<FlyoutManageDrilldowns dynamicActionManager={mockDynamicActionManager} />);
const screen = render(
<FlyoutManageDrilldowns
dynamicActionManager={mockDynamicActionManager}
supportedTriggers={mockSupportedTriggers}
/>
);
// wait for initial render. It is async because resolving compatible action factories is async
await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible());
fireEvent.click(screen.getByText(/Create new/i));
@ -218,7 +245,12 @@ test("Error when can't save drilldown changes", async () => {
});
test('Should show drilldown welcome message. Should be able to dismiss it', async () => {
let screen = render(<FlyoutManageDrilldowns dynamicActionManager={mockDynamicActionManager} />);
let screen = render(
<FlyoutManageDrilldowns
dynamicActionManager={mockDynamicActionManager}
supportedTriggers={mockSupportedTriggers}
/>
);
// wait for initial render. It is async because resolving compatible action factories is async
await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible());
@ -228,8 +260,63 @@ test('Should show drilldown welcome message. Should be able to dismiss it', asyn
expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull();
cleanup();
screen = render(<FlyoutManageDrilldowns dynamicActionManager={mockDynamicActionManager} />);
screen = render(
<FlyoutManageDrilldowns
dynamicActionManager={mockDynamicActionManager}
supportedTriggers={mockSupportedTriggers}
/>
);
// wait for initial render. It is async because resolving compatible action factories is async
await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible());
expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull();
});
test('Drilldown type is not shown if no supported trigger', async () => {
const screen = render(
<FlyoutManageDrilldowns
dynamicActionManager={mockDynamicActionManager}
supportedTriggers={['VALUE_CLICK_TRIGGER']}
viewMode={'create'}
/>
);
// wait for initial render. It is async because resolving compatible action factories is async
await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0));
expect(screen.queryByText(/Go to Dashboard/i)).not.toBeInTheDocument(); // dashboard action is not visible, because APPLY_FILTER_TRIGGER not supported
expect(screen.getByTestId('selectedActionFactory-Url')).toBeInTheDocument();
});
test('Can pick a trigger', async () => {
const screen = render(
<FlyoutManageDrilldowns
dynamicActionManager={mockDynamicActionManager}
supportedTriggers={mockSupportedTriggers}
viewMode={'create'}
/>
);
// wait for initial render. It is async because resolving compatible action factories is async
await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0));
// input drilldown name
const name = 'Test name';
fireEvent.change(screen.getByLabelText(/name/i), {
target: { value: name },
});
// select URL one
fireEvent.click(screen.getByText(/Go to URL/i));
// Input url
const URL = 'https://elastic.co';
fireEvent.change(screen.getByLabelText(/url/i), {
target: { value: URL },
});
fireEvent.click(screen.getByTestId('triggerPicker-SELECT_RANGE_TRIGGER').querySelector('input')!);
const [, createButton] = screen.getAllByText(/Create Drilldown/i);
expect(createButton).toBeEnabled();
fireEvent.click(createButton);
await wait(() => expect(toasts.addSuccess).toBeCalled());
expect(mockDynamicActionManager.state.get().events[0].triggers).toEqual(['SELECT_RANGE_TRIGGER']);
});

View file

@ -4,16 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { ToastsStart } from 'kibana/public';
import useMountedState from 'react-use/lib/useMountedState';
import intersection from 'lodash/intersection';
import { DrilldownWizardConfig, FlyoutDrilldownWizard } from '../flyout_drilldown_wizard';
import { FlyoutListManageDrilldowns } from '../flyout_list_manage_drilldowns';
import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public';
import {
TriggerContextMapping,
APPLY_FILTER_TRIGGER,
} from '../../../../../../../src/plugins/ui_actions/public';
import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public';
import { useContainerState } from '../../../../../../../src/plugins/kibana_utils/public';
import { DrilldownListItem } from '../list_manage_drilldowns';
import {
@ -27,15 +25,29 @@ import {
} from './i18n';
import {
ActionFactory,
BaseActionFactoryContext,
DynamicActionManager,
SerializedAction,
SerializedEvent,
} from '../../../dynamic_actions';
import { ExtraActionFactoryContext } from '../types';
interface ConnectedFlyoutManageDrilldownsProps {
interface ConnectedFlyoutManageDrilldownsProps<
ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext
> {
dynamicActionManager: DynamicActionManager;
viewMode?: 'create' | 'manage';
onClose?: () => void;
/**
* List of possible triggers in current context
*/
supportedTriggers: TriggerId[];
/**
* Extra action factory context passed into action factories CollectConfig, getIconType, getDisplayName and etc...
*/
extraContext?: ExtraActionFactoryContext<ActionFactoryContext>;
}
/**
@ -52,8 +64,10 @@ export function createFlyoutManageDrilldowns({
storage,
toastService,
docsLink,
getTrigger,
}: {
actionFactories: ActionFactory[];
getTrigger: (triggerId: TriggerId) => Trigger;
storage: IStorageWrapper;
toastService: ToastsStart;
docsLink?: string;
@ -66,19 +80,10 @@ export function createFlyoutManageDrilldowns({
return (props: ConnectedFlyoutManageDrilldownsProps) => {
const isCreateOnly = props.viewMode === 'create';
// TODO: https://github.com/elastic/kibana/issues/59569
const selectedTriggers: Array<keyof TriggerContextMapping> = React.useMemo(
() => [APPLY_FILTER_TRIGGER],
[]
const factoryContext: BaseActionFactoryContext = useMemo(
() => ({ ...props.extraContext, triggers: props.supportedTriggers }),
[props.extraContext, props.supportedTriggers]
);
const factoryContext: object = React.useMemo(
() => ({
triggers: selectedTriggers,
}),
[selectedTriggers]
);
const actionFactories = useCompatibleActionFactoriesForCurrentContext(
allActionFactories,
factoryContext
@ -122,6 +127,7 @@ export function createFlyoutManageDrilldowns({
actionFactory: allActionFactoriesById[drilldownToEdit.action.factoryId],
actionConfig: drilldownToEdit.action.config as object,
name: drilldownToEdit.action.name,
selectedTriggers: (drilldownToEdit.triggers ?? []) as TriggerId[],
};
}
@ -130,16 +136,22 @@ export function createFlyoutManageDrilldowns({
*/
function mapToDrilldownToDrilldownListItem(drilldown: SerializedEvent): DrilldownListItem {
const actionFactory = allActionFactoriesById[drilldown.action.factoryId];
const drilldownFactoryContext: BaseActionFactoryContext = {
...props.extraContext,
triggers: drilldown.triggers as TriggerId[],
};
return {
id: drilldown.eventId,
drilldownName: drilldown.action.name,
actionName: actionFactory?.getDisplayName(factoryContext) ?? drilldown.action.factoryId,
icon: actionFactory?.getIconType(factoryContext),
actionName:
actionFactory?.getDisplayName(drilldownFactoryContext) ?? drilldown.action.factoryId,
icon: actionFactory?.getIconType(drilldownFactoryContext),
error: !actionFactory
? invalidDrilldownType(drilldown.action.factoryId) // this shouldn't happen for the end user, but useful during development
: !actionFactory.isCompatibleLicence()
? insufficientLicenseLevel
: undefined,
triggers: drilldown.triggers.map((trigger) => getTrigger(trigger as TriggerId)),
};
}
@ -155,7 +167,7 @@ export function createFlyoutManageDrilldowns({
onClose={props.onClose}
mode={route === Routes.Create ? 'create' : 'edit'}
onBack={isCreateOnly ? undefined : () => setRoute(Routes.Manage)}
onSubmit={({ actionConfig, actionFactory, name }) => {
onSubmit={({ actionConfig, actionFactory, name, selectedTriggers }) => {
if (route === Routes.Create) {
createDrilldown(
{
@ -192,13 +204,23 @@ export function createFlyoutManageDrilldowns({
setRoute(Routes.Manage);
setCurrentEditId(null);
}}
actionFactoryContext={factoryContext}
extraActionFactoryContext={props.extraContext}
initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()}
supportedTriggers={props.supportedTriggers}
getTrigger={getTrigger}
/>
);
case Routes.Manage:
default:
// show trigger column in case if there is more then 1 possible trigger in current context
const showTriggerColumn =
intersection(
props.supportedTriggers,
actionFactories
.map((factory) => factory.supportedTriggers())
.reduce((res, next) => res.concat(next), [])
).length > 1;
return (
<FlyoutListManageDrilldowns
docsLink={docsLink}
@ -218,16 +240,16 @@ export function createFlyoutManageDrilldowns({
setRoute(Routes.Create);
}}
onClose={props.onClose}
showTriggerColumn={showTriggerColumn}
/>
);
}
};
}
function useCompatibleActionFactoriesForCurrentContext<Context extends object = object>(
actionFactories: ActionFactory[],
context: Context
) {
function useCompatibleActionFactoriesForCurrentContext<
Context extends BaseActionFactoryContext = BaseActionFactoryContext
>(actionFactories: ActionFactory[], context: Context) {
const [compatibleActionFactories, setCompatibleActionFactories] = useState<ActionFactory[]>();
useEffect(() => {
let canceled = false;
@ -236,13 +258,18 @@ function useCompatibleActionFactoriesForCurrentContext<Context extends object =
actionFactories.map((factory) => factory.isCompatible(context))
);
if (canceled) return;
setCompatibleActionFactories(actionFactories.filter((_, i) => compatibility[i]));
const compatibleFactories = actionFactories.filter((_, i) => compatibility[i]);
const triggerSupportedFactories = compatibleFactories.filter((factory) =>
factory.supportedTriggers().some((trigger) => context.triggers.includes(trigger))
);
setCompatibleActionFactories(triggerSupportedFactories);
}
updateCompatibleFactoriesForContext();
return () => {
canceled = true;
};
}, [context, actionFactories]);
}, [context, actionFactories, context.triggers]);
return compatibleActionFactories;
}
@ -280,10 +307,7 @@ function useDrilldownsStateManager(actionManager: DynamicActionManager, toastSer
}
}
async function createDrilldown(
action: SerializedAction,
selectedTriggers: Array<keyof TriggerContextMapping>
) {
async function createDrilldown(action: SerializedAction, selectedTriggers: TriggerId[]) {
await run(async () => {
await actionManager.createEvent(action, selectedTriggers);
toastService.addSuccess({
@ -296,7 +320,7 @@ function useDrilldownsStateManager(actionManager: DynamicActionManager, toastSer
async function editDrilldown(
drilldownId: string,
action: SerializedAction,
selectedTriggers: Array<keyof TriggerContextMapping>
selectedTriggers: TriggerId[]
) {
await run(async () => {
await actionManager.updateEvent(drilldownId, action, selectedTriggers);

View file

@ -10,12 +10,24 @@ import { storiesOf } from '@storybook/react';
import { FlyoutDrilldownWizard } from './index';
import { dashboardFactory, urlFactory } from '../../../components/action_wizard/test_data';
import { ActionFactory } from '../../../dynamic_actions';
import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public';
const otherProps = {
supportedTriggers: [
'VALUE_CLICK_TRIGGER',
'SELECT_RANGE_TRIGGER',
'FILTER_TRIGGER',
] as TriggerId[],
onClose: () => {},
getTrigger: (id: TriggerId) => ({ id } as Trigger),
};
storiesOf('components/FlyoutDrilldownWizard', module)
.add('default', () => {
return (
<FlyoutDrilldownWizard
drilldownActionFactories={[urlFactory as ActionFactory, dashboardFactory as ActionFactory]}
{...otherProps}
/>
);
})
@ -23,11 +35,11 @@ storiesOf('components/FlyoutDrilldownWizard', module)
return (
<EuiFlyout onClose={() => {}}>
<FlyoutDrilldownWizard
onClose={() => {}}
drilldownActionFactories={[
urlFactory as ActionFactory,
dashboardFactory as ActionFactory,
]}
{...otherProps}
/>
</EuiFlyout>
);
@ -36,7 +48,6 @@ storiesOf('components/FlyoutDrilldownWizard', module)
return (
<EuiFlyout onClose={() => {}}>
<FlyoutDrilldownWizard
onClose={() => {}}
drilldownActionFactories={[
urlFactory as ActionFactory,
dashboardFactory as ActionFactory,
@ -50,6 +61,7 @@ storiesOf('components/FlyoutDrilldownWizard', module)
},
}}
mode={'edit'}
{...otherProps}
/>
</EuiFlyout>
);
@ -58,7 +70,6 @@ storiesOf('components/FlyoutDrilldownWizard', module)
return (
<EuiFlyout onClose={() => {}}>
<FlyoutDrilldownWizard
onClose={() => {}}
drilldownActionFactories={[dashboardFactory as ActionFactory]}
initialDrilldownWizardConfig={{
name: 'My fancy drilldown',
@ -69,6 +80,7 @@ storiesOf('components/FlyoutDrilldownWizard', module)
},
}}
mode={'edit'}
{...otherProps}
/>
</EuiFlyout>
);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { EuiButton, EuiSpacer } from '@elastic/eui';
import { FormDrilldownWizard } from '../form_drilldown_wizard';
import { FlyoutFrame } from '../flyout_frame';
@ -16,15 +16,21 @@ import {
txtEditDrilldownTitle,
} from './i18n';
import { DrilldownHelloBar } from '../drilldown_hello_bar';
import { ActionFactory } from '../../../dynamic_actions';
import { ActionFactory, BaseActionFactoryContext } from '../../../dynamic_actions';
import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public';
import { ExtraActionFactoryContext } from '../types';
export interface DrilldownWizardConfig<ActionConfig extends object = object> {
name: string;
actionFactory?: ActionFactory;
actionConfig?: ActionConfig;
selectedTriggers?: TriggerId[];
}
export interface FlyoutDrilldownWizardProps<CurrentActionConfig extends object = object> {
export interface FlyoutDrilldownWizardProps<
CurrentActionConfig extends object = object,
ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext
> {
drilldownActionFactories: ActionFactory[];
onSubmit?: (drilldownWizardConfig: Required<DrilldownWizardConfig>) => void;
@ -38,9 +44,16 @@ export interface FlyoutDrilldownWizardProps<CurrentActionConfig extends object =
showWelcomeMessage?: boolean;
onWelcomeHideClick?: () => void;
actionFactoryContext?: object;
extraActionFactoryContext?: ExtraActionFactoryContext<ActionFactoryContext>;
docsLink?: string;
getTrigger: (triggerId: TriggerId) => Trigger;
/**
* List of possible triggers in current context
*/
supportedTriggers: TriggerId[];
}
function useWizardConfigState(
@ -51,6 +64,7 @@ function useWizardConfigState(
setName: (name: string) => void;
setActionConfig: (actionConfig: object) => void;
setActionFactory: (actionFactory?: ActionFactory) => void;
setSelectedTriggers: (triggers?: TriggerId[]) => void;
}
] {
const [wizardConfig, setWizardConfig] = useState<DrilldownWizardConfig>(
@ -105,6 +119,12 @@ function useWizardConfigState(
});
}
},
setSelectedTriggers: (selectedTriggers: TriggerId[] = []) => {
setWizardConfig({
...wizardConfig,
selectedTriggers,
});
},
},
];
}
@ -119,12 +139,15 @@ export function FlyoutDrilldownWizard<CurrentActionConfig extends object = objec
showWelcomeMessage = true,
onWelcomeHideClick,
drilldownActionFactories,
actionFactoryContext,
extraActionFactoryContext,
docsLink,
getTrigger,
supportedTriggers,
}: FlyoutDrilldownWizardProps<CurrentActionConfig>) {
const [wizardConfig, { setActionFactory, setActionConfig, setName }] = useWizardConfigState(
initialDrilldownWizardConfig
);
const [
wizardConfig,
{ setActionFactory, setActionConfig, setName, setSelectedTriggers },
] = useWizardConfigState(initialDrilldownWizardConfig);
const isActionValid = (
config: DrilldownWizardConfig
@ -132,10 +155,19 @@ export function FlyoutDrilldownWizard<CurrentActionConfig extends object = objec
if (!wizardConfig.name) return false;
if (!wizardConfig.actionFactory) return false;
if (!wizardConfig.actionConfig) return false;
if (!wizardConfig.selectedTriggers || wizardConfig.selectedTriggers.length === 0) return false;
return wizardConfig.actionFactory.isConfigValid(wizardConfig.actionConfig);
};
const actionFactoryContext: BaseActionFactoryContext = useMemo(
() => ({
...extraActionFactoryContext,
triggers: wizardConfig.selectedTriggers ?? [],
}),
[extraActionFactoryContext, wizardConfig.selectedTriggers]
);
const footer = (
<EuiButton
onClick={() => {
@ -171,7 +203,11 @@ export function FlyoutDrilldownWizard<CurrentActionConfig extends object = objec
currentActionFactory={wizardConfig.actionFactory}
onActionFactoryChange={setActionFactory}
actionFactories={drilldownActionFactories}
actionFactoryContext={actionFactoryContext!}
actionFactoryContext={actionFactoryContext}
onSelectedTriggersChange={setSelectedTriggers}
supportedTriggers={supportedTriggers}
getTriggerInfo={getTrigger}
triggerPickerDocsLink={docsLink}
/>
{mode === 'edit' && (
<>

View file

@ -19,6 +19,7 @@ export interface FlyoutListManageDrilldownsProps {
onDelete?: (drilldownIds: string[]) => void;
showWelcomeMessage?: boolean;
onWelcomeHideClick?: () => void;
showTriggerColumn?: boolean;
}
export function FlyoutListManageDrilldowns({
@ -30,6 +31,7 @@ export function FlyoutListManageDrilldowns({
onEdit,
showWelcomeMessage = true,
onWelcomeHideClick,
showTriggerColumn,
}: FlyoutListManageDrilldownsProps) {
return (
<FlyoutFrame
@ -46,6 +48,7 @@ export function FlyoutListManageDrilldowns({
onCreate={onCreate}
onEdit={onEdit}
onDelete={onDelete}
showTriggerColumn={showTriggerColumn}
/>
</FlyoutFrame>
);

View file

@ -7,13 +7,25 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { FormDrilldownWizard } from './index';
import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public';
const otherProps = {
supportedTriggers: [
'VALUE_CLICK_TRIGGER',
'SELECT_RANGE_TRIGGER',
'FILTER_TRIGGER',
] as TriggerId[],
getTriggerInfo: (id: TriggerId) => ({ id } as Trigger),
onSelectedTriggersChange: () => {},
actionFactoryContext: { triggers: [] as TriggerId[] },
};
const DemoEditName: React.FC = () => {
const [name, setName] = React.useState('');
return (
<>
<FormDrilldownWizard name={name} onNameChange={setName} actionFactoryContext={{}} />{' '}
<FormDrilldownWizard name={name} onNameChange={setName} {...otherProps} />{' '}
<div>name: {name}</div>
</>
);
@ -21,9 +33,9 @@ const DemoEditName: React.FC = () => {
storiesOf('components/FormDrilldownWizard', module)
.add('default', () => {
return <FormDrilldownWizard actionFactoryContext={{}} />;
return <FormDrilldownWizard {...otherProps} />;
})
.add('[name=foobar]', () => {
return <FormDrilldownWizard name={'foobar'} actionFactoryContext={{}} />;
return <FormDrilldownWizard name={'foobar'} {...otherProps} />;
})
.add('can edit name', () => <DemoEditName />);

View file

@ -9,20 +9,32 @@ import { render } from 'react-dom';
import { FormDrilldownWizard } from './form_drilldown_wizard';
import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure';
import { txtNameOfDrilldown } from './i18n';
import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public';
afterEach(cleanup);
const otherProps = {
actionFactoryContext: { triggers: [] as TriggerId[] },
supportedTriggers: [
'VALUE_CLICK_TRIGGER',
'SELECT_RANGE_TRIGGER',
'FILTER_TRIGGER',
] as TriggerId[],
getTriggerInfo: (id: TriggerId) => ({ id } as Trigger),
onSelectedTriggersChange: () => {},
};
describe('<FormDrilldownWizard>', () => {
test('renders without crashing', () => {
const div = document.createElement('div');
render(<FormDrilldownWizard onNameChange={() => {}} actionFactoryContext={{}} />, div);
render(<FormDrilldownWizard onNameChange={() => {}} {...otherProps} />, div);
});
describe('[name=]', () => {
test('if name not provided, uses to empty string', () => {
const div = document.createElement('div');
render(<FormDrilldownWizard actionFactoryContext={{}} />, div);
render(<FormDrilldownWizard {...otherProps} />, div);
const input = div.querySelector('[data-test-subj="drilldownNameInput"]') as HTMLInputElement;
@ -32,13 +44,13 @@ describe('<FormDrilldownWizard>', () => {
test('can set initial name input field value', () => {
const div = document.createElement('div');
render(<FormDrilldownWizard name={'foo'} actionFactoryContext={{}} />, div);
render(<FormDrilldownWizard name={'foo'} {...otherProps} />, div);
const input = div.querySelector('[data-test-subj="drilldownNameInput"]') as HTMLInputElement;
expect(input?.value).toBe('foo');
render(<FormDrilldownWizard name={'bar'} actionFactoryContext={{}} />, div);
render(<FormDrilldownWizard name={'bar'} {...otherProps} />, div);
expect(input?.value).toBe('bar');
});
@ -46,7 +58,7 @@ describe('<FormDrilldownWizard>', () => {
test('fires onNameChange callback on name change', () => {
const onNameChange = jest.fn();
const utils = renderTestingLibrary(
<FormDrilldownWizard name={''} onNameChange={onNameChange} actionFactoryContext={{}} />
<FormDrilldownWizard name={''} onNameChange={onNameChange} {...otherProps} />
);
const input = utils.getByLabelText(txtNameOfDrilldown);

View file

@ -8,25 +8,43 @@ import React from 'react';
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 { ActionFactory, BaseActionFactoryContext } from '../../../dynamic_actions';
import { ActionWizard } from '../../../components/action_wizard';
import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public';
const GET_MORE_ACTIONS_LINK = 'https://www.elastic.co/subscriptions';
const noopFn = () => {};
export interface FormDrilldownWizardProps {
export interface FormDrilldownWizardProps<
ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext
> {
name?: string;
onNameChange?: (name: string) => void;
currentActionFactory?: ActionFactory;
onActionFactoryChange?: (actionFactory?: ActionFactory) => void;
actionFactoryContext: object;
actionFactoryContext: ActionFactoryContext;
actionConfig?: object;
onActionConfigChange?: (config: object) => void;
actionFactories?: ActionFactory[];
/**
* Trigger selection has changed
* @param triggers
*/
onSelectedTriggersChange: (triggers?: TriggerId[]) => void;
getTriggerInfo: (triggerId: TriggerId) => Trigger;
/**
* List of possible triggers in current context
*/
supportedTriggers: TriggerId[];
triggerPickerDocsLink?: string;
}
export const FormDrilldownWizard: React.FC<FormDrilldownWizardProps> = ({
@ -38,6 +56,10 @@ export const FormDrilldownWizard: React.FC<FormDrilldownWizardProps> = ({
onActionFactoryChange = noopFn,
actionFactories = [],
actionFactoryContext,
onSelectedTriggersChange,
getTriggerInfo,
supportedTriggers,
triggerPickerDocsLink,
}) => {
const nameFragment = (
<EuiFormRow label={txtNameOfDrilldown}>
@ -86,6 +108,10 @@ export const FormDrilldownWizard: React.FC<FormDrilldownWizardProps> = ({
onActionFactoryChange={(actionFactory) => onActionFactoryChange(actionFactory)}
onConfigChange={(config) => onActionConfigChange(config)}
context={actionFactoryContext}
onSelectedTriggersChange={onSelectedTriggersChange}
getTriggerInfo={getTriggerInfo}
supportedTriggers={supportedTriggers}
triggerPickerDocsLink={triggerPickerDocsLink}
/>
</EuiFormRow>
);

View file

@ -11,9 +11,26 @@ import { ListManageDrilldowns } from './list_manage_drilldowns';
storiesOf('components/ListManageDrilldowns', module).add('default', () => (
<ListManageDrilldowns
drilldowns={[
{ id: '1', actionName: 'Dashboard', drilldownName: 'Drilldown 1', icon: 'dashboardApp' },
{ id: '2', actionName: 'Dashboard', drilldownName: 'Drilldown 2', icon: 'dashboardApp' },
{ id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3' },
{
id: '1',
actionName: 'Dashboard',
drilldownName: 'Drilldown 1',
icon: 'dashboardApp',
triggers: [{ title: 'trigger' }],
},
{
id: '2',
actionName: 'Dashboard',
drilldownName: 'Drilldown 2',
icon: 'dashboardApp',
triggers: [{ title: 'trigger' }],
},
{
id: '3',
actionName: 'Dashboard',
drilldownName: 'Drilldown 3',
triggers: [{ title: 'trigger', description: 'trigger' }],
},
]}
/>
));

View file

@ -30,6 +30,12 @@ export interface DrilldownListItem {
drilldownName: string;
icon?: string;
error?: string;
triggers?: Trigger[];
}
interface Trigger {
title?: string;
description?: string;
}
export interface ListManageDrilldownsProps {
@ -38,6 +44,8 @@ export interface ListManageDrilldownsProps {
onEdit?: (id: string) => void;
onCreate?: () => void;
onDelete?: (ids: string[]) => void;
showTriggerColumn?: boolean;
}
const noop = () => {};
@ -49,14 +57,13 @@ export function ListManageDrilldowns({
onEdit = noop,
onCreate = noop,
onDelete = noop,
showTriggerColumn = true,
}: ListManageDrilldownsProps) {
const [selectedDrilldowns, setSelectedDrilldowns] = useState<string[]>([]);
const columns: Array<EuiBasicTableColumn<DrilldownListItem>> = [
{
name: 'Name',
truncateText: true,
width: '50%',
'data-test-subj': 'drilldownListItemName',
render: (drilldown: DrilldownListItem) => (
<div>
@ -85,21 +92,38 @@ export function ListManageDrilldowns({
<EuiIcon type={drilldown.icon} />
</EuiFlexItem>
)}
<EuiFlexItem grow={false} className="eui-textTruncate">
<EuiFlexItem grow={false} style={{ flexWrap: 'wrap' }}>
<EuiTextColor color="subdued">{drilldown.actionName}</EuiTextColor>
</EuiFlexItem>
</EuiFlexGroup>
),
},
showTriggerColumn && {
name: 'Trigger',
textOnly: true,
render: (drilldown: DrilldownListItem) =>
drilldown.triggers?.map((trigger, idx) =>
trigger.description ? (
<EuiToolTip content={trigger.description} key={idx}>
<EuiTextColor color="subdued">{trigger.title ?? 'unknown'}</EuiTextColor>
</EuiToolTip>
) : (
<EuiTextColor color="subdued" key={idx}>
{trigger.title ?? 'unknown'}
</EuiTextColor>
)
),
},
{
align: 'right',
width: '64px',
render: (drilldown: DrilldownListItem) => (
<EuiButtonEmpty size="xs" onClick={() => onEdit(drilldown.id)}>
{txtEditDrilldown}
</EuiButtonEmpty>
),
},
];
].filter(Boolean) as Array<EuiBasicTableColumn<DrilldownListItem>>;
return (
<>

View file

@ -0,0 +1,15 @@
/*
* 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 { BaseActionFactoryContext } from '../../dynamic_actions';
/**
* Interface used as piece of ActionFactoryContext that is passed in from drilldown wizard component to action factories
* Omitted values are added inside the wizard and then full {@link BaseActionFactoryContext} passed into action factory methods
*/
export type ExtraActionFactoryContext<
ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext
> = Omit<ActionFactoryContext, 'triggers'>;

View file

@ -4,8 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ActionFactoryDefinition } from '../dynamic_actions';
import { ActionFactoryDefinition, BaseActionFactoryContext } from '../dynamic_actions';
import { LicenseType } from '../../../licensing/public';
import { TriggerContextMapping, TriggerId } from '../../../../../src/plugins/ui_actions/public';
import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public';
/**
@ -21,9 +22,14 @@ import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/pu
* and provided to the `execute` function of the drilldown. This object contains
* information about the action user performed.
*/
export interface DrilldownDefinition<
Config extends object = object,
ExecutionContext extends object = object
SupportedTriggers extends TriggerId = TriggerId,
FactoryContext extends BaseActionFactoryContext<SupportedTriggers> = {
triggers: SupportedTriggers[];
},
ExecutionContext extends TriggerContextMapping[SupportedTriggers] = TriggerContextMapping[SupportedTriggers]
> {
/**
* Globally unique identifier for this drilldown.
@ -45,7 +51,12 @@ export interface DrilldownDefinition<
/**
* Function that returns default config for this drilldown.
*/
createConfig: ActionFactoryDefinition<Config, object, ExecutionContext>['createConfig'];
createConfig: ActionFactoryDefinition<
Config,
SupportedTriggers,
FactoryContext,
ExecutionContext
>['createConfig'];
/**
* `UiComponent` that collections config for this drilldown. You can create
@ -66,13 +77,23 @@ export interface DrilldownDefinition<
* export const CollectConfig = uiToReactComponent(ReactCollectConfig);
* ```
*/
CollectConfig: ActionFactoryDefinition<Config, object, ExecutionContext>['CollectConfig'];
CollectConfig: ActionFactoryDefinition<
Config,
SupportedTriggers,
FactoryContext,
ExecutionContext
>['CollectConfig'];
/**
* A validator function for the config object. Should always return a boolean
* given any input.
*/
isConfigValid: ActionFactoryDefinition<Config, object, ExecutionContext>['isConfigValid'];
isConfigValid: ActionFactoryDefinition<
Config,
SupportedTriggers,
FactoryContext,
ExecutionContext
>['isConfigValid'];
/**
* Name of EUI icon to display when showing this drilldown to user.
@ -106,4 +127,10 @@ export interface DrilldownDefinition<
config: Config,
context: ExecutionContext | ActionExecutionContext<ExecutionContext>
): Promise<string | undefined>;
/**
* List of triggers supported by this drilldown type
* This is used in trigger picker when configuring drilldown
*/
supportedTriggers(): SupportedTriggers[];
}

View file

@ -19,12 +19,13 @@ const def: ActionFactoryDefinition = {
getDisplayName: () => name,
enhancements: {},
}),
supportedTriggers: () => [],
};
describe('License & ActionFactory', () => {
test('no license requirements', async () => {
const factory = new ActionFactory(def, () => licensingMock.createLicense());
expect(await factory.isCompatible({})).toBe(true);
expect(await factory.isCompatible({ triggers: [] })).toBe(true);
expect(factory.isCompatibleLicence()).toBe(true);
});
@ -32,7 +33,7 @@ describe('License & ActionFactory', () => {
const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () =>
licensingMock.createLicense()
);
expect(await factory.isCompatible({})).toBe(true);
expect(await factory.isCompatible({ triggers: [] })).toBe(true);
expect(factory.isCompatibleLicence()).toBe(false);
});
@ -40,7 +41,7 @@ describe('License & ActionFactory', () => {
const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () =>
licensingMock.createLicense({ license: { type: 'gold' } })
);
expect(await factory.isCompatible({})).toBe(true);
expect(await factory.isCompatible({ triggers: [] })).toBe(true);
expect(factory.isCompatibleLicence()).toBe(true);
});
});

View file

@ -5,20 +5,32 @@
*/
import { uiToReactComponent } from '../../../../../src/plugins/kibana_react/public';
import { UiActionsPresentable as Presentable } from '../../../../../src/plugins/ui_actions/public';
import {
TriggerContextMapping,
TriggerId,
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 { BaseActionFactoryContext, 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,
FactoryContext extends object = object,
ActionContext extends object = object
SupportedTriggers extends TriggerId = TriggerId,
FactoryContext extends BaseActionFactoryContext<SupportedTriggers> = {
triggers: SupportedTriggers[];
},
ActionContext extends TriggerContextMapping[SupportedTriggers] = TriggerContextMapping[SupportedTriggers]
> implements Omit<Presentable<FactoryContext>, 'getHref'>, Configurable<Config, FactoryContext> {
constructor(
protected readonly def: ActionFactoryDefinition<Config, FactoryContext, ActionContext>,
protected readonly def: ActionFactoryDefinition<
Config,
SupportedTriggers,
FactoryContext,
ActionContext
>,
protected readonly getLicence: () => ILicense
) {}
@ -74,4 +86,8 @@ export class ActionFactory<
},
};
}
public supportedTriggers(): SupportedTriggers[] {
return this.def.supportedTriggers();
}
}

View file

@ -5,9 +5,11 @@
*/
import { Configurable } from '../../../../../src/plugins/kibana_utils/public';
import { SerializedAction } from './types';
import { BaseActionFactoryContext, SerializedAction } from './types';
import { LicenseType } from '../../../licensing/public';
import {
TriggerContextMapping,
TriggerId,
UiActionsActionDefinition as ActionDefinition,
UiActionsPresentable as Presentable,
} from '../../../../../src/plugins/ui_actions/public';
@ -17,8 +19,11 @@ import {
*/
export interface ActionFactoryDefinition<
Config extends object = object,
FactoryContext extends object = object,
ActionContext extends object = object
SupportedTriggers extends TriggerId = TriggerId,
FactoryContext extends BaseActionFactoryContext<SupportedTriggers> = {
triggers: SupportedTriggers[];
},
ActionContext extends TriggerContextMapping[SupportedTriggers] = TriggerContextMapping[SupportedTriggers]
>
extends Partial<Omit<Presentable<FactoryContext>, 'getHref'>>,
Configurable<Config, FactoryContext> {
@ -42,4 +47,6 @@ export interface ActionFactoryDefinition<
create(
serializedAction: Omit<SerializedAction<Config>, 'factoryId'>
): ActionDefinition<ActionContext>;
supportedTriggers(): SupportedTriggers[];
}

View file

@ -24,6 +24,9 @@ const actionFactoryDefinition1: ActionFactoryDefinition = {
execute: async () => {},
getDisplayName: () => name,
}),
supportedTriggers() {
return ['VALUE_CLICK_TRIGGER'];
},
};
const actionFactoryDefinition2: ActionFactoryDefinition = {
@ -36,6 +39,9 @@ const actionFactoryDefinition2: ActionFactoryDefinition = {
execute: async () => {},
getDisplayName: () => name,
}),
supportedTriggers() {
return ['VALUE_CLICK_TRIGGER'];
},
};
const event1: SerializedEvent = {
@ -417,6 +423,21 @@ describe('DynamicActionManager', () => {
expect(actions.size).toBe(0);
});
test('throws when trigger is unknown', async () => {
const { manager, uiActions } = setup([]);
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
await expect(manager.createEvent(action, ['SELECT_RANGE_TRIGGER'])).rejects;
});
});
});

View file

@ -84,7 +84,17 @@ export class DynamicActionManager {
return actionDefinition.isCompatible(context);
},
});
for (const trigger of triggers) uiActions.attachAction(trigger as any, actionId);
const supportedTriggers = factory.supportedTriggers();
for (const trigger of triggers) {
if (!supportedTriggers.includes(trigger as any))
throw new Error(
`Can't attach [action=${actionId}] to [trigger=${trigger}]. Supported triggers for this action: ${supportedTriggers.join(
','
)}`
);
uiActions.attachAction(trigger as any, actionId);
}
}
protected killAction({ eventId, triggers }: SerializedEvent) {

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { TriggerId } from '../../../../../src/plugins/ui_actions/public';
export interface SerializedAction<Config = unknown> {
readonly factoryId: string;
readonly name: string;
@ -18,3 +20,10 @@ export interface SerializedEvent {
triggers: string[];
action: SerializedAction;
}
/**
* Action factory context passed into ActionFactories' CollectConfig, getDisplayName, getIconType
*/
export interface BaseActionFactoryContext<SupportedTriggers extends TriggerId = TriggerId> {
triggers: SupportedTriggers[];
}

View file

@ -28,6 +28,7 @@ export {
DynamicActionManagerParams as UiActionsEnhancedDynamicActionManagerParams,
DynamicActionManagerState as UiActionsEnhancedDynamicActionManagerState,
MemoryActionStorage as UiActionsEnhancedMemoryActionStorage,
BaseActionFactoryContext as UiActionsEnhancedBaseActionFactoryContext,
} from './dynamic_actions';
export { DrilldownDefinition as UiActionsEnhancedDrilldownDefinition } from './drilldowns';

View file

@ -13,7 +13,11 @@ import {
} from '../../../../src/core/public';
import { createReactOverlays } from '../../../../src/plugins/kibana_react/public';
import { UI_SETTINGS } from '../../../../src/plugins/data/public';
import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public';
import {
TriggerId,
UiActionsSetup,
UiActionsStart,
} from '../../../../src/plugins/ui_actions/public';
import {
CONTEXT_MENU_TRIGGER,
PANEL_BADGE_TRIGGER,
@ -116,6 +120,7 @@ export class AdvancedUiActionsPublicPlugin
...this.enhancements,
FlyoutManageDrilldowns: createFlyoutManageDrilldowns({
actionFactories: this.enhancements.getActionFactories(),
getTrigger: (triggerId: TriggerId) => uiActions.getTrigger(triggerId),
storage: new Storage(window?.localStorage),
toastService: core.notifications.toasts,
docsLink: core.docLinks.links.dashboard.drilldowns,

View file

@ -18,6 +18,9 @@ describe('UiActionsService', () => {
createConfig: () => ({}),
isConfigValid: () => true,
create: () => ({} as any),
supportedTriggers() {
return ['VALUE_CLICK_TRIGGER'];
},
};
const factoryDefinition2: ActionFactoryDefinition = {
id: 'test-factory-2',
@ -25,6 +28,9 @@ describe('UiActionsService', () => {
createConfig: () => ({}),
isConfigValid: () => true,
create: () => ({} as any),
supportedTriggers() {
return ['VALUE_CLICK_TRIGGER'];
},
};
test('.getActionFactories() returns empty array if no action factories registered', () => {

View file

@ -5,9 +5,14 @@
*/
import { ActionFactoryRegistry } from '../types';
import { ActionFactory, ActionFactoryDefinition } from '../dynamic_actions';
import {
ActionFactory,
ActionFactoryDefinition,
BaseActionFactoryContext,
} from '../dynamic_actions';
import { DrilldownDefinition } from '../drilldowns';
import { ILicense } from '../../../licensing/common/types';
import { TriggerContextMapping, TriggerId } from '../../../../../src/plugins/ui_actions/public';
export interface UiActionsServiceEnhancementsParams {
readonly actionFactories?: ActionFactoryRegistry;
@ -29,19 +34,24 @@ export class UiActionsServiceEnhancements {
*/
public readonly registerActionFactory = <
Config extends object = object,
FactoryContext extends object = object,
ActionContext extends object = object
SupportedTriggers extends TriggerId = TriggerId,
FactoryContext extends BaseActionFactoryContext<SupportedTriggers> = {
triggers: SupportedTriggers[];
},
ActionContext extends TriggerContextMapping[SupportedTriggers] = TriggerContextMapping[SupportedTriggers]
>(
definition: ActionFactoryDefinition<Config, FactoryContext, ActionContext>
definition: ActionFactoryDefinition<Config, SupportedTriggers, FactoryContext, ActionContext>
) => {
if (this.actionFactories.has(definition.id)) {
throw new Error(`ActionFactory [actionFactory.id = ${definition.id}] already registered.`);
}
const actionFactory = new ActionFactory<Config, FactoryContext, ActionContext>(
definition,
this.getLicenseInfo
);
const actionFactory = new ActionFactory<
Config,
SupportedTriggers,
FactoryContext,
ActionContext
>(definition, this.getLicenseInfo);
this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory<any, any, any>);
};
@ -68,7 +78,11 @@ export class UiActionsServiceEnhancements {
*/
public readonly registerDrilldown = <
Config extends object = object,
ExecutionContext extends object = object
SupportedTriggers extends TriggerId = TriggerId,
FactoryContext extends BaseActionFactoryContext<SupportedTriggers> = {
triggers: SupportedTriggers[];
},
ExecutionContext extends TriggerContextMapping[SupportedTriggers] = TriggerContextMapping[SupportedTriggers]
>({
id: factoryId,
order,
@ -80,8 +94,14 @@ export class UiActionsServiceEnhancements {
execute,
getHref,
minimalLicense,
}: DrilldownDefinition<Config, ExecutionContext>): void => {
const actionFactory: ActionFactoryDefinition<Config, object, ExecutionContext> = {
supportedTriggers,
}: DrilldownDefinition<Config, SupportedTriggers, FactoryContext, ExecutionContext>): void => {
const actionFactory: ActionFactoryDefinition<
Config,
SupportedTriggers,
FactoryContext,
ExecutionContext
> = {
id: factoryId,
minimalLicense,
order,
@ -89,6 +109,7 @@ export class UiActionsServiceEnhancements {
createConfig,
isConfigValid,
getDisplayName,
supportedTriggers,
getIconType: () => euiIcon,
isCompatible: async () => true,
create: (serializedAction) => ({
@ -99,7 +120,7 @@ export class UiActionsServiceEnhancements {
execute: async (context) => await execute(serializedAction.config, context),
getHref: getHref ? async (context) => getHref(serializedAction.config, context) : undefined,
}),
} as ActionFactoryDefinition<Config, object, ExecutionContext>;
} as ActionFactoryDefinition<Config, SupportedTriggers, FactoryContext, ExecutionContext>;
this.registerActionFactory(actionFactory);
};