[8.x] [Response Ops][Rules] Add New Rule Form to Stack Management (#194655) (#196290)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Response Ops][Rules] Add New Rule Form to Stack Management
(#194655)](https://github.com/elastic/kibana/pull/194655)

<!--- Backport version: 8.9.8 -->

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

<!--BACKPORT [{"author":{"name":"Jiawei
Wu","email":"74562234+JiaweiWu@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-10-15T10:17:59Z","message":"[Response
Ops][Rules] Add New Rule Form to Stack Management (#194655)\n\n##
Summary\r\n\r\nEnables and adds the new rule form to stack management.
We are only\r\ngoing to turn this on for stack management for now until
we are\r\nconfident that this is fairly bug free.\r\n\r\n### To
test:\r\n\r\n1. Switch `USE_NEW_RULE_FORM_FEATURE_FLAG` to true\r\n2.
Navigate to stack management -> rules list\r\n3. Click \"Create rule\"
\r\n4. Assert the user is navigated to the new form\r\n5. Create
rule\r\n6. Assert the user is navigated to the rule details page\r\n7.
Click \"Edit\"\r\n8. Edit rule\r\n9. Assert the user is navigated to the
rule details page\r\n10. Try editing a rule in the rules list and assert
everything works as\r\nexpected\r\n\r\nWe should also make sure this
rule form is not enabled in other\r\nsolutions.\r\n\r\n###
Checklist\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>\r\nCo-authored-by: Christos
Nasikas
<christos.nasikas@elastic.co>","sha":"5c2df6347d779f577946634e972d30224299079a","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:ResponseOps","v9.0.0","backport:prev-minor","v8.16.0"],"number":194655,"url":"https://github.com/elastic/kibana/pull/194655","mergeCommit":{"message":"[Response
Ops][Rules] Add New Rule Form to Stack Management (#194655)\n\n##
Summary\r\n\r\nEnables and adds the new rule form to stack management.
We are only\r\ngoing to turn this on for stack management for now until
we are\r\nconfident that this is fairly bug free.\r\n\r\n### To
test:\r\n\r\n1. Switch `USE_NEW_RULE_FORM_FEATURE_FLAG` to true\r\n2.
Navigate to stack management -> rules list\r\n3. Click \"Create rule\"
\r\n4. Assert the user is navigated to the new form\r\n5. Create
rule\r\n6. Assert the user is navigated to the rule details page\r\n7.
Click \"Edit\"\r\n8. Edit rule\r\n9. Assert the user is navigated to the
rule details page\r\n10. Try editing a rule in the rules list and assert
everything works as\r\nexpected\r\n\r\nWe should also make sure this
rule form is not enabled in other\r\nsolutions.\r\n\r\n###
Checklist\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>\r\nCo-authored-by: Christos
Nasikas
<christos.nasikas@elastic.co>","sha":"5c2df6347d779f577946634e972d30224299079a"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/194655","number":194655,"mergeCommit":{"message":"[Response
Ops][Rules] Add New Rule Form to Stack Management (#194655)\n\n##
Summary\r\n\r\nEnables and adds the new rule form to stack management.
We are only\r\ngoing to turn this on for stack management for now until
we are\r\nconfident that this is fairly bug free.\r\n\r\n### To
test:\r\n\r\n1. Switch `USE_NEW_RULE_FORM_FEATURE_FLAG` to true\r\n2.
Navigate to stack management -> rules list\r\n3. Click \"Create rule\"
\r\n4. Assert the user is navigated to the new form\r\n5. Create
rule\r\n6. Assert the user is navigated to the rule details page\r\n7.
Click \"Edit\"\r\n8. Edit rule\r\n9. Assert the user is navigated to the
rule details page\r\n10. Try editing a rule in the rules list and assert
everything works as\r\nexpected\r\n\r\nWe should also make sure this
rule form is not enabled in other\r\nsolutions.\r\n\r\n###
Checklist\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>\r\nCo-authored-by: Christos
Nasikas
<christos.nasikas@elastic.co>","sha":"5c2df6347d779f577946634e972d30224299079a"}},{"branch":"8.x","label":"v8.16.0","labelRegex":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2024-10-15 16:57:08 +03:00 committed by GitHub
parent bf0432de4e
commit 99bddf8fa6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 856 additions and 296 deletions

View file

@ -54,6 +54,6 @@ export const transformUpdateRuleBody: RewriteResponseCase<UpdateRuleBody> = ({
...(uuid && { uuid }),
};
}),
...(alertDelay ? { alert_delay: alertDelay } : {}),
...(alertDelay !== undefined ? { alert_delay: alertDelay } : {}),
...(flapping !== undefined ? { flapping: transformUpdateRuleFlapping(flapping) } : {}),
});

View file

@ -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;

View file

@ -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;
}

View file

@ -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,
});

View file

@ -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,
});

View file

@ -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;

View file

@ -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;
}

View file

@ -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<string, string> & { description?: string };
export type RuleTypeIndexWithDescriptions = Map<string, RuleTypeWithDescription>;

View file

@ -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 },
};
};

View file

@ -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) => {
<div data-test-subj="createRuleForm">
<RuleFormStateProvider
initialRuleFormState={{
formData: GET_DEFAULT_FORM_DATA({
formData: getDefaultFormData({
ruleTypeId,
name: `${ruleType.name} rule`,
consumer: getInitialConsumer({
@ -174,6 +179,11 @@ 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) => {
}),
}}
>
<RulePage isEdit={false} isSaving={isSaving} returnUrl={returnUrl} onSave={onSave} />
<RulePage isEdit={false} isSaving={isSaving} onCancel={onCancel} onSave={onSave} />
</RuleFormStateProvider>
</div>
);

View file

@ -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,
}}
>
<RulePage isEdit={true} isSaving={isSaving} returnUrl={returnUrl} onSave={onSave} />
<RulePage isEdit={true} isSaving={isSaving} onSave={onSave} onCancel={onCancel} />
</RuleFormStateProvider>
</div>
);

View file

@ -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,
});
});

View file

@ -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]);
return ruleTypeIndex.get(computedRuleTypeId);
}, [computedRuleTypeId, ruleTypeIndex]);
const [ruleType, ruleTypeModel] = useMemo(() => {
const item = authorizedRuleTypeItems.find(({ ruleType: rt }) => {
return rt.id === computedRuleTypeId;
});
return [item?.ruleType, item?.ruleTypeModel];
}, [authorizedRuleTypeItems, computedRuleTypeId]);
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,

View file

@ -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',

View file

@ -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<boolean>(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,
},
});

View file

@ -68,6 +68,7 @@ export const RuleActionsAlertsFilter = ({
() => onChange(state ? undefined : query),
[state, query, onChange]
);
const updateQuery = useCallback(
(update: Partial<AlertsFilter['query']>) => {
setQuery({

View file

@ -163,7 +163,10 @@ export const RuleActionsConnectorsModal = (props: RuleActionsConnectorsModalProp
const connectorFacetButtons = useMemo(() => {
return (
<EuiFacetGroup data-test-subj="ruleActionsConnectorsModalFilterButtonGroup">
<EuiFacetGroup
data-test-subj="ruleActionsConnectorsModalFilterButtonGroup"
style={{ overflow: 'auto' }}
>
<EuiFacetButton
data-test-subj="ruleActionsConnectorsModalFilterButton"
key="all"

View file

@ -324,7 +324,7 @@ describe('ruleActionsItem', () => {
await userEvent.click(screen.getByText('onTimeframeChange'));
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange).toHaveBeenCalledTimes(2);
expect(mockOnChange).toHaveBeenCalledWith({
payload: {

View file

@ -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 (
<EuiFlexGroup
direction="column"
style={{
padding: euiTheme.size.l,
backgroundColor: plain,
borderRadius: euiTheme.border.radius.medium,
}}
>
<EuiFlexItem>{checkEnabledResult.messageCard}</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiFlexGroup
direction="column"
@ -504,6 +532,7 @@ export const RuleActionsItem = (props: RuleActionsItemProps) => {
templateFields,
useDefaultMessage,
warning,
checkEnabledResult,
onNotifyWhenChange,
onActionGroupChange,
onAlertsFilterChange,

View file

@ -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(<RuleAlertDelay />);
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', () => {

View file

@ -28,16 +28,8 @@ export const RuleAlertDelay = () => {
const onAlertDelayChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
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 = () => {
<EuiFieldNumber
fullWidth
min={1}
value={alertDelay?.active ?? ''}
value={alertDelay?.active ?? 1}
name="alertDelay"
data-test-subj="alertDelayInput"
prepend={[ALERT_DELAY_TITLE_PREFIX]}

View file

@ -68,7 +68,7 @@ const ruleType = {
recoveryActionGroup: 'recovered',
producer: 'logs',
authorizedConsumers: {
alerting: { read: true, all: true },
alerts: { read: true, all: true },
test: { read: true, all: true },
stackAlerts: { read: true, all: true },
logs: { read: true, all: true },
@ -101,6 +101,7 @@ const plugins = {
application: {
capabilities: {
rulesSettings: {
readFlappingSettingsUI: true,
writeFlappingSettingsUI: true,
},
},
@ -133,12 +134,14 @@ describe('Rule Definition', () => {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
consumer: 'alerts',
ruleTypeId: '.es-query',
},
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
canShowConsumerSelection: true,
validConsumers: ['logs', 'stackAlerts'],
availableRuleTypes: [ruleType],
});
render(<RuleDefinition />);
@ -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(<RuleDefinition />);
@ -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(<RuleDefinition />);
@ -292,9 +305,11 @@ describe('Rule Definition', () => {
},
notifyWhen: null,
consumer: 'stackAlerts',
ruleTypeId: '.es-query',
},
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
availableRuleTypes: [ruleType],
});
render(<RuleDefinition />);
@ -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(<RuleDefinition />);
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'],
});

View file

@ -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<boolean>(false);
const [isFlappingPopoverOpen, setIsFlappingPopoverOpen] = useState<boolean>(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 = () => {
>
<RuleAlertDelay />
</EuiDescribedFormGroup>
{IS_RULE_SPECIFIC_FLAPPING_ENABLED && (
{IS_RULE_SPECIFIC_FLAPPING_ENABLED && readFlappingSettingsUI && (
<EuiDescribedFormGroup
data-test-subj="ruleDefinitionFlappingFormGroup"
fullWidth
title={<h4>{ALERT_FLAPPING_DETECTION_TITLE}</h4>}
description={

View file

@ -80,9 +80,6 @@ export const RuleSchedule = () => {
const onIntervalNumberChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.validity.valid) {
return;
}
const value = e.target.value.trim();
if (INTEGER_REGEX.test(value)) {
const parsedValue = parseInt(value, 10);

View file

@ -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 <EditRuleForm id={id} plugins={plugins} returnUrl={returnUrl} />;
return <EditRuleForm id={id} plugins={plugins} onCancel={onCancel} onSubmit={onSubmit} />;
}
if (ruleTypeId) {
return <CreateRuleForm ruleTypeId={ruleTypeId} plugins={plugins} returnUrl={returnUrl} />;
return (
<CreateRuleForm
ruleTypeId={ruleTypeId}
plugins={plugins}
onCancel={onCancel}
onSubmit={onSubmit}
/>
);
}
return (
<EuiEmptyPrompt
color="danger"
iconType="error"
title={<h2>{RULE_FORM_ROUTE_PARAMS_ERROR_TITLE}</h2>}
>
<EuiText>
<p>{RULE_FORM_ROUTE_PARAMS_ERROR_TEXT}</p>
</EuiText>
</EuiEmptyPrompt>
body={
<EuiText>
<p>{RULE_FORM_ROUTE_PARAMS_ERROR_TEXT}</p>
</EuiText>
}
/>
);
}, [id, ruleTypeId, plugins, returnUrl]);
}, [id, ruleTypeId, plugins, onCancel, onSubmit]);
return <QueryClientProvider client={queryClient}>{ruleFormComponent}</QueryClientProvider>;
};

View file

@ -76,6 +76,8 @@ const initialState: RuleFormState = {
selectedRuleType: indexThresholdRuleType,
selectedRuleTypeModel: indexThresholdRuleTypeModel,
multiConsumerSelection: 'stackAlerts',
availableRuleTypes: [],
validConsumers: [],
connectors: [],
connectorTypes: [],
aadTemplateFields: [],

View file

@ -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;
}

View file

@ -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(<RulePage returnUrl={returnUrl} onSave={onSave} />);
render(<RulePage onCancel={onCancel} onSave={onSave} />);
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(<RulePage returnUrl={returnUrl} onSave={onSave} />);
render(<RulePage onCancel={onCancel} onSave={onSave} />);
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(<RulePage returnUrl={returnUrl} onSave={onSave} />);
render(<RulePage onCancel={onCancel} onSave={onSave} />);
fireEvent.click(screen.getByTestId('rulePageFooterCancelButton'));
expect(navigateToUrl).toHaveBeenCalledWith('management');
expect(onCancel).toHaveBeenCalled();
});
test('should call onCancel when the return button is clicked', () => {
render(<RulePage returnUrl={returnUrl} onSave={onSave} />);
render(<RulePage onCancel={onCancel} onSave={onSave} />);
fireEvent.click(screen.getByTestId('rulePageReturnButton'));
expect(navigateToUrl).toHaveBeenCalledWith('management');
expect(onCancel).toHaveBeenCalled();
});
});

View file

@ -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<boolean>(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: (
<>
<RuleActions />
@ -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: <RuleDefinition />,
},
...actionComponent,
{
title: RULE_FORM_PAGE_RULE_DETAILS_TITLE,
status: hasRuleDetailsError ? 'danger' : undefined,
children: (
<>
<RuleDetails />
@ -102,46 +159,73 @@ export const RulePage = (props: RulePageProps) => {
),
},
];
}, [actionComponent]);
}, [hasRuleDefinitionErrors, hasRuleDetailsError, actionComponent]);
return (
<EuiPageTemplate grow bottomBorder offset={0} css={styles}>
<EuiPageTemplate.Header>
<EuiFlexGroup
direction="column"
gutterSize="none"
alignItems="flexStart"
className="eui-fullWidth"
<>
<EuiPageTemplate grow bottomBorder offset={0} css={styles}>
<EuiPageTemplate.Header>
<EuiFlexGroup
direction="column"
gutterSize="none"
alignItems="flexStart"
className="eui-fullWidth"
>
<EuiFlexItem grow={false} style={{ alignItems: 'start' }}>
<EuiButtonEmpty
data-test-subj="rulePageReturnButton"
onClick={onCancelInternal}
style={{ padding: 0 }}
iconType="arrowLeft"
iconSide="left"
aria-label="Return link"
>
{RULE_FORM_RETURN_TITLE}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiSpacer />
<EuiFlexItem grow={false} className="eui-fullWidth">
<RulePageNameInput />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageTemplate.Header>
<EuiPageTemplate.Section>
{hasActionsDisabled && (
<>
<EuiCallOut
size="s"
color="danger"
iconType="error"
data-test-subj="hasActionsDisabled"
title={DISABLED_ACTIONS_WARNING_TITLE}
/>
<EuiSpacer />
</>
)}
<EuiSteps steps={steps} />
</EuiPageTemplate.Section>
<EuiPageTemplate.Section>
<RulePageFooter
isEdit={isEdit}
isSaving={isSaving}
onCancel={onCancelInternal}
onSave={onSaveInternal}
/>
</EuiPageTemplate.Section>
</EuiPageTemplate>
{isCancelModalOpen && (
<EuiConfirmModal
onCancel={() => 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}
>
<EuiFlexItem grow={false} style={{ alignItems: 'start' }}>
<EuiButtonEmpty
data-test-subj="rulePageReturnButton"
onClick={onCancel}
style={{ padding: 0 }}
iconType="arrowLeft"
iconSide="left"
aria-label="Return link"
>
{RULE_FORM_RETURN_TITLE}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiSpacer />
<EuiFlexItem grow={false} className="eui-fullWidth">
<RulePageNameInput />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageTemplate.Header>
<EuiPageTemplate.Section>
<EuiSteps steps={steps} />
</EuiPageTemplate.Section>
<EuiPageTemplate.Section>
<RulePageFooter
isEdit={isEdit}
isSaving={isSaving}
onCancel={onCancel}
onSave={onSaveInternal}
/>
</EuiPageTemplate.Section>
</EuiPageTemplate>
<p>{RULE_FORM_CANCEL_MODAL_DESCRIPTION}</p>
</EuiConfirmModal>
)}
</>
);
};

View file

@ -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(<RulePageFooter onSave={onSave} onCancel={onCancel} />);
fireEvent.click(screen.getByTestId('rulePageFooterSaveButton'));
expect(screen.queryByTestId('rulePageConfirmCreateRule')).not.toBeInTheDocument();
expect(onSave).toHaveBeenCalled();
});
test('should show call onSave if clicking rule confirmation', () => {
render(<RulePageFooter onSave={onSave} onCancel={onCancel} />);

View file

@ -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);

View file

@ -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',
}
);

View file

@ -72,6 +72,7 @@ export interface RuleFormState<Params extends RuleTypeParams = RuleTypeParams> {
connectors: ActionConnector[];
connectorTypes: ActionType[];
aadTemplateFields: ActionVariable[];
availableRuleTypes: RuleTypeWithDescription[];
baseErrors?: RuleFormBaseErrors;
paramsErrors?: RuleFormParamsErrors;
actionsErrors?: Record<string, RuleFormActionsErrors>;
@ -83,8 +84,9 @@ export interface RuleFormState<Params extends RuleTypeParams = RuleTypeParams> {
metadata?: Record<string, unknown>;
minimumScheduleInterval?: MinimumScheduleInterval;
canShowConsumerSelection?: boolean;
validConsumers?: RuleCreationValidConsumer[];
validConsumers: RuleCreationValidConsumer[];
flappingSettings?: RulesSettingsFlapping;
touched?: boolean;
}
export type InitialRule = Partial<Rule> &

View file

@ -17,9 +17,6 @@ export const getAuthorizedConsumers = ({
ruleType: RuleTypeWithDescription;
validConsumers: RuleCreationValidConsumer[];
}) => {
if (!ruleType.authorizedConsumers) {
return [];
}
return Object.entries(ruleType.authorizedConsumers).reduce<RuleCreationValidConsumer[]>(
(result, [authorizedConsumer, privilege]) => {
if (

View file

@ -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;
}
};

View file

@ -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';

View file

@ -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<string, RuleFormActionsErrors>) => {
export const hasActionsError = (actionsErrors: Record<string, RuleFormActionsErrors>) => {
return Object.values(actionsErrors).some((errors: RuleFormActionsErrors) => {
return Object.values(errors).some((error: string[]) => error.length > 0);
});
};
const hasParamsErrors = (errors: RuleFormParamsErrors): boolean => {
const values = Object.values(errors);
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<string, RuleFormParamsErrors>) => {
export const hasActionsParamsErrors = (
actionsParamsErrors: Record<string, RuleFormParamsErrors>
) => {
return Object.values(actionsParamsErrors).some((errors: RuleFormParamsErrors) => {
return hasParamsErrors(errors);
});

View file

@ -218,15 +218,17 @@ export const RuleSettingsFlappingForm = (props: RuleSettingsFlappingFormProps) =
direction={isDesktop ? 'row' : 'column'}
alignItems={isDesktop ? 'center' : undefined}
>
<EuiFlexItem style={{ flexDirection: 'row' }}>
<EuiFlexItem style={{ flexDirection: 'row', alignItems: 'center' }}>
<EuiText size="s" style={{ marginRight: euiTheme.size.xs }}>
{flappingLabel}
</EuiText>
<EuiBadge color={enabled ? 'success' : 'default'}>
<EuiBadge color={enabled ? 'success' : 'default'} style={{ height: '100%' }}>
{enabled ? flappingOnLabel : flappingOffLabel}
</EuiBadge>
{flappingSettings && enabled && (
<EuiBadge color="primary">{flappingOverrideLabel}</EuiBadge>
<EuiBadge color="primary" style={{ height: '100%' }}>
{flappingOverrideLabel}
</EuiBadge>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -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}
/>
</EuiFlexItem>
);
}, [
flappingSettings,
spaceFlappingSettings,
canWriteFlappingSettingsUI,
onLookBackWindowChange,
onStatusChangeThresholdChange,
]);

View file

@ -80,6 +80,7 @@ export const RuleSettingsFlappingTitleTooltip = (props: RuleSettingsFlappingTitl
panelStyle={{
width: 500,
}}
closePopover={() => setIsPopoverOpen(false)}
button={
<EuiButtonIcon
data-test-subj="ruleSettingsFlappingTitleTooltipButton"

View file

@ -9,5 +9,10 @@
export const ruleDetailsRoute = '/rule/:ruleId' as const;
export const triggersActionsRoute = '/app/management/insightsAndAlerting/triggersActions' as const;
export const createRuleRoute = '/rules/create/:ruleTypeId' as const;
export const editRuleRoute = '/rules/edit/:id' as const;
export const getRuleDetailsRoute = (ruleId: string) => ruleDetailsRoute.replace(':ruleId', ruleId);
export const getCreateRuleRoute = (ruleTypeId: string) =>
createRuleRoute.replace(':ruleTypeId', ruleTypeId);
export const getEditRuleRoute = (ruleId: string) => editRuleRoute.replace(':id', ruleId);

View file

@ -203,7 +203,6 @@ const TriggersActionsUiExampleApp = ({
ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry,
actionTypeRegistry: triggersActionsUi.actionTypeRegistry,
}}
returnUrl={application.getUrlForApp('triggersActionsUiExample')}
/>
</Page>
)}
@ -229,7 +228,6 @@ const TriggersActionsUiExampleApp = ({
ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry,
actionTypeRegistry: triggersActionsUi.actionTypeRegistry,
}}
returnUrl={application.getUrlForApp('triggersActionsUiExample')}
/>
</Page>
)}

View file

@ -69,7 +69,7 @@ export const StorybookContextDecorator: FC<PropsWithChildren<StorybookContextDec
ruleKqlBar: true,
isMustacheAutocompleteOn: false,
showMustacheAutocompleteSwitch: false,
ruleFormV2: false,
isUsingRuleCreateFlyout: false,
},
});
return (

View file

@ -21,7 +21,7 @@ export const allowedExperimentalValues = Object.freeze({
ruleKqlBar: false,
isMustacheAutocompleteOn: false,
showMustacheAutocompleteSwitch: false,
ruleFormV2: false,
isUsingRuleCreateFlyout: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -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`;

View file

@ -72,6 +72,7 @@ export const TriggersActionsUIHome: React.FunctionComponent<RouteComponentProps<
'xl'
)({
showCreateRuleButtonInPrompt: true,
useNewRuleForm: true,
setHeaderActions,
});
}, []);

View file

@ -64,6 +64,18 @@ export const getAlertingSectionBreadcrumb = (
}
: {}),
};
case 'createRule':
return {
text: i18n.translate('xpack.triggersActionsUI.rules.create.breadcrumbTitle', {
defaultMessage: 'Create',
}),
};
case 'editRule':
return {
text: i18n.translate('xpack.triggersActionsUI.rules.edit.breadcrumbTitle', {
defaultMessage: 'Edit',
}),
};
default:
return {
text: i18n.translate('xpack.triggersActionsUI.home.breadcrumbTitle', {

View file

@ -26,6 +26,16 @@ export const getCurrentDocTitle = (page: string): string => {
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',

View file

@ -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 (
<ConnectorProvider value={{ services: { validateEmailAddresses } }}>
<Routes>
{!isUsingRuleCreateFlyout && (
<Route
exact
path={createRuleRoute}
component={suspendedComponentWithProps(CreateRuleRoute, 'xl')}
/>
)}
{!isUsingRuleCreateFlyout && (
<Route
exact
path={editRuleRoute}
component={suspendedComponentWithProps(EditRuleRoute, 'xl')}
/>
)}
<Route
path={`/:section(${sectionsRegex})`}
component={suspendedComponentWithProps(TriggersActionsUIHome, 'xl')}
@ -154,7 +173,6 @@ export const AppWithoutRouter = ({ sectionsRegex }: { sectionsRegex: string }) =
return null;
}}
/>
<Redirect from={'/'} to="rules" />
</Routes>
</ConnectorProvider>

View file

@ -199,6 +199,7 @@ export function RuleComponent({
actionTypeRegistry,
ruleTypeRegistry,
hideEditButton: true,
useNewRuleForm: true,
onEditRule: requestRefresh,
})}
</EuiFlexGroup>

View file

@ -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),

View file

@ -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<RuleDefinitionProps> = ({
onEditRule,
hideEditButton = false,
filteredRuleTypes = [],
useNewRuleForm = false,
}) => {
const {
application: { capabilities },
application: { capabilities, navigateToApp },
} = useKibana().services;
const isUsingRuleCreateFlyout = getIsExperimentalFeatureEnabled('isUsingRuleCreateFlyout');
const [editFlyoutVisible, setEditFlyoutVisible] = useState<boolean>(false);
const [ruleType, setRuleType] = useState<RuleType>();
const {
@ -103,6 +107,20 @@ export const RuleDefinition: React.FunctionComponent<RuleDefinitionProps> = ({
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<RuleDefinitionProps> = ({
>
<EuiFlexItem grow={false}>
{hasEditButton ? (
<EuiButtonEmpty onClick={() => setEditFlyoutVisible(true)} flush="left">
<EuiButtonEmpty onClick={onEditRuleClick} flush="left">
<EuiText size="s">{getRuleConditionsWording()}</EuiText>
</EuiButtonEmpty>
) : (
@ -206,7 +224,7 @@ export const RuleDefinition: React.FunctionComponent<RuleDefinitionProps> = ({
<EuiButtonEmpty
data-test-subj="ruleDetailsEditButton"
iconType={'pencil'}
onClick={() => setEditFlyoutVisible(true)}
onClick={onEditRuleClick}
/>
</EuiFlexItem>
)

View file

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

View file

@ -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<void>;
refreshToken?: RefreshToken;
useNewRuleForm?: boolean;
} & Pick<
BulkOperationsComponentOpts,
'bulkDisableRules' | 'bulkEnableRules' | 'bulkDeleteRules' | 'snoozeRule' | 'unsnoozeRule'
@ -98,7 +100,7 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
}) => {
const history = useHistory();
const {
application: { capabilities },
application: { capabilities, navigateToApp },
ruleTypeRegistry,
actionTypeRegistry,
setBreadcrumbs,
@ -108,6 +110,9 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
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<RuleDetailsProps> = ({
data-test-subj="ruleIntervalToastEditButton"
onClick={() => {
toasts.remove(configurationToast);
setEditFlyoutVisibility(true);
onEditRuleClick();
}}
>
<FormattedMessage
@ -223,6 +228,7 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
i18nStart,
theme,
@ -256,12 +262,26 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
}
};
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 ? (
<>
<EuiButtonEmpty
data-test-subj="openEditRuleFlyoutButton"
iconType="pencil"
onClick={() => setEditFlyoutVisibility(true)}
onClick={onEditRuleClick}
name="edit"
disabled={!ruleType.enabledInLicense}
>
@ -529,7 +549,7 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
<EuiLink
data-test-subj="actionWithBrokenConnectorWarningBannerEdit"
color="primary"
onClick={() => setEditFlyoutVisibility(true)}
onClick={onEditRuleClick}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleDetails.actionWithBrokenConnectorWarningBannerEditText"

View file

@ -24,6 +24,11 @@ jest.mock('@kbn/alerts-ui-shared/src/common/apis/fetch_ui_config', () => ({
.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();

View file

@ -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 (
<IntlProvider locale="en">
<RuleForm
plugins={{
http,
i18n,
theme,
application,
notifications,
charts,
settings,
data,
dataViews,
unifiedSearch,
docLinks,
ruleTypeRegistry,
actionTypeRegistry,
}}
onCancel={() => {
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)}`,
});
}}
/>
</IntlProvider>
);
};
// eslint-disable-next-line import/no-default-export
export { RuleFormRoute as default };

View file

@ -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 | string>(null);
const isRuleStatusFilterEnabled = getIsExperimentalFeatureEnabled('ruleStatusFilter');
const isUsingRuleCreateFlyout = getIsExperimentalFeatureEnabled('isUsingRuleCreateFlyout');
const [percentileOptions, setPercentileOptions] =
useState<EuiSelectableOption[]>(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 = ({
<RuleTypeModal
onClose={() => 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}

View file

@ -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);

View file

@ -395,6 +395,7 @@ export interface RuleDefinitionProps<Params extends RuleTypeParams = RuleTypePar
onEditRule: () => Promise<void>;
hideEditButton?: boolean;
filteredRuleTypes?: string[];
useNewRuleForm?: boolean;
}
export enum Percentiles {

View file

@ -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)}`,

View file

@ -32,6 +32,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',
])}`,
...(options.kbnServerArgs ?? []),
],
},