mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[ResponseOps][Stack Connectors] Opsgenie UI phase 2 (#143480)
* Starting opsgenie backend * Adding more integration tests * Updating readme * Starting ui * Adding hash and alias * Fixing tests * Switch to platinum for now * Adding server side translations * Fixing merge issues * Fixing file location error * Working ui * Default alias is working * Almost working validation fails sometimes * Adding end to end tests * Adding more tests * Adding note and description fields * Removing todo * Adding in advanced sections * Adding tags and finish mode * Working editor and toggle * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * Refactoring code * Adding tests * Fixing tests and reordering input fields * Using io-ts for schema validation in ui * Adding more e2e tests and clean up * Fixing type errors * Adding spacing and label * Adding more tests and fixing come failure message errors * Making json editor errors more readable * Fixing errors and adding docs * Updating enabled action types * Update docs/management/connectors/action-types/opsgenie.asciidoc Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> * Update docs/management/connectors/action-types/opsgenie.asciidoc Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> * Update docs/management/connectors/action-types/opsgenie.asciidoc Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> * Update docs/management/connectors/action-types/opsgenie.asciidoc Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> * Update docs/management/connectors/action-types/opsgenie.asciidoc Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> * Update docs/management/connectors/action-types/opsgenie.asciidoc Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> * Addressing feedback * Adding new image with lowercase tags * Addressing feedback * Making executionMode optional * [CI] Auto-commit changed files from 'node scripts/generate codeowners' Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Tyler Smalley <tyler.smalley@elastic.co> Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com>
This commit is contained in:
parent
4b863abc18
commit
ae9dd59137
54 changed files with 2984 additions and 273 deletions
|
@ -27,6 +27,10 @@ a| <<teams-action-type,Microsoft Teams>>
|
|||
|
||||
| Send a message to a Microsoft Teams channel.
|
||||
|
||||
a| <<opsgenie-action-type,Opsgenie>>
|
||||
|
||||
| Create or close an alert in Opsgenie.
|
||||
|
||||
a| <<pagerduty-action-type,PagerDuty>>
|
||||
|
||||
| Send an event in PagerDuty.
|
||||
|
|
175
docs/management/connectors/action-types/opsgenie.asciidoc
Normal file
175
docs/management/connectors/action-types/opsgenie.asciidoc
Normal file
|
@ -0,0 +1,175 @@
|
|||
[role="xpack"]
|
||||
[[opsgenie-action-type]]
|
||||
=== Opsgenie connector and action
|
||||
++++
|
||||
<titleabbrev>Opsgenie</titleabbrev>
|
||||
++++
|
||||
|
||||
The Opsgenie connector uses the https://docs.opsgenie.com/docs/alert-api[Opsgenie alert API].
|
||||
|
||||
[float]
|
||||
[[opsgenie-connector-configuration]]
|
||||
==== Connector configuration
|
||||
|
||||
Opsgenie connectors have the following configuration properties.
|
||||
|
||||
Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action.
|
||||
URL:: The Opsgenie URL. For example, https://api.opsgenie.com or https://api.eu.opsgenie.com.
|
||||
+
|
||||
NOTE: If you are using the <<action-settings, `xpack.actions.allowedHosts`>> setting, make sure the hostname is added to the allowed hosts.
|
||||
API Key:: The Opsgenie API authentication key for HTTP Basic authentication. For more details about generating Opsgenie API keys, refer to https://support.atlassian.com/opsgenie/docs/create-a-default-api-integration/[Opsgenie documentation].
|
||||
|
||||
[float]
|
||||
[[opgenie-connector-networking-configuration]]
|
||||
==== Connector networking configuration
|
||||
|
||||
Use the <<action-settings, Action configuration settings>> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations.
|
||||
|
||||
[float]
|
||||
[[Preconfigured-opsgenie-configuration]]
|
||||
==== Preconfigured connector type
|
||||
|
||||
[source,text]
|
||||
--
|
||||
my-opsgenie:
|
||||
name: preconfigured-opsgenie-connector-type
|
||||
actionTypeId: .opsgenie
|
||||
config:
|
||||
apiUrl: https://api.opsgenie.com
|
||||
secrets:
|
||||
apiKey: apikey
|
||||
--
|
||||
|
||||
Config defines information for the connector type.
|
||||
|
||||
`apiUrl`:: A string that corresponds to *URL*.
|
||||
|
||||
Secrets defines sensitive information for the connector type.
|
||||
|
||||
`apiKey`:: A string that corresponds to *API Key*.
|
||||
|
||||
[float]
|
||||
[[define-opsgenie-ui]]
|
||||
==== Define connector in {stack-manage-app}
|
||||
|
||||
Define Opsgenie connector properties.
|
||||
|
||||
[role="screenshot"]
|
||||
image::management/connectors/images/opsgenie-connector.png[Opsgenie connector]
|
||||
|
||||
Test Opsgenie action parameters.
|
||||
|
||||
[role="screenshot"]
|
||||
image::management/connectors/images/opsgenie-params-test.png[Opsgenie params test]
|
||||
|
||||
[float]
|
||||
[[opsgenie-action-configuration]]
|
||||
==== Action configuration
|
||||
|
||||
The Opsgenie connector supports two types of actions: Create alert and Close alert. The properties supported for each action are different because Opsgenie defines different properties for each operation.
|
||||
|
||||
When testing the Opsgenie connector, choose the appropriate action from the selector. Each action has different properties that can be configured.
|
||||
|
||||
Action:: Select *Create alert* to configure the actions that occur when a rule's conditions are met. Select *Close alert* to define the recovery actions that occur when a rule's conditions are no longer met.
|
||||
|
||||
[float]
|
||||
[[opsgenie-action-create-alert-configuration]]
|
||||
===== Configure the create alert action
|
||||
|
||||
You can configure the create alert action through the form view or using a JSON editor.
|
||||
|
||||
[float]
|
||||
[[opsgenie-action-create-alert-form-configuration]]
|
||||
====== Form view
|
||||
|
||||
The create alert action form has the following configuration properties.
|
||||
|
||||
Message:: The message for the alert (required).
|
||||
Opsgenie tags:: The tags for the alert (optional).
|
||||
Priority:: The priority level for the alert.
|
||||
Description:: A description that provides detailed information about the alert (optional).
|
||||
Alias:: The alert identifier, which is used for alert de-duplication in Opsgenie. For more information, refer to the https://support.atlassian.com/opsgenie/docs/what-is-alert-de-duplication/[Opsgenie documentation] (optional).
|
||||
Entity:: The domain of the alert (optional).
|
||||
Source:: The source of the alert (optional).
|
||||
User:: The display name of the owner (optional).
|
||||
Note:: Additional information for the alert (optional).
|
||||
|
||||
[float]
|
||||
[[opsgenie-action-create-alert-json-configuration]]
|
||||
====== JSON editor
|
||||
|
||||
A JSON editor is provided as an alternative to the form view and supports additional fields not shown in the form view. The JSON editor supports all of the forms configuration properties but as lowercase keys as https://docs.opsgenie.com/docs/alert-api#create-alert[described in the Opsgenie API documentation]. The JSON editor supports the following additional properties:
|
||||
|
||||
responders:: The entities to receive notifications about the alert (optional).
|
||||
visibleTo:: The teams and users that the alert will be visible to without sending a notification to them (optional).
|
||||
actions:: The custom actions available to the alert (optional).
|
||||
details:: The custom properties of the alert (optional).
|
||||
|
||||
[float]
|
||||
[[opsgenie-action-create-alert-json-example-configuration]]
|
||||
Example JSON editor contents
|
||||
|
||||
[source,json]
|
||||
--
|
||||
{
|
||||
"message": "An example alert message",
|
||||
"alias": "Life is too short for no alias",
|
||||
"description":"Every alert needs a description",
|
||||
"responders":[
|
||||
{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c", "type":"team"},
|
||||
{"name":"NOC", "type":"team"},
|
||||
{"id":"bb4d9938-c3c2-455d-aaab-727aa701c0d8", "type":"user"},
|
||||
{"username":"trinity@opsgenie.com", "type":"user"},
|
||||
{"id":"aee8a0de-c80f-4515-a232-501c0bc9d715", "type":"escalation"},
|
||||
{"name":"Nightwatch Escalation", "type":"escalation"},
|
||||
{"id":"80564037-1984-4f38-b98e-8a1f662df552", "type":"schedule"},
|
||||
{"name":"First Responders Schedule", "type":"schedule"}
|
||||
],
|
||||
"visibleTo":[
|
||||
{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},
|
||||
{"name":"rocket_team","type":"team"},
|
||||
{"id":"bb4d9938-c3c2-455d-aaab-727aa701c0d8","type":"user"},
|
||||
{"username":"trinity@opsgenie.com","type":"user"}
|
||||
],
|
||||
"actions": ["Restart", "AnExampleAction"],
|
||||
"tags": ["OverwriteQuietHours","Critical"],
|
||||
"details":{"key1":"value1","key2":"value2"},
|
||||
"entity":"An example entity",
|
||||
"priority":"P1"
|
||||
}
|
||||
--
|
||||
|
||||
[float]
|
||||
[[opsgenie-action-close-alert-configuration]]
|
||||
===== Close alert configuration
|
||||
|
||||
The close alert action has the following configuration properties.
|
||||
|
||||
Alias:: The alert identifier, which is used for alert de-duplication in Opsgenie (required). The alias must match the value used when creating the alert. For more information, refer to the https://support.atlassian.com/opsgenie/docs/what-is-alert-de-duplication/[Opsgenie documentation].
|
||||
Note:: Additional information for the alert (optional).
|
||||
Source:: The display name of the source (optional).
|
||||
User:: The display name of the owner (optional).
|
||||
|
||||
[float]
|
||||
[[configuring-opsgenie]]
|
||||
==== Configure an Opsgenie account
|
||||
|
||||
After obtaining an Opsgenie instance, configure the API integration. For details, refer to the https://support.atlassian.com/opsgenie/docs/create-a-default-api-integration/[Opsgenie documentation].
|
||||
|
||||
After creating an Opsgenie instance, https://support.atlassian.com/opsgenie/docs/create-a-default-api-integration/[configure the API integration].
|
||||
|
||||
If you're using a free trial, go to the `Teams` dashboard and select the appropriate team.
|
||||
|
||||
image::management/connectors/images/opsgenie-teams.png[Opsgenie teams dashboard]
|
||||
|
||||
Select the `Integrations` menu item, then select `Add integration`.
|
||||
|
||||
image::management/connectors/images/opsgenie-integrations.png[Opsgenie teams integrations]
|
||||
|
||||
Search for `API` and select the `API` integration.
|
||||
|
||||
image::management/connectors/images/opsgenie-add-api-integration.png[Opsgenie API integration]
|
||||
|
||||
Configure the integration and ensure you record the `API Key`. This key will be used to populate the `API Key` field when creating the Kibana Opsgenie connector. Click `Save Integration` after you finish configuring the integration.
|
||||
|
||||
image::management/connectors/images/opsgenie-save-integration.png[Opsgenie save integration]
|
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
BIN
docs/management/connectors/images/opsgenie-connector.png
Normal file
BIN
docs/management/connectors/images/opsgenie-connector.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 54 KiB |
BIN
docs/management/connectors/images/opsgenie-integrations.png
Normal file
BIN
docs/management/connectors/images/opsgenie-integrations.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
docs/management/connectors/images/opsgenie-params-test.png
Normal file
BIN
docs/management/connectors/images/opsgenie-params-test.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 56 KiB |
BIN
docs/management/connectors/images/opsgenie-save-integration.png
Normal file
BIN
docs/management/connectors/images/opsgenie-save-integration.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 85 KiB |
BIN
docs/management/connectors/images/opsgenie-teams.png
Normal file
BIN
docs/management/connectors/images/opsgenie-teams.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
|
@ -12,5 +12,6 @@ include::action-types/swimlane.asciidoc[]
|
|||
include::action-types/slack.asciidoc[]
|
||||
include::action-types/webhook.asciidoc[]
|
||||
include::action-types/cases-webhook.asciidoc[leveloffset=+1]
|
||||
include::action-types/opsgenie.asciidoc[]
|
||||
include::action-types/xmatters.asciidoc[]
|
||||
include::pre-configured-connectors.asciidoc[]
|
||||
|
|
|
@ -131,7 +131,7 @@ A list of allowed email domains which can be used with the email connector. When
|
|||
WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not supported in {kib} 8.0, 8.1 or 8.2. As such, this setting should be removed before upgrading from 7.17 to 8.0, 8.1 or 8.2. It is possible to configure the settings in 7.17.4 and then upgrade to 8.3.0 directly.
|
||||
|
||||
`xpack.actions.enabledActionTypes` {ess-icon}::
|
||||
A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types.
|
||||
A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types.
|
||||
+
|
||||
Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function.
|
||||
|
||||
|
|
|
@ -146,9 +146,9 @@ export abstract class SubActionConnector<Config, Secrets> {
|
|||
`Request to external service failed. Connector Id: ${this.connector.id}. Connector type: ${this.connector.type}. Method: ${error.config.method}. URL: ${error.config.url}`
|
||||
);
|
||||
|
||||
const errorMessage = `Status code: ${error.status}. Message: ${this.getResponseErrorMessage(
|
||||
error
|
||||
)}`;
|
||||
const errorMessage = `Status code: ${
|
||||
error.status ?? error.response?.status
|
||||
}. Message: ${this.getResponseErrorMessage(error)}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* 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 { screen, render, within, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { CloseAlert } from './close_alert';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
describe('CloseAlert', () => {
|
||||
const editSubAction = jest.fn();
|
||||
const editOptionalSubAction = jest.fn();
|
||||
|
||||
const options = {
|
||||
showSaveError: false,
|
||||
errors: {
|
||||
'subActionParams.message': [],
|
||||
'subActionParams.alias': [],
|
||||
},
|
||||
index: 0,
|
||||
editSubAction,
|
||||
editOptionalSubAction,
|
||||
};
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('does not render the additional options by default', () => {
|
||||
render(<CloseAlert {...options} />);
|
||||
|
||||
expect(screen.queryByTestId('opsgenie-source-row')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the form fields by default', () => {
|
||||
render(<CloseAlert {...options} />);
|
||||
|
||||
expect(screen.getByTestId('opsgenie-alias-row')).toBeInTheDocument();
|
||||
expect(screen.getByText('Note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the form fields with the subActionParam values', () => {
|
||||
render(
|
||||
<CloseAlert
|
||||
{...{
|
||||
...options,
|
||||
subActionParams: {
|
||||
alias: 'an alias',
|
||||
note: 'a note',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('opsgenie-alias-row')).getByDisplayValue('an alias')
|
||||
).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('noteTextArea')).getByText('a note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the additional form fields with the subActionParam values', () => {
|
||||
render(
|
||||
<CloseAlert
|
||||
{...{
|
||||
...options,
|
||||
subActionParams: {
|
||||
source: 'a source',
|
||||
user: 'a user',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId('opsgenie-display-more-options'));
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('opsgenie-source-row')).getByDisplayValue('a source')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('opsgenie-user-row')).getByDisplayValue('a user')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['alias', 'aliasInput', 'an alias', editSubAction],
|
||||
['note', 'noteTextArea', 'a note', editOptionalSubAction],
|
||||
['source', 'sourceInput', 'a source', editOptionalSubAction],
|
||||
['user', 'userInput', 'a user', editOptionalSubAction],
|
||||
])(
|
||||
'calls the callback for field %s data-test-subj %s with input %s',
|
||||
(field, dataTestSubj, input, callback) => {
|
||||
render(<CloseAlert {...options} />);
|
||||
|
||||
userEvent.click(screen.getByTestId('opsgenie-display-more-options'));
|
||||
|
||||
fireEvent.change(screen.getByTestId(dataTestSubj), { target: { value: input } });
|
||||
|
||||
expect(callback.mock.calls[0]).toEqual([field, input, 0]);
|
||||
}
|
||||
);
|
||||
|
||||
it('shows the additional options when clicking the more options button', () => {
|
||||
render(<CloseAlert {...options} />);
|
||||
|
||||
userEvent.click(screen.getByTestId('opsgenie-display-more-options'));
|
||||
|
||||
expect(screen.getByTestId('opsgenie-source-row')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the message required error when showSaveError is true', async () => {
|
||||
render(
|
||||
<CloseAlert
|
||||
{...{
|
||||
...options,
|
||||
showSaveError: true,
|
||||
errors: {
|
||||
'subActionParams.alias': ['MessageError'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('MessageError')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* 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, { useCallback, useState } from 'react';
|
||||
import {
|
||||
ActionParamsProps,
|
||||
TextAreaWithMessageVariables,
|
||||
TextFieldWithMessageVariables,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer, RecursivePartial } from '@elastic/eui';
|
||||
import type {
|
||||
OpsgenieActionParams,
|
||||
OpsgenieCloseAlertParams,
|
||||
} from '../../../../server/connector_types/stack';
|
||||
import * as i18n from './translations';
|
||||
import { EditActionCallback } from './types';
|
||||
import { DisplayMoreOptions } from './display_more_options';
|
||||
|
||||
type AdditionalOptionsProps = Pick<
|
||||
CloseAlertProps,
|
||||
'subActionParams' | 'editOptionalSubAction' | 'index' | 'messageVariables'
|
||||
>;
|
||||
|
||||
const AdditionalOptions: React.FC<AdditionalOptionsProps> = ({
|
||||
subActionParams,
|
||||
editOptionalSubAction,
|
||||
index,
|
||||
messageVariables,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size={'m'} />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
data-test-subj="opsgenie-source-row"
|
||||
fullWidth
|
||||
label={i18n.SOURCE_FIELD_LABEL}
|
||||
>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editOptionalSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'source'}
|
||||
inputTargetValue={subActionParams?.source}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow data-test-subj="opsgenie-user-row" fullWidth label={i18n.USER_FIELD_LABEL}>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editOptionalSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'user'}
|
||||
inputTargetValue={subActionParams?.user}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AdditionalOptions.displayName = 'AdditionalOptions';
|
||||
|
||||
type CloseAlertProps = Pick<
|
||||
ActionParamsProps<OpsgenieActionParams>,
|
||||
'errors' | 'index' | 'messageVariables'
|
||||
> & {
|
||||
subActionParams?: RecursivePartial<OpsgenieCloseAlertParams>;
|
||||
editSubAction: EditActionCallback;
|
||||
editOptionalSubAction: EditActionCallback;
|
||||
showSaveError: boolean;
|
||||
};
|
||||
|
||||
const CloseAlertComponent: React.FC<CloseAlertProps> = ({
|
||||
editSubAction,
|
||||
editOptionalSubAction,
|
||||
errors,
|
||||
index,
|
||||
messageVariables,
|
||||
subActionParams,
|
||||
showSaveError,
|
||||
}) => {
|
||||
const isAliasInvalid =
|
||||
(errors['subActionParams.alias'] !== undefined &&
|
||||
errors['subActionParams.alias'].length > 0 &&
|
||||
subActionParams?.alias !== undefined) ||
|
||||
showSaveError;
|
||||
|
||||
const [showingMoreOptions, setShowingMoreOptions] = useState<boolean>(false);
|
||||
const toggleShowingMoreOptions = useCallback(
|
||||
() => setShowingMoreOptions((previousState) => !previousState),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow
|
||||
data-test-subj="opsgenie-alias-row"
|
||||
fullWidth
|
||||
error={errors['subActionParams.alias']}
|
||||
isInvalid={isAliasInvalid}
|
||||
label={i18n.ALIAS_REQUIRED_FIELD_LABEL}
|
||||
>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'alias'}
|
||||
inputTargetValue={subActionParams?.alias}
|
||||
errors={errors['subActionParams.alias'] as string[]}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<TextAreaWithMessageVariables
|
||||
index={index}
|
||||
editAction={editOptionalSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'note'}
|
||||
inputTargetValue={subActionParams?.note}
|
||||
label={i18n.NOTE_FIELD_LABEL}
|
||||
/>
|
||||
|
||||
{showingMoreOptions ? (
|
||||
<AdditionalOptions
|
||||
subActionParams={subActionParams}
|
||||
index={index}
|
||||
messageVariables={messageVariables}
|
||||
editOptionalSubAction={editOptionalSubAction}
|
||||
/>
|
||||
) : null}
|
||||
<EuiSpacer size={'m'} />
|
||||
<DisplayMoreOptions
|
||||
showingMoreOptions={showingMoreOptions}
|
||||
toggleShowingMoreOptions={toggleShowingMoreOptions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CloseAlertComponent.displayName = 'CloseAlert';
|
||||
|
||||
export const CloseAlert = React.memo(CloseAlertComponent);
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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 { screen, render, within, fireEvent } from '@testing-library/react';
|
||||
import { AdditionalOptions } from './additional_options';
|
||||
|
||||
describe('AdditionalOptions', () => {
|
||||
const editOptionalSubAction = jest.fn();
|
||||
|
||||
const options = {
|
||||
index: 0,
|
||||
editOptionalSubAction,
|
||||
};
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the component with empty states', () => {
|
||||
render(<AdditionalOptions {...options} />);
|
||||
|
||||
expect(screen.getByTestId('opsgenie-entity-row')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('opsgenie-source-row')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('opsgenie-user-row')).toBeInTheDocument();
|
||||
expect(screen.getByText('Note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with the subActionParams displayed in the fields', async () => {
|
||||
render(
|
||||
<AdditionalOptions
|
||||
{...{
|
||||
...options,
|
||||
subActionParams: {
|
||||
tags: ['super tag'],
|
||||
entity: 'entity',
|
||||
source: 'source',
|
||||
user: 'user',
|
||||
note: 'note',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('opsgenie-entity-row')).getByDisplayValue('entity')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('opsgenie-entity-row')).getByDisplayValue('entity')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('opsgenie-source-row')).getByDisplayValue('source')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('opsgenie-user-row')).getByDisplayValue('user')
|
||||
).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('noteTextArea')).getByText('note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['entity', 'entityInput', 'an entity'],
|
||||
['source', 'sourceInput', 'a source'],
|
||||
['user', 'userInput', 'a user'],
|
||||
['note', 'noteTextArea', 'a note'],
|
||||
])(
|
||||
'calls the callback for field %s data-test-subj %s with input %s',
|
||||
(field, dataTestSubj, input) => {
|
||||
render(<AdditionalOptions {...options} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId(dataTestSubj), { target: { value: input } });
|
||||
|
||||
expect(editOptionalSubAction.mock.calls[0]).toEqual([field, input, 0]);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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 {
|
||||
TextAreaWithMessageVariables,
|
||||
TextFieldWithMessageVariables,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer } from '@elastic/eui';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { CreateAlertProps } from '.';
|
||||
|
||||
type AdditionalOptionsProps = Pick<
|
||||
CreateAlertProps,
|
||||
'subActionParams' | 'editOptionalSubAction' | 'messageVariables' | 'index'
|
||||
>;
|
||||
|
||||
const AdditionalOptionsComponent: React.FC<AdditionalOptionsProps> = ({
|
||||
subActionParams,
|
||||
editOptionalSubAction,
|
||||
messageVariables,
|
||||
index,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size={'m'} />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
data-test-subj="opsgenie-entity-row"
|
||||
fullWidth
|
||||
label={i18n.ENTITY_FIELD_LABEL}
|
||||
>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editOptionalSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'entity'}
|
||||
inputTargetValue={subActionParams?.entity}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
data-test-subj="opsgenie-source-row"
|
||||
fullWidth
|
||||
label={i18n.SOURCE_FIELD_LABEL}
|
||||
>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editOptionalSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'source'}
|
||||
inputTargetValue={subActionParams?.source}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow data-test-subj="opsgenie-user-row" fullWidth label={i18n.USER_FIELD_LABEL}>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editOptionalSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'user'}
|
||||
inputTargetValue={subActionParams?.user}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<TextAreaWithMessageVariables
|
||||
index={index}
|
||||
editAction={editOptionalSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'note'}
|
||||
inputTargetValue={subActionParams?.note}
|
||||
label={i18n.NOTE_FIELD_LABEL}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AdditionalOptionsComponent.displayName = 'AdditionalOptions';
|
||||
|
||||
export const AdditionalOptions = React.memo(AdditionalOptionsComponent);
|
|
@ -0,0 +1,178 @@
|
|||
/*
|
||||
* 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 { screen, render, within, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { CreateAlert } from '.';
|
||||
import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const kibanaReactPath = '../../../../../../../../src/plugins/kibana_react/public';
|
||||
|
||||
jest.mock(kibanaReactPath, () => {
|
||||
const original = jest.requireActual(kibanaReactPath);
|
||||
return {
|
||||
...original,
|
||||
CodeEditor: (props: any) => {
|
||||
return <MockCodeEditor {...props} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('CreateAlert', () => {
|
||||
const editSubAction = jest.fn();
|
||||
const editAction = jest.fn();
|
||||
const editOptionalSubAction = jest.fn();
|
||||
|
||||
const options = {
|
||||
showSaveError: false,
|
||||
errors: {
|
||||
'subActionParams.message': [],
|
||||
'subActionParams.alias': [],
|
||||
},
|
||||
index: 0,
|
||||
editAction,
|
||||
editSubAction,
|
||||
editOptionalSubAction,
|
||||
};
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('does not render the json editor by default', () => {
|
||||
render(<CreateAlert {...options} />);
|
||||
|
||||
expect(screen.queryByTestId('actionJsonEditor')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the additional options by default', () => {
|
||||
render(<CreateAlert {...options} />);
|
||||
|
||||
expect(screen.queryByTestId('opsgenie-entity-row')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the form fields by default', () => {
|
||||
render(<CreateAlert {...options} />);
|
||||
|
||||
expect(screen.getByTestId('opsgenie-message-row')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('opsgenie-alias-row')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('opsgenie-tags')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('opsgenie-prioritySelect')).toBeInTheDocument();
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the form fields with the subActionParam values', () => {
|
||||
render(
|
||||
<CreateAlert
|
||||
{...{
|
||||
...options,
|
||||
subActionParams: {
|
||||
message: 'a message',
|
||||
tags: ['super tag'],
|
||||
alias: 'an alias',
|
||||
description: 'a description',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(within(screen.getByTestId('opsgenie-tags')).getByText('super tag')).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('opsgenie-message-row')).getByDisplayValue('a message')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('opsgenie-alias-row')).getByDisplayValue('an alias')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('descriptionTextArea')).getByText('a description')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['message', 'messageInput', 'a message', editSubAction],
|
||||
['alias', 'aliasInput', 'an alias', editOptionalSubAction],
|
||||
['description', 'descriptionTextArea', 'a description', editOptionalSubAction],
|
||||
])(
|
||||
'calls the callback for field %s data-test-subj %s with input %s',
|
||||
(field, dataTestSubj, input, callback) => {
|
||||
render(<CreateAlert {...options} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId(dataTestSubj), { target: { value: input } });
|
||||
|
||||
expect(callback.mock.calls[0]).toEqual([field, input, 0]);
|
||||
}
|
||||
);
|
||||
|
||||
it('shows the json editor when clicking the editor toggle', async () => {
|
||||
render(<CreateAlert {...options} />);
|
||||
|
||||
userEvent.click(screen.getByTestId('opsgenie-show-json-editor-toggle'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('actionJsonEditor')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('opsgenie-message-row')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('opsgenie-alias-row')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Description')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the additional options when clicking the more options button', () => {
|
||||
render(<CreateAlert {...options} />);
|
||||
|
||||
userEvent.click(screen.getByTestId('opsgenie-display-more-options'));
|
||||
|
||||
expect(screen.getByTestId('opsgenie-entity-row')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets the json editor error to undefined when the toggle is switched off', async () => {
|
||||
render(<CreateAlert {...options} />);
|
||||
|
||||
userEvent.click(screen.getByTestId('opsgenie-show-json-editor-toggle'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('actionJsonEditor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
userEvent.click(screen.getByTestId('opsgenie-show-json-editor-toggle'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('actionJsonEditor')).not.toBeInTheDocument();
|
||||
// first call to edit actions is because the editor was rendered and validation failed
|
||||
expect(editAction.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
"jsonEditorError",
|
||||
true,
|
||||
0,
|
||||
],
|
||||
Array [
|
||||
"jsonEditorError",
|
||||
undefined,
|
||||
0,
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the message required error when showSaveError is true', async () => {
|
||||
render(
|
||||
<CreateAlert
|
||||
{...{
|
||||
...options,
|
||||
showSaveError: true,
|
||||
errors: {
|
||||
'subActionParams.message': ['MessageError'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('MessageError')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,198 @@
|
|||
/*
|
||||
* 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, { lazy, Suspense, useCallback, useState } from 'react';
|
||||
import {
|
||||
ActionParamsProps,
|
||||
TextAreaWithMessageVariables,
|
||||
TextFieldWithMessageVariables,
|
||||
SectionLoading,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import {
|
||||
EuiErrorBoundary,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiSpacer,
|
||||
EuiSwitch,
|
||||
} from '@elastic/eui';
|
||||
import type {
|
||||
OpsgenieActionParams,
|
||||
OpsgenieCreateAlertParams,
|
||||
} from '../../../../../server/connector_types/stack';
|
||||
import * as i18n from './translations';
|
||||
import { EditActionCallback } from '../types';
|
||||
import { DisplayMoreOptions } from '../display_more_options';
|
||||
import { AdditionalOptions } from './additional_options';
|
||||
import { Tags } from './tags';
|
||||
import { Priority } from './priority';
|
||||
import type { JsonEditorProps } from './json_editor';
|
||||
|
||||
const JsonEditorLazy: React.FC<JsonEditorProps> = lazy(() => import('./json_editor'));
|
||||
|
||||
type FormViewProps = Omit<CreateAlertProps, 'editAction'>;
|
||||
|
||||
const FormView: React.FC<FormViewProps> = ({
|
||||
editSubAction,
|
||||
editOptionalSubAction,
|
||||
errors,
|
||||
index,
|
||||
messageVariables,
|
||||
subActionParams,
|
||||
showSaveError,
|
||||
}) => {
|
||||
const isMessageInvalid =
|
||||
(errors['subActionParams.message'] !== undefined &&
|
||||
errors['subActionParams.message'].length > 0 &&
|
||||
subActionParams?.message !== undefined) ||
|
||||
showSaveError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow
|
||||
data-test-subj="opsgenie-message-row"
|
||||
fullWidth
|
||||
error={errors['subActionParams.message']}
|
||||
label={i18n.MESSAGE_FIELD_LABEL}
|
||||
isInvalid={isMessageInvalid}
|
||||
>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'message'}
|
||||
inputTargetValue={subActionParams?.message}
|
||||
errors={errors['subActionParams.message'] as string[]}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size={'m'} />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<Tags values={subActionParams?.tags ?? []} onChange={editOptionalSubAction} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<Priority priority={subActionParams?.priority} onChange={editOptionalSubAction} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size={'m'} />
|
||||
<TextAreaWithMessageVariables
|
||||
index={index}
|
||||
editAction={editOptionalSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'description'}
|
||||
inputTargetValue={subActionParams?.description}
|
||||
label={i18n.DESCRIPTION_FIELD_LABEL}
|
||||
/>
|
||||
<EuiFormRow data-test-subj="opsgenie-alias-row" fullWidth label={i18n.ALIAS_FIELD_LABEL}>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editOptionalSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'alias'}
|
||||
inputTargetValue={subActionParams?.alias}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
FormView.displayName = 'FormView';
|
||||
|
||||
export type CreateAlertProps = Pick<
|
||||
ActionParamsProps<OpsgenieActionParams>,
|
||||
'errors' | 'index' | 'messageVariables' | 'editAction'
|
||||
> & {
|
||||
subActionParams?: Partial<OpsgenieCreateAlertParams>;
|
||||
editSubAction: EditActionCallback;
|
||||
editOptionalSubAction: EditActionCallback;
|
||||
showSaveError: boolean;
|
||||
};
|
||||
|
||||
const CreateAlertComponent: React.FC<CreateAlertProps> = ({
|
||||
editSubAction,
|
||||
editAction,
|
||||
editOptionalSubAction,
|
||||
errors,
|
||||
index,
|
||||
messageVariables,
|
||||
subActionParams,
|
||||
showSaveError,
|
||||
}) => {
|
||||
const [showingMoreOptions, setShowingMoreOptions] = useState<boolean>(false);
|
||||
const [showJsonEditor, setShowJsonEditor] = useState<boolean>(false);
|
||||
|
||||
const toggleShowJsonEditor = useCallback(
|
||||
(event) => {
|
||||
if (!event.target.checked) {
|
||||
// when the user switches back remove the json editor error if there was one
|
||||
// must mark as undefined to remove the field so it is not sent to the server side
|
||||
editAction('jsonEditorError', undefined, index);
|
||||
}
|
||||
setShowJsonEditor(event.target.checked);
|
||||
},
|
||||
[editAction, index]
|
||||
);
|
||||
|
||||
const toggleShowingMoreOptions = useCallback(
|
||||
() => setShowingMoreOptions((previousState) => !previousState),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size={'m'} />
|
||||
<EuiSwitch
|
||||
label={i18n.USE_JSON_EDITOR_LABEL}
|
||||
checked={showJsonEditor}
|
||||
onChange={toggleShowJsonEditor}
|
||||
data-test-subj="opsgenie-show-json-editor-toggle"
|
||||
/>
|
||||
<EuiSpacer size={'m'} />
|
||||
{showJsonEditor ? (
|
||||
<EuiErrorBoundary>
|
||||
<Suspense fallback={<SectionLoading>{i18n.LOADING_JSON_EDITOR}</SectionLoading>}>
|
||||
<JsonEditorLazy
|
||||
editAction={editAction}
|
||||
index={index}
|
||||
messageVariables={messageVariables}
|
||||
subActionParams={subActionParams}
|
||||
/>
|
||||
</Suspense>
|
||||
</EuiErrorBoundary>
|
||||
) : (
|
||||
<>
|
||||
<FormView
|
||||
editOptionalSubAction={editOptionalSubAction}
|
||||
editSubAction={editSubAction}
|
||||
errors={errors}
|
||||
index={index}
|
||||
messageVariables={messageVariables}
|
||||
subActionParams={subActionParams}
|
||||
showSaveError={showSaveError}
|
||||
/>
|
||||
{showingMoreOptions ? (
|
||||
<AdditionalOptions
|
||||
subActionParams={subActionParams}
|
||||
editOptionalSubAction={editOptionalSubAction}
|
||||
messageVariables={messageVariables}
|
||||
index={index}
|
||||
/>
|
||||
) : null}
|
||||
<EuiSpacer size={'m'} />
|
||||
<DisplayMoreOptions
|
||||
showingMoreOptions={showingMoreOptions}
|
||||
toggleShowingMoreOptions={toggleShowingMoreOptions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CreateAlertComponent.displayName = 'CreateAlert';
|
||||
|
||||
export const CreateAlert = React.memo(CreateAlertComponent);
|
|
@ -0,0 +1,211 @@
|
|||
/*
|
||||
* 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 { screen, render, within, fireEvent, waitFor } from '@testing-library/react';
|
||||
import JsonEditor from './json_editor';
|
||||
import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock';
|
||||
|
||||
const kibanaReactPath = '../../../../../../../../src/plugins/kibana_react/public';
|
||||
|
||||
jest.mock(kibanaReactPath, () => {
|
||||
const original = jest.requireActual(kibanaReactPath);
|
||||
return {
|
||||
...original,
|
||||
CodeEditor: (props: any) => {
|
||||
return <MockCodeEditor {...props} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('JsonEditor', () => {
|
||||
const editAction = jest.fn();
|
||||
|
||||
const options = {
|
||||
index: 0,
|
||||
editAction,
|
||||
};
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('sets the default value for the json editor to {}', () => {
|
||||
render(<JsonEditor {...options} />);
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('actionJsonEditor')).getByDisplayValue('{}')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays an error for the message field initially', async () => {
|
||||
render(<JsonEditor {...options} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('[message]: expected value of type [string] but got [undefined]')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls editActions setting the error state to true', async () => {
|
||||
render(<JsonEditor {...options} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('[message]: expected value of type [string] but got [undefined]')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editAction).toHaveBeenCalledWith('jsonEditorError', true, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls editActions setting the error state to true twice', async () => {
|
||||
render(<JsonEditor {...options} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), {
|
||||
target: { value: 'invalid json' },
|
||||
});
|
||||
|
||||
// first time is from the useEffect, second is from the fireEvent
|
||||
expect(editAction.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
"jsonEditorError",
|
||||
true,
|
||||
0,
|
||||
],
|
||||
Array [
|
||||
"jsonEditorError",
|
||||
true,
|
||||
0,
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('calls the callback when the json input is valid', async () => {
|
||||
render(<JsonEditor {...options} />);
|
||||
|
||||
const validJson = JSON.stringify({ message: 'awesome' });
|
||||
|
||||
fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), {
|
||||
target: { value: validJson },
|
||||
});
|
||||
|
||||
expect(editAction).toHaveBeenCalledWith('subActionParams', { message: 'awesome' }, 0);
|
||||
});
|
||||
|
||||
it('does not show an error when the message field is a valid non empty string', async () => {
|
||||
render(<JsonEditor {...options} />);
|
||||
|
||||
const validJson = JSON.stringify({ message: 'awesome' });
|
||||
|
||||
fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), {
|
||||
target: { value: validJson },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText('[message]: expected value of type [string] but got [undefined]')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error when the message field is only spaces', async () => {
|
||||
render(<JsonEditor {...options} />);
|
||||
|
||||
const validJson = JSON.stringify({ message: ' ' });
|
||||
|
||||
fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), {
|
||||
target: { value: validJson },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('[message]: must be populated with a value other than just whitespace')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls editAction setting editor error to true when validation fails', async () => {
|
||||
render(<JsonEditor {...options} />);
|
||||
|
||||
const validJson = JSON.stringify({
|
||||
tags: 'tags should be an array not a string',
|
||||
message: 'a message',
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), {
|
||||
target: { value: validJson },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('Invalid value "tags should be an array not a string" supplied to "tags"')
|
||||
).toBeInTheDocument();
|
||||
expect(editAction.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
"jsonEditorError",
|
||||
true,
|
||||
0,
|
||||
],
|
||||
Array [
|
||||
"jsonEditorError",
|
||||
true,
|
||||
0,
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('calls the callback with only the message field after editing the json', async () => {
|
||||
render(
|
||||
<JsonEditor
|
||||
{...{
|
||||
...options,
|
||||
subActionParams: {
|
||||
message: 'a message',
|
||||
alias: 'an alias',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const validJson = JSON.stringify({
|
||||
message: 'a new message',
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), {
|
||||
target: { value: validJson },
|
||||
});
|
||||
|
||||
expect(editAction).toHaveBeenCalledWith('subActionParams', { message: 'a new message' }, 0);
|
||||
});
|
||||
|
||||
it('sets the editor error to undefined when validation succeeds', async () => {
|
||||
render(
|
||||
<JsonEditor
|
||||
{...{
|
||||
...options,
|
||||
subActionParams: {
|
||||
message: 'a message',
|
||||
alias: 'an alias',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editAction.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
"jsonEditorError",
|
||||
undefined,
|
||||
0,
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* 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, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { JsonEditorWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import type { OpsgenieCreateAlertParams } from '../../../../../server/connector_types/stack';
|
||||
import * as i18n from './translations';
|
||||
import { CreateAlertProps } from '.';
|
||||
import { decodeCreateAlert, isDecodeError } from './schema';
|
||||
|
||||
export type JsonEditorProps = Pick<
|
||||
CreateAlertProps,
|
||||
'editAction' | 'index' | 'messageVariables' | 'subActionParams'
|
||||
>;
|
||||
|
||||
const JsonEditorComponent: React.FC<JsonEditorProps> = ({
|
||||
editAction,
|
||||
index,
|
||||
messageVariables,
|
||||
subActionParams,
|
||||
}) => {
|
||||
const [jsonEditorErrors, setJsonEditorErrors] = useState<string[]>([]);
|
||||
|
||||
const jsonEditorValue = useMemo(() => getJsonEditorValue(subActionParams), [subActionParams]);
|
||||
|
||||
const decodeJsonWithSchema = useCallback((jsonBlob: unknown) => {
|
||||
try {
|
||||
const decodedValue = decodeCreateAlert(jsonBlob);
|
||||
setJsonEditorErrors([]);
|
||||
return decodedValue;
|
||||
} catch (error) {
|
||||
if (isDecodeError(error)) {
|
||||
setJsonEditorErrors(error.decodeErrors);
|
||||
} else {
|
||||
setJsonEditorErrors([error.message]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onAdvancedEditorChange = useCallback(
|
||||
(json: string) => {
|
||||
const parsedJson = parseJson(json);
|
||||
if (!parsedJson) {
|
||||
editAction('jsonEditorError', true, index);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedValue = decodeJsonWithSchema(parsedJson);
|
||||
if (!decodedValue) {
|
||||
editAction('jsonEditorError', true, index);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
editAction('subActionParams', decodedValue, index);
|
||||
},
|
||||
[editAction, index, decodeJsonWithSchema]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// show the initial error messages
|
||||
const decodedValue = decodeJsonWithSchema(subActionParams ?? {});
|
||||
if (!decodedValue) {
|
||||
editAction('jsonEditorError', true, index);
|
||||
} else {
|
||||
// must mark as undefined to remove the field so it is not sent to the server side
|
||||
editAction('jsonEditorError', undefined, index);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subActionParams, decodeJsonWithSchema, index]);
|
||||
|
||||
return (
|
||||
<JsonEditorWithMessageVariables
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'subActionParams'}
|
||||
inputTargetValue={jsonEditorValue}
|
||||
aria-label={i18n.JSON_EDITOR_ARIA}
|
||||
onDocumentsChange={onAdvancedEditorChange}
|
||||
errors={jsonEditorErrors}
|
||||
label={i18n.ALERT_FIELDS_LABEL}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
JsonEditorComponent.displayName = 'JsonEditor';
|
||||
|
||||
const JsonEditor = React.memo(JsonEditorComponent);
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { JsonEditor as default };
|
||||
|
||||
const parseJson = (jsonValue: string): Record<string, unknown> | undefined => {
|
||||
try {
|
||||
return JSON.parse(jsonValue);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const getJsonEditorValue = (subActionParams?: Partial<OpsgenieCreateAlertParams>) => {
|
||||
const defaultValue = '{}';
|
||||
try {
|
||||
const value = JSON.stringify(subActionParams, null, 2);
|
||||
if (isEmpty(value)) {
|
||||
return defaultValue;
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 { screen, render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Priority } from './priority';
|
||||
|
||||
describe('Priority', () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
const options = {
|
||||
priority: undefined,
|
||||
onChange,
|
||||
};
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the priority selectable', () => {
|
||||
render(<Priority {...options} />);
|
||||
|
||||
expect(screen.getByTestId('opsgenie-prioritySelect')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when P1 is selected', async () => {
|
||||
render(<Priority {...options} />);
|
||||
|
||||
userEvent.selectOptions(screen.getByTestId('opsgenie-prioritySelect'), 'P1');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onChange.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"priority",
|
||||
"P1",
|
||||
]
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* 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, { useCallback } from 'react';
|
||||
|
||||
import { EuiFormRow, EuiSelect } from '@elastic/eui';
|
||||
|
||||
import type { OpsgenieCreateAlertParams } from '../../../../../server/connector_types/stack';
|
||||
import * as i18n from './translations';
|
||||
import { EditActionCallback } from '../types';
|
||||
|
||||
interface PriorityComponentProps {
|
||||
priority: OpsgenieCreateAlertParams['priority'];
|
||||
onChange: EditActionCallback;
|
||||
}
|
||||
|
||||
const PriorityComponent: React.FC<PriorityComponentProps> = ({ priority, onChange }) => {
|
||||
const onPriorityChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
onChange('priority', event.target.value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow fullWidth label={i18n.PRIORITY_LABEL} data-test-subj="opsgenie-priority-row">
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="opsgenie-prioritySelect"
|
||||
options={priorityOptions}
|
||||
hasNoInitialSelection={true}
|
||||
value={priority}
|
||||
onChange={onPriorityChange}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
||||
|
||||
PriorityComponent.displayName = 'Priority';
|
||||
|
||||
export const Priority = React.memo(PriorityComponent);
|
||||
|
||||
const priorityOptions = [
|
||||
{
|
||||
value: i18n.PRIORITY_1,
|
||||
text: i18n.PRIORITY_1,
|
||||
['data-test-subj']: 'opsgenie-priority-p1',
|
||||
},
|
||||
{
|
||||
value: i18n.PRIORITY_2,
|
||||
text: i18n.PRIORITY_2,
|
||||
['data-test-subj']: 'opsgenie-priority-p2',
|
||||
},
|
||||
{
|
||||
value: i18n.PRIORITY_3,
|
||||
text: i18n.PRIORITY_3,
|
||||
['data-test-subj']: 'opsgenie-priority-p3',
|
||||
},
|
||||
{
|
||||
value: i18n.PRIORITY_4,
|
||||
text: i18n.PRIORITY_4,
|
||||
['data-test-subj']: 'opsgenie-priority-p4',
|
||||
},
|
||||
{
|
||||
value: i18n.PRIORITY_5,
|
||||
text: i18n.PRIORITY_5,
|
||||
['data-test-subj']: 'opsgenie-priority-p5',
|
||||
},
|
||||
];
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* 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 { decodeCreateAlert } from './schema';
|
||||
import {
|
||||
OpsgenieCreateAlertExample,
|
||||
ValidCreateAlertSchema,
|
||||
} from '../../../../../server/connector_types/stack/opsgenie/test_schema';
|
||||
|
||||
describe('decodeCreateAlert', () => {
|
||||
it('throws an error when the message field is not present', () => {
|
||||
expect(() => decodeCreateAlert({ alias: '123' })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[message]: expected value of type [string] but got [undefined]"`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when the message field is only spaces', () => {
|
||||
expect(() => decodeCreateAlert({ message: ' ' })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[message]: must be populated with a value other than just whitespace"`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when the message field is an empty string', () => {
|
||||
expect(() => decodeCreateAlert({ message: '' })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"[message]: must be populated with a value other than just whitespace"`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when additional fields are present in the data that are not defined in the schema', () => {
|
||||
expect(() =>
|
||||
decodeCreateAlert({ invalidField: 'hi', message: 'hi' })
|
||||
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
|
||||
});
|
||||
|
||||
it('throws an error when additional fields are present in responders with name field than in the schema', () => {
|
||||
expect(() =>
|
||||
decodeCreateAlert({
|
||||
message: 'hi',
|
||||
responders: [{ name: 'sam', type: 'team', invalidField: 'scott' }],
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
|
||||
});
|
||||
|
||||
it('throws an error when additional fields are present in responders with id field than in the schema', () => {
|
||||
expect(() =>
|
||||
decodeCreateAlert({
|
||||
message: 'hi',
|
||||
responders: [{ id: 'id', type: 'team', invalidField: 'scott' }],
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
|
||||
});
|
||||
|
||||
it('throws an error when additional fields are present in visibleTo with name and type=team', () => {
|
||||
expect(() =>
|
||||
decodeCreateAlert({
|
||||
message: 'hi',
|
||||
visibleTo: [{ name: 'sam', type: 'team', invalidField: 'scott' }],
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
|
||||
});
|
||||
|
||||
it('throws an error when additional fields are present in visibleTo with id and type=team', () => {
|
||||
expect(() =>
|
||||
decodeCreateAlert({
|
||||
message: 'hi',
|
||||
visibleTo: [{ id: 'id', type: 'team', invalidField: 'scott' }],
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
|
||||
});
|
||||
|
||||
it('throws an error when additional fields are present in visibleTo with id and type=user', () => {
|
||||
expect(() =>
|
||||
decodeCreateAlert({
|
||||
message: 'hi',
|
||||
visibleTo: [{ id: 'id', type: 'user', invalidField: 'scott' }],
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
|
||||
});
|
||||
|
||||
it('throws an error when additional fields are present in visibleTo with username and type=user', () => {
|
||||
expect(() =>
|
||||
decodeCreateAlert({
|
||||
message: 'hi',
|
||||
visibleTo: [{ username: 'sam', type: 'user', invalidField: 'scott' }],
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`);
|
||||
});
|
||||
|
||||
it('throws an error when details is a record of string to number', () => {
|
||||
expect(() =>
|
||||
decodeCreateAlert({
|
||||
message: 'hi',
|
||||
details: { id: 1 },
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Invalid value \\"1\\" supplied to \\"details.id\\""`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ValidCreateAlertSchema', ValidCreateAlertSchema],
|
||||
['OpsgenieCreateAlertExample', OpsgenieCreateAlertExample],
|
||||
])('validates the test object [%s] correctly', (objectName, testObject) => {
|
||||
expect(() => decodeCreateAlert(testObject)).not.toThrow();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* 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 { Either, fold } from 'fp-ts/lib/Either';
|
||||
import { pipe } from 'fp-ts/lib/pipeable';
|
||||
import * as rt from 'io-ts';
|
||||
import { exactCheck } from '@kbn/securitysolution-io-ts-utils';
|
||||
import { identity } from 'fp-ts/lib/function';
|
||||
import { isEmpty, isObject } from 'lodash';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const MessageNonEmptyString = new rt.Type<string, string, unknown>(
|
||||
'MessageNonEmptyString',
|
||||
rt.string.is,
|
||||
(input, context): Either<rt.Errors, string> => {
|
||||
if (input === undefined) {
|
||||
return rt.failure(input, context, i18n.MESSAGE_NOT_DEFINED);
|
||||
} else if (typeof input !== 'string') {
|
||||
return rt.failure(input, context);
|
||||
} else if (isEmpty(input.trim())) {
|
||||
return rt.failure(input, context, i18n.MESSAGE_NON_WHITESPACE);
|
||||
} else {
|
||||
return rt.success(input);
|
||||
}
|
||||
},
|
||||
rt.identity
|
||||
);
|
||||
|
||||
const ResponderTypes = rt.union([
|
||||
rt.literal('team'),
|
||||
rt.literal('user'),
|
||||
rt.literal('escalation'),
|
||||
rt.literal('schedule'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* This schema is duplicated from the server. The only difference is that it is using io-ts vs kbn-schema.
|
||||
* NOTE: This schema must be the same as defined here: x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts
|
||||
*
|
||||
* The reason it is duplicated here is because the server uses kbn-schema which uses Joi under the hood. If we import
|
||||
* Joi on the frontend it will cause ~500KB of data to be loaded on page loads. To avoid this we'll use io-ts in the frontend.
|
||||
* Ideally we could use io-ts in the backend as well but the server requires kbn-schema to be used.
|
||||
*
|
||||
* Issue: https://github.com/elastic/kibana/issues/143891
|
||||
*
|
||||
* For more information on the Opsgenie create alert schema see: https://docs.opsgenie.com/docs/alert-api#create-alert
|
||||
*/
|
||||
const CreateAlertSchema = rt.intersection([
|
||||
rt.strict({ message: MessageNonEmptyString }),
|
||||
rt.exact(
|
||||
rt.partial({
|
||||
alias: rt.string,
|
||||
description: rt.string,
|
||||
responders: rt.array(
|
||||
rt.union([
|
||||
rt.strict({ name: rt.string, type: ResponderTypes }),
|
||||
rt.strict({ id: rt.string, type: ResponderTypes }),
|
||||
rt.strict({ username: rt.string, type: rt.literal('user') }),
|
||||
])
|
||||
),
|
||||
visibleTo: rt.array(
|
||||
rt.union([
|
||||
rt.strict({ name: rt.string, type: rt.literal('team') }),
|
||||
rt.strict({ id: rt.string, type: rt.literal('team') }),
|
||||
rt.strict({ id: rt.string, type: rt.literal('user') }),
|
||||
rt.strict({ username: rt.string, type: rt.literal('user') }),
|
||||
])
|
||||
),
|
||||
actions: rt.array(rt.string),
|
||||
tags: rt.array(rt.string),
|
||||
details: rt.record(rt.string, rt.string),
|
||||
entity: rt.string,
|
||||
source: rt.string,
|
||||
priority: rt.union([
|
||||
rt.literal('P1'),
|
||||
rt.literal('P2'),
|
||||
rt.literal('P3'),
|
||||
rt.literal('P4'),
|
||||
rt.literal('P5'),
|
||||
]),
|
||||
user: rt.string,
|
||||
note: rt.string,
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
export const formatErrors = (errors: rt.Errors): string[] => {
|
||||
const err = errors.map((error) => {
|
||||
if (error.message != null) {
|
||||
return error.message;
|
||||
} else {
|
||||
const keyContext = error.context
|
||||
.filter(
|
||||
(entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== ''
|
||||
)
|
||||
.map((entry) => entry.key)
|
||||
.join('.');
|
||||
|
||||
const nameContext = error.context.find(
|
||||
(entry) => entry.type != null && entry.type.name != null && entry.type.name.length > 0
|
||||
);
|
||||
|
||||
const suppliedValue =
|
||||
keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : '';
|
||||
const value = isObject(error.value) ? JSON.stringify(error.value) : error.value;
|
||||
return `Invalid value "${value}" supplied to "${suppliedValue}"`;
|
||||
}
|
||||
});
|
||||
|
||||
return [...new Set(err)];
|
||||
};
|
||||
|
||||
type CreateAlertSchemaType = rt.TypeOf<typeof CreateAlertSchema>;
|
||||
|
||||
export const decodeCreateAlert = (data: unknown): CreateAlertSchemaType => {
|
||||
const onLeft = (errors: rt.Errors) => {
|
||||
throw new DecodeError(formatErrors(errors));
|
||||
};
|
||||
|
||||
const onRight = (a: CreateAlertSchemaType): CreateAlertSchemaType => identity(a);
|
||||
|
||||
return pipe(
|
||||
CreateAlertSchema.decode(data),
|
||||
(decoded) => exactCheck(data, decoded),
|
||||
fold(onLeft, onRight)
|
||||
);
|
||||
};
|
||||
|
||||
export class DecodeError extends Error {
|
||||
constructor(public readonly decodeErrors: string[]) {
|
||||
super(decodeErrors.join());
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
export function isDecodeError(error: unknown): error is DecodeError {
|
||||
return error instanceof DecodeError;
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* 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 { screen, render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Tags } from './tags';
|
||||
|
||||
describe('Tags', () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
const options = {
|
||||
values: [],
|
||||
onChange,
|
||||
};
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders tags initially', () => {
|
||||
render(<Tags {...{ ...options, values: ['super', 'hello'] }} />);
|
||||
|
||||
expect(screen.getByText('super')).toBeInTheDocument();
|
||||
expect(screen.getByText('hello')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the tags', async () => {
|
||||
render(<Tags {...{ ...options, values: ['super', 'hello'] }} />);
|
||||
|
||||
userEvent.click(screen.getByTestId('comboBoxClearButton'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onChange.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"tags",
|
||||
Array [],
|
||||
]
|
||||
`)
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onChange when removing a tag', async () => {
|
||||
render(<Tags {...{ ...options, values: ['super', 'hello'] }} />);
|
||||
|
||||
userEvent.click(screen.getByTitle('Remove super from selection in this group'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onChange.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"tags",
|
||||
Array [
|
||||
"hello",
|
||||
],
|
||||
]
|
||||
`)
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onChange when adding a tag', async () => {
|
||||
render(<Tags {...options} />);
|
||||
|
||||
userEvent.click(screen.getByTestId('opsgenie-tags'));
|
||||
userEvent.type(screen.getByTestId('comboBoxSearchInput'), 'awesome{enter}');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onChange.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"tags",
|
||||
Array [
|
||||
"awesome",
|
||||
],
|
||||
]
|
||||
`)
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { EditActionCallback } from '../types';
|
||||
|
||||
interface TagsProps {
|
||||
onChange: EditActionCallback;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
const TagsComponent: React.FC<TagsProps> = ({ onChange, values }) => {
|
||||
const tagOptions = useMemo(() => values.map((value) => getTagAsOption(value)), [values]);
|
||||
|
||||
const onCreateOption = useCallback(
|
||||
(tagValue: string) => {
|
||||
const newTags = [...tagOptions, getTagAsOption(tagValue)];
|
||||
onChange(
|
||||
'tags',
|
||||
newTags.map((tag) => tag.label)
|
||||
);
|
||||
},
|
||||
[onChange, tagOptions]
|
||||
);
|
||||
|
||||
const onTagsChange = useCallback(
|
||||
(newOptions: EuiComboBoxOptionOption[]) => {
|
||||
onChange(
|
||||
'tags',
|
||||
newOptions.map((option) => option.label)
|
||||
);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
data-test-subj="opsgenie-tags-row"
|
||||
fullWidth
|
||||
label={i18n.TAGS_FIELD_LABEL}
|
||||
helpText={i18n.TAGS_HELP}
|
||||
>
|
||||
<EuiComboBox
|
||||
fullWidth
|
||||
isClearable
|
||||
noSuggestions
|
||||
selectedOptions={tagOptions}
|
||||
onCreateOption={onCreateOption}
|
||||
onChange={onTagsChange}
|
||||
data-test-subj="opsgenie-tags"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
||||
|
||||
TagsComponent.displayName = 'Tags';
|
||||
|
||||
export const Tags = React.memo(TagsComponent);
|
||||
|
||||
const getTagAsOption = (value: string) => ({ label: value, key: value });
|
|
@ -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 { i18n } from '@kbn/i18n';
|
||||
export * from '../translations';
|
||||
|
||||
export const MESSAGE_NOT_DEFINED = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.messageNotDefined',
|
||||
{ defaultMessage: '[message]: expected value of type [string] but got [undefined]' }
|
||||
);
|
||||
|
||||
export const MESSAGE_NON_WHITESPACE = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.messageNotWhitespace',
|
||||
{ defaultMessage: '[message]: must be populated with a value other than just whitespace' }
|
||||
);
|
||||
|
||||
export const LOADING_JSON_EDITOR = i18n.translate(
|
||||
'xpack.stackConnectors.sections.ospgenie.loadingJsonEditor',
|
||||
{ defaultMessage: 'Loading JSON editor' }
|
||||
);
|
||||
|
||||
export const MESSAGE_FIELD_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.messageLabel',
|
||||
{
|
||||
defaultMessage: 'Message (required)',
|
||||
}
|
||||
);
|
||||
|
||||
export const DESCRIPTION_FIELD_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.descriptionLabel',
|
||||
{
|
||||
defaultMessage: 'Description',
|
||||
}
|
||||
);
|
||||
|
||||
export const MESSAGE_FIELD_IS_REQUIRED = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.messageFieldRequired',
|
||||
{
|
||||
defaultMessage: '"message" field must be populated with a value other than just whitespace',
|
||||
}
|
||||
);
|
||||
|
||||
export const USE_JSON_EDITOR_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.useJsonEditorLabel',
|
||||
{
|
||||
defaultMessage: 'Use JSON editor',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERT_FIELDS_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.alertFieldsLabel',
|
||||
{
|
||||
defaultMessage: 'Alert fields',
|
||||
}
|
||||
);
|
||||
|
||||
export const JSON_EDITOR_ARIA = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.jsonEditorAriaLabel',
|
||||
{
|
||||
defaultMessage: 'JSON editor',
|
||||
}
|
||||
);
|
||||
|
||||
export const ENTITY_FIELD_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.entityLabel',
|
||||
{
|
||||
defaultMessage: 'Entity',
|
||||
}
|
||||
);
|
||||
|
||||
export const TAGS_HELP = i18n.translate('xpack.stackConnectors.components.opsgenie.tagsHelp', {
|
||||
defaultMessage:
|
||||
'Type one or more custom identifying tags for this case. Press enter after each tag to begin a new one.',
|
||||
});
|
||||
|
||||
export const TAGS_FIELD_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.tagsLabel',
|
||||
{ defaultMessage: 'Opsgenie tags' }
|
||||
);
|
||||
|
||||
export const PRIORITY_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.priorityLabel',
|
||||
{
|
||||
defaultMessage: 'Priority',
|
||||
}
|
||||
);
|
||||
|
||||
export const PRIORITY_1 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority1', {
|
||||
defaultMessage: 'P1',
|
||||
});
|
||||
|
||||
export const PRIORITY_2 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority2', {
|
||||
defaultMessage: 'P2',
|
||||
});
|
||||
|
||||
export const PRIORITY_3 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority3', {
|
||||
defaultMessage: 'P3',
|
||||
});
|
||||
|
||||
export const PRIORITY_4 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority4', {
|
||||
defaultMessage: 'P4',
|
||||
});
|
||||
|
||||
export const PRIORITY_5 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority5', {
|
||||
defaultMessage: 'P5',
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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 { screen, render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DisplayMoreOptions } from './display_more_options';
|
||||
|
||||
describe('DisplayMoreOptions', () => {
|
||||
const toggleShowingMoreOptions = jest.fn();
|
||||
|
||||
const options = {
|
||||
showingMoreOptions: false,
|
||||
toggleShowingMoreOptions,
|
||||
};
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders the more options text', () => {
|
||||
render(<DisplayMoreOptions {...options} />);
|
||||
|
||||
expect(screen.getByText('More options')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the hide options text', () => {
|
||||
render(<DisplayMoreOptions {...{ ...options, showingMoreOptions: true }} />);
|
||||
|
||||
expect(screen.getByText('Hide options')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls toggleShowingMoreOptions when clicked', () => {
|
||||
render(<DisplayMoreOptions {...options} />);
|
||||
|
||||
userEvent.click(screen.getByTestId('opsgenie-display-more-options'));
|
||||
|
||||
expect(toggleShowingMoreOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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 { EuiButtonEmpty } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface DisplayMoreOptionsProps {
|
||||
showingMoreOptions: boolean;
|
||||
toggleShowingMoreOptions: () => void;
|
||||
}
|
||||
|
||||
const DisplayMoreOptionsComponent: React.FC<DisplayMoreOptionsProps> = ({
|
||||
showingMoreOptions,
|
||||
toggleShowingMoreOptions,
|
||||
}) => {
|
||||
return (
|
||||
<EuiButtonEmpty
|
||||
color="primary"
|
||||
iconSide="right"
|
||||
iconType={showingMoreOptions ? 'arrowUp' : 'arrowDown'}
|
||||
flush="left"
|
||||
onClick={toggleShowingMoreOptions}
|
||||
data-test-subj="opsgenie-display-more-options"
|
||||
>
|
||||
{showingMoreOptions ? i18n.HIDE_OPTIONS : i18n.MORE_OPTIONS}
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
};
|
||||
|
||||
DisplayMoreOptionsComponent.displayName = 'MoreOptions';
|
||||
|
||||
export const DisplayMoreOptions = React.memo(DisplayMoreOptionsComponent);
|
|
@ -5,11 +5,56 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiIcon } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { LogoProps } from '../../types';
|
||||
|
||||
const Logo = (props: LogoProps) => <EuiIcon type="casesApp" size="xl" />;
|
||||
const Logo = (props: LogoProps) => (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 305 305"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M152.496 152.116C194.502 152.116 228.554 118.064 228.554 76.0579C228.554 34.0523 194.502 0 152.496 0C110.491 0 76.4382 34.0523 76.4382 76.0579C76.4382 118.064 110.491 152.116 152.496 152.116Z"
|
||||
fill="url(#paint0_linear_3056_223288)"
|
||||
/>
|
||||
<path
|
||||
d="M146.016 302.953C96.3433 271.266 55.0322 228.083 25.5766 177.056C24.438 175.011 24.1904 172.587 24.8918 170.354C25.5933 168.121 27.1821 166.274 29.2857 165.247L86.8833 136.981C90.9123 135.016 95.7746 136.507 98.0106 140.392C127.131 189.198 170.19 228.174 221.647 252.304C202.67 271.425 181.654 288.41 158.976 302.953C155.011 305.429 149.981 305.429 146.016 302.953Z"
|
||||
fill="url(#paint1_linear_3056_223288)"
|
||||
/>
|
||||
<path
|
||||
d="M158.976 302.953C208.655 271.274 249.968 228.089 279.416 177.056C280.557 175.016 280.811 172.597 280.118 170.364C279.425 168.132 277.845 166.282 275.749 165.247L218.109 136.981C214.08 135.016 209.217 136.507 206.981 140.392C177.867 189.203 134.806 228.181 83.3447 252.304C102.311 271.438 123.328 288.424 146.016 302.953C149.981 305.429 155.011 305.429 158.976 302.953Z"
|
||||
fill="#2684FF"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_3056_223288"
|
||||
x1="152.496"
|
||||
y1="25.2816"
|
||||
x2="152.496"
|
||||
y2="181.448"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2684FF" />
|
||||
<stop offset="0.82" stopColor="#0052CC" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_3056_223288"
|
||||
x1="105.685"
|
||||
y1="188.682"
|
||||
x2="146.589"
|
||||
y2="274.292"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2684FF" />
|
||||
<stop offset="0.62" stopColor="#0052CC" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { Logo as default };
|
||||
|
|
|
@ -41,6 +41,7 @@ describe('opsgenie action params validation', () => {
|
|||
errors: {
|
||||
'subActionParams.message': [],
|
||||
'subActionParams.alias': [],
|
||||
jsonEditorError: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -57,6 +58,7 @@ describe('opsgenie action params validation', () => {
|
|||
errors: {
|
||||
'subActionParams.message': [],
|
||||
'subActionParams.alias': [],
|
||||
jsonEditorError: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -71,6 +73,7 @@ describe('opsgenie action params validation', () => {
|
|||
errors: {
|
||||
'subActionParams.message': ['Message is required.'],
|
||||
'subActionParams.alias': [],
|
||||
jsonEditorError: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -85,6 +88,21 @@ describe('opsgenie action params validation', () => {
|
|||
errors: {
|
||||
'subActionParams.message': [],
|
||||
'subActionParams.alias': ['Alias is required.'],
|
||||
jsonEditorError: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the jsonEditorError when the jsonEditorError field is set to true', async () => {
|
||||
const actionParams = {
|
||||
jsonEditorError: true,
|
||||
};
|
||||
|
||||
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
|
||||
errors: {
|
||||
'subActionParams.message': [],
|
||||
'subActionParams.alias': [],
|
||||
jsonEditorError: ['JSON editor error exists'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,14 +11,13 @@ import {
|
|||
ActionTypeModel as ConnectorTypeModel,
|
||||
GenericValidationResult,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { RecursivePartial } from '@elastic/eui';
|
||||
import { OpsgenieSubActions } from '../../../../common';
|
||||
import type {
|
||||
OpsgenieActionConfig,
|
||||
OpsgenieActionParams,
|
||||
OpsgenieActionSecrets,
|
||||
} from '../../../../server/connector_types/stack';
|
||||
import { DEFAULT_ALIAS } from './constants';
|
||||
import { OpsgenieConnectorTypeParams, ValidationParams } from './types';
|
||||
|
||||
const SELECT_MESSAGE = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.selectMessageText',
|
||||
|
@ -34,42 +33,14 @@ const TITLE = i18n.translate('xpack.stackConnectors.components.opsgenie.connecto
|
|||
export const getConnectorType = (): ConnectorTypeModel<
|
||||
OpsgenieActionConfig,
|
||||
OpsgenieActionSecrets,
|
||||
OpsgenieActionParams
|
||||
OpsgenieConnectorTypeParams
|
||||
> => {
|
||||
return {
|
||||
id: '.opsgenie',
|
||||
iconClass: lazy(() => import('./logo')),
|
||||
selectMessage: SELECT_MESSAGE,
|
||||
actionTypeTitle: TITLE,
|
||||
validateParams: async (
|
||||
actionParams: RecursivePartial<OpsgenieActionParams>
|
||||
): Promise<GenericValidationResult<unknown>> => {
|
||||
const translations = await import('./translations');
|
||||
const errors = {
|
||||
'subActionParams.message': new Array<string>(),
|
||||
'subActionParams.alias': new Array<string>(),
|
||||
};
|
||||
|
||||
const validationResult = {
|
||||
errors,
|
||||
};
|
||||
|
||||
if (
|
||||
actionParams.subAction === OpsgenieSubActions.CreateAlert &&
|
||||
!actionParams?.subActionParams?.message?.length
|
||||
) {
|
||||
errors['subActionParams.message'].push(translations.MESSAGE_IS_REQUIRED);
|
||||
}
|
||||
|
||||
if (
|
||||
actionParams.subAction === OpsgenieSubActions.CloseAlert &&
|
||||
!actionParams?.subActionParams?.alias?.length
|
||||
) {
|
||||
errors['subActionParams.alias'].push(translations.ALIAS_IS_REQUIRED);
|
||||
}
|
||||
|
||||
return validationResult;
|
||||
},
|
||||
validateParams,
|
||||
actionConnectorFields: lazy(() => import('./connector')),
|
||||
actionParamsFields: lazy(() => import('./params')),
|
||||
defaultActionParams: {
|
||||
|
@ -86,3 +57,39 @@ export const getConnectorType = (): ConnectorTypeModel<
|
|||
},
|
||||
};
|
||||
};
|
||||
|
||||
const validateParams = async (
|
||||
actionParams: ValidationParams
|
||||
): Promise<GenericValidationResult<unknown>> => {
|
||||
const translations = await import('./translations');
|
||||
const errors = {
|
||||
'subActionParams.message': new Array<string>(),
|
||||
'subActionParams.alias': new Array<string>(),
|
||||
jsonEditorError: new Array<string>(),
|
||||
};
|
||||
|
||||
const validationResult = {
|
||||
errors,
|
||||
};
|
||||
|
||||
if (
|
||||
actionParams.subAction === OpsgenieSubActions.CreateAlert &&
|
||||
!actionParams?.subActionParams?.message?.length
|
||||
) {
|
||||
errors['subActionParams.message'].push(translations.MESSAGE_IS_REQUIRED);
|
||||
}
|
||||
|
||||
if (
|
||||
actionParams.subAction === OpsgenieSubActions.CloseAlert &&
|
||||
!actionParams?.subActionParams?.alias?.length
|
||||
) {
|
||||
errors['subActionParams.alias'].push(translations.ALIAS_IS_REQUIRED);
|
||||
}
|
||||
|
||||
if (actionParams.jsonEditorError) {
|
||||
// This error doesn't actually get displayed it is used to cause the run/save button to fail within the action form
|
||||
errors.jsonEditorError.push(translations.JSON_EDITOR_ERROR);
|
||||
}
|
||||
|
||||
return validationResult;
|
||||
};
|
||||
|
|
|
@ -11,6 +11,20 @@ import userEvent from '@testing-library/user-event';
|
|||
import OpsgenieParamFields from './params';
|
||||
import { OpsgenieSubActions } from '../../../../common';
|
||||
import { OpsgenieActionParams } from '../../../../server/connector_types/stack';
|
||||
import { ActionConnectorMode } from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock';
|
||||
|
||||
const kibanaReactPath = '../../../../../../../src/plugins/kibana_react/public';
|
||||
|
||||
jest.mock(kibanaReactPath, () => {
|
||||
const original = jest.requireActual(kibanaReactPath);
|
||||
return {
|
||||
...original,
|
||||
CodeEditor: (props: any) => {
|
||||
return <MockCodeEditor {...props} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('OpsgenieParamFields', () => {
|
||||
const editAction = jest.fn();
|
||||
|
@ -44,6 +58,7 @@ describe('OpsgenieParamFields', () => {
|
|||
index: 0,
|
||||
messageVariables: [],
|
||||
actionConnector: connector,
|
||||
executionMode: ActionConnectorMode.Test,
|
||||
};
|
||||
|
||||
const defaultCloseAlertProps = {
|
||||
|
@ -56,6 +71,7 @@ describe('OpsgenieParamFields', () => {
|
|||
index: 0,
|
||||
messageVariables: [],
|
||||
actionConnector: connector,
|
||||
executionMode: ActionConnectorMode.Test,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -65,7 +81,7 @@ describe('OpsgenieParamFields', () => {
|
|||
it('renders the create alert component', async () => {
|
||||
render(<OpsgenieParamFields {...defaultCreateAlertProps} />);
|
||||
|
||||
expect(screen.getByText('Message')).toBeInTheDocument();
|
||||
expect(screen.getByText('Message (required)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alias')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('opsgenie-subActionSelect'));
|
||||
|
||||
|
@ -77,7 +93,7 @@ describe('OpsgenieParamFields', () => {
|
|||
render(<OpsgenieParamFields {...defaultCloseAlertProps} />);
|
||||
|
||||
expect(screen.queryByText('Message')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Alias')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alias (required)')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('opsgenie-subActionSelect'));
|
||||
|
||||
expect(screen.queryByDisplayValue('hello')).not.toBeInTheDocument();
|
||||
|
@ -85,6 +101,37 @@ describe('OpsgenieParamFields', () => {
|
|||
expect(screen.getByDisplayValue('456')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the sub action select for creating an alert when execution mode is ActionForm', async () => {
|
||||
render(
|
||||
<OpsgenieParamFields
|
||||
{...{ ...defaultCreateAlertProps, executionMode: ActionConnectorMode.ActionForm }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Message (required)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alias')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('opsgenie-subActionSelect')).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByDisplayValue('123')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('hello')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the sub action select for closing an alert when execution mode is ActionForm', async () => {
|
||||
render(
|
||||
<OpsgenieParamFields
|
||||
{...{ ...defaultCloseAlertProps, executionMode: ActionConnectorMode.ActionForm }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('opsgenie-subActionSelect')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the sub action select for closing an alert when execution mode is undefined', async () => {
|
||||
render(<OpsgenieParamFields {...{ ...defaultCloseAlertProps, executionMode: undefined }} />);
|
||||
|
||||
expect(screen.queryByTestId('opsgenie-subActionSelect')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls editAction when the message field is changed', async () => {
|
||||
render(<OpsgenieParamFields {...defaultCreateAlertProps} />);
|
||||
|
||||
|
@ -231,7 +278,7 @@ describe('OpsgenieParamFields', () => {
|
|||
act(() =>
|
||||
userEvent.selectOptions(
|
||||
screen.getByTestId('opsgenie-subActionSelect'),
|
||||
screen.getByText('Close Alert')
|
||||
screen.getByText('Close alert')
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
@ -8,131 +8,30 @@
|
|||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ActionParamsProps,
|
||||
TextAreaWithMessageVariables,
|
||||
TextFieldWithMessageVariables,
|
||||
ActionConnectorMode,
|
||||
IErrorObject,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||
import { EuiFormRow, EuiSelect, RecursivePartial } from '@elastic/eui';
|
||||
import { EuiFormRow, EuiSelect } from '@elastic/eui';
|
||||
import { isEmpty, unset, cloneDeep } from 'lodash';
|
||||
import { OpsgenieSubActions } from '../../../../common';
|
||||
import type {
|
||||
OpsgenieActionParams,
|
||||
OpsgenieCloseAlertParams,
|
||||
OpsgenieCreateAlertParams,
|
||||
OpsgenieCreateAlertSubActionParams,
|
||||
} from '../../../../server/connector_types/stack';
|
||||
import * as i18n from './translations';
|
||||
|
||||
type SubActionProps<SubActionParams> = Omit<
|
||||
ActionParamsProps<OpsgenieActionParams>,
|
||||
'actionParams' | 'editAction'
|
||||
> & {
|
||||
subActionParams?: RecursivePartial<SubActionParams>;
|
||||
editSubAction: ActionParamsProps<OpsgenieActionParams>['editAction'];
|
||||
};
|
||||
|
||||
const CreateAlertComponent: React.FC<SubActionProps<OpsgenieCreateAlertParams>> = ({
|
||||
editSubAction,
|
||||
errors,
|
||||
index,
|
||||
messageVariables,
|
||||
subActionParams,
|
||||
}) => {
|
||||
const isMessageInvalid =
|
||||
errors['subActionParams.message'] !== undefined &&
|
||||
errors['subActionParams.message'].length > 0 &&
|
||||
subActionParams?.message !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow
|
||||
data-test-subj="opsgenie-message-row"
|
||||
fullWidth
|
||||
error={errors['subActionParams.message']}
|
||||
label={i18n.MESSAGE_FIELD_LABEL}
|
||||
isInvalid={isMessageInvalid}
|
||||
>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'message'}
|
||||
inputTargetValue={subActionParams?.message}
|
||||
errors={errors['subActionParams.message'] as string[]}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<TextAreaWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'description'}
|
||||
inputTargetValue={subActionParams?.description}
|
||||
label={i18n.DESCRIPTION_FIELD_LABEL}
|
||||
/>
|
||||
<EuiFormRow data-test-subj="opsgenie-alias-row" fullWidth label={i18n.ALIAS_FIELD_LABEL}>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'alias'}
|
||||
inputTargetValue={subActionParams?.alias}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CreateAlertComponent.displayName = 'CreateAlertComponent';
|
||||
|
||||
const CloseAlertComponent: React.FC<SubActionProps<OpsgenieCloseAlertParams>> = ({
|
||||
editSubAction,
|
||||
errors,
|
||||
index,
|
||||
messageVariables,
|
||||
subActionParams,
|
||||
}) => {
|
||||
const isAliasInvalid =
|
||||
errors['subActionParams.alias'] !== undefined &&
|
||||
errors['subActionParams.alias'].length > 0 &&
|
||||
subActionParams?.alias !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow
|
||||
data-test-subj="opsgenie-alias-row"
|
||||
fullWidth
|
||||
error={errors['subActionParams.alias']}
|
||||
isInvalid={isAliasInvalid}
|
||||
label={i18n.ALIAS_FIELD_LABEL}
|
||||
>
|
||||
<TextFieldWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'alias'}
|
||||
inputTargetValue={subActionParams?.alias}
|
||||
errors={errors['subActionParams.alias'] as string[]}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<TextAreaWithMessageVariables
|
||||
index={index}
|
||||
editAction={editSubAction}
|
||||
messageVariables={messageVariables}
|
||||
paramsProperty={'note'}
|
||||
inputTargetValue={subActionParams?.note}
|
||||
label={i18n.NOTE_FIELD_LABEL}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CloseAlertComponent.displayName = 'CloseAlertComponent';
|
||||
import { CreateAlert } from './create_alert';
|
||||
import { CloseAlert } from './close_alert';
|
||||
|
||||
const actionOptions = [
|
||||
{
|
||||
value: OpsgenieSubActions.CreateAlert,
|
||||
text: i18n.CREATE_ALERT_ACTION,
|
||||
'data-test-subj': 'opsgenie-subActionSelect-create-alert',
|
||||
},
|
||||
{
|
||||
value: OpsgenieSubActions.CloseAlert,
|
||||
text: i18n.CLOSE_ALERT_ACTION,
|
||||
'data-test-subj': 'opsgenie-subActionSelect-close-alert',
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -142,6 +41,7 @@ const OpsgenieParamFields: React.FC<ActionParamsProps<OpsgenieActionParams>> = (
|
|||
errors,
|
||||
index,
|
||||
messageVariables,
|
||||
executionMode,
|
||||
}) => {
|
||||
const { subAction, subActionParams } = actionParams;
|
||||
|
||||
|
@ -154,6 +54,20 @@ const OpsgenieParamFields: React.FC<ActionParamsProps<OpsgenieActionParams>> = (
|
|||
[editAction, index]
|
||||
);
|
||||
|
||||
const editOptionalSubAction = useCallback(
|
||||
(key, value) => {
|
||||
if (isEmpty(value)) {
|
||||
const paramsCopy = cloneDeep(subActionParams);
|
||||
unset(paramsCopy, key);
|
||||
editAction('subActionParams', paramsCopy, index);
|
||||
return;
|
||||
}
|
||||
|
||||
editAction('subActionParams', { ...subActionParams, [key]: value }, index);
|
||||
},
|
||||
[editAction, index, subActionParams]
|
||||
);
|
||||
|
||||
const editSubAction = useCallback(
|
||||
(key, value) => {
|
||||
editAction('subActionParams', { ...subActionParams, [key]: value }, index);
|
||||
|
@ -175,35 +89,42 @@ const OpsgenieParamFields: React.FC<ActionParamsProps<OpsgenieActionParams>> = (
|
|||
editAction('subActionParams', params, index);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subAction, currentSubAction]);
|
||||
}, [subAction, currentSubAction, subActionParams?.alias, index]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow fullWidth label={i18n.ACTION_LABEL}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="opsgenie-subActionSelect"
|
||||
options={actionOptions}
|
||||
hasNoInitialSelection={subAction == null}
|
||||
value={subAction}
|
||||
onChange={onActionChange}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
{executionMode === ActionConnectorMode.Test && (
|
||||
<EuiFormRow fullWidth label={i18n.ACTION_LABEL}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
data-test-subj="opsgenie-subActionSelect"
|
||||
options={actionOptions}
|
||||
hasNoInitialSelection={subAction == null}
|
||||
value={subAction}
|
||||
onChange={onActionChange}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
|
||||
{subAction != null && subAction === OpsgenieSubActions.CreateAlert && (
|
||||
<CreateAlertComponent
|
||||
{subAction === OpsgenieSubActions.CreateAlert && (
|
||||
<CreateAlert
|
||||
showSaveError={showCreateAlertSaveError(actionParams, errors)}
|
||||
subActionParams={subActionParams}
|
||||
editAction={editAction}
|
||||
editSubAction={editSubAction}
|
||||
editOptionalSubAction={editOptionalSubAction}
|
||||
errors={errors}
|
||||
index={index}
|
||||
messageVariables={messageVariables}
|
||||
/>
|
||||
)}
|
||||
|
||||
{subAction != null && subAction === OpsgenieSubActions.CloseAlert && (
|
||||
<CloseAlertComponent
|
||||
{subAction === OpsgenieSubActions.CloseAlert && (
|
||||
<CloseAlert
|
||||
showSaveError={showCloseAlertSaveError(actionParams, errors)}
|
||||
subActionParams={subActionParams}
|
||||
editSubAction={editSubAction}
|
||||
editOptionalSubAction={editOptionalSubAction}
|
||||
errors={errors}
|
||||
index={index}
|
||||
messageVariables={messageVariables}
|
||||
|
@ -215,5 +136,44 @@ const OpsgenieParamFields: React.FC<ActionParamsProps<OpsgenieActionParams>> = (
|
|||
|
||||
OpsgenieParamFields.displayName = 'OpsgenieParamFields';
|
||||
|
||||
/**
|
||||
* The show*AlertSaveError functions are used to cause a rerender when fields are set to `null` when a user attempts to
|
||||
* save the form before providing values for the required fields (message for creating an alert and alias for closing an alert).
|
||||
* If we only passed in subActionParams the child components would not rerender because the objects field is only updated
|
||||
* and not the entire object.
|
||||
*/
|
||||
|
||||
const showCreateAlertSaveError = (
|
||||
params: Partial<OpsgenieActionParams>,
|
||||
errors: IErrorObject
|
||||
): boolean => {
|
||||
const errorArray = errors['subActionParams.message'] as string[] | undefined;
|
||||
const errorsLength = errorArray?.length ?? 0;
|
||||
|
||||
return (
|
||||
isCreateAlertParams(params) && params.subActionParams?.message === null && errorsLength > 0
|
||||
);
|
||||
};
|
||||
|
||||
const showCloseAlertSaveError = (
|
||||
params: Partial<OpsgenieActionParams>,
|
||||
errors: IErrorObject
|
||||
): boolean => {
|
||||
const errorArray = errors['subActionParams.alias'] as string[] | undefined;
|
||||
const errorsLength = errorArray?.length ?? 0;
|
||||
|
||||
return isCloseAlertParams(params) && params.subActionParams?.alias === null && errorsLength > 0;
|
||||
};
|
||||
|
||||
const isCreateAlertParams = (
|
||||
params: Partial<OpsgenieActionParams>
|
||||
): params is Partial<OpsgenieCreateAlertSubActionParams> =>
|
||||
params.subAction === OpsgenieSubActions.CreateAlert;
|
||||
|
||||
const isCloseAlertParams = (
|
||||
params: Partial<OpsgenieActionParams>
|
||||
): params is OpsgenieCreateAlertSubActionParams =>
|
||||
params.subAction === OpsgenieSubActions.CloseAlert;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { OpsgenieParamFields as default };
|
||||
|
|
|
@ -21,6 +21,13 @@ export const API_KEY_LABEL = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const MESSAGE_IS_REQUIRED = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.requiredMessageTextField',
|
||||
{
|
||||
defaultMessage: 'Message is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const ACTION_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.actionLabel',
|
||||
{
|
||||
|
@ -31,42 +38,21 @@ export const ACTION_LABEL = i18n.translate(
|
|||
export const CREATE_ALERT_ACTION = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.createAlertAction',
|
||||
{
|
||||
defaultMessage: 'Create Alert',
|
||||
defaultMessage: 'Create alert',
|
||||
}
|
||||
);
|
||||
|
||||
export const CLOSE_ALERT_ACTION = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.closeAlertAction',
|
||||
{
|
||||
defaultMessage: 'Close Alert',
|
||||
}
|
||||
);
|
||||
|
||||
export const MESSAGE_FIELD_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.messageLabel',
|
||||
{
|
||||
defaultMessage: 'Message',
|
||||
defaultMessage: 'Close alert',
|
||||
}
|
||||
);
|
||||
|
||||
export const NOTE_FIELD_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.noteLabel',
|
||||
{
|
||||
defaultMessage: 'Note (optional)',
|
||||
}
|
||||
);
|
||||
|
||||
export const DESCRIPTION_FIELD_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.descriptionLabel',
|
||||
{
|
||||
defaultMessage: 'Description (optional)',
|
||||
}
|
||||
);
|
||||
|
||||
export const MESSAGE_IS_REQUIRED = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.requiredMessageTextField',
|
||||
{
|
||||
defaultMessage: 'Message is required.',
|
||||
defaultMessage: 'Note',
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -77,9 +63,51 @@ export const ALIAS_FIELD_LABEL = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const ALIAS_REQUIRED_FIELD_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.aliasRequiredLabel',
|
||||
{
|
||||
defaultMessage: 'Alias (required)',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALIAS_IS_REQUIRED = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.requiredAliasTextField',
|
||||
{
|
||||
defaultMessage: 'Alias is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const MORE_OPTIONS = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.moreOptions',
|
||||
{
|
||||
defaultMessage: 'More options',
|
||||
}
|
||||
);
|
||||
|
||||
export const HIDE_OPTIONS = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.hideOptions',
|
||||
{
|
||||
defaultMessage: 'Hide options',
|
||||
}
|
||||
);
|
||||
|
||||
export const USER_FIELD_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.userLabel',
|
||||
{
|
||||
defaultMessage: 'User',
|
||||
}
|
||||
);
|
||||
|
||||
export const SOURCE_FIELD_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.sourceLabel',
|
||||
{
|
||||
defaultMessage: 'Source',
|
||||
}
|
||||
);
|
||||
|
||||
export const JSON_EDITOR_ERROR = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.jsonEditorError',
|
||||
{
|
||||
defaultMessage: 'JSON editor error exists',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -5,13 +5,38 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import { RecursivePartial } from '@elastic/eui';
|
||||
import {
|
||||
ActionParamsProps,
|
||||
UserConfiguredActionConnector,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import type {
|
||||
OpsgenieActionConfig,
|
||||
OpsgenieActionSecrets,
|
||||
OpsgenieActionParams,
|
||||
} from '../../../../server/connector_types/stack';
|
||||
|
||||
export type OpsgenieActionConnector = UserConfiguredActionConnector<
|
||||
OpsgenieActionConfig,
|
||||
OpsgenieActionSecrets
|
||||
>;
|
||||
|
||||
/**
|
||||
* These fields will never be sent to Opsgenie or the sub actions framework. This allows us to pass a value to the
|
||||
* validation functions so it cause a validation failure if the json editor has an error. That way the user can't save
|
||||
* test.
|
||||
*/
|
||||
interface JsonEditorError {
|
||||
jsonEditorError: boolean;
|
||||
}
|
||||
|
||||
export type OpsgenieConnectorTypeParams = OpsgenieActionParams & JsonEditorError;
|
||||
|
||||
export type ValidationParams = RecursivePartial<OpsgenieActionParams> & JsonEditorError;
|
||||
|
||||
type EditActionParameters = Parameters<ActionParamsProps<OpsgenieActionParams>['editAction']>;
|
||||
|
||||
export type EditActionCallback = (
|
||||
key: EditActionParameters[0],
|
||||
value: EditActionParameters[1]
|
||||
) => ReturnType<ActionParamsProps<OpsgenieActionParams>['editAction']>;
|
||||
|
|
|
@ -147,4 +147,62 @@ describe('OpsgenieConnector', () => {
|
|||
data: { user: 'sam' },
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponseErrorMessage', () => {
|
||||
it('returns an unknown error message', () => {
|
||||
// @ts-expect-error expects an axios error as the parameter
|
||||
expect(connector.getResponseErrorMessage({})).toMatchInlineSnapshot(`"unknown error"`);
|
||||
});
|
||||
|
||||
it('returns the error.message', () => {
|
||||
// @ts-expect-error expects an axios error as the parameter
|
||||
expect(connector.getResponseErrorMessage({ message: 'a message' })).toMatchInlineSnapshot(
|
||||
`"a message"`
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the error.response.data.message', () => {
|
||||
expect(
|
||||
// @ts-expect-error expects an axios error as the parameter
|
||||
connector.getResponseErrorMessage({ response: { data: { message: 'a message' } } })
|
||||
).toMatchInlineSnapshot(`"a message"`);
|
||||
});
|
||||
|
||||
it('returns detailed message', () => {
|
||||
// @ts-expect-error expects an axios error as the parameter
|
||||
const error: AxiosError<FailureResponseType> = {
|
||||
response: {
|
||||
data: {
|
||||
errors: {
|
||||
message: 'message field had a problem',
|
||||
},
|
||||
message: 'toplevel message',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(connector.getResponseErrorMessage(error)).toMatchInlineSnapshot(
|
||||
`"toplevel message: {\\"message\\":\\"message field had a problem\\"}"`
|
||||
);
|
||||
});
|
||||
|
||||
it('returns detailed message with multiple entires', () => {
|
||||
// @ts-expect-error expects an axios error as the parameter
|
||||
const error: AxiosError<FailureResponseType> = {
|
||||
response: {
|
||||
data: {
|
||||
errors: {
|
||||
message: 'message field had a problem',
|
||||
visibleTo: 'visibleTo field had a problem',
|
||||
},
|
||||
message: 'toplevel message',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(connector.getResponseErrorMessage(error)).toMatchInlineSnapshot(
|
||||
`"toplevel message: {\\"message\\":\\"message field had a problem\\",\\"visibleTo\\":\\"visibleTo field had a problem\\"}"`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,18 +8,12 @@
|
|||
import crypto from 'crypto';
|
||||
import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server';
|
||||
import { AxiosError } from 'axios';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { OpsgenieSubActions } from '../../../../common';
|
||||
import { CloseAlertParamsSchema, CreateAlertParamsSchema, Response } from './schema';
|
||||
import { CloseAlertParams, Config, CreateAlertParams, Secrets } from './types';
|
||||
import { CreateAlertParamsSchema, CloseAlertParamsSchema, Response } from './schema';
|
||||
import { CloseAlertParams, Config, CreateAlertParams, FailureResponseType, Secrets } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface ErrorSchema {
|
||||
message?: string;
|
||||
errors?: {
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class OpsgenieConnector extends SubActionConnector<Config, Secrets> {
|
||||
constructor(params: ServiceParams<Config, Secrets>) {
|
||||
super(params);
|
||||
|
@ -37,13 +31,40 @@ export class OpsgenieConnector extends SubActionConnector<Config, Secrets> {
|
|||
});
|
||||
}
|
||||
|
||||
public getResponseErrorMessage(error: AxiosError<ErrorSchema>) {
|
||||
return `Message: ${
|
||||
error.response?.data.errors?.message ??
|
||||
error.response?.data.message ??
|
||||
error.message ??
|
||||
i18n.UNKNOWN_ERROR
|
||||
}`;
|
||||
public getResponseErrorMessage(error: AxiosError<FailureResponseType>) {
|
||||
const mainMessage = error.response?.data.message ?? error.message ?? i18n.UNKNOWN_ERROR;
|
||||
|
||||
if (error.response?.data?.errors != null) {
|
||||
const message = this.getDetailedErrorMessage(error.response?.data?.errors);
|
||||
if (!isEmpty(message)) {
|
||||
return `${mainMessage}: ${message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return mainMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* When testing invalid requests with Opsgenie the response seems to take the form:
|
||||
* {
|
||||
* ['field that is invalid']: 'message about what the issue is'
|
||||
* }
|
||||
*
|
||||
* e.g.
|
||||
*
|
||||
* {
|
||||
* "message": "Message can not be empty.",
|
||||
* "username": "must be a well-formed email address"
|
||||
* }
|
||||
*
|
||||
* So we'll just stringify it.
|
||||
*/
|
||||
private getDetailedErrorMessage(errorField: unknown) {
|
||||
try {
|
||||
return JSON.stringify(errorField);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async createAlert(params: CreateAlertParams) {
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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 { CreateAlertParamsSchema } from './schema';
|
||||
import { OpsgenieCreateAlertExample, ValidCreateAlertSchema } from './test_schema';
|
||||
|
||||
describe('opsgenie schema', () => {
|
||||
describe('CreateAlertParamsSchema', () => {
|
||||
it.each([
|
||||
['ValidCreateAlertSchema', ValidCreateAlertSchema],
|
||||
['OpsgenieCreateAlertExample', OpsgenieCreateAlertExample],
|
||||
])('validates the test object [%s] correctly', (objectName, testObject) => {
|
||||
expect(() => CreateAlertParamsSchema.validate(testObject)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -6,6 +6,8 @@
|
|||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { isEmpty } from 'lodash';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const ConfigSchema = schema.object({
|
||||
apiUrl: schema.string(),
|
||||
|
@ -15,6 +17,48 @@ export const SecretsSchema = schema.object({
|
|||
apiKey: schema.string(),
|
||||
});
|
||||
|
||||
const SuccessfulResponse = schema.object(
|
||||
{
|
||||
took: schema.number(),
|
||||
requestId: schema.string(),
|
||||
result: schema.string(),
|
||||
},
|
||||
{ unknowns: 'allow' }
|
||||
);
|
||||
|
||||
export const FailureResponse = schema.object(
|
||||
{
|
||||
took: schema.number(),
|
||||
requestId: schema.string(),
|
||||
message: schema.maybe(schema.string()),
|
||||
result: schema.maybe(schema.string()),
|
||||
/**
|
||||
* When testing invalid requests with Opsgenie the response seems to take the form:
|
||||
* {
|
||||
* ['field that is invalid']: 'message about what the issue is'
|
||||
* }
|
||||
*
|
||||
* e.g.
|
||||
*
|
||||
* {
|
||||
* "message": "Message can not be empty.",
|
||||
* "username": "must be a well-formed email address"
|
||||
* }
|
||||
*/
|
||||
errors: schema.maybe(schema.any()),
|
||||
},
|
||||
{ unknowns: 'allow' }
|
||||
);
|
||||
|
||||
export const Response = schema.oneOf([SuccessfulResponse, FailureResponse]);
|
||||
|
||||
export const CloseAlertParamsSchema = schema.object({
|
||||
alias: schema.string(),
|
||||
user: schema.maybe(schema.string({ maxLength: 100 })),
|
||||
source: schema.maybe(schema.string({ maxLength: 100 })),
|
||||
note: schema.maybe(schema.string({ maxLength: 25000 })),
|
||||
});
|
||||
|
||||
const responderTypes = schema.oneOf([
|
||||
schema.literal('team'),
|
||||
schema.literal('user'),
|
||||
|
@ -22,8 +66,15 @@ const responderTypes = schema.oneOf([
|
|||
schema.literal('schedule'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* For more information on the Opsgenie create alert schema see: https://docs.opsgenie.com/docs/alert-api#create-alert
|
||||
*/
|
||||
export const CreateAlertParamsSchema = schema.object({
|
||||
message: schema.string({ maxLength: 130 }),
|
||||
message: schema.string({
|
||||
maxLength: 130,
|
||||
minLength: 1,
|
||||
validate: (message) => (isEmpty(message.trim()) ? i18n.MESSAGE_NON_EMPTY : undefined),
|
||||
}),
|
||||
/**
|
||||
* The max length here should be 512 according to Opsgenie's docs but we will sha256 hash the alias if it is longer than 512
|
||||
* so we'll not impose a limit on the schema otherwise it'll get rejected prematurely.
|
||||
|
@ -38,6 +89,12 @@ export const CreateAlertParamsSchema = schema.object({
|
|||
type: responderTypes,
|
||||
}),
|
||||
schema.object({ id: schema.string(), type: responderTypes }),
|
||||
/**
|
||||
* This field is not explicitly called out in the description of responders within Opsgenie's API docs but it is
|
||||
* shown in an example and when I tested it, it seems to work as they throw an error if you try to specify a username
|
||||
* without a valid email
|
||||
*/
|
||||
schema.object({ username: schema.string(), type: schema.literal('user') }),
|
||||
]),
|
||||
{ maxSize: 50 }
|
||||
)
|
||||
|
@ -87,32 +144,3 @@ export const CreateAlertParamsSchema = schema.object({
|
|||
user: schema.maybe(schema.string({ maxLength: 100 })),
|
||||
note: schema.maybe(schema.string({ maxLength: 25000 })),
|
||||
});
|
||||
|
||||
const SuccessfulResponse = schema.object(
|
||||
{
|
||||
took: schema.number(),
|
||||
requestId: schema.string(),
|
||||
result: schema.string(),
|
||||
},
|
||||
{ unknowns: 'allow' }
|
||||
);
|
||||
|
||||
const FailureResponse = schema.object(
|
||||
{
|
||||
took: schema.number(),
|
||||
requestId: schema.string(),
|
||||
message: schema.maybe(schema.string()),
|
||||
result: schema.maybe(schema.string()),
|
||||
errors: schema.maybe(schema.object({ message: schema.string() })),
|
||||
},
|
||||
{ unknowns: 'allow' }
|
||||
);
|
||||
|
||||
export const Response = schema.oneOf([SuccessfulResponse, FailureResponse]);
|
||||
|
||||
export const CloseAlertParamsSchema = schema.object({
|
||||
alias: schema.string(),
|
||||
user: schema.maybe(schema.string({ maxLength: 100 })),
|
||||
source: schema.maybe(schema.string({ maxLength: 100 })),
|
||||
note: schema.maybe(schema.string({ maxLength: 25000 })),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* 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 { CreateAlertParams } from './types';
|
||||
|
||||
export const ValidCreateAlertSchema: CreateAlertParams = {
|
||||
message: 'a message',
|
||||
alias: 'an alias',
|
||||
description: 'a description',
|
||||
responders: [
|
||||
{ name: 'name for team', type: 'team' },
|
||||
{ name: 'name for user', type: 'user' },
|
||||
{ name: 'name for escalation', type: 'escalation' },
|
||||
{ name: 'name for schedule', type: 'schedule' },
|
||||
{
|
||||
id: '4513b7ea-3b91-438f-b7e4-e3e54af9147c',
|
||||
type: 'team',
|
||||
},
|
||||
{
|
||||
name: 'NOC',
|
||||
type: 'team',
|
||||
},
|
||||
{
|
||||
id: 'bb4d9938-c3c2-455d-aaab-727aa701c0d8',
|
||||
type: 'user',
|
||||
},
|
||||
{
|
||||
username: 'trinity@opsgenie.com',
|
||||
type: 'user',
|
||||
},
|
||||
{
|
||||
id: 'aee8a0de-c80f-4515-a232-501c0bc9d715',
|
||||
type: 'escalation',
|
||||
},
|
||||
{
|
||||
name: 'Nightwatch Escalation',
|
||||
type: 'escalation',
|
||||
},
|
||||
{
|
||||
id: '80564037-1984-4f38-b98e-8a1f662df552',
|
||||
type: 'schedule',
|
||||
},
|
||||
{
|
||||
name: 'First Responders Schedule',
|
||||
type: 'schedule',
|
||||
},
|
||||
],
|
||||
visibleTo: [
|
||||
{ name: 'name for team', type: 'team' },
|
||||
{ id: 'id for team', type: 'team' },
|
||||
{ id: 'id for user', type: 'user' },
|
||||
{ username: 'username for user', type: 'user' },
|
||||
],
|
||||
actions: ['action1', 'action2'],
|
||||
tags: ['tag1', 'tag2'],
|
||||
details: { keyA: 'valueA', keyB: 'valueB' },
|
||||
entity: 'an entity',
|
||||
source: 'a source',
|
||||
priority: 'P2',
|
||||
user: 'a user',
|
||||
note: 'a note',
|
||||
};
|
||||
|
||||
/**
|
||||
* This example is pulled from the sample curl request here: https://docs.opsgenie.com/docs/alert-api#create-alert
|
||||
*/
|
||||
export const OpsgenieCreateAlertExample: CreateAlertParams = {
|
||||
message: 'An example alert message',
|
||||
alias: 'Life is too short for no alias',
|
||||
description: 'Every alert needs a description',
|
||||
responders: [
|
||||
{ id: '4513b7ea-3b91-438f-b7e4-e3e54af9147c', type: 'team' },
|
||||
{ name: 'NOC', type: 'team' },
|
||||
{ id: 'bb4d9938-c3c2-455d-aaab-727aa701c0d8', type: 'user' },
|
||||
{ username: 'trinity@opsgenie.com', type: 'user' },
|
||||
{ id: 'aee8a0de-c80f-4515-a232-501c0bc9d715', type: 'escalation' },
|
||||
{ name: 'Nightwatch Escalation', type: 'escalation' },
|
||||
{ id: '80564037-1984-4f38-b98e-8a1f662df552', type: 'schedule' },
|
||||
{ name: 'First Responders Schedule', type: 'schedule' },
|
||||
],
|
||||
visibleTo: [
|
||||
{ id: '4513b7ea-3b91-438f-b7e4-e3e54af9147c', type: 'team' },
|
||||
{ name: 'rocket_team', type: 'team' },
|
||||
{ id: 'bb4d9938-c3c2-455d-aaab-727aa701c0d8', type: 'user' },
|
||||
{ username: 'trinity@opsgenie.com', type: 'user' },
|
||||
],
|
||||
actions: ['Restart', 'AnExampleAction'],
|
||||
tags: ['OverwriteQuietHours', 'Critical'],
|
||||
details: { key1: 'value1', key2: 'value2' },
|
||||
entity: 'An example entity',
|
||||
priority: 'P1',
|
||||
};
|
|
@ -14,3 +14,10 @@ export const UNKNOWN_ERROR = i18n.translate('xpack.stackConnectors.opsgenie.unkn
|
|||
export const OPSGENIE_NAME = i18n.translate('xpack.stackConnectors.opsgenie.name', {
|
||||
defaultMessage: 'Opsgenie',
|
||||
});
|
||||
|
||||
export const MESSAGE_NON_EMPTY = i18n.translate(
|
||||
'xpack.stackConnectors.components.opsgenie.nonEmptyMessageField',
|
||||
{
|
||||
defaultMessage: 'must be populated with a value other than just whitespace',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -6,10 +6,11 @@
|
|||
*/
|
||||
import { TypeOf } from '@kbn/config-schema';
|
||||
import {
|
||||
CreateAlertParamsSchema,
|
||||
CloseAlertParamsSchema,
|
||||
ConfigSchema,
|
||||
CreateAlertParamsSchema,
|
||||
SecretsSchema,
|
||||
FailureResponse,
|
||||
} from './schema';
|
||||
import { OpsgenieSubActions } from '../../../../common';
|
||||
|
||||
|
@ -30,3 +31,5 @@ export interface CloseAlertSubActionParams {
|
|||
}
|
||||
|
||||
export type Params = CreateAlertSubActionParams | CloseAlertSubActionParams;
|
||||
|
||||
export type FailureResponseType = TypeOf<typeof FailureResponse>;
|
||||
|
|
|
@ -15,3 +15,4 @@ export type { ConfigFieldSchema, SecretsFieldSchema } from './simple_connector_f
|
|||
export { ButtonGroupField } from './button_group_field';
|
||||
export { JsonFieldWrapper } from './json_field_wrapper';
|
||||
export { MustacheTextFieldWrapper } from './mustache_text_field_wrapper';
|
||||
export { SectionLoading } from './section_loading';
|
||||
|
|
|
@ -8,9 +8,17 @@ import * as React from 'react';
|
|||
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
|
||||
import { ActionTypeForm } from './action_type_form';
|
||||
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
|
||||
import { ActionConnector, ActionType, RuleAction, GenericValidationResult } from '../../../types';
|
||||
import {
|
||||
ActionConnector,
|
||||
ActionType,
|
||||
RuleAction,
|
||||
GenericValidationResult,
|
||||
ActionConnectorMode,
|
||||
} from '../../../types';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { EuiFieldText } from '@elastic/eui';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { render, waitFor, screen } from '@testing-library/react';
|
||||
|
||||
jest.mock('../../../common/lib/kibana');
|
||||
const actionTypeRegistry = actionTypeRegistryMock.create();
|
||||
|
@ -31,6 +39,24 @@ describe('action_type_form', () => {
|
|||
},
|
||||
}));
|
||||
|
||||
const mockedActionParamsFieldsWithExecutionMode = React.lazy(async () => ({
|
||||
default({ executionMode }: { executionMode?: ActionConnectorMode }) {
|
||||
return (
|
||||
<>
|
||||
{executionMode === ActionConnectorMode.Test && (
|
||||
<EuiFieldText data-test-subj="executionModeFieldTest" />
|
||||
)}
|
||||
{executionMode === ActionConnectorMode.ActionForm && (
|
||||
<EuiFieldText data-test-subj="executionModeFieldActionForm" />
|
||||
)}
|
||||
{executionMode === undefined && (
|
||||
<EuiFieldText data-test-subj="executionModeFieldUndefined" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
it('calls "setActionParamsProperty" to set the default value for the empty dedupKey', async () => {
|
||||
const actionType = actionTypeRegistryMock.createMockActionTypeModel({
|
||||
id: '.pagerduty',
|
||||
|
@ -81,6 +107,52 @@ describe('action_type_form', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('renders the actionParamsField with the execution mode set to ActionForm', async () => {
|
||||
const actionType = actionTypeRegistryMock.createMockActionTypeModel({
|
||||
id: '.pagerduty',
|
||||
iconClass: 'test',
|
||||
selectMessage: 'test',
|
||||
validateParams: (): Promise<GenericValidationResult<unknown>> => {
|
||||
const validationResult = { errors: {} };
|
||||
return Promise.resolve(validationResult);
|
||||
},
|
||||
actionConnectorFields: null,
|
||||
actionParamsFields: mockedActionParamsFieldsWithExecutionMode,
|
||||
defaultActionParams: {
|
||||
dedupKey: 'test',
|
||||
eventAction: 'resolve',
|
||||
},
|
||||
});
|
||||
actionTypeRegistry.get.mockReturnValue(actionType);
|
||||
|
||||
render(
|
||||
<I18nProvider>
|
||||
{getActionTypeForm(1, undefined, {
|
||||
id: '123',
|
||||
actionTypeId: '.pagerduty',
|
||||
group: 'recovered',
|
||||
params: {
|
||||
eventAction: 'recovered',
|
||||
dedupKey: undefined,
|
||||
summary: '2323',
|
||||
source: 'source',
|
||||
severity: '1',
|
||||
timestamp: new Date().toISOString(),
|
||||
component: 'test',
|
||||
group: 'group',
|
||||
class: 'test class',
|
||||
},
|
||||
})}
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('executionModeFieldActionForm')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('executionModeFieldTest')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('executionModeFieldUndefined')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call "setActionParamsProperty" because dedupKey is not empty', async () => {
|
||||
const actionType = actionTypeRegistryMock.createMockActionTypeModel({
|
||||
id: '.pagerduty',
|
||||
|
|
|
@ -36,6 +36,7 @@ import {
|
|||
ActionConnector,
|
||||
ActionVariables,
|
||||
ActionTypeRegistryContract,
|
||||
ActionConnectorMode,
|
||||
} from '../../../types';
|
||||
import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled';
|
||||
import { hasSaveActionsCapability } from '../../lib/capabilities';
|
||||
|
@ -280,6 +281,7 @@ export const ActionTypeForm = ({
|
|||
messageVariables={availableActionVariables}
|
||||
defaultMessage={selectedActionGroup?.defaultActionMessage ?? defaultActionMessage}
|
||||
actionConnector={actionConnector}
|
||||
executionMode={ActionConnectorMode.ActionForm}
|
||||
/>
|
||||
</Suspense>
|
||||
</EuiErrorBoundary>
|
||||
|
|
|
@ -9,10 +9,16 @@ import React, { lazy } from 'react';
|
|||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import TestConnectorForm from './test_connector_form';
|
||||
import { none, some } from 'fp-ts/lib/Option';
|
||||
import { ActionConnector, GenericValidationResult } from '../../../types';
|
||||
import {
|
||||
ActionConnector,
|
||||
ActionConnectorMode,
|
||||
ActionParamsProps,
|
||||
GenericValidationResult,
|
||||
} from '../../../types';
|
||||
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
|
||||
import { EuiFormRow, EuiFieldText, EuiText, EuiLink, EuiForm, EuiSelect } from '@elastic/eui';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { waitFor, screen, render } from '@testing-library/react';
|
||||
jest.mock('../../../common/lib/kibana');
|
||||
|
||||
const mockedActionParamsFields = lazy(async () => ({
|
||||
|
@ -59,6 +65,34 @@ const actionType = {
|
|||
const actionTypeRegistry = actionTypeRegistryMock.create();
|
||||
actionTypeRegistry.get.mockReturnValue(actionType);
|
||||
|
||||
const ExecutionModeComponent: React.FC<Pick<ActionParamsProps<{}>, 'executionMode'>> = ({
|
||||
executionMode,
|
||||
}) => {
|
||||
return (
|
||||
<EuiForm component="form">
|
||||
<EuiFormRow label="Execution mode" helpText="Execution mode help text.">
|
||||
<>
|
||||
{executionMode === ActionConnectorMode.Test && (
|
||||
<EuiFieldText data-test-subj="executionModeFieldTest" />
|
||||
)}
|
||||
{executionMode === ActionConnectorMode.ActionForm && (
|
||||
<EuiFieldText data-test-subj="executionModeFieldActionForm" />
|
||||
)}
|
||||
{executionMode === undefined && (
|
||||
<EuiFieldText data-test-subj="executionModeFieldUndefined" />
|
||||
)}
|
||||
</>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
);
|
||||
};
|
||||
|
||||
const mockedActionParamsFieldsExecutionMode = lazy(async () => ({
|
||||
default: ({ executionMode }: { executionMode?: ActionConnectorMode }) => {
|
||||
return <ExecutionModeComponent executionMode={executionMode} />;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('test_connector_form', () => {
|
||||
it('renders initially as the action form and execute button and no result', async () => {
|
||||
const connector = {
|
||||
|
@ -88,6 +122,49 @@ describe('test_connector_form', () => {
|
|||
expect(result?.exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the execution test field', async () => {
|
||||
const actionTypeExecutionMode = {
|
||||
id: 'execution-mode-type',
|
||||
iconClass: 'test',
|
||||
selectMessage: 'test',
|
||||
validateParams: (): Promise<GenericValidationResult<unknown>> => {
|
||||
const validationResult = { errors: {} };
|
||||
return Promise.resolve(validationResult);
|
||||
},
|
||||
actionConnectorFields: null,
|
||||
actionParamsFields: mockedActionParamsFieldsExecutionMode,
|
||||
};
|
||||
const actionTypeRegistryExecutionMode = actionTypeRegistryMock.create();
|
||||
actionTypeRegistryExecutionMode.get.mockReturnValue(actionTypeExecutionMode);
|
||||
|
||||
const connector = {
|
||||
actionTypeId: actionTypeExecutionMode.id,
|
||||
config: {},
|
||||
secrets: {},
|
||||
} as ActionConnector;
|
||||
|
||||
render(
|
||||
<I18nProvider>
|
||||
<TestConnectorForm
|
||||
connector={connector}
|
||||
executeEnabled={true}
|
||||
actionParams={{}}
|
||||
setActionParams={() => {}}
|
||||
isExecutingAction={false}
|
||||
onExecutionAction={async () => {}}
|
||||
executionResult={none}
|
||||
actionTypeRegistry={actionTypeRegistryExecutionMode}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('executionModeFieldTest')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('executionModeFieldActionForm')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('executionModeFieldUndefined')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders successful results', async () => {
|
||||
const connector = {
|
||||
actionTypeId: actionType.id,
|
||||
|
|
|
@ -23,7 +23,12 @@ import { pipe } from 'fp-ts/lib/pipeable';
|
|||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
|
||||
import { ActionConnector, ActionTypeRegistryContract, IErrorObject } from '../../../types';
|
||||
import {
|
||||
ActionConnector,
|
||||
ActionConnectorMode,
|
||||
ActionTypeRegistryContract,
|
||||
IErrorObject,
|
||||
} from '../../../types';
|
||||
|
||||
export interface TestConnectorFormProps {
|
||||
connector: ActionConnector;
|
||||
|
@ -90,6 +95,7 @@ export const TestConnectorForm = ({
|
|||
}
|
||||
messageVariables={[]}
|
||||
actionConnector={connector}
|
||||
executionMode={ActionConnectorMode.Test}
|
||||
/>
|
||||
</Suspense>
|
||||
</EuiErrorBoundary>
|
||||
|
|
|
@ -55,6 +55,7 @@ export {
|
|||
ALERT_HISTORY_PREFIX,
|
||||
AlertHistoryDocumentTemplate,
|
||||
AlertHistoryEsIndexConnectorId,
|
||||
ActionConnectorMode,
|
||||
} from './types';
|
||||
|
||||
export { useConnectorContext } from './application/context/use_connector_context';
|
||||
|
@ -79,6 +80,7 @@ export {
|
|||
SimpleConnectorForm,
|
||||
TextAreaWithMessageVariables,
|
||||
TextFieldWithMessageVariables,
|
||||
SectionLoading,
|
||||
} from './application/components';
|
||||
|
||||
export {
|
||||
|
|
|
@ -162,6 +162,11 @@ export interface BulkEditResponse {
|
|||
total: number;
|
||||
}
|
||||
|
||||
export enum ActionConnectorMode {
|
||||
Test = 'test',
|
||||
ActionForm = 'actionForm',
|
||||
}
|
||||
|
||||
export interface ActionParamsProps<TParams> {
|
||||
actionParams: Partial<TParams>;
|
||||
index: number;
|
||||
|
@ -173,6 +178,7 @@ export interface ActionParamsProps<TParams> {
|
|||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
showEmailSubjectAndMessage?: boolean;
|
||||
executionMode?: ActionConnectorMode;
|
||||
}
|
||||
|
||||
export interface Pagination {
|
||||
|
|
|
@ -253,7 +253,7 @@ export default function opsgenieTest({ getService }: FtrProviderContext) {
|
|||
retry: true,
|
||||
message: 'an error occurred while running the action',
|
||||
service_message:
|
||||
'Request validation failed (Error: [responders.0]: types that failed validation:\n- [responders.0.0.type]: types that failed validation:\n - [responders.0.type.0]: expected value to equal [team]\n - [responders.0.type.1]: expected value to equal [user]\n - [responders.0.type.2]: expected value to equal [escalation]\n - [responders.0.type.3]: expected value to equal [schedule]\n- [responders.0.1.id]: expected value of type [string] but got [undefined])',
|
||||
'Request validation failed (Error: [responders.0]: types that failed validation:\n- [responders.0.0.type]: types that failed validation:\n - [responders.0.type.0]: expected value to equal [team]\n - [responders.0.type.1]: expected value to equal [user]\n - [responders.0.type.2]: expected value to equal [escalation]\n - [responders.0.type.3]: expected value to equal [schedule]\n- [responders.0.1.id]: expected value of type [string] but got [undefined]\n- [responders.0.2.username]: expected value of type [string] but got [undefined])',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -282,7 +282,7 @@ export default function opsgenieTest({ getService }: FtrProviderContext) {
|
|||
retry: true,
|
||||
message: 'an error occurred while running the action',
|
||||
service_message:
|
||||
'Request validation failed (Error: [responders.0]: types that failed validation:\n- [responders.0.0.name]: expected value of type [string] but got [undefined]\n- [responders.0.1.id]: expected value of type [string] but got [undefined])',
|
||||
'Request validation failed (Error: [responders.0]: types that failed validation:\n- [responders.0.0.name]: expected value of type [string] but got [undefined]\n- [responders.0.1.id]: expected value of type [string] but got [undefined]\n- [responders.0.2.username]: expected value of type [string] but got [undefined])',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -682,7 +682,8 @@ export default function opsgenieTest({ getService }: FtrProviderContext) {
|
|||
message: 'an error occurred while running the action',
|
||||
retry: true,
|
||||
connector_id: opsgenieActionId,
|
||||
service_message: 'Status code: undefined. Message: Message: failed',
|
||||
service_message:
|
||||
'Status code: 422. Message: Request failed with status code 422: {"message":"failed"}',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -704,7 +705,8 @@ export default function opsgenieTest({ getService }: FtrProviderContext) {
|
|||
message: 'an error occurred while running the action',
|
||||
retry: true,
|
||||
connector_id: opsgenieActionId,
|
||||
service_message: 'Status code: undefined. Message: Message: failed',
|
||||
service_message:
|
||||
'Status code: 422. Message: Request failed with status code 422: {"message":"failed"}',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -25,5 +25,16 @@ export function ActionsCommonServiceProvider({ getService, getPageObject }: FtrP
|
|||
|
||||
await testSubjects.click(`.${name}-card`);
|
||||
},
|
||||
|
||||
async cancelConnectorForm() {
|
||||
const flyOutCancelButton = await testSubjects.find('edit-connector-flyout-close-btn');
|
||||
const isEnabled = await flyOutCancelButton.isEnabled();
|
||||
const isDisplayed = await flyOutCancelButton.isDisplayed();
|
||||
|
||||
if (isEnabled && isDisplayed) {
|
||||
await flyOutCancelButton.click();
|
||||
await testSubjects.missingOrFail('edit-connector-flyout-close-btn');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ export function ActionsOpsgenieServiceProvider(
|
|||
common: ActionsCommon
|
||||
) {
|
||||
const testSubjects = getService('testSubjects');
|
||||
const find = getService('find');
|
||||
|
||||
return {
|
||||
async createNewConnector(fields: ConnectorFormFields) {
|
||||
|
@ -44,5 +45,20 @@ export function ActionsOpsgenieServiceProvider(
|
|||
expect(await editFlyOutSaveButton.isEnabled()).to.be(true);
|
||||
await editFlyOutSaveButton.click();
|
||||
},
|
||||
|
||||
async getObjFromJsonEditor() {
|
||||
const jsonEditor = await find.byCssSelector('.monaco-editor .view-lines');
|
||||
|
||||
return JSON.parse(await jsonEditor.getVisibleText());
|
||||
},
|
||||
|
||||
async setJsonEditor(value: object) {
|
||||
const stringified = JSON.stringify(value);
|
||||
|
||||
await find.clickByCssSelector('.monaco-editor');
|
||||
const input = await find.activeElement();
|
||||
await input.clearValueWithKeyboard({ charByChar: true });
|
||||
await input.type(stringified);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -140,6 +140,149 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
|
||||
expect(await (await testSubjects.find('executeActionButton')).isEnabled()).to.be(false);
|
||||
});
|
||||
|
||||
describe('test page', () => {
|
||||
let connectorId = '';
|
||||
|
||||
before(async () => {
|
||||
const connectorName = generateUniqueKey();
|
||||
const createdAction = await createOpsgenieConnector(connectorName);
|
||||
connectorId = createdAction.id;
|
||||
objectRemover.add(createdAction.id, 'action', 'actions');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testSubjects.click(`edit${connectorId}`);
|
||||
await testSubjects.click('testConnectorTab');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await actions.common.cancelConnectorForm();
|
||||
});
|
||||
|
||||
it('should show the sub action selector when in test mode', async () => {
|
||||
await testSubjects.existOrFail('opsgenie-subActionSelect');
|
||||
});
|
||||
|
||||
it('should preserve the alias when switching between create and close alert actions', async () => {
|
||||
await testSubjects.setValue('aliasInput', 'new alias');
|
||||
await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert');
|
||||
|
||||
expect(await testSubjects.getAttribute('opsgenie-subActionSelect', 'value')).to.be(
|
||||
'closeAlert'
|
||||
);
|
||||
expect(await testSubjects.getAttribute('aliasInput', 'value')).to.be('new alias');
|
||||
});
|
||||
|
||||
it('should not preserve the message when switching to close alert and back to create alert', async () => {
|
||||
await testSubjects.setValue('messageInput', 'a message');
|
||||
await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert');
|
||||
|
||||
await testSubjects.missingOrFail('messageInput');
|
||||
await retry.waitFor('message input to be displayed', async () => {
|
||||
await testSubjects.selectValue('opsgenie-subActionSelect', 'createAlert');
|
||||
return await testSubjects.exists('messageInput');
|
||||
});
|
||||
|
||||
expect(await testSubjects.getAttribute('messageInput', 'value')).to.be('');
|
||||
});
|
||||
|
||||
describe('createAlert', () => {
|
||||
it('should show the additional options for creating an alert when clicking more options', async () => {
|
||||
await testSubjects.click('opsgenie-display-more-options');
|
||||
|
||||
await testSubjects.existOrFail('entityInput');
|
||||
await testSubjects.existOrFail('sourceInput');
|
||||
await testSubjects.existOrFail('userInput');
|
||||
await testSubjects.existOrFail('noteTextArea');
|
||||
});
|
||||
|
||||
it('should show and then hide the additional form options for creating an alert when clicking the button twice', async () => {
|
||||
await testSubjects.click('opsgenie-display-more-options');
|
||||
|
||||
await testSubjects.existOrFail('entityInput');
|
||||
|
||||
await testSubjects.click('opsgenie-display-more-options');
|
||||
await testSubjects.missingOrFail('entityInput');
|
||||
});
|
||||
|
||||
it('should populate the json editor with the message, description, and alias', async () => {
|
||||
await testSubjects.setValue('messageInput', 'a message');
|
||||
await testSubjects.setValue('descriptionTextArea', 'a description');
|
||||
await testSubjects.setValue('aliasInput', 'an alias');
|
||||
await testSubjects.setValue('opsgenie-prioritySelect', 'P5');
|
||||
await testSubjects.setValue('opsgenie-tags', 'a tag');
|
||||
|
||||
await testSubjects.click('opsgenie-show-json-editor-toggle');
|
||||
|
||||
const parsedValue = await actions.opsgenie.getObjFromJsonEditor();
|
||||
expect(parsedValue).to.eql({
|
||||
message: 'a message',
|
||||
description: 'a description',
|
||||
alias: 'an alias',
|
||||
priority: 'P5',
|
||||
tags: ['a tag'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should populate the form with the values from the json editor', async () => {
|
||||
await testSubjects.click('opsgenie-show-json-editor-toggle');
|
||||
|
||||
await actions.opsgenie.setJsonEditor({
|
||||
message: 'a message',
|
||||
description: 'a description',
|
||||
alias: 'an alias',
|
||||
priority: 'P3',
|
||||
tags: ['tag1'],
|
||||
});
|
||||
await testSubjects.click('opsgenie-show-json-editor-toggle');
|
||||
|
||||
expect(await testSubjects.getAttribute('messageInput', 'value')).to.be('a message');
|
||||
expect(await testSubjects.getAttribute('descriptionTextArea', 'value')).to.be(
|
||||
'a description'
|
||||
);
|
||||
expect(await testSubjects.getAttribute('aliasInput', 'value')).to.be('an alias');
|
||||
expect(await testSubjects.getAttribute('opsgenie-prioritySelect', 'value')).to.eql(
|
||||
'P3'
|
||||
);
|
||||
expect(await (await testSubjects.find('opsgenie-tags')).getVisibleText()).to.eql(
|
||||
'tag1'
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable the run button when the json editor validation fails', async () => {
|
||||
await testSubjects.click('opsgenie-show-json-editor-toggle');
|
||||
|
||||
await actions.opsgenie.setJsonEditor({
|
||||
message: '',
|
||||
});
|
||||
|
||||
expect(await testSubjects.isEnabled('executeActionButton')).to.be(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeAlert', () => {
|
||||
it('should show the additional options for closing an alert when clicking more options', async () => {
|
||||
await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert');
|
||||
|
||||
await testSubjects.click('opsgenie-display-more-options');
|
||||
|
||||
await testSubjects.existOrFail('sourceInput');
|
||||
await testSubjects.existOrFail('userInput');
|
||||
});
|
||||
|
||||
it('should show and then hide the additional form options for closing an alert when clicking the button twice', async () => {
|
||||
await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert');
|
||||
|
||||
await testSubjects.click('opsgenie-display-more-options');
|
||||
|
||||
await testSubjects.existOrFail('sourceInput');
|
||||
|
||||
await testSubjects.click('opsgenie-display-more-options');
|
||||
await testSubjects.missingOrFail('sourceInput');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('alerts page', () => {
|
||||
|
@ -163,9 +306,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
});
|
||||
|
||||
it('should default to the create alert action', async () => {
|
||||
expect(await testSubjects.getAttribute('opsgenie-subActionSelect', 'value')).to.eql(
|
||||
'createAlert'
|
||||
);
|
||||
await testSubjects.existOrFail('messageInput');
|
||||
|
||||
expect(await testSubjects.getAttribute('aliasInput', 'value')).to.eql(defaultAlias);
|
||||
});
|
||||
|
@ -174,33 +315,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
await testSubjects.click('addNewActionConnectorActionGroup-0');
|
||||
await testSubjects.click('addNewActionConnectorActionGroup-0-option-recovered');
|
||||
|
||||
expect(await testSubjects.getAttribute('opsgenie-subActionSelect', 'value')).to.eql(
|
||||
'closeAlert'
|
||||
);
|
||||
expect(await testSubjects.getAttribute('aliasInput', 'value')).to.eql(defaultAlias);
|
||||
});
|
||||
|
||||
it('should preserve the alias when switching between create and close alert actions', async () => {
|
||||
await testSubjects.setValue('aliasInput', 'new alias');
|
||||
await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert');
|
||||
|
||||
expect(await testSubjects.getAttribute('opsgenie-subActionSelect', 'value')).to.be(
|
||||
'closeAlert'
|
||||
);
|
||||
expect(await testSubjects.getAttribute('aliasInput', 'value')).to.be('new alias');
|
||||
});
|
||||
|
||||
it('should not preserve the message when switching to close alert and back to create alert', async () => {
|
||||
await testSubjects.setValue('messageInput', 'a message');
|
||||
await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert');
|
||||
|
||||
await testSubjects.existOrFail('noteTextArea');
|
||||
await testSubjects.missingOrFail('messageInput');
|
||||
await retry.waitFor('message input to be displayed', async () => {
|
||||
await testSubjects.selectValue('opsgenie-subActionSelect', 'createAlert');
|
||||
return await testSubjects.exists('messageInput');
|
||||
});
|
||||
|
||||
expect(await testSubjects.getAttribute('messageInput', 'value')).to.be('');
|
||||
});
|
||||
|
||||
it('should not preserve the alias when switching run when to recover', async () => {
|
||||
|
@ -225,6 +342,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
|||
|
||||
expect(await testSubjects.getAttribute('aliasInput', 'value')).to.be(defaultAlias);
|
||||
});
|
||||
|
||||
it('should show the message is required error when clicking the save button', async () => {
|
||||
await testSubjects.click('saveRuleButton');
|
||||
const messageError = await find.byClassName('euiFormErrorText');
|
||||
|
||||
expect(await messageError.getVisibleText()).to.eql('Message is required.');
|
||||
});
|
||||
});
|
||||
|
||||
const setupRule = async () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue