[Response Ops][Rule Form V2] Rule Form V2: Rule Form Page and State Management (#184892)

## Summary
Issue: https://github.com/elastic/kibana/issues/179105
Related PR: https://github.com/elastic/kibana/pull/180539

Part 3/3 PRs of the new rule form. This PR adds the create and edit rule
page as well as the state management using react reducers.

I have also created a example plugin to demonstrate this PR. To access:

1. Run the branch with yarn start --run-examples
2. Navigate to
`http://localhost:5601/app/triggersActionsUiExample/rule/create/<ruleTypeId>`
(I use `.es-query`)
3. Create a rule
4. Navigate to
`http://localhost:5601/app/triggersActionsUiExample/rule/edit/<ruleId>`
with the rule you just created to edit the rule

<img width="1196" alt="Screenshot 2024-05-14 at 8 27 00 PM"
src="576fecdd-bd7b-4cad-a3db-aab3163abc46">


### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Jiawei Wu 2024-07-03 20:34:31 -07:00 committed by GitHub
parent 2c7b381089
commit 762f4cd14f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 5069 additions and 931 deletions

View file

@ -0,0 +1,9 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export const errorMessageHeader = 'Error validating circuit breaker';

View file

@ -15,3 +15,4 @@ export * from './r_rule_types';
export * from './rule_types';
export * from './alerting_framework_health_types';
export * from './action_variable';
export * from './circuit_breaker_message_header';

View file

@ -237,7 +237,7 @@ export interface Rule<Params extends RuleTypeParams = never> {
revision: number;
running?: boolean | null;
viewInAppRelativeUrl?: string;
alertDelay?: AlertDelay;
alertDelay?: AlertDelay | null;
}
export type SanitizedRule<Params extends RuleTypeParams = never> = Omit<

View file

@ -11,7 +11,7 @@ import { CreateRuleBody } from './types';
export const transformCreateRuleBody: RewriteResponseCase<CreateRuleBody> = ({
ruleTypeId,
actions,
actions = [],
alertDelay,
...res
}): any => ({

View file

@ -14,10 +14,10 @@ export interface CreateRuleBody<Params extends RuleTypeParams = RuleTypeParams>
enabled: Rule<Params>['enabled'];
consumer: Rule<Params>['consumer'];
tags: Rule<Params>['tags'];
throttle?: Rule<Params>['throttle'];
params: Rule<Params>['params'];
schedule: Rule<Params>['schedule'];
actions: Rule<Params>['actions'];
throttle?: Rule<Params>['throttle'];
notifyWhen?: Rule<Params>['notifyWhen'];
alertDelay?: Rule<Params>['alertDelay'];
}

View file

@ -13,3 +13,12 @@ export interface UiHealthCheck {
export interface UiHealthCheckResponse {
isAlertsAvailable: boolean;
}
export const healthCheckErrors = {
ALERTS_ERROR: 'alertsError',
ENCRYPTION_ERROR: 'encryptionError',
API_KEYS_DISABLED_ERROR: 'apiKeysDisabledError',
API_KEYS_AND_ENCRYPTION_ERROR: 'apiKeysAndEncryptionError',
} as const;
export type HealthCheckErrors = typeof healthCheckErrors[keyof typeof healthCheckErrors];

View file

@ -0,0 +1,14 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export * from './fetch_ui_health_status';
export * from './fetch_alerting_framework_health';
export * from './fetch_ui_config';
export * from './create_rule';
export * from './update_rule';
export * from './resolve_rule';

View file

@ -10,7 +10,7 @@ import { RewriteResponseCase } from '@kbn/actions-types';
import { UpdateRuleBody } from './types';
export const transformUpdateRuleBody: RewriteResponseCase<UpdateRuleBody> = ({
actions,
actions = [],
alertDelay,
...res
}): any => ({

View file

@ -12,9 +12,9 @@ export interface UpdateRuleBody<Params extends RuleTypeParams = RuleTypeParams>
name: Rule<Params>['name'];
tags: Rule<Params>['tags'];
schedule: Rule<Params>['schedule'];
throttle?: Rule<Params>['throttle'];
params: Rule<Params>['params'];
actions: Rule<Params>['actions'];
throttle?: Rule<Params>['throttle'];
notifyWhen?: Rule<Params>['notifyWhen'];
alertDelay?: Rule<Params>['alertDelay'];
}

View file

@ -118,7 +118,7 @@ describe('updateRule', () => {
Array [
"/api/alerting/rule/12%2F3",
Object {
"body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[{\\"group\\":\\"default\\",\\"id\\":\\"2\\",\\"params\\":{},\\"frequency\\":{\\"notify_when\\":\\"onActionGroupChange\\",\\"throttle\\":null,\\"summary\\":false},\\"use_alert_data_for_template\\":false},{\\"id\\":\\".test-system-action\\",\\"params\\":{}}],\\"alert_delay\\":{\\"active\\":10}}",
"body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[],\\"alert_delay\\":{\\"active\\":10}}",
},
]
`);

View file

@ -21,6 +21,14 @@ export const UPDATE_FIELDS: Array<keyof UpdateRuleBody> = [
'schedule',
'params',
'alertDelay',
];
export const UPDATE_FIELDS_WITH_ACTIONS: Array<keyof UpdateRuleBody> = [
'name',
'tags',
'schedule',
'params',
'alertDelay',
'actions',
];

View file

@ -8,6 +8,7 @@
export * from './use_load_rule_types_query';
export * from './use_load_ui_config';
export * from './use_health_check';
export * from './use_load_ui_health';
export * from './use_load_alerting_framework_health';
export * from './use_create_rule';

View file

@ -0,0 +1,175 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook } from '@testing-library/react-hooks/dom';
import { waitFor } from '@testing-library/react';
import type { HttpStart } from '@kbn/core-http-browser';
import { useHealthCheck } from './use_health_check';
import { healthCheckErrors } from '../apis';
jest.mock('../apis/fetch_ui_health_status/fetch_ui_health_status', () => ({
fetchUiHealthStatus: jest.fn(),
}));
jest.mock('../apis/fetch_alerting_framework_health/fetch_alerting_framework_health', () => ({
fetchAlertingFrameworkHealth: jest.fn(),
}));
const { fetchUiHealthStatus } = jest.requireMock(
'../apis/fetch_ui_health_status/fetch_ui_health_status'
);
const { fetchAlertingFrameworkHealth } = jest.requireMock(
'../apis/fetch_alerting_framework_health/fetch_alerting_framework_health'
);
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: Node }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const httpMock = jest.fn();
describe('useHealthCheck', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('should return null if there are no errors', async () => {
fetchUiHealthStatus.mockResolvedValueOnce({
isRulesAvailable: true,
});
fetchAlertingFrameworkHealth.mockResolvedValueOnce({
isSufficientlySecure: true,
hasPermanentEncryptionKey: true,
});
const { result } = renderHook(
() => {
return useHealthCheck({
http: httpMock as unknown as HttpStart,
});
},
{ wrapper }
);
await waitFor(() => {
return expect(result.current.isLoading).toEqual(false);
});
expect(result.current.error).toBeNull();
});
test('should return alerts error if rules are not available', async () => {
fetchUiHealthStatus.mockResolvedValueOnce({
isRulesAvailable: false,
});
fetchAlertingFrameworkHealth.mockResolvedValueOnce({
isSufficientlySecure: true,
hasPermanentEncryptionKey: true,
});
const { result } = renderHook(
() => {
return useHealthCheck({
http: httpMock as unknown as HttpStart,
});
},
{ wrapper }
);
await waitFor(() => {
return expect(result.current.isLoading).toEqual(false);
});
expect(result.current.error).toEqual(healthCheckErrors.ALERTS_ERROR);
});
test('should return API keys encryption error if not secure or has no encryption key', async () => {
fetchUiHealthStatus.mockResolvedValueOnce({
isRulesAvailable: true,
});
fetchAlertingFrameworkHealth.mockResolvedValueOnce({
isSufficientlySecure: false,
hasPermanentEncryptionKey: false,
});
const { result } = renderHook(
() => {
return useHealthCheck({
http: httpMock as unknown as HttpStart,
});
},
{ wrapper }
);
await waitFor(() => {
return expect(result.current.isLoading).toEqual(false);
});
expect(result.current.error).toEqual(healthCheckErrors.API_KEYS_AND_ENCRYPTION_ERROR);
});
test('should return encryption error if has no encryption key', async () => {
fetchUiHealthStatus.mockResolvedValueOnce({
isRulesAvailable: true,
});
fetchAlertingFrameworkHealth.mockResolvedValueOnce({
isSufficientlySecure: true,
hasPermanentEncryptionKey: false,
});
const { result } = renderHook(
() => {
return useHealthCheck({
http: httpMock as unknown as HttpStart,
});
},
{ wrapper }
);
await waitFor(() => {
return expect(result.current.isLoading).toEqual(false);
});
expect(result.current.error).toEqual(healthCheckErrors.ENCRYPTION_ERROR);
});
test('should return API keys disabled error is API keys are disabled', async () => {
fetchUiHealthStatus.mockResolvedValueOnce({
isRulesAvailable: true,
});
fetchAlertingFrameworkHealth.mockResolvedValueOnce({
isSufficientlySecure: false,
hasPermanentEncryptionKey: true,
});
const { result } = renderHook(
() => {
return useHealthCheck({
http: httpMock as unknown as HttpStart,
});
},
{ wrapper }
);
await waitFor(() => {
return expect(result.current.isLoading).toEqual(false);
});
expect(result.current.error).toEqual(healthCheckErrors.API_KEYS_DISABLED_ERROR);
});
});

View file

@ -0,0 +1,101 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { useMemo } from 'react';
import type { HttpStart } from '@kbn/core-http-browser';
import { useLoadAlertingFrameworkHealth } from './use_load_alerting_framework_health';
import { useLoadUiHealth } from './use_load_ui_health';
import { healthCheckErrors, HealthCheckErrors } from '../apis';
export interface UseHealthCheckProps {
http: HttpStart;
}
export interface UseHealthCheckResult {
isLoading: boolean;
healthCheckError: HealthCheckErrors | null;
}
export interface HealthStatus {
isRulesAvailable: boolean;
isSufficientlySecure: boolean;
hasPermanentEncryptionKey: boolean;
}
export const useHealthCheck = (props: UseHealthCheckProps) => {
const { http } = props;
const {
data: uiHealth,
isLoading: isLoadingUiHealth,
isInitialLoading: isInitialLoadingUiHealth,
} = useLoadUiHealth({ http });
const {
data: alertingFrameworkHealth,
isLoading: isLoadingAlertingFrameworkHealth,
isInitialLoading: isInitialLoadingAlertingFrameworkHealth,
} = useLoadAlertingFrameworkHealth({ http });
const isLoading = useMemo(() => {
return isLoadingUiHealth || isLoadingAlertingFrameworkHealth;
}, [isLoadingUiHealth, isLoadingAlertingFrameworkHealth]);
const isInitialLoading = useMemo(() => {
return isInitialLoadingUiHealth || isInitialLoadingAlertingFrameworkHealth;
}, [isInitialLoadingUiHealth, isInitialLoadingAlertingFrameworkHealth]);
const alertingHealth: HealthStatus | null = useMemo(() => {
if (isLoading || isInitialLoading || !uiHealth || !alertingFrameworkHealth) {
return null;
}
if (!uiHealth.isRulesAvailable) {
return {
...uiHealth,
isSufficientlySecure: false,
hasPermanentEncryptionKey: false,
};
}
return {
...uiHealth,
isSufficientlySecure: alertingFrameworkHealth.isSufficientlySecure,
hasPermanentEncryptionKey: alertingFrameworkHealth.hasPermanentEncryptionKey,
};
}, [isLoading, isInitialLoading, uiHealth, alertingFrameworkHealth]);
const error = useMemo(() => {
const {
isRulesAvailable,
isSufficientlySecure = false,
hasPermanentEncryptionKey = false,
} = alertingHealth || {};
if (isLoading || isInitialLoading || !alertingHealth) {
return null;
}
if (isSufficientlySecure && hasPermanentEncryptionKey) {
return null;
}
if (!isRulesAvailable) {
return healthCheckErrors.ALERTS_ERROR;
}
if (!isSufficientlySecure && !hasPermanentEncryptionKey) {
return healthCheckErrors.API_KEYS_AND_ENCRYPTION_ERROR;
}
if (!hasPermanentEncryptionKey) {
return healthCheckErrors.ENCRYPTION_ERROR;
}
return healthCheckErrors.API_KEYS_DISABLED_ERROR;
}, [isLoading, isInitialLoading, alertingHealth]);
return {
isLoading,
isInitialLoading,
error,
};
};

View file

@ -108,7 +108,7 @@ export const useLoadRuleTypesQuery = ({
return {
ruleTypesState: {
initialLoad: isLoading || isInitialLoading,
isInitialLoad: isInitialLoading,
isLoading: isLoading || isFetching,
data: filteredIndex,
error,

View file

@ -11,7 +11,7 @@ import type { RuleActionParam, ActionVariable } from '@kbn/alerting-types';
import { IconType, RecursivePartial } from '@elastic/eui';
import { PublicMethodsOf } from '@kbn/utility-types';
import { TypeRegistry } from '../type_registry';
import { RuleFormErrors } from '.';
import { RuleFormParamsErrors } from './rule_types';
export interface GenericValidationResult<T> {
errors: Record<Extract<keyof T, string>, string[] | unknown>;
@ -77,7 +77,7 @@ export interface ActionParamsProps<TParams> {
actionParams: Partial<TParams>;
index: number;
editAction: (key: string, value: RuleActionParam, index: number) => void;
errors: RuleFormErrors;
errors: RuleFormParamsErrors;
ruleTypeId?: string;
messageVariables?: ActionVariable[];
defaultMessage?: string;

View file

@ -30,8 +30,18 @@ export type RuleTypeIndexWithDescriptions = Map<string, RuleTypeWithDescription>
export type RuleTypeParams = Record<string, unknown>;
export interface RuleFormErrors {
[key: string]: string | string[] | RuleFormErrors;
export interface RuleFormBaseErrors {
name?: string[];
interval?: string[];
consumer?: string[];
ruleTypeId?: string[];
actionConnectors?: string[];
alertDelay?: string[];
tags?: string[];
}
export interface RuleFormParamsErrors {
[key: string]: string | string[] | RuleFormParamsErrors;
}
export interface MinimumScheduleInterval {
@ -81,7 +91,7 @@ export interface RuleTypeParamsExpressionProps<
value: SanitizedRule<Params>[Prop] | null
) => void;
onChangeMetaData: (metadata: MetaData) => void;
errors: RuleFormErrors;
errors: RuleFormParamsErrors;
defaultActionGroupId: string;
actionGroups: Array<ActionGroup<ActionGroupIds>>;
metadata?: MetaData;

View file

@ -0,0 +1,59 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import {
ES_QUERY_ID,
OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
ML_ANOMALY_DETECTION_RULE_TYPE_ID,
RuleCreationValidConsumer,
AlertConsumers,
} from '@kbn/rule-data-utils';
import { RuleFormData } from './types';
export const DEFAULT_RULE_INTERVAL = '1m';
export const ALERTING_FEATURE_ID = 'alerts';
export const GET_DEFAULT_FORM_DATA = ({
ruleTypeId,
name,
consumer,
schedule,
}: {
ruleTypeId: RuleFormData['ruleTypeId'];
name: RuleFormData['name'];
consumer: RuleFormData['consumer'];
schedule?: RuleFormData['schedule'];
}) => {
return {
tags: [],
params: {},
schedule: schedule || {
interval: DEFAULT_RULE_INTERVAL,
},
consumer,
ruleTypeId,
name,
};
};
export const MULTI_CONSUMER_RULE_TYPE_IDS = [
OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
ES_QUERY_ID,
ML_ANOMALY_DETECTION_RULE_TYPE_ID,
];
export const DEFAULT_VALID_CONSUMERS: RuleCreationValidConsumer[] = [
AlertConsumers.LOGS,
AlertConsumers.INFRASTRUCTURE,
'stackAlerts',
'alerts',
];
export const createRuleRoute = '/rule/create/:ruleTypeId' as const;
export const editRuleRoute = '/rule/edit/:id' as const;

View file

@ -0,0 +1,170 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React, { useCallback } from 'react';
import { EuiLoadingElastic } from '@elastic/eui';
import { toMountPoint } from '@kbn/react-kibana-mount';
import type { RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import type { RuleFormData, RuleFormPlugins } from './types';
import { ALERTING_FEATURE_ID, DEFAULT_VALID_CONSUMERS, GET_DEFAULT_FORM_DATA } from './constants';
import { RuleFormStateProvider } from './rule_form_state';
import { useCreateRule } from '../common/hooks';
import { RulePage } from './rule_page';
import {
RuleFormCircuitBreakerError,
RuleFormErrorPromptWrapper,
RuleFormHealthCheckError,
RuleFormRuleTypeError,
} from './rule_form_errors';
import { useLoadDependencies } from './hooks/use_load_dependencies';
import {
getInitialConsumer,
getInitialMultiConsumer,
getInitialSchedule,
parseRuleCircuitBreakerErrorMessage,
} from './utils';
import { RULE_CREATE_SUCCESS_TEXT, RULE_CREATE_ERROR_TEXT } from './translations';
export interface CreateRuleFormProps {
ruleTypeId: string;
plugins: RuleFormPlugins;
consumer?: string;
multiConsumerSelection?: RuleCreationValidConsumer | null;
hideInterval?: boolean;
validConsumers?: RuleCreationValidConsumer[];
filteredRuleTypes?: string[];
shouldUseRuleProducer?: boolean;
returnUrl: string;
}
export const CreateRuleForm = (props: CreateRuleFormProps) => {
const {
ruleTypeId,
plugins,
consumer = ALERTING_FEATURE_ID,
multiConsumerSelection,
validConsumers = DEFAULT_VALID_CONSUMERS,
filteredRuleTypes = [],
shouldUseRuleProducer = false,
returnUrl,
} = props;
const { http, docLinks, notification, ruleTypeRegistry, i18n, theme } = plugins;
const { toasts } = notification;
const { mutate, isLoading: isSaving } = useCreateRule({
http,
onSuccess: ({ name }) => {
toasts.addSuccess(RULE_CREATE_SUCCESS_TEXT(name));
},
onError: (error) => {
const message = parseRuleCircuitBreakerErrorMessage(
error.body?.message || RULE_CREATE_ERROR_TEXT
);
toasts.addDanger({
title: message.summary,
...(message.details && {
text: toMountPoint(
<RuleFormCircuitBreakerError>{message.details}</RuleFormCircuitBreakerError>,
{ i18n, theme }
),
}),
});
},
});
const { isInitialLoading, ruleType, ruleTypeModel, uiConfig, healthCheckError } =
useLoadDependencies({
http,
toasts: notification.toasts,
ruleTypeRegistry,
ruleTypeId,
consumer,
validConsumers,
filteredRuleTypes,
});
const onSave = useCallback(
(newFormData: RuleFormData) => {
mutate({
formData: {
name: newFormData.name,
ruleTypeId: newFormData.ruleTypeId!,
enabled: true,
consumer: newFormData.consumer,
tags: newFormData.tags,
params: newFormData.params,
schedule: newFormData.schedule,
// TODO: Will add actions in the actions PR
actions: [],
notifyWhen: newFormData.notifyWhen,
alertDelay: newFormData.alertDelay,
},
});
},
[mutate]
);
if (isInitialLoading) {
return (
<RuleFormErrorPromptWrapper hasBorder={false} hasShadow={false}>
<EuiLoadingElastic size="xl" />
</RuleFormErrorPromptWrapper>
);
}
if (!ruleType || !ruleTypeModel) {
return (
<RuleFormErrorPromptWrapper hasBorder={false} hasShadow={false}>
<RuleFormRuleTypeError />
</RuleFormErrorPromptWrapper>
);
}
if (healthCheckError) {
return (
<RuleFormErrorPromptWrapper>
<RuleFormHealthCheckError error={healthCheckError} docLinks={docLinks} />
</RuleFormErrorPromptWrapper>
);
}
return (
<div data-test-subj="createRuleForm">
<RuleFormStateProvider
initialRuleFormState={{
formData: GET_DEFAULT_FORM_DATA({
ruleTypeId,
name: `${ruleType.name} rule`,
consumer: getInitialConsumer({
consumer,
ruleType,
shouldUseRuleProducer,
}),
schedule: getInitialSchedule({
ruleType,
minimumScheduleInterval: uiConfig?.minimumScheduleInterval,
}),
}),
plugins,
minimumScheduleInterval: uiConfig?.minimumScheduleInterval,
selectedRuleTypeModel: ruleTypeModel,
selectedRuleType: ruleType,
validConsumers,
multiConsumerSelection: getInitialMultiConsumer({
multiConsumerSelection,
validConsumers,
ruleType,
}),
}}
>
<RulePage isEdit={false} isSaving={isSaving} returnUrl={returnUrl} onSave={onSave} />
</RuleFormStateProvider>
</div>
);
};

View file

@ -0,0 +1,134 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React, { useCallback } from 'react';
import { EuiLoadingElastic } from '@elastic/eui';
import { toMountPoint } from '@kbn/react-kibana-mount';
import type { RuleFormData, RuleFormPlugins } from './types';
import { RuleFormStateProvider } from './rule_form_state';
import { useUpdateRule } from '../common/hooks';
import { RulePage } from './rule_page';
import { RuleFormHealthCheckError } from './rule_form_errors/rule_form_health_check_error';
import { useLoadDependencies } from './hooks/use_load_dependencies';
import {
RuleFormCircuitBreakerError,
RuleFormErrorPromptWrapper,
RuleFormResolveRuleError,
RuleFormRuleTypeError,
} from './rule_form_errors';
import { RULE_EDIT_ERROR_TEXT, RULE_EDIT_SUCCESS_TEXT } from './translations';
import { parseRuleCircuitBreakerErrorMessage } from './utils';
export interface EditRuleFormProps {
id: string;
plugins: RuleFormPlugins;
returnUrl: string;
}
export const EditRuleForm = (props: EditRuleFormProps) => {
const { id, plugins, returnUrl } = props;
const { http, notification, docLinks, ruleTypeRegistry, i18n, theme } = plugins;
const { toasts } = notification;
const { mutate, isLoading: isSaving } = useUpdateRule({
http,
onSuccess: ({ name }) => {
toasts.addSuccess(RULE_EDIT_SUCCESS_TEXT(name));
},
onError: (error) => {
const message = parseRuleCircuitBreakerErrorMessage(
error.body?.message || RULE_EDIT_ERROR_TEXT
);
toasts.addDanger({
title: message.summary,
...(message.details && {
text: toMountPoint(
<RuleFormCircuitBreakerError>{message.details}</RuleFormCircuitBreakerError>,
{ i18n, theme }
),
}),
});
},
});
const { isInitialLoading, ruleType, ruleTypeModel, uiConfig, healthCheckError, fetchedFormData } =
useLoadDependencies({
http,
toasts: notification.toasts,
ruleTypeRegistry,
id,
});
const onSave = useCallback(
(newFormData: RuleFormData) => {
mutate({
id,
formData: {
name: newFormData.name,
tags: newFormData.tags,
schedule: newFormData.schedule,
params: newFormData.params,
// TODO: Will add actions in the actions PR
actions: [],
notifyWhen: newFormData.notifyWhen,
alertDelay: newFormData.alertDelay,
},
});
},
[id, mutate]
);
if (isInitialLoading) {
return (
<RuleFormErrorPromptWrapper hasBorder={false} hasShadow={false}>
<EuiLoadingElastic size="xl" />
</RuleFormErrorPromptWrapper>
);
}
if (!ruleType || !ruleTypeModel) {
return (
<RuleFormErrorPromptWrapper hasBorder={false} hasShadow={false}>
<RuleFormRuleTypeError />
</RuleFormErrorPromptWrapper>
);
}
if (!fetchedFormData) {
return (
<RuleFormErrorPromptWrapper hasBorder={false} hasShadow={false}>
<RuleFormResolveRuleError />
</RuleFormErrorPromptWrapper>
);
}
if (healthCheckError) {
return (
<RuleFormErrorPromptWrapper>
<RuleFormHealthCheckError error={healthCheckError} docLinks={docLinks} />
</RuleFormErrorPromptWrapper>
);
}
return (
<div data-test-subj="editRuleForm">
<RuleFormStateProvider
initialRuleFormState={{
formData: fetchedFormData,
id,
plugins,
minimumScheduleInterval: uiConfig?.minimumScheduleInterval,
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleTypeModel,
}}
>
<RulePage isEdit={true} isSaving={isSaving} returnUrl={returnUrl} onSave={onSave} />
</RuleFormStateProvider>
</div>
);
};

View file

@ -0,0 +1,10 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export * from './use_rule_form_dispatch';
export * from './use_rule_form_state';

View file

@ -0,0 +1,317 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook } from '@testing-library/react-hooks/dom';
import { waitFor } from '@testing-library/react';
import type { HttpStart } from '@kbn/core-http-browser';
import type { ToastsStart } from '@kbn/core-notifications-browser';
import { useLoadDependencies } from './use_load_dependencies';
import { RuleTypeRegistryContract } from '../../common';
jest.mock('../../common/hooks/use_load_ui_config', () => ({
useLoadUiConfig: jest.fn(),
}));
jest.mock('../../common/hooks/use_health_check', () => ({
useHealthCheck: jest.fn(),
}));
jest.mock('../../common/hooks/use_resolve_rule', () => ({
useResolveRule: jest.fn(),
}));
jest.mock('../../common/hooks/use_load_rule_types_query', () => ({
useLoadRuleTypesQuery: jest.fn(),
}));
jest.mock('../utils/get_authorized_rule_types', () => ({
getAvailableRuleTypes: jest.fn(),
}));
const { useLoadUiConfig } = jest.requireMock('../../common/hooks/use_load_ui_config');
const { useHealthCheck } = jest.requireMock('../../common/hooks/use_health_check');
const { useResolveRule } = jest.requireMock('../../common/hooks/use_resolve_rule');
const { useLoadRuleTypesQuery } = jest.requireMock('../../common/hooks/use_load_rule_types_query');
const { getAvailableRuleTypes } = jest.requireMock('../utils/get_authorized_rule_types');
const uiConfigMock = {
isUsingSecurity: true,
minimumScheduleInterval: {
value: '1m',
enforce: true,
},
};
const ruleMock = {
params: {},
consumer: 'stackAlerts',
schedule: { interval: '1m' },
tags: [],
name: 'test',
enabled: true,
throttle: null,
ruleTypeId: '.index-threshold',
actions: [],
notifyWhen: 'onActionGroupChange',
alertDelay: {
active: 10,
},
};
useLoadUiConfig.mockReturnValue({
isLoading: false,
isInitialLoading: false,
data: uiConfigMock,
});
useHealthCheck.mockReturnValue({
isLoading: false,
isInitialLoading: false,
error: null,
});
useResolveRule.mockReturnValue({
isLoading: false,
isInitialLoading: false,
data: ruleMock,
});
const indexThresholdRuleType = {
enabledInLicense: true,
recoveryActionGroup: {
id: 'recovered',
name: 'Recovered',
},
actionGroups: [],
defaultActionGroupId: 'threshold met',
minimumLicenseRequired: 'basic',
authorizedConsumers: {
stackAlerts: {
read: true,
all: true,
},
},
ruleTaskTimeout: '5m',
doesSetRecoveryContext: true,
hasAlertsMappings: true,
hasFieldsForAAD: false,
id: '.index-threshold',
name: 'Index threshold',
category: 'management',
producer: 'stackAlerts',
alerts: {},
is_exportable: true,
};
const indexThresholdRuleTypeModel = {
id: '.index-threshold',
description: 'Alert when an aggregated query meets the threshold.',
iconClass: 'alert',
ruleParamsExpression: () => <div />,
defaultActionMessage:
'Rule {{rule.name}} is active for group {{context.group}}:\n\n- Value: {{context.value}}\n- Conditions Met: {{context.conditions}} over {{rule.params.timeWindowSize}}{{rule.params.timeWindowUnit}}\n- Timestamp: {{context.date}}',
requiresAppContext: false,
};
const ruleTypeIndex = new Map();
ruleTypeIndex.set('.index-threshold', indexThresholdRuleType);
useLoadRuleTypesQuery.mockReturnValue({
ruleTypesState: {
isLoading: false,
isInitialLoading: false,
data: ruleTypeIndex,
},
});
getAvailableRuleTypes.mockReturnValue([
{
ruleType: indexThresholdRuleType,
ruleTypeModel: indexThresholdRuleTypeModel,
},
]);
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: Node }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const httpMock = jest.fn();
const toastsMock = jest.fn();
const ruleTypeRegistryMock: RuleTypeRegistryContract = {
has: jest.fn(),
register: jest.fn(),
get: jest.fn(),
list: jest.fn(),
};
describe('useLoadDependencies', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('loads all rule form dependencies', async () => {
const { result } = renderHook(
() => {
return useLoadDependencies({
http: httpMock as unknown as HttpStart,
toasts: toastsMock as unknown as ToastsStart,
ruleTypeRegistry: ruleTypeRegistryMock,
});
},
{ wrapper }
);
await waitFor(() => {
return expect(result.current.isLoading).toEqual(false);
});
expect(result.current).toEqual({
isLoading: false,
isInitialLoading: false,
ruleType: indexThresholdRuleType,
ruleTypeModel: indexThresholdRuleTypeModel,
uiConfig: uiConfigMock,
healthCheckError: null,
fetchedFormData: ruleMock,
});
});
test('should call useLoadRuleTypesQuery with fitlered rule types', async () => {
const { result } = renderHook(
() => {
return useLoadDependencies({
http: httpMock as unknown as HttpStart,
toasts: toastsMock as unknown as ToastsStart,
ruleTypeRegistry: ruleTypeRegistryMock,
filteredRuleTypes: ['test-rule-type'],
});
},
{ wrapper }
);
await waitFor(() => {
return expect(result.current.isInitialLoading).toEqual(false);
});
expect(useLoadRuleTypesQuery).toBeCalledWith({
http: httpMock,
toasts: toastsMock,
filteredRuleTypes: ['test-rule-type'],
});
});
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',
});
},
{ 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(
() => {
return useLoadDependencies({
http: httpMock as unknown as HttpStart,
toasts: toastsMock as unknown as ToastsStart,
ruleTypeRegistry: ruleTypeRegistryMock,
id: 'test-rule-id',
});
},
{ wrapper }
);
await waitFor(() => {
return expect(result.current.isInitialLoading).toEqual(false);
});
expect(useResolveRule).toBeCalledWith({
http: httpMock,
id: 'test-rule-id',
});
});
test('should use the ruleTypeId passed in if creating a rule', async () => {
useResolveRule.mockReturnValue({
isLoading: false,
isInitialLoading: false,
data: null,
});
const { result } = renderHook(
() => {
return useLoadDependencies({
http: httpMock as unknown as HttpStart,
toasts: toastsMock as unknown as ToastsStart,
ruleTypeRegistry: ruleTypeRegistryMock,
ruleTypeId: '.index-threshold',
consumer: 'stackAlerts',
});
},
{ wrapper }
);
await waitFor(() => {
return expect(result.current.isInitialLoading).toEqual(false);
});
expect(result.current.ruleType).toEqual(indexThresholdRuleType);
});
test('should not use ruleTypeId if it is editing a rule', async () => {
useResolveRule.mockReturnValue({
isLoading: false,
isInitialLoading: false,
data: null,
});
const { result } = renderHook(
() => {
return useLoadDependencies({
http: httpMock as unknown as HttpStart,
toasts: toastsMock as unknown as ToastsStart,
ruleTypeRegistry: ruleTypeRegistryMock,
id: 'rule-id',
consumer: 'stackAlerts',
});
},
{ wrapper }
);
await waitFor(() => {
return expect(result.current.isInitialLoading).toEqual(false);
});
expect(result.current.ruleType).toBeFalsy();
});
});

View file

@ -0,0 +1,134 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { HttpStart } from '@kbn/core-http-browser';
import type { ToastsStart } from '@kbn/core-notifications-browser';
import { RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import { useMemo } from 'react';
import {
useHealthCheck,
useLoadRuleTypesQuery,
useLoadUiConfig,
useResolveRule,
} from '../../common/hooks';
import { getAvailableRuleTypes } from '../utils';
import { RuleTypeRegistryContract } from '../../common';
export interface UseLoadDependencies {
http: HttpStart;
toasts: ToastsStart;
ruleTypeRegistry: RuleTypeRegistryContract;
consumer?: string;
id?: string;
ruleTypeId?: string;
validConsumers?: RuleCreationValidConsumer[];
filteredRuleTypes?: string[];
}
export const useLoadDependencies = (props: UseLoadDependencies) => {
const {
http,
toasts,
ruleTypeRegistry,
consumer,
validConsumers,
id,
ruleTypeId,
filteredRuleTypes = [],
} = props;
const {
data: uiConfig,
isLoading: isLoadingUiConfig,
isInitialLoading: isInitialLoadingUiConfig,
} = useLoadUiConfig({ http });
const {
error: healthCheckError,
isLoading: isLoadingHealthCheck,
isInitialLoading: isInitialLoadingHealthCheck,
} = useHealthCheck({ http });
const {
data: fetchedFormData,
isLoading: isLoadingRule,
isInitialLoading: isInitialLoadingRule,
} = useResolveRule({ http, id });
const {
ruleTypesState: {
data: ruleTypeIndex,
isLoading: isLoadingRuleTypes,
isInitialLoad: isInitialLoadingRuleTypes,
},
} = useLoadRuleTypesQuery({
http,
toasts,
filteredRuleTypes,
});
const computedRuleTypeId = useMemo(() => {
return fetchedFormData?.ruleTypeId || ruleTypeId;
}, [fetchedFormData, ruleTypeId]);
const authorizedRuleTypeItems = useMemo(() => {
const computedConsumer = consumer || fetchedFormData?.consumer;
if (!computedConsumer) {
return [];
}
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]);
const isLoading = useMemo(() => {
if (id === undefined) {
return isLoadingUiConfig || isLoadingHealthCheck || isLoadingRuleTypes;
}
return isLoadingUiConfig || isLoadingHealthCheck || isLoadingRule || isLoadingRuleTypes;
}, [id, isLoadingUiConfig, isLoadingHealthCheck, isLoadingRule, isLoadingRuleTypes]);
const isInitialLoading = useMemo(() => {
if (id === undefined) {
return isInitialLoadingUiConfig || isInitialLoadingHealthCheck || isInitialLoadingRuleTypes;
}
return (
isInitialLoadingUiConfig ||
isInitialLoadingHealthCheck ||
isInitialLoadingRule ||
isInitialLoadingRuleTypes
);
}, [
id,
isInitialLoadingUiConfig,
isInitialLoadingHealthCheck,
isInitialLoadingRule,
isInitialLoadingRuleTypes,
]);
return {
isLoading,
isInitialLoading: !!isInitialLoading,
ruleType,
ruleTypeModel,
uiConfig,
healthCheckError,
fetchedFormData,
};
};

View file

@ -0,0 +1,14 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { useContext } from 'react';
import { RuleFormReducerContext } from '../rule_form_state/rule_form_state_context';
export const useRuleFormDispatch = () => {
return useContext(RuleFormReducerContext);
};

View file

@ -0,0 +1,14 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { useContext } from 'react';
import { RuleFormStateContext } from '../rule_form_state/rule_form_state_context';
export const useRuleFormState = () => {
return useContext(RuleFormStateContext);
};

View file

@ -9,5 +9,8 @@
export * from './rule_definition';
export * from './rule_actions';
export * from './rule_details';
export * from './rule_page';
export * from './rule_form';
export * from './utils';
export * from './types';
export * from './constants';

View file

@ -12,45 +12,56 @@ import { RuleAlertDelay } from './rule_alert_delay';
const mockOnChange = jest.fn();
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));
const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');
describe('RuleAlertDelay', () => {
beforeEach(() => {
useRuleFormState.mockReturnValue({
formData: {
alertDelay: {
active: 5,
},
},
});
useRuleFormDispatch.mockReturnValue(mockOnChange);
});
afterEach(() => {
jest.resetAllMocks();
});
test('Renders correctly', () => {
render(
<RuleAlertDelay
alertDelay={{
active: 5,
}}
onChange={mockOnChange}
/>
);
render(<RuleAlertDelay />);
expect(screen.getByTestId('alertDelay')).toBeInTheDocument();
});
test('Should handle input change', () => {
render(
<RuleAlertDelay
alertDelay={{
active: 5,
}}
onChange={mockOnChange}
/>
);
render(<RuleAlertDelay />);
fireEvent.change(screen.getByTestId('alertDelayInput'), {
target: {
value: '3',
},
});
expect(mockOnChange).toHaveBeenCalledWith('alertDelay', { active: 3 });
expect(mockOnChange).toHaveBeenCalledWith({
type: 'setAlertDelay',
payload: { active: 3 },
});
});
test('Should only allow integers as inputs', async () => {
render(<RuleAlertDelay onChange={mockOnChange} />);
useRuleFormState.mockReturnValue({
formData: {
alertDelay: null,
},
});
render(<RuleAlertDelay />);
['-', '+', 'e', 'E', '.', 'a', '01'].forEach((char) => {
fireEvent.change(screen.getByTestId('alertDelayInput'), {
@ -63,36 +74,33 @@ describe('RuleAlertDelay', () => {
});
test('Should call onChange with null if empty string is typed', () => {
render(
<RuleAlertDelay
alertDelay={{
active: 5,
}}
onChange={mockOnChange}
/>
);
render(<RuleAlertDelay />);
fireEvent.change(screen.getByTestId('alertDelayInput'), {
target: {
value: '',
},
});
expect(mockOnChange).toHaveBeenCalledWith('alertDelay', null);
expect(mockOnChange).toHaveBeenCalledWith({
type: 'setAlertDelay',
payload: null,
});
});
test('Should display error when input is invalid', () => {
render(
<RuleAlertDelay
alertDelay={{
useRuleFormState.mockReturnValue({
formData: {
alertDelay: {
active: -5,
}}
errors={{
alertDelay: 'Alert delay must be greater than 1.',
}}
onChange={mockOnChange}
/>
);
},
},
baseErrors: {
alertDelay: 'Alert delay must be greater than 1.',
},
});
render(<RuleAlertDelay />);
expect(screen.getByTestId('alertDelayInput')).toBeInvalid();
expect(screen.getByText('Alert delay must be greater than 1.')).toBeInTheDocument();
});
});

View file

@ -8,32 +8,43 @@
import React, { useCallback } from 'react';
import { EuiFieldNumber, EuiFormRow } from '@elastic/eui';
import { ALERT_DELAY_TITLE_PREFIX, ALERT_DELAY_TITLE_SUFFIX } from '../translations';
import { RuleFormErrors, Rule, RuleTypeParams } from '../../common';
import {
ALERT_DELAY_TITLE_PREFIX,
ALERT_DELAY_TITLE_SUFFIX,
ALERT_DELAY_TITLE,
} from '../translations';
import { useRuleFormState, useRuleFormDispatch } from '../hooks';
const INTEGER_REGEX = /^[1-9][0-9]*$/;
const INVALID_KEYS = ['-', '+', '.', 'e', 'E'];
export interface RuleAlertDelayProps {
alertDelay?: Rule<RuleTypeParams>['alertDelay'] | null;
errors?: RuleFormErrors;
onChange: (property: string, value: unknown) => void;
}
export const RuleAlertDelay = () => {
const { formData, baseErrors } = useRuleFormState();
export const RuleAlertDelay = (props: RuleAlertDelayProps) => {
const { alertDelay, errors = {}, onChange } = props;
const dispatch = useRuleFormDispatch();
const { alertDelay } = formData;
const onAlertDelayChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.trim();
if (!e.target.validity.valid) {
return;
}
const value = e.target.value;
if (value === '') {
onChange('alertDelay', null);
dispatch({
type: 'setAlertDelay',
payload: null,
});
} else if (INTEGER_REGEX.test(value)) {
const parsedValue = parseInt(value, 10);
onChange('alertDelay', { active: parsedValue });
dispatch({
type: 'setAlertDelay',
payload: { active: parsedValue },
});
}
},
[onChange]
[dispatch]
);
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
@ -45,8 +56,9 @@ export const RuleAlertDelay = (props: RuleAlertDelayProps) => {
return (
<EuiFormRow
fullWidth
isInvalid={errors.alertDelay?.length > 0}
error={errors.alertDelay}
label={ALERT_DELAY_TITLE}
isInvalid={!!baseErrors?.alertDelay?.length}
error={baseErrors?.alertDelay}
data-test-subj="alertDelay"
display="rowCompressed"
>
@ -57,7 +69,7 @@ export const RuleAlertDelay = (props: RuleAlertDelayProps) => {
name="alertDelay"
data-test-subj="alertDelayInput"
prepend={[ALERT_DELAY_TITLE_PREFIX]}
isInvalid={errors.alertDelay?.length > 0}
isInvalid={!!baseErrors?.alertDelay?.length}
append={ALERT_DELAY_TITLE_SUFFIX}
onChange={onAlertDelayChange}
onKeyDown={onKeyDown}

View file

@ -14,78 +14,72 @@ import { RuleConsumerSelection } from './rule_consumer_selection';
const mockOnChange = jest.fn();
const mockConsumers: RuleCreationValidConsumer[] = ['logs', 'infrastructure', 'stackAlerts'];
describe('RuleConsumerSelection', () => {
test('Renders correctly', () => {
render(
<RuleConsumerSelection
consumers={mockConsumers}
selectedConsumer={'stackAlerts'}
onChange={mockOnChange}
/>
);
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));
const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');
describe('RuleConsumerSelection', () => {
beforeEach(() => {
useRuleFormState.mockReturnValue({
multiConsumerSelection: 'stackAlerts',
});
useRuleFormDispatch.mockReturnValue(mockOnChange);
});
afterEach(() => {
jest.resetAllMocks();
});
test('Renders correctly', () => {
render(<RuleConsumerSelection validConsumers={mockConsumers} />);
expect(screen.getByTestId('ruleConsumerSelection')).toBeInTheDocument();
});
test('Should default to the selected consumer', () => {
render(
<RuleConsumerSelection
consumers={mockConsumers}
selectedConsumer={'stackAlerts'}
onChange={mockOnChange}
/>
);
render(<RuleConsumerSelection validConsumers={mockConsumers} />);
expect(screen.getByTestId('comboBoxSearchInput')).toHaveValue('Stack Rules');
});
it('Should not display the initial selected consumer if it is not a selectable option', () => {
render(
<RuleConsumerSelection
consumers={['stackAlerts', 'infrastructure']}
selectedConsumer={'logs'}
onChange={mockOnChange}
/>
);
useRuleFormState.mockReturnValue({
multiConsumerSelection: 'logs',
});
render(<RuleConsumerSelection validConsumers={['stackAlerts', 'infrastructure']} />);
expect(screen.getByTestId('comboBoxSearchInput')).toHaveValue('');
});
it('should display nothing if there is only 1 consumer to select', () => {
render(
<RuleConsumerSelection
selectedConsumer={null}
consumers={['stackAlerts']}
onChange={mockOnChange}
/>
);
useRuleFormState.mockReturnValue({
multiConsumerSelection: null,
});
render(<RuleConsumerSelection validConsumers={['stackAlerts']} />);
expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument();
});
it('should be able to select logs and call onChange', () => {
render(
<RuleConsumerSelection
selectedConsumer={null}
consumers={mockConsumers}
onChange={mockOnChange}
errors={{}}
/>
);
useRuleFormState.mockReturnValue({
multiConsumerSelection: null,
});
render(<RuleConsumerSelection validConsumers={mockConsumers} />);
fireEvent.click(screen.getByTestId('comboBoxToggleListButton'));
fireEvent.click(screen.getByTestId('ruleConsumerSelectionOption-logs'));
expect(mockOnChange).toHaveBeenLastCalledWith('consumer', 'logs');
expect(mockOnChange).toHaveBeenLastCalledWith({
type: 'setMultiConsumer',
payload: 'logs',
});
});
it('should be able to show errors when there is one', () => {
render(
<RuleConsumerSelection
selectedConsumer={null}
consumers={mockConsumers}
onChange={mockOnChange}
errors={{ consumer: ['Scope is required'] }}
/>
);
useRuleFormState.mockReturnValue({
multiConsumerSelection: null,
baseErrors: { consumer: ['Scope is required'] },
});
render(<RuleConsumerSelection validConsumers={mockConsumers} />);
expect(screen.queryAllByText('Scope is required')).toHaveLength(1);
});
});

View file

@ -9,8 +9,13 @@
import React, { useMemo, useCallback } from 'react';
import { EuiComboBox, EuiFormRow, EuiComboBoxOptionOption } from '@elastic/eui';
import { AlertConsumers, RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import { FEATURE_NAME_MAP, CONSUMER_SELECT_COMBO_BOX_TITLE } from '../translations';
import { RuleFormErrors } from '../../common';
import {
CONSUMER_SELECT_TITLE,
FEATURE_NAME_MAP,
CONSUMER_SELECT_COMBO_BOX_TITLE,
} from '../translations';
import { useRuleFormState, useRuleFormDispatch } from '../hooks';
import { getValidatedMultiConsumer } from '../utils';
export const VALID_CONSUMERS: RuleCreationValidConsumer[] = [
AlertConsumers.LOGS,
@ -20,10 +25,7 @@ export const VALID_CONSUMERS: RuleCreationValidConsumer[] = [
];
export interface RuleConsumerSelectionProps {
consumers: RuleCreationValidConsumer[];
selectedConsumer?: RuleCreationValidConsumer | null;
errors?: RuleFormErrors;
onChange: (property: string, value: unknown) => void;
validConsumers: RuleCreationValidConsumer[];
}
const SINGLE_SELECTION = { asPlainText: true };
@ -31,26 +33,24 @@ const SINGLE_SELECTION = { asPlainText: true };
type ComboBoxOption = EuiComboBoxOptionOption<RuleCreationValidConsumer>;
export const RuleConsumerSelection = (props: RuleConsumerSelectionProps) => {
const { consumers, selectedConsumer, errors = {}, onChange } = props;
const { validConsumers } = props;
const isInvalid = (errors.consumer?.length || 0) > 0;
const { multiConsumerSelection, baseErrors } = useRuleFormState();
const dispatch = useRuleFormDispatch();
const validatedSelectedConsumer = useMemo(() => {
if (
selectedConsumer &&
consumers.includes(selectedConsumer) &&
FEATURE_NAME_MAP[selectedConsumer]
) {
return selectedConsumer;
}
return null;
}, [selectedConsumer, consumers]);
return getValidatedMultiConsumer({
multiConsumerSelection,
validConsumers,
});
}, [multiConsumerSelection, validConsumers]);
const selectedOptions = useMemo(() => {
if (validatedSelectedConsumer) {
return [
{
value: validatedSelectedConsumer,
value: validatedSelectedConsumer as RuleCreationValidConsumer,
label: FEATURE_NAME_MAP[validatedSelectedConsumer],
},
];
@ -59,7 +59,7 @@ export const RuleConsumerSelection = (props: RuleConsumerSelectionProps) => {
}, [validatedSelectedConsumer]);
const formattedSelectOptions = useMemo(() => {
return consumers
return validConsumers
.reduce<ComboBoxOption[]>((result, consumer) => {
if (FEATURE_NAME_MAP[consumer]) {
result.push({
@ -71,29 +71,36 @@ export const RuleConsumerSelection = (props: RuleConsumerSelectionProps) => {
return result;
}, [])
.sort((a, b) => a.value!.localeCompare(b.value!));
}, [consumers]);
}, [validConsumers]);
const onConsumerChange = useCallback(
(selected: ComboBoxOption[]) => {
if (selected.length > 0) {
const newSelectedConsumer = selected[0];
onChange('consumer', newSelectedConsumer.value);
dispatch({
type: 'setMultiConsumer',
payload: newSelectedConsumer.value!,
});
} else {
onChange('consumer', null);
dispatch({
type: 'setMultiConsumer',
payload: 'alerts',
});
}
},
[onChange]
[dispatch]
);
if (consumers.length <= 1 || consumers.includes(AlertConsumers.OBSERVABILITY)) {
if (validConsumers.length <= 1 || validConsumers.includes(AlertConsumers.OBSERVABILITY)) {
return null;
}
return (
<EuiFormRow
fullWidth
isInvalid={isInvalid}
error={errors?.consumer ?? ''}
label={CONSUMER_SELECT_TITLE}
isInvalid={!!baseErrors?.consumer?.length}
error={baseErrors?.consumer}
data-test-subj="ruleConsumerSelection"
>
<EuiComboBox

View file

@ -16,9 +16,13 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { RuleDefinition } from './rule_definition';
import { RuleTypeModel } from '../../common';
import { RuleType } from '@kbn/triggers-actions-ui-types';
import { ALERT_DELAY_TITLE } from '../translations';
import { RuleType } from '@kbn/alerting-types';
import { RuleTypeModel } from '../../common/types';
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));
const ruleType = {
id: '.es-query',
@ -40,6 +44,8 @@ const ruleType = {
authorizedConsumers: {
alerting: { read: true, all: true },
test: { read: true, all: true },
stackAlerts: { read: true, all: true },
logs: { read: true, all: true },
},
actionVariables: {
params: [],
@ -60,7 +66,7 @@ const ruleModel: RuleTypeModel = {
requiresAppContext: false,
};
const requiredPlugins = {
const plugins = {
charts: {} as ChartsPluginSetup,
data: {} as DataPublicPluginStart,
dataViews: {} as DataViewsPublicPluginStart,
@ -68,152 +74,156 @@ const requiredPlugins = {
docLinks: {} as DocLinksStart,
};
const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');
const mockOnChange = jest.fn();
describe('Rule Definition', () => {
beforeEach(() => {
useRuleFormDispatch.mockReturnValue(mockOnChange);
});
afterEach(() => {
jest.resetAllMocks();
});
test('Renders correctly', () => {
render(
<RuleDefinition
requiredPlugins={requiredPlugins}
formValues={{
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
}}
selectedRuleType={ruleType as RuleType}
selectedRuleTypeModel={ruleModel as RuleTypeModel}
canShowConsumerSelection
authorizedConsumers={['logs', 'stackAlerts']}
onChange={mockOnChange}
/>
);
useRuleFormState.mockReturnValue({
plugins,
formData: {
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
},
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
canShowConsumerSelection: true,
validConsumers: ['logs', 'stackAlerts'],
});
render(<RuleDefinition />);
expect(screen.getByTestId('ruleDefinition')).toBeInTheDocument();
expect(screen.getByTestId('ruleSchedule')).toBeInTheDocument();
expect(screen.getByTestId('ruleConsumerSelection')).toBeInTheDocument();
expect(screen.getByTestId('ruleDefinitionHeaderDocsLink')).toBeInTheDocument();
expect(screen.getByTestId('alertDelay')).not.toBeVisible();
expect(screen.getByText(ALERT_DELAY_TITLE)).not.toBeVisible();
expect(screen.getByText('Expression')).toBeInTheDocument();
});
test('Hides doc link if not provided', () => {
render(
<RuleDefinition
requiredPlugins={requiredPlugins}
formValues={{
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
}}
selectedRuleType={ruleType}
selectedRuleTypeModel={{
...ruleModel,
documentationUrl: null,
}}
canShowConsumerSelection
authorizedConsumers={['logs', 'stackAlerts']}
onChange={mockOnChange}
/>
);
useRuleFormState.mockReturnValue({
plugins,
formData: {
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
},
selectedRuleType: ruleType,
selectedRuleTypeModel: {
...ruleModel,
documentationUrl: null,
},
});
render(<RuleDefinition />);
expect(screen.queryByTestId('ruleDefinitionHeaderDocsLink')).not.toBeInTheDocument();
});
test('Hides consumer selection if canShowConsumerSelection is false', () => {
render(
<RuleDefinition
requiredPlugins={requiredPlugins}
formValues={{
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
}}
selectedRuleType={ruleType}
selectedRuleTypeModel={ruleModel}
authorizedConsumers={['logs', 'stackAlerts']}
onChange={mockOnChange}
/>
);
useRuleFormState.mockReturnValue({
plugins,
formData: {
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
},
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
});
render(<RuleDefinition />);
expect(screen.queryByTestId('ruleConsumerSelection')).not.toBeInTheDocument();
});
test('Can toggle advanced options', async () => {
render(
<RuleDefinition
requiredPlugins={requiredPlugins}
formValues={{
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
}}
selectedRuleType={ruleType}
selectedRuleTypeModel={ruleModel}
authorizedConsumers={['logs', 'stackAlerts']}
onChange={mockOnChange}
/>
);
useRuleFormState.mockReturnValue({
plugins,
formData: {
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
},
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
});
render(<RuleDefinition />);
fireEvent.click(screen.getByTestId('advancedOptionsAccordionButton'));
expect(screen.getByText(ALERT_DELAY_TITLE)).toBeVisible();
expect(screen.getByTestId('alertDelay')).toBeVisible();
});
test('Calls onChange when inputs are modified', () => {
render(
<RuleDefinition
requiredPlugins={requiredPlugins}
formValues={{
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
}}
selectedRuleType={ruleType}
selectedRuleTypeModel={ruleModel}
authorizedConsumers={['logs', 'stackAlerts']}
onChange={mockOnChange}
/>
);
useRuleFormState.mockReturnValue({
plugins,
formData: {
id: 'test-id',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
},
selectedRuleType: ruleType,
selectedRuleTypeModel: ruleModel,
});
render(<RuleDefinition />);
fireEvent.change(screen.getByTestId('ruleScheduleNumberInput'), {
target: {
value: '10',
},
});
expect(mockOnChange).toHaveBeenCalledWith('interval', '10m');
expect(mockOnChange).toHaveBeenCalledWith({
type: 'setSchedule',
payload: {
interval: '10m',
},
});
});
});

View file

@ -16,100 +16,63 @@ import {
EuiText,
EuiLink,
EuiDescribedFormGroup,
EuiIconTip,
EuiAccordion,
EuiPanel,
EuiSpacer,
EuiErrorBoundary,
EuiIconTip,
} from '@elastic/eui';
import {
RuleCreationValidConsumer,
ES_QUERY_ID,
OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
ML_ANOMALY_DETECTION_RULE_TYPE_ID,
} from '@kbn/rule-data-utils';
import type { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import type { RuleType } from '@kbn/triggers-actions-ui-types';
import type {
RuleTypeModel,
RuleFormErrors,
MinimumScheduleInterval,
Rule,
RuleTypeParams,
} from '../../common';
import {
DOC_LINK_TITLE,
LOADING_RULE_TYPE_PARAMS_TITLE,
SCHEDULE_TITLE,
SCHEDULE_DESCRIPTION_TEXT,
SCHEDULE_TOOLTIP_TEXT,
ALERT_DELAY_TITLE,
SCOPE_TITLE,
SCOPE_DESCRIPTION_TEXT,
ADVANCED_OPTIONS_TITLE,
ALERT_DELAY_DESCRIPTION_TEXT,
SCHEDULE_TOOLTIP_TEXT,
ALERT_DELAY_HELP_TEXT,
} from '../translations';
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 { getAuthorizedConsumers } from '../utils';
const MULTI_CONSUMER_RULE_TYPE_IDS = [
OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
ES_QUERY_ID,
ML_ANOMALY_DETECTION_RULE_TYPE_ID,
];
interface RuleDefinitionProps {
requiredPlugins: {
charts: ChartsPluginSetup;
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
docLinks: DocLinksStart;
};
formValues: {
id?: Rule<RuleTypeParams>['id'];
params: Rule<RuleTypeParams>['params'];
schedule: Rule<RuleTypeParams>['schedule'];
alertDelay?: Rule<RuleTypeParams>['alertDelay'];
notifyWhen?: Rule<RuleTypeParams>['notifyWhen'];
consumer?: Rule<RuleTypeParams>['consumer'];
};
minimumScheduleInterval?: MinimumScheduleInterval;
errors?: RuleFormErrors;
canShowConsumerSelection?: boolean;
authorizedConsumers?: RuleCreationValidConsumer[];
selectedRuleTypeModel: RuleTypeModel;
selectedRuleType: RuleType;
validConsumers?: RuleCreationValidConsumer[];
onChange: (property: string, value: unknown) => void;
}
export const RuleDefinition = (props: RuleDefinitionProps) => {
export const RuleDefinition = () => {
const {
requiredPlugins,
formValues,
errors = {},
canShowConsumerSelection = false,
authorizedConsumers = [],
selectedRuleTypeModel,
id,
formData,
plugins,
paramsErrors,
metadata,
selectedRuleType,
minimumScheduleInterval,
onChange,
} = props;
selectedRuleTypeModel,
validConsumers,
canShowConsumerSelection = false,
} = useRuleFormState();
const { charts, data, dataViews, unifiedSearch, docLinks } = requiredPlugins;
const dispatch = useRuleFormDispatch();
const { id, params, schedule, alertDelay, notifyWhen, consumer = 'alerts' } = formValues;
const { charts, data, dataViews, unifiedSearch, docLinks } = plugins;
const { params, schedule, notifyWhen } = formData;
const [metadata, setMetadata] = useState<Record<string, unknown>>();
const [isAdvancedOptionsVisible, setIsAdvancedOptionsVisible] = useState<boolean>(false);
const authorizedConsumers = useMemo(() => {
if (!validConsumers?.length) {
return [];
}
return getAuthorizedConsumers({
ruleType: selectedRuleType,
validConsumers,
});
}, [selectedRuleType, validConsumers]);
const shouldShowConsumerSelect = useMemo(() => {
if (!canShowConsumerSelection) {
return false;
@ -132,21 +95,40 @@ export const RuleDefinition = (props: RuleDefinitionProps) => {
return documentationUrl;
}, [selectedRuleTypeModel, docLinks]);
const onSetRuleParams = useCallback(
(property: string, value: unknown) => {
onChange('params', {
...params,
[property]: value,
const onChangeMetaData = useCallback(
(newMetadata) => {
dispatch({
type: 'setMetadata',
payload: newMetadata,
});
},
[onChange, params]
[dispatch]
);
const onSetRule = useCallback(
const onSetRuleParams = useCallback(
(property: string, value: unknown) => {
onChange(property, value);
dispatch({
type: 'setParamsProperty',
payload: {
property,
value,
},
});
},
[onChange]
[dispatch]
);
const onSetRuleProperty = useCallback(
(property: string, value: unknown) => {
dispatch({
type: 'setRuleProperty',
payload: {
property,
value,
},
});
},
[dispatch]
);
return (
@ -197,9 +179,9 @@ export const RuleDefinition = (props: RuleDefinitionProps) => {
ruleInterval={schedule.interval}
ruleThrottle={''}
alertNotifyWhen={notifyWhen || 'onActionGroupChange'}
errors={errors}
errors={paramsErrors || {}}
setRuleParams={onSetRuleParams}
setRuleProperty={onSetRule}
setRuleProperty={onSetRuleProperty}
defaultActionGroupId={selectedRuleType.defaultActionGroupId}
actionGroups={selectedRuleType.actionGroups}
metadata={metadata}
@ -207,7 +189,7 @@ export const RuleDefinition = (props: RuleDefinitionProps) => {
data={data}
dataViews={dataViews}
unifiedSearch={unifiedSearch}
onChangeMetaData={setMetadata}
onChangeMetaData={onChangeMetaData}
/>
</EuiErrorBoundary>
</EuiFlexItem>
@ -232,12 +214,7 @@ export const RuleDefinition = (props: RuleDefinitionProps) => {
</EuiText>
}
>
<RuleSchedule
interval={schedule.interval}
minimumScheduleInterval={minimumScheduleInterval}
errors={errors}
onChange={onChange}
/>
<RuleSchedule />
</EuiDescribedFormGroup>
{shouldShowConsumerSelect && (
<EuiDescribedFormGroup
@ -245,12 +222,7 @@ export const RuleDefinition = (props: RuleDefinitionProps) => {
title={<h3>{SCOPE_TITLE}</h3>}
description={<p>{SCOPE_DESCRIPTION_TEXT}</p>}
>
<RuleConsumerSelection
consumers={authorizedConsumers}
selectedConsumer={consumer as RuleCreationValidConsumer}
errors={errors}
onChange={onChange}
/>
<RuleConsumerSelection validConsumers={authorizedConsumers} />
</EuiDescribedFormGroup>
)}
<EuiFlexItem>
@ -286,7 +258,7 @@ export const RuleDefinition = (props: RuleDefinitionProps) => {
</EuiText>
}
>
<RuleAlertDelay alertDelay={alertDelay} errors={errors} onChange={onChange} />
<RuleAlertDelay />
</EuiDescribedFormGroup>
</EuiPanel>
</EuiAccordion>

View file

@ -13,37 +13,86 @@ import { RuleSchedule } from './rule_schedule';
const mockOnChange = jest.fn();
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));
const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');
describe('RuleSchedule', () => {
beforeEach(() => {
useRuleFormDispatch.mockReturnValue(mockOnChange);
});
afterEach(() => {
jest.resetAllMocks();
});
test('Renders correctly', () => {
render(<RuleSchedule interval={'5m'} onChange={mockOnChange} />);
useRuleFormState.mockReturnValue({
formData: {
schedule: {
interval: '5m',
},
},
});
render(<RuleSchedule />);
expect(screen.getByTestId('ruleSchedule')).toBeInTheDocument();
});
test('Should allow interval number to be changed', () => {
render(<RuleSchedule interval={'5m'} onChange={mockOnChange} />);
useRuleFormState.mockReturnValue({
formData: {
schedule: {
interval: '5m',
},
},
});
render(<RuleSchedule />);
fireEvent.change(screen.getByTestId('ruleScheduleNumberInput'), {
target: {
value: '10',
},
});
expect(mockOnChange).toHaveBeenCalledWith('interval', '10m');
expect(mockOnChange).toHaveBeenCalledWith({
type: 'setSchedule',
payload: {
interval: '10m',
},
});
});
test('Should allow interval unit to be changed', () => {
render(<RuleSchedule interval={'5m'} onChange={mockOnChange} />);
useRuleFormState.mockReturnValue({
formData: {
schedule: {
interval: '5m',
},
},
});
render(<RuleSchedule />);
userEvent.selectOptions(screen.getByTestId('ruleScheduleUnitInput'), 'hours');
expect(mockOnChange).toHaveBeenCalledWith('interval', '5h');
expect(mockOnChange).toHaveBeenCalledWith({
type: 'setSchedule',
payload: {
interval: '5h',
},
});
});
test('Should only allow integers as inputs', async () => {
render(<RuleSchedule interval={'5m'} onChange={mockOnChange} />);
useRuleFormState.mockReturnValue({
formData: {
schedule: {
interval: '5m',
},
},
});
render(<RuleSchedule />);
['-', '+', 'e', 'E', '.', 'a', '01'].forEach((char) => {
fireEvent.change(screen.getByTestId('ruleScheduleNumberInput'), {
@ -56,30 +105,35 @@ describe('RuleSchedule', () => {
});
test('Should display error properly', () => {
render(
<RuleSchedule
interval={'5m'}
errors={{
interval: 'something went wrong!',
}}
onChange={mockOnChange}
/>
);
useRuleFormState.mockReturnValue({
formData: {
schedule: {
interval: '5m',
},
},
baseErrors: {
interval: 'something went wrong!',
},
});
render(<RuleSchedule />);
expect(screen.getByText('something went wrong!')).toBeInTheDocument();
expect(screen.getByTestId('ruleScheduleNumberInput')).toBeInvalid();
});
test('Should enforce minimum schedule interval', () => {
render(
<RuleSchedule
interval={'30s'}
minimumScheduleInterval={{
enforce: true,
value: '1m',
}}
onChange={mockOnChange}
/>
);
useRuleFormState.mockReturnValue({
formData: {
schedule: {
interval: '30s',
},
},
minimumScheduleInterval: {
enforce: true,
value: '1m',
},
});
render(<RuleSchedule />);
expect(screen.getByText('Interval must be at least 1 minute.')).toBeInTheDocument();
});

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useCallback } from 'react';
import React, { useMemo, useCallback } from 'react';
import { EuiFlexItem, EuiFormRow, EuiFlexGroup, EuiSelect, EuiFieldNumber } from '@elastic/eui';
import {
parseDuration,
@ -15,12 +15,13 @@ import {
getDurationNumberInItsUnit,
} from '../utils/parse_duration';
import { getTimeOptions } from '../utils/get_time_options';
import { MinimumScheduleInterval, RuleFormErrors } from '../../common';
import {
SCHEDULE_TITLE_PREFIX,
INTERVAL_MINIMUM_TEXT,
INTERVAL_WARNING_TEXT,
} from '../translations';
import { useRuleFormState, useRuleFormDispatch } from '../hooks';
import { MinimumScheduleInterval } from '../../common';
const INTEGER_REGEX = /^[1-9][0-9]*$/;
const INVALID_KEYS = ['-', '+', '.', 'e', 'E'];
@ -47,44 +48,64 @@ const getHelpTextForInterval = (
}
};
export interface RuleScheduleProps {
interval: string;
minimumScheduleInterval?: MinimumScheduleInterval;
errors?: RuleFormErrors;
onChange: (property: string, value: unknown) => void;
}
export const RuleSchedule = () => {
const { formData, baseErrors, minimumScheduleInterval } = useRuleFormState();
export const RuleSchedule = (props: RuleScheduleProps) => {
const { interval, minimumScheduleInterval, errors = {}, onChange } = props;
const dispatch = useRuleFormDispatch();
const hasIntervalError = errors.interval?.length > 0;
const {
schedule: { interval },
} = formData;
const intervalNumber = getDurationNumberInItsUnit(interval);
const hasIntervalError = useMemo(() => {
return !!baseErrors?.interval?.length;
}, [baseErrors]);
const intervalUnit = getDurationUnitValue(interval);
const intervalNumber = useMemo(() => {
return getDurationNumberInItsUnit(interval ?? 1);
}, [interval]);
// No help text if there is an error
const helpText =
minimumScheduleInterval && !hasIntervalError
? getHelpTextForInterval(interval, minimumScheduleInterval)
: '';
const intervalUnit = useMemo(() => {
return getDurationUnitValue(interval);
}, [interval]);
const helpText = useMemo(
() =>
minimumScheduleInterval && !hasIntervalError // No help text if there is an error
? getHelpTextForInterval(interval, minimumScheduleInterval)
: '',
[interval, minimumScheduleInterval, hasIntervalError]
);
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);
onChange('interval', `${parsedValue}${intervalUnit}`);
dispatch({
type: 'setSchedule',
payload: {
interval: `${parsedValue}${intervalUnit}`,
},
});
}
},
[intervalUnit, onChange]
[intervalUnit, dispatch]
);
const onIntervalUnitChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
onChange('interval', `${intervalNumber}${e.target.value}`);
dispatch({
type: 'setSchedule',
payload: {
interval: `${intervalNumber}${e.target.value}`,
},
});
},
[intervalNumber, onChange]
[intervalNumber, dispatch]
);
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
@ -99,15 +120,15 @@ export const RuleSchedule = (props: RuleScheduleProps) => {
data-test-subj="ruleSchedule"
display="rowCompressed"
helpText={helpText}
isInvalid={errors.interval?.length > 0}
error={errors.interval}
isInvalid={hasIntervalError}
error={baseErrors?.interval}
>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={2}>
<EuiFieldNumber
fullWidth
prepend={[SCHEDULE_TITLE_PREFIX]}
isInvalid={errors.interval?.length > 0}
isInvalid={hasIntervalError}
value={intervalNumber}
name="interval"
data-test-subj="ruleScheduleNumberInput"

View file

@ -13,65 +13,66 @@ import { RuleDetails } from './rule_details';
const mockOnChange = jest.fn();
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));
const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');
describe('RuleDetails', () => {
beforeEach(() => {
useRuleFormState.mockReturnValue({
formData: {
name: 'test',
tags: [],
},
});
useRuleFormDispatch.mockReturnValue(mockOnChange);
});
afterEach(() => {
jest.resetAllMocks();
});
test('Renders correctly', () => {
render(
<RuleDetails
formValues={{
name: 'test',
tags: [],
}}
onChange={mockOnChange}
/>
);
render(<RuleDetails />);
expect(screen.getByTestId('ruleDetails')).toBeInTheDocument();
});
test('Should allow name to be changed', () => {
render(
<RuleDetails
formValues={{
name: 'test',
tags: [],
}}
onChange={mockOnChange}
/>
);
render(<RuleDetails />);
fireEvent.change(screen.getByTestId('ruleDetailsNameInput'), { target: { value: 'hello' } });
expect(mockOnChange).toHaveBeenCalledWith('name', 'hello');
expect(mockOnChange).toHaveBeenCalledWith({
type: 'setName',
payload: 'hello',
});
});
test('Should allow tags to be changed', () => {
render(
<RuleDetails
formValues={{
name: 'test',
tags: [],
}}
onChange={mockOnChange}
/>
);
render(<RuleDetails />);
userEvent.type(screen.getByTestId('comboBoxInput'), 'tag{enter}');
expect(mockOnChange).toHaveBeenCalledWith('tags', ['tag']);
expect(mockOnChange).toHaveBeenCalledWith({
type: 'setTags',
payload: ['tag'],
});
});
test('Should display error', () => {
render(
<RuleDetails
formValues={{
name: 'test',
tags: [],
}}
errors={{
name: 'name is invalid',
tags: 'tags is invalid',
}}
onChange={mockOnChange}
/>
);
useRuleFormState.mockReturnValue({
formData: {
name: 'test',
tags: [],
},
baseErrors: {
name: 'name is invalid',
tags: 'tags is invalid',
},
});
render(<RuleDetails />);
expect(screen.getByText('name is invalid')).toBeInTheDocument();
expect(screen.getByText('tags is invalid')).toBeInTheDocument();

View file

@ -15,27 +15,21 @@ import {
EuiComboBoxOptionOption,
EuiText,
} from '@elastic/eui';
import type { RuleFormErrors, Rule, RuleTypeParams } from '../../common';
import {
RULE_DETAILS_TITLE,
RULE_DETAILS_DESCRIPTION,
RULE_NAME_INPUT_TITLE,
RULE_TAG_INPUT_TITLE,
RULE_TAG_PLACEHOLDER,
} from '../translations';
import { useRuleFormState, useRuleFormDispatch } from '../hooks';
export interface RuleDetailsProps {
formValues: {
tags?: Rule<RuleTypeParams>['tags'];
name: Rule<RuleTypeParams>['name'];
};
errors?: RuleFormErrors;
onChange: (property: string, value: unknown) => void;
}
export const RuleDetails = () => {
const { formData, baseErrors } = useRuleFormState();
export const RuleDetails = (props: RuleDetailsProps) => {
const { formValues, errors = {}, onChange } = props;
const dispatch = useRuleFormDispatch();
const { tags = [], name } = formValues;
const { tags = [], name } = formData;
const tagsOptions = useMemo(() => {
return tags.map((tag: string) => ({ label: tag }));
@ -43,40 +37,49 @@ export const RuleDetails = (props: RuleDetailsProps) => {
const onNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange('name', e.target.value);
dispatch({
type: 'setName',
payload: e.target.value,
});
},
[onChange]
[dispatch]
);
const onAddTag = useCallback(
(searchValue: string) => {
onChange('tags', tags.concat([searchValue]));
dispatch({
type: 'setTags',
payload: tags.concat([searchValue]),
});
},
[onChange, tags]
[dispatch, tags]
);
const onSetTag = useCallback(
(options: Array<EuiComboBoxOptionOption<string>>) => {
onChange(
'tags',
options.map((selectedOption) => selectedOption.label)
);
dispatch({
type: 'setTags',
payload: options.map((selectedOption) => selectedOption.label),
});
},
[onChange]
[dispatch]
);
const onBlur = useCallback(() => {
if (!tags) {
onChange('tags', []);
dispatch({
type: 'setTags',
payload: [],
});
}
}, [onChange, tags]);
}, [dispatch, tags]);
return (
<EuiDescribedFormGroup
fullWidth
title={<h3>{RULE_DETAILS_TITLE}</h3>}
description={
<EuiText>
<EuiText size="s">
<p>{RULE_DETAILS_DESCRIPTION}</p>
</EuiText>
}
@ -85,12 +88,13 @@ export const RuleDetails = (props: RuleDetailsProps) => {
<EuiFormRow
fullWidth
label={RULE_NAME_INPUT_TITLE}
isInvalid={errors.name?.length > 0}
error={errors.name}
isInvalid={!!baseErrors?.name?.length}
error={baseErrors?.name}
>
<EuiFieldText
fullWidth
value={name}
placeholder={RULE_NAME_INPUT_TITLE}
onChange={onNameChange}
data-test-subj="ruleDetailsNameInput"
/>
@ -98,12 +102,13 @@ export const RuleDetails = (props: RuleDetailsProps) => {
<EuiFormRow
fullWidth
label={RULE_TAG_INPUT_TITLE}
isInvalid={errors.tags?.length > 0}
error={errors.tags}
isInvalid={!!baseErrors?.tags?.length}
error={baseErrors?.tags}
>
<EuiComboBox
fullWidth
noSuggestions
placeholder={RULE_TAG_PLACEHOLDER}
data-test-subj="ruleDetailsTagsInput"
selectedOptions={tagsOptions}
onCreateOption={onAddTag}

View file

@ -0,0 +1,56 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React, { useMemo } from 'react';
import { EuiEmptyPrompt, EuiText } from '@elastic/eui';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { CreateRuleForm } from './create_rule_form';
import { EditRuleForm } from './edit_rule_form';
import {
RULE_FORM_ROUTE_PARAMS_ERROR_TITLE,
RULE_FORM_ROUTE_PARAMS_ERROR_TEXT,
} from './translations';
import { RuleFormPlugins } from './types';
const queryClient = new QueryClient();
export interface RuleFormProps {
plugins: RuleFormPlugins;
returnUrl: string;
}
export const RuleForm = (props: RuleFormProps) => {
const { plugins, returnUrl } = props;
const { id, ruleTypeId } = useParams<{
id?: string;
ruleTypeId?: string;
}>();
const ruleFormComponent = useMemo(() => {
if (id) {
return <EditRuleForm id={id} plugins={plugins} returnUrl={returnUrl} />;
}
if (ruleTypeId) {
return <CreateRuleForm ruleTypeId={ruleTypeId} plugins={plugins} returnUrl={returnUrl} />;
}
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>
);
}, [id, ruleTypeId, plugins, returnUrl]);
return <QueryClientProvider client={queryClient}>{ruleFormComponent}</QueryClientProvider>;
};

View file

@ -0,0 +1,13 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export * from './rule_form_health_check_error';
export * from './rule_form_circuit_breaker_error';
export * from './rule_form_resolve_rule_error';
export * from './rule_form_rule_type_error';
export * from './rule_form_error_prompt_wrapper';

View file

@ -0,0 +1,32 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { RuleFormCircuitBreakerError } from './rule_form_circuit_breaker_error';
import { CIRCUIT_BREAKER_SEE_FULL_ERROR_TEXT } from '../translations';
describe('RuleFormCircuitBreakerError', () => {
test('renders correctly', () => {
render(<RuleFormCircuitBreakerError />);
expect(screen.getByText(CIRCUIT_BREAKER_SEE_FULL_ERROR_TEXT)).toBeInTheDocument();
});
test('can toggle details', () => {
render(
<RuleFormCircuitBreakerError>
<div>child component</div>
</RuleFormCircuitBreakerError>
);
fireEvent.click(screen.getByTestId('ruleFormCircuitBreakerErrorToggleButton'));
expect(screen.getByText('child component')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,51 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React, { useState, useCallback, FC } from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
import {
CIRCUIT_BREAKER_HIDE_FULL_ERROR_TEXT,
CIRCUIT_BREAKER_SEE_FULL_ERROR_TEXT,
} from '../translations';
export const RuleFormCircuitBreakerError: FC<{}> = ({ children }) => {
const [showDetails, setShowDetails] = useState(false);
const onToggleShowDetails = useCallback(() => {
setShowDetails((prev) => !prev);
}, []);
return (
<>
{showDetails && (
<>
<EuiText size="s">{children}</EuiText>
<EuiSpacer />
</>
)}
<EuiFlexGroup
justifyContent="flexEnd"
gutterSize="s"
data-test-subj="ruleFormCircuitBreakerError"
>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
color="danger"
onClick={onToggleShowDetails}
data-test-subj="ruleFormCircuitBreakerErrorToggleButton"
>
{showDetails
? CIRCUIT_BREAKER_HIDE_FULL_ERROR_TEXT
: CIRCUIT_BREAKER_SEE_FULL_ERROR_TEXT}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};

View file

@ -0,0 +1,30 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { useEuiBackgroundColorCSS, EuiPageTemplate } from '@elastic/eui';
interface RuleFormErrorPromptWrapperProps {
hasBorder?: boolean;
hasShadow?: boolean;
}
export const RuleFormErrorPromptWrapper: React.FC<RuleFormErrorPromptWrapperProps> = ({
children,
hasBorder,
hasShadow,
}) => {
const styles = useEuiBackgroundColorCSS().transparent;
return (
<EuiPageTemplate offset={0} css={styles}>
<EuiPageTemplate.EmptyPrompt paddingSize="none" hasBorder={hasBorder} hasShadow={hasShadow}>
{children}
</EuiPageTemplate.EmptyPrompt>
</EuiPageTemplate>
);
};

View file

@ -0,0 +1,116 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { render, screen } from '@testing-library/react';
import { RuleFormHealthCheckError } from './rule_form_health_check_error';
import { HealthCheckErrors, healthCheckErrors } from '../../common/apis';
import {
HEALTH_CHECK_ALERTS_ERROR_TEXT,
HEALTH_CHECK_ENCRYPTION_ERROR_TEXT,
HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TEXT,
HEALTH_CHECK_API_KEY_DISABLED_ERROR_TEXT,
HEALTH_CHECK_ALERTS_ERROR_TITLE,
HEALTH_CHECK_API_KEY_DISABLED_ERROR_TITLE,
HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TITLE,
HEALTH_CHECK_ENCRYPTION_ERROR_TITLE,
} from '../translations';
const docLinksMock = {
links: {
alerting: {
generalSettings: 'generalSettings',
setupPrerequisites: 'setupPrerequisites',
},
security: {
elasticsearchEnableApiKeys: 'elasticsearchEnableApiKeys',
},
},
} as DocLinksStart;
describe('ruleFormHealthCheckError', () => {
test('renders correctly', () => {
render(
<RuleFormHealthCheckError error={healthCheckErrors.ALERTS_ERROR} docLinks={docLinksMock} />
);
expect(screen.getByTestId('ruleFormHealthCheckError')).toBeInTheDocument();
});
test('renders alerts error', () => {
render(
<RuleFormHealthCheckError error={healthCheckErrors.ALERTS_ERROR} docLinks={docLinksMock} />
);
expect(screen.getByText(HEALTH_CHECK_ALERTS_ERROR_TITLE)).toBeInTheDocument();
expect(screen.getByText(HEALTH_CHECK_ALERTS_ERROR_TEXT)).toBeInTheDocument();
expect(screen.getByTestId('ruleFormHealthCheckErrorLink')).toHaveAttribute(
'href',
'generalSettings'
);
});
test('renders encryption error', () => {
render(
<RuleFormHealthCheckError
error={healthCheckErrors.ENCRYPTION_ERROR}
docLinks={docLinksMock}
/>
);
expect(screen.getByText(HEALTH_CHECK_ENCRYPTION_ERROR_TEXT)).toBeInTheDocument();
expect(screen.getByText(HEALTH_CHECK_ENCRYPTION_ERROR_TITLE)).toBeInTheDocument();
expect(screen.getByTestId('ruleFormHealthCheckErrorLink')).toHaveAttribute(
'href',
'generalSettings'
);
});
test('renders API keys and encryption error', () => {
render(
<RuleFormHealthCheckError
error={healthCheckErrors.API_KEYS_AND_ENCRYPTION_ERROR}
docLinks={docLinksMock}
/>
);
expect(screen.getByText(HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TEXT)).toBeInTheDocument();
expect(screen.getByText(HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TITLE)).toBeInTheDocument();
expect(screen.getByTestId('ruleFormHealthCheckErrorLink')).toHaveAttribute(
'href',
'setupPrerequisites'
);
});
test('renders API keys disabled error', () => {
render(
<RuleFormHealthCheckError
error={healthCheckErrors.API_KEYS_DISABLED_ERROR}
docLinks={docLinksMock}
/>
);
expect(screen.getByText(HEALTH_CHECK_API_KEY_DISABLED_ERROR_TEXT)).toBeInTheDocument();
expect(screen.getByText(HEALTH_CHECK_API_KEY_DISABLED_ERROR_TITLE)).toBeInTheDocument();
expect(screen.getByTestId('ruleFormHealthCheckErrorLink')).toHaveAttribute(
'href',
'elasticsearchEnableApiKeys'
);
});
test('should not render if unknown error is passed in', () => {
render(
<RuleFormHealthCheckError
error={'unknown error' as HealthCheckErrors}
docLinks={docLinksMock}
/>
);
expect(screen.queryByTestId('ruleFormHealthCheckError')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,96 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React, { useMemo } from 'react';
import { EuiEmptyPrompt, EuiLink, EuiText } from '@elastic/eui';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { HealthCheckErrors, healthCheckErrors } from '../../common/apis';
import {
HEALTH_CHECK_ALERTS_ERROR_TITLE,
HEALTH_CHECK_ALERTS_ERROR_TEXT,
HEALTH_CHECK_ENCRYPTION_ERROR_TITLE,
HEALTH_CHECK_ENCRYPTION_ERROR_TEXT,
HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TITLE,
HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TEXT,
HEALTH_CHECK_API_KEY_DISABLED_ERROR_TITLE,
HEALTH_CHECK_API_KEY_DISABLED_ERROR_TEXT,
HEALTH_CHECK_ACTION_TEXT,
} from '../translations';
export interface RuleFormHealthCheckErrorProps {
error: HealthCheckErrors;
docLinks: DocLinksStart;
}
export const RuleFormHealthCheckError = (props: RuleFormHealthCheckErrorProps) => {
const { error, docLinks } = props;
const errorState = useMemo(() => {
if (error === healthCheckErrors.ALERTS_ERROR) {
return {
errorTitle: HEALTH_CHECK_ALERTS_ERROR_TITLE,
errorBodyText: HEALTH_CHECK_ALERTS_ERROR_TEXT,
errorDocLink: docLinks.links.alerting.generalSettings,
};
}
if (error === healthCheckErrors.ENCRYPTION_ERROR) {
return {
errorTitle: HEALTH_CHECK_ENCRYPTION_ERROR_TITLE,
errorBodyText: HEALTH_CHECK_ENCRYPTION_ERROR_TEXT,
errorDocLink: docLinks.links.alerting.generalSettings,
};
}
if (error === healthCheckErrors.API_KEYS_AND_ENCRYPTION_ERROR) {
return {
errorTitle: HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TITLE,
errorBodyText: HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TEXT,
errorDocLink: docLinks.links.alerting.setupPrerequisites,
};
}
if (error === healthCheckErrors.API_KEYS_DISABLED_ERROR) {
return {
errorTitle: HEALTH_CHECK_API_KEY_DISABLED_ERROR_TITLE,
errorBodyText: HEALTH_CHECK_API_KEY_DISABLED_ERROR_TEXT,
errorDocLink: docLinks.links.security.elasticsearchEnableApiKeys,
};
}
}, [error, docLinks]);
if (!errorState) {
return null;
}
return (
<EuiEmptyPrompt
data-test-subj="ruleFormHealthCheckError"
iconType="watchesApp"
titleSize="xs"
title={
<EuiText color="default">
<h2>{errorState.errorTitle}</h2>
</EuiText>
}
body={
<div>
<p role="banner">
{errorState.errorBodyText}&nbsp;
<EuiLink
data-test-subj="ruleFormHealthCheckErrorLink"
external
href={errorState.errorDocLink}
target="_blank"
>
{HEALTH_CHECK_ACTION_TEXT}
</EuiLink>
</p>
</div>
}
/>
);
};

View file

@ -0,0 +1,33 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiEmptyPrompt, EuiText } from '@elastic/eui';
import {
RULE_FORM_RULE_NOT_FOUND_ERROR_TITLE,
RULE_FORM_RULE_NOT_FOUND_ERROR_TEXT,
} from '../translations';
export const RuleFormResolveRuleError = () => {
return (
<EuiEmptyPrompt
iconType="error"
color="danger"
title={
<EuiText color="default">
<h2>{RULE_FORM_RULE_NOT_FOUND_ERROR_TITLE}</h2>
</EuiText>
}
body={
<EuiText>
<p>{RULE_FORM_RULE_NOT_FOUND_ERROR_TEXT}</p>
</EuiText>
}
/>
);
};

View file

@ -0,0 +1,33 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiEmptyPrompt, EuiText } from '@elastic/eui';
import {
RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TITLE,
RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TEXT,
} from '../translations';
export const RuleFormRuleTypeError = () => {
return (
<EuiEmptyPrompt
iconType="error"
color="danger"
title={
<EuiText color="default">
<h2>{RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TITLE}</h2>
</EuiText>
}
body={
<EuiText>
<p>{RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TEXT}</p>
</EuiText>
}
/>
);
};

View file

@ -0,0 +1,11 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export * from './rule_form_state_context';
export * from './rule_form_state_provider';
export * from './rule_form_state_reducer';

View file

@ -0,0 +1,17 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { createContext } from 'react';
import type { RuleFormState } from '../types';
import type { RuleFormStateReducerAction } from './rule_form_state_reducer';
export const RuleFormStateContext = createContext<RuleFormState>({} as RuleFormState);
export const RuleFormReducerContext = createContext<React.Dispatch<RuleFormStateReducerAction>>(
() => {}
);

View file

@ -0,0 +1,43 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React, { useReducer } from 'react';
import { RuleFormState } from '../types';
import { RuleFormStateContext, RuleFormReducerContext } from './rule_form_state_context';
import { ruleFormStateReducer } from './rule_form_state_reducer';
import { validateRuleBase, validateRuleParams } from '../validation';
export interface RuleFormStateProviderProps {
initialRuleFormState: RuleFormState;
}
export const RuleFormStateProvider: React.FC<RuleFormStateProviderProps> = (props) => {
const { children, initialRuleFormState } = props;
const {
formData,
selectedRuleTypeModel: ruleTypeModel,
minimumScheduleInterval,
} = initialRuleFormState;
const [ruleFormState, dispatch] = useReducer(ruleFormStateReducer, {
...initialRuleFormState,
baseErrors: validateRuleBase({
formData,
minimumScheduleInterval,
}),
paramsErrors: validateRuleParams({
formData,
ruleTypeModel,
}),
});
return (
<RuleFormStateContext.Provider value={ruleFormState}>
<RuleFormReducerContext.Provider value={dispatch}>{children}</RuleFormReducerContext.Provider>
</RuleFormStateContext.Provider>
);
};

View file

@ -0,0 +1,347 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React, { useReducer } from 'react';
import { act, renderHook } from '@testing-library/react-hooks/dom';
import { ruleFormStateReducer } from './rule_form_state_reducer';
import { RuleFormState } from '../types';
jest.mock('../validation/validate_form', () => ({
validateRuleBase: jest.fn(),
validateRuleParams: jest.fn(),
}));
const { validateRuleBase, validateRuleParams } = jest.requireMock('../validation/validate_form');
validateRuleBase.mockReturnValue({});
validateRuleParams.mockReturnValue({});
const indexThresholdRuleType = {
enabledInLicense: true,
recoveryActionGroup: {
id: 'recovered',
name: 'Recovered',
},
actionGroups: [],
defaultActionGroupId: 'threshold met',
minimumLicenseRequired: 'basic',
authorizedConsumers: {
stackAlerts: {
read: true,
all: true,
},
},
ruleTaskTimeout: '5m',
doesSetRecoveryContext: true,
hasAlertsMappings: true,
hasFieldsForAAD: false,
id: '.index-threshold',
name: 'Index threshold',
category: 'management',
producer: 'stackAlerts',
alerts: {},
is_exportable: true,
} as unknown as RuleFormState['selectedRuleType'];
const indexThresholdRuleTypeModel = {
id: '.index-threshold',
description: 'Alert when an aggregated query meets the threshold.',
iconClass: 'alert',
ruleParamsExpression: () => <div />,
defaultActionMessage:
'Rule {{rule.name}} is active for group {{context.group}}:\n\n- Value: {{context.value}}\n- Conditions Met: {{context.conditions}} over {{rule.params.timeWindowSize}}{{rule.params.timeWindowUnit}}\n- Timestamp: {{context.date}}',
requiresAppContext: false,
} as unknown as RuleFormState['selectedRuleTypeModel'];
const initialState: RuleFormState = {
formData: {
name: 'test-rule',
tags: [],
params: {
paramsValue: 'value-1',
},
schedule: { interval: '5m' },
consumer: 'stackAlerts',
notifyWhen: 'onActionGroupChange',
},
plugins: {} as unknown as RuleFormState['plugins'],
selectedRuleType: indexThresholdRuleType,
selectedRuleTypeModel: indexThresholdRuleTypeModel,
multiConsumerSelection: 'stackAlerts',
};
describe('ruleFormStateReducer', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('should initialize properly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
expect(result.current[0]).toEqual(initialState);
});
test('setRule works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
const updatedRule = {
name: 'test-rule-updated',
tags: ['tag'],
params: {
test: 'hello',
},
schedule: { interval: '2m' },
consumer: 'logs',
};
act(() => {
dispatch({
type: 'setRule',
payload: updatedRule,
});
});
expect(result.current[0].formData).toEqual(updatedRule);
expect(validateRuleBase).toHaveBeenCalled();
expect(validateRuleParams).toHaveBeenCalled();
});
test('setRuleProperty works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setRuleProperty',
payload: {
property: 'name',
value: 'test-rule-name-updated',
},
});
});
expect(result.current[0].formData).toEqual({
...initialState.formData,
name: 'test-rule-name-updated',
});
expect(validateRuleBase).toHaveBeenCalled();
expect(validateRuleParams).toHaveBeenCalled();
});
test('setName works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setName',
payload: 'test-rule-name-updated',
});
});
expect(result.current[0].formData).toEqual({
...initialState.formData,
name: 'test-rule-name-updated',
});
expect(validateRuleBase).toHaveBeenCalled();
expect(validateRuleParams).toHaveBeenCalled();
});
test('setTags works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setTags',
payload: ['tag1', 'tag2'],
});
});
expect(result.current[0].formData).toEqual({
...initialState.formData,
tags: ['tag1', 'tag2'],
});
expect(validateRuleBase).toHaveBeenCalled();
expect(validateRuleParams).toHaveBeenCalled();
});
test('setParams works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setParams',
payload: {
anotherParamsValue: 'value-2',
},
});
});
expect(result.current[0].formData).toEqual({
...initialState.formData,
params: {
anotherParamsValue: 'value-2',
},
});
expect(validateRuleBase).toHaveBeenCalled();
expect(validateRuleParams).toHaveBeenCalled();
});
test('setParamsProperty works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setParamsProperty',
payload: {
property: 'anotherParamsValue',
value: 'value-2',
},
});
});
expect(result.current[0].formData).toEqual({
...initialState.formData,
params: {
...initialState.formData.params,
anotherParamsValue: 'value-2',
},
});
expect(validateRuleBase).toHaveBeenCalled();
expect(validateRuleParams).toHaveBeenCalled();
});
test('setSchedule works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setSchedule',
payload: { interval: '10m' },
});
});
expect(result.current[0].formData).toEqual({
...initialState.formData,
schedule: { interval: '10m' },
});
expect(validateRuleBase).toHaveBeenCalled();
expect(validateRuleParams).toHaveBeenCalled();
});
test('setAlertDelay works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setAlertDelay',
payload: { active: 5 },
});
});
expect(result.current[0].formData).toEqual({
...initialState.formData,
alertDelay: { active: 5 },
});
expect(validateRuleBase).toHaveBeenCalled();
expect(validateRuleParams).toHaveBeenCalled();
});
test('setNotifyWhen works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setNotifyWhen',
payload: 'onActiveAlert',
});
});
expect(result.current[0].formData).toEqual({
...initialState.formData,
notifyWhen: 'onActiveAlert',
});
expect(validateRuleBase).toHaveBeenCalled();
expect(validateRuleParams).toHaveBeenCalled();
});
test('setConsumer works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setConsumer',
payload: 'logs',
});
});
expect(result.current[0].formData).toEqual({
...initialState.formData,
consumer: 'logs',
});
expect(validateRuleBase).toHaveBeenCalled();
expect(validateRuleParams).toHaveBeenCalled();
});
test('setMultiConsumer works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setMultiConsumer',
payload: 'logs',
});
});
expect(result.current[0].multiConsumerSelection).toEqual('logs');
expect(validateRuleBase).not.toHaveBeenCalled();
expect(validateRuleParams).not.toHaveBeenCalled();
});
test('setMetadata works correctly', () => {
const { result } = renderHook(() => useReducer(ruleFormStateReducer, initialState));
const dispatch = result.current[1];
act(() => {
dispatch({
type: 'setMetadata',
payload: {
value1: 'value1',
value2: 'value2',
},
});
});
expect(result.current[0].metadata).toEqual({
value1: 'value1',
value2: 'value2',
});
expect(validateRuleBase).not.toHaveBeenCalled();
expect(validateRuleParams).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,195 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { RuleFormData, RuleFormState } from '../types';
import { validateRuleBase, validateRuleParams } from '../validation';
export type RuleFormStateReducerAction =
| {
type: 'setRule';
payload: RuleFormData;
}
| {
type: 'setRuleProperty';
payload: {
property: string;
value: unknown;
};
}
| {
type: 'setName';
payload: RuleFormData['name'];
}
| {
type: 'setTags';
payload: RuleFormData['tags'];
}
| {
type: 'setParams';
payload: RuleFormData['params'];
}
| {
type: 'setParamsProperty';
payload: {
property: string;
value: unknown;
};
}
| {
type: 'setSchedule';
payload: RuleFormData['schedule'];
}
| {
type: 'setAlertDelay';
payload: RuleFormData['alertDelay'];
}
| {
type: 'setNotifyWhen';
payload: RuleFormData['notifyWhen'];
}
| {
type: 'setConsumer';
payload: RuleFormData['consumer'];
}
| {
type: 'setMultiConsumer';
payload: RuleFormState['multiConsumerSelection'];
}
| {
type: 'setMetadata';
payload: Record<string, unknown>;
};
const getUpdateWithValidation =
(ruleFormState: RuleFormState) =>
(updater: () => RuleFormData): RuleFormState => {
const { minimumScheduleInterval, selectedRuleTypeModel, multiConsumerSelection } =
ruleFormState;
const formData = updater();
const formDataWithMultiConsumer = {
...formData,
...(multiConsumerSelection ? { consumer: multiConsumerSelection } : {}),
};
return {
...ruleFormState,
formData,
baseErrors: validateRuleBase({
formData: formDataWithMultiConsumer,
minimumScheduleInterval,
}),
paramsErrors: validateRuleParams({
formData: formDataWithMultiConsumer,
ruleTypeModel: selectedRuleTypeModel,
}),
};
};
export const ruleFormStateReducer = (
ruleFormState: RuleFormState,
action: RuleFormStateReducerAction
): RuleFormState => {
const { formData } = ruleFormState;
const updateWithValidation = getUpdateWithValidation(ruleFormState);
switch (action.type) {
case 'setRule': {
const { payload } = action;
return updateWithValidation(() => payload);
}
case 'setRuleProperty': {
const {
payload: { property, value },
} = action;
return updateWithValidation(() => ({
...ruleFormState.formData,
[property]: value,
}));
}
case 'setName': {
const { payload } = action;
return updateWithValidation(() => ({
...formData,
name: payload,
}));
}
case 'setTags': {
const { payload } = action;
return updateWithValidation(() => ({
...formData,
tags: payload,
}));
}
case 'setParams': {
const { payload } = action;
return updateWithValidation(() => ({
...formData,
params: payload,
}));
}
case 'setParamsProperty': {
const {
payload: { property, value },
} = action;
return updateWithValidation(() => ({
...formData,
params: {
...formData.params,
[property]: value,
},
}));
}
case 'setSchedule': {
const { payload } = action;
return updateWithValidation(() => ({
...formData,
schedule: payload,
}));
}
case 'setAlertDelay': {
const { payload } = action;
return updateWithValidation(() => ({
...formData,
alertDelay: payload,
}));
}
case 'setNotifyWhen': {
const { payload } = action;
return updateWithValidation(() => ({
...formData,
notifyWhen: payload,
}));
}
case 'setConsumer': {
const { payload } = action;
return updateWithValidation(() => ({
...formData,
consumer: payload,
}));
}
case 'setMultiConsumer': {
const { payload } = action;
return {
...ruleFormState,
multiConsumerSelection: payload,
};
}
case 'setMetadata': {
const { payload } = action;
return {
...ruleFormState,
metadata: payload,
};
}
default: {
return ruleFormState;
}
}
};

View file

@ -0,0 +1,12 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export * from './rule_page';
export * from './rule_page_name_input';
export * from './rule_page_footer';
export * from './rule_page_confirm_create_rule';

View file

@ -0,0 +1,115 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { RulePage } from './rule_page';
import {
RULE_FORM_PAGE_RULE_DEFINITION_TITLE,
RULE_FORM_PAGE_RULE_ACTIONS_TITLE,
RULE_FORM_PAGE_RULE_DETAILS_TITLE,
} from '../translations';
import { RuleFormData } from '../types';
jest.mock('../rule_definition', () => ({
RuleDefinition: () => <div />,
}));
jest.mock('../rule_actions', () => ({
RuleActions: () => <div />,
}));
jest.mock('../rule_details', () => ({
RuleDetails: () => <div />,
}));
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));
const { useRuleFormState } = jest.requireMock('../hooks');
const navigateToUrl = jest.fn();
const formDataMock: RuleFormData = {
params: {
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
groupBy: 'all',
threshold: [1000],
index: ['.kibana'],
timeField: 'alert.executionStatus.lastExecutionDate',
},
consumer: 'stackAlerts',
schedule: { interval: '1m' },
tags: [],
name: 'test',
notifyWhen: 'onActionGroupChange',
alertDelay: {
active: 10,
},
};
useRuleFormState.mockReturnValue({
plugins: {
application: {
navigateToUrl,
},
},
baseErrors: {},
paramsErrors: {},
multiConsumerSelection: 'logs',
formData: formDataMock,
});
const onSave = jest.fn();
const returnUrl = 'management';
describe('rulePage', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('renders correctly', () => {
render(<RulePage returnUrl={returnUrl} onSave={onSave} />);
expect(screen.getByText(RULE_FORM_PAGE_RULE_DEFINITION_TITLE)).toBeInTheDocument();
expect(screen.getByText(RULE_FORM_PAGE_RULE_ACTIONS_TITLE)).toBeInTheDocument();
expect(screen.getByText(RULE_FORM_PAGE_RULE_DETAILS_TITLE)).toBeInTheDocument();
});
test('should call onSave when save button is pressed', () => {
render(<RulePage returnUrl={returnUrl} onSave={onSave} />);
fireEvent.click(screen.getByTestId('rulePageFooterSaveButton'));
fireEvent.click(screen.getByTestId('confirmModalConfirmButton'));
expect(onSave).toHaveBeenCalledWith({
...formDataMock,
consumer: 'logs',
});
});
test('should call onCancel when the cancel button is clicked', () => {
render(<RulePage returnUrl={returnUrl} onSave={onSave} />);
fireEvent.click(screen.getByTestId('rulePageFooterCancelButton'));
expect(navigateToUrl).toHaveBeenCalledWith('management');
});
test('should call onCancel when the return button is clicked', () => {
render(<RulePage returnUrl={returnUrl} onSave={onSave} />);
fireEvent.click(screen.getByTestId('rulePageReturnButton'));
expect(navigateToUrl).toHaveBeenCalledWith('management');
});
});

View file

@ -0,0 +1,135 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React, { useCallback, useMemo } from 'react';
import {
EuiPageTemplate,
EuiHorizontalRule,
EuiSpacer,
EuiSteps,
EuiStepsProps,
useEuiBackgroundColorCSS,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import {
RuleDefinition,
RuleActions,
RuleDetails,
RulePageNameInput,
RulePageFooter,
RuleFormData,
} from '..';
import { useRuleFormState } from '../hooks';
import {
RULE_FORM_PAGE_RULE_DEFINITION_TITLE,
RULE_FORM_PAGE_RULE_ACTIONS_TITLE,
RULE_FORM_PAGE_RULE_DETAILS_TITLE,
RULE_FORM_RETURN_TITLE,
} from '../translations';
export interface RulePageProps {
isEdit?: boolean;
isSaving?: boolean;
returnUrl: string;
onSave: (formData: RuleFormData) => void;
}
export const RulePage = (props: RulePageProps) => {
const { isEdit = false, isSaving = false, returnUrl, onSave } = props;
const {
plugins: { application },
formData,
multiConsumerSelection,
} = useRuleFormState();
const styles = useEuiBackgroundColorCSS().transparent;
const onCancel = useCallback(() => {
application.navigateToUrl(returnUrl);
}, [application, returnUrl]);
const onSaveInternal = useCallback(() => {
onSave({
...formData,
...(multiConsumerSelection ? { consumer: multiConsumerSelection } : {}),
});
}, [onSave, formData, multiConsumerSelection]);
const steps: EuiStepsProps['steps'] = useMemo(() => {
return [
{
title: RULE_FORM_PAGE_RULE_DEFINITION_TITLE,
children: <RuleDefinition />,
},
{
title: RULE_FORM_PAGE_RULE_ACTIONS_TITLE,
children: (
<>
<RuleActions onClick={() => {}} />
<EuiSpacer />
<EuiHorizontalRule margin="none" />
</>
),
},
{
title: RULE_FORM_PAGE_RULE_DETAILS_TITLE,
children: (
<>
<RuleDetails />
<EuiSpacer />
<EuiHorizontalRule margin="none" />
</>
),
},
];
}, []);
return (
<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={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>
);
};

View file

@ -0,0 +1,48 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { RulePageConfirmCreateRule } from './rule_page_confirm_create_rule';
import {
CONFIRM_RULE_SAVE_CONFIRM_BUTTON_TEXT,
CONFIRM_RULE_SAVE_CANCEL_BUTTON_TEXT,
CONFIRM_RULE_SAVE_MESSAGE_TEXT,
} from '../translations';
const onConfirmMock = jest.fn();
const onCancelMock = jest.fn();
describe('rulePageConfirmCreateRule', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('renders correctly', () => {
render(<RulePageConfirmCreateRule onConfirm={onConfirmMock} onCancel={onCancelMock} />);
expect(screen.getByTestId('rulePageConfirmCreateRule')).toBeInTheDocument();
expect(screen.getByText(CONFIRM_RULE_SAVE_CONFIRM_BUTTON_TEXT)).toBeInTheDocument();
expect(screen.getByText(CONFIRM_RULE_SAVE_CANCEL_BUTTON_TEXT)).toBeInTheDocument();
expect(screen.getByText(CONFIRM_RULE_SAVE_MESSAGE_TEXT)).toBeInTheDocument();
});
test('can confirm rule creation', () => {
render(<RulePageConfirmCreateRule onConfirm={onConfirmMock} onCancel={onCancelMock} />);
fireEvent.click(screen.getByTestId('confirmModalConfirmButton'));
expect(onConfirmMock).toHaveBeenCalled();
});
test('can cancel rule creation', () => {
render(<RulePageConfirmCreateRule onConfirm={onConfirmMock} onCancel={onCancelMock} />);
fireEvent.click(screen.getByTestId('confirmModalCancelButton'));
expect(onCancelMock).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,41 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiConfirmModal, EuiText } from '@elastic/eui';
import {
CONFIRMATION_RULE_SAVE_TITLE,
CONFIRM_RULE_SAVE_CONFIRM_BUTTON_TEXT,
CONFIRM_RULE_SAVE_CANCEL_BUTTON_TEXT,
CONFIRM_RULE_SAVE_MESSAGE_TEXT,
} from '../translations';
export interface RulePageConfirmCreateRuleProps {
onCancel: () => void;
onConfirm: () => void;
}
export const RulePageConfirmCreateRule = (props: RulePageConfirmCreateRuleProps) => {
const { onCancel, onConfirm } = props;
return (
<EuiConfirmModal
data-test-subj="rulePageConfirmCreateRule"
title={CONFIRMATION_RULE_SAVE_TITLE}
onCancel={onCancel}
onConfirm={onConfirm}
confirmButtonText={CONFIRM_RULE_SAVE_CONFIRM_BUTTON_TEXT}
cancelButtonText={CONFIRM_RULE_SAVE_CANCEL_BUTTON_TEXT}
defaultFocusedButton="confirm"
>
<EuiText>
<p>{CONFIRM_RULE_SAVE_MESSAGE_TEXT}</p>
</EuiText>
</EuiConfirmModal>
);
};

View file

@ -0,0 +1,105 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { RulePageFooter } from './rule_page_footer';
import {
RULE_PAGE_FOOTER_CANCEL_TEXT,
RULE_PAGE_FOOTER_CREATE_TEXT,
RULE_PAGE_FOOTER_SAVE_TEXT,
RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT,
} from '../translations';
jest.mock('../validation/validate_form', () => ({
hasRuleErrors: jest.fn(),
}));
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
}));
const { hasRuleErrors } = jest.requireMock('../validation/validate_form');
const { useRuleFormState } = jest.requireMock('../hooks');
const onSave = jest.fn();
const onCancel = jest.fn();
hasRuleErrors.mockReturnValue(false);
useRuleFormState.mockReturnValue({
baseErrors: {},
paramsErrors: {},
});
describe('rulePageFooter', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('renders create footer correctly', () => {
render(<RulePageFooter onSave={onSave} onCancel={onCancel} />);
expect(screen.getByText(RULE_PAGE_FOOTER_CANCEL_TEXT)).toBeInTheDocument();
expect(screen.getByText(RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT)).toBeInTheDocument();
expect(screen.getByText(RULE_PAGE_FOOTER_CREATE_TEXT)).toBeInTheDocument();
});
test('renders edit footer correctly', () => {
render(<RulePageFooter isEdit onSave={onSave} onCancel={onCancel} />);
expect(screen.getByText(RULE_PAGE_FOOTER_CANCEL_TEXT)).toBeInTheDocument();
expect(screen.getByText(RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT)).toBeInTheDocument();
expect(screen.getByText(RULE_PAGE_FOOTER_SAVE_TEXT)).toBeInTheDocument();
});
test('should open show request modal when the button is clicked', () => {
render(<RulePageFooter onSave={onSave} onCancel={onCancel} />);
fireEvent.click(screen.getByTestId('rulePageFooterShowRequestButton'));
expect(screen.getByTestId('rulePageShowRequestModal')).toBeInTheDocument();
});
test('should show create rule confirmation', () => {
render(<RulePageFooter onSave={onSave} onCancel={onCancel} />);
fireEvent.click(screen.getByTestId('rulePageFooterSaveButton'));
expect(screen.getByTestId('rulePageConfirmCreateRule')).toBeInTheDocument();
});
test('should show call onSave if clicking rule confirmation', () => {
render(<RulePageFooter onSave={onSave} onCancel={onCancel} />);
fireEvent.click(screen.getByTestId('rulePageFooterSaveButton'));
fireEvent.click(screen.getByTestId('confirmModalConfirmButton'));
expect(onSave).toHaveBeenCalled();
});
test('should cancel when the cancel button is clicked', () => {
render(<RulePageFooter onSave={onSave} onCancel={onCancel} />);
fireEvent.click(screen.getByTestId('rulePageFooterCancelButton'));
expect(onCancel).toHaveBeenCalled();
});
test('should disable buttons when saving', () => {
render(<RulePageFooter isSaving onSave={onSave} onCancel={onCancel} />);
expect(screen.getByTestId('rulePageFooterCancelButton')).toBeDisabled();
expect(screen.getByTestId('rulePageFooterShowRequestButton')).toBeDisabled();
expect(screen.getByTestId('rulePageFooterSaveButton')).toBeDisabled();
});
test('should disable save and show request buttons when there is an error', () => {
hasRuleErrors.mockReturnValue(true);
render(<RulePageFooter onSave={onSave} onCancel={onCancel} />);
expect(screen.getByTestId('rulePageFooterShowRequestButton')).toBeDisabled();
expect(screen.getByTestId('rulePageFooterSaveButton')).toBeDisabled();
expect(screen.getByTestId('rulePageFooterCancelButton')).not.toBeDisabled();
});
});

View file

@ -0,0 +1,126 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 or the Server
* Side Public License, v 1.
*/
import React, { useCallback, useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty } from '@elastic/eui';
import {
RULE_PAGE_FOOTER_CANCEL_TEXT,
RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT,
RULE_PAGE_FOOTER_CREATE_TEXT,
RULE_PAGE_FOOTER_SAVE_TEXT,
} from '../translations';
import { useRuleFormState } from '../hooks';
import { hasRuleErrors } from '../validation';
import { RulePageShowRequestModal } from './rule_page_show_request_modal';
import { RulePageConfirmCreateRule } from './rule_page_confirm_create_rule';
export interface RulePageFooterProps {
isEdit?: boolean;
isSaving?: boolean;
onCancel: () => void;
onSave: () => void;
}
export const RulePageFooter = (props: RulePageFooterProps) => {
const [showRequestModal, setShowRequestModal] = useState<boolean>(false);
const [showCreateConfirmation, setShowCreateConfirmation] = useState<boolean>(false);
const { isEdit = false, isSaving = false, onCancel, onSave } = props;
const { baseErrors, paramsErrors } = useRuleFormState();
const hasErrors = useMemo(() => {
return hasRuleErrors({
baseErrors: baseErrors || {},
paramsErrors: paramsErrors || {},
});
}, [baseErrors, paramsErrors]);
const saveButtonText = useMemo(() => {
if (isEdit) {
return RULE_PAGE_FOOTER_SAVE_TEXT;
}
return RULE_PAGE_FOOTER_CREATE_TEXT;
}, [isEdit]);
const onOpenShowRequestModalClick = useCallback(() => {
setShowRequestModal(true);
}, []);
const onCloseShowRequestModalClick = useCallback(() => {
setShowRequestModal(false);
}, []);
const onSaveClick = useCallback(() => {
if (isEdit) {
onSave();
} else {
setShowCreateConfirmation(true);
}
}, [isEdit, onSave]);
const onCreateConfirmClick = useCallback(() => {
setShowCreateConfirmation(false);
onSave();
}, [onSave]);
const onCreateCancelClick = useCallback(() => {
setShowCreateConfirmation(false);
}, []);
return (
<>
<EuiFlexGroup data-test-subj="rulePageFooter" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="rulePageFooterCancelButton"
onClick={onCancel}
disabled={isSaving}
isLoading={isSaving}
>
{RULE_PAGE_FOOTER_CANCEL_TEXT}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="rulePageFooterShowRequestButton"
onClick={onOpenShowRequestModalClick}
disabled={isSaving || hasErrors}
isLoading={isSaving}
>
{RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
data-test-subj="rulePageFooterSaveButton"
onClick={onSaveClick}
disabled={isSaving || hasErrors}
isLoading={isSaving}
>
{saveButtonText}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{showRequestModal && (
<RulePageShowRequestModal onClose={onCloseShowRequestModalClick} isEdit={isEdit} />
)}
{showCreateConfirmation && (
<RulePageConfirmCreateRule
onConfirm={onCreateConfirmClick}
onCancel={onCreateCancelClick}
/>
)}
</>
);
};

View file

@ -0,0 +1,75 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { RulePageNameInput } from './rule_page_name_input';
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
useRuleFormDispatch: jest.fn(),
}));
const { useRuleFormState, useRuleFormDispatch } = jest.requireMock('../hooks');
const dispatch = jest.fn();
useRuleFormState.mockReturnValue({
formData: {
name: 'test-name',
},
});
useRuleFormDispatch.mockReturnValue(dispatch);
describe('rulePageNameInput', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('renders correctly', () => {
render(<RulePageNameInput />);
expect(screen.getByText('test-name')).toBeInTheDocument();
});
test('should become an input if the edit button is pressed', () => {
render(<RulePageNameInput />);
fireEvent.click(screen.getByTestId('rulePageNameInputButton'));
fireEvent.change(screen.getByTestId('rulePageNameInputField'), {
target: {
value: 'hello',
},
});
expect(dispatch).toHaveBeenLastCalledWith({
type: 'setName',
payload: 'hello',
});
});
test('should be invalid if there is an error', () => {
useRuleFormState.mockReturnValue({
formData: {
name: '',
},
baseErrors: {
name: ['Invalid name'],
},
});
render(<RulePageNameInput />);
fireEvent.click(screen.getByTestId('rulePageNameInputButton'));
expect(screen.getByTestId('rulePageNameInputField')).toBeInvalid();
expect(screen.getByText('Invalid name')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,143 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
EuiTitle,
EuiFieldText,
EuiButtonEmpty,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
useEuiTheme,
EuiFormRow,
} from '@elastic/eui';
import {
RULE_NAME_ARIA_LABEL_TEXT,
RULE_NAME_INPUT_TITLE,
RULE_NAME_INPUT_BUTTON_ARIA_LABEL,
} from '../translations';
import { useRuleFormState, useRuleFormDispatch } from '../hooks';
export const RulePageNameInput = () => {
const [isEditing, setIsEditing] = useState<boolean>(false);
const { formData, baseErrors } = useRuleFormState();
const { name } = formData;
const dispatch = useRuleFormDispatch();
const { euiTheme } = useEuiTheme();
const isNameInvalid = useMemo(() => {
return !!baseErrors?.name?.length;
}, [baseErrors]);
const inputStyles: React.CSSProperties = useMemo(() => {
return {
fontSize: 'inherit',
fontWeight: 'inherit',
lineHeight: 'inherit',
padding: 'inherit',
boxShadow: 'none',
backgroundColor: euiTheme.colors.lightestShade,
};
}, [euiTheme]);
const buttonStyles: React.CSSProperties = useMemo(() => {
return {
padding: 'inherit',
};
}, []);
const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({
type: 'setName',
payload: e.target.value,
});
},
[dispatch]
);
const onEdit = useCallback(() => {
setIsEditing(true);
}, []);
const onCancelEdit = useCallback(() => {
if (isNameInvalid) {
return;
}
setIsEditing(false);
}, [isNameInvalid]);
const onkeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (isNameInvalid) {
return;
}
if (e.key === 'Enter' || e.key === 'Escape') {
setIsEditing(false);
}
},
[isNameInvalid]
);
if (isEditing) {
return (
<EuiFlexGroup data-test-subj="rulePageNameInput" gutterSize="s" responsive={false}>
<EuiFlexItem className="eui-fullWidth">
<EuiFormRow fullWidth isInvalid={isNameInvalid} error={baseErrors?.name}>
<EuiTitle size="l">
<h1>
<EuiFieldText
autoFocus
fullWidth
data-test-subj="rulePageNameInputField"
placeholder={RULE_NAME_INPUT_TITLE}
style={inputStyles}
value={name}
isInvalid={isNameInvalid}
onChange={onInputChange}
onBlur={onCancelEdit}
onKeyDown={onkeyDown}
/>
</h1>
</EuiTitle>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="success"
iconType="check"
size="m"
onClick={onCancelEdit}
aria-label={RULE_NAME_INPUT_BUTTON_ARIA_LABEL}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiButtonEmpty
iconSide="right"
iconType="pencil"
color="text"
style={buttonStyles}
onClick={onEdit}
data-test-subj="rulePageNameInputButton"
aria-label={RULE_NAME_ARIA_LABEL_TEXT}
>
<EuiTitle size="l" className="eui-textTruncate">
<h1>{name}</h1>
</EuiTitle>
</EuiButtonEmpty>
);
};

View file

@ -0,0 +1,156 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { RulePageShowRequestModal } from './rule_page_show_request_modal';
import { RuleFormData } from '../types';
jest.mock('../hooks', () => ({
useRuleFormState: jest.fn(),
}));
const { useRuleFormState } = jest.requireMock('../hooks');
const formData: RuleFormData = {
params: {
searchType: 'esQuery',
timeWindowSize: 5,
timeWindowUnit: 'm',
threshold: [1000],
thresholdComparator: '>',
size: 100,
esQuery: '{\n "query":{\n "match_all" : {}\n }\n }',
aggType: 'count',
groupBy: 'all',
termSize: 5,
excludeHitsFromPreviousRun: false,
sourceFields: [],
index: ['.kibana'],
timeField: 'created_at',
},
consumer: 'stackAlerts',
ruleTypeId: '.es-query',
schedule: { interval: '1m' },
tags: ['test'],
name: 'test',
};
const onCloseMock = jest.fn();
describe('rulePageShowRequestModal', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('renders create request correctly', async () => {
useRuleFormState.mockReturnValue({ formData, multiConsumerSelection: 'logs' });
render(<RulePageShowRequestModal onClose={onCloseMock} />);
expect(screen.getByTestId('modalHeaderTitle').textContent).toBe('Create alerting rule request');
expect(screen.getByTestId('modalSubtitle').textContent).toBe(
'This Kibana request will create this rule.'
);
expect(screen.getByTestId('modalRequestCodeBlock').textContent).toMatchInlineSnapshot(`
"POST kbn:/api/alerting/rule
{
\\"params\\": {
\\"searchType\\": \\"esQuery\\",
\\"timeWindowSize\\": 5,
\\"timeWindowUnit\\": \\"m\\",
\\"threshold\\": [
1000
],
\\"thresholdComparator\\": \\">\\",
\\"size\\": 100,
\\"esQuery\\": \\"{\\\\n \\\\\\"query\\\\\\":{\\\\n \\\\\\"match_all\\\\\\" : {}\\\\n }\\\\n }\\",
\\"aggType\\": \\"count\\",
\\"groupBy\\": \\"all\\",
\\"termSize\\": 5,
\\"excludeHitsFromPreviousRun\\": false,
\\"sourceFields\\": [],
\\"index\\": [
\\".kibana\\"
],
\\"timeField\\": \\"created_at\\"
},
\\"consumer\\": \\"logs\\",
\\"schedule\\": {
\\"interval\\": \\"1m\\"
},
\\"tags\\": [
\\"test\\"
],
\\"name\\": \\"test\\",
\\"rule_type_id\\": \\".es-query\\",
\\"actions\\": []
}"
`);
});
test('renders edit request correctly', async () => {
useRuleFormState.mockReturnValue({
formData,
multiConsumerSelection: 'logs',
id: 'test-id',
});
render(<RulePageShowRequestModal isEdit onClose={onCloseMock} />);
expect(screen.getByTestId('modalHeaderTitle').textContent).toBe('Edit alerting rule request');
expect(screen.getByTestId('modalSubtitle').textContent).toBe(
'This Kibana request will edit this rule.'
);
expect(screen.getByTestId('modalRequestCodeBlock').textContent).toMatchInlineSnapshot(`
"PUT kbn:/api/alerting/rule/test-id
{
\\"name\\": \\"test\\",
\\"tags\\": [
\\"test\\"
],
\\"schedule\\": {
\\"interval\\": \\"1m\\"
},
\\"params\\": {
\\"searchType\\": \\"esQuery\\",
\\"timeWindowSize\\": 5,
\\"timeWindowUnit\\": \\"m\\",
\\"threshold\\": [
1000
],
\\"thresholdComparator\\": \\">\\",
\\"size\\": 100,
\\"esQuery\\": \\"{\\\\n \\\\\\"query\\\\\\":{\\\\n \\\\\\"match_all\\\\\\" : {}\\\\n }\\\\n }\\",
\\"aggType\\": \\"count\\",
\\"groupBy\\": \\"all\\",
\\"termSize\\": 5,
\\"excludeHitsFromPreviousRun\\": false,
\\"sourceFields\\": [],
\\"index\\": [
\\".kibana\\"
],
\\"timeField\\": \\"created_at\\"
},
\\"actions\\": []
}"
`);
});
test('can close modal', () => {
useRuleFormState.mockReturnValue({
formData,
multiConsumerSelection: 'logs',
id: 'test-id',
});
render(<RulePageShowRequestModal isEdit onClose={onCloseMock} />);
fireEvent.click(screen.getByLabelText('Closes this modal window'));
expect(onCloseMock).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,151 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React, { useMemo } from 'react';
import { pick, omit } from 'lodash';
import { i18n } from '@kbn/i18n';
import {
EuiModal,
EuiModalHeader,
EuiModalHeaderTitle,
EuiModalBody,
EuiCodeBlock,
EuiText,
EuiTextColor,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { BASE_ALERTING_API_PATH } from '../../common/constants';
import { RuleFormData } from '../types';
import {
CreateRuleBody,
UPDATE_FIELDS,
UpdateRuleBody,
transformCreateRuleBody,
transformUpdateRuleBody,
} from '../../common/apis';
import { useRuleFormState } from '../hooks';
const stringifyBodyRequest = ({
formData,
isEdit,
}: {
formData: RuleFormData;
isEdit: boolean;
}): string => {
try {
const request = isEdit
? transformUpdateRuleBody(pick(formData, UPDATE_FIELDS) as UpdateRuleBody)
: transformCreateRuleBody(omit(formData, 'id') as CreateRuleBody);
return JSON.stringify(request, null, 2);
} catch {
return SHOW_REQUEST_MODAL_ERROR;
}
};
export interface RulePageShowRequestModalProps {
onClose: () => void;
isEdit?: boolean;
}
export const RulePageShowRequestModal = (props: RulePageShowRequestModalProps) => {
const { onClose, isEdit = false } = props;
const { formData, id, multiConsumerSelection } = useRuleFormState();
const formattedRequest = useMemo(() => {
return stringifyBodyRequest({
formData: {
...formData,
...(multiConsumerSelection ? { consumer: multiConsumerSelection } : {}),
},
isEdit,
});
}, [formData, isEdit, multiConsumerSelection]);
return (
<EuiModal
data-test-subj="rulePageShowRequestModal"
aria-labelledby="showRequestModal"
onClose={onClose}
>
<EuiModalHeader>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiModalHeaderTitle id="showRequestModal" data-test-subj="modalHeaderTitle">
{SHOW_REQUEST_MODAL_TITLE(isEdit)}
</EuiModalHeaderTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText data-test-subj="modalSubtitle">
<p>
<EuiTextColor color="subdued">{SHOW_REQUEST_MODAL_SUBTITLE(isEdit)}</EuiTextColor>
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalHeader>
<EuiModalBody>
<EuiCodeBlock language="json" isCopyable data-test-subj="modalRequestCodeBlock">
{`${isEdit ? 'PUT' : 'POST'} kbn:${BASE_ALERTING_API_PATH}/rule${
isEdit ? `/${id}` : ''
}\n${formattedRequest}`}
</EuiCodeBlock>
</EuiModalBody>
</EuiModal>
);
};
const SHOW_REQUEST_MODAL_EDIT = i18n.translate(
'alertsUIShared.ruleForm.showRequestModal.subheadingTitleEdit',
{
defaultMessage: 'edit',
}
);
const SHOW_REQUEST_MODAL_CREATE = i18n.translate(
'alertsUIShared.ruleForm.showRequestModal.subheadingTitleCreate',
{
defaultMessage: 'create',
}
);
const SHOW_REQUEST_MODAL_SUBTITLE = (edit: boolean) =>
i18n.translate('alertsUIShared.ruleForm.showRequestModal.subheadingTitle', {
defaultMessage: 'This Kibana request will {requestType} this rule.',
values: { requestType: edit ? SHOW_REQUEST_MODAL_EDIT : SHOW_REQUEST_MODAL_CREATE },
});
const SHOW_REQUEST_MODAL_TITLE_EDIT = i18n.translate(
'alertsUIShared.ruleForm.showRequestModal.headerTitleEdit',
{
defaultMessage: 'Edit',
}
);
const SHOW_REQUEST_MODAL_TITLE_CREATE = i18n.translate(
'alertsUIShared.ruleForm.showRequestModal.headerTitleCreate',
{
defaultMessage: 'Create',
}
);
const SHOW_REQUEST_MODAL_TITLE = (edit: boolean) =>
i18n.translate('alertsUIShared.ruleForm.showRequestModal.headerTitle', {
defaultMessage: '{requestType} alerting rule request',
values: {
requestType: edit ? SHOW_REQUEST_MODAL_TITLE_EDIT : SHOW_REQUEST_MODAL_TITLE_CREATE,
},
});
const SHOW_REQUEST_MODAL_ERROR = i18n.translate(
'alertsUIShared.ruleForm.showRequestModal.somethingWentWrongDescription',
{
defaultMessage: 'Sorry about that, something went wrong.',
}
);

View file

@ -217,9 +217,253 @@ export const RULE_NAME_INPUT_TITLE = i18n.translate(
}
);
export const RULE_NAME_INPUT_BUTTON_ARIA_LABEL = i18n.translate(
'alertsUIShared.ruleForm.ruleDetails.ruleNameInputButtonAriaLabel',
{
defaultMessage: 'Save rule name',
}
);
export const RULE_TAG_INPUT_TITLE = i18n.translate(
'alertsUIShared.ruleForm.ruleDetails.ruleTagsInputTitle',
{
defaultMessage: 'Tags',
}
);
export const RULE_TAG_PLACEHOLDER = i18n.translate(
'alertsUIShared.ruleForm.ruleDetails.ruleTagsPlaceholder',
{
defaultMessage: 'Add tags',
}
);
export const RULE_NAME_ARIA_LABEL_TEXT = i18n.translate(
'alertsUIShared.ruleForm.rulePage.ruleNameAriaLabelText',
{
defaultMessage: 'Edit rule name',
}
);
export const RULE_PAGE_FOOTER_CANCEL_TEXT = i18n.translate(
'alertsUIShared.ruleForm.rulePageFooter.cancelText',
{
defaultMessage: 'Cancel',
}
);
export const RULE_PAGE_FOOTER_SHOW_REQUEST_TEXT = i18n.translate(
'alertsUIShared.ruleForm.rulePageFooter.showRequestText',
{
defaultMessage: 'Show request',
}
);
export const RULE_PAGE_FOOTER_CREATE_TEXT = i18n.translate(
'alertsUIShared.ruleForm.rulePageFooter.createText',
{
defaultMessage: 'Create rule',
}
);
export const RULE_PAGE_FOOTER_SAVE_TEXT = i18n.translate(
'alertsUIShared.ruleForm.rulePageFooter.saveText',
{
defaultMessage: 'Save rule',
}
);
export const HEALTH_CHECK_ALERTS_ERROR_TITLE = i18n.translate(
'alertsUIShared.healthCheck.alertsErrorTitle',
{
defaultMessage: 'You must enable Alerting and Actions',
}
);
export const HEALTH_CHECK_ALERTS_ERROR_TEXT = i18n.translate(
'alertsUIShared.healthCheck.alertsErrorText',
{
defaultMessage: 'To create a rule, you must enable the alerting and actions plugins.',
}
);
export const HEALTH_CHECK_ENCRYPTION_ERROR_TITLE = i18n.translate(
'alertsUIShared.healthCheck.encryptionErrorTitle',
{
defaultMessage: 'Additional setup required',
}
);
export const HEALTH_CHECK_ENCRYPTION_ERROR_TEXT = i18n.translate(
'alertsUIShared.healthCheck.encryptionErrorText',
{
defaultMessage: 'You must configure an encryption key to use Alerting.',
}
);
export const HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TITLE = i18n.translate(
'alertsUIShared.healthCheck.healthCheck.apiKeysAndEncryptionErrorTitle',
{
defaultMessage: 'Additional setup required',
}
);
export const HEALTH_CHECK_API_KEY_ENCRYPTION_ERROR_TEXT = i18n.translate(
'alertsUIShared.healthCheck.apiKeysAndEncryptionErrorText',
{
defaultMessage: 'You must enable API keys and configure an encryption key to use Alerting.',
}
);
export const HEALTH_CHECK_API_KEY_DISABLED_ERROR_TITLE = i18n.translate(
'alertsUIShared.healthCheck.apiKeysDisabledErrorTitle',
{
defaultMessage: 'Additional setup required',
}
);
export const HEALTH_CHECK_API_KEY_DISABLED_ERROR_TEXT = i18n.translate(
'alertsUIShared.healthCheck.apiKeysDisabledErrorText',
{
defaultMessage: 'You must enable API keys to use Alerting.',
}
);
export const HEALTH_CHECK_ACTION_TEXT = i18n.translate('alertsUIShared.healthCheck.actionText', {
defaultMessage: 'Learn more.',
});
export const RULE_FORM_ROUTE_PARAMS_ERROR_TITLE = i18n.translate(
'alertsUIShared.ruleForm.routeParamsErrorTitle',
{
defaultMessage: 'Unable to load rule form.',
}
);
export const RULE_FORM_ROUTE_PARAMS_ERROR_TEXT = i18n.translate(
'alertsUIShared.ruleForm.routeParamsErrorText',
{
defaultMessage: 'There was an error loading the rule form. Please ensure the route is correct.',
}
);
export const RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TITLE = i18n.translate(
'alertsUIShared.ruleForm.ruleTypeNotFoundErrorTitle',
{
defaultMessage: 'Unable to load rule type.',
}
);
export const RULE_FORM_RULE_NOT_FOUND_ERROR_TITLE = i18n.translate(
'alertsUIShared.ruleForm.ruleNotFoundErrorTitle',
{
defaultMessage: 'Unable to load rule',
}
);
export const RULE_FORM_RULE_TYPE_NOT_FOUND_ERROR_TEXT = i18n.translate(
'alertsUIShared.ruleForm.ruleTypeNotFoundErrorText',
{
defaultMessage:
'There was an error loading the rule type. Please ensure you have access to the rule type selected.',
}
);
export const RULE_FORM_RULE_NOT_FOUND_ERROR_TEXT = i18n.translate(
'alertsUIShared.ruleForm.ruleNotFoundErrorText',
{
defaultMessage:
'There was an error loading the rule. Please ensure you have access to the rule selected.',
}
);
export const RULE_CREATE_SUCCESS_TEXT = (ruleName: string) =>
i18n.translate('alertsUIShared.ruleForm.createSuccessText', {
defaultMessage: 'Created rule "{ruleName}"',
values: {
ruleName,
},
});
export const RULE_CREATE_ERROR_TEXT = i18n.translate('alertsUIShared.ruleForm.createErrorText', {
defaultMessage: 'Cannot create rule.',
});
export const RULE_EDIT_ERROR_TEXT = i18n.translate('alertsUIShared.ruleForm.editErrorText', {
defaultMessage: 'Cannot update rule.',
});
export const RULE_EDIT_SUCCESS_TEXT = (ruleName: string) =>
i18n.translate('alertsUIShared.ruleForm.editSuccessText', {
defaultMessage: 'Updated "{ruleName}"',
values: {
ruleName,
},
});
export const CIRCUIT_BREAKER_SEE_FULL_ERROR_TEXT = i18n.translate(
'alertsUIShared.ruleForm.circuitBreakerSeeFullErrorText',
{
defaultMessage: 'See full error',
}
);
export const CIRCUIT_BREAKER_HIDE_FULL_ERROR_TEXT = i18n.translate(
'alertsUIShared.ruleForm.circuitBreakerHideFullErrorText',
{
defaultMessage: 'Hide full error',
}
);
export const CONFIRMATION_RULE_SAVE_TITLE = i18n.translate(
'alertsUIShared.ruleForm.confirmRuleSaveTitle',
{
defaultMessage: 'Save rule with no actions?',
}
);
export const CONFIRM_RULE_SAVE_CONFIRM_BUTTON_TEXT = i18n.translate(
'alertsUIShared.ruleForm.confirmRuleSaveConfirmButtonText',
{
defaultMessage: 'Save rule',
}
);
export const CONFIRM_RULE_SAVE_CANCEL_BUTTON_TEXT = i18n.translate(
'alertsUIShared.ruleForm.confirmRuleSaveCancelButtonText',
{
defaultMessage: 'Cancel',
}
);
export const CONFIRM_RULE_SAVE_MESSAGE_TEXT = i18n.translate(
'alertsUIShared.ruleForm.confirmRuleSaveMessageText',
{
defaultMessage: 'You can add an action at anytime.',
}
);
export const RULE_FORM_PAGE_RULE_DEFINITION_TITLE = i18n.translate(
'alertsUIShared.ruleForm.ruleDefinitionTitle',
{
defaultMessage: 'Rule definition',
}
);
export const RULE_FORM_PAGE_RULE_ACTIONS_TITLE = i18n.translate(
'alertsUIShared.ruleForm.ruleActionsTitle',
{
defaultMessage: 'Actions',
}
);
export const RULE_FORM_PAGE_RULE_DETAILS_TITLE = i18n.translate(
'alertsUIShared.ruleForm.ruleDetailsTitle',
{
defaultMessage: 'Rule details',
}
);
export const RULE_FORM_RETURN_TITLE = i18n.translate('alertsUIShared.ruleForm.returnTitle', {
defaultMessage: 'Return',
});

View file

@ -6,7 +6,27 @@
* Side Public License, v 1.
*/
import { Rule, RuleTypeParams } from '../common';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import type { HttpStart } from '@kbn/core-http-browser';
import type { I18nStart } from '@kbn/core-i18n-browser';
import type { ThemeServiceStart } from '@kbn/core-theme-browser';
import type { ApplicationStart } from '@kbn/core-application-browser';
import type { NotificationsStart } from '@kbn/core-notifications-browser';
import type { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import {
MinimumScheduleInterval,
Rule,
RuleFormBaseErrors,
RuleFormParamsErrors,
RuleTypeModel,
RuleTypeParams,
RuleTypeRegistryContract,
RuleTypeWithDescription,
} from '../common/types';
export interface RuleFormData<Params extends RuleTypeParams = RuleTypeParams> {
name: Rule<Params>['name'];
@ -19,5 +39,34 @@ export interface RuleFormData<Params extends RuleTypeParams = RuleTypeParams> {
ruleTypeId?: Rule<Params>['ruleTypeId'];
}
export interface RuleFormPlugins {
http: HttpStart;
i18n: I18nStart;
theme: ThemeServiceStart;
application: ApplicationStart;
notification: NotificationsStart;
charts: ChartsPluginSetup;
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
docLinks: DocLinksStart;
ruleTypeRegistry: RuleTypeRegistryContract;
}
export interface RuleFormState<Params extends RuleTypeParams = RuleTypeParams> {
id?: string;
formData: RuleFormData<Params>;
plugins: RuleFormPlugins;
baseErrors?: RuleFormBaseErrors;
paramsErrors?: RuleFormParamsErrors;
selectedRuleType: RuleTypeWithDescription;
selectedRuleTypeModel: RuleTypeModel<Params>;
multiConsumerSelection?: RuleCreationValidConsumer | null;
metadata?: Record<string, unknown>;
minimumScheduleInterval?: MinimumScheduleInterval;
canShowConsumerSelection?: boolean;
validConsumers?: RuleCreationValidConsumer[];
}
export type InitialRule = Partial<Rule> &
Pick<Rule, 'params' | 'consumer' | 'schedule' | 'actions' | 'tags'>;

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 or the Server
* Side Public License, v 1.
*/
import { RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import { RuleTypeWithDescription } from '../../common/types';
export const getAuthorizedConsumers = ({
ruleType,
validConsumers,
}: {
ruleType: RuleTypeWithDescription;
validConsumers: RuleCreationValidConsumer[];
}) => {
if (!ruleType.authorizedConsumers) {
return [];
}
return Object.entries(ruleType.authorizedConsumers).reduce<RuleCreationValidConsumer[]>(
(result, [authorizedConsumer, privilege]) => {
if (
privilege.all &&
validConsumers.includes(authorizedConsumer as RuleCreationValidConsumer)
) {
result.push(authorizedConsumer as RuleCreationValidConsumer);
}
return result;
},
[]
);
};

View file

@ -0,0 +1,91 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import {
RuleTypeModel,
RuleTypeRegistryContract,
RuleTypeWithDescription,
} from '../../common/types';
import { ALERTING_FEATURE_ID, MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants';
export type RuleTypeItems = Array<{
ruleTypeModel: RuleTypeModel;
ruleType: RuleTypeWithDescription;
}>;
const hasAllPrivilege = (consumer: string, ruleType: RuleTypeWithDescription): boolean => {
return ruleType.authorizedConsumers[consumer]?.all ?? false;
};
const authorizedToDisplayRuleType = ({
consumer,
ruleType,
validConsumers,
}: {
consumer: string;
ruleType: RuleTypeWithDescription;
validConsumers?: RuleCreationValidConsumer[];
}) => {
if (!ruleType) {
return false;
}
// If we have a generic threshold/ES query rule...
if (MULTI_CONSUMER_RULE_TYPE_IDS.includes(ruleType.id)) {
// And an array of valid consumers are passed in, we will show it
// if the rule type has at least one of the consumers as authorized
if (Array.isArray(validConsumers)) {
return validConsumers.some((c) => hasAllPrivilege(c, ruleType));
}
// If no array was passed in, then we will show it if at least one of its
// authorized consumers allows it to be shown.
return Object.entries(ruleType.authorizedConsumers).some(([_, privilege]) => {
return privilege.all;
});
}
// For non-generic threshold/ES query rules, we will still do the check
// against `alerts` since we are still setting rule consumers to `alerts`
return hasAllPrivilege(consumer, ruleType);
};
export const getAvailableRuleTypes = ({
consumer,
ruleTypes,
ruleTypeRegistry,
validConsumers,
}: {
consumer: string;
ruleTypes: RuleTypeWithDescription[];
ruleTypeRegistry: RuleTypeRegistryContract;
validConsumers?: RuleCreationValidConsumer[];
}): RuleTypeItems => {
return ruleTypeRegistry
.list()
.reduce((arr: RuleTypeItems, ruleTypeRegistryItem: RuleTypeModel) => {
const ruleType = ruleTypes.find((item) => ruleTypeRegistryItem.id === item.id);
if (ruleType) {
arr.push({
ruleType,
ruleTypeModel: ruleTypeRegistryItem,
});
}
return arr;
}, [])
.filter(({ ruleType }) =>
authorizedToDisplayRuleType({
consumer,
ruleType,
validConsumers,
})
)
.filter((item) =>
consumer === ALERTING_FEATURE_ID
? !item.ruleTypeModel.requiresAppContext
: item.ruleType!.producer === consumer
);
};

View file

@ -1,107 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 or the Server
* Side Public License, v 1.
*/
import {
RuleTypeModel,
RuleFormErrors,
ValidationResult,
MinimumScheduleInterval,
} from '../../common';
import { parseDuration, formatDuration } from './parse_duration';
import {
NAME_REQUIRED_TEXT,
CONSUMER_REQUIRED_TEXT,
RULE_TYPE_REQUIRED_TEXT,
INTERVAL_REQUIRED_TEXT,
INTERVAL_MINIMUM_TEXT,
RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT,
} from '../translations';
import { InitialRule } from '../types';
export function validateBaseProperties({
rule,
minimumScheduleInterval,
}: {
rule: InitialRule;
minimumScheduleInterval?: MinimumScheduleInterval;
}): ValidationResult {
const validationResult = { errors: {} };
const errors = {
name: new Array<string>(),
interval: new Array<string>(),
consumer: new Array<string>(),
ruleTypeId: new Array<string>(),
actionConnectors: new Array<string>(),
alertDelay: new Array<string>(),
};
validationResult.errors = errors;
if (!rule.name) {
errors.name.push(NAME_REQUIRED_TEXT);
}
if (rule.consumer === null) {
errors.consumer.push(CONSUMER_REQUIRED_TEXT);
}
if (rule.schedule.interval.length < 2) {
errors.interval.push(INTERVAL_REQUIRED_TEXT);
} else if (minimumScheduleInterval && minimumScheduleInterval.enforce) {
const duration = parseDuration(rule.schedule.interval);
const minimumDuration = parseDuration(minimumScheduleInterval.value);
if (duration < minimumDuration) {
errors.interval.push(
INTERVAL_MINIMUM_TEXT(formatDuration(minimumScheduleInterval.value, true))
);
}
}
if (!rule.ruleTypeId) {
errors.ruleTypeId.push(RULE_TYPE_REQUIRED_TEXT);
}
if (rule.alertDelay?.active && rule.alertDelay?.active < 1) {
errors.alertDelay.push(RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT);
}
return validationResult;
}
export function getRuleErrors({
rule,
ruleTypeModel,
minimumScheduleInterval,
isServerless,
}: {
rule: InitialRule;
ruleTypeModel: RuleTypeModel | null;
minimumScheduleInterval?: MinimumScheduleInterval;
isServerless?: boolean;
}) {
const ruleParamsErrors: RuleFormErrors = ruleTypeModel
? ruleTypeModel.validate(rule.params, isServerless).errors
: {};
const ruleBaseErrors = validateBaseProperties({
rule,
minimumScheduleInterval,
}).errors as RuleFormErrors;
const ruleErrors = {
...ruleParamsErrors,
...ruleBaseErrors,
} as RuleFormErrors;
return {
ruleParamsErrors,
ruleBaseErrors,
ruleErrors,
};
}

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 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 or the Server
* Side Public License, v 1.
*/
import { RuleTypeWithDescription } from '../../common';
import { MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants';
export const getInitialConsumer = ({
consumer,
ruleType,
shouldUseRuleProducer,
}: {
consumer: string;
ruleType: RuleTypeWithDescription;
shouldUseRuleProducer: boolean;
}) => {
if (shouldUseRuleProducer && !MULTI_CONSUMER_RULE_TYPE_IDS.includes(ruleType.id)) {
return ruleType.producer;
}
return consumer;
};

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 or the Server
* Side Public License, v 1.
*/
import { AlertConsumers, RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import { RuleTypeWithDescription } from '../../common/types';
import { MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants';
import { FEATURE_NAME_MAP } from '../translations';
export const getValidatedMultiConsumer = ({
multiConsumerSelection,
validConsumers,
}: {
multiConsumerSelection?: RuleCreationValidConsumer | null;
validConsumers: RuleCreationValidConsumer[];
}) => {
if (
multiConsumerSelection &&
validConsumers.includes(multiConsumerSelection) &&
FEATURE_NAME_MAP[multiConsumerSelection]
) {
return multiConsumerSelection;
}
return null;
};
export const getInitialMultiConsumer = ({
multiConsumerSelection,
validConsumers,
ruleType,
}: {
multiConsumerSelection?: RuleCreationValidConsumer | null;
validConsumers: RuleCreationValidConsumer[];
ruleType: RuleTypeWithDescription;
}): RuleCreationValidConsumer | null => {
// If rule type doesn't support multi-consumer or no valid consumers exists,
// return nothing
if (!MULTI_CONSUMER_RULE_TYPE_IDS.includes(ruleType.id) || validConsumers.length === 0) {
return null;
}
// Use the only value in valid consumers
if (validConsumers.length === 1) {
return validConsumers[0];
}
// If o11y is in the valid consumers, just use that
if (validConsumers.includes(AlertConsumers.OBSERVABILITY)) {
return AlertConsumers.OBSERVABILITY;
}
// User passed in null explicitly, won't set initial consumer
if (multiConsumerSelection === null) {
return null;
}
const validatedConsumer = getValidatedMultiConsumer({
multiConsumerSelection,
validConsumers,
});
// If validated consumer exists and no o11y in valid consumers, just use that
if (validatedConsumer) {
return validatedConsumer;
}
// If validated consumer doesn't exist and stack alerts does, use that
if (validConsumers.includes(AlertConsumers.STACK_ALERTS)) {
return AlertConsumers.STACK_ALERTS;
}
// All else fails, just use the first valid consumer
return validConsumers[0];
};

View file

@ -0,0 +1,43 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { parseDuration } from './parse_duration';
import { DEFAULT_RULE_INTERVAL } from '../constants';
import { MinimumScheduleInterval, RuleTypeWithDescription } from '../../common/types';
import { RuleFormData } from '../types';
const getInitialInterval = (interval: string) => {
if (parseDuration(interval) > parseDuration(DEFAULT_RULE_INTERVAL)) {
return interval;
}
return DEFAULT_RULE_INTERVAL;
};
export const getInitialSchedule = ({
ruleType,
minimumScheduleInterval,
initialSchedule,
}: {
ruleType: RuleTypeWithDescription;
minimumScheduleInterval?: MinimumScheduleInterval;
initialSchedule?: RuleFormData['schedule'];
}): RuleFormData['schedule'] => {
if (initialSchedule) {
return initialSchedule;
}
if (minimumScheduleInterval?.value) {
return { interval: getInitialInterval(minimumScheduleInterval.value) };
}
if (ruleType.defaultScheduleInterval) {
return { interval: ruleType.defaultScheduleInterval };
}
return { interval: DEFAULT_RULE_INTERVAL };
};

View file

@ -6,6 +6,11 @@
* Side Public License, v 1.
*/
export * from './get_errors';
export * from './get_time_options';
export * from './parse_duration';
export * from './parse_rule_circuit_breaker_error_message';
export * from './get_authorized_rule_types';
export * from './get_authorized_consumers';
export * from './get_initial_multi_consumer';
export * from './get_initial_schedule';
export * from './get_initial_consumer';

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
export const DEFAULT_RULE_INTERVAL = '1m';
import { DEFAULT_RULE_INTERVAL } from '../constants';
const SECONDS_REGEX = /^[1-9][0-9]*s$/;
const MINUTES_REGEX = /^[1-9][0-9]*m$/;

View file

@ -0,0 +1,27 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { errorMessageHeader } from '@kbn/alerting-types';
export const parseRuleCircuitBreakerErrorMessage = (
message: string
): {
summary: string;
details?: string;
} => {
if (!message.includes(errorMessageHeader)) {
return {
summary: message,
};
}
const segments = message.split(' - ');
return {
summary: segments[1],
details: segments[2],
};
};

View file

@ -0,0 +1,9 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export * from './validate_form';

View file

@ -0,0 +1,192 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { validateRuleBase, validateRuleParams, hasRuleErrors } from './validate_form';
import { RuleFormData } from '../types';
import {
CONSUMER_REQUIRED_TEXT,
INTERVAL_MINIMUM_TEXT,
INTERVAL_REQUIRED_TEXT,
NAME_REQUIRED_TEXT,
RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT,
RULE_TYPE_REQUIRED_TEXT,
} from '../translations';
import { formatDuration } from '../utils';
import { RuleTypeModel } from '../../common';
const formDataMock: RuleFormData = {
params: {
aggType: 'count',
termSize: 5,
thresholdComparator: '>',
timeWindowSize: 5,
timeWindowUnit: 'm',
groupBy: 'all',
threshold: [1000],
index: ['.kibana'],
timeField: 'alert.executionStatus.lastExecutionDate',
},
consumer: 'stackAlerts',
schedule: { interval: '1m' },
tags: [],
name: 'test',
notifyWhen: 'onActionGroupChange',
alertDelay: {
active: 10,
},
};
const ruleTypeModelMock = {
validate: jest.fn().mockReturnValue({
errors: {
someError: 'test',
},
}),
};
describe('validateRuleBase', () => {
test('should validate name', () => {
const result = validateRuleBase({
formData: {
...formDataMock,
name: '',
},
});
expect(result.name).toEqual([NAME_REQUIRED_TEXT]);
});
test('should validate consumer', () => {
const result = validateRuleBase({
formData: {
...formDataMock,
consumer: '',
},
});
expect(result.consumer).toEqual([CONSUMER_REQUIRED_TEXT]);
});
test('should validate schedule', () => {
let result = validateRuleBase({
formData: {
...formDataMock,
schedule: {
interval: '1',
},
},
});
expect(result.interval).toEqual([INTERVAL_REQUIRED_TEXT]);
result = validateRuleBase({
formData: {
...formDataMock,
schedule: {
interval: '1m',
},
},
minimumScheduleInterval: {
value: '5m',
enforce: true,
},
});
expect(result.interval).toEqual([INTERVAL_MINIMUM_TEXT(formatDuration('5m', true))]);
});
test('should validate rule type ID', () => {
const result = validateRuleBase({
formData: {
...formDataMock,
ruleTypeId: '',
},
});
expect(result.ruleTypeId).toEqual([RULE_TYPE_REQUIRED_TEXT]);
});
test('should validate alert delay', () => {
const result = validateRuleBase({
formData: {
...formDataMock,
alertDelay: {
active: 0,
},
},
});
expect(result.alertDelay).toEqual([RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT]);
});
});
describe('validateRuleParams', () => {
test('should validate rule params', () => {
const result = validateRuleParams({
formData: formDataMock,
ruleTypeModel: ruleTypeModelMock as unknown as RuleTypeModel,
isServerless: false,
});
expect(ruleTypeModelMock.validate).toHaveBeenCalledWith(
{
aggType: 'count',
groupBy: 'all',
index: ['.kibana'],
termSize: 5,
threshold: [1000],
thresholdComparator: '>',
timeField: 'alert.executionStatus.lastExecutionDate',
timeWindowSize: 5,
timeWindowUnit: 'm',
},
false
);
expect(result).toEqual({
someError: 'test',
});
});
});
describe('hasRuleErrors', () => {
test('should return false if there are no errors', () => {
const result = hasRuleErrors({
baseErrors: {},
paramsErrors: {},
});
expect(result).toBeFalsy();
});
test('should return true if base has errors', () => {
const result = hasRuleErrors({
baseErrors: {
name: ['error'],
},
paramsErrors: {},
});
expect(result).toBeTruthy();
});
test('should return true if params have errors', () => {
let result = hasRuleErrors({
baseErrors: {},
paramsErrors: {
someValue: ['error'],
},
});
expect(result).toBeTruthy();
result = hasRuleErrors({
baseErrors: {},
paramsErrors: {
someNestedValue: {
someValue: ['error'],
},
},
});
expect(result).toBeTruthy();
});
});

View file

@ -0,0 +1,120 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { isObject } from 'lodash';
import { RuleFormData } from '../types';
import { parseDuration, formatDuration } from '../utils';
import {
NAME_REQUIRED_TEXT,
CONSUMER_REQUIRED_TEXT,
RULE_TYPE_REQUIRED_TEXT,
INTERVAL_REQUIRED_TEXT,
INTERVAL_MINIMUM_TEXT,
RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT,
} from '../translations';
import {
MinimumScheduleInterval,
RuleFormBaseErrors,
RuleFormParamsErrors,
RuleTypeModel,
} from '../../common';
export function validateRuleBase({
formData,
minimumScheduleInterval,
}: {
formData: RuleFormData;
minimumScheduleInterval?: MinimumScheduleInterval;
}): RuleFormBaseErrors {
const errors = {
name: new Array<string>(),
interval: new Array<string>(),
consumer: new Array<string>(),
ruleTypeId: new Array<string>(),
actionConnectors: new Array<string>(),
alertDelay: new Array<string>(),
tags: new Array<string>(),
};
if (!formData.name) {
errors.name.push(NAME_REQUIRED_TEXT);
}
if (!formData.consumer) {
errors.consumer.push(CONSUMER_REQUIRED_TEXT);
}
if (formData.schedule.interval.length < 2) {
errors.interval.push(INTERVAL_REQUIRED_TEXT);
} else if (minimumScheduleInterval && minimumScheduleInterval.enforce) {
const duration = parseDuration(formData.schedule.interval);
const minimumDuration = parseDuration(minimumScheduleInterval.value);
if (duration < minimumDuration) {
errors.interval.push(
INTERVAL_MINIMUM_TEXT(formatDuration(minimumScheduleInterval.value, true))
);
}
}
if (!formData.ruleTypeId) {
errors.ruleTypeId.push(RULE_TYPE_REQUIRED_TEXT);
}
if (
formData.alertDelay &&
!isNaN(formData.alertDelay?.active) &&
formData.alertDelay?.active < 1
) {
errors.alertDelay.push(RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT);
}
return errors;
}
export const validateRuleParams = ({
formData,
ruleTypeModel,
isServerless,
}: {
formData: RuleFormData;
ruleTypeModel: RuleTypeModel;
isServerless?: boolean;
}): RuleFormParamsErrors => {
return ruleTypeModel.validate(formData.params, isServerless).errors;
};
const hasRuleBaseErrors = (errors: RuleFormBaseErrors) => {
return Object.values(errors).some((error: string[]) => error.length > 0);
};
const hasRuleParamsErrors = (errors: RuleFormParamsErrors): boolean => {
const values = Object.values(errors);
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 = hasRuleParamsErrors(value as RuleFormParamsErrors);
}
}
return hasError;
};
export const hasRuleErrors = ({
baseErrors,
paramsErrors,
}: {
baseErrors: RuleFormBaseErrors;
paramsErrors: RuleFormParamsErrors;
}): boolean => {
return hasRuleBaseErrors(baseErrors) || hasRuleParamsErrors(paramsErrors);
};

View file

@ -38,5 +38,9 @@
"@kbn/charts-plugin",
"@kbn/data-plugin",
"@kbn/utility-types",
"@kbn/core-application-browser",
"@kbn/react-kibana-mount",
"@kbn/core-i18n-browser",
"@kbn/core-theme-browser",
]
}

View file

@ -7,11 +7,10 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import { Router, Routes, Route } from '@kbn/shared-ux-router';
import { QueryClient } from '@tanstack/react-query';
import { Route } from '@kbn/shared-ux-router';
import { EuiPage, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui';
import { AppMountParameters, CoreStart } from '@kbn/core/public';
import { AppMountParameters, CoreStart, ScopedHistory } from '@kbn/core/public';
import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
@ -22,6 +21,7 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { createRuleRoute, editRuleRoute, RuleForm } from '@kbn/alerts-ui-shared/src/rule_form';
import { TriggersActionsUiExamplePublicStartDeps } from './plugin';
import { Page } from './components/page';
@ -38,13 +38,17 @@ import { RuleStatusFilterSandbox } from './components/rule_status_filter_sandbox
import { AlertsTableSandbox } from './components/alerts_table_sandbox';
import { RulesSettingsLinkSandbox } from './components/rules_settings_link_sandbox';
import { RuleDefinitionSandbox } from './components/rule_form/rule_definition_sandbox';
import { RuleActionsSandbox } from './components/rule_form/rule_actions_sandbox';
import { RuleDetailsSandbox } from './components/rule_form/rule_details_sandbox';
export interface TriggersActionsUiExampleComponentParams {
http: CoreStart['http'];
basename: string;
notification: CoreStart['notifications'];
application: CoreStart['application'];
docLinks: CoreStart['docLinks'];
i18n: CoreStart['i18n'];
theme: CoreStart['theme'];
history: ScopedHistory;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
data: DataPublicPluginStart;
charts: ChartsPluginSetup;
@ -54,144 +58,198 @@ export interface TriggersActionsUiExampleComponentParams {
}
const TriggersActionsUiExampleApp = ({
basename,
history,
triggersActionsUi,
http,
application,
notification,
docLinks,
i18n,
theme,
data,
charts,
dataViews,
unifiedSearch,
}: TriggersActionsUiExampleComponentParams) => {
return (
<Router basename={basename}>
<Router history={history}>
<EuiPage>
<Sidebar />
<Route
exact
path="/"
render={() => (
<Page title="Home" isHome>
<EuiTitle size="l">
<h1>Welcome to the Triggers Actions UI plugin example</h1>
</EuiTitle>
<EuiSpacer />
<EuiText>
This example plugin displays the shareable components in the Triggers Actions UI
plugin. It also serves as a sandbox to run functional tests to ensure the shareable
components are functioning correctly outside of their original plugin.
</EuiText>
</Page>
)}
/>
<Route
path="/rules_list"
render={() => (
<Page title="Rules List">
<RulesListSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
path="/rules_list_notify_badge"
render={() => (
<Page title="Rule List Notify Badge">
<RulesListNotifyBadgeSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
path="/rule_tag_badge"
render={() => (
<Page title="Rule Tag Badge">
<RuleTagBadgeSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
path="/rule_tag_filter"
render={() => (
<Page title="Rule Tag Filter">
<RuleTagFilterSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
path="/rule_event_log_list"
render={() => (
<Page title="Run History List">
<RuleEventLogListSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
path="/global_rule_event_log_list"
render={() => (
<Page title="Global Run History List">
<GlobalRuleEventLogListSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
path="/rule_status_dropdown"
render={() => (
<Page title="Rule Status Dropdown">
<RuleStatusDropdownSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
path="/rule_status_filter"
render={() => (
<Page title="Rule Status Filter">
<RuleStatusFilterSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
path="/alerts_table"
render={() => (
<Page title="Alerts Table">
<AlertsTableSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
path="/rules_settings_link"
render={() => (
<Page title="Rules Settings Link">
<RulesSettingsLinkSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
path="/rule_definition"
render={() => (
<Page title="Rule Definition">
<RuleDefinitionSandbox
triggersActionsUi={triggersActionsUi}
data={data}
charts={charts}
dataViews={dataViews}
unifiedSearch={unifiedSearch}
/>
</Page>
)}
/>
<Route
path="/rule_actions"
render={() => (
<Page title="Rule Actions">
<RuleActionsSandbox />
</Page>
)}
/>
<Route
path="/rule_details"
render={() => (
<Page title="Rule Details">
<RuleDetailsSandbox />
</Page>
)}
/>
<Sidebar history={history} />
<Routes>
<Route
exact
path="/"
render={() => (
<Page title="Home" isHome>
<EuiTitle size="l">
<h1>Welcome to the Triggers Actions UI plugin example</h1>
</EuiTitle>
<EuiSpacer />
<EuiText>
This example plugin displays the shareable components in the Triggers Actions UI
plugin. It also serves as a sandbox to run functional tests to ensure the
shareable components are functioning correctly outside of their original plugin.
</EuiText>
</Page>
)}
/>
<Route
exact
path="/rules_list"
render={() => (
<Page title="Rules List">
<RulesListSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
exact
path="/rules_list_notify_badge"
render={() => (
<Page title="Rule List Notify Badge">
<RulesListNotifyBadgeSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
exact
path="/rule_tag_badge"
render={() => (
<Page title="Rule Tag Badge">
<RuleTagBadgeSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
exact
path="/rule_tag_filter"
render={() => (
<Page title="Rule Tag Filter">
<RuleTagFilterSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
exact
path="/rule_event_log_list"
render={() => (
<Page title="Run History List">
<RuleEventLogListSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
exact
path="/global_rule_event_log_list"
render={() => (
<Page title="Global Run History List">
<GlobalRuleEventLogListSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
exact
path="/rule_status_dropdown"
render={() => (
<Page title="Rule Status Dropdown">
<RuleStatusDropdownSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
exact
path="/rule_status_filter"
render={() => (
<Page title="Rule Status Filter">
<RuleStatusFilterSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
exact
path="/alerts_table"
render={() => (
<Page title="Alerts Table">
<AlertsTableSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
exact
path="/rules_settings_link"
render={() => (
<Page title="Rules Settings Link">
<RulesSettingsLinkSandbox triggersActionsUi={triggersActionsUi} />
</Page>
)}
/>
<Route
exact
path={createRuleRoute}
render={() => (
<Page title="Rule Create">
<RuleForm
plugins={{
http,
application,
notification,
docLinks,
i18n,
theme,
charts,
data,
dataViews,
unifiedSearch,
ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry,
}}
returnUrl={application.getUrlForApp('triggersActionsUiExample')}
/>
</Page>
)}
/>
<Route
exact
path={editRuleRoute}
render={() => (
<Page title="Rule Edit">
<RuleForm
plugins={{
http,
application,
notification,
docLinks,
theme,
i18n,
charts,
data,
dataViews,
unifiedSearch,
ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry,
}}
returnUrl={application.getUrlForApp('triggersActionsUiExample')}
/>
</Page>
)}
/>
<Route
exact
path="/rule_actions"
render={() => (
<Page title="Rule Actions">
<RuleActionsSandbox />
</Page>
)}
/>
<Route
exact
path="/rule_details"
render={() => (
<Page title="Rule Details">
<RuleDetailsSandbox />
</Page>
)}
/>
</Routes>
</EuiPage>
</Router>
);
@ -202,11 +260,12 @@ export const queryClient = new QueryClient();
export const renderApp = (
core: CoreStart,
deps: TriggersActionsUiExamplePublicStartDeps,
{ appBasePath, element }: AppMountParameters
{ appBasePath, element, history }: AppMountParameters
) => {
const { http } = core;
const { http, notifications, docLinks, application, i18n, theme } = core;
const { triggersActionsUi } = deps;
const { ruleTypeRegistry, actionTypeRegistry } = triggersActionsUi;
ReactDOM.render(
<KibanaRenderContextProvider {...core}>
<KibanaContextProvider
@ -217,11 +276,16 @@ export const renderApp = (
actionTypeRegistry,
}}
>
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<TriggersActionsUiExampleApp
basename={appBasePath}
history={history}
http={http}
notification={notifications}
application={application}
docLinks={docLinks}
i18n={i18n}
theme={theme}
triggersActionsUi={deps.triggersActionsUi}
data={deps.data}
charts={deps.charts}
@ -229,8 +293,8 @@ export const renderApp = (
dataViewsEditor={deps.dataViewsEditor}
unifiedSearch={deps.unifiedSearch}
/>
</QueryClientProvider>
</IntlProvider>
</IntlProvider>
</QueryClientProvider>
</KibanaContextProvider>
</KibanaRenderContextProvider>,
element

View file

@ -40,14 +40,14 @@ export const Page: React.FC<PageProps> = (props) => {
}
return (
<EuiPageTemplate offset={0}>
<EuiPageTemplate grow={false} offset={0}>
<EuiPageTemplate.Header>
<EuiTitle size="l">
<h1>{title}</h1>
</EuiTitle>
<EuiBreadcrumbs responsive={false} breadcrumbs={breadcrumbs} />
</EuiPageTemplate.Header>
<EuiPageTemplate.Section>{children}</EuiPageTemplate.Section>
<EuiPageTemplate.Section paddingSize="none">{children}</EuiPageTemplate.Section>
</EuiPageTemplate>
);
};

View file

@ -1,167 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useState, useCallback } from 'react';
import { EuiLoadingSpinner, EuiCodeBlock, EuiTitle, EuiButton } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { DocLinksStart } from '@kbn/core/public';
import type { HttpStart } from '@kbn/core-http-browser';
import type { ToastsStart } from '@kbn/core-notifications-browser';
import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public';
import type { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { AlertConsumers, RuleCreationValidConsumer } from '@kbn/rule-data-utils';
import { RuleDefinition, getRuleErrors, InitialRule } from '@kbn/alerts-ui-shared/src/rule_form';
import { useLoadRuleTypesQuery } from '@kbn/alerts-ui-shared/src/common/hooks';
interface RuleDefinitionSandboxProps {
data: DataPublicPluginStart;
charts: ChartsPluginSetup;
dataViews: DataViewsPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
}
export const VALID_CONSUMERS: RuleCreationValidConsumer[] = [
AlertConsumers.LOGS,
AlertConsumers.INFRASTRUCTURE,
AlertConsumers.STACK_ALERTS,
];
const DEFAULT_FORM_VALUES = (ruleTypeId: string) => ({
id: 'test-id',
name: 'test',
params: {},
schedule: {
interval: '1m',
},
alertDelay: {
active: 5,
},
notifyWhen: null,
consumer: 'stackAlerts',
enabled: true,
tags: [],
actions: [],
ruleTypeId,
});
export const RuleDefinitionSandbox = (props: RuleDefinitionSandboxProps) => {
const { data, charts, dataViews, unifiedSearch, triggersActionsUi } = props;
const [ruleTypeId, setRuleTypeId] = useState<string>('.es-query');
const [formValue, setFormValue] = useState<InitialRule>(DEFAULT_FORM_VALUES(ruleTypeId));
const onChange = useCallback(
(property: string, value: unknown) => {
if (property === 'interval') {
setFormValue({
...formValue,
schedule: {
interval: value as string,
},
});
return;
}
if (property === 'params') {
setFormValue({
...formValue,
params: value as Record<string, unknown>,
});
return;
}
setFormValue({
...formValue,
[property]: value,
});
},
[formValue]
);
const onRuleTypeChange = useCallback((newRuleTypeId: string) => {
setRuleTypeId(newRuleTypeId);
setFormValue(DEFAULT_FORM_VALUES(newRuleTypeId));
}, []);
const { docLinks, http, toasts } = useKibana<{
docLinks: DocLinksStart;
http: HttpStart;
toasts: ToastsStart;
}>().services;
const {
ruleTypesState: { data: ruleTypeIndex, isLoading },
} = useLoadRuleTypesQuery({
http,
toasts,
});
const ruleTypes = useMemo(() => [...ruleTypeIndex.values()], [ruleTypeIndex]);
const selectedRuleType = ruleTypes.find((ruleType) => ruleType.id === ruleTypeId);
const selectedRuleTypeModel = triggersActionsUi.ruleTypeRegistry.get(ruleTypeId);
const errors = useMemo(() => {
if (!selectedRuleType || !selectedRuleTypeModel) {
return {};
}
return getRuleErrors({
rule: formValue,
minimumScheduleInterval: {
value: '1m',
enforce: true,
},
ruleTypeModel: selectedRuleTypeModel,
}).ruleErrors;
}, [formValue, selectedRuleType, selectedRuleTypeModel]);
if (isLoading || !selectedRuleType) {
return <EuiLoadingSpinner />;
}
return (
<>
<div>
<EuiTitle>
<h1>Form State</h1>
</EuiTitle>
<EuiCodeBlock>{JSON.stringify(formValue, null, 2)}</EuiCodeBlock>
</div>
<div>
<EuiTitle>
<h1>Switch Rule Types:</h1>
</EuiTitle>
<EuiButton onClick={() => onRuleTypeChange('.es-query')}>Es Query</EuiButton>
<EuiButton onClick={() => onRuleTypeChange('metrics.alert.threshold')}>
Metric Threshold
</EuiButton>
<EuiButton onClick={() => onRuleTypeChange('observability.rules.custom_threshold')}>
Custom Threshold
</EuiButton>
</div>
<RuleDefinition
requiredPlugins={{
data,
charts,
dataViews,
unifiedSearch,
docLinks,
}}
formValues={formValue}
canShowConsumerSelection
authorizedConsumers={VALID_CONSUMERS}
errors={errors}
selectedRuleType={selectedRuleType}
selectedRuleTypeModel={selectedRuleTypeModel}
onChange={onChange}
/>
</>
);
};

View file

@ -5,32 +5,9 @@
* 2.0.
*/
import React, { useState, useCallback } from 'react';
import React from 'react';
import { RuleDetails } from '@kbn/alerts-ui-shared/src/rule_form';
import { EuiCodeBlock, EuiTitle } from '@elastic/eui';
export const RuleDetailsSandbox = () => {
const [formValues, setFormValues] = useState({
tags: [],
name: 'test-rule',
});
const onChange = useCallback((property: string, value: unknown) => {
setFormValues((prevFormValues) => ({
...prevFormValues,
[property]: value,
}));
}, []);
return (
<>
<div>
<EuiTitle>
<h1>Form State</h1>
</EuiTitle>
<EuiCodeBlock>{JSON.stringify(formValues, null, 2)}</EuiCodeBlock>
</div>
<RuleDetails formValues={formValues} onChange={onChange} />
</>
);
return <RuleDetails />;
};

View file

@ -6,11 +6,10 @@
*/
import React from 'react';
import { useHistory } from 'react-router-dom';
import { EuiPageSidebar, EuiSideNav } from '@elastic/eui';
import { ScopedHistory } from '@kbn/core/public';
export const Sidebar = () => {
const history = useHistory();
export const Sidebar = ({ history }: { history: ScopedHistory }) => {
return (
<EuiPageSidebar>
<EuiSideNav
@ -81,9 +80,14 @@ export const Sidebar = () => {
id: 'rule-form-components',
items: [
{
id: 'rule-definition',
name: 'Rule Definition',
onClick: () => history.push('/rule_definition'),
id: 'rule-create',
name: 'Rule Create',
onClick: () => history.push('/rule/create/.es-query'),
},
{
id: 'rule-edit',
name: 'Rule Edit',
onClick: () => history.push('/rule/edit/test'),
},
{
id: 'rule-actions',

View file

@ -31,8 +31,6 @@
"@kbn/unified-search-plugin",
"@kbn/alerts-ui-shared",
"@kbn/data-view-editor-plugin",
"@kbn/core-http-browser",
"@kbn/core-notifications-browser",
"@kbn/react-kibana-context-render",
]
}

View file

@ -23,6 +23,7 @@ export type {
RuleTaskState,
RuleTaskParams,
} from '@kbn/alerting-state-types';
export type { AlertingFrameworkHealth } from '@kbn/alerting-types';
export * from './alert_summary';
export * from './builtin_action_groups';
export * from './bulk_edit';

View file

@ -52,6 +52,7 @@ export {
RuleLastRunOutcomeValues,
RuleExecutionStatusErrorReasons,
RuleExecutionStatusWarningReasons,
HealthStatus,
} from '@kbn/alerting-types';
export type RuleTypeState = Record<string, unknown>;

View file

@ -6,8 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
const errorMessageHeader = 'Error validating circuit breaker';
import { errorMessageHeader } from '@kbn/alerting-types';
const getCreateRuleErrorSummary = (name: string) => {
return i18n.translate('xpack.alerting.ruleCircuitBreaker.error.createSummary', {

View file

@ -794,7 +794,7 @@ export const RuleForm = ({
data-test-subj="intervalFormRow"
display="rowCompressed"
helpText={getHelpTextForInterval()}
isInvalid={errors['schedule.interval'].length > 0}
isInvalid={!!errors['schedule.interval'].length}
error={errors['schedule.interval']}
>
<EuiFlexGroup gutterSize="s">
@ -803,7 +803,7 @@ export const RuleForm = ({
prepend={labelForRuleChecked}
fullWidth
min={1}
isInvalid={errors['schedule.interval'].length > 0}
isInvalid={!!errors['schedule.interval'].length}
value={ruleInterval || ''}
name="interval"
data-test-subj="intervalInput"

View file

@ -107,7 +107,7 @@ const ShowRequestModalWithProviders: React.FunctionComponent<ShowRequestModalPro
</IntlProvider>
);
describe('rules_settings_modal', () => {
describe('showRequestModal', () => {
afterEach(() => {
jest.clearAllMocks();
cleanup();

View file

@ -20,7 +20,7 @@ import {
} from '@elastic/eui';
import {
transformUpdateRuleBody as rewriteUpdateBodyRequest,
UPDATE_FIELDS,
UPDATE_FIELDS_WITH_ACTIONS,
} from '@kbn/alerts-ui-shared/src/common/apis/update_rule';
import { transformCreateRuleBody as rewriteCreateBodyRequest } from '@kbn/alerts-ui-shared/src/common/apis/create_rule';
import * as i18n from '../translations';
@ -30,7 +30,7 @@ import { BASE_ALERTING_API_PATH } from '../../constants';
const stringify = (rule: RuleUpdates, edit: boolean): string => {
try {
const request = edit
? rewriteUpdateBodyRequest(pick(rule, UPDATE_FIELDS))
? rewriteUpdateBodyRequest(pick(rule, UPDATE_FIELDS_WITH_ACTIONS))
: rewriteCreateBodyRequest(rule);
return JSON.stringify(request, null, 2);
} catch {