[Alerting UI] Reduced triggersActionsUi bundle size by making all action types UI validation messages translations asynchronous. (#100525)

* [Alerting UI] Reduced triggersActionsUi bundle size by making all connectors validation messages translations asyncronus.

* changed validation logic to be async

* fixed action form

* fixed tests

* fixed tests

* fixed validation usage in security

* fixed due to comments

* fixed due to comments

* added spinner for the validation awaiting

* fixed typechecks

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yuliia Naumenko 2021-06-02 22:33:43 -07:00 committed by GitHub
parent 71b4c38c4a
commit 45ae6cc39b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 1149 additions and 843 deletions

View file

@ -25,7 +25,7 @@ const validateParams = (actionParams: CaseActionParams) => {
validationResult.errors.caseId.push(i18n.CASE_CONNECTOR_CASE_REQUIRED);
}
return validationResult;
return Promise.resolve(validationResult);
};
export function getActionType(): ActionTypeModel {
@ -34,7 +34,7 @@ export function getActionType(): ActionTypeModel {
iconClass: 'securityAnalyticsApp',
selectMessage: i18n.CASE_CONNECTOR_DESC,
actionTypeTitle: i18n.CASE_CONNECTOR_TITLE,
validateConnector: () => ({ config: { errors: {} }, secrets: { errors: {} } }),
validateConnector: () => Promise.resolve({ config: { errors: {} }, secrets: { errors: {} } }),
validateParams,
actionConnectorFields: null,
actionParamsFields: lazy(() => import('./alert_fields')),

View file

@ -94,12 +94,12 @@ describe('alert_form', () => {
id: 'alert-action-type',
iconClass: '',
selectMessage: '',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,

View file

@ -89,6 +89,7 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({
...(defaultValues ?? stepActionsDefaultValue),
kibanaSiemAppUrl: kibanaAbsoluteUrl,
};
const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]);
const { form } = useForm<ActionsStepRule>({
defaultValue: initialState,

View file

@ -15,13 +15,13 @@ describe('stepRuleActions schema', () => {
const actionTypeRegistry = actionTypeRegistryMock.create();
describe('validateSingleAction', () => {
it('should validate single action', () => {
it('should validate single action', async () => {
(isUuid as jest.Mock).mockReturnValue(true);
(validateActionParams as jest.Mock).mockReturnValue([]);
(validateMustache as jest.Mock).mockReturnValue([]);
expect(
validateSingleAction(
await validateSingleAction(
{
id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4',
group: 'default',
@ -33,12 +33,12 @@ describe('stepRuleActions schema', () => {
).toHaveLength(0);
});
it('should validate single action with invalid mustache template', () => {
it('should validate single action with invalid mustache template', async () => {
(isUuid as jest.Mock).mockReturnValue(true);
(validateActionParams as jest.Mock).mockReturnValue([]);
(validateMustache as jest.Mock).mockReturnValue(['Message is not valid mustache template']);
const errors = validateSingleAction(
const errors = await validateSingleAction(
{
id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4',
group: 'default',
@ -54,12 +54,12 @@ describe('stepRuleActions schema', () => {
expect(errors[0]).toEqual('Message is not valid mustache template');
});
it('should validate single action with incorrect id', () => {
it('should validate single action with incorrect id', async () => {
(isUuid as jest.Mock).mockReturnValue(false);
(validateMustache as jest.Mock).mockReturnValue([]);
(validateActionParams as jest.Mock).mockReturnValue([]);
const errors = validateSingleAction(
const errors = await validateSingleAction(
{
id: '823d4',
group: 'default',
@ -74,10 +74,10 @@ describe('stepRuleActions schema', () => {
});
describe('validateRuleActionsField', () => {
it('should validate rule actions field', () => {
it('should validate rule actions field', async () => {
const validator = validateRuleActionsField(actionTypeRegistry);
const result = validator({
const result = await validator({
path: '',
value: [],
form: {} as FormHook,
@ -88,11 +88,11 @@ describe('stepRuleActions schema', () => {
expect(result).toEqual(undefined);
});
it('should validate incorrect rule actions field', () => {
it('should validate incorrect rule actions field', async () => {
(getActionTypeName as jest.Mock).mockReturnValue('Slack');
const validator = validateRuleActionsField(actionTypeRegistry);
const result = validator({
const result = await validator({
path: '',
value: [
{
@ -117,7 +117,7 @@ describe('stepRuleActions schema', () => {
});
});
it('should validate multiple incorrect rule actions field', () => {
it('should validate multiple incorrect rule actions field', async () => {
(isUuid as jest.Mock).mockReturnValueOnce(false);
(getActionTypeName as jest.Mock).mockReturnValueOnce('Slack');
(isUuid as jest.Mock).mockReturnValueOnce(true);
@ -126,7 +126,7 @@ describe('stepRuleActions schema', () => {
(validateMustache as jest.Mock).mockReturnValue(['Component is not valid mustache template']);
const validator = validateRuleActionsField(actionTypeRegistry);
const result = validator({
const result = await validator({
path: '',
value: [
{

View file

@ -13,42 +13,46 @@ import {
AlertAction,
ActionTypeRegistryContract,
} from '../../../../../../triggers_actions_ui/public';
import { FormSchema, ValidationFunc, ERROR_CODE } from '../../../../shared_imports';
import {
FormSchema,
ValidationFunc,
ERROR_CODE,
ValidationError,
} from '../../../../shared_imports';
import { ActionsStepRule } from '../../../pages/detection_engine/rules/types';
import * as I18n from './translations';
import { isUuid, getActionTypeName, validateMustache, validateActionParams } from './utils';
export const validateSingleAction = (
export const validateSingleAction = async (
actionItem: AlertAction,
actionTypeRegistry: ActionTypeRegistryContract
): string[] => {
): Promise<string[]> => {
if (!isUuid(actionItem.id)) {
return [I18n.NO_CONNECTOR_SELECTED];
}
const actionParamsErrors = validateActionParams(actionItem, actionTypeRegistry);
const actionParamsErrors = await validateActionParams(actionItem, actionTypeRegistry);
const mustacheErrors = validateMustache(actionItem.params);
return [...actionParamsErrors, ...mustacheErrors];
};
export const validateRuleActionsField = (actionTypeRegistry: ActionTypeRegistryContract) => (
export const validateRuleActionsField = (actionTypeRegistry: ActionTypeRegistryContract) => async (
...data: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<{}, ERROR_CODE>> | undefined => {
): Promise<ValidationError<ERROR_CODE> | void | undefined> => {
const [{ value, path }] = data as [{ value: AlertAction[]; path: string }];
const errors = value.reduce((acc, actionItem) => {
const errorsArray = validateSingleAction(actionItem, actionTypeRegistry);
const errors = [];
for (const actionItem of value) {
const errorsArray = await validateSingleAction(actionItem, actionTypeRegistry);
if (errorsArray.length) {
const actionTypeName = getActionTypeName(actionItem.actionTypeId);
const errorsListItems = errorsArray.map((error) => `* ${error}\n`);
return [...acc, `\n**${actionTypeName}:**\n${errorsListItems.join('')}`];
errors.push(`\n**${actionTypeName}:**\n${errorsListItems.join('')}`);
}
return acc;
}, [] as string[]);
}
if (errors.length) {
return {

View file

@ -61,11 +61,11 @@ describe('stepRuleActions utils', () => {
actionTypeRegistry.get.mockReturnValue(actionMock);
});
it('should validate action params', () => {
it('should validate action params', async () => {
validateParamsMock.mockReturnValue({ errors: [] });
expect(
validateActionParams(
await validateActionParams(
{
id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4',
group: 'default',
@ -79,13 +79,13 @@ describe('stepRuleActions utils', () => {
).toHaveLength(0);
});
it('should validate incorrect action params', () => {
it('should validate incorrect action params', async () => {
validateParamsMock.mockReturnValue({
errors: ['Message is required'],
});
expect(
validateActionParams(
await validateActionParams(
{
id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4',
group: 'default',
@ -97,7 +97,7 @@ describe('stepRuleActions utils', () => {
).toHaveLength(1);
});
it('should validate incorrect action params and filter error objects', () => {
it('should validate incorrect action params and filter error objects', async () => {
validateParamsMock.mockReturnValue({
errors: [
{
@ -107,7 +107,7 @@ describe('stepRuleActions utils', () => {
});
expect(
validateActionParams(
await validateActionParams(
{
id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4',
group: 'default',
@ -119,13 +119,13 @@ describe('stepRuleActions utils', () => {
).toHaveLength(0);
});
it('should validate incorrect action params and filter duplicated errors', () => {
it('should validate incorrect action params and filter duplicated errors', async () => {
validateParamsMock.mockReturnValue({
errors: ['Message is required', 'Message is required', 'Message is required'],
});
expect(
validateActionParams(
await validateActionParams(
{
id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4',
group: 'default',

View file

@ -41,11 +41,11 @@ export const validateMustache = (params: AlertAction['params']) => {
return errors;
};
export const validateActionParams = (
export const validateActionParams = async (
actionItem: AlertAction,
actionTypeRegistry: ActionTypeRegistryContract
): string[] => {
const actionErrors = actionTypeRegistry
): Promise<string[]> => {
const actionErrors = await actionTypeRegistry
.get(actionItem.actionTypeId)
?.validateParams(actionItem.params);

View file

@ -888,10 +888,10 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Send to Server log',
}
),
validateConnector: (): ValidationResult => {
validateConnector: (): Promise<ValidationResult> => {
return { errors: {} };
},
validateParams: (actionParams: ServerLogActionParams): ValidationResult => {
validateParams: (actionParams: ServerLogActionParams): Promise<ValidationResult> => {
// validation of action params implementation
},
actionConnectorFields: null,
@ -929,10 +929,10 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Send to email',
}
),
validateConnector: (action: EmailActionConnector): ValidationResult => {
validateConnector: (action: EmailActionConnector): Promise<ValidationResult> => {
// validation of connector properties implementation
},
validateParams: (actionParams: EmailActionParams): ValidationResult => {
validateParams: (actionParams: EmailActionParams): Promise<ValidationResult> => {
// validation of action params implementation
},
actionConnectorFields: EmailActionConnectorFields,
@ -967,10 +967,10 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Send to Slack',
}
),
validateConnector: (action: SlackActionConnector): ValidationResult => {
validateConnector: (action: SlackActionConnector): Promise<ValidationResult> => {
// validation of connector properties implementation
},
validateParams: (actionParams: SlackActionParams): ValidationResult => {
validateParams: (actionParams: SlackActionParams): Promise<ValidationResult> => {
// validation of action params implementation
},
actionConnectorFields: SlackActionFields,
@ -1000,12 +1000,12 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Index data into Elasticsearch.',
}
),
validateConnector: (): ValidationResult => {
validateConnector: (): Promise<ValidationResult> => {
return { errors: {} };
},
actionConnectorFields: IndexActionConnectorFields,
actionParamsFields: IndexParamsFields,
validateParams: (): ValidationResult => {
validateParams: (): Promise<ValidationResult> => {
return { errors: {} };
},
};
@ -1046,10 +1046,10 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Send a request to a web service.',
}
),
validateConnector: (action: WebhookActionConnector): ValidationResult => {
validateConnector: (action: WebhookActionConnector): Promise<ValidationResult> => {
// validation of connector properties implementation
},
validateParams: (actionParams: WebhookActionParams): ValidationResult => {
validateParams: (actionParams: WebhookActionParams): Promise<ValidationResult> => {
// validation of action params implementation
},
actionConnectorFields: WebhookActionConnectorFields,
@ -1086,10 +1086,10 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Send to PagerDuty',
}
),
validateConnector: (action: PagerDutyActionConnector): ValidationResult => {
validateConnector: (action: PagerDutyActionConnector): Promise<ValidationResult> => {
// validation of connector properties implementation
},
validateParams: (actionParams: PagerDutyActionParams): ValidationResult => {
validateParams: (actionParams: PagerDutyActionParams): Promise<ValidationResult> => {
// validation of action params implementation
},
actionConnectorFields: PagerDutyActionConnectorFields,
@ -1113,8 +1113,8 @@ Each action type should be defined as an `ActionTypeModel` object with the follo
iconClass: IconType;
selectMessage: string;
actionTypeTitle?: string;
validateConnector: (connector: any) => ValidationResult;
validateParams: (actionParams: any) => ValidationResult;
validateConnector: (connector: any) => Promise<ValidationResult>;
validateParams: (actionParams: any) => Promise<ValidationResult>;
actionConnectorFields: React.FunctionComponent<any> | null;
actionParamsFields: React.LazyExoticComponent<ComponentType<ActionParamsProps<ActionParams>>>;
```
@ -1186,7 +1186,7 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Example Action',
}
),
validateConnector: (action: ExampleActionConnector): ValidationResult => {
validateConnector: (action: ExampleActionConnector): Promise<ValidationResult> => {
const validationResult = { errors: {} };
const errors = {
someConnectorField: new Array<string>(),
@ -1204,7 +1204,7 @@ export function getActionType(): ActionTypeModel {
}
return validationResult;
},
validateParams: (actionParams: ExampleActionParams): ValidationResult => {
validateParams: (actionParams: ExampleActionParams): Promise<ValidationResult> => {
const validationResult = { errors: {} };
const errors = {
message: new Array<string>(),

View file

@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => {
});
describe('connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
test('connector validation succeeds when connector config is valid', async () => {
const actionConnector = {
secrets: {
user: 'user',
@ -49,7 +49,7 @@ describe('connector validation', () => {
},
} as EmailActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
from: [],
@ -66,7 +66,7 @@ describe('connector validation', () => {
});
});
test('connector validation succeeds when connector config is valid with empty user/password', () => {
test('connector validation succeeds when connector config is valid with empty user/password', async () => {
const actionConnector = {
secrets: {
user: null,
@ -85,7 +85,7 @@ describe('connector validation', () => {
},
} as EmailActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
from: [],
@ -101,7 +101,7 @@ describe('connector validation', () => {
},
});
});
test('connector validation fails when connector config is not valid', () => {
test('connector validation fails when connector config is not valid', async () => {
const actionConnector = {
secrets: {
user: 'user',
@ -116,7 +116,7 @@ describe('connector validation', () => {
},
} as EmailActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
from: [],
@ -132,7 +132,7 @@ describe('connector validation', () => {
},
});
});
test('connector validation fails when user specified but not password', () => {
test('connector validation fails when user specified but not password', async () => {
const actionConnector = {
secrets: {
user: 'user',
@ -151,7 +151,7 @@ describe('connector validation', () => {
},
} as EmailActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
from: [],
@ -167,7 +167,7 @@ describe('connector validation', () => {
},
});
});
test('connector validation fails when password specified but not user', () => {
test('connector validation fails when password specified but not user', async () => {
const actionConnector = {
secrets: {
user: null,
@ -186,7 +186,7 @@ describe('connector validation', () => {
},
} as EmailActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
from: [],
@ -205,7 +205,7 @@ describe('connector validation', () => {
});
describe('action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
test('action params validation succeeds when action params is valid', async () => {
const actionParams = {
to: [],
cc: ['test1@test.com'],
@ -213,7 +213,7 @@ describe('action params validation', () => {
subject: 'test',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
to: [],
cc: [],
@ -224,13 +224,13 @@ describe('action params validation', () => {
});
});
test('action params validation fails when action params is not valid', () => {
test('action params validation fails when action params is not valid', async () => {
const actionParams = {
to: ['test@test.com'],
subject: 'test',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
to: [],
cc: [],

View file

@ -31,9 +31,12 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
defaultMessage: 'Send to email',
}
),
validateConnector: (
validateConnector: async (
action: EmailActionConnector
): ConnectorValidationResult<Omit<EmailConfig, 'secure' | 'hasAuth'>, EmailSecrets> => {
): Promise<
ConnectorValidationResult<Omit<EmailConfig, 'secure' | 'hasAuth'>, EmailSecrets>
> => {
const translations = await import('./translations');
const configErrors = {
from: new Array<string>(),
port: new Array<string>(),
@ -49,74 +52,25 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
secrets: { errors: secretsErrors },
};
if (!action.config.from) {
configErrors.from.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText',
{
defaultMessage: 'Sender is required.',
}
)
);
configErrors.from.push(translations.SENDER_REQUIRED);
}
if (action.config.from && !action.config.from.trim().match(mailformat)) {
configErrors.from.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText',
{
defaultMessage: 'Sender is not a valid email address.',
}
)
);
configErrors.from.push(translations.SENDER_NOT_VALID);
}
if (!action.config.port) {
configErrors.port.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText',
{
defaultMessage: 'Port is required.',
}
)
);
configErrors.port.push(translations.PORT_REQUIRED);
}
if (!action.config.host) {
configErrors.host.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText',
{
defaultMessage: 'Host is required.',
}
)
);
configErrors.host.push(translations.HOST_REQUIRED);
}
if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) {
secretsErrors.user.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthUserNameText',
{
defaultMessage: 'Username is required.',
}
)
);
secretsErrors.user.push(translations.USERNAME_REQUIRED);
}
if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) {
secretsErrors.password.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthPasswordText',
{
defaultMessage: 'Password is required.',
}
)
);
secretsErrors.password.push(translations.PASSWORD_REQUIRED);
}
if (action.secrets.user && !action.secrets.password) {
secretsErrors.password.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText',
{
defaultMessage: 'Password is required when username is used.',
}
)
);
secretsErrors.password.push(translations.PASSWORD_REQUIRED_FOR_USER_USED);
}
if (!action.secrets.user && action.secrets.password) {
secretsErrors.user.push(
@ -130,9 +84,10 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
}
return validationResult;
},
validateParams: (
validateParams: async (
actionParams: EmailActionParams
): GenericValidationResult<EmailActionParams> => {
): Promise<GenericValidationResult<EmailActionParams>> => {
const translations = await import('./translations');
const errors = {
to: new Array<string>(),
cc: new Array<string>(),
@ -146,35 +101,16 @@ export function getActionType(): ActionTypeModel<EmailConfig, EmailSecrets, Emai
(!(actionParams.cc instanceof Array) || actionParams.cc.length === 0) &&
(!(actionParams.bcc instanceof Array) || actionParams.bcc.length === 0)
) {
const errorText = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText',
{
defaultMessage: 'No To, Cc, or Bcc entry. At least one entry is required.',
}
);
const errorText = translations.TO_CC_REQUIRED;
errors.to.push(errorText);
errors.cc.push(errorText);
errors.bcc.push(errorText);
}
if (!actionParams.message?.length) {
errors.message.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText',
{
defaultMessage: 'Message is required.',
}
)
);
errors.message.push(translations.MESSAGE_REQUIRED);
}
if (!actionParams.subject?.length) {
errors.subject.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText',
{
defaultMessage: 'Subject is required.',
}
)
);
errors.subject.push(translations.SUBJECT_REQUIRED);
}
return validationResult;
},

View file

@ -38,6 +38,17 @@ export const EmailActionConnectorFields: React.FunctionComponent<
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isFromInvalid: boolean =
from !== undefined && errors.from !== undefined && errors.from.length > 0;
const isHostInvalid: boolean =
host !== undefined && errors.host !== undefined && errors.host.length > 0;
const isPortInvalid: boolean =
port !== undefined && errors.port !== undefined && errors.port.length > 0;
const isPasswordInvalid: boolean =
password !== undefined && errors.password !== undefined && errors.password.length > 0;
const isUserInvalid: boolean =
user !== undefined && errors.user !== undefined && errors.user.length > 0;
return (
<>
<EuiFlexGroup>
@ -46,7 +57,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
id="from"
fullWidth
error={errors.from}
isInvalid={errors.from.length > 0 && from !== undefined}
isInvalid={isFromInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel',
{
@ -65,7 +76,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
<EuiFieldText
fullWidth
readOnly={readOnly}
isInvalid={errors.from.length > 0 && from !== undefined}
isInvalid={isFromInvalid}
name="from"
value={from || ''}
data-test-subj="emailFromInput"
@ -87,7 +98,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
id="emailHost"
fullWidth
error={errors.host}
isInvalid={errors.host.length > 0 && host !== undefined}
isInvalid={isHostInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel',
{
@ -98,7 +109,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
<EuiFieldText
fullWidth
readOnly={readOnly}
isInvalid={errors.host.length > 0 && host !== undefined}
isInvalid={isHostInvalid}
name="host"
value={host || ''}
data-test-subj="emailHostInput"
@ -121,7 +132,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
fullWidth
placeholder="587"
error={errors.port}
isInvalid={errors.port.length > 0 && port !== undefined}
isInvalid={isPortInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.portTextFieldLabel',
{
@ -131,7 +142,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
>
<EuiFieldNumber
prepend=":"
isInvalid={errors.port.length > 0 && port !== undefined}
isInvalid={isPortInvalid}
fullWidth
readOnly={readOnly}
name="port"
@ -221,7 +232,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
id="emailUser"
fullWidth
error={errors.user}
isInvalid={errors.user.length > 0 && user !== undefined}
isInvalid={isUserInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel',
{
@ -231,7 +242,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
>
<EuiFieldText
fullWidth
isInvalid={errors.user.length > 0 && user !== undefined}
isInvalid={isUserInvalid}
name="user"
readOnly={readOnly}
value={user || ''}
@ -252,7 +263,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
id="emailPassword"
fullWidth
error={errors.password}
isInvalid={errors.password.length > 0 && password !== undefined}
isInvalid={isPasswordInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel',
{
@ -263,7 +274,7 @@ export const EmailActionConnectorFields: React.FunctionComponent<
<EuiFieldPassword
fullWidth
readOnly={readOnly}
isInvalid={errors.password.length > 0 && password !== undefined}
isInvalid={isPasswordInvalid}
name="password"
value={password || ''}
data-test-subj="emailPasswordInput"

View file

@ -44,13 +44,18 @@ export const EmailParamsFields = ({
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultMessage]);
const isToInvalid: boolean = to !== undefined && errors.to !== undefined && errors.to.length > 0;
const isSubjectInvalid: boolean =
subject !== undefined && errors.subject !== undefined && errors.subject.length > 0;
const isCCInvalid: boolean = errors.cc !== undefined && errors.cc.length > 0 && cc !== undefined;
const isBCCInvalid: boolean =
errors.bcc !== undefined && errors.bcc.length > 0 && bcc !== undefined;
return (
<>
<EuiFormRow
fullWidth
error={errors.to}
isInvalid={errors.to.length > 0 && to !== undefined}
isInvalid={isToInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientTextFieldLabel',
{
@ -82,7 +87,7 @@ export const EmailParamsFields = ({
>
<EuiComboBox
noSuggestions
isInvalid={errors.to.length > 0 && to !== undefined}
isInvalid={isToInvalid}
fullWidth
data-test-subj="toEmailAddressInput"
selectedOptions={toOptions}
@ -112,7 +117,7 @@ export const EmailParamsFields = ({
<EuiFormRow
fullWidth
error={errors.cc}
isInvalid={errors.cc.length > 0 && cc !== undefined}
isInvalid={isCCInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientCopyTextFieldLabel',
{
@ -122,7 +127,7 @@ export const EmailParamsFields = ({
>
<EuiComboBox
noSuggestions
isInvalid={errors.cc.length > 0 && cc !== undefined}
isInvalid={isCCInvalid}
fullWidth
data-test-subj="ccEmailAddressInput"
selectedOptions={ccOptions}
@ -153,7 +158,7 @@ export const EmailParamsFields = ({
<EuiFormRow
fullWidth
error={errors.bcc}
isInvalid={errors.bcc.length > 0 && bcc !== undefined}
isInvalid={isBCCInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientBccTextFieldLabel',
{
@ -163,7 +168,7 @@ export const EmailParamsFields = ({
>
<EuiComboBox
noSuggestions
isInvalid={errors.bcc.length > 0 && bcc !== undefined}
isInvalid={isBCCInvalid}
fullWidth
data-test-subj="bccEmailAddressInput"
selectedOptions={bccOptions}
@ -193,7 +198,7 @@ export const EmailParamsFields = ({
<EuiFormRow
fullWidth
error={errors.subject}
isInvalid={errors.subject.length > 0 && subject !== undefined}
isInvalid={isSubjectInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.subjectTextFieldLabel',
{
@ -207,7 +212,7 @@ export const EmailParamsFields = ({
messageVariables={messageVariables}
paramsProperty={'subject'}
inputTargetValue={subject}
errors={errors.subject as string[]}
errors={(errors.subject ?? []) as string[]}
/>
</EuiFormRow>
<TextAreaWithMessageVariables
@ -222,7 +227,7 @@ export const EmailParamsFields = ({
defaultMessage: 'Message',
}
)}
errors={errors.message as string[]}
errors={(errors.message ?? []) as string[]}
/>
</>
);

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SENDER_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText',
{
defaultMessage: 'Sender is required.',
}
);
export const SENDER_NOT_VALID = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText',
{
defaultMessage: 'Sender is not a valid email address.',
}
);
export const PORT_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText',
{
defaultMessage: 'Port is required.',
}
);
export const HOST_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText',
{
defaultMessage: 'Host is required.',
}
);
export const USERNAME_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthUserNameText',
{
defaultMessage: 'Username is required.',
}
);
export const PASSWORD_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthPasswordText',
{
defaultMessage: 'Password is required.',
}
);
export const PASSWORD_REQUIRED_FOR_USER_USED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText',
{
defaultMessage: 'Password is required when username is used.',
}
);
export const TO_CC_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText',
{
defaultMessage: 'No To, Cc, or Bcc entry. At least one entry is required.',
}
);
export const MESSAGE_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText',
{
defaultMessage: 'Message is required.',
}
);
export const SUBJECT_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText',
{
defaultMessage: 'Subject is required.',
}
);

View file

@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => {
});
describe('index connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
test('connector validation succeeds when connector config is valid', async () => {
const actionConnector = {
secrets: {},
id: 'test',
@ -43,7 +43,7 @@ describe('index connector validation', () => {
},
} as EsIndexActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
index: [],
@ -57,7 +57,7 @@ describe('index connector validation', () => {
});
describe('index connector validation with minimal config', () => {
test('connector validation succeeds when connector config is valid', () => {
test('connector validation succeeds when connector config is valid', async () => {
const actionConnector = {
secrets: {},
id: 'test',
@ -68,7 +68,7 @@ describe('index connector validation with minimal config', () => {
},
} as EsIndexActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
index: [],
@ -82,9 +82,9 @@ describe('index connector validation with minimal config', () => {
});
describe('action params validation', () => {
test('action params validation succeeds when action params are valid', () => {
test('action params validation succeeds when action params are valid', async () => {
expect(
actionTypeModel.validateParams({
await actionTypeModel.validateParams({
documents: [{ test: 1234 }],
})
).toEqual({
@ -95,7 +95,7 @@ describe('action params validation', () => {
});
expect(
actionTypeModel.validateParams({
await actionTypeModel.validateParams({
documents: [{ test: 1234 }],
indexOverride: 'kibana-alert-history-anything',
})
@ -107,8 +107,8 @@ describe('action params validation', () => {
});
});
test('action params validation fails when action params are invalid', () => {
expect(actionTypeModel.validateParams({})).toEqual({
test('action params validation fails when action params are invalid', async () => {
expect(await actionTypeModel.validateParams({})).toEqual({
errors: {
documents: ['Document is required and should be a valid JSON object.'],
indexOverride: [],
@ -116,7 +116,7 @@ describe('action params validation', () => {
});
expect(
actionTypeModel.validateParams({
await actionTypeModel.validateParams({
documents: [{}],
})
).toEqual({
@ -127,7 +127,7 @@ describe('action params validation', () => {
});
expect(
actionTypeModel.validateParams({
await actionTypeModel.validateParams({
documents: [{}],
indexOverride: 'kibana-alert-history-',
})
@ -139,7 +139,7 @@ describe('action params validation', () => {
});
expect(
actionTypeModel.validateParams({
await actionTypeModel.validateParams({
documents: [{}],
indexOverride: 'this.is-a_string',
})

View file

@ -31,44 +31,32 @@ export function getActionType(): ActionTypeModel<EsIndexConfig, unknown, IndexAc
defaultMessage: 'Index data',
}
),
validateConnector: (
validateConnector: async (
action: EsIndexActionConnector
): ConnectorValidationResult<Pick<EsIndexConfig, 'index'>, unknown> => {
): Promise<ConnectorValidationResult<Pick<EsIndexConfig, 'index'>, unknown>> => {
const translations = await import('./translations');
const configErrors = {
index: new Array<string>(),
};
const validationResult = { config: { errors: configErrors }, secrets: { errors: {} } };
if (!action.config.index) {
configErrors.index.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText',
{
defaultMessage: 'Index is required.',
}
)
);
configErrors.index.push(translations.INDEX_REQUIRED);
}
return validationResult;
},
actionConnectorFields: lazy(() => import('./es_index_connector')),
actionParamsFields: lazy(() => import('./es_index_params')),
validateParams: (
validateParams: async (
actionParams: IndexActionParams
): GenericValidationResult<IndexActionParams> => {
): Promise<GenericValidationResult<IndexActionParams>> => {
const translations = await import('./translations');
const errors = {
documents: new Array<string>(),
indexOverride: new Array<string>(),
};
const validationResult = { errors };
if (!actionParams.documents?.length || Object.keys(actionParams.documents[0]).length === 0) {
errors.documents.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredDocumentJson',
{
defaultMessage: 'Document is required and should be a valid JSON object.',
}
)
);
errors.documents.push(translations.DOCUMENT_NOT_VALID);
}
if (actionParams.indexOverride) {
if (!actionParams.indexOverride.startsWith(ALERT_HISTORY_PREFIX)) {
@ -85,14 +73,7 @@ export function getActionType(): ActionTypeModel<EsIndexConfig, unknown, IndexAc
const indexSuffix = actionParams.indexOverride.replace(ALERT_HISTORY_PREFIX, '');
if (indexSuffix.length === 0) {
errors.indexOverride.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.badIndexOverrideSuffix',
{
defaultMessage: 'Alert history index must contain valid suffix.',
}
)
);
errors.indexOverride.push(translations.HISTORY_NOT_VALID);
}
}

View file

@ -74,6 +74,8 @@ const IndexActionConnectorFields: React.FunctionComponent<
indexPatternsFunction();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isIndexInvalid: boolean =
errors.index !== undefined && errors.index.length > 0 && index !== undefined;
return (
<>
@ -95,7 +97,7 @@ const IndexActionConnectorFields: React.FunctionComponent<
defaultMessage="Index"
/>
}
isInvalid={errors.index.length > 0 && index !== undefined}
isInvalid={isIndexInvalid}
error={errors.index}
helpText={
<>
@ -118,7 +120,7 @@ const IndexActionConnectorFields: React.FunctionComponent<
singleSelection={{ asPlainText: true }}
async
isLoading={isIndiciesLoading}
isInvalid={errors.index.length > 0 && index !== undefined}
isInvalid={isIndexInvalid}
noSuggestions={!indexOptions.length}
options={indexOptions}
data-test-subj="connectorIndexesComboBox"

View file

@ -117,7 +117,11 @@ export const IndexParamsFields = ({
<EuiFormRow
fullWidth
error={errors.indexOverride as string[]}
isInvalid={(errors.indexOverride as string[]) && errors.indexOverride.length > 0}
isInvalid={
errors.indexOverride !== undefined &&
(errors.indexOverride as string[]) &&
errors.indexOverride.length > 0
}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.preconfiguredIndex',
{

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const INDEX_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText',
{
defaultMessage: 'Index is required.',
}
);
export const DOCUMENT_NOT_VALID = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredDocumentJson',
{
defaultMessage: 'Document is required and should be a valid JSON object.',
}
);
export const HISTORY_NOT_VALID = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.badIndexOverrideSuffix',
{
defaultMessage: 'Alert history index must contain valid suffix.',
}
);

View file

@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => {
});
describe('jira connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
test('connector validation succeeds when connector config is valid', async () => {
const actionConnector = {
secrets: {
email: 'email',
@ -45,7 +45,7 @@ describe('jira connector validation', () => {
},
} as JiraActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: [],
@ -61,7 +61,7 @@ describe('jira connector validation', () => {
});
});
test('connector validation fails when connector config is not valid', () => {
test('connector validation fails when connector config is not valid', async () => {
const actionConnector = ({
secrets: {
email: 'user',
@ -72,7 +72,7 @@ describe('jira connector validation', () => {
config: {},
} as unknown) as JiraActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: ['URL is required.'],
@ -90,22 +90,22 @@ describe('jira connector validation', () => {
});
describe('jira action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
test('action params validation succeeds when action params is valid', async () => {
const actionParams = {
subActionParams: { incident: { summary: 'some title {{test}}' }, comments: [] },
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { 'subActionParams.incident.summary': [], 'subActionParams.incident.labels': [] },
});
});
test('params validation fails when body is not valid', () => {
test('params validation fails when body is not valid', async () => {
const actionParams = {
subActionParams: { incident: { summary: '' }, comments: [] },
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
'subActionParams.incident.summary': ['Summary is required.'],
'subActionParams.incident.labels': [],
@ -113,7 +113,7 @@ describe('jira action params validation', () => {
});
});
test('params validation fails when labels contain spaces', () => {
test('params validation fails when labels contain spaces', async () => {
const actionParams = {
subActionParams: {
incident: { summary: 'some title', labels: ['label with spaces'] },
@ -121,7 +121,7 @@ describe('jira action params validation', () => {
},
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
'subActionParams.incident.summary': [],
'subActionParams.incident.labels': ['Labels cannot contain spaces.'],

View file

@ -6,18 +6,19 @@
*/
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import {
GenericValidationResult,
ActionTypeModel,
ConnectorValidationResult,
} from '../../../../types';
import { JiraActionConnector, JiraConfig, JiraSecrets, JiraActionParams } from './types';
import * as i18n from './translations';
import { isValidUrl } from '../../../lib/value_validators';
const validateConnector = (
const validateConnector = async (
action: JiraActionConnector
): ConnectorValidationResult<JiraConfig, JiraSecrets> => {
): Promise<ConnectorValidationResult<JiraConfig, JiraSecrets>> => {
const translations = await import('./translations');
const configErrors = {
apiUrl: new Array<string>(),
projectKey: new Array<string>(),
@ -33,41 +34,58 @@ const validateConnector = (
};
if (!action.config.apiUrl) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED];
configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED];
}
if (action.config.apiUrl) {
if (!isValidUrl(action.config.apiUrl)) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID];
configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID];
} else if (!isValidUrl(action.config.apiUrl, 'https:')) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS];
configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS];
}
}
if (!action.config.projectKey) {
configErrors.projectKey = [...configErrors.projectKey, i18n.JIRA_PROJECT_KEY_REQUIRED];
configErrors.projectKey = [...configErrors.projectKey, translations.JIRA_PROJECT_KEY_REQUIRED];
}
if (!action.secrets.email) {
secretsErrors.email = [...secretsErrors.email, i18n.JIRA_EMAIL_REQUIRED];
secretsErrors.email = [...secretsErrors.email, translations.JIRA_EMAIL_REQUIRED];
}
if (!action.secrets.apiToken) {
secretsErrors.apiToken = [...secretsErrors.apiToken, i18n.JIRA_API_TOKEN_REQUIRED];
secretsErrors.apiToken = [...secretsErrors.apiToken, translations.JIRA_API_TOKEN_REQUIRED];
}
return validationResult;
};
export const JIRA_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText',
{
defaultMessage: 'Create an incident in Jira.',
}
);
export const JIRA_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.actionTypeTitle',
{
defaultMessage: 'Jira',
}
);
export function getActionType(): ActionTypeModel<JiraConfig, JiraSecrets, JiraActionParams> {
return {
id: '.jira',
iconClass: lazy(() => import('./logo')),
selectMessage: i18n.JIRA_DESC,
actionTypeTitle: i18n.JIRA_TITLE,
selectMessage: JIRA_DESC,
actionTypeTitle: JIRA_TITLE,
validateConnector,
actionConnectorFields: lazy(() => import('./jira_connectors')),
validateParams: (actionParams: JiraActionParams): GenericValidationResult<unknown> => {
validateParams: async (
actionParams: JiraActionParams
): Promise<GenericValidationResult<unknown>> => {
const translations = await import('./translations');
const errors = {
'subActionParams.incident.summary': new Array<string>(),
'subActionParams.incident.labels': new Array<string>(),
@ -80,13 +98,13 @@ export function getActionType(): ActionTypeModel<JiraConfig, JiraSecrets, JiraAc
actionParams.subActionParams.incident &&
!actionParams.subActionParams.incident.summary?.length
) {
errors['subActionParams.incident.summary'].push(i18n.SUMMARY_REQUIRED);
errors['subActionParams.incident.summary'].push(translations.SUMMARY_REQUIRED);
}
if (actionParams.subActionParams?.incident?.labels?.length) {
// Jira do not allows empty spaces on labels. If the label includes a whitespace show an error.
if (actionParams.subActionParams.incident.labels.some((label) => label.match(/\s/g)))
errors['subActionParams.incident.labels'].push(i18n.LABELS_WHITE_SPACES);
errors['subActionParams.incident.labels'].push(translations.LABELS_WHITE_SPACES);
}
return validationResult;
},

View file

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

View file

@ -186,6 +186,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
const areLabelsInvalid =
errors['subActionParams.incident.labels'] != null &&
errors['subActionParams.incident.labels'] !== undefined &&
errors['subActionParams.incident.labels'].length > 0 &&
incident.labels !== undefined;
@ -277,6 +278,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
fullWidth
error={errors['subActionParams.incident.summary']}
isInvalid={
errors['subActionParams.incident.summary'] !== undefined &&
errors['subActionParams.incident.summary'].length > 0 &&
incident.summary !== undefined
}

View file

@ -7,20 +7,6 @@
import { i18n } from '@kbn/i18n';
export const JIRA_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText',
{
defaultMessage: 'Create an incident in Jira.',
}
);
export const JIRA_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.actionTypeTitle',
{
defaultMessage: 'Jira',
}
);
export const API_URL_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.apiUrlTextFieldLabel',
{

View file

@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => {
});
describe('pagerduty connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
test('connector validation succeeds when connector config is valid', async () => {
const actionConnector = {
secrets: {
routingKey: 'test',
@ -43,7 +43,7 @@ describe('pagerduty connector validation', () => {
},
} as PagerDutyActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
secrets: {
errors: {
routingKey: [],
@ -53,7 +53,7 @@ describe('pagerduty connector validation', () => {
delete actionConnector.config.apiUrl;
actionConnector.secrets.routingKey = 'test1';
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
secrets: {
errors: {
routingKey: [],
@ -62,7 +62,7 @@ describe('pagerduty connector validation', () => {
});
});
test('connector validation fails when connector config is not valid', () => {
test('connector validation fails when connector config is not valid', async () => {
const actionConnector = {
secrets: {},
id: 'test',
@ -73,7 +73,7 @@ describe('pagerduty connector validation', () => {
},
} as PagerDutyActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
secrets: {
errors: {
routingKey: ['An integration key / routing key is required.'],
@ -84,7 +84,7 @@ describe('pagerduty connector validation', () => {
});
describe('pagerduty action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
test('action params validation succeeds when action params is valid', async () => {
const actionParams = {
eventAction: 'trigger',
dedupKey: 'test',
@ -97,7 +97,7 @@ describe('pagerduty action params validation', () => {
class: 'test class',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
dedupKey: [],
summary: [],

View file

@ -42,9 +42,10 @@ export function getActionType(): ActionTypeModel<
defaultMessage: 'Send to PagerDuty',
}
),
validateConnector: (
validateConnector: async (
action: PagerDutyActionConnector
): ConnectorValidationResult<PagerDutyConfig, PagerDutySecrets> => {
): Promise<ConnectorValidationResult<PagerDutyConfig, PagerDutySecrets>> => {
const translations = await import('./translations');
const secretsErrors = {
routingKey: new Array<string>(),
};
@ -53,22 +54,16 @@ export function getActionType(): ActionTypeModel<
};
if (!action.secrets.routingKey) {
secretsErrors.routingKey.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText',
{
defaultMessage: 'An integration key / routing key is required.',
}
)
);
secretsErrors.routingKey.push(translations.INTEGRATION_KEY_REQUIRED);
}
return validationResult;
},
validateParams: (
validateParams: async (
actionParams: PagerDutyActionParams
): GenericValidationResult<
Pick<PagerDutyActionParams, 'summary' | 'timestamp' | 'dedupKey'>
): Promise<
GenericValidationResult<Pick<PagerDutyActionParams, 'summary' | 'timestamp' | 'dedupKey'>>
> => {
const translations = await import('./translations');
const errors = {
summary: new Array<string>(),
timestamp: new Array<string>(),
@ -79,27 +74,13 @@ export function getActionType(): ActionTypeModel<
!actionParams.dedupKey?.length &&
(actionParams.eventAction === 'resolve' || actionParams.eventAction === 'acknowledge')
) {
errors.dedupKey.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredDedupKeyText',
{
defaultMessage: 'DedupKey is required when resolving or acknowledging an incident.',
}
)
);
errors.dedupKey.push(translations.DEDUP_KEY_REQUIRED);
}
if (
actionParams.eventAction === EventActionOptions.TRIGGER &&
!actionParams.summary?.length
) {
errors.summary.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText',
{
defaultMessage: 'Summary is required.',
}
)
);
errors.summary.push(translations.SUMMARY_REQUIRED);
}
if (actionParams.timestamp && !hasMustacheTokens(actionParams.timestamp)) {
if (isNaN(Date.parse(actionParams.timestamp))) {

View file

@ -20,6 +20,9 @@ const PagerDutyActionConnectorFields: React.FunctionComponent<
const { docLinks } = useKibana().services;
const { apiUrl } = action.config;
const { routingKey } = action.secrets;
const isRoutingKeyInvalid: boolean =
routingKey !== undefined && errors.routingKey !== undefined && errors.routingKey.length > 0;
return (
<>
<EuiFormRow
@ -60,7 +63,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent<
</EuiLink>
}
error={errors.routingKey}
isInvalid={errors.routingKey.length > 0 && routingKey !== undefined}
isInvalid={isRoutingKeyInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyTextFieldLabel',
{
@ -80,7 +83,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent<
)}
<EuiFieldText
fullWidth
isInvalid={errors.routingKey.length > 0 && routingKey !== undefined}
isInvalid={isRoutingKeyInvalid}
name="routingKey"
readOnly={readOnly}
value={routingKey || ''}

View file

@ -101,6 +101,12 @@ const PagerDutyParamsFields: React.FunctionComponent<ActionParamsProps<PagerDuty
const isDedupeKeyRequired = eventAction !== 'trigger';
const isTriggerPagerDutyEvent = eventAction === 'trigger';
const isDedupKeyInvalid: boolean = errors.dedupKey !== undefined && errors.dedupKey.length > 0;
const isSummaryInvalid: boolean =
errors.summary !== undefined && errors.summary.length > 0 && summary !== undefined;
const isTimestampInvalid: boolean =
errors.timestamp !== undefined && errors.timestamp.length > 0 && timestamp !== undefined;
return (
<>
<EuiFlexGroup>
@ -132,7 +138,7 @@ const PagerDutyParamsFields: React.FunctionComponent<ActionParamsProps<PagerDuty
<EuiFormRow
fullWidth
error={errors.dedupKey}
isInvalid={errors.dedupKey.length > 0}
isInvalid={isDedupKeyInvalid}
label={
isDedupeKeyRequired
? i18n.translate(
@ -166,7 +172,7 @@ const PagerDutyParamsFields: React.FunctionComponent<ActionParamsProps<PagerDuty
id="pagerDutySummary"
fullWidth
error={errors.summary}
isInvalid={errors.summary.length > 0 && summary !== undefined}
isInvalid={isSummaryInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.summaryFieldLabel',
{
@ -180,7 +186,7 @@ const PagerDutyParamsFields: React.FunctionComponent<ActionParamsProps<PagerDuty
messageVariables={messageVariables}
paramsProperty={'summary'}
inputTargetValue={summary}
errors={errors.summary as string[]}
errors={(errors.summary ?? []) as string[]}
/>
</EuiFormRow>
<EuiSpacer size="m" />
@ -211,7 +217,7 @@ const PagerDutyParamsFields: React.FunctionComponent<ActionParamsProps<PagerDuty
<EuiFormRow
fullWidth
error={errors.timestamp}
isInvalid={errors.timestamp.length > 0 && timestamp !== undefined}
isInvalid={isTimestampInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.timestampTextFieldLabel',
{

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SUMMARY_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText',
{
defaultMessage: 'Summary is required.',
}
);
export const DEDUP_KEY_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredDedupKeyText',
{
defaultMessage: 'DedupKey is required when resolving or acknowledging an incident.',
}
);
export const INTEGRATION_KEY_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText',
{
defaultMessage: 'An integration key / routing key is required.',
}
);

View file

@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => {
});
describe('resilient connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
test('connector validation succeeds when connector config is valid', async () => {
const actionConnector = {
secrets: {
apiKeyId: 'email',
@ -45,7 +45,7 @@ describe('resilient connector validation', () => {
},
} as ResilientActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: [],
@ -61,7 +61,7 @@ describe('resilient connector validation', () => {
});
});
test('connector validation fails when connector config is not valid', () => {
test('connector validation fails when connector config is not valid', async () => {
const actionConnector = ({
secrets: {
apiKeyId: 'user',
@ -72,7 +72,7 @@ describe('resilient connector validation', () => {
config: {},
} as unknown) as ResilientActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: ['URL is required.'],
@ -90,22 +90,22 @@ describe('resilient connector validation', () => {
});
describe('resilient action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
test('action params validation succeeds when action params is valid', async () => {
const actionParams = {
subActionParams: { incident: { name: 'some title {{test}}' }, comments: [] },
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { 'subActionParams.incident.name': [] },
});
});
test('params validation fails when body is not valid', () => {
test('params validation fails when body is not valid', async () => {
const actionParams = {
subActionParams: { incident: { name: '' }, comments: [] },
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
'subActionParams.incident.name': ['Name is required.'],
},

View file

@ -6,6 +6,7 @@
*/
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import {
GenericValidationResult,
ActionTypeModel,
@ -17,12 +18,12 @@ import {
ResilientSecrets,
ResilientActionParams,
} from './types';
import * as i18n from './translations';
import { isValidUrl } from '../../../lib/value_validators';
const validateConnector = (
const validateConnector = async (
action: ResilientActionConnector
): ConnectorValidationResult<ResilientConfig, ResilientSecrets> => {
): Promise<ConnectorValidationResult<ResilientConfig, ResilientSecrets>> => {
const translations = await import('./translations');
const configErrors = {
apiUrl: new Array<string>(),
orgId: new Array<string>(),
@ -38,32 +39,49 @@ const validateConnector = (
};
if (!action.config.apiUrl) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED];
configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED];
}
if (action.config.apiUrl) {
if (!isValidUrl(action.config.apiUrl)) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID];
configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID];
} else if (!isValidUrl(action.config.apiUrl, 'https:')) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS];
configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS];
}
}
if (!action.config.orgId) {
configErrors.orgId = [...configErrors.orgId, i18n.ORG_ID_REQUIRED];
configErrors.orgId = [...configErrors.orgId, translations.ORG_ID_REQUIRED];
}
if (!action.secrets.apiKeyId) {
secretsErrors.apiKeyId = [...secretsErrors.apiKeyId, i18n.API_KEY_ID_REQUIRED];
secretsErrors.apiKeyId = [...secretsErrors.apiKeyId, translations.API_KEY_ID_REQUIRED];
}
if (!action.secrets.apiKeySecret) {
secretsErrors.apiKeySecret = [...secretsErrors.apiKeySecret, i18n.API_KEY_SECRET_REQUIRED];
secretsErrors.apiKeySecret = [
...secretsErrors.apiKeySecret,
translations.API_KEY_SECRET_REQUIRED,
];
}
return validationResult;
};
export const DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.selectMessageText',
{
defaultMessage: 'Create an incident in IBM Resilient.',
}
);
export const TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.actionTypeTitle',
{
defaultMessage: 'Resilient',
}
);
export function getActionType(): ActionTypeModel<
ResilientConfig,
ResilientSecrets,
@ -72,11 +90,14 @@ export function getActionType(): ActionTypeModel<
return {
id: '.resilient',
iconClass: lazy(() => import('./logo')),
selectMessage: i18n.DESC,
actionTypeTitle: i18n.TITLE,
selectMessage: DESC,
actionTypeTitle: TITLE,
validateConnector,
actionConnectorFields: lazy(() => import('./resilient_connectors')),
validateParams: (actionParams: ResilientActionParams): GenericValidationResult<unknown> => {
validateParams: async (
actionParams: ResilientActionParams
): Promise<GenericValidationResult<unknown>> => {
const translations = await import('./translations');
const errors = {
'subActionParams.incident.name': new Array<string>(),
};
@ -88,7 +109,7 @@ export function getActionType(): ActionTypeModel<
actionParams.subActionParams.incident &&
!actionParams.subActionParams.incident.name?.length
) {
errors['subActionParams.incident.name'].push(i18n.NAME_REQUIRED);
errors['subActionParams.incident.name'].push(translations.NAME_REQUIRED);
}
return validationResult;
},

View file

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

View file

@ -213,7 +213,9 @@ const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<Resilient
fullWidth
error={errors['subActionParams.incident.name']}
isInvalid={
errors['subActionParams.incident.name'].length > 0 && incident.name !== undefined
errors['subActionParams.incident.name'] !== undefined &&
errors['subActionParams.incident.name'].length > 0 &&
incident.name !== undefined
}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.nameFieldLabel',
@ -226,7 +228,7 @@ const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<Resilient
messageVariables={messageVariables}
paramsProperty={'name'}
inputTargetValue={incident.name ?? undefined}
errors={errors['subActionParams.incident.name'] as string[]}
errors={(errors['subActionParams.incident.name'] ?? []) as string[]}
/>
</EuiFormRow>
<TextAreaWithMessageVariables

View file

@ -7,20 +7,6 @@
import { i18n } from '@kbn/i18n';
export const DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.selectMessageText',
{
defaultMessage: 'Create an incident in IBM Resilient.',
}
);
export const TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.actionTypeTitle',
{
defaultMessage: 'Resilient',
}
);
export const API_URL_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.apiUrlTextFieldLabel',
{

View file

@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => {
});
describe('server-log connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
test('connector validation succeeds when connector config is valid', async () => {
const actionConnector: UserConfiguredActionConnector<{}, {}> = {
secrets: {},
id: 'test',
@ -39,7 +39,7 @@ describe('server-log connector validation', () => {
isPreconfigured: false,
};
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {},
},
@ -51,23 +51,23 @@ describe('server-log connector validation', () => {
});
describe('action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
test('action params validation succeeds when action params is valid', async () => {
const actionParams = {
message: 'test message',
level: 'trace',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { message: [] },
});
});
test('params validation fails when message is not valid', () => {
test('params validation fails when message is not valid', async () => {
const actionParams = {
message: '',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
message: ['Message is required.'],
},

View file

@ -30,12 +30,12 @@ export function getActionType(): ActionTypeModel<unknown, unknown, ServerLogActi
defaultMessage: 'Send to Server log',
}
),
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return { config: { errors: {} }, secrets: { errors: {} } };
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({ config: { errors: {} }, secrets: { errors: {} } });
},
validateParams: (
actionParams: ServerLogActionParams
): GenericValidationResult<Pick<ServerLogActionParams, 'message'>> => {
): Promise<GenericValidationResult<Pick<ServerLogActionParams, 'message'>>> => {
const errors = {
message: new Array<string>(),
};
@ -50,7 +50,7 @@ export function getActionType(): ActionTypeModel<unknown, unknown, ServerLogActi
)
);
}
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: lazy(() => import('./server_log_params')),

View file

@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => {
describe('servicenow connector validation', () => {
[SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => {
test(`${id}: connector validation succeeds when connector config is valid`, () => {
test(`${id}: connector validation succeeds when connector config is valid`, async () => {
const actionTypeModel = actionTypeRegistry.get(id);
const actionConnector = {
secrets: {
@ -46,7 +46,7 @@ describe('servicenow connector validation', () => {
},
} as ServiceNowActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: [],
@ -61,7 +61,7 @@ describe('servicenow connector validation', () => {
});
});
test(`${id}: connector validation fails when connector config is not valid`, () => {
test(`${id}: connector validation fails when connector config is not valid`, async () => {
const actionTypeModel = actionTypeRegistry.get(id);
const actionConnector = ({
secrets: {
@ -73,7 +73,7 @@ describe('servicenow connector validation', () => {
config: {},
} as unknown) as ServiceNowActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: ['URL is required.'],
@ -92,24 +92,24 @@ describe('servicenow connector validation', () => {
describe('servicenow action params validation', () => {
[SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => {
test(`${id}: action params validation succeeds when action params is valid`, () => {
test(`${id}: action params validation succeeds when action params is valid`, async () => {
const actionTypeModel = actionTypeRegistry.get(id);
const actionParams = {
subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] },
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { ['subActionParams.incident.short_description']: [] },
});
});
test(`${id}: params validation fails when body is not valid`, () => {
test(`${id}: params validation fails when body is not valid`, async () => {
const actionTypeModel = actionTypeRegistry.get(id);
const actionParams = {
subActionParams: { incident: { short_description: '' }, comments: [] },
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
['subActionParams.incident.short_description']: ['Short description is required.'],
},

View file

@ -6,6 +6,7 @@
*/
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import {
GenericValidationResult,
ActionTypeModel,
@ -18,12 +19,12 @@ import {
ServiceNowITSMActionParams,
ServiceNowSIRActionParams,
} from './types';
import * as i18n from './translations';
import { isValidUrl } from '../../../lib/value_validators';
const validateConnector = (
const validateConnector = async (
action: ServiceNowActionConnector
): ConnectorValidationResult<ServiceNowConfig, ServiceNowSecrets> => {
): Promise<ConnectorValidationResult<ServiceNowConfig, ServiceNowSecrets>> => {
const translations = await import('./translations');
const configErrors = {
apiUrl: new Array<string>(),
};
@ -38,28 +39,56 @@ const validateConnector = (
};
if (!action.config.apiUrl) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED];
configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED];
}
if (action.config.apiUrl) {
if (!isValidUrl(action.config.apiUrl)) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID];
configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID];
} else if (!isValidUrl(action.config.apiUrl, 'https:')) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS];
configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS];
}
}
if (!action.secrets.username) {
secretsErrors.username = [...secretsErrors.username, i18n.USERNAME_REQUIRED];
secretsErrors.username = [...secretsErrors.username, translations.USERNAME_REQUIRED];
}
if (!action.secrets.password) {
secretsErrors.password = [...secretsErrors.password, i18n.PASSWORD_REQUIRED];
secretsErrors.password = [...secretsErrors.password, translations.PASSWORD_REQUIRED];
}
return validationResult;
};
export const SERVICENOW_ITSM_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText',
{
defaultMessage: 'Create an incident in ServiceNow ITSM.',
}
);
export const SERVICENOW_SIR_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText',
{
defaultMessage: 'Create an incident in ServiceNow SecOps.',
}
);
export const SERVICENOW_ITSM_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle',
{
defaultMessage: 'ServiceNow ITSM',
}
);
export const SERVICENOW_SIR_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle',
{
defaultMessage: 'ServiceNow SecOps',
}
);
export function getServiceNowITSMActionType(): ActionTypeModel<
ServiceNowConfig,
ServiceNowSecrets,
@ -68,13 +97,14 @@ export function getServiceNowITSMActionType(): ActionTypeModel<
return {
id: '.servicenow',
iconClass: lazy(() => import('./logo')),
selectMessage: i18n.SERVICENOW_ITSM_DESC,
actionTypeTitle: i18n.SERVICENOW_ITSM_TITLE,
selectMessage: SERVICENOW_ITSM_DESC,
actionTypeTitle: SERVICENOW_ITSM_TITLE,
validateConnector,
actionConnectorFields: lazy(() => import('./servicenow_connectors')),
validateParams: (
validateParams: async (
actionParams: ServiceNowITSMActionParams
): GenericValidationResult<unknown> => {
): Promise<GenericValidationResult<unknown>> => {
const translations = await import('./translations');
const errors = {
// eslint-disable-next-line @typescript-eslint/naming-convention
'subActionParams.incident.short_description': new Array<string>(),
@ -87,7 +117,7 @@ export function getServiceNowITSMActionType(): ActionTypeModel<
actionParams.subActionParams.incident &&
!actionParams.subActionParams.incident.short_description?.length
) {
errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED);
errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED);
}
return validationResult;
},
@ -103,11 +133,14 @@ export function getServiceNowSIRActionType(): ActionTypeModel<
return {
id: '.servicenow-sir',
iconClass: lazy(() => import('./logo')),
selectMessage: i18n.SERVICENOW_SIR_DESC,
actionTypeTitle: i18n.SERVICENOW_SIR_TITLE,
selectMessage: SERVICENOW_SIR_DESC,
actionTypeTitle: SERVICENOW_SIR_TITLE,
validateConnector,
actionConnectorFields: lazy(() => import('./servicenow_connectors')),
validateParams: (actionParams: ServiceNowSIRActionParams): GenericValidationResult<unknown> => {
validateParams: async (
actionParams: ServiceNowSIRActionParams
): Promise<GenericValidationResult<unknown>> => {
const translations = await import('./translations');
const errors = {
// eslint-disable-next-line @typescript-eslint/naming-convention
'subActionParams.incident.short_description': new Array<string>(),
@ -120,7 +153,7 @@ export function getServiceNowSIRActionType(): ActionTypeModel<
actionParams.subActionParams.incident &&
!actionParams.subActionParams.incident.short_description?.length
) {
errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED);
errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED);
}
return validationResult;
},

View file

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

View file

@ -240,6 +240,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
fullWidth
error={errors['subActionParams.incident.short_description']}
isInvalid={
errors['subActionParams.incident.short_description'] !== undefined &&
errors['subActionParams.incident.short_description'].length > 0 &&
incident.short_description !== undefined
}

View file

@ -151,6 +151,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
fullWidth
error={errors['subActionParams.incident.short_description']}
isInvalid={
errors['subActionParams.incident.short_description'] !== undefined &&
errors['subActionParams.incident.short_description'].length > 0 &&
incident.short_description !== undefined
}

View file

@ -7,34 +7,6 @@
import { i18n } from '@kbn/i18n';
export const SERVICENOW_ITSM_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText',
{
defaultMessage: 'Create an incident in ServiceNow ITSM.',
}
);
export const SERVICENOW_SIR_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText',
{
defaultMessage: 'Create an incident in ServiceNow SecOps.',
}
);
export const SERVICENOW_ITSM_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle',
{
defaultMessage: 'ServiceNow ITSM',
}
);
export const SERVICENOW_SIR_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle',
{
defaultMessage: 'ServiceNow SecOps',
}
);
export const API_URL_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel',
{

View file

@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => {
});
describe('slack connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
test('connector validation succeeds when connector config is valid', async () => {
const actionConnector = {
secrets: {
webhookUrl: 'https:\\test',
@ -41,7 +41,7 @@ describe('slack connector validation', () => {
config: {},
} as SlackActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {},
},
@ -53,7 +53,7 @@ describe('slack connector validation', () => {
});
});
test('connector validation fails when connector config is not valid - no webhook url', () => {
test('connector validation fails when connector config is not valid - no webhook url', async () => {
const actionConnector = {
secrets: {},
id: 'test',
@ -62,7 +62,7 @@ describe('slack connector validation', () => {
config: {},
} as SlackActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {},
},
@ -74,7 +74,7 @@ describe('slack connector validation', () => {
});
});
test('connector validation fails when connector config is not valid - invalid webhook protocol', () => {
test('connector validation fails when connector config is not valid - invalid webhook protocol', async () => {
const actionConnector = {
secrets: {
webhookUrl: 'http:\\test',
@ -85,7 +85,7 @@ describe('slack connector validation', () => {
config: {},
} as SlackActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {},
},
@ -97,7 +97,7 @@ describe('slack connector validation', () => {
});
});
test('connector validation fails when connector config is not valid - invalid webhook url', () => {
test('connector validation fails when connector config is not valid - invalid webhook url', async () => {
const actionConnector = {
secrets: {
webhookUrl: 'h',
@ -108,7 +108,7 @@ describe('slack connector validation', () => {
config: {},
} as SlackActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {},
},
@ -122,22 +122,22 @@ describe('slack connector validation', () => {
});
describe('slack action params validation', () => {
test('if action params validation succeeds when action params is valid', () => {
test('if action params validation succeeds when action params is valid', async () => {
const actionParams = {
message: 'message {test}',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { message: [] },
});
});
test('params validation fails when message is not valid', () => {
test('params validation fails when message is not valid', async () => {
const actionParams = {
message: '',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
message: ['Message is required.'],
},

View file

@ -31,61 +31,35 @@ export function getActionType(): ActionTypeModel<unknown, SlackSecrets, SlackAct
defaultMessage: 'Send to Slack',
}
),
validateConnector: (
validateConnector: async (
action: SlackActionConnector
): ConnectorValidationResult<unknown, SlackSecrets> => {
): Promise<ConnectorValidationResult<unknown, SlackSecrets>> => {
const translations = await import('./translations');
const secretsErrors = {
webhookUrl: new Array<string>(),
};
const validationResult = { config: { errors: {} }, secrets: { errors: secretsErrors } };
if (!action.secrets.webhookUrl) {
secretsErrors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText',
{
defaultMessage: 'Webhook URL is required.',
}
)
);
secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_REQUIRED);
} else if (action.secrets.webhookUrl) {
if (!isValidUrl(action.secrets.webhookUrl)) {
secretsErrors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.invalidWebhookUrlText',
{
defaultMessage: 'Webhook URL is invalid.',
}
)
);
secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_INVALID);
} else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) {
secretsErrors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requireHttpsWebhookUrlText',
{
defaultMessage: 'Webhook URL must start with https://.',
}
)
);
secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_HTTP_INVALID);
}
}
return validationResult;
},
validateParams: (
validateParams: async (
actionParams: SlackActionParams
): GenericValidationResult<SlackActionParams> => {
): Promise<GenericValidationResult<SlackActionParams>> => {
const translations = await import('./translations');
const errors = {
message: new Array<string>(),
};
const validationResult = { errors };
if (!actionParams.message?.length) {
errors.message.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText',
{
defaultMessage: 'Message is required.',
}
)
);
errors.message.push(translations.MESSAGE_REQUIRED);
}
return validationResult;
},

View file

@ -19,6 +19,8 @@ const SlackActionFields: React.FunctionComponent<
> = ({ action, editActionSecrets, errors, readOnly }) => {
const { docLinks } = useKibana().services;
const { webhookUrl } = action.secrets;
const isWebhookUrlInvalid: boolean =
errors.webhookUrl !== undefined && errors.webhookUrl.length > 0 && webhookUrl !== undefined;
return (
<>
@ -34,7 +36,7 @@ const SlackActionFields: React.FunctionComponent<
</EuiLink>
}
error={errors.webhookUrl}
isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined}
isInvalid={isWebhookUrlInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel',
{
@ -54,7 +56,7 @@ const SlackActionFields: React.FunctionComponent<
)}
<EuiFieldText
fullWidth
isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined}
isInvalid={isWebhookUrlInvalid}
name="webhookUrl"
readOnly={readOnly}
value={webhookUrl || ''}

View file

@ -49,7 +49,7 @@ const SlackParamsFields: React.FunctionComponent<ActionParamsProps<SlackActionPa
defaultMessage: 'Message',
}
)}
errors={errors.message as string[]}
errors={(errors.message ?? []) as string[]}
/>
);
};

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const WEBHOOK_URL_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText',
{
defaultMessage: 'Webhook URL is required.',
}
);
export const WEBHOOK_URL_INVALID = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.invalidWebhookUrlText',
{
defaultMessage: 'Webhook URL is invalid.',
}
);
export const WEBHOOK_URL_HTTP_INVALID = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requireHttpsWebhookUrlText',
{
defaultMessage: 'Webhook URL must start with https://.',
}
);
export const MESSAGE_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText',
{
defaultMessage: 'Message is required.',
}
);

View file

@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => {
});
describe('teams connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
test('connector validation succeeds when connector config is valid', async () => {
const actionConnector = {
secrets: {
webhookUrl: 'https:\\test',
@ -40,7 +40,7 @@ describe('teams connector validation', () => {
config: {},
} as TeamsActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {},
},
@ -52,7 +52,7 @@ describe('teams connector validation', () => {
});
});
test('connector validation fails when connector config is not valid - empty webhook url', () => {
test('connector validation fails when connector config is not valid - empty webhook url', async () => {
const actionConnector = {
secrets: {},
id: 'test',
@ -61,7 +61,7 @@ describe('teams connector validation', () => {
config: {},
} as TeamsActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {},
},
@ -73,7 +73,7 @@ describe('teams connector validation', () => {
});
});
test('connector validation fails when connector config is not valid - invalid webhook url', () => {
test('connector validation fails when connector config is not valid - invalid webhook url', async () => {
const actionConnector = {
secrets: {
webhookUrl: 'h',
@ -84,7 +84,7 @@ describe('teams connector validation', () => {
config: {},
} as TeamsActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {},
},
@ -96,7 +96,7 @@ describe('teams connector validation', () => {
});
});
test('connector validation fails when connector config is not valid - invalid webhook url protocol', () => {
test('connector validation fails when connector config is not valid - invalid webhook url protocol', async () => {
const actionConnector = {
secrets: {
webhookUrl: 'http://insecure',
@ -107,7 +107,7 @@ describe('teams connector validation', () => {
config: {},
} as TeamsActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {},
},
@ -121,22 +121,22 @@ describe('teams connector validation', () => {
});
describe('teams action params validation', () => {
test('if action params validation succeeds when action params is valid', () => {
test('if action params validation succeeds when action params is valid', async () => {
const actionParams = {
message: 'message {test}',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { message: [] },
});
});
test('params validation fails when message is not valid', () => {
test('params validation fails when message is not valid', async () => {
const actionParams = {
message: '',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
message: ['Message is required.'],
},

View file

@ -31,61 +31,35 @@ export function getActionType(): ActionTypeModel<unknown, TeamsSecrets, TeamsAct
defaultMessage: 'Send a message to a Microsoft Teams channel.',
}
),
validateConnector: (
validateConnector: async (
action: TeamsActionConnector
): ConnectorValidationResult<unknown, TeamsSecrets> => {
): Promise<ConnectorValidationResult<unknown, TeamsSecrets>> => {
const translations = await import('./translations');
const secretsErrors = {
webhookUrl: new Array<string>(),
};
const validationResult = { config: { errors: {} }, secrets: { errors: secretsErrors } };
if (!action.secrets.webhookUrl) {
secretsErrors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredWebhookUrlText',
{
defaultMessage: 'Webhook URL is required.',
}
)
);
secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_REQUIRED);
} else if (action.secrets.webhookUrl) {
if (!isValidUrl(action.secrets.webhookUrl)) {
secretsErrors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.invalidWebhookUrlText',
{
defaultMessage: 'Webhook URL is invalid.',
}
)
);
secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_INVALID);
} else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) {
secretsErrors.webhookUrl.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requireHttpsWebhookUrlText',
{
defaultMessage: 'Webhook URL must start with https://.',
}
)
);
secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_HTTP_INVALID);
}
}
return validationResult;
},
validateParams: (
validateParams: async (
actionParams: TeamsActionParams
): GenericValidationResult<TeamsActionParams> => {
): Promise<GenericValidationResult<TeamsActionParams>> => {
const translations = await import('./translations');
const errors = {
message: new Array<string>(),
};
const validationResult = { errors };
if (!actionParams.message?.length) {
errors.message.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredMessageText',
{
defaultMessage: 'Message is required.',
}
)
);
errors.message.push(translations.MESSAGE_REQUIRED);
}
return validationResult;
},

View file

@ -20,6 +20,9 @@ const TeamsActionFields: React.FunctionComponent<
const { webhookUrl } = action.secrets;
const { docLinks } = useKibana().services;
const isWebhookUrlInvalid: boolean =
errors.webhookUrl !== undefined && errors.webhookUrl.length > 0 && webhookUrl !== undefined;
return (
<>
<EuiFormRow
@ -34,7 +37,7 @@ const TeamsActionFields: React.FunctionComponent<
</EuiLink>
}
error={errors.webhookUrl}
isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined}
isInvalid={isWebhookUrlInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.webhookUrlTextFieldLabel',
{
@ -54,7 +57,7 @@ const TeamsActionFields: React.FunctionComponent<
)}
<EuiFieldText
fullWidth
isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined}
isInvalid={isWebhookUrlInvalid}
name="webhookUrl"
readOnly={readOnly}
value={webhookUrl || ''}

View file

@ -40,7 +40,7 @@ const TeamsParamsFields: React.FunctionComponent<ActionParamsProps<TeamsActionPa
defaultMessage: 'Message',
}
)}
errors={errors.message as string[]}
errors={(errors.message ?? []) as string[]}
/>
);
};

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const WEBHOOK_URL_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredWebhookUrlText',
{
defaultMessage: 'Webhook URL is required.',
}
);
export const WEBHOOK_URL_INVALID = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.invalidWebhookUrlText',
{
defaultMessage: 'Webhook URL is invalid.',
}
);
export const WEBHOOK_URL_HTTP_INVALID = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requireHttpsWebhookUrlText',
{
defaultMessage: 'Webhook URL must start with https://.',
}
);
export const MESSAGE_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredMessageText',
{
defaultMessage: 'Message is required.',
}
);

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const URL_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText',
{
defaultMessage: 'URL is required.',
}
);
export const URL_INVALID = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.invalidUrlTextField',
{
defaultMessage: 'URL is invalid.',
}
);
export const METHOD_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText',
{
defaultMessage: 'Method is required.',
}
);
export const USERNAME_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthUserNameText',
{
defaultMessage: 'Username is required.',
}
);
export const PASSWORD_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthPasswordText',
{
defaultMessage: 'Password is required.',
}
);
export const PASSWORD_REQUIRED_FOR_USER = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText',
{
defaultMessage: 'Password is required when username is used.',
}
);
export const USERNAME_REQUIRED_FOR_PASSWORD = i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredUserText',
{
defaultMessage: 'Username is required when password is used.',
}
);
export const BODY_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText',
{
defaultMessage: 'Body is required.',
}
);

View file

@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => {
});
describe('webhook connector validation', () => {
test('connector validation succeeds when hasAuth is true and connector config is valid', () => {
test('connector validation succeeds when hasAuth is true and connector config is valid', async () => {
const actionConnector = {
secrets: {
user: 'user',
@ -48,7 +48,7 @@ describe('webhook connector validation', () => {
},
} as WebhookActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
url: [],
@ -64,7 +64,7 @@ describe('webhook connector validation', () => {
});
});
test('connector validation succeeds when hasAuth is false and connector config is valid', () => {
test('connector validation succeeds when hasAuth is false and connector config is valid', async () => {
const actionConnector = {
secrets: {
user: '',
@ -82,7 +82,7 @@ describe('webhook connector validation', () => {
},
} as WebhookActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
url: [],
@ -98,7 +98,7 @@ describe('webhook connector validation', () => {
});
});
test('connector validation fails when connector config is not valid', () => {
test('connector validation fails when connector config is not valid', async () => {
const actionConnector = {
secrets: {
user: 'user',
@ -112,7 +112,7 @@ describe('webhook connector validation', () => {
},
} as WebhookActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
url: ['URL is required.'],
@ -128,7 +128,7 @@ describe('webhook connector validation', () => {
});
});
test('connector validation fails when url in config is not valid', () => {
test('connector validation fails when url in config is not valid', async () => {
const actionConnector = {
secrets: {
user: 'user',
@ -144,7 +144,7 @@ describe('webhook connector validation', () => {
},
} as WebhookActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
url: ['URL is invalid.'],
@ -162,22 +162,22 @@ describe('webhook connector validation', () => {
});
describe('webhook action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
test('action params validation succeeds when action params is valid', async () => {
const actionParams = {
body: 'message {test}',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { body: [] },
});
});
test('params validation fails when body is not valid', () => {
test('params validation fails when body is not valid', async () => {
const actionParams = {
body: '',
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
body: ['Body is required.'],
},

View file

@ -40,9 +40,12 @@ export function getActionType(): ActionTypeModel<
defaultMessage: 'Webhook data',
}
),
validateConnector: (
validateConnector: async (
action: WebhookActionConnector
): ConnectorValidationResult<Pick<WebhookConfig, 'url' | 'method'>, WebhookSecrets> => {
): Promise<
ConnectorValidationResult<Pick<WebhookConfig, 'url' | 'method'>, WebhookSecrets>
> => {
const translations = await import('./translations');
const configErrors = {
url: new Array<string>(),
method: new Array<string>(),
@ -56,95 +59,39 @@ export function getActionType(): ActionTypeModel<
secrets: { errors: secretsErrors },
};
if (!action.config.url) {
configErrors.url.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText',
{
defaultMessage: 'URL is required.',
}
)
);
configErrors.url.push(translations.URL_REQUIRED);
}
if (action.config.url && !isValidUrl(action.config.url)) {
configErrors.url = [
...configErrors.url,
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.invalidUrlTextField',
{
defaultMessage: 'URL is invalid.',
}
),
];
configErrors.url = [...configErrors.url, translations.URL_INVALID];
}
if (!action.config.method) {
configErrors.method.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText',
{
defaultMessage: 'Method is required.',
}
)
);
configErrors.method.push(translations.METHOD_REQUIRED);
}
if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) {
secretsErrors.user.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthUserNameText',
{
defaultMessage: 'Username is required.',
}
)
);
secretsErrors.user.push(translations.USERNAME_REQUIRED);
}
if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) {
secretsErrors.password.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthPasswordText',
{
defaultMessage: 'Password is required.',
}
)
);
secretsErrors.password.push(translations.PASSWORD_REQUIRED);
}
if (action.secrets.user && !action.secrets.password) {
secretsErrors.password.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText',
{
defaultMessage: 'Password is required when username is used.',
}
)
);
secretsErrors.password.push(translations.PASSWORD_REQUIRED_FOR_USER);
}
if (!action.secrets.user && action.secrets.password) {
secretsErrors.user.push(
i18n.translate(
'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredUserText',
{
defaultMessage: 'Username is required when password is used.',
}
)
);
secretsErrors.user.push(translations.USERNAME_REQUIRED_FOR_PASSWORD);
}
return validationResult;
},
validateParams: (
validateParams: async (
actionParams: WebhookActionParams
): GenericValidationResult<WebhookActionParams> => {
): Promise<GenericValidationResult<WebhookActionParams>> => {
const translations = await import('./translations');
const errors = {
body: new Array<string>(),
};
const validationResult = { errors };
validationResult.errors = errors;
if (!actionParams.body?.length) {
errors.body.push(
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText',
{
defaultMessage: 'Body is required.',
}
)
);
errors.body.push(translations.BODY_REQUIRED);
}
return validationResult;
},

View file

@ -76,7 +76,11 @@ const WebhookActionConnectorFields: React.FunctionComponent<
)
);
}
const hasHeaderErrors = headerErrors.keyHeader.length > 0 || headerErrors.valueHeader.length > 0;
const hasHeaderErrors: boolean =
(headerErrors.keyHeader !== undefined &&
headerErrors.valueHeader !== undefined &&
headerErrors.keyHeader.length > 0) ||
headerErrors.valueHeader.length > 0;
function addHeader() {
if (headers && !!Object.keys(headers).find((key) => key === httpHeaderKey)) {
@ -219,6 +223,13 @@ const WebhookActionConnectorFields: React.FunctionComponent<
);
});
const isUrlInvalid: boolean =
errors.url !== undefined && errors.url.length > 0 && url !== undefined;
const isPasswordInvalid: boolean =
password !== undefined && errors.password !== undefined && errors.password.length > 0;
const isUserInvalid: boolean =
user !== undefined && errors.user !== undefined && errors.user.length > 0;
return (
<>
<EuiFlexGroup justifyContent="spaceBetween">
@ -248,7 +259,7 @@ const WebhookActionConnectorFields: React.FunctionComponent<
id="url"
fullWidth
error={errors.url}
isInvalid={errors.url.length > 0 && url !== undefined}
isInvalid={isUrlInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.urlTextFieldLabel',
{
@ -258,7 +269,7 @@ const WebhookActionConnectorFields: React.FunctionComponent<
>
<EuiFieldText
name="url"
isInvalid={errors.url.length > 0 && url !== undefined}
isInvalid={isUrlInvalid}
fullWidth
readOnly={readOnly}
value={url || ''}
@ -326,7 +337,7 @@ const WebhookActionConnectorFields: React.FunctionComponent<
id="webhookUser"
fullWidth
error={errors.user}
isInvalid={errors.user.length > 0 && user !== undefined}
isInvalid={isUserInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.userTextFieldLabel',
{
@ -336,7 +347,7 @@ const WebhookActionConnectorFields: React.FunctionComponent<
>
<EuiFieldText
fullWidth
isInvalid={errors.user.length > 0 && user !== undefined}
isInvalid={isUserInvalid}
name="user"
readOnly={readOnly}
value={user || ''}
@ -357,7 +368,7 @@ const WebhookActionConnectorFields: React.FunctionComponent<
id="webhookPassword"
fullWidth
error={errors.password}
isInvalid={errors.password.length > 0 && password !== undefined}
isInvalid={isPasswordInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.passwordTextFieldLabel',
{
@ -369,7 +380,7 @@ const WebhookActionConnectorFields: React.FunctionComponent<
fullWidth
name="password"
readOnly={readOnly}
isInvalid={errors.password.length > 0 && password !== undefined}
isInvalid={isPasswordInvalid}
value={password || ''}
data-test-subj="webhookPasswordInput"
onChange={(e) => {

View file

@ -23,12 +23,12 @@ describe('action_connector_form', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
});
actionTypeRegistry.get.mockReturnValue(actionType);

View file

@ -51,11 +51,11 @@ export function validateBaseProperties<ConnectorConfig, ConnectorSecrets>(
return validationResult;
}
export function getConnectorErrors<ConnectorConfig, ConnectorSecrets>(
export async function getConnectorErrors<ConnectorConfig, ConnectorSecrets>(
connector: UserConfiguredActionConnector<ConnectorConfig, ConnectorSecrets>,
actionTypeModel: ActionTypeModel
) {
const connectorValidationResult = actionTypeModel?.validateConnector(connector);
const connectorValidationResult = await actionTypeModel?.validateConnector(connector);
const configErrors = (connectorValidationResult.config
? connectorValidationResult.config.errors
: {}) as IErrorObject;
@ -173,7 +173,8 @@ export const ActionConnectorForm = ({
);
const FieldsComponent = actionTypeRegistered.actionConnectorFields;
const isNameInvalid: boolean =
connector.name !== undefined && errors.name !== undefined && errors.name.length > 0;
return (
<EuiForm isInvalid={!!serverError} error={serverError?.body.message}>
<EuiFormRow
@ -185,13 +186,13 @@ export const ActionConnectorForm = ({
defaultMessage="Connector name"
/>
}
isInvalid={errors.name.length > 0 && connector.name !== undefined}
isInvalid={isNameInvalid}
error={errors.name}
>
<EuiFieldText
fullWidth
readOnly={!canSave}
isInvalid={errors.name.length > 0 && connector.name !== undefined}
isInvalid={isNameInvalid}
name="name"
placeholder="Untitled"
data-test-subj="nameInput"

View file

@ -53,12 +53,12 @@ describe('action_form', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,
@ -68,12 +68,12 @@ describe('action_form', () => {
id: 'disabled-by-config',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,
@ -83,12 +83,12 @@ describe('action_form', () => {
id: '.jira',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): ValidationResult => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,
@ -98,12 +98,12 @@ describe('action_form', () => {
id: 'disabled-by-license',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,
@ -113,12 +113,12 @@ describe('action_form', () => {
id: 'preconfigured',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { Fragment, useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
@ -30,7 +30,7 @@ import {
ActionTypeRegistryContract,
} from '../../../types';
import { SectionLoading } from '../../components/section_loading';
import { ActionTypeForm, ActionTypeFormProps } from './action_type_form';
import { ActionTypeForm } from './action_type_form';
import { AddConnectorInline } from './connector_add_inline';
import { actionTypeCompare } from '../../lib/action_type_compare';
import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled';
@ -357,49 +357,42 @@ export const ActionForm = ({
);
}
const actionParamsErrors: ActionTypeFormProps['actionParamsErrors'] = actionTypeRegistry
.get(actionItem.actionTypeId)
?.validateParams(actionItem.params);
return (
<Fragment key={`action-form-action-at-${index}`}>
<ActionTypeForm
actionItem={actionItem}
actionConnector={actionConnector}
actionParamsErrors={actionParamsErrors}
index={index}
setActionParamsProperty={setActionParamsProperty}
actionTypesIndex={actionTypesIndex}
connectors={connectors}
defaultActionGroupId={defaultActionGroupId}
messageVariables={messageVariables}
actionGroups={actionGroups}
defaultActionMessage={defaultActionMessage}
defaultParams={getDefaultActionParams?.(actionItem.actionTypeId, actionItem.group)}
isActionGroupDisabledForActionType={isActionGroupDisabledForActionType}
setActionGroupIdByIndex={setActionGroupIdByIndex}
onAddConnector={() => {
setActiveActionItem({ actionTypeId: actionItem.actionTypeId, indices: [index] });
setAddModalVisibility(true);
}}
onConnectorSelected={(id: string) => {
setActionIdByIndex(id, index);
}}
actionTypeRegistry={actionTypeRegistry}
onDeleteAction={() => {
const updatedActions = actions.filter(
(_item: AlertAction, i: number) => i !== index
);
setActions(updatedActions);
setIsAddActionPanelOpen(
updatedActions.filter((item: AlertAction) => item.id !== actionItem.id)
.length === 0
);
setActiveActionItem(undefined);
}}
/>
<EuiSpacer size="m" />
</Fragment>
<ActionTypeForm
actionItem={actionItem}
actionConnector={actionConnector}
index={index}
key={`action-form-action-at-${index}`}
setActionParamsProperty={setActionParamsProperty}
actionTypesIndex={actionTypesIndex}
connectors={connectors}
defaultActionGroupId={defaultActionGroupId}
messageVariables={messageVariables}
actionGroups={actionGroups}
defaultActionMessage={defaultActionMessage}
defaultParams={getDefaultActionParams?.(actionItem.actionTypeId, actionItem.group)}
isActionGroupDisabledForActionType={isActionGroupDisabledForActionType}
setActionGroupIdByIndex={setActionGroupIdByIndex}
onAddConnector={() => {
setActiveActionItem({ actionTypeId: actionItem.actionTypeId, indices: [index] });
setAddModalVisibility(true);
}}
onConnectorSelected={(id: string) => {
setActionIdByIndex(id, index);
}}
actionTypeRegistry={actionTypeRegistry}
onDeleteAction={() => {
const updatedActions = actions.filter(
(_item: AlertAction, i: number) => i !== index
);
setActions(updatedActions);
setIsAddActionPanelOpen(
updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length ===
0
);
setActiveActionItem(undefined);
}}
/>
);
})}
<EuiSpacer size="m" />

View file

@ -43,12 +43,12 @@ describe('action_type_form', () => {
id: '.pagerduty',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,
@ -92,12 +92,12 @@ describe('action_type_form', () => {
id: '.pagerduty',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,
@ -220,7 +220,6 @@ function getActionTypeForm(
onAddConnector={onAddConnector ?? jest.fn()}
onDeleteAction={onDeleteAction ?? jest.fn()}
onConnectorSelected={onConnectorSelected ?? jest.fn()}
actionParamsErrors={{ errors: { summary: [], timestamp: [], dedupKey: [] } }}
defaultActionGroupId={defaultActionGroupId ?? 'default'}
setActionParamsProperty={jest.fn()}
index={index ?? 1}

View file

@ -47,9 +47,6 @@ import { DefaultActionParams } from '../../lib/get_defaults_for_action_params';
export type ActionTypeFormProps = {
actionItem: AlertAction;
actionConnector: ActionConnector;
actionParamsErrors: {
errors: IErrorObject;
};
index: number;
onAddConnector: () => void;
onConnectorSelected: (id: string) => void;
@ -80,7 +77,6 @@ const preconfiguredMessage = i18n.translate(
export const ActionTypeForm = ({
actionItem,
actionConnector,
actionParamsErrors,
index,
onAddConnector,
onConnectorSelected,
@ -106,6 +102,9 @@ export const ActionTypeForm = ({
const selectedActionGroup =
actionGroups?.find(({ id }) => id === actionItem.group) ?? defaultActionGroup;
const [actionGroup, setActionGroup] = useState<string>();
const [actionParamsErrors, setActionParamsErrors] = useState<{ errors: IErrorObject }>({
errors: {},
});
useEffect(() => {
setAvailableActionVariables(
@ -130,6 +129,16 @@ export const ActionTypeForm = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionGroup]);
useEffect(() => {
(async () => {
const res: { errors: IErrorObject } = await actionTypeRegistry
.get(actionItem.actionTypeId)
?.validateParams(actionItem.params);
setActionParamsErrors(res);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionItem]);
const canSave = hasSaveActionsCapability(capabilities);
const getSelectedOptions = (actionItemId: string) => {
const selectedConnector = connectors.find((connector) => connector.id === actionItemId);

View file

@ -40,12 +40,12 @@ describe('connector_add_flyout', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});
@ -77,12 +77,12 @@ describe('connector_add_flyout', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});
@ -114,12 +114,12 @@ describe('connector_add_flyout', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});

View file

@ -198,12 +198,12 @@ function createActionType() {
id: `my-action-type-${++count}`,
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useState, useReducer } from 'react';
import React, { useCallback, useState, useReducer, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiTitle,
@ -30,7 +30,9 @@ import {
ActionType,
ActionConnector,
UserConfiguredActionConnector,
IErrorObject,
ConnectorAddFlyoutProps,
ActionTypeModel,
} from '../../../types';
import { hasSaveActionsCapability } from '../../lib/capabilities';
import { createActionConnector } from '../../lib/action_connector_api';
@ -38,6 +40,7 @@ import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants';
import { useKibana } from '../../../common/lib/kibana';
import { createConnectorReducer, InitialConnector, ConnectorReducer } from './connector_reducer';
import { getConnectorWithInvalidatedFields } from '../../lib/value_validators';
import { CenterJustifiedSpinner } from '../../components/center_justified_spinner';
const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
onClose,
@ -47,7 +50,9 @@ const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
consumer,
actionTypeRegistry,
}) => {
let hasErrors = false;
const [hasErrors, setHasErrors] = useState<boolean>(true);
let actionTypeModel: ActionTypeModel | undefined;
const {
http,
notifications: { toasts },
@ -55,7 +60,17 @@ const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
} = useKibana().services;
const [actionType, setActionType] = useState<ActionType | undefined>(undefined);
const [hasActionsUpgradeableByTrial, setHasActionsUpgradeableByTrial] = useState<boolean>(false);
const [errors, setErrors] = useState<{
configErrors: IErrorObject;
connectorBaseErrors: IErrorObject;
connectorErrors: IErrorObject;
secretsErrors: IErrorObject;
}>({
configErrors: {},
connectorBaseErrors: {},
connectorErrors: {},
secretsErrors: {},
});
// hooks
const initialConnector: InitialConnector<Record<string, unknown>, Record<string, unknown>> = {
actionTypeId: actionType?.id ?? '',
@ -73,6 +88,24 @@ const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
Record<string, unknown>
>,
});
const [isLoading, setIsLoading] = useState<boolean>(false);
useEffect(() => {
(async () => {
if (actionTypeModel) {
setIsLoading(true);
const res = await getConnectorErrors(connector, actionTypeModel);
setHasErrors(
!!Object.keys(res.connectorErrors).find(
(errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1
)
);
setIsLoading(false);
setErrors({ ...res });
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connector, actionType]);
const setActionProperty = <Key extends keyof ActionConnector>(
key: Key,
@ -101,7 +134,6 @@ const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
}
let currentForm;
let actionTypeModel;
let saveButton;
if (!actionType) {
currentForm = (
@ -115,22 +147,12 @@ const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
} else {
actionTypeModel = actionTypeRegistry.get(actionType.id);
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={connectorErrors}
errors={errors.connectorErrors}
actionTypeRegistry={actionTypeRegistry}
consumer={consumer}
/>
@ -170,9 +192,9 @@ const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
setConnector(
getConnectorWithInvalidatedFields(
connector,
configErrors,
secretsErrors,
connectorBaseErrors
errors.configErrors,
errors.secretsErrors,
errors.connectorBaseErrors
)
);
return;
@ -235,13 +257,13 @@ const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
<EuiFlyout onClose={closeFlyout} aria-labelledby="flyoutActionAddTitle" size="m">
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup gutterSize="m" alignItems="center">
{actionTypeModel && actionTypeModel.iconClass ? (
{!!actionTypeModel && actionTypeModel.iconClass ? (
<EuiFlexItem grow={false}>
<EuiIcon type={actionTypeModel.iconClass} size="xl" />
</EuiFlexItem>
) : null}
<EuiFlexItem>
{actionTypeModel && actionType ? (
{!!actionTypeModel && actionType ? (
<>
<EuiTitle size="s">
<h3 id="flyoutTitle">
@ -280,7 +302,17 @@ const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
)
}
>
{currentForm}
<>
{currentForm}
{isLoading ? (
<>
<EuiSpacer size="m" />
<CenterJustifiedSpinner size="l" />{' '}
</>
) : (
<></>
)}
</>
</EuiFlyoutBody>
<EuiFlyoutFooter>
@ -314,7 +346,7 @@ const ConnectorAddFlyout: React.FunctionComponent<ConnectorAddFlyoutProps> = ({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="spaceBetween">
{canSave && actionTypeModel && actionType ? saveButton : null}
{canSave && !!actionTypeModel && actionType ? saveButton : null}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -39,12 +39,12 @@ describe('connector_add_modal', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useMemo, useReducer, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiModal,
@ -19,6 +19,7 @@ import {
EuiFlexItem,
EuiIcon,
EuiFlexGroup,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ActionConnectorForm, getConnectorErrors } from './action_connector_form';
@ -31,9 +32,11 @@ import {
ActionConnector,
ActionTypeRegistryContract,
UserConfiguredActionConnector,
IErrorObject,
} from '../../../types';
import { useKibana } from '../../../common/lib/kibana';
import { getConnectorWithInvalidatedFields } from '../../lib/value_validators';
import { CenterJustifiedSpinner } from '../../components/center_justified_spinner';
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type ConnectorAddModalProps = {
@ -56,7 +59,7 @@ const ConnectorAddModal = ({
notifications: { toasts },
application: { capabilities },
} = useKibana().services;
let hasErrors = false;
const [hasErrors, setHasErrors] = useState<boolean>(true);
const initialConnector: InitialConnector<
Record<string, unknown>,
Record<string, unknown>
@ -69,6 +72,7 @@ const ConnectorAddModal = ({
[actionType.id]
);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const canSave = hasSaveActionsCapability(capabilities);
const reducer: ConnectorReducer<
@ -81,6 +85,34 @@ const ConnectorAddModal = ({
Record<string, unknown>
>,
});
const [errors, setErrors] = useState<{
configErrors: IErrorObject;
connectorBaseErrors: IErrorObject;
connectorErrors: IErrorObject;
secretsErrors: IErrorObject;
}>({
configErrors: {},
connectorBaseErrors: {},
connectorErrors: {},
secretsErrors: {},
});
const actionTypeModel = actionTypeRegistry.get(actionType.id);
useEffect(() => {
(async () => {
setIsLoading(true);
const res = await getConnectorErrors(connector, actionTypeModel);
setHasErrors(
!!Object.keys(res.connectorErrors).find(
(errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1
)
);
setIsLoading(false);
setErrors({ ...res });
})();
}, [connector, actionTypeModel]);
const setConnector = (value: any) => {
dispatch({ command: { type: 'setConnector' }, payload: { key: 'connector', value } });
};
@ -97,15 +129,6 @@ const ConnectorAddModal = ({
onClose();
}, [initialConnector, onClose]);
const actionTypeModel = actionTypeRegistry.get(actionType.id);
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 })
.then((savedConnector) => {
@ -157,15 +180,25 @@ const ConnectorAddModal = ({
</EuiModalHeader>
<EuiModalBody>
<ActionConnectorForm
connector={connector}
actionTypeName={actionType.name}
dispatch={dispatch}
serverError={serverError}
errors={connectorErrors}
actionTypeRegistry={actionTypeRegistry}
consumer={consumer}
/>
<>
<ActionConnectorForm
connector={connector}
actionTypeName={actionType.name}
dispatch={dispatch}
serverError={serverError}
errors={errors.connectorErrors}
actionTypeRegistry={actionTypeRegistry}
consumer={consumer}
/>
{isLoading ? (
<>
<EuiSpacer size="m" />
<CenterJustifiedSpinner size="l" />{' '}
</>
) : (
<></>
)}
</>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={closeModal}>
@ -189,9 +222,9 @@ const ConnectorAddModal = ({
setConnector(
getConnectorWithInvalidatedFields(
connector,
configErrors,
secretsErrors,
connectorBaseErrors
errors.configErrors,
errors.secretsErrors,
errors.connectorBaseErrors
)
);
return;

View file

@ -51,12 +51,12 @@ describe('connector_edit_flyout', () => {
id: 'test-action-type-id',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});
@ -95,12 +95,12 @@ describe('connector_edit_flyout', () => {
id: 'test-action-type-id',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useReducer, useState } from 'react';
import React, { useCallback, useReducer, useState, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiTitle,
@ -23,6 +23,7 @@ import {
EuiLink,
EuiTabs,
EuiTab,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Option, none, some } from 'fp-ts/lib/Option';
@ -31,6 +32,7 @@ import { TestConnectorForm } from './test_connector_form';
import {
ActionConnector,
ConnectorEditFlyoutProps,
IErrorObject,
EditConectorTabs,
UserConfiguredActionConnector,
} from '../../../types';
@ -44,6 +46,7 @@ import {
import './connector_edit_flyout.scss';
import { useKibana } from '../../../common/lib/kibana';
import { getConnectorWithInvalidatedFields } from '../../lib/value_validators';
import { CenterJustifiedSpinner } from '../../components/center_justified_spinner';
const ConnectorEditFlyout = ({
initialConnector,
@ -53,12 +56,14 @@ const ConnectorEditFlyout = ({
consumer,
actionTypeRegistry,
}: ConnectorEditFlyoutProps) => {
const [hasErrors, setHasErrors] = useState<boolean>(true);
const {
http,
notifications: { toasts },
docLinks,
application: { capabilities },
} = useKibana().services;
const getConnectorWithoutSecrets = () => ({
...(initialConnector as UserConfiguredActionConnector<
Record<string, unknown>,
@ -75,6 +80,35 @@ const ConnectorEditFlyout = ({
const [{ connector }, dispatch] = useReducer(reducer, {
connector: getConnectorWithoutSecrets(),
});
const [isLoading, setIsLoading] = useState<boolean>(false);
const [errors, setErrors] = useState<{
configErrors: IErrorObject;
connectorBaseErrors: IErrorObject;
connectorErrors: IErrorObject;
secretsErrors: IErrorObject;
}>({
configErrors: {},
connectorBaseErrors: {},
connectorErrors: {},
secretsErrors: {},
});
const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId);
useEffect(() => {
(async () => {
setIsLoading(true);
const res = await getConnectorErrors(connector, actionTypeModel);
setHasErrors(
!!Object.keys(res.connectorErrors).find(
(errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1
)
);
setIsLoading(false);
setErrors({ ...res });
})();
}, [connector, actionTypeModel]);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [selectedTab, setTab] = useState<EditConectorTabs>(tab);
@ -113,25 +147,6 @@ const ConnectorEditFlyout = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onClose]);
const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId);
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> =>
await updateActionConnector({ http, connector, id: connector.id })
.then((savedConnector) => {
@ -227,9 +242,9 @@ const ConnectorEditFlyout = ({
setConnector(
getConnectorWithInvalidatedFields(
connector,
configErrors,
secretsErrors,
connectorBaseErrors
errors.configErrors,
errors.secretsErrors,
errors.connectorBaseErrors
)
);
return;
@ -286,19 +301,29 @@ const ConnectorEditFlyout = ({
<EuiFlyoutBody>
{selectedTab === EditConectorTabs.Configuration ? (
!connector.isPreconfigured ? (
<ActionConnectorForm
connector={connector}
errors={connectorErrors}
dispatch={(changes) => {
setHasChanges(true);
// if the user changes the connector, "forget" the last execution
// so the user comes back to a clean form ready to run a fresh test
setTestExecutionResult(none);
dispatch(changes);
}}
actionTypeRegistry={actionTypeRegistry}
consumer={consumer}
/>
<>
<ActionConnectorForm
connector={connector}
errors={errors.connectorErrors}
dispatch={(changes) => {
setHasChanges(true);
// if the user changes the connector, "forget" the last execution
// so the user comes back to a clean form ready to run a fresh test
setTestExecutionResult(none);
dispatch(changes);
}}
actionTypeRegistry={actionTypeRegistry}
consumer={consumer}
/>
{isLoading ? (
<>
<EuiSpacer size="m" />
<CenterJustifiedSpinner size="l" />{' '}
</>
) : (
<></>
)}
</>
) : (
<>
<EuiText>

View file

@ -53,12 +53,12 @@ const actionType = {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { Suspense } from 'react';
import React, { Suspense, useEffect, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
@ -46,11 +46,18 @@ export const TestConnectorForm = ({
isExecutingAction,
actionTypeRegistry,
}: ConnectorAddFlyoutProps) => {
const [actionErrors, setActionErrors] = useState<IErrorObject>({});
const [hasErrors, setHasErrors] = useState<boolean>(false);
const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId);
const ParamsFieldsComponent = actionTypeModel.actionParamsFields;
const actionErrors = actionTypeModel?.validateParams(actionParams).errors as IErrorObject;
const hasErrors = !!Object.values(actionErrors).find((errors) => errors.length > 0);
useEffect(() => {
(async () => {
const res = (await actionTypeModel?.validateParams(actionParams)).errors as IErrorObject;
setActionErrors({ ...res });
setHasErrors(!!Object.values(res).find((errors) => errors.length > 0));
})();
}, [actionTypeModel, actionParams]);
const steps = [
{

View file

@ -162,12 +162,12 @@ describe('actions_connectors_list component with items', () => {
id: 'test',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,

View file

@ -135,12 +135,12 @@ describe('alert_add', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});

View file

@ -15,9 +15,10 @@ import {
AlertTypeParams,
AlertUpdates,
AlertFlyoutCloseReason,
IErrorObject,
AlertAddProps,
} from '../../../types';
import { AlertForm, getAlertErrors, isValidAlert } from './alert_form';
import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form';
import { alertReducer, InitialAlert, InitialAlertReducer } from './alert_reducer';
import { createAlert } from '../../lib/alert_api';
import { HealthCheck } from '../../components/health_check';
@ -102,6 +103,18 @@ const AlertAdd = ({
}
}, [alert.params, initialAlertParams, setInitialAlertParams]);
const [alertActionsErrors, setAlertActionsErrors] = useState<IErrorObject[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
useEffect(() => {
(async () => {
setIsLoading(true);
const res = await getAlertActionErrors(alert as Alert, actionTypeRegistry);
setIsLoading(false);
setAlertActionsErrors([...res]);
})();
}, [alert, actionTypeRegistry]);
const checkForChangesAndCloseFlyout = () => {
if (
hasAlertChanged(alert, initialAlert, false) ||
@ -125,9 +138,8 @@ const AlertAdd = ({
};
const alertType = alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null;
const { alertActionsErrors, alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors(
const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors(
alert as Alert,
actionTypeRegistry,
alertType
);
@ -195,9 +207,10 @@ const AlertAdd = ({
</EuiFlyoutBody>
<AlertAddFooter
isSaving={isSaving}
isFormLoading={isLoading}
onSave={async () => {
setIsSaving(true);
if (!isValidAlert(alert, alertErrors, alertActionsErrors)) {
if (isLoading || !isValidAlert(alert, alertErrors, alertActionsErrors)) {
setAlert(
getAlertWithInvalidatedFields(
alert as Alert,

View file

@ -13,17 +13,25 @@ import {
EuiFlexItem,
EuiButtonEmpty,
EuiButton,
EuiLoadingSpinner,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useHealthContext } from '../../context/health_context';
interface AlertAddFooterProps {
isSaving: boolean;
isFormLoading: boolean;
onSave: () => void;
onCancel: () => void;
}
export const AlertAddFooter = ({ isSaving, onSave, onCancel }: AlertAddFooterProps) => {
export const AlertAddFooter = ({
isSaving,
onSave,
onCancel,
isFormLoading,
}: AlertAddFooterProps) => {
const { loadingHealthCheck } = useHealthContext();
return (
@ -36,6 +44,14 @@ export const AlertAddFooter = ({ isSaving, onSave, onCancel }: AlertAddFooterPro
})}
</EuiButtonEmpty>
</EuiFlexItem>
{isFormLoading ? (
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
<EuiLoadingSpinner size="l" />
</EuiFlexItem>
) : (
<></>
)}
<EuiFlexItem grow={false}>
<EuiButton
fill

View file

@ -106,12 +106,12 @@ describe('alert_edit', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useReducer, useState } from 'react';
import React, { useReducer, useState, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiTitle,
@ -20,11 +20,12 @@ import {
EuiPortal,
EuiCallOut,
EuiSpacer,
EuiLoadingSpinner,
} from '@elastic/eui';
import { cloneDeep } from 'lodash';
import { i18n } from '@kbn/i18n';
import { Alert, AlertEditProps, AlertFlyoutCloseReason } from '../../../types';
import { AlertForm, getAlertErrors, isValidAlert } from './alert_form';
import { Alert, AlertFlyoutCloseReason, AlertEditProps, IErrorObject } from '../../../types';
import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form';
import { alertReducer, ConcreteAlertReducer } from './alert_reducer';
import { updateAlert } from '../../lib/alert_api';
import { HealthCheck } from '../../components/health_check';
@ -53,6 +54,8 @@ export const AlertEdit = ({
false
);
const [isConfirmAlertCloseModalOpen, setIsConfirmAlertCloseModalOpen] = useState<boolean>(false);
const [alertActionsErrors, setAlertActionsErrors] = useState<IErrorObject[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const {
http,
@ -64,9 +67,17 @@ export const AlertEdit = ({
const alertType = alertTypeRegistry.get(alert.alertTypeId);
const { alertActionsErrors, alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors(
useEffect(() => {
(async () => {
setIsLoading(true);
const res = await getAlertActionErrors(alert as Alert, actionTypeRegistry);
setAlertActionsErrors([...res]);
setIsLoading(false);
})();
}, [alert, actionTypeRegistry]);
const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors(
alert as Alert,
actionTypeRegistry,
alertType
);
@ -80,7 +91,11 @@ export const AlertEdit = ({
async function onSaveAlert(): Promise<Alert | undefined> {
try {
if (isValidAlert(alert, alertErrors, alertActionsErrors) && !hasActionsWithBrokenConnector) {
if (
!isLoading &&
isValidAlert(alert, alertErrors, alertActionsErrors) &&
!hasActionsWithBrokenConnector
) {
const newAlert = await updateAlert({ http, alert, id: alert.id });
toasts.addSuccess(
i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText', {
@ -177,6 +192,14 @@ export const AlertEdit = ({
)}
</EuiButtonEmpty>
</EuiFlexItem>
{isLoading ? (
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
<EuiLoadingSpinner size="l" />
</EuiFlexItem>
) : (
<></>
)}
<EuiFlexItem grow={false}>
<EuiButton
fill

View file

@ -47,19 +47,19 @@ describe('alert_form', () => {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({
config: {
errors: {},
},
secrets: {
errors: {},
},
};
});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});

View file

@ -121,11 +121,7 @@ export function validateBaseProperties(alertObject: InitialAlert): ValidationRes
return validationResult;
}
export function getAlertErrors(
alert: Alert,
actionTypeRegistry: ActionTypeRegistryContract,
alertTypeModel: AlertTypeModel | null
) {
export function getAlertErrors(alert: Alert, alertTypeModel: AlertTypeModel | null) {
const alertParamsErrors: IErrorObject = alertTypeModel
? alertTypeModel.validate(alert.params).errors
: [];
@ -135,18 +131,26 @@ export function getAlertErrors(
...alertBaseErrors,
} as IErrorObject;
const alertActionsErrors = alert.actions.map((alertAction: AlertAction) => {
return actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params)
.errors;
});
return {
alertParamsErrors,
alertBaseErrors,
alertActionsErrors,
alertErrors,
};
}
export async function getAlertActionErrors(
alert: Alert,
actionTypeRegistry: ActionTypeRegistryContract
): Promise<IErrorObject[]> {
return await Promise.all(
alert.actions.map(
async (alertAction: AlertAction) =>
(await actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params))
.errors
)
);
}
export const hasObjectErrors: (errors: IErrorObject) => boolean = (errors) =>
!!Object.values(errors).find((errorList) => {
if (isObject(errorList)) return hasObjectErrors(errorList as IErrorObject);

View file

@ -42,12 +42,12 @@ const getTestActionType = (
id: id || 'my-action-type',
iconClass: iconClass || 'test',
selectMessage: selectedMessage || 'test',
validateConnector: (): ConnectorValidationResult<unknown, unknown> => {
return {};
validateConnector: (): Promise<ConnectorValidationResult<unknown, unknown>> => {
return Promise.resolve({});
},
validateParams: (): GenericValidationResult<unknown> => {
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return validationResult;
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
});

View file

@ -109,10 +109,10 @@ export interface ActionTypeModel<ActionConfig = any, ActionSecrets = any, Action
actionTypeTitle?: string;
validateConnector: (
connector: UserConfiguredActionConnector<ActionConfig, ActionSecrets>
) => ConnectorValidationResult<Partial<ActionConfig>, Partial<ActionSecrets>>;
) => Promise<ConnectorValidationResult<Partial<ActionConfig>, Partial<ActionSecrets>>>;
validateParams: (
actionParams: ActionParams
) => GenericValidationResult<Partial<ActionParams> | unknown>;
) => Promise<GenericValidationResult<Partial<ActionParams> | unknown>>;
actionConnectorFields: React.LazyExoticComponent<
ComponentType<
ActionConnectorFieldsProps<UserConfiguredActionConnector<ActionConfig, ActionSecrets>>