diff --git a/docs/management/connectors/action-types/servicenow-sir.asciidoc b/docs/management/connectors/action-types/servicenow-sir.asciidoc index 4556746284d5b..2fa49fe552c2e 100644 --- a/docs/management/connectors/action-types/servicenow-sir.asciidoc +++ b/docs/management/connectors/action-types/servicenow-sir.asciidoc @@ -72,13 +72,11 @@ image::management/connectors/images/servicenow-sir-params-test.png[ServiceNow Se ServiceNow SecOps actions have the following configuration properties. Short description:: A short description for the incident, used for searching the contents of the knowledge base. -Source Ips:: A list of source IPs related to the incident. The IPs will be added as observables to the security incident. -Destination Ips:: A list of destination IPs related to the incident. The IPs will be added as observables to the security incident. -Malware URLs:: A list of malware URLs related to the incident. The URLs will be added as observables to the security incident. -Malware Hashes:: A list of malware hashes related to the incident. The hashes will be added as observables to the security incident. Priority:: The priority of the incident. Category:: The category of the incident. Subcategory:: The subcategory of the incident. +Correlation ID:: All actions sharing this ID will be associated with the same ServiceNow security incident. If an incident exists in ServiceNow with the same correlation ID the security incident will be updated. Default value: `:`. +Correlation Display:: A descriptive label of the alert for correlation purposes in ServiceNow. Description:: The details about the incident. Additional comments:: Additional information for the client, such as how to troubleshoot the issue. diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index cf5244a9e3f9e..f7c3187f3f024 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -76,6 +76,8 @@ Severity:: The severity of the incident. Impact:: The effect an incident has on business. Can be measured by the number of affected users or by how critical it is to the business in question. Category:: The category of the incident. Subcategory:: The category of the incident. +Correlation ID:: All actions sharing this ID will be associated with the same ServiceNow incident. If an incident exists in ServiceNow with the same correlation ID the incident will be updated. Default value: `:`. +Correlation Display:: A descriptive label of the alert for correlation purposes in ServiceNow. Short description:: A short description for the incident, used for searching the contents of the knowledge base. Description:: The details about the incident. Additional comments:: Additional information for the client, such as how to troubleshoot the issue. diff --git a/docs/management/connectors/images/servicenow-sir-params-test.png b/docs/management/connectors/images/servicenow-sir-params-test.png index 80103a4272bfa..a2bf8761a8824 100644 Binary files a/docs/management/connectors/images/servicenow-sir-params-test.png and b/docs/management/connectors/images/servicenow-sir-params-test.png differ diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index ac0aac3466f5f..a07e12eae8d71 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -327,6 +327,7 @@ export class DocLinksService { preconfiguredConnectors: `${KIBANA_DOCS}pre-configured-connectors.html`, preconfiguredAlertHistoryConnector: `${KIBANA_DOCS}index-action-type.html#preconfigured-connector-alert-history`, serviceNowAction: `${KIBANA_DOCS}servicenow-action-type.html#configuring-servicenow`, + serviceNowSIRAction: `${KIBANA_DOCS}servicenow-sir-action-type.html`, setupPrerequisites: `${KIBANA_DOCS}alerting-setup.html#alerting-prerequisites`, slackAction: `${KIBANA_DOCS}slack-action-type.html#configuring-slack`, teamsAction: `${KIBANA_DOCS}teams-action-type.html#configuring-teams`, diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx index 38923784d862c..4e0f1689bd4d1 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors.test.tsx @@ -135,12 +135,8 @@ describe('Connectors', () => { } ); - expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); - expect( - screen.getByText( - 'This connector type is deprecated. Create a new connector or update this connector' - ) - ).toBeInTheDocument(); + expect(screen.getByText('This connector type is deprecated')).toBeInTheDocument(); + expect(screen.getByText('Update this connector, or create a new one.')).toBeInTheDocument(); }); test('it does not shows the deprecated callout when the connector is none', async () => { diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index 34422392b7efa..6f05f9f940d25 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -190,17 +190,17 @@ describe('ConnectorsDropdown', () => { > My Connector + (deprecated) - @@ -293,7 +293,9 @@ describe('ConnectorsDropdown', () => { wrapper: ({ children }) => {children}, }); - const tooltips = screen.getAllByLabelText('Deprecated connector'); + const tooltips = screen.getAllByLabelText( + 'This connector is deprecated. Update it, or create a new one.' + ); expect(tooltips[0]).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index f21b3ab3d544f..c5fe9c7470745 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -14,6 +14,7 @@ import { ActionConnector } from '../../containers/configure/types'; import * as i18n from './translations'; import { useKibana } from '../../common/lib/kibana'; import { getConnectorIcon, isLegacyConnector } from '../utils'; +import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; export interface Props { connectors: ActionConnector[]; @@ -57,6 +58,11 @@ const addNewConnector = { 'data-test-subj': 'dropdown-connector-add-connector', }; +const StyledEuiIconTip = euiStyled(EuiIconTip)` + margin-left: ${({ theme }) => theme.eui.euiSizeS} + margin-bottom: 0 !important; +`; + const ConnectorsDropdownComponent: React.FC = ({ connectors, disabled, @@ -87,16 +93,18 @@ const ConnectorsDropdownComponent: React.FC = ({ /> - {connector.name} + + {connector.name} + {isLegacyConnector(connector) && ` (${i18n.DEPRECATED_TOOLTIP_TEXT})`} + {isLegacyConnector(connector) && ( - diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index 4a775c78d4ab8..26b45a8c3a250 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -163,16 +163,16 @@ export const UPDATE_SELECTED_CONNECTOR = (connectorName: string): string => defaultMessage: 'Update { connectorName }', }); -export const DEPRECATED_TOOLTIP_TITLE = i18n.translate( - 'xpack.cases.configureCases.deprecatedTooltipTitle', +export const DEPRECATED_TOOLTIP_TEXT = i18n.translate( + 'xpack.cases.configureCases.deprecatedTooltipText', { - defaultMessage: 'Deprecated connector', + defaultMessage: 'deprecated', } ); export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate( 'xpack.cases.configureCases.deprecatedTooltipContent', { - defaultMessage: 'Please update your connector', + defaultMessage: 'This connector is deprecated. Update it, or create a new one.', } ); diff --git a/x-pack/plugins/cases/public/components/connectors/card.tsx b/x-pack/plugins/cases/public/components/connectors/card.tsx index 86cd90dafb376..ec4b52c54f707 100644 --- a/x-pack/plugins/cases/public/components/connectors/card.tsx +++ b/x-pack/plugins/cases/public/components/connectors/card.tsx @@ -6,7 +6,7 @@ */ import React, { memo, useMemo } from 'react'; -import { EuiCard, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; import styled from 'styled-components'; import { ConnectorTypes } from '../../../common'; @@ -59,16 +59,20 @@ const ConnectorCardDisplay: React.FC = ({ <> {isLoading && } {!isLoading && ( - + + + + + {icon} + )} ); diff --git a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx index 6b1475e3c4bd0..367609df3c887 100644 --- a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx @@ -12,12 +12,8 @@ import { DeprecatedCallout } from './deprecated_callout'; describe('DeprecatedCallout', () => { test('it renders correctly', () => { render(); - expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); - expect( - screen.getByText( - 'This connector type is deprecated. Create a new connector or update this connector' - ) - ).toBeInTheDocument(); + expect(screen.getByText('This connector type is deprecated')).toBeInTheDocument(); + expect(screen.getByText('Update this connector, or create a new one.')).toBeInTheDocument(); expect(screen.getByTestId('legacy-connector-warning-callout')).toHaveClass( 'euiCallOut euiCallOut--warning' ); diff --git a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx index 937f8406e218a..9337f2843506b 100644 --- a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx +++ b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx @@ -12,15 +12,14 @@ import { i18n } from '@kbn/i18n'; const LEGACY_CONNECTOR_WARNING_TITLE = i18n.translate( 'xpack.cases.connectors.serviceNow.legacyConnectorWarningTitle', { - defaultMessage: 'Deprecated connector type', + defaultMessage: 'This connector type is deprecated', } ); const LEGACY_CONNECTOR_WARNING_DESC = i18n.translate( 'xpack.cases.connectors.serviceNow.legacyConnectorWarningDesc', { - defaultMessage: - 'This connector type is deprecated. Create a new connector or update this connector', + defaultMessage: 'Update this connector, or create a new one.', } ); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index 096e450c736c1..e24b25065a1c8 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -157,7 +157,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< {showConnectorWarning && ( - + )} diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index a7b8aa7b27df5..d502b7382664b 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -173,7 +173,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< {showConnectorWarning && ( - + )} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0445d9de0634e..d95acd2e7d2bc 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -24915,11 +24915,8 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.categoryTitle": "カテゴリー", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.commentsTextAreaFieldLabel": "追加のコメント", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.descriptionTextAreaFieldLabel": "説明", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle": "デスティネーション IP", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.impactSelectFieldLabel": "インパクト", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.invalidApiUrlTextField": "URL が無効です。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle": "マルウェアハッシュ", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle": "マルウェアURL", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.passwordTextFieldLabel": "パスワード", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.prioritySelectFieldLabel": "優先度", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel": "ユーザー名とパスワードは暗号化されます。これらのフィールドの値を再入力してください。", @@ -24929,7 +24926,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredUsernameTextField": "ユーザー名が必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requireHttpsApiUrlTextField": "URL は https:// から始める必要があります。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectFieldLabel": "深刻度", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle": "ソース IP", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.subcategoryTitle": "サブカテゴリー", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.title": "インシデント", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.titleFieldLabel": "短い説明(必須)", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 210392d11514e..3d11a22b24e1b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25342,11 +25342,8 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.categoryTitle": "类别", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.commentsTextAreaFieldLabel": "其他注释", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.descriptionTextAreaFieldLabel": "描述", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle": "目标 IP", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.impactSelectFieldLabel": "影响", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.invalidApiUrlTextField": "URL 无效。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle": "恶意软件哈希", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle": "恶意软件 URL", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.passwordTextFieldLabel": "密码", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.prioritySelectFieldLabel": "优先级", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel": "用户名和密码已加密。请为这些字段重新输入值。", @@ -25356,7 +25353,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredUsernameTextField": "“用户名”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requireHttpsApiUrlTextField": "URL 必须以 https:// 开头。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectFieldLabel": "严重性", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle": "源 IP", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.subcategoryTitle": "子类别", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.title": "事件", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.titleFieldLabel": "简短描述(必填)", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx index 561dae95fe1b7..2faa5a9f4a5e0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx @@ -35,7 +35,7 @@ const ApplicationRequiredCalloutComponent: React.FC = ({ message }) => { ['action']; @@ -41,24 +30,6 @@ const CredentialsComponent: React.FC = ({ editActionSecrets, editActionConfig, }) => { - const { docLinks } = useKibana().services; - const { apiUrl } = action.config; - const { username, password } = action.secrets; - - const isApiUrlInvalid = isFieldInvalid(apiUrl, errors.apiUrl); - const isUsernameInvalid = isFieldInvalid(username, errors.username); - const isPasswordInvalid = isFieldInvalid(password, errors.password); - - const handleOnChangeActionConfig = useCallback( - (key: string, value: string) => editActionConfig(key, value), - [editActionConfig] - ); - - const handleOnChangeSecretConfig = useCallback( - (key: string, value: string) => editActionSecrets(key, value), - [editActionSecrets] - ); - return ( <> @@ -66,45 +37,13 @@ const CredentialsComponent: React.FC = ({

{i18n.SN_INSTANCE_LABEL}

-

- - {i18n.SETUP_DEV_INSTANCE} - - ), - }} - /> -

- - - - handleOnChangeActionConfig('apiUrl', evt.target.value)} - onBlur={() => { - if (!apiUrl) { - editActionConfig('apiUrl', ''); - } - }} - disabled={isLoading} - /> - +
@@ -115,75 +54,15 @@ const CredentialsComponent: React.FC = ({ - - - - - {getEncryptedFieldNotifyLabel( - !action.id, - 2, - action.isMissingSecrets ?? false, - i18n.REENTER_VALUES_LABEL - )} - - - - - - - - handleOnChangeSecretConfig('username', evt.target.value)} - onBlur={() => { - if (!username) { - editActionSecrets('username', ''); - } - }} - disabled={isLoading} - /> - - - - - - - - handleOnChangeSecretConfig('password', evt.target.value)} - onBlur={() => { - if (!password) { - editActionSecrets('password', ''); - } - }} - disabled={isLoading} - /> - - - + + + ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_api_url.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_api_url.tsx new file mode 100644 index 0000000000000..5ddef8bab6700 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_api_url.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormRow, EuiLink, EuiFieldText, EuiSpacer } from '@elastic/eui'; +import { useKibana } from '../../../../common/lib/kibana'; +import type { ActionConnectorFieldsProps } from '../../../../types'; +import * as i18n from './translations'; +import type { ServiceNowActionConnector } from './types'; +import { isFieldInvalid } from './helpers'; + +interface Props { + action: ActionConnectorFieldsProps['action']; + errors: ActionConnectorFieldsProps['errors']; + readOnly: boolean; + isLoading: boolean; + editActionConfig: ActionConnectorFieldsProps['editActionConfig']; +} + +const CredentialsApiUrlComponent: React.FC = ({ + action, + errors, + isLoading, + readOnly, + editActionConfig, +}) => { + const { docLinks } = useKibana().services; + const { apiUrl } = action.config; + + const isApiUrlInvalid = isFieldInvalid(apiUrl, errors.apiUrl); + + const onChangeApiUrlEvent = useCallback( + (event?: React.ChangeEvent) => + editActionConfig('apiUrl', event?.target.value ?? ''), + [editActionConfig] + ); + + return ( + <> + +

+ + {i18n.SETUP_DEV_INSTANCE} + + ), + }} + /> +

+
+ + + { + if (!apiUrl) { + onChangeApiUrlEvent(); + } + }} + disabled={isLoading} + /> + + + ); +}; + +export const CredentialsApiUrl = memo(CredentialsApiUrlComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_auth.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_auth.tsx new file mode 100644 index 0000000000000..c9fccc9faec99 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_auth.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiFormRow, EuiFieldText, EuiFieldPassword } from '@elastic/eui'; +import type { ActionConnectorFieldsProps } from '../../../../types'; +import * as i18n from './translations'; +import type { ServiceNowActionConnector } from './types'; +import { isFieldInvalid } from './helpers'; +import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; + +interface Props { + action: ActionConnectorFieldsProps['action']; + errors: ActionConnectorFieldsProps['errors']; + readOnly: boolean; + isLoading: boolean; + editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; +} + +const NUMBER_OF_FIELDS = 2; + +const CredentialsAuthComponent: React.FC = ({ + action, + errors, + isLoading, + readOnly, + editActionSecrets, +}) => { + const { username, password } = action.secrets; + + const isUsernameInvalid = isFieldInvalid(username, errors.username); + const isPasswordInvalid = isFieldInvalid(password, errors.password); + + const onChangeUsernameEvent = useCallback( + (event?: React.ChangeEvent) => + editActionSecrets('username', event?.target.value ?? ''), + [editActionSecrets] + ); + + const onChangePasswordEvent = useCallback( + (event?: React.ChangeEvent) => + editActionSecrets('password', event?.target.value ?? ''), + [editActionSecrets] + ); + + return ( + <> + + {getEncryptedFieldNotifyLabel( + !action.id, + NUMBER_OF_FIELDS, + action.isMissingSecrets ?? false, + i18n.REENTER_VALUES_LABEL + )} + + + { + if (!username) { + onChangeUsernameEvent(); + } + }} + disabled={isLoading} + /> + + + { + if (!password) { + onChangePasswordEvent(); + } + }} + disabled={isLoading} + /> + + + ); +}; + +export const CredentialsAuth = memo(CredentialsAuthComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx index 767b38ebcf6ad..0c125f3851636 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx @@ -19,7 +19,7 @@ describe('DeprecatedCallout', () => { wrapper: ({ children }) => {children}, }); - expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); + expect(screen.getByText('This connector type is deprecated')).toBeInTheDocument(); }); test('it calls onMigrate when pressing the button', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx index 101d1572a67ad..faeeaa1bbbffe 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { EuiSpacer, EuiCallOut, EuiButtonEmpty } from '@elastic/eui'; +import { EuiSpacer, EuiCallOut, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,23 +26,33 @@ const DeprecatedCalloutComponent: React.FC = ({ onMigrate }) => { title={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutTitle', { - defaultMessage: 'Deprecated connector type', + defaultMessage: 'This connector type is deprecated', } )} > + update: ( + {i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutMigrate', { - defaultMessage: 'update this connector.', + defaultMessage: 'Update this connector,', } )} - + + ), + create: ( + + {i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutCreate', + { + defaultMessage: 'or create a new one.', + } + )} + ), }} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts index ca557b31c4f4f..0134133645bb3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts @@ -14,6 +14,8 @@ import { import { IErrorObject } from '../../../../../public/types'; import { AppInfo, Choice, RESTApiError, ServiceNowActionConnector } from './types'; +export const DEFAULT_CORRELATION_ID = '{{rule.id}}:{{alert.id}}'; + export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => choices.map((choice) => ({ value: choice.value, text: choice.label })); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx index 8e1c1820920c5..ee63a546e6aa1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx @@ -15,7 +15,7 @@ describe('DeprecatedCallout', () => { render(); expect( screen.getByText( - 'To use this connector, you must first install the Elastic App from the ServiceNow App Store' + 'To use this connector, first install the Elastic app from the ServiceNow app store.' ) ).toBeInTheDocument(); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index 02f3ae47728ab..7c720148780a4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -6,29 +6,52 @@ */ import React from 'react'; +import { act } from '@testing-library/react'; import { mountWithIntl } from '@kbn/test/jest'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { ActionConnectorFieldsSetCallbacks } from '../../../../types'; +import { updateActionConnector } from '../../../lib/action_connector_api'; import ServiceNowConnectorFields from './servicenow_connectors'; import { ServiceNowActionConnector } from './types'; +import { getAppInfo } from './api'; + jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../lib/action_connector_api'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked; +const getAppInfoMock = getAppInfo as jest.Mock; +const updateActionConnectorMock = updateActionConnector as jest.Mock; describe('ServiceNowActionConnectorFields renders', () => { + const usesTableApiConnector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + isPreconfigured: false, + name: 'SN', + config: { + apiUrl: 'https://test/', + isLegacy: true, + }, + } as ServiceNowActionConnector; + + const usesImportSetApiConnector = { + ...usesTableApiConnector, + config: { + ...usesTableApiConnector.config, + isLegacy: false, + }, + } as ServiceNowActionConnector; + test('alerting servicenow connector fields are rendered', () => { - const actionConnector = { - secrets: { - username: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.webhook', - isPreconfigured: false, - name: 'webhook', - config: { - apiUrl: 'https://test/', - }, - } as ServiceNowActionConnector; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} @@ -41,30 +64,16 @@ describe('ServiceNowActionConnectorFields renders', () => { wrapper.find('[data-test-subj="connector-servicenow-username-form-input"]').length > 0 ).toBeTruthy(); - expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="credentialsApiUrlFromInput"]').length > 0).toBeTruthy(); expect( wrapper.find('[data-test-subj="connector-servicenow-password-form-input"]').length > 0 ).toBeTruthy(); }); test('case specific servicenow connector fields is rendered', () => { - const actionConnector = { - secrets: { - username: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.servicenow', - isPreconfigured: false, - name: 'servicenow', - config: { - apiUrl: 'https://test/', - isLegacy: false, - }, - } as ServiceNowActionConnector; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} @@ -74,7 +83,8 @@ describe('ServiceNowActionConnectorFields renders', () => { isEdit={false} /> ); - expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); + + expect(wrapper.find('[data-test-subj="credentialsApiUrlFromInput"]').length > 0).toBeTruthy(); expect( wrapper.find('[data-test-subj="connector-servicenow-password-form-input"]').length > 0 ).toBeTruthy(); @@ -87,6 +97,7 @@ describe('ServiceNowActionConnectorFields renders', () => { config: {}, secrets: {}, } as ServiceNowActionConnector; + const wrapper = mountWithIntl( { config: {}, secrets: {}, } as ServiceNowActionConnector; + const wrapper = mountWithIntl( { }); test('should display a message on edit to re-enter credentials', () => { - const actionConnector = { - secrets: { - username: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.servicenow', - isPreconfigured: false, - name: 'servicenow', - config: { - apiUrl: 'https://test/', - }, - } as ServiceNowActionConnector; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} @@ -152,4 +151,268 @@ describe('ServiceNowActionConnectorFields renders', () => { expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); }); + + describe('Elastic certified ServiceNow application', () => { + const { services } = useKibanaMock(); + const applicationInfoData = { + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }; + + let beforeActionConnectorSaveFn: () => Promise; + const setCallbacks = (({ + beforeActionConnectorSave, + }: { + beforeActionConnectorSave: () => Promise; + }) => { + beforeActionConnectorSaveFn = beforeActionConnectorSave; + }) as ActionConnectorFieldsSetCallbacks; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should render the correct callouts when the connectors needs the application', () => { + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={() => {}} + isEdit={false} + /> + ); + expect(wrapper.find('[data-test-subj="snInstallationCallout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="snDeprecatedCallout"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeFalsy(); + }); + + test('should render the correct callouts if the connector uses the table API', () => { + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={() => {}} + isEdit={false} + /> + ); + expect(wrapper.find('[data-test-subj="snInstallationCallout"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="snDeprecatedCallout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeFalsy(); + }); + + test('should get application information when saving the connector', async () => { + getAppInfoMock.mockResolvedValue(applicationInfoData); + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={setCallbacks} + isEdit={false} + /> + ); + + await act(async () => { + await beforeActionConnectorSaveFn(); + }); + + expect(getAppInfoMock).toHaveBeenCalledTimes(1); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeFalsy(); + }); + + test('should NOT get application information when the connector uses the old API', async () => { + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={setCallbacks} + isEdit={false} + /> + ); + + await act(async () => { + await beforeActionConnectorSaveFn(); + }); + + expect(getAppInfoMock).toHaveBeenCalledTimes(0); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeFalsy(); + }); + + test('should render error when save failed', async () => { + expect.assertions(4); + + const errorMessage = 'request failed'; + getAppInfoMock.mockRejectedValueOnce(new Error(errorMessage)); + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={setCallbacks} + isEdit={false} + /> + ); + + await expect( + // The async is needed so the act will finished before asserting for the callout + async () => await act(async () => await beforeActionConnectorSaveFn()) + ).rejects.toThrow(errorMessage); + expect(getAppInfoMock).toHaveBeenCalledTimes(1); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="snApplicationCallout"]') + .first() + .text() + .includes(errorMessage) + ).toBeTruthy(); + }); + + test('should render error when the response is a REST api error', async () => { + expect.assertions(4); + + const errorMessage = 'request failed'; + getAppInfoMock.mockResolvedValue({ error: { message: errorMessage }, status: 'failure' }); + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={setCallbacks} + isEdit={false} + /> + ); + + await expect( + // The async is needed so the act will finished before asserting for the callout + async () => await act(async () => await beforeActionConnectorSaveFn()) + ).rejects.toThrow(errorMessage); + expect(getAppInfoMock).toHaveBeenCalledTimes(1); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="snApplicationCallout"]') + .first() + .text() + .includes(errorMessage) + ).toBeTruthy(); + }); + + test('should migrate the deprecated connector when the application throws', async () => { + getAppInfoMock.mockResolvedValue(applicationInfoData); + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={setCallbacks} + isEdit={false} + /> + ); + + expect(wrapper.find('[data-test-subj="update-connector-btn"]').exists()).toBeTruthy(); + wrapper.find('[data-test-subj="update-connector-btn"]').first().simulate('click'); + expect(wrapper.find('[data-test-subj="updateConnectorForm"]').exists()).toBeTruthy(); + + await act(async () => { + // Update the connector + wrapper.find('[data-test-subj="snUpdateInstallationSubmit"]').first().simulate('click'); + }); + + expect(getAppInfoMock).toHaveBeenCalledTimes(1); + expect(updateActionConnectorMock).toHaveBeenCalledWith( + expect.objectContaining({ + id: usesTableApiConnector.id, + connector: { + name: usesTableApiConnector.name, + config: { ...usesTableApiConnector.config, isLegacy: false }, + secrets: usesTableApiConnector.secrets, + }, + }) + ); + + expect(services.notifications.toasts.addSuccess).toHaveBeenCalledWith({ + text: 'Connector has been updated.', + title: 'SN connector updated', + }); + + // The flyout is closed + wrapper.update(); + expect(wrapper.find('[data-test-subj="updateConnectorForm"]').exists()).toBeFalsy(); + }); + + test('should NOT migrate the deprecated connector when there is an error', async () => { + const errorMessage = 'request failed'; + getAppInfoMock.mockRejectedValueOnce(new Error(errorMessage)); + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={setCallbacks} + isEdit={false} + /> + ); + + expect(wrapper.find('[data-test-subj="update-connector-btn"]').exists()).toBeTruthy(); + wrapper.find('[data-test-subj="update-connector-btn"]').first().simulate('click'); + expect(wrapper.find('[data-test-subj="updateConnectorForm"]').exists()).toBeTruthy(); + + // The async is needed so the act will finished before asserting for the callout + await act(async () => { + wrapper.find('[data-test-subj="snUpdateInstallationSubmit"]').first().simulate('click'); + }); + + expect(getAppInfoMock).toHaveBeenCalledTimes(1); + expect(updateActionConnectorMock).not.toHaveBeenCalled(); + + expect(services.notifications.toasts.addSuccess).not.toHaveBeenCalled(); + + // The flyout is still open + wrapper.update(); + expect(wrapper.find('[data-test-subj="updateConnectorForm"]').exists()).toBeTruthy(); + + // The error message should be shown to the user + expect( + wrapper + .find('[data-test-subj="updateConnectorForm"] [data-test-subj="snApplicationCallout"]') + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="updateConnectorForm"] [data-test-subj="snApplicationCallout"]') + .first() + .text() + .includes(errorMessage) + ).toBeTruthy(); + }); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 2cf738c5e0c13..20d38cfc7cea8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -17,7 +17,7 @@ import { useGetAppInfo } from './use_get_app_info'; import { ApplicationRequiredCallout } from './application_required_callout'; import { isRESTApiError, isLegacyConnector } from './helpers'; import { InstallationCallout } from './installation_callout'; -import { UpdateConnectorModal } from './update_connector_modal'; +import { UpdateConnector } from './update_connector'; import { updateActionConnector } from '../../../lib/action_connector_api'; import { Credentials } from './credentials'; @@ -40,7 +40,7 @@ const ServiceNowConnectorFields: React.FC setShowModal(true), []); - const onModalCancel = useCallback(() => setShowModal(false), []); - - const onModalConfirm = useCallback(async () => { - await getApplicationInfo(); - await updateActionConnector({ - http, - connector: { - name: action.name, - config: { apiUrl, isLegacy: false }, - secrets: { username, password }, - }, - id: action.id, - }); - - editActionConfig('isLegacy', false); - setShowModal(false); - - toasts.addSuccess({ - title: i18n.MIGRATION_SUCCESS_TOAST_TITLE(action.name), - text: i18n.MIGRATION_SUCCESS_TOAST_TEXT, - }); + const onMigrateClick = useCallback(() => setShowUpdateConnector(true), []); + const onModalCancel = useCallback(() => setShowUpdateConnector(false), []); + + const onUpdateConnectorConfirm = useCallback(async () => { + try { + await getApplicationInfo(); + + await updateActionConnector({ + http, + connector: { + name: action.name, + config: { apiUrl, isLegacy: false }, + secrets: { username, password }, + }, + id: action.id, + }); + + editActionConfig('isLegacy', false); + setShowUpdateConnector(false); + + toasts.addSuccess({ + title: i18n.UPDATE_SUCCESS_TOAST_TITLE(action.name), + text: i18n.UPDATE_SUCCESS_TOAST_TEXT, + }); + } catch (err) { + /** + * getApplicationInfo may throw an error if the request + * fails or if there is a REST api error. + * + * We silent the errors as a callout will show and inform the user + */ + } }, [ getApplicationInfo, http, @@ -115,8 +125,8 @@ const ServiceNowConnectorFields: React.FC - {showModal && ( - )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx index 30e09356e95dd..078b5535c16eb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { mount } from 'enzyme'; +import { mountWithIntl } from '@kbn/test/jest'; import { act } from '@testing-library/react'; import { ActionConnector } from '../../../../types'; @@ -115,13 +115,15 @@ describe('ServiceNowITSMParamsFields renders', () => { }); test('all params fields is rendered', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="short_descriptionInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="correlation_idInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="correlation_displayInput"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').exists()).toBeTruthy(); }); @@ -132,7 +134,7 @@ describe('ServiceNowITSMParamsFields renders', () => { // eslint-disable-next-line @typescript-eslint/naming-convention errors: { 'subActionParams.incident.short_description': ['error'] }, }; - const wrapper = mount(); + const wrapper = mountWithIntl(); const title = wrapper.find('[data-test-subj="short_descriptionInput"]').first(); expect(title.prop('isInvalid')).toBeTruthy(); }); @@ -144,10 +146,9 @@ describe('ServiceNowITSMParamsFields renders', () => { ...defaultProps, actionParams: newParams, }; - mount(); + mountWithIntl(); expect(editAction.mock.calls[0][1]).toEqual({ incident: { - correlation_display: 'Alerting', correlation_id: '{{rule.id}}:{{alert.id}}', }, comments: [], @@ -161,18 +162,17 @@ describe('ServiceNowITSMParamsFields renders', () => { ...defaultProps, actionParams: newParams, }; - mount(); + mountWithIntl(); expect(editAction.mock.calls[0][1]).toEqual('pushToService'); }); test('Resets fields when connector changes', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); expect(editAction.mock.calls.length).toEqual(0); wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); expect(editAction.mock.calls.length).toEqual(1); expect(editAction.mock.calls[0][1]).toEqual({ incident: { - correlation_display: 'Alerting', correlation_id: '{{rule.id}}:{{alert.id}}', }, comments: [], @@ -180,7 +180,7 @@ describe('ServiceNowITSMParamsFields renders', () => { }); test('it transforms the categories to options correctly', async () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); act(() => { onChoices(useGetChoicesResponse.choices); }); @@ -195,7 +195,7 @@ describe('ServiceNowITSMParamsFields renders', () => { }); test('it transforms the subcategories to options correctly', async () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); act(() => { onChoices(useGetChoicesResponse.choices); }); @@ -210,7 +210,7 @@ describe('ServiceNowITSMParamsFields renders', () => { }); test('it transforms the options correctly', async () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); act(() => { onChoices(useGetChoicesResponse.choices); }); @@ -231,6 +231,11 @@ describe('ServiceNowITSMParamsFields renders', () => { const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent; const simpleFields = [ { dataTestSubj: 'input[data-test-subj="short_descriptionInput"]', key: 'short_description' }, + { dataTestSubj: 'input[data-test-subj="correlation_idInput"]', key: 'correlation_id' }, + { + dataTestSubj: 'input[data-test-subj="correlation_displayInput"]', + key: 'correlation_display', + }, { dataTestSubj: 'textarea[data-test-subj="descriptionTextArea"]', key: 'description' }, { dataTestSubj: '[data-test-subj="urgencySelect"]', key: 'urgency' }, { dataTestSubj: '[data-test-subj="severitySelect"]', key: 'severity' }, @@ -241,7 +246,7 @@ describe('ServiceNowITSMParamsFields renders', () => { simpleFields.forEach((field) => test(`${field.key} update triggers editAction :D`, () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); const theField = wrapper.find(field.dataTestSubj).first(); theField.prop('onChange')!(changeEvent); expect(editAction.mock.calls[0][1].incident[field.key]).toEqual(changeEvent.target.value); @@ -249,14 +254,14 @@ describe('ServiceNowITSMParamsFields renders', () => { ); test('A comment triggers editAction', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); const comments = wrapper.find('textarea[data-test-subj="commentsTextArea"]'); expect(comments.simulate('change', changeEvent)); expect(editAction.mock.calls[0][1].comments.length).toEqual(1); }); test('An empty comment does not trigger editAction', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); const emptyComment = { target: { value: '' } }; const comments = wrapper.find('[data-test-subj="commentsTextArea"] textarea'); expect(comments.simulate('change', emptyComment)); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index 81428cd7f0a73..09b04f0fa3c48 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -13,18 +13,19 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, - EuiSwitch, + EuiLink, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; import { ServiceNowITSMActionParams, Choice, Fields, ServiceNowActionConnector } from './types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; import { useGetChoices } from './use_get_choices'; -import { choicesToEuiOptions, isLegacyConnector } from './helpers'; +import { choicesToEuiOptions, DEFAULT_CORRELATION_ID, isLegacyConnector } from './helpers'; import * as i18n from './translations'; -import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { @@ -40,11 +41,14 @@ const ServiceNowParamsFields: React.FunctionComponent< ActionParamsProps > = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { const { + docLinks, http, notifications: { toasts }, } = useKibana().services; - const isOldConnector = isLegacyConnector(actionConnector as unknown as ServiceNowActionConnector); + const isDeprecatedConnector = isLegacyConnector( + actionConnector as unknown as ServiceNowActionConnector + ); const actionConnectorRef = useRef(actionConnector?.id ?? ''); const { incident, comments } = useMemo( @@ -57,13 +61,8 @@ const ServiceNowParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); - const hasUpdateIncident = - incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE; - const [updateIncident, setUpdateIncident] = useState(hasUpdateIncident); const [choices, setChoices] = useState(defaultFields); - const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE; - const editSubActionProperty = useCallback( (key: string, value: any) => { const newProps = @@ -99,14 +98,6 @@ const ServiceNowParamsFields: React.FunctionComponent< ); }, []); - const onUpdateIncidentSwitchChange = useCallback(() => { - const newCorrelationID = !updateIncident - ? UPDATE_INCIDENT_VARIABLE - : NOT_UPDATE_INCIDENT_VARIABLE; - editSubActionProperty('correlation_id', newCorrelationID); - setUpdateIncident(!updateIncident); - }, [editSubActionProperty, updateIncident]); - const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); @@ -136,7 +127,7 @@ const ServiceNowParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, + incident: { correlation_id: DEFAULT_CORRELATION_ID }, comments: [], }, index @@ -153,7 +144,7 @@ const ServiceNowParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, + incident: { correlation_id: DEFAULT_CORRELATION_ID }, comments: [], }, index @@ -253,6 +244,46 @@ const ServiceNowParamsFields: React.FunctionComponent< + {!isDeprecatedConnector && ( + <> + + + + + + } + > + + + + + + + + + + + + )} - {!isOldConnector && ( - - - - - - )} { }); test('all params fields is rendered', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="short_descriptionInput"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="source_ipInput"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="dest_ipInput"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="malware_urlInput"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="malware_hashInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="correlation_idInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="correlation_displayInput"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="prioritySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); @@ -162,7 +160,7 @@ describe('ServiceNowSIRParamsFields renders', () => { // eslint-disable-next-line @typescript-eslint/naming-convention errors: { 'subActionParams.incident.short_description': ['error'] }, }; - const wrapper = mount(); + const wrapper = mountWithIntl(); const title = wrapper.find('[data-test-subj="short_descriptionInput"]').first(); expect(title.prop('isInvalid')).toBeTruthy(); }); @@ -174,10 +172,9 @@ describe('ServiceNowSIRParamsFields renders', () => { ...defaultProps, actionParams: newParams, }; - mount(); + mountWithIntl(); expect(editAction.mock.calls[0][1]).toEqual({ incident: { - correlation_display: 'Alerting', correlation_id: '{{rule.id}}:{{alert.id}}', }, comments: [], @@ -191,18 +188,17 @@ describe('ServiceNowSIRParamsFields renders', () => { ...defaultProps, actionParams: newParams, }; - mount(); + mountWithIntl(); expect(editAction.mock.calls[0][1]).toEqual('pushToService'); }); test('Resets fields when connector changes', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); expect(editAction.mock.calls.length).toEqual(0); wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); expect(editAction.mock.calls.length).toEqual(1); expect(editAction.mock.calls[0][1]).toEqual({ incident: { - correlation_display: 'Alerting', correlation_id: '{{rule.id}}:{{alert.id}}', }, comments: [], @@ -210,7 +206,7 @@ describe('ServiceNowSIRParamsFields renders', () => { }); test('it transforms the categories to options correctly', async () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); act(() => { onChoicesSuccess(choicesResponse.choices); }); @@ -227,7 +223,7 @@ describe('ServiceNowSIRParamsFields renders', () => { }); test('it transforms the subcategories to options correctly', async () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); act(() => { onChoicesSuccess(choicesResponse.choices); }); @@ -250,7 +246,7 @@ describe('ServiceNowSIRParamsFields renders', () => { }); test('it transforms the priorities to options correctly', async () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); act(() => { onChoicesSuccess(choicesResponse.choices); }); @@ -284,11 +280,12 @@ describe('ServiceNowSIRParamsFields renders', () => { const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent; const simpleFields = [ { dataTestSubj: 'input[data-test-subj="short_descriptionInput"]', key: 'short_description' }, + { dataTestSubj: 'input[data-test-subj="correlation_idInput"]', key: 'correlation_id' }, + { + dataTestSubj: 'input[data-test-subj="correlation_displayInput"]', + key: 'correlation_display', + }, { dataTestSubj: 'textarea[data-test-subj="descriptionTextArea"]', key: 'description' }, - { dataTestSubj: '[data-test-subj="source_ipInput"]', key: 'source_ip' }, - { dataTestSubj: '[data-test-subj="dest_ipInput"]', key: 'dest_ip' }, - { dataTestSubj: '[data-test-subj="malware_urlInput"]', key: 'malware_url' }, - { dataTestSubj: '[data-test-subj="malware_hashInput"]', key: 'malware_hash' }, { dataTestSubj: '[data-test-subj="prioritySelect"]', key: 'priority' }, { dataTestSubj: '[data-test-subj="categorySelect"]', key: 'category' }, { dataTestSubj: '[data-test-subj="subcategorySelect"]', key: 'subcategory' }, @@ -296,7 +293,7 @@ describe('ServiceNowSIRParamsFields renders', () => { simpleFields.forEach((field) => test(`${field.key} update triggers editAction :D`, () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); const theField = wrapper.find(field.dataTestSubj).first(); theField.prop('onChange')!(changeEvent); expect(editAction.mock.calls[0][1].incident[field.key]).toEqual(changeEvent.target.value); @@ -304,14 +301,14 @@ describe('ServiceNowSIRParamsFields renders', () => { ); test('A comment triggers editAction', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); const comments = wrapper.find('textarea[data-test-subj="commentsTextArea"]'); expect(comments.simulate('change', changeEvent)); expect(editAction.mock.calls[0][1].comments.length).toEqual(1); }); test('An empty comment does not trigger editAction', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); const emptyComment = { target: { value: '' } }; const comments = wrapper.find('[data-test-subj="commentsTextArea"] textarea'); expect(comments.simulate('change', emptyComment)); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index 7b7cfc67d9971..72f6d7635268f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -13,8 +13,10 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, - EuiSwitch, + EuiLink, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; @@ -23,8 +25,7 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var import * as i18n from './translations'; import { useGetChoices } from './use_get_choices'; import { ServiceNowSIRActionParams, Fields, Choice, ServiceNowActionConnector } from './types'; -import { choicesToEuiOptions, isLegacyConnector } from './helpers'; -import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config'; +import { choicesToEuiOptions, isLegacyConnector, DEFAULT_CORRELATION_ID } from './helpers'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; const defaultFields: Fields = { @@ -33,23 +34,18 @@ const defaultFields: Fields = { priority: [], }; -const valuesToString = (value: string | string[] | null): string | undefined => { - if (Array.isArray(value)) { - return value.join(','); - } - - return value ?? undefined; -}; - const ServiceNowSIRParamsFields: React.FunctionComponent< ActionParamsProps > = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { const { + docLinks, http, notifications: { toasts }, } = useKibana().services; - const isOldConnector = isLegacyConnector(actionConnector as unknown as ServiceNowActionConnector); + const isDeprecatedConnector = isLegacyConnector( + actionConnector as unknown as ServiceNowActionConnector + ); const actionConnectorRef = useRef(actionConnector?.id ?? ''); const { incident, comments } = useMemo( @@ -62,13 +58,8 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); - const hasUpdateIncident = - incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE; - const [updateIncident, setUpdateIncident] = useState(hasUpdateIncident); const [choices, setChoices] = useState(defaultFields); - const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE; - const editSubActionProperty = useCallback( (key: string, value: any) => { const newProps = @@ -104,14 +95,6 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< ); }, []); - const onUpdateIncidentSwitchChange = useCallback(() => { - const newCorrelationID = !updateIncident - ? UPDATE_INCIDENT_VARIABLE - : NOT_UPDATE_INCIDENT_VARIABLE; - editSubActionProperty('correlation_id', newCorrelationID); - setUpdateIncident(!updateIncident); - }, [editSubActionProperty, updateIncident]); - const { isLoading: isLoadingChoices } = useGetChoices({ http, toastNotifications: toasts, @@ -140,7 +123,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, + incident: { correlation_id: DEFAULT_CORRELATION_ID }, comments: [], }, index @@ -157,7 +140,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, + incident: { correlation_id: DEFAULT_CORRELATION_ID }, comments: [], }, index @@ -192,46 +175,6 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< /> - - - - - - - - - - - - - - - - + {!isDeprecatedConnector && ( + <> + + + + + + } + > + + + + + + + + + + + + )} - {!isOldConnector && ( - - - - )} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx index fe73653234170..500325202b651 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx @@ -7,21 +7,43 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import { SNStoreButton } from './sn_store_button'; +import { SNStoreButton, SNStoreLink } from './sn_store_button'; describe('SNStoreButton', () => { - test('it renders the button', () => { + it('should render the button', () => { render(); expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument(); }); - test('it renders a danger button', () => { + it('should render a danger button', () => { render(); expect(screen.getByRole('link')).toHaveClass('euiButton--danger'); }); - test('it renders with correct href', () => { + it('should render with correct href', () => { render(); expect(screen.getByRole('link')).toHaveAttribute('href', 'https://store.servicenow.com/'); }); + + it('should render with target blank', () => { + render(); + expect(screen.getByRole('link')).toHaveAttribute('target', '_blank'); + }); +}); + +describe('SNStoreLink', () => { + it('should render the link', () => { + render(); + expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument(); + }); + + it('should render with correct href', () => { + render(); + expect(screen.getByRole('link')).toHaveAttribute('href', 'https://store.servicenow.com/'); + }); + + it('should render with target blank', () => { + render(); + expect(screen.getByRole('link')).toHaveAttribute('target', '_blank'); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx index 5921f679d3f50..5a33237159a02 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { EuiButtonProps, EuiButton } from '@elastic/eui'; +import { EuiButtonProps, EuiButton, EuiLink } from '@elastic/eui'; import * as i18n from './translations'; @@ -18,10 +18,18 @@ interface Props { const SNStoreButtonComponent: React.FC = ({ color }) => { return ( - + {i18n.VISIT_SN_STORE} ); }; export const SNStoreButton = memo(SNStoreButtonComponent); + +const SNStoreLinkComponent: React.FC = () => ( + + {i18n.VISIT_SN_STORE} + +); + +export const SNStoreLink = memo(SNStoreLinkComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 90292a35a88df..d068b120bd7ce 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -17,7 +17,7 @@ export const API_URL_LABEL = i18n.translate( export const API_URL_HELPTEXT = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlHelpText', { - defaultMessage: 'Include the full URL', + defaultMessage: 'Include the full URL.', } ); @@ -60,7 +60,7 @@ export const REMEMBER_VALUES_LABEL = i18n.translate( export const REENTER_VALUES_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel', { - defaultMessage: 'You will need to re-authenticate each time you edit the connector', + defaultMessage: 'You must authenticate each time you edit the connector.', } ); @@ -99,34 +99,6 @@ export const TITLE_REQUIRED = i18n.translate( } ); -export const SOURCE_IP_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle', - { - defaultMessage: 'Source IPs', - } -); - -export const SOURCE_IP_HELP_TEXT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPHelpText', - { - defaultMessage: 'List of source IPs (comma, or pipe delimited)', - } -); - -export const DEST_IP_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle', - { - defaultMessage: 'Destination IPs', - } -); - -export const DEST_IP_HELP_TEXT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destIPHelpText', - { - defaultMessage: 'List of destination IPs (comma, or pipe delimited)', - } -); - export const INCIDENT = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.title', { @@ -155,34 +127,6 @@ export const COMMENTS_LABEL = i18n.translate( } ); -export const MALWARE_URL_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle', - { - defaultMessage: 'Malware URLs', - } -); - -export const MALWARE_URL_HELP_TEXT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLHelpText', - { - defaultMessage: 'List of malware URLs (comma, or pipe delimited)', - } -); - -export const MALWARE_HASH_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle', - { - defaultMessage: 'Malware Hashes', - } -); - -export const MALWARE_HASH_HELP_TEXT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashHelpText', - { - defaultMessage: 'List of malware hashes (comma, or pipe delimited)', - } -); - export const CHOICES_API_ERROR = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage', { @@ -249,25 +193,25 @@ export const INSTALLATION_CALLOUT_TITLE = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutTitle', { defaultMessage: - 'To use this connector, you must first install the Elastic App from the ServiceNow App Store', + 'To use this connector, first install the Elastic app from the ServiceNow app store.', } ); -export const MIGRATION_SUCCESS_TOAST_TITLE = (connectorName: string) => +export const UPDATE_SUCCESS_TOAST_TITLE = (connectorName: string) => i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.migrationSuccessToastTitle', + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateSuccessToastTitle', { - defaultMessage: 'Migrated connector {connectorName}', + defaultMessage: '{connectorName} connector updated', values: { connectorName, }, } ); -export const MIGRATION_SUCCESS_TOAST_TEXT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutText', +export const UPDATE_SUCCESS_TOAST_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateCalloutText', { - defaultMessage: 'Connector has been successfully migrated.', + defaultMessage: 'Connector has been updated.', } ); @@ -299,23 +243,16 @@ export const UNKNOWN = i18n.translate( } ); -export const UPDATE_INCIDENT_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentCheckboxLabel', - { - defaultMessage: 'Update incident', - } -); - -export const ON = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentOn', +export const CORRELATION_ID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.correlationID', { - defaultMessage: 'On', + defaultMessage: 'Correlation ID (optional)', } ); -export const OFF = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentOff', +export const CORRELATION_DISPLAY = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.correlationDisplay', { - defaultMessage: 'Off', + defaultMessage: 'Correlation display (optional)', } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.test.tsx new file mode 100644 index 0000000000000..2d95bfa85ceb9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.test.tsx @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { UpdateConnector, Props } from './update_connector'; +import { ServiceNowActionConnector } from './types'; +jest.mock('../../../../common/lib/kibana'); + +const actionConnector: ServiceNowActionConnector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + isPreconfigured: false, + name: 'servicenow', + config: { + apiUrl: 'https://test/', + isLegacy: true, + }, +}; + +const mountUpdateConnector = (props: Partial = {}) => { + return mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + isLoading={false} + onConfirm={() => {}} + onCancel={() => {}} + {...props} + /> + ); +}; + +describe('UpdateConnector renders', () => { + it('should render update connector fields', () => { + const wrapper = mountUpdateConnector(); + + expect(wrapper.find('[data-test-subj="snUpdateInstallationCallout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="updateConnectorForm"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="credentialsApiUrlFromInput"]').exists()).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="connector-servicenow-username-form-input"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="connector-servicenow-password-form-input"]').exists() + ).toBeTruthy(); + }); + + it('should disable inputs on loading', () => { + const wrapper = mountUpdateConnector({ isLoading: true }); + expect( + wrapper.find('[data-test-subj="credentialsApiUrlFromInput"]').first().prop('disabled') + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="connector-servicenow-username-form-input"]') + .first() + .prop('disabled') + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="connector-servicenow-password-form-input"]') + .first() + .prop('disabled') + ).toBeTruthy(); + }); + + it('should set inputs as read-only', () => { + const wrapper = mountUpdateConnector({ readOnly: true }); + + expect( + wrapper.find('[data-test-subj="credentialsApiUrlFromInput"]').first().prop('readOnly') + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="connector-servicenow-username-form-input"]') + .first() + .prop('readOnly') + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="connector-servicenow-password-form-input"]') + .first() + .prop('readOnly') + ).toBeTruthy(); + }); + + it('should disable submit button if errors or fields missing', () => { + const wrapper = mountUpdateConnector({ + errors: { apiUrl: ['some error'], username: [], password: [] }, + }); + + expect( + wrapper.find('[data-test-subj="snUpdateInstallationSubmit"]').first().prop('disabled') + ).toBeTruthy(); + + wrapper.setProps({ ...wrapper.props(), errors: { apiUrl: [], username: [], password: [] } }); + expect( + wrapper.find('[data-test-subj="snUpdateInstallationSubmit"]').first().prop('disabled') + ).toBeFalsy(); + + wrapper.setProps({ + ...wrapper.props(), + action: { ...actionConnector, secrets: { ...actionConnector.secrets, username: undefined } }, + }); + expect( + wrapper.find('[data-test-subj="snUpdateInstallationSubmit"]').first().prop('disabled') + ).toBeTruthy(); + }); + + it('should call editActionConfig when editing api url', () => { + const editActionConfig = jest.fn(); + const wrapper = mountUpdateConnector({ editActionConfig }); + + expect(editActionConfig).not.toHaveBeenCalled(); + wrapper + .find('input[data-test-subj="credentialsApiUrlFromInput"]') + .simulate('change', { target: { value: 'newUrl' } }); + expect(editActionConfig).toHaveBeenCalledWith('apiUrl', 'newUrl'); + }); + + it('should call editActionSecrets when editing username or password', () => { + const editActionSecrets = jest.fn(); + const wrapper = mountUpdateConnector({ editActionSecrets }); + + expect(editActionSecrets).not.toHaveBeenCalled(); + wrapper + .find('input[data-test-subj="connector-servicenow-username-form-input"]') + .simulate('change', { target: { value: 'new username' } }); + expect(editActionSecrets).toHaveBeenCalledWith('username', 'new username'); + + wrapper + .find('input[data-test-subj="connector-servicenow-password-form-input"]') + .simulate('change', { target: { value: 'new pass' } }); + + expect(editActionSecrets).toHaveBeenCalledTimes(2); + expect(editActionSecrets).toHaveBeenLastCalledWith('password', 'new pass'); + }); + + it('should confirm the update when submit button clicked', () => { + const onConfirm = jest.fn(); + const wrapper = mountUpdateConnector({ onConfirm }); + + expect(onConfirm).not.toHaveBeenCalled(); + wrapper.find('[data-test-subj="snUpdateInstallationSubmit"]').first().simulate('click'); + expect(onConfirm).toHaveBeenCalled(); + }); + + it('should cancel the update when cancel button clicked', () => { + const onCancel = jest.fn(); + const wrapper = mountUpdateConnector({ onCancel }); + + expect(onCancel).not.toHaveBeenCalled(); + wrapper.find('[data-test-subj="snUpdateInstallationCancel"]').first().simulate('click'); + expect(onCancel).toHaveBeenCalled(); + }); + + it('should show error message if present', () => { + const applicationInfoErrorMsg = 'some application error'; + const wrapper = mountUpdateConnector({ + applicationInfoErrorMsg, + }); + + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').first().text()).toContain( + applicationInfoErrorMsg + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.tsx new file mode 100644 index 0000000000000..02127eb6ff4f0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.tsx @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiCallOut, + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiSteps, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionConnectorFieldsProps } from '../../../../../public/types'; +import { ServiceNowActionConnector } from './types'; +import { CredentialsApiUrl } from './credentials_api_url'; +import { isFieldInvalid } from './helpers'; +import { ApplicationRequiredCallout } from './application_required_callout'; +import { SNStoreLink } from './sn_store_button'; +import { CredentialsAuth } from './credentials_auth'; + +const title = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.updateFormTitle', + { + defaultMessage: 'Update ServiceNow connector', + } +); + +const step1InstallTitle = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.updateFormInstallTitle', + { + defaultMessage: 'Install the Elastic ServiceNow app', + } +); + +const step2InstanceUrlTitle = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.updateFormUrlTitle', + { + defaultMessage: 'Enter your ServiceNow instance URL', + } +); + +const step3CredentialsTitle = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.updateFormCredentialsTitle', + { + defaultMessage: 'Provide authentication credentials', + } +); + +const cancelButtonText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.cancelButtonText', + { + defaultMessage: 'Cancel', + } +); + +const confirmButtonText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmButtonText', + { + defaultMessage: 'Update', + } +); + +const warningMessage = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.warningMessage', + { + defaultMessage: 'This updates all instances of this connector and cannot be reversed.', + } +); + +export interface Props { + action: ActionConnectorFieldsProps['action']; + applicationInfoErrorMsg: string | null; + errors: ActionConnectorFieldsProps['errors']; + isLoading: boolean; + readOnly: boolean; + editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; + editActionConfig: ActionConnectorFieldsProps['editActionConfig']; + onCancel: () => void; + onConfirm: () => void; +} + +const UpdateConnectorComponent: React.FC = ({ + action, + applicationInfoErrorMsg, + errors, + isLoading, + readOnly, + editActionSecrets, + editActionConfig, + onCancel, + onConfirm, +}) => { + const { apiUrl } = action.config; + const { username, password } = action.secrets; + + const hasErrorsOrEmptyFields = + apiUrl === undefined || + username === undefined || + password === undefined || + isFieldInvalid(apiUrl, errors.apiUrl) || + isFieldInvalid(username, errors.username) || + isFieldInvalid(password, errors.password); + + return ( + + + +

{title}

+
+
+ + } + > + + , + }} + /> + ), + }, + { + title: step2InstanceUrlTitle, + children: ( + + ), + }, + { + title: step3CredentialsTitle, + children: ( + + ), + }, + ]} + /> + + + + {applicationInfoErrorMsg && ( + + )} + + + + + + + + {cancelButtonText} + + + + + {confirmButtonText} + + + + +
+ ); +}; + +export const UpdateConnector = memo(UpdateConnectorComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx deleted file mode 100644 index b9d660f16dff7..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx +++ /dev/null @@ -1,156 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo } from 'react'; -import { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiCallOut, - EuiTextColor, - EuiHorizontalRule, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { ActionConnectorFieldsProps } from '../../../../../public/types'; -import { ServiceNowActionConnector } from './types'; -import { Credentials } from './credentials'; -import { isFieldInvalid } from './helpers'; -import { ApplicationRequiredCallout } from './application_required_callout'; - -const title = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmationModalTitle', - { - defaultMessage: 'Update ServiceNow connector', - } -); - -const cancelButtonText = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.cancelButtonText', - { - defaultMessage: 'Cancel', - } -); - -const confirmButtonText = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmButtonText', - { - defaultMessage: 'Update', - } -); - -const calloutTitle = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalCalloutTitle', - { - defaultMessage: - 'The Elastic App from the ServiceNow App Store must be installed prior to running the update.', - } -); - -const warningMessage = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalWarningMessage', - { - defaultMessage: 'This will update all instances of this connector. This can not be reversed.', - } -); - -interface Props { - action: ActionConnectorFieldsProps['action']; - applicationInfoErrorMsg: string | null; - errors: ActionConnectorFieldsProps['errors']; - isLoading: boolean; - readOnly: boolean; - editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; - editActionConfig: ActionConnectorFieldsProps['editActionConfig']; - onCancel: () => void; - onConfirm: () => void; -} - -const UpdateConnectorModalComponent: React.FC = ({ - action, - applicationInfoErrorMsg, - errors, - isLoading, - readOnly, - editActionSecrets, - editActionConfig, - onCancel, - onConfirm, -}) => { - const { apiUrl } = action.config; - const { username, password } = action.secrets; - - const hasErrorsOrEmptyFields = - apiUrl === undefined || - username === undefined || - password === undefined || - isFieldInvalid(apiUrl, errors.apiUrl) || - isFieldInvalid(username, errors.username) || - isFieldInvalid(password, errors.password); - - return ( - - - -

{title}

-
-
- - - - - - - - - - - {warningMessage} - - - - - {applicationInfoErrorMsg && ( - - )} - - - - - {cancelButtonText} - - {confirmButtonText} - - -
- ); -}; - -export const UpdateConnectorModal = memo(UpdateConnectorModalComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index 04f2334f8e8fa..844f28f022547 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { ClassNames } from '@emotion/react'; import React, { useState, useEffect } from 'react'; import { EuiInMemoryTable, @@ -24,6 +25,7 @@ import { import { i18n } from '@kbn/i18n'; import { omit } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; +import { withTheme, EuiTheme } from '../../../../../../../../src/plugins/kibana_react/common'; import { loadAllActions, loadActionTypes, deleteActions } from '../../../lib/action_connector_api'; import { hasDeleteActionsCapability, @@ -52,6 +54,33 @@ import { // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../../actions/server/constants/connectors'; +const ConnectorIconTipWithSpacing = withTheme(({ theme }: { theme: EuiTheme }) => { + return ( + + {({ css }) => ( + + )} + + ); +}); + const ActionsConnectorsList: React.FunctionComponent = () => { const { http, @@ -204,23 +233,7 @@ const ActionsConnectorsList: React.FunctionComponent = () => { position="right" /> ) : null} - {showLegacyTooltip && ( - - )} + {showLegacyTooltip && } );