[8.x] [Response Ops][Rule Form V2] Rule form v2: Actions Modal and Actions Form (#187434) (#194254)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Response Ops][Rule Form V2] Rule form v2: Actions Modal and Actions
Form (#187434)](https://github.com/elastic/kibana/pull/187434)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Jiawei
Wu","email":"74562234+JiaweiWu@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-09-27T06:34:27Z","message":"[Response
Ops][Rule Form V2] Rule form v2: Actions Modal and Actions Form
(#187434)\n\n## Summary\r\nIssue:
https://github.com/elastic/kibana/issues/179105\r\nRelated PR:
https://github.com/elastic/kibana/pull/180539\r\n\r\nFinal PR of the
rule actions V2 PR (2/2 of the actions PRs). This PR\r\ncontains the
actions modal and actions form. This PR depends
on\r\nhttps://github.com/elastic/kibana/pull/186490.\r\n\r\nI have also
created a example plugin to demonstrate this PR. To access:\r\n\r\n1.
Run the branch with yarn start --run-examples\r\n2. Navigate
to\r\nhttp://localhost:5601/app/triggersActionsUiExample/rule/create/<ruleTypeId>\r\n(I
use .es-query)\r\n3. Create a rule\r\n4. Navigate
to\r\nhttp://localhost:5601/app/triggersActionsUiExample/rule/edit/<ruleId>\r\nwith
the rule you just created to edit the rule\r\n\r\n<img width=\"1236\"
alt=\"Screenshot 2024-07-02 at 5 15
51 PM\"\r\nsrc=\"1dc5d2a9-804a-4861-94ba-814de73dc3ab\">\r\n\r\n![Screenshot
2024-07-08 at 10
53\r\n44 PM](07efade1-4b9c-485f-9833-84698dc29219)\r\n\r\n<img
width=\"1087\" alt=\"Screenshot 2024-07-02 at 5 15
58 PM\"\r\nsrc=\"903e66b5-f9a1-4d09-b121-b1dcecdff72c\">\r\n\r\n\r\n###
Checklist\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"54659e8ae002aa68be3ee472ef12b3d3f546a1ea","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:ResponseOps","v9.0.0","backport:prev-minor","v8.16.0"],"title":"[Response
Ops][Rule Form V2] Rule form v2: Actions Modal and Actions
Form","number":187434,"url":"https://github.com/elastic/kibana/pull/187434","mergeCommit":{"message":"[Response
Ops][Rule Form V2] Rule form v2: Actions Modal and Actions Form
(#187434)\n\n## Summary\r\nIssue:
https://github.com/elastic/kibana/issues/179105\r\nRelated PR:
https://github.com/elastic/kibana/pull/180539\r\n\r\nFinal PR of the
rule actions V2 PR (2/2 of the actions PRs). This PR\r\ncontains the
actions modal and actions form. This PR depends
on\r\nhttps://github.com/elastic/kibana/pull/186490.\r\n\r\nI have also
created a example plugin to demonstrate this PR. To access:\r\n\r\n1.
Run the branch with yarn start --run-examples\r\n2. Navigate
to\r\nhttp://localhost:5601/app/triggersActionsUiExample/rule/create/<ruleTypeId>\r\n(I
use .es-query)\r\n3. Create a rule\r\n4. Navigate
to\r\nhttp://localhost:5601/app/triggersActionsUiExample/rule/edit/<ruleId>\r\nwith
the rule you just created to edit the rule\r\n\r\n<img width=\"1236\"
alt=\"Screenshot 2024-07-02 at 5 15
51 PM\"\r\nsrc=\"1dc5d2a9-804a-4861-94ba-814de73dc3ab\">\r\n\r\n![Screenshot
2024-07-08 at 10
53\r\n44 PM](07efade1-4b9c-485f-9833-84698dc29219)\r\n\r\n<img
width=\"1087\" alt=\"Screenshot 2024-07-02 at 5 15
58 PM\"\r\nsrc=\"903e66b5-f9a1-4d09-b121-b1dcecdff72c\">\r\n\r\n\r\n###
Checklist\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"54659e8ae002aa68be3ee472ef12b3d3f546a1ea"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/187434","number":187434,"mergeCommit":{"message":"[Response
Ops][Rule Form V2] Rule form v2: Actions Modal and Actions Form
(#187434)\n\n## Summary\r\nIssue:
https://github.com/elastic/kibana/issues/179105\r\nRelated PR:
https://github.com/elastic/kibana/pull/180539\r\n\r\nFinal PR of the
rule actions V2 PR (2/2 of the actions PRs). This PR\r\ncontains the
actions modal and actions form. This PR depends
on\r\nhttps://github.com/elastic/kibana/pull/186490.\r\n\r\nI have also
created a example plugin to demonstrate this PR. To access:\r\n\r\n1.
Run the branch with yarn start --run-examples\r\n2. Navigate
to\r\nhttp://localhost:5601/app/triggersActionsUiExample/rule/create/<ruleTypeId>\r\n(I
use .es-query)\r\n3. Create a rule\r\n4. Navigate
to\r\nhttp://localhost:5601/app/triggersActionsUiExample/rule/edit/<ruleId>\r\nwith
the rule you just created to edit the rule\r\n\r\n<img width=\"1236\"
alt=\"Screenshot 2024-07-02 at 5 15
51 PM\"\r\nsrc=\"1dc5d2a9-804a-4861-94ba-814de73dc3ab\">\r\n\r\n![Screenshot
2024-07-08 at 10
53\r\n44 PM](07efade1-4b9c-485f-9833-84698dc29219)\r\n\r\n<img
width=\"1087\" alt=\"Screenshot 2024-07-02 at 5 15
58 PM\"\r\nsrc=\"903e66b5-f9a1-4d09-b121-b1dcecdff72c\">\r\n\r\n\r\n###
Checklist\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"54659e8ae002aa68be3ee472ef12b3d3f546a1ea"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2024-09-27 18:09:40 +10:00 committed by GitHub
parent c7b29d18dd
commit d52ad0a173
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 5944 additions and 500 deletions

View file

@ -16,8 +16,9 @@ import { SEARCH_BAR_PLACEHOLDER } from './translations';
import type { AlertsSearchBarProps, QueryLanguageType } from './types';
import { useLoadRuleTypesQuery, useAlertsDataView, useRuleAADFields } from '../common/hooks';
const SA_ALERTS = { type: 'alerts', fields: {} } as SuggestionsAbstraction;
export type { AlertsSearchBarProps } from './types';
const SA_ALERTS = { type: 'alerts', fields: {} } as SuggestionsAbstraction;
const EMPTY_FEATURE_IDS: ValidFeatureId[] = [];
export const AlertsSearchBar = ({

View file

@ -10,3 +10,5 @@
export * from './alerts';
export * from './i18n_weekdays';
export * from './routes';
export const VIEW_LICENSE_OPTIONS_LINK = 'https://www.elastic.co/subscriptions';

View file

@ -9,6 +9,10 @@
export * from './use_alerts_data_view';
export * from './use_create_rule';
export * from './use_update_rule';
export * from './use_resolve_rule';
export * from './use_load_connectors';
export * from './use_load_connector_types';
export * from './use_get_alerts_group_aggregations_query';
export * from './use_health_check';
export * from './use_load_alerting_framework_health';

View file

@ -13,7 +13,7 @@ import { renderHook } from '@testing-library/react-hooks/dom';
import { waitFor } from '@testing-library/react';
import { httpServiceMock } from '@kbn/core/public/mocks';
import { useLoadActionTypes } from './use_load_connector_types';
import { useLoadConnectorTypes } from './use_load_connector_types';
const queryClient = new QueryClient();
@ -46,7 +46,7 @@ describe('useLoadConnectorTypes', () => {
test('should call API endpoint with the correct parameters', async () => {
const { result } = renderHook(
() =>
useLoadActionTypes({
useLoadConnectorTypes({
http,
includeSystemActions: true,
}),
@ -74,7 +74,7 @@ describe('useLoadConnectorTypes', () => {
test('should call the correct endpoint if system actions is true', async () => {
const { result } = renderHook(
() =>
useLoadActionTypes({
useLoadConnectorTypes({
http,
includeSystemActions: true,
}),
@ -91,7 +91,7 @@ describe('useLoadConnectorTypes', () => {
test('should call the correct endpoint if system actions is false', async () => {
const { result } = renderHook(
() =>
useLoadActionTypes({
useLoadConnectorTypes({
http,
includeSystemActions: false,
}),

View file

@ -11,13 +11,14 @@ import { useQuery } from '@tanstack/react-query';
import type { HttpStart } from '@kbn/core-http-browser';
import { fetchConnectorTypes } from '../apis';
export interface UseLoadActionTypesProps {
export interface UseLoadConnectorTypesProps {
http: HttpStart;
includeSystemActions?: boolean;
enabled?: boolean;
}
export const useLoadActionTypes = (props: UseLoadActionTypesProps) => {
const { http, includeSystemActions } = props;
export const useLoadConnectorTypes = (props: UseLoadConnectorTypesProps) => {
const { http, includeSystemActions, enabled = true } = props;
const queryFn = () => {
return fetchConnectorTypes({ http, includeSystemActions });
@ -27,6 +28,7 @@ export const useLoadActionTypes = (props: UseLoadActionTypesProps) => {
queryKey: ['useLoadConnectorTypes', includeSystemActions],
queryFn,
refetchOnWindowFocus: false,
enabled,
});
return {

View file

@ -14,10 +14,11 @@ import { fetchConnectors } from '../apis';
export interface UseLoadConnectorsProps {
http: HttpStart;
includeSystemActions?: boolean;
enabled?: boolean;
}
export const useLoadConnectors = (props: UseLoadConnectorsProps) => {
const { http, includeSystemActions = false } = props;
const { http, includeSystemActions = false, enabled = true } = props;
const queryFn = () => {
return fetchConnectors({ http, includeSystemActions });
@ -27,6 +28,7 @@ export const useLoadConnectors = (props: UseLoadConnectorsProps) => {
queryKey: ['useLoadConnectors', includeSystemActions],
queryFn,
refetchOnWindowFocus: false,
enabled,
});
return {

View file

@ -15,7 +15,7 @@ import { fetchRuleTypeAadTemplateFields, getDescription } from '../apis';
export interface UseLoadRuleTypeAadTemplateFieldProps {
http: HttpStart;
ruleTypeId: string;
ruleTypeId?: string;
enabled: boolean;
}
@ -23,6 +23,9 @@ export const useLoadRuleTypeAadTemplateField = (props: UseLoadRuleTypeAadTemplat
const { http, ruleTypeId, enabled } = props;
const queryFn = () => {
if (!ruleTypeId) {
return;
}
return fetchRuleTypeAadTemplateFields({ http, ruleTypeId });
};
@ -35,7 +38,7 @@ export const useLoadRuleTypeAadTemplateField = (props: UseLoadRuleTypeAadTemplat
queryKey: ['useLoadRuleTypeAadTemplateField', ruleTypeId],
queryFn,
select: (dataViewFields) => {
return dataViewFields.map<ActionVariable>((d) => ({
return dataViewFields?.map<ActionVariable>((d) => ({
name: d.name,
description: getDescription(d.name, EcsFlat),
}));

View file

@ -41,6 +41,7 @@ export const useResolveRule = (props: UseResolveProps) => {
};
},
refetchOnWindowFocus: false,
retry: false,
});
return {

View file

@ -0,0 +1,85 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ActionType } from '@kbn/actions-types';
import { RuleSystemAction } from '@kbn/alerting-types';
import { ActionConnector, ActionTypeModel, GenericValidationResult, RuleAction } from '../types';
import { actionTypeRegistryMock } from './action_type_registry.mock';
export const getConnector = (
id: string,
overwrites?: Partial<ActionConnector>
): ActionConnector => {
return {
id: `connector-${id}`,
secrets: { secret: 'secret' },
actionTypeId: `actionType-${id}`,
name: `connector-${id}`,
config: { config: `config-${id}` },
isPreconfigured: false,
isSystemAction: false,
isDeprecated: false,
...overwrites,
};
};
export const getAction = (id: string, overwrites?: Partial<RuleAction>): RuleAction => {
return {
id: `action-${id}`,
uuid: `uuid-action-${id}`,
group: `group-${id}`,
actionTypeId: `actionType-${id}`,
params: {},
...overwrites,
};
};
export const getSystemAction = (
id: string,
overwrites?: Partial<RuleSystemAction>
): RuleSystemAction => {
return {
uuid: `uuid-system-action-${id}`,
id: `system-action-${id}`,
actionTypeId: `actionType-${id}`,
params: {},
...overwrites,
};
};
export const getActionType = (id: string, overwrites?: Partial<ActionType>): ActionType => {
return {
id: `actionType-${id}`,
name: `actionType: ${id}`,
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
supportedFeatureIds: ['stackAlerts'],
isSystemActionType: false,
...overwrites,
};
};
export const getActionTypeModel = (
id: string,
overwrites?: Partial<ActionTypeModel>
): ActionTypeModel => {
return actionTypeRegistryMock.createMockActionTypeModel({
id: `actionTypeModel-${id}`,
iconClass: 'test',
selectMessage: 'test',
validateParams: (): Promise<GenericValidationResult<unknown>> => {
const validationResult = { errors: {} };
return Promise.resolve(validationResult);
},
actionConnectorFields: null,
...overwrites,
});
};

View file

@ -43,6 +43,10 @@ export interface RuleFormBaseErrors {
tags?: string[];
}
export interface RuleFormActionsErrors {
filterQuery?: string[];
}
export interface RuleFormParamsErrors {
[key: string]: string | string[] | RuleFormParamsErrors;
}

View file

@ -32,10 +32,12 @@ export const GET_DEFAULT_FORM_DATA = ({
name,
consumer,
schedule,
actions,
}: {
ruleTypeId: RuleFormData['ruleTypeId'];
name: RuleFormData['name'];
consumer: RuleFormData['consumer'];
actions: RuleFormData['actions'];
schedule?: RuleFormData['schedule'];
}) => {
return {
@ -47,6 +49,7 @@ export const GET_DEFAULT_FORM_DATA = ({
consumer,
ruleTypeId,
name,
actions,
};
};

View file

@ -10,9 +10,9 @@
import React, { useCallback } from 'react';
import { EuiLoadingElastic } from '@elastic/eui';
import { toMountPoint } from '@kbn/react-kibana-mount';
import type { RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import { type RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import type { RuleFormData, RuleFormPlugins } from './types';
import { ALERTING_FEATURE_ID, DEFAULT_VALID_CONSUMERS, GET_DEFAULT_FORM_DATA } from './constants';
import { DEFAULT_VALID_CONSUMERS, GET_DEFAULT_FORM_DATA } from './constants';
import { RuleFormStateProvider } from './rule_form_state';
import { useCreateRule } from '../common/hooks';
import { RulePage } from './rule_page';
@ -40,6 +40,8 @@ export interface CreateRuleFormProps {
validConsumers?: RuleCreationValidConsumer[];
filteredRuleTypes?: string[];
shouldUseRuleProducer?: boolean;
canShowConsumerSelection?: boolean;
showMustacheAutocompleteSwitch?: boolean;
returnUrl: string;
}
@ -47,16 +49,18 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => {
const {
ruleTypeId,
plugins,
consumer = ALERTING_FEATURE_ID,
consumer = 'alerts',
multiConsumerSelection,
validConsumers = DEFAULT_VALID_CONSUMERS,
filteredRuleTypes = [],
shouldUseRuleProducer = false,
canShowConsumerSelection = true,
showMustacheAutocompleteSwitch = false,
returnUrl,
} = props;
const { http, docLinks, notification, ruleTypeRegistry, i18n, theme } = plugins;
const { toasts } = notification;
const { http, docLinks, notifications, ruleTypeRegistry, i18n, theme } = plugins;
const { toasts } = notifications;
const { mutate, isLoading: isSaving } = useCreateRule({
http,
@ -79,16 +83,25 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => {
},
});
const { isInitialLoading, ruleType, ruleTypeModel, uiConfig, healthCheckError } =
useLoadDependencies({
http,
toasts: notification.toasts,
ruleTypeRegistry,
ruleTypeId,
consumer,
validConsumers,
filteredRuleTypes,
});
const {
isInitialLoading,
ruleType,
ruleTypeModel,
uiConfig,
healthCheckError,
connectors,
connectorTypes,
aadTemplateFields,
} = useLoadDependencies({
http,
toasts: notifications.toasts,
capabilities: plugins.application.capabilities,
ruleTypeRegistry,
ruleTypeId,
consumer,
validConsumers,
filteredRuleTypes,
});
const onSave = useCallback(
(newFormData: RuleFormData) => {
@ -101,8 +114,7 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => {
tags: newFormData.tags,
params: newFormData.params,
schedule: newFormData.schedule,
// TODO: Will add actions in the actions PR
actions: [],
actions: newFormData.actions,
notifyWhen: newFormData.notifyWhen,
alertDelay: newFormData.alertDelay,
},
@ -151,12 +163,18 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => {
ruleType,
minimumScheduleInterval: uiConfig?.minimumScheduleInterval,
}),
actions: [],
}),
plugins,
connectors,
connectorTypes,
aadTemplateFields,
minimumScheduleInterval: uiConfig?.minimumScheduleInterval,
selectedRuleTypeModel: ruleTypeModel,
selectedRuleType: ruleType,
validConsumers,
canShowConsumerSelection,
showMustacheAutocompleteSwitch,
multiConsumerSelection: getInitialMultiConsumer({
multiConsumerSelection,
validConsumers,

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { EuiLoadingElastic } from '@elastic/eui';
import { toMountPoint } from '@kbn/react-kibana-mount';
import type { RuleFormData, RuleFormPlugins } from './types';
@ -17,6 +17,7 @@ import { RulePage } from './rule_page';
import { RuleFormHealthCheckError } from './rule_form_errors/rule_form_health_check_error';
import { useLoadDependencies } from './hooks/use_load_dependencies';
import {
RuleFormActionPermissionError,
RuleFormCircuitBreakerError,
RuleFormErrorPromptWrapper,
RuleFormResolveRuleError,
@ -28,13 +29,14 @@ import { parseRuleCircuitBreakerErrorMessage } from './utils';
export interface EditRuleFormProps {
id: string;
plugins: RuleFormPlugins;
showMustacheAutocompleteSwitch?: boolean;
returnUrl: string;
}
export const EditRuleForm = (props: EditRuleFormProps) => {
const { id, plugins, returnUrl } = props;
const { http, notification, docLinks, ruleTypeRegistry, i18n, theme } = plugins;
const { toasts } = notification;
const { id, plugins, returnUrl, showMustacheAutocompleteSwitch = false } = props;
const { http, notifications, docLinks, ruleTypeRegistry, i18n, theme, application } = plugins;
const { toasts } = notifications;
const { mutate, isLoading: isSaving } = useUpdateRule({
http,
@ -57,13 +59,23 @@ export const EditRuleForm = (props: EditRuleFormProps) => {
},
});
const { isInitialLoading, ruleType, ruleTypeModel, uiConfig, healthCheckError, fetchedFormData } =
useLoadDependencies({
http,
toasts: notification.toasts,
ruleTypeRegistry,
id,
});
const {
isInitialLoading,
ruleType,
ruleTypeModel,
uiConfig,
healthCheckError,
fetchedFormData,
connectors,
connectorTypes,
aadTemplateFields,
} = useLoadDependencies({
http,
toasts: notifications.toasts,
capabilities: plugins.application.capabilities,
ruleTypeRegistry,
id,
});
const onSave = useCallback(
(newFormData: RuleFormData) => {
@ -74,8 +86,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => {
tags: newFormData.tags,
schedule: newFormData.schedule,
params: newFormData.params,
// TODO: Will add actions in the actions PR
actions: [],
actions: newFormData.actions,
notifyWhen: newFormData.notifyWhen,
alertDelay: newFormData.alertDelay,
},
@ -84,6 +95,18 @@ export const EditRuleForm = (props: EditRuleFormProps) => {
[id, mutate]
);
const canEditRule = useMemo(() => {
if (!ruleType || !fetchedFormData) {
return false;
}
const { consumer, actions } = fetchedFormData;
const hasAllPrivilege = !!ruleType.authorizedConsumers[consumer]?.all;
const canExecuteActions = !!application.capabilities.actions?.execute;
return hasAllPrivilege && (canExecuteActions || (!canExecuteActions && !actions.length));
}, [ruleType, fetchedFormData, application]);
if (isInitialLoading) {
return (
<RuleFormErrorPromptWrapper hasBorder={false} hasShadow={false}>
@ -92,14 +115,6 @@ export const EditRuleForm = (props: EditRuleFormProps) => {
);
}
if (!ruleType || !ruleTypeModel) {
return (
<RuleFormErrorPromptWrapper hasBorder={false} hasShadow={false}>
<RuleFormRuleTypeError />
</RuleFormErrorPromptWrapper>
);
}
if (!fetchedFormData) {
return (
<RuleFormErrorPromptWrapper hasBorder={false} hasShadow={false}>
@ -108,6 +123,14 @@ export const EditRuleForm = (props: EditRuleFormProps) => {
);
}
if (!ruleType || !ruleTypeModel) {
return (
<RuleFormErrorPromptWrapper hasBorder={false} hasShadow={false}>
<RuleFormRuleTypeError />
</RuleFormErrorPromptWrapper>
);
}
if (healthCheckError) {
return (
<RuleFormErrorPromptWrapper>
@ -116,16 +139,28 @@ export const EditRuleForm = (props: EditRuleFormProps) => {
);
}
if (!canEditRule) {
return (
<RuleFormErrorPromptWrapper hasBorder={false} hasShadow={false}>
<RuleFormActionPermissionError />
</RuleFormErrorPromptWrapper>
);
}
return (
<div data-test-subj="editRuleForm">
<RuleFormStateProvider
initialRuleFormState={{
connectors,
connectorTypes,
aadTemplateFields,
formData: fetchedFormData,
id,
plugins,
minimumScheduleInterval: uiConfig?.minimumScheduleInterval,
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleTypeModel,
showMustacheAutocompleteSwitch,
}}
>
<RulePage isEdit={true} isSaving={isSaving} returnUrl={returnUrl} onSave={onSave} />

View file

@ -16,6 +16,7 @@ import type { ToastsStart } from '@kbn/core-notifications-browser';
import { useLoadDependencies } from './use_load_dependencies';
import { RuleTypeRegistryContract } from '../../common';
import { ApplicationStart } from '@kbn/core-application-browser';
jest.mock('../../common/hooks/use_load_ui_config', () => ({
useLoadUiConfig: jest.fn(),
@ -33,6 +34,18 @@ jest.mock('../../common/hooks/use_load_rule_types_query', () => ({
useLoadRuleTypesQuery: jest.fn(),
}));
jest.mock('../../common/hooks/use_load_connectors', () => ({
useLoadConnectors: jest.fn(),
}));
jest.mock('../../common/hooks/use_load_connector_types', () => ({
useLoadConnectorTypes: jest.fn(),
}));
jest.mock('../../common/hooks/use_load_rule_type_aad_template_fields', () => ({
useLoadRuleTypeAadTemplateField: jest.fn(),
}));
jest.mock('../utils/get_authorized_rule_types', () => ({
getAvailableRuleTypes: jest.fn(),
}));
@ -40,6 +53,11 @@ jest.mock('../utils/get_authorized_rule_types', () => ({
const { useLoadUiConfig } = jest.requireMock('../../common/hooks/use_load_ui_config');
const { useHealthCheck } = jest.requireMock('../../common/hooks/use_health_check');
const { useResolveRule } = jest.requireMock('../../common/hooks/use_resolve_rule');
const { useLoadConnectors } = jest.requireMock('../../common/hooks/use_load_connectors');
const { useLoadConnectorTypes } = jest.requireMock('../../common/hooks/use_load_connector_types');
const { useLoadRuleTypeAadTemplateField } = jest.requireMock(
'../../common/hooks/use_load_rule_type_aad_template_fields'
);
const { useLoadRuleTypesQuery } = jest.requireMock('../../common/hooks/use_load_rule_types_query');
const { getAvailableRuleTypes } = jest.requireMock('../utils/get_authorized_rule_types');
@ -141,6 +159,55 @@ getAvailableRuleTypes.mockReturnValue([
},
]);
const mockConnector = {
id: 'test-connector',
name: 'Test',
connector_type_id: 'test',
is_preconfigured: false,
is_deprecated: false,
is_missing_secrets: false,
is_system_action: false,
referenced_by_count: 0,
secrets: {},
config: {},
};
const mockConnectorType = {
id: 'test',
name: 'Test',
enabled: true,
enabled_in_config: true,
enabled_in_license: true,
supported_feature_ids: ['alerting'],
minimum_license_required: 'basic',
is_system_action_type: false,
};
const mockAadTemplateField = {
name: '@timestamp',
deprecated: false,
useWithTripleBracesInTemplates: false,
usesPublicBaseUrl: false,
};
useLoadConnectors.mockReturnValue({
data: [mockConnector],
isLoading: false,
isInitialLoading: false,
});
useLoadConnectorTypes.mockReturnValue({
data: [mockConnectorType],
isLoading: false,
isInitialLoading: false,
});
useLoadRuleTypeAadTemplateField.mockReturnValue({
data: [mockAadTemplateField],
isLoading: false,
isInitialLoading: false,
});
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
@ -169,6 +236,13 @@ describe('useLoadDependencies', () => {
http: httpMock as unknown as HttpStart,
toasts: toastsMock as unknown as ToastsStart,
ruleTypeRegistry: ruleTypeRegistryMock,
capabilities: {
actions: {
show: true,
save: true,
execute: true,
},
} as unknown as ApplicationStart['capabilities'],
});
},
{ wrapper }
@ -186,6 +260,9 @@ describe('useLoadDependencies', () => {
uiConfig: uiConfigMock,
healthCheckError: null,
fetchedFormData: ruleMock,
connectors: [mockConnector],
connectorTypes: [mockConnectorType],
aadTemplateFields: [mockAadTemplateField],
});
});
@ -197,6 +274,13 @@ describe('useLoadDependencies', () => {
toasts: toastsMock as unknown as ToastsStart,
ruleTypeRegistry: ruleTypeRegistryMock,
filteredRuleTypes: ['test-rule-type'],
capabilities: {
actions: {
show: true,
save: true,
execute: true,
},
} as unknown as ApplicationStart['capabilities'],
});
},
{ wrapper }
@ -222,6 +306,13 @@ describe('useLoadDependencies', () => {
ruleTypeRegistry: ruleTypeRegistryMock,
validConsumers: ['stackAlerts', 'logs'],
consumer: 'logs',
capabilities: {
actions: {
show: true,
save: true,
execute: true,
},
} as unknown as ApplicationStart['capabilities'],
});
},
{ wrapper }
@ -247,6 +338,13 @@ describe('useLoadDependencies', () => {
toasts: toastsMock as unknown as ToastsStart,
ruleTypeRegistry: ruleTypeRegistryMock,
id: 'test-rule-id',
capabilities: {
actions: {
show: true,
save: true,
execute: true,
},
} as unknown as ApplicationStart['capabilities'],
});
},
{ wrapper }
@ -277,6 +375,13 @@ describe('useLoadDependencies', () => {
ruleTypeRegistry: ruleTypeRegistryMock,
ruleTypeId: '.index-threshold',
consumer: 'stackAlerts',
capabilities: {
actions: {
show: true,
save: true,
execute: true,
},
} as unknown as ApplicationStart['capabilities'],
});
},
{ wrapper }
@ -304,6 +409,13 @@ describe('useLoadDependencies', () => {
ruleTypeRegistry: ruleTypeRegistryMock,
id: 'rule-id',
consumer: 'stackAlerts',
capabilities: {
actions: {
show: true,
save: true,
execute: true,
},
} as unknown as ApplicationStart['capabilities'],
});
},
{ wrapper }

View file

@ -9,21 +9,26 @@
import { HttpStart } from '@kbn/core-http-browser';
import type { ToastsStart } from '@kbn/core-notifications-browser';
import { ApplicationStart } from '@kbn/core-application-browser';
import { RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import { useMemo } from 'react';
import {
useHealthCheck,
useLoadConnectors,
useLoadConnectorTypes,
useLoadRuleTypesQuery,
useLoadUiConfig,
useResolveRule,
} from '../../common/hooks';
import { getAvailableRuleTypes } from '../utils';
import { RuleTypeRegistryContract } from '../../common';
import { useLoadRuleTypeAadTemplateField } from '../../common/hooks/use_load_rule_type_aad_template_fields';
export interface UseLoadDependencies {
http: HttpStart;
toasts: ToastsStart;
ruleTypeRegistry: RuleTypeRegistryContract;
capabilities: ApplicationStart['capabilities'];
consumer?: string;
id?: string;
ruleTypeId?: string;
@ -40,9 +45,12 @@ export const useLoadDependencies = (props: UseLoadDependencies) => {
validConsumers,
id,
ruleTypeId,
capabilities,
filteredRuleTypes = [],
} = props;
const canReadConnectors = !!capabilities.actions?.show;
const {
data: uiConfig,
isLoading: isLoadingUiConfig,
@ -73,10 +81,41 @@ export const useLoadDependencies = (props: UseLoadDependencies) => {
filteredRuleTypes,
});
const {
data: connectors = [],
isLoading: isLoadingConnectors,
isInitialLoading: isInitialLoadingConnectors,
} = useLoadConnectors({
http,
includeSystemActions: true,
enabled: canReadConnectors,
});
const computedRuleTypeId = useMemo(() => {
return fetchedFormData?.ruleTypeId || ruleTypeId;
}, [fetchedFormData, ruleTypeId]);
// Fetching Action related dependencies
const {
data: connectorTypes = [],
isLoading: isLoadingConnectorTypes,
isInitialLoading: isInitialLoadingConnectorTypes,
} = useLoadConnectorTypes({
http,
includeSystemActions: true,
enabled: canReadConnectors,
});
const {
data: aadTemplateFields,
isLoading: isLoadingAadtemplateFields,
isInitialLoading: isInitialLoadingAadTemplateField,
} = useLoadRuleTypeAadTemplateField({
http,
ruleTypeId: computedRuleTypeId,
enabled: !!computedRuleTypeId && canReadConnectors,
});
const authorizedRuleTypeItems = useMemo(() => {
const computedConsumer = consumer || fetchedFormData?.consumer;
if (!computedConsumer) {
@ -99,21 +138,61 @@ export const useLoadDependencies = (props: UseLoadDependencies) => {
}, [authorizedRuleTypeItems, computedRuleTypeId]);
const isLoading = useMemo(() => {
// Create Mode
if (id === undefined) {
return isLoadingUiConfig || isLoadingHealthCheck || isLoadingRuleTypes;
return (
isLoadingUiConfig ||
isLoadingHealthCheck ||
isLoadingRuleTypes ||
isLoadingConnectors ||
isLoadingConnectorTypes ||
isLoadingAadtemplateFields
);
}
return isLoadingUiConfig || isLoadingHealthCheck || isLoadingRule || isLoadingRuleTypes;
}, [id, isLoadingUiConfig, isLoadingHealthCheck, isLoadingRule, isLoadingRuleTypes]);
// Edit Mode
return (
isLoadingUiConfig ||
isLoadingHealthCheck ||
isLoadingRule ||
isLoadingRuleTypes ||
isLoadingConnectors ||
isLoadingConnectorTypes ||
isLoadingAadtemplateFields
);
}, [
id,
isLoadingUiConfig,
isLoadingHealthCheck,
isLoadingRule,
isLoadingRuleTypes,
isLoadingConnectors,
isLoadingConnectorTypes,
isLoadingAadtemplateFields,
]);
const isInitialLoading = useMemo(() => {
// Create Mode
if (id === undefined) {
return isInitialLoadingUiConfig || isInitialLoadingHealthCheck || isInitialLoadingRuleTypes;
return (
isInitialLoadingUiConfig ||
isInitialLoadingHealthCheck ||
isInitialLoadingRuleTypes ||
isInitialLoadingConnectors ||
isInitialLoadingConnectorTypes ||
isInitialLoadingAadTemplateField
);
}
// Edit Mode
return (
isInitialLoadingUiConfig ||
isInitialLoadingHealthCheck ||
isInitialLoadingRule ||
isInitialLoadingRuleTypes
isInitialLoadingRuleTypes ||
isInitialLoadingConnectors ||
isInitialLoadingConnectorTypes ||
isInitialLoadingAadTemplateField
);
}, [
id,
@ -121,6 +200,9 @@ export const useLoadDependencies = (props: UseLoadDependencies) => {
isInitialLoadingHealthCheck,
isInitialLoadingRule,
isInitialLoadingRuleTypes,
isInitialLoadingConnectors,
isInitialLoadingConnectorTypes,
isInitialLoadingAadTemplateField,
]);
return {
@ -131,5 +213,8 @@ export const useLoadDependencies = (props: UseLoadDependencies) => {
uiConfig,
healthCheckError,
fetchedFormData,
connectors,
connectorTypes,
aadTemplateFields,
};
};

View file

@ -8,27 +8,264 @@
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { httpServiceMock } from '@kbn/core/public/mocks';
import { RuleActions } from './rule_actions';
import {
getActionType,
getAction,
getSystemAction,
getConnector,
getActionTypeModel,
} from '../../common/test_utils/actions_test_utils';
import userEvent from '@testing-library/user-event';
import { ActionConnector, ActionTypeModel } from '../../common/types';
import { RuleActionsItemProps } from './rule_actions_item';
import { TypeRegistry } from '../../common/type_registry';
const http = httpServiceMock.createStartContract();
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));
jest.mock('./rule_actions_system_actions_item', () => ({
RuleActionsSystemActionsItem: ({ action, producerId }: RuleActionsItemProps) => (
<div>
RuleActionsSystemActionsItem
<div>
{action.id} producerId: {producerId}
</div>
</div>
),
}));
jest.mock('./rule_actions_item', () => ({
RuleActionsItem: ({ action, producerId }: RuleActionsItemProps) => (
<div>
RuleActionsItem
<div>
{action.id} producerId: {producerId}
</div>
</div>
),
}));
jest.mock('./rule_actions_connectors_modal', () => ({
RuleActionsConnectorsModal: ({
onSelectConnector,
}: {
onSelectConnector: (connector: ActionConnector) => void;
}) => (
<div>
RuleActionsConnectorsModal
<button
onClick={() =>
onSelectConnector({
id: 'connector-1',
secrets: { secret: 'secret' },
actionTypeId: 'actionType-1',
name: 'connector-1',
config: { config: 'config-1' },
isPreconfigured: false,
isSystemAction: false,
isDeprecated: false,
})
}
>
select connector
</button>
</div>
),
}));
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'),
}));
jest.mock('../../common/hooks', () => ({
useLoadConnectors: jest.fn(),
useLoadConnectorTypes: jest.fn(),
useLoadRuleTypeAadTemplateField: jest.fn(),
}));
const mockValidate = jest.fn().mockResolvedValue({
errors: {},
});
const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');
const { useLoadConnectors, useLoadConnectorTypes, useLoadRuleTypeAadTemplateField } =
jest.requireMock('../../common/hooks');
const mockConnectors = [getConnector('1')];
const mockConnectorTypes = [
getActionType('1'),
getActionType('2'),
getActionType('3', { isSystemActionType: true }),
];
const mockActions = [getAction('1'), getAction('2')];
const mockSystemActions = [getSystemAction('3')];
const mockOnChange = jest.fn();
describe('Rule actions', () => {
afterEach(() => {
jest.resetAllMocks();
describe('ruleActions', () => {
beforeEach(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(
getActionTypeModel('1', {
id: 'actionType-1',
validateParams: mockValidate,
})
);
actionTypeRegistry.register(
getActionTypeModel('2', {
id: 'actionType-2',
validateParams: mockValidate,
})
);
useLoadConnectors.mockReturnValue({
data: mockConnectors,
isInitialLoading: false,
});
useLoadConnectorTypes.mockReturnValue({
data: mockConnectorTypes,
isInitialLoading: false,
});
useLoadRuleTypeAadTemplateField.mockReturnValue({
data: {},
isInitialLoading: false,
});
useRuleFormState.mockReturnValue({
plugins: {
http,
actionTypeRegistry,
},
formData: {
actions: [...mockActions, ...mockSystemActions],
consumer: 'stackAlerts',
},
selectedRuleType: {
id: 'selectedRuleTypeId',
defaultActionGroupId: 'test',
producer: 'stackAlerts',
},
connectors: mockConnectors,
connectorTypes: mockConnectorTypes,
aadTemplateFields: [],
});
useRuleFormDispatch.mockReturnValue(mockOnChange);
});
test('Renders correctly', () => {
render(<RuleActions onClick={mockOnChange} />);
afterEach(() => {
jest.clearAllMocks();
});
test('renders correctly', () => {
render(<RuleActions />);
expect(screen.getByTestId('ruleActions')).toBeInTheDocument();
expect(screen.getByTestId('ruleActionsAddActionButton')).toBeInTheDocument();
expect(screen.queryByText('RuleActionsConnectorsModal')).not.toBeInTheDocument();
});
test('Calls onChange when button is click', () => {
render(<RuleActions onClick={mockOnChange} />);
test('renders actions correctly', () => {
render(<RuleActions />);
fireEvent.click(screen.getByTestId('ruleActionsAddActionButton'));
expect(screen.getAllByText('RuleActionsItem').length).toEqual(2);
expect(screen.getAllByText('RuleActionsSystemActionsItem').length).toEqual(1);
});
expect(mockOnChange).toHaveBeenCalled();
test('should show no actions if none are selected', () => {
useRuleFormState.mockReturnValue({
plugins: {
http,
},
formData: {
actions: [],
consumer: 'stackAlerts',
},
selectedRuleType: {
id: 'selectedRuleTypeId',
defaultActionGroupId: 'test',
producer: 'stackAlerts',
},
connectors: mockConnectors,
connectorTypes: mockConnectorTypes,
aadTemplateFields: [],
});
render(<RuleActions />);
expect(screen.queryAllByText('RuleActionsItem').length).toEqual(0);
expect(screen.queryAllByText('RuleActionsSystemActionsItem').length).toEqual(0);
});
test('should be able to open and close the connector modal', async () => {
render(<RuleActions />);
await userEvent.click(screen.getByTestId('ruleActionsAddActionButton'));
expect(screen.getByText('RuleActionsConnectorsModal')).toBeInTheDocument();
});
test('should call onSelectConnector with the correct parameters', async () => {
render(<RuleActions />);
await userEvent.click(screen.getByTestId('ruleActionsAddActionButton'));
expect(screen.getByText('RuleActionsConnectorsModal')).toBeInTheDocument();
await userEvent.click(screen.getByText('select connector'));
expect(mockOnChange).toHaveBeenCalledWith({
payload: {
actionTypeId: 'actionType-1',
frequency: { notifyWhen: 'onActionGroupChange', summary: false, throttle: null },
group: 'test',
id: 'connector-1',
params: {},
uuid: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
},
type: 'addAction',
});
expect(screen.queryByText('RuleActionsConnectorsModal')).not.toBeInTheDocument();
});
test('should use the rule producer ID if it is not a multi-consumer rule', async () => {
render(<RuleActions />);
expect(await screen.findByText('action-1 producerId: stackAlerts')).toBeInTheDocument();
expect(await screen.findByText('action-1 producerId: stackAlerts')).toBeInTheDocument();
expect(await screen.findByText('system-action-3 producerId: stackAlerts')).toBeInTheDocument();
});
test('should use the rules consumer if the rule is a multi-consumer rule', async () => {
useRuleFormState.mockReturnValue({
plugins: {
http,
},
formData: {
actions: [...mockActions, ...mockSystemActions],
consumer: 'logs',
},
selectedRuleType: {
id: 'observability.rules.custom_threshold',
defaultActionGroupId: 'test',
producer: 'stackAlerts',
},
connectors: mockConnectors,
connectorTypes: [
getActionType('1'),
getActionType('2'),
getActionType('3', { isSystemActionType: true }),
],
aadTemplateFields: [],
});
render(<RuleActions />);
expect(await screen.findByText('action-1 producerId: logs')).toBeInTheDocument();
expect(await screen.findByText('action-1 producerId: logs')).toBeInTheDocument();
expect(await screen.findByText('system-action-3 producerId: logs')).toBeInTheDocument();
});
});

View file

@ -7,26 +7,121 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiButton } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { EuiButton, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { v4 as uuidv4 } from 'uuid';
import { RuleSystemAction } from '@kbn/alerting-types';
import { ADD_ACTION_TEXT } from '../translations';
import { RuleActionsConnectorsModal } from './rule_actions_connectors_modal';
import { useRuleFormDispatch, useRuleFormState } from '../hooks';
import { ActionConnector, RuleAction, RuleFormParamsErrors } from '../../common/types';
import { DEFAULT_FREQUENCY, MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants';
import { RuleActionsItem } from './rule_actions_item';
import { RuleActionsSystemActionsItem } from './rule_actions_system_actions_item';
export interface RuleActionsProps {
onClick: () => void;
}
export const RuleActions = () => {
const [isConnectorModalOpen, setIsConnectorModalOpen] = useState<boolean>(false);
const {
formData: { actions, consumer },
plugins: { actionTypeRegistry },
multiConsumerSelection,
selectedRuleType,
connectorTypes,
} = useRuleFormState();
const dispatch = useRuleFormDispatch();
const onModalOpen = useCallback(() => {
setIsConnectorModalOpen(true);
}, []);
const onModalClose = useCallback(() => {
setIsConnectorModalOpen(false);
}, []);
const onSelectConnector = useCallback(
async (connector: ActionConnector) => {
const { id, actionTypeId } = connector;
const uuid = uuidv4();
const params = {};
dispatch({
type: 'addAction',
payload: {
id,
actionTypeId,
uuid,
params,
group: selectedRuleType.defaultActionGroupId,
frequency: DEFAULT_FREQUENCY,
},
});
const res: { errors: RuleFormParamsErrors } = await actionTypeRegistry
.get(actionTypeId)
?.validateParams(params);
dispatch({
type: 'setActionParamsError',
payload: {
uuid,
errors: res.errors,
},
});
onModalClose();
},
[dispatch, onModalClose, selectedRuleType, actionTypeRegistry]
);
const producerId = useMemo(() => {
if (MULTI_CONSUMER_RULE_TYPE_IDS.includes(selectedRuleType.id)) {
return multiConsumerSelection || consumer;
}
return selectedRuleType.producer;
}, [consumer, multiConsumerSelection, selectedRuleType]);
export const RuleActions = (props: RuleActionsProps) => {
const { onClick } = props;
return (
<div data-test-subj="ruleActions">
<>
<EuiFlexGroup data-test-subj="ruleActions" direction="column">
{actions.map((action, index) => {
const isSystemAction = connectorTypes.some((connectorType) => {
return connectorType.id === action.actionTypeId && connectorType.isSystemActionType;
});
return (
<EuiFlexItem key={action.uuid}>
{isSystemAction && (
<RuleActionsSystemActionsItem
action={action as RuleSystemAction}
index={index}
producerId={producerId}
/>
)}
{!isSystemAction && (
<RuleActionsItem
action={action as RuleAction}
index={index}
producerId={producerId}
/>
)}
</EuiFlexItem>
);
})}
</EuiFlexGroup>
<EuiSpacer />
<EuiButton
data-test-subj="ruleActionsAddActionButton"
iconType="push"
iconSide="left"
onClick={onClick}
data-test-subj="ruleActionsAddActionButton"
onClick={onModalOpen}
>
{ADD_ACTION_TEXT}
</EuiButton>
</div>
{isConnectorModalOpen && (
<RuleActionsConnectorsModal onClose={onModalClose} onSelectConnector={onSelectConnector} />
)}
</>
);
};

View file

@ -0,0 +1,183 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { httpServiceMock } from '@kbn/core/public/mocks';
import { RuleActionsAlertsFilter } from './rule_actions_alerts_filter';
import type { AlertsSearchBarProps } from '../../alerts_search_bar';
import { FilterStateStore } from '@kbn/es-query';
import { getAction } from '../../common/test_utils/actions_test_utils';
import userEvent from '@testing-library/user-event';
const http = httpServiceMock.createStartContract();
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
}));
jest.mock('../../alerts_search_bar', () => ({
AlertsSearchBar: ({ onFiltersUpdated, onQueryChange, onQuerySubmit }: AlertsSearchBarProps) => (
<div>
AlertsSearchBar
<button
onClick={() =>
onFiltersUpdated?.([
{
$state: { store: 'appState' as FilterStateStore },
meta: {},
},
])
}
>
Update Filter
</button>
<button
onClick={() =>
onQueryChange?.({
dateRange: { from: 'now', to: 'now' },
query: 'onQueryChange',
})
}
>
Update Query
</button>
<button
onClick={() =>
onQuerySubmit?.({
dateRange: { from: 'now', to: 'now' },
query: 'onQuerySubmit',
})
}
>
Submit Query
</button>
</div>
),
}));
const { useRuleFormState } = jest.requireMock('../hooks');
const mockOnChange = jest.fn();
describe('ruleActionsAlertsFilter', () => {
beforeEach(() => {
useRuleFormState.mockReturnValue({
plugins: {
http,
notifications: {
toasts: {},
},
unifiedSearch: {
ui: {
SearchBar: {},
},
},
dataViews: {},
},
formData: {
actions: [],
consumer: 'stackAlerts',
},
selectedRuleType: {
id: 'selectedRuleTypeId',
defaultActionGroupId: 'test',
producer: 'stackAlerts',
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('should render correctly', () => {
render(
<RuleActionsAlertsFilter
action={getAction('1')}
onChange={mockOnChange}
appName="stackAlerts"
featureIds={['stackAlerts']}
/>
);
expect(screen.getByTestId('alertsFilterQueryToggle')).toBeInTheDocument();
});
test('should allow for toggling on of query', async () => {
render(
<RuleActionsAlertsFilter
action={getAction('1')}
onChange={mockOnChange}
appName="stackAlerts"
featureIds={['stackAlerts']}
/>
);
await userEvent.click(screen.getByTestId('alertsFilterQueryToggle'));
expect(mockOnChange).toHaveBeenLastCalledWith({ filters: [], kql: '' });
});
test('should allow for toggling off of query', async () => {
render(
<RuleActionsAlertsFilter
action={getAction('1', {
alertsFilter: {
query: {
kql: 'test',
filters: [],
},
},
})}
onChange={mockOnChange}
appName="stackAlerts"
featureIds={['stackAlerts']}
/>
);
await userEvent.click(screen.getByTestId('alertsFilterQueryToggle'));
expect(mockOnChange).toHaveBeenLastCalledWith(undefined);
});
test('should allow for changing query', async () => {
render(
<RuleActionsAlertsFilter
action={getAction('1', {
alertsFilter: {
query: {
kql: 'test',
filters: [],
},
},
})}
onChange={mockOnChange}
appName="stackAlerts"
featureIds={['stackAlerts']}
/>
);
await userEvent.click(screen.getByText('Update Filter'));
expect(mockOnChange).toHaveBeenLastCalledWith({
filters: [{ $state: { store: 'appState' }, meta: {} }],
kql: 'test',
});
await userEvent.click(screen.getByText('Update Query'));
expect(mockOnChange).toHaveBeenLastCalledWith({
filters: [{ $state: { store: 'appState' }, meta: {} }],
kql: 'onQueryChange',
});
await userEvent.click(screen.getByText('Submit Query'));
expect(mockOnChange).toHaveBeenLastCalledWith({
filters: [{ $state: { store: 'appState' }, meta: {} }],
kql: 'onQuerySubmit',
});
});
});

View file

@ -1,35 +1,61 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ValidFeatureId } from '@kbn/rule-data-utils';
import { Filter } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { EuiSwitch, EuiSpacer } from '@elastic/eui';
import { AlertsFilter } from '@kbn/alerting-plugin/common';
import type { AlertsFilter } from '@kbn/alerting-types';
import deepEqual from 'fast-deep-equal';
import { AlertsSearchBar, AlertsSearchBarProps } from '../alerts_search_bar';
import { useRuleFormState } from '../hooks';
import { RuleAction } from '../../common';
import { RuleFormPlugins } from '../types';
import { AlertsSearchBar, AlertsSearchBarProps } from '../../alerts_search_bar';
interface ActionAlertsFilterQueryProps {
state?: AlertsFilter['query'];
const DEFAULT_QUERY = { kql: '', filters: [] };
export interface RuleActionsAlertsFilterProps {
action: RuleAction;
onChange: (update?: AlertsFilter['query']) => void;
appName: string;
featureIds: ValidFeatureId[];
ruleTypeId?: string;
plugins?: {
http: RuleFormPlugins['http'];
notifications: RuleFormPlugins['notifications'];
unifiedSearch: RuleFormPlugins['unifiedSearch'];
dataViews: RuleFormPlugins['dataViews'];
};
}
export const ActionAlertsFilterQuery: React.FC<ActionAlertsFilterQueryProps> = ({
state,
export const RuleActionsAlertsFilter = ({
action,
onChange,
appName,
featureIds,
ruleTypeId,
}) => {
const [query, setQuery] = useState(state ?? { kql: '', filters: [] });
plugins: propsPlugins,
}: RuleActionsAlertsFilterProps) => {
const { plugins } = useRuleFormState();
const {
http,
notifications: { toasts },
unifiedSearch,
dataViews,
} = propsPlugins || plugins;
const [query, setQuery] = useState(action.alertsFilter?.query ?? DEFAULT_QUERY);
const state = useMemo(() => {
return action.alertsFilter?.query;
}, [action]);
const queryEnabled = useMemo(() => Boolean(state), [state]);
@ -66,7 +92,7 @@ export const ActionAlertsFilterQuery: React.FC<ActionAlertsFilterQueryProps> = (
<>
<EuiSwitch
label={i18n.translate(
'xpack.triggersActionsUI.sections.actionTypeForm.ActionAlertsFilterQueryToggleLabel',
'alertsUIShared.ruleActionsAlertsFilter.ActionAlertsFilterQueryToggleLabel',
{
defaultMessage: 'If alert matches a query',
}
@ -79,6 +105,10 @@ export const ActionAlertsFilterQuery: React.FC<ActionAlertsFilterQueryProps> = (
<>
<EuiSpacer size="s" />
<AlertsSearchBar
http={http}
toasts={toasts}
unifiedSearchBar={unifiedSearch.ui.SearchBar}
dataViewsService={dataViews}
appName={appName}
featureIds={featureIds}
ruleTypeId={ruleTypeId}
@ -93,7 +123,7 @@ export const ActionAlertsFilterQuery: React.FC<ActionAlertsFilterQueryProps> = (
showDatePicker={false}
showSubmitButton={false}
placeholder={i18n.translate(
'xpack.triggersActionsUI.sections.actionTypeForm.ActionAlertsFilterQueryPlaceholder',
'alertsUIShared.ruleActionsAlertsFilter.ActionAlertsFilterQueryPlaceholder',
{
defaultMessage: 'Filter alerts using KQL syntax',
}

View file

@ -14,12 +14,13 @@ import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import type { SettingsStart } from '@kbn/core-ui-settings-browser';
import { RuleActionsAlertsFilterTimeframe } from './rule_actions_alerts_filter_timeframe';
import { AlertsFilterTimeframe } from '@kbn/alerting-types';
import { getAction } from '../../common/test_utils/actions_test_utils';
describe('ruleActionsAlertsFilterTimeframe', () => {
async function setup(timeframe?: AlertsFilterTimeframe) {
const wrapper = mountWithIntl(
<RuleActionsAlertsFilterTimeframe
state={timeframe}
action={getAction('1', { alertsFilter: { timeframe } })}
settings={
{
client: {

View file

@ -24,9 +24,10 @@ import {
import deepEqual from 'fast-deep-equal';
import { ISO_WEEKDAYS, type IsoWeekday, type AlertsFilterTimeframe } from '@kbn/alerting-types';
import { I18N_WEEKDAY_OPTIONS_DDD } from '../../common/constants';
import { RuleAction } from '../../common';
interface RuleActionsAlertsFilterTimeframeProps {
state?: AlertsFilterTimeframe;
export interface RuleActionsAlertsFilterTimeframeProps {
action: RuleAction;
settings: SettingsStart;
onChange: (update?: AlertsFilterTimeframe) => void;
}
@ -76,29 +77,30 @@ const useTimeFormat = (settings: SettingsStart) => {
};
export const RuleActionsAlertsFilterTimeframe: React.FC<RuleActionsAlertsFilterTimeframeProps> = ({
state,
action,
settings,
onChange,
}) => {
const actionTimeFrame = action.alertsFilter?.timeframe;
const timeFormat = useTimeFormat(settings);
const [timeframe, setTimeframe] = useTimeframe({
initialTimeframe: state,
initialTimeframe: actionTimeFrame,
settings,
});
const [selectedTimezone, setSelectedTimezone] = useState([{ label: timeframe.timezone }]);
const timeframeEnabled = useMemo(() => Boolean(state), [state]);
const timeframeEnabled = useMemo(() => Boolean(actionTimeFrame), [actionTimeFrame]);
const weekdayOptions = useSortedWeekdayOptions(settings);
useEffect(() => {
const nextState = timeframeEnabled ? timeframe : undefined;
if (!deepEqual(state, nextState)) onChange(nextState);
}, [timeframeEnabled, timeframe, state, onChange]);
if (!deepEqual(actionTimeFrame, nextState)) onChange(nextState);
}, [timeframeEnabled, timeframe, actionTimeFrame, onChange]);
const toggleTimeframe = useCallback(
() => onChange(state ? undefined : timeframe),
[state, timeframe, onChange]
() => onChange(actionTimeFrame ? undefined : timeframe),
[actionTimeFrame, timeframe, onChange]
);
const updateTimeframe = useCallback(
(update: Partial<AlertsFilterTimeframe>) => {

View file

@ -0,0 +1,322 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RuleActionsConnectorsModal } from './rule_actions_connectors_modal';
import { ActionConnector, ActionTypeModel } from '../../common';
import { ActionType } from '@kbn/actions-types';
import { TypeRegistry } from '../../common/type_registry';
import {
getActionType,
getActionTypeModel,
getConnector,
} from '../../common/test_utils/actions_test_utils';
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));
const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');
const mockConnectors: ActionConnector[] = [getConnector('1'), getConnector('2')];
const mockActionTypes: ActionType[] = [getActionType('1'), getActionType('2')];
const mockOnClose = jest.fn();
const mockOnSelectConnector = jest.fn();
const mockOnChange = jest.fn();
describe('ruleActionsConnectorsModal', () => {
beforeEach(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(getActionTypeModel('1', { id: 'actionType-1' }));
actionTypeRegistry.register(getActionTypeModel('2', { id: 'actionType-2' }));
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
},
formData: {
actions: [],
},
connectors: mockConnectors,
connectorTypes: mockActionTypes,
aadTemplateFields: [],
});
useRuleFormDispatch.mockReturnValue(mockOnChange);
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders correctly', () => {
render(
<RuleActionsConnectorsModal onClose={mockOnClose} onSelectConnector={mockOnSelectConnector} />
);
expect(screen.getByTestId('ruleActionsConnectorsModal'));
});
test('should render connectors and filters', () => {
render(
<RuleActionsConnectorsModal onClose={mockOnClose} onSelectConnector={mockOnSelectConnector} />
);
expect(screen.getByText('connector-1')).toBeInTheDocument();
expect(screen.getByText('connector-2')).toBeInTheDocument();
expect(screen.getByTestId('ruleActionsConnectorsModalSearch')).toBeInTheDocument();
expect(screen.getAllByTestId('ruleActionsConnectorsModalFilterButton').length).toEqual(3);
const filterButtonGroup = screen.getByTestId('ruleActionsConnectorsModalFilterButtonGroup');
expect(within(filterButtonGroup).getByText('actionType: 1')).toBeInTheDocument();
expect(within(filterButtonGroup).getByText('actionType: 2')).toBeInTheDocument();
expect(within(filterButtonGroup).getByText('All')).toBeInTheDocument();
});
test('should allow for searching of connectors', async () => {
render(
<RuleActionsConnectorsModal onClose={mockOnClose} onSelectConnector={mockOnSelectConnector} />
);
// Type first connector
await userEvent.type(screen.getByTestId('ruleActionsConnectorsModalSearch'), 'connector-1');
expect(screen.getAllByTestId('ruleActionsConnectorsModalCard').length).toEqual(1);
expect(screen.getByText('connector-1')).toBeInTheDocument();
// Clear
await userEvent.clear(screen.getByTestId('ruleActionsConnectorsModalSearch'));
// Type second connector
await userEvent.type(screen.getByTestId('ruleActionsConnectorsModalSearch'), 'actionType: 2');
expect(screen.getAllByTestId('ruleActionsConnectorsModalCard').length).toEqual(1);
expect(screen.getByText('connector-2')).toBeInTheDocument();
// Clear
await userEvent.clear(screen.getByTestId('ruleActionsConnectorsModalSearch'));
// Type a connector that doesn't exist
await userEvent.type(screen.getByTestId('ruleActionsConnectorsModalSearch'), 'doesntexist');
expect(screen.getByTestId('ruleActionsConnectorsModalEmpty')).toBeInTheDocument();
// Clear
await userEvent.click(screen.getByTestId('ruleActionsConnectorsModalClearFiltersButton'));
expect(screen.getAllByTestId('ruleActionsConnectorsModalCard').length).toEqual(2);
});
test('should allow for filtering of connectors', async () => {
render(
<RuleActionsConnectorsModal onClose={mockOnClose} onSelectConnector={mockOnSelectConnector} />
);
const filterButtonGroup = screen.getByTestId('ruleActionsConnectorsModalFilterButtonGroup');
await userEvent.click(within(filterButtonGroup).getByText('actionType: 1'));
expect(screen.getAllByTestId('ruleActionsConnectorsModalCard').length).toEqual(1);
expect(screen.getByText('connector-1')).toBeInTheDocument();
await userEvent.click(within(filterButtonGroup).getByText('actionType: 2'));
expect(screen.getByText('connector-2')).toBeInTheDocument();
expect(screen.getAllByTestId('ruleActionsConnectorsModalCard').length).toEqual(1);
await userEvent.click(within(filterButtonGroup).getByText('All'));
expect(screen.getAllByTestId('ruleActionsConnectorsModalCard').length).toEqual(2);
});
test('should call onSelectConnector when connector is clicked', async () => {
render(
<RuleActionsConnectorsModal onClose={mockOnClose} onSelectConnector={mockOnSelectConnector} />
);
await userEvent.click(screen.getByText('connector-1'));
expect(mockOnSelectConnector).toHaveBeenLastCalledWith({
actionTypeId: 'actionType-1',
config: { config: 'config-1' },
id: 'connector-1',
isDeprecated: false,
isPreconfigured: false,
isSystemAction: false,
name: 'connector-1',
secrets: { secret: 'secret' },
});
await userEvent.click(screen.getByText('connector-2'));
expect(mockOnSelectConnector).toHaveBeenLastCalledWith({
actionTypeId: 'actionType-2',
config: { config: 'config-2' },
id: 'connector-2',
isDeprecated: false,
isPreconfigured: false,
isSystemAction: false,
name: 'connector-2',
secrets: { secret: 'secret' },
});
});
test('should not render connector if action type doesnt exist', () => {
render(
<RuleActionsConnectorsModal onClose={mockOnClose} onSelectConnector={mockOnSelectConnector} />
);
expect(screen.queryByText('connector2')).not.toBeInTheDocument();
});
test('should not render connector if hideInUi is true', () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(getActionTypeModel('1', { id: 'actionType-1' }));
actionTypeRegistry.register(getActionTypeModel('2', { id: 'actionType-2', hideInUi: true }));
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
},
formData: {
actions: [],
},
connectors: mockConnectors,
connectorTypes: mockActionTypes,
});
render(
<RuleActionsConnectorsModal onClose={mockOnClose} onSelectConnector={mockOnSelectConnector} />
);
expect(screen.queryByText('connector2')).not.toBeInTheDocument();
});
test('should not render connector if actionsParamsField doesnt exist', () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(getActionTypeModel('1', { id: 'actionType-1' }));
actionTypeRegistry.register(
getActionTypeModel('2', {
id: 'actionType-2',
actionParamsFields: null as unknown as React.LazyExoticComponent<any>,
})
);
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
},
formData: {
actions: [],
},
connectors: mockConnectors,
connectorTypes: mockActionTypes,
});
render(
<RuleActionsConnectorsModal onClose={mockOnClose} onSelectConnector={mockOnSelectConnector} />
);
expect(screen.queryByText('connector-2')).not.toBeInTheDocument();
});
test('should not render connector if the action type is not enabled', () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(getActionTypeModel('1', { id: 'actionType-1' }));
actionTypeRegistry.register(getActionTypeModel('2', { id: 'actionType-2' }));
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
},
formData: {
actions: [],
},
connectors: mockConnectors,
connectorTypes: [getActionType('1'), getActionType('2', { enabledInConfig: false })],
});
render(
<RuleActionsConnectorsModal onClose={mockOnClose} onSelectConnector={mockOnSelectConnector} />
);
expect(screen.queryByText('connector-2')).not.toBeInTheDocument();
});
test('should render connector if the action is not enabled but its a preconfigured connector', () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(getActionTypeModel('1', { id: 'actionType-1' }));
actionTypeRegistry.register(getActionTypeModel('2', { id: 'actionType-2' }));
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
},
formData: {
actions: [],
},
connectors: [getConnector('1'), getConnector('2', { isPreconfigured: true })],
connectorTypes: [getActionType('1'), getActionType('2', { enabledInConfig: false })],
});
render(
<RuleActionsConnectorsModal onClose={mockOnClose} onSelectConnector={mockOnSelectConnector} />
);
expect(screen.getByText('connector-2')).toBeInTheDocument();
});
test('should disable connector if it fails license check', () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(getActionTypeModel('1', { id: 'actionType-1' }));
actionTypeRegistry.register(getActionTypeModel('2', { id: 'actionType-2' }));
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
},
formData: {
actions: [],
},
connectors: mockConnectors,
connectorTypes: [getActionType('1'), getActionType('2', { enabledInLicense: false })],
});
render(
<RuleActionsConnectorsModal onClose={mockOnClose} onSelectConnector={mockOnSelectConnector} />
);
expect(screen.getByText('connector-2')).toBeDisabled();
});
test('should disable connector if its a selected system action', () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(getActionTypeModel('1', { id: 'actionType-1' }));
actionTypeRegistry.register(
getActionTypeModel('2', { isSystemActionType: true, id: 'actionType-2' })
);
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
},
formData: {
actions: [{ actionTypeId: 'actionType-2' }],
},
connectors: mockConnectors,
connectorTypes: [getActionType('1'), getActionType('2', { isSystemActionType: true })],
});
render(
<RuleActionsConnectorsModal onClose={mockOnClose} onSelectConnector={mockOnSelectConnector} />
);
expect(screen.getByText('connector-2')).toBeDisabled();
});
});

View file

@ -0,0 +1,348 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useState, useCallback, useMemo, Suspense } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiModal,
EuiModalHeader,
EuiFieldSearch,
EuiFacetButton,
EuiModalBody,
EuiHorizontalRule,
EuiModalHeaderTitle,
useEuiTheme,
EuiEmptyPrompt,
EuiFacetGroup,
EuiCard,
EuiIcon,
EuiText,
EuiSpacer,
useCurrentEuiBreakpoint,
EuiButton,
EuiLoadingSpinner,
EuiToolTip,
} from '@elastic/eui';
import { ActionConnector } from '../../common';
import { useRuleFormState } from '../hooks';
import {
ACTION_TYPE_MODAL_EMPTY_TEXT,
ACTION_TYPE_MODAL_EMPTY_TITLE,
ACTION_TYPE_MODAL_FILTER_ALL,
ACTION_TYPE_MODAL_TITLE,
MODAL_SEARCH_CLEAR_FILTERS_TEXT,
MODAL_SEARCH_PLACEHOLDER,
} from '../translations';
import { checkActionFormActionTypeEnabled } from '../utils/check_action_type_enabled';
type ConnectorsMap = Record<string, { actionTypeId: string; name: string; total: number }>;
export interface RuleActionsConnectorsModalProps {
onClose: () => void;
onSelectConnector: (connector: ActionConnector) => void;
}
export const RuleActionsConnectorsModal = (props: RuleActionsConnectorsModalProps) => {
const { onClose, onSelectConnector } = props;
const [searchValue, setSearchValue] = useState<string>('');
const [selectedConnectorType, setSelectedConnectorType] = useState<string>('all');
const { euiTheme } = useEuiTheme();
const currentBreakpoint = useCurrentEuiBreakpoint() ?? 'm';
const isFullscreenPortrait = ['s', 'xs'].includes(currentBreakpoint);
const {
plugins: { actionTypeRegistry },
formData: { actions },
connectors,
connectorTypes,
} = useRuleFormState();
const preconfiguredConnectors = useMemo(() => {
return connectors.filter((connector) => connector.isPreconfigured);
}, [connectors]);
const availableConnectors = useMemo(() => {
return connectors.filter(({ actionTypeId }) => {
const actionType = connectorTypes.find(({ id }) => id === actionTypeId);
const actionTypeModel = actionTypeRegistry.get(actionTypeId);
if (!actionType) {
return false;
}
if (actionTypeModel.hideInUi) {
return false;
}
if (!actionTypeModel.actionParamsFields) {
return false;
}
const checkEnabledResult = checkActionFormActionTypeEnabled(
actionType,
preconfiguredConnectors
);
if (!actionType.enabledInConfig && !checkEnabledResult.isEnabled) {
return false;
}
return true;
});
}, [connectors, connectorTypes, preconfiguredConnectors, actionTypeRegistry]);
const onSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value);
}, []);
const onConnectorOptionSelect = useCallback(
(id: string) => () => {
setSelectedConnectorType((prev) => {
if (prev === id) {
return '';
}
return id;
});
},
[]
);
const onClearFilters = useCallback(() => {
setSearchValue('');
setSelectedConnectorType('all');
}, []);
const connectorsMap: ConnectorsMap | null = useMemo(() => {
return availableConnectors.reduce<ConnectorsMap>((result, { actionTypeId }) => {
if (result[actionTypeId]) {
result[actionTypeId].total += 1;
} else {
result[actionTypeId] = {
actionTypeId,
total: 1,
name: connectorTypes.find(({ id }) => actionTypeId === id)?.name || '',
};
}
return result;
}, {});
}, [availableConnectors, connectorTypes]);
const filteredConnectors = useMemo(() => {
return availableConnectors
.filter(({ actionTypeId }) => {
if (selectedConnectorType === 'all' || selectedConnectorType === '') {
return true;
}
if (selectedConnectorType === actionTypeId) {
return true;
}
return false;
})
.filter(({ actionTypeId, name }) => {
const trimmedSearchValue = searchValue.trim().toLocaleLowerCase();
if (trimmedSearchValue === '') {
return true;
}
const actionTypeModel = actionTypeRegistry.get(actionTypeId);
const actionType = connectorTypes.find(({ id }) => id === actionTypeId);
const textSearchTargets = [
name.toLocaleLowerCase(),
actionTypeModel.selectMessage?.toLocaleLowerCase(),
actionTypeModel.actionTypeTitle?.toLocaleLowerCase(),
actionType?.name?.toLocaleLowerCase(),
];
return textSearchTargets.some((text) => text?.includes(trimmedSearchValue));
});
}, [availableConnectors, selectedConnectorType, searchValue, connectorTypes, actionTypeRegistry]);
const connectorFacetButtons = useMemo(() => {
return (
<EuiFacetGroup data-test-subj="ruleActionsConnectorsModalFilterButtonGroup">
<EuiFacetButton
data-test-subj="ruleActionsConnectorsModalFilterButton"
key="all"
quantity={availableConnectors.length}
isSelected={selectedConnectorType === 'all'}
onClick={onConnectorOptionSelect('all')}
>
{ACTION_TYPE_MODAL_FILTER_ALL}
</EuiFacetButton>
{Object.values(connectorsMap)
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ actionTypeId, name, total }) => {
return (
<EuiFacetButton
data-test-subj="ruleActionsConnectorsModalFilterButton"
key={actionTypeId}
quantity={total}
isSelected={selectedConnectorType === actionTypeId}
onClick={onConnectorOptionSelect(actionTypeId)}
>
{name}
</EuiFacetButton>
);
})}
</EuiFacetGroup>
);
}, [availableConnectors, connectorsMap, selectedConnectorType, onConnectorOptionSelect]);
const connectorCards = useMemo(() => {
if (!filteredConnectors.length) {
return (
<EuiEmptyPrompt
data-test-subj="ruleActionsConnectorsModalEmpty"
color="subdued"
iconType="search"
title={<h2>{ACTION_TYPE_MODAL_EMPTY_TITLE}</h2>}
body={
<EuiText>
<p>{ACTION_TYPE_MODAL_EMPTY_TEXT}</p>
</EuiText>
}
actions={
<EuiButton
data-test-subj="ruleActionsConnectorsModalClearFiltersButton"
size="s"
color="primary"
fill
onClick={onClearFilters}
>
{MODAL_SEARCH_CLEAR_FILTERS_TEXT}
</EuiButton>
}
/>
);
}
return (
<EuiFlexGroup direction="column">
{filteredConnectors.map((connector) => {
const { id, actionTypeId, name } = connector;
const actionTypeModel = actionTypeRegistry.get(actionTypeId);
const actionType = connectorTypes.find((item) => item.id === actionTypeId);
if (!actionType) {
return null;
}
const checkEnabledResult = checkActionFormActionTypeEnabled(
actionType,
preconfiguredConnectors
);
const isSystemActionsSelected = Boolean(
actionTypeModel.isSystemActionType &&
actions.find((action) => action.actionTypeId === actionTypeModel.id)
);
const isDisabled = !checkEnabledResult.isEnabled || isSystemActionsSelected;
const connectorCard = (
<EuiCard
data-test-subj="ruleActionsConnectorsModalCard"
hasBorder
isDisabled={isDisabled}
titleSize="xs"
layout="horizontal"
icon={
<div style={{ marginInlineEnd: `16px` }}>
<Suspense fallback={<EuiLoadingSpinner />}>
<EuiIcon size="l" type={actionTypeModel.iconClass} />
</Suspense>
</div>
}
title={name}
description={
<>
<EuiText size="xs">{actionTypeModel.selectMessage}</EuiText>
<EuiSpacer size="s" />
<EuiText color="subdued" size="xs" style={{ textTransform: 'uppercase' }}>
<strong>{actionType?.name}</strong>
</EuiText>
</>
}
onClick={() => onSelectConnector(connector)}
/>
);
return (
<EuiFlexItem key={id} grow={false}>
{checkEnabledResult.isEnabled && connectorCard}
{!checkEnabledResult.isEnabled && (
<EuiToolTip position="top" content={checkEnabledResult.message}>
{connectorCard}
</EuiToolTip>
)}
</EuiFlexItem>
);
})}
</EuiFlexGroup>
);
}, [
actions,
preconfiguredConnectors,
filteredConnectors,
actionTypeRegistry,
connectorTypes,
onSelectConnector,
onClearFilters,
]);
const responseiveHeight = isFullscreenPortrait ? 'initial' : '80vh';
const responsiveOverflow = isFullscreenPortrait ? 'auto' : 'hidden';
return (
<EuiModal
onClose={onClose}
maxWidth={euiTheme.breakpoint[currentBreakpoint]}
style={{
width: euiTheme.breakpoint[currentBreakpoint],
maxHeight: responseiveHeight,
height: responseiveHeight,
overflow: responsiveOverflow,
}}
data-test-subj="ruleActionsConnectorsModal"
>
<EuiModalHeader>
<EuiModalHeaderTitle size="s">{ACTION_TYPE_MODAL_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup direction="column" style={{ overflow: responsiveOverflow, height: '100%' }}>
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFieldSearch
data-test-subj="ruleActionsConnectorsModalSearch"
placeholder={MODAL_SEARCH_PLACEHOLDER}
value={searchValue}
onChange={onSearchChange}
/>
</EuiFlexItem>
<EuiHorizontalRule margin="none" />
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem style={{ overflow: responsiveOverflow }}>
<EuiFlexGroup style={{ overflow: responsiveOverflow }}>
<EuiFlexItem grow={1}>{connectorFacetButtons}</EuiFlexItem>
<EuiFlexItem
grow={3}
style={{
overflow: 'auto',
width: '100%',
padding: `${euiTheme.size.base} ${euiTheme.size.base} ${euiTheme.size.xl}`,
}}
>
{connectorCards}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalBody>
</EuiModal>
);
};

View file

@ -0,0 +1,385 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RuleType } from '@kbn/alerting-types';
import { ActionTypeModel, RuleTypeModel } from '../../common';
import { TypeRegistry } from '../../common/type_registry';
import {
getAction,
getActionType,
getActionTypeModel,
getConnector,
} from '../../common/test_utils/actions_test_utils';
import { RuleActionsItem } from './rule_actions_item';
import userEvent from '@testing-library/user-event';
import { RuleActionsSettingsProps } from './rule_actions_settings';
import { RuleActionsMessageProps } from './rule_actions_message';
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));
jest.mock('./rule_actions_settings', () => ({
RuleActionsSettings: ({
onNotifyWhenChange,
onActionGroupChange,
onAlertsFilterChange,
onTimeframeChange,
}: RuleActionsSettingsProps) => (
<div>
ruleActionsSettings
<button
onClick={() =>
onNotifyWhenChange({
summary: true,
notifyWhen: 'onThrottleInterval',
throttle: '5m',
})
}
>
onNotifyWhenChange
</button>
<button onClick={() => onActionGroupChange('recovered')}>onActionGroupChange</button>
<button
onClick={() =>
onAlertsFilterChange({
kql: '',
filters: [],
})
}
>
onAlertsFilterChange
</button>
<button
onClick={() =>
onTimeframeChange({
days: [1, 2, 3],
timezone: 'UTC',
hours: {
start: 'now',
end: 'now',
},
})
}
>
onTimeframeChange
</button>
</div>
),
}));
jest.mock('./rule_actions_message', () => ({
RuleActionsMessage: ({ onParamsChange, templateFields }: RuleActionsMessageProps) => (
<div>
ruleActionsMessage
<button onClick={() => onParamsChange('paramsKey', { paramsKey: 'paramsValue' })}>
onParamsChange
</button>
</div>
),
}));
jest.mock('../validation/validate_params_for_warnings', () => ({
validateParamsForWarnings: jest.fn(),
}));
jest.mock('../../action_variables/get_available_action_variables', () => ({
getAvailableActionVariables: jest.fn(),
}));
const ruleType = {
id: '.es-query',
name: 'Test',
actionGroups: [
{
id: 'testActionGroup',
name: 'Test Action Group',
},
{
id: 'recovered',
name: 'Recovered',
},
],
defaultActionGroupId: 'testActionGroup',
minimumLicenseRequired: 'basic',
recoveryActionGroup: {
id: 'recovered',
},
producer: 'logs',
authorizedConsumers: {
alerting: { read: true, all: true },
test: { read: true, all: true },
stackAlerts: { read: true, all: true },
logs: { read: true, all: true },
},
actionVariables: {
params: [],
state: [],
},
enabledInLicense: true,
} as unknown as RuleType;
const ruleModel: RuleTypeModel = {
id: '.es-query',
description: 'Sample rule type model',
iconClass: 'sampleIconClass',
documentationUrl: 'testurl',
validate: (params, isServerless) => ({ errors: {} }),
ruleParamsExpression: () => <div>Expression</div>,
defaultSummaryMessage: 'Sample default summary message',
defaultActionMessage: 'Sample default action message',
defaultRecoveryMessage: 'Sample default recovery message',
requiresAppContext: false,
};
const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');
const { validateParamsForWarnings } = jest.requireMock(
'../validation/validate_params_for_warnings'
);
const { getAvailableActionVariables } = jest.requireMock(
'../../action_variables/get_available_action_variables'
);
const mockConnectors = [getConnector('1', { id: 'action-1' })];
const mockActionTypes = [getActionType('1')];
const mockOnChange = jest.fn();
const mockValidate = jest.fn().mockResolvedValue({
errors: {},
});
describe('ruleActionsItem', () => {
beforeEach(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(
getActionTypeModel('1', {
id: 'actionType-1',
defaultRecoveredActionParams: { recoveredParamKey: 'recoveredParamValue' },
defaultActionParams: { actionParamKey: 'actionParamValue' },
validateParams: mockValidate,
})
);
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
http: {
basePath: {
publicBaseUrl: 'publicUrl',
},
},
},
connectors: mockConnectors,
connectorTypes: mockActionTypes,
aadTemplateFields: [],
actionsParamsErrors: {},
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
});
useRuleFormDispatch.mockReturnValue(mockOnChange);
validateParamsForWarnings.mockReturnValue(null);
getAvailableActionVariables.mockReturnValue(['mockActionVariable']);
});
afterEach(() => {
jest.clearAllMocks();
});
test('should render correctly', () => {
render(
<RuleActionsItem
action={getAction('1', { actionTypeId: 'actionType-1' })}
index={0}
producerId="stackAlerts"
/>
);
expect(screen.getByTestId('ruleActionsItem')).toBeInTheDocument();
expect(screen.queryByText('ruleActionsSettings')).not.toBeInTheDocument();
expect(screen.getByText('ruleActionsMessage')).toBeInTheDocument();
});
test('should allow for toggling between setting and message', async () => {
render(
<RuleActionsItem
action={getAction('1', { actionTypeId: 'actionType-1' })}
index={0}
producerId="stackAlerts"
/>
);
await userEvent.click(screen.getByText('Message'));
expect(screen.getByText('ruleActionsMessage')).toBeInTheDocument();
expect(screen.queryByText('ruleActionsSettings')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Settings'));
expect(screen.getByText('ruleActionsSettings')).toBeInTheDocument();
expect(screen.queryByText('ruleActionsMessage')).not.toBeInTheDocument();
});
test('should allow notify when to be changed', async () => {
render(
<RuleActionsItem
action={getAction('1', {
actionTypeId: 'actionType-1',
group: 'recovered',
})}
index={0}
producerId="stackAlerts"
/>
);
await userEvent.click(screen.getByText('Settings'));
await userEvent.click(screen.getByText('onNotifyWhenChange'));
expect(mockOnChange).toHaveBeenCalledTimes(3);
expect(mockOnChange).toHaveBeenCalledWith({
payload: { uuid: 'uuid-action-1', value: { recoveredParamKey: 'recoveredParamValue' } },
type: 'setActionParams',
});
expect(mockOnChange).toHaveBeenCalledWith({
payload: {
key: 'frequency',
uuid: 'uuid-action-1',
value: { notifyWhen: 'onThrottleInterval', summary: true, throttle: '5m' },
},
type: 'setActionProperty',
});
expect(mockOnChange).toHaveBeenCalledWith({
payload: { errors: {}, uuid: 'uuid-action-1' },
type: 'setActionParamsError',
});
expect(getAvailableActionVariables).toHaveBeenCalledWith(
{ params: [], state: [] },
undefined,
{
defaultActionMessage: 'Sample default recovery message',
id: 'recovered',
name: 'Recovered',
omitMessageVariables: 'all',
},
true
);
});
test('should allow alerts filter to be changed', async () => {
render(
<RuleActionsItem
action={getAction('1', { actionTypeId: 'actionType-1' })}
index={0}
producerId="stackAlerts"
/>
);
await userEvent.click(screen.getByText('Settings'));
await userEvent.click(screen.getByText('onAlertsFilterChange'));
expect(mockOnChange).toHaveBeenCalledTimes(2);
expect(mockOnChange).toHaveBeenCalledWith({
payload: {
key: 'alertsFilter',
uuid: 'uuid-action-1',
value: { query: { filters: [], kql: '' } },
},
type: 'setActionProperty',
});
expect(mockOnChange).toHaveBeenCalledWith({
payload: { errors: { filterQuery: ['A custom query is required.'] }, uuid: 'uuid-action-1' },
type: 'setActionError',
});
});
test('should allow timeframe to be changed', async () => {
render(
<RuleActionsItem
action={getAction('1', { actionTypeId: 'actionType-1' })}
index={0}
producerId="stackAlerts"
/>
);
await userEvent.click(screen.getByText('Settings'));
await userEvent.click(screen.getByText('onTimeframeChange'));
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange).toHaveBeenCalledWith({
payload: {
key: 'alertsFilter',
uuid: 'uuid-action-1',
value: {
timeframe: { days: [1, 2, 3], hours: { end: 'now', start: 'now' }, timezone: 'UTC' },
},
},
type: 'setActionProperty',
});
});
test('should allow params to be changed', async () => {
render(
<RuleActionsItem
action={getAction('1', { actionTypeId: 'actionType-1' })}
index={0}
producerId="stackAlerts"
/>
);
await userEvent.click(screen.getByText('Message'));
await userEvent.click(screen.getByText('onParamsChange'));
expect(mockOnChange).toHaveBeenCalledTimes(2);
expect(mockOnChange).toHaveBeenCalledWith({
payload: { uuid: 'uuid-action-1', value: { paramsKey: { paramsKey: 'paramsValue' } } },
type: 'setActionParams',
});
expect(mockOnChange).toHaveBeenCalledWith({
payload: { errors: {}, uuid: 'uuid-action-1' },
type: 'setActionParamsError',
});
});
test('should allow action to be deleted', async () => {
render(
<RuleActionsItem
action={getAction('1', { actionTypeId: 'actionType-1' })}
index={0}
producerId="stackAlerts"
/>
);
await userEvent.click(screen.getByText('Settings'));
await userEvent.click(screen.getByTestId('ruleActionsItemDeleteButton'));
expect(mockOnChange).toHaveBeenCalledWith({
payload: { uuid: 'uuid-action-1' },
type: 'removeAction',
});
});
});

View file

@ -0,0 +1,686 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
import React, { Suspense, useCallback, useMemo, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiAccordion,
EuiPanel,
EuiButtonIcon,
useEuiTheme,
useEuiBackgroundColor,
EuiIcon,
EuiText,
EuiTabs,
EuiTab,
EuiToolTip,
EuiBadge,
RecursivePartial,
EuiBetaBadge,
EuiEmptyPrompt,
} from '@elastic/eui';
import {
ActionVariable,
AlertsFilter,
AlertsFilterTimeframe,
RuleAction,
RuleActionFrequency,
RuleActionParam,
RuleActionParams,
} from '@kbn/alerting-types';
import { isEmpty, some } from 'lodash';
import { css } from '@emotion/react';
import { SavedObjectAttribute } from '@kbn/core/types';
import { useRuleFormDispatch, useRuleFormState } from '../hooks';
import {
ActionConnector,
ActionTypeModel,
RuleFormParamsErrors,
RuleTypeWithDescription,
} from '../../common/types';
import { getAvailableActionVariables } from '../../action_variables';
import { validateAction, validateParamsForWarnings } from '../validation';
import { RuleActionsSettings } from './rule_actions_settings';
import { getSelectedActionGroup } from '../utils';
import { RuleActionsMessage } from './rule_actions_message';
import {
ACTION_ERROR_TOOLTIP,
ACTION_UNABLE_TO_LOAD_CONNECTOR_DESCRIPTION,
ACTION_UNABLE_TO_LOAD_CONNECTOR_TITLE,
ACTION_WARNING_TITLE,
TECH_PREVIEW_DESCRIPTION,
TECH_PREVIEW_LABEL,
} from '../translations';
const SUMMARY_GROUP_TITLE = i18n.translate('alertsUIShared.ruleActionsItem.summaryGroupTitle', {
defaultMessage: 'Summary of alerts',
});
const RUN_WHEN_GROUP_TITLE = (groupName: string) =>
i18n.translate('alertsUIShared.ruleActionsItem.runWhenGroupTitle', {
defaultMessage: 'Run when {groupName}',
values: {
groupName,
},
});
const ACTION_TITLE = (connector: ActionConnector) =>
i18n.translate('alertsUIShared.ruleActionsItem.existingAlertActionTypeEditTitle', {
defaultMessage: '{actionConnectorName}',
values: {
actionConnectorName: `${connector.name} ${
connector.isPreconfigured ? '(preconfigured)' : ''
}`,
},
});
const getDefaultParams = ({
group,
ruleType,
actionTypeModel,
}: {
group: string;
actionTypeModel: ActionTypeModel;
ruleType: RuleTypeWithDescription;
}) => {
if (group === ruleType.recoveryActionGroup.id) {
return actionTypeModel.defaultRecoveredActionParams;
} else {
return actionTypeModel.defaultActionParams;
}
};
export interface RuleActionsItemProps {
action: RuleAction;
index: number;
producerId: string;
}
type ParamsType = RecursivePartial<any>;
const MESSAGES_TAB = 'messages';
const SETTINGS_TAB = 'settings';
export const RuleActionsItem = (props: RuleActionsItemProps) => {
const { action, index, producerId } = props;
const {
plugins: { actionTypeRegistry, http },
actionsParamsErrors = {},
selectedRuleType,
selectedRuleTypeModel,
connectors,
connectorTypes,
aadTemplateFields,
} = useRuleFormState();
const [tab, setTab] = useState<string>(MESSAGES_TAB);
const subdued = useEuiBackgroundColor('subdued');
const plain = useEuiBackgroundColor('plain');
const { euiTheme } = useEuiTheme();
const [availableActionVariables, setAvailableActionVariables] = useState<ActionVariable[]>(() => {
if (!selectedRuleType.actionVariables) {
return [];
}
const selectedActionGroup = getSelectedActionGroup({
group: action.group,
ruleType: selectedRuleType,
ruleTypeModel: selectedRuleTypeModel,
});
return getAvailableActionVariables(
selectedRuleType.actionVariables,
// TODO: this is always undefined for now, might need to make this a prop later on
undefined,
selectedActionGroup,
!!action.frequency?.summary
);
});
const [useDefaultMessage, setUseDefaultMessage] = useState(false);
const [storedActionParamsForAadToggle, setStoredActionParamsForAadToggle] = useState<
Record<string, SavedObjectAttribute>
>({});
const [warning, setWarning] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(true);
const dispatch = useRuleFormDispatch();
const actionTypeModel = actionTypeRegistry.get(action.actionTypeId);
const actionType = connectorTypes.find(({ id }) => id === action.actionTypeId);
const connector = connectors.find(({ id }) => id === action.id);
const showActionGroupErrorIcon = useMemo(() => {
const actionParamsError = actionsParamsErrors[action.uuid!] || {};
return !isOpen && some(actionParamsError, (error) => !isEmpty(error));
}, [isOpen, action, actionsParamsErrors]);
const selectedActionGroup = getSelectedActionGroup({
group: action.group,
ruleType: selectedRuleType,
ruleTypeModel: selectedRuleTypeModel,
});
const templateFields = action.useAlertDataForTemplate
? aadTemplateFields
: availableActionVariables;
const onDelete = (id: string) => {
dispatch({ type: 'removeAction', payload: { uuid: id } });
};
const validateActionBase = useCallback(
(newAction: RuleAction) => {
const errors = validateAction({ action: newAction });
dispatch({
type: 'setActionError',
payload: {
uuid: newAction.uuid!,
errors,
},
});
},
[dispatch]
);
const validateActionParams = useCallback(
async (params: RuleActionParam) => {
const res: { errors: RuleFormParamsErrors } = await actionTypeRegistry
.get(action.actionTypeId)
?.validateParams(params);
dispatch({
type: 'setActionParamsError',
payload: {
uuid: action.uuid!,
errors: res.errors,
},
});
},
[actionTypeRegistry, action, dispatch]
);
const onStoredActionParamsChange = useCallback(
(
aadParams: Record<string, SavedObjectAttribute>,
params: Record<string, SavedObjectAttribute>
) => {
if (isEmpty(aadParams) && action.params.subAction) {
setStoredActionParamsForAadToggle(params);
} else {
setStoredActionParamsForAadToggle(aadParams);
}
},
[action]
);
const onAvailableActionVariablesChange = useCallback(
({ actionGroup, summary: isSummaryAction }: { actionGroup: string; summary?: boolean }) => {
const messageVariables = selectedRuleType.actionVariables;
if (!messageVariables) {
setAvailableActionVariables([]);
return;
}
const newSelectedActionGroup = getSelectedActionGroup({
group: actionGroup,
ruleType: selectedRuleType,
ruleTypeModel: selectedRuleTypeModel,
});
setAvailableActionVariables(
getAvailableActionVariables(
messageVariables,
undefined,
newSelectedActionGroup,
!!isSummaryAction
)
);
},
[selectedRuleType, selectedRuleTypeModel]
);
const setDefaultParams = useCallback(
(actionGroup: string) => {
const defaultParams = getDefaultParams({
group: actionGroup,
ruleType: selectedRuleType,
actionTypeModel,
});
if (!defaultParams) {
return;
}
const newDefaultParams: ParamsType = {};
const defaultAADParams: ParamsType = {};
for (const [key, paramValue] of Object.entries(defaultParams)) {
newDefaultParams[key] = paramValue;
// Collects AAD params by checking if the value is {x}.{y}
if (typeof paramValue !== 'string' || !paramValue.match(/{{.*?}}/g)) {
defaultAADParams[key] = paramValue;
}
}
const newParams = {
...action.params,
...newDefaultParams,
};
dispatch({
type: 'setActionParams',
payload: {
uuid: action.uuid!,
value: newParams,
},
});
validateActionParams(newParams);
onStoredActionParamsChange(defaultAADParams, newParams);
},
[
action,
dispatch,
validateActionParams,
selectedRuleType,
actionTypeModel,
onStoredActionParamsChange,
]
);
const onDefaultParamsChange = useCallback(
(actionGroup: string, summary?: boolean) => {
onAvailableActionVariablesChange({
actionGroup,
summary,
});
setDefaultParams(actionGroup);
},
[onAvailableActionVariablesChange, setDefaultParams]
);
const onParamsChange = useCallback(
(key: string, value: RuleActionParam) => {
const newParams = {
...action.params,
[key]: value,
};
dispatch({
type: 'setActionParams',
payload: {
uuid: action.uuid!,
value: newParams,
},
});
setWarning(
validateParamsForWarnings({
value,
publicBaseUrl: http.basePath.publicBaseUrl,
actionVariables: availableActionVariables,
})
);
validateActionParams(newParams);
onStoredActionParamsChange(storedActionParamsForAadToggle, newParams);
},
[
http,
action,
availableActionVariables,
dispatch,
validateActionParams,
onStoredActionParamsChange,
storedActionParamsForAadToggle,
]
);
const onNotifyWhenChange = useCallback(
(frequency: RuleActionFrequency) => {
dispatch({
type: 'setActionProperty',
payload: {
uuid: action.uuid!,
key: 'frequency',
value: frequency,
},
});
if (frequency.summary !== action.frequency?.summary) {
onDefaultParamsChange(action.group, frequency.summary);
}
},
[action, onDefaultParamsChange, dispatch]
);
const onActionGroupChange = useCallback(
(group: string) => {
dispatch({
type: 'setActionProperty',
payload: {
uuid: action.uuid!,
key: 'group',
value: group,
},
});
onDefaultParamsChange(group, action.frequency?.summary);
},
[action, onDefaultParamsChange, dispatch]
);
const onAlertsFilterChange = useCallback(
(query?: AlertsFilter['query']) => {
const newAlertsFilter = {
...action.alertsFilter,
query,
};
const newAction = {
...action,
alertsFilter: newAlertsFilter,
};
dispatch({
type: 'setActionProperty',
payload: {
uuid: action.uuid!,
key: 'alertsFilter',
value: newAlertsFilter,
},
});
validateActionBase(newAction);
},
[action, dispatch, validateActionBase]
);
const onTimeframeChange = useCallback(
(timeframe?: AlertsFilterTimeframe) => {
dispatch({
type: 'setActionProperty',
payload: {
uuid: action.uuid!,
key: 'alertsFilter',
value: {
...action.alertsFilter,
timeframe,
},
},
});
},
[action, dispatch]
);
const onUseAadTemplateFieldsChange = useCallback(() => {
dispatch({
type: 'setActionProperty',
payload: {
uuid: action.uuid!,
key: 'useAlertDataForTemplate',
value: !!!action.useAlertDataForTemplate,
},
});
const currentActionParams = { ...action.params };
const newActionParams: RuleActionParams = {};
for (const key of Object.keys(currentActionParams)) {
newActionParams[key] = storedActionParamsForAadToggle[key] ?? '';
}
dispatch({
type: 'setActionParams',
payload: {
uuid: action.uuid!,
value: newActionParams,
},
});
setStoredActionParamsForAadToggle(currentActionParams);
}, [action, storedActionParamsForAadToggle, dispatch]);
const accordionContent = useMemo(() => {
if (!connector) {
return null;
}
return (
<EuiFlexGroup
direction="column"
style={{
padding: euiTheme.size.l,
backgroundColor: plain,
borderRadius: euiTheme.border.radius.medium,
}}
>
<EuiFlexItem>
<EuiTabs>
<EuiTab isSelected={tab === MESSAGES_TAB} onClick={() => setTab(MESSAGES_TAB)}>
Message
</EuiTab>
<EuiTab isSelected={tab === SETTINGS_TAB} onClick={() => setTab(SETTINGS_TAB)}>
Settings
</EuiTab>
</EuiTabs>
</EuiFlexItem>
<EuiFlexItem>
{tab === MESSAGES_TAB && (
<RuleActionsMessage
action={action}
index={index}
useDefaultMessage={useDefaultMessage}
connector={connector}
producerId={producerId}
warning={warning}
templateFields={templateFields}
onParamsChange={onParamsChange}
onUseAadTemplateFieldsChange={onUseAadTemplateFieldsChange}
/>
)}
{tab === SETTINGS_TAB && (
<RuleActionsSettings
action={action}
producerId={producerId}
onUseDefaultMessageChange={() => setUseDefaultMessage(true)}
onNotifyWhenChange={onNotifyWhenChange}
onActionGroupChange={onActionGroupChange}
onAlertsFilterChange={onAlertsFilterChange}
onTimeframeChange={onTimeframeChange}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
}, [
action,
connector,
producerId,
euiTheme,
plain,
index,
tab,
templateFields,
useDefaultMessage,
warning,
onNotifyWhenChange,
onActionGroupChange,
onAlertsFilterChange,
onTimeframeChange,
onParamsChange,
onUseAadTemplateFieldsChange,
]);
const noConnectorContent = useMemo(() => {
return (
<EuiEmptyPrompt
iconType="magnifyWithExclamation"
title={<h2>{ACTION_UNABLE_TO_LOAD_CONNECTOR_TITLE}</h2>}
body={ACTION_UNABLE_TO_LOAD_CONNECTOR_DESCRIPTION}
/>
);
}, []);
const accordionIcon = useMemo(() => {
if (!connector) {
return (
<EuiFlexItem grow={false}>
<EuiToolTip content={ACTION_UNABLE_TO_LOAD_CONNECTOR_TITLE}>
<EuiIcon
data-test-subj="action-group-error-icon"
type="warning"
color="danger"
size="l"
/>
</EuiToolTip>
</EuiFlexItem>
);
}
return (
<EuiFlexItem grow={false}>
{showActionGroupErrorIcon ? (
<EuiToolTip content={ACTION_ERROR_TOOLTIP}>
<EuiIcon
data-test-subj="action-group-error-icon"
type="warning"
color="danger"
size="l"
/>
</EuiToolTip>
) : (
<Suspense fallback={null}>
<EuiIcon size="l" type={actionTypeModel.iconClass} />
</Suspense>
)}
</EuiFlexItem>
);
}, [connector, showActionGroupErrorIcon, actionTypeModel]);
const connectorTitle = useMemo(() => {
const title = connector ? ACTION_TITLE(connector) : actionTypeModel.actionTypeTitle;
return (
<EuiFlexItem grow={false}>
<EuiText>{title}</EuiText>
</EuiFlexItem>
);
}, [connector, actionTypeModel]);
const actionTypeTitle = useMemo(() => {
if (!connector || !actionType) {
return null;
}
return (
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
<strong>{actionType.name}</strong>
</EuiText>
</EuiFlexItem>
);
}, [connector, actionType]);
const runWhenTitle = useMemo(() => {
if (!connector) {
return null;
}
if (isOpen) {
return null;
}
if (selectedActionGroup || action.frequency?.summary) {
return (
<EuiFlexItem grow={false}>
<EuiBadge iconType="clock">
{action.frequency?.summary
? SUMMARY_GROUP_TITLE
: RUN_WHEN_GROUP_TITLE(selectedActionGroup!.name.toLocaleLowerCase())}
</EuiBadge>
</EuiFlexItem>
);
}
}, [connector, isOpen, selectedActionGroup, action]);
const warningIcon = useMemo(() => {
if (!connector) {
return null;
}
if (isOpen) {
return null;
}
if (warning) {
return (
<EuiFlexItem grow={false}>
<EuiBadge data-test-subj="warning-badge" iconType="warning" color="warning">
{ACTION_WARNING_TITLE}
</EuiBadge>
</EuiFlexItem>
);
}
}, [connector, isOpen, warning]);
return (
<EuiAccordion
initialIsOpen
data-test-subj="ruleActionsItem"
borders="all"
style={{
backgroundColor: subdued,
borderRadius: euiTheme.border.radius.medium,
}}
id={action.id}
onToggle={setIsOpen}
buttonProps={{
style: {
width: '100%',
},
}}
arrowProps={{
css: css`
margin-left: ${euiTheme.size.m};
`,
}}
extraAction={
<EuiButtonIcon
data-test-subj="ruleActionsItemDeleteButton"
style={{
marginRight: euiTheme.size.l,
}}
aria-label={i18n.translate(
'alertsUIShared.ruleActionsSystemActionsItem.deleteActionAriaLabel',
{
defaultMessage: 'delete action',
}
)}
iconType="trash"
color="danger"
onClick={() => onDelete(action.uuid!)}
/>
}
buttonContentClassName="eui-fullWidth"
buttonContent={
<EuiPanel color="subdued" paddingSize="m">
<EuiFlexGroup alignItems="center" responsive={false}>
{accordionIcon}
{connectorTitle}
{actionTypeTitle}
{runWhenTitle}
{warningIcon}
{actionTypeModel.isExperimental && (
<EuiFlexItem grow={false}>
<EuiBetaBadge
alignment="middle"
data-test-subj="ruleActionsSystemActionsItemBetaBadge"
label={TECH_PREVIEW_LABEL}
tooltipContent={TECH_PREVIEW_DESCRIPTION}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiPanel>
}
>
{connector && accordionContent}
{!connector && noConnectorContent}
</EuiAccordion>
);
};

View file

@ -0,0 +1,353 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { lazy } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { RuleActionsMessage } from './rule_actions_message';
import { RuleType } from '@kbn/alerting-types';
import { ActionParamsProps, ActionTypeModel, RuleTypeModel } from '../../common';
import { TypeRegistry } from '../../common/type_registry';
import {
getAction,
getActionType,
getActionTypeModel,
getConnector,
getSystemAction,
} from '../../common/test_utils/actions_test_utils';
import userEvent from '@testing-library/user-event';
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
}));
const { useRuleFormState } = jest.requireMock('../hooks');
const ruleType = {
id: '.es-query',
name: 'Test',
actionGroups: [
{
id: 'testActionGroup',
name: 'Test Action Group',
},
{
id: 'recovered',
name: 'Recovered',
},
],
defaultActionGroupId: 'testActionGroup',
minimumLicenseRequired: 'basic',
recoveryActionGroup: {
id: 'recovered',
},
producer: 'logs',
authorizedConsumers: {
alerting: { read: true, all: true },
test: { read: true, all: true },
stackAlerts: { read: true, all: true },
logs: { read: true, all: true },
},
actionVariables: {
params: [],
state: [],
},
enabledInLicense: true,
} as unknown as RuleType;
const ruleModel: RuleTypeModel = {
id: '.es-query',
description: 'Sample rule type model',
iconClass: 'sampleIconClass',
documentationUrl: 'testurl',
validate: (params, isServerless) => ({ errors: {} }),
ruleParamsExpression: () => <div>Expression</div>,
defaultSummaryMessage: 'Sample default summary message',
defaultActionMessage: 'Sample default action message',
defaultRecoveryMessage: 'Sample default recovery message',
requiresAppContext: false,
};
const mockOnParamsChange = jest.fn();
const mockedActionParamsFields = lazy(async () => ({
default({ defaultMessage, selectedActionGroupId, errors, editAction }: ActionParamsProps<any>) {
return (
<div data-test-subj="actionParamsFieldMock">
{defaultMessage && <div data-test-subj="defaultMessageMock">{defaultMessage}</div>}
{selectedActionGroupId && (
<div data-test-subj="selectedActionGroupIdMock">{selectedActionGroupId}</div>
)}
<div data-test-subj="errorsMock">{JSON.stringify(errors)}</div>
<button
data-test-subj="editActionMock"
onClick={() => editAction('paramsKey', { paramsKey: 'paramsValue' }, 1)}
>
editAction
</button>
</div>
);
},
}));
describe('RuleActionsMessage', () => {
beforeEach(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(
getActionTypeModel('1', {
actionParamsFields: mockedActionParamsFields,
})
);
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
},
actionsParamsErrors: {},
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
connectors: [getConnector('1')],
connectorTypes: [getActionType('1')],
aadTemplateFields: [],
});
});
afterEach(() => {
jest.resetAllMocks();
});
test('should render correctly', async () => {
render(
<RuleActionsMessage
action={getAction('1', { actionTypeId: 'actionTypeModel-1' })}
index={1}
templateFields={[]}
useDefaultMessage
connector={getConnector('1')}
producerId="stackAlerts"
onParamsChange={mockOnParamsChange}
/>
);
await waitFor(() => {
return expect(screen.getByTestId('actionParamsFieldMock')).toBeInTheDocument();
});
expect(screen.getByTestId('ruleActionsMessage')).toBeInTheDocument();
});
test('should display warning if it exists', async () => {
render(
<RuleActionsMessage
action={getAction('1', { actionTypeId: 'actionTypeModel-1' })}
index={1}
templateFields={[]}
useDefaultMessage
connector={getConnector('1')}
producerId="stackAlerts"
warning="test warning"
onParamsChange={mockOnParamsChange}
/>
);
await waitFor(() => {
return expect(screen.getByTestId('actionParamsFieldMock')).toBeInTheDocument();
});
expect(screen.getByText('test warning')).toBeInTheDocument();
});
test('should render default action message for normal actions', async () => {
render(
<RuleActionsMessage
action={getAction('1', { actionTypeId: 'actionTypeModel-1' })}
index={1}
templateFields={[]}
useDefaultMessage
connector={getConnector('1')}
producerId="stackAlerts"
warning="test warning"
onParamsChange={mockOnParamsChange}
/>
);
await waitFor(() => {
return expect(screen.getByTestId('actionParamsFieldMock')).toBeInTheDocument();
});
expect(screen.getByText('Sample default action message')).toBeInTheDocument();
});
test('should render default summary message for actions with summaries', async () => {
render(
<RuleActionsMessage
action={getAction('1', {
actionTypeId: 'actionTypeModel-1',
frequency: {
summary: true,
notifyWhen: 'onActionGroupChange',
throttle: '5m',
},
})}
index={1}
templateFields={[]}
useDefaultMessage
connector={getConnector('1')}
producerId="stackAlerts"
warning="test warning"
onParamsChange={mockOnParamsChange}
/>
);
await waitFor(() => {
return expect(screen.getByTestId('actionParamsFieldMock')).toBeInTheDocument();
});
expect(screen.getByText('Sample default summary message')).toBeInTheDocument();
});
test('should render default recovery message for action recovery group', async () => {
render(
<RuleActionsMessage
action={getAction('1', {
actionTypeId: 'actionTypeModel-1',
group: 'recovered',
})}
index={1}
templateFields={[]}
useDefaultMessage
connector={getConnector('1')}
producerId="stackAlerts"
warning="test warning"
onParamsChange={mockOnParamsChange}
/>
);
await waitFor(() => {
return expect(screen.getByTestId('actionParamsFieldMock')).toBeInTheDocument();
});
expect(screen.getByText('Sample default recovery message')).toBeInTheDocument();
});
test('should render default summary message for system actions', async () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(
getActionTypeModel('1', {
actionParamsFields: mockedActionParamsFields,
})
);
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
},
actionsParamsErrors: {},
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
connectors: [getConnector('1')],
connectorTypes: [
getActionType('1', {
isSystemActionType: true,
id: 'actionTypeModel-1',
}),
],
aadTemplateFields: [],
});
render(
<RuleActionsMessage
action={getSystemAction('1', {
actionTypeId: 'actionTypeModel-1',
})}
index={1}
templateFields={[]}
useDefaultMessage
connector={getConnector('1')}
producerId="stackAlerts"
warning="test warning"
onParamsChange={mockOnParamsChange}
/>
);
await waitFor(() => {
return expect(screen.getByTestId('actionParamsFieldMock')).toBeInTheDocument();
});
expect(screen.getByText('Sample default summary message')).toBeInTheDocument();
expect(screen.queryByTestId('selectedActionGroupIdMock')).not.toBeInTheDocument();
});
test('should render action param errors if it exists', async () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(
getActionTypeModel('1', {
actionParamsFields: mockedActionParamsFields,
})
);
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
},
actionsParamsErrors: {
'uuid-action-1': { paramsKey: 'error' },
},
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
connectors: [getConnector('1')],
connectorTypes: [getActionType('1')],
aadTemplateFields: [],
});
render(
<RuleActionsMessage
action={getAction('1', { actionTypeId: 'actionTypeModel-1' })}
index={1}
templateFields={[]}
useDefaultMessage
connector={getConnector('1')}
producerId="stackAlerts"
warning="test warning"
onParamsChange={mockOnParamsChange}
/>
);
await waitFor(() => {
return expect(screen.getByTestId('actionParamsFieldMock')).toBeInTheDocument();
});
expect(screen.getByText(JSON.stringify({ paramsKey: 'error' }))).toBeInTheDocument();
});
test('should call onParamsChange if the params are edited', async () => {
render(
<RuleActionsMessage
action={getAction('1', { actionTypeId: 'actionTypeModel-1' })}
index={1}
templateFields={[]}
useDefaultMessage
connector={getConnector('1')}
producerId="stackAlerts"
warning="test warning"
onParamsChange={mockOnParamsChange}
/>
);
await waitFor(() => {
return expect(screen.getByTestId('actionParamsFieldMock')).toBeInTheDocument();
});
await userEvent.click(screen.getByTestId('editActionMock'));
expect(mockOnParamsChange).toHaveBeenLastCalledWith(
'paramsKey',
{ paramsKey: 'paramsValue' },
1
);
});
});

View file

@ -0,0 +1,138 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { Suspense, useMemo } from 'react';
import {
EuiCallOut,
EuiErrorBoundary,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiSwitch,
} from '@elastic/eui';
import { ActionVariable, RuleActionParam } from '@kbn/alerting-types';
import { useRuleFormState } from '../hooks';
import { ActionConnector, ActionConnectorMode, RuleAction, RuleUiAction } from '../../common';
import { getSelectedActionGroup } from '../utils';
import { ACTION_USE_AAD_TEMPLATE_FIELDS_LABEL } from '../translations';
export interface RuleActionsMessageProps {
action: RuleUiAction;
index: number;
templateFields: ActionVariable[];
useDefaultMessage: boolean;
connector: ActionConnector;
producerId: string;
warning?: string | null;
onParamsChange: (key: string, value: RuleActionParam) => void;
onUseAadTemplateFieldsChange?: () => void;
}
export const RuleActionsMessage = (props: RuleActionsMessageProps) => {
const {
action,
index,
templateFields,
useDefaultMessage,
connector,
producerId,
warning,
onParamsChange,
onUseAadTemplateFieldsChange,
} = props;
const {
plugins: { actionTypeRegistry },
actionsParamsErrors = {},
selectedRuleType,
selectedRuleTypeModel,
connectorTypes,
showMustacheAutocompleteSwitch,
} = useRuleFormState();
const actionTypeModel = actionTypeRegistry.get(action.actionTypeId);
const ParamsFieldsComponent = actionTypeModel.actionParamsFields;
const actionsParamsError = actionsParamsErrors[action.uuid!] || {};
const isSystemAction = useMemo(() => {
return connectorTypes.some((actionType) => {
return actionType.id === action.actionTypeId && actionType.isSystemActionType;
});
}, [action, connectorTypes]);
const selectedActionGroup = useMemo(() => {
if (isSystemAction) {
return;
}
return getSelectedActionGroup({
group: (action as RuleAction).group,
ruleType: selectedRuleType,
ruleTypeModel: selectedRuleTypeModel,
});
}, [isSystemAction, action, selectedRuleType, selectedRuleTypeModel]);
const defaultMessage = useMemo(() => {
if (isSystemAction) {
return selectedRuleTypeModel.defaultSummaryMessage;
}
// if action is a summary action, show the default summary message
return (action as RuleAction).frequency?.summary
? selectedRuleTypeModel.defaultSummaryMessage
: selectedActionGroup?.defaultActionMessage ?? selectedRuleTypeModel.defaultActionMessage;
}, [isSystemAction, action, selectedRuleTypeModel, selectedActionGroup]);
if (!ParamsFieldsComponent) {
return null;
}
return (
<EuiErrorBoundary>
<EuiFlexGroup direction="column" data-test-subj="ruleActionsMessage">
{showMustacheAutocompleteSwitch && onUseAadTemplateFieldsChange && (
<EuiFlexItem>
<EuiSwitch
label={ACTION_USE_AAD_TEMPLATE_FIELDS_LABEL}
checked={(action as RuleAction).useAlertDataForTemplate || false}
onChange={onUseAadTemplateFieldsChange}
data-test-subj="ruleActionsMessageUseAadTemplateFieldsSwitch"
/>
</EuiFlexItem>
)}
<EuiFlexItem>
<Suspense fallback={null}>
<ParamsFieldsComponent
actionParams={action.params as any}
errors={actionsParamsError}
index={index}
selectedActionGroupId={selectedActionGroup?.id}
editAction={onParamsChange}
messageVariables={templateFields}
defaultMessage={defaultMessage}
useDefaultMessage={useDefaultMessage}
actionConnector={connector}
executionMode={ActionConnectorMode.ActionForm}
ruleTypeId={selectedRuleType.id}
producerId={producerId}
/>
{warning ? (
<>
<EuiSpacer size="s" />
<EuiCallOut size="s" color="warning" title={warning} />
</>
) : null}
</Suspense>
</EuiFlexItem>
</EuiFlexGroup>
</EuiErrorBoundary>
);
};

View file

@ -25,9 +25,8 @@ describe('ruleActionsNotifyWhen', () => {
frequency={frequency}
throttle={frequency.throttle ? Number(frequency.throttle[0]) : null}
throttleUnit={frequency.throttle ? frequency.throttle[1] : 'm'}
onNotifyWhenChange={jest.fn()}
onThrottleChange={jest.fn()}
onSummaryChange={jest.fn()}
onChange={jest.fn()}
onUseDefaultMessage={jest.fn()}
hasAlertsMappings={hasAlertsMappings}
/>
);

View file

@ -7,9 +7,15 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo } from 'react';
import { css } from '@emotion/css'; // We can't use @emotion/react - this component gets used with plugins that use both styled-components and Emotion
import { i18n } from '@kbn/i18n';
import {
RuleNotifyWhenType,
RuleNotifyWhen,
RuleAction,
RuleActionFrequency,
} from '@kbn/alerting-types';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlexGroup,
@ -29,10 +35,15 @@ import {
} from '@elastic/eui';
import { some, filter, map } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import { RuleNotifyWhenType, RuleNotifyWhen } from '@kbn/alerting-types';
import { DEFAULT_FREQUENCY } from '../constants';
import { getTimeOptions } from '../utils';
import { RuleAction } from '../../common';
const FOR_EACH_ALERT = i18n.translate('alertsUIShared.actiActionsonNotifyWhen.forEachOption', {
defaultMessage: 'For each alert',
});
const SUMMARY_OF_ALERTS = i18n.translate('alertsUIShared.actiActionsonNotifyWhen.summaryOption', {
defaultMessage: 'Summary of alerts',
});
export interface NotifyWhenSelectOptions {
isSummaryOption?: boolean;
@ -46,23 +57,26 @@ export const NOTIFY_WHEN_OPTIONS: NotifyWhenSelectOptions[] = [
isForEachAlertOption: true,
value: {
value: 'onActionGroupChange',
inputDisplay: i18n.translate('alertsUIShared.ruleForm.onActionGroupChange.display', {
defaultMessage: 'On status changes',
}),
inputDisplay: i18n.translate(
'alertsUIShared.ruleActionsNotifyWhen.onActionGroupChange.display',
{
defaultMessage: 'On status changes',
}
),
'data-test-subj': 'onActionGroupChange',
dropdownDisplay: (
<>
<strong>
<FormattedMessage
defaultMessage="On status changes"
id="alertsUIShared.ruleForm.onActionGroupChange.label"
id="alertsUIShared.ruleActionsNotifyWhen.onActionGroupChange.label"
/>
</strong>
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
defaultMessage="Actions run if the alert status changes."
id="alertsUIShared.ruleForm.onActionGroupChange.description"
id="alertsUIShared.ruleActionsNotifyWhen.onActionGroupChange.description"
/>
</p>
</EuiText>
@ -75,7 +89,7 @@ export const NOTIFY_WHEN_OPTIONS: NotifyWhenSelectOptions[] = [
isForEachAlertOption: true,
value: {
value: 'onActiveAlert',
inputDisplay: i18n.translate('alertsUIShared.ruleForm.onActiveAlert.display', {
inputDisplay: i18n.translate('alertsUIShared.ruleActionsNotifyWhen.onActiveAlert.display', {
defaultMessage: 'On check intervals',
}),
'data-test-subj': 'onActiveAlert',
@ -84,14 +98,14 @@ export const NOTIFY_WHEN_OPTIONS: NotifyWhenSelectOptions[] = [
<strong>
<FormattedMessage
defaultMessage="On check intervals"
id="alertsUIShared.ruleForm.onActiveAlert.label"
id="alertsUIShared.ruleActionsNotifyWhen.onActiveAlert.label"
/>
</strong>
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
defaultMessage="Actions run if rule conditions are met."
id="alertsUIShared.ruleForm.onActiveAlert.description"
id="alertsUIShared.ruleActionsNotifyWhen.onActiveAlert.description"
/>
</p>
</EuiText>
@ -104,23 +118,26 @@ export const NOTIFY_WHEN_OPTIONS: NotifyWhenSelectOptions[] = [
isForEachAlertOption: true,
value: {
value: 'onThrottleInterval',
inputDisplay: i18n.translate('alertsUIShared.ruleForm.onThrottleInterval.display', {
defaultMessage: 'On custom action intervals',
}),
inputDisplay: i18n.translate(
'alertsUIShared.ruleActionsNotifyWhen.onThrottleInterval.display',
{
defaultMessage: 'On custom action intervals',
}
),
'data-test-subj': 'onThrottleInterval',
dropdownDisplay: (
<>
<strong>
<FormattedMessage
defaultMessage="On custom action intervals"
id="alertsUIShared.ruleForm.onThrottleInterval.label"
id="alertsUIShared.ruleActionsNotifyWhen.onThrottleInterval.label"
/>
</strong>
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
defaultMessage="Actions run if rule conditions are met."
id="alertsUIShared.ruleForm.onThrottleInterval.description"
id="alertsUIShared.ruleActionsNotifyWhen.onThrottleInterval.description"
/>
</p>
</EuiText>
@ -130,18 +147,16 @@ export const NOTIFY_WHEN_OPTIONS: NotifyWhenSelectOptions[] = [
},
];
interface RuleActionsNotifyWhenProps {
export interface RuleActionsNotifyWhenProps {
frequency: RuleAction['frequency'];
throttle: number | null;
throttleUnit: string;
onNotifyWhenChange: (notifyWhen: RuleNotifyWhenType) => void;
onThrottleChange: (throttle: number | null, throttleUnit: string) => void;
onSummaryChange: (summary: boolean) => void;
hasAlertsMappings?: boolean;
showMinimumThrottleWarning?: boolean;
showMinimumThrottleUnitWarning?: boolean;
notifyWhenSelectOptions?: NotifyWhenSelectOptions[];
defaultNotifyWhenValue?: RuleNotifyWhenType;
onChange: (frequency: RuleActionFrequency) => void;
onUseDefaultMessage: () => void;
}
export const RuleActionsNotifyWhen = ({
@ -149,49 +164,26 @@ export const RuleActionsNotifyWhen = ({
frequency = DEFAULT_FREQUENCY,
throttle,
throttleUnit,
onNotifyWhenChange,
onThrottleChange,
onSummaryChange,
showMinimumThrottleWarning,
showMinimumThrottleUnitWarning,
notifyWhenSelectOptions = NOTIFY_WHEN_OPTIONS,
defaultNotifyWhenValue = DEFAULT_FREQUENCY.notifyWhen,
onChange,
onUseDefaultMessage,
}: RuleActionsNotifyWhenProps) => {
const [showCustomThrottleOpts, setShowCustomThrottleOpts] = useState<boolean>(false);
const [notifyWhenValue, setNotifyWhenValue] =
useState<RuleNotifyWhenType>(defaultNotifyWhenValue);
const [summaryMenuOpen, setSummaryMenuOpen] = useState(false);
useEffect(() => {
if (frequency.notifyWhen) {
setNotifyWhenValue(frequency.notifyWhen);
} else {
// If 'notifyWhen' is not set, derive value from existence of throttle value
setNotifyWhenValue(frequency.throttle ? RuleNotifyWhen.THROTTLE : RuleNotifyWhen.ACTIVE);
}
}, [frequency]);
useEffect(() => {
setShowCustomThrottleOpts(notifyWhenValue === RuleNotifyWhen.THROTTLE);
}, [notifyWhenValue]);
const showCustomThrottleOpts = frequency?.notifyWhen === RuleNotifyWhen.THROTTLE;
const onNotifyWhenValueChange = useCallback(
(newValue: RuleNotifyWhenType) => {
onNotifyWhenChange(newValue);
setNotifyWhenValue(newValue);
// Calling onNotifyWhenChange and onThrottleChange at the same time interferes with the React state lifecycle
// so wait for onNotifyWhenChange to process before calling onThrottleChange
setTimeout(
() =>
onThrottleChange(
newValue === RuleNotifyWhen.THROTTLE ? throttle ?? 1 : null,
throttleUnit
),
100
);
const newThrottle = newValue === RuleNotifyWhen.THROTTLE ? throttle ?? 1 : null;
onChange({
...frequency,
notifyWhen: newValue,
throttle: newThrottle ? `${newThrottle}${throttleUnit}` : null,
});
},
[onNotifyWhenChange, onThrottleChange, throttle, throttleUnit]
[onChange, throttle, throttleUnit, frequency]
);
const summaryNotifyWhenOptions = useMemo(
@ -234,13 +226,23 @@ export const RuleActionsNotifyWhen = ({
const selectSummaryOption = useCallback(
(summary: boolean) => {
onSummaryChange(summary);
onChange({
summary,
notifyWhen: selectedOptionDoesNotExist(summary)
? getDefaultNotifyWhenOption(summary)
: frequency.notifyWhen,
throttle: frequency.throttle,
});
onUseDefaultMessage();
setSummaryMenuOpen(false);
if (selectedOptionDoesNotExist(summary)) {
onNotifyWhenChange(getDefaultNotifyWhenOption(summary));
}
},
[onSummaryChange, selectedOptionDoesNotExist, onNotifyWhenChange, getDefaultNotifyWhenOption]
[
frequency,
onUseDefaultMessage,
selectedOptionDoesNotExist,
getDefaultNotifyWhenOption,
onChange,
]
);
const { euiTheme } = useEuiTheme();
@ -320,7 +322,7 @@ export const RuleActionsNotifyWhen = ({
prepend={hasAlertsMappings ? summaryOrPerRuleSelect : <></>}
data-test-subj="notifyWhenSelect"
options={notifyWhenOptions}
valueOfSelected={notifyWhenValue}
valueOfSelected={frequency.notifyWhen}
onChange={onNotifyWhenValueChange}
/>
{showCustomThrottleOpts && (
@ -328,7 +330,6 @@ export const RuleActionsNotifyWhen = ({
<EuiSpacer size="s" />
<EuiFormRow fullWidth>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem style={{ flexGrow: 0.1 }} />
<EuiFlexItem grow={2}>
<EuiFieldNumber
isInvalid={showMinimumThrottleWarning}
@ -349,7 +350,10 @@ export const RuleActionsNotifyWhen = ({
map((value) => parseInt(value, 10)),
filter((value) => !isNaN(value)),
map((value) => {
onThrottleChange(value, throttleUnit);
onChange({
...frequency,
throttle: `${value}${throttleUnit}`,
});
})
);
}}
@ -362,7 +366,10 @@ export const RuleActionsNotifyWhen = ({
value={throttleUnit}
options={getTimeOptions(throttle ?? 1)}
onChange={(e) => {
onThrottleChange(throttle, e.target.value);
onChange({
...frequency,
throttle: `${throttle}${e.target.value}`,
});
}}
/>
</EuiFlexItem>
@ -389,10 +396,3 @@ export const RuleActionsNotifyWhen = ({
</EuiFormRow>
);
};
const FOR_EACH_ALERT = i18n.translate('alertsUIShared.ruleActionsNotifyWhen.forEachOption', {
defaultMessage: 'For each alert',
});
const SUMMARY_OF_ALERTS = i18n.translate('alertsUIShared.ruleActionsNotifyWhen.summaryOption', {
defaultMessage: 'Summary of alerts',
});

View file

@ -0,0 +1,432 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RuleActionsSettings } from './rule_actions_settings';
import { getAction } from '../../common/test_utils/actions_test_utils';
import { RuleTypeModel } from '../../common';
import { RuleType } from '@kbn/alerting-types';
import userEvent from '@testing-library/user-event';
import type { RuleActionsNotifyWhenProps } from './rule_actions_notify_when';
import type { RuleActionsAlertsFilterProps } from './rule_actions_alerts_filter';
import type { RuleActionsAlertsFilterTimeframeProps } from './rule_actions_alerts_filter_timeframe';
jest.mock('./rule_actions_notify_when', () => ({
RuleActionsNotifyWhen: ({
showMinimumThrottleUnitWarning,
showMinimumThrottleWarning,
onChange,
onUseDefaultMessage,
}: RuleActionsNotifyWhenProps) => (
<div>
RuleActionsNotifyWhen
{showMinimumThrottleUnitWarning && <div>showMinimumThrottleUnitWarning</div>}
{showMinimumThrottleWarning && <div>showMinimumThrottleWarning</div>}
<button
onClick={() =>
onChange({
summary: true,
notifyWhen: 'onActionGroupChange',
throttle: '5m',
})
}
>
RuleActionsNotifyWhenOnChange
</button>
<button onClick={onUseDefaultMessage}>RuleActionsNotifyWhenOnUseDefaultMessage</button>
</div>
),
}));
jest.mock('./rule_actions_alerts_filter', () => ({
RuleActionsAlertsFilter: ({ onChange }: RuleActionsAlertsFilterProps) => (
<div>
RuleActionsAlertsFilter
<button
onClick={() =>
onChange({
kql: 'test',
filters: [],
})
}
>
RuleActionsAlertsFilterButton
</button>
</div>
),
}));
jest.mock('./rule_actions_alerts_filter_timeframe', () => ({
RuleActionsAlertsFilterTimeframe: ({ onChange }: RuleActionsAlertsFilterTimeframeProps) => (
<div>
RuleActionsAlertsFilterTimeframe
<button
onClick={() =>
onChange({
days: [1],
timezone: 'utc',
hours: {
start: 'now',
end: 'now',
},
})
}
>
RuleActionsAlertsFilterTimeframeButton
</button>
</div>
),
}));
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));
const ruleType = {
id: '.es-query',
name: 'Test',
actionGroups: [
{
id: 'testActionGroup',
name: 'Test Action Group',
},
{
id: 'recovered',
name: 'Recovered',
},
],
defaultActionGroupId: 'testActionGroup',
minimumLicenseRequired: 'basic',
recoveryActionGroup: 'recovered',
producer: 'logs',
authorizedConsumers: {
alerting: { read: true, all: true },
test: { read: true, all: true },
stackAlerts: { read: true, all: true },
logs: { read: true, all: true },
},
actionVariables: {
params: [],
state: [],
},
enabledInLicense: true,
} as unknown as RuleType;
const ruleModel: RuleTypeModel = {
id: '.es-query',
description: 'Sample rule type model',
iconClass: 'sampleIconClass',
documentationUrl: 'testurl',
validate: (params, isServerless) => ({ errors: {} }),
ruleParamsExpression: () => <div>Expression</div>,
defaultActionMessage: 'Sample default action message',
defaultRecoveryMessage: 'Sample default recovery message',
requiresAppContext: false,
};
const mockOnUseDefaultMessageChange = jest.fn();
const mockOnNotifyWhenChange = jest.fn();
const mockOnActionGroupChange = jest.fn();
const mockOnAlertsFilterChange = jest.fn();
const mockOnTimeframeChange = jest.fn();
const mockDispatch = jest.fn();
const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');
describe('ruleActionsSettings', () => {
beforeEach(() => {
useRuleFormState.mockReturnValue({
plugins: {
settings: {},
},
formData: {
consumer: 'stackAlerts',
schedule: { interval: '5m' },
},
actionErrors: {},
validConsumers: ['stackAlerts', 'logs'],
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
});
useRuleFormDispatch.mockReturnValue(mockDispatch);
});
afterEach(() => {
jest.resetAllMocks();
});
test('should render correctly', () => {
render(
<RuleActionsSettings
action={getAction('1')}
producerId="stackAlerts"
onUseDefaultMessageChange={mockOnUseDefaultMessageChange}
onNotifyWhenChange={mockOnNotifyWhenChange}
onActionGroupChange={mockOnActionGroupChange}
onAlertsFilterChange={mockOnAlertsFilterChange}
onTimeframeChange={mockOnTimeframeChange}
/>
);
expect(screen.getByTestId('ruleActionsSettings')).toBeInTheDocument();
expect(screen.getByTestId('ruleActionsSettingsSelectActionGroup')).toBeInTheDocument();
});
test('should render notify when component', () => {
render(
<RuleActionsSettings
action={getAction('1')}
producerId="stackAlerts"
onUseDefaultMessageChange={mockOnUseDefaultMessageChange}
onNotifyWhenChange={mockOnNotifyWhenChange}
onActionGroupChange={mockOnActionGroupChange}
onAlertsFilterChange={mockOnAlertsFilterChange}
onTimeframeChange={mockOnTimeframeChange}
/>
);
expect(screen.getByText('RuleActionsNotifyWhen')).toBeInTheDocument();
expect(screen.queryByText('showMinimumThrottleUnitWarning')).not.toBeInTheDocument();
expect(screen.queryByText('showMinimumThrottleWarning')).not.toBeInTheDocument();
});
test('should render show minimum throttle unit warning', () => {
useRuleFormState.mockReturnValue({
plugins: {
settings: {},
},
formData: {
consumer: 'stackAlerts',
schedule: { interval: '5h' },
},
actionErrors: {},
validConsumers: ['stackAlerts', 'logs'],
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
});
render(
<RuleActionsSettings
action={getAction('1', {
frequency: {
throttle: '5m',
summary: true,
notifyWhen: 'onActionGroupChange',
},
})}
producerId="stackAlerts"
onUseDefaultMessageChange={mockOnUseDefaultMessageChange}
onNotifyWhenChange={mockOnNotifyWhenChange}
onActionGroupChange={mockOnActionGroupChange}
onAlertsFilterChange={mockOnAlertsFilterChange}
onTimeframeChange={mockOnTimeframeChange}
/>
);
expect(screen.queryByText('showMinimumThrottleUnitWarning')).toBeInTheDocument();
expect(screen.queryByText('showMinimumThrottleWarning')).not.toBeInTheDocument();
});
test('should render show minimum throttle warning', () => {
useRuleFormState.mockReturnValue({
plugins: {
settings: {},
},
formData: {
consumer: 'stackAlerts',
schedule: { interval: '5h' },
},
actionErrors: {},
validConsumers: ['stackAlerts', 'logs'],
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
});
render(
<RuleActionsSettings
action={getAction('1', {
frequency: {
throttle: '4h',
summary: true,
notifyWhen: 'onActionGroupChange',
},
})}
producerId="stackAlerts"
onUseDefaultMessageChange={mockOnUseDefaultMessageChange}
onNotifyWhenChange={mockOnNotifyWhenChange}
onActionGroupChange={mockOnActionGroupChange}
onAlertsFilterChange={mockOnAlertsFilterChange}
onTimeframeChange={mockOnTimeframeChange}
/>
);
expect(screen.queryByText('showMinimumThrottleWarning')).toBeInTheDocument();
expect(screen.queryByText('showMinimumThrottleUnitWarning')).not.toBeInTheDocument();
});
test('should call notifyWhen component event handlers with the correct parameters', async () => {
render(
<RuleActionsSettings
action={getAction('1')}
producerId="stackAlerts"
onUseDefaultMessageChange={mockOnUseDefaultMessageChange}
onNotifyWhenChange={mockOnNotifyWhenChange}
onActionGroupChange={mockOnActionGroupChange}
onAlertsFilterChange={mockOnAlertsFilterChange}
onTimeframeChange={mockOnTimeframeChange}
/>
);
await userEvent.click(screen.getByText('RuleActionsNotifyWhenOnChange'));
expect(mockOnNotifyWhenChange).toHaveBeenLastCalledWith({
notifyWhen: 'onActionGroupChange',
summary: true,
throttle: '5m',
});
await userEvent.click(screen.getByText('RuleActionsNotifyWhenOnUseDefaultMessage'));
expect(mockOnUseDefaultMessageChange).toHaveBeenCalled();
});
test('should allow for selecting of action groups', async () => {
render(
<RuleActionsSettings
action={getAction('1')}
producerId="stackAlerts"
onUseDefaultMessageChange={mockOnUseDefaultMessageChange}
onNotifyWhenChange={mockOnNotifyWhenChange}
onActionGroupChange={mockOnActionGroupChange}
onAlertsFilterChange={mockOnAlertsFilterChange}
onTimeframeChange={mockOnTimeframeChange}
/>
);
await userEvent.click(screen.getByTestId('ruleActionsSettingsSelectActionGroup'));
await userEvent.click(screen.getByTestId('addNewActionConnectorActionGroup-testActionGroup'));
expect(mockOnActionGroupChange).toHaveBeenLastCalledWith('testActionGroup');
});
test('should render alerts filter and filter timeframe inputs', () => {
useRuleFormState.mockReturnValue({
plugins: {
settings: {},
},
formData: {
consumer: 'stackAlerts',
schedule: { interval: '5h' },
},
actionErrors: {},
validConsumers: ['stackAlerts', 'logs'],
selectedRuleType: {
...ruleType,
hasFieldsForAAD: true,
},
selectedRuleTypeModel: ruleModel,
});
render(
<RuleActionsSettings
action={getAction('1')}
producerId="stackAlerts"
onUseDefaultMessageChange={mockOnUseDefaultMessageChange}
onNotifyWhenChange={mockOnNotifyWhenChange}
onActionGroupChange={mockOnActionGroupChange}
onAlertsFilterChange={mockOnAlertsFilterChange}
onTimeframeChange={mockOnTimeframeChange}
/>
);
expect(screen.queryByText('RuleActionsAlertsFilter')).toBeInTheDocument();
expect(screen.queryByText('RuleActionsAlertsFilterTimeframe')).toBeInTheDocument();
});
test('should call filter and filter timeframe onChange', async () => {
useRuleFormState.mockReturnValue({
plugins: {
settings: {},
},
formData: {
consumer: 'stackAlerts',
schedule: { interval: '5h' },
},
actionErrors: {},
validConsumers: ['stackAlerts', 'logs'],
selectedRuleType: {
...ruleType,
hasFieldsForAAD: true,
},
selectedRuleTypeModel: ruleModel,
});
render(
<RuleActionsSettings
action={getAction('1')}
producerId="stackAlerts"
onUseDefaultMessageChange={mockOnUseDefaultMessageChange}
onNotifyWhenChange={mockOnNotifyWhenChange}
onActionGroupChange={mockOnActionGroupChange}
onAlertsFilterChange={mockOnAlertsFilterChange}
onTimeframeChange={mockOnTimeframeChange}
/>
);
await userEvent.click(screen.getByText('RuleActionsAlertsFilterButton'));
expect(mockOnAlertsFilterChange).toHaveBeenLastCalledWith({ filters: [], kql: 'test' });
await userEvent.click(screen.getByText('RuleActionsAlertsFilterTimeframeButton'));
expect(mockOnTimeframeChange).toHaveBeenLastCalledWith({
days: [1],
hours: { end: 'now', start: 'now' },
timezone: 'utc',
});
});
test('should render filter query error', () => {
useRuleFormState.mockReturnValue({
plugins: {
settings: {},
},
formData: {
consumer: 'stackAlerts',
schedule: { interval: '5h' },
},
actionsErrors: {
'uuid-action-1': { filterQuery: ['filter query error'] },
},
validConsumers: ['stackAlerts', 'logs'],
selectedRuleType: {
...ruleType,
hasFieldsForAAD: true,
},
selectedRuleTypeModel: ruleModel,
});
render(
<RuleActionsSettings
action={getAction('1')}
producerId="stackAlerts"
onUseDefaultMessageChange={mockOnUseDefaultMessageChange}
onNotifyWhenChange={mockOnNotifyWhenChange}
onActionGroupChange={mockOnActionGroupChange}
onAlertsFilterChange={mockOnAlertsFilterChange}
onTimeframeChange={mockOnTimeframeChange}
/>
);
expect(screen.queryByText('filter query error')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,279 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiFormLabel, EuiFormRow, EuiSuperSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
AlertsFilter,
AlertsFilterTimeframe,
RecoveredActionGroup,
RuleActionFrequency,
} from '@kbn/alerting-types';
import { AlertConsumers, ValidFeatureId } from '@kbn/rule-data-utils';
import { useRuleFormState } from '../hooks';
import { RuleAction, RuleTypeWithDescription } from '../../common';
import {
getActionGroups,
getDurationNumberInItsUnit,
getDurationUnitValue,
getSelectedActionGroup,
hasFieldsForAad,
parseDuration,
} from '../utils';
import { DEFAULT_VALID_CONSUMERS } from '../constants';
import { RuleActionsNotifyWhen } from './rule_actions_notify_when';
import { RuleActionsAlertsFilter } from './rule_actions_alerts_filter';
import { RuleActionsAlertsFilterTimeframe } from './rule_actions_alerts_filter_timeframe';
const getMinimumThrottleWarnings = ({
actionThrottle,
actionThrottleUnit,
minimumActionThrottle,
minimumActionThrottleUnit,
}: {
actionThrottle: number | null;
actionThrottleUnit: string;
minimumActionThrottle: number;
minimumActionThrottleUnit: string;
}) => {
try {
if (!actionThrottle) return [false, false];
const throttleUnitDuration = parseDuration(`1${actionThrottleUnit}`);
const minThrottleUnitDuration = parseDuration(`1${minimumActionThrottleUnit}`);
const boundedThrottle =
throttleUnitDuration > minThrottleUnitDuration
? actionThrottle
: Math.max(actionThrottle, minimumActionThrottle);
const boundedThrottleUnit =
parseDuration(`${actionThrottle}${actionThrottleUnit}`) >= minThrottleUnitDuration
? actionThrottleUnit
: minimumActionThrottleUnit;
return [boundedThrottle !== actionThrottle, boundedThrottleUnit !== actionThrottleUnit];
} catch (e) {
return [false, false];
}
};
const ACTION_GROUP_NOT_SUPPORTED = (actionGroupName: string) =>
i18n.translate('alertsUIShared.ruleActionsSetting.actionGroupNotSupported', {
defaultMessage: '{actionGroupName} (Not Currently Supported)',
values: { actionGroupName },
});
const ACTION_GROUP_RUN_WHEN = i18n.translate(
'alertsUIShared.ruleActionsSetting.actionGroupRunWhen',
{
defaultMessage: 'Run when',
}
);
const DisabledActionGroupsByActionType: Record<string, string[]> = {
[RecoveredActionGroup.id]: ['.jira', '.resilient'],
};
const DisabledActionTypeIdsForActionGroup: Map<string, string[]> = new Map(
Object.entries(DisabledActionGroupsByActionType)
);
function isActionGroupDisabledForActionTypeId(actionGroup: string, actionTypeId: string): boolean {
return (
DisabledActionTypeIdsForActionGroup.has(actionGroup) &&
DisabledActionTypeIdsForActionGroup.get(actionGroup)!.includes(actionTypeId)
);
}
const isActionGroupDisabledForActionType = (
ruleType: RuleTypeWithDescription,
actionGroupId: string,
actionTypeId: string
): boolean => {
return isActionGroupDisabledForActionTypeId(
actionGroupId === ruleType?.recoveryActionGroup?.id ? RecoveredActionGroup.id : actionGroupId,
actionTypeId
);
};
const actionGroupDisplay = ({
ruleType,
actionGroupId,
actionGroupName,
actionTypeId,
}: {
ruleType: RuleTypeWithDescription;
actionGroupId: string;
actionGroupName: string;
actionTypeId: string;
}): string => {
if (isActionGroupDisabledForActionType(ruleType, actionGroupId, actionTypeId)) {
return ACTION_GROUP_NOT_SUPPORTED(actionGroupName);
}
return actionGroupName;
};
export interface RuleActionsSettingsProps {
action: RuleAction;
producerId: string;
onUseDefaultMessageChange: () => void;
onNotifyWhenChange: (frequency: RuleActionFrequency) => void;
onActionGroupChange: (group: string) => void;
onAlertsFilterChange: (query?: AlertsFilter['query']) => void;
onTimeframeChange: (timeframe?: AlertsFilterTimeframe) => void;
}
export const RuleActionsSettings = (props: RuleActionsSettingsProps) => {
const {
action,
producerId,
onUseDefaultMessageChange,
onNotifyWhenChange,
onActionGroupChange,
onAlertsFilterChange,
onTimeframeChange,
} = props;
const {
plugins: { settings },
formData: {
consumer,
schedule: { interval },
},
actionsErrors = {},
validConsumers = DEFAULT_VALID_CONSUMERS,
selectedRuleType,
selectedRuleTypeModel,
} = useRuleFormState();
const actionGroups = getActionGroups({
ruleType: selectedRuleType,
ruleTypeModel: selectedRuleTypeModel,
});
const selectedActionGroup = getSelectedActionGroup({
group: action.group,
ruleType: selectedRuleType,
ruleTypeModel: selectedRuleTypeModel,
});
const actionError = actionsErrors[action.uuid!] || {};
const showSelectActionGroup = actionGroups && selectedActionGroup && !action.frequency?.summary;
const intervalNumber = getDurationNumberInItsUnit(interval ?? 1);
const intervalUnit = getDurationUnitValue(interval);
const actionThrottle = action.frequency?.throttle
? getDurationNumberInItsUnit(action.frequency.throttle)
: null;
const actionThrottleUnit = action.frequency?.throttle
? getDurationUnitValue(action.frequency?.throttle)
: 'h';
const [minimumActionThrottle = -1, minimumActionThrottleUnit] = [
intervalNumber,
intervalUnit,
] ?? [-1, 's'];
const [showMinimumThrottleWarning, showMinimumThrottleUnitWarning] = getMinimumThrottleWarnings({
actionThrottle,
actionThrottleUnit,
minimumActionThrottle,
minimumActionThrottleUnit,
});
const showActionAlertsFilter =
hasFieldsForAad({
ruleType: selectedRuleType,
consumer,
validConsumers,
}) || producerId === AlertConsumers.SIEM;
return (
<EuiFlexGroup direction="column" data-test-subj="ruleActionsSettings">
<EuiFlexItem>
<EuiFlexGroup alignItems="flexEnd">
<EuiFlexItem>
<RuleActionsNotifyWhen
frequency={action.frequency}
throttle={actionThrottle}
throttleUnit={actionThrottleUnit}
hasAlertsMappings={selectedRuleType.hasAlertsMappings}
onChange={onNotifyWhenChange}
onUseDefaultMessage={onUseDefaultMessageChange}
showMinimumThrottleWarning={showMinimumThrottleWarning}
showMinimumThrottleUnitWarning={showMinimumThrottleUnitWarning}
/>
</EuiFlexItem>
<EuiFlexItem>
{showSelectActionGroup && (
<EuiSuperSelect
prepend={
<EuiFormLabel htmlFor={`addNewActionConnectorActionGroup-${action.actionTypeId}`}>
{ACTION_GROUP_RUN_WHEN}
</EuiFormLabel>
}
data-test-subj="ruleActionsSettingsSelectActionGroup"
fullWidth
id={`addNewActionConnectorActionGroup-${action.actionTypeId}`}
options={actionGroups.map(({ id: value, name }) => ({
value,
['data-test-subj']: `addNewActionConnectorActionGroup-${value}`,
inputDisplay: actionGroupDisplay({
ruleType: selectedRuleType,
actionGroupId: value,
actionGroupName: name,
actionTypeId: action.actionTypeId,
}),
disabled: isActionGroupDisabledForActionType(
selectedRuleType,
value,
action.actionTypeId
),
}))}
valueOfSelected={selectedActionGroup.id}
onChange={onActionGroupChange}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{showActionAlertsFilter && (
<EuiFlexItem>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFormRow
fullWidth
error={actionError.filterQuery}
isInvalid={!!actionError.filterQuery?.length}
>
<RuleActionsAlertsFilter
action={action}
onChange={onAlertsFilterChange}
featureIds={[producerId as ValidFeatureId]}
appName="stackAlerts"
ruleTypeId={selectedRuleType.id}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<RuleActionsAlertsFilterTimeframe
action={action}
settings={settings}
onChange={onTimeframeChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,263 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RuleType } from '@kbn/alerting-types';
import userEvent from '@testing-library/user-event';
import { TypeRegistry } from '../../common/type_registry';
import {
getAction,
getActionType,
getActionTypeModel,
getConnector,
} from '../../common/test_utils/actions_test_utils';
import { ActionTypeModel } from '../../common';
import { RuleActionsMessageProps } from './rule_actions_message';
import { RuleActionsSystemActionsItem } from './rule_actions_system_actions_item';
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));
jest.mock('./rule_actions_message', () => ({
RuleActionsMessage: ({ onParamsChange, warning }: RuleActionsMessageProps) => (
<div>
RuleActionsMessage
<button onClick={() => onParamsChange('param', { paramKey: 'someValue' })}>
RuleActionsMessageButton
</button>
{warning && <div>{warning}</div>}
</div>
),
}));
jest.mock('../validation/validate_params_for_warnings', () => ({
validateParamsForWarnings: jest.fn(),
}));
const ruleType = {
id: '.es-query',
name: 'Test',
actionGroups: [
{
id: 'testActionGroup',
name: 'Test Action Group',
},
{
id: 'recovered',
name: 'Recovered',
},
],
defaultActionGroupId: 'testActionGroup',
minimumLicenseRequired: 'basic',
recoveryActionGroup: {
id: 'recovered',
},
producer: 'logs',
authorizedConsumers: {
alerting: { read: true, all: true },
test: { read: true, all: true },
stackAlerts: { read: true, all: true },
logs: { read: true, all: true },
},
actionVariables: {
params: [],
state: [],
},
enabledInLicense: true,
} as unknown as RuleType;
const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');
const { validateParamsForWarnings } = jest.requireMock(
'../validation/validate_params_for_warnings'
);
const mockConnectors = [getConnector('1', { id: 'action-1' })];
const mockActionTypes = [getActionType('1')];
const mockOnChange = jest.fn();
const mockValidate = jest.fn().mockResolvedValue({
errors: {},
});
describe('ruleActionsSystemActionsItem', () => {
beforeEach(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(
getActionTypeModel('1', {
id: 'actionType-1',
validateParams: mockValidate,
})
);
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
http: {
basePath: {
publicBaseUrl: 'publicUrl',
},
},
},
actionsParamsErrors: {},
selectedRuleType: ruleType,
aadTemplateFields: [],
connectors: mockConnectors,
connectorTypes: mockActionTypes,
});
useRuleFormDispatch.mockReturnValue(mockOnChange);
validateParamsForWarnings.mockReturnValue(null);
});
afterEach(() => {
jest.clearAllMocks();
});
test('should render correctly', () => {
render(
<RuleActionsSystemActionsItem
action={getAction('1', { actionTypeId: 'actionType-1' })}
index={0}
producerId="stackAlerts"
/>
);
expect(screen.getByTestId('ruleActionsSystemActionsItem')).toBeInTheDocument();
expect(screen.getByText('connector-1')).toBeInTheDocument();
expect(screen.getByText('actionType: 1')).toBeInTheDocument();
expect(screen.getByTestId('ruleActionsSystemActionsItemAccordionContent')).toBeVisible();
expect(screen.getByText('RuleActionsMessage')).toBeInTheDocument();
});
test('should be able to hide the accordion content', async () => {
render(
<RuleActionsSystemActionsItem
action={getAction('1', { actionTypeId: 'actionType-1' })}
index={0}
producerId="stackAlerts"
/>
);
await userEvent.click(screen.getByTestId('ruleActionsSystemActionsItemAccordionButton'));
expect(screen.getByTestId('ruleActionsSystemActionsItemAccordionContent')).not.toBeVisible();
});
test('should be able to delete the action', async () => {
render(
<RuleActionsSystemActionsItem
action={getAction('1', { actionTypeId: 'actionType-1' })}
index={0}
producerId="stackAlerts"
/>
);
await userEvent.click(screen.getByTestId('ruleActionsSystemActionsItemDeleteActionButton'));
expect(mockOnChange).toHaveBeenCalledWith({
payload: { uuid: 'uuid-action-1' },
type: 'removeAction',
});
});
test('should render error icon if error exists', async () => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(getActionTypeModel('1', { id: 'actionType-1' }));
useRuleFormState.mockReturnValue({
plugins: {
actionTypeRegistry,
http: {
basePath: {
publicBaseUrl: 'publicUrl',
},
},
},
actionsParamsErrors: {
'uuid-action-1': {
param: ['something went wrong!'],
},
},
selectedRuleType: ruleType,
aadTemplateFields: [],
connectors: mockConnectors,
connectorTypes: mockActionTypes,
});
render(
<RuleActionsSystemActionsItem
action={getAction('1', { actionTypeId: 'actionType-1' })}
index={0}
producerId="stackAlerts"
/>
);
await userEvent.click(screen.getByTestId('ruleActionsSystemActionsItemAccordionButton'));
expect(screen.getByTestId('action-group-error-icon')).toBeInTheDocument();
});
test('should allow params to be changed', async () => {
render(
<RuleActionsSystemActionsItem
action={getAction('1', { actionTypeId: 'actionType-1' })}
index={0}
producerId="stackAlerts"
/>
);
await userEvent.click(screen.getByText('RuleActionsMessageButton'));
expect(mockOnChange).toHaveBeenCalledWith({
payload: { uuid: 'uuid-action-1', value: { param: { paramKey: 'someValue' } } },
type: 'setActionParams',
});
expect(mockValidate).toHaveBeenCalledWith({ param: { paramKey: 'someValue' } });
});
test('should set warning and error if params have errors', async () => {
validateParamsForWarnings.mockReturnValue('warning message!');
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
actionTypeRegistry.register(
getActionTypeModel('1', {
id: 'actionType-1',
validateParams: mockValidate.mockResolvedValue({
errors: { paramsValue: ['something went wrong!'] },
}),
})
);
render(
<RuleActionsSystemActionsItem
action={getAction('1', { actionTypeId: 'actionType-1' })}
index={0}
producerId="stackAlerts"
/>
);
await userEvent.click(screen.getByText('RuleActionsMessageButton'));
expect(mockOnChange).toHaveBeenCalledTimes(2);
expect(mockOnChange).toHaveBeenCalledWith({
payload: { uuid: 'uuid-action-1', value: { param: { paramKey: 'someValue' } } },
type: 'setActionParams',
});
expect(mockOnChange).toHaveBeenCalledWith({
payload: { errors: { paramsValue: ['something went wrong!'] }, uuid: 'uuid-action-1' },
type: 'setActionParamsError',
});
expect(screen.getByText('warning message!')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,273 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { Suspense, useCallback, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { isEmpty, some } from 'lodash';
import {
EuiAccordion,
EuiBadge,
EuiBetaBadge,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiPanel,
EuiText,
EuiToolTip,
useEuiBackgroundColor,
useEuiTheme,
} from '@elastic/eui';
import { RuleActionParam, RuleSystemAction } from '@kbn/alerting-types';
import { SavedObjectAttribute } from '@kbn/core/types';
import { css } from '@emotion/react';
import { useRuleFormDispatch, useRuleFormState } from '../hooks';
import { RuleFormParamsErrors } from '../../common';
import {
ACTION_ERROR_TOOLTIP,
ACTION_WARNING_TITLE,
TECH_PREVIEW_DESCRIPTION,
TECH_PREVIEW_LABEL,
} from '../translations';
import { RuleActionsMessage } from './rule_actions_message';
import { validateParamsForWarnings } from '../validation';
import { getAvailableActionVariables } from '../../action_variables';
interface RuleActionsSystemActionsItemProps {
action: RuleSystemAction;
index: number;
producerId: string;
}
export const RuleActionsSystemActionsItem = (props: RuleActionsSystemActionsItemProps) => {
const { action, index, producerId } = props;
const {
plugins: { actionTypeRegistry, http },
actionsParamsErrors = {},
selectedRuleType,
connectorTypes,
connectors,
aadTemplateFields,
} = useRuleFormState();
const [isOpen, setIsOpen] = useState(true);
const [storedActionParamsForAadToggle, setStoredActionParamsForAadToggle] = useState<
Record<string, SavedObjectAttribute>
>({});
const [warning, setWarning] = useState<string | null>(null);
const subdued = useEuiBackgroundColor('subdued');
const plain = useEuiBackgroundColor('plain');
const { euiTheme } = useEuiTheme();
const dispatch = useRuleFormDispatch();
const actionTypeModel = actionTypeRegistry.get(action.actionTypeId);
const actionType = connectorTypes.find(({ id }) => id === action.actionTypeId)!;
const connector = connectors.find(({ id }) => id === action.id)!;
const actionParamsError = actionsParamsErrors[action.uuid!] || {};
const availableActionVariables = useMemo(() => {
const messageVariables = selectedRuleType.actionVariables;
return messageVariables
? getAvailableActionVariables(messageVariables, undefined, undefined, true)
: [];
}, [selectedRuleType]);
const showActionGroupErrorIcon = (): boolean => {
return !isOpen && some(actionParamsError, (error) => !isEmpty(error));
};
const onDelete = (id: string) => {
dispatch({ type: 'removeAction', payload: { uuid: id } });
};
const onStoredActionParamsChange = useCallback(
(
aadParams: Record<string, SavedObjectAttribute>,
params: Record<string, SavedObjectAttribute>
) => {
if (isEmpty(aadParams) && action.params.subAction) {
setStoredActionParamsForAadToggle(params);
} else {
setStoredActionParamsForAadToggle(aadParams);
}
},
[action]
);
const validateActionParams = useCallback(
async (params: RuleActionParam) => {
const res: { errors: RuleFormParamsErrors } = await actionTypeRegistry
.get(action.actionTypeId)
?.validateParams(params);
dispatch({
type: 'setActionParamsError',
payload: {
uuid: action.uuid!,
errors: res.errors,
},
});
},
[actionTypeRegistry, action, dispatch]
);
const onParamsChange = useCallback(
(key: string, value: RuleActionParam) => {
const newParams = {
...action.params,
[key]: value,
};
dispatch({
type: 'setActionParams',
payload: {
uuid: action.uuid!,
value: newParams,
},
});
setWarning(
validateParamsForWarnings({
value,
publicBaseUrl: http.basePath.publicBaseUrl,
actionVariables: availableActionVariables,
})
);
validateActionParams(newParams);
onStoredActionParamsChange(storedActionParamsForAadToggle, newParams);
},
[
http,
action,
availableActionVariables,
dispatch,
validateActionParams,
onStoredActionParamsChange,
storedActionParamsForAadToggle,
]
);
return (
<EuiAccordion
data-test-subj="ruleActionsSystemActionsItem"
initialIsOpen
borders="all"
style={{
backgroundColor: subdued,
borderRadius: euiTheme.border.radius.medium,
}}
id={action.id}
onToggle={setIsOpen}
buttonProps={{
style: {
width: '100%',
},
}}
arrowProps={{
css: css`
margin-left: ${euiTheme.size.m};
`,
}}
extraAction={
<EuiButtonIcon
data-test-subj="ruleActionsSystemActionsItemDeleteActionButton"
style={{
marginRight: euiTheme.size.l,
}}
aria-label={i18n.translate(
'alertsUIShared.ruleActionsSystemActionsItem.deleteActionAriaLabel',
{
defaultMessage: 'delete action',
}
)}
iconType="trash"
color="danger"
onClick={() => onDelete(action.uuid!)}
/>
}
buttonContentClassName="eui-fullWidth"
buttonContent={
<EuiPanel
data-test-subj="ruleActionsSystemActionsItemAccordionButton"
color="subdued"
paddingSize="m"
>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
{showActionGroupErrorIcon() ? (
<EuiToolTip content={ACTION_ERROR_TOOLTIP}>
<EuiIcon
data-test-subj="action-group-error-icon"
type="warning"
color="danger"
size="l"
/>
</EuiToolTip>
) : (
<Suspense fallback={null}>
<EuiIcon size="l" type={actionTypeModel.iconClass} />
</Suspense>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>{connector.name}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
<strong>{actionType?.name}</strong>
</EuiText>
</EuiFlexItem>
{warning && !isOpen && (
<EuiFlexItem grow={false}>
<EuiBadge data-test-subj="warning-badge" iconType="warning" color="warning">
{ACTION_WARNING_TITLE}
</EuiBadge>
</EuiFlexItem>
)}
{actionTypeModel.isExperimental && (
<EuiFlexItem grow={false}>
<EuiBetaBadge
alignment="middle"
data-test-subj="ruleActionsSystemActionsItemBetaBadge"
label={TECH_PREVIEW_LABEL}
tooltipContent={TECH_PREVIEW_DESCRIPTION}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiPanel>
}
>
<EuiFlexGroup
data-test-subj="ruleActionsSystemActionsItemAccordionContent"
direction="column"
style={{
padding: euiTheme.size.l,
backgroundColor: plain,
}}
>
<EuiFlexItem>
<RuleActionsMessage
useDefaultMessage
action={action}
index={index}
connector={connector}
producerId={producerId}
warning={warning}
templateFields={aadTemplateFields}
onParamsChange={onParamsChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiAccordion>
);
};

View file

@ -52,15 +52,6 @@ describe('RuleConsumerSelection', () => {
expect(screen.getByTestId('comboBoxSearchInput')).toHaveValue('');
});
it('should display nothing if there is only 1 consumer to select', () => {
useRuleFormState.mockReturnValue({
multiConsumerSelection: null,
});
render(<RuleConsumerSelection validConsumers={['stackAlerts']} />);
expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument();
});
it('should be able to select logs and call onChange', () => {
useRuleFormState.mockReturnValue({
multiConsumerSelection: null,

View file

@ -91,10 +91,6 @@ export const RuleConsumerSelection = (props: RuleConsumerSelectionProps) => {
[dispatch]
);
if (validConsumers.length <= 1 || validConsumers.includes(AlertConsumers.OBSERVABILITY)) {
return null;
}
return (
<EuiFormRow
fullWidth

View file

@ -169,6 +169,58 @@ describe('Rule Definition', () => {
expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument();
});
test('Hides consumer selection if there is only 1 consumer to select', () => {
useRuleFormState.mockReturnValue({
plugins,
formData: {
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
},
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
canShowConsumerSelect: true,
validConsumers: ['logs'],
});
render(<RuleDefinition />);
expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument();
});
test('Hides consumer selection if valid consumers contain observability', () => {
useRuleFormState.mockReturnValue({
plugins,
formData: {
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
},
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
canShowConsumerSelect: true,
validConsumers: ['logs', 'observability'],
});
render(<RuleDefinition />);
expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument();
});
test('Can toggle advanced options', async () => {
useRuleFormState.mockReturnValue({
plugins,

View file

@ -22,7 +22,11 @@ import {
EuiPanel,
EuiSpacer,
EuiErrorBoundary,
useEuiTheme,
COLOR_MODES_STANDARD,
} from '@elastic/eui';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { AlertConsumers } from '@kbn/rule-data-utils';
import {
DOC_LINK_TITLE,
LOADING_RULE_TYPE_PARAMS_TITLE,
@ -56,6 +60,7 @@ export const RuleDefinition = () => {
canShowConsumerSelection = false,
} = useRuleFormState();
const { colorMode } = useEuiTheme();
const dispatch = useRuleFormDispatch();
const { charts, data, dataViews, unifiedSearch, docLinks } = plugins;
@ -81,6 +86,12 @@ export const RuleDefinition = () => {
if (!authorizedConsumers.length) {
return false;
}
if (
authorizedConsumers.length <= 1 ||
authorizedConsumers.includes(AlertConsumers.OBSERVABILITY)
) {
return false;
}
return (
selectedRuleTypeModel.id && MULTI_CONSUMER_RULE_TYPE_IDS.includes(selectedRuleTypeModel.id)
);
@ -174,24 +185,26 @@ export const RuleDefinition = () => {
<EuiFlexGroup gutterSize="none" direction="column">
<EuiFlexItem>
<EuiErrorBoundary>
<RuleParamsExpressionComponent
id={id}
ruleParams={params}
ruleInterval={schedule.interval}
ruleThrottle={''}
alertNotifyWhen={notifyWhen || 'onActionGroupChange'}
errors={paramsErrors || {}}
setRuleParams={onSetRuleParams}
setRuleProperty={onSetRuleProperty}
defaultActionGroupId={selectedRuleType.defaultActionGroupId}
actionGroups={selectedRuleType.actionGroups}
metadata={metadata}
charts={charts}
data={data}
dataViews={dataViews}
unifiedSearch={unifiedSearch}
onChangeMetaData={onChangeMetaData}
/>
<EuiThemeProvider darkMode={colorMode === COLOR_MODES_STANDARD.dark}>
<RuleParamsExpressionComponent
id={id}
ruleParams={params}
ruleInterval={schedule.interval}
ruleThrottle={''}
alertNotifyWhen={notifyWhen || 'onActionGroupChange'}
errors={paramsErrors || {}}
setRuleParams={onSetRuleParams}
setRuleProperty={onSetRuleProperty}
defaultActionGroupId={selectedRuleType.defaultActionGroupId}
actionGroups={selectedRuleType.actionGroups}
metadata={metadata}
charts={charts}
data={data}
dataViews={dataViews}
unifiedSearch={unifiedSearch}
onChangeMetaData={onChangeMetaData}
/>
</EuiThemeProvider>
</EuiErrorBoundary>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -12,3 +12,4 @@ export * from './rule_form_circuit_breaker_error';
export * from './rule_form_resolve_rule_error';
export * from './rule_form_rule_type_error';
export * from './rule_form_error_prompt_wrapper';
export * from './rule_form_action_permission_error';

View file

@ -0,0 +1,34 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiEmptyPrompt, EuiText } from '@elastic/eui';
import {
RULE_FORM_PAGE_RULE_ACTIONS_NO_PERMISSION_TITLE,
RULE_FORM_PAGE_RULE_ACTIONS_NO_PERMISSION_DESCRIPTION,
} from '../translations';
export const RuleFormActionPermissionError = () => {
return (
<EuiEmptyPrompt
iconType="error"
color="danger"
title={
<EuiText color="default">
<h2>{RULE_FORM_PAGE_RULE_ACTIONS_NO_PERMISSION_TITLE}</h2>
</EuiText>
}
body={
<EuiText>
<p>{RULE_FORM_PAGE_RULE_ACTIONS_NO_PERMISSION_DESCRIPTION}</p>
</EuiText>
}
/>
);
};

View file

@ -11,6 +11,7 @@ import React, { useReducer } from 'react';
import { act, renderHook } from '@testing-library/react-hooks/dom';
import { ruleFormStateReducer } from './rule_form_state_reducer';
import { RuleFormState } from '../types';
import { getAction } from '../../common/test_utils/actions_test_utils';
jest.mock('../validation/validate_form', () => ({
validateRuleBase: jest.fn(),
@ -63,6 +64,7 @@ const initialState: RuleFormState = {
formData: {
name: 'test-rule',
tags: [],
actions: [],
params: {
paramsValue: 'value-1',
},
@ -74,6 +76,9 @@ const initialState: RuleFormState = {
selectedRuleType: indexThresholdRuleType,
selectedRuleTypeModel: indexThresholdRuleTypeModel,
multiConsumerSelection: 'stackAlerts',
connectors: [],
connectorTypes: [],
aadTemplateFields: [],
};
describe('ruleFormStateReducer', () => {
@ -97,6 +102,7 @@ describe('ruleFormStateReducer', () => {
params: {
test: 'hello',
},
actions: [],
schedule: { interval: '2m' },
consumer: 'logs',
};
@ -345,4 +351,218 @@ describe('ruleFormStateReducer', () => {
expect(validateRuleBase).not.toHaveBeenCalled();
expect(validateRuleParams).not.toHaveBeenCalled();
});
test('addAction works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
const action = getAction('1');
act(() => {
dispatch({
type: 'addAction',
payload: action,
});
});
expect(result.current[0].formData.actions).toEqual([action]);
expect(validateRuleBase).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({ actions: [action] }),
})
);
expect(validateRuleParams).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({ actions: [action] }),
})
);
});
test('removeAction works correctly', () => {
const action1 = getAction('1');
const action2 = getAction('2');
const { result } = renderHook(() =>
useReducer(ruleFormStateReducer, {
...initialState,
formData: {
...initialState.formData,
actions: [action1, action2],
},
})
);
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'removeAction',
payload: {
uuid: action1.uuid!,
},
});
});
expect(result.current[0].formData.actions).toEqual([action2]);
expect(validateRuleBase).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({ actions: [action2] }),
})
);
expect(validateRuleParams).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({ actions: [action2] }),
})
);
});
test('setActionProperty works correctly', () => {
const action = getAction('1');
const { result } = renderHook(() =>
useReducer(ruleFormStateReducer, {
...initialState,
formData: {
...initialState.formData,
actions: [action],
},
})
);
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setActionProperty',
payload: {
uuid: action.uuid!,
key: 'params',
value: {
test: 'value',
},
},
});
});
const updatedAction = {
...action,
params: {
test: 'value',
},
};
expect(result.current[0].formData.actions).toEqual([updatedAction]);
expect(validateRuleBase).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({ actions: [updatedAction] }),
})
);
expect(validateRuleParams).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({ actions: [updatedAction] }),
})
);
});
test('setActionParams works correctly', () => {
const action = getAction('1');
const { result } = renderHook(() =>
useReducer(ruleFormStateReducer, {
...initialState,
formData: {
...initialState.formData,
actions: [action],
},
})
);
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setActionParams',
payload: {
uuid: action.uuid!,
value: {
test: 'value',
},
},
});
});
const updatedAction = {
...action,
params: {
test: 'value',
},
};
expect(result.current[0].formData.actions).toEqual([updatedAction]);
expect(validateRuleBase).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({ actions: [updatedAction] }),
})
);
expect(validateRuleParams).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({ actions: [updatedAction] }),
})
);
});
test('setActionError works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
const action = getAction('1');
act(() => {
dispatch({
type: 'setActionError',
payload: {
uuid: action.uuid!,
errors: { ['property' as string]: 'something went wrong' },
},
});
});
expect(result.current[0].actionsErrors).toEqual({
'uuid-action-1': { property: 'something went wrong' },
});
expect(validateRuleBase).not.toHaveBeenCalled();
expect(validateRuleParams).not.toHaveBeenCalled();
});
test('setActionParamsError works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
const action = getAction('1');
act(() => {
dispatch({
type: 'setActionParamsError',
payload: {
uuid: action.uuid!,
errors: { ['property' as string]: 'something went wrong' },
},
});
});
expect(result.current[0].actionsParamsErrors).toEqual({
'uuid-action-1': { property: 'something went wrong' },
});
expect(validateRuleBase).not.toHaveBeenCalled();
expect(validateRuleParams).not.toHaveBeenCalled();
});
});

View file

@ -7,6 +7,9 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { RuleActionParams } from '@kbn/alerting-types';
import { omit } from 'lodash';
import { RuleFormActionsErrors, RuleFormParamsErrors, RuleUiAction } from '../../common';
import { RuleFormData, RuleFormState } from '../types';
import { validateRuleBase, validateRuleParams } from '../validation';
@ -64,6 +67,45 @@ export type RuleFormStateReducerAction =
| {
type: 'setMetadata';
payload: Record<string, unknown>;
}
| {
type: 'addAction';
payload: RuleUiAction;
}
| {
type: 'removeAction';
payload: {
uuid: string;
};
}
| {
type: 'setActionProperty';
payload: {
uuid: string;
key: string;
value: unknown;
};
}
| {
type: 'setActionParams';
payload: {
uuid: string;
value: RuleActionParams;
};
}
| {
type: 'setActionError';
payload: {
uuid: string;
errors: RuleFormActionsErrors;
};
}
| {
type: 'setActionParamsError';
payload: {
uuid: string;
errors: RuleFormParamsErrors;
};
};
const getUpdateWithValidation =
@ -189,6 +231,101 @@ export const ruleFormStateReducer = (
metadata: payload,
};
}
case 'addAction': {
const { payload } = action;
return updateWithValidation(() => ({
...formData,
actions: [...formData.actions, payload],
}));
}
case 'removeAction': {
const {
payload: { uuid },
} = action;
return {
...ruleFormState,
...updateWithValidation(() => ({
...formData,
actions: formData.actions.filter((existingAction) => existingAction.uuid !== uuid),
})),
...(ruleFormState.actionsErrors
? {
actionsErrors: omit(ruleFormState.actionsErrors, uuid),
}
: {}),
...(ruleFormState.actionsParamsErrors
? {
actionsParamsErrors: omit(ruleFormState.actionsParamsErrors, uuid),
}
: {}),
};
}
case 'setActionProperty': {
const {
payload: { uuid, key, value },
} = action;
return updateWithValidation(() => ({
...formData,
actions: formData.actions.map((existingAction) => {
if (existingAction.uuid === uuid) {
return {
...existingAction,
[key]: value,
};
}
return existingAction;
}),
}));
}
case 'setActionParams': {
const {
payload: { uuid, value },
} = action;
return updateWithValidation(() => ({
...formData,
actions: formData.actions.map((existingAction) => {
if (existingAction.uuid === uuid) {
return {
...existingAction,
params: value,
};
}
return existingAction;
}),
}));
}
case 'setActionError': {
const {
payload: { uuid, errors },
} = action;
const newActionsError = {
...(ruleFormState.actionsErrors || {})[uuid],
...errors,
};
return {
...ruleFormState,
actionsErrors: {
...ruleFormState.actionsErrors,
[uuid]: newActionsError,
},
};
}
case 'setActionParamsError': {
const {
payload: { uuid, errors },
} = action;
const newActionsParamsError = {
...(ruleFormState.actionsParamsErrors || {})[uuid],
...errors,
};
return {
...ruleFormState,
actionsParamsErrors: {
...ruleFormState.actionsParamsErrors,
[uuid]: newActionsParamsError,
},
};
}
default: {
return ruleFormState;
}

View file

@ -50,6 +50,7 @@ const formDataMock: RuleFormData = {
index: ['.kibana'],
timeField: 'alert.executionStatus.lastExecutionDate',
},
actions: [],
consumer: 'stackAlerts',
schedule: { interval: '1m' },
tags: [],
@ -64,12 +65,22 @@ useRuleFormState.mockReturnValue({
plugins: {
application: {
navigateToUrl,
capabilities: {
actions: {
show: true,
save: true,
execute: true,
},
},
},
},
baseErrors: {},
paramsErrors: {},
multiConsumerSelection: 'logs',
formData: formDataMock,
connectors: [],
connectorTypes: [],
aadTemplateFields: [],
});
const onSave = jest.fn();

View file

@ -51,6 +51,8 @@ export const RulePage = (props: RulePageProps) => {
multiConsumerSelection,
} = useRuleFormState();
const canReadConnectors = !!application.capabilities.actions?.show;
const styles = useEuiBackgroundColorCSS().transparent;
const onCancel = useCallback(() => {
@ -64,22 +66,31 @@ export const RulePage = (props: RulePageProps) => {
});
}, [onSave, formData, multiConsumerSelection]);
const actionComponent = useMemo(() => {
if (canReadConnectors) {
return [
{
title: RULE_FORM_PAGE_RULE_ACTIONS_TITLE,
children: (
<>
<RuleActions />
<EuiSpacer />
<EuiHorizontalRule margin="none" />
</>
),
},
];
}
return [];
}, [canReadConnectors]);
const steps: EuiStepsProps['steps'] = useMemo(() => {
return [
{
title: RULE_FORM_PAGE_RULE_DEFINITION_TITLE,
children: <RuleDefinition />,
},
{
title: RULE_FORM_PAGE_RULE_ACTIONS_TITLE,
children: (
<>
<RuleActions onClick={() => {}} />
<EuiSpacer />
<EuiHorizontalRule margin="none" />
</>
),
},
...actionComponent,
{
title: RULE_FORM_PAGE_RULE_DETAILS_TITLE,
children: (
@ -91,7 +102,7 @@ export const RulePage = (props: RulePageProps) => {
),
},
];
}, []);
}, [actionComponent]);
return (
<EuiPageTemplate grow bottomBorder offset={0} css={styles}>

View file

@ -35,6 +35,9 @@ hasRuleErrors.mockReturnValue(false);
useRuleFormState.mockReturnValue({
baseErrors: {},
paramsErrors: {},
formData: {
actions: [],
},
});
describe('rulePageFooter', () => {

View file

@ -33,14 +33,31 @@ export const RulePageFooter = (props: RulePageFooterProps) => {
const { isEdit = false, isSaving = false, onCancel, onSave } = props;
const { baseErrors, paramsErrors } = useRuleFormState();
const {
formData: { actions },
connectors,
baseErrors = {},
paramsErrors = {},
actionsErrors = {},
actionsParamsErrors = {},
} = useRuleFormState();
const hasErrors = useMemo(() => {
return hasRuleErrors({
baseErrors: baseErrors || {},
paramsErrors: paramsErrors || {},
const hasBrokenConnectors = actions.some((action) => {
return !connectors.find((connector) => connector.id === action.id);
});
}, [baseErrors, paramsErrors]);
if (hasBrokenConnectors) {
return true;
}
return hasRuleErrors({
baseErrors,
paramsErrors,
actionsErrors,
actionsParamsErrors,
});
}, [actions, connectors, baseErrors, paramsErrors, actionsErrors, actionsParamsErrors]);
const saveButtonText = useMemo(() => {
if (isEdit) {
@ -59,11 +76,13 @@ export const RulePageFooter = (props: RulePageFooterProps) => {
const onSaveClick = useCallback(() => {
if (isEdit) {
onSave();
} else {
setShowCreateConfirmation(true);
return onSave();
}
}, [isEdit, onSave]);
if (actions.length === 0) {
return setShowCreateConfirmation(true);
}
onSave();
}, [actions, isEdit, onSave]);
const onCreateConfirmClick = useCallback(() => {
setShowCreateConfirmation(false);

View file

@ -35,6 +35,7 @@ const formData: RuleFormData = {
index: ['.kibana'],
timeField: 'created_at',
},
actions: [],
consumer: 'stackAlerts',
ruleTypeId: '.es-query',
schedule: { interval: '1m' },

View file

@ -25,7 +25,7 @@ import { BASE_ALERTING_API_PATH } from '../../common/constants';
import { RuleFormData } from '../types';
import {
CreateRuleBody,
UPDATE_FIELDS,
UPDATE_FIELDS_WITH_ACTIONS,
UpdateRuleBody,
transformCreateRuleBody,
transformUpdateRuleBody,
@ -41,7 +41,7 @@ const stringifyBodyRequest = ({
}): string => {
try {
const request = isEdit
? transformUpdateRuleBody(pick(formData, UPDATE_FIELDS) as UpdateRuleBody)
? transformUpdateRuleBody(pick(formData, UPDATE_FIELDS_WITH_ACTIONS) as UpdateRuleBody)
: transformCreateRuleBody(omit(formData, 'id') as CreateRuleBody);
return JSON.stringify(request, null, 2);
} catch {

View file

@ -337,7 +337,7 @@ export const HEALTH_CHECK_ACTION_TEXT = i18n.translate('alertsUIShared.healthChe
export const RULE_FORM_ROUTE_PARAMS_ERROR_TITLE = i18n.translate(
'alertsUIShared.ruleForm.routeParamsErrorTitle',
{
defaultMessage: 'Unable to load rule form.',
defaultMessage: 'Unable to load rule form',
}
);
@ -351,7 +351,7 @@ export const RULE_FORM_ROUTE_PARAMS_ERROR_TEXT = i18n.translate(
export const RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TITLE = i18n.translate(
'alertsUIShared.ruleForm.ruleTypeNotFoundErrorTitle',
{
defaultMessage: 'Unable to load rule type.',
defaultMessage: 'Unable to load rule type',
}
);
@ -374,7 +374,7 @@ export const RULE_FORM_RULE_NOT_FOUND_ERROR_TEXT = i18n.translate(
'alertsUIShared.ruleForm.ruleNotFoundErrorText',
{
defaultMessage:
'There was an error loading the rule. Please ensure you have access to the rule selected.',
'There was an error loading the rule. Please ensure the rule exists and you have access to the rule selected.',
}
);
@ -458,6 +458,20 @@ export const RULE_FORM_PAGE_RULE_ACTIONS_TITLE = i18n.translate(
}
);
export const RULE_FORM_PAGE_RULE_ACTIONS_NO_PERMISSION_TITLE = i18n.translate(
'alertsUIShared.ruleForm.ruleActionsNoPermissionTitle',
{
defaultMessage: 'Actions and connectors privileges missing',
}
);
export const RULE_FORM_PAGE_RULE_ACTIONS_NO_PERMISSION_DESCRIPTION = i18n.translate(
'alertsUIShared.ruleForm.ruleActionsNoPermissionDescription',
{
defaultMessage: 'You must have read access to actions and connectors to edit rules.',
}
);
export const RULE_FORM_PAGE_RULE_DETAILS_TITLE = i18n.translate(
'alertsUIShared.ruleForm.ruleDetailsTitle',
{
@ -468,3 +482,92 @@ export const RULE_FORM_PAGE_RULE_DETAILS_TITLE = i18n.translate(
export const RULE_FORM_RETURN_TITLE = i18n.translate('alertsUIShared.ruleForm.returnTitle', {
defaultMessage: 'Return',
});
export const MODAL_SEARCH_PLACEHOLDER = i18n.translate(
'alertsUIShared.ruleForm.modalSearchPlaceholder',
{
defaultMessage: 'Search',
}
);
export const MODAL_SEARCH_CLEAR_FILTERS_TEXT = i18n.translate(
'alertsUIShared.ruleForm.modalSearchClearFiltersText',
{
defaultMessage: 'Clear filters',
}
);
export const ACTION_TYPE_MODAL_TITLE = i18n.translate(
'alertsUIShared.ruleForm.actionTypeModalTitle',
{
defaultMessage: 'Select connector',
}
);
export const ACTION_TYPE_MODAL_FILTER_ALL = i18n.translate(
'alertsUIShared.ruleForm.actionTypeModalFilterAll',
{
defaultMessage: 'All',
}
);
export const ACTION_TYPE_MODAL_EMPTY_TITLE = i18n.translate(
'alertsUIShared.ruleForm.actionTypeModalEmptyTitle',
{
defaultMessage: 'No connectors found',
}
);
export const ACTION_TYPE_MODAL_EMPTY_TEXT = i18n.translate(
'alertsUIShared.ruleForm.actionTypeModalEmptyText',
{
defaultMessage: 'Try a different search or change your filter settings.',
}
);
export const ACTION_ERROR_TOOLTIP = i18n.translate(
'alertsUIShared.ruleActionsItem.actionErrorToolTip',
{
defaultMessage: 'Action contains errors.',
}
);
export const ACTION_WARNING_TITLE = i18n.translate(
'alertsUIShared.ruleActionsItem.actionWarningsTitle',
{
defaultMessage: '1 warning',
}
);
export const ACTION_UNABLE_TO_LOAD_CONNECTOR_TITLE = i18n.translate(
'alertsUIShared.ruleActionsItem.actionUnableToLoadConnectorTitle',
{
defaultMessage: 'Unable to find connector',
}
);
export const ACTION_UNABLE_TO_LOAD_CONNECTOR_DESCRIPTION = i18n.translate(
'alertsUIShared.ruleActionsItem.actionUnableToLoadConnectorTitle',
{
defaultMessage: `Create a connector and try again. If you can't create a connector, contact your system administrator.`,
}
);
export const ACTION_USE_AAD_TEMPLATE_FIELDS_LABEL = i18n.translate(
'alertsUIShared.ruleActionsItem.actionUseAadTemplateFieldsLabel',
{
defaultMessage: 'Use template fields from alerts index',
}
);
export const TECH_PREVIEW_LABEL = i18n.translate('alertsUIShared.technicalPreviewBadgeLabel', {
defaultMessage: 'Technical preview',
});
export const TECH_PREVIEW_DESCRIPTION = i18n.translate(
'alertsUIShared.technicalPreviewBadgeDescription',
{
defaultMessage:
'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.',
}
);

View file

@ -17,16 +17,23 @@ import type { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { SettingsStart } from '@kbn/core-ui-settings-browser';
import { RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import { ActionType } from '@kbn/actions-types';
import { ActionVariable } from '@kbn/alerting-types';
import {
ActionConnector,
ActionTypeRegistryContract,
MinimumScheduleInterval,
Rule,
RuleFormActionsErrors,
RuleFormBaseErrors,
RuleFormParamsErrors,
RuleTypeModel,
RuleTypeParams,
RuleTypeRegistryContract,
RuleTypeWithDescription,
RuleUiAction,
} from '../common/types';
export interface RuleFormData<Params extends RuleTypeParams = RuleTypeParams> {
@ -35,6 +42,7 @@ export interface RuleFormData<Params extends RuleTypeParams = RuleTypeParams> {
params: Rule<Params>['params'];
schedule: Rule<Params>['schedule'];
consumer: Rule<Params>['consumer'];
actions: RuleUiAction[];
alertDelay?: Rule<Params>['alertDelay'];
notifyWhen?: Rule<Params>['notifyWhen'];
ruleTypeId?: Rule<Params>['ruleTypeId'];
@ -45,24 +53,32 @@ export interface RuleFormPlugins {
i18n: I18nStart;
theme: ThemeServiceStart;
application: ApplicationStart;
notification: NotificationsStart;
notifications: NotificationsStart;
charts: ChartsPluginSetup;
settings: SettingsStart;
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
docLinks: DocLinksStart;
ruleTypeRegistry: RuleTypeRegistryContract;
actionTypeRegistry: ActionTypeRegistryContract;
}
export interface RuleFormState<Params extends RuleTypeParams = RuleTypeParams> {
id?: string;
formData: RuleFormData<Params>;
plugins: RuleFormPlugins;
connectors: ActionConnector[];
connectorTypes: ActionType[];
aadTemplateFields: ActionVariable[];
baseErrors?: RuleFormBaseErrors;
paramsErrors?: RuleFormParamsErrors;
actionsErrors?: Record<string, RuleFormActionsErrors>;
actionsParamsErrors?: Record<string, RuleFormParamsErrors>;
selectedRuleType: RuleTypeWithDescription;
selectedRuleTypeModel: RuleTypeModel<Params>;
multiConsumerSelection?: RuleCreationValidConsumer | null;
showMustacheAutocompleteSwitch?: boolean;
metadata?: Record<string, unknown>;
minimumScheduleInterval?: MinimumScheduleInterval;
canShowConsumerSelection?: boolean;

View file

@ -1,15 +1,18 @@
/*
* 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.
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ActionType, PreConfiguredActionConnector } from '../../types';
import { ActionType } from '@kbn/actions-types';
import {
checkActionTypeEnabled,
checkActionFormActionTypeEnabled,
} from './check_action_type_enabled';
import { PreConfiguredActionConnector } from '../../common';
describe('checkActionTypeEnabled', () => {
test(`returns isEnabled:true when action type isn't provided`, async () => {
@ -65,7 +68,7 @@ describe('checkActionTypeEnabled', () => {
>
<Memo(MemoizedFormattedMessage)
defaultMessage="View license options"
id="xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseLinkTitle"
id="alertsUIShared.licenseCheck.actionTypeDisabledByLicenseLinkTitle"
/>
</EuiLink>
</EuiCard>,

View file

@ -0,0 +1,57 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ActionType } from '@kbn/actions-types';
import { configurationCheckResult, getLicenseCheckResult } from './get_license_check_result';
import { ActionConnector } from '../../common';
import './check_action_type_enabled.scss';
export interface IsEnabledResult {
isEnabled: true;
}
export interface IsDisabledResult {
isEnabled: false;
message: string;
messageCard: JSX.Element;
}
export const checkActionTypeEnabled = (
actionType?: ActionType
): IsEnabledResult | IsDisabledResult => {
if (actionType?.enabledInLicense === false) {
return getLicenseCheckResult(actionType);
}
if (actionType?.enabledInConfig === false) {
return configurationCheckResult;
}
return { isEnabled: true };
};
export const checkActionFormActionTypeEnabled = (
actionType: ActionType,
preconfiguredConnectors: ActionConnector[]
): IsEnabledResult | IsDisabledResult => {
if (actionType?.enabledInLicense === false) {
return getLicenseCheckResult(actionType);
}
if (
actionType?.enabledInConfig === false &&
// do not disable action type if it contains preconfigured connectors (is preconfigured)
!preconfiguredConnectors.find(
(preconfiguredConnector) => preconfiguredConnector.actionTypeId === actionType.id
)
) {
return configurationCheckResult;
}
return { isEnabled: true };
};

View file

@ -75,5 +75,5 @@ export const getInitialMultiConsumer = ({
}
// All else fails, just use the first valid consumer
return validConsumers[0];
return validConsumers[0] || null;
};

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { upperFirst } from 'lodash';
import { i18n } from '@kbn/i18n';
import { EuiCard, EuiLink } from '@elastic/eui';
import { ActionType } from '@kbn/actions-types';
import { FormattedMessage } from '@kbn/i18n-react';
import { VIEW_LICENSE_OPTIONS_LINK } from '../../common/constants';
export const getLicenseCheckResult = (actionType: ActionType) => {
return {
isEnabled: false,
message: i18n.translate(
'alertsUIShared.checkActionTypeEnabled.actionTypeDisabledByLicenseMessage',
{
defaultMessage: 'This connector requires a {minimumLicenseRequired} license.',
values: {
minimumLicenseRequired: upperFirst(actionType.minimumLicenseRequired),
},
}
),
messageCard: (
<EuiCard
titleSize="xs"
title={i18n.translate(
'alertsUIShared.licenseCheck.actionTypeDisabledByLicenseMessageTitle',
{
defaultMessage: 'This feature requires a {minimumLicenseRequired} license.',
values: {
minimumLicenseRequired: upperFirst(actionType.minimumLicenseRequired),
},
}
)}
// The "re-enable" terminology is used here because this message is used when an alert
// action was previously enabled and needs action to be re-enabled.
description={i18n.translate(
'alertsUIShared.licenseCheck.actionTypeDisabledByLicenseMessageDescription',
{ defaultMessage: 'To re-enable this action, please upgrade your license.' }
)}
className="actCheckActionTypeEnabled__disabledActionWarningCard"
children={
<EuiLink href={VIEW_LICENSE_OPTIONS_LINK} target="_blank">
<FormattedMessage
defaultMessage="View license options"
id="alertsUIShared.licenseCheck.actionTypeDisabledByLicenseLinkTitle"
/>
</EuiLink>
}
/>
),
};
};
export const configurationCheckResult = {
isEnabled: false,
message: i18n.translate(
'alertsUIShared.checkActionTypeEnabled.actionTypeDisabledByConfigMessage',
{ defaultMessage: 'This connector is disabled by the Kibana configuration.' }
),
messageCard: (
<EuiCard
title={i18n.translate('alertsUIShared.licenseCheck.actionTypeDisabledByConfigMessageTitle', {
defaultMessage: 'This feature is disabled by the Kibana configuration.',
})}
description=""
className="actCheckActionTypeEnabled__disabledActionWarningCard"
/>
),
};

View file

@ -0,0 +1,126 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { RuleTypeModel, RuleTypeParams, RuleTypeWithDescription } from '../../common';
import { getActionGroups, getSelectedActionGroup } from './get_selected_action_group';
describe('getActionGroups', () => {
test('should get action groups when setting recovery context', () => {
const actionGroups = getActionGroups({
ruleType: {
actionGroups: [
{
id: 'group-1',
name: 'group-1',
},
{
id: 'group-2',
name: 'group-2',
},
],
recoveryActionGroup: {
id: 'group-1',
},
doesSetRecoveryContext: true,
} as RuleTypeWithDescription,
ruleTypeModel: {
defaultRecoveryMessage: 'default recovery message',
defaultActionMessage: 'default action message',
} as RuleTypeModel<RuleTypeParams>,
});
expect(actionGroups).toEqual([
{
defaultActionMessage: 'default recovery message',
id: 'group-1',
name: 'group-1',
omitMessageVariables: 'keepContext',
},
{
defaultActionMessage: 'default action message',
id: 'group-2',
name: 'group-2',
},
]);
});
test('should get action groups when not setting recovery context', () => {
const actionGroups = getActionGroups({
ruleType: {
actionGroups: [
{
id: 'group-1',
name: 'group-1',
},
{
id: 'group-2',
name: 'group-2',
},
],
recoveryActionGroup: {
id: 'group-1',
},
doesSetRecoveryContext: false,
} as RuleTypeWithDescription,
ruleTypeModel: {
defaultRecoveryMessage: 'default recovery message',
defaultActionMessage: 'default action message',
} as RuleTypeModel<RuleTypeParams>,
});
expect(actionGroups).toEqual([
{
defaultActionMessage: 'default recovery message',
id: 'group-1',
name: 'group-1',
omitMessageVariables: 'all',
},
{
defaultActionMessage: 'default action message',
id: 'group-2',
name: 'group-2',
},
]);
});
});
describe('getSelectedActionGroup', () => {
test('should get selected action group', () => {
const result = getSelectedActionGroup({
group: 'group-1',
ruleType: {
actionGroups: [
{
id: 'group-1',
name: 'group-1',
},
{
id: 'group-2',
name: 'group-2',
},
],
recoveryActionGroup: {
id: 'group-1',
},
doesSetRecoveryContext: false,
} as RuleTypeWithDescription,
ruleTypeModel: {
defaultRecoveryMessage: 'default recovery message',
defaultActionMessage: 'default action message',
} as RuleTypeModel<RuleTypeParams>,
});
expect(result).toEqual({
defaultActionMessage: 'default recovery message',
id: 'group-1',
name: 'group-1',
omitMessageVariables: 'all',
});
});
});

View file

@ -0,0 +1,55 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
import { RuleTypeModel, RuleTypeParams, RuleTypeWithDescription } from '../../common';
const recoveredActionGroupMessage = i18n.translate(
'alertsUIShared.actionForm.actionGroupRecoveredMessage',
{
defaultMessage: 'Recovered',
}
);
export const getActionGroups = ({
ruleType,
ruleTypeModel,
}: {
ruleType: RuleTypeWithDescription;
ruleTypeModel: RuleTypeModel<RuleTypeParams>;
}) => {
return ruleType.actionGroups.map((item) =>
item.id === ruleType.recoveryActionGroup.id
? {
...item,
omitMessageVariables: ruleType.doesSetRecoveryContext ? 'keepContext' : 'all',
defaultActionMessage: ruleTypeModel.defaultRecoveryMessage || recoveredActionGroupMessage,
}
: { ...item, defaultActionMessage: ruleTypeModel.defaultActionMessage }
);
};
export const getSelectedActionGroup = ({
group,
ruleType,
ruleTypeModel,
}: {
group: string;
ruleType: RuleTypeWithDescription;
ruleTypeModel: RuleTypeModel<RuleTypeParams>;
}) => {
const actionGroups = getActionGroups({
ruleType,
ruleTypeModel,
});
const defaultActionGroup = actionGroups?.find(({ id }) => id === ruleType.defaultActionGroupId);
return actionGroups?.find(({ id }) => id === group) ?? defaultActionGroup;
};

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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { AlertConsumers, ES_QUERY_ID } from '@kbn/rule-data-utils';
import { RuleTypeWithDescription } from '../../common/types';
import { hasFieldsForAad } from './has_fields_for_aad';
describe('hasFieldsForAad', () => {
test('should return true if alert has fields for add', () => {
const hasFields = hasFieldsForAad({
ruleType: {
hasFieldsForAAD: true,
} as RuleTypeWithDescription,
consumer: 'stackAlerts',
validConsumers: [],
});
expect(hasFields).toBeTruthy();
});
test('should return true if producer is SIEM', () => {
const hasFields = hasFieldsForAad({
ruleType: {
hasFieldsForAAD: false,
producer: AlertConsumers.SIEM,
} as RuleTypeWithDescription,
consumer: 'stackAlerts',
validConsumers: [],
});
expect(hasFields).toBeTruthy();
});
test('should return true if has alerts mappings', () => {
const hasFields = hasFieldsForAad({
ruleType: {
hasFieldsForAAD: false,
hasAlertsMappings: true,
} as RuleTypeWithDescription,
consumer: 'stackAlerts',
validConsumers: [],
});
expect(hasFields).toBeTruthy();
});
test('should return true if it is a multi-consumer rule and valid consumer contains it', () => {
const hasFields = hasFieldsForAad({
ruleType: {
hasFieldsForAAD: true,
id: ES_QUERY_ID,
} as RuleTypeWithDescription,
consumer: 'stackAlerts',
validConsumers: ['stackAlerts'],
});
expect(hasFields).toBeTruthy();
});
test('should return false if it is a multi-consumer rule and valid consumer does not contain it', () => {
const hasFields = hasFieldsForAad({
ruleType: {
hasFieldsForAAD: true,
id: ES_QUERY_ID,
} as RuleTypeWithDescription,
consumer: 'stackAlerts',
validConsumers: ['logs'],
});
expect(hasFields).toBeFalsy();
});
});

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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { AlertConsumers, RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import { RuleTypeWithDescription } from '../../common/types';
import { DEFAULT_VALID_CONSUMERS, MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants';
export const hasFieldsForAad = ({
ruleType,
consumer,
validConsumers,
}: {
ruleType: RuleTypeWithDescription;
consumer: string;
validConsumers: RuleCreationValidConsumer[];
}) => {
const hasAlertHasData = ruleType
? ruleType.hasFieldsForAAD ||
ruleType.producer === AlertConsumers.SIEM ||
ruleType.hasAlertsMappings
: false;
if (MULTI_CONSUMER_RULE_TYPE_IDS.includes(ruleType.id)) {
return !!(
(validConsumers || DEFAULT_VALID_CONSUMERS).includes(consumer as RuleCreationValidConsumer) &&
hasAlertHasData
);
}
return !!hasAlertHasData;
};

View file

@ -14,4 +14,6 @@ export * from './get_authorized_rule_types';
export * from './get_authorized_consumers';
export * from './get_initial_multi_consumer';
export * from './get_initial_schedule';
export * from './has_fields_for_aad';
export * from './get_selected_action_group';
export * from './get_initial_consumer';

View file

@ -8,3 +8,4 @@
*/
export * from './validate_form';
export * from './validate_params_for_warnings';

View file

@ -7,7 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { validateRuleBase, validateRuleParams, hasRuleErrors } from './validate_form';
import {
validateRuleBase,
validateRuleParams,
hasRuleErrors,
validateAction,
} from './validate_form';
import { RuleFormData } from '../types';
import {
CONSUMER_REQUIRED_TEXT,
@ -19,6 +24,7 @@ import {
} from '../translations';
import { formatDuration } from '../utils';
import { RuleTypeModel } from '../../common';
import { getAction } from '../../common/test_utils/actions_test_utils';
const formDataMock: RuleFormData = {
params: {
@ -32,6 +38,7 @@ const formDataMock: RuleFormData = {
index: ['.kibana'],
timeField: 'alert.executionStatus.lastExecutionDate',
},
actions: [],
consumer: 'stackAlerts',
schedule: { interval: '1m' },
tags: [],
@ -50,6 +57,23 @@ const ruleTypeModelMock = {
}),
};
describe('validateAction', () => {
test('should validate filter query', () => {
const result = validateAction({
action: getAction('1', {
alertsFilter: {
query: {
kql: '',
filters: [],
},
},
}),
});
expect(result).toEqual({ filterQuery: ['A custom query is required.'] });
});
});
describe('validateRuleBase', () => {
test('should validate name', () => {
const result = validateRuleBase({
@ -153,6 +177,8 @@ describe('hasRuleErrors', () => {
const result = hasRuleErrors({
baseErrors: {},
paramsErrors: {},
actionsErrors: {},
actionsParamsErrors: {},
});
expect(result).toBeFalsy();
@ -164,6 +190,8 @@ describe('hasRuleErrors', () => {
name: ['error'],
},
paramsErrors: {},
actionsErrors: {},
actionsParamsErrors: {},
});
expect(result).toBeTruthy();
@ -175,6 +203,19 @@ describe('hasRuleErrors', () => {
paramsErrors: {
someValue: ['error'],
},
actionsErrors: {},
actionsParamsErrors: {},
});
expect(result).toBeTruthy();
result = hasRuleErrors({
baseErrors: {},
paramsErrors: {
someValue: 'error',
},
actionsErrors: {},
actionsParamsErrors: {},
});
expect(result).toBeTruthy();
@ -186,6 +227,8 @@ describe('hasRuleErrors', () => {
someValue: ['error'],
},
},
actionsErrors: {},
actionsParamsErrors: {},
});
expect(result).toBeTruthy();

View file

@ -8,6 +8,7 @@
*/
import { isObject } from 'lodash';
import { i18n } from '@kbn/i18n';
import { RuleFormData } from '../types';
import { parseDuration, formatDuration } from '../utils';
import {
@ -20,11 +21,32 @@ import {
} from '../translations';
import {
MinimumScheduleInterval,
RuleFormActionsErrors,
RuleFormBaseErrors,
RuleFormParamsErrors,
RuleTypeModel,
RuleUiAction,
} from '../../common';
export const validateAction = ({ action }: { action: RuleUiAction }): RuleFormActionsErrors => {
const errors = {
filterQuery: new Array<string>(),
};
if ('alertsFilter' in action) {
const query = action?.alertsFilter?.query;
if (query && !query.kql) {
errors.filterQuery.push(
i18n.translate('alertsUIShared.ruleForm.actionsForm.requiredFilterQuery', {
defaultMessage: 'A custom query is required.',
})
);
}
}
return errors;
};
export function validateRuleBase({
formData,
minimumScheduleInterval,
@ -93,7 +115,13 @@ const hasRuleBaseErrors = (errors: RuleFormBaseErrors) => {
return Object.values(errors).some((error: string[]) => error.length > 0);
};
const hasRuleParamsErrors = (errors: RuleFormParamsErrors): boolean => {
const hasActionsError = (actionsErrors: Record<string, RuleFormActionsErrors>) => {
return Object.values(actionsErrors).some((errors: RuleFormActionsErrors) => {
return Object.values(errors).some((error: string[]) => error.length > 0);
});
};
const hasParamsErrors = (errors: RuleFormParamsErrors): boolean => {
const values = Object.values(errors);
let hasError = false;
for (const value of values) {
@ -104,18 +132,33 @@ const hasRuleParamsErrors = (errors: RuleFormParamsErrors): boolean => {
return true;
}
if (isObject(value)) {
hasError = hasRuleParamsErrors(value as RuleFormParamsErrors);
hasError = hasParamsErrors(value as RuleFormParamsErrors);
}
}
return hasError;
};
const hasActionsParamsErrors = (actionsParamsErrors: Record<string, RuleFormParamsErrors>) => {
return Object.values(actionsParamsErrors).some((errors: RuleFormParamsErrors) => {
return hasParamsErrors(errors);
});
};
export const hasRuleErrors = ({
baseErrors,
paramsErrors,
actionsErrors,
actionsParamsErrors,
}: {
baseErrors: RuleFormBaseErrors;
paramsErrors: RuleFormParamsErrors;
actionsErrors: Record<string, RuleFormActionsErrors>;
actionsParamsErrors: Record<string, RuleFormParamsErrors>;
}): boolean => {
return hasRuleBaseErrors(baseErrors) || hasRuleParamsErrors(paramsErrors);
return (
hasRuleBaseErrors(baseErrors) ||
hasParamsErrors(paramsErrors) ||
hasActionsError(actionsErrors) ||
hasActionsParamsErrors(actionsParamsErrors)
);
};

View file

@ -0,0 +1,87 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ActionVariable } from '@kbn/alerting-types';
import { validateParamsForWarnings } from './validate_params_for_warnings';
describe('validateParamsForWarnings', () => {
const actionVariables: ActionVariable[] = [
{
name: 'context.url',
description: 'Test url',
usesPublicBaseUrl: true,
},
{
name: 'context.name',
description: 'Test name',
},
];
test('returns warnings when publicUrl is not set and there are publicUrl variables used', () => {
const warning =
'server.publicBaseUrl is not set. Generated URLs will be either relative or empty.';
expect(
validateParamsForWarnings({
value: 'Test for {{context.url}}',
actionVariables,
})
).toEqual(warning);
expect(
validateParamsForWarnings({
value: 'link: {{ context.url }}',
actionVariables,
})
).toEqual(warning);
expect(
validateParamsForWarnings({
value: '{{=<% %>=}}link: <%context.url%>',
actionVariables,
})
).toEqual(warning);
});
test('does not return warnings when publicUrl is not set and there are publicUrl variables not used', () => {
expect(
validateParamsForWarnings({
value: 'Test for {{context.name}}',
actionVariables,
})
).toBeFalsy();
});
test('does not return warnings when publicUrl is set and there are publicUrl variables used', () => {
expect(
validateParamsForWarnings({
value: 'Test for {{context.url}}',
publicBaseUrl: 'http://test',
actionVariables,
})
).toBeFalsy();
});
test('does not returns warnings when publicUrl is not set and the value is not a string', () => {
expect(
validateParamsForWarnings({
value: 10,
actionVariables,
})
).toBeFalsy();
});
test('does not throw an error when passing in invalid mustache', () => {
expect(() =>
validateParamsForWarnings({
value: '{{',
actionVariables,
})
).not.toThrowError();
});
});

View file

@ -0,0 +1,55 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
import Mustache from 'mustache';
import { some } from 'lodash';
import { ActionVariable, RuleActionParam } from '@kbn/alerting-types';
const publicUrlWarning = i18n.translate('alertsUIShared.ruleForm.actionsForm.publicBaseUrl', {
defaultMessage:
'server.publicBaseUrl is not set. Generated URLs will be either relative or empty.',
});
export const validateParamsForWarnings = ({
value,
publicBaseUrl,
actionVariables,
}: {
value: RuleActionParam;
publicBaseUrl?: string;
actionVariables?: ActionVariable[];
}): string | null => {
if (!publicBaseUrl && value && typeof value === 'string') {
const publicUrlFields = (actionVariables || []).reduce((acc, v) => {
if (v.usesPublicBaseUrl) {
acc.push(v.name.replace(/^(params\.|context\.|state\.)/, ''));
acc.push(v.name);
}
return acc;
}, new Array<string>());
try {
const variables = new Set(
(Mustache.parse(value) as Array<[string, string]>)
.filter(([type]) => type === 'name')
.map(([, v]) => v)
);
const hasUrlFields = some(publicUrlFields, (publicUrlField) => variables.has(publicUrlField));
if (hasUrlFields) {
return publicUrlWarning;
}
} catch (e) {
// Better to set the warning msg if you do not know if the mustache template is invalid
return publicUrlWarning;
}
}
return null;
};

View file

@ -49,5 +49,6 @@
"@kbn/core-ui-settings-browser",
"@kbn/core-http-browser-mocks",
"@kbn/core-notifications-browser-mocks",
"@kbn/kibana-react-plugin"
]
}

View file

@ -38,16 +38,14 @@ import { RuleStatusFilterSandbox } from './components/rule_status_filter_sandbox
import { AlertsTableSandbox } from './components/alerts_table_sandbox';
import { RulesSettingsLinkSandbox } from './components/rules_settings_link_sandbox';
import { RuleActionsSandbox } from './components/rule_form/rule_actions_sandbox';
import { RuleDetailsSandbox } from './components/rule_form/rule_details_sandbox';
export interface TriggersActionsUiExampleComponentParams {
http: CoreStart['http'];
notification: CoreStart['notifications'];
notifications: CoreStart['notifications'];
application: CoreStart['application'];
docLinks: CoreStart['docLinks'];
i18n: CoreStart['i18n'];
theme: CoreStart['theme'];
settings: CoreStart['settings'];
history: ScopedHistory;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
data: DataPublicPluginStart;
@ -62,7 +60,8 @@ const TriggersActionsUiExampleApp = ({
triggersActionsUi,
http,
application,
notification,
notifications,
settings,
docLinks,
i18n,
theme,
@ -192,7 +191,7 @@ const TriggersActionsUiExampleApp = ({
plugins={{
http,
application,
notification,
notifications,
docLinks,
i18n,
theme,
@ -200,7 +199,9 @@ const TriggersActionsUiExampleApp = ({
data,
dataViews,
unifiedSearch,
settings,
ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry,
actionTypeRegistry: triggersActionsUi.actionTypeRegistry,
}}
returnUrl={application.getUrlForApp('triggersActionsUiExample')}
/>
@ -216,7 +217,7 @@ const TriggersActionsUiExampleApp = ({
plugins={{
http,
application,
notification,
notifications,
docLinks,
theme,
i18n,
@ -224,31 +225,15 @@ const TriggersActionsUiExampleApp = ({
data,
dataViews,
unifiedSearch,
settings,
ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry,
actionTypeRegistry: triggersActionsUi.actionTypeRegistry,
}}
returnUrl={application.getUrlForApp('triggersActionsUiExample')}
/>
</Page>
)}
/>
<Route
exact
path="/rule_actions"
render={() => (
<Page title="Rule Actions">
<RuleActionsSandbox />
</Page>
)}
/>
<Route
exact
path="/rule_details"
render={() => (
<Page title="Rule Details">
<RuleDetailsSandbox />
</Page>
)}
/>
</Routes>
</EuiPage>
</Router>
@ -262,7 +247,7 @@ export const renderApp = (
deps: TriggersActionsUiExamplePublicStartDeps,
{ appBasePath, element, history }: AppMountParameters
) => {
const { http, notifications, docLinks, application, i18n, theme } = core;
const { http, notifications, docLinks, application, i18n, theme, settings } = core;
const { triggersActionsUi } = deps;
const { ruleTypeRegistry, actionTypeRegistry } = triggersActionsUi;
@ -281,11 +266,12 @@ export const renderApp = (
<TriggersActionsUiExampleApp
history={history}
http={http}
notification={notifications}
notifications={notifications}
application={application}
docLinks={docLinks}
i18n={i18n}
theme={theme}
settings={settings}
triggersActionsUi={deps.triggersActionsUi}
data={deps.data}
charts={deps.charts}

View file

@ -1,13 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { RuleActions } from '@kbn/alerts-ui-shared/src/rule_form';
export const RuleActionsSandbox = () => {
return <RuleActions onClick={() => {}} />;
};

View file

@ -1,13 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { RuleDetails } from '@kbn/alerts-ui-shared/src/rule_form';
export const RuleDetailsSandbox = () => {
return <RuleDetails />;
};

View file

@ -89,16 +89,6 @@ export const Sidebar = ({ history }: { history: ScopedHistory }) => {
name: 'Rule Edit',
onClick: () => history.push('/rule/edit/test'),
},
{
id: 'rule-actions',
name: 'Rule Actions',
onClick: () => history.push('/rule_actions'),
},
{
id: 'rule-details',
name: 'Rule Details',
onClick: () => history.push('/rule_details'),
},
],
},
]}

View file

@ -45573,8 +45573,6 @@
"xpack.triggersActionsUI.bulkActions.selectRowCheckbox.AriaLabel": "Sélectionner la ligne {displayedRowIndex}",
"xpack.triggersActionsUI.cases.api.bulkGet": "Erreur lors de la récupération des données sur les cas",
"xpack.triggersActionsUI.cases.label": "Cas",
"xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByConfigMessage": "Ce connecteur est désactivé par la configuration de Kibana.",
"xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByLicenseMessage": "Ce connecteur requiert une licence {minimumLicenseRequired}.",
"xpack.triggersActionsUI.checkRuleTypeEnabled.ruleTypeDisabledByLicenseMessage": "Ce type de règle requiert une licence {minimumLicenseRequired}.",
"xpack.triggersActionsUI.common.constants.comparators.groupByTypes.allDocumentsLabel": "tous les documents",
"xpack.triggersActionsUI.common.constants.comparators.groupByTypes.topLabel": "premiers",
@ -45704,10 +45702,6 @@
"xpack.triggersActionsUI.inspect.modal.somethingWentWrongDescription": "Désolé, un problème est survenu.",
"xpack.triggersActionsUI.inspectDescription": "Inspecter",
"xpack.triggersActionsUI.jsonFieldWrapper.defaultLabel": "Éditeur JSON",
"xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByConfigMessageTitle": "Cette fonctionnalité est désactivée par la configuration de Kibana.",
"xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseLinkTitle": "Afficher les options de licence",
"xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseMessageDescription": "Pour réactiver cette action, veuillez mettre à niveau votre licence.",
"xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseMessageTitle": "Cette fonctionnalité requiert une licence {minimumLicenseRequired}.",
"xpack.triggersActionsUI.logs.breadcrumbTitle": "Logs",
"xpack.triggersActionsUI.maintenanceWindows.label": "Fenêtres de maintenance",
"xpack.triggersActionsUI.managementSection.alerts.displayDescription": "Monitorer toutes vos alertes au même endroit",
@ -45851,8 +45845,6 @@
"xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadConnectorTypesMessage": "Impossible de charger les types de connecteurs",
"xpack.triggersActionsUI.sections.actionsConnectorsList.warningText": "{connectors, plural, one {Ce connecteur est} other {Certains connecteurs sont}} actuellement en cours d'utilisation.",
"xpack.triggersActionsUI.sections.actionTypeForm.accordion.deleteIconAriaLabel": "Supprimer",
"xpack.triggersActionsUI.sections.actionTypeForm.ActionAlertsFilterQueryPlaceholder": "Filtrer les alertes à l'aide de la syntaxe KQL",
"xpack.triggersActionsUI.sections.actionTypeForm.ActionAlertsFilterQueryToggleLabel": "Si l'alerte correspond à une requête",
"xpack.triggersActionsUI.sections.actionTypeForm.actionDisabledTitle": "Cette action est désactivée",
"xpack.triggersActionsUI.sections.actionTypeForm.actionErrorToolTip": "Laction contient des erreurs.",
"xpack.triggersActionsUI.sections.actionTypeForm.actionIdLabel": "Connecteur {connectorInstance}",

View file

@ -45315,8 +45315,6 @@
"xpack.triggersActionsUI.bulkActions.selectRowCheckbox.AriaLabel": "行\"{displayedRowIndex}\"を選択",
"xpack.triggersActionsUI.cases.api.bulkGet": "ケースデータの取得エラー",
"xpack.triggersActionsUI.cases.label": "ケース",
"xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByConfigMessage": "このコネクターは Kibana の構成で無効になっています。",
"xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByLicenseMessage": "このコネクターには {minimumLicenseRequired} ライセンスが必要です。",
"xpack.triggersActionsUI.checkRuleTypeEnabled.ruleTypeDisabledByLicenseMessage": "このルールタイプには{minimumLicenseRequired}ライセンスが必要です。",
"xpack.triggersActionsUI.common.constants.comparators.groupByTypes.allDocumentsLabel": "すべてのドキュメント",
"xpack.triggersActionsUI.common.constants.comparators.groupByTypes.topLabel": "トップ",
@ -45445,10 +45443,6 @@
"xpack.triggersActionsUI.inspect.modal.somethingWentWrongDescription": "申し訳ございませんが、何か問題が発生しました。",
"xpack.triggersActionsUI.inspectDescription": "検査",
"xpack.triggersActionsUI.jsonFieldWrapper.defaultLabel": "JSONエディター",
"xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByConfigMessageTitle": "この機能は Kibana の構成で無効になっています。",
"xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseLinkTitle": "ライセンスオプションを表示",
"xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseMessageDescription": "このアクションを再び有効にするには、ライセンスをアップグレードしてください。",
"xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseMessageTitle": "この機能には {minimumLicenseRequired} ライセンスが必要です。",
"xpack.triggersActionsUI.logs.breadcrumbTitle": "ログ",
"xpack.triggersActionsUI.maintenanceWindows.label": "保守時間枠",
"xpack.triggersActionsUI.managementSection.alerts.displayDescription": "すべてのアラートを1か所で監視",
@ -45592,8 +45586,6 @@
"xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadConnectorTypesMessage": "コネクタータイプを読み込めません",
"xpack.triggersActionsUI.sections.actionsConnectorsList.warningText": "{connectors, plural, one {このコネクターは} other {一部のコネクターが}}現在使用中です。",
"xpack.triggersActionsUI.sections.actionTypeForm.accordion.deleteIconAriaLabel": "削除",
"xpack.triggersActionsUI.sections.actionTypeForm.ActionAlertsFilterQueryPlaceholder": "KQL構文を使用してアラートをフィルター",
"xpack.triggersActionsUI.sections.actionTypeForm.ActionAlertsFilterQueryToggleLabel": "アラートがクエリと一致する場合",
"xpack.triggersActionsUI.sections.actionTypeForm.actionDisabledTitle": "このアクションは無効です",
"xpack.triggersActionsUI.sections.actionTypeForm.actionErrorToolTip": "アクションにはエラーがあります。",
"xpack.triggersActionsUI.sections.actionTypeForm.actionIdLabel": "{connectorInstance}コネクター",

View file

@ -45368,8 +45368,6 @@
"xpack.triggersActionsUI.bulkActions.selectRowCheckbox.AriaLabel": "选择行 {displayedRowIndex}",
"xpack.triggersActionsUI.cases.api.bulkGet": "提取案例数据时出错",
"xpack.triggersActionsUI.cases.label": "案例",
"xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByConfigMessage": "连接器已由 Kibana 配置禁用。",
"xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByLicenseMessage": "此连接器需要{minimumLicenseRequired}许可证。",
"xpack.triggersActionsUI.checkRuleTypeEnabled.ruleTypeDisabledByLicenseMessage": "此规则类型需要{minimumLicenseRequired}许可证。",
"xpack.triggersActionsUI.common.constants.comparators.groupByTypes.allDocumentsLabel": "所有文档",
"xpack.triggersActionsUI.common.constants.comparators.groupByTypes.topLabel": "排名前",
@ -45498,10 +45496,6 @@
"xpack.triggersActionsUI.inspect.modal.somethingWentWrongDescription": "抱歉,出现问题。",
"xpack.triggersActionsUI.inspectDescription": "检查",
"xpack.triggersActionsUI.jsonFieldWrapper.defaultLabel": "JSON 编辑器",
"xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByConfigMessageTitle": "此功能已由 Kibana 配置禁用。",
"xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseLinkTitle": "查看许可证选项",
"xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseMessageDescription": "要重新启用此操作,请升级您的许可证。",
"xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseMessageTitle": "此功能需要{minimumLicenseRequired}许可证。",
"xpack.triggersActionsUI.logs.breadcrumbTitle": "日志",
"xpack.triggersActionsUI.maintenanceWindows.label": "维护窗口",
"xpack.triggersActionsUI.managementSection.alerts.displayDescription": "在一个位置监测您的所有告警",
@ -45645,8 +45639,6 @@
"xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadConnectorTypesMessage": "无法加载连接器类型",
"xpack.triggersActionsUI.sections.actionsConnectorsList.warningText": "{connectors, plural, one {此连接器} other {这些连接器}}当前正在使用中。",
"xpack.triggersActionsUI.sections.actionTypeForm.accordion.deleteIconAriaLabel": "删除",
"xpack.triggersActionsUI.sections.actionTypeForm.ActionAlertsFilterQueryPlaceholder": "使用 KQL 语法筛选告警",
"xpack.triggersActionsUI.sections.actionTypeForm.ActionAlertsFilterQueryToggleLabel": "如果告警与查询匹配",
"xpack.triggersActionsUI.sections.actionTypeForm.actionDisabledTitle": "此操作已禁用",
"xpack.triggersActionsUI.sections.actionTypeForm.actionErrorToolTip": "操作包含错误。",
"xpack.triggersActionsUI.sections.actionTypeForm.actionIdLabel": "{connectorInstance} 连接器",

View file

@ -1,121 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { upperFirst } from 'lodash';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiCard, EuiLink } from '@elastic/eui';
import { ActionType, ActionConnector } from '../../types';
import { VIEW_LICENSE_OPTIONS_LINK } from '../../common/constants';
import './check_action_type_enabled.scss';
export interface IsEnabledResult {
isEnabled: true;
}
export interface IsDisabledResult {
isEnabled: false;
message: string;
messageCard: JSX.Element;
}
const getLicenseCheckResult = (actionType: ActionType) => {
return {
isEnabled: false,
message: i18n.translate(
'xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByLicenseMessage',
{
defaultMessage: 'This connector requires a {minimumLicenseRequired} license.',
values: {
minimumLicenseRequired: upperFirst(actionType.minimumLicenseRequired),
},
}
),
messageCard: (
<EuiCard
titleSize="xs"
title={i18n.translate(
'xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseMessageTitle',
{
defaultMessage: 'This feature requires a {minimumLicenseRequired} license.',
values: {
minimumLicenseRequired: upperFirst(actionType.minimumLicenseRequired),
},
}
)}
// The "re-enable" terminology is used here because this message is used when an alert
// action was previously enabled and needs action to be re-enabled.
description={i18n.translate(
'xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseMessageDescription',
{ defaultMessage: 'To re-enable this action, please upgrade your license.' }
)}
className="actCheckActionTypeEnabled__disabledActionWarningCard"
children={
<EuiLink href={VIEW_LICENSE_OPTIONS_LINK} target="_blank">
<FormattedMessage
defaultMessage="View license options"
id="xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByLicenseLinkTitle"
/>
</EuiLink>
}
/>
),
};
};
const configurationCheckResult = {
isEnabled: false,
message: i18n.translate(
'xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByConfigMessage',
{ defaultMessage: 'This connector is disabled by the Kibana configuration.' }
),
messageCard: (
<EuiCard
title={i18n.translate(
'xpack.triggersActionsUI.licenseCheck.actionTypeDisabledByConfigMessageTitle',
{ defaultMessage: 'This feature is disabled by the Kibana configuration.' }
)}
description=""
className="actCheckActionTypeEnabled__disabledActionWarningCard"
/>
),
};
export function checkActionTypeEnabled(
actionType?: ActionType
): IsEnabledResult | IsDisabledResult {
if (actionType?.enabledInLicense === false) {
return getLicenseCheckResult(actionType);
}
if (actionType?.enabledInConfig === false) {
return configurationCheckResult;
}
return { isEnabled: true };
}
export function checkActionFormActionTypeEnabled(
actionType: ActionType,
preconfiguredConnectors: ActionConnector[]
): IsEnabledResult | IsDisabledResult {
if (actionType?.enabledInLicense === false) {
return getLicenseCheckResult(actionType);
}
if (
actionType?.enabledInConfig === false &&
// do not disable action type if it contains preconfigured connectors (is preconfigured)
!preconfiguredConnectors.find(
(preconfiguredConnector) => preconfiguredConnector.actionTypeId === actionType.id
)
) {
return configurationCheckResult;
}
return { isEnabled: true };
}

View file

@ -29,6 +29,7 @@ import {
} from '@kbn/alerting-plugin/common';
import { v4 as uuidv4 } from 'uuid';
import { ActionGroupWithMessageVariables } from '@kbn/triggers-actions-ui-types';
import { checkActionFormActionTypeEnabled } from '@kbn/alerts-ui-shared/src/rule_form/utils/check_action_type_enabled';
import { TECH_PREVIEW_DESCRIPTION, TECH_PREVIEW_LABEL } from '../translations';
import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api';
import {
@ -45,7 +46,6 @@ import { SectionLoading } from '../../components/section_loading';
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';
import { DEFAULT_FREQUENCY, VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants';
import { useKibana } from '../../../common/lib/kibana';
import { ConnectorAddModal } from '.';
@ -552,7 +552,6 @@ export const ActionForm = ({
hasAlertsMappings={hasAlertsMappings}
minimumThrottleInterval={minimumThrottleInterval}
notifyWhenSelectOptions={notifyWhenSelectOptions}
defaultNotifyWhenValue={defaultRuleFrequency.notifyWhen}
featureId={featureId}
producerId={producerId}
ruleTypeId={ruleTypeId}

View file

@ -21,11 +21,7 @@ import { EuiFieldText } from '@elastic/eui';
import { I18nProvider, __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { render, waitFor, screen } from '@testing-library/react';
import { DEFAULT_FREQUENCY } from '../../../common/constants';
import {
RuleNotifyWhen,
RuleNotifyWhenType,
SanitizedRuleAction,
} from '@kbn/alerting-plugin/common';
import { RuleNotifyWhen, SanitizedRuleAction } from '@kbn/alerting-plugin/common';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { transformActionVariables } from '@kbn/alerts-ui-shared/src/action_variables/transforms';
@ -557,7 +553,6 @@ describe('action_type_form', () => {
index: 1,
actionItem,
notifyWhenSelectOptions: CUSTOM_NOTIFY_WHEN_OPTIONS,
defaultNotifyWhenValue: RuleNotifyWhen.ACTIVE,
})}
</IntlProvider>
);
@ -611,7 +606,6 @@ describe('action_type_form', () => {
index: 1,
actionItem,
notifyWhenSelectOptions: CUSTOM_NOTIFY_WHEN_OPTIONS,
defaultNotifyWhenValue: RuleNotifyWhen.ACTIVE,
})}
</IntlProvider>
);
@ -650,7 +644,6 @@ function getActionTypeForm({
messageVariables = { context: [], state: [], params: [] },
summaryMessageVariables = { context: [], state: [], params: [] },
notifyWhenSelectOptions,
defaultNotifyWhenValue,
ruleTypeId,
producerId = AlertConsumers.INFRASTRUCTURE,
featureId = AlertConsumers.INFRASTRUCTURE,
@ -671,7 +664,6 @@ function getActionTypeForm({
messageVariables?: ActionVariables;
summaryMessageVariables?: ActionVariables;
notifyWhenSelectOptions?: NotifyWhenSelectOptions[];
defaultNotifyWhenValue?: RuleNotifyWhenType;
ruleTypeId?: string;
producerId?: string;
featureId?: string;
@ -766,7 +758,6 @@ function getActionTypeForm({
messageVariables={messageVariables}
summaryMessageVariables={summaryMessageVariables}
notifyWhenSelectOptions={notifyWhenSelectOptions}
defaultNotifyWhenValue={defaultNotifyWhenValue}
producerId={producerId}
featureId={featureId}
ruleTypeId={ruleTypeId}

View file

@ -35,8 +35,8 @@ import { isEmpty, partition, some } from 'lodash';
import {
ActionVariable,
RuleActionAlertsFilterProperty,
RuleActionFrequency,
RuleActionParam,
RuleNotifyWhenType,
} from '@kbn/alerting-plugin/common';
import {
getDurationNumberInItsUnit,
@ -46,6 +46,8 @@ import {
import type { SavedObjectAttribute } from '@kbn/core-saved-objects-api-server';
import { transformActionVariables } from '@kbn/alerts-ui-shared/src/action_variables/transforms';
import { RuleActionsNotifyWhen } from '@kbn/alerts-ui-shared/src/rule_form/rule_actions/rule_actions_notify_when';
import { RuleActionsAlertsFilter } from '@kbn/alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter';
import { checkActionFormActionTypeEnabled } from '@kbn/alerts-ui-shared/src/rule_form/utils/check_action_type_enabled';
import { RuleActionsAlertsFilterTimeframe } from '@kbn/alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter_timeframe';
import { ActionGroupWithMessageVariables } from '@kbn/triggers-actions-ui-types';
import { TECH_PREVIEW_DESCRIPTION, TECH_PREVIEW_LABEL } from '../translations';
@ -60,13 +62,11 @@ import {
ActionConnectorMode,
NotifyWhenSelectOptions,
} from '../../../types';
import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled';
import { hasSaveActionsCapability } from '../../lib/capabilities';
import { ActionAccordionFormProps } from './action_form';
import { useKibana } from '../../../common/lib/kibana';
import { ConnectorsSelection } from './connectors_selection';
import { validateParamsForWarnings } from '../../lib/validate_params_for_warnings';
import { ActionAlertsFilterQuery } from './action_alerts_filter_query';
import { validateActionFilterQuery } from '../../lib/value_validators';
import { useRuleTypeAadTemplateFields } from '../../hooks/use_rule_aad_template_fields';
@ -94,7 +94,6 @@ export type ActionTypeFormProps = {
hasAlertsMappings?: boolean;
minimumThrottleInterval?: [number | undefined, string];
notifyWhenSelectOptions?: NotifyWhenSelectOptions[];
defaultNotifyWhenValue?: RuleNotifyWhenType;
featureId: string;
producerId: string;
ruleTypeId?: string;
@ -146,7 +145,6 @@ export const ActionTypeForm = ({
hasAlertsMappings,
minimumThrottleInterval,
notifyWhenSelectOptions,
defaultNotifyWhenValue,
producerId,
featureId,
ruleTypeId,
@ -157,6 +155,9 @@ export const ActionTypeForm = ({
application: { capabilities },
settings,
http,
notifications,
unifiedSearch,
dataViews,
} = useKibana().services;
const { euiTheme } = useEuiTheme();
const [isOpen, setIsOpen] = useState(true);
@ -369,44 +370,32 @@ export const ActionTypeForm = ({
? isActionGroupDisabledForActionType(actionGroupId, actionTypeId)
: false;
const onActionFrequencyChange = (frequency: RuleActionFrequency | undefined) => {
const { notifyWhen, throttle, summary } = frequency || {};
setActionFrequencyProperty('notifyWhen', notifyWhen, index);
if (throttle) {
setActionThrottle(getDurationNumberInItsUnit(throttle));
setActionThrottleUnit(getDurationUnitValue(throttle));
}
setActionFrequencyProperty('throttle', throttle ? throttle : null, index);
setActionFrequencyProperty('summary', summary, index);
};
const actionNotifyWhen = (
<RuleActionsNotifyWhen
frequency={actionItem.frequency}
throttle={actionThrottle}
throttleUnit={actionThrottleUnit}
hasAlertsMappings={hasAlertsMappings}
onNotifyWhenChange={useCallback(
(notifyWhen: RuleNotifyWhenType) => {
setActionFrequencyProperty('notifyWhen', notifyWhen, index);
},
[setActionFrequencyProperty, index]
)}
onThrottleChange={useCallback(
(throttle: number | null, throttleUnit: string) => {
if (throttle) {
setActionThrottle(throttle);
setActionThrottleUnit(throttleUnit);
}
setActionFrequencyProperty(
'throttle',
throttle ? `${throttle}${throttleUnit}` : null,
index
);
},
[setActionFrequencyProperty, index]
)}
onSummaryChange={useCallback(
(summary: boolean) => {
// use the default message when a user toggles between action frequencies
setUseDefaultMessage(true);
setActionFrequencyProperty('summary', summary, index);
},
[setActionFrequencyProperty, index]
)}
onChange={onActionFrequencyChange}
showMinimumThrottleWarning={showMinimumThrottleWarning}
showMinimumThrottleUnitWarning={showMinimumThrottleUnitWarning}
notifyWhenSelectOptions={notifyWhenSelectOptions}
defaultNotifyWhenValue={defaultNotifyWhenValue}
onUseDefaultMessage={() => setUseDefaultMessage(true)}
/>
);
@ -515,17 +504,23 @@ export const ActionTypeForm = ({
<>
{!hideNotifyWhen && <EuiSpacer size="xl" />}
<EuiFormRow error={queryError} isInvalid={!!queryError} fullWidth>
<ActionAlertsFilterQuery
state={actionItem.alertsFilter?.query}
<RuleActionsAlertsFilter
action={actionItem}
onChange={(query) => setActionAlertsFilterProperty('query', query, index)}
featureIds={[producerId as ValidFeatureId]}
appName={featureId!}
ruleTypeId={ruleTypeId}
plugins={{
http,
unifiedSearch,
dataViews,
notifications,
}}
/>
</EuiFormRow>
<EuiSpacer size="s" />
<RuleActionsAlertsFilterTimeframe
state={actionItem.alertsFilter?.timeframe}
action={actionItem}
settings={settings}
onChange={(timeframe) => setActionAlertsFilterProperty('timeframe', timeframe, index)}
/>

View file

@ -10,11 +10,11 @@ import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid, EuiSpacer } from '@elastic/
import { i18n } from '@kbn/i18n';
import { EuiToolTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { checkActionTypeEnabled } from '@kbn/alerts-ui-shared/src/rule_form/utils/check_action_type_enabled';
import { TECH_PREVIEW_DESCRIPTION, TECH_PREVIEW_LABEL } from '../translations';
import { ActionType, ActionTypeIndex, ActionTypeRegistryContract } from '../../../types';
import { loadActionTypes } from '../../lib/action_connector_api';
import { actionTypeCompare } from '../../lib/action_type_compare';
import { checkActionTypeEnabled } from '../../lib/check_action_type_enabled';
import { useKibana } from '../../../common/lib/kibana';
import { SectionLoading } from '../../components/section_loading';

View file

@ -26,6 +26,7 @@ import { i18n } from '@kbn/i18n';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { getConnectorCompatibility } from '@kbn/actions-plugin/common';
import { FormattedMessage } from '@kbn/i18n-react';
import { checkActionTypeEnabled } from '@kbn/alerts-ui-shared/src/rule_form/utils/check_action_type_enabled';
import { loadActionTypes, deleteActions } from '../../../lib/action_connector_api';
import {
hasDeleteActionsCapability,
@ -33,7 +34,6 @@ import {
hasExecuteActionsCapability,
} from '../../../lib/capabilities';
import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation';
import { checkActionTypeEnabled } from '../../../lib/check_action_type_enabled';
import './actions_connectors_list.scss';
import {
ActionConnector,

View file

@ -8,6 +8,7 @@
import { Comparator, COMPARATORS } from '@kbn/alerting-comparators';
import { i18n } from '@kbn/i18n';
export { VIEW_LICENSE_OPTIONS_LINK } from '@kbn/alerts-ui-shared/src/common/constants';
export { AGGREGATION_TYPES, builtInAggregationTypes } from './aggregation_types';
export { loadAllActions, loadActionTypes } from '../../application/lib/action_connector_api';
export { ConnectorAddModal } from '../../application/sections/action_connector_form';
@ -16,8 +17,6 @@ export type { ActionConnector } from '../..';
export { builtInGroupByTypes } from './group_by_types';
export * from './action_frequency_types';
export const VIEW_LICENSE_OPTIONS_LINK = 'https://www.elastic.co/subscriptions';
export const PLUGIN_ID = 'triggersActions';
export const ALERTS_PAGE_ID = 'triggersActionsAlerts';
export const CONNECTORS_PLUGIN_ID = 'triggersActionsConnectors';