[Alerting] Adds generic UI for the definition of conditions for Action Groups (#83278)
This PR adds two components to aid in creating a uniform UI for specifying the conditions for Action Groups: 1. `AlertConditions`: A component that generates a container which renders custom component for each Action Group which has had its _conditions_ specified. 2. `AlertConditionsGroup`: A component that provides a unified container for the Action Group with its name and a button for resetting its condition. This can be used by any Alert Type to easily create the UI for adding action groups with whichever UI is specific to their component.
This commit is contained in:
parent
63cb5aee4e
commit
8aa7e13cb5
|
@ -8,6 +8,15 @@ export const ALERTING_EXAMPLE_APP_ID = 'AlertingExample';
|
|||
|
||||
// always firing
|
||||
export const DEFAULT_INSTANCES_TO_GENERATE = 5;
|
||||
export interface AlwaysFiringParams {
|
||||
instances?: number;
|
||||
thresholds?: {
|
||||
small?: number;
|
||||
medium?: number;
|
||||
large?: number;
|
||||
};
|
||||
}
|
||||
export type AlwaysFiringActionGroupIds = keyof AlwaysFiringParams['thresholds'];
|
||||
|
||||
// Astros
|
||||
export enum Craft {
|
||||
|
|
|
@ -4,17 +4,31 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { Fragment } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiFieldNumber, EuiFormRow } from '@elastic/eui';
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFieldNumber,
|
||||
EuiFormRow,
|
||||
EuiPopover,
|
||||
EuiExpression,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { AlertTypeModel } from '../../../../plugins/triggers_actions_ui/public';
|
||||
import { DEFAULT_INSTANCES_TO_GENERATE } from '../../common/constants';
|
||||
|
||||
interface AlwaysFiringParamsProps {
|
||||
alertParams: { instances?: number };
|
||||
setAlertParams: (property: string, value: any) => void;
|
||||
errors: { [key: string]: string[] };
|
||||
}
|
||||
import { omit, pick } from 'lodash';
|
||||
import {
|
||||
ActionGroupWithCondition,
|
||||
AlertConditions,
|
||||
AlertConditionsGroup,
|
||||
AlertTypeModel,
|
||||
AlertTypeParamsExpressionProps,
|
||||
AlertsContextValue,
|
||||
} from '../../../../plugins/triggers_actions_ui/public';
|
||||
import {
|
||||
AlwaysFiringParams,
|
||||
AlwaysFiringActionGroupIds,
|
||||
DEFAULT_INSTANCES_TO_GENERATE,
|
||||
} from '../../common/constants';
|
||||
|
||||
export function getAlertType(): AlertTypeModel {
|
||||
return {
|
||||
|
@ -24,7 +38,7 @@ export function getAlertType(): AlertTypeModel {
|
|||
iconClass: 'bolt',
|
||||
documentationUrl: null,
|
||||
alertParamsExpression: AlwaysFiringExpression,
|
||||
validate: (alertParams: AlwaysFiringParamsProps['alertParams']) => {
|
||||
validate: (alertParams: AlwaysFiringParams) => {
|
||||
const { instances } = alertParams;
|
||||
const validationResult = {
|
||||
errors: {
|
||||
|
@ -44,11 +58,30 @@ export function getAlertType(): AlertTypeModel {
|
|||
};
|
||||
}
|
||||
|
||||
export const AlwaysFiringExpression: React.FunctionComponent<AlwaysFiringParamsProps> = ({
|
||||
alertParams,
|
||||
setAlertParams,
|
||||
}) => {
|
||||
const { instances = DEFAULT_INSTANCES_TO_GENERATE } = alertParams;
|
||||
const DEFAULT_THRESHOLDS: AlwaysFiringParams['thresholds'] = {
|
||||
small: 0,
|
||||
medium: 5000,
|
||||
large: 10000,
|
||||
};
|
||||
|
||||
export const AlwaysFiringExpression: React.FunctionComponent<AlertTypeParamsExpressionProps<
|
||||
AlwaysFiringParams,
|
||||
AlertsContextValue
|
||||
>> = ({ alertParams, setAlertParams, actionGroups, defaultActionGroupId }) => {
|
||||
const {
|
||||
instances = DEFAULT_INSTANCES_TO_GENERATE,
|
||||
thresholds = pick(DEFAULT_THRESHOLDS, defaultActionGroupId),
|
||||
} = alertParams;
|
||||
|
||||
const actionGroupsWithConditions = actionGroups.map((actionGroup) =>
|
||||
Number.isInteger(thresholds[actionGroup.id as AlwaysFiringActionGroupIds])
|
||||
? {
|
||||
...actionGroup,
|
||||
conditions: thresholds[actionGroup.id as AlwaysFiringActionGroupIds]!,
|
||||
}
|
||||
: actionGroup
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiFlexGroup gutterSize="s" wrap direction="column">
|
||||
|
@ -67,6 +100,88 @@ export const AlwaysFiringExpression: React.FunctionComponent<AlwaysFiringParamsP
|
|||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={true}>
|
||||
<AlertConditions
|
||||
headline={'Set different thresholds for randomly generated T-Shirt sizes'}
|
||||
actionGroups={actionGroupsWithConditions}
|
||||
onInitializeConditionsFor={(actionGroup) => {
|
||||
setAlertParams('thresholds', {
|
||||
...thresholds,
|
||||
...pick(DEFAULT_THRESHOLDS, actionGroup.id),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<AlertConditionsGroup
|
||||
onResetConditionsFor={(actionGroup) => {
|
||||
setAlertParams('thresholds', omit(thresholds, actionGroup.id));
|
||||
}}
|
||||
>
|
||||
<TShirtSelector
|
||||
setTShirtThreshold={(actionGroup) => {
|
||||
setAlertParams('thresholds', {
|
||||
...thresholds,
|
||||
[actionGroup.id]: actionGroup.conditions,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AlertConditionsGroup>
|
||||
</AlertConditions>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
interface TShirtSelectorProps {
|
||||
actionGroup?: ActionGroupWithCondition<number>;
|
||||
setTShirtThreshold: (actionGroup: ActionGroupWithCondition<number>) => void;
|
||||
}
|
||||
const TShirtSelector = ({ actionGroup, setTShirtThreshold }: TShirtSelectorProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (!actionGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
panelPaddingSize="s"
|
||||
button={
|
||||
<EuiExpression
|
||||
description={'Is Above'}
|
||||
value={actionGroup.conditions}
|
||||
isActive={isOpen}
|
||||
onClick={() => setIsOpen(true)}
|
||||
/>
|
||||
}
|
||||
isOpen={isOpen}
|
||||
closePopover={() => setIsOpen(false)}
|
||||
ownFocus
|
||||
anchorPosition="downLeft"
|
||||
>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false} style={{ width: 150 }}>
|
||||
{'Is Above'}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={{ width: 100 }}>
|
||||
<EuiFieldNumber
|
||||
compressed
|
||||
value={actionGroup.conditions}
|
||||
onChange={(e) => {
|
||||
const conditions = parseInt(e.target.value, 10);
|
||||
if (e.target.value && !isNaN(conditions)) {
|
||||
setTShirtThreshold({
|
||||
...actionGroup,
|
||||
conditions,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,31 +5,56 @@
|
|||
*/
|
||||
|
||||
import uuid from 'uuid';
|
||||
import { range, random } from 'lodash';
|
||||
import { range } from 'lodash';
|
||||
import { AlertType } from '../../../../plugins/alerts/server';
|
||||
import { DEFAULT_INSTANCES_TO_GENERATE, ALERTING_EXAMPLE_APP_ID } from '../../common/constants';
|
||||
import {
|
||||
DEFAULT_INSTANCES_TO_GENERATE,
|
||||
ALERTING_EXAMPLE_APP_ID,
|
||||
AlwaysFiringParams,
|
||||
} from '../../common/constants';
|
||||
|
||||
const ACTION_GROUPS = [
|
||||
{ id: 'small', name: 'small' },
|
||||
{ id: 'medium', name: 'medium' },
|
||||
{ id: 'large', name: 'large' },
|
||||
{ id: 'small', name: 'Small t-shirt' },
|
||||
{ id: 'medium', name: 'Medium t-shirt' },
|
||||
{ id: 'large', name: 'Large t-shirt' },
|
||||
];
|
||||
const DEFAULT_ACTION_GROUP = 'small';
|
||||
|
||||
export const alertType: AlertType = {
|
||||
function getTShirtSizeByIdAndThreshold(id: string, thresholds: AlwaysFiringParams['thresholds']) {
|
||||
const idAsNumber = parseInt(id, 10);
|
||||
if (!isNaN(idAsNumber)) {
|
||||
if (thresholds?.large && thresholds.large < idAsNumber) {
|
||||
return 'large';
|
||||
}
|
||||
if (thresholds?.medium && thresholds.medium < idAsNumber) {
|
||||
return 'medium';
|
||||
}
|
||||
if (thresholds?.small && thresholds.small < idAsNumber) {
|
||||
return 'small';
|
||||
}
|
||||
}
|
||||
return DEFAULT_ACTION_GROUP;
|
||||
}
|
||||
|
||||
export const alertType: AlertType<AlwaysFiringParams> = {
|
||||
id: 'example.always-firing',
|
||||
name: 'Always firing',
|
||||
actionGroups: ACTION_GROUPS,
|
||||
defaultActionGroupId: 'small',
|
||||
async executor({ services, params: { instances = DEFAULT_INSTANCES_TO_GENERATE }, state }) {
|
||||
defaultActionGroupId: DEFAULT_ACTION_GROUP,
|
||||
async executor({
|
||||
services,
|
||||
params: { instances = DEFAULT_INSTANCES_TO_GENERATE, thresholds },
|
||||
state,
|
||||
}) {
|
||||
const count = (state.count ?? 0) + 1;
|
||||
|
||||
range(instances)
|
||||
.map(() => ({ id: uuid.v4(), tshirtSize: ACTION_GROUPS[random(0, 2)].id! }))
|
||||
.forEach((instance: { id: string; tshirtSize: string }) => {
|
||||
.map(() => uuid.v4())
|
||||
.forEach((id: string) => {
|
||||
services
|
||||
.alertInstanceFactory(instance.id)
|
||||
.alertInstanceFactory(id)
|
||||
.replaceState({ triggerdOnCycle: count })
|
||||
.scheduleActions(instance.tshirtSize);
|
||||
.scheduleActions(getTShirtSizeByIdAndThreshold(id, thresholds));
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { SavedObjectAttributes } from 'kibana/server';
|
||||
import { SavedObjectAttribute, SavedObjectAttributes } from 'kibana/server';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AlertTypeState = Record<string, any>;
|
||||
|
@ -37,6 +37,7 @@ export interface AlertExecutionStatus {
|
|||
}
|
||||
|
||||
export type AlertActionParams = SavedObjectAttributes;
|
||||
export type AlertActionParam = SavedObjectAttribute;
|
||||
|
||||
export interface AlertAction {
|
||||
group: string;
|
||||
|
|
|
@ -8,7 +8,11 @@ import React, { Fragment, FunctionComponent, useEffect, useRef } from 'react';
|
|||
import { EuiFormRow } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { IErrorObject, AlertsContextValue } from '../../../../../../triggers_actions_ui/public';
|
||||
import {
|
||||
IErrorObject,
|
||||
AlertsContextValue,
|
||||
AlertTypeParamsExpressionProps,
|
||||
} from '../../../../../../triggers_actions_ui/public';
|
||||
import { ES_GEO_FIELD_TYPES } from '../../types';
|
||||
import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select';
|
||||
import { SingleFieldSelect } from '../util_components/single_field_select';
|
||||
|
@ -23,7 +27,7 @@ interface Props {
|
|||
errors: IErrorObject;
|
||||
setAlertParamsDate: (date: string) => void;
|
||||
setAlertParamsGeoField: (geoField: string) => void;
|
||||
setAlertProperty: (alertProp: string, alertParams: unknown) => void;
|
||||
setAlertProperty: AlertTypeParamsExpressionProps['setAlertProperty'];
|
||||
setIndexPattern: (indexPattern: IIndexPattern) => void;
|
||||
indexPattern: IIndexPattern;
|
||||
isInvalid: boolean;
|
||||
|
|
|
@ -25,6 +25,7 @@ Table of Contents
|
|||
- [GROUPED BY expression component](#grouped-by-expression-component)
|
||||
- [FOR THE LAST expression component](#for-the-last-expression-component)
|
||||
- [THRESHOLD expression component](#threshold-expression-component)
|
||||
- [Alert Conditions Components](#alert-conditions-components)
|
||||
- [Embed the Create Alert flyout within any Kibana plugin](#embed-the-create-alert-flyout-within-any-kibana-plugin)
|
||||
- [Build and register Action Types](#build-and-register-action-types)
|
||||
- [Built-in Action Types](#built-in-action-types)
|
||||
|
@ -634,6 +635,155 @@ interface ThresholdExpressionProps {
|
|||
|customComparators|(Optional) List of comparators that replaces the default options defined in constants `x-pack/plugins/triggers_actions_ui/public/common/constants/comparators.ts`.|
|
||||
|popupPosition|(Optional) expression popup position. Default is `downLeft`. Recommend changing it for a small parent window space.|
|
||||
|
||||
## Alert Conditions Components
|
||||
To aid in creating a uniform UX across Alert Types, we provide two components for specifying the conditions for detection of a certain alert under within any specific Action Groups:
|
||||
1. `AlertConditions`: A component that generates a container which renders custom component for each Action Group which has had its _conditions_ specified.
|
||||
2. `AlertConditionsGroup`: A component that provides a unified container for the Action Group with its name and a button for resetting its condition.
|
||||
|
||||
These can be used by any Alert Type to easily create the UI for adding action groups along with an Alert Type specific component.
|
||||
|
||||
For Example:
|
||||
Given an Alert Type which requires different thresholds for each detected Action Group (for example), you might have a `ThresholdSpecifier` component for specifying the threshold for a specific Action Group.
|
||||
|
||||
```
|
||||
const ThresholdSpecifier = (
|
||||
{
|
||||
actionGroup,
|
||||
setThreshold
|
||||
} : {
|
||||
actionGroup?: ActionGroupWithCondition<number>;
|
||||
setThreshold: (actionGroup: ActionGroupWithCondition<number>) => void;
|
||||
}) => {
|
||||
if (!actionGroup) {
|
||||
// render empty if no condition action group is specified
|
||||
return <Fragment />;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFieldNumber
|
||||
value={actionGroup.conditions}
|
||||
onChange={(e) => {
|
||||
const conditions = parseInt(e.target.value, 10);
|
||||
if (e.target.value && !isNaN(conditions)) {
|
||||
setThreshold({
|
||||
...actionGroup,
|
||||
conditions,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
This component takes two props, one which is required (`actionGroup`) and one which is alert type specific (`setThreshold`).
|
||||
The `actionGroup` will be populated by the `AlertConditions` component, but `setThreshold` will have to be provided by the AlertType itself.
|
||||
|
||||
To understand how this is used, lets take a closer look at `actionGroup`:
|
||||
|
||||
```
|
||||
type ActionGroupWithCondition<T> = ActionGroup &
|
||||
(
|
||||
| // allow isRequired=false with or without conditions
|
||||
{
|
||||
conditions?: T;
|
||||
isRequired?: false;
|
||||
}
|
||||
// but if isRequired=true then conditions must be specified
|
||||
| {
|
||||
conditions: T;
|
||||
isRequired: true;
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
The `condition` field is Alert Type specific, and holds whichever type an Alert Type needs for specifying the condition under which a certain detection falls under that specific Action Group.
|
||||
In our example, this is a `number` as that's all we need to speciufy the threshold which dictates whether an alert falls into one actio ngroup rather than another.
|
||||
|
||||
The `isRequired` field specifies whether this specific action group is _required_, that is, you can't reset its condition and _have_ to specify a some condition for it.
|
||||
|
||||
Using this `ThresholdSpecifier` component, we can now use `AlertConditionsGroup` & `AlertConditions` to enable the user to specify these thresholds for each action group in the alert type.
|
||||
|
||||
Like so:
|
||||
```
|
||||
interface ThresholdAlertTypeParams {
|
||||
thresholds?: {
|
||||
alert?: number;
|
||||
warning?: number;
|
||||
error?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLDS: ThresholdAlertTypeParams['threshold] = {
|
||||
alert: 50,
|
||||
warning: 80,
|
||||
error: 90,
|
||||
};
|
||||
```
|
||||
|
||||
```
|
||||
<AlertConditions
|
||||
headline={'Set different thresholds for each level'}
|
||||
actionGroups={[
|
||||
{
|
||||
id: 'alert',
|
||||
name: 'Alert',
|
||||
condition: DEFAULT_THRESHOLD
|
||||
},
|
||||
{
|
||||
id: 'warning',
|
||||
name: 'Warning',
|
||||
},
|
||||
{
|
||||
id: 'error',
|
||||
name: 'Error',
|
||||
},
|
||||
]}
|
||||
onInitializeConditionsFor={(actionGroup) => {
|
||||
setAlertParams('thresholds', {
|
||||
...thresholds,
|
||||
...pick(DEFAULT_THRESHOLDS, actionGroup.id),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<AlertConditionsGroup
|
||||
onResetConditionsFor={(actionGroup) => {
|
||||
setAlertParams('thresholds', omit(thresholds, actionGroup.id));
|
||||
}}
|
||||
>
|
||||
<TShirtSelector
|
||||
setTShirtThreshold={(actionGroup) => {
|
||||
setAlertParams('thresholds', {
|
||||
...thresholds,
|
||||
[actionGroup.id]: actionGroup.conditions,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AlertConditionsGroup>
|
||||
</AlertConditions>
|
||||
```
|
||||
|
||||
### The AlertConditions component
|
||||
|
||||
This component will render the `Conditions` header & headline, along with the selectors for adding every Action Group you specity.
|
||||
Additionally it will clone its `children` for _each_ action group which has a `condition` specified for it, passing in the appropriate `actionGroup` prop for each one.
|
||||
|
||||
|Property|Description|
|
||||
|---|---|
|
||||
|headline|The headline title displayed above the fields |
|
||||
|actionGroups|A list of `ActionGroupWithCondition` which includes all the action group you wish to offer the user and what conditions they are already configured to follow|
|
||||
|onInitializeConditionsFor|A callback which is called when the user ask for a certain actionGroup to be initialized with an initial default condition. If you have no specific default, that's fine, as the component will render the action group's field even if the condition is empty (using a `null` or an `undefined`) and determines whether to render these fields by _the very presence_ of a `condition` field|
|
||||
|
||||
### The AlertConditionsGroup component
|
||||
|
||||
This component renders a standard EuiTitle foe each action group, wrapping the Alert Type specific component, in addition to a "reset" button which allows the user to reset the condition for that action group. The definition of what a _reset_ actually means is Alert Type specific, and up to the implementor to decide. In some case it might mean removing the condition, in others it might mean to reset it to some default value on the server side. In either case, it should _delete_ the `condition` field from the appropriate `actionGroup` as per the above example.
|
||||
|
||||
|Property|Description|
|
||||
|---|---|
|
||||
|onResetConditionsFor|A callback which is called when the user clicks the _reset_ button besides the action group's title. The implementor should use this to remove the `condition` from the specified actionGroup|
|
||||
|
||||
|
||||
## Embed the Create Alert flyout within any Kibana plugin
|
||||
|
||||
Follow the instructions bellow to embed the Create Alert flyout within any Kibana plugin:
|
||||
|
|
|
@ -4,7 +4,8 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { Alert, AlertType } from '../../types';
|
||||
import { AlertType } from '../../types';
|
||||
import { InitialAlert } from '../sections/alert_form/alert_reducer';
|
||||
|
||||
/**
|
||||
* NOTE: Applications that want to show the alerting UIs will need to add
|
||||
|
@ -21,9 +22,9 @@ export const hasExecuteActionsCapability = (capabilities: Capabilities) =>
|
|||
export const hasDeleteActionsCapability = (capabilities: Capabilities) =>
|
||||
capabilities?.actions?.delete;
|
||||
|
||||
export function hasAllPrivilege(alert: Alert, alertType?: AlertType): boolean {
|
||||
export function hasAllPrivilege(alert: InitialAlert, alertType?: AlertType): boolean {
|
||||
return alertType?.authorizedConsumers[alert.consumer]?.all ?? false;
|
||||
}
|
||||
export function hasReadPrivilege(alert: Alert, alertType?: AlertType): boolean {
|
||||
export function hasReadPrivilege(alert: InitialAlert, alertType?: AlertType): boolean {
|
||||
return alertType?.authorizedConsumers[alert.consumer]?.read ?? false;
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ import { AddConnectorInline } from './connector_add_inline';
|
|||
import { actionTypeCompare } from '../../lib/action_type_compare';
|
||||
import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled';
|
||||
import { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants';
|
||||
import { ActionGroup } from '../../../../../alerts/common';
|
||||
import { ActionGroup, AlertActionParam } from '../../../../../alerts/common';
|
||||
|
||||
export interface ActionAccordionFormProps {
|
||||
actions: AlertAction[];
|
||||
|
@ -45,7 +45,7 @@ export interface ActionAccordionFormProps {
|
|||
setActionIdByIndex: (id: string, index: number) => void;
|
||||
setActionGroupIdByIndex?: (group: string, index: number) => void;
|
||||
setAlertProperty: (actions: AlertAction[]) => void;
|
||||
setActionParamsProperty: (key: string, value: any, index: number) => void;
|
||||
setActionParamsProperty: (key: string, value: AlertActionParam, index: number) => void;
|
||||
http: HttpSetup;
|
||||
actionTypeRegistry: ActionTypeRegistryContract;
|
||||
toastNotifications: ToastsSetup;
|
||||
|
|
|
@ -25,7 +25,7 @@ import {
|
|||
EuiLoadingSpinner,
|
||||
EuiBadge,
|
||||
} from '@elastic/eui';
|
||||
import { ResolvedActionGroup } from '../../../../../alerts/common';
|
||||
import { AlertActionParam, ResolvedActionGroup } from '../../../../../alerts/common';
|
||||
import {
|
||||
IErrorObject,
|
||||
AlertAction,
|
||||
|
@ -50,7 +50,7 @@ export type ActionTypeFormProps = {
|
|||
onAddConnector: () => void;
|
||||
onConnectorSelected: (id: string) => void;
|
||||
onDeleteAction: () => void;
|
||||
setActionParamsProperty: (key: string, value: any, index: number) => void;
|
||||
setActionParamsProperty: (key: string, value: AlertActionParam, index: number) => void;
|
||||
actionTypesIndex: ActionTypeIndex;
|
||||
connectors: ActionConnector[];
|
||||
} & Pick<
|
||||
|
|
|
@ -75,8 +75,8 @@ export const AlertDetails: React.FunctionComponent<AlertDetailsProps> = ({
|
|||
chrome,
|
||||
} = useAppDependencies();
|
||||
const [{}, dispatch] = useReducer(alertReducer, { alert });
|
||||
const setInitialAlert = (key: string, value: any) => {
|
||||
dispatch({ command: { type: 'setAlert' }, payload: { key, value } });
|
||||
const setInitialAlert = (value: Alert) => {
|
||||
dispatch({ command: { type: 'setAlert' }, payload: { key: 'alert', value } });
|
||||
};
|
||||
|
||||
// Set breadcrumb and page title
|
||||
|
@ -172,7 +172,7 @@ export const AlertDetails: React.FunctionComponent<AlertDetailsProps> = ({
|
|||
<AlertEdit
|
||||
initialAlert={alert}
|
||||
onClose={() => {
|
||||
setInitialAlert('alert', alert);
|
||||
setInitialAlert(alert);
|
||||
setEditFlyoutVisibility(false);
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -3,15 +3,14 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { useCallback, useReducer, useState, useEffect } from 'react';
|
||||
import { isObject } from 'lodash';
|
||||
import React, { useCallback, useReducer, useMemo, useState, useEffect } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiTitle, EuiFlyoutHeader, EuiFlyout, EuiFlyoutBody, EuiPortal } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useAlertsContext } from '../../context/alerts_context';
|
||||
import { Alert, AlertAction, IErrorObject } from '../../../types';
|
||||
import { AlertForm, validateBaseProperties } from './alert_form';
|
||||
import { alertReducer } from './alert_reducer';
|
||||
import { AlertForm, isValidAlert, validateBaseProperties } from './alert_form';
|
||||
import { alertReducer, InitialAlert, InitialAlertReducer } from './alert_reducer';
|
||||
import { createAlert } from '../../lib/alert_api';
|
||||
import { HealthCheck } from '../../components/health_check';
|
||||
import { ConfirmAlertSave } from './confirm_alert_save';
|
||||
|
@ -36,27 +35,32 @@ export const AlertAdd = ({
|
|||
alertTypeId,
|
||||
initialValues,
|
||||
}: AlertAddProps) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const initialAlert = ({
|
||||
params: {},
|
||||
consumer,
|
||||
alertTypeId,
|
||||
schedule: {
|
||||
interval: '1m',
|
||||
},
|
||||
actions: [],
|
||||
tags: [],
|
||||
...(initialValues ? initialValues : {}),
|
||||
} as unknown) as Alert;
|
||||
const initialAlert: InitialAlert = useMemo(
|
||||
() => ({
|
||||
params: {},
|
||||
consumer,
|
||||
alertTypeId,
|
||||
schedule: {
|
||||
interval: '1m',
|
||||
},
|
||||
actions: [],
|
||||
tags: [],
|
||||
...(initialValues ? initialValues : {}),
|
||||
}),
|
||||
[alertTypeId, consumer, initialValues]
|
||||
);
|
||||
|
||||
const [{ alert }, dispatch] = useReducer(alertReducer, { alert: initialAlert });
|
||||
const [{ alert }, dispatch] = useReducer(alertReducer as InitialAlertReducer, {
|
||||
alert: initialAlert,
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [isConfirmAlertSaveModalOpen, setIsConfirmAlertSaveModalOpen] = useState<boolean>(false);
|
||||
|
||||
const setAlert = (value: any) => {
|
||||
const setAlert = (value: InitialAlert) => {
|
||||
dispatch({ command: { type: 'setAlert' }, payload: { key: 'alert', value } });
|
||||
};
|
||||
const setAlertProperty = (key: string, value: any) => {
|
||||
|
||||
const setAlertProperty = <Key extends keyof Alert>(key: Key, value: Alert[Key] | null) => {
|
||||
dispatch({ command: { type: 'setProperty' }, payload: { key, value } });
|
||||
};
|
||||
|
||||
|
@ -73,7 +77,7 @@ export const AlertAdd = ({
|
|||
const canShowActions = hasShowActionsCapability(capabilities);
|
||||
|
||||
useEffect(() => {
|
||||
setAlertProperty('alertTypeId', alertTypeId);
|
||||
setAlertProperty('alertTypeId', alertTypeId ?? null);
|
||||
}, [alertTypeId]);
|
||||
|
||||
const closeFlyout = useCallback(() => {
|
||||
|
@ -101,7 +105,7 @@ export const AlertAdd = ({
|
|||
...(alertType ? alertType.validate(alert.params).errors : []),
|
||||
...validateBaseProperties(alert).errors,
|
||||
} as IErrorObject;
|
||||
const hasErrors = parseErrors(errors);
|
||||
const hasErrors = !isValidAlert(alert, errors);
|
||||
|
||||
const actionsErrors: Array<{
|
||||
errors: IErrorObject;
|
||||
|
@ -121,16 +125,18 @@ export const AlertAdd = ({
|
|||
|
||||
async function onSaveAlert(): Promise<Alert | undefined> {
|
||||
try {
|
||||
const newAlert = await createAlert({ http, alert });
|
||||
toastNotifications.addSuccess(
|
||||
i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText', {
|
||||
defaultMessage: 'Created alert "{alertName}"',
|
||||
values: {
|
||||
alertName: newAlert.name,
|
||||
},
|
||||
})
|
||||
);
|
||||
return newAlert;
|
||||
if (isValidAlert(alert, errors)) {
|
||||
const newAlert = await createAlert({ http, alert });
|
||||
toastNotifications.addSuccess(
|
||||
i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText', {
|
||||
defaultMessage: 'Created alert "{alertName}"',
|
||||
values: {
|
||||
alertName: newAlert.name,
|
||||
},
|
||||
})
|
||||
);
|
||||
return newAlert;
|
||||
}
|
||||
} catch (errorRes) {
|
||||
toastNotifications.addDanger(
|
||||
errorRes.body?.message ??
|
||||
|
@ -207,11 +213,5 @@ export const AlertAdd = ({
|
|||
);
|
||||
};
|
||||
|
||||
const parseErrors: (errors: IErrorObject) => boolean = (errors) =>
|
||||
!!Object.values(errors).find((errorList) => {
|
||||
if (isObject(errorList)) return parseErrors(errorList as IErrorObject);
|
||||
return errorList.length >= 1;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { AlertAdd as default };
|
||||
|
|
|
@ -0,0 +1,260 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { mountWithIntl, nextTick } from '@kbn/test/jest';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
import { AlertConditions, ActionGroupWithCondition } from './alert_conditions';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import {
|
||||
EuiTitle,
|
||||
EuiDescriptionList,
|
||||
EuiDescriptionListTitle,
|
||||
EuiDescriptionListDescription,
|
||||
EuiButtonEmpty,
|
||||
} from '@elastic/eui';
|
||||
|
||||
describe('alert_conditions', () => {
|
||||
async function setup(element: React.ReactElement): Promise<ReactWrapper<unknown>> {
|
||||
const wrapper = mountWithIntl(element);
|
||||
|
||||
// Wait for active space to resolve before requesting the component to update
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
it('renders with custom headline', async () => {
|
||||
const wrapper = await setup(
|
||||
<AlertConditions
|
||||
headline={'Set different threshold with their own status'}
|
||||
actionGroups={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper.find(EuiTitle).find(FormattedMessage).prop('id')).toMatchInlineSnapshot(
|
||||
`"xpack.triggersActionsUI.sections.alertForm.conditions.title"`
|
||||
);
|
||||
expect(
|
||||
wrapper.find(EuiTitle).find(FormattedMessage).prop('defaultMessage')
|
||||
).toMatchInlineSnapshot(`"Conditions:"`);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="alertConditionsHeadline"]').get(0))
|
||||
.toMatchInlineSnapshot(`
|
||||
<EuiText
|
||||
color="subdued"
|
||||
data-test-subj="alertConditionsHeadline"
|
||||
size="s"
|
||||
>
|
||||
Set different threshold with their own status
|
||||
</EuiText>
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders any action group with conditions on it', async () => {
|
||||
const ConditionForm = ({
|
||||
actionGroup,
|
||||
}: {
|
||||
actionGroup?: ActionGroupWithCondition<{ someProp: string }>;
|
||||
}) => {
|
||||
return (
|
||||
<EuiDescriptionList>
|
||||
<EuiDescriptionListTitle>ID</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>{actionGroup?.id}</EuiDescriptionListDescription>
|
||||
<EuiDescriptionListTitle>Name</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>{actionGroup?.name}</EuiDescriptionListDescription>
|
||||
<EuiDescriptionListTitle>SomeProp</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>
|
||||
{actionGroup?.conditions?.someProp}
|
||||
</EuiDescriptionListDescription>
|
||||
</EuiDescriptionList>
|
||||
);
|
||||
};
|
||||
|
||||
const wrapper = await setup(
|
||||
<AlertConditions
|
||||
actionGroups={[
|
||||
{ id: 'default', name: 'Default', conditions: { someProp: 'my prop value' } },
|
||||
]}
|
||||
>
|
||||
<ConditionForm />
|
||||
</AlertConditions>
|
||||
);
|
||||
|
||||
expect(wrapper.find(EuiDescriptionList).find(EuiDescriptionListDescription).get(0))
|
||||
.toMatchInlineSnapshot(`
|
||||
<EuiDescriptionListDescription>
|
||||
default
|
||||
</EuiDescriptionListDescription>
|
||||
`);
|
||||
|
||||
expect(wrapper.find(EuiDescriptionList).find(EuiDescriptionListDescription).get(1))
|
||||
.toMatchInlineSnapshot(`
|
||||
<EuiDescriptionListDescription>
|
||||
Default
|
||||
</EuiDescriptionListDescription>
|
||||
`);
|
||||
|
||||
expect(wrapper.find(EuiDescriptionList).find(EuiDescriptionListDescription).get(2))
|
||||
.toMatchInlineSnapshot(`
|
||||
<EuiDescriptionListDescription>
|
||||
my prop value
|
||||
</EuiDescriptionListDescription>
|
||||
`);
|
||||
});
|
||||
|
||||
it('doesnt render action group without conditions', async () => {
|
||||
const ConditionForm = ({
|
||||
actionGroup,
|
||||
}: {
|
||||
actionGroup?: ActionGroupWithCondition<{ someProp: string }>;
|
||||
}) => {
|
||||
return (
|
||||
<EuiDescriptionList>
|
||||
<EuiDescriptionListTitle>ID</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>{actionGroup?.id}</EuiDescriptionListDescription>
|
||||
</EuiDescriptionList>
|
||||
);
|
||||
};
|
||||
|
||||
const wrapper = await setup(
|
||||
<AlertConditions
|
||||
actionGroups={[
|
||||
{ id: 'default', name: 'Default', conditions: { someProp: 'default on a prop' } },
|
||||
{
|
||||
id: 'shouldRender',
|
||||
name: 'Should Render',
|
||||
conditions: { someProp: 'shouldRender on a prop' },
|
||||
},
|
||||
{
|
||||
id: 'shouldntRender',
|
||||
name: 'Should Not Render',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ConditionForm />
|
||||
</AlertConditions>
|
||||
);
|
||||
|
||||
expect(wrapper.find(EuiDescriptionList).find(EuiDescriptionListDescription).get(0))
|
||||
.toMatchInlineSnapshot(`
|
||||
<EuiDescriptionListDescription>
|
||||
default
|
||||
</EuiDescriptionListDescription>
|
||||
`);
|
||||
|
||||
expect(wrapper.find(EuiDescriptionList).find(EuiDescriptionListDescription).get(1))
|
||||
.toMatchInlineSnapshot(`
|
||||
<EuiDescriptionListDescription>
|
||||
shouldRender
|
||||
</EuiDescriptionListDescription>
|
||||
`);
|
||||
|
||||
expect(wrapper.find(EuiDescriptionList).find(EuiDescriptionListDescription).length).toEqual(2);
|
||||
});
|
||||
|
||||
it('render add buttons for action group without conditions', async () => {
|
||||
const onInitializeConditionsFor = jest.fn();
|
||||
|
||||
const ConditionForm = ({
|
||||
actionGroup,
|
||||
}: {
|
||||
actionGroup?: ActionGroupWithCondition<{ someProp: string }>;
|
||||
}) => {
|
||||
return (
|
||||
<EuiDescriptionList>
|
||||
<EuiDescriptionListTitle>ID</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>{actionGroup?.id}</EuiDescriptionListDescription>
|
||||
</EuiDescriptionList>
|
||||
);
|
||||
};
|
||||
|
||||
const wrapper = await setup(
|
||||
<AlertConditions
|
||||
actionGroups={[
|
||||
{
|
||||
id: 'shouldntRenderLink',
|
||||
name: 'Should Not Render Link',
|
||||
conditions: { someProp: 'shouldRender on a prop' },
|
||||
},
|
||||
{
|
||||
id: 'shouldRenderLink',
|
||||
name: 'Should Render A Link',
|
||||
},
|
||||
]}
|
||||
onInitializeConditionsFor={onInitializeConditionsFor}
|
||||
>
|
||||
<ConditionForm />
|
||||
</AlertConditions>
|
||||
);
|
||||
|
||||
expect(wrapper.find(EuiButtonEmpty).get(0)).toMatchInlineSnapshot(`
|
||||
<EuiButtonEmpty
|
||||
flush="left"
|
||||
onClick={[Function]}
|
||||
size="s"
|
||||
>
|
||||
Should Render A Link
|
||||
</EuiButtonEmpty>
|
||||
`);
|
||||
wrapper.find(EuiButtonEmpty).simulate('click');
|
||||
|
||||
expect(onInitializeConditionsFor).toHaveBeenCalledWith({
|
||||
id: 'shouldRenderLink',
|
||||
name: 'Should Render A Link',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes in any additional props the container passes in', async () => {
|
||||
const callbackProp = jest.fn();
|
||||
|
||||
const ConditionForm = ({
|
||||
actionGroup,
|
||||
someCallbackProp,
|
||||
}: {
|
||||
actionGroup?: ActionGroupWithCondition<{ someProp: string }>;
|
||||
someCallbackProp: (actionGroup: ActionGroupWithCondition<{ someProp: string }>) => void;
|
||||
}) => {
|
||||
if (!actionGroup) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
// call callback when the actionGroup is available
|
||||
someCallbackProp(actionGroup);
|
||||
return (
|
||||
<EuiDescriptionList>
|
||||
<EuiDescriptionListTitle>ID</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>{actionGroup?.id}</EuiDescriptionListDescription>
|
||||
<EuiDescriptionListTitle>Name</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>{actionGroup?.name}</EuiDescriptionListDescription>
|
||||
<EuiDescriptionListTitle>SomeProp</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>
|
||||
{actionGroup?.conditions?.someProp}
|
||||
</EuiDescriptionListDescription>
|
||||
</EuiDescriptionList>
|
||||
);
|
||||
};
|
||||
|
||||
await setup(
|
||||
<AlertConditions
|
||||
actionGroups={[
|
||||
{ id: 'default', name: 'Default', conditions: { someProp: 'my prop value' } },
|
||||
]}
|
||||
>
|
||||
<ConditionForm someCallbackProp={callbackProp} />
|
||||
</AlertConditions>
|
||||
);
|
||||
|
||||
expect(callbackProp).toHaveBeenCalledWith({
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
conditions: { someProp: 'my prop value' },
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiFlexItem, EuiText, EuiFlexGroup, EuiTitle, EuiButtonEmpty } from '@elastic/eui';
|
||||
import { partition } from 'lodash';
|
||||
import { ActionGroup, getBuiltinActionGroups } from '../../../../../alerts/common';
|
||||
|
||||
const BUILT_IN_ACTION_GROUPS: Set<string> = new Set(getBuiltinActionGroups().map(({ id }) => id));
|
||||
|
||||
export type ActionGroupWithCondition<T> = ActionGroup &
|
||||
(
|
||||
| // allow isRequired=false with or without conditions
|
||||
{
|
||||
conditions?: T;
|
||||
isRequired?: false;
|
||||
}
|
||||
// but if isRequired=true then conditions must be specified
|
||||
| {
|
||||
conditions: T;
|
||||
isRequired: true;
|
||||
}
|
||||
);
|
||||
|
||||
export interface AlertConditionsProps<ConditionProps> {
|
||||
headline?: string;
|
||||
actionGroups: Array<ActionGroupWithCondition<ConditionProps>>;
|
||||
onInitializeConditionsFor?: (actionGroup: ActionGroupWithCondition<ConditionProps>) => void;
|
||||
onResetConditionsFor?: (actionGroup: ActionGroupWithCondition<ConditionProps>) => void;
|
||||
includeBuiltInActionGroups?: boolean;
|
||||
}
|
||||
|
||||
export const AlertConditions = <ConditionProps extends any>({
|
||||
headline,
|
||||
actionGroups,
|
||||
onInitializeConditionsFor,
|
||||
onResetConditionsFor,
|
||||
includeBuiltInActionGroups = false,
|
||||
children,
|
||||
}: PropsWithChildren<AlertConditionsProps<ConditionProps>>) => {
|
||||
const [withConditions, withoutConditions] = partition(
|
||||
includeBuiltInActionGroups
|
||||
? actionGroups
|
||||
: actionGroups.filter(({ id }) => !BUILT_IN_ACTION_GROUPS.has(id)),
|
||||
(actionGroup) => actionGroup.hasOwnProperty('conditions')
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="s">
|
||||
<EuiFlexGroup component="span" alignItems="baseline">
|
||||
<EuiFlexItem grow={false}>
|
||||
<h6 className="alertConditions">
|
||||
<FormattedMessage
|
||||
id="xpack.triggersActionsUI.sections.alertForm.conditions.title"
|
||||
defaultMessage="Conditions:"
|
||||
/>
|
||||
</h6>
|
||||
</EuiFlexItem>
|
||||
{headline && (
|
||||
<EuiFlexItem>
|
||||
<EuiText color="subdued" size="s" data-test-subj="alertConditionsHeadline">
|
||||
{headline}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup direction="column">
|
||||
{withConditions.map((actionGroup) => (
|
||||
<EuiFlexItem key={`condition-${actionGroup.id}`}>
|
||||
{React.isValidElement(children) &&
|
||||
React.cloneElement(
|
||||
React.Children.only(children),
|
||||
onResetConditionsFor
|
||||
? {
|
||||
actionGroup,
|
||||
onResetConditionsFor,
|
||||
}
|
||||
: { actionGroup }
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
{onInitializeConditionsFor && withoutConditions.length > 0 && (
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup direction="row" alignItems="baseline">
|
||||
<EuiFlexItem grow={false}>
|
||||
<FormattedMessage
|
||||
id="xpack.triggersActionsUI.sections.alertForm.conditions.addConditionLabel"
|
||||
defaultMessage="Add:"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{withoutConditions.map((actionGroup) => (
|
||||
<EuiFlexItem key={`condition-add-${actionGroup.id}`} grow={false}>
|
||||
<EuiButtonEmpty
|
||||
flush="left"
|
||||
size="s"
|
||||
onClick={() => onInitializeConditionsFor(actionGroup)}
|
||||
>
|
||||
{actionGroup.name}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import { mountWithIntl, nextTick } from '@kbn/test/jest';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
import { AlertConditionsGroup } from './alert_conditions_group';
|
||||
import { EuiFormRow, EuiButtonIcon } from '@elastic/eui';
|
||||
|
||||
describe('alert_conditions_group', () => {
|
||||
async function setup(element: React.ReactElement): Promise<ReactWrapper<unknown>> {
|
||||
const wrapper = mountWithIntl(element);
|
||||
|
||||
// Wait for active space to resolve before requesting the component to update
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
it('renders with actionGroup name as label', async () => {
|
||||
const InnerComponent = () => <div>{'inner component'}</div>;
|
||||
const wrapper = await setup(
|
||||
<AlertConditionsGroup
|
||||
actionGroup={{
|
||||
id: 'myGroup',
|
||||
name: 'My Group',
|
||||
}}
|
||||
>
|
||||
<InnerComponent />
|
||||
</AlertConditionsGroup>
|
||||
);
|
||||
|
||||
expect(wrapper.find(EuiFormRow).prop('label')).toMatchInlineSnapshot(`
|
||||
<EuiTitle
|
||||
size="s"
|
||||
>
|
||||
<strong>
|
||||
My Group
|
||||
</strong>
|
||||
</EuiTitle>
|
||||
`);
|
||||
expect(wrapper.find(InnerComponent).prop('actionGroup')).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"id": "myGroup",
|
||||
"name": "My Group",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders a reset button when onResetConditionsFor is specified', async () => {
|
||||
const onResetConditionsFor = jest.fn();
|
||||
const wrapper = await setup(
|
||||
<AlertConditionsGroup
|
||||
actionGroup={{
|
||||
id: 'myGroup',
|
||||
name: 'My Group',
|
||||
}}
|
||||
onResetConditionsFor={onResetConditionsFor}
|
||||
>
|
||||
<div>{'inner component'}</div>
|
||||
</AlertConditionsGroup>
|
||||
);
|
||||
|
||||
expect(wrapper.find(EuiButtonIcon).prop('aria-label')).toMatchInlineSnapshot(`"Remove"`);
|
||||
|
||||
wrapper.find(EuiButtonIcon).simulate('click');
|
||||
|
||||
expect(onResetConditionsFor).toHaveBeenCalledWith({
|
||||
id: 'myGroup',
|
||||
name: 'My Group',
|
||||
});
|
||||
});
|
||||
|
||||
it('shouldnt render a reset button when isRequired is true', async () => {
|
||||
const onResetConditionsFor = jest.fn();
|
||||
const wrapper = await setup(
|
||||
<AlertConditionsGroup
|
||||
actionGroup={{
|
||||
id: 'myGroup',
|
||||
name: 'My Group',
|
||||
conditions: true,
|
||||
isRequired: true,
|
||||
}}
|
||||
onResetConditionsFor={onResetConditionsFor}
|
||||
>
|
||||
<div>{'inner component'}</div>
|
||||
</AlertConditionsGroup>
|
||||
);
|
||||
|
||||
expect(wrapper.find(EuiButtonIcon).length).toEqual(0);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { Fragment, PropsWithChildren } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiFormRow, EuiButtonIcon, EuiTitle } from '@elastic/eui';
|
||||
import { AlertConditionsProps, ActionGroupWithCondition } from './alert_conditions';
|
||||
|
||||
export type AlertConditionsGroupProps<ConditionProps> = {
|
||||
actionGroup?: ActionGroupWithCondition<ConditionProps>;
|
||||
} & Pick<AlertConditionsProps<ConditionProps>, 'onResetConditionsFor'>;
|
||||
|
||||
export const AlertConditionsGroup = <ConditionProps extends unknown>({
|
||||
actionGroup,
|
||||
onResetConditionsFor,
|
||||
children,
|
||||
...otherProps
|
||||
}: PropsWithChildren<AlertConditionsGroupProps<ConditionProps>>) => {
|
||||
if (!actionGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={
|
||||
<EuiTitle size="s">
|
||||
<strong>{actionGroup.name}</strong>
|
||||
</EuiTitle>
|
||||
}
|
||||
fullWidth
|
||||
labelAppend={
|
||||
onResetConditionsFor &&
|
||||
!actionGroup.isRequired && (
|
||||
<EuiButtonIcon
|
||||
iconType="minusInCircle"
|
||||
color="danger"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.triggersActionsUI.sections.alertForm.conditions.removeConditionLabel',
|
||||
{
|
||||
defaultMessage: 'Remove',
|
||||
}
|
||||
)}
|
||||
onClick={() => onResetConditionsFor(actionGroup)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{React.isValidElement(children) ? (
|
||||
React.cloneElement(React.Children.only(children), {
|
||||
actionGroup,
|
||||
...otherProps,
|
||||
})
|
||||
) : (
|
||||
<Fragment />
|
||||
)}
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
|
@ -23,7 +23,7 @@ import { i18n } from '@kbn/i18n';
|
|||
import { useAlertsContext } from '../../context/alerts_context';
|
||||
import { Alert, AlertAction, IErrorObject } from '../../../types';
|
||||
import { AlertForm, validateBaseProperties } from './alert_form';
|
||||
import { alertReducer } from './alert_reducer';
|
||||
import { alertReducer, ConcreteAlertReducer } from './alert_reducer';
|
||||
import { updateAlert } from '../../lib/alert_api';
|
||||
import { HealthCheck } from '../../components/health_check';
|
||||
import { HealthContextProvider } from '../../context/health_context';
|
||||
|
@ -34,7 +34,9 @@ interface AlertEditProps {
|
|||
}
|
||||
|
||||
export const AlertEdit = ({ initialAlert, onClose }: AlertEditProps) => {
|
||||
const [{ alert }, dispatch] = useReducer(alertReducer, { alert: initialAlert });
|
||||
const [{ alert }, dispatch] = useReducer(alertReducer as ConcreteAlertReducer, {
|
||||
alert: initialAlert,
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [hasActionsDisabled, setHasActionsDisabled] = useState<boolean>(false);
|
||||
const [hasActionsWithBrokenConnector, setHasActionsWithBrokenConnector] = useState<boolean>(
|
||||
|
|
|
@ -33,14 +33,14 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { some, filter, map, fold } from 'fp-ts/lib/Option';
|
||||
import { pipe } from 'fp-ts/lib/pipeable';
|
||||
import { capitalize } from 'lodash';
|
||||
import { capitalize, isObject } from 'lodash';
|
||||
import { KibanaFeature } from '../../../../../features/public';
|
||||
import {
|
||||
getDurationNumberInItsUnit,
|
||||
getDurationUnitValue,
|
||||
} from '../../../../../alerts/common/parse_duration';
|
||||
import { loadAlertTypes } from '../../lib/alert_api';
|
||||
import { AlertReducerAction } from './alert_reducer';
|
||||
import { AlertReducerAction, InitialAlert } from './alert_reducer';
|
||||
import {
|
||||
AlertTypeModel,
|
||||
Alert,
|
||||
|
@ -48,18 +48,19 @@ import {
|
|||
AlertAction,
|
||||
AlertTypeIndex,
|
||||
AlertType,
|
||||
ValidationResult,
|
||||
} from '../../../types';
|
||||
import { getTimeOptions } from '../../../common/lib/get_time_options';
|
||||
import { useAlertsContext } from '../../context/alerts_context';
|
||||
import { ActionForm } from '../action_connector_form';
|
||||
import { ALERTS_FEATURE_ID } from '../../../../../alerts/common';
|
||||
import { AlertActionParam, ALERTS_FEATURE_ID } from '../../../../../alerts/common';
|
||||
import { hasAllPrivilege, hasShowActionsCapability } from '../../lib/capabilities';
|
||||
import { SolutionFilter } from './solution_filter';
|
||||
import './alert_form.scss';
|
||||
|
||||
const ENTER_KEY = 13;
|
||||
|
||||
export function validateBaseProperties(alertObject: Alert) {
|
||||
export function validateBaseProperties(alertObject: InitialAlert): ValidationResult {
|
||||
const validationResult = { errors: {} };
|
||||
const errors = {
|
||||
name: new Array<string>(),
|
||||
|
@ -92,12 +93,25 @@ export function validateBaseProperties(alertObject: Alert) {
|
|||
return validationResult;
|
||||
}
|
||||
|
||||
const hasErrors: (errors: IErrorObject) => boolean = (errors) =>
|
||||
!!Object.values(errors).find((errorList) => {
|
||||
if (isObject(errorList)) return hasErrors(errorList as IErrorObject);
|
||||
return errorList.length >= 1;
|
||||
});
|
||||
|
||||
export function isValidAlert(
|
||||
alertObject: InitialAlert | Alert,
|
||||
validationResult: IErrorObject
|
||||
): alertObject is Alert {
|
||||
return !hasErrors(validationResult);
|
||||
}
|
||||
|
||||
function getProducerFeatureName(producer: string, kibanaFeatures: KibanaFeature[]) {
|
||||
return kibanaFeatures.find((featureItem) => featureItem.id === producer)?.name;
|
||||
}
|
||||
|
||||
interface AlertFormProps {
|
||||
alert: Alert;
|
||||
alert: InitialAlert;
|
||||
dispatch: React.Dispatch<AlertReducerAction>;
|
||||
errors: IErrorObject;
|
||||
canChangeTrigger?: boolean; // to hide Change trigger button
|
||||
|
@ -203,10 +217,13 @@ export const AlertForm = ({
|
|||
|
||||
useEffect(() => {
|
||||
setAlertTypeModel(alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null);
|
||||
}, [alert, alertTypeRegistry]);
|
||||
if (alert.alertTypeId && alertTypesIndex && alertTypesIndex.has(alert.alertTypeId)) {
|
||||
setDefaultActionGroupId(alertTypesIndex.get(alert.alertTypeId)!.defaultActionGroupId);
|
||||
}
|
||||
}, [alert, alert.alertTypeId, alertTypesIndex, alertTypeRegistry]);
|
||||
|
||||
const setAlertProperty = useCallback(
|
||||
(key: string, value: any) => {
|
||||
<Key extends keyof Alert>(key: Key, value: Alert[Key] | null) => {
|
||||
dispatch({ command: { type: 'setProperty' }, payload: { key, value } });
|
||||
},
|
||||
[dispatch]
|
||||
|
@ -225,12 +242,16 @@ export const AlertForm = ({
|
|||
dispatch({ command: { type: 'setScheduleProperty' }, payload: { key, value } });
|
||||
};
|
||||
|
||||
const setActionProperty = (key: string, value: any, index: number) => {
|
||||
const setActionProperty = <Key extends keyof AlertAction>(
|
||||
key: Key,
|
||||
value: AlertAction[Key] | null,
|
||||
index: number
|
||||
) => {
|
||||
dispatch({ command: { type: 'setAlertActionProperty' }, payload: { key, value, index } });
|
||||
};
|
||||
|
||||
const setActionParamsProperty = useCallback(
|
||||
(key: string, value: any, index: number) => {
|
||||
(key: string, value: AlertActionParam, index: number) => {
|
||||
dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } });
|
||||
},
|
||||
[dispatch]
|
||||
|
@ -436,7 +457,10 @@ export const AlertForm = ({
|
|||
</EuiFlexGroup>
|
||||
)}
|
||||
<EuiHorizontalRule />
|
||||
{AlertParamsExpressionComponent ? (
|
||||
{AlertParamsExpressionComponent &&
|
||||
defaultActionGroupId &&
|
||||
alert.alertTypeId &&
|
||||
alertTypesIndex?.has(alert.alertTypeId) ? (
|
||||
<Suspense fallback={<CenterJustifiedSpinner />}>
|
||||
<AlertParamsExpressionComponent
|
||||
alertParams={alert.params}
|
||||
|
@ -446,12 +470,15 @@ export const AlertForm = ({
|
|||
setAlertParams={setAlertParams}
|
||||
setAlertProperty={setAlertProperty}
|
||||
alertsContext={alertsContext}
|
||||
defaultActionGroupId={defaultActionGroupId}
|
||||
actionGroups={alertTypesIndex.get(alert.alertTypeId)!.actionGroups}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{canShowActions &&
|
||||
defaultActionGroupId &&
|
||||
alertTypeModel &&
|
||||
alert.alertTypeId &&
|
||||
alertTypesIndex?.has(alert.alertTypeId) ? (
|
||||
<ActionForm
|
||||
actions={alert.actions}
|
||||
|
|
|
@ -3,38 +3,93 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { SavedObjectAttribute } from 'kibana/public';
|
||||
import { isEqual } from 'lodash';
|
||||
import { Reducer } from 'react';
|
||||
import { AlertActionParam, IntervalSchedule } from '../../../../../alerts/common';
|
||||
import { Alert, AlertAction } from '../../../types';
|
||||
|
||||
interface CommandType {
|
||||
type:
|
||||
export type InitialAlert = Partial<Alert> &
|
||||
Pick<Alert, 'params' | 'consumer' | 'schedule' | 'actions' | 'tags'>;
|
||||
|
||||
interface CommandType<
|
||||
T extends
|
||||
| 'setAlert'
|
||||
| 'setProperty'
|
||||
| 'setScheduleProperty'
|
||||
| 'setAlertParams'
|
||||
| 'setAlertActionParams'
|
||||
| 'setAlertActionProperty';
|
||||
| 'setAlertActionProperty'
|
||||
> {
|
||||
type: T;
|
||||
}
|
||||
|
||||
export interface AlertState {
|
||||
alert: any;
|
||||
alert: InitialAlert;
|
||||
}
|
||||
|
||||
export interface AlertReducerAction {
|
||||
command: CommandType;
|
||||
payload: {
|
||||
key: string;
|
||||
value: {};
|
||||
index?: number;
|
||||
};
|
||||
interface Payload<Keys, Value> {
|
||||
key: Keys;
|
||||
value: Value;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export const alertReducer = (state: any, action: AlertReducerAction) => {
|
||||
const { command, payload } = action;
|
||||
interface AlertPayload<Key extends keyof Alert> {
|
||||
key: Key;
|
||||
value: Alert[Key] | null;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
interface AlertActionPayload<Key extends keyof AlertAction> {
|
||||
key: Key;
|
||||
value: AlertAction[Key] | null;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
interface AlertSchedulePayload<Key extends keyof IntervalSchedule> {
|
||||
key: Key;
|
||||
value: IntervalSchedule[Key];
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export type AlertReducerAction =
|
||||
| {
|
||||
command: CommandType<'setAlert'>;
|
||||
payload: Payload<'alert', InitialAlert>;
|
||||
}
|
||||
| {
|
||||
command: CommandType<'setProperty'>;
|
||||
payload: AlertPayload<keyof Alert>;
|
||||
}
|
||||
| {
|
||||
command: CommandType<'setScheduleProperty'>;
|
||||
payload: AlertSchedulePayload<keyof IntervalSchedule>;
|
||||
}
|
||||
| {
|
||||
command: CommandType<'setAlertParams'>;
|
||||
payload: Payload<string, unknown>;
|
||||
}
|
||||
| {
|
||||
command: CommandType<'setAlertActionParams'>;
|
||||
payload: Payload<string, AlertActionParam>;
|
||||
}
|
||||
| {
|
||||
command: CommandType<'setAlertActionProperty'>;
|
||||
payload: AlertActionPayload<keyof AlertAction>;
|
||||
};
|
||||
|
||||
export type InitialAlertReducer = Reducer<{ alert: InitialAlert }, AlertReducerAction>;
|
||||
export type ConcreteAlertReducer = Reducer<{ alert: Alert }, AlertReducerAction>;
|
||||
|
||||
export const alertReducer = <AlertPhase extends InitialAlert | Alert>(
|
||||
state: { alert: AlertPhase },
|
||||
action: AlertReducerAction
|
||||
) => {
|
||||
const { alert } = state;
|
||||
|
||||
switch (command.type) {
|
||||
switch (action.command.type) {
|
||||
case 'setAlert': {
|
||||
const { key, value } = payload;
|
||||
const { key, value } = action.payload as Payload<'alert', AlertPhase>;
|
||||
if (key === 'alert') {
|
||||
return {
|
||||
...state,
|
||||
|
@ -45,7 +100,7 @@ export const alertReducer = (state: any, action: AlertReducerAction) => {
|
|||
}
|
||||
}
|
||||
case 'setProperty': {
|
||||
const { key, value } = payload;
|
||||
const { key, value } = action.payload as AlertPayload<keyof Alert>;
|
||||
if (isEqual(alert[key], value)) {
|
||||
return state;
|
||||
} else {
|
||||
|
@ -59,8 +114,8 @@ export const alertReducer = (state: any, action: AlertReducerAction) => {
|
|||
}
|
||||
}
|
||||
case 'setScheduleProperty': {
|
||||
const { key, value } = payload;
|
||||
if (isEqual(alert.schedule[key], value)) {
|
||||
const { key, value } = action.payload as AlertSchedulePayload<keyof IntervalSchedule>;
|
||||
if (alert.schedule && isEqual(alert.schedule[key], value)) {
|
||||
return state;
|
||||
} else {
|
||||
return {
|
||||
|
@ -76,7 +131,7 @@ export const alertReducer = (state: any, action: AlertReducerAction) => {
|
|||
}
|
||||
}
|
||||
case 'setAlertParams': {
|
||||
const { key, value } = payload;
|
||||
const { key, value } = action.payload as Payload<string, Record<string, unknown>>;
|
||||
if (isEqual(alert.params[key], value)) {
|
||||
return state;
|
||||
} else {
|
||||
|
@ -93,7 +148,10 @@ export const alertReducer = (state: any, action: AlertReducerAction) => {
|
|||
}
|
||||
}
|
||||
case 'setAlertActionParams': {
|
||||
const { key, value, index } = payload;
|
||||
const { key, value, index } = action.payload as Payload<
|
||||
keyof AlertAction,
|
||||
SavedObjectAttribute
|
||||
>;
|
||||
if (index === undefined || isEqual(alert.actions[index][key], value)) {
|
||||
return state;
|
||||
} else {
|
||||
|
@ -116,7 +174,7 @@ export const alertReducer = (state: any, action: AlertReducerAction) => {
|
|||
}
|
||||
}
|
||||
case 'setAlertActionProperty': {
|
||||
const { key, value, index } = payload;
|
||||
const { key, value, index } = action.payload as AlertActionPayload<keyof AlertAction>;
|
||||
if (index === undefined || isEqual(alert.actions[index][key], value)) {
|
||||
return state;
|
||||
} else {
|
||||
|
|
|
@ -5,6 +5,12 @@
|
|||
*/
|
||||
import { lazy } from 'react';
|
||||
import { suspendedComponentWithProps } from '../../lib/suspended_component_with_props';
|
||||
export {
|
||||
AlertConditions,
|
||||
ActionGroupWithCondition,
|
||||
AlertConditionsProps,
|
||||
} from './alert_conditions';
|
||||
export { AlertConditionsGroup } from './alert_conditions_group';
|
||||
|
||||
export const AlertAdd = suspendedComponentWithProps(lazy(() => import('./alert_add')));
|
||||
export const AlertEdit = suspendedComponentWithProps(lazy(() => import('./alert_edit')));
|
||||
|
|
|
@ -6,6 +6,12 @@
|
|||
|
||||
import { lazy } from 'react';
|
||||
import { suspendedComponentWithProps } from '../lib/suspended_component_with_props';
|
||||
export {
|
||||
ActionGroupWithCondition,
|
||||
AlertConditionsProps,
|
||||
AlertConditions,
|
||||
AlertConditionsGroup,
|
||||
} from './alert_form';
|
||||
|
||||
export const AlertAdd = suspendedComponentWithProps(lazy(() => import('./alert_form/alert_add')));
|
||||
export const AlertEdit = suspendedComponentWithProps(lazy(() => import('./alert_form/alert_edit')));
|
||||
|
|
|
@ -9,7 +9,12 @@ import { Plugin } from './plugin';
|
|||
export { AlertsContextProvider, AlertsContextValue } from './application/context/alerts_context';
|
||||
export { ActionsConnectorsContextProvider } from './application/context/actions_connectors_context';
|
||||
export { AlertAdd } from './application/sections/alert_form';
|
||||
export { AlertEdit } from './application/sections';
|
||||
export {
|
||||
AlertEdit,
|
||||
AlertConditions,
|
||||
AlertConditionsGroup,
|
||||
ActionGroupWithCondition,
|
||||
} from './application/sections';
|
||||
export { ActionForm } from './application/sections/action_connector_form';
|
||||
export {
|
||||
AlertAction,
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import type { PublicMethodsOf } from '@kbn/utility-types';
|
||||
import type { HttpSetup, DocLinksStart, ToastsSetup } from 'kibana/public';
|
||||
import { ComponentType } from 'react';
|
||||
import { ActionGroup } from '../../alerts/common';
|
||||
import { ActionGroup, AlertActionParam } from '../../alerts/common';
|
||||
import { ActionType } from '../../actions/common';
|
||||
import { TypeRegistry } from './application/type_registry';
|
||||
import {
|
||||
|
@ -52,7 +52,7 @@ export interface ActionConnectorFieldsProps<TActionConnector> {
|
|||
export interface ActionParamsProps<TParams> {
|
||||
actionParams: TParams;
|
||||
index: number;
|
||||
editAction: (property: string, value: any, index: number) => void;
|
||||
editAction: (key: string, value: AlertActionParam, index: number) => void;
|
||||
errors: IErrorObject;
|
||||
messageVariables?: ActionVariable[];
|
||||
defaultMessage?: string;
|
||||
|
@ -166,9 +166,11 @@ export interface AlertTypeParamsExpressionProps<
|
|||
alertInterval: string;
|
||||
alertThrottle: string;
|
||||
setAlertParams: (property: string, value: any) => void;
|
||||
setAlertProperty: (key: string, value: any) => void;
|
||||
setAlertProperty: <Key extends keyof Alert>(key: Key, value: Alert[Key] | null) => void;
|
||||
errors: IErrorObject;
|
||||
alertsContext: AlertsContextValue;
|
||||
defaultActionGroupId: string;
|
||||
actionGroups: ActionGroup[];
|
||||
}
|
||||
|
||||
export interface AlertTypeModel<AlertParamsType = any, AlertsContextValue = any> {
|
||||
|
|
Loading…
Reference in a new issue