From b458b3b35239a3c1366045982d34cd3b44fca81e Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Tue, 4 Feb 2020 11:05:30 -0800 Subject: [PATCH] Implement UI for Create Alert form (#55232) * Updated Alert ui model, fixed form validation and small issues * Fixed error messages and validation for action params forms * Fixed typecheck error * Moved alert add/edit common fields to alert form * Fixed type checks * Refactored alert add flyout by splitting it to the form and flyout components, added some unit tests * Refactored connector add/edit flyouts and created add connector modal * Refactored add/edit flyout tests * Fixed test * Removed orig files * Removed orig file * Added unit tests for add connector modal dialog * Action Groups idea of implementation * Removed action group tabs and set only first action group as default (temporary till design will be ready for support multiple groups) * Added missing unit tests * Changed design of the email params form * Fixed actions params forms according to latest mockups * Fixed options list for available actions connectors * Fixed modal dialog update on action delete * fixed build fail * Added functionality for action types with Message field to Add variables * Added alertReducer unit tests * Added create alert functional test * Added types for Params * Some design fixes * alerts empty prompt * Fixed failing app on save alert and added possibility to hide Change trigger button * Fixed type check issues * Added connector config types * fixed type check * Fixed merge issues * Fixed type checks * Fixed functional tests and error expression message * Fixed jest tests * Review changes Co-authored-by: dave.snider@gmail.com --- .../np_ready/public/application/app.tsx | 2 +- .../builtin_action_types/email.test.tsx | 58 +- .../components/builtin_action_types/email.tsx | 307 ++++--- .../builtin_action_types/es_index.test.tsx | 19 +- .../builtin_action_types/es_index.tsx | 35 +- .../builtin_action_types/pagerduty.test.tsx | 36 +- .../builtin_action_types/pagerduty.tsx | 82 +- .../builtin_action_types/server_log.test.tsx | 26 +- .../builtin_action_types/server_log.tsx | 90 +- .../builtin_action_types/slack.test.tsx | 22 +- .../components/builtin_action_types/slack.tsx | 105 ++- .../components/builtin_action_types/types.ts | 130 +++ .../builtin_action_types/webhook.test.tsx | 22 +- .../builtin_action_types/webhook.tsx | 28 +- .../components/builtin_alert_types/index.ts | 2 +- .../threshold/expression.tsx | 153 +--- .../builtin_alert_types/threshold/index.ts | 103 +++ .../threshold/visualization.tsx | 5 +- .../application/constants/action_groups.ts | 11 - .../application/context/alerts_context.tsx | 5 +- .../public/application/lib/alert_api.test.ts | 10 +- .../action_connector_form/_index.scss | 3 + .../action_connector_form.test.tsx | 63 +- .../action_connector_form.tsx | 327 +++---- .../action_type_menu.tsx | 40 +- .../connector_add_flyout.test.tsx | 58 +- .../connector_add_flyout.tsx | 131 ++- .../connector_add_modal.test.tsx | 102 +++ .../connector_add_modal.tsx | 168 ++++ .../connector_edit_flyout.test.tsx | 2 +- .../connector_edit_flyout.tsx | 120 ++- .../connector_reducer.ts | 13 +- .../components/actions_connectors_list.tsx | 7 +- .../sections/alert_add/alert_add.test.tsx | 117 +++ .../sections/alert_add/alert_add.tsx | 759 ++-------------- .../sections/alert_add/alert_form.test.tsx | 200 ++++ .../sections/alert_add/alert_form.tsx | 853 ++++++++++++++++++ .../sections/alert_add/alert_reducer.test.ts | 163 ++++ .../sections/alert_add/alert_reducer.ts | 18 + .../components/alert_details.test.tsx | 32 + .../components/alerts_list.test.tsx | 12 +- .../alerts_list/components/alerts_list.tsx | 230 +++-- .../np_ready/public/types.ts | 25 +- .../triggers_actions_ui/public/index.scss | 3 + .../apps/triggers_actions_ui/alerts.ts | 90 +- .../apps/triggers_actions_ui/connectors.ts | 10 +- .../page_objects/triggers_actions_ui_page.ts | 24 +- 47 files changed, 3160 insertions(+), 1661 deletions(-) create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/types.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/index.ts delete mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/action_groups.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/_index.scss create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_modal.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_modal.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_form.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_form.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.test.ts diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx index 7d9a963c9c6b3..8bc292c58468e 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx @@ -48,7 +48,7 @@ export const App = (appDeps: AppDeps) => { ); }; -export const AppWithoutRouter = ({ sectionsRegex }: any) => { +export const AppWithoutRouter = ({ sectionsRegex }: { sectionsRegex: string }) => { const { capabilities } = useAppDependencies(); const canShowAlerts = hasShowAlertsCapability(capabilities); const DEFAULT_SECTION: Section = canShowAlerts ? 'alerts' : 'connectors'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.test.tsx index 5c924982c3536..49a611167cf16 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.test.tsx @@ -3,11 +3,12 @@ * 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 React, { FunctionComponent } from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { TypeRegistry } from '../../type_registry'; import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionConnector } from '../../../types'; +import { ActionTypeModel, ActionParamsProps } from '../../../types'; +import { EmailActionParams, EmailActionConnector } from './types'; const ACTION_TYPE_ID = '.email'; let actionTypeModel: ActionTypeModel; @@ -40,43 +41,15 @@ describe('connector validation', () => { name: 'email', config: { from: 'test@test.com', - port: '2323', + port: 2323, host: 'localhost', test: 'test', }, - } as ActionConnector; + } as EmailActionConnector; expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ errors: { from: [], - service: [], - port: [], - host: [], - user: [], - password: [], - }, - }); - - delete actionConnector.config.test; - actionConnector.config.host = 'elastic.co'; - actionConnector.config.port = 8080; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - from: [], - service: [], - port: [], - host: [], - user: [], - password: [], - }, - }); - delete actionConnector.config.host; - delete actionConnector.config.port; - actionConnector.config.service = 'testService'; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - from: [], - service: [], port: [], host: [], user: [], @@ -97,12 +70,11 @@ describe('connector validation', () => { config: { from: 'test@test.com', }, - } as ActionConnector; + } as EmailActionConnector; expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ errors: { from: [], - service: ['Service is required.'], port: ['Port is required.'], host: ['Host is required.'], user: [], @@ -168,14 +140,13 @@ describe('EmailActionConnectorFields renders', () => { config: { from: 'test@test.com', }, - } as ActionConnector; + } as EmailActionConnector; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - hasErrors={false} /> ); expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); @@ -198,19 +169,22 @@ describe('EmailParamsFields renders', () => { if (!actionTypeModel.actionParamsFields) { return; } - const ParamsFields = actionTypeModel.actionParamsFields; + const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< + ActionParamsProps + >; const actionParams = { + cc: [], + bcc: [], to: ['test@test.com'], subject: 'test', message: 'test message', }; const wrapper = mountWithIntl( {}} index={0} - hasErrors={false} /> ); expect(wrapper.find('[data-test-subj="toEmailAddressInput"]').length > 0).toBeTruthy(); @@ -220,8 +194,6 @@ describe('EmailParamsFields renders', () => { .first() .prop('selectedOptions') ).toStrictEqual([{ label: 'test@test.com' }]); - expect(wrapper.find('[data-test-subj="ccEmailAddressInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="bccEmailAddressInput"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="emailSubjectInput"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="emailMessageInput"]').length > 0).toBeTruthy(); }); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.tsx index a6750ccf96deb..f82b2c8c88ada 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.tsx @@ -3,7 +3,8 @@ * 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 React, { Fragment, useState, useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFieldText, EuiFlexItem, @@ -12,17 +13,22 @@ import { EuiFieldPassword, EuiComboBox, EuiTextArea, + EuiButtonEmpty, EuiSwitch, EuiFormRow, + EuiContextMenuItem, + EuiButtonIcon, + EuiContextMenuPanel, + EuiPopover, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ActionTypeModel, ActionConnectorFieldsProps, - ActionConnector, ValidationResult, ActionParamsProps, } from '../../../types'; +import { EmailActionParams, EmailActionConnector } from './types'; export function getActionType(): ActionTypeModel { const mailformat = /^[^@\s]+@[^@\s]+$/; @@ -35,11 +41,16 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send email from your server.', } ), - validateConnector: (action: ActionConnector): ValidationResult => { + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle', + { + defaultMessage: 'Send to email', + } + ), + validateConnector: (action: EmailActionConnector): ValidationResult => { const validationResult = { errors: {} }; const errors = { from: new Array(), - service: new Array(), port: new Array(), host: new Array(), user: new Array(), @@ -66,7 +77,7 @@ export function getActionType(): ActionTypeModel { ) ); } - if (!action.config.port && !action.config.service) { + if (!action.config.port) { errors.port.push( i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText', @@ -76,17 +87,7 @@ export function getActionType(): ActionTypeModel { ) ); } - if (!action.config.service && (!action.config.port || !action.config.host)) { - errors.service.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServiceText', - { - defaultMessage: 'Service is required.', - } - ) - ); - } - if (!action.config.host && !action.config.service) { + if (!action.config.host) { errors.host.push( i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText', @@ -118,7 +119,7 @@ export function getActionType(): ActionTypeModel { } return validationResult; }, - validateParams: (actionParams: any): ValidationResult => { + validateParams: (actionParams: EmailActionParams): ValidationResult => { const validationResult = { errors: {} }; const errors = { to: new Array(), @@ -143,7 +144,7 @@ export function getActionType(): ActionTypeModel { errors.cc.push(errorText); errors.bcc.push(errorText); } - if (!actionParams.message) { + if (!actionParams.message?.length) { errors.message.push( i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText', @@ -153,7 +154,7 @@ export function getActionType(): ActionTypeModel { ) ); } - if (!actionParams.subject) { + if (!actionParams.subject?.length) { errors.subject.push( i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText', @@ -170,12 +171,9 @@ export function getActionType(): ActionTypeModel { }; } -const EmailActionConnectorFields: React.FunctionComponent = ({ - action, - editActionConfig, - editActionSecrets, - errors, -}) => { +const EmailActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, editActionSecrets, errors }) => { const { from, host, port, secure } = action.config; const { user, password } = action.secrets; @@ -265,7 +263,7 @@ 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)); @@ -365,34 +363,79 @@ const EmailActionConnectorFields: React.FunctionComponent = ({ - action, +const EmailParamsFields: React.FunctionComponent> = ({ + actionParams, editAction, index, errors, - hasErrors, + messageVariables, + defaultMessage, }) => { - const { to, cc, bcc, subject, message } = action; + const { to, cc, bcc, subject, message } = actionParams; const toOptions = to ? to.map((label: string) => ({ label })) : []; const ccOptions = cc ? cc.map((label: string) => ({ label })) : []; const bccOptions = bcc ? bcc.map((label: string) => ({ label })) : []; + const [addCC, setAddCC] = useState(false); + const [addBCC, setAddBCC] = useState(false); + const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState(false); + useEffect(() => { + if (defaultMessage && defaultMessage.length > 0) { + editAction('message', defaultMessage, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const messageVariablesItems = messageVariables?.map((variable: string) => ( + { + editAction('message', (message ?? '').concat(` {{${variable}}}`), index); + setIsVariablesPopoverOpen(false); + }} + > + {`{{${variable}}}`} + + )); return ( 0 && to !== undefined} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientTextFieldLabel', { - defaultMessage: 'To', + defaultMessage: 'To:', } )} + labelAppend={ + + + {!addCC ? ( + setAddCC(true)}> + + + ) : null} + {!addBCC ? ( + setAddBCC(true)}> + + + ) : null} + + + } > 0 && to !== undefined} fullWidth data-test-subj="toEmailAddressInput" selectedOptions={toOptions} @@ -418,126 +461,164 @@ const EmailParamsFields: React.FunctionComponent = ({ }} /> - - { - const newOptions = [...ccOptions, { label: searchValue }]; - editAction( - 'cc', - newOptions.map(newOption => newOption.label), - index - ); - }} - onChange={(selectedOptions: Array<{ label: string }>) => { - editAction( - 'cc', - selectedOptions.map(selectedOption => selectedOption.label), - index - ); - }} - onBlur={() => { - if (!cc) { - editAction('cc', [], index); + error={errors.cc} + isInvalid={errors.cc.length > 0 && cc !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientCopyTextFieldLabel', + { + defaultMessage: 'Cc:', } - }} - /> - - - + 0 && cc !== undefined} + fullWidth + data-test-subj="ccEmailAddressInput" + selectedOptions={ccOptions} + onCreateOption={(searchValue: string) => { + const newOptions = [...ccOptions, { label: searchValue }]; + editAction( + 'cc', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'cc', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!cc) { + editAction('cc', [], index); + } + }} + /> + + ) : null} + {addBCC ? ( + { - const newOptions = [...bccOptions, { label: searchValue }]; - editAction( - 'bcc', - newOptions.map(newOption => newOption.label), - index - ); - }} - onChange={(selectedOptions: Array<{ label: string }>) => { - editAction( - 'bcc', - selectedOptions.map(selectedOption => selectedOption.label), - index - ); - }} - onBlur={() => { - if (!bcc) { - editAction('bcc', [], index); + error={errors.bcc} + isInvalid={errors.bcc.length > 0 && bcc !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientBccTextFieldLabel', + { + defaultMessage: 'Bcc:', } - }} - /> - + )} + > + 0 && bcc !== undefined} + fullWidth + data-test-subj="bccEmailAddressInput" + selectedOptions={bccOptions} + onCreateOption={(searchValue: string) => { + const newOptions = [...bccOptions, { label: searchValue }]; + editAction( + 'bcc', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'bcc', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!bcc) { + editAction('bcc', [], index); + } + }} + /> + + ) : null} 0 && subject !== undefined} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.subjectTextFieldLabel', { - defaultMessage: 'Subject', + defaultMessage: 'Subject:', } )} > 0 && subject !== undefined} name="subject" data-test-subj="emailSubjectInput" value={subject || ''} + placeholder="Text field (placeholder)" onChange={e => { editAction('subject', e.target.value, index); }} + onBlur={() => { + if (!subject) { + editAction('subject', '', index); + } + }} /> 0 && message !== undefined} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.messageTextAreaFieldLabel', { - defaultMessage: 'Message', + defaultMessage: 'Message:', } )} + labelAppend={ + setIsVariablesPopoverOpen(true)} + iconType="indexOpen" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.addVariablePopoverButton', + { + defaultMessage: 'Add variable', + } + )} + /> + } + isOpen={isVariablesPopoverOpen} + closePopover={() => setIsVariablesPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + } > 0 && message !== undefined} value={message || ''} name="message" data-test-subj="emailMessageInput" onChange={e => { editAction('message', e.target.value, index); }} + onBlur={() => { + if (!message) { + editAction('message', '', index); + } + }} /> diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.test.tsx index b6a7c4d82aca4..d44787f0c4ed6 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.test.tsx @@ -3,11 +3,12 @@ * 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 React, { FunctionComponent } from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { TypeRegistry } from '../../type_registry'; import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionConnector } from '../../../types'; +import { ActionTypeModel, ActionParamsProps } from '../../../types'; +import { IndexActionParams, EsIndexActionConnector } from './types'; const ACTION_TYPE_ID = '.index'; let actionTypeModel: ActionTypeModel; @@ -38,7 +39,7 @@ describe('index connector validation', () => { config: { index: 'test_es_index', }, - } as ActionConnector; + } as EsIndexActionConnector; expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ errors: {}, @@ -87,14 +88,13 @@ describe('IndexActionConnectorFields renders', () => { config: { index: 'test', }, - } as ActionConnector; + } as EsIndexActionConnector; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - hasErrors={false} /> ); expect(wrapper.find('[data-test-subj="indexInput"]').length > 0).toBeTruthy(); @@ -113,7 +113,9 @@ describe('IndexParamsFields renders', () => { if (!actionTypeModel.actionParamsFields) { return; } - const ParamsFields = actionTypeModel.actionParamsFields; + const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< + ActionParamsProps + >; const actionParams = { index: 'test_index', refresh: false, @@ -121,11 +123,10 @@ describe('IndexParamsFields renders', () => { }; const wrapper = mountWithIntl( {}} index={0} - hasErrors={false} /> ); expect(wrapper.find('[data-test-subj="indexInput"]').length > 0).toBeTruthy(); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.tsx index aa15195cdc286..6af54d2bf15b4 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { Fragment } from 'react'; -import { EuiFieldText, EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { EuiFieldText, EuiFormRow, EuiSwitch, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { @@ -13,6 +13,7 @@ import { ValidationResult, ActionParamsProps, } from '../../../types'; +import { IndexActionParams, EsIndexActionConnector } from './types'; export function getActionType(): ActionTypeModel { return { @@ -29,17 +30,15 @@ export function getActionType(): ActionTypeModel { }, actionConnectorFields: IndexActionConnectorFields, actionParamsFields: IndexParamsFields, - validateParams: (actionParams: any): ValidationResult => { - const validationResult = { errors: {} }; - return validationResult; + validateParams: (): ValidationResult => { + return { errors: {} }; }, }; } -const IndexActionConnectorFields: React.FunctionComponent = ({ - action, - editActionConfig, -}) => { +const IndexActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig }) => { const { index } = action.config; return ( = ({ - action, +const IndexParamsFields: React.FunctionComponent> = ({ + actionParams, index, editAction, - errors, - hasErrors, }) => { - const { refresh } = action; + const { refresh } = actionParams; return ( = ({ > ) => { editAction('index', e.target.value, index); }} onBlur={() => { - if (!action.index) { + if (!actionParams.index) { editAction('index', '', index); } }} /> + { + checked={refresh || false} + onChange={e => { editAction('refresh', e.target.checked, index); }} label={ diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.test.tsx index 582315c95812a..31a69f9fd94ac 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.test.tsx @@ -3,11 +3,17 @@ * 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 React, { FunctionComponent } from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { TypeRegistry } from '../../type_registry'; import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionConnector } from '../../../types'; +import { ActionTypeModel, ActionParamsProps } from '../../../types'; +import { + PagerDutyActionParams, + EventActionOptions, + SeverityActionOptions, + PagerDutyActionConnector, +} from './types'; const ACTION_TYPE_ID = '.pagerduty'; let actionTypeModel: ActionTypeModel; @@ -40,7 +46,7 @@ describe('pagerduty connector validation', () => { config: { apiUrl: 'http:\\test', }, - } as ActionConnector; + } as PagerDutyActionConnector; expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ errors: { @@ -66,7 +72,7 @@ describe('pagerduty connector validation', () => { config: { apiUrl: 'http:\\test', }, - } as ActionConnector; + } as PagerDutyActionConnector; expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ errors: { @@ -91,7 +97,9 @@ describe('pagerduty action params validation', () => { }; expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: {}, + errors: { + summary: [], + }, }); }); }); @@ -113,14 +121,13 @@ describe('PagerDutyActionConnectorFields renders', () => { config: { apiUrl: 'http:\\test', }, - } as ActionConnector; + } as PagerDutyActionConnector; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - hasErrors={false} /> ); expect(wrapper.find('[data-test-subj="pagerdutyApiUrlInput"]').length > 0).toBeTruthy(); @@ -140,13 +147,15 @@ describe('PagerDutyParamsFields renders', () => { if (!actionTypeModel.actionParamsFields) { return; } - const ParamsFields = actionTypeModel.actionParamsFields; + const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< + ActionParamsProps + >; const actionParams = { - eventAction: 'trigger', + eventAction: EventActionOptions.TRIGGER, dedupKey: 'test', summary: '2323', source: 'source', - severity: 'critical', + severity: SeverityActionOptions.CRITICAL, timestamp: '234654564654', component: 'test', group: 'group', @@ -154,11 +163,10 @@ describe('PagerDutyParamsFields renders', () => { }; const wrapper = mountWithIntl( {}} index={0} - hasErrors={false} /> ); expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); @@ -174,6 +182,6 @@ describe('PagerDutyParamsFields renders', () => { expect(wrapper.find('[data-test-subj="componentInput"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="groupInput"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="sourceInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="pagerdutyDescriptionInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="pagerdutySummaryInput"]').length > 0).toBeTruthy(); }); }); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.tsx index 69c7ec166df60..3c1b1d258cfe2 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.tsx @@ -17,10 +17,10 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ActionTypeModel, ActionConnectorFieldsProps, - ActionConnector, ValidationResult, ActionParamsProps, } from '../../../types'; +import { PagerDutyActionParams, PagerDutyActionConnector } from './types'; export function getActionType(): ActionTypeModel { return { @@ -32,7 +32,13 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send an event in PagerDuty.', } ), - validateConnector: (action: ActionConnector): ValidationResult => { + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.actionTypeTitle', + { + defaultMessage: 'Send to PagerDuty', + } + ), + validateConnector: (action: PagerDutyActionConnector): ValidationResult => { const validationResult = { errors: {} }; const errors = { routingKey: new Array(), @@ -50,8 +56,22 @@ export function getActionType(): ActionTypeModel { } return validationResult; }, - validateParams: (actionParams: any): ValidationResult => { + validateParams: (actionParams: PagerDutyActionParams): ValidationResult => { const validationResult = { errors: {} }; + const errors = { + summary: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.summary?.length) { + errors.summary.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', + { + defaultMessage: 'Summary is required.', + } + ) + ); + } return validationResult; }, actionConnectorFields: PagerDutyActionConnectorFields, @@ -59,12 +79,9 @@ export function getActionType(): ActionTypeModel { }; } -const PagerDutyActionConnectorFields: React.FunctionComponent = ({ - errors, - action, - editActionConfig, - editActionSecrets, -}) => { +const PagerDutyActionConnectorFields: React.FunctionComponent> = ({ errors, action, editActionConfig, editActionSecrets }) => { const { apiUrl } = action.config; const { routingKey } = action.secrets; return ( @@ -137,14 +154,22 @@ const PagerDutyActionConnectorFields: React.FunctionComponent = ({ - action, +const PagerDutyParamsFields: React.FunctionComponent> = ({ + actionParams, editAction, index, errors, - hasErrors, }) => { - const { eventAction, dedupKey, summary, source, severity, timestamp, component, group } = action; + const { + eventAction, + dedupKey, + summary, + source, + severity, + timestamp, + component, + group, + } = actionParams; const severityOptions = [ { value: 'critical', text: 'Critical' }, { value: 'info', text: 'Info' }, @@ -332,7 +357,7 @@ const PagerDutyParamsFields: React.FunctionComponent = ({ id="pagerDutySummary" fullWidth error={errors.summary} - isInvalid={hasErrors === true && summary !== undefined} + isInvalid={errors.summary.length > 0 && summary !== undefined} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.summaryFieldLabel', { @@ -342,10 +367,10 @@ const PagerDutyParamsFields: React.FunctionComponent = ({ > 0 && summary !== undefined} name="summary" value={summary || ''} - data-test-subj="pagerdutyDescriptionInput" + data-test-subj="pagerdutySummaryInput" onChange={(e: React.ChangeEvent) => { editAction('summary', e.target.value, index); }} @@ -356,6 +381,31 @@ const PagerDutyParamsFields: React.FunctionComponent = ({ }} /> + + ) => { + editAction('class', e.target.value, index); + }} + onBlur={() => { + if (!actionParams.class) { + editAction('class', '', index); + } + }} + /> + ); }; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.test.tsx index b79be4eef523b..fce8f25e51131 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.test.tsx @@ -3,11 +3,12 @@ * 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 React, { FunctionComponent } from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { TypeRegistry } from '../../type_registry'; import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionConnector } from '../../../types'; +import { ActionTypeModel, ActionConnector, ActionParamsProps } from '../../../types'; +import { ServerLogActionParams, ServerLogLevelOptions } from './types'; const ACTION_TYPE_ID = '.server-log'; let actionTypeModel: ActionTypeModel; @@ -63,18 +64,20 @@ describe('ServerLogParamsFields renders', () => { if (!actionTypeModel.actionParamsFields) { return; } - const ParamsFields = actionTypeModel.actionParamsFields; + const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< + ActionParamsProps + >; const actionParams = { - message: 'test message', - level: 'trace', + level: ServerLogLevelOptions.TRACE, + message: 'test', }; const wrapper = mountWithIntl( {}} index={0} - hasErrors={false} + defaultMessage={'test default message'} /> ); expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); @@ -92,18 +95,19 @@ describe('ServerLogParamsFields renders', () => { if (!actionTypeModel.actionParamsFields) { return; } - const ParamsFields = actionTypeModel.actionParamsFields; + const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< + ActionParamsProps + >; const actionParams = { message: 'test message', - level: 'info', + level: ServerLogLevelOptions.INFO, }; const wrapper = mountWithIntl( {}} index={0} - hasErrors={false} /> ); expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.tsx index 885061aa81924..8d8045042cfc3 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.tsx @@ -3,10 +3,19 @@ * 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 React, { Fragment, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiSelect, EuiTextArea, EuiFormRow } from '@elastic/eui'; +import { + EuiSelect, + EuiTextArea, + EuiFormRow, + EuiContextMenuItem, + EuiPopover, + EuiButtonIcon, + EuiContextMenuPanel, +} from '@elastic/eui'; import { ActionTypeModel, ValidationResult, ActionParamsProps } from '../../../types'; +import { ServerLogActionParams } from './types'; export function getActionType(): ActionTypeModel { return { @@ -18,16 +27,22 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Add a message to a Kibana log.', } ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.actionTypeTitle', + { + defaultMessage: 'Send to Server log', + } + ), validateConnector: (): ValidationResult => { return { errors: {} }; }, - validateParams: (actionParams: any): ValidationResult => { + validateParams: (actionParams: ServerLogActionParams): ValidationResult => { const validationResult = { errors: {} }; const errors = { message: new Array(), }; validationResult.errors = errors; - if (!actionParams.message || actionParams.message.length === 0) { + if (!actionParams.message?.length) { errors.message.push( i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServerLogMessageText', @@ -44,14 +59,10 @@ export function getActionType(): ActionTypeModel { }; } -export const ServerLogParamsFields: React.FunctionComponent = ({ - action, - editAction, - index, - errors, - hasErrors, -}) => { - const { message, level } = action; +export const ServerLogParamsFields: React.FunctionComponent> = ({ actionParams, editAction, index, errors, messageVariables, defaultMessage }) => { + const { message, level } = actionParams; const levelOptions = [ { value: 'trace', text: 'Trace' }, { value: 'debug', text: 'Debug' }, @@ -60,10 +71,27 @@ export const ServerLogParamsFields: React.FunctionComponent = { value: 'error', text: 'Error' }, { value: 'fatal', text: 'Fatal' }, ]; + const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState(false); - // Set default value 'info' for level param - editAction('level', 'info', index); - + useEffect(() => { + editAction('level', 'info', index); + if (defaultMessage && defaultMessage.length > 0) { + editAction('message', defaultMessage, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const messageVariablesItems = messageVariables?.map((variable: string) => ( + { + editAction('message', (message ?? '').concat(` {{${variable}}}`), index); + setIsVariablesPopoverOpen(false); + }} + > + {`{{${variable}}}`} + + )); return ( = id="loggingMessage" fullWidth error={errors.message} - isInvalid={hasErrors && message !== undefined} + isInvalid={errors.message.length > 0 && message !== undefined} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.logMessageFieldLabel', { defaultMessage: 'Message', } )} + labelAppend={ + setIsVariablesPopoverOpen(true)} + iconType="indexOpen" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.addVariablePopoverButton', + { + defaultMessage: 'Add variable', + } + )} + /> + } + isOpen={isVariablesPopoverOpen} + closePopover={() => setIsVariablesPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + } > 0 && message !== undefined} value={message || ''} name="message" data-test-subj="loggingMessageInput" onChange={e => { editAction('message', e.target.value, index); }} + onBlur={() => { + if (!message) { + editAction('message', '', index); + } + }} /> diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.test.tsx index 36beea4d2f2be..e4f8599d2e46e 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.test.tsx @@ -3,11 +3,12 @@ * 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 React, { FunctionComponent } from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { TypeRegistry } from '../../type_registry'; import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionConnector } from '../../../types'; +import { ActionTypeModel, ActionParamsProps } from '../../../types'; +import { SlackActionParams, SlackActionConnector } from './types'; const ACTION_TYPE_ID = '.slack'; let actionTypeModel: ActionTypeModel; @@ -38,7 +39,7 @@ describe('slack connector validation', () => { actionTypeId: '.email', name: 'email', config: {}, - } as ActionConnector; + } as SlackActionConnector; expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ errors: { @@ -54,7 +55,7 @@ describe('slack connector validation', () => { actionTypeId: '.email', name: 'email', config: {}, - } as ActionConnector; + } as SlackActionConnector; expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ errors: { @@ -91,7 +92,7 @@ describe('SlackActionFields renders', () => { actionTypeId: '.email', name: 'email', config: {}, - } as ActionConnector; + } as SlackActionConnector; const wrapper = mountWithIntl( { if (!actionTypeModel.actionParamsFields) { return; } - const ParamsFields = actionTypeModel.actionParamsFields; + const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< + ActionParamsProps + >; const actionParams = { message: 'test message', }; const wrapper = mountWithIntl( {}} index={0} - hasErrors={false} /> ); - expect(wrapper.find('[data-test-subj="slackMessageTextarea"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="slackMessageTextArea"]').length > 0).toBeTruthy(); expect( wrapper - .find('[data-test-subj="slackMessageTextarea"]') + .find('[data-test-subj="slackMessageTextArea"]') .first() .prop('value') ).toStrictEqual('test message'); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.tsx index 0ae51725169bf..916715de7ae18 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.tsx @@ -3,25 +3,26 @@ * 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 React, { Fragment, useState, useEffect } from 'react'; import { EuiFieldText, EuiTextArea, - EuiFlexGroup, - EuiFlexItem, EuiButtonIcon, EuiFormRow, EuiLink, + EuiPopover, + EuiContextMenuPanel, + EuiContextMenuItem, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ActionTypeModel, ActionConnectorFieldsProps, - ActionConnector, ValidationResult, ActionParamsProps, } from '../../../types'; +import { SlackActionParams, SlackActionConnector } from './types'; export function getActionType(): ActionTypeModel { return { @@ -33,7 +34,13 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send a message to a Slack channel or user.', } ), - validateConnector: (action: ActionConnector): ValidationResult => { + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.actionTypeTitle', + { + defaultMessage: 'Send to Slack', + } + ), + validateConnector: (action: SlackActionConnector): ValidationResult => { const validationResult = { errors: {} }; const errors = { webhookUrl: new Array(), @@ -51,13 +58,13 @@ export function getActionType(): ActionTypeModel { } return validationResult; }, - validateParams: (actionParams: any): ValidationResult => { + validateParams: (actionParams: SlackActionParams): ValidationResult => { const validationResult = { errors: {} }; const errors = { message: new Array(), }; validationResult.errors = errors; - if (!actionParams.message || actionParams.message.length === 0) { + if (!actionParams.message?.length) { errors.message.push( i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', @@ -74,11 +81,9 @@ export function getActionType(): ActionTypeModel { }; } -const SlackActionFields: React.FunctionComponent = ({ - action, - editActionSecrets, - errors, -}) => { +const SlackActionFields: React.FunctionComponent> = ({ action, editActionSecrets, errors }) => { const { webhookUrl } = action.secrets; return ( @@ -127,47 +132,87 @@ const SlackActionFields: React.FunctionComponent = ( ); }; -const SlackParamsFields: React.FunctionComponent = ({ - action, +const SlackParamsFields: React.FunctionComponent> = ({ + actionParams, editAction, index, errors, - hasErrors, + messageVariables, + defaultMessage, }) => { - const { message } = action; - + const { message } = actionParams; + const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState(false); + useEffect(() => { + if (defaultMessage && defaultMessage.length > 0) { + editAction('message', defaultMessage, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const messageVariablesItems = messageVariables?.map((variable: string, i: number) => ( + { + editAction('message', (message ?? '').concat(` {{${variable}}}`), index); + setIsVariablesPopoverOpen(false); + }} + > + {`{{${variable}}}`} + + )); return ( - - - window.alert('Button clicked')} - iconType="indexOpen" - aria-label="Add variable" - /> - - 0 && message !== undefined} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.messageTextAreaFieldLabel', { defaultMessage: 'Message', } )} + labelAppend={ + setIsVariablesPopoverOpen(true)} + iconType="indexOpen" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.addVariablePopoverButton', + { + defaultMessage: 'Add variable', + } + )} + /> + } + isOpen={isVariablesPopoverOpen} + closePopover={() => setIsVariablesPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + } > 0 && message !== undefined} name="message" - value={message} - data-test-subj="slackMessageTextarea" + value={message || ''} + data-test-subj="slackMessageTextArea" onChange={e => { editAction('message', e.target.value, index); }} + onBlur={() => { + if (!message) { + editAction('message', '', index); + } + }} /> diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/types.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/types.ts new file mode 100644 index 0000000000000..45a08b2d5263a --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/types.ts @@ -0,0 +1,130 @@ +/* + * 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 { ActionConnector } from '../../../types'; + +export interface EmailActionParams { + to: string[]; + cc: string[]; + bcc: string[]; + subject: string; + message: string; +} + +export enum EventActionOptions { + TRIGGER = 'trigger', + RESOLVE = 'resolve', + ACKNOWLEDGE = 'acknowledge', +} + +export enum SeverityActionOptions { + CRITICAL = 'critical', + ERROR = 'error', + WARNING = 'warning', + INFO = 'info', +} + +export interface PagerDutyActionParams { + eventAction?: EventActionOptions; + dedupKey?: string; + summary?: string; + source?: string; + severity?: SeverityActionOptions; + timestamp?: string; + component?: string; + group?: string; + class?: string; +} + +export interface IndexActionParams { + index?: string; + refresh?: boolean; + executionTimeField?: string; + documents: string[]; +} + +export enum ServerLogLevelOptions { + TRACE = 'trace', + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', + FATAL = 'fatal', +} + +export interface ServerLogActionParams { + level?: ServerLogLevelOptions; + message: string; +} + +export interface SlackActionParams { + message: string; +} + +export interface WebhookActionParams { + body?: string; +} + +interface EmailConfig { + from: string; + host: string; + port: number; + secure?: boolean; +} + +interface EmailSecrets { + user: string; + password: string; +} + +export interface EmailActionConnector extends ActionConnector { + config: EmailConfig; + secrets: EmailSecrets; +} + +interface EsIndexConfig { + index?: string; +} + +export interface EsIndexActionConnector extends ActionConnector { + config: EsIndexConfig; +} + +interface PagerDutyConfig { + apiUrl?: string; +} + +interface PagerDutySecrets { + routingKey: string; +} + +export interface PagerDutyActionConnector extends ActionConnector { + config: PagerDutyConfig; + secrets: PagerDutySecrets; +} + +interface SlackSecrets { + webhookUrl: string; +} + +export interface SlackActionConnector extends ActionConnector { + secrets: SlackSecrets; +} + +interface WebhookConfig { + method: string; + url: string; + headers: Record; +} + +interface WebhookSecrets { + user: string; + password: string; +} + +export interface WebhookActionConnector extends ActionConnector { + config: WebhookConfig; + secrets: WebhookSecrets; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.test.tsx index cd342f2e19969..5e6c0404db532 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.test.tsx @@ -3,11 +3,12 @@ * 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 React, { FunctionComponent } from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { TypeRegistry } from '../../type_registry'; import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionConnector } from '../../../types'; +import { ActionTypeModel, ActionParamsProps } from '../../../types'; +import { WebhookActionParams, WebhookActionConnector } from './types'; const ACTION_TYPE_ID = '.webhook'; let actionTypeModel: ActionTypeModel; @@ -41,9 +42,9 @@ describe('webhook connector validation', () => { config: { method: 'PUT', url: 'http:\\test', - headers: ['content-type: text'], + headers: { 'content-type': 'text' }, }, - } as ActionConnector; + } as WebhookActionConnector; expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ errors: { @@ -66,7 +67,7 @@ describe('webhook connector validation', () => { config: { method: 'PUT', }, - } as ActionConnector; + } as WebhookActionConnector; expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ errors: { @@ -109,9 +110,9 @@ describe('WebhookActionConnectorFields renders', () => { config: { method: 'PUT', url: 'http:\\test', - headers: ['content-type: text'], + headers: { 'content-type': 'text' }, }, - } as ActionConnector; + } as WebhookActionConnector; const wrapper = mountWithIntl( { if (!actionTypeModel.actionParamsFields) { return; } - const ParamsFields = actionTypeModel.actionParamsFields; + const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< + ActionParamsProps + >; const actionParams = { body: 'test message', }; const wrapper = mountWithIntl( {}} index={0} - hasErrors={false} /> ); expect(wrapper.find('[data-test-subj="webhookBodyEditor"]').length > 0).toBeTruthy(); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.tsx index 70a9a6f3d75b3..3000191218932 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.tsx @@ -27,10 +27,10 @@ import { i18n } from '@kbn/i18n'; import { ActionTypeModel, ActionConnectorFieldsProps, - ActionConnector, ValidationResult, ActionParamsProps, } from '../../../types'; +import { WebhookActionParams, WebhookActionConnector } from './types'; const HTTP_VERBS = ['post', 'put']; @@ -44,7 +44,7 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send a request to a web service.', } ), - validateConnector: (action: ActionConnector): ValidationResult => { + validateConnector: (action: WebhookActionConnector): ValidationResult => { const validationResult = { errors: {} }; const errors = { url: new Array(), @@ -95,13 +95,13 @@ export function getActionType(): ActionTypeModel { } return validationResult; }, - validateParams: (actionParams: any): ValidationResult => { + validateParams: (actionParams: WebhookActionParams): ValidationResult => { const validationResult = { errors: {} }; const errors = { body: new Array(), }; validationResult.errors = errors; - if (!actionParams.body || actionParams.body.length === 0) { + if (!actionParams.body?.length) { errors.body.push( i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', @@ -118,12 +118,9 @@ export function getActionType(): ActionTypeModel { }; } -const WebhookActionConnectorFields: React.FunctionComponent = ({ - action, - editActionConfig, - editActionSecrets, - errors, -}) => { +const WebhookActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, editActionSecrets, errors }) => { const [httpHeaderKey, setHttpHeaderKey] = useState(''); const [httpHeaderValue, setHttpHeaderValue] = useState(''); const [hasHeaders, setHasHeaders] = useState(false); @@ -453,14 +450,13 @@ const WebhookActionConnectorFields: React.FunctionComponent = ({ - action, +const WebhookParamsFields: React.FunctionComponent> = ({ + actionParams, editAction, index, errors, - hasErrors, }) => { - const { body } = action; + const { body } = actionParams; return ( @@ -472,13 +468,13 @@ const WebhookParamsFields: React.FunctionComponent = ({ defaultMessage: 'Body', } )} - isInvalid={hasErrors === true} + isInvalid={errors.body.length > 0 && body !== undefined} fullWidth error={errors.body} > 0 && body !== undefined} mode="json" width="100%" height="200px" diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/index.ts index 6c5d440e47888..436a034e06051 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/index.ts +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getActionType as getThresholdAlertType } from './threshold/expression'; +import { getAlertType as getThresholdAlertType } from './threshold'; import { TypeRegistry } from '../../type_registry'; import { AlertTypeModel } from '../../../types'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/expression.tsx index 907a61677b263..73e2e9cbf9b76 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/expression.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -22,7 +22,7 @@ import { EuiFormRow, EuiCallOut, } from '@elastic/eui'; -import { AlertTypeModel, Alert, ValidationResult } from '../../../../types'; +import { Alert } from '../../../../types'; import { Comparator, AggregationType, GroupByType } from './types'; import { AGGREGATION_TYPES, COMPARATORS } from './constants'; import { @@ -58,99 +58,6 @@ const expressionFieldsWithValidation = [ 'timeWindowSize', ]; -const validateAlertType = (alert: Alert): ValidationResult => { - const { - index, - timeField, - aggType, - aggField, - groupBy, - termSize, - termField, - threshold, - timeWindowSize, - } = alert.params; - const validationResult = { errors: {} }; - const errors = { - aggField: new Array(), - termSize: new Array(), - termField: new Array(), - timeWindowSize: new Array(), - threshold0: new Array(), - threshold1: new Array(), - index: new Array(), - timeField: new Array(), - }; - validationResult.errors = errors; - if (!index) { - errors.index.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredIndexText', { - defaultMessage: 'Index is required.', - }) - ); - } - if (!timeField) { - errors.timeField.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTimeFieldText', { - defaultMessage: 'Time field is required.', - }) - ); - } - if (aggType && aggregationTypes[aggType].fieldRequired && !aggField) { - errors.aggField.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredAggFieldText', { - defaultMessage: 'Aggregation field is required.', - }) - ); - } - if (!termSize) { - errors.termSize.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTermSizedText', { - defaultMessage: 'Term size is required.', - }) - ); - } - if (groupBy && groupByTypes[groupBy].sizeRequired && !termField) { - errors.termField.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredtTermFieldText', { - defaultMessage: 'Term field is required.', - }) - ); - } - if (!timeWindowSize) { - errors.timeWindowSize.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTimeWindowSizeText', { - defaultMessage: 'Time window size is required.', - }) - ); - } - if (threshold && threshold.length > 0 && !threshold[0]) { - errors.threshold0.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredThreshold0Text', { - defaultMessage: 'Threshold0, is required.', - }) - ); - } - if (threshold && threshold.length > 1 && !threshold[1]) { - errors.threshold1.push( - i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredThreshold1Text', { - defaultMessage: 'Threshold1 is required.', - }) - ); - } - return validationResult; -}; - -export function getActionType(): AlertTypeModel { - return { - id: 'threshold', - name: 'Index Threshold', - iconClass: 'alert', - alertParamsExpression: IndexThresholdAlertTypeExpression, - validate: validateAlertType, - }; -} - export const aggregationTypes: { [key: string]: AggregationType } = { count: { text: 'count()', @@ -275,7 +182,6 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = setAlertParams, setAlertProperty, errors, - hasErrors, }) => { const { http } = useAppDependencies(); @@ -307,7 +213,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); const [indexPatterns, setIndexPatterns] = useState([]); const [esFields, setEsFields] = useState>([]); - const [indexOptions, setIndexOptions] = useState([]); + const [indexOptions, setIndexOptions] = useState([]); const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); const [isIndiciesLoading, setIsIndiciesLoading] = useState(false); const [alertThresholdPopoverOpen, setAlertThresholdPopoverOpen] = useState(false); @@ -323,6 +229,13 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = ); const hasExpressionErrors = !!Object.keys(errors).find( + errorKey => + expressionFieldsWithValidation.includes(errorKey) && + errors[errorKey].length >= 1 && + alert.params[errorKey] !== undefined + ); + + const canShowVizualization = !!Object.keys(errors).find( errorKey => expressionFieldsWithValidation.includes(errorKey) && errors[errorKey].length >= 1 ); @@ -433,7 +346,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = defaultMessage="Indices to query" /> } - isInvalid={hasErrors && index !== undefined} + isInvalid={errors.index.length > 0 && index !== undefined} error={errors.index} helpText={ = fullWidth async isLoading={isIndiciesLoading} - isInvalid={hasErrors && index !== undefined} + isInvalid={errors.index.length > 0 && index !== undefined} noSuggestions={!indexOptions.length} options={indexOptions} data-test-subj="thresholdIndexesComboBox" @@ -466,8 +379,6 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = // reset time field and expression fields if indices are deleted if (indices.length === 0) { setTimeFieldOptions([firstFieldOption]); - setAlertParams('timeFields', []); - setDefaultExpressionValues(); return; } @@ -475,7 +386,6 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = const timeFields = getTimeFieldOptions(currentEsFields as any); setEsFields(currentEsFields); - setAlertParams('timeFields', timeFields); setTimeFieldOptions([firstFieldOption, ...timeFields]); }} onSearchChange={async search => { @@ -493,7 +403,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = = defaultMessage="Time field" /> } - isInvalid={hasErrors && timeField !== undefined} + isInvalid={errors.timeField.length > 0 && timeField !== undefined} error={errors.timeField} > 0 && timeField !== undefined} fullWidth - name="watchTimeField" - data-test-subj="watchTimeFieldSelect" + name="thresholdTimeField" + data-test-subj="thresholdAlertTimeFieldSelect" value={timeField} onChange={e => { setAlertParams('timeField', e.target.value); @@ -542,6 +452,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = id="indexPopover" button={ = 0 && aggField !== undefined} error={errors.aggField} > 0 && aggField !== undefined} placeholder={firstFieldOption.text} options={esFields.reduce((esFieldOptions: any[], field: any) => { if ( @@ -777,9 +688,9 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = {groupByTypes[groupBy || DEFAULT_VALUES.GROUP_BY].sizeRequired ? ( - + 0} error={errors.termSize}> 0} value={termSize || 0} onChange={e => { const { value } = e.target; @@ -792,12 +703,12 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = 0 && termField !== undefined} error={errors.termField} > 0 && termField !== undefined} onChange={e => { setAlertParams('termField', e.target.value); }} @@ -896,14 +807,17 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = className="alertThresholdWatchInBetweenComparatorText" > {andThresholdText} - {hasErrors && } + {errors[`threshold${i}`].length > 0 && } ) : null} - + 0} + error={errors[`threshold${i}`]} + > 0} value={!threshold || threshold[i] === null ? 0 : threshold[i]} min={0} step={0.1} @@ -963,9 +877,12 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = - + 0} + error={errors.timeWindowSize} + > 0} min={1} value={timeWindowSize ? parseInt(timeWindowSize, 10) : 1} onChange={e => { @@ -990,7 +907,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent = - {hasExpressionErrors ? null : ( + {canShowVizualization ? null : ( diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/index.ts new file mode 100644 index 0000000000000..a0a5b05d49680 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/index.ts @@ -0,0 +1,103 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Alert, AlertTypeModel, ValidationResult } from '../../../../types'; +import { IndexThresholdAlertTypeExpression, aggregationTypes, groupByTypes } from './expression'; + +export function getAlertType(): AlertTypeModel { + return { + id: 'threshold', + name: 'Index Threshold', + iconClass: 'alert', + alertParamsExpression: IndexThresholdAlertTypeExpression, + validate: (alert: Alert): ValidationResult => { + const { + index, + timeField, + aggType, + aggField, + groupBy, + termSize, + termField, + threshold, + timeWindowSize, + } = alert.params; + const validationResult = { errors: {} }; + const errors = { + aggField: new Array(), + termSize: new Array(), + termField: new Array(), + timeWindowSize: new Array(), + threshold0: new Array(), + threshold1: new Array(), + index: new Array(), + timeField: new Array(), + }; + validationResult.errors = errors; + if (!index || index.length === 0) { + errors.index.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredIndexText', { + defaultMessage: 'Index is required.', + }) + ); + } + if (!timeField) { + errors.timeField.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTimeFieldText', { + defaultMessage: 'Time field is required.', + }) + ); + } + if (aggType && aggregationTypes[aggType].fieldRequired && !aggField) { + errors.aggField.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredAggFieldText', { + defaultMessage: 'Aggregation field is required.', + }) + ); + } + if (!termSize) { + errors.termSize.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTermSizedText', { + defaultMessage: 'Term size is required.', + }) + ); + } + if (groupBy && groupByTypes[groupBy].sizeRequired && !termField) { + errors.termField.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredtTermFieldText', { + defaultMessage: 'Term field is required.', + }) + ); + } + if (!timeWindowSize) { + errors.timeWindowSize.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAlert.error.requiredTimeWindowSizeText', + { + defaultMessage: 'Time window size is required.', + } + ) + ); + } + if (threshold && threshold.length > 0 && !threshold[0]) { + errors.threshold0.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredThreshold0Text', { + defaultMessage: 'Threshold0, is required.', + }) + ); + } + if (threshold && threshold.length > 1 && !threshold[1]) { + errors.threshold1.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredThreshold1Text', { + defaultMessage: 'Threshold1 is required.', + }) + ); + } + return validationResult; + }, + defaultActionMessage: 'Alert [{{ctx.metadata.name}}] has exceeded the threshold', + }; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/visualization.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/visualization.tsx index 9ba79bba8fef6..f91c3cf9a7708 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/visualization.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/visualization.tsx @@ -280,17 +280,18 @@ export const ThresholdVisualization: React.FunctionComponent = ({ alert } ) : ( } color="warning" > )} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/action_groups.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/action_groups.ts deleted file mode 100644 index 83a03010d55ad..0000000000000 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/action_groups.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * 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. - */ - -export enum ACTION_GROUPS { - ALERT = 'alert', - WARNING = 'warning', - UNACKNOWLEDGED = 'unacknowledged', -} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/alerts_context.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/alerts_context.tsx index 06be1bb7c5851..e019319d843a8 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/alerts_context.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/alerts_context.tsx @@ -7,8 +7,9 @@ import React, { useContext, createContext } from 'react'; export interface AlertsContextValue { - alertFlyoutVisible: boolean; - setAlertFlyoutVisibility: React.Dispatch>; + addFlyoutVisible: boolean; + setAddFlyoutVisibility: React.Dispatch>; + reloadAlerts: () => Promise; } const AlertsContext = createContext(null as any); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts index 35d1a095188de..9e64fd86f9873 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts @@ -35,6 +35,8 @@ describe('loadAlertTypes', () => { { id: 'test', name: 'Test', + actionVariables: ['var1'], + actionGroups: ['default'], }, ]; http.get.mockResolvedValueOnce(resolvedValue); @@ -300,6 +302,7 @@ describe('createAlert', () => { test('should call create alert API', async () => { const alertToCreate = { name: 'test', + consumer: 'alerting', tags: ['foo'], enabled: true, alertTypeId: 'test', @@ -309,7 +312,6 @@ describe('createAlert', () => { actions: [], params: {}, throttle: null, - consumer: '', createdAt: new Date('1970-01-01T00:00:00.000Z'), updatedAt: new Date('1970-01-01T00:00:00.000Z'), apiKey: null, @@ -331,7 +333,7 @@ describe('createAlert', () => { Array [ "/api/alert", Object { - "body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"enabled\\":true,\\"alertTypeId\\":\\"test\\",\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"actions\\":[],\\"params\\":{},\\"throttle\\":null,\\"consumer\\":\\"\\",\\"createdAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"updatedAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"apiKey\\":null,\\"apiKeyOwner\\":null}", + "body": "{\\"name\\":\\"test\\",\\"consumer\\":\\"alerting\\",\\"tags\\":[\\"foo\\"],\\"enabled\\":true,\\"alertTypeId\\":\\"test\\",\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"actions\\":[],\\"params\\":{},\\"throttle\\":null,\\"createdAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"updatedAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"apiKey\\":null,\\"apiKeyOwner\\":null}", }, ] `); @@ -342,6 +344,7 @@ describe('updateAlert', () => { test('should call alert update API', async () => { const alertToUpdate = { throttle: '1m', + consumer: 'alerting', name: 'test', tags: ['foo'], schedule: { @@ -349,7 +352,6 @@ describe('updateAlert', () => { }, params: {}, actions: [], - consumer: '', createdAt: new Date('1970-01-01T00:00:00.000Z'), updatedAt: new Date('1970-01-01T00:00:00.000Z'), apiKey: null, @@ -373,7 +375,7 @@ describe('updateAlert', () => { Array [ "/api/alert/123", Object { - "body": "{\\"throttle\\":\\"1m\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[],\\"consumer\\":\\"\\",\\"createdAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"updatedAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"apiKey\\":null,\\"apiKeyOwner\\":null}", + "body": "{\\"throttle\\":\\"1m\\",\\"consumer\\":\\"alerting\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[],\\"createdAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"updatedAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"apiKey\\":null,\\"apiKeyOwner\\":null}", }, ] `); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/_index.scss b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/_index.scss new file mode 100644 index 0000000000000..f8fa882cd617d --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/_index.scss @@ -0,0 +1,3 @@ +.actConnectorModal { + z-index: 9000; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx index f27f7d8c3054d..7bccf7a4328b6 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -6,9 +6,6 @@ import * as React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../../../src/core/public/mocks'; -import { ReactWrapper } from 'enzyme'; -import { act } from 'react-dom/test-utils'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, ActionConnector } from '../../../types'; import { ActionConnectorForm } from './action_connector_form'; @@ -16,8 +13,7 @@ import { AppContextProvider } from '../../app_context'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('action_connector_form', () => { - let wrapper: ReactWrapper; - + let deps: any; beforeAll(async () => { const mockes = coreMock.createSetup(); const [ @@ -27,7 +23,7 @@ describe('action_connector_form', () => { application: { capabilities }, }, ] = await mockes.getStartServices(); - const deps = { + deps = { chrome, docLinks, toastNotifications: mockes.notifications.toasts, @@ -48,7 +44,9 @@ describe('action_connector_form', () => { actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: {} as any, }; + }); + it('renders action_connector_form', () => { const actionType = { id: 'my-action-type', iconClass: 'test', @@ -71,50 +69,19 @@ describe('action_connector_form', () => { config: {}, secrets: {}, } as ActionConnector; - - await act(async () => { - wrapper = mountWithIntl( - - {}, - editFlyoutVisible: false, - setEditFlyoutVisibility: () => {}, - actionTypesIndex: { - 'my-action-type': { - id: 'my-action-type', - name: 'my-action-type-name', - enabled: true, - }, - }, - reloadConnectors: () => { - return new Promise(() => {}); - }, - }} - > - {}} - /> - - - ); - }); - - await waitForRender(wrapper); - }); - - it('renders action_connector_form', () => { + const wrapper = mountWithIntl( + + {}} + serverError={null} + errors={{ name: [] }} + /> + + ); const connectorNameField = wrapper.find('[data-test-subj="nameInput"]'); expect(connectorNameField.exists()).toBeTruthy(); expect(connectorNameField.first().prop('value')).toBe(''); }); }); - -async function waitForRender(wrapper: ReactWrapper) { - await Promise.resolve(); - await Promise.resolve(); - wrapper.update(); -} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.tsx index 852e713b38ed7..c29064efcde35 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.tsx @@ -3,49 +3,59 @@ * 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, useReducer } from 'react'; +import React, { Fragment } from 'react'; import { - EuiButton, - EuiFlexGroup, - EuiFlexItem, EuiForm, EuiCallOut, EuiLink, EuiText, EuiSpacer, - EuiButtonEmpty, - EuiFlyoutFooter, EuiFieldText, - EuiFlyoutBody, EuiFormRow, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { createActionConnector, updateActionConnector } from '../../lib/action_connector_api'; import { useAppDependencies } from '../../app_context'; -import { connectorReducer } from './connector_reducer'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { ReducerAction } from './connector_reducer'; import { ActionConnector, IErrorObject } from '../../../types'; -import { hasSaveActionsCapability } from '../../lib/capabilities'; + +export function validateBaseProperties(actionObject: ActionConnector) { + const validationResult = { errors: {} }; + const verrors = { + name: new Array(), + }; + validationResult.errors = verrors; + if (!actionObject.name) { + verrors.name.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorForm.error.requiredNameText', + { + defaultMessage: 'Name is required.', + } + ) + ); + } + return validationResult; +} interface ActionConnectorProps { - initialConnector: ActionConnector; + connector: ActionConnector; + dispatch: React.Dispatch; actionTypeName: string; - setFlyoutVisibility: React.Dispatch>; + serverError: { + body: { message: string; error: string }; + } | null; + errors: IErrorObject; } export const ActionConnectorForm = ({ - initialConnector, + connector, + dispatch, actionTypeName, - setFlyoutVisibility, + serverError, + errors, }: ActionConnectorProps) => { - const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies(); - - const { reloadConnectors } = useActionsConnectorsContext(); - const canSave = hasSaveActionsCapability(capabilities); - - // hooks - const [{ connector }, dispatch] = useReducer(connectorReducer, { connector: initialConnector }); + const { actionTypeRegistry } = useAppDependencies(); const setActionProperty = (key: string, value: any) => { dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); @@ -59,207 +69,86 @@ export const ActionConnectorForm = ({ dispatch({ command: { type: 'setSecretsProperty' }, payload: { key, value } }); }; - const [isSaving, setIsSaving] = useState(false); - const [serverError, setServerError] = useState<{ - body: { message: string; error: string }; - } | null>(null); - - const actionTypeRegistered = actionTypeRegistry.get(initialConnector.actionTypeId); - if (!actionTypeRegistered) return null; - - function validateBaseProperties(actionObject: ActionConnector) { - const validationResult = { errors: {} }; - const errors = { - name: new Array(), - }; - validationResult.errors = errors; - if (!actionObject.name) { - errors.name.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.actionConnectorForm.error.requiredNameText', - { - defaultMessage: 'Name is required.', - } - ) - ); - } - return validationResult; - } + const actionTypeRegistered = actionTypeRegistry.get(connector.actionTypeId); + if (!actionTypeRegistered) + return ( + + + +

+ + + + ), + }} + /> +

+
+
+ +
+ ); const FieldsComponent = actionTypeRegistered.actionConnectorFields; - const errors = { - ...actionTypeRegistered.validateConnector(connector).errors, - ...validateBaseProperties(connector).errors, - } as IErrorObject; - const hasErrors = !!Object.keys(errors).find(errorKey => errors[errorKey].length >= 1); - - async function onActionConnectorSave(): Promise { - let message: string; - let savedConnector: ActionConnector | undefined; - let error; - if (connector.id === undefined) { - await createActionConnector({ http, connector }) - .then(res => { - savedConnector = res; - }) - .catch(errorRes => { - error = errorRes; - }); - - message = 'Created'; - } else { - await updateActionConnector({ http, connector, id: connector.id }) - .then(res => { - savedConnector = res; - }) - .catch(errorRes => { - error = errorRes; - }); - message = 'Updated'; - } - if (error) { - return { - error, - }; - } - toastNotifications.addSuccess( - i18n.translate( - 'xpack.triggersActionsUI.sections.actionConnectorForm.updateSuccessNotificationText', - { - defaultMessage: "{message} '{connectorName}'", - values: { - message, - connectorName: savedConnector ? savedConnector.name : '', - }, - } - ) - ); - return savedConnector; - } return ( - - - - + + + } + isInvalid={errors.name.length > 0 && connector.name !== undefined} + error={errors.name} + > + 0 && connector.name !== undefined} + name="name" + placeholder="Untitled" + data-test-subj="nameInput" + value={connector.name || ''} + onChange={e => { + setActionProperty('name', e.target.value); + }} + onBlur={() => { + if (!connector.name) { + setActionProperty('name', ''); } - isInvalid={errors.name.length > 0 && connector.name !== undefined} - error={errors.name} - > - 0 && connector.name !== undefined} - name="name" - placeholder="Untitled" - data-test-subj="nameInput" - value={connector.name || ''} - onChange={e => { - setActionProperty('name', e.target.value); - }} - onBlur={() => { - if (!connector.name) { - setActionProperty('name', ''); - } - }} - /> - - - {FieldsComponent !== null ? ( - - {initialConnector.actionTypeId === null ? ( - - - -

- - - - ), - }} - /> -

-
-
- -
- ) : null} -
- ) : null} -
-
- - - - setFlyoutVisibility(false)}> - {i18n.translate( - 'xpack.triggersActionsUI.sections.actionConnectorForm.cancelButtonLabel', - { - defaultMessage: 'Cancel', - } - )} - - - {canSave ? ( - - { - setIsSaving(true); - const savedAction = await onActionConnectorSave(); - setIsSaving(false); - if (savedAction && savedAction.error) { - return setServerError(savedAction.error); - } - setFlyoutVisibility(false); - reloadConnectors(); - }} - > - - - - ) : null} - - -
+ }} + /> +
+ + {FieldsComponent !== null ? ( + + ) : null} + ); }; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.tsx index 19373fda79b9e..f89d61acb59ca 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.tsx @@ -3,18 +3,8 @@ * 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 { - EuiFlexGroup, - EuiFlexItem, - EuiCard, - EuiIcon, - EuiFlexGrid, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiButtonEmpty, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid } from '@elastic/eui'; import { ActionType } from '../../../types'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { useAppDependencies } from '../../app_context'; @@ -25,7 +15,7 @@ interface Props { export const ActionTypeMenu = ({ onActionTypeChange }: Props) => { const { actionTypeRegistry } = useAppDependencies(); - const { actionTypesIndex, setAddFlyoutVisibility } = useActionsConnectorsContext(); + const { actionTypesIndex } = useActionsConnectorsContext(); if (!actionTypesIndex) { return null; } @@ -45,7 +35,7 @@ export const ActionTypeMenu = ({ onActionTypeChange }: Props) => { const cardNodes = actionTypes .sort((a, b) => a.name.localeCompare(b.name)) - .map((item, index): any => { + .map((item, index) => { return ( { ); }); - return ( - - - {cardNodes} - - - - - setAddFlyoutVisibility(false)}> - {i18n.translate( - 'xpack.triggersActionsUI.sections.actionConnectorForm.cancelButtonLabel', - { - defaultMessage: 'Cancel', - } - )} - - - - - - ); + return {cardNodes}; }; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx index a03296c7c3679..52b9d1c0ed1bb 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -6,17 +6,16 @@ import * as React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../../../src/core/public/mocks'; -import { ReactWrapper } from 'enzyme'; -import { act } from 'react-dom/test-utils'; import { ConnectorAddFlyout } from './connector_add_flyout'; import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult } from '../../../types'; import { AppContextProvider } from '../../app_context'; +import { AppDeps } from '../../app'; const actionTypeRegistry = actionTypeRegistryMock.create(); describe('connector_add_flyout', () => { - let wrapper: ReactWrapper; + let deps: AppDeps | null; beforeAll(async () => { const mockes = coreMock.createSetup(); @@ -27,7 +26,7 @@ describe('connector_add_flyout', () => { application: { capabilities }, }, ] = await mockes.getStartServices(); - const deps = { + deps = { chrome, docLinks, toastNotifications: mockes.notifications.toasts, @@ -48,31 +47,6 @@ describe('connector_add_flyout', () => { actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: {} as any, }; - - await act(async () => { - wrapper = mountWithIntl( - - {}, - editFlyoutVisible: false, - setEditFlyoutVisibility: state => {}, - actionTypesIndex: { - 'my-action-type': { id: 'my-action-type', name: 'test', enabled: true }, - }, - reloadConnectors: () => { - return new Promise(() => {}); - }, - }} - > - - - - ); - }); - - await waitForRender(wrapper); }); it('renders action type menu on flyout open', () => { @@ -93,13 +67,27 @@ describe('connector_add_flyout', () => { actionTypeRegistry.get.mockReturnValueOnce(actionType); actionTypeRegistry.has.mockReturnValue(true); + const wrapper = mountWithIntl( + + {}, + editFlyoutVisible: false, + setEditFlyoutVisibility: state => {}, + actionTypesIndex: { + 'my-action-type': { id: 'my-action-type', name: 'test', enabled: true }, + }, + reloadConnectors: () => { + return new Promise(() => {}); + }, + }} + > + + + + ); expect(wrapper.find('ActionTypeMenu')).toHaveLength(1); expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); }); }); - -async function waitForRender(wrapper: ReactWrapper) { - await Promise.resolve(); - await Promise.resolve(); - wrapper.update(); -} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.tsx index a3ec7ab4b3ab9..8a50513f158a1 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -3,7 +3,7 @@ * 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, { useCallback, useState, Fragment } from 'react'; +import React, { useCallback, useState, Fragment, useReducer } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -13,21 +13,57 @@ import { EuiFlexItem, EuiIcon, EuiText, + EuiFlyoutFooter, + EuiButtonEmpty, + EuiButton, + EuiFlyoutBody, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { ActionTypeMenu } from './action_type_menu'; -import { ActionConnectorForm } from './action_connector_form'; -import { ActionType, ActionConnector } from '../../../types'; +import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; +import { ActionType, ActionConnector, IErrorObject } from '../../../types'; import { useAppDependencies } from '../../app_context'; +import { connectorReducer } from './connector_reducer'; +import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { createActionConnector } from '../../lib/action_connector_api'; export const ConnectorAddFlyout = () => { - const { actionTypeRegistry } = useAppDependencies(); - const { addFlyoutVisible, setAddFlyoutVisibility } = useActionsConnectorsContext(); + let hasErrors = false; + const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies(); const [actionType, setActionType] = useState(undefined); + + // hooks + const initialConnector = { + actionTypeId: actionType?.id ?? '', + config: {}, + secrets: {}, + } as ActionConnector; + const [{ connector }, dispatch] = useReducer(connectorReducer, { connector: initialConnector }); + const setActionProperty = (key: string, value: any) => { + dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); + }; + const setConnector = (value: any) => { + dispatch({ command: { type: 'setConnector' }, payload: { key: 'connector', value } }); + }; + + const { + addFlyoutVisible, + setAddFlyoutVisibility, + reloadConnectors, + } = useActionsConnectorsContext(); + const [isSaving, setIsSaving] = useState(false); + const closeFlyout = useCallback(() => { setAddFlyoutVisibility(false); setActionType(undefined); - }, [setAddFlyoutVisibility, setActionType]); + setConnector(initialConnector); + }, [setAddFlyoutVisibility, initialConnector]); + + const [serverError, setServerError] = useState<{ + body: { message: string; error: string }; + } | null>(null); + const canSave = hasSaveActionsCapability(capabilities); if (!addFlyoutVisible) { return null; @@ -35,6 +71,7 @@ export const ConnectorAddFlyout = () => { function onActionTypeChange(newActionType: ActionType) { setActionType(newActionType); + setActionProperty('actionTypeId', newActionType.id); } let currentForm; @@ -43,21 +80,45 @@ export const ConnectorAddFlyout = () => { currentForm = ; } else { actionTypeModel = actionTypeRegistry.get(actionType.id); - const initialConnector = { - actionTypeId: actionType.id, - config: {}, - secrets: {}, - } as ActionConnector; + + const errors = { + ...actionTypeModel?.validateConnector(connector).errors, + ...validateBaseProperties(connector).errors, + } as IErrorObject; + hasErrors = !!Object.keys(errors).find(errorKey => errors[errorKey].length >= 1); currentForm = ( ); } + const onActionConnectorSave = async (): Promise => + await createActionConnector({ http, connector }) + .then(savedConnector => { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.triggersActionsUI.sections.addConnectorForm.updateSuccessNotificationText', + { + defaultMessage: "Created '{connectorName}'", + values: { + connectorName: savedConnector.name, + }, + } + ) + ); + return savedConnector; + }) + .catch(errorRes => { + setServerError(errorRes); + return undefined; + }); + return ( @@ -98,7 +159,49 @@ export const ConnectorAddFlyout = () => {
- {currentForm} + {currentForm} + + + + + + {i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorAdd.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + )} + + + {canSave && actionTypeModel && actionType ? ( + + { + setIsSaving(true); + const savedAction = await onActionConnectorSave(); + setIsSaving(false); + if (savedAction) { + closeFlyout(); + reloadConnectors(); + } + }} + > + + + + ) : null} + + ); }; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_modal.test.tsx new file mode 100644 index 0000000000000..cc87b16035ad3 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../../../src/core/public/mocks'; +import { ConnectorAddModal } from './connector_add_modal'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ValidationResult } from '../../../types'; +import { AppContextProvider } from '../../app_context'; +import { AppDeps } from '../../app'; +const actionTypeRegistry = actionTypeRegistryMock.create(); + +describe('connector_add_modal', () => { + let deps: AppDeps | null; + + beforeAll(async () => { + const mocks = coreMock.createSetup(); + const [ + { + chrome, + docLinks, + application: { capabilities }, + }, + ] = await mocks.getStartServices(); + deps = { + chrome, + docLinks, + toastNotifications: mocks.notifications.toasts, + injectedMetadata: mocks.injectedMetadata, + http: mocks.http, + uiSettings: mocks.uiSettings, + capabilities: { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, + }, + }, + legacy: { + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: {} as any, + }; + }); + it('renders connector modal form if addModalVisible is true', () => { + const actionTypeModel = { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + actionTypeRegistry.get.mockReturnValueOnce(actionTypeModel); + actionTypeRegistry.has.mockReturnValue(true); + + const actionType = { + id: 'my-action-type', + name: 'test', + enabled: true, + }; + + const wrapper = mountWithIntl( + + {}, + editFlyoutVisible: false, + setEditFlyoutVisibility: state => {}, + actionTypesIndex: { + 'my-action-type': { id: 'my-action-type', name: 'test', enabled: true }, + }, + reloadConnectors: () => { + return new Promise(() => {}); + }, + }} + > + {}} + actionType={actionType} + /> + + + ); + expect(wrapper.find('EuiModalHeader')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="saveActionButtonModal"]').exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_modal.tsx new file mode 100644 index 0000000000000..2ce282e946a38 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -0,0 +1,168 @@ +/* + * 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, { useCallback, useReducer, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiTitle, EuiFlexItem, EuiIcon, EuiFlexGroup } from '@elastic/eui'; +import { + EuiModal, + EuiButton, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, +} from '@elastic/eui'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiOverlayMask } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; +import { ActionType, ActionConnector, IErrorObject } from '../../../types'; +import { connectorReducer } from './connector_reducer'; +import { createActionConnector } from '../../lib/action_connector_api'; +import { useAppDependencies } from '../../app_context'; + +export const ConnectorAddModal = ({ + actionType, + addModalVisible, + setAddModalVisibility, + postSaveEventHandler, +}: { + actionType: ActionType; + addModalVisible: boolean; + setAddModalVisibility: React.Dispatch>; + postSaveEventHandler?: (savedAction: ActionConnector) => void; +}) => { + let hasErrors = false; + const { http, toastNotifications, actionTypeRegistry } = useAppDependencies(); + const initialConnector = { + actionTypeId: actionType.id, + config: {}, + secrets: {}, + } as ActionConnector; + const [isSaving, setIsSaving] = useState(false); + + const [{ connector }, dispatch] = useReducer(connectorReducer, { connector: initialConnector }); + const setConnector = (value: any) => { + dispatch({ command: { type: 'setConnector' }, payload: { key: 'connector', value } }); + }; + const [serverError, setServerError] = useState<{ + body: { message: string; error: string }; + } | null>(null); + + const closeModal = useCallback(() => { + setAddModalVisibility(false); + setConnector(initialConnector); + setServerError(null); + }, [initialConnector, setAddModalVisibility]); + + if (!addModalVisible) { + return null; + } + const actionTypeModel = actionTypeRegistry.get(actionType.id); + const errors = { + ...actionTypeModel?.validateConnector(connector).errors, + ...validateBaseProperties(connector).errors, + } as IErrorObject; + hasErrors = !!Object.keys(errors).find(errorKey => errors[errorKey].length >= 1); + + const onActionConnectorSave = async (): Promise => + await createActionConnector({ http, connector }) + .then(savedConnector => { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.triggersActionsUI.sections.addModalConnectorForm.updateSuccessNotificationText', + { + defaultMessage: "Created '{connectorName}'", + values: { + connectorName: savedConnector.name, + }, + } + ) + ); + return savedConnector; + }) + .catch(errorRes => { + setServerError(errorRes); + return undefined; + }); + + return ( + + + + + + {actionTypeModel && actionTypeModel.iconClass ? ( + + + + ) : null} + + +

+ +

+
+
+
+
+
+ + + + + + + + {i18n.translate( + 'xpack.triggersActionsUI.sections.addModalConnectorForm.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + )} + + + { + setIsSaving(true); + const savedAction = await onActionConnectorSave(); + setIsSaving(false); + if (savedAction) { + if (postSaveEventHandler) { + postSaveEventHandler(savedAction); + } + closeModal(); + } + }} + > + + + +
+
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx index 0dc38523bfab8..9687707d0876a 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -91,7 +91,7 @@ describe('connector_edit_flyout', () => { }, }} > - + ); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 408989609d2ec..9c1f2ddc7f7f6 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -3,7 +3,7 @@ * 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, { useCallback } from 'react'; +import React, { useCallback, useReducer, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -12,26 +12,73 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiButtonEmpty, + EuiButton, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; -import { ActionConnectorForm } from './action_connector_form'; +import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; import { useAppDependencies } from '../../app_context'; -import { ActionConnectorTableItem } from '../../../types'; +import { ActionConnectorTableItem, ActionConnector, IErrorObject } from '../../../types'; +import { connectorReducer } from './connector_reducer'; +import { updateActionConnector } from '../../lib/action_connector_api'; +import { hasSaveActionsCapability } from '../../lib/capabilities'; export interface ConnectorEditProps { - connector: ActionConnectorTableItem; + initialConnector: ActionConnectorTableItem; } -export const ConnectorEditFlyout = ({ connector }: ConnectorEditProps) => { - const { actionTypeRegistry } = useAppDependencies(); - const { editFlyoutVisible, setEditFlyoutVisibility } = useActionsConnectorsContext(); +export const ConnectorEditFlyout = ({ initialConnector }: ConnectorEditProps) => { + let hasErrors = false; + const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies(); + const canSave = hasSaveActionsCapability(capabilities); + const { + editFlyoutVisible, + setEditFlyoutVisibility, + reloadConnectors, + } = useActionsConnectorsContext(); const closeFlyout = useCallback(() => setEditFlyoutVisibility(false), [setEditFlyoutVisibility]); + const [{ connector }, dispatch] = useReducer(connectorReducer, { + connector: { ...initialConnector, secrets: {} }, + }); + const [isSaving, setIsSaving] = useState(false); + const [serverError, setServerError] = useState<{ + body: { message: string; error: string }; + } | null>(null); if (!editFlyoutVisible) { return null; } const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); + const errors = { + ...actionTypeModel?.validateConnector(connector).errors, + ...validateBaseProperties(connector).errors, + } as IErrorObject; + hasErrors = !!Object.keys(errors).find(errorKey => errors[errorKey].length >= 1); + + const onActionConnectorSave = async (): Promise => + await updateActionConnector({ http, connector, id: connector.id }) + .then(savedConnector => { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText', + { + defaultMessage: "Updated '{connectorName}'", + values: { + connectorName: savedConnector.name, + }, + } + ) + ); + return savedConnector; + }) + .catch(errorRes => { + setServerError(errorRes); + return undefined; + }); return ( @@ -54,15 +101,56 @@ export const ConnectorEditFlyout = ({ connector }: ConnectorEditProps) => {
- + + + + + + + + {i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + )} + + + {canSave && actionTypeModel ? ( + + { + setIsSaving(true); + const savedAction = await onActionConnectorSave(); + setIsSaving(false); + if (savedAction) { + closeFlyout(); + reloadConnectors(); + } + }} + > + + + + ) : null} + + ); }; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.ts index 4a2610f965735..4d094cd869420 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.ts +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.ts @@ -6,7 +6,7 @@ import { isEqual } from 'lodash'; interface CommandType { - type: 'setProperty' | 'setConfigProperty' | 'setSecretsProperty'; + type: 'setConnector' | 'setProperty' | 'setConfigProperty' | 'setSecretsProperty'; } export interface ActionState { @@ -26,6 +26,17 @@ export const connectorReducer = (state: ActionState, action: ReducerAction) => { const { connector } = state; switch (command.type) { + case 'setConnector': { + const { key, value } = payload; + if (key === 'connector') { + return { + ...state, + connector: value, + }; + } else { + return state; + } + } case 'setProperty': { const { key, value } = payload; if (isEqual(connector[key], value)) { diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index e98c3b2c08749..bed285f668e01 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -384,7 +384,12 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { }} > - {editedConnectorItem ? : null} + {editedConnectorItem ? ( + + ) : null} ); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.test.tsx new file mode 100644 index 0000000000000..bf94ffc73b85c --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.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 * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { act } from 'react-dom/test-utils'; +import { coreMock } from '../../../../../../../../../src/core/public/mocks'; +import { AlertAdd } from './alert_add'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ValidationResult } from '../../../types'; +import { AppContextProvider } from '../../app_context'; +import { AppDeps } from '../../app'; +import { AlertsContextProvider } from '../../context/alerts_context'; +import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; +import { ReactWrapper } from 'enzyme'; +const actionTypeRegistry = actionTypeRegistryMock.create(); +const alertTypeRegistry = alertTypeRegistryMock.create(); + +describe('alert_add', () => { + let deps: AppDeps | null; + let wrapper: ReactWrapper; + + beforeAll(async () => { + const mockes = coreMock.createSetup(); + const [ + { + chrome, + docLinks, + application: { capabilities }, + }, + ] = await mockes.getStartServices(); + deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + capabilities: { + ...capabilities, + alerting: { + delete: true, + save: true, + show: true, + }, + }, + legacy: { + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; + const alertType = { + id: 'my-alert-type', + iconClass: 'test', + name: 'test-alert', + validate: (): ValidationResult => { + return { errors: {} }; + }, + alertParamsExpression: () => , + }; + + const actionTypeModel = { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + actionTypeRegistry.get.mockReturnValueOnce(actionTypeModel); + actionTypeRegistry.has.mockReturnValue(true); + alertTypeRegistry.list.mockReturnValue([alertType]); + alertTypeRegistry.get.mockReturnValue(alertType); + alertTypeRegistry.has.mockReturnValue(true); + actionTypeRegistry.list.mockReturnValue([actionTypeModel]); + actionTypeRegistry.has.mockReturnValue(true); + + await act(async () => { + wrapper = mountWithIntl( + + {}, + reloadAlerts: () => { + return new Promise(() => {}); + }, + }} + > + + + + ); + }); + await waitForRender(wrapper); + }); + + it('renders alert add flyout', () => { + expect(wrapper.find('[data-test-subj="addAlertFlyoutTitle"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="saveAlertButton"]').exists()).toBeTruthy(); + }); +}); + +async function waitForRender(wrapper: ReactWrapper) { + await Promise.resolve(); + await Promise.resolve(); + wrapper.update(); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.tsx index f11b0b948b2a7..d73feff938076 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.tsx @@ -3,584 +3,120 @@ * 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, useCallback, useReducer, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; +import React, { useCallback, useReducer, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiButton, + EuiTitle, + EuiFlyoutHeader, + EuiFlyout, + EuiFlyoutFooter, EuiFlexGroup, EuiFlexItem, - EuiIcon, - EuiTitle, - EuiForm, - EuiSpacer, EuiButtonEmpty, - EuiFlyoutFooter, + EuiButton, EuiFlyoutBody, - EuiFlyoutHeader, - EuiFlyout, - EuiFieldText, - EuiFlexGrid, - EuiFormRow, - EuiComboBox, - EuiKeyPadMenuItem, - EuiTabs, - EuiTab, - EuiLink, - EuiFieldNumber, - EuiSelect, - EuiIconTip, EuiPortal, - EuiAccordion, - EuiButtonIcon, } from '@elastic/eui'; -import { useAppDependencies } from '../../app_context'; -import { createAlert } from '../../lib/alert_api'; -import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api'; +import { i18n } from '@kbn/i18n'; import { useAlertsContext } from '../../context/alerts_context'; +import { Alert, AlertAction, IErrorObject } from '../../../types'; +import { AlertForm, validateBaseProperties } from './alert_form'; import { alertReducer } from './alert_reducer'; -import { - AlertTypeModel, - Alert, - IErrorObject, - ActionTypeModel, - AlertAction, - ActionTypeIndex, - ActionConnector, -} from '../../../types'; -import { ACTION_GROUPS } from '../../constants/action_groups'; -import { getTimeOptions } from '../../lib/get_time_options'; -import { SectionLoading } from '../../components/section_loading'; - -interface Props { - refreshList: () => Promise; -} - -function validateBaseProperties(alertObject: Alert) { - const validationResult = { errors: {} }; - const errors = { - name: new Array(), - interval: new Array(), - alertTypeId: new Array(), - actionConnectors: new Array(), - }; - validationResult.errors = errors; - if (!alertObject.name) { - errors.name.push( - i18n.translate('xpack.triggersActionsUI.sections.alertAdd.error.requiredNameText', { - defaultMessage: 'Name is required.', - }) - ); - } - if (!(alertObject.schedule && alertObject.schedule.interval)) { - errors.interval.push( - i18n.translate('xpack.triggersActionsUI.sections.alertAdd.error.requiredIntervalText', { - defaultMessage: 'Check interval is required.', - }) - ); - } - if (!alertObject.alertTypeId) { - errors.alertTypeId.push( - i18n.translate('xpack.triggersActionsUI.sections.alertAdd.error.requiredAlertTypeIdText', { - defaultMessage: 'Alert trigger is required.', - }) - ); - } - return validationResult; -} +import { useAppDependencies } from '../../app_context'; +import { createAlert } from '../../lib/alert_api'; -export const AlertAdd = ({ refreshList }: Props) => { +export const AlertAdd = () => { const { http, toastNotifications, alertTypeRegistry, actionTypeRegistry } = useAppDependencies(); - const initialAlert = { + const initialAlert = ({ params: {}, + consumer: 'alerting', alertTypeId: null, schedule: { interval: '1m', }, actions: [], tags: [], - }; + } as unknown) as Alert; - const { alertFlyoutVisible, setAlertFlyoutVisibility } = useAlertsContext(); - // hooks - const [alertType, setAlertType] = useState(undefined); const [{ alert }, dispatch] = useReducer(alertReducer, { alert: initialAlert }); const [isSaving, setIsSaving] = useState(false); - const [isLoadingActionTypes, setIsLoadingActionTypes] = useState(false); - const [selectedTabId, setSelectedTabId] = useState('alert'); - const [actionTypesIndex, setActionTypesIndex] = useState(undefined); - const [alertInterval, setAlertInterval] = useState(null); - const [alertIntervalUnit, setAlertIntervalUnit] = useState('m'); - const [alertThrottle, setAlertThrottle] = useState(null); - const [alertThrottleUnit, setAlertThrottleUnit] = useState(''); - const [serverError, setServerError] = useState<{ - body: { message: string; error: string }; - } | null>(null); - const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState(true); - const [connectors, setConnectors] = useState([]); - - 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) { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertAdd.unableToLoadActionTypesMessage', - { defaultMessage: 'Unable to load action types' } - ), - }); - } finally { - setIsLoadingActionTypes(false); - } - })(); - }, [toastNotifications, http]); - - useEffect(() => { - dispatch({ - command: { type: 'setAlert' }, - payload: { - key: 'alert', - value: { - params: {}, - alertTypeId: null, - schedule: { - interval: '1m', - }, - actions: [], - tags: [], - }, - }, - }); - }, [alertFlyoutVisible]); - - useEffect(() => { - loadConnectors(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [alertFlyoutVisible]); - - const setAlertProperty = (key: string, value: any) => { - dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); - }; - const setAlertParams = (key: string, value: any) => { - dispatch({ command: { type: 'setAlertParams' }, payload: { key, value } }); + const setAlert = (value: any) => { + dispatch({ command: { type: 'setAlert' }, payload: { key: 'alert', 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 { addFlyoutVisible, setAddFlyoutVisibility, reloadAlerts } = useAlertsContext(); const closeFlyout = useCallback(() => { - setAlertFlyoutVisibility(false); - setAlertType(undefined); - setIsAddActionPanelOpen(true); - setSelectedTabId('alert'); + setAddFlyoutVisibility(false); + setAlert(initialAlert); setServerError(null); - }, [setAlertFlyoutVisibility]); - - if (!alertFlyoutVisible) { - return null; - } + }, [initialAlert, setAddFlyoutVisibility]); - const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; + const [serverError, setServerError] = useState<{ + body: { message: string; error: string }; + } | null>(null); - async function loadConnectors() { - try { - const actionsResponse = await loadAllActions({ http }); - setConnectors(actionsResponse.data); - } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertAdd.unableToLoadActionsMessage', - { - defaultMessage: 'Unable to load connectors', - } - ), - }); - } + if (!addFlyoutVisible) { + return null; } - const AlertParamsExpressionComponent = alertType ? alertType.alertParamsExpression : null; - + const alertType = alertTypeRegistry.get(alert.alertTypeId); const errors = { ...(alertType ? alertType.validate(alert).errors : []), ...validateBaseProperties(alert).errors, } as IErrorObject; const hasErrors = !!Object.keys(errors).find(errorKey => errors[errorKey].length >= 1); - const actionErrors = alert.actions.reduce((acc: any, alertAction: AlertAction) => { - const actionTypeConnectors = connectors.find(field => field.id === alertAction.id); - if (!actionTypeConnectors) { - return []; - } - const actionType = actionTypeRegistry.get(actionTypeConnectors.actionTypeId); - if (!actionType) { - return []; - } - const actionValidationErrors = actionType.validateParams(alertAction.params); - acc[alertAction.id] = actionValidationErrors; - return acc; - }, {}); - - const hasActionErrors = !!Object.keys(actionErrors).find(actionError => { - return !!Object.keys(actionErrors[actionError]).find((actionErrorKey: string) => { - return actionErrors[actionError][actionErrorKey].length >= 1; - }); - }); - - const tabs = [ - { - id: ACTION_GROUPS.ALERT, - name: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.alertTabText', { - defaultMessage: 'Alert', - }), - }, - { - id: ACTION_GROUPS.WARNING, - name: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.warningTabText', { - defaultMessage: 'Warning', - }), - }, - { - id: ACTION_GROUPS.UNACKNOWLEDGED, - name: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.unacknowledgedTabText', { - defaultMessage: 'If unacknowledged', - }), - disabled: false, + 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 }; }, - ]; + {} + ) as Record; + + const hasActionErrors = !!Object.entries(actionsErrors) + .map(([, actionErrors]) => actionErrors) + .find((actionErrors: { errors: IErrorObject }) => { + return !!Object.keys(actionErrors.errors).find( + errorKey => actionErrors.errors[errorKey].length >= 1 + ); + }); - async function onSaveAlert(): Promise { + async function onSaveAlert(): Promise { try { const newAlert = await createAlert({ http, alert }); toastNotifications.addSuccess( - i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText', { + i18n.translate('xpack.triggersActionsUI.sections.alertForm.saveSuccessNotificationText', { defaultMessage: "Saved '{alertName}'", values: { - alertName: newAlert.id, + alertName: newAlert.name, }, }) ); return newAlert; - } catch (error) { - return { - error, - }; - } - } - - function addActionType(actionTypeModel: ActionTypeModel) { - setIsAddActionPanelOpen(false); - const actionTypeConnectors = connectors.filter( - field => field.actionTypeId === actionTypeModel.id - ); - if (actionTypeConnectors.length > 0) { - alert.actions.push({ id: actionTypeConnectors[0].id, group: selectedTabId, params: {} }); + } catch (errorRes) { + setServerError(errorRes); + return undefined; } } - const alertTypeNodes = alertTypeRegistry.list().map(function(item, index) { - return ( - { - setAlertProperty('alertTypeId', item.id); - setAlertType(item); - }} - > - - - ); - }); - - const actionTypeNodes = actionTypeRegistry.list().map(function(item, index) { - return ( - addActionType(item)} - > - - - ); - }); - - const alertTabs = tabs.map(function(tab, index): any { - return ( - { - setSelectedTabId(tab.id); - if (!alert.actions.find((action: AlertAction) => action.group === tab.id)) { - setIsAddActionPanelOpen(true); - } else { - setIsAddActionPanelOpen(false); - } - }} - isSelected={tab.id === selectedTabId} - disabled={tab.disabled} - key={index} - > - {tab.name} - - ); - }); - - const alertTypeDetails = ( - - - - -
- -
-
-
- - { - setAlertProperty('alertTypeId', null); - setAlertType(undefined); - }} - > - - - -
- {AlertParamsExpressionComponent ? ( - - ) : null} -
- ); - - 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 actionsListForGroup = ( - - {alert.actions.map((actionItem: AlertAction, index: number) => { - const actionConnector = connectors.find(field => field.id === actionItem.id); - if (!actionConnector) { - return null; - } - const optionsList = connectors - .filter(field => field.actionTypeId === actionConnector.actionTypeId) - .map(({ name, id }) => ({ - label: name, - key: id, - id, - })); - const actionTypeRegisterd = actionTypeRegistry.get(actionConnector.actionTypeId); - if (actionTypeRegisterd === null || actionItem.group !== selectedTabId) return null; - const ParamsFieldsComponent = actionTypeRegisterd.actionParamsFields; - const actionParamsErrors = - Object.keys(actionErrors).length > 0 ? actionErrors[actionItem.id] : []; - const hasActionParamsErrors = !!Object.keys(actionParamsErrors).find( - errorKey => actionParamsErrors[errorKey].length >= 1 - ); - return ( - - - - - - -
- -
-
-
- - } - extraAction={ - { - const updatedActions = alert.actions.filter( - (item: AlertAction) => item.id !== actionItem.id - ); - setAlertProperty('actions', updatedActions); - }} - /> - } - paddingSize="l" - > - - } - // errorKey="name" - // isShowingErrors={hasErrors} - // errors={errors} - > - { - setActionProperty('id', selectedOptions[0].id, index); - }} - isClearable={false} - /> - - - {ParamsFieldsComponent ? ( - - ) : null} -
- ); - })} - - {!isAddActionPanelOpen ? ( - setIsAddActionPanelOpen(true)} - > - - - ) : null} -
- ); - - let alertTypeArea; - if (alertType) { - alertTypeArea = {alertTypeDetails}; - } else { - alertTypeArea = ( - - -
- -
-
- - - {alertTypeNodes} - -
- ); - } - - const labelForAlertChecked = ( - <> - {' '} - - - ); - - const labelForAlertRenotify = ( - <> - {' '} - - - ); - return ( - +

{ - - - - - } - isInvalid={hasErrors && alert.name !== undefined} - error={errors.name} - > - { - setAlertProperty('name', e.target.value); - }} - onBlur={() => { - if (!alert.name) { - setAlertProperty('name', ''); - } - }} - /> - - - - - { - const newOptions = [...tagsOptions, { label: searchValue }]; - setAlertProperty( - 'tags', - newOptions.map(newOption => newOption.label) - ); - }} - onChange={(selectedOptions: Array<{ label: string }>) => { - setAlertProperty( - 'tags', - selectedOptions.map(selectedOption => selectedOption.label) - ); - }} - onBlur={() => { - if (!alert.tags) { - setAlertProperty('tags', []); - } - }} - /> - - - - - - - - - - { - const interval = - e.target.value !== '' ? parseInt(e.target.value, 10) : null; - setAlertInterval(interval); - setAlertProperty('schedule', { - interval: `${e.target.value}${alertIntervalUnit}`, - }); - }} - /> - - - { - setAlertIntervalUnit(e.target.value); - setAlertProperty('schedule', { - interval: `${alertInterval}${e.target.value}`, - }); - }} - /> - - - - - - - - - { - const throttle = - e.target.value !== '' ? parseInt(e.target.value, 10) : null; - setAlertThrottle(throttle); - setAlertProperty('throttle', `${e.target.value}${alertThrottleUnit}`); - }} - /> - - - { - setAlertThrottleUnit(e.target.value); - setAlertProperty('throttle', `${alertThrottle}${e.target.value}`); - }} - /> - - - - - - - {alertTabs} - - {alertTypeArea} - - {actionsListForGroup} - {isAddActionPanelOpen ? ( - - -
- -
-
- - - {isLoadingActionTypes ? ( - - - - ) : ( - actionTypeNodes - )} - -
- ) : null} -
+
- + {i18n.translate('xpack.triggersActionsUI.sections.alertAdd.cancelButtonLabel', { defaultMessage: 'Cancel', })} @@ -781,7 +141,7 @@ export const AlertAdd = ({ refreshList }: Props) => { { setIsSaving(true); const savedAlert = await onSaveAlert(); setIsSaving(false); - if (savedAlert && savedAlert.error) { - return setServerError(savedAlert.error); + if (savedAlert) { + closeFlyout(); + reloadAlerts(); } - closeFlyout(); - refreshList(); }} > { + let deps: AppDeps | null; + 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, + }; + beforeAll(async () => { + const mockes = coreMock.createSetup(); + const [ + { + chrome, + docLinks, + application: { capabilities }, + }, + ] = await mockes.getStartServices(); + deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + capabilities: { + ...capabilities, + siem: { + 'alerting:show': true, + 'alerting:save': true, + 'alerting:delete': false, + }, + }, + legacy: { + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; + }); + + describe('alert_form create alert', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + alertTypeRegistry.list.mockReturnValue([alertType]); + alertTypeRegistry.has.mockReturnValue(true); + actionTypeRegistry.list.mockReturnValue([actionType]); + actionTypeRegistry.has.mockReturnValue(true); + + const initialAlert = ({ + name: 'test', + params: {}, + consumer: 'alerting', + schedule: { + interval: '1m', + }, + actions: [], + tags: [], + muteAll: false, + enabled: false, + mutedInstanceIds: [], + } as unknown) as Alert; + + await act(async () => { + wrapper = mountWithIntl( + + {}} + errors={{ name: [] }} + serverError={null} + /> + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders alert name', () => { + const alertNameField = wrapper.find('[data-test-subj="alertNameInput"]'); + expect(alertNameField.exists()).toBeTruthy(); + expect(alertNameField.first().prop('value')).toBe('test'); + }); + + it('renders registered selected alert type', () => { + const alertTypeSelectOptions = wrapper.find('[data-test-subj="my-alert-type-SelectOption"]'); + expect(alertTypeSelectOptions.exists()).toBeTruthy(); + }); + + it('renders registered action types', () => { + const alertTypeSelectOptions = wrapper.find( + '[data-test-subj=".server-log-ActionTypeSelectOption"]' + ); + expect(alertTypeSelectOptions.exists()).toBeFalsy(); + }); + }); + + describe('alert_form edit alert', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + alertTypeRegistry.list.mockReturnValue([alertType]); + alertTypeRegistry.get.mockReturnValue(alertType); + alertTypeRegistry.has.mockReturnValue(true); + actionTypeRegistry.list.mockReturnValue([actionType]); + actionTypeRegistry.has.mockReturnValue(true); + + const initialAlert = ({ + name: 'test', + alertTypeId: alertType.id, + params: {}, + consumer: 'alerting', + schedule: { + interval: '1m', + }, + actions: [], + tags: [], + muteAll: false, + enabled: false, + mutedInstanceIds: [], + } as unknown) as Alert; + + await act(async () => { + wrapper = mountWithIntl( + + {}} + errors={{ name: [] }} + serverError={null} + /> + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders alert name', () => { + const alertNameField = wrapper.find('[data-test-subj="alertNameInput"]'); + expect(alertNameField.exists()).toBeTruthy(); + expect(alertNameField.first().prop('value')).toBe('test'); + }); + + it('renders registered selected alert type', () => { + const alertTypeSelectOptions = wrapper.find('[data-test-subj="selectedAlertTypeTitle"]'); + expect(alertTypeSelectOptions.exists()).toBeTruthy(); + }); + + it('renders registered action types', () => { + const actionTypeSelectOptions = wrapper.find( + '[data-test-subj="my-action-type-ActionTypeSelectOption"]' + ); + expect(actionTypeSelectOptions.exists()).toBeTruthy(); + }); + }); + + async function waitForRender(wrapper: ReactWrapper) { + await Promise.resolve(); + await Promise.resolve(); + wrapper.update(); + } +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_form.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_form.tsx new file mode 100644 index 0000000000000..78aca3ec78e66 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_form.tsx @@ -0,0 +1,853 @@ +/* + * 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, + EuiForm, + EuiSpacer, + EuiFieldText, + EuiFlexGrid, + EuiFormRow, + EuiComboBox, + EuiKeyPadMenuItem, + EuiLink, + EuiFieldNumber, + EuiSelect, + EuiIconTip, + EuiAccordion, + EuiButtonIcon, + EuiEmptyPrompt, + EuiButtonEmpty, +} from '@elastic/eui'; +import { useAppDependencies } from '../../app_context'; +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 { getTimeOptions } from '../../lib/get_time_options'; +import { SectionLoading } from '../../components/section_loading'; +import { ConnectorAddModal } from '../action_connector_form/connector_add_modal'; + +export function validateBaseProperties(alertObject: Alert) { + const validationResult = { errors: {} }; + const errors = { + name: new Array(), + interval: new Array(), + alertTypeId: new Array(), + actionConnectors: new Array(), + }; + validationResult.errors = errors; + if (!alertObject.name) { + errors.name.push( + i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredNameText', { + defaultMessage: 'Name is required.', + }) + ); + } + if (!alertObject.schedule.interval) { + errors.interval.push( + i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredIntervalText', { + defaultMessage: 'Check interval is required.', + }) + ); + } + if (!alertObject.alertTypeId) { + errors.alertTypeId.push( + i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredAlertTypeIdText', { + defaultMessage: 'Alert trigger is required.', + }) + ); + } + return validationResult; +} + +interface AlertFormProps { + alert: Alert; + canChangeTrigger?: boolean; // to hide Change trigger button + dispatch: React.Dispatch; + errors: IErrorObject; + serverError: { + body: { message: string; error: string }; + } | null; +} + +interface ActiveActionConnectorState { + actionTypeId: string; + index: number; +} + +export const AlertForm = ({ + alert, + canChangeTrigger = true, + dispatch, + errors, + serverError, +}: AlertFormProps) => { + const { http, toastNotifications, alertTypeRegistry, actionTypeRegistry } = useAppDependencies(); + const [alertTypeModel, setAlertTypeModel] = useState( + alertTypeRegistry.get(alert.alertTypeId) + ); + + const [addModalVisible, setAddModalVisibility] = useState(false); + const [isLoadingActionTypes, setIsLoadingActionTypes] = useState(false); + const [actionTypesIndex, setActionTypesIndex] = useState(undefined); + const [alertTypesIndex, setAlertTypesIndex] = useState(undefined); + const [alertInterval, setAlertInterval] = useState(null); + const [alertIntervalUnit, setAlertIntervalUnit] = useState('m'); + const [alertThrottle, setAlertThrottle] = useState(null); + const [alertThrottleUnit, setAlertThrottleUnit] = useState('m'); + const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState(true); + const [connectors, setConnectors] = useState([]); + const [defaultActionGroup, setDefaultActionGroup] = 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) { + 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(() => { + (async () => { + try { + const alertTypes = await loadAlertTypes({ http }); + // temp hack of API result + alertTypes.push({ + id: 'threshold', + actionGroups: ['Alert', 'Warning', 'If unacknowledged'], + name: 'threshold', + actionVariables: ['ctx.metadata.name', 'ctx.metadata.test'], + }); + const index: AlertTypeIndex = {}; + for (const alertTypeItem of alertTypes) { + index[alertTypeItem.id] = alertTypeItem; + } + setAlertTypesIndex(index); + } catch (e) { + 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 } }); + }; + + const setAlertParams = (key: string, value: any) => { + dispatch({ command: { type: 'setAlertParams' }, payload: { key, value } }); + }; + + const setScheduleProperty = (key: string, value: any) => { + 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) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage', + { + defaultMessage: 'Unable to load connectors', + } + ), + }); + } + } + + 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 AlertParamsExpressionComponent = alertTypeModel + ? alertTypeModel.alertParamsExpression + : null; + + function addActionType(actionTypeModel: ActionTypeModel) { + 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: defaultActionGroup ?? 'Alert', + 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: defaultActionGroup ?? 'Alert', + params: {}, + }); + setActionProperty('id', alert.actions.length, alert.actions.length - 1); + } + } + + const alertTypeNodes = alertTypeRegistry.list().map(function(item, index) { + return ( + { + setAlertProperty('alertTypeId', item.id); + setAlertTypeModel(item); + if ( + alertTypesIndex && + alertTypesIndex[item.id] && + alertTypesIndex[item.id].actionGroups.length > 0 + ) { + setDefaultActionGroup(alertTypesIndex[item.id].actionGroups[0]); + } + }} + > + + + ); + }); + + const actionTypeNodes = actionTypeRegistry.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 === null || actionItem.group !== defaultActionGroup) 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 === null || actionItem.group !== defaultActionGroup) 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 = ( + + + + +
+ +
+
+
+ {canChangeTrigger ? ( + + { + setAlertProperty('alertTypeId', null); + setAlertTypeModel(null); + }} + > + + + + ) : null} +
+ {AlertParamsExpressionComponent ? ( + + ) : null} + + {selectedGroupActions} + {isAddActionPanelOpen ? ( + + +
+ +
+
+ + + {isLoadingActionTypes ? ( + + + + ) : ( + actionTypeNodes + )} + +
+ ) : null} +
+ ); + + const labelForAlertChecked = ( + <> + {' '} + + + ); + + const labelForAlertRenotify = ( + <> + {' '} + + + ); + + return ( + + + + + } + isInvalid={errors.name.length > 0 && alert.name !== undefined} + error={errors.name} + > + 0 && alert.name !== undefined} + compressed + name="name" + data-test-subj="alertNameInput" + value={alert.name || ''} + onChange={e => { + setAlertProperty('name', e.target.value); + }} + onBlur={() => { + if (!alert.name) { + setAlertProperty('name', ''); + } + }} + /> + + + + + { + const newOptions = [...tagsOptions, { label: searchValue }]; + setAlertProperty( + 'tags', + newOptions.map(newOption => newOption.label) + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + setAlertProperty( + 'tags', + selectedOptions.map(selectedOption => selectedOption.label) + ); + }} + onBlur={() => { + if (!alert.tags) { + setAlertProperty('tags', []); + } + }} + /> + + + + + + + + + + { + const interval = e.target.value !== '' ? parseInt(e.target.value, 10) : null; + setAlertInterval(interval); + setScheduleProperty('interval', `${e.target.value}${alertIntervalUnit}`); + }} + /> + + + { + setAlertIntervalUnit(e.target.value); + setScheduleProperty('interval', `${alertInterval}${e.target.value}`); + }} + /> + + + + + + + + + { + const throttle = e.target.value !== '' ? parseInt(e.target.value, 10) : null; + setAlertThrottle(throttle); + setAlertProperty('throttle', `${e.target.value}${alertThrottleUnit}`); + }} + /> + + + { + setAlertThrottleUnit(e.target.value); + setAlertProperty('throttle', `${alertThrottle}${e.target.value}`); + }} + /> + + + + + + + {alertTypeModel ? ( + {alertTypeDetails} + ) : ( + + +
+ +
+
+ + + {alertTypeNodes} + +
+ )} + {actionTypesIndex && activeActionItem ? ( + { + connectors.push(savedAction); + setActionProperty('id', savedAction.id, activeActionItem.index); + }} + /> + ) : null} +
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.test.ts new file mode 100644 index 0000000000000..bd320de144024 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.test.ts @@ -0,0 +1,163 @@ +/* + * 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 { alertReducer } from './alert_reducer'; +import { Alert } from '../../../types'; + +describe('alert reducer', () => { + let initialAlert: Alert; + beforeAll(() => { + initialAlert = ({ + params: {}, + consumer: 'alerting', + alertTypeId: null, + schedule: { + interval: '1m', + }, + actions: [], + tags: [], + } as unknown) as Alert; + }); + + // setAlert + test('if modified alert was reset to initial', () => { + const alert = alertReducer( + { alert: initialAlert }, + { + command: { type: 'setProperty' }, + payload: { + key: 'name', + value: 'new name', + }, + } + ); + expect(alert.alert.name).toBe('new name'); + + const updatedAlert = alertReducer( + { alert: initialAlert }, + { + command: { type: 'setAlert' }, + payload: { + key: 'alert', + value: initialAlert, + }, + } + ); + expect(updatedAlert.alert.name).toBeUndefined(); + }); + + test('if property name was changed', () => { + const updatedAlert = alertReducer( + { alert: initialAlert }, + { + command: { type: 'setProperty' }, + payload: { + key: 'name', + value: 'new name', + }, + } + ); + expect(updatedAlert.alert.name).toBe('new name'); + }); + + test('if initial schedule property was updated', () => { + const updatedAlert = alertReducer( + { alert: initialAlert }, + { + command: { type: 'setScheduleProperty' }, + payload: { + key: 'interval', + value: '10s', + }, + } + ); + expect(updatedAlert.alert.schedule.interval).toBe('10s'); + }); + + test('if alert params property was added and updated', () => { + const updatedAlert = alertReducer( + { alert: initialAlert }, + { + command: { type: 'setAlertParams' }, + payload: { + key: 'testParam', + value: 'new test params property', + }, + } + ); + expect(updatedAlert.alert.params.testParam).toBe('new test params property'); + + const updatedAlertParamsProperty = alertReducer( + { alert: updatedAlert.alert }, + { + command: { type: 'setAlertParams' }, + payload: { + key: 'testParam', + value: 'test params property updated', + }, + } + ); + expect(updatedAlertParamsProperty.alert.params.testParam).toBe('test params property updated'); + }); + + test('if alert action params property was added and updated', () => { + initialAlert.actions.push({ + id: '', + actionTypeId: 'testId', + group: 'Alert', + params: {}, + }); + const updatedAlert = alertReducer( + { alert: initialAlert }, + { + command: { type: 'setAlertActionParams' }, + payload: { + key: 'testActionParam', + value: 'new test action params property', + index: 0, + }, + } + ); + expect(updatedAlert.alert.actions[0].params.testActionParam).toBe( + 'new test action params property' + ); + + const updatedAlertActionParamsProperty = alertReducer( + { alert: updatedAlert.alert }, + { + command: { type: 'setAlertActionParams' }, + payload: { + key: 'testActionParam', + value: 'test action params property updated', + index: 0, + }, + } + ); + expect(updatedAlertActionParamsProperty.alert.actions[0].params.testActionParam).toBe( + 'test action params property updated' + ); + }); + + test('if alert action property was updated', () => { + initialAlert.actions.push({ + id: '', + actionTypeId: 'testId', + group: 'Alert', + params: {}, + }); + const updatedAlert = alertReducer( + { alert: initialAlert }, + { + command: { type: 'setAlertActionProperty' }, + payload: { + key: 'group', + value: 'Warning', + index: 0, + }, + } + ); + expect(updatedAlert.alert.actions[0].group).toBe('Warning'); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.ts index 9c2260f0178be..2e56f4b026b4a 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.ts +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.ts @@ -9,6 +9,7 @@ interface CommandType { type: | 'setAlert' | 'setProperty' + | 'setScheduleProperty' | 'setAlertParams' | 'setAlertActionParams' | 'setAlertActionProperty'; @@ -57,6 +58,23 @@ export const alertReducer = (state: any, action: AlertReducerAction) => { }; } } + case 'setScheduleProperty': { + const { key, value } = payload; + if (isEqual(alert.schedule[key], value)) { + return state; + } else { + return { + ...state, + alert: { + ...alert, + schedule: { + ...alert.schedule, + [key]: value, + }, + }, + }; + } + } case 'setAlertParams': { const { key, value } = payload; if (isEqual(alert.params[key], value)) { diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details.test.tsx index 228bceb87cad7..683aca742ac87 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details.test.tsx @@ -43,6 +43,8 @@ describe('alert_details', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; expect( @@ -61,6 +63,8 @@ describe('alert_details', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; expect( @@ -86,6 +90,8 @@ describe('alert_details', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; const actionTypes: ActionType[] = [ @@ -133,6 +139,8 @@ describe('alert_details', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; const actionTypes: ActionType[] = [ { @@ -181,6 +189,8 @@ describe('alert_details', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; expect( @@ -203,6 +213,8 @@ describe('alert_details', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; expect( @@ -225,6 +237,8 @@ describe('alert_details', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; expect( @@ -252,6 +266,8 @@ describe('enable button', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; const enableButton = shallow( @@ -275,6 +291,8 @@ describe('enable button', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; const enableButton = shallow( @@ -298,6 +316,8 @@ describe('enable button', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; const disableAlert = jest.fn(); @@ -330,6 +350,8 @@ describe('enable button', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; const enableAlert = jest.fn(); @@ -365,6 +387,8 @@ describe('mute button', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; const enableButton = shallow( @@ -389,6 +413,8 @@ describe('mute button', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; const enableButton = shallow( @@ -413,6 +439,8 @@ describe('mute button', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; const muteAlert = jest.fn(); @@ -446,6 +474,8 @@ describe('mute button', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; const unmuteAlert = jest.fn(); @@ -479,6 +509,8 @@ describe('mute button', () => { const alertType = { id: '.noop', name: 'No Op', + actionGroups: ['default'], + actionVariables: [], }; const enableButton = shallow( diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx index f410fff44172f..60435552a5759 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -46,7 +46,6 @@ actionTypeRegistry.list.mockReturnValue([]); describe('alerts_list component empty', () => { let wrapper: ReactWrapper; - beforeEach(async () => { const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); const { loadActionTypes, loadAllActions } = jest.requireMock( @@ -124,14 +123,13 @@ describe('alerts_list component empty', () => { }); it('renders empty list', () => { - expect(wrapper.find('[data-test-subj="createAlertButton"]').find('EuiButton')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="createFirstAlertEmptyPrompt"]').exists()).toBeTruthy(); }); - test('if click create button should render AlertAdd', () => { - wrapper - .find('[data-test-subj="createAlertButton"]') - .first() - .simulate('click'); + it('renders Create alert button', () => { + expect( + wrapper.find('[data-test-subj="createFirstAlertButton"]').find('EuiButton') + ).toHaveLength(1); expect(wrapper.find('AlertAdd')).toHaveLength(1); }); }); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx index 32de924f63e80..643816e728d1a 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { Fragment, useEffect, useState } from 'react'; +import React, { useEffect, useState, Fragment } from 'react'; import { EuiBasicTable, EuiButton, @@ -15,6 +15,7 @@ import { EuiFlexItem, EuiIcon, EuiSpacer, + EuiEmptyPrompt, EuiLink, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; @@ -245,100 +246,149 @@ export const AlertsList: React.FunctionComponent = () => { ); } - return ( -
- - - - - {selectedIds.length > 0 && canDelete && ( - - - setIsPerformingAction(true)} - onActionPerformed={() => { - loadAlertsData(); - setIsPerformingAction(false); - }} - /> - - - )} - - } - onChange={e => setInputText(e.target.value)} - onKeyUp={e => { - if (e.keyCode === ENTER_KEY) { - setSearchText(inputText); - } - }} - placeholder={i18n.translate( - 'xpack.triggersActionsUI.sections.alertsList.searchPlaceholderTitle', - { defaultMessage: 'Search...' } + const emptyPrompt = ( + + +

+ } + body={ +

+ +

+ } + actions={ + setAlertFlyoutVisibility(true)} + > + + + } + /> + ); + + const table = ( + + + {selectedIds.length > 0 && canDelete && ( + + + setIsPerformingAction(true)} + onActionPerformed={() => { + loadAlertsData(); + setIsPerformingAction(false); + }} /> - - - - {toolsRight.map((tool, index: number) => ( - - {tool} - - ))} - - + + + )} + + } + onChange={e => setInputText(e.target.value)} + onKeyUp={e => { + if (e.keyCode === ENTER_KEY) { + setSearchText(inputText); + } + }} + placeholder={i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.searchPlaceholderTitle', + { defaultMessage: 'Search' } + )} + /> + + + + {toolsRight.map((tool, index: number) => ( + + {tool} + + ))} + + - {/* Large to remain consistent with ActionsList table spacing */} - + {/* Large to remain consistent with ActionsList table spacing */} + - ({ - 'data-test-subj': 'alert-row', - })} - cellProps={() => ({ - 'data-test-subj': 'cell', - })} - data-test-subj="alertsList" - pagination={{ - pageIndex: page.index, - pageSize: page.size, - /* Don't display alert count until we have the alert types initialized */ - totalItemCount: - alertTypesState.isInitialized === false ? 0 : alertsState.totalItemCount, - }} - selection={ - canDelete - ? { - onSelectionChange(updatedSelectedItemsList: AlertTableItem[]) { - setSelectedIds(updatedSelectedItemsList.map(item => item.id)); - }, - } - : undefined - } - onChange={({ page: changedPage }: { page: Pagination }) => { - setPage(changedPage); - }} - /> - - - + ({ + 'data-test-subj': 'alert-row', + })} + cellProps={() => ({ + 'data-test-subj': 'cell', + })} + data-test-subj="alertsList" + pagination={{ + pageIndex: page.index, + pageSize: page.size, + /* Don't display alert count until we have the alert types initialized */ + totalItemCount: alertTypesState.isInitialized === false ? 0 : alertsState.totalItemCount, + }} + selection={ + canDelete + ? { + onSelectionChange(updatedSelectedItemsList: AlertTableItem[]) { + setSelectedIds(updatedSelectedItemsList.map(item => item.id)); + }, + } + : undefined + } + onChange={({ page: changedPage }: { page: Pagination }) => { + setPage(changedPage); + }} + /> +
+ ); + + return ( +
+ + {convertAlertsToTableItems(alertsState.data, alertTypesState.data).length !== 0 && table} + {convertAlertsToTableItems(alertsState.data, alertTypesState.data).length === 0 && + emptyPrompt} + + +
); }; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts index 7fb7d0bf48e4d..1bed658940a6e 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts @@ -3,10 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { ActionType } from '../../../../../plugins/actions/common'; import { TypeRegistry } from './application/type_registry'; import { SanitizedAlert as Alert, AlertAction } from '../../../alerting/common'; -import { ActionType } from '../../../../../plugins/actions/common'; - export { Alert, AlertAction }; export { ActionType }; @@ -15,20 +14,20 @@ export type AlertTypeIndex = Record; export type ActionTypeRegistryContract = PublicMethodsOf>; export type AlertTypeRegistryContract = PublicMethodsOf>; -export interface ActionConnectorFieldsProps { - action: ActionConnector; +export interface ActionConnectorFieldsProps { + action: TActionCOnnector; editActionConfig: (property: string, value: any) => void; editActionSecrets: (property: string, value: any) => void; errors: { [key: string]: string[] }; - hasErrors?: boolean; } -export interface ActionParamsProps { - action: any; +export interface ActionParamsProps { + actionParams: TParams; index: number; editAction: (property: string, value: any, index: number) => void; errors: { [key: string]: string[] }; - hasErrors?: boolean; + messageVariables?: string[]; + defaultMessage?: string; } export interface Pagination { @@ -40,10 +39,11 @@ export interface ActionTypeModel { id: string; iconClass: string; selectMessage: string; - validateConnector: (action: ActionConnector) => ValidationResult; + actionTypeTitle?: string; + validateConnector: (connector: any) => ValidationResult; validateParams: (actionParams: any) => ValidationResult; - actionConnectorFields: React.FunctionComponent | null; - actionParamsFields: React.FunctionComponent | null; + actionConnectorFields: React.FunctionComponent | null; + actionParamsFields: any; } export interface ValidationResult { @@ -68,6 +68,8 @@ export interface ActionConnectorTableItem extends ActionConnector { export interface AlertType { id: string; name: string; + actionGroups: string[]; + actionVariables: string[]; } export type AlertWithoutId = Omit; @@ -83,6 +85,7 @@ export interface AlertTypeModel { iconClass: string; validate: (alert: Alert) => ValidationResult; alertParamsExpression: React.FunctionComponent; + defaultActionMessage?: string; } export interface IErrorObject { diff --git a/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss b/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss index 6faad81630b2b..810176a57f9e3 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss +++ b/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss @@ -3,3 +3,6 @@ // Styling within the app @import '../np_ready/public/application/sections/actions_connectors_list/components/index'; + +@import '../np_ready/public/application/sections/action_connector_form/index'; + diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 0276ca8109732..84081309c18d9 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -16,7 +16,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); const supertest = getService('supertest'); - const retry = getService('retry'); + const find = getService('find'); async function createAlert() { const { body: createdAlert } = await supertest @@ -43,9 +43,52 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('alertsTab'); }); + it('should create an alert', async () => { + const alertName = generateUniqueKey(); + + await pageObjects.triggersActionsUI.clickCreateAlertButton(); + + const nameInput = await testSubjects.find('alertNameInput'); + await nameInput.click(); + await nameInput.clearValue(); + await nameInput.type(alertName); + + await testSubjects.click('threshold-SelectOption'); + + await testSubjects.click('.slack-ActionTypeSelectOption'); + await testSubjects.click('createActionConnectorButton'); + const connectorNameInput = await testSubjects.find('nameInput'); + await connectorNameInput.click(); + await connectorNameInput.clearValue(); + const connectorName = generateUniqueKey(); + await connectorNameInput.type(connectorName); + + const slackWebhookUrlInput = await testSubjects.find('slackWebhookUrlInput'); + await slackWebhookUrlInput.click(); + await slackWebhookUrlInput.clearValue(); + await slackWebhookUrlInput.type('https://test'); + + await find.clickByCssSelector('[data-test-subj="saveActionButtonModal"]:not(disabled)'); + + const loggingMessageInput = await testSubjects.find('slackMessageTextArea'); + await loggingMessageInput.click(); + await loggingMessageInput.clearValue(); + await loggingMessageInput.type('test message'); + + await testSubjects.click('slackAddVariableButton'); + const variableMenuButton = await testSubjects.find('variableMenuButton-0'); + await variableMenuButton.click(); + + await testSubjects.click('selectIndexExpression'); + + await find.clickByCssSelector('[data-test-subj="cancelSaveAlertButton"]'); + + // TODO: implement saving to the server, when threshold API will be ready + }); + it('should search for alert', async () => { const createdAlert = await createAlert(); - + await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); @@ -61,7 +104,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should search for tags', async () => { const createdAlert = await createAlert(); - + await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(`${createdAlert.name} foo`); const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); @@ -77,7 +120,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should disable single alert', async () => { const createdAlert = await createAlert(); - + await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await testSubjects.click('collapsedItemActions'); @@ -95,7 +138,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should re-enable single alert', async () => { const createdAlert = await createAlert(); - + await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await testSubjects.click('collapsedItemActions'); @@ -119,7 +162,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should mute single alert', async () => { const createdAlert = await createAlert(); - + await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await testSubjects.click('collapsedItemActions'); @@ -137,7 +180,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should unmute single alert', async () => { const createdAlert = await createAlert(); - + await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await testSubjects.click('collapsedItemActions'); @@ -161,24 +204,21 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should delete single alert', async () => { const createdAlert = await createAlert(); - + await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await testSubjects.click('collapsedItemActions'); await testSubjects.click('deleteAlert'); - - await retry.try(async () => { - await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); - - const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); - expect(searchResults.length).to.eql(0); - }); + const emptyPrompt = await find.byCssSelector( + '[data-test-subj="createFirstAlertEmptyPrompt"]' + ); + expect(await emptyPrompt.elementHasClass('euiEmptyPrompt')).to.be(true); }); it('should mute all selection', async () => { const createdAlert = await createAlert(); - + await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await testSubjects.click(`checkboxSelectRow-${createdAlert.id}`); @@ -201,7 +241,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should unmute all selection', async () => { const createdAlert = await createAlert(); - + await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await testSubjects.click(`checkboxSelectRow-${createdAlert.id}`); @@ -226,7 +266,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should disable all selection', async () => { const createdAlert = await createAlert(); - + await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await testSubjects.click(`checkboxSelectRow-${createdAlert.id}`); @@ -249,7 +289,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should enable all selection', async () => { const createdAlert = await createAlert(); - + await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await testSubjects.click(`checkboxSelectRow-${createdAlert.id}`); @@ -274,7 +314,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should delete all selection', async () => { const createdAlert = await createAlert(); - + await pageObjects.common.navigateToApp('triggersActions'); await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); await testSubjects.click(`checkboxSelectRow-${createdAlert.id}`); @@ -283,12 +323,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('deleteAll'); - await retry.try(async () => { - await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); - - const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); - expect(searchResults.length).to.eql(0); - }); + const emptyPrompt = await find.byCssSelector( + '[data-test-subj="createFirstAlertEmptyPrompt"]' + ); + expect(await emptyPrompt.elementHasClass('euiEmptyPrompt')).to.be(true); }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts index d037155a29e12..9d656b08a3abd 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts @@ -35,7 +35,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await nameInput.clearValue(); await nameInput.type(connectorName); - await find.clickByCssSelector('[data-test-subj="saveActionButton"]:not(disabled)'); + await find.clickByCssSelector('[data-test-subj="saveNewActionButton"]:not(disabled)'); const toastTitle = await pageObjects.common.closeToast(); expect(toastTitle).to.eql(`Created '${connectorName}'`); @@ -65,7 +65,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await nameInput.clearValue(); await nameInput.type(connectorName); - await find.clickByCssSelector('[data-test-subj="saveActionButton"]:not(disabled)'); + await find.clickByCssSelector('[data-test-subj="saveNewActionButton"]:not(disabled)'); await pageObjects.common.closeToast(); @@ -81,7 +81,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await nameInputToUpdate.clearValue(); await nameInputToUpdate.type(updatedConnectorName); - await find.clickByCssSelector('[data-test-subj="saveActionButton"]:not(disabled)'); + await find.clickByCssSelector('[data-test-subj="saveEditedActionButton"]:not(disabled)'); const toastTitle = await pageObjects.common.closeToast(); expect(toastTitle).to.eql(`Updated '${updatedConnectorName}'`); @@ -109,7 +109,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await nameInput.clearValue(); await nameInput.type(connectorName); - await find.clickByCssSelector('[data-test-subj="saveActionButton"]:not(disabled)'); + await find.clickByCssSelector('[data-test-subj="saveNewActionButton"]:not(disabled)'); await pageObjects.common.closeToast(); } const connectorName = generateUniqueKey(); @@ -147,7 +147,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await nameInput.clearValue(); await nameInput.type(connectorName); - await find.clickByCssSelector('[data-test-subj="saveActionButton"]:not(disabled)'); + await find.clickByCssSelector('[data-test-subj="saveNewActionButton"]:not(disabled)'); await pageObjects.common.closeToast(); } diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts index ae66ac0ddddfb..91c7fe1f97d12 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -18,11 +18,21 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) async getSectionHeadingText() { return await testSubjects.getVisibleText('appTitle'); }, + async clickCreateFirstConnectorButton() { + const createBtn = await find.byCssSelector('[data-test-subj="createFirstActionButton"]'); + const createBtnIsVisible = await createBtn.isDisplayed(); + if (createBtnIsVisible) { + await createBtn.click(); + } + }, async clickCreateConnectorButton() { - const createBtn = await find.byCssSelector( - '[data-test-subj="createActionButton"],[data-test-subj="createFirstActionButton"]' - ); - await createBtn.click(); + const createBtn = await find.byCssSelector('[data-test-subj="createActionButton"]'); + const createBtnIsVisible = await createBtn.isDisplayed(); + if (createBtnIsVisible) { + await createBtn.click(); + } else { + await this.clickCreateFirstConnectorButton(); + } }, async searchConnectors(searchText: string) { const searchBox = await find.byCssSelector('[data-test-subj="actionsList"] .euiFieldSearch'); @@ -109,5 +119,11 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) expect(valueAfter).not.to.eql(valueBefore); }); }, + async clickCreateAlertButton() { + const createBtn = await find.byCssSelector( + '[data-test-subj="createAlertButton"],[data-test-subj="createFirstAlertButton"]' + ); + await createBtn.click(); + }, }; }