From 54659e8ae002aa68be3ee472ef12b3d3f546a1ea Mon Sep 17 00:00:00 2001 From: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com> Date: Thu, 26 Sep 2024 23:34:27 -0700 Subject: [PATCH] [Response Ops][Rule Form V2] Rule form v2: Actions Modal and Actions Form (#187434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Issue: https://github.com/elastic/kibana/issues/179105 Related PR: https://github.com/elastic/kibana/pull/180539 Final PR of the rule actions V2 PR (2/2 of the actions PRs). This PR contains the actions modal and actions form. This PR depends on https://github.com/elastic/kibana/pull/186490. I have also created a example plugin to demonstrate this PR. To access: 1. Run the branch with yarn start --run-examples 2. Navigate to http://localhost:5601/app/triggersActionsUiExample/rule/create/ (I use .es-query) 3. Create a rule 4. Navigate to http://localhost:5601/app/triggersActionsUiExample/rule/edit/ with the rule you just created to edit the rule Screenshot 2024-07-02 at 5 15 51 PM ![Screenshot 2024-07-08 at 10 53 44 PM](https://github.com/elastic/kibana/assets/74562234/07efade1-4b9c-485f-9833-84698dc29219) Screenshot 2024-07-02 at 5 15 58 PM ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine --- .../src/alerts_search_bar/index.tsx | 3 +- .../src/common/constants/index.ts | 2 + .../src/common/hooks/index.ts | 4 + .../hooks/use_load_connector_types.test.tsx | 8 +- .../common/hooks/use_load_connector_types.ts | 8 +- .../src/common/hooks/use_load_connectors.ts | 4 +- .../use_load_rule_type_aad_template_fields.ts | 7 +- .../src/common/hooks/use_resolve_rule.ts | 1 + .../common/test_utils/actions_test_utils.ts | 85 +++ .../src/common/types/rule_types.ts | 4 + .../src/rule_form/constants.ts | 3 + .../src/rule_form/create_rule_form.tsx | 52 +- .../src/rule_form/edit_rule_form.tsx | 69 +- .../hooks/use_load_dependencies.test.tsx | 112 +++ .../rule_form/hooks/use_load_dependencies.ts | 95 ++- .../rule_actions/rule_actions.test.tsx | 255 ++++++- .../rule_form/rule_actions/rule_actions.tsx | 117 ++- .../rule_actions_alerts_filter.test.tsx | 183 +++++ .../rule_actions_alerts_filter.tsx | 58 +- ...e_actions_alerts_filter_timeframe.test.tsx | 3 +- .../rule_actions_alerts_filter_timeframe.tsx | 20 +- .../rule_actions_connectors_modal.test.tsx | 322 ++++++++ .../rule_actions_connectors_modal.tsx | 348 +++++++++ .../rule_actions/rule_actions_item.test.tsx | 385 ++++++++++ .../rule_actions/rule_actions_item.tsx | 686 ++++++++++++++++++ .../rule_actions_message.test.tsx | 353 +++++++++ .../rule_actions/rule_actions_message.tsx | 138 ++++ .../rule_actions_notify_when.test.tsx | 5 +- .../rule_actions/rule_actions_notify_when.tsx | 140 ++-- .../rule_actions_settings.test.tsx | 432 +++++++++++ .../rule_actions/rule_actions_settings.tsx | 279 +++++++ .../rule_actions_system_actions_item.test.tsx | 263 +++++++ .../rule_actions_system_actions_item.tsx | 273 +++++++ .../rule_consumer_selection.test.tsx | 9 - .../rule_consumer_selection.tsx | 4 - .../rule_definition/rule_definition.test.tsx | 52 ++ .../rule_definition/rule_definition.tsx | 49 +- .../src/rule_form/rule_form_errors/index.ts | 1 + .../rule_form_action_permission_error.tsx | 34 + .../rule_form_state_reducer.test.tsx | 220 ++++++ .../rule_form_state_reducer.ts | 137 ++++ .../rule_form/rule_page/rule_page.test.tsx | 11 + .../src/rule_form/rule_page/rule_page.tsx | 33 +- .../rule_page/rule_page_footer.test.tsx | 3 + .../rule_form/rule_page/rule_page_footer.tsx | 35 +- .../rule_page_show_request_modal.test.tsx | 1 + .../rule_page_show_request_modal.tsx | 4 +- .../src/rule_form/translations.ts | 109 ++- .../src/rule_form/types.ts | 18 +- .../utils}/check_action_type_enabled.scss | 0 .../utils/check_action_type_enabled.test.ts | 13 +- .../utils/check_action_type_enabled.ts | 57 ++ .../utils/get_initial_multi_consumer.ts | 2 +- .../utils/get_license_check_result.tsx | 77 ++ .../utils/get_selected_action_group.test.ts | 126 ++++ .../utils/get_selected_action_group.ts | 55 ++ .../utils/has_fields_for_aad.test.ts | 78 ++ .../src/rule_form/utils/has_fields_for_aad.ts | 36 + .../src/rule_form/utils/index.ts | 2 + .../src/rule_form/validation/index.ts | 1 + .../validation/validate_form.test.ts | 45 +- .../src/rule_form/validation/validate_form.ts | 49 +- .../validate_params_for_warnings.test.ts | 87 +++ .../validate_params_for_warnings.ts | 55 ++ packages/kbn-alerts-ui-shared/tsconfig.json | 1 + .../public/application.tsx | 40 +- .../rule_form/rule_actions_sandbox.tsx | 13 - .../rule_form/rule_details_sandbox.tsx | 13 - .../public/components/sidebar.tsx | 10 - .../translations/translations/fr-FR.json | 8 - .../translations/translations/ja-JP.json | 8 - .../translations/translations/zh-CN.json | 8 - .../lib/check_action_type_enabled.tsx | 121 --- .../action_connector_form/action_form.tsx | 3 +- .../action_type_form.test.tsx | 11 +- .../action_type_form.tsx | 69 +- .../action_type_menu.tsx | 2 +- .../components/actions_connectors_list.tsx | 2 +- .../public/common/constants/index.ts | 3 +- 79 files changed, 5938 insertions(+), 494 deletions(-) create mode 100644 packages/kbn-alerts-ui-shared/src/common/test_utils/actions_test_utils.ts create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.test.tsx rename x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_alerts_filter_query.tsx => packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.tsx (56%) create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_connectors_modal.test.tsx create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_connectors_modal.tsx create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_item.test.tsx create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_item.tsx create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_message.test.tsx create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_message.tsx create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_settings.test.tsx create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_settings.tsx create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_system_actions_item.test.tsx create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_system_actions_item.tsx create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_action_permission_error.tsx rename {x-pack/plugins/triggers_actions_ui/public/application/lib => packages/kbn-alerts-ui-shared/src/rule_form/utils}/check_action_type_enabled.scss (100%) rename x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx => packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.test.ts (89%) create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.ts create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/utils/get_license_check_result.tsx create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/utils/get_selected_action_group.test.ts create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/utils/get_selected_action_group.ts create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/utils/has_fields_for_aad.test.ts create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/utils/has_fields_for_aad.ts create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_params_for_warnings.test.ts create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_params_for_warnings.ts delete mode 100644 x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_actions_sandbox.tsx delete mode 100644 x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_details_sandbox.tsx delete mode 100644 x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.tsx diff --git a/packages/kbn-alerts-ui-shared/src/alerts_search_bar/index.tsx b/packages/kbn-alerts-ui-shared/src/alerts_search_bar/index.tsx index 3cdb3b5b08a0c..efda77df2c406 100644 --- a/packages/kbn-alerts-ui-shared/src/alerts_search_bar/index.tsx +++ b/packages/kbn-alerts-ui-shared/src/alerts_search_bar/index.tsx @@ -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 = ({ diff --git a/packages/kbn-alerts-ui-shared/src/common/constants/index.ts b/packages/kbn-alerts-ui-shared/src/common/constants/index.ts index d58549e99592a..5213094216c6b 100644 --- a/packages/kbn-alerts-ui-shared/src/common/constants/index.ts +++ b/packages/kbn-alerts-ui-shared/src/common/constants/index.ts @@ -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'; diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/index.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/index.ts index 88b44f26a1fc2..2c9988f2be0db 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/index.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/index.ts @@ -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'; diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connector_types.test.tsx b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connector_types.test.tsx index 506aa947136e4..63e494ab87084 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connector_types.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connector_types.test.tsx @@ -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, }), diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connector_types.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connector_types.ts index f9b4d33962039..2810758a57f0f 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connector_types.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connector_types.ts @@ -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 { diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connectors.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connectors.ts index 9b9a2f4206e9b..9ae876d06278b 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connectors.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connectors.ts @@ -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 { diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_type_aad_template_fields.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_type_aad_template_fields.ts index aafcd1f5167d1..fab6fd3336f2e 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_type_aad_template_fields.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_type_aad_template_fields.ts @@ -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((d) => ({ + return dataViewFields?.map((d) => ({ name: d.name, description: getDescription(d.name, EcsFlat), })); diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_resolve_rule.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_resolve_rule.ts index 36648172f4ff6..fafd372dc3640 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_resolve_rule.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_resolve_rule.ts @@ -41,6 +41,7 @@ export const useResolveRule = (props: UseResolveProps) => { }; }, refetchOnWindowFocus: false, + retry: false, }); return { diff --git a/packages/kbn-alerts-ui-shared/src/common/test_utils/actions_test_utils.ts b/packages/kbn-alerts-ui-shared/src/common/test_utils/actions_test_utils.ts new file mode 100644 index 0000000000000..01f88ab1e01b4 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/common/test_utils/actions_test_utils.ts @@ -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 => { + 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 => { + return { + id: `action-${id}`, + uuid: `uuid-action-${id}`, + group: `group-${id}`, + actionTypeId: `actionType-${id}`, + params: {}, + ...overwrites, + }; +}; + +export const getSystemAction = ( + id: string, + overwrites?: Partial +): RuleSystemAction => { + return { + uuid: `uuid-system-action-${id}`, + id: `system-action-${id}`, + actionTypeId: `actionType-${id}`, + params: {}, + ...overwrites, + }; +}; + +export const getActionType = (id: string, overwrites?: Partial): 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 => { + return actionTypeRegistryMock.createMockActionTypeModel({ + id: `actionTypeModel-${id}`, + iconClass: 'test', + selectMessage: 'test', + validateParams: (): Promise> => { + const validationResult = { errors: {} }; + return Promise.resolve(validationResult); + }, + actionConnectorFields: null, + ...overwrites, + }); +}; diff --git a/packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts b/packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts index deef7aa4147cd..29eaf17552a2b 100644 --- a/packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts +++ b/packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts @@ -43,6 +43,10 @@ export interface RuleFormBaseErrors { tags?: string[]; } +export interface RuleFormActionsErrors { + filterQuery?: string[]; +} + export interface RuleFormParamsErrors { [key: string]: string | string[] | RuleFormParamsErrors; } diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts b/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts index 84346f9737ef2..f557dc5ebdb42 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts @@ -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, }; }; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx index c252bbdfb3401..71aeb2bcaab77 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx @@ -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, diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx index e0b81aeac2715..5091444276873 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx @@ -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 ( @@ -92,18 +115,18 @@ export const EditRuleForm = (props: EditRuleFormProps) => { ); } - if (!ruleType || !ruleTypeModel) { + if (!fetchedFormData) { return ( - + ); } - if (!fetchedFormData) { + if (!ruleType || !ruleTypeModel) { return ( - + ); } @@ -116,16 +139,28 @@ export const EditRuleForm = (props: EditRuleFormProps) => { ); } + if (!canEditRule) { + return ( + + + + ); + } + return (
diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx index e80bda0692a69..263c9e2118056 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx @@ -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 } diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts index 2eb9878107007..da59e85a933a1 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts @@ -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, }; }; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.test.tsx index ee531defc91fe..63846fb3628ce 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.test.tsx @@ -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) => ( +
+ RuleActionsSystemActionsItem +
+ {action.id} producerId: {producerId} +
+
+ ), +})); + +jest.mock('./rule_actions_item', () => ({ + RuleActionsItem: ({ action, producerId }: RuleActionsItemProps) => ( +
+ RuleActionsItem +
+ {action.id} producerId: {producerId} +
+
+ ), +})); + +jest.mock('./rule_actions_connectors_modal', () => ({ + RuleActionsConnectorsModal: ({ + onSelectConnector, + }: { + onSelectConnector: (connector: ActionConnector) => void; + }) => ( +
+ RuleActionsConnectorsModal + +
+ ), +})); + +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', () => { +describe('ruleActions', () => { + beforeEach(() => { + const actionTypeRegistry = new TypeRegistry(); + 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); + }); + afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); - test('Renders correctly', () => { - render(); + test('renders correctly', () => { + render(); expect(screen.getByTestId('ruleActions')).toBeInTheDocument(); + expect(screen.getByTestId('ruleActionsAddActionButton')).toBeInTheDocument(); + expect(screen.queryByText('RuleActionsConnectorsModal')).not.toBeInTheDocument(); + }); + + test('renders actions correctly', () => { + render(); + + expect(screen.getAllByText('RuleActionsItem').length).toEqual(2); + expect(screen.getAllByText('RuleActionsSystemActionsItem').length).toEqual(1); + }); + + 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(); + 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(); + + await userEvent.click(screen.getByTestId('ruleActionsAddActionButton')); + expect(screen.getByText('RuleActionsConnectorsModal')).toBeInTheDocument(); + }); + + test('should call onSelectConnector with the correct parameters', async () => { + render(); + + 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(); + + 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('Calls onChange when button is click', () => { - render(); + 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: [], + }); - fireEvent.click(screen.getByTestId('ruleActionsAddActionButton')); + render(); - expect(mockOnChange).toHaveBeenCalled(); + 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(); }); }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.tsx index 74b8c531b4287..b9eb28025205c 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.tsx @@ -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(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 ( -
+ <> + + {actions.map((action, index) => { + const isSystemAction = connectorTypes.some((connectorType) => { + return connectorType.id === action.actionTypeId && connectorType.isSystemActionType; + }); + + return ( + + {isSystemAction && ( + + )} + {!isSystemAction && ( + + )} + + ); + })} + + {ADD_ACTION_TEXT} -
+ {isConnectorModalOpen && ( + + )} + ); }; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.test.tsx new file mode 100644 index 0000000000000..d3ef68206cfa4 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.test.tsx @@ -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) => ( +
+ AlertsSearchBar + + + +
+ ), +})); + +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( + + ); + + expect(screen.getByTestId('alertsFilterQueryToggle')).toBeInTheDocument(); + }); + + test('should allow for toggling on of query', async () => { + render( + + ); + + await userEvent.click(screen.getByTestId('alertsFilterQueryToggle')); + + expect(mockOnChange).toHaveBeenLastCalledWith({ filters: [], kql: '' }); + }); + + test('should allow for toggling off of query', async () => { + render( + + ); + + await userEvent.click(screen.getByTestId('alertsFilterQueryToggle')); + + expect(mockOnChange).toHaveBeenLastCalledWith(undefined); + }); + + test('should allow for changing query', async () => { + render( + + ); + + 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', + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_alerts_filter_query.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.tsx similarity index 56% rename from x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_alerts_filter_query.tsx rename to packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.tsx index 0abcce240b17e..646d1b359969b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_alerts_filter_query.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.tsx @@ -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 = ({ - 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 = ( <> = ( <> = ( showDatePicker={false} showSubmitButton={false} placeholder={i18n.translate( - 'xpack.triggersActionsUI.sections.actionTypeForm.ActionAlertsFilterQueryPlaceholder', + 'alertsUIShared.ruleActionsAlertsFilter.ActionAlertsFilterQueryPlaceholder', { defaultMessage: 'Filter alerts using KQL syntax', } diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter_timeframe.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter_timeframe.test.tsx index 056b342e8af01..27aa3ded72363 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter_timeframe.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter_timeframe.test.tsx @@ -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( void; } @@ -76,29 +77,30 @@ const useTimeFormat = (settings: SettingsStart) => { }; export const RuleActionsAlertsFilterTimeframe: React.FC = ({ - 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) => { diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_connectors_modal.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_connectors_modal.test.tsx new file mode 100644 index 0000000000000..264e2c557f7a6 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_connectors_modal.test.tsx @@ -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(); + 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( + + ); + expect(screen.getByTestId('ruleActionsConnectorsModal')); + }); + + test('should render connectors and filters', () => { + render( + + ); + + 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( + + ); + + // 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.queryByText('connector2')).not.toBeInTheDocument(); + }); + + test('should not render connector if hideInUi is true', () => { + const actionTypeRegistry = new TypeRegistry(); + 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( + + ); + + expect(screen.queryByText('connector2')).not.toBeInTheDocument(); + }); + + test('should not render connector if actionsParamsField doesnt exist', () => { + const actionTypeRegistry = new TypeRegistry(); + actionTypeRegistry.register(getActionTypeModel('1', { id: 'actionType-1' })); + actionTypeRegistry.register( + getActionTypeModel('2', { + id: 'actionType-2', + actionParamsFields: null as unknown as React.LazyExoticComponent, + }) + ); + + useRuleFormState.mockReturnValue({ + plugins: { + actionTypeRegistry, + }, + formData: { + actions: [], + }, + connectors: mockConnectors, + connectorTypes: mockActionTypes, + }); + + render( + + ); + + expect(screen.queryByText('connector-2')).not.toBeInTheDocument(); + }); + + test('should not render connector if the action type is not enabled', () => { + const actionTypeRegistry = new TypeRegistry(); + + 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( + + ); + + 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(); + + 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( + + ); + + expect(screen.getByText('connector-2')).toBeInTheDocument(); + }); + + test('should disable connector if it fails license check', () => { + const actionTypeRegistry = new TypeRegistry(); + + 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( + + ); + + expect(screen.getByText('connector-2')).toBeDisabled(); + }); + + test('should disable connector if its a selected system action', () => { + const actionTypeRegistry = new TypeRegistry(); + 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( + + ); + + expect(screen.getByText('connector-2')).toBeDisabled(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_connectors_modal.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_connectors_modal.tsx new file mode 100644 index 0000000000000..9c3dbcf15e364 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_connectors_modal.tsx @@ -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; + +export interface RuleActionsConnectorsModalProps { + onClose: () => void; + onSelectConnector: (connector: ActionConnector) => void; +} + +export const RuleActionsConnectorsModal = (props: RuleActionsConnectorsModalProps) => { + const { onClose, onSelectConnector } = props; + + const [searchValue, setSearchValue] = useState(''); + const [selectedConnectorType, setSelectedConnectorType] = useState('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) => { + 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((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 ( + + + {ACTION_TYPE_MODAL_FILTER_ALL} + + {Object.values(connectorsMap) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(({ actionTypeId, name, total }) => { + return ( + + {name} + + ); + })} + + ); + }, [availableConnectors, connectorsMap, selectedConnectorType, onConnectorOptionSelect]); + + const connectorCards = useMemo(() => { + if (!filteredConnectors.length) { + return ( + {ACTION_TYPE_MODAL_EMPTY_TITLE}} + body={ + +

{ACTION_TYPE_MODAL_EMPTY_TEXT}

+
+ } + actions={ + + {MODAL_SEARCH_CLEAR_FILTERS_TEXT} + + } + /> + ); + } + return ( + + {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 = ( + + }> + + +
+ } + title={name} + description={ + <> + {actionTypeModel.selectMessage} + + + {actionType?.name} + + + } + onClick={() => onSelectConnector(connector)} + /> + ); + + return ( + + {checkEnabledResult.isEnabled && connectorCard} + {!checkEnabledResult.isEnabled && ( + + {connectorCard} + + )} + + ); + })} + + ); + }, [ + actions, + preconfiguredConnectors, + filteredConnectors, + actionTypeRegistry, + connectorTypes, + onSelectConnector, + onClearFilters, + ]); + + const responseiveHeight = isFullscreenPortrait ? 'initial' : '80vh'; + const responsiveOverflow = isFullscreenPortrait ? 'auto' : 'hidden'; + + return ( + + + {ACTION_TYPE_MODAL_TITLE} + + + + + + + + + + + + + + {connectorFacetButtons} + + {connectorCards} + + + + + + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_item.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_item.test.tsx new file mode 100644 index 0000000000000..a36ad8979aea9 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_item.test.tsx @@ -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) => ( +
+ ruleActionsSettings + + + + +
+ ), +})); + +jest.mock('./rule_actions_message', () => ({ + RuleActionsMessage: ({ onParamsChange, templateFields }: RuleActionsMessageProps) => ( +
+ ruleActionsMessage + +
+ ), +})); + +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: () =>
Expression
, + 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(); + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + await userEvent.click(screen.getByText('Settings')); + + await userEvent.click(screen.getByTestId('ruleActionsItemDeleteButton')); + + expect(mockOnChange).toHaveBeenCalledWith({ + payload: { uuid: 'uuid-action-1' }, + type: 'removeAction', + }); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_item.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_item.tsx new file mode 100644 index 0000000000000..b80a79a69cfcf --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_item.tsx @@ -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; + +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(MESSAGES_TAB); + const subdued = useEuiBackgroundColor('subdued'); + const plain = useEuiBackgroundColor('plain'); + const { euiTheme } = useEuiTheme(); + + const [availableActionVariables, setAvailableActionVariables] = useState(() => { + 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 + >({}); + + const [warning, setWarning] = useState(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, + params: Record + ) => { + 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 ( + + + + setTab(MESSAGES_TAB)}> + Message + + setTab(SETTINGS_TAB)}> + Settings + + + + + {tab === MESSAGES_TAB && ( + + )} + {tab === SETTINGS_TAB && ( + setUseDefaultMessage(true)} + onNotifyWhenChange={onNotifyWhenChange} + onActionGroupChange={onActionGroupChange} + onAlertsFilterChange={onAlertsFilterChange} + onTimeframeChange={onTimeframeChange} + /> + )} + + + ); + }, [ + action, + connector, + producerId, + euiTheme, + plain, + index, + tab, + templateFields, + useDefaultMessage, + warning, + onNotifyWhenChange, + onActionGroupChange, + onAlertsFilterChange, + onTimeframeChange, + onParamsChange, + onUseAadTemplateFieldsChange, + ]); + + const noConnectorContent = useMemo(() => { + return ( + {ACTION_UNABLE_TO_LOAD_CONNECTOR_TITLE}} + body={ACTION_UNABLE_TO_LOAD_CONNECTOR_DESCRIPTION} + /> + ); + }, []); + + const accordionIcon = useMemo(() => { + if (!connector) { + return ( + + + + + + ); + } + + return ( + + {showActionGroupErrorIcon ? ( + + + + ) : ( + + + + )} + + ); + }, [connector, showActionGroupErrorIcon, actionTypeModel]); + + const connectorTitle = useMemo(() => { + const title = connector ? ACTION_TITLE(connector) : actionTypeModel.actionTypeTitle; + return ( + + {title} + + ); + }, [connector, actionTypeModel]); + + const actionTypeTitle = useMemo(() => { + if (!connector || !actionType) { + return null; + } + return ( + + + {actionType.name} + + + ); + }, [connector, actionType]); + + const runWhenTitle = useMemo(() => { + if (!connector) { + return null; + } + if (isOpen) { + return null; + } + if (selectedActionGroup || action.frequency?.summary) { + return ( + + + {action.frequency?.summary + ? SUMMARY_GROUP_TITLE + : RUN_WHEN_GROUP_TITLE(selectedActionGroup!.name.toLocaleLowerCase())} + + + ); + } + }, [connector, isOpen, selectedActionGroup, action]); + + const warningIcon = useMemo(() => { + if (!connector) { + return null; + } + if (isOpen) { + return null; + } + if (warning) { + return ( + + + {ACTION_WARNING_TITLE} + + + ); + } + }, [connector, isOpen, warning]); + + return ( + onDelete(action.uuid!)} + /> + } + buttonContentClassName="eui-fullWidth" + buttonContent={ + + + {accordionIcon} + {connectorTitle} + {actionTypeTitle} + {runWhenTitle} + {warningIcon} + {actionTypeModel.isExperimental && ( + + + + )} + + + } + > + {connector && accordionContent} + {!connector && noConnectorContent} + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_message.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_message.test.tsx new file mode 100644 index 0000000000000..d2c064cba2aad --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_message.test.tsx @@ -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: () =>
Expression
, + 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) { + return ( +
+ {defaultMessage &&
{defaultMessage}
} + {selectedActionGroupId && ( +
{selectedActionGroupId}
+ )} +
{JSON.stringify(errors)}
+ +
+ ); + }, +})); + +describe('RuleActionsMessage', () => { + beforeEach(() => { + const actionTypeRegistry = new TypeRegistry(); + 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( + + ); + + await waitFor(() => { + return expect(screen.getByTestId('actionParamsFieldMock')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('ruleActionsMessage')).toBeInTheDocument(); + }); + + test('should display warning if it exists', async () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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(); + 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( + + ); + + 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(); + 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( + + ); + + 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( + + ); + + await waitFor(() => { + return expect(screen.getByTestId('actionParamsFieldMock')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByTestId('editActionMock')); + expect(mockOnParamsChange).toHaveBeenLastCalledWith( + 'paramsKey', + { paramsKey: 'paramsValue' }, + 1 + ); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_message.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_message.tsx new file mode 100644 index 0000000000000..e828f0bb22cf8 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_message.tsx @@ -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 ( + + + {showMustacheAutocompleteSwitch && onUseAadTemplateFieldsChange && ( + + + + )} + + + + {warning ? ( + <> + + + + ) : null} + + + + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_notify_when.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_notify_when.test.tsx index a580f9be40596..3686193739235 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_notify_when.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_notify_when.test.tsx @@ -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} /> ); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_notify_when.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_notify_when.tsx index 6306600d3d2bc..d3695ba28a8db 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_notify_when.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_notify_when.tsx @@ -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: ( <>

@@ -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[] = [

@@ -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: ( <>

@@ -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(false); - const [notifyWhenValue, setNotifyWhenValue] = - useState(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 = ({ - 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}`, + }); }} /> @@ -389,10 +396,3 @@ export const RuleActionsNotifyWhen = ({ ); }; - -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', -}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_settings.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_settings.test.tsx new file mode 100644 index 0000000000000..a9b9a5b9cc454 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_settings.test.tsx @@ -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) => ( +
+ RuleActionsNotifyWhen + {showMinimumThrottleUnitWarning &&
showMinimumThrottleUnitWarning
} + {showMinimumThrottleWarning &&
showMinimumThrottleWarning
} + + +
+ ), +})); + +jest.mock('./rule_actions_alerts_filter', () => ({ + RuleActionsAlertsFilter: ({ onChange }: RuleActionsAlertsFilterProps) => ( +
+ RuleActionsAlertsFilter + +
+ ), +})); + +jest.mock('./rule_actions_alerts_filter_timeframe', () => ({ + RuleActionsAlertsFilterTimeframe: ({ onChange }: RuleActionsAlertsFilterTimeframeProps) => ( +
+ RuleActionsAlertsFilterTimeframe + +
+ ), +})); + +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: () =>
Expression
, + 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( + + ); + + expect(screen.getByTestId('ruleActionsSettings')).toBeInTheDocument(); + expect(screen.getByTestId('ruleActionsSettingsSelectActionGroup')).toBeInTheDocument(); + }); + + test('should render notify when component', () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.queryByText('showMinimumThrottleWarning')).toBeInTheDocument(); + expect(screen.queryByText('showMinimumThrottleUnitWarning')).not.toBeInTheDocument(); + }); + + test('should call notifyWhen component event handlers with the correct parameters', async () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.queryByText('filter query error')).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_settings.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_settings.tsx new file mode 100644 index 0000000000000..4d748b8530cb5 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_settings.tsx @@ -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 = { + [RecoveredActionGroup.id]: ['.jira', '.resilient'], +}; + +const DisabledActionTypeIdsForActionGroup: Map = 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 ( + + + + + + + + {showSelectActionGroup && ( + + {ACTION_GROUP_RUN_WHEN} + + } + 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} + /> + )} + + + + {showActionAlertsFilter && ( + + + + + + + + + + + + + )} + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_system_actions_item.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_system_actions_item.test.tsx new file mode 100644 index 0000000000000..a64dcca57387f --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_system_actions_item.test.tsx @@ -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) => ( +
+ RuleActionsMessage + + {warning &&
{warning}
} +
+ ), +})); + +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(); + 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( + + ); + + 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( + + ); + + await userEvent.click(screen.getByTestId('ruleActionsSystemActionsItemAccordionButton')); + + expect(screen.getByTestId('ruleActionsSystemActionsItemAccordionContent')).not.toBeVisible(); + }); + + test('should be able to delete the action', async () => { + render( + + ); + + 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(); + 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( + + ); + + await userEvent.click(screen.getByTestId('ruleActionsSystemActionsItemAccordionButton')); + + expect(screen.getByTestId('action-group-error-icon')).toBeInTheDocument(); + }); + + test('should allow params to be changed', async () => { + render( + + ); + + 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(); + actionTypeRegistry.register( + getActionTypeModel('1', { + id: 'actionType-1', + validateParams: mockValidate.mockResolvedValue({ + errors: { paramsValue: ['something went wrong!'] }, + }), + }) + ); + + render( + + ); + + 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(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_system_actions_item.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_system_actions_item.tsx new file mode 100644 index 0000000000000..4598d42d91aac --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_system_actions_item.tsx @@ -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 + >({}); + const [warning, setWarning] = useState(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, + params: Record + ) => { + 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 ( + onDelete(action.uuid!)} + /> + } + buttonContentClassName="eui-fullWidth" + buttonContent={ + + + + {showActionGroupErrorIcon() ? ( + + + + ) : ( + + + + )} + + + {connector.name} + + + + {actionType?.name} + + + {warning && !isOpen && ( + + + {ACTION_WARNING_TITLE} + + + )} + {actionTypeModel.isExperimental && ( + + + + )} + + + } + > + + + + + + + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.test.tsx index f5124ee7d956f..935aec998929d 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.test.tsx @@ -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(); - - expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument(); - }); - it('should be able to select logs and call onChange', () => { useRuleFormState.mockReturnValue({ multiConsumerSelection: null, diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.tsx index dbfc597dc6eaa..f416a3531895e 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_consumer_selection.tsx @@ -91,10 +91,6 @@ export const RuleConsumerSelection = (props: RuleConsumerSelectionProps) => { [dispatch] ); - if (validConsumers.length <= 1 || validConsumers.includes(AlertConsumers.OBSERVABILITY)) { - return null; - } - return ( { 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(); + + 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(); + + expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument(); + }); + test('Can toggle advanced options', async () => { useRuleFormState.mockReturnValue({ plugins, diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx index 6bfdfd54b7d5b..fe4812436144a 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx @@ -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 = () => { - + + + diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/index.ts index 896d1c46109b9..68d0a0415980a 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/index.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/index.ts @@ -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'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_action_permission_error.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_action_permission_error.tsx new file mode 100644 index 0000000000000..0bf755d0962a5 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_errors/rule_form_action_permission_error.tsx @@ -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 ( + +

{RULE_FORM_PAGE_RULE_ACTIONS_NO_PERMISSION_TITLE}

+ + } + body={ + +

{RULE_FORM_PAGE_RULE_ACTIONS_NO_PERMISSION_DESCRIPTION}

+
+ } + /> + ); +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.test.tsx index 9a37e168da474..81d1aab4b2c3f 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.test.tsx @@ -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(); + }); }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.ts b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.ts index 29fe01b5d7180..a65842125b6a8 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.ts @@ -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; + } + | { + 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; } diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.test.tsx index d7300333b79af..ca80c0b77aae3 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.test.tsx @@ -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(); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.tsx index f550c64d5d695..4e2e019d41269 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.tsx @@ -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: ( + <> + + + + + ), + }, + ]; + } + return []; + }, [canReadConnectors]); + const steps: EuiStepsProps['steps'] = useMemo(() => { return [ { title: RULE_FORM_PAGE_RULE_DEFINITION_TITLE, children: , }, - { - title: RULE_FORM_PAGE_RULE_ACTIONS_TITLE, - children: ( - <> - {}} /> - - - - ), - }, + ...actionComponent, { title: RULE_FORM_PAGE_RULE_DETAILS_TITLE, children: ( @@ -91,7 +102,7 @@ export const RulePage = (props: RulePageProps) => { ), }, ]; - }, []); + }, [actionComponent]); return ( diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.test.tsx index 4631b4d1d4b77..45e2008773583 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.test.tsx @@ -35,6 +35,9 @@ hasRuleErrors.mockReturnValue(false); useRuleFormState.mockReturnValue({ baseErrors: {}, paramsErrors: {}, + formData: { + actions: [], + }, }); describe('rulePageFooter', () => { diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.tsx index f0b729748c95f..09d2ac429fd50 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.tsx @@ -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(() => { + const hasBrokenConnectors = actions.some((action) => { + return !connectors.find((connector) => connector.id === action.id); + }); + + if (hasBrokenConnectors) { + return true; + } + return hasRuleErrors({ - baseErrors: baseErrors || {}, - paramsErrors: paramsErrors || {}, + baseErrors, + paramsErrors, + actionsErrors, + actionsParamsErrors, }); - }, [baseErrors, paramsErrors]); + }, [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(); + } + if (actions.length === 0) { + return setShowCreateConfirmation(true); } - }, [isEdit, onSave]); + onSave(); + }, [actions, isEdit, onSave]); const onCreateConfirmClick = useCallback(() => { setShowCreateConfirmation(false); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.test.tsx index 7159c8603bda8..a2ee47de52a63 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.test.tsx @@ -35,6 +35,7 @@ const formData: RuleFormData = { index: ['.kibana'], timeField: 'created_at', }, + actions: [], consumer: 'stackAlerts', ruleTypeId: '.es-query', schedule: { interval: '1m' }, diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.tsx index 0eee53d198cc2..f2cd4be5e1057 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_show_request_modal.tsx @@ -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 { diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts b/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts index 70c6d35280282..e7b060dce9831 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts @@ -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.', + } +); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/types.ts b/packages/kbn-alerts-ui-shared/src/rule_form/types.ts index 3fb5b04de5c11..ac81f45de19e6 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/types.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/types.ts @@ -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 { @@ -35,6 +42,7 @@ export interface RuleFormData { params: Rule['params']; schedule: Rule['schedule']; consumer: Rule['consumer']; + actions: RuleUiAction[]; alertDelay?: Rule['alertDelay']; notifyWhen?: Rule['notifyWhen']; ruleTypeId?: Rule['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 { id?: string; formData: RuleFormData; plugins: RuleFormPlugins; + connectors: ActionConnector[]; + connectorTypes: ActionType[]; + aadTemplateFields: ActionVariable[]; baseErrors?: RuleFormBaseErrors; paramsErrors?: RuleFormParamsErrors; + actionsErrors?: Record; + actionsParamsErrors?: Record; selectedRuleType: RuleTypeWithDescription; selectedRuleTypeModel: RuleTypeModel; multiConsumerSelection?: RuleCreationValidConsumer | null; + showMustacheAutocompleteSwitch?: boolean; metadata?: Record; minimumScheduleInterval?: MinimumScheduleInterval; canShowConsumerSelection?: boolean; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss b/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.scss similarity index 100% rename from x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss rename to packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.scss diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.test.ts similarity index 89% rename from x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx rename to packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.test.ts index ad0dfd696184a..987d95ef3d070 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.test.ts @@ -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', () => { > , diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.ts new file mode 100644 index 0000000000000..891012f0eeb23 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/check_action_type_enabled.ts @@ -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 }; +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_multi_consumer.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_multi_consumer.ts index f6dab691f2b7c..ee80bf46b99c8 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_multi_consumer.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_initial_multi_consumer.ts @@ -75,5 +75,5 @@ export const getInitialMultiConsumer = ({ } // All else fails, just use the first valid consumer - return validConsumers[0]; + return validConsumers[0] || null; }; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_license_check_result.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_license_check_result.tsx new file mode 100644 index 0000000000000..e4477822ca92f --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_license_check_result.tsx @@ -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: ( + + + + } + /> + ), + }; +}; + +export const configurationCheckResult = { + isEnabled: false, + message: i18n.translate( + 'alertsUIShared.checkActionTypeEnabled.actionTypeDisabledByConfigMessage', + { defaultMessage: 'This connector is disabled by the Kibana configuration.' } + ), + messageCard: ( + + ), +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_selected_action_group.test.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_selected_action_group.test.ts new file mode 100644 index 0000000000000..0b91d3dbbec9c --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_selected_action_group.test.ts @@ -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, + }); + + 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, + }); + + 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, + }); + + expect(result).toEqual({ + defaultActionMessage: 'default recovery message', + id: 'group-1', + name: 'group-1', + omitMessageVariables: 'all', + }); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_selected_action_group.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_selected_action_group.ts new file mode 100644 index 0000000000000..36c73c7034402 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_selected_action_group.ts @@ -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; +}) => { + 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; +}) => { + const actionGroups = getActionGroups({ + ruleType, + ruleTypeModel, + }); + + const defaultActionGroup = actionGroups?.find(({ id }) => id === ruleType.defaultActionGroupId); + + return actionGroups?.find(({ id }) => id === group) ?? defaultActionGroup; +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/has_fields_for_aad.test.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/has_fields_for_aad.test.ts new file mode 100644 index 0000000000000..e7e7f23844683 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/has_fields_for_aad.test.ts @@ -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(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/has_fields_for_aad.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/has_fields_for_aad.ts new file mode 100644 index 0000000000000..c0433898c85d6 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/has_fields_for_aad.ts @@ -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; +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts index e46ba79c74fc8..f5b583a1a9c63 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts @@ -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'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/validation/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/validation/index.ts index 8e4e1047c6f42..7d43b562b7e2b 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/validation/index.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/validation/index.ts @@ -8,3 +8,4 @@ */ export * from './validate_form'; +export * from './validate_params_for_warnings'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.test.ts b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.test.ts index ba4d7ef1a9ed0..c8855c0aa90a4 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.test.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.test.ts @@ -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(); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.ts b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.ts index 9fdb02a1eaccf..d65e9c5893937 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.ts @@ -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(), + }; + + 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) => { + 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) => { + return Object.values(actionsParamsErrors).some((errors: RuleFormParamsErrors) => { + return hasParamsErrors(errors); + }); +}; + export const hasRuleErrors = ({ baseErrors, paramsErrors, + actionsErrors, + actionsParamsErrors, }: { baseErrors: RuleFormBaseErrors; paramsErrors: RuleFormParamsErrors; + actionsErrors: Record; + actionsParamsErrors: Record; }): boolean => { - return hasRuleBaseErrors(baseErrors) || hasRuleParamsErrors(paramsErrors); + return ( + hasRuleBaseErrors(baseErrors) || + hasParamsErrors(paramsErrors) || + hasActionsError(actionsErrors) || + hasActionsParamsErrors(actionsParamsErrors) + ); }; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_params_for_warnings.test.ts b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_params_for_warnings.test.ts new file mode 100644 index 0000000000000..021c869d78c1a --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_params_for_warnings.test.ts @@ -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(); + }); +}); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_params_for_warnings.ts b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_params_for_warnings.ts new file mode 100644 index 0000000000000..035a3408656a6 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_params_for_warnings.ts @@ -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()); + + 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; +}; diff --git a/packages/kbn-alerts-ui-shared/tsconfig.json b/packages/kbn-alerts-ui-shared/tsconfig.json index 79fc9d6fc9bdb..0da17dfe3d1ac 100644 --- a/packages/kbn-alerts-ui-shared/tsconfig.json +++ b/packages/kbn-alerts-ui-shared/tsconfig.json @@ -49,5 +49,6 @@ "@kbn/core-ui-settings-browser", "@kbn/core-http-browser-mocks", "@kbn/core-notifications-browser-mocks", + "@kbn/kibana-react-plugin" ] } diff --git a/x-pack/examples/triggers_actions_ui_example/public/application.tsx b/x-pack/examples/triggers_actions_ui_example/public/application.tsx index 49871c9e46ce8..4a429fbfd58d7 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/application.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/application.tsx @@ -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')} /> )} /> - ( - - - - )} - /> - ( - - - - )} - /> @@ -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 = ( { - return {}} />; -}; diff --git a/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_details_sandbox.tsx b/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_details_sandbox.tsx deleted file mode 100644 index 56397d6030bf8..0000000000000 --- a/x-pack/examples/triggers_actions_ui_example/public/components/rule_form/rule_details_sandbox.tsx +++ /dev/null @@ -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 ; -}; diff --git a/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx b/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx index 7faaae11ef0f4..1d73a88d8ee2f 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/components/sidebar.tsx @@ -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'), - }, ], }, ]} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 3f9ce70273432..eb427185d3fe1 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -45554,8 +45554,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", @@ -45685,10 +45683,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", @@ -45832,8 +45826,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": "L’action contient des erreurs.", "xpack.triggersActionsUI.sections.actionTypeForm.actionIdLabel": "Connecteur {connectorInstance}", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 455de83d0e16a..91fb104c16df1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -45296,8 +45296,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": "トップ", @@ -45426,10 +45424,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か所で監視", @@ -45573,8 +45567,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}コネクター", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 44599632f8bb8..7615268da55dc 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -45349,8 +45349,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": "排名前", @@ -45479,10 +45477,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": "在一个位置监测您的所有告警", @@ -45626,8 +45620,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} 连接器", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.tsx b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.tsx deleted file mode 100644 index fa85719ed11a0..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.tsx +++ /dev/null @@ -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: ( - - - - } - /> - ), - }; -}; - -const configurationCheckResult = { - isEnabled: false, - message: i18n.translate( - 'xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByConfigMessage', - { defaultMessage: 'This connector is disabled by the Kibana configuration.' } - ), - messageCard: ( - - ), -}; - -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 }; -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 2c3feb19561fd..940583ea5bced 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -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} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx index de4463721d3de..116b416ac98f1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx @@ -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, })} ); @@ -611,7 +606,6 @@ describe('action_type_form', () => { index: 1, actionItem, notifyWhenSelectOptions: CUSTOM_NOTIFY_WHEN_OPTIONS, - defaultNotifyWhenValue: RuleNotifyWhen.ACTIVE, })} ); @@ -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} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 1a55e2990fbf7..9176d9d54ef3a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -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 = ( { - 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 && } - setActionAlertsFilterProperty('query', query, index)} featureIds={[producerId as ValidFeatureId]} appName={featureId!} ruleTypeId={ruleTypeId} + plugins={{ + http, + unifiedSearch, + dataViews, + notifications, + }} /> setActionAlertsFilterProperty('timeframe', timeframe, index)} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index cd5a4a81b4b2b..c9cab9defaffc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -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'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index cdfde543a3a69..e00b08d9c8512 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -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, diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts index a2b54a0562f66..2d6548062eed9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts @@ -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';