From 5ff13ada6b3b8a48d9bf9f8fca79421cedbc7940 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Thu, 5 Mar 2020 17:47:08 -0800 Subject: [PATCH] Add custom action to registry and show actions list in siem (#58395) * Add custom action to registry and show actions list in siem * Exposed action form as reusable component * Fixed few small bugs * Fixed red ci * Fixed type checks * Fixed failed tests * Fixed due to comments * Fixed type check errors * Fixed plugin check * Rebalancing CI groups according to #58930 * Fixed merge issues --- x-pack/plugins/triggers_actions_ui/README.md | 165 +++++- .../components/builtin_action_types/email.tsx | 6 +- .../builtin_action_types/server_log.tsx | 2 +- .../components/builtin_action_types/slack.tsx | 2 +- .../application/context/alerts_context.tsx | 4 +- .../action_form.test.tsx | 117 ++++ .../action_connector_form/action_form.tsx | 512 ++++++++++++++++++ .../connector_add_modal.test.tsx | 1 - .../connector_add_modal.tsx | 9 +- .../sections/action_connector_form/index.ts | 1 + .../sections/alert_form/alert_form.test.tsx | 8 - .../sections/alert_form/alert_form.tsx | 508 ++--------------- .../triggers_actions_ui/public/index.ts | 2 + .../security_and_spaces/tests/index.ts | 2 +- .../spaces_only/tests/index.ts | 2 +- 15 files changed, 830 insertions(+), 511 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index ccd33c99f9e1c..0d667f477f936 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -43,6 +43,7 @@ Table of Contents - [Action type model definition](#action-type-model-definition) - [Register action type model](#register-action-type-model) - [Create and register new action type UI example](#reate-and-register-new-action-type-ui-example) + - [Embed the Alert Actions form within any Kibana plugin](#embed-the-alert-actions-form-within-any-kibana-plugin) - [Embed the Create Connector flyout within any Kibana plugin](#embed-the-create-connector-flyout-within-any-kibana-plugin) - [Embed the Edit Connector flyout within any Kibana plugin](#embed-the-edit-connector-flyout-within-any-kibana-plugin) @@ -71,7 +72,7 @@ AlertTypeModel: ``` export function getAlertType(): AlertTypeModel { return { - id: 'threshold', + id: '.index-threshold', name: 'Index Threshold', iconClass: 'alert', alertParamsExpression: IndexThresholdAlertTypeExpression, @@ -660,8 +661,6 @@ const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); // in render section of component (false); metadata: { test: 'some value', fields: ['test'] }, }} > - + ``` @@ -680,6 +680,8 @@ AlertAdd Props definition: ``` interface AlertAddProps { consumer: string; + addFlyoutVisible: boolean; + setAddFlyoutVisibility: React.Dispatch>; alertTypeId?: string; canChangeTrigger?: boolean; } @@ -688,20 +690,20 @@ interface AlertAddProps { |Property|Description| |---|---| |consumer|Name of the plugin that creates an alert.| +|addFlyoutVisible|Visibility state of the Create Alert flyout.| +|setAddFlyoutVisibility|Function for changing visibility state of the Create Alert flyout.| |alertTypeId|Optional property to preselect alert type.| |canChangeTrigger|Optional property, that hides change alert type possibility.| AlertsContextProvider value options: ``` export interface AlertsContextValue> { - addFlyoutVisible: boolean; - setAddFlyoutVisibility: React.Dispatch>; reloadAlerts?: () => Promise; http: HttpSetup; alertTypeRegistry: TypeRegistry; actionTypeRegistry: TypeRegistry; uiSettings?: IUiSettingsClient; - toastNotifications?: Pick< + toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' >; @@ -713,14 +715,12 @@ export interface AlertsContextValue> { |Property|Description| |---|---| -|addFlyoutVisible|Visibility state of the Create Alert flyout.| -|setAddFlyoutVisibility|Function for changing visibility state of the Create Alert flyout.| |reloadAlerts|Optional function, which will be executed if alert was saved sucsessfuly.| |http|HttpSetup needed for executing API calls.| |alertTypeRegistry|Registry for alert types.| |actionTypeRegistry|Registry for action types.| |uiSettings|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| -|toastNotifications|Optional toast messages.| +|toastNotifications|Toast messages.| |charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| |dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| |metadata|Optional generic property, which allows to define component specific metadata. This metadata can be used for passing down preloaded data for Alert type expression component.| @@ -1204,6 +1204,150 @@ Clicking on the select card for `Example Action Type` will open the action type or create a new connector: ![Example Action Type with empty connectors list](https://i.imgur.com/EamA9Xv.png) +## Embed the Alert Actions form within any Kibana plugin + +Follow the instructions bellow to embed the Alert Actions form within any Kibana plugin: +1. Add TriggersAndActionsUIPublicPluginSetup and TriggersAndActionsUIPublicPluginStart to Kibana plugin setup dependencies: + +``` +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, + } from '../../../../../x-pack/plugins/triggers_actions_ui/public'; + +triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +... + +triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; +``` +Then this dependencies will be used to embed Actions form or register your own action type. + +2. Add Actions form to React component: + +``` + import React, { useCallback } from 'react'; + import { ActionForm } from '../../../../../../../../../plugins/triggers_actions_ui/public'; + import { AlertAction } from '../../../../../../../../../plugins/triggers_actions_ui/public/types'; + + const ALOWED_BY_PLUGIN_ACTION_TYPES = [ + { id: '.email', name: 'Email', enabled: true }, + { id: '.index', name: 'Index', enabled: false }, + { id: '.example-action', name: 'Example Action', enabled: false }, + ]; + + export const ComponentWithActionsForm: () => { + const { http, triggers_actions_ui, toastNotifications } = useKibana().services; + const actionTypeRegistry = triggers_actions_ui.actionTypeRegistry; + const initialAlert = ({ + name: 'test', + params: {}, + consumer: 'alerting', + alertTypeId: '.index-threshold', + schedule: { + interval: '1m', + }, + actions: [ + { + group: 'default', + id: 'test', + actionTypeId: '.index', + params: { + message: '', + }, + }, + ], + tags: [], + muteAll: false, + enabled: false, + mutedInstanceIds: [], + } as unknown) as Alert; + + return ( + { + initialAlert.actions[index].id = id; + }} + setAlertProperty={(_updatedActions: AlertAction[]) => {}} + setActionParamsProperty={(key: string, value: any, index: number) => + (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) + } + http={http} + actionTypeRegistry={actionTypeRegistry} + defaultActionMessage={'Alert [{{ctx.metadata.name}}] has exceeded the threshold'} + actionTypes={ALOWED_BY_PLUGIN_ACTION_TYPES} + toastNotifications={toastNotifications} + /> + ); + }; +``` + +ActionForm Props definition: +``` +interface ActionAccordionFormProps { + actions: AlertAction[]; + defaultActionGroupId: string; + setActionIdByIndex: (id: string, index: number) => void; + setAlertProperty: (actions: AlertAction[]) => void; + setActionParamsProperty: (key: string, value: any, index: number) => void; + http: HttpSetup; + actionTypeRegistry: TypeRegistry; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionTypes?: ActionType[]; + messageVariables?: string[]; + defaultActionMessage?: string; +} + +``` + +|Property|Description| +|---|---| +|actions|List of actions comes from alert.actions property.| +|defaultActionGroupId|Default action group id to which each new action will belong to.| +|setActionIdByIndex|Function for changing action 'id' by the proper index in alert.actions array.| +|setAlertProperty|Function for changing alert property 'actions'. Used when deleting action from the array to reset it.| +|setActionParamsProperty|Function for changing action key/value property by index in alert.actions array.| +|http|HttpSetup needed for executing API calls.| +|actionTypeRegistry|Registry for action types.| +|toastNotifications|Toast messages.| +|actionTypes|Optional property, which allowes to define a list of available actions specific for a current plugin.| +|actionTypes|Optional property, which allowes to define a list of variables for action 'message' property.| +|defaultActionMessage|Optional property, which allowes to define a message value for action with 'message' property.| + + +AlertsContextProvider value options: +``` +export interface AlertsContextValue { + reloadAlerts?: () => Promise; + http: HttpSetup; + alertTypeRegistry: TypeRegistry; + actionTypeRegistry: TypeRegistry; + uiSettings?: IUiSettingsClient; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + charts?: ChartsPluginSetup; + dataFieldsFormats?: Pick; +} +``` + +|Property|Description| +|---|---| +|reloadAlerts|Optional function, which will be executed if alert was saved sucsessfuly.| +|http|HttpSetup needed for executing API calls.| +|alertTypeRegistry|Registry for alert types.| +|actionTypeRegistry|Registry for action types.| +|uiSettings|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| +|toastNotifications|Toast messages.| +|charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| +|dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| + ## Embed the Create Connector flyout within any Kibana plugin Follow the instructions bellow to embed the Create Connector flyout within any Kibana plugin: @@ -1413,3 +1557,4 @@ export interface ActionsConnectorsContextValue { |capabilities|Property, which is defining action current user usage capabilities like canSave or canDelete.| |toastNotifications|Toast messages.| |reloadConnectors|Optional function, which will be executed if connector was saved sucsessfuly, like reload list of connecotrs.| + diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx index f82b2c8c88ada..6c994051ec980 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx @@ -263,14 +263,14 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && port !== undefined} fullWidth name="port" - value={port} + value={port || ''} data-test-subj="emailPortInput" onChange={e => { editActionConfig('port', parseInt(e.target.value, 10)); }} onBlur={() => { if (!port) { - editActionConfig('port', ''); + editActionConfig('port', 0); } }} /> @@ -380,7 +380,7 @@ const EmailParamsFields: React.FunctionComponent(false); useEffect(() => { - if (defaultMessage && defaultMessage.length > 0) { + if (!message && defaultMessage && defaultMessage.length > 0) { editAction('message', defaultMessage, index); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx index 8d8045042cfc3..f0ac43c04ee0e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx @@ -75,7 +75,7 @@ export const ServerLogParamsFields: React.FunctionComponent { editAction('level', 'info', index); - if (defaultMessage && defaultMessage.length > 0) { + if (!message && defaultMessage && defaultMessage.length > 0) { editAction('message', defaultMessage, index); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx index 916715de7ae18..a8ba11faa08dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx @@ -143,7 +143,7 @@ const SlackParamsFields: React.FunctionComponent(false); useEffect(() => { - if (defaultMessage && defaultMessage.length > 0) { + if (!message && defaultMessage && defaultMessage.length > 0) { editAction('message', defaultMessage, index); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx index a8578acc24636..1944cdeab7552 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx @@ -16,11 +16,11 @@ export interface AlertsContextValue> { http: HttpSetup; alertTypeRegistry: TypeRegistry; actionTypeRegistry: TypeRegistry; - uiSettings?: IUiSettingsClient; - toastNotifications?: Pick< + toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' >; + uiSettings?: IUiSettingsClient; charts?: ChartsPluginSetup; dataFieldsFormats?: DataPublicPluginSetup['fieldFormats']; metadata?: MetaData; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx new file mode 100644 index 0000000000000..caed0caefe109 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -0,0 +1,117 @@ +/* + * 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, { Fragment } from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ValidationResult, Alert, AlertAction } from '../../../types'; +import { ActionForm } from './action_form'; +const actionTypeRegistry = actionTypeRegistryMock.create(); +describe('action_form', () => { + let deps: any; + const alertType = { + id: 'my-alert-type', + iconClass: 'test', + name: 'test-alert', + validate: (): ValidationResult => { + return { errors: {} }; + }, + alertParamsExpression: () => , + }; + + const actionType = { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + + describe('action_form in alert', () => { + let wrapper: ReactWrapper; + + async function setup() { + const mockes = coreMock.createSetup(); + deps = { + toastNotifications: mockes.notifications.toasts, + http: mockes.http, + actionTypeRegistry: actionTypeRegistry as any, + }; + actionTypeRegistry.list.mockReturnValue([actionType]); + actionTypeRegistry.has.mockReturnValue(true); + + const initialAlert = ({ + name: 'test', + params: {}, + consumer: 'alerting', + alertTypeId: alertType.id, + schedule: { + interval: '1m', + }, + actions: [ + { + group: 'default', + id: 'test', + actionTypeId: actionType.id, + params: { + message: '', + }, + }, + ], + tags: [], + muteAll: false, + enabled: false, + mutedInstanceIds: [], + } as unknown) as Alert; + + wrapper = mountWithIntl( + { + initialAlert.actions[index].id = id; + }} + setAlertProperty={(_updatedActions: AlertAction[]) => {}} + setActionParamsProperty={(key: string, value: any, index: number) => + (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) + } + http={deps!.http} + actionTypeRegistry={deps!.actionTypeRegistry} + defaultActionMessage={'Alert [{{ctx.metadata.name}}] has exceeded the threshold'} + actionTypes={[ + { id: actionType.id, name: 'Test', enabled: true }, + { id: '.index', name: 'Index', enabled: true }, + ]} + toastNotifications={deps!.toastNotifications} + /> + ); + + // Wait for active space to resolve before requesting the component to update + await act(async () => { + await nextTick(); + wrapper.update(); + }); + } + + it('renders available action cards', async () => { + await setup(); + const actionOption = wrapper.find( + `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` + ); + expect(actionOption.exists()).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx new file mode 100644 index 0000000000000..a43aa22026710 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -0,0 +1,512 @@ +/* + * 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, { Fragment, useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiTitle, + EuiSpacer, + EuiFormRow, + EuiComboBox, + EuiKeyPadMenuItem, + EuiAccordion, + EuiButtonIcon, + EuiEmptyPrompt, + EuiButtonEmpty, +} from '@elastic/eui'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api'; +import { + IErrorObject, + ActionTypeModel, + AlertAction, + ActionTypeIndex, + ActionConnector, + ActionType, +} from '../../../types'; +import { SectionLoading } from '../../components/section_loading'; +import { ConnectorAddModal } from './connector_add_modal'; +import { TypeRegistry } from '../../type_registry'; + +interface ActionAccordionFormProps { + actions: AlertAction[]; + defaultActionGroupId: string; + setActionIdByIndex: (id: string, index: number) => void; + setAlertProperty: (actions: AlertAction[]) => void; + setActionParamsProperty: (key: string, value: any, index: number) => void; + http: HttpSetup; + actionTypeRegistry: TypeRegistry; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + actionTypes?: ActionType[]; + messageVariables?: string[]; + defaultActionMessage?: string; +} + +interface ActiveActionConnectorState { + actionTypeId: string; + index: number; +} + +export const ActionForm = ({ + actions, + defaultActionGroupId, + setActionIdByIndex, + setAlertProperty, + setActionParamsProperty, + http, + actionTypeRegistry, + actionTypes, + messageVariables, + defaultActionMessage, + toastNotifications, +}: ActionAccordionFormProps) => { + const [addModalVisible, setAddModalVisibility] = useState(false); + const [activeActionItem, setActiveActionItem] = useState( + undefined + ); + const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState(true); + const [connectors, setConnectors] = useState([]); + const [isLoadingActionTypes, setIsLoadingActionTypes] = useState(false); + const [actionTypesIndex, setActionTypesIndex] = useState(undefined); + + // load action types + useEffect(() => { + (async () => { + try { + setIsLoadingActionTypes(true); + const registeredActionTypes = actionTypes ?? (await loadActionTypes({ http })); + const index: ActionTypeIndex = {}; + for (const actionTypeItem of registeredActionTypes) { + index[actionTypeItem.id] = actionTypeItem; + } + setActionTypesIndex(index); + } catch (e) { + if (toastNotifications) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionTypesMessage', + { defaultMessage: 'Unable to load action types' } + ), + }); + } + } finally { + setIsLoadingActionTypes(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + loadConnectors(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function loadConnectors() { + try { + const actionsResponse = await loadAllActions({ http }); + setConnectors(actionsResponse.data); + } catch (e) { + if (toastNotifications) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage', + { + defaultMessage: 'Unable to load connectors', + } + ), + }); + } + } + } + + const actionsErrors = actions.reduce( + (acc: Record, alertAction: AlertAction) => { + const actionType = actionTypeRegistry.get(alertAction.actionTypeId); + if (!actionType) { + return { ...acc }; + } + const actionValidationErrors = actionType.validateParams(alertAction.params); + return { ...acc, [alertAction.id]: actionValidationErrors }; + }, + {} + ) as Record; + + const getSelectedOptions = (actionItemId: string) => { + const val = connectors.find(connector => connector.id === actionItemId); + if (!val) { + return []; + } + return [ + { + label: val.name, + value: val.name, + id: actionItemId, + }, + ]; + }; + + const getActionTypeForm = ( + actionItem: AlertAction, + actionConnector: ActionConnector, + index: number + ) => { + const optionsList = connectors + .filter( + connectorItem => + connectorItem.actionTypeId === actionItem.actionTypeId && + (connectorItem.id === actionItem.id || + !actions.find( + (existingAction: AlertAction) => + existingAction.id === connectorItem.id && existingAction.group === actionItem.group + )) + ) + .map(({ name, id }) => ({ + label: name, + key: id, + id, + })); + const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId); + if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; + const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields; + const actionParamsErrors: { errors: IErrorObject } = + Object.keys(actionsErrors).length > 0 ? actionsErrors[actionItem.id] : { errors: {} }; + + return ( + + + + + + +
+ +
+
+
+ + } + extraAction={ + { + const updatedActions = actions.filter( + (item: AlertAction) => item.id !== actionItem.id + ); + setAlertProperty(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0 + ); + setActiveActionItem(undefined); + }} + /> + } + paddingSize="l" + > + + + + } + labelAppend={ + { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} + > + + + } + > + { + setActionIdByIndex(selectedOptions[0].id ?? '', index); + }} + isClearable={false} + /> + + + + + {ParamsFieldsComponent ? ( + + ) : null} +
+ ); + }; + + const getAddConnectorsForm = (actionItem: AlertAction, index: number) => { + const actionTypeName = actionTypesIndex + ? actionTypesIndex[actionItem.actionTypeId].name + : actionItem.actionTypeId; + const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId); + if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; + return ( + + + + + + +
+ +
+
+
+ + } + extraAction={ + { + const updatedActions = actions.filter( + (item: AlertAction) => item.id !== actionItem.id + ); + setAlertProperty(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0 + ); + setActiveActionItem(undefined); + }} + /> + } + paddingSize="l" + > + + } + actions={[ + { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} + > + + , + ]} + /> +
+ ); + }; + + function addActionType(actionTypeModel: ActionTypeModel) { + if (!defaultActionGroupId) { + toastNotifications!.addDanger({ + title: i18n.translate('xpack.triggersActionsUI.sections.alertForm.unableToAddAction', { + defaultMessage: 'Unable to add action, because default action group is not defined', + }), + }); + return; + } + setIsAddActionPanelOpen(false); + const actionTypeConnectors = connectors.filter( + field => field.actionTypeId === actionTypeModel.id + ); + let freeConnectors; + if (actionTypeConnectors.length > 0) { + // Should we allow adding multiple actions to the same connector under the alert? + freeConnectors = actionTypeConnectors.filter( + (actionConnector: ActionConnector) => + !actions.find((actionItem: AlertAction) => actionItem.id === actionConnector.id) + ); + if (freeConnectors.length > 0) { + actions.push({ + id: '', + actionTypeId: actionTypeModel.id, + group: defaultActionGroupId, + params: {}, + }); + setActionIdByIndex(freeConnectors[0].id, actions.length - 1); + } + } + if (actionTypeConnectors.length === 0 || !freeConnectors || freeConnectors.length === 0) { + // if no connectors exists or all connectors is already assigned an action under current alert + // set actionType as id to be able to create new connector within the alert form + actions.push({ + id: '', + actionTypeId: actionTypeModel.id, + group: defaultActionGroupId, + params: {}, + }); + setActionIdByIndex(actions.length.toString(), actions.length - 1); + } + } + + const actionTypeNodes = actionTypesIndex + ? actionTypeRegistry.list().map(function(item, index) { + return actionTypesIndex[item.id] ? ( + addActionType(item)} + > + + + ) : null; + }) + : null; + + return ( + + {actions.map((actionItem: AlertAction, index: number) => { + const actionConnector = connectors.find(field => field.id === actionItem.id); + // connectors doesn't exists + if (!actionConnector) { + return getAddConnectorsForm(actionItem, index); + } + return getActionTypeForm(actionItem, actionConnector, index); + })} + + {isAddActionPanelOpen === false ? ( + setIsAddActionPanelOpen(true)} + > + + + ) : null} + {isAddActionPanelOpen ? ( + + +
+ +
+
+ + + {isLoadingActionTypes ? ( + + + + ) : ( + actionTypeNodes + )} + +
+ ) : null} + {actionTypesIndex && activeActionItem ? ( + { + connectors.push(savedAction); + setActionIdByIndex(savedAction.id, activeActionItem.index); + }} + actionTypeRegistry={actionTypeRegistry} + http={http} + toastNotifications={toastNotifications} + /> + ) : null} +
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index 94c2b823e8bcf..31d801bb340f3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -68,7 +68,6 @@ describe('connector_add_modal', () => { actionType={actionType} http={deps.http} actionTypeRegistry={deps.actionTypeRegistry} - alertTypeRegistry={{} as any} toastNotifications={deps.toastNotifications} /> ) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 6486292725660..1cc26f39990ff 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -19,13 +19,7 @@ import { EuiOverlayMask } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { HttpSetup, ToastsApi } from 'kibana/public'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; -import { - ActionType, - ActionConnector, - IErrorObject, - AlertTypeModel, - ActionTypeModel, -} from '../../../types'; +import { ActionType, ActionConnector, IErrorObject, ActionTypeModel } from '../../../types'; import { connectorReducer } from './connector_reducer'; import { createActionConnector } from '../../lib/action_connector_api'; import { TypeRegistry } from '../../type_registry'; @@ -36,7 +30,6 @@ interface ConnectorAddModalProps { setAddModalVisibility: React.Dispatch>; postSaveEventHandler?: (savedAction: ActionConnector) => void; http: HttpSetup; - alertTypeRegistry: TypeRegistry; actionTypeRegistry: TypeRegistry; toastNotifications?: Pick< ToastsApi, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/index.ts index aac7a514948d1..52ee1efbdaf9f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/index.ts @@ -6,3 +6,4 @@ export { ConnectorAddFlyout } from './connector_add_flyout'; export { ConnectorEditFlyout } from './connector_edit_flyout'; +export { ActionForm } from './action_form'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx index bd18c99dca8fb..6119b407a6590 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx @@ -197,13 +197,5 @@ describe('alert_form', () => { const alertTypeSelectOptions = wrapper.find('[data-test-subj="selectedAlertTypeTitle"]'); expect(alertTypeSelectOptions.exists()).toBeTruthy(); }); - - it('renders registered action types', async () => { - await setup(); - const actionTypeSelectOptions = wrapper.find( - '[data-test-subj="my-action-type-ActionTypeSelectOption"]' - ); - expect(actionTypeSelectOptions.exists()).toBeTruthy(); - }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index b875fae75c7df..190f14f0428d8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -7,7 +7,6 @@ import React, { Fragment, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiButton, EuiFlexGroup, EuiFlexItem, EuiIcon, @@ -22,29 +21,15 @@ import { EuiFieldNumber, EuiSelect, EuiIconTip, - EuiAccordion, EuiButtonIcon, - EuiEmptyPrompt, - EuiButtonEmpty, EuiHorizontalRule, } from '@elastic/eui'; import { loadAlertTypes } from '../../lib/alert_api'; -import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api'; import { AlertReducerAction } from './alert_reducer'; -import { - AlertTypeModel, - Alert, - IErrorObject, - ActionTypeModel, - AlertAction, - ActionTypeIndex, - ActionConnector, - AlertTypeIndex, -} from '../../../types'; -import { SectionLoading } from '../../components/section_loading'; -import { ConnectorAddModal } from '../action_connector_form/connector_add_modal'; +import { AlertTypeModel, Alert, IErrorObject, AlertAction, AlertTypeIndex } from '../../../types'; import { getTimeOptions } from '../../../common/lib/get_time_options'; import { useAlertsContext } from '../../context/alerts_context'; +import { ActionForm } from '../action_connector_form/action_form'; export function validateBaseProperties(alertObject: Alert) { const validationResult = { errors: {} }; @@ -89,11 +74,6 @@ interface AlertFormProps { canChangeTrigger?: boolean; // to hide Change trigger button } -interface ActiveActionConnectorState { - actionTypeId: string; - index: number; -} - export const AlertForm = ({ alert, canChangeTrigger = true, @@ -108,9 +88,6 @@ export const AlertForm = ({ alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null ); - const [addModalVisible, setAddModalVisibility] = useState(false); - const [isLoadingActionTypes, setIsLoadingActionTypes] = useState(false); - const [actionTypesIndex, setActionTypesIndex] = useState(undefined); const [alertTypesIndex, setAlertTypesIndex] = useState(undefined); const [alertInterval, setAlertInterval] = useState( alert.schedule.interval ? parseInt(alert.schedule.interval.replace(/^[A-Za-z]+$/, ''), 0) : 1 @@ -124,39 +101,7 @@ export const AlertForm = ({ const [alertThrottleUnit, setAlertThrottleUnit] = useState( alert.throttle ? alert.throttle.replace((alertThrottle ?? '').toString(), '') : 'm' ); - const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState(true); - const [connectors, setConnectors] = useState([]); const [defaultActionGroupId, setDefaultActionGroupId] = useState(undefined); - const [activeActionItem, setActiveActionItem] = useState( - undefined - ); - - // load action types - useEffect(() => { - (async () => { - try { - setIsLoadingActionTypes(true); - const actionTypes = await loadActionTypes({ http }); - const index: ActionTypeIndex = {}; - for (const actionTypeItem of actionTypes) { - index[actionTypeItem.id] = actionTypeItem; - } - setActionTypesIndex(index); - } catch (e) { - if (toastNotifications) { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionTypesMessage', - { defaultMessage: 'Unable to load action types' } - ), - }); - } - } finally { - setIsLoadingActionTypes(false); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // load alert types useEffect(() => { @@ -172,24 +117,17 @@ export const AlertForm = ({ } setAlertTypesIndex(index); } catch (e) { - if (toastNotifications) { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.unableToLoadAlertTypesMessage', - { defaultMessage: 'Unable to load alert types' } - ), - }); - } + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.unableToLoadAlertTypesMessage', + { defaultMessage: 'Unable to load alert types' } + ), + }); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - loadConnectors(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const setAlertProperty = (key: string, value: any) => { dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); }; @@ -202,93 +140,20 @@ export const AlertForm = ({ dispatch({ command: { type: 'setScheduleProperty' }, payload: { key, value } }); }; - const setActionParamsProperty = (key: string, value: any, index: number) => { - dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); - }; - const setActionProperty = (key: string, value: any, index: number) => { dispatch({ command: { type: 'setAlertActionProperty' }, payload: { key, value, index } }); }; - const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; - - async function loadConnectors() { - try { - const actionsResponse = await loadAllActions({ http }); - setConnectors(actionsResponse.data); - } catch (e) { - if (toastNotifications) { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage', - { - defaultMessage: 'Unable to load connectors', - } - ), - }); - } - } - } + const setActionParamsProperty = (key: string, value: any, index: number) => { + dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); + }; - const actionsErrors = alert.actions.reduce( - (acc: Record, alertAction: AlertAction) => { - const actionType = actionTypeRegistry.get(alertAction.actionTypeId); - if (!actionType) { - return { ...acc }; - } - const actionValidationErrors = actionType.validateParams(alertAction.params); - return { ...acc, [alertAction.id]: actionValidationErrors }; - }, - {} - ); + const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; const AlertParamsExpressionComponent = alertTypeModel ? alertTypeModel.alertParamsExpression : null; - function addActionType(actionTypeModel: ActionTypeModel) { - if (!defaultActionGroupId) { - toastNotifications!.addDanger({ - title: i18n.translate('xpack.triggersActionsUI.sections.alertForm.unableToAddAction', { - defaultMessage: 'Unable to add action, because default action group is not defined', - }), - }); - return; - } - setIsAddActionPanelOpen(false); - const actionTypeConnectors = connectors.filter( - field => field.actionTypeId === actionTypeModel.id - ); - let freeConnectors; - if (actionTypeConnectors.length > 0) { - // Should we allow adding multiple actions to the same connector under the alert? - freeConnectors = actionTypeConnectors.filter( - (actionConnector: ActionConnector) => - !alert.actions.find((actionItem: AlertAction) => actionItem.id === actionConnector.id) - ); - if (freeConnectors.length > 0) { - alert.actions.push({ - id: '', - actionTypeId: actionTypeModel.id, - group: defaultActionGroupId, - params: {}, - }); - setActionProperty('id', freeConnectors[0].id, alert.actions.length - 1); - } - } - if (actionTypeConnectors.length === 0 || !freeConnectors || freeConnectors.length === 0) { - // if no connectors exists or all connectors is already assigned an action under current alert - // set actionType as id to be able to create new connector within the alert form - alert.actions.push({ - id: '', - actionTypeId: actionTypeModel.id, - group: defaultActionGroupId, - params: {}, - }); - setActionProperty('id', alert.actions.length, alert.actions.length - 1); - } - } - const alertTypeNodes = alertTypeRegistry.list().map(function(item, index) { return ( addActionType(item)} - > - - - ); - }); - - const getSelectedOptions = (actionItemId: string) => { - const val = connectors.find(connector => connector.id === actionItemId); - if (!val) { - return []; - } - return [ - { - label: val.name, - value: val.name, - id: actionItemId, - }, - ]; - }; - - const getActionTypeForm = ( - actionItem: AlertAction, - actionConnector: ActionConnector, - index: number - ) => { - const optionsList = connectors - .filter( - connectorItem => - connectorItem.actionTypeId === actionItem.actionTypeId && - (connectorItem.id === actionItem.id || - !alert.actions.find( - (existingAction: AlertAction) => - existingAction.id === connectorItem.id && existingAction.group === actionItem.group - )) - ) - .map(({ name, id }) => ({ - label: name, - key: id, - id, - })); - const actionTypeRegisterd = actionTypeRegistry.get(actionConnector.actionTypeId); - if (!actionTypeRegisterd || actionItem.group !== defaultActionGroupId) return null; - const ParamsFieldsComponent = actionTypeRegisterd.actionParamsFields; - const actionParamsErrors: { errors: IErrorObject } = - Object.keys(actionsErrors).length > 0 ? actionsErrors[actionItem.id] : { errors: {} }; - - return ( - - - - - - -
- -
-
-
- - } - extraAction={ - { - const updatedActions = alert.actions.filter( - (item: AlertAction) => item.id !== actionItem.id - ); - setAlertProperty('actions', updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0 - ); - setActiveActionItem(undefined); - }} - /> - } - paddingSize="l" - > - - - - } - labelAppend={ - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - - - } - > - { - setActionProperty('id', selectedOptions[0].id, index); - }} - isClearable={false} - /> - - - - - {ParamsFieldsComponent ? ( - - ) : null} -
- ); - }; - - const getAddConnectorsForm = (actionItem: AlertAction, index: number) => { - const actionTypeName = actionTypesIndex - ? actionTypesIndex[actionItem.actionTypeId].name - : actionItem.actionTypeId; - const actionTypeRegisterd = actionTypeRegistry.get(actionItem.actionTypeId); - if (!actionTypeRegisterd || actionItem.group !== defaultActionGroupId) return null; - return ( - - - - - - -
- -
-
-
- - } - extraAction={ - { - const updatedActions = alert.actions.filter( - (item: AlertAction) => item.id !== actionItem.id - ); - setAlertProperty('actions', updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0 - ); - setActiveActionItem(undefined); - }} - /> - } - paddingSize="l" - > - - } - actions={[ - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - - , - ]} - /> -
- ); - }; - - const selectedGroupActions = ( - - {alert.actions.map((actionItem: AlertAction, index: number) => { - const actionConnector = connectors.find(field => field.id === actionItem.id); - // connectors doesn't exists - if (!actionConnector) { - return getAddConnectorsForm(actionItem, index); - } - return getActionTypeForm(actionItem, actionConnector, index); - })} - - {isAddActionPanelOpen === false ? ( - setIsAddActionPanelOpen(true)} - > - - - ) : null} - - ); - const alertTypeDetails = ( @@ -639,31 +217,27 @@ export const AlertForm = ({ /> ) : null} - {selectedGroupActions} - {isAddActionPanelOpen ? ( - - -
- -
-
- - - {isLoadingActionTypes ? ( - - - - ) : ( - actionTypeNodes - )} - -
+ {defaultActionGroupId ? ( + setActionProperty('id', id, index)} + setAlertProperty={(updatedActions: AlertAction[]) => + setAlertProperty('actions', updatedActions) + } + setActionParamsProperty={(key: string, value: any, index: number) => + setActionParamsProperty(key, value, index) + } + http={http} + actionTypeRegistry={actionTypeRegistry} + defaultActionMessage={alertTypeModel?.defaultActionMessage} + toastNotifications={toastNotifications} + /> ) : null}
); @@ -862,22 +436,6 @@ export const AlertForm = ({
)} - {actionTypesIndex && activeActionItem ? ( - { - connectors.push(savedAction); - setActionProperty('id', savedAction.id, activeActionItem.index); - }} - actionTypeRegistry={actionTypeRegistry} - alertTypeRegistry={alertTypeRegistry} - http={http} - toastNotifications={toastNotifications} - /> - ) : null} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 74af4a77d0ef0..fbffd5c2f999d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -10,6 +10,8 @@ import { Plugin } from './plugin'; export { AlertsContextProvider } from './application/context/alerts_context'; export { ActionsConnectorsContextProvider } from './application/context/actions_connectors_context'; export { AlertAdd } from './application/sections/alert_form'; +export { ActionForm } from './application/sections/action_connector_form'; +export { AlertAction, Alert } from './types'; export { ConnectorAddFlyout, ConnectorEditFlyout, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts index c0f56c55ba850..50cc80011777e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/index.ts @@ -18,7 +18,7 @@ export default function alertingApiIntegrationTests({ const esArchiver = getService('esArchiver'); describe('alerting api integration security and spaces enabled', function() { - this.tags('ciGroup3'); + this.tags('ciGroup5'); before(async () => { for (const space of Spaces) { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts index b118a48fd642c..10397a571b0ef 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts @@ -16,7 +16,7 @@ export default function alertingApiIntegrationTests({ const esArchiver = getService('esArchiver'); describe('alerting api integration spaces only', function() { - this.tags('ciGroup3'); + this.tags('ciGroup9'); before(async () => { for (const space of Object.values(Spaces)) {