[Actions] makes savedObjectId field optional (#79186)

This PR makes the `savedObjectId` parameter optional in the Jira, ServiceNow and IBM Resilient Connectors.
This allows them to execute without this field outside of Alerts, as it is currently populated using the `alertId` which isn't available in other places.
Additionally this adds an optional field in the `Params` Components for all three of the connectors, which allows users to provide a value for the `savedObjectId` field if the so wish.
This commit is contained in:
Gidi Meir Morris 2020-10-05 18:21:20 +01:00 committed by GitHub
parent de130abfbc
commit 4fdf2f1566
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 296 additions and 84 deletions

View file

@ -26,11 +26,25 @@ export interface ActionResult {
}
// the result returned from an action type executor function
const ActionTypeExecutorResultStatusValues = ['ok', 'error'] as const;
type ActionTypeExecutorResultStatus = typeof ActionTypeExecutorResultStatusValues[number];
export interface ActionTypeExecutorResult<Data> {
actionId: string;
status: 'ok' | 'error';
status: ActionTypeExecutorResultStatus;
message?: string;
serviceMessage?: string;
data?: Data;
retry?: null | boolean | Date;
}
export function isActionTypeExecutorResult(
result: unknown
): result is ActionTypeExecutorResult<unknown> {
const unsafeResult = result as ActionTypeExecutorResult<unknown>;
return (
unsafeResult &&
typeof unsafeResult?.actionId === 'string' &&
ActionTypeExecutorResultStatusValues.includes(unsafeResult?.status)
);
}

View file

@ -37,7 +37,7 @@ export const ExecutorSubActionSchema = schema.oneOf([
]);
export const ExecutorSubActionPushParamsSchema = schema.object({
savedObjectId: schema.string(),
savedObjectId: schema.nullable(schema.string()),
title: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),

View file

@ -37,7 +37,7 @@ export const ExecutorSubActionSchema = schema.oneOf([
]);
export const ExecutorSubActionPushParamsSchema = schema.object({
savedObjectId: schema.string(),
savedObjectId: schema.nullable(schema.string()),
title: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),

View file

@ -34,7 +34,7 @@ export const ExecutorSubActionSchema = schema.oneOf([
]);
export const ExecutorSubActionPushParamsSchema = schema.object({
savedObjectId: schema.string(),
savedObjectId: schema.nullable(schema.string()),
title: schema.string(),
description: schema.nullable(schema.string()),
comment: schema.nullable(schema.string()),

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { fromNullable, Option } from 'fp-ts/lib/Option';
import { ActionVariable } from '../../../types';
export function extractActionVariable(
actionVariables: ActionVariable[],
variableName: string
): Option<ActionVariable> {
return fromNullable(actionVariables?.find((variable) => variable.name === variableName));
}

View file

@ -90,7 +90,7 @@ describe('JiraParamsFields renders', () => {
errors={{ title: [] }}
editAction={() => {}}
index={0}
messageVariables={[]}
messageVariables={[{ name: 'alertId', description: '' }]}
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}
toastNotifications={mocks.notifications.toasts}
http={mocks.http}
@ -107,6 +107,27 @@ describe('JiraParamsFields renders', () => {
expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="labelsComboBox"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy();
// ensure savedObjectIdInput isnt rendered
expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length === 0).toBeTruthy();
});
test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => {
const wrapper = mountWithIntl(
<JiraParamsFields
actionParams={actionParams}
errors={{ title: [] }}
editAction={() => {}}
index={0}
messageVariables={[]}
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}
toastNotifications={mocks.notifications.toasts}
http={mocks.http}
actionConnector={connector}
/>
);
expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length > 0).toBeTruthy();
});
test('it shows loading when loading issue types', () => {

View file

@ -6,12 +6,20 @@
import React, { Fragment, useEffect, useState, useMemo } from 'react';
import { map } from 'lodash/fp';
import { EuiFormRow, EuiComboBox, EuiSelectOption, EuiHorizontalRule } from '@elastic/eui';
import { isSome } from 'fp-ts/lib/Option';
import { i18n } from '@kbn/i18n';
import { EuiSelect } from '@elastic/eui';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiFlexItem } from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
import {
EuiFormRow,
EuiComboBox,
EuiSelectOption,
EuiHorizontalRule,
EuiSelect,
EuiFormControlLayout,
EuiIconTip,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
} from '@elastic/eui';
import { ActionParamsProps } from '../../../../types';
import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables';
@ -20,6 +28,7 @@ import { JiraActionParams } from './types';
import { useGetIssueTypes } from './use_get_issue_types';
import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type';
import { SearchIssues } from './search_issues';
import { extractActionVariable } from '../extract_action_variable';
const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionParams>> = ({
actionParams,
@ -38,6 +47,10 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
const [firstLoad, setFirstLoad] = useState(false);
const [prioritiesSelectOptions, setPrioritiesSelectOptions] = useState<EuiSelectOption[]>([]);
const isActionBeingConfiguredByAnAlert = messageVariables
? isSome(extractActionVariable(messageVariables, 'alertId'))
: false;
useEffect(() => {
setFirstLoad(true);
}, []);
@ -127,7 +140,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
if (!actionParams.subAction) {
editAction('subAction', 'pushToService', index);
}
if (!savedObjectId && messageVariables?.find((variable) => variable.name === 'alertId')) {
if (!savedObjectId && isActionBeingConfiguredByAnAlert) {
editSubActionProperty('savedObjectId', '{{alertId}}');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -261,6 +274,45 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
/>
</EuiFormRow>
<EuiSpacer size="m" />
{!isActionBeingConfiguredByAnAlert && (
<Fragment>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.savedObjectIdFieldLabel',
{
defaultMessage: 'Object ID (optional)',
}
)}
>
<EuiFlexItem>
<EuiFormControlLayout
fullWidth
append={
<EuiIconTip
content={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.savedObjectIdFieldHelp',
{
defaultMessage:
'JIRA will associate this action with the ID of a Kibana saved object.',
}
)}
/>
}
>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'savedObjectId'}
inputTargetValue={savedObjectId}
/>
</EuiFormControlLayout>
</EuiFlexItem>
</EuiFormRow>
<EuiSpacer size="m" />
</Fragment>
)}
{hasLabels && (
<>
<EuiFlexGroup>

View file

@ -86,7 +86,7 @@ describe('ResilientParamsFields renders', () => {
errors={{ title: [] }}
editAction={() => {}}
index={0}
messageVariables={[]}
messageVariables={[{ name: 'alertId', description: '' }]}
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}
toastNotifications={mocks.notifications.toasts}
http={mocks.http}
@ -100,6 +100,27 @@ describe('ResilientParamsFields renders', () => {
expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy();
// ensure savedObjectIdInput isnt rendered
expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length === 0).toBeTruthy();
});
test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => {
const wrapper = mountWithIntl(
<ResilientParamsFields
actionParams={actionParams}
errors={{ title: [] }}
editAction={() => {}}
index={0}
messageVariables={[]}
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}
toastNotifications={mocks.notifications.toasts}
http={mocks.http}
actionConnector={connector}
/>
);
expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length > 0).toBeTruthy();
});
test('it shows loading when loading incident types', () => {

View file

@ -13,8 +13,11 @@ import {
EuiTitle,
EuiComboBoxOptionOption,
EuiSelectOption,
EuiFormControlLayout,
EuiIconTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isSome } from 'fp-ts/lib/Option';
import { ActionParamsProps } from '../../../../types';
import { ResilientActionParams } from './types';
@ -23,6 +26,7 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var
import { useGetIncidentTypes } from './use_get_incident_types';
import { useGetSeverity } from './use_get_severity';
import { extractActionVariable } from '../extract_action_variable';
const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<ResilientActionParams>> = ({
actionParams,
@ -38,6 +42,10 @@ const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<Resilient
const { title, description, comments, incidentTypes, severityCode, savedObjectId } =
actionParams.subActionParams || {};
const isActionBeingConfiguredByAnAlert = messageVariables
? isSome(extractActionVariable(messageVariables, 'alertId'))
: false;
const [incidentTypesComboBoxOptions, setIncidentTypesComboBoxOptions] = useState<
Array<EuiComboBoxOptionOption<string>>
>([]);
@ -98,7 +106,7 @@ const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<Resilient
if (!actionParams.subAction) {
editAction('subAction', 'pushToService', index);
}
if (!savedObjectId && messageVariables?.find((variable) => variable.name === 'alertId')) {
if (!savedObjectId && isActionBeingConfiguredByAnAlert) {
editSubActionProperty('savedObjectId', '{{alertId}}');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -218,6 +226,43 @@ const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<Resilient
errors={errors.title as string[]}
/>
</EuiFormRow>
{!isActionBeingConfiguredByAnAlert && (
<Fragment>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.savedObjectIdFieldLabel',
{
defaultMessage: 'Object ID (optional)',
}
)}
>
<EuiFormControlLayout
fullWidth
append={
<EuiIconTip
content={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.savedObjectIdFieldHelp',
{
defaultMessage:
'IBM Resilient will associate this action with the ID of a Kibana saved object.',
}
)}
/>
}
>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'savedObjectId'}
inputTargetValue={savedObjectId}
/>
</EuiFormControlLayout>
</EuiFormRow>
<EuiSpacer size="m" />
</Fragment>
)}
<TextAreaWithMessageVariables
index={index}
editAction={editSubActionProperty}

View file

@ -32,7 +32,7 @@ describe('ServiceNowParamsFields renders', () => {
errors={{ title: [] }}
editAction={() => {}}
index={0}
messageVariables={[]}
messageVariables={[{ name: 'alertId', description: '' }]}
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}
toastNotifications={mocks.notifications.toasts}
http={mocks.http}
@ -46,5 +46,41 @@ describe('ServiceNowParamsFields renders', () => {
expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="commentTextArea"]').length > 0).toBeTruthy();
// ensure savedObjectIdInput isnt rendered
expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length === 0).toBeTruthy();
});
test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => {
const mocks = coreMock.createSetup();
const actionParams = {
subAction: 'pushToService',
subActionParams: {
title: 'sn title',
description: 'some description',
comment: 'comment for sn',
severity: '1',
urgency: '2',
impact: '3',
savedObjectId: '123',
externalId: null,
},
};
const wrapper = mountWithIntl(
<ServiceNowParamsFields
actionParams={actionParams}
errors={{ title: [] }}
editAction={() => {}}
index={0}
messageVariables={[]}
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}
toastNotifications={mocks.notifications.toasts}
http={mocks.http}
/>
);
// ensure savedObjectIdInput isnt rendered
expect(wrapper.find('[data-test-subj="savedObjectIdInput"]').length > 0).toBeTruthy();
});
});

View file

@ -5,23 +5,34 @@
*/
import React, { Fragment, useEffect } from 'react';
import { EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiSelect } from '@elastic/eui';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiFlexItem } from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
import { EuiTitle } from '@elastic/eui';
import {
EuiFormRow,
EuiSelect,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiTitle,
EuiFormControlLayout,
EuiIconTip,
} from '@elastic/eui';
import { isSome } from 'fp-ts/lib/Option';
import { ActionParamsProps } from '../../../../types';
import { ServiceNowActionParams } from './types';
import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables';
import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables';
import { extractActionVariable } from '../extract_action_variable';
const ServiceNowParamsFields: React.FunctionComponent<ActionParamsProps<
ServiceNowActionParams
>> = ({ actionParams, editAction, index, errors, messageVariables }) => {
const { title, description, comment, severity, urgency, impact, savedObjectId } =
actionParams.subActionParams || {};
const isActionBeingConfiguredByAnAlert = messageVariables
? isSome(extractActionVariable(messageVariables, 'alertId'))
: false;
const selectOptions = [
{
value: '1',
@ -61,7 +72,7 @@ const ServiceNowParamsFields: React.FunctionComponent<ActionParamsProps<
if (!actionParams.subAction) {
editAction('subAction', 'pushToService', index);
}
if (!savedObjectId && messageVariables?.find((variable) => variable.name === 'alertId')) {
if (!savedObjectId && isActionBeingConfiguredByAnAlert) {
editSubActionProperty('savedObjectId', '{{alertId}}');
}
if (!urgency) {
@ -174,6 +185,43 @@ const ServiceNowParamsFields: React.FunctionComponent<ActionParamsProps<
errors={errors.title as string[]}
/>
</EuiFormRow>
{!isActionBeingConfiguredByAnAlert && (
<Fragment>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.savedObjectIdFieldLabel',
{
defaultMessage: 'Object ID (optional)',
}
)}
>
<EuiFormControlLayout
fullWidth
append={
<EuiIconTip
content={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.savedObjectIdFieldHelp',
{
defaultMessage:
'ServiceNow will associate this action with the ID of a Kibana saved object.',
}
)}
/>
}
>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'savedObjectId'}
inputTargetValue={savedObjectId}
/>
</EuiFormControlLayout>
</EuiFormRow>
<EuiSpacer size="m" />
</Fragment>
)}
<TextAreaWithMessageVariables
index={index}
editAction={editSubActionProperty}

View file

@ -76,7 +76,7 @@ export async function executeAction({
http: HttpSetup;
params: Record<string, unknown>;
}): Promise<ActionTypeExecutorResult<unknown>> {
return await http.post(`${BASE_ACTION_API_PATH}/action/${id}/_execute`, {
return http.post(`${BASE_ACTION_API_PATH}/action/${id}/_execute`, {
body: JSON.stringify({ params }),
});
}

View file

@ -32,7 +32,10 @@ import { updateActionConnector, executeAction } from '../../lib/action_connector
import { hasSaveActionsCapability } from '../../lib/capabilities';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
import { PLUGIN } from '../../constants/plugin';
import { ActionTypeExecutorResult } from '../../../../../actions/common';
import {
ActionTypeExecutorResult,
isActionTypeExecutorResult,
} from '../../../../../actions/common';
import './connector_edit_flyout.scss';
export interface ConnectorEditProps {
@ -204,13 +207,24 @@ export const ConnectorEditFlyout = ({
const onExecutAction = () => {
setIsExecutinAction(true);
return executeAction({ id: connector.id, params: testExecutionActionParams, http }).then(
(result) => {
return executeAction({ id: connector.id, params: testExecutionActionParams, http })
.then((result) => {
setIsExecutinAction(false);
setTestExecutionResult(some(result));
return result;
}
);
})
.catch((ex: Error | ActionTypeExecutorResult<unknown>) => {
const result: ActionTypeExecutorResult<unknown> = isActionTypeExecutorResult(ex)
? ex
: {
actionId: connector.id,
status: 'error',
message: ex.message,
};
setIsExecutinAction(false);
setTestExecutionResult(some(result));
return result;
});
};
const onSaveClicked = async (closeAfterSave: boolean = true) => {

View file

@ -351,25 +351,7 @@ export default function jiraTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]',
});
});
});
it('should handle failing with a simulated success without savedObjectId', async () => {
await supertest
.post(`/api/actions/action/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'pushToService', subActionParams: {} },
})
.then((resp: any) => {
expect(resp.body).to.eql({
actionId: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]',
});
});
});

View file

@ -352,25 +352,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [incidentTypes]\n- [4.subAction]: expected value to equal [severity]',
});
});
});
it('should handle failing with a simulated success without savedObjectId', async () => {
await supertest
.post(`/api/actions/action/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'pushToService', subActionParams: {} },
})
.then((resp: any) => {
expect(resp.body).to.eql({
actionId: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [incidentTypes]\n- [4.subAction]: expected value to equal [severity]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [incidentTypes]\n- [4.subAction]: expected value to equal [severity]',
});
});
});

View file

@ -343,25 +343,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]',
});
});
});
it('should handle failing with a simulated success without savedObjectId', async () => {
await supertest
.post(`/api/actions/action/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'pushToService', subActionParams: {} },
})
.then((resp: any) => {
expect(resp.body).to.eql({
actionId: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]',
});
});
});