mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
2c7b381089
commit
762f4cd14f
88 changed files with 5069 additions and 931 deletions
|
@ -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';
|
|
@ -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';
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -11,7 +11,7 @@ import { CreateRuleBody } from './types';
|
|||
|
||||
export const transformCreateRuleBody: RewriteResponseCase<CreateRuleBody> = ({
|
||||
ruleTypeId,
|
||||
actions,
|
||||
actions = [],
|
||||
alertDelay,
|
||||
...res
|
||||
}): any => ({
|
||||
|
|
|
@ -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'];
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
|
|
14
packages/kbn-alerts-ui-shared/src/common/apis/index.ts
Normal file
14
packages/kbn-alerts-ui-shared/src/common/apis/index.ts
Normal 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';
|
|
@ -10,7 +10,7 @@ import { RewriteResponseCase } from '@kbn/actions-types';
|
|||
import { UpdateRuleBody } from './types';
|
||||
|
||||
export const transformUpdateRuleBody: RewriteResponseCase<UpdateRuleBody> = ({
|
||||
actions,
|
||||
actions = [],
|
||||
alertDelay,
|
||||
...res
|
||||
}): any => ({
|
||||
|
|
|
@ -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'];
|
||||
}
|
||||
|
|
|
@ -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}}",
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -108,7 +108,7 @@ export const useLoadRuleTypesQuery = ({
|
|||
|
||||
return {
|
||||
ruleTypesState: {
|
||||
initialLoad: isLoading || isInitialLoading,
|
||||
isInitialLoad: isInitialLoading,
|
||||
isLoading: isLoading || isFetching,
|
||||
data: filteredIndex,
|
||||
error,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
59
packages/kbn-alerts-ui-shared/src/rule_form/constants.ts
Normal file
59
packages/kbn-alerts-ui-shared/src/rule_form/constants.ts
Normal 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;
|
170
packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx
Normal file
170
packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx
Normal 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>
|
||||
);
|
||||
};
|
134
packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx
Normal file
134
packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx
Normal 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>
|
||||
);
|
||||
};
|
10
packages/kbn-alerts-ui-shared/src/rule_form/hooks/index.ts
Normal file
10
packages/kbn-alerts-ui-shared/src/rule_form/hooks/index.ts
Normal 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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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}
|
||||
|
|
56
packages/kbn-alerts-ui-shared/src/rule_form/rule_form.tsx
Normal file
56
packages/kbn-alerts-ui-shared/src/rule_form/rule_form.tsx
Normal 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>;
|
||||
};
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
<EuiLink
|
||||
data-test-subj="ruleFormHealthCheckErrorLink"
|
||||
external
|
||||
href={errorState.errorDocLink}
|
||||
target="_blank"
|
||||
>
|
||||
{HEALTH_CHECK_ACTION_TEXT}
|
||||
</EuiLink>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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>>(
|
||||
() => {}
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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.',
|
||||
}
|
||||
);
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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'>;
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
[]
|
||||
);
|
||||
};
|
|
@ -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
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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];
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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$/;
|
||||
|
|
|
@ -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],
|
||||
};
|
||||
};
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 />;
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -52,6 +52,7 @@ export {
|
|||
RuleLastRunOutcomeValues,
|
||||
RuleExecutionStatusErrorReasons,
|
||||
RuleExecutionStatusWarningReasons,
|
||||
HealthStatus,
|
||||
} from '@kbn/alerting-types';
|
||||
|
||||
export type RuleTypeState = Record<string, unknown>;
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -107,7 +107,7 @@ const ShowRequestModalWithProviders: React.FunctionComponent<ShowRequestModalPro
|
|||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('rules_settings_modal', () => {
|
||||
describe('showRequestModal', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
cleanup();
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue