[Alerting UI] Alert and Connector flyouts Save and Save&Test buttons should be active by default. (#86708)

* Alert and Connector flyouts Save and Save&Test buttons should be active by default.

* fixed typechecks

* fixed typechecks

* refactored repeted code

* fixed typechecks

* fixed typechecks

* fixed typechecks

* fixed due to comments

* fixed failing tests

* fixed due to comments

* fixed due to comments

* fixed due to comments

* fixed typescript checks
This commit is contained in:
Yuliia Naumenko 2021-01-05 11:49:44 -08:00 committed by GitHub
parent 051be28c69
commit dddd8e4fe7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 1287 additions and 586 deletions

View file

@ -15,7 +15,12 @@ import { act } from 'react-dom/test-utils';
import { coreMock } from 'src/core/public/mocks';
import { actionTypeRegistryMock } from '../../../triggers_actions_ui/public/application/action_type_registry.mock';
import { alertTypeRegistryMock } from '../../../triggers_actions_ui/public/application/alert_type_registry.mock';
import { ValidationResult, Alert } from '../../../triggers_actions_ui/public/types';
import {
ValidationResult,
Alert,
ConnectorValidationResult,
GenericValidationResult,
} from '../../../triggers_actions_ui/public/types';
import { AlertForm } from '../../../triggers_actions_ui/public/application/sections/alert_form/alert_form';
import ActionForm from '../../../triggers_actions_ui/public/application/sections/action_connector_form/action_form';
import { Legacy } from '../legacy_shims';
@ -88,8 +93,13 @@ describe('alert_form', () => {
id: 'alert-action-type',
iconClass: '',
selectMessage: '',
validateConnector: validationMethod,
validateParams: validationMethod,
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,
};

View file

@ -32,7 +32,7 @@ export function getActionType(): ActionTypeModel {
iconClass: 'securityAnalyticsApp',
selectMessage: i18n.CASE_CONNECTOR_DESC,
actionTypeTitle: i18n.CASE_CONNECTOR_TITLE,
validateConnector: () => ({ errors: {} }),
validateConnector: () => ({ config: { errors: {} }, secrets: { errors: {} } }),
validateParams,
actionConnectorFields: null,
actionParamsFields: lazy(() => import('./fields')),

View file

@ -253,7 +253,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<
fullWidth
name="thresholdTimeField"
data-test-subj="thresholdAlertTimeFieldSelect"
value={timeField}
value={timeField || ''}
onChange={(e) => {
setAlertParams('timeField', e.target.value);
}}

View file

@ -48,12 +48,18 @@ describe('connector validation', () => {
} as EmailActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
from: [],
port: [],
host: [],
user: [],
password: [],
config: {
errors: {
from: [],
port: [],
host: [],
},
},
secrets: {
errors: {
user: [],
password: [],
},
},
});
});
@ -78,12 +84,18 @@ describe('connector validation', () => {
} as EmailActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
from: [],
port: [],
host: [],
user: [],
password: [],
config: {
errors: {
from: [],
port: [],
host: [],
},
},
secrets: {
errors: {
user: [],
password: [],
},
},
});
});
@ -103,12 +115,18 @@ describe('connector validation', () => {
} as EmailActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
from: [],
port: ['Port is required.'],
host: ['Host is required.'],
user: [],
password: [],
config: {
errors: {
from: [],
port: ['Port is required.'],
host: ['Host is required.'],
},
},
secrets: {
errors: {
user: [],
password: [],
},
},
});
});
@ -132,12 +150,18 @@ describe('connector validation', () => {
} as EmailActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
from: [],
port: [],
host: [],
user: [],
password: ['Password is required when username is used.'],
config: {
errors: {
from: [],
port: [],
host: [],
},
},
secrets: {
errors: {
user: [],
password: ['Password is required when username is used.'],
},
},
});
});
@ -161,12 +185,18 @@ describe('connector validation', () => {
} as EmailActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
from: [],
port: [],
host: [],
user: ['Username is required when password is used.'],
password: [],
config: {
errors: {
from: [],
port: [],
host: [],
},
},
secrets: {
errors: {
user: ['Username is required when password is used.'],
password: [],
},
},
});
});

View file

@ -5,7 +5,11 @@
*/
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { ActionTypeModel, ValidationResult } from '../../../../types';
import {
ActionTypeModel,
ConnectorValidationResult,
GenericValidationResult,
} from '../../../../types';
import { EmailActionParams, EmailConfig, EmailSecrets, EmailActionConnector } from '../types';
export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, EmailActionParams> {
@ -25,18 +29,25 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
defaultMessage: 'Send to email',
}
),
validateConnector: (action: EmailActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
validateConnector: (
action: EmailActionConnector
): ConnectorValidationResult<Omit<EmailConfig, 'secure' | 'hasAuth'>, EmailSecrets> => {
const configErrors = {
from: new Array<string>(),
port: new Array<string>(),
host: new Array<string>(),
};
const secretsErrors = {
user: new Array<string>(),
password: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = {
config: { errors: configErrors },
secrets: { errors: secretsErrors },
};
if (!action.config.from) {
errors.from.push(
configErrors.from.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText',
{
@ -46,7 +57,7 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
);
}
if (action.config.from && !action.config.from.trim().match(mailformat)) {
errors.from.push(
configErrors.from.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText',
{
@ -56,7 +67,7 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
);
}
if (!action.config.port) {
errors.port.push(
configErrors.port.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText',
{
@ -66,7 +77,7 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
);
}
if (!action.config.host) {
errors.host.push(
configErrors.host.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText',
{
@ -76,7 +87,7 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
);
}
if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) {
errors.user.push(
secretsErrors.user.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthUserNameText',
{
@ -86,7 +97,7 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
);
}
if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) {
errors.password.push(
secretsErrors.password.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthPasswordText',
{
@ -96,7 +107,7 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
);
}
if (action.secrets.user && !action.secrets.password) {
errors.password.push(
secretsErrors.password.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText',
{
@ -106,7 +117,7 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
);
}
if (!action.secrets.user && action.secrets.password) {
errors.user.push(
secretsErrors.user.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText',
{
@ -117,8 +128,9 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
}
return validationResult;
},
validateParams: (actionParams: EmailActionParams): ValidationResult => {
const validationResult = { errors: {} };
validateParams: (
actionParams: EmailActionParams
): GenericValidationResult<EmailActionParams> => {
const errors = {
to: new Array<string>(),
cc: new Array<string>(),
@ -126,7 +138,7 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
message: new Array<string>(),
subject: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = { errors };
if (
(!(actionParams.to instanceof Array) || actionParams.to.length === 0) &&
(!(actionParams.cc instanceof Array) || actionParams.cc.length === 0) &&

View file

@ -192,7 +192,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
}
)}
disabled={readOnly}
checked={hasAuth}
checked={hasAuth || false}
onChange={(e) => {
editActionConfig('hasAuth', e.target.checked);
if (!e.target.checked) {

View file

@ -42,8 +42,13 @@ describe('index connector validation', () => {
} as EsIndexActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
index: [],
config: {
errors: {
index: [],
},
},
secrets: {
errors: {},
},
});
});
@ -62,8 +67,13 @@ describe('index connector validation with minimal config', () => {
} as EsIndexActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
index: [],
config: {
errors: {
index: [],
},
},
secrets: {
errors: {},
},
});
});

View file

@ -5,7 +5,11 @@
*/
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { ActionTypeModel, ValidationResult } from '../../../../types';
import {
ActionTypeModel,
GenericValidationResult,
ConnectorValidationResult,
} from '../../../../types';
import { EsIndexActionConnector, EsIndexConfig, IndexActionParams } from '../types';
export function getActionType(): ActionTypeModel<EsIndexConfig, unknown, IndexActionParams> {
@ -24,14 +28,15 @@ export function getActionType(): ActionTypeModel<EsIndexConfig, unknown, IndexAc
defaultMessage: 'Index data',
}
),
validateConnector: (action: EsIndexActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
validateConnector: (
action: EsIndexActionConnector
): ConnectorValidationResult<Pick<EsIndexConfig, 'index'>, unknown> => {
const configErrors = {
index: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = { config: { errors: configErrors }, secrets: { errors: {} } };
if (!action.config.index) {
errors.index.push(
configErrors.index.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText',
{
@ -44,12 +49,13 @@ export function getActionType(): ActionTypeModel<EsIndexConfig, unknown, IndexAc
},
actionConnectorFields: lazy(() => import('./es_index_connector')),
actionParamsFields: lazy(() => import('./es_index_params')),
validateParams: (actionParams: IndexActionParams): ValidationResult => {
const validationResult = { errors: {} };
validateParams: (
actionParams: IndexActionParams
): GenericValidationResult<IndexActionParams> => {
const errors = {
documents: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = { errors };
if (!actionParams.documents?.length || Object.keys(actionParams.documents[0]).length === 0) {
errors.documents.push(
i18n.translate(

View file

@ -38,7 +38,11 @@ export const IndexParamsFields = ({
paramsProperty={'documents'}
data-test-subj="documentToIndex"
inputTargetValue={
documents && documents.length > 0 ? ((documents[0] as unknown) as string) : undefined
documents === null
? '{}' // need this to trigger validation
: documents && documents.length > 0
? ((documents[0] as unknown) as string)
: undefined
}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.documentsFieldLabel',

View file

@ -44,11 +44,17 @@ describe('jira connector validation', () => {
} as JiraActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
apiUrl: [],
email: [],
apiToken: [],
projectKey: [],
config: {
errors: {
apiUrl: [],
projectKey: [],
},
},
secrets: {
errors: {
apiToken: [],
email: [],
},
},
});
});
@ -65,11 +71,17 @@ describe('jira connector validation', () => {
} as unknown) as JiraActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
apiUrl: ['URL is required.'],
email: [],
apiToken: ['API token or password is required'],
projectKey: ['Project key is required'],
config: {
errors: {
apiUrl: ['URL is required.'],
projectKey: ['Project key is required'],
},
},
secrets: {
errors: {
apiToken: ['API token or password is required'],
email: [],
},
},
});
});
@ -82,7 +94,7 @@ describe('jira action params validation', () => {
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: { summary: [] },
errors: { 'subActionParams.incident.summary': [] },
});
});
@ -93,7 +105,7 @@ describe('jira action params validation', () => {
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
summary: ['Summary is required.'],
'subActionParams.incident.summary': ['Summary is required.'],
},
});
});

View file

@ -5,46 +5,56 @@
*/
import { lazy } from 'react';
import { ValidationResult, ActionTypeModel } from '../../../../types';
import {
GenericValidationResult,
ActionTypeModel,
ConnectorValidationResult,
} from '../../../../types';
import { connectorConfiguration } from './config';
import logo from './logo.svg';
import { JiraActionConnector, JiraConfig, JiraSecrets, JiraActionParams } from './types';
import * as i18n from './translations';
import { isValidUrl } from '../../../lib/value_validators';
const validateConnector = (action: JiraActionConnector): ValidationResult => {
const validationResult = {
errors: {
apiUrl: new Array<string>(),
projectKey: new Array<string>(),
email: new Array<string>(),
apiToken: new Array<string>(),
},
const validateConnector = (
action: JiraActionConnector
): ConnectorValidationResult<JiraConfig, JiraSecrets> => {
const configErrors = {
apiUrl: new Array<string>(),
projectKey: new Array<string>(),
};
const secretsErrors = {
email: new Array<string>(),
apiToken: new Array<string>(),
};
const validationResult = {
config: { errors: configErrors },
secrets: { errors: secretsErrors },
};
const { errors } = validationResult;
if (!action.config.apiUrl) {
errors.apiUrl = [...errors.apiUrl, i18n.API_URL_REQUIRED];
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED];
}
if (action.config.apiUrl) {
if (!isValidUrl(action.config.apiUrl)) {
errors.apiUrl = [...errors.apiUrl, i18n.API_URL_INVALID];
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID];
} else if (!isValidUrl(action.config.apiUrl, 'https:')) {
errors.apiUrl = [...errors.apiUrl, i18n.API_URL_REQUIRE_HTTPS];
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS];
}
}
if (!action.config.projectKey) {
errors.projectKey = [...errors.projectKey, i18n.JIRA_PROJECT_KEY_REQUIRED];
configErrors.projectKey = [...configErrors.projectKey, i18n.JIRA_PROJECT_KEY_REQUIRED];
}
if (!action.secrets.email) {
errors.email = [...errors.email, i18n.JIRA_EMAIL_REQUIRED];
secretsErrors.email = [...secretsErrors.email, i18n.JIRA_EMAIL_REQUIRED];
}
if (!action.secrets.apiToken) {
errors.apiToken = [...errors.apiToken, i18n.JIRA_API_TOKEN_REQUIRED];
secretsErrors.apiToken = [...secretsErrors.apiToken, i18n.JIRA_API_TOKEN_REQUIRED];
}
return validationResult;
@ -58,18 +68,19 @@ export function getActionType(): ActionTypeModel<JiraConfig, JiraSecrets, JiraAc
actionTypeTitle: connectorConfiguration.name,
validateConnector,
actionConnectorFields: lazy(() => import('./jira_connectors')),
validateParams: (actionParams: JiraActionParams): ValidationResult => {
const validationResult = { errors: {} };
validateParams: (actionParams: JiraActionParams): GenericValidationResult<unknown> => {
const errors = {
summary: new Array<string>(),
'subActionParams.incident.summary': new Array<string>(),
};
const validationResult = {
errors,
};
validationResult.errors = errors;
if (
actionParams.subActionParams &&
actionParams.subActionParams.incident &&
!actionParams.subActionParams.incident.summary?.length
) {
errors.summary.push(i18n.SUMMARY_REQUIRED);
errors['subActionParams.incident.summary'].push(i18n.SUMMARY_REQUIRED);
}
return validationResult;
},

View file

@ -31,13 +31,13 @@ const JiraConnectorFields: React.FC<ActionConnectorFieldsProps<JiraActionConnect
}) => {
const { apiUrl, projectKey } = action.config;
const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl != null;
const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined;
const { email, apiToken } = action.secrets;
const isProjectKeyInvalid: boolean = errors.projectKey.length > 0 && projectKey != null;
const isEmailInvalid: boolean = errors.email.length > 0 && email != null;
const isApiTokenInvalid: boolean = errors.apiToken.length > 0 && apiToken != null;
const isProjectKeyInvalid: boolean = errors.projectKey.length > 0 && projectKey !== undefined;
const isEmailInvalid: boolean = errors.email.length > 0 && email !== undefined;
const isApiTokenInvalid: boolean = errors.apiToken.length > 0 && apiToken !== undefined;
const handleOnChangeActionConfig = useCallback(
(key: string, value: string) => editActionConfig(key, value),

View file

@ -48,7 +48,7 @@ const defaultProps = {
actionConnector: connector,
actionParams,
editAction,
errors: { summary: [] },
errors: { 'subActionParams.incident.summary': [] },
index: 0,
messageVariables: [],
};
@ -244,7 +244,7 @@ describe('JiraParamsFields renders', () => {
test('If summary has errors, form row is invalid', () => {
const newProps = {
...defaultProps,
errors: { summary: ['error'] },
errors: { 'subActionParams.incident.summary': ['error'] },
};
const wrapper = mount(<JiraParamsFields {...newProps} />);
const summary = wrapper.find('[data-test-subj="summary-row"]').first();

View file

@ -249,8 +249,11 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
<EuiFormRow
data-test-subj="summary-row"
fullWidth
error={errors.summary}
isInvalid={errors.summary.length > 0 && incident.summary !== undefined}
error={errors['subActionParams.incident.summary']}
isInvalid={
errors['subActionParams.incident.summary'].length > 0 &&
incident.summary !== undefined
}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.summaryFieldLabel',
{
@ -264,7 +267,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
messageVariables={messageVariables}
paramsProperty={'summary'}
inputTargetValue={incident.summary ?? undefined}
errors={errors.summary as string[]}
errors={errors['subActionParams.incident.summary'] as string[]}
/>
</EuiFormRow>
<EuiSpacer size="m" />
@ -327,7 +330,6 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
defaultMessage: 'Description',
}
)}
errors={errors.description as string[]}
/>
)}
<TextAreaWithMessageVariables
@ -342,7 +344,6 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
defaultMessage: 'Additional comments',
}
)}
errors={errors.comments as string[]}
/>
</>
</>

View file

@ -9,7 +9,6 @@ import { UserConfiguredActionConnector } from '../../../../types';
import { ExecutorSubActionPushParams } from '../../../../../../actions/server/builtin_action_types/jira/types';
export type JiraActionConnector = UserConfiguredActionConnector<JiraConfig, JiraSecrets>;
export interface JiraActionParams {
subAction: string;
subActionParams: ExecutorSubActionPushParams;

View file

@ -42,16 +42,20 @@ describe('pagerduty connector validation', () => {
} as PagerDutyActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
routingKey: [],
secrets: {
errors: {
routingKey: [],
},
},
});
delete actionConnector.config.apiUrl;
actionConnector.secrets.routingKey = 'test1';
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
routingKey: [],
secrets: {
errors: {
routingKey: [],
},
},
});
});
@ -68,8 +72,10 @@ describe('pagerduty connector validation', () => {
} as PagerDutyActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
routingKey: ['An integration key / routing key is required.'],
secrets: {
errors: {
routingKey: ['An integration key / routing key is required.'],
},
},
});
});

View file

@ -6,7 +6,11 @@
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import { ActionTypeModel, ValidationResult } from '../../../../types';
import {
ActionTypeModel,
GenericValidationResult,
ConnectorValidationResult,
} from '../../../../types';
import {
PagerDutyActionConnector,
PagerDutyConfig,
@ -36,15 +40,18 @@ export function getActionType(): ActionTypeModel<
defaultMessage: 'Send to PagerDuty',
}
),
validateConnector: (action: PagerDutyActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
validateConnector: (
action: PagerDutyActionConnector
): ConnectorValidationResult<PagerDutyConfig, PagerDutySecrets> => {
const secretsErrors = {
routingKey: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = {
secrets: { errors: secretsErrors },
};
if (!action.secrets.routingKey) {
errors.routingKey.push(
secretsErrors.routingKey.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText',
{
@ -55,14 +62,17 @@ export function getActionType(): ActionTypeModel<
}
return validationResult;
},
validateParams: (actionParams: PagerDutyActionParams): ValidationResult => {
const validationResult = { errors: {} };
validateParams: (
actionParams: PagerDutyActionParams
): GenericValidationResult<
Pick<PagerDutyActionParams, 'summary' | 'timestamp' | 'dedupKey'>
> => {
const errors = {
summary: new Array<string>(),
timestamp: new Array<string>(),
dedupKey: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = { errors };
if (
!actionParams.dedupKey?.length &&
(actionParams.eventAction === 'resolve' || actionParams.eventAction === 'acknowledge')

View file

@ -44,11 +44,17 @@ describe('resilient connector validation', () => {
} as ResilientActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
apiUrl: [],
apiKeyId: [],
apiKeySecret: [],
orgId: [],
config: {
errors: {
apiUrl: [],
orgId: [],
},
},
secrets: {
errors: {
apiKeySecret: [],
apiKeyId: [],
},
},
});
});
@ -65,11 +71,17 @@ describe('resilient connector validation', () => {
} as unknown) as ResilientActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
apiUrl: ['URL is required.'],
apiKeyId: [],
apiKeySecret: ['Secret is required'],
orgId: ['Organization ID is required'],
config: {
errors: {
apiUrl: ['URL is required.'],
orgId: ['Organization ID is required'],
},
},
secrets: {
errors: {
apiKeySecret: ['Secret is required'],
apiKeyId: [],
},
},
});
});
@ -82,7 +94,7 @@ describe('resilient action params validation', () => {
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: { name: [] },
errors: { 'subActionParams.incident.name': [] },
});
});
@ -93,7 +105,7 @@ describe('resilient action params validation', () => {
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
name: ['Name is required.'],
'subActionParams.incident.name': ['Name is required.'],
},
});
});

View file

@ -5,7 +5,11 @@
*/
import { lazy } from 'react';
import { ValidationResult, ActionTypeModel } from '../../../../types';
import {
GenericValidationResult,
ActionTypeModel,
ConnectorValidationResult,
} from '../../../../types';
import { connectorConfiguration } from './config';
import logo from './logo.svg';
import {
@ -17,38 +21,45 @@ import {
import * as i18n from './translations';
import { isValidUrl } from '../../../lib/value_validators';
const validateConnector = (action: ResilientActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
const validateConnector = (
action: ResilientActionConnector
): ConnectorValidationResult<ResilientConfig, ResilientSecrets> => {
const configErrors = {
apiUrl: new Array<string>(),
orgId: new Array<string>(),
};
const secretsErrors = {
apiKeyId: new Array<string>(),
apiKeySecret: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = {
config: { errors: configErrors },
secrets: { errors: secretsErrors },
};
if (!action.config.apiUrl) {
errors.apiUrl = [...errors.apiUrl, i18n.API_URL_REQUIRED];
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED];
}
if (action.config.apiUrl) {
if (!isValidUrl(action.config.apiUrl)) {
errors.apiUrl = [...errors.apiUrl, i18n.API_URL_INVALID];
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID];
} else if (!isValidUrl(action.config.apiUrl, 'https:')) {
errors.apiUrl = [...errors.apiUrl, i18n.API_URL_REQUIRE_HTTPS];
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS];
}
}
if (!action.config.orgId) {
errors.orgId = [...errors.orgId, i18n.ORG_ID_REQUIRED];
configErrors.orgId = [...configErrors.orgId, i18n.ORG_ID_REQUIRED];
}
if (!action.secrets.apiKeyId) {
errors.apiKeyId = [...errors.apiKeyId, i18n.API_KEY_ID_REQUIRED];
secretsErrors.apiKeyId = [...secretsErrors.apiKeyId, i18n.API_KEY_ID_REQUIRED];
}
if (!action.secrets.apiKeySecret) {
errors.apiKeySecret = [...errors.apiKeySecret, i18n.API_KEY_SECRET_REQUIRED];
secretsErrors.apiKeySecret = [...secretsErrors.apiKeySecret, i18n.API_KEY_SECRET_REQUIRED];
}
return validationResult;
@ -66,18 +77,19 @@ export function getActionType(): ActionTypeModel<
actionTypeTitle: connectorConfiguration.name,
validateConnector,
actionConnectorFields: lazy(() => import('./resilient_connectors')),
validateParams: (actionParams: ResilientActionParams): ValidationResult => {
const validationResult = { errors: {} };
validateParams: (actionParams: ResilientActionParams): GenericValidationResult<unknown> => {
const errors = {
name: new Array<string>(),
'subActionParams.incident.name': new Array<string>(),
};
const validationResult = {
errors,
};
validationResult.errors = errors;
if (
actionParams.subActionParams &&
actionParams.subActionParams.incident &&
!actionParams.subActionParams.incident.name?.length
) {
errors.name.push(i18n.NAME_REQUIRED);
errors['subActionParams.incident.name'].push(i18n.NAME_REQUIRED);
}
return validationResult;
},

View file

@ -29,13 +29,14 @@ const ResilientConnectorFields: React.FC<ActionConnectorFieldsProps<ResilientAct
readOnly,
}) => {
const { apiUrl, orgId } = action.config;
const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl != null;
const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined;
const { apiKeyId, apiKeySecret } = action.secrets;
const isOrgIdInvalid: boolean = errors.orgId.length > 0 && orgId != null;
const isApiKeyInvalid: boolean = errors.apiKeyId.length > 0 && apiKeyId != null;
const isApiKeySecretInvalid: boolean = errors.apiKeySecret.length > 0 && apiKeySecret != null;
const isOrgIdInvalid: boolean = errors.orgId.length > 0 && orgId !== undefined;
const isApiKeyInvalid: boolean = errors.apiKeyId.length > 0 && apiKeyId !== undefined;
const isApiKeySecretInvalid: boolean =
errors.apiKeySecret.length > 0 && apiKeySecret !== undefined;
const handleOnChangeActionConfig = useCallback(
(key: string, value: string) => editActionConfig(key, value),

View file

@ -42,7 +42,7 @@ const connector = {
const editAction = jest.fn();
const defaultProps = {
actionParams,
errors: { name: [] },
errors: { 'subActionParams.incident.name': [] },
editAction,
index: 0,
messageVariables: [],
@ -128,7 +128,7 @@ describe('ResilientParamsFields renders', () => {
test('If name has errors, form row is invalid', () => {
const newProps = {
...defaultProps,
errors: { name: ['error'] },
errors: { 'subActionParams.incident.name': ['error'] },
};
const wrapper = mount(<ResilientParamsFields {...newProps} />);
const title = wrapper.find('[data-test-subj="nameInput"]').first();

View file

@ -210,8 +210,10 @@ const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<Resilient
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
error={errors.name}
isInvalid={errors.name.length > 0 && incident.name !== undefined}
error={errors['subActionParams.incident.name']}
isInvalid={
errors['subActionParams.incident.name'].length > 0 && incident.name !== undefined
}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.nameFieldLabel',
{ defaultMessage: 'Name (required)' }
@ -223,7 +225,7 @@ const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<Resilient
messageVariables={messageVariables}
paramsProperty={'name'}
inputTargetValue={incident.name ?? undefined}
errors={errors.name as string[]}
errors={errors['subActionParams.incident.name'] as string[]}
/>
</EuiFormRow>
<TextAreaWithMessageVariables
@ -236,7 +238,6 @@ const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<Resilient
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.descriptionTextAreaFieldLabel',
{ defaultMessage: 'Description' }
)}
errors={errors.description as string[]}
/>
<TextAreaWithMessageVariables
index={index}
@ -248,7 +249,6 @@ const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<Resilient
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.commentsTextAreaFieldLabel',
{ defaultMessage: 'Additional comments' }
)}
errors={errors.comments as string[]}
/>
</Fragment>
);

View file

@ -38,7 +38,12 @@ describe('server-log connector validation', () => {
};
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {},
config: {
errors: {},
},
secrets: {
errors: {},
},
});
});
});

View file

@ -5,7 +5,11 @@
*/
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { ActionTypeModel, ValidationResult } from '../../../../types';
import {
ActionTypeModel,
GenericValidationResult,
ConnectorValidationResult,
} from '../../../../types';
import { ServerLogActionParams } from '../types';
export function getActionType(): ActionTypeModel<unknown, unknown, ServerLogActionParams> {
@ -24,15 +28,16 @@ export function getActionType(): ActionTypeModel<unknown, unknown, ServerLogActi
defaultMessage: 'Send to Server log',
}
),
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return { config: { errors: {} }, secrets: { errors: {} } };
},
validateParams: (actionParams: ServerLogActionParams): ValidationResult => {
const validationResult = { errors: {} };
validateParams: (
actionParams: ServerLogActionParams
): GenericValidationResult<Pick<ServerLogActionParams, 'message'>> => {
const errors = {
message: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = { errors };
if (!actionParams.message?.length) {
errors.message.push(
i18n.translate(

View file

@ -43,10 +43,16 @@ describe('servicenow connector validation', () => {
} as ServiceNowActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
apiUrl: [],
username: [],
password: [],
config: {
errors: {
apiUrl: [],
},
},
secrets: {
errors: {
username: [],
password: [],
},
},
});
});
@ -63,10 +69,16 @@ describe('servicenow connector validation', () => {
} as unknown) as ServiceNowActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
apiUrl: ['URL is required.'],
username: [],
password: ['Password is required.'],
config: {
errors: {
apiUrl: ['URL is required.'],
},
},
secrets: {
errors: {
username: [],
password: ['Password is required.'],
},
},
});
});
@ -79,7 +91,7 @@ describe('servicenow action params validation', () => {
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: { short_description: [] },
errors: { ['subActionParams.incident.short_description']: [] },
});
});
@ -90,7 +102,7 @@ describe('servicenow action params validation', () => {
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
short_description: ['Short description is required.'],
['subActionParams.incident.short_description']: ['Short description is required.'],
},
});
});

View file

@ -5,7 +5,11 @@
*/
import { lazy } from 'react';
import { ValidationResult, ActionTypeModel } from '../../../../types';
import {
GenericValidationResult,
ActionTypeModel,
ConnectorValidationResult,
} from '../../../../types';
import { connectorConfiguration } from './config';
import logo from './logo.svg';
import {
@ -17,33 +21,40 @@ import {
import * as i18n from './translations';
import { isValidUrl } from '../../../lib/value_validators';
const validateConnector = (action: ServiceNowActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
const validateConnector = (
action: ServiceNowActionConnector
): ConnectorValidationResult<ServiceNowConfig, ServiceNowSecrets> => {
const configErrors = {
apiUrl: new Array<string>(),
};
const secretsErrors = {
username: new Array<string>(),
password: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = {
config: { errors: configErrors },
secrets: { errors: secretsErrors },
};
if (!action.config.apiUrl) {
errors.apiUrl = [...errors.apiUrl, i18n.API_URL_REQUIRED];
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED];
}
if (action.config.apiUrl) {
if (!isValidUrl(action.config.apiUrl)) {
errors.apiUrl = [...errors.apiUrl, i18n.API_URL_INVALID];
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID];
} else if (!isValidUrl(action.config.apiUrl, 'https:')) {
errors.apiUrl = [...errors.apiUrl, i18n.API_URL_REQUIRE_HTTPS];
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS];
}
}
if (!action.secrets.username) {
errors.username = [...errors.username, i18n.USERNAME_REQUIRED];
secretsErrors.username = [...secretsErrors.username, i18n.USERNAME_REQUIRED];
}
if (!action.secrets.password) {
errors.password = [...errors.password, i18n.PASSWORD_REQUIRED];
secretsErrors.password = [...secretsErrors.password, i18n.PASSWORD_REQUIRED];
}
return validationResult;
@ -61,18 +72,20 @@ export function getActionType(): ActionTypeModel<
actionTypeTitle: connectorConfiguration.name,
validateConnector,
actionConnectorFields: lazy(() => import('./servicenow_connectors')),
validateParams: (actionParams: ServiceNowActionParams): ValidationResult => {
const validationResult = { errors: {} };
validateParams: (actionParams: ServiceNowActionParams): GenericValidationResult<unknown> => {
const errors = {
short_description: new Array<string>(),
// eslint-disable-next-line @typescript-eslint/naming-convention
'subActionParams.incident.short_description': new Array<string>(),
};
const validationResult = {
errors,
};
validationResult.errors = errors;
if (
actionParams.subActionParams &&
actionParams.subActionParams.incident &&
!actionParams.subActionParams.incident.short_description?.length
) {
errors.short_description.push(i18n.TITLE_REQUIRED);
errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED);
}
return validationResult;
},

View file

@ -31,12 +31,12 @@ const ServiceNowConnectorFields: React.FC<
const { docLinks } = useKibana().services;
const { apiUrl } = action.config;
const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl != null;
const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined;
const { username, password } = action.secrets;
const isUsernameInvalid: boolean = errors.username.length > 0 && username != null;
const isPasswordInvalid: boolean = errors.password.length > 0 && password != null;
const isUsernameInvalid: boolean = errors.username.length > 0 && username !== undefined;
const isPasswordInvalid: boolean = errors.password.length > 0 && password !== undefined;
const handleOnChangeActionConfig = useCallback(
(key: string, value: string) => editActionConfig(key, value),

View file

@ -35,7 +35,7 @@ const editAction = jest.fn();
const defaultProps = {
actionConnector: connector,
actionParams,
errors: { short_description: [] },
errors: { ['subActionParams.incident.short_description']: [] },
editAction,
index: 0,
messageVariables: [],
@ -58,7 +58,8 @@ describe('ServiceNowParamsFields renders', () => {
test('If short_description has errors, form row is invalid', () => {
const newProps = {
...defaultProps,
errors: { short_description: ['error'] },
// eslint-disable-next-line @typescript-eslint/naming-convention
errors: { 'subActionParams.incident.short_description': ['error'] },
};
const wrapper = mount(<ServiceNowParamsFields {...newProps} />);
const title = wrapper.find('[data-test-subj="short_descriptionInput"]').first();

View file

@ -180,8 +180,11 @@ const ServiceNowParamsFields: React.FunctionComponent<
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
error={errors.short_description}
isInvalid={errors.short_description.length > 0 && incident.short_description !== undefined}
error={errors['subActionParams.incident.short_description']}
isInvalid={
errors['subActionParams.incident.short_description'].length > 0 &&
incident.short_description !== undefined
}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.titleFieldLabel',
{ defaultMessage: 'Short description (required)' }
@ -193,7 +196,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
messageVariables={messageVariables}
paramsProperty={'short_description'}
inputTargetValue={incident?.short_description ?? undefined}
errors={errors.short_description as string[]}
errors={errors['subActionParams.incident.short_description'] as string[]}
/>
</EuiFormRow>
<TextAreaWithMessageVariables
@ -206,7 +209,6 @@ const ServiceNowParamsFields: React.FunctionComponent<
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.descriptionTextAreaFieldLabel',
{ defaultMessage: 'Description' }
)}
errors={errors.description as string[]}
/>
<TextAreaWithMessageVariables
index={index}
@ -218,7 +220,6 @@ const ServiceNowParamsFields: React.FunctionComponent<
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.commentsTextAreaFieldLabel',
{ defaultMessage: 'Additional comments' }
)}
errors={errors.comments as string[]}
/>
</Fragment>
);

View file

@ -40,8 +40,13 @@ describe('slack connector validation', () => {
} as SlackActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
webhookUrl: [],
config: {
errors: {},
},
secrets: {
errors: {
webhookUrl: [],
},
},
});
});
@ -56,8 +61,13 @@ describe('slack connector validation', () => {
} as SlackActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
webhookUrl: ['Webhook URL is required.'],
config: {
errors: {},
},
secrets: {
errors: {
webhookUrl: ['Webhook URL is required.'],
},
},
});
});
@ -74,8 +84,13 @@ describe('slack connector validation', () => {
} as SlackActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
webhookUrl: ['Webhook URL must start with https://.'],
config: {
errors: {},
},
secrets: {
errors: {
webhookUrl: ['Webhook URL must start with https://.'],
},
},
});
});
@ -92,8 +107,13 @@ describe('slack connector validation', () => {
} as SlackActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
webhookUrl: ['Webhook URL is invalid.'],
config: {
errors: {},
},
secrets: {
errors: {
webhookUrl: ['Webhook URL is invalid.'],
},
},
});
});

View file

@ -5,7 +5,11 @@
*/
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { ActionTypeModel, ValidationResult } from '../../../../types';
import {
ActionTypeModel,
GenericValidationResult,
ConnectorValidationResult,
} from '../../../../types';
import { SlackActionParams, SlackSecrets, SlackActionConnector } from '../types';
import { isValidUrl } from '../../../lib/value_validators';
@ -25,14 +29,15 @@ export function getActionType(): ActionTypeModel<unknown, SlackSecrets, SlackAct
defaultMessage: 'Send to Slack',
}
),
validateConnector: (action: SlackActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
validateConnector: (
action: SlackActionConnector
): ConnectorValidationResult<unknown, SlackSecrets> => {
const secretsErrors = {
webhookUrl: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = { config: { errors: {} }, secrets: { errors: secretsErrors } };
if (!action.secrets.webhookUrl) {
errors.webhookUrl.push(
secretsErrors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText',
{
@ -42,7 +47,7 @@ export function getActionType(): ActionTypeModel<unknown, SlackSecrets, SlackAct
);
} else if (action.secrets.webhookUrl) {
if (!isValidUrl(action.secrets.webhookUrl)) {
errors.webhookUrl.push(
secretsErrors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.invalidWebhookUrlText',
{
@ -51,7 +56,7 @@ export function getActionType(): ActionTypeModel<unknown, SlackSecrets, SlackAct
)
);
} else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) {
errors.webhookUrl.push(
secretsErrors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requireHttpsWebhookUrlText',
{
@ -63,12 +68,13 @@ export function getActionType(): ActionTypeModel<unknown, SlackSecrets, SlackAct
}
return validationResult;
},
validateParams: (actionParams: SlackActionParams): ValidationResult => {
const validationResult = { errors: {} };
validateParams: (
actionParams: SlackActionParams
): GenericValidationResult<SlackActionParams> => {
const errors = {
message: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = { errors };
if (!actionParams.message?.length) {
errors.message.push(
i18n.translate(

View file

@ -39,8 +39,13 @@ describe('teams connector validation', () => {
} as TeamsActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
webhookUrl: [],
config: {
errors: {},
},
secrets: {
errors: {
webhookUrl: [],
},
},
});
});
@ -55,8 +60,13 @@ describe('teams connector validation', () => {
} as TeamsActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
webhookUrl: ['Webhook URL is required.'],
config: {
errors: {},
},
secrets: {
errors: {
webhookUrl: ['Webhook URL is required.'],
},
},
});
});
@ -73,8 +83,13 @@ describe('teams connector validation', () => {
} as TeamsActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
webhookUrl: ['Webhook URL is invalid.'],
config: {
errors: {},
},
secrets: {
errors: {
webhookUrl: ['Webhook URL is invalid.'],
},
},
});
});
@ -91,8 +106,13 @@ describe('teams connector validation', () => {
} as TeamsActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
webhookUrl: ['Webhook URL must start with https://.'],
config: {
errors: {},
},
secrets: {
errors: {
webhookUrl: ['Webhook URL must start with https://.'],
},
},
});
});

View file

@ -6,7 +6,11 @@
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import teamsSvg from './teams.svg';
import { ActionTypeModel, ValidationResult } from '../../../../types';
import {
ActionTypeModel,
GenericValidationResult,
ConnectorValidationResult,
} from '../../../../types';
import { TeamsActionParams, TeamsSecrets, TeamsActionConnector } from '../types';
import { isValidUrl } from '../../../lib/value_validators';
@ -26,14 +30,15 @@ export function getActionType(): ActionTypeModel<unknown, TeamsSecrets, TeamsAct
defaultMessage: 'Send a message to a Microsoft Teams channel.',
}
),
validateConnector: (action: TeamsActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
validateConnector: (
action: TeamsActionConnector
): ConnectorValidationResult<unknown, TeamsSecrets> => {
const secretsErrors = {
webhookUrl: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = { config: { errors: {} }, secrets: { errors: secretsErrors } };
if (!action.secrets.webhookUrl) {
errors.webhookUrl.push(
secretsErrors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredWebhookUrlText',
{
@ -43,7 +48,7 @@ export function getActionType(): ActionTypeModel<unknown, TeamsSecrets, TeamsAct
);
} else if (action.secrets.webhookUrl) {
if (!isValidUrl(action.secrets.webhookUrl)) {
errors.webhookUrl.push(
secretsErrors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.invalidWebhookUrlText',
{
@ -52,7 +57,7 @@ export function getActionType(): ActionTypeModel<unknown, TeamsSecrets, TeamsAct
)
);
} else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) {
errors.webhookUrl.push(
secretsErrors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requireHttpsWebhookUrlText',
{
@ -64,12 +69,13 @@ export function getActionType(): ActionTypeModel<unknown, TeamsSecrets, TeamsAct
}
return validationResult;
},
validateParams: (actionParams: TeamsActionParams): ValidationResult => {
const validationResult = { errors: {} };
validateParams: (
actionParams: TeamsActionParams
): GenericValidationResult<TeamsActionParams> => {
const errors = {
message: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = { errors };
if (!actionParams.message?.length) {
errors.message.push(
i18n.translate(

View file

@ -47,11 +47,17 @@ describe('webhook connector validation', () => {
} as WebhookActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
url: [],
method: [],
user: [],
password: [],
config: {
errors: {
url: [],
method: [],
},
},
secrets: {
errors: {
user: [],
password: [],
},
},
});
});
@ -75,11 +81,17 @@ describe('webhook connector validation', () => {
} as WebhookActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
url: [],
method: [],
user: [],
password: [],
config: {
errors: {
url: [],
method: [],
},
},
secrets: {
errors: {
user: [],
password: [],
},
},
});
});
@ -99,11 +111,17 @@ describe('webhook connector validation', () => {
} as WebhookActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
url: ['URL is required.'],
method: [],
user: [],
password: ['Password is required when username is used.'],
config: {
errors: {
url: ['URL is required.'],
method: [],
},
},
secrets: {
errors: {
user: [],
password: ['Password is required when username is used.'],
},
},
});
});
@ -125,11 +143,17 @@ describe('webhook connector validation', () => {
} as WebhookActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
url: ['URL is invalid.'],
method: [],
user: [],
password: [],
config: {
errors: {
url: ['URL is invalid.'],
method: [],
},
},
secrets: {
errors: {
user: [],
password: [],
},
},
});
});

View file

@ -5,7 +5,11 @@
*/
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { ActionTypeModel, ValidationResult } from '../../../../types';
import {
ActionTypeModel,
GenericValidationResult,
ConnectorValidationResult,
} from '../../../../types';
import {
WebhookActionParams,
WebhookConfig,
@ -34,17 +38,23 @@ export function getActionType(): ActionTypeModel<
defaultMessage: 'Webhook data',
}
),
validateConnector: (action: WebhookActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
validateConnector: (
action: WebhookActionConnector
): ConnectorValidationResult<Pick<WebhookConfig, 'url' | 'method'>, WebhookSecrets> => {
const configErrors = {
url: new Array<string>(),
method: new Array<string>(),
};
const secretsErrors = {
user: new Array<string>(),
password: new Array<string>(),
};
validationResult.errors = errors;
const validationResult = {
config: { errors: configErrors },
secrets: { errors: secretsErrors },
};
if (!action.config.url) {
errors.url.push(
configErrors.url.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText',
{
@ -54,8 +64,8 @@ export function getActionType(): ActionTypeModel<
);
}
if (action.config.url && !isValidUrl(action.config.url)) {
errors.url = [
...errors.url,
configErrors.url = [
...configErrors.url,
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.invalidUrlTextField',
{
@ -65,7 +75,7 @@ export function getActionType(): ActionTypeModel<
];
}
if (!action.config.method) {
errors.method.push(
configErrors.method.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText',
{
@ -75,7 +85,7 @@ export function getActionType(): ActionTypeModel<
);
}
if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) {
errors.user.push(
secretsErrors.user.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthUserNameText',
{
@ -85,7 +95,7 @@ export function getActionType(): ActionTypeModel<
);
}
if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) {
errors.password.push(
secretsErrors.password.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthPasswordText',
{
@ -95,7 +105,7 @@ export function getActionType(): ActionTypeModel<
);
}
if (action.secrets.user && !action.secrets.password) {
errors.password.push(
secretsErrors.password.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText',
{
@ -105,7 +115,7 @@ export function getActionType(): ActionTypeModel<
);
}
if (!action.secrets.user && action.secrets.password) {
errors.user.push(
secretsErrors.user.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredUserText',
{
@ -116,11 +126,13 @@ export function getActionType(): ActionTypeModel<
}
return validationResult;
},
validateParams: (actionParams: WebhookActionParams): ValidationResult => {
const validationResult = { errors: {} };
validateParams: (
actionParams: WebhookActionParams
): GenericValidationResult<WebhookActionParams> => {
const errors = {
body: new Array<string>(),
};
const validationResult = { errors };
validationResult.errors = errors;
if (!actionParams.body?.length) {
errors.body.push(

View file

@ -3,8 +3,15 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { throwIfAbsent, throwIfIsntContained, isValidUrl } from './value_validators';
import {
throwIfAbsent,
throwIfIsntContained,
isValidUrl,
getConnectorWithInvalidatedFields,
getAlertWithInvalidatedFields,
} from './value_validators';
import uuid from 'uuid';
import { Alert, UserConfiguredActionConnector } from '../../types';
describe('throwIfAbsent', () => {
test('throws if value is absent', () => {
@ -93,3 +100,178 @@ describe('isValidUrl', () => {
expect(isValidUrl('https://www.elastic.co/', 'https:')).toBeTruthy();
});
});
describe('getConnectorWithInvalidatedFields', () => {
test('set nulls to all required undefined fields in connector secrets', () => {
const connector: UserConfiguredActionConnector<{}, { webhookUrl: string }> = {
secrets: {} as any,
id: 'test',
actionTypeId: '.slack',
name: 'slack',
config: {},
isPreconfigured: false,
};
const secretsErrors = { webhookUrl: ['Webhook URL is required.'] };
const configErrors = {};
const baseConnectorErrors = {};
getConnectorWithInvalidatedFields(connector, configErrors, secretsErrors, baseConnectorErrors);
expect(connector.secrets.webhookUrl).toBeNull();
});
test('set nulls to all required undefined fields in connector config', () => {
const connector: UserConfiguredActionConnector<{ apiUrl: string }, {}> = {
secrets: {},
id: 'test',
actionTypeId: '.jira',
name: 'jira',
config: {} as any,
isPreconfigured: false,
};
const secretsErrors = {};
const configErrors = { apiUrl: ['apiUrl is required'] };
const baseConnectorErrors = {};
getConnectorWithInvalidatedFields(connector, configErrors, secretsErrors, baseConnectorErrors);
expect(connector.config.apiUrl).toBeNull();
});
test('do not set nulls to the invalid fields with values in the connector properties, config and secrets', () => {
const connector: UserConfiguredActionConnector<{}, { webhookUrl: string }> = {
secrets: {
webhookUrl: 'http://test',
},
id: 'test',
actionTypeId: '.slack',
name: 'slack',
config: {},
isPreconfigured: false,
};
const secretsErrors = { webhookUrl: ['Webhook URL must start with https://.'] };
const configErrors = {};
const baseConnectorErrors = {};
getConnectorWithInvalidatedFields(connector, configErrors, secretsErrors, baseConnectorErrors);
expect(connector.secrets.webhookUrl).toEqual('http://test');
});
});
describe('getAlertWithInvalidatedFields', () => {
test('set nulls to all required undefined fields in alert', () => {
const alert: Alert = {
params: {},
consumer: 'test',
schedule: {
interval: '1m',
},
actions: [],
tags: [],
muteAll: false,
enabled: false,
mutedInstanceIds: [],
} as any;
const baseAlertErrors = { name: ['Name is required.'] };
const actionsErrors = {};
const paramsErrors = {};
getAlertWithInvalidatedFields(alert, paramsErrors, baseAlertErrors, actionsErrors);
expect(alert.name).toBeNull();
});
test('set nulls to all required undefined fields in alert params', () => {
const alert: Alert = {
name: 'test',
alertTypeId: '.threshold',
id: '123',
params: {},
consumer: 'test',
schedule: {
interval: '1m',
},
actions: [],
tags: [],
muteAll: false,
enabled: false,
mutedInstanceIds: [],
createdBy: '',
apiKeyOwner: '',
createdAt: new Date(),
executionStatus: {
status: 'ok',
lastExecutionDate: new Date(),
},
notifyWhen: 'onActionGroupChange',
throttle: '',
updatedAt: new Date(),
updatedBy: '',
};
const baseAlertErrors = {};
const actionsErrors = {};
const paramsErrors = { index: ['Index is required.'] };
getAlertWithInvalidatedFields(alert, paramsErrors, baseAlertErrors, actionsErrors);
expect(alert.params.index).toBeNull();
});
test('do not set nulls to the invalid fields with values in the connector properties, config and secrets', () => {
const alert: Alert = {
name: 'test',
id: '123',
params: {},
consumer: '@@@@',
schedule: {
interval: '1m',
},
actions: [],
tags: [],
muteAll: false,
enabled: false,
mutedInstanceIds: [],
} as any;
const baseAlertErrors = { consumer: ['Consumer is invalid.'] };
const actionsErrors = {};
const paramsErrors = {};
getAlertWithInvalidatedFields(alert, paramsErrors, baseAlertErrors, actionsErrors);
expect(alert.consumer).toEqual('@@@@');
});
test('if complex alert action fields which is required is set to nulls if it is undefined', () => {
const alert: Alert = {
name: 'test',
alertTypeId: '.threshold',
id: '123',
params: {},
consumer: 'test',
schedule: {
interval: '1m',
},
actions: [
{
actionTypeId: 'test',
group: 'qwer',
id: '123',
params: {
incident: {
field: {},
},
},
},
],
tags: [],
muteAll: false,
enabled: false,
mutedInstanceIds: [],
createdBy: '',
apiKeyOwner: '',
createdAt: new Date(),
executionStatus: {
status: 'ok',
lastExecutionDate: new Date(),
},
notifyWhen: 'onActionGroupChange',
throttle: '',
updatedAt: new Date(),
updatedBy: '',
};
const baseAlertErrors = {};
const actionsErrors = { '123': { 'incident.field.name': ['Name is required.'] } };
const paramsErrors = {};
getAlertWithInvalidatedFields(alert, paramsErrors, baseAlertErrors, actionsErrors);
expect((alert.actions[0].params as any).incident.field.name).toBeNull();
});
});

View file

@ -4,7 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { constant } from 'lodash';
import { constant, get, set } from 'lodash';
import { UserConfiguredActionConnector, IErrorObject, Alert } from '../../types';
export function throwIfAbsent<T>(message: string) {
return (value: T | undefined): T => {
@ -43,3 +44,58 @@ export const isValidUrl = (urlString: string, protocol?: string) => {
return false;
}
};
export function getConnectorWithInvalidatedFields(
connector: UserConfiguredActionConnector<Record<string, unknown>, Record<string, unknown>>,
configErrors: IErrorObject,
secretsErrors: IErrorObject,
baseConnectorErrors: IErrorObject
) {
Object.keys(configErrors).forEach((errorKey) => {
if (configErrors[errorKey].length >= 1 && get(connector.config, errorKey) === undefined) {
set(connector.config, errorKey, null);
}
});
Object.keys(secretsErrors).forEach((errorKey) => {
if (secretsErrors[errorKey].length >= 1 && get(connector.secrets, errorKey) === undefined) {
set(connector.secrets, errorKey, null);
}
});
Object.keys(baseConnectorErrors).forEach((errorKey) => {
if (baseConnectorErrors[errorKey].length >= 1 && get(connector, errorKey) === undefined) {
set(connector, errorKey, null);
}
});
return connector;
}
export function getAlertWithInvalidatedFields(
alert: Alert,
paramsErrors: IErrorObject,
baseAlertErrors: IErrorObject,
actionsErrors: Record<string, IErrorObject>
) {
Object.keys(paramsErrors).forEach((errorKey) => {
if (paramsErrors[errorKey].length >= 1 && get(alert.params, errorKey) === undefined) {
set(alert.params, errorKey, null);
}
});
Object.keys(baseAlertErrors).forEach((errorKey) => {
if (baseAlertErrors[errorKey].length >= 1 && get(alert, errorKey) === undefined) {
set(alert, errorKey, null);
}
});
Object.keys(actionsErrors).forEach((actionId) => {
const actionToValidate = alert.actions.find((action) => action.id === actionId);
Object.keys(actionsErrors[actionId]).forEach((errorKey) => {
if (
actionToValidate &&
actionsErrors[actionId][errorKey].length >= 1 &&
get(actionToValidate!.params, errorKey) === undefined
) {
set(actionToValidate!.params, errorKey, null);
}
});
});
return alert;
}

View file

@ -6,7 +6,11 @@
import * as React from 'react';
import { mountWithIntl } from '@kbn/test/jest';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult, UserConfiguredActionConnector } from '../../../types';
import {
UserConfiguredActionConnector,
GenericValidationResult,
ConnectorValidationResult,
} from '../../../types';
import { ActionConnectorForm } from './action_connector_form';
const actionTypeRegistry = actionTypeRegistryMock.create();
jest.mock('../../../common/lib/kibana');
@ -17,10 +21,10 @@ describe('action_connector_form', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},

View file

@ -23,6 +23,7 @@ import {
IErrorObject,
ActionTypeRegistryContract,
UserConfiguredActionConnector,
ActionTypeModel,
} from '../../../types';
import { hasSaveActionsCapability } from '../../lib/capabilities';
import { useKibana } from '../../../common/lib/kibana';
@ -47,6 +48,31 @@ export function validateBaseProperties(actionObject: ActionConnector) {
return validationResult;
}
export function getConnectorErrors<ConnectorConfig, ConnectorSecrets>(
connector: UserConfiguredActionConnector<ConnectorConfig, ConnectorSecrets>,
actionTypeModel: ActionTypeModel
) {
const connectorValidationResult = actionTypeModel?.validateConnector(connector);
const configErrors = (connectorValidationResult.config
? connectorValidationResult.config.errors
: {}) as IErrorObject;
const secretsErrors = (connectorValidationResult.secrets
? connectorValidationResult.secrets.errors
: {}) as IErrorObject;
const connectorBaseErrors = validateBaseProperties(connector).errors;
const connectorErrors = {
...configErrors,
...secretsErrors,
...connectorBaseErrors,
} as IErrorObject;
return {
configErrors,
secretsErrors,
connectorBaseErrors,
connectorErrors,
};
}
interface ActionConnectorProps<
ConnectorConfig = Record<string, any>,
ConnectorSecrets = Record<string, any>

View file

@ -8,7 +8,13 @@ import { mountWithIntl, nextTick } from '@kbn/test/jest';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { act } from 'react-dom/test-utils';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult, Alert, AlertAction } from '../../../types';
import {
ValidationResult,
Alert,
AlertAction,
ConnectorValidationResult,
GenericValidationResult,
} from '../../../types';
import ActionForm from './action_form';
import { useKibana } from '../../../common/lib/kibana';
import {
@ -44,10 +50,10 @@ describe('action_form', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},
@ -59,10 +65,10 @@ describe('action_form', () => {
id: 'disabled-by-config',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},
@ -74,8 +80,8 @@ describe('action_form', () => {
id: '.jira',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
const validationResult = { errors: {} };
@ -89,10 +95,10 @@ describe('action_form', () => {
id: 'disabled-by-license',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},
@ -104,10 +110,10 @@ describe('action_form', () => {
id: 'preconfigured',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},

View file

@ -8,7 +8,7 @@ import { mountWithIntl } from '@kbn/test/jest';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ActionTypeMenu } from './action_type_menu';
import { ValidationResult } from '../../../types';
import { ConnectorValidationResult, GenericValidationResult } from '../../../types';
import { useKibana } from '../../../common/lib/kibana';
jest.mock('../../../common/lib/kibana');
const actionTypeRegistry = actionTypeRegistryMock.create();
@ -38,10 +38,10 @@ describe('connector_add_flyout', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},
@ -75,10 +75,10 @@ describe('connector_add_flyout', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},
@ -112,10 +112,10 @@ describe('connector_add_flyout', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},

View file

@ -8,7 +8,7 @@ import { mountWithIntl } from '@kbn/test/jest';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import ConnectorAddFlyout from './connector_add_flyout';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult } from '../../../types';
import { ConnectorValidationResult, GenericValidationResult } from '../../../types';
import { useKibana } from '../../../common/lib/kibana';
jest.mock('../../../common/lib/kibana');
@ -196,10 +196,10 @@ function createActionType() {
id: `my-action-type-${++count}`,
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},

View file

@ -23,18 +23,14 @@ import {
import { HttpSetup } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { ActionTypeMenu } from './action_type_menu';
import { ActionConnectorForm, validateBaseProperties } from './action_connector_form';
import {
ActionType,
ActionConnector,
IErrorObject,
ActionTypeRegistryContract,
} from '../../../types';
import { ActionConnectorForm, getConnectorErrors } from './action_connector_form';
import { ActionType, ActionConnector, ActionTypeRegistryContract } from '../../../types';
import { connectorReducer } from './connector_reducer';
import { hasSaveActionsCapability } from '../../lib/capabilities';
import { createActionConnector } from '../../lib/action_connector_api';
import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants';
import { useKibana } from '../../../common/lib/kibana';
import { getConnectorWithInvalidatedFields } from '../../lib/value_validators';
export interface ConnectorAddFlyoutProps {
onClose: () => void;
@ -91,6 +87,7 @@ const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
let currentForm;
let actionTypeModel;
let saveButton;
if (!actionType) {
currentForm = (
<ActionTypeMenu
@ -103,64 +100,121 @@ const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
} else {
actionTypeModel = actionTypeRegistry.get(actionType.id);
const errors = {
...actionTypeModel?.validateConnector(connector).errors,
...validateBaseProperties(connector).errors,
} as IErrorObject;
hasErrors = !!Object.keys(errors).find((errorKey) => errors[errorKey].length >= 1);
const {
configErrors,
connectorBaseErrors,
connectorErrors,
secretsErrors,
} = getConnectorErrors(connector, actionTypeModel);
hasErrors = !!Object.keys(connectorErrors).find(
(errorKey) => connectorErrors[errorKey].length >= 1
);
currentForm = (
<ActionConnectorForm
actionTypeName={actionType.name}
connector={connector}
dispatch={dispatch}
errors={errors}
errors={connectorErrors}
actionTypeRegistry={actionTypeRegistry}
consumer={consumer}
/>
);
}
const onActionConnectorSave = async (): Promise<ActionConnector | undefined> =>
await createActionConnector({ http, connector })
.then((savedConnector) => {
if (toasts) {
toasts.addSuccess(
i18n.translate(
'xpack.triggersActionsUI.sections.addConnectorForm.updateSuccessNotificationText',
{
defaultMessage: "Created '{connectorName}'",
values: {
connectorName: savedConnector.name,
},
}
)
);
}
return savedConnector;
})
.catch((errorRes) => {
toasts.addDanger(
errorRes.body?.message ??
i18n.translate(
'xpack.triggersActionsUI.sections.addConnectorForm.updateErrorNotificationText',
{ defaultMessage: 'Cannot create a connector.' }
)
);
return undefined;
});
const onSaveClicked = async () => {
setIsSaving(true);
const savedAction = await onActionConnectorSave();
setIsSaving(false);
if (savedAction) {
closeFlyout();
if (reloadConnectors) {
await reloadConnectors();
const onActionConnectorSave = async (): Promise<ActionConnector | undefined> =>
await createActionConnector({ http, connector })
.then((savedConnector) => {
if (toasts) {
toasts.addSuccess(
i18n.translate(
'xpack.triggersActionsUI.sections.addConnectorForm.updateSuccessNotificationText',
{
defaultMessage: "Created '{connectorName}'",
values: {
connectorName: savedConnector.name,
},
}
)
);
}
return savedConnector;
})
.catch((errorRes) => {
toasts.addDanger(
errorRes.body?.message ??
i18n.translate(
'xpack.triggersActionsUI.sections.addConnectorForm.updateErrorNotificationText',
{ defaultMessage: 'Cannot create a connector.' }
)
);
return undefined;
});
const onSaveClicked = async () => {
if (hasErrors) {
setConnector(
getConnectorWithInvalidatedFields(
connector,
configErrors,
secretsErrors,
connectorBaseErrors
)
);
return;
}
}
return savedAction;
};
setIsSaving(true);
const savedAction = await onActionConnectorSave();
setIsSaving(false);
if (savedAction) {
closeFlyout();
if (reloadConnectors) {
await reloadConnectors();
}
}
return savedAction;
};
saveButton = (
<Fragment>
{onTestConnector && (
<EuiFlexItem grow={false}>
<EuiButton
color="secondary"
data-test-subj="saveAndTestNewActionButton"
type="submit"
isLoading={isSaving}
onClick={async () => {
const savedConnector = await onSaveClicked();
if (savedConnector) {
onTestConnector(savedConnector);
}
}}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionConnectorAdd.saveAndTestButtonLabel"
defaultMessage="Save & Test"
/>
</EuiButton>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
data-test-subj="saveNewActionButton"
type="submit"
isLoading={isSaving}
onClick={onSaveClicked}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionConnectorAdd.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</Fragment>
);
}
return (
<EuiFlyout onClose={closeFlyout} aria-labelledby="flyoutActionAddTitle" size="m">
@ -245,48 +299,7 @@ const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="spaceBetween">
{canSave && actionTypeModel && actionType ? (
<Fragment>
{onTestConnector && (
<EuiFlexItem grow={false}>
<EuiButton
color="secondary"
data-test-subj="saveAndTestNewActionButton"
type="submit"
isDisabled={hasErrors}
isLoading={isSaving}
onClick={async () => {
const savedConnector = await onSaveClicked();
if (savedConnector) {
onTestConnector(savedConnector);
}
}}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionConnectorAdd.saveAndTestButtonLabel"
defaultMessage="Save & Test"
/>
</EuiButton>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
data-test-subj="saveNewActionButton"
type="submit"
isDisabled={hasErrors}
isLoading={isSaving}
onClick={onSaveClicked}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionConnectorAdd.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</Fragment>
) : null}
{canSave && actionTypeModel && actionType ? saveButton : null}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -7,7 +7,7 @@ import * as React from 'react';
import { mountWithIntl } from '@kbn/test/jest';
import { ConnectorAddModal } from './connector_add_modal';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult, ActionType } from '../../../types';
import { ActionType, ConnectorValidationResult, GenericValidationResult } from '../../../types';
import { useKibana } from '../../../common/lib/kibana';
import { coreMock } from '../../../../../../../src/core/public/mocks';
@ -37,10 +37,10 @@ describe('connector_add_modal', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},

View file

@ -17,18 +17,14 @@ import {
import { EuiButtonEmpty } from '@elastic/eui';
import { EuiOverlayMask } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ActionConnectorForm, validateBaseProperties } from './action_connector_form';
import { ActionConnectorForm, getConnectorErrors } from './action_connector_form';
import { connectorReducer } from './connector_reducer';
import { createActionConnector } from '../../lib/action_connector_api';
import './connector_add_modal.scss';
import { hasSaveActionsCapability } from '../../lib/capabilities';
import {
ActionType,
ActionConnector,
IErrorObject,
ActionTypeRegistryContract,
} from '../../../types';
import { ActionType, ActionConnector, ActionTypeRegistryContract } from '../../../types';
import { useKibana } from '../../../common/lib/kibana';
import { getConnectorWithInvalidatedFields } from '../../lib/value_validators';
interface ConnectorAddModalProps {
actionType: ActionType;
@ -80,11 +76,13 @@ export const ConnectorAddModal = ({
}, [initialConnector, onClose]);
const actionTypeModel = actionTypeRegistry.get(actionType.id);
const errors = {
...actionTypeModel?.validateConnector(connector).errors,
...validateBaseProperties(connector).errors,
} as IErrorObject;
hasErrors = !!Object.keys(errors).find((errorKey) => errors[errorKey].length >= 1);
const { configErrors, connectorBaseErrors, connectorErrors, secretsErrors } = getConnectorErrors(
connector,
actionTypeModel
);
hasErrors = !!Object.keys(connectorErrors).find(
(errorKey) => connectorErrors[errorKey].length >= 1
);
const onActionConnectorSave = async (): Promise<ActionConnector | undefined> =>
await createActionConnector({ http, connector })
@ -143,7 +141,7 @@ export const ConnectorAddModal = ({
actionTypeName={actionType.name}
dispatch={dispatch}
serverError={serverError}
errors={errors}
errors={connectorErrors}
actionTypeRegistry={actionTypeRegistry}
consumer={consumer}
/>
@ -164,9 +162,19 @@ export const ConnectorAddModal = ({
data-test-subj="saveActionButtonModal"
type="submit"
iconType="check"
isDisabled={hasErrors}
isLoading={isSaving}
onClick={async () => {
if (hasErrors) {
setConnector(
getConnectorWithInvalidatedFields(
connector,
configErrors,
secretsErrors,
connectorBaseErrors
)
);
return;
}
setIsSaving(true);
const savedAction = await onActionConnectorSave();
setIsSaving(false);

View file

@ -7,7 +7,7 @@ import * as React from 'react';
import { mountWithIntl } from '@kbn/test/jest';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult } from '../../../types';
import { ConnectorValidationResult, GenericValidationResult } from '../../../types';
import ConnectorEditFlyout from './connector_edit_flyout';
import { useKibana } from '../../../common/lib/kibana';
@ -49,10 +49,10 @@ describe('connector_edit_flyout', () => {
id: 'test-action-type-id',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},
@ -93,10 +93,10 @@ describe('connector_edit_flyout', () => {
id: 'test-action-type-id',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},

View file

@ -24,9 +24,9 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Option, none, some } from 'fp-ts/lib/Option';
import { ActionConnectorForm, validateBaseProperties } from './action_connector_form';
import { ActionConnectorForm, getConnectorErrors } from './action_connector_form';
import { TestConnectorForm } from './test_connector_form';
import { ActionConnector, ActionTypeRegistryContract, IErrorObject } from '../../../types';
import { ActionConnector, ActionTypeRegistryContract } from '../../../types';
import { connectorReducer } from './connector_reducer';
import { updateActionConnector, executeAction } from '../../lib/action_connector_api';
import { hasSaveActionsCapability } from '../../lib/capabilities';
@ -36,6 +36,7 @@ import {
} from '../../../../../actions/common';
import './connector_edit_flyout.scss';
import { useKibana } from '../../../common/lib/kibana';
import { getConnectorWithInvalidatedFields } from '../../lib/value_validators';
export interface ConnectorEditFlyoutProps {
initialConnector: ActionConnector;
@ -108,14 +109,22 @@ export const ConnectorEditFlyout = ({
}, [onClose]);
const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId);
const errorsInConnectorConfig = (!connector.isPreconfigured
? {
...actionTypeModel?.validateConnector(connector).errors,
...validateBaseProperties(connector).errors,
}
: {}) as IErrorObject;
const hasErrorsInConnectorConfig = !!Object.keys(errorsInConnectorConfig).find(
(errorKey) => errorsInConnectorConfig[errorKey].length >= 1
const {
configErrors,
connectorBaseErrors,
connectorErrors,
secretsErrors,
} = !connector.isPreconfigured
? getConnectorErrors(connector, actionTypeModel)
: {
configErrors: {},
connectorBaseErrors: {},
connectorErrors: {},
secretsErrors: {},
};
const hasErrors = !!Object.keys(connectorErrors).find(
(errorKey) => connectorErrors[errorKey].length >= 1
);
const onActionConnectorSave = async (): Promise<ActionConnector | undefined> =>
@ -209,6 +218,18 @@ export const ConnectorEditFlyout = ({
};
const onSaveClicked = async (closeAfterSave: boolean = true) => {
if (hasErrors) {
setConnector(
'connector',
getConnectorWithInvalidatedFields(
connector,
configErrors,
secretsErrors,
connectorBaseErrors
)
);
return;
}
setIsSaving(true);
const savedAction = await onActionConnectorSave();
setIsSaving(false);
@ -260,7 +281,7 @@ export const ConnectorEditFlyout = ({
!connector.isPreconfigured ? (
<ActionConnectorForm
connector={connector}
errors={errorsInConnectorConfig}
errors={connectorErrors}
actionTypeName={connector.actionType}
dispatch={(changes) => {
setHasChanges(true);
@ -326,7 +347,6 @@ export const ConnectorEditFlyout = ({
<EuiButton
color="secondary"
data-test-subj="saveEditedActionButton"
isDisabled={hasErrorsInConnectorConfig || !hasChanges}
isLoading={isSaving || isExecutingAction}
onClick={async () => {
await onSaveClicked(false);
@ -344,7 +364,6 @@ export const ConnectorEditFlyout = ({
color="secondary"
data-test-subj="saveAndCloseEditedActionButton"
type="submit"
isDisabled={hasErrorsInConnectorConfig || !hasChanges}
isLoading={isSaving || isExecutingAction}
onClick={async () => {
await onSaveClicked();

View file

@ -7,7 +7,11 @@ 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, ValidationResult } from '../../../types';
import {
ActionConnector,
ConnectorValidationResult,
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';
@ -47,10 +51,10 @@ const actionType = {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},

View file

@ -20,7 +20,7 @@ import { Option, map, getOrElse } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { ActionConnector, ActionTypeRegistryContract } from '../../../types';
import { ActionConnector, ActionTypeRegistryContract, IErrorObject } from '../../../types';
import { ActionTypeExecutorResult } from '../../../../../actions/common';
export interface ConnectorAddFlyoutProps {
@ -47,8 +47,8 @@ export const TestConnectorForm = ({
const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId);
const ParamsFieldsComponent = actionTypeModel.actionParamsFields;
const actionErrors = actionTypeModel?.validateParams(actionParams);
const hasErrors = !!Object.values(actionErrors.errors).find((errors) => errors.length > 0);
const actionErrors = actionTypeModel?.validateParams(actionParams).errors as IErrorObject;
const hasErrors = !!Object.values(actionErrors).find((errors) => errors.length > 0);
const steps = [
{
@ -67,7 +67,7 @@ export const TestConnectorForm = ({
<ParamsFieldsComponent
actionParams={actionParams}
index={0}
errors={actionErrors.errors}
errors={actionErrors}
editAction={(field, value) =>
setActionParams({
...actionParams,

View file

@ -14,7 +14,11 @@ import { actionTypeRegistryMock } from '../../../action_type_registry.mock';
import { useKibana } from '../../../../common/lib/kibana';
jest.mock('../../../../common/lib/kibana');
import { ActionConnector } from '../../../../types';
import {
ActionConnector,
ConnectorValidationResult,
GenericValidationResult,
} from '../../../../types';
import { times } from 'lodash';
jest.mock('../../../lib/action_connector_api', () => ({
@ -145,6 +149,26 @@ describe('actions_connectors_list component with items', () => {
},
] = await mocks.getStartServices();
const mockedActionParamsFields = React.lazy(async () => ({
default() {
return <React.Fragment />;
},
}));
actionTypeRegistry.get.mockReturnValue({
id: 'test',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,
});
// eslint-disable-next-line react-hooks/rules-of-hooks
useKibanaMock().services.actionTypeRegistry = actionTypeRegistry;
// eslint-disable-next-line react-hooks/rules-of-hooks

View file

@ -11,7 +11,12 @@ import { EuiFormLabel } from '@elastic/eui';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import AlertAdd from './alert_add';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { Alert, ValidationResult } from '../../../types';
import {
Alert,
ConnectorValidationResult,
GenericValidationResult,
ValidationResult,
} from '../../../types';
import { alertTypeRegistryMock } from '../../alert_type_registry.mock';
import { ReactWrapper } from 'enzyme';
import { ALERTS_FEATURE_ID } from '../../../../../alerts/common';
@ -107,10 +112,10 @@ describe('alert_add', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},

View file

@ -10,11 +10,10 @@ import { i18n } from '@kbn/i18n';
import {
ActionTypeRegistryContract,
Alert,
AlertAction,
AlertTypeRegistryContract,
IErrorObject,
AlertUpdates,
} from '../../../types';
import { AlertForm, isValidAlert, validateBaseProperties } from './alert_form';
import { AlertForm, getAlertErrors, isValidAlert } from './alert_form';
import { alertReducer, InitialAlert, InitialAlertReducer } from './alert_reducer';
import { createAlert } from '../../lib/alert_api';
import { HealthCheck } from '../../components/health_check';
@ -23,6 +22,7 @@ import { hasShowActionsCapability } from '../../lib/capabilities';
import AlertAddFooter from './alert_add_footer';
import { HealthContextProvider } from '../../context/health_context';
import { useKibana } from '../../../common/lib/kibana';
import { getAlertWithInvalidatedFields } from '../../lib/value_validators';
export interface AlertAddProps<MetaData = Record<string, any>> {
consumer: string;
@ -86,7 +86,9 @@ const AlertAdd = ({
const canShowActions = hasShowActionsCapability(capabilities);
useEffect(() => {
setAlertProperty('alertTypeId', alertTypeId ?? null);
if (alertTypeId) {
setAlertProperty('alertTypeId', alertTypeId);
}
}, [alertTypeId]);
const closeFlyout = useCallback(() => {
@ -106,42 +108,27 @@ const AlertAdd = ({
};
const alertType = alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null;
const errors = {
...(alertType ? alertType.validate(alert.params).errors : []),
...validateBaseProperties(alert).errors,
} as IErrorObject;
const hasErrors = !isValidAlert(alert, errors);
const actionsErrors: Array<{
errors: IErrorObject;
}> = alert.actions.map((alertAction: AlertAction) =>
actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params)
const { alertActionsErrors, alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors(
alert as Alert,
actionTypeRegistry,
alertType
);
const hasActionErrors =
actionsErrors.find(
(errorObj: { errors: IErrorObject }) =>
errorObj &&
!!Object.keys(errorObj.errors).find((errorKey) => errorObj.errors[errorKey].length >= 1)
) !== undefined;
// Confirm before saving if user is able to add actions but hasn't added any to this alert
const shouldConfirmSave = canShowActions && alert.actions?.length === 0;
async function onSaveAlert(): Promise<Alert | undefined> {
try {
if (isValidAlert(alert, errors)) {
const newAlert = await createAlert({ http, alert });
toasts.addSuccess(
i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText', {
defaultMessage: 'Created alert "{alertName}"',
values: {
alertName: newAlert.name,
},
})
);
return newAlert;
}
const newAlert = await createAlert({ http, alert: alert as AlertUpdates });
toasts.addSuccess(
i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText', {
defaultMessage: 'Created alert "{alertName}"',
values: {
alertName: newAlert.name,
},
})
);
return newAlert;
} catch (errorRes) {
toasts.addDanger(
errorRes.body?.message ??
@ -176,7 +163,7 @@ const AlertAdd = ({
<AlertForm
alert={alert}
dispatch={dispatch}
errors={errors}
errors={alertErrors}
canChangeTrigger={canChangeTrigger}
operation={i18n.translate(
'xpack.triggersActionsUI.sections.alertAdd.operationName',
@ -191,9 +178,20 @@ const AlertAdd = ({
</EuiFlyoutBody>
<AlertAddFooter
isSaving={isSaving}
hasErrors={hasErrors || hasActionErrors}
onSave={async () => {
setIsSaving(true);
if (!isValidAlert(alert, alertErrors, alertActionsErrors)) {
setAlert(
getAlertWithInvalidatedFields(
alert as Alert,
alertParamsErrors,
alertBaseErrors,
alertActionsErrors
)
);
setIsSaving(false);
return;
}
if (shouldConfirmSave) {
setIsConfirmAlertSaveModalOpen(true);
} else {

View file

@ -17,12 +17,11 @@ import { useHealthContext } from '../../context/health_context';
interface AlertAddFooterProps {
isSaving: boolean;
hasErrors: boolean;
onSave: () => void;
onCancel: () => void;
}
export const AlertAddFooter = ({ isSaving, hasErrors, onSave, onCancel }: AlertAddFooterProps) => {
export const AlertAddFooter = ({ isSaving, onSave, onCancel }: AlertAddFooterProps) => {
const { loadingHealthCheck } = useHealthContext();
return (
@ -42,7 +41,7 @@ export const AlertAddFooter = ({ isSaving, hasErrors, onSave, onCancel }: AlertA
data-test-subj="saveAlertButton"
type="submit"
iconType="check"
isDisabled={hasErrors || loadingHealthCheck}
isDisabled={loadingHealthCheck}
isLoading={isSaving}
onClick={onSave}
>

View file

@ -8,7 +8,12 @@ import { mountWithIntl, nextTick } from '@kbn/test/jest';
import { act } from 'react-dom/test-utils';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult, Alert } from '../../../types';
import {
ValidationResult,
Alert,
ConnectorValidationResult,
GenericValidationResult,
} from '../../../types';
import { alertTypeRegistryMock } from '../../alert_type_registry.mock';
import { ReactWrapper } from 'enzyme';
import AlertEdit from './alert_edit';
@ -65,10 +70,10 @@ describe('alert_edit', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},

View file

@ -20,19 +20,14 @@ import {
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
ActionTypeRegistryContract,
Alert,
AlertAction,
AlertTypeRegistryContract,
IErrorObject,
} from '../../../types';
import { AlertForm, validateBaseProperties } from './alert_form';
import { ActionTypeRegistryContract, Alert, AlertTypeRegistryContract } from '../../../types';
import { AlertForm, getAlertErrors, isValidAlert } from './alert_form';
import { alertReducer, ConcreteAlertReducer } from './alert_reducer';
import { updateAlert } from '../../lib/alert_api';
import { HealthCheck } from '../../components/health_check';
import { HealthContextProvider } from '../../context/health_context';
import { useKibana } from '../../../common/lib/kibana';
import { getAlertWithInvalidatedFields } from '../../lib/value_validators';
export interface AlertEditProps<MetaData = Record<string, any>> {
initialAlert: Alert;
@ -64,40 +59,41 @@ export const AlertEdit = ({
http,
notifications: { toasts },
} = useKibana().services;
const setAlert = (value: Alert) => {
dispatch({ command: { type: 'setAlert' }, payload: { key: 'alert', value } });
};
const alertType = alertTypeRegistry.get(alert.alertTypeId);
const errors = {
...(alertType ? alertType.validate(alert.params).errors : []),
...validateBaseProperties(alert).errors,
} as IErrorObject;
const hasErrors = !!Object.keys(errors).find((errorKey) => errors[errorKey].length >= 1);
const actionsErrors: Array<{
errors: IErrorObject;
}> = alert.actions.map((alertAction: AlertAction) =>
actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params)
const { alertActionsErrors, alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors(
alert as Alert,
actionTypeRegistry,
alertType
);
const hasActionErrors =
actionsErrors.find(
(errorObj: { errors: IErrorObject }) =>
errorObj &&
!!Object.keys(errorObj.errors).find((errorKey) => errorObj.errors[errorKey].length >= 1)
) !== undefined;
async function onSaveAlert(): Promise<Alert | undefined> {
try {
const newAlert = await updateAlert({ http, alert, id: alert.id });
toasts.addSuccess(
i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText', {
defaultMessage: "Updated '{alertName}'",
values: {
alertName: newAlert.name,
},
})
);
return newAlert;
if (isValidAlert(alert, alertErrors, alertActionsErrors) && !hasActionsWithBrokenConnector) {
const newAlert = await updateAlert({ http, alert, id: alert.id });
toasts.addSuccess(
i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText', {
defaultMessage: "Updated '{alertName}'",
values: {
alertName: newAlert.name,
},
})
);
return newAlert;
} else {
setAlert(
getAlertWithInvalidatedFields(
alert as Alert,
alertParamsErrors,
alertBaseErrors,
alertActionsErrors
)
);
}
} catch (errorRes) {
toasts.addDanger(
errorRes.body?.message ??
@ -147,7 +143,7 @@ export const AlertEdit = ({
<AlertForm
alert={alert}
dispatch={dispatch}
errors={errors}
errors={alertErrors}
actionTypeRegistry={actionTypeRegistry}
alertTypeRegistry={alertTypeRegistry}
canChangeTrigger={false}
@ -181,7 +177,6 @@ export const AlertEdit = ({
data-test-subj="saveEditedAlertButton"
type="submit"
iconType="check"
isDisabled={hasErrors || hasActionErrors || hasActionsWithBrokenConnector}
isLoading={isSaving}
onClick={async () => {
setIsSaving(true);

View file

@ -9,7 +9,13 @@ import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { alertTypeRegistryMock } from '../../alert_type_registry.mock';
import { ValidationResult, Alert, AlertType } from '../../../types';
import {
ValidationResult,
Alert,
AlertType,
ConnectorValidationResult,
GenericValidationResult,
} from '../../../types';
import { AlertForm } from './alert_form';
import { coreMock } from 'src/core/public/mocks';
import { ALERTS_FEATURE_ID, RecoveredActionGroup } from '../../../../../alerts/common';
@ -39,10 +45,17 @@ describe('alert_form', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {
config: {
errors: {},
},
secrets: {
errors: {},
},
};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},
@ -152,7 +165,7 @@ describe('alert_form', () => {
alertTypeRegistry.has.mockReturnValue(true);
actionTypeRegistry.list.mockReturnValue([actionType]);
actionTypeRegistry.has.mockReturnValue(true);
actionTypeRegistry.get.mockReturnValue(actionType);
const initialAlert = ({
name: 'test',
params: {},
@ -171,7 +184,7 @@ describe('alert_form', () => {
<AlertForm
alert={initialAlert}
dispatch={() => {}}
errors={{ name: [], interval: [] }}
errors={{ name: [], interval: [], alertTypeId: [] }}
operation="create"
actionTypeRegistry={actionTypeRegistry}
alertTypeRegistry={alertTypeRegistry}
@ -323,6 +336,7 @@ describe('alert_form', () => {
},
]);
alertTypeRegistry.has.mockReturnValue(true);
actionTypeRegistry.get.mockReturnValue(actionType);
const initialAlert = ({
name: 'non alerting consumer test',
@ -342,7 +356,7 @@ describe('alert_form', () => {
<AlertForm
alert={initialAlert}
dispatch={() => {}}
errors={{ name: [], interval: [] }}
errors={{ name: [], interval: [], alertTypeId: [] }}
operation="create"
actionTypeRegistry={actionTypeRegistry}
alertTypeRegistry={alertTypeRegistry}
@ -404,7 +418,7 @@ describe('alert_form', () => {
<AlertForm
alert={initialAlert}
dispatch={() => {}}
errors={{ name: [], interval: [] }}
errors={{ name: [], interval: [], alertTypeId: [] }}
operation="create"
actionTypeRegistry={actionTypeRegistry}
alertTypeRegistry={alertTypeRegistry}

View file

@ -31,6 +31,7 @@ import {
EuiNotificationBadge,
EuiErrorBoundary,
EuiToolTip,
EuiCallOut,
} from '@elastic/eui';
import { capitalize, isObject } from 'lodash';
import { KibanaFeature } from '../../../../../features/public';
@ -100,24 +101,71 @@ export function validateBaseProperties(alertObject: InitialAlert): ValidationRes
if (!alertObject.alertTypeId) {
errors.alertTypeId.push(
i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredAlertTypeIdText', {
defaultMessage: 'Alert trigger is required.',
defaultMessage: 'Alert type is required.',
})
);
}
const emptyConnectorActions = alertObject.actions.find(
(actionItem) => /^\d+$/.test(actionItem.id) && Object.keys(actionItem.params).length > 0
);
if (emptyConnectorActions !== undefined) {
errors.actionConnectors.push(
i18n.translate('xpack.triggersActionsUI.sections.alertForm.error.requiredActionConnector', {
defaultMessage: 'Action connector for {actionTypeId} is required.',
values: { actionTypeId: emptyConnectorActions.actionTypeId },
})
);
}
return validationResult;
}
const hasErrors: (errors: IErrorObject) => boolean = (errors) =>
export function getAlertErrors(
alert: Alert,
actionTypeRegistry: ActionTypeRegistryContract,
alertTypeModel: AlertTypeModel | null
) {
const alertParamsErrors: IErrorObject = alertTypeModel
? alertTypeModel.validate(alert.params).errors
: [];
const alertBaseErrors = validateBaseProperties(alert).errors as IErrorObject;
const alertErrors = {
...alertParamsErrors,
...alertBaseErrors,
} as IErrorObject;
const alertActionsErrors = alert.actions.reduce((prev, alertAction: AlertAction) => {
return {
...prev,
[alertAction.id]: actionTypeRegistry
.get(alertAction.actionTypeId)
?.validateParams(alertAction.params).errors,
};
}, {}) as Record<string, IErrorObject>;
return {
alertParamsErrors,
alertBaseErrors,
alertActionsErrors,
alertErrors,
};
}
export const hasObjectErrors: (errors: IErrorObject) => boolean = (errors) =>
!!Object.values(errors).find((errorList) => {
if (isObject(errorList)) return hasErrors(errorList as IErrorObject);
if (isObject(errorList)) return hasObjectErrors(errorList as IErrorObject);
return errorList.length >= 1;
});
export function isValidAlert(
alertObject: InitialAlert | Alert,
validationResult: IErrorObject
validationResult: IErrorObject,
actionsErrors: Record<string, IErrorObject>
): alertObject is Alert {
return !hasErrors(validationResult);
return (
!hasObjectErrors(validationResult) &&
Object.keys(actionsErrors).find((actionErrorsKey) =>
hasObjectErrors(actionsErrors[actionErrorsKey])
) === undefined
);
}
function getProducerFeatureName(producer: string, kibanaFeatures: KibanaFeature[]) {
@ -558,33 +606,42 @@ export const AlertForm = ({
alertTypeModel &&
alert.alertTypeId &&
selectedAlertType ? (
<ActionForm
actions={alert.actions}
setHasActionsDisabled={setHasActionsDisabled}
setHasActionsWithBrokenConnector={setHasActionsWithBrokenConnector}
messageVariables={selectedAlertType.actionVariables}
defaultActionGroupId={defaultActionGroupId}
isActionGroupDisabledForActionType={(actionGroupId: string, actionTypeId: string) =>
isActionGroupDisabledForActionType(selectedAlertType, actionGroupId, actionTypeId)
}
actionGroups={selectedAlertType.actionGroups.map((actionGroup) =>
actionGroup.id === selectedAlertType.recoveryActionGroup.id
? {
...actionGroup,
omitOptionalMessageVariables: true,
defaultActionMessage: recoveredActionGroupMessage,
}
: { ...actionGroup, defaultActionMessage: alertTypeModel?.defaultActionMessage }
)}
getDefaultActionParams={getDefaultActionParams}
setActionIdByIndex={(id: string, index: number) => setActionProperty('id', id, index)}
setActionGroupIdByIndex={(group: string, index: number) =>
setActionProperty('group', group, index)
}
setActions={setActions}
setActionParamsProperty={setActionParamsProperty}
actionTypeRegistry={actionTypeRegistry}
/>
<>
{errors.actionConnectors.length >= 1 ? (
<Fragment>
<EuiSpacer />
<EuiCallOut color="danger" size="s" title={errors.actionConnectors} />
<EuiSpacer />
</Fragment>
) : null}
<ActionForm
actions={alert.actions}
setHasActionsDisabled={setHasActionsDisabled}
setHasActionsWithBrokenConnector={setHasActionsWithBrokenConnector}
messageVariables={selectedAlertType.actionVariables}
defaultActionGroupId={defaultActionGroupId}
isActionGroupDisabledForActionType={(actionGroupId: string, actionTypeId: string) =>
isActionGroupDisabledForActionType(selectedAlertType, actionGroupId, actionTypeId)
}
actionGroups={selectedAlertType.actionGroups.map((actionGroup) =>
actionGroup.id === selectedAlertType.recoveryActionGroup.id
? {
...actionGroup,
omitOptionalMessageVariables: true,
defaultActionMessage: recoveredActionGroupMessage,
}
: { ...actionGroup, defaultActionMessage: alertTypeModel?.defaultActionMessage }
)}
getDefaultActionParams={getDefaultActionParams}
setActionIdByIndex={(id: string, index: number) => setActionProperty('id', id, index)}
setActionGroupIdByIndex={(group: string, index: number) =>
setActionProperty('group', group, index)
}
setActions={setActions}
setActionParamsProperty={setActionParamsProperty}
actionTypeRegistry={actionTypeRegistry}
/>
</>
) : null}
</Fragment>
);
@ -801,6 +858,13 @@ export const AlertForm = ({
</EuiFlexGroup>
</EuiFormRow>
<EuiSpacer />
{errors.alertTypeId.length >= 1 && alert.alertTypeId !== undefined ? (
<Fragment>
<EuiSpacer />
<EuiCallOut color="danger" size="s" title={errors.alertTypeId} />
<EuiSpacer />
</Fragment>
) : null}
{alertTypeNodes}
</Fragment>
) : alertTypesIndex ? (

View file

@ -5,7 +5,13 @@
*/
import { TypeRegistry } from './type_registry';
import { ValidationResult, AlertTypeModel, ActionTypeModel } from '../types';
import {
ValidationResult,
AlertTypeModel,
ActionTypeModel,
ConnectorValidationResult,
GenericValidationResult,
} from '../types';
import { actionTypeRegistryMock } from './action_type_registry.mock';
export const ExpressionComponent: React.FunctionComponent = () => {
@ -35,10 +41,10 @@ const getTestActionType = (
id: id || 'my-action-type',
iconClass: iconClass || 'test',
selectMessage: selectedMessage || 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
},
validateParams: (): ValidationResult => {
validateParams: (): GenericValidationResult<unknown> => {
const validationResult = { errors: {} };
return validationResult;
},

View file

@ -79,8 +79,10 @@ export interface ActionTypeModel<ActionConfig = any, ActionSecrets = any, Action
actionTypeTitle?: string;
validateConnector: (
connector: UserConfiguredActionConnector<ActionConfig, ActionSecrets>
) => ValidationResult;
validateParams: (actionParams: any) => ValidationResult;
) => ConnectorValidationResult<Partial<ActionConfig>, Partial<ActionSecrets>>;
validateParams: (
actionParams: ActionParams
) => GenericValidationResult<Partial<ActionParams> | unknown>;
actionConnectorFields: React.LazyExoticComponent<
ComponentType<
ActionConnectorFieldsProps<UserConfiguredActionConnector<ActionConfig, ActionSecrets>>
@ -89,10 +91,19 @@ export interface ActionTypeModel<ActionConfig = any, ActionSecrets = any, Action
actionParamsFields: React.LazyExoticComponent<ComponentType<ActionParamsProps<ActionParams>>>;
}
export interface GenericValidationResult<T> {
errors: Record<Extract<keyof T, string>, string[] | unknown>;
}
export interface ValidationResult {
errors: Record<string, any>;
}
export interface ConnectorValidationResult<Config, Secrets> {
config?: GenericValidationResult<Config>;
secrets?: GenericValidationResult<Secrets>;
}
interface ActionConnectorProps<Config, Secrets> {
secrets: Secrets;
id: string;

View file

@ -256,6 +256,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const searchResultsBeforeEdit = await pageObjects.triggersActionsUI.getConnectorsList();
expect(searchResultsBeforeEdit.length).to.eql(1);
expect(await testSubjects.exists('preConfiguredTitleMessage')).to.be(true);
await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button');
expect(await testSubjects.exists('preconfiguredBadge')).to.be(true);