diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/constants/aggregation_types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/constants/aggregation_types.ts deleted file mode 100644 index 68c2818502b2c2..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/constants/aggregation_types.ts +++ /dev/null @@ -1,17 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -export const AGGREGATION_TYPES: { [key: string]: string } = { - COUNT: 'count', - - AVERAGE: 'avg', - - SUM: 'sum', - - MIN: 'min', - - MAX: 'max', -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/constants/comparators.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/constants/comparators.ts deleted file mode 100644 index 21b350c0f8ce41..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/constants/comparators.ts +++ /dev/null @@ -1,13 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -export const COMPARATORS: { [key: string]: string } = { - GREATER_THAN: '>', - GREATER_THAN_OR_EQUALS: '>=', - BETWEEN: 'between', - LESS_THAN: '<', - LESS_THAN_OR_EQUALS: '<=', -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/constants/index.ts deleted file mode 100644 index f88ee5ee23f901..00000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/constants/index.ts +++ /dev/null @@ -1,8 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -export { COMPARATORS } from './comparators'; -export { AGGREGATION_TYPES } from './aggregation_types'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx index a34a032f833b28..a2ef67be7bca26 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -132,16 +132,24 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent { + const setDefaultExpressionValues = async () => { setAlertProperty('params', { - aggType: DEFAULT_VALUES.AGGREGATION_TYPE, - termSize: DEFAULT_VALUES.TERM_SIZE, - thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, - timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE, - timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT, - groupBy: DEFAULT_VALUES.GROUP_BY, - threshold: DEFAULT_VALUES.THRESHOLD, + ...alertParams, + aggType: aggType ?? DEFAULT_VALUES.AGGREGATION_TYPE, + termSize: termSize ?? DEFAULT_VALUES.TERM_SIZE, + thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, + timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT, + groupBy: groupBy ?? DEFAULT_VALUES.GROUP_BY, + threshold: threshold ?? DEFAULT_VALUES.THRESHOLD, }); + if (index.length > 0) { + const currentEsFields = await getFields(index); + const timeFields = getTimeFieldOptions(currentEsFields as any); + + setEsFields(currentEsFields); + setTimeFieldOptions([firstFieldOption, ...timeFields]); + } }; const getFields = async (indexes: string[]) => { @@ -258,7 +266,17 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent>; reloadAlerts?: () => Promise; http: HttpSetup; alertTypeRegistry: TypeRegistry; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index 1e53e7d9838480..ebbfb0fc4b76fc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -443,7 +443,7 @@ describe('updateAlert', () => { Array [ "/api/alert/123", Object { - "body": "{\\"throttle\\":\\"1m\\",\\"consumer\\":\\"alerting\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[],\\"createdAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"updatedAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"apiKey\\":null,\\"apiKeyOwner\\":null}", + "body": "{\\"throttle\\":\\"1m\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[]}", }, ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index e0ecae976146c0..ff6b4ba17c6d98 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -8,6 +8,7 @@ import { HttpSetup } from 'kibana/public'; import * as t from 'io-ts'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; +import { pick } from 'lodash'; import { alertStateSchema } from '../../../../alerting/common'; import { BASE_ALERT_API_PATH } from '../constants'; import { Alert, AlertType, AlertWithoutId, AlertTaskState } from '../../types'; @@ -126,7 +127,9 @@ export async function updateAlert({ id: string; }): Promise { return await http.put(`${BASE_ALERT_API_PATH}/${id}`, { - body: JSON.stringify(alert), + body: JSON.stringify( + pick(alert, ['throttle', 'name', 'tags', 'schedule', 'params', 'actions']) + ), }); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx similarity index 96% rename from x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_add.test.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index d52ca19f580229..7bc44eafe75434 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -69,8 +69,6 @@ describe('alert_add', () => { wrapper = mountWithIntl( {}, reloadAlerts: () => { return new Promise(() => {}); }, @@ -81,7 +79,11 @@ describe('alert_add', () => { uiSettings: deps.uiSettings, }} > - + {}} + /> ); // Wait for active space to resolve before requesting the component to update diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx similarity index 94% rename from x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_add.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index 20ba9f5a497153..2cb7435c1b5999 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -27,11 +27,19 @@ import { createAlert } from '../../lib/alert_api'; interface AlertAddProps { consumer: string; + addFlyoutVisible: boolean; + setAddFlyoutVisibility: React.Dispatch>; alertTypeId?: string; canChangeTrigger?: boolean; } -export const AlertAdd = ({ consumer, canChangeTrigger, alertTypeId }: AlertAddProps) => { +export const AlertAdd = ({ + consumer, + addFlyoutVisible, + setAddFlyoutVisibility, + canChangeTrigger, + alertTypeId, +}: AlertAddProps) => { const initialAlert = ({ params: {}, consumer, @@ -51,8 +59,6 @@ export const AlertAdd = ({ consumer, canChangeTrigger, alertTypeId }: AlertAddPr }; const { - addFlyoutVisible, - setAddFlyoutVisibility, reloadAlerts, http, toastNotifications, @@ -74,7 +80,7 @@ export const AlertAdd = ({ consumer, canChangeTrigger, alertTypeId }: AlertAddPr return null; } - const alertType = alertTypeRegistry.get(alert.alertTypeId); + const alertType = alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null; const errors = { ...(alertType ? alertType.validate(alert.params).errors : []), ...validateBaseProperties(alert).errors, @@ -106,7 +112,7 @@ export const AlertAdd = ({ consumer, canChangeTrigger, alertTypeId }: AlertAddPr const newAlert = await createAlert({ http, alert }); if (toastNotifications) { toastNotifications.addSuccess( - i18n.translate('xpack.triggersActionsUI.sections.alertForm.saveSuccessNotificationText', { + i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText', { defaultMessage: "Saved '{alertName}'", values: { alertName: newAlert.name, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx new file mode 100644 index 00000000000000..d216b4d2a4afe4 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -0,0 +1,131 @@ +/* + * 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 'test_utils/enzyme_helpers'; +import { act } from 'react-dom/test-utils'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ValidationResult } from '../../../types'; +import { AlertsContextProvider } from '../../context/alerts_context'; +import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; +import { ReactWrapper } from 'enzyme'; +import { AlertEdit } from './alert_edit'; +const actionTypeRegistry = actionTypeRegistryMock.create(); +const alertTypeRegistry = alertTypeRegistryMock.create(); + +describe('alert_edit', () => { + let deps: any; + let wrapper: ReactWrapper; + + beforeAll(async () => { + const mockes = coreMock.createSetup(); + deps = { + toastNotifications: mockes.notifications.toasts, + http: mockes.http, + uiSettings: mockes.uiSettings, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; + const alertType = { + id: 'my-alert-type', + iconClass: 'test', + name: 'test-alert', + validate: (): ValidationResult => { + return { errors: {} }; + }, + alertParamsExpression: () => , + }; + + const actionTypeModel = { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + + const alert = { + id: 'ab5661e0-197e-45ee-b477-302d89193b5e', + params: { + aggType: 'average', + threshold: [1000, 5000], + index: 'kibana_sample_data_flights', + timeField: 'timestamp', + aggField: 'DistanceMiles', + window: '1s', + comparator: 'between', + }, + consumer: 'alerting', + alertTypeId: 'my-alert-type', + enabled: false, + schedule: { interval: '1m' }, + actions: [ + { + actionTypeId: 'my-action-type', + group: 'threshold met', + params: { message: 'Alert [{{ctx.metadata.name}}] has exceeded the threshold' }, + message: 'Alert [{{ctx.metadata.name}}] has exceeded the threshold', + id: '917f5d41-fbc4-4056-a8ad-ac592f7dcee2', + }, + ], + tags: [], + name: 'test alert', + throttle: null, + apiKeyOwner: null, + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: new Date(), + muteAll: false, + mutedInstanceIds: [], + updatedAt: new Date(), + }; + actionTypeRegistry.get.mockReturnValueOnce(actionTypeModel); + actionTypeRegistry.has.mockReturnValue(true); + alertTypeRegistry.list.mockReturnValue([alertType]); + alertTypeRegistry.get.mockReturnValue(alertType); + alertTypeRegistry.has.mockReturnValue(true); + actionTypeRegistry.list.mockReturnValue([actionTypeModel]); + actionTypeRegistry.has.mockReturnValue(true); + + wrapper = mountWithIntl( + { + return new Promise(() => {}); + }, + http: deps!.http, + actionTypeRegistry: deps!.actionTypeRegistry, + alertTypeRegistry: deps!.alertTypeRegistry, + toastNotifications: deps!.toastNotifications, + uiSettings: deps!.uiSettings, + }} + > + {}} + initialAlert={alert} + /> + + ); + // Wait for active space to resolve before requesting the component to update + await act(async () => { + await nextTick(); + wrapper.update(); + }); + }); + + it('renders alert add flyout', () => { + expect(wrapper.find('[data-test-subj="editAlertFlyoutTitle"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="saveEditedAlertButton"]').exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx new file mode 100644 index 00000000000000..06d21c05582e09 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx @@ -0,0 +1,189 @@ +/* + * 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, { useCallback, useReducer, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiFlyoutHeader, + EuiFlyout, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiFlyoutBody, + EuiPortal, + EuiBetaBadge, +} 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 { updateAlert } from '../../lib/alert_api'; + +interface AlertEditProps { + initialAlert: Alert; + editFlyoutVisible: boolean; + setEditFlyoutVisibility: React.Dispatch>; +} + +export const AlertEdit = ({ + initialAlert, + editFlyoutVisible, + setEditFlyoutVisibility, +}: AlertEditProps) => { + const [{ alert }, dispatch] = useReducer(alertReducer, { alert: initialAlert }); + const [isSaving, setIsSaving] = useState(false); + + const { + reloadAlerts, + http, + toastNotifications, + alertTypeRegistry, + actionTypeRegistry, + } = useAlertsContext(); + + const closeFlyout = useCallback(() => { + setEditFlyoutVisibility(false); + setServerError(null); + }, [setEditFlyoutVisibility]); + + const [serverError, setServerError] = useState<{ + body: { message: string; error: string }; + } | null>(null); + + if (!editFlyoutVisible) { + return null; + } + + const alertType = alertTypeRegistry.get(alert.alertTypeId); + + const errors = { + ...(alertType ? alertType.validate(alert.params).errors : []), + ...validateBaseProperties(alert).errors, + } as IErrorObject; + const hasErrors = !!Object.keys(errors).find(errorKey => errors[errorKey].length >= 1); + + const actionsErrors = alert.actions.reduce( + (acc: Record, alertAction: AlertAction) => { + const actionType = actionTypeRegistry.get(alertAction.actionTypeId); + if (!actionType) { + return { ...acc }; + } + const actionValidationErrors = actionType.validateParams(alertAction.params); + return { ...acc, [alertAction.id]: actionValidationErrors }; + }, + {} + ) as Record; + + const hasActionErrors = !!Object.entries(actionsErrors) + .map(([, actionErrors]) => actionErrors) + .find((actionErrors: { errors: IErrorObject }) => { + return !!Object.keys(actionErrors.errors).find( + errorKey => actionErrors.errors[errorKey].length >= 1 + ); + }); + + async function onSaveAlert(): Promise { + try { + const newAlert = await updateAlert({ http, alert, id: alert.id }); + if (toastNotifications) { + toastNotifications.addSuccess( + i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText', { + defaultMessage: "Updated '{alertName}'", + values: { + alertName: newAlert.name, + }, + }) + ); + } + return newAlert; + } catch (errorRes) { + setServerError(errorRes); + } + } + + return ( + + + + +

+ +   + +

+
+
+ + + + + + + + {i18n.translate('xpack.triggersActionsUI.sections.alertEdit.cancelButtonLabel', { + defaultMessage: 'Cancel', + })} + + + + { + setIsSaving(true); + const savedAlert = await onSaveAlert(); + setIsSaving(false); + if (savedAlert) { + closeFlyout(); + if (reloadAlerts) { + reloadAlerts(); + } + } + }} + > + + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx similarity index 97% rename from x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_form.test.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx index aa71621f1a914e..0c22ce0fca80c2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx @@ -104,8 +104,6 @@ describe('alert_form', () => { wrapper = mountWithIntl( {}, reloadAlerts: () => { return new Promise(() => {}); }, @@ -180,8 +178,6 @@ describe('alert_form', () => { wrapper = mountWithIntl( {}, reloadAlerts: () => { return new Promise(() => {}); }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx similarity index 97% rename from x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_form.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 18dc88f54e9070..b875fae75c7dfd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -105,17 +105,25 @@ export const AlertForm = ({ const { http, toastNotifications, alertTypeRegistry, actionTypeRegistry } = alertsContext; const [alertTypeModel, setAlertTypeModel] = useState( - alertTypeRegistry.get(alert.alertTypeId) + alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null ); const [addModalVisible, setAddModalVisibility] = useState(false); const [isLoadingActionTypes, setIsLoadingActionTypes] = useState(false); const [actionTypesIndex, setActionTypesIndex] = useState(undefined); const [alertTypesIndex, setAlertTypesIndex] = useState(undefined); - const [alertInterval, setAlertInterval] = useState(null); - const [alertIntervalUnit, setAlertIntervalUnit] = useState('m'); - const [alertThrottle, setAlertThrottle] = useState(null); - const [alertThrottleUnit, setAlertThrottleUnit] = useState('m'); + const [alertInterval, setAlertInterval] = useState( + alert.schedule.interval ? parseInt(alert.schedule.interval.replace(/^[A-Za-z]+$/, ''), 0) : 1 + ); + const [alertIntervalUnit, setAlertIntervalUnit] = useState( + alert.schedule.interval ? alert.schedule.interval.replace(alertInterval.toString(), '') : 'm' + ); + const [alertThrottle, setAlertThrottle] = useState( + alert.throttle ? parseInt(alert.throttle.replace(/^[A-Za-z]+$/, ''), 0) : null + ); + const [alertThrottleUnit, setAlertThrottleUnit] = useState( + alert.throttle ? alert.throttle.replace((alertThrottle ?? '').toString(), '') : 'm' + ); const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState(true); const [connectors, setConnectors] = useState([]); const [defaultActionGroupId, setDefaultActionGroupId] = useState(undefined); @@ -155,18 +163,6 @@ export const AlertForm = ({ (async () => { try { const alertTypes = await loadAlertTypes({ http }); - // temp hack of API result - alertTypes.push({ - id: 'threshold', - actionGroups: [ - { id: 'alert', name: 'Alert' }, - { id: 'warning', name: 'Warning' }, - { id: 'ifUnacknowledged', name: 'If unacknowledged' }, - ], - name: 'threshold', - actionVariables: ['ctx.metadata.name', 'ctx.metadata.test'], - defaultActionGroupId: 'alert', - }); const index: AlertTypeIndex = {}; for (const alertTypeItem of alertTypes) { index[alertTypeItem.id] = alertTypeItem; @@ -786,12 +782,12 @@ export const AlertForm = ({ fullWidth min={1} compressed - value={alertInterval || 1} + value={alertInterval} name="interval" data-test-subj="intervalInput" onChange={e => { const interval = e.target.value !== '' ? parseInt(e.target.value, 10) : null; - setAlertInterval(interval); + setAlertInterval(interval ?? 1); setScheduleProperty('interval', `${e.target.value}${alertIntervalUnit}`); }} /> @@ -801,7 +797,7 @@ export const AlertForm = ({ fullWidth compressed value={alertIntervalUnit} - options={getTimeOptions(alertInterval ?? 1)} + options={getTimeOptions(alertInterval)} onChange={e => { setAlertIntervalUnit(e.target.value); setScheduleProperty('interval', `${alertInterval}${e.target.value}`); @@ -836,7 +832,9 @@ export const AlertForm = ({ options={getTimeOptions(alertThrottle ?? 1)} onChange={e => { setAlertThrottleUnit(e.target.value); - setAlertProperty('throttle', `${alertThrottle}${e.target.value}`); + if (alertThrottle) { + setAlertProperty('throttle', `${alertThrottle}${e.target.value}`); + } }} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_reducer.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.test.ts similarity index 100% rename from x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_reducer.test.ts rename to x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.test.ts diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_reducer.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts similarity index 100% rename from x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/alert_reducer.ts rename to x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/index.ts similarity index 87% rename from x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/index.ts rename to x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/index.ts index f88a8bb1c49d01..83ed9671238b16 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_add/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/index.ts @@ -5,3 +5,4 @@ */ export { AlertAdd } from './alert_add'; +export { AlertEdit } from './alert_edit'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 49e25dfbbf957c..2975b1ef6eba2d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -24,7 +24,7 @@ import { useHistory } from 'react-router-dom'; import { AlertsContextProvider } from '../../../context/alerts_context'; import { useAppDependencies } from '../../../app_context'; import { ActionType, Alert, AlertTableItem, AlertTypeIndex, Pagination } from '../../../../types'; -import { AlertAdd } from '../../alert_add'; +import { AlertAdd, AlertEdit } from '../../alert_form'; import { BulkOperationPopover } from '../../common/components/bulk_operation_popover'; import { AlertQuickEditButtonsWithApi as AlertQuickEditButtons } from '../../common/components/alert_quick_edit_buttons'; import { CollapsedItemActionsWithApi as CollapsedItemActions } from './collapsed_item_actions'; @@ -84,6 +84,8 @@ export const AlertsList: React.FunctionComponent = () => { data: [], totalItemCount: 0, }); + const [editedAlertItem, setEditedAlertItem] = useState(undefined); + const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); useEffect(() => { loadAlertsData(); @@ -158,6 +160,11 @@ export const AlertsList: React.FunctionComponent = () => { } } + async function editItem(alertTableItem: AlertTableItem) { + setEditedAlertItem(alertTableItem); + setEditFlyoutVisibility(true); + } + const alertsTableColumns = [ { field: 'name', @@ -210,6 +217,31 @@ export const AlertsList: React.FunctionComponent = () => { truncateText: false, 'data-test-subj': 'alertsTableCell-interval', }, + { + field: '', + name: '', + width: '50px', + actions: canSave + ? [ + { + render: (item: AlertTableItem) => { + return ( + editItem(item)} + > + + + ); + }, + }, + ] + : [], + }, { name: '', width: '40px', @@ -396,8 +428,6 @@ export const AlertsList: React.FunctionComponent = () => { {(alertTypesState.isLoading || alertsState.isLoading) && } { dataFieldsFormats: dataPlugin.fieldFormats, }} > - + + {editedAlertItem ? ( + + ) : null} ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts index efe58aedb8353c..93e61cf5b4f43e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts @@ -78,9 +78,13 @@ describe('get()', () => { `); }); - test(`return null when action type doesn't exist`, () => { + test(`throw error when action type doesn't exist`, () => { const actionTypeRegistry = new TypeRegistry(); - expect(actionTypeRegistry.get('not-exist-action-type')).toBeNull(); + expect(() => + actionTypeRegistry.get('not-exist-action-type') + ).toThrowErrorMatchingInlineSnapshot( + `"Object type \\"not-exist-action-type\\" is not registered."` + ); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.ts b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.ts index 3390d8910a45fa..8eaa9638d0806f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.ts @@ -43,9 +43,16 @@ export class TypeRegistry { /** * Returns an object type, null if not registered */ - public get(id: string): T | null { + public get(id: string): T { if (!this.has(id)) { - return null; + throw new Error( + i18n.translate('xpack.triggersActionsUI.typeRegistry.get.missingActionTypeErrorMessage', { + defaultMessage: 'Object type "{id}" is not registered.', + values: { + id, + }, + }) + ); } return this.objectTypes.get(id)!; } diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index f13ed5983d0d16..0be0a919112f8d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -8,7 +8,7 @@ import { PluginInitializerContext } from 'src/core/public'; import { Plugin } from './plugin'; export { AlertsContextProvider } from './application/context/alerts_context'; -export { AlertAdd } from './application/sections/alert_add'; +export { AlertAdd } from './application/sections/alert_form'; export function plugin(ctx: PluginInitializerContext) { return new Plugin(ctx); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 60ba03df6a9a8d..25ebc6d610f869 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -18,20 +18,20 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const supertest = getService('supertest'); const find = getService('find'); - async function createAlert() { + async function createAlert(alertTypeId?: string, name?: string, params?: any) { const { body: createdAlert } = await supertest .post(`/api/alert`) .set('kbn-xsrf', 'foo') .send({ enabled: true, - name: generateUniqueKey(), + name: name ?? generateUniqueKey(), tags: ['foo', 'bar'], - alertTypeId: 'test.noop', + alertTypeId: alertTypeId ?? 'test.noop', consumer: 'test', schedule: { interval: '1m' }, throttle: '1m', actions: [], - params: {}, + params: params ?? {}, }) .expect(200); return createdAlert; @@ -60,6 +60,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('thresholdAlertTimeFieldSelect'); const fieldOptions = await find.allByCssSelector('#thresholdTimeField option'); await fieldOptions[1].click(); + await nameInput.click(); await testSubjects.click('.slack-ActionTypeSelectOption'); await testSubjects.click('createActionConnectorButton'); const connectorNameInput = await testSubjects.find('nameInput'); @@ -84,8 +85,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const toastTitle = await pageObjects.common.closeToast(); expect(toastTitle).to.eql(`Saved '${alertName}'`); await pageObjects.triggersActionsUI.searchAlerts(alertName); - const searchResultsAfterEdit = await pageObjects.triggersActionsUI.getAlertsList(); - expect(searchResultsAfterEdit).to.eql([ + const searchResultsAfterSave = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResultsAfterSave).to.eql([ { name: alertName, tagsText: '', @@ -111,6 +112,57 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ]); }); + it('should edit an alert', async () => { + const createdAlert = await createAlert('.index-threshold', 'new alert', { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000, 5000], + index: ['.kibana_1'], + timeField: 'alert', + }); + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.eql([ + { + name: createdAlert.name, + tagsText: 'foo, bar', + alertType: 'Index Threshold', + interval: '1m', + }, + ]); + const editLink = await testSubjects.findAll('alertsTableCell-editLink'); + await editLink[0].click(); + + const updatedAlertName = 'Changed Alert Name'; + const nameInputToUpdate = await testSubjects.find('alertNameInput'); + await nameInputToUpdate.click(); + await nameInputToUpdate.clearValue(); + await nameInputToUpdate.type(updatedAlertName); + + await find.clickByCssSelector('[data-test-subj="saveEditedAlertButton"]:not(disabled)'); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Updated '${updatedAlertName}'`); + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(updatedAlertName); + + const searchResultsAfterEdit = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResultsAfterEdit).to.eql([ + { + name: updatedAlertName, + tagsText: 'foo, bar', + alertType: 'Index Threshold', + interval: '1m', + }, + ]); + }); + it('should search for tags', async () => { const createdAlert = await createAlert(); await pageObjects.common.navigateToApp('triggersActions');