diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts index 8f4e59d80458b..9a719c24076f7 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts @@ -54,6 +54,6 @@ export const transformUpdateRuleBody: RewriteResponseCase = ({ ...(uuid && { uuid }), }; }), - ...(alertDelay ? { alert_delay: alertDelay } : {}), + ...(alertDelay !== undefined ? { alert_delay: alertDelay } : {}), ...(flapping !== undefined ? { flapping: transformUpdateRuleFlapping(flapping) } : {}), }); diff --git a/packages/kbn-alerts-ui-shared/src/common/constants/rule_flapping.ts b/packages/kbn-alerts-ui-shared/src/common/constants/rule_flapping.ts index 49ea5a63b3fca..542bb055fd431 100644 --- a/packages/kbn-alerts-ui-shared/src/common/constants/rule_flapping.ts +++ b/packages/kbn-alerts-ui-shared/src/common/constants/rule_flapping.ts @@ -8,4 +8,4 @@ */ // Feature flag for frontend rule specific flapping in rule flyout -export const IS_RULE_SPECIFIC_FLAPPING_ENABLED = false; +export const IS_RULE_SPECIFIC_FLAPPING_ENABLED = true; diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_create_rule.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_create_rule.ts index 4ee00a94b90ed..ebdfeeafbe2fd 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_create_rule.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_create_rule.ts @@ -10,10 +10,11 @@ import { useMutation } from '@tanstack/react-query'; import type { HttpStart, IHttpFetchError } from '@kbn/core-http-browser'; import { createRule, CreateRuleBody } from '../apis/create_rule'; +import { Rule } from '../types'; export interface UseCreateRuleProps { http: HttpStart; - onSuccess?: (formData: CreateRuleBody) => void; + onSuccess?: (rule: Rule) => void; onError?: (error: IHttpFetchError<{ message: string }>) => void; } 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 9ae876d06278b..8c93881762b1a 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 @@ -15,10 +15,11 @@ export interface UseLoadConnectorsProps { http: HttpStart; includeSystemActions?: boolean; enabled?: boolean; + cacheTime?: number; } export const useLoadConnectors = (props: UseLoadConnectorsProps) => { - const { http, includeSystemActions = false, enabled = true } = props; + const { http, includeSystemActions = false, enabled = true, cacheTime } = props; const queryFn = () => { return fetchConnectors({ http, includeSystemActions }); @@ -27,6 +28,7 @@ export const useLoadConnectors = (props: UseLoadConnectorsProps) => { const { data, isLoading, isFetching, isInitialLoading } = useQuery({ queryKey: ['useLoadConnectors', includeSystemActions], queryFn, + cacheTime, refetchOnWindowFocus: false, enabled, }); 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 fab6fd3336f2e..c9dbc6c75ff35 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 @@ -17,10 +17,11 @@ export interface UseLoadRuleTypeAadTemplateFieldProps { http: HttpStart; ruleTypeId?: string; enabled: boolean; + cacheTime?: number; } export const useLoadRuleTypeAadTemplateField = (props: UseLoadRuleTypeAadTemplateFieldProps) => { - const { http, ruleTypeId, enabled } = props; + const { http, ruleTypeId, enabled, cacheTime } = props; const queryFn = () => { if (!ruleTypeId) { @@ -43,6 +44,7 @@ export const useLoadRuleTypeAadTemplateField = (props: UseLoadRuleTypeAadTemplat description: getDescription(d.name, EcsFlat), })); }, + cacheTime, refetchOnWindowFocus: false, enabled, }); 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 fafd372dc3640..95c3ca6baad02 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 @@ -15,10 +15,11 @@ import { RuleFormData } from '../../rule_form'; export interface UseResolveProps { http: HttpStart; id?: string; + cacheTime?: number; } export const useResolveRule = (props: UseResolveProps) => { - const { id, http } = props; + const { id, http, cacheTime } = props; const queryFn = () => { if (id) { @@ -30,6 +31,7 @@ export const useResolveRule = (props: UseResolveProps) => { queryKey: ['useResolveRule', id], queryFn, enabled: !!id, + cacheTime, select: (rule): RuleFormData | null => { if (!rule) { return null; diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_update_rule.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_update_rule.ts index 0e8199fc1cca2..5764b8128ef42 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_update_rule.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_update_rule.ts @@ -10,10 +10,11 @@ import { useMutation } from '@tanstack/react-query'; import type { HttpStart, IHttpFetchError } from '@kbn/core-http-browser'; import { updateRule, UpdateRuleBody } from '../apis/update_rule'; +import { Rule } from '../types'; export interface UseUpdateRuleProps { http: HttpStart; - onSuccess?: (formData: UpdateRuleBody) => void; + onSuccess?: (rule: Rule) => void; onError?: (error: IHttpFetchError<{ message: string }>) => void; } 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 40498f1a27886..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 @@ -27,8 +27,6 @@ import { TypeRegistry } from '../type_registry'; export type { SanitizedRuleAction as RuleAction } from '@kbn/alerting-types'; -export type { Flapping } from '@kbn/alerting-types'; - export type RuleTypeWithDescription = RuleType & { description?: string }; export type RuleTypeIndexWithDescriptions = Map; 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 f557dc5ebdb42..a3748eeabe697 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts @@ -27,7 +27,7 @@ export const DEFAULT_FREQUENCY = { summary: false, }; -export const GET_DEFAULT_FORM_DATA = ({ +export const getDefaultFormData = ({ ruleTypeId, name, consumer, @@ -50,6 +50,7 @@ export const GET_DEFAULT_FORM_DATA = ({ ruleTypeId, name, actions, + alertDelay: { active: 1 }, }; }; 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 fc96ae214a7a8..4399dc5239ec7 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 @@ -12,7 +12,7 @@ import { EuiLoadingElastic } from '@elastic/eui'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { type RuleCreationValidConsumer } from '@kbn/rule-data-utils'; import type { RuleFormData, RuleFormPlugins } from './types'; -import { DEFAULT_VALID_CONSUMERS, GET_DEFAULT_FORM_DATA } from './constants'; +import { DEFAULT_VALID_CONSUMERS, getDefaultFormData } from './constants'; import { RuleFormStateProvider } from './rule_form_state'; import { useCreateRule } from '../common/hooks'; import { RulePage } from './rule_page'; @@ -24,6 +24,7 @@ import { } from './rule_form_errors'; import { useLoadDependencies } from './hooks/use_load_dependencies'; import { + getAvailableRuleTypes, getInitialConsumer, getInitialMultiConsumer, getInitialSchedule, @@ -42,7 +43,8 @@ export interface CreateRuleFormProps { shouldUseRuleProducer?: boolean; canShowConsumerSelection?: boolean; showMustacheAutocompleteSwitch?: boolean; - returnUrl: string; + onCancel?: () => void; + onSubmit?: (ruleId: string) => void; } export const CreateRuleForm = (props: CreateRuleFormProps) => { @@ -56,7 +58,8 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => { shouldUseRuleProducer = false, canShowConsumerSelection = true, showMustacheAutocompleteSwitch = false, - returnUrl, + onCancel, + onSubmit, } = props; const { http, docLinks, notifications, ruleTypeRegistry, i18n, theme } = plugins; @@ -64,8 +67,9 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => { const { mutate, isLoading: isSaving } = useCreateRule({ http, - onSuccess: ({ name }) => { + onSuccess: ({ name, id }) => { toasts.addSuccess(RULE_CREATE_SUCCESS_TEXT(name)); + onSubmit?.(id); }, onError: (error) => { const message = parseRuleCircuitBreakerErrorMessage( @@ -86,6 +90,7 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => { const { isInitialLoading, ruleType, + ruleTypes, ruleTypeModel, uiConfig, healthCheckError, @@ -153,7 +158,7 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => {
{ minimumScheduleInterval: uiConfig?.minimumScheduleInterval, selectedRuleTypeModel: ruleTypeModel, selectedRuleType: ruleType, + availableRuleTypes: getAvailableRuleTypes({ + consumer, + ruleTypes, + ruleTypeRegistry, + }).map(({ ruleType: rt }) => rt), validConsumers, flappingSettings, canShowConsumerSelection, @@ -185,7 +195,7 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => { }), }} > - +
); 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 6e92b94cc2e0d..917fc87420f9a 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 @@ -24,17 +24,19 @@ import { RuleFormRuleTypeError, } from './rule_form_errors'; import { RULE_EDIT_ERROR_TEXT, RULE_EDIT_SUCCESS_TEXT } from './translations'; -import { parseRuleCircuitBreakerErrorMessage } from './utils'; +import { getAvailableRuleTypes, parseRuleCircuitBreakerErrorMessage } from './utils'; +import { DEFAULT_VALID_CONSUMERS, getDefaultFormData } from './constants'; export interface EditRuleFormProps { id: string; plugins: RuleFormPlugins; showMustacheAutocompleteSwitch?: boolean; - returnUrl: string; + onCancel?: () => void; + onSubmit?: (ruleId: string) => void; } export const EditRuleForm = (props: EditRuleFormProps) => { - const { id, plugins, returnUrl, showMustacheAutocompleteSwitch = false } = props; + const { id, plugins, showMustacheAutocompleteSwitch = false, onCancel, onSubmit } = props; const { http, notifications, docLinks, ruleTypeRegistry, i18n, theme, application } = plugins; const { toasts } = notifications; @@ -42,6 +44,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => { http, onSuccess: ({ name }) => { toasts.addSuccess(RULE_EDIT_SUCCESS_TEXT(name)); + onSubmit?.(id); }, onError: (error) => { const message = parseRuleCircuitBreakerErrorMessage( @@ -62,6 +65,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => { const { isInitialLoading, ruleType, + ruleTypes, ruleTypeModel, uiConfig, healthCheckError, @@ -156,17 +160,31 @@ export const EditRuleForm = (props: EditRuleFormProps) => { connectors, connectorTypes, aadTemplateFields, - formData: fetchedFormData, + formData: { + ...getDefaultFormData({ + ruleTypeId: fetchedFormData.ruleTypeId, + name: fetchedFormData.name, + consumer: fetchedFormData.consumer, + actions: fetchedFormData.actions, + }), + ...fetchedFormData, + }, id, plugins, minimumScheduleInterval: uiConfig?.minimumScheduleInterval, selectedRuleType: ruleType, selectedRuleTypeModel: ruleTypeModel, + availableRuleTypes: getAvailableRuleTypes({ + consumer: fetchedFormData.consumer, + ruleTypes, + ruleTypeRegistry, + }).map(({ ruleType: rt }) => rt), flappingSettings, + validConsumers: DEFAULT_VALID_CONSUMERS, showMustacheAutocompleteSwitch, }} > - + ); 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 9d2ce3b6f1211..f0a14ac82e4a6 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 @@ -46,10 +46,6 @@ jest.mock('../../common/hooks/use_load_rule_type_aad_template_fields', () => ({ useLoadRuleTypeAadTemplateField: jest.fn(), })); -jest.mock('../utils/get_authorized_rule_types', () => ({ - getAvailableRuleTypes: jest.fn(), -})); - jest.mock('../../common/hooks/use_fetch_flapping_settings', () => ({ useFetchFlappingSettings: jest.fn(), })); @@ -63,7 +59,6 @@ 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'); const { useFetchFlappingSettings } = jest.requireMock( '../../common/hooks/use_fetch_flapping_settings' ); @@ -168,13 +163,6 @@ useLoadRuleTypesQuery.mockReturnValue({ }, }); -getAvailableRuleTypes.mockReturnValue([ - { - ruleType: indexThresholdRuleType, - ruleTypeModel: indexThresholdRuleTypeModel, - }, -]); - const mockConnector = { id: 'test-connector', name: 'Test', @@ -236,7 +224,7 @@ const toastsMock = jest.fn(); const ruleTypeRegistryMock: RuleTypeRegistryContract = { has: jest.fn(), register: jest.fn(), - get: jest.fn(), + get: jest.fn().mockReturnValue(indexThresholdRuleTypeModel), list: jest.fn(), }; @@ -272,6 +260,7 @@ describe('useLoadDependencies', () => { isLoading: false, isInitialLoading: false, ruleType: indexThresholdRuleType, + ruleTypes: [...ruleTypeIndex.values()], ruleTypeModel: indexThresholdRuleTypeModel, uiConfig: uiConfigMock, healthCheckError: null, @@ -317,39 +306,6 @@ describe('useLoadDependencies', () => { }); }); - test('should call getAvailableRuleTypes with the correct params', async () => { - const { result } = renderHook( - () => { - return useLoadDependencies({ - http: httpMock as unknown as HttpStart, - toasts: toastsMock as unknown as ToastsStart, - ruleTypeRegistry: ruleTypeRegistryMock, - validConsumers: ['stackAlerts', 'logs'], - consumer: 'logs', - capabilities: { - actions: { - show: true, - save: true, - execute: true, - }, - } as unknown as ApplicationStart['capabilities'], - }); - }, - { wrapper } - ); - - await waitFor(() => { - return expect(result.current.isInitialLoading).toEqual(false); - }); - - expect(getAvailableRuleTypes).toBeCalledWith({ - consumer: 'logs', - ruleTypeRegistry: ruleTypeRegistryMock, - ruleTypes: [indexThresholdRuleType], - validConsumers: ['stackAlerts', 'logs'], - }); - }); - test('should call resolve rule with the correct params', async () => { const { result } = renderHook( () => { @@ -377,6 +333,7 @@ describe('useLoadDependencies', () => { expect(useResolveRule).toBeCalledWith({ http: httpMock, id: 'test-rule-id', + cacheTime: 0, }); }); 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 5e0c52b1089ba..9fb0f173b9d21 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 @@ -20,7 +20,6 @@ import { useLoadUiConfig, useResolveRule, } from '../../common/hooks'; -import { getAvailableRuleTypes } from '../utils'; import { RuleTypeRegistryContract } from '../../common'; import { useFetchFlappingSettings } from '../../common/hooks/use_fetch_flapping_settings'; import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '../../common/constants/rule_flapping'; @@ -43,8 +42,6 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { http, toasts, ruleTypeRegistry, - consumer, - validConsumers, id, ruleTypeId, capabilities, @@ -69,7 +66,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { data: fetchedFormData, isLoading: isLoadingRule, isInitialLoading: isInitialLoadingRule, - } = useResolveRule({ http, id }); + } = useResolveRule({ http, id, cacheTime: 0 }); const { ruleTypesState: { @@ -100,6 +97,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { http, includeSystemActions: true, enabled: canReadConnectors, + cacheTime: 0, }); const computedRuleTypeId = useMemo(() => { @@ -125,28 +123,22 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { http, ruleTypeId: computedRuleTypeId, enabled: !!computedRuleTypeId && canReadConnectors, + cacheTime: 0, }); - const authorizedRuleTypeItems = useMemo(() => { - const computedConsumer = consumer || fetchedFormData?.consumer; - if (!computedConsumer) { - return []; + const ruleType = useMemo(() => { + if (!computedRuleTypeId || !ruleTypeIndex) { + return null; } - return getAvailableRuleTypes({ - consumer: computedConsumer, - ruleTypes: [...ruleTypeIndex.values()], - ruleTypeRegistry, - validConsumers, - }); - }, [consumer, ruleTypeIndex, ruleTypeRegistry, validConsumers, fetchedFormData]); - - const [ruleType, ruleTypeModel] = useMemo(() => { - const item = authorizedRuleTypeItems.find(({ ruleType: rt }) => { - return rt.id === computedRuleTypeId; - }); - - return [item?.ruleType, item?.ruleTypeModel]; - }, [authorizedRuleTypeItems, computedRuleTypeId]); + return ruleTypeIndex.get(computedRuleTypeId); + }, [computedRuleTypeId, ruleTypeIndex]); + + const ruleTypeModel = useMemo(() => { + if (!computedRuleTypeId) { + return null; + } + return ruleTypeRegistry.get(computedRuleTypeId); + }, [computedRuleTypeId, ruleTypeRegistry]); const isLoading = useMemo(() => { // Create Mode @@ -227,6 +219,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { isInitialLoading: !!isInitialLoading, ruleType, ruleTypeModel, + ruleTypes: [...ruleTypeIndex.values()], uiConfig, healthCheckError, fetchedFormData, 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 63846fb3628ce..9560d933060f6 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 @@ -117,12 +117,18 @@ describe('ruleActions', () => { getActionTypeModel('1', { id: 'actionType-1', validateParams: mockValidate, + defaultActionParams: { + key: 'value', + }, }) ); actionTypeRegistry.register( getActionTypeModel('2', { id: 'actionType-2', validateParams: mockValidate, + defaultActionParams: { + key: 'value', + }, }) ); @@ -150,6 +156,10 @@ describe('ruleActions', () => { selectedRuleType: { id: 'selectedRuleTypeId', defaultActionGroupId: 'test', + recoveryActionGroup: { + id: 'test-recovery-group-id', + name: 'test-recovery-group', + }, producer: 'stackAlerts', }, connectors: mockConnectors, @@ -222,7 +232,7 @@ describe('ruleActions', () => { frequency: { notifyWhen: 'onActionGroupChange', summary: false, throttle: null }, group: 'test', id: 'connector-1', - params: {}, + params: { key: 'value' }, uuid: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', }, type: 'addAction', 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 b9eb28025205c..47588b487be6d 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 @@ -18,6 +18,7 @@ import { ActionConnector, RuleAction, RuleFormParamsErrors } from '../../common/ import { DEFAULT_FREQUENCY, MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants'; import { RuleActionsItem } from './rule_actions_item'; import { RuleActionsSystemActionsItem } from './rule_actions_system_actions_item'; +import { getDefaultParams } from '../utils'; export const RuleActions = () => { const [isConnectorModalOpen, setIsConnectorModalOpen] = useState(false); @@ -44,7 +45,15 @@ export const RuleActions = () => { async (connector: ActionConnector) => { const { id, actionTypeId } = connector; const uuid = uuidv4(); - const params = {}; + const group = selectedRuleType.defaultActionGroupId; + const actionTypeModel = actionTypeRegistry.get(actionTypeId); + + const params = + getDefaultParams({ + group, + ruleType: selectedRuleType, + actionTypeModel, + }) || {}; dispatch({ type: 'addAction', @@ -53,7 +62,7 @@ export const RuleActions = () => { actionTypeId, uuid, params, - group: selectedRuleType.defaultActionGroupId, + group, frequency: DEFAULT_FREQUENCY, }, }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.tsx index 791c1ce0491f2..a5bbacc74d7a5 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.tsx @@ -68,6 +68,7 @@ export const RuleActionsAlertsFilter = ({ () => onChange(state ? undefined : query), [state, query, onChange] ); + const updateQuery = useCallback( (update: Partial) => { setQuery({ 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 index 9c3dbcf15e364..82496d9578ff0 100644 --- 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 @@ -163,7 +163,10 @@ export const RuleActionsConnectorsModal = (props: RuleActionsConnectorsModalProp const connectorFacetButtons = useMemo(() => { return ( - + { await userEvent.click(screen.getByText('onTimeframeChange')); - expect(mockOnChange).toHaveBeenCalledTimes(1); + expect(mockOnChange).toHaveBeenCalledTimes(2); expect(mockOnChange).toHaveBeenCalledWith({ payload: { 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 index b80a79a69cfcf..9bf6cac970b19 100644 --- 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 @@ -40,17 +40,12 @@ 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 { ActionConnector, RuleFormParamsErrors } from '../../common/types'; import { getAvailableActionVariables } from '../../action_variables'; import { validateAction, validateParamsForWarnings } from '../validation'; import { RuleActionsSettings } from './rule_actions_settings'; -import { getSelectedActionGroup } from '../utils'; +import { getDefaultParams, getSelectedActionGroup } from '../utils'; import { RuleActionsMessage } from './rule_actions_message'; import { ACTION_ERROR_TOOLTIP, @@ -60,6 +55,7 @@ import { TECH_PREVIEW_DESCRIPTION, TECH_PREVIEW_LABEL, } from '../translations'; +import { checkActionFormActionTypeEnabled } from '../utils/check_action_type_enabled'; const SUMMARY_GROUP_TITLE = i18n.translate('alertsUIShared.ruleActionsItem.summaryGroupTitle', { defaultMessage: 'Summary of alerts', @@ -83,22 +79,6 @@ const ACTION_TITLE = (connector: ActionConnector) => }, }); -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; @@ -178,6 +158,16 @@ export const RuleActionsItem = (props: RuleActionsItemProps) => { ? aadTemplateFields : availableActionVariables; + const checkEnabledResult = useMemo(() => { + if (!actionType) { + return null; + } + return checkActionFormActionTypeEnabled( + actionType, + connectors.filter((c) => c.isPreconfigured) + ); + }, [actionType, connectors]); + const onDelete = (id: string) => { dispatch({ type: 'removeAction', payload: { uuid: id } }); }; @@ -381,16 +371,24 @@ export const RuleActionsItem = (props: RuleActionsItemProps) => { ...action.alertsFilter, query, }; + + if (!newAlertsFilter.query) { + delete newAlertsFilter.query; + } + + const alertsFilter = isEmpty(newAlertsFilter) ? undefined : newAlertsFilter; + const newAction = { ...action, - alertsFilter: newAlertsFilter, + alertsFilter, }; + dispatch({ type: 'setActionProperty', payload: { uuid: action.uuid!, key: 'alertsFilter', - value: newAlertsFilter, + value: alertsFilter, }, }); validateActionBase(newAction); @@ -400,19 +398,33 @@ export const RuleActionsItem = (props: RuleActionsItemProps) => { const onTimeframeChange = useCallback( (timeframe?: AlertsFilterTimeframe) => { + const newAlertsFilter = { + ...action.alertsFilter, + timeframe, + }; + + if (!newAlertsFilter.timeframe) { + delete newAlertsFilter.timeframe; + } + + const alertsFilter = isEmpty(newAlertsFilter) ? undefined : newAlertsFilter; + + const newAction = { + ...action, + alertsFilter, + }; + dispatch({ type: 'setActionProperty', payload: { uuid: action.uuid!, key: 'alertsFilter', - value: { - ...action.alertsFilter, - timeframe, - }, + value: alertsFilter, }, }); + validateActionBase(newAction); }, - [action, dispatch] + [action, dispatch, validateActionBase] ); const onUseAadTemplateFieldsChange = useCallback(() => { @@ -443,9 +455,25 @@ export const RuleActionsItem = (props: RuleActionsItemProps) => { }, [action, storedActionParamsForAadToggle, dispatch]); const accordionContent = useMemo(() => { - if (!connector) { + if (!connector || !checkEnabledResult) { return null; } + + if (!checkEnabledResult.isEnabled) { + return ( + + {checkEnabledResult.messageCard} + + ); + } + return ( { templateFields, useDefaultMessage, warning, + checkEnabledResult, onNotifyWhenChange, onActionGroupChange, onAlertsFilterChange, diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx index 7b12160c1dadd..327a0ba12634c 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx @@ -74,17 +74,14 @@ describe('RuleAlertDelay', () => { expect(mockOnChange).not.toHaveBeenCalled(); }); - test('Should call onChange with null if empty string is typed', () => { + test('Should not call onChange if empty string is typed', () => { render(); fireEvent.change(screen.getByTestId('alertDelayInput'), { target: { value: '', }, }); - expect(mockOnChange).toHaveBeenCalledWith({ - type: 'setAlertDelay', - payload: null, - }); + expect(mockOnChange).not.toHaveBeenCalled(); }); test('Should display error when input is invalid', () => { diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx index 5b26c38232ab4..a79f1f5efe447 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx @@ -28,16 +28,8 @@ export const RuleAlertDelay = () => { const onAlertDelayChange = useCallback( (e: React.ChangeEvent) => { - if (!e.target.validity.valid) { - return; - } - const value = e.target.value; - if (value === '') { - dispatch({ - type: 'setAlertDelay', - payload: null, - }); - } else if (INTEGER_REGEX.test(value)) { + const value = e.target.value.trim(); + if (INTEGER_REGEX.test(value)) { const parsedValue = parseInt(value, 10); dispatch({ type: 'setAlertDelay', @@ -66,7 +58,7 @@ export const RuleAlertDelay = () => { { active: 5, }, notifyWhen: null, - consumer: 'stackAlerts', + consumer: 'alerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, canShowConsumerSelection: true, validConsumers: ['logs', 'stackAlerts'], + availableRuleTypes: [ruleType], }); render(); @@ -164,13 +167,16 @@ describe('Rule Definition', () => { active: 5, }, notifyWhen: null, - consumer: 'stackAlerts', + consumer: 'alerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: { ...ruleModel, documentationUrl: null, }, + availableRuleTypes: [ruleType], + validConsumers: ['logs', 'stackAlerts'], }); render(); @@ -191,6 +197,7 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, @@ -215,9 +222,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], canShowConsumerSelect: true, validConsumers: ['logs'], }); @@ -241,9 +250,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], canShowConsumerSelect: true, validConsumers: ['logs', 'observability'], }); @@ -267,9 +278,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], }); render(); @@ -292,9 +305,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], }); render(); @@ -326,9 +341,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], canShowConsumerSelection: true, validConsumers: ['logs', 'stackAlerts'], }); @@ -339,6 +356,48 @@ describe('Rule Definition', () => { expect(screen.getByTestId('ruleSettingsFlappingForm')).toBeInTheDocument(); }); + test('should hide flapping if the user does not have read access', async () => { + useRuleFormState.mockReturnValue({ + plugins: { + charts: {} as ChartsPluginSetup, + data: {} as DataPublicPluginStart, + dataViews: {} as DataViewsPublicPluginStart, + unifiedSearch: {} as UnifiedSearchPublicPluginStart, + docLinks: {} as DocLinksStart, + application: { + capabilities: { + rulesSettings: { + readFlappingSettingsUI: false, + writeFlappingSettingsUI: true, + }, + }, + }, + }, + formData: { + id: 'test-id', + params: {}, + schedule: { + interval: '1m', + }, + alertDelay: { + active: 5, + }, + notifyWhen: null, + consumer: 'stackAlerts', + ruleTypeId: '.es-query', + }, + selectedRuleType: ruleType, + selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], + canShowConsumerSelection: true, + validConsumers: ['logs', 'stackAlerts'], + }); + + render(); + + expect(screen.queryByTestId('ruleDefinitionFlappingFormGroup')).not.toBeInTheDocument(); + }); + test('should allow flapping to be changed', async () => { useRuleFormState.mockReturnValue({ plugins, @@ -353,9 +412,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], canShowConsumerSelection: true, validConsumers: ['logs', 'stackAlerts'], }); @@ -389,9 +450,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], canShowConsumerSelection: true, validConsumers: ['logs', 'stackAlerts'], }); 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 3b404edc5d029..997e666e8340f 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 @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { Suspense, useMemo, useState, useCallback } from 'react'; +import React, { Suspense, useMemo, useState, useCallback, useEffect } from 'react'; import { EuiEmptyPrompt, EuiLoadingSpinner, @@ -47,7 +47,7 @@ import { RuleAlertDelay } from './rule_alert_delay'; import { RuleConsumerSelection } from './rule_consumer_selection'; import { RuleSchedule } from './rule_schedule'; import { useRuleFormState, useRuleFormDispatch } from '../hooks'; -import { MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants'; +import { ALERTING_FEATURE_ID, MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants'; import { getAuthorizedConsumers } from '../utils'; import { RuleSettingsFlappingTitleTooltip } from '../../rule_settings/rule_settings_flapping_title_tooltip'; import { RuleSettingsFlappingForm } from '../../rule_settings/rule_settings_flapping_form'; @@ -62,6 +62,7 @@ export const RuleDefinition = () => { metadata, selectedRuleType, selectedRuleTypeModel, + availableRuleTypes, validConsumers, canShowConsumerSelection = false, flappingSettings, @@ -70,29 +71,44 @@ export const RuleDefinition = () => { const { colorMode } = useEuiTheme(); const dispatch = useRuleFormDispatch(); + useEffect(() => { + // Need to do a dry run validating the params because the Missing Monitor Data rule type + // does not properly initialize the params + if (selectedRuleType.id === 'monitoring_alert_missing_monitoring_data') { + dispatch({ type: 'runValidation' }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const { charts, data, dataViews, unifiedSearch, docLinks, application } = plugins; const { capabilities: { rulesSettings }, } = application; - const { writeFlappingSettingsUI } = rulesSettings || {}; + const { readFlappingSettingsUI, writeFlappingSettingsUI } = rulesSettings || {}; - const { params, schedule, notifyWhen, flapping } = formData; + const { params, schedule, notifyWhen, flapping, consumer, ruleTypeId } = formData; const [isAdvancedOptionsVisible, setIsAdvancedOptionsVisible] = useState(false); const [isFlappingPopoverOpen, setIsFlappingPopoverOpen] = useState(false); const authorizedConsumers = useMemo(() => { - if (!validConsumers?.length) { + if (consumer !== ALERTING_FEATURE_ID) { + return []; + } + const selectedAvailableRuleType = availableRuleTypes.find((ruleType) => { + return ruleType.id === selectedRuleType.id; + }); + if (!selectedAvailableRuleType?.authorizedConsumers) { return []; } return getAuthorizedConsumers({ - ruleType: selectedRuleType, + ruleType: selectedAvailableRuleType, validConsumers, }); - }, [selectedRuleType, validConsumers]); + }, [consumer, selectedRuleType, availableRuleTypes, validConsumers]); const shouldShowConsumerSelect = useMemo(() => { if (!canShowConsumerSelection) { @@ -107,10 +123,8 @@ export const RuleDefinition = () => { ) { return false; } - return ( - selectedRuleTypeModel.id && MULTI_CONSUMER_RULE_TYPE_IDS.includes(selectedRuleTypeModel.id) - ); - }, [authorizedConsumers, selectedRuleTypeModel, canShowConsumerSelection]); + return !!(ruleTypeId && MULTI_CONSUMER_RULE_TYPE_IDS.includes(ruleTypeId)); + }, [ruleTypeId, authorizedConsumers, canShowConsumerSelection]); const RuleParamsExpressionComponent = selectedRuleTypeModel.ruleParamsExpression ?? null; @@ -305,8 +319,9 @@ export const RuleDefinition = () => { > - {IS_RULE_SPECIFIC_FLAPPING_ENABLED && ( + {IS_RULE_SPECIFIC_FLAPPING_ENABLED && readFlappingSettingsUI && ( {ALERT_FLAPPING_DETECTION_TITLE}} description={ diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx index 26342d99580a6..1768303c55223 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx @@ -80,9 +80,6 @@ export const RuleSchedule = () => { const onIntervalNumberChange = useCallback( (e: React.ChangeEvent) => { - if (!e.target.validity.valid) { - return; - } const value = e.target.value.trim(); if (INTEGER_REGEX.test(value)) { const parsedValue = parseInt(value, 10); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form.tsx index d1a0f6a56fe2b..c09add5ae1c06 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form.tsx @@ -23,11 +23,12 @@ const queryClient = new QueryClient(); export interface RuleFormProps { plugins: RuleFormPlugins; - returnUrl: string; + onCancel?: () => void; + onSubmit?: (ruleId: string) => void; } export const RuleForm = (props: RuleFormProps) => { - const { plugins, returnUrl } = props; + const { plugins, onCancel, onSubmit } = props; const { id, ruleTypeId } = useParams<{ id?: string; ruleTypeId?: string; @@ -35,23 +36,31 @@ export const RuleForm = (props: RuleFormProps) => { const ruleFormComponent = useMemo(() => { if (id) { - return ; + return ; } if (ruleTypeId) { - return ; + return ( + + ); } return ( {RULE_FORM_ROUTE_PARAMS_ERROR_TITLE}} - > - -

{RULE_FORM_ROUTE_PARAMS_ERROR_TEXT}

-
-
+ body={ + +

{RULE_FORM_ROUTE_PARAMS_ERROR_TEXT}

+
+ } + /> ); - }, [id, ruleTypeId, plugins, returnUrl]); + }, [id, ruleTypeId, plugins, onCancel, onSubmit]); return {ruleFormComponent}; }; 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 81d1aab4b2c3f..d8e6380462f9b 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 @@ -76,6 +76,8 @@ const initialState: RuleFormState = { selectedRuleType: indexThresholdRuleType, selectedRuleTypeModel: indexThresholdRuleTypeModel, multiConsumerSelection: 'stackAlerts', + availableRuleTypes: [], + validConsumers: [], connectors: [], connectorTypes: [], aadTemplateFields: [], 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 a65842125b6a8..d79ae00988875 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 @@ -8,7 +8,7 @@ */ import { RuleActionParams } from '@kbn/alerting-types'; -import { omit } from 'lodash'; +import { isEmpty, omit } from 'lodash'; import { RuleFormActionsErrors, RuleFormParamsErrors, RuleUiAction } from '../../common'; import { RuleFormData, RuleFormState } from '../types'; import { validateRuleBase, validateRuleParams } from '../validation'; @@ -106,13 +106,20 @@ export type RuleFormStateReducerAction = uuid: string; errors: RuleFormParamsErrors; }; + } + | { + type: 'runValidation'; }; const getUpdateWithValidation = (ruleFormState: RuleFormState) => (updater: () => RuleFormData): RuleFormState => { - const { minimumScheduleInterval, selectedRuleTypeModel, multiConsumerSelection } = - ruleFormState; + const { + minimumScheduleInterval, + selectedRuleTypeModel, + multiConsumerSelection, + selectedRuleType, + } = ruleFormState; const formData = updater(); @@ -121,17 +128,33 @@ const getUpdateWithValidation = ...(multiConsumerSelection ? { consumer: multiConsumerSelection } : {}), }; + const baseErrors = validateRuleBase({ + formData: formDataWithMultiConsumer, + minimumScheduleInterval, + }); + + const paramsErrors = validateRuleParams({ + formData: formDataWithMultiConsumer, + ruleTypeModel: selectedRuleTypeModel, + }); + + // We need to do this because the Missing Monitor Data rule type + // for whatever reason does not initialize the params with any data, + // therefore the expression component renders as blank + if (selectedRuleType.id === 'monitoring_alert_missing_monitoring_data') { + if (isEmpty(formData.params) && !isEmpty(paramsErrors)) { + Object.keys(paramsErrors).forEach((key) => { + formData.params[key] = null; + }); + } + } + return { ...ruleFormState, formData, - baseErrors: validateRuleBase({ - formData: formDataWithMultiConsumer, - minimumScheduleInterval, - }), - paramsErrors: validateRuleParams({ - formData: formDataWithMultiConsumer, - ruleTypeModel: selectedRuleTypeModel, - }), + baseErrors, + paramsErrors, + touched: true, }; }; @@ -222,6 +245,7 @@ export const ruleFormStateReducer = ( return { ...ruleFormState, multiConsumerSelection: payload, + touched: true, }; } case 'setMetadata': { @@ -326,6 +350,9 @@ export const ruleFormStateReducer = ( }, }; } + case 'runValidation': { + return updateWithValidation(() => formData); + } 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 ca80c0b77aae3..ac07c580fbd49 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 @@ -61,6 +61,8 @@ const formDataMock: RuleFormData = { }, }; +const onCancel = jest.fn(); + useRuleFormState.mockReturnValue({ plugins: { application: { @@ -84,7 +86,6 @@ useRuleFormState.mockReturnValue({ }); const onSave = jest.fn(); -const returnUrl = 'management'; describe('rulePage', () => { afterEach(() => { @@ -92,7 +93,7 @@ describe('rulePage', () => { }); test('renders correctly', () => { - render(); + render(); expect(screen.getByText(RULE_FORM_PAGE_RULE_DEFINITION_TITLE)).toBeInTheDocument(); expect(screen.getByText(RULE_FORM_PAGE_RULE_ACTIONS_TITLE)).toBeInTheDocument(); @@ -100,7 +101,7 @@ describe('rulePage', () => { }); test('should call onSave when save button is pressed', () => { - render(); + render(); fireEvent.click(screen.getByTestId('rulePageFooterSaveButton')); fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); @@ -112,16 +113,16 @@ describe('rulePage', () => { }); test('should call onCancel when the cancel button is clicked', () => { - render(); + render(); fireEvent.click(screen.getByTestId('rulePageFooterCancelButton')); - expect(navigateToUrl).toHaveBeenCalledWith('management'); + expect(onCancel).toHaveBeenCalled(); }); test('should call onCancel when the return button is clicked', () => { - render(); + render(); fireEvent.click(screen.getByTestId('rulePageReturnButton')); - expect(navigateToUrl).toHaveBeenCalledWith('management'); + expect(onCancel).toHaveBeenCalled(); }); }); 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 4e2e019d41269..68ff6d5db6b19 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 @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiPageTemplate, EuiHorizontalRule, @@ -18,6 +18,8 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, + EuiCallOut, + EuiConfirmModal, } from '@elastic/eui'; import { RuleDefinition, @@ -33,32 +35,45 @@ import { RULE_FORM_PAGE_RULE_ACTIONS_TITLE, RULE_FORM_PAGE_RULE_DETAILS_TITLE, RULE_FORM_RETURN_TITLE, + DISABLED_ACTIONS_WARNING_TITLE, + RULE_FORM_CANCEL_MODAL_TITLE, + RULE_FORM_CANCEL_MODAL_DESCRIPTION, + RULE_FORM_CANCEL_MODAL_CONFIRM, + RULE_FORM_CANCEL_MODAL_CANCEL, } from '../translations'; +import { hasActionsError, hasActionsParamsErrors, hasParamsErrors } from '../validation'; +import { checkActionFormActionTypeEnabled } from '../utils/check_action_type_enabled'; export interface RulePageProps { isEdit?: boolean; isSaving?: boolean; - returnUrl: string; + onCancel?: () => void; onSave: (formData: RuleFormData) => void; } export const RulePage = (props: RulePageProps) => { - const { isEdit = false, isSaving = false, returnUrl, onSave } = props; + const { isEdit = false, isSaving = false, onCancel = () => {}, onSave } = props; + const [isCancelModalOpen, setIsCancelModalOpen] = useState(false); const { plugins: { application }, + baseErrors = {}, + paramsErrors = {}, + actionsErrors = {}, + actionsParamsErrors = {}, formData, multiConsumerSelection, + connectorTypes, + connectors, + touched, } = useRuleFormState(); + const { actions } = formData; + const canReadConnectors = !!application.capabilities.actions?.show; const styles = useEuiBackgroundColorCSS().transparent; - const onCancel = useCallback(() => { - application.navigateToUrl(returnUrl); - }, [application, returnUrl]); - const onSaveInternal = useCallback(() => { onSave({ ...formData, @@ -66,11 +81,51 @@ export const RulePage = (props: RulePageProps) => { }); }, [onSave, formData, multiConsumerSelection]); - const actionComponent = useMemo(() => { + const onCancelInternal = useCallback(() => { + if (touched) { + setIsCancelModalOpen(true); + } else { + onCancel(); + } + }, [touched, onCancel]); + + const hasActionsDisabled = useMemo(() => { + const preconfiguredConnectors = connectors.filter((connector) => connector.isPreconfigured); + return actions.some((action) => { + const actionType = connectorTypes.find(({ id }) => id === action.actionTypeId); + if (!actionType) { + return false; + } + const checkEnabledResult = checkActionFormActionTypeEnabled( + actionType, + preconfiguredConnectors + ); + return !actionType.enabled && !checkEnabledResult.isEnabled; + }); + }, [actions, connectors, connectorTypes]); + + const hasRuleDefinitionErrors = useMemo(() => { + return !!( + hasParamsErrors(paramsErrors) || + baseErrors.interval?.length || + baseErrors.alertDelay?.length + ); + }, [paramsErrors, baseErrors]); + + const hasActionErrors = useMemo(() => { + return hasActionsError(actionsErrors) || hasActionsParamsErrors(actionsParamsErrors); + }, [actionsErrors, actionsParamsErrors]); + + const hasRuleDetailsError = useMemo(() => { + return baseErrors.name?.length || baseErrors.tags?.length; + }, [baseErrors]); + + const actionComponent: EuiStepsProps['steps'] = useMemo(() => { if (canReadConnectors) { return [ { title: RULE_FORM_PAGE_RULE_ACTIONS_TITLE, + status: hasActionErrors ? 'danger' : undefined, children: ( <> @@ -82,17 +137,19 @@ export const RulePage = (props: RulePageProps) => { ]; } return []; - }, [canReadConnectors]); + }, [hasActionErrors, canReadConnectors]); const steps: EuiStepsProps['steps'] = useMemo(() => { return [ { title: RULE_FORM_PAGE_RULE_DEFINITION_TITLE, + status: hasRuleDefinitionErrors ? 'danger' : undefined, children: , }, ...actionComponent, { title: RULE_FORM_PAGE_RULE_DETAILS_TITLE, + status: hasRuleDetailsError ? 'danger' : undefined, children: ( <> @@ -102,46 +159,73 @@ export const RulePage = (props: RulePageProps) => { ), }, ]; - }, [actionComponent]); + }, [hasRuleDefinitionErrors, hasRuleDetailsError, actionComponent]); return ( - - - + + + + + + {RULE_FORM_RETURN_TITLE} + + + + + + + + + + {hasActionsDisabled && ( + <> + + + + )} + + + + + + + {isCancelModalOpen && ( + setIsCancelModalOpen(false)} + onConfirm={onCancel} + buttonColor="danger" + defaultFocusedButton="confirm" + title={RULE_FORM_CANCEL_MODAL_TITLE} + confirmButtonText={RULE_FORM_CANCEL_MODAL_CONFIRM} + cancelButtonText={RULE_FORM_CANCEL_MODAL_CANCEL} > - - - {RULE_FORM_RETURN_TITLE} - - - - - - - - - - - - - - - +

{RULE_FORM_CANCEL_MODAL_DESCRIPTION}

+ + )} + ); }; 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 45e2008773583..d937c60aa3a52 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 @@ -32,15 +32,27 @@ const onSave = jest.fn(); const onCancel = jest.fn(); hasRuleErrors.mockReturnValue(false); -useRuleFormState.mockReturnValue({ - baseErrors: {}, - paramsErrors: {}, - formData: { - actions: [], - }, -}); describe('rulePageFooter', () => { + beforeEach(() => { + useRuleFormState.mockReturnValue({ + plugins: { + application: { + capabilities: { + actions: { + show: true, + }, + }, + }, + }, + baseErrors: {}, + paramsErrors: {}, + formData: { + actions: [], + }, + }); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -75,6 +87,30 @@ describe('rulePageFooter', () => { expect(screen.getByTestId('rulePageConfirmCreateRule')).toBeInTheDocument(); }); + test('should not show creat rule confirmation if user cannot read actions', () => { + useRuleFormState.mockReturnValue({ + plugins: { + application: { + capabilities: { + actions: { + show: false, + }, + }, + }, + }, + baseErrors: {}, + paramsErrors: {}, + formData: { + actions: [], + }, + }); + + render(); + fireEvent.click(screen.getByTestId('rulePageFooterSaveButton')); + expect(screen.queryByTestId('rulePageConfirmCreateRule')).not.toBeInTheDocument(); + expect(onSave).toHaveBeenCalled(); + }); + test('should show call onSave if clicking rule confirmation', () => { render(); 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 09d2ac429fd50..62a0e4b64e4f1 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 @@ -34,6 +34,7 @@ export const RulePageFooter = (props: RulePageFooterProps) => { const { isEdit = false, isSaving = false, onCancel, onSave } = props; const { + plugins: { application }, formData: { actions }, connectors, baseErrors = {}, @@ -78,11 +79,12 @@ export const RulePageFooter = (props: RulePageFooterProps) => { if (isEdit) { return onSave(); } - if (actions.length === 0) { + const canReadConnectors = !!application.capabilities.actions?.show; + if (actions.length === 0 && canReadConnectors) { return setShowCreateConfirmation(true); } onSave(); - }, [actions, isEdit, onSave]); + }, [actions, isEdit, application, onSave]); const onCreateConfirmClick = useCallback(() => { setShowCreateConfirmation(false); 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 20e87c66f10f4..fca2e30b94434 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts @@ -194,7 +194,7 @@ export const RULE_TYPE_REQUIRED_TEXT = i18n.translate( export const RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT = i18n.translate( 'alertsUIShared.ruleForm.error.belowMinimumAlertDelayText', { - defaultMessage: 'Alert delay must be greater than 1.', + defaultMessage: 'Alert delay must be 1 or greater.', } ); @@ -498,6 +498,34 @@ export const RULE_FORM_RETURN_TITLE = i18n.translate('alertsUIShared.ruleForm.re defaultMessage: 'Return', }); +export const RULE_FORM_CANCEL_MODAL_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleFormCancelModalTitle', + { + defaultMessage: 'Discard unsaved changes to rule?', + } +); + +export const RULE_FORM_CANCEL_MODAL_DESCRIPTION = i18n.translate( + 'alertsUIShared.ruleForm.ruleFormCancelModalDescription', + { + defaultMessage: "You can't recover unsaved changes.", + } +); + +export const RULE_FORM_CANCEL_MODAL_CONFIRM = i18n.translate( + 'alertsUIShared.ruleForm.ruleFormCancelModalConfirm', + { + defaultMessage: 'Discard changes', + } +); + +export const RULE_FORM_CANCEL_MODAL_CANCEL = i18n.translate( + 'alertsUIShared.ruleForm.ruleFormCancelModalCancel', + { + defaultMessage: 'Cancel', + } +); + export const MODAL_SEARCH_PLACEHOLDER = i18n.translate( 'alertsUIShared.ruleForm.modalSearchPlaceholder', { @@ -586,3 +614,10 @@ export const TECH_PREVIEW_DESCRIPTION = i18n.translate( '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.', } ); + +export const DISABLED_ACTIONS_WARNING_TITLE = i18n.translate( + 'alertsUIShared.disabledActionsWarningTitle', + { + defaultMessage: 'This rule has actions that are disabled', + } +); 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 d33c74da528db..4b45f64d3ead4 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/types.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/types.ts @@ -72,6 +72,7 @@ export interface RuleFormState { connectors: ActionConnector[]; connectorTypes: ActionType[]; aadTemplateFields: ActionVariable[]; + availableRuleTypes: RuleTypeWithDescription[]; baseErrors?: RuleFormBaseErrors; paramsErrors?: RuleFormParamsErrors; actionsErrors?: Record; @@ -83,8 +84,9 @@ export interface RuleFormState { metadata?: Record; minimumScheduleInterval?: MinimumScheduleInterval; canShowConsumerSelection?: boolean; - validConsumers?: RuleCreationValidConsumer[]; + validConsumers: RuleCreationValidConsumer[]; flappingSettings?: RulesSettingsFlapping; + touched?: boolean; } export type InitialRule = Partial & diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_consumers.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_consumers.ts index 217bb18328d0e..0b5234c669440 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_consumers.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_consumers.ts @@ -17,9 +17,6 @@ export const getAuthorizedConsumers = ({ ruleType: RuleTypeWithDescription; validConsumers: RuleCreationValidConsumer[]; }) => { - if (!ruleType.authorizedConsumers) { - return []; - } return Object.entries(ruleType.authorizedConsumers).reduce( (result, [authorizedConsumer, privilege]) => { if ( diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_default_params.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_default_params.ts new file mode 100644 index 0000000000000..d2aab787d6eb5 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_default_params.ts @@ -0,0 +1,25 @@ +/* + * 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 { ActionTypeModel, RuleTypeWithDescription } from '../../common/types'; + +export const getDefaultParams = ({ + group, + ruleType, + actionTypeModel, +}: { + group: string; + actionTypeModel: ActionTypeModel; + ruleType: RuleTypeWithDescription; +}) => { + if (group === ruleType.recoveryActionGroup.id) { + return actionTypeModel.defaultRecoveredActionParams; + } else { + return actionTypeModel.defaultActionParams; + } +}; 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 f5b583a1a9c63..53c9aedda7545 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 @@ -17,3 +17,4 @@ export * from './get_initial_schedule'; export * from './has_fields_for_aad'; export * from './get_selected_action_group'; export * from './get_initial_consumer'; +export * from './get_default_params'; 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 d65e9c5893937..57afe66b53edf 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 @@ -35,7 +35,10 @@ export const validateAction = ({ action }: { action: RuleUiAction }): RuleFormAc if ('alertsFilter' in action) { const query = action?.alertsFilter?.query; - if (query && !query.kql) { + if (!query) { + return errors; + } + if (!query.filters.length && !query.kql) { errors.filterQuery.push( i18n.translate('alertsUIShared.ruleForm.actionsForm.requiredFilterQuery', { defaultMessage: 'A custom query is required.', @@ -43,7 +46,6 @@ export const validateAction = ({ action }: { action: RuleUiAction }): RuleFormAc ); } } - return errors; }; @@ -88,11 +90,7 @@ export function validateRuleBase({ errors.ruleTypeId.push(RULE_TYPE_REQUIRED_TEXT); } - if ( - formData.alertDelay && - !isNaN(formData.alertDelay?.active) && - formData.alertDelay?.active < 1 - ) { + if (!formData.alertDelay || isNaN(formData.alertDelay.active) || formData.alertDelay.active < 1) { errors.alertDelay.push(RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT); } @@ -111,34 +109,41 @@ export const validateRuleParams = ({ return ruleTypeModel.validate(formData.params, isServerless).errors; }; -const hasRuleBaseErrors = (errors: RuleFormBaseErrors) => { +export const hasRuleBaseErrors = (errors: RuleFormBaseErrors) => { return Object.values(errors).some((error: string[]) => error.length > 0); }; -const hasActionsError = (actionsErrors: Record) => { +export 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); +export const hasParamsErrors = (errors: RuleFormParamsErrors | string | string[]): boolean => { let hasError = false; - for (const value of values) { - if (Array.isArray(value) && value.length > 0) { - return true; - } - if (typeof value === 'string' && value.trim() !== '') { - return true; - } - if (isObject(value)) { - hasError = hasParamsErrors(value as RuleFormParamsErrors); - } + + if (typeof errors === 'string' && errors.trim() !== '') { + hasError = true; } + + if (Array.isArray(errors)) { + errors.forEach((error) => { + hasError = hasError || hasParamsErrors(error); + }); + } + + if (isObject(errors)) { + Object.entries(errors).forEach(([_, value]) => { + hasError = hasError || hasParamsErrors(value); + }); + } + return hasError; }; -const hasActionsParamsErrors = (actionsParamsErrors: Record) => { +export const hasActionsParamsErrors = ( + actionsParamsErrors: Record +) => { return Object.values(actionsParamsErrors).some((errors: RuleFormParamsErrors) => { return hasParamsErrors(errors); }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_form.tsx b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_form.tsx index 99f64f0a3977f..030cde8127b0a 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_form.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_form.tsx @@ -218,15 +218,17 @@ export const RuleSettingsFlappingForm = (props: RuleSettingsFlappingFormProps) = direction={isDesktop ? 'row' : 'column'} alignItems={isDesktop ? 'center' : undefined} > - + {flappingLabel} - + {enabled ? flappingOnLabel : flappingOffLabel} {flappingSettings && enabled && ( - {flappingOverrideLabel} + + {flappingOverrideLabel} + )} @@ -236,6 +238,7 @@ export const RuleSettingsFlappingForm = (props: RuleSettingsFlappingFormProps) = compressed checked={!!flappingSettings} label={flappingOverrideConfiguration} + disabled={!canWriteFlappingSettingsUI} onChange={onFlappingToggle} /> )} @@ -256,6 +259,7 @@ export const RuleSettingsFlappingForm = (props: RuleSettingsFlappingFormProps) = spaceFlappingSettings, flappingSettings, flappingOffTooltip, + canWriteFlappingSettingsUI, onFlappingToggle, ]); @@ -273,12 +277,14 @@ export const RuleSettingsFlappingForm = (props: RuleSettingsFlappingFormProps) = statusChangeThreshold={flappingSettings.statusChangeThreshold} onLookBackWindowChange={onLookBackWindowChange} onStatusChangeThresholdChange={onStatusChangeThresholdChange} + isDisabled={!canWriteFlappingSettingsUI} /> ); }, [ flappingSettings, spaceFlappingSettings, + canWriteFlappingSettingsUI, onLookBackWindowChange, onStatusChangeThresholdChange, ]); diff --git a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_title_tooltip.tsx b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_title_tooltip.tsx index 2a5cc4186013d..149eb5b792c1b 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_title_tooltip.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_title_tooltip.tsx @@ -80,6 +80,7 @@ export const RuleSettingsFlappingTitleTooltip = (props: RuleSettingsFlappingTitl panelStyle={{ width: 500, }} + closePopover={() => setIsPopoverOpen(false)} button={ ruleDetailsRoute.replace(':ruleId', ruleId); +export const getCreateRuleRoute = (ruleTypeId: string) => + createRuleRoute.replace(':ruleTypeId', ruleTypeId); +export const getEditRuleRoute = (ruleId: string) => editRuleRoute.replace(':id', ruleId); 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 4a429fbfd58d7..b3c11beb5285c 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/application.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/application.tsx @@ -203,7 +203,6 @@ const TriggersActionsUiExampleApp = ({ ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry, actionTypeRegistry: triggersActionsUi.actionTypeRegistry, }} - returnUrl={application.getUrlForApp('triggersActionsUiExample')} /> )} @@ -229,7 +228,6 @@ const TriggersActionsUiExampleApp = ({ ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry, actionTypeRegistry: triggersActionsUi.actionTypeRegistry, }} - returnUrl={application.getUrlForApp('triggersActionsUiExample')} /> )} diff --git a/x-pack/plugins/triggers_actions_ui/.storybook/decorator.tsx b/x-pack/plugins/triggers_actions_ui/.storybook/decorator.tsx index 183b1acd3ca53..233b673353929 100644 --- a/x-pack/plugins/triggers_actions_ui/.storybook/decorator.tsx +++ b/x-pack/plugins/triggers_actions_ui/.storybook/decorator.tsx @@ -69,7 +69,7 @@ export const StorybookContextDecorator: FC; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts index 8a6003960473b..8d39d7851d9bf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts @@ -25,6 +25,8 @@ export const routeToConnectors = `/connectors`; export const routeToConnectorEdit = `/connectors/:connectorId`; export const routeToRules = `/rules`; export const routeToLogs = `/logs`; +export const routeToCreateRule = '/rules/create'; +export const routeToEditRule = '/rules/edit'; export const legacyRouteToAlerts = `/alerts`; export const legacyRouteToRuleDetails = `/alert/:alertId`; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx index 045732830c891..a2a2187c75895 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx @@ -72,6 +72,7 @@ export const TriggersActionsUIHome: React.FunctionComponent { defaultMessage: 'Rules', }); break; + case 'createRule': + updatedTitle = i18n.translate('xpack.triggersActionsUI.rules.createRule.breadcrumbTitle', { + defaultMessage: 'Create rule', + }); + break; + case 'editRule': + updatedTitle = i18n.translate('xpack.triggersActionsUI.rules.editRule.breadcrumbTitle', { + defaultMessage: 'Edit rule', + }); + break; case 'alerts': updatedTitle = i18n.translate('xpack.triggersActionsUI.alerts.breadcrumbTitle', { defaultMessage: 'Alerts', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/rules_app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/rules_app.tsx index 9f472c251a91b..8550518edb457 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/rules_app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/rules_app.tsx @@ -31,7 +31,7 @@ import type { LensPublicStart } from '@kbn/lens-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; -import { ruleDetailsRoute } from '@kbn/rule-data-utils'; +import { ruleDetailsRoute, createRuleRoute, editRuleRoute } from '@kbn/rule-data-utils'; import { QueryClientProvider } from '@tanstack/react-query'; import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { ExpressionsStart } from '@kbn/expressions-plugin/public'; @@ -54,11 +54,14 @@ import { KibanaContextProvider, useKibana } from '../common/lib/kibana'; import { ConnectorProvider } from './context/connector_context'; import { ALERTS_PAGE_ID, CONNECTORS_PLUGIN_ID } from '../common/constants'; import { queryClient } from './query_client'; +import { getIsExperimentalFeatureEnabled } from '../common/get_experimental_features'; const TriggersActionsUIHome = lazy(() => import('./home')); const RuleDetailsRoute = lazy( () => import('./sections/rule_details/components/rule_details_route') ); +const CreateRuleRoute = lazy(() => import('./sections/rule_form/rule_form_route')); +const EditRuleRoute = lazy(() => import('./sections/rule_form/rule_form_route')); export interface TriggersAndActionsUiServices extends CoreStart { actions: ActionsPublicPluginSetup; @@ -122,9 +125,25 @@ export const AppWithoutRouter = ({ sectionsRegex }: { sectionsRegex: string }) = application: { navigateToApp }, } = useKibana().services; + const isUsingRuleCreateFlyout = getIsExperimentalFeatureEnabled('isUsingRuleCreateFlyout'); + return ( + {!isUsingRuleCreateFlyout && ( + + )} + {!isUsingRuleCreateFlyout && ( + + )} - diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx index c6598becec313..ca4de13be903b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx @@ -199,6 +199,7 @@ export function RuleComponent({ actionTypeRegistry, ruleTypeRegistry, hideEditButton: true, + useNewRuleForm: true, onEditRule: requestRefresh, })}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx index 91a77d18009c5..bc5da8218d118 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx @@ -21,6 +21,10 @@ jest.mock('./rule_actions', () => ({ }, })); +jest.mock('../../../../common/get_experimental_features', () => ({ + getIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), +})); + jest.mock('../../../lib/capabilities', () => ({ hasAllPrivilege: jest.fn(() => true), hasSaveRulesCapability: jest.fn(() => true), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.tsx index e608a71af05a6..ed21bb88992a8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.tsx @@ -16,13 +16,14 @@ import { EuiLoadingSpinner, EuiDescriptionList, } from '@elastic/eui'; -import { AlertConsumers } from '@kbn/rule-data-utils'; +import { AlertConsumers, getEditRuleRoute, getRuleDetailsRoute } from '@kbn/rule-data-utils'; import { i18n } from '@kbn/i18n'; import { formatDuration } from '@kbn/alerting-plugin/common'; import { useLoadRuleTypesQuery } from '../../../hooks/use_load_rule_types_query'; import { RuleDefinitionProps } from '../../../../types'; import { RuleType } from '../../../..'; import { useKibana } from '../../../../common/lib/kibana'; +import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; import { hasAllPrivilege, hasExecuteActionsCapability, @@ -38,11 +39,14 @@ export const RuleDefinition: React.FunctionComponent = ({ onEditRule, hideEditButton = false, filteredRuleTypes = [], + useNewRuleForm = false, }) => { const { - application: { capabilities }, + application: { capabilities, navigateToApp }, } = useKibana().services; + const isUsingRuleCreateFlyout = getIsExperimentalFeatureEnabled('isUsingRuleCreateFlyout'); + const [editFlyoutVisible, setEditFlyoutVisible] = useState(false); const [ruleType, setRuleType] = useState(); const { @@ -103,6 +107,20 @@ export const RuleDefinition: React.FunctionComponent = ({ return ''; }, [rule, ruleTypeRegistry]); + const onEditRuleClick = () => { + if (!isUsingRuleCreateFlyout && useNewRuleForm) { + navigateToApp('management', { + path: `insightsAndAlerting/triggersActions/${getEditRuleRoute(rule.id)}`, + state: { + returnApp: 'management', + returnPath: `insightsAndAlerting/triggersActions/${getRuleDetailsRoute(rule.id)}`, + }, + }); + } else { + setEditFlyoutVisible(true); + } + }; + const ruleDefinitionList = [ { title: i18n.translate('xpack.triggersActionsUI.ruleDetails.ruleType', { @@ -153,7 +171,7 @@ export const RuleDefinition: React.FunctionComponent = ({ > {hasEditButton ? ( - setEditFlyoutVisible(true)} flush="left"> + {getRuleConditionsWording()} ) : ( @@ -206,7 +224,7 @@ export const RuleDefinition: React.FunctionComponent = ({ setEditFlyoutVisible(true)} + onClick={onEditRuleClick} /> ) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx index 615efb5ed74b6..ffde171117385 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx @@ -23,6 +23,10 @@ import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/get_experimental_features', () => ({ + getIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), +})); + jest.mock('@kbn/alerts-ui-shared/src/common/apis/fetch_ui_config', () => ({ fetchUiConfig: jest .fn() diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx index 8b2ee15db87d1..9422abdba3ec0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx @@ -26,7 +26,7 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { RuleExecutionStatusErrorReasons, parseDuration } from '@kbn/alerting-plugin/common'; -import { getRuleDetailsRoute } from '@kbn/rule-data-utils'; +import { getEditRuleRoute, getRuleDetailsRoute } from '@kbn/rule-data-utils'; import { fetchUiConfig as triggersActionsUiConfig } from '@kbn/alerts-ui-shared/src/common/apis/fetch_ui_config'; import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation'; import { bulkUpdateAPIKey } from '../../../lib/rule_api/update_api_key'; @@ -71,6 +71,7 @@ import { import { useBulkOperationToast } from '../../../hooks/use_bulk_operation_toast'; import { RefreshToken } from './types'; import { UntrackAlertsModal } from '../../common/components/untrack_alerts_modal'; +import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; export type RuleDetailsProps = { rule: Rule; @@ -78,6 +79,7 @@ export type RuleDetailsProps = { actionTypes: ActionType[]; requestRefresh: () => Promise; refreshToken?: RefreshToken; + useNewRuleForm?: boolean; } & Pick< BulkOperationsComponentOpts, 'bulkDisableRules' | 'bulkEnableRules' | 'bulkDeleteRules' | 'snoozeRule' | 'unsnoozeRule' @@ -98,7 +100,7 @@ export const RuleDetails: React.FunctionComponent = ({ }) => { const history = useHistory(); const { - application: { capabilities }, + application: { capabilities, navigateToApp }, ruleTypeRegistry, actionTypeRegistry, setBreadcrumbs, @@ -108,6 +110,9 @@ export const RuleDetails: React.FunctionComponent = ({ theme, notifications: { toasts }, } = useKibana().services; + + const isUsingRuleCreateFlyout = getIsExperimentalFeatureEnabled('isUsingRuleCreateFlyout'); + const ruleReducer = useMemo(() => getRuleReducer(actionTypeRegistry), [actionTypeRegistry]); const [{}, dispatch] = useReducer(ruleReducer, { rule }); const setInitialRule = (value: Rule) => { @@ -206,7 +211,7 @@ export const RuleDetails: React.FunctionComponent = ({ data-test-subj="ruleIntervalToastEditButton" onClick={() => { toasts.remove(configurationToast); - setEditFlyoutVisibility(true); + onEditRuleClick(); }} > = ({ }); } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ i18nStart, theme, @@ -256,12 +262,26 @@ export const RuleDetails: React.FunctionComponent = ({ } }; + const onEditRuleClick = () => { + if (!isUsingRuleCreateFlyout) { + navigateToApp('management', { + path: `insightsAndAlerting/triggersActions/${getEditRuleRoute(rule.id)}`, + state: { + returnApp: 'management', + returnPath: `insightsAndAlerting/triggersActions/${getRuleDetailsRoute(rule.id)}`, + }, + }); + } else { + setEditFlyoutVisibility(true); + } + }; + const editButton = hasEditButton ? ( <> setEditFlyoutVisibility(true)} + onClick={onEditRuleClick} name="edit" disabled={!ruleType.enabledInLicense} > @@ -529,7 +549,7 @@ export const RuleDetails: React.FunctionComponent = ({ setEditFlyoutVisibility(true)} + onClick={onEditRuleClick} > ({ .fn() .mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }), })); + +jest.mock('../../../../common/get_experimental_features', () => ({ + getIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), +})); + describe('rule_details_route', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx new file mode 100644 index 0000000000000..496b4d30873e6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx @@ -0,0 +1,100 @@ +/* + * 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, { useEffect } from 'react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { RuleForm } from '@kbn/alerts-ui-shared/src/rule_form/rule_form'; +import { getRuleDetailsRoute } from '@kbn/rule-data-utils'; +import { useLocation, useParams } from 'react-router-dom'; +import { useKibana } from '../../../common/lib/kibana'; +import { getAlertingSectionBreadcrumb } from '../../lib/breadcrumb'; +import { getCurrentDocTitle } from '../../lib/doc_title'; + +export const RuleFormRoute = () => { + const { + http, + i18n, + theme, + application, + notifications, + charts, + settings, + data, + dataViews, + unifiedSearch, + docLinks, + ruleTypeRegistry, + actionTypeRegistry, + chrome, + setBreadcrumbs, + } = useKibana().services; + + const location = useLocation<{ returnApp?: string; returnPath?: string }>(); + const { id, ruleTypeId } = useParams<{ + id?: string; + ruleTypeId?: string; + }>(); + const { returnApp, returnPath } = location.state || {}; + + // Set breadcrumb and page title + useEffect(() => { + if (id) { + setBreadcrumbs([ + getAlertingSectionBreadcrumb('rules', true), + getAlertingSectionBreadcrumb('editRule'), + ]); + chrome.docTitle.change(getCurrentDocTitle('editRule')); + } + if (ruleTypeId) { + setBreadcrumbs([ + getAlertingSectionBreadcrumb('rules', true), + getAlertingSectionBreadcrumb('createRule'), + ]); + chrome.docTitle.change(getCurrentDocTitle('createRule')); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + { + if (returnApp && returnPath) { + application.navigateToApp(returnApp, { path: returnPath }); + } else { + application.navigateToApp('management', { + path: `insightsAndAlerting/triggersActions/rules`, + }); + } + }} + onSubmit={(ruleId) => { + application.navigateToApp('management', { + path: `insightsAndAlerting/triggersActions/${getRuleDetailsRoute(ruleId)}`, + }); + }} + /> + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { RuleFormRoute as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index a36068125a6a5..d98aa2c5dec67 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -45,6 +45,8 @@ import { RuleCreationValidConsumer, ruleDetailsRoute as commonRuleDetailsRoute, STACK_ALERTS_FEATURE_ID, + getCreateRuleRoute, + getEditRuleRoute, } from '@kbn/rule-data-utils'; import { MaintenanceWindowCallout } from '@kbn/alerts-ui-shared'; import { @@ -139,6 +141,7 @@ export interface RulesListProps { onRefresh?: (refresh: Date) => void; setHeaderActions?: (components?: React.ReactNode[]) => void; initialSelectedConsumer?: RuleCreationValidConsumer | null; + useNewRuleForm?: boolean; } export const percentileFields = { @@ -180,12 +183,13 @@ export const RulesList = ({ onRefresh, setHeaderActions, initialSelectedConsumer = STACK_ALERTS_FEATURE_ID, + useNewRuleForm = false, }: RulesListProps) => { const history = useHistory(); const kibanaServices = useKibana().services; const { actionTypeRegistry, - application: { capabilities }, + application: { capabilities, navigateToApp }, http, kibanaFeatures, notifications: { toasts }, @@ -211,6 +215,7 @@ export const RulesList = ({ const cloneRuleId = useRef(null); const isRuleStatusFilterEnabled = getIsExperimentalFeatureEnabled('ruleStatusFilter'); + const isUsingRuleCreateFlyout = getIsExperimentalFeatureEnabled('isUsingRuleCreateFlyout'); const [percentileOptions, setPercentileOptions] = useState(initialPercentileOptions); @@ -312,8 +317,18 @@ export const RulesList = ({ }); const onRuleEdit = (ruleItem: RuleTableItem) => { - setEditFlyoutVisibility(true); - setCurrentRuleToEdit(ruleItem); + if (!isUsingRuleCreateFlyout && useNewRuleForm) { + navigateToApp('management', { + path: `insightsAndAlerting/triggersActions/${getEditRuleRoute(ruleItem.id)}`, + state: { + returnApp: 'management', + returnPath: `insightsAndAlerting/triggersActions/rules`, + }, + }); + } else { + setEditFlyoutVisibility(true); + setCurrentRuleToEdit(ruleItem); + } }; const onRunRule = async (id: string) => { @@ -1006,9 +1021,15 @@ export const RulesList = ({ setRuleTypeModalVisibility(false)} onSelectRuleType={(ruleTypeId) => { - setRuleTypeIdToCreate(ruleTypeId); - setRuleTypeModalVisibility(false); - setRuleFlyoutVisibility(true); + if (!isUsingRuleCreateFlyout) { + navigateToApp('management', { + path: `insightsAndAlerting/triggersActions/${getCreateRuleRoute(ruleTypeId)}`, + }); + } else { + setRuleTypeIdToCreate(ruleTypeId); + setRuleTypeModalVisibility(false); + setRuleFlyoutVisibility(true); + } }} http={http} toasts={toasts} diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx index b7ffa1aa48b18..b33622423b92d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx @@ -24,7 +24,7 @@ describe('getIsExperimentalFeatureEnabled', () => { ruleKqlBar: true, isMustacheAutocompleteOn: false, showMustacheAutocompleteSwitch: false, - ruleFormV2: false, + isUsingRuleCreateFlyout: false, }, }); @@ -64,7 +64,7 @@ describe('getIsExperimentalFeatureEnabled', () => { expect(result).toEqual(false); - result = getIsExperimentalFeatureEnabled('ruleFormV2'); + result = getIsExperimentalFeatureEnabled('isUsingRuleCreateFlyout'); expect(result).toEqual(false); diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 6ad86397606c8..a592b19ae8a79 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -395,6 +395,7 @@ export interface RuleDefinitionProps Promise; hideEditButton?: boolean; filteredRuleTypes?: string[]; + useNewRuleForm?: boolean; } export enum Percentiles { diff --git a/x-pack/test/functional_with_es_ssl/config.base.ts b/x-pack/test/functional_with_es_ssl/config.base.ts index 2fdf49bc41fef..b4cc8a734a270 100644 --- a/x-pack/test/functional_with_es_ssl/config.base.ts +++ b/x-pack/test/functional_with_es_ssl/config.base.ts @@ -85,6 +85,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { 'stackAlertsPage', 'ruleTagFilter', 'ruleStatusFilter', + 'isUsingRuleCreateFlyout', ])}`, `--xpack.alerting.rules.minimumScheduleInterval.value="2s"`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, diff --git a/x-pack/test_serverless/functional/config.base.ts b/x-pack/test_serverless/functional/config.base.ts index b0cd556fe1d36..1a3cd2ffd6a5b 100644 --- a/x-pack/test_serverless/functional/config.base.ts +++ b/x-pack/test_serverless/functional/config.base.ts @@ -36,6 +36,11 @@ export function createTestConfig(options: CreateTestConfigOptions) { serverArgs: [ ...svlSharedConfig.get('kbnTestServer.serverArgs'), `--serverless=${options.serverlessProject}`, + // Ensures the existing E2E tests are backwards compatible with the old rule create flyout + // Remove this experiment once all of the migration has been completed + `--xpack.trigger_actions_ui.enableExperimental=${JSON.stringify([ + 'isUsingRuleCreateFlyout', + ])}`, // custom native roles are enabled only for search and security projects ...(options.serverlessProject !== 'oblt' ? ['--xpack.security.roleManagementEnabled=true']