diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx index 57e6fc4a9e18b..7d9a963c9c6b3 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx @@ -13,12 +13,13 @@ import { IUiSettingsClient, ApplicationStart, } from 'kibana/public'; -import { BASE_PATH, Section } from './constants'; +import { BASE_PATH, Section, routeToAlertDetails } from './constants'; import { TriggersActionsUIHome } from './home'; import { AppContextProvider, useAppDependencies } from './app_context'; import { hasShowAlertsCapability } from './lib/capabilities'; import { LegacyDependencies, ActionTypeModel, AlertTypeModel } from '../types'; import { TypeRegistry } from './type_registry'; +import { AlertDetailsRouteWithApi as AlertDetailsRoute } from './sections/alert_details/components/alert_details_route'; export interface AppDeps { chrome: ChromeStart; @@ -53,11 +54,8 @@ export const AppWithoutRouter = ({ sectionsRegex }: any) => { const DEFAULT_SECTION: Section = canShowAlerts ? 'alerts' : 'connectors'; return ( - + + {canShowAlerts && } ); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/index.ts index a8364ffe21019..11b094dea0e62 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/index.ts +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/index.ts @@ -13,6 +13,7 @@ export type Section = 'connectors' | 'alerts'; export const routeToHome = `${BASE_PATH}`; export const routeToConnectors = `${BASE_PATH}/connectors`; export const routeToAlerts = `${BASE_PATH}/alerts`; +export const routeToAlertDetails = `${BASE_PATH}/alert/:alertId`; export { TIME_UNITS } from './time_units'; export enum SORT_ORDERS { diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.test.ts index bc2949917edea..00a55bb2588bb 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.test.ts +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.test.ts @@ -24,6 +24,7 @@ describe('loadActionTypes', () => { { id: 'test', name: 'Test', + enabled: true, }, ]; http.get.mockResolvedValueOnce(resolvedValue); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts index 0106970cf9c38..35d1a095188de 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts @@ -8,15 +8,22 @@ import { Alert, AlertType } from '../../types'; import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; import { createAlert, + deleteAlert, deleteAlerts, disableAlerts, enableAlerts, + disableAlert, + enableAlert, + loadAlert, loadAlerts, loadAlertTypes, muteAlerts, unmuteAlerts, + muteAlert, + unmuteAlert, updateAlert, } from './alert_api'; +import uuid from 'uuid'; const http = httpServiceMock.createStartContract(); @@ -42,6 +49,31 @@ describe('loadAlertTypes', () => { }); }); +describe('loadAlert', () => { + test('should call get API with base parameters', async () => { + const alertId = uuid.v4(); + const resolvedValue = { + id: alertId, + name: 'name', + tags: [], + enabled: true, + alertTypeId: '.noop', + schedule: { interval: '1s' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + expect(await loadAlert({ http, alertId })).toEqual(resolvedValue); + expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}`); + }); +}); + describe('loadAlerts', () => { test('should call find API with base parameters', async () => { const resolvedValue = { @@ -230,6 +262,19 @@ describe('loadAlerts', () => { }); }); +describe('deleteAlert', () => { + test('should call delete API for alert', async () => { + const id = '1'; + const result = await deleteAlert({ http, id }); + expect(result).toEqual(undefined); + expect(http.delete.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/1", + ] + `); + }); +}); + describe('deleteAlerts', () => { test('should call delete API for each alert', async () => { const ids = ['1', '2', '3']; @@ -335,6 +380,62 @@ describe('updateAlert', () => { }); }); +describe('enableAlert', () => { + test('should call enable alert API', async () => { + const result = await enableAlert({ http, id: '1' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/_enable", + ], + ] + `); + }); +}); + +describe('disableAlert', () => { + test('should call disable alert API', async () => { + const result = await disableAlert({ http, id: '1' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/_disable", + ], + ] + `); + }); +}); + +describe('muteAlert', () => { + test('should call mute alert API', async () => { + const result = await muteAlert({ http, id: '1' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/_mute_all", + ], + ] + `); + }); +}); + +describe('unmuteAlert', () => { + test('should call unmute alert API', async () => { + const result = await unmuteAlert({ http, id: '1' }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/_unmute_all", + ], + ] + `); + }); +}); + describe('enableAlerts', () => { test('should call enable alert API per alert', async () => { const ids = ['1', '2', '3']; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.ts index 0b4f5731c1315..acc318bd5fbea 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.ts +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.ts @@ -12,6 +12,16 @@ export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { + return await http.get(`${BASE_ALERT_API_PATH}/${alertId}`); +} + export async function loadAlerts({ http, page, @@ -55,6 +65,10 @@ export async function loadAlerts({ }); } +export async function deleteAlert({ id, http }: { id: string; http: HttpSetup }): Promise { + await http.delete(`${BASE_ALERT_API_PATH}/${id}`); +} + export async function deleteAlerts({ ids, http, @@ -62,7 +76,7 @@ export async function deleteAlerts({ ids: string[]; http: HttpSetup; }): Promise { - await Promise.all(ids.map(id => http.delete(`${BASE_ALERT_API_PATH}/${id}`))); + await Promise.all(ids.map(id => deleteAlert({ http, id }))); } export async function createAlert({ @@ -91,6 +105,10 @@ export async function updateAlert({ }); } +export async function enableAlert({ id, http }: { id: string; http: HttpSetup }): Promise { + await http.post(`${BASE_ALERT_API_PATH}/${id}/_enable`); +} + export async function enableAlerts({ ids, http, @@ -98,7 +116,11 @@ export async function enableAlerts({ ids: string[]; http: HttpSetup; }): Promise { - await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_enable`))); + await Promise.all(ids.map(id => enableAlert({ id, http }))); +} + +export async function disableAlert({ id, http }: { id: string; http: HttpSetup }): Promise { + await http.post(`${BASE_ALERT_API_PATH}/${id}/_disable`); } export async function disableAlerts({ @@ -108,11 +130,19 @@ export async function disableAlerts({ ids: string[]; http: HttpSetup; }): Promise { - await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_disable`))); + await Promise.all(ids.map(id => disableAlert({ id, http }))); +} + +export async function muteAlert({ id, http }: { id: string; http: HttpSetup }): Promise { + await http.post(`${BASE_ALERT_API_PATH}/${id}/_mute_all`); } export async function muteAlerts({ ids, http }: { ids: string[]; http: HttpSetup }): Promise { - await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_mute_all`))); + await Promise.all(ids.map(id => muteAlert({ http, id }))); +} + +export async function unmuteAlert({ id, http }: { id: string; http: HttpSetup }): Promise { + await http.post(`${BASE_ALERT_API_PATH}/${id}/_unmute_all`); } export async function unmuteAlerts({ @@ -122,5 +152,5 @@ export async function unmuteAlerts({ ids: string[]; http: HttpSetup; }): Promise { - await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_unmute_all`))); + await Promise.all(ids.map(id => unmuteAlert({ id, http }))); } diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/value_validators.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/value_validators.test.ts new file mode 100644 index 0000000000000..90f575d9391b3 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/value_validators.test.ts @@ -0,0 +1,77 @@ +/* + * 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 { throwIfAbsent, throwIfIsntContained } from './value_validators'; +import uuid from 'uuid'; + +describe('throwIfAbsent', () => { + test('throws if value is absent', () => { + [undefined, null].forEach(val => { + expect(() => { + throwIfAbsent('OMG no value')(val); + }).toThrowErrorMatchingInlineSnapshot(`"OMG no value"`); + }); + }); + + test('doesnt throws if value is present but falsey', () => { + [false, ''].forEach(val => { + expect(throwIfAbsent('OMG no value')(val)).toEqual(val); + }); + }); + + test('doesnt throw if value is present', () => { + expect(throwIfAbsent('OMG no value')({})).toEqual({}); + }); +}); + +describe('throwIfIsntContained', () => { + test('throws if value is absent', () => { + expect(() => { + throwIfIsntContained(new Set([uuid.v4()]), 'OMG no value', val => val)([uuid.v4()]); + }).toThrowErrorMatchingInlineSnapshot(`"OMG no value"`); + }); + + test('throws if value is absent using custom message', () => { + const id = uuid.v4(); + expect(() => { + throwIfIsntContained( + new Set([id]), + (value: string) => `OMG no ${value}`, + val => val + )([uuid.v4()]); + }).toThrow(`OMG no ${id}`); + }); + + test('returns values if value is present', () => { + const id = uuid.v4(); + const values = [uuid.v4(), uuid.v4(), id, uuid.v4()]; + expect(throwIfIsntContained(new Set([id]), 'OMG no value', val => val)(values)).toEqual( + values + ); + }); + + test('returns values if multiple values is present', () => { + const [firstId, secondId] = [uuid.v4(), uuid.v4()]; + const values = [uuid.v4(), uuid.v4(), secondId, uuid.v4(), firstId]; + expect( + throwIfIsntContained(new Set([firstId, secondId]), 'OMG no value', val => val)(values) + ).toEqual(values); + }); + + test('allows a custom value extractor', () => { + const [firstId, secondId] = [uuid.v4(), uuid.v4()]; + const values = [ + { id: firstId, some: 'prop' }, + { id: secondId, someOther: 'prop' }, + ]; + expect( + throwIfIsntContained<{ id: string }>( + new Set([firstId, secondId]), + 'OMG no value', + (val: { id: string }) => val.id + )(values) + ).toEqual(values); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/value_validators.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/value_validators.ts new file mode 100644 index 0000000000000..7ee7359086406 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/value_validators.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { constant } from 'lodash'; + +export function throwIfAbsent(message: string) { + return (value: T | undefined): T => { + if (value === undefined || value === null) { + throw new Error(message); + } + return value; + }; +} + +export function throwIfIsntContained( + requiredValues: Set, + message: string | ((requiredValue: string) => string), + valueExtractor: (value: T) => string +) { + const toError = typeof message === 'function' ? message : constant(message); + return (values: T[]) => { + const availableValues = new Set(values.map(valueExtractor)); + for (const value of requiredValues.values()) { + if (!availableValues.has(value)) { + throw new Error(toError(value)); + } + } + return values; + }; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx index 6896ac954bb06..f27f7d8c3054d 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -82,7 +82,11 @@ describe('action_connector_form', () => { editFlyoutVisible: false, setEditFlyoutVisibility: () => {}, actionTypesIndex: { - 'my-action-type': { id: 'my-action-type', name: 'my-action-type-name' }, + 'my-action-type': { + id: 'my-action-type', + name: 'my-action-type-name', + enabled: true, + }, }, reloadConnectors: () => { return new Promise(() => {}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.test.tsx index 6ef2f62315d9a..6d98a5e3d120f 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -75,8 +75,8 @@ describe('connector_add_flyout', () => { editFlyoutVisible: false, setEditFlyoutVisibility: state => {}, actionTypesIndex: { - 'first-action-type': { id: 'first-action-type', name: 'first' }, - 'second-action-type': { id: 'second-action-type', name: 'second' }, + 'first-action-type': { id: 'first-action-type', name: 'first', enabled: true }, + 'second-action-type': { id: 'second-action-type', name: 'second', enabled: true }, }, reloadConnectors: () => { return new Promise(() => {}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx index 71ba52f047d61..a03296c7c3679 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -58,7 +58,9 @@ describe('connector_add_flyout', () => { setAddFlyoutVisibility: state => {}, editFlyoutVisible: false, setEditFlyoutVisibility: state => {}, - actionTypesIndex: { 'my-action-type': { id: 'my-action-type', name: 'test' } }, + actionTypesIndex: { + 'my-action-type': { id: 'my-action-type', name: 'test', enabled: true }, + }, reloadConnectors: () => { return new Promise(() => {}); }, diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx index 57e950a98eb2a..0dc38523bfab8 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -84,7 +84,7 @@ describe('connector_edit_flyout', () => { editFlyoutVisible: true, setEditFlyoutVisibility: state => {}, actionTypesIndex: { - 'test-action-type-id': { id: 'test-action-type-id', name: 'test' }, + 'test-action-type-id': { id: 'test-action-type-id', name: 'test', enabled: true }, }, reloadConnectors: () => { return new Promise(() => {}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details.test.tsx new file mode 100644 index 0000000000000..228bceb87cad7 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details.test.tsx @@ -0,0 +1,519 @@ +/* + * 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 uuid from 'uuid'; +import { shallow } from 'enzyme'; +import { AlertDetails } from './alert_details'; +import { Alert, ActionType } from '../../../../types'; +import { EuiTitle, EuiBadge, EuiFlexItem, EuiButtonEmpty, EuiSwitch } from '@elastic/eui'; +import { times, random } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n/react'; + +jest.mock('../../../app_context', () => ({ + useAppDependencies: jest.fn(() => ({ + http: jest.fn(), + legacy: { + capabilities: { + get: jest.fn(() => ({})), + }, + }, + })), +})); + +jest.mock('../../../lib/capabilities', () => ({ + hasSaveAlertsCapability: jest.fn(() => true), +})); + +const mockAlertApis = { + muteAlert: jest.fn(), + unmuteAlert: jest.fn(), + enableAlert: jest.fn(), + disableAlert: jest.fn(), +}; + +// const AlertDetails = withBulkAlertOperations(RawAlertDetails); +describe('alert_details', () => { + // mock Api handlers + + it('renders the alert name as a title', () => { + const alert = mockAlert(); + const alertType = { + id: '.noop', + name: 'No Op', + }; + + expect( + shallow( + + ).containsMatchingElement( + +

{alert.name}

+
+ ) + ).toBeTruthy(); + }); + + it('renders the alert type badge', () => { + const alert = mockAlert(); + const alertType = { + id: '.noop', + name: 'No Op', + }; + + expect( + shallow( + + ).containsMatchingElement({alertType.name}) + ).toBeTruthy(); + }); + + describe('actions', () => { + it('renders an alert action', () => { + const alert = mockAlert({ + actions: [ + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + const actionTypes: ActionType[] = [ + { + id: '.server-log', + name: 'Server log', + enabled: true, + }, + ]; + + expect( + shallow( + + ).containsMatchingElement( + + {actionTypes[0].name} + + ) + ).toBeTruthy(); + }); + + it('renders a counter for multiple alert action', () => { + const actionCount = random(1, 10); + const alert = mockAlert({ + actions: [ + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.server-log', + }, + ...times(actionCount, () => ({ + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.email', + })), + ], + }); + const alertType = { + id: '.noop', + name: 'No Op', + }; + const actionTypes: ActionType[] = [ + { + id: '.server-log', + name: 'Server log', + enabled: true, + }, + { + id: '.email', + name: 'Send email', + enabled: true, + }, + ]; + + const details = shallow( + + ); + + expect( + details.containsMatchingElement( + + {actionTypes[0].name} + + ) + ).toBeTruthy(); + + expect( + details.containsMatchingElement( + + {`+${actionCount}`} + + ) + ).toBeTruthy(); + }); + }); + + describe('links', () => { + it('links to the Edit flyout', () => { + const alert = mockAlert(); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + expect( + shallow( + + ).containsMatchingElement( + + + + ) + ).toBeTruthy(); + }); + + it('links to the app that created the alert', () => { + const alert = mockAlert(); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + expect( + shallow( + + ).containsMatchingElement( + + + + ) + ).toBeTruthy(); + }); + + it('links to the activity log', () => { + const alert = mockAlert(); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + expect( + shallow( + + ).containsMatchingElement( + + + + ) + ).toBeTruthy(); + }); + }); +}); + +describe('enable button', () => { + it('should render an enable button when alert is enabled', () => { + const alert = mockAlert({ + enabled: true, + }); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + const enableButton = shallow( + + ) + .find(EuiSwitch) + .find('[name="enable"]') + .first(); + + expect(enableButton.props()).toMatchObject({ + checked: true, + disabled: false, + }); + }); + + it('should render an enable button when alert is disabled', () => { + const alert = mockAlert({ + enabled: false, + }); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + const enableButton = shallow( + + ) + .find(EuiSwitch) + .find('[name="enable"]') + .first(); + + expect(enableButton.props()).toMatchObject({ + checked: false, + disabled: false, + }); + }); + + it('should enable the alert when alert is disabled and button is clicked', () => { + const alert = mockAlert({ + enabled: true, + }); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + const disableAlert = jest.fn(); + const enableButton = shallow( + + ) + .find(EuiSwitch) + .find('[name="enable"]') + .first(); + + enableButton.simulate('click'); + const handler = enableButton.prop('onChange'); + expect(typeof handler).toEqual('function'); + expect(disableAlert).toHaveBeenCalledTimes(0); + handler!({} as React.FormEvent); + expect(disableAlert).toHaveBeenCalledTimes(1); + }); + + it('should disable the alert when alert is enabled and button is clicked', () => { + const alert = mockAlert({ + enabled: false, + }); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + const enableAlert = jest.fn(); + const enableButton = shallow( + + ) + .find(EuiSwitch) + .find('[name="enable"]') + .first(); + + enableButton.simulate('click'); + const handler = enableButton.prop('onChange'); + expect(typeof handler).toEqual('function'); + expect(enableAlert).toHaveBeenCalledTimes(0); + handler!({} as React.FormEvent); + expect(enableAlert).toHaveBeenCalledTimes(1); + }); +}); + +describe('mute button', () => { + it('should render an mute button when alert is enabled', () => { + const alert = mockAlert({ + enabled: true, + muteAll: false, + }); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + const enableButton = shallow( + + ) + .find(EuiSwitch) + .find('[name="mute"]') + .first(); + + expect(enableButton.props()).toMatchObject({ + checked: false, + disabled: false, + }); + }); + + it('should render an muted button when alert is muted', () => { + const alert = mockAlert({ + enabled: true, + muteAll: true, + }); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + const enableButton = shallow( + + ) + .find(EuiSwitch) + .find('[name="mute"]') + .first(); + + expect(enableButton.props()).toMatchObject({ + checked: true, + disabled: false, + }); + }); + + it('should mute the alert when alert is unmuted and button is clicked', () => { + const alert = mockAlert({ + enabled: true, + muteAll: false, + }); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + const muteAlert = jest.fn(); + const enableButton = shallow( + + ) + .find(EuiSwitch) + .find('[name="mute"]') + .first(); + + enableButton.simulate('click'); + const handler = enableButton.prop('onChange'); + expect(typeof handler).toEqual('function'); + expect(muteAlert).toHaveBeenCalledTimes(0); + handler!({} as React.FormEvent); + expect(muteAlert).toHaveBeenCalledTimes(1); + }); + + it('should unmute the alert when alert is muted and button is clicked', () => { + const alert = mockAlert({ + enabled: true, + muteAll: true, + }); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + const unmuteAlert = jest.fn(); + const enableButton = shallow( + + ) + .find(EuiSwitch) + .find('[name="mute"]') + .first(); + + enableButton.simulate('click'); + const handler = enableButton.prop('onChange'); + expect(typeof handler).toEqual('function'); + expect(unmuteAlert).toHaveBeenCalledTimes(0); + handler!({} as React.FormEvent); + expect(unmuteAlert).toHaveBeenCalledTimes(1); + }); + + it('should disabled mute button when alert is disabled', () => { + const alert = mockAlert({ + enabled: false, + muteAll: false, + }); + + const alertType = { + id: '.noop', + name: 'No Op', + }; + + const enableButton = shallow( + + ) + .find(EuiSwitch) + .find('[name="mute"]') + .first(); + + expect(enableButton.props()).toMatchObject({ + checked: false, + disabled: true, + }); + }); +}); + +function mockAlert(overloads: Partial = {}): Alert { + return { + id: uuid.v4(), + enabled: true, + name: `alert-${uuid.v4()}`, + tags: [], + alertTypeId: '.noop', + consumer: 'consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + ...overloads, + }; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details.tsx new file mode 100644 index 0000000000000..ffdf846efd49d --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details.tsx @@ -0,0 +1,176 @@ +/* + * 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, { useState } from 'react'; +import { indexBy } from 'lodash'; +import { + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiBadge, + EuiPage, + EuiPageContentBody, + EuiButtonEmpty, + EuiSwitch, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useAppDependencies } from '../../../app_context'; +import { hasSaveAlertsCapability } from '../../../lib/capabilities'; +import { Alert, AlertType, ActionType } from '../../../../types'; +import { + ComponentOpts as BulkOperationsComponentOpts, + withBulkAlertOperations, +} from '../../common/components/with_bulk_alert_api_operations'; + +type AlertDetailsProps = { + alert: Alert; + alertType: AlertType; + actionTypes: ActionType[]; +} & Pick; + +export const AlertDetails: React.FunctionComponent = ({ + alert, + alertType, + actionTypes, + disableAlert, + enableAlert, + unmuteAlert, + muteAlert, +}) => { + const { capabilities } = useAppDependencies(); + + const canSave = hasSaveAlertsCapability(capabilities); + + const actionTypesByTypeId = indexBy(actionTypes, 'id'); + const [firstAction, ...otherActions] = alert.actions; + + const [isEnabled, setIsEnabled] = useState(alert.enabled); + const [isMuted, setIsMuted] = useState(alert.muteAll); + + return ( + + + + + + +

{alert.name}

+
+
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + {alertType.name} + + {firstAction && ( + + + {actionTypesByTypeId[firstAction.actionTypeId].name ?? + firstAction.actionTypeId} + + + )} + {otherActions.length ? ( + + +{otherActions.length} + + ) : null} + + + + + + { + if (isEnabled) { + setIsEnabled(false); + await disableAlert(alert); + } else { + setIsEnabled(true); + await enableAlert(alert); + } + }} + label={ + + } + /> + + + { + if (isMuted) { + setIsMuted(false); + await unmuteAlert(alert); + } else { + setIsMuted(true); + await muteAlert(alert); + } + }} + label={ + + } + /> + + + + + +
+
+
+ ); +}; + +export const AlertDetailsWithApi = withBulkAlertOperations(AlertDetails); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details_route.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details_route.test.tsx new file mode 100644 index 0000000000000..7a40104e97d9f --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details_route.test.tsx @@ -0,0 +1,409 @@ +/* + * 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 uuid from 'uuid'; +import { shallow } from 'enzyme'; +import { createMemoryHistory, createLocation } from 'history'; +import { ToastsApi } from 'kibana/public'; +import { AlertDetailsRoute, getAlertData } from './alert_details_route'; +import { Alert } from '../../../../types'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +jest.mock('../../../app_context', () => { + const toastNotifications = jest.fn(); + return { + useAppDependencies: jest.fn(() => ({ toastNotifications })), + }; +}); +describe('alert_details_route', () => { + it('render a loader while fetching data', () => { + const alert = mockAlert(); + + expect( + shallow( + + ).containsMatchingElement() + ).toBeTruthy(); + }); +}); + +describe('getAlertData useEffect handler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches alert', async () => { + const alert = mockAlert(); + const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); + + loadAlert.mockImplementationOnce(async () => alert); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + + await getAlertData( + alert.id, + loadAlert, + loadAlertTypes, + loadActionTypes, + setAlert, + setAlertType, + setActionTypes, + toastNotifications + ); + + expect(loadAlert).toHaveBeenCalledWith(alert.id); + expect(setAlert).toHaveBeenCalledWith(alert); + }); + + it('fetches alert and action types', async () => { + const actionType = { + id: '.server-log', + name: 'Server log', + enabled: true, + }; + const alert = mockAlert({ + actions: [ + { + group: '', + id: uuid.v4(), + actionTypeId: actionType.id, + params: {}, + }, + ], + }); + const alertType = { + id: alert.alertTypeId, + name: 'type name', + }; + const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); + + loadAlert.mockImplementation(async () => alert); + loadAlertTypes.mockImplementation(async () => [alertType]); + loadActionTypes.mockImplementation(async () => [actionType]); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + + await getAlertData( + alert.id, + loadAlert, + loadAlertTypes, + loadActionTypes, + setAlert, + setAlertType, + setActionTypes, + toastNotifications + ); + + expect(loadAlertTypes).toHaveBeenCalledTimes(1); + expect(loadActionTypes).toHaveBeenCalledTimes(1); + + expect(setAlertType).toHaveBeenCalledWith(alertType); + expect(setActionTypes).toHaveBeenCalledWith([actionType]); + }); + + it('displays an error if the alert isnt found', async () => { + const actionType = { + id: '.server-log', + name: 'Server log', + enabled: true, + }; + const alert = mockAlert({ + actions: [ + { + group: '', + id: uuid.v4(), + actionTypeId: actionType.id, + params: {}, + }, + ], + }); + + const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); + + loadAlert.mockImplementation(async () => { + throw new Error('OMG'); + }); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + await getAlertData( + alert.id, + loadAlert, + loadAlertTypes, + loadActionTypes, + setAlert, + setAlertType, + setActionTypes, + toastNotifications + ); + expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); + expect(toastNotifications.addDanger).toHaveBeenCalledWith({ + title: 'Unable to load alert: OMG', + }); + }); + + it('displays an error if the alert type isnt loaded', async () => { + const actionType = { + id: '.server-log', + name: 'Server log', + enabled: true, + }; + const alert = mockAlert({ + actions: [ + { + group: '', + id: uuid.v4(), + actionTypeId: actionType.id, + params: {}, + }, + ], + }); + + const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); + + loadAlert.mockImplementation(async () => alert); + + loadAlertTypes.mockImplementation(async () => { + throw new Error('OMG no alert type'); + }); + loadActionTypes.mockImplementation(async () => [actionType]); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + await getAlertData( + alert.id, + loadAlert, + loadAlertTypes, + loadActionTypes, + setAlert, + setAlertType, + setActionTypes, + toastNotifications + ); + expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); + expect(toastNotifications.addDanger).toHaveBeenCalledWith({ + title: 'Unable to load alert: OMG no alert type', + }); + }); + + it('displays an error if the action type isnt loaded', async () => { + const actionType = { + id: '.server-log', + name: 'Server log', + enabled: true, + }; + const alert = mockAlert({ + actions: [ + { + group: '', + id: uuid.v4(), + actionTypeId: actionType.id, + params: {}, + }, + ], + }); + const alertType = { + id: alert.alertTypeId, + name: 'type name', + }; + + const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); + + loadAlert.mockImplementation(async () => alert); + + loadAlertTypes.mockImplementation(async () => [alertType]); + loadActionTypes.mockImplementation(async () => { + throw new Error('OMG no action type'); + }); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + await getAlertData( + alert.id, + loadAlert, + loadAlertTypes, + loadActionTypes, + setAlert, + setAlertType, + setActionTypes, + toastNotifications + ); + expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); + expect(toastNotifications.addDanger).toHaveBeenCalledWith({ + title: 'Unable to load alert: OMG no action type', + }); + }); + + it('displays an error if the alert type isnt found', async () => { + const actionType = { + id: '.server-log', + name: 'Server log', + enabled: true, + }; + const alert = mockAlert({ + actions: [ + { + group: '', + id: uuid.v4(), + actionTypeId: actionType.id, + params: {}, + }, + ], + }); + + const alertType = { + id: uuid.v4(), + name: 'type name', + }; + + const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); + + loadAlert.mockImplementation(async () => alert); + loadAlertTypes.mockImplementation(async () => [alertType]); + loadActionTypes.mockImplementation(async () => [actionType]); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + await getAlertData( + alert.id, + loadAlert, + loadAlertTypes, + loadActionTypes, + setAlert, + setAlertType, + setActionTypes, + toastNotifications + ); + expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); + expect(toastNotifications.addDanger).toHaveBeenCalledWith({ + title: `Unable to load alert: Invalid Alert Type: ${alert.alertTypeId}`, + }); + }); + + it('displays an error if an action type isnt found', async () => { + const availableActionType = { + id: '.server-log', + name: 'Server log', + enabled: true, + }; + const missingActionType = { + id: '.noop', + name: 'No Op', + enabled: true, + }; + const alert = mockAlert({ + actions: [ + { + group: '', + id: uuid.v4(), + actionTypeId: availableActionType.id, + params: {}, + }, + { + group: '', + id: uuid.v4(), + actionTypeId: missingActionType.id, + params: {}, + }, + ], + }); + + const alertType = { + id: uuid.v4(), + name: 'type name', + }; + + const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); + + loadAlert.mockImplementation(async () => alert); + loadAlertTypes.mockImplementation(async () => [alertType]); + loadActionTypes.mockImplementation(async () => [availableActionType]); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + await getAlertData( + alert.id, + loadAlert, + loadAlertTypes, + loadActionTypes, + setAlert, + setAlertType, + setActionTypes, + toastNotifications + ); + expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); + expect(toastNotifications.addDanger).toHaveBeenCalledWith({ + title: `Unable to load alert: Invalid Action Type: ${missingActionType.id}`, + }); + }); +}); + +function mockApis() { + return { + loadAlert: jest.fn(), + loadAlertTypes: jest.fn(), + loadActionTypes: jest.fn(), + }; +} + +function mockStateSetter() { + return { + setAlert: jest.fn(), + setAlertType: jest.fn(), + setActionTypes: jest.fn(), + }; +} + +function mockRouterProps(alert: Alert) { + return { + match: { + isExact: false, + path: `/alert/${alert.id}`, + url: '', + params: { alertId: alert.id }, + }, + history: createMemoryHistory(), + location: createLocation(`/alert/${alert.id}`), + }; +} +function mockAlert(overloads: Partial = {}): Alert { + return { + id: uuid.v4(), + enabled: true, + name: `alert-${uuid.v4()}`, + tags: [], + alertTypeId: '.noop', + consumer: 'consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + ...overloads, + }; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details_route.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details_route.tsx new file mode 100644 index 0000000000000..4e00ea304d987 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_details/components/alert_details_route.tsx @@ -0,0 +1,118 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import React, { useState, useEffect } from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { ToastsApi } from 'kibana/public'; +import { Alert, AlertType, ActionType } from '../../../../types'; +import { useAppDependencies } from '../../../app_context'; +import { AlertDetailsWithApi as AlertDetails } from './alert_details'; +import { throwIfAbsent, throwIfIsntContained } from '../../../lib/value_validators'; +import { + ComponentOpts as AlertApis, + withBulkAlertOperations, +} from '../../common/components/with_bulk_alert_api_operations'; +import { + ComponentOpts as ActionApis, + withActionOperations, +} from '../../common/components/with_actions_api_operations'; + +type AlertDetailsRouteProps = RouteComponentProps<{ + alertId: string; +}> & + Pick & + Pick; + +export const AlertDetailsRoute: React.FunctionComponent = ({ + match: { + params: { alertId }, + }, + loadAlert, + loadAlertTypes, + loadActionTypes, +}) => { + const { http, toastNotifications } = useAppDependencies(); + + const [alert, setAlert] = useState(null); + const [alertType, setAlertType] = useState(null); + const [actionTypes, setActionTypes] = useState(null); + + useEffect(() => { + getAlertData( + alertId, + loadAlert, + loadAlertTypes, + loadActionTypes, + setAlert, + setAlertType, + setActionTypes, + toastNotifications + ); + }, [alertId, http, loadActionTypes, loadAlert, loadAlertTypes, toastNotifications]); + + return alert && alertType && actionTypes ? ( + + ) : ( +
+ +
+ ); +}; + +export async function getAlertData( + alertId: string, + loadAlert: AlertApis['loadAlert'], + loadAlertTypes: AlertApis['loadAlertTypes'], + loadActionTypes: ActionApis['loadActionTypes'], + setAlert: React.Dispatch>, + setAlertType: React.Dispatch>, + setActionTypes: React.Dispatch>, + toastNotifications: Pick +) { + try { + const loadedAlert = await loadAlert(alertId); + setAlert(loadedAlert); + + const [loadedAlertType, loadedActionTypes] = await Promise.all([ + loadAlertTypes() + .then(types => types.find(type => type.id === loadedAlert.alertTypeId)) + .then(throwIfAbsent(`Invalid Alert Type: ${loadedAlert.alertTypeId}`)), + loadActionTypes().then( + throwIfIsntContained( + new Set(loadedAlert.actions.map(action => action.actionTypeId)), + (requiredActionType: string) => `Invalid Action Type: ${requiredActionType}`, + (action: ActionType) => action.id + ) + ), + ]); + + setAlertType(loadedAlertType); + setActionTypes(loadedActionTypes); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertMessage', + { + defaultMessage: 'Unable to load alert: {message}', + values: { + message: e.message, + }, + } + ), + }); + } +} + +export const AlertDetailsRouteWithApi = withActionOperations( + withBulkAlertOperations(AlertDetailsRoute) +); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx index ff1510ea873d3..f410fff44172f 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -21,7 +21,14 @@ jest.mock('../../../lib/alert_api', () => ({ loadAlerts: jest.fn(), loadAlertTypes: jest.fn(), })); - +jest.mock('react-router-dom', () => ({ + useHistory: () => ({ + push: jest.fn(), + }), + useLocation: () => ({ + pathname: '/triggersActions/alerts/', + }), +})); const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx index 12122983161bd..32de924f63e80 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx @@ -15,19 +15,23 @@ import { EuiFlexItem, EuiIcon, EuiSpacer, + EuiLink, } from '@elastic/eui'; +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 { BulkActionPopover } from './bulk_action_popover'; -import { CollapsedItemActions } from './collapsed_item_actions'; +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'; import { TypeFilter } from './type_filter'; import { ActionTypeFilter } from './action_type_filter'; import { loadAlerts, loadAlertTypes } from '../../../lib/alert_api'; import { loadActionTypes } from '../../../lib/action_connector_api'; import { hasDeleteAlertsCapability, hasSaveAlertsCapability } from '../../../lib/capabilities'; +import { routeToAlertDetails } from '../../../constants'; const ENTER_KEY = 13; @@ -43,6 +47,7 @@ interface AlertState { } export const AlertsList: React.FunctionComponent = () => { + const history = useHistory(); const { http, injectedMetadata, toastNotifications, capabilities } = useAppDependencies(); const canDelete = hasDeleteAlertsCapability(capabilities); const canSave = hasSaveAlertsCapability(capabilities); @@ -151,6 +156,18 @@ export const AlertsList: React.FunctionComponent = () => { sortable: false, truncateText: true, 'data-test-subj': 'alertsTableCell-name', + render: (name: string, alert: AlertTableItem) => { + return ( + { + history.push(routeToAlertDetails.replace(`:alertId`, alert.id)); + }} + > + {name} + + ); + }, }, { field: 'tagsText', @@ -236,17 +253,19 @@ export const AlertsList: React.FunctionComponent = () => { {selectedIds.length > 0 && canDelete && ( - setIsPerformingAction(true)} - onActionPerformed={() => { - loadAlertsData(); - setIsPerformingAction(false); - }} - /> + + setIsPerformingAction(true)} + onActionPerformed={() => { + loadAlertsData(); + setIsPerformingAction(false); + }} + /> + )} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/collapsed_item_actions.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/collapsed_item_actions.tsx index aa1c6dd7c5b9a..2bac159ed79ed 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/collapsed_item_actions.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/collapsed_item_actions.tsx @@ -20,23 +20,25 @@ import { AlertTableItem } from '../../../../types'; import { useAppDependencies } from '../../../app_context'; import { hasDeleteAlertsCapability, hasSaveAlertsCapability } from '../../../lib/capabilities'; import { - deleteAlerts, - disableAlerts, - enableAlerts, - muteAlerts, - unmuteAlerts, -} from '../../../lib/alert_api'; + ComponentOpts as BulkOperationsComponentOpts, + withBulkAlertOperations, +} from '../../common/components/with_bulk_alert_api_operations'; -export interface ComponentOpts { +export type ComponentOpts = { item: AlertTableItem; onAlertChanged: () => void; -} +} & BulkOperationsComponentOpts; export const CollapsedItemActions: React.FunctionComponent = ({ item, onAlertChanged, + disableAlert, + enableAlert, + unmuteAlert, + muteAlert, + deleteAlert, }: ComponentOpts) => { - const { http, capabilities } = useAppDependencies(); + const { capabilities } = useAppDependencies(); const canDelete = hasDeleteAlertsCapability(capabilities); const canSave = hasSaveAlertsCapability(capabilities); @@ -71,9 +73,9 @@ export const CollapsedItemActions: React.FunctionComponent = ({ data-test-subj="enableSwitch" onChange={async () => { if (item.enabled) { - await disableAlerts({ http, ids: [item.id] }); + await disableAlert(item); } else { - await enableAlerts({ http, ids: [item.id] }); + await enableAlert(item); } onAlertChanged(); }} @@ -93,9 +95,9 @@ export const CollapsedItemActions: React.FunctionComponent = ({ data-test-subj="muteSwitch" onChange={async () => { if (item.muteAll) { - await unmuteAlerts({ http, ids: [item.id] }); + await unmuteAlert(item); } else { - await muteAlerts({ http, ids: [item.id] }); + await muteAlert(item); } onAlertChanged(); }} @@ -115,7 +117,7 @@ export const CollapsedItemActions: React.FunctionComponent = ({ color="text" data-test-subj="deleteAlert" onClick={async () => { - await deleteAlerts({ http, ids: [item.id] }); + await deleteAlert(item); onAlertChanged(); }} > @@ -129,3 +131,5 @@ export const CollapsedItemActions: React.FunctionComponent = ({ ); }; + +export const CollapsedItemActionsWithApi = withBulkAlertOperations(CollapsedItemActions); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/bulk_action_popover.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/alert_quick_edit_buttons.tsx similarity index 52% rename from x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/bulk_action_popover.tsx rename to x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/alert_quick_edit_buttons.tsx index 59ec52ac83a6c..9635e6cd11983 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/bulk_action_popover.tsx +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/alert_quick_edit_buttons.tsx @@ -5,34 +5,35 @@ */ import { i18n } from '@kbn/i18n'; -import React, { useState } from 'react'; +import React, { useState, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButton, EuiButtonEmpty, EuiFormRow, EuiPopover } from '@elastic/eui'; +import { EuiButtonEmpty } from '@elastic/eui'; -import { AlertTableItem } from '../../../../types'; +import { Alert } from '../../../../types'; import { useAppDependencies } from '../../../app_context'; import { - deleteAlerts, - disableAlerts, - enableAlerts, - muteAlerts, - unmuteAlerts, -} from '../../../lib/alert_api'; + withBulkAlertOperations, + ComponentOpts as BulkOperationsComponentOpts, +} from './with_bulk_alert_api_operations'; -export interface ComponentOpts { - selectedItems: AlertTableItem[]; - onPerformingAction: () => void; - onActionPerformed: () => void; -} +export type ComponentOpts = { + selectedItems: Alert[]; + onPerformingAction?: () => void; + onActionPerformed?: () => void; +} & BulkOperationsComponentOpts; -export const BulkActionPopover: React.FunctionComponent = ({ +export const AlertQuickEditButtons: React.FunctionComponent = ({ selectedItems, - onPerformingAction, - onActionPerformed, + onPerformingAction = noop, + onActionPerformed = noop, + muteAlerts, + unmuteAlerts, + enableAlerts, + disableAlerts, + deleteAlerts, }: ComponentOpts) => { - const { http, toastNotifications } = useAppDependencies(); + const { toastNotifications } = useAppDependencies(); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isMutingAlerts, setIsMutingAlerts] = useState(false); const [isUnmutingAlerts, setIsUnmutingAlerts] = useState(false); const [isEnablingAlerts, setIsEnablingAlerts] = useState(false); @@ -47,9 +48,8 @@ export const BulkActionPopover: React.FunctionComponent = ({ async function onmMuteAllClick() { onPerformingAction(); setIsMutingAlerts(true); - const ids = selectedItems.filter(item => !isAlertMuted(item)).map(item => item.id); try { - await muteAlerts({ http, ids }); + await muteAlerts(selectedItems); } catch (e) { toastNotifications.addDanger({ title: i18n.translate( @@ -68,9 +68,8 @@ export const BulkActionPopover: React.FunctionComponent = ({ async function onUnmuteAllClick() { onPerformingAction(); setIsUnmutingAlerts(true); - const ids = selectedItems.filter(isAlertMuted).map(item => item.id); try { - await unmuteAlerts({ http, ids }); + await unmuteAlerts(selectedItems); } catch (e) { toastNotifications.addDanger({ title: i18n.translate( @@ -89,9 +88,8 @@ export const BulkActionPopover: React.FunctionComponent = ({ async function onEnableAllClick() { onPerformingAction(); setIsEnablingAlerts(true); - const ids = selectedItems.filter(isAlertDisabled).map(item => item.id); try { - await enableAlerts({ http, ids }); + await enableAlerts(selectedItems); } catch (e) { toastNotifications.addDanger({ title: i18n.translate( @@ -110,9 +108,8 @@ export const BulkActionPopover: React.FunctionComponent = ({ async function onDisableAllClick() { onPerformingAction(); setIsDisablingAlerts(true); - const ids = selectedItems.filter(item => !isAlertDisabled(item)).map(item => item.id); try { - await disableAlerts({ http, ids }); + await disableAlerts(selectedItems); } catch (e) { toastNotifications.addDanger({ title: i18n.translate( @@ -131,9 +128,8 @@ export const BulkActionPopover: React.FunctionComponent = ({ async function deleteSelectedItems() { onPerformingAction(); setIsDeletingAlerts(true); - const ids = selectedItems.map(item => item.id); try { - await deleteAlerts({ http, ids }); + await deleteAlerts(selectedItems); } catch (e) { toastNotifications.addDanger({ title: i18n.translate( @@ -150,104 +146,83 @@ export const BulkActionPopover: React.FunctionComponent = ({ } return ( - setIsPopoverOpen(false)} - data-test-subj="bulkAction" - button={ - setIsPopoverOpen(!isPopoverOpen)} + + {!allAlertsMuted && ( + - - } - > - {!allAlertsMuted && ( - - - - - + )} {allAlertsMuted && ( - - - - - + + + )} {allAlertsDisabled && ( - - - - - + + + )} {!allAlertsDisabled && ( - - - - - - )} - - - + )} + + + + + ); }; -function isAlertDisabled(alert: AlertTableItem) { +export const AlertQuickEditButtonsWithApi = withBulkAlertOperations(AlertQuickEditButtons); + +function isAlertDisabled(alert: Alert) { return alert.enabled === false; } -function isAlertMuted(alert: AlertTableItem) { +function isAlertMuted(alert: Alert) { return alert.muteAll === true; } + +function noop() {} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/bulk_operation_popover.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/bulk_operation_popover.tsx new file mode 100644 index 0000000000000..d0fd0e1792818 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/bulk_operation_popover.tsx @@ -0,0 +1,42 @@ +/* + * 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, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiFormRow, EuiPopover } from '@elastic/eui'; + +export const BulkOperationPopover: React.FunctionComponent = ({ children }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + setIsPopoverOpen(false)} + data-test-subj="bulkAction" + button={ + setIsPopoverOpen(!isPopoverOpen)} + > + + + } + > + {children && + React.Children.map(children, child => + React.isValidElement(child) ? ( + {React.cloneElement(child, {})} + ) : ( + child + ) + )} + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/with_actions_api_operations.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/with_actions_api_operations.test.tsx new file mode 100644 index 0000000000000..dd6b8775ba3d0 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/with_actions_api_operations.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import { shallow, mount } from 'enzyme'; +import { withActionOperations, ComponentOpts } from './with_actions_api_operations'; +import * as actionApis from '../../../lib/action_connector_api'; +import { useAppDependencies } from '../../../app_context'; + +jest.mock('../../../lib/action_connector_api'); + +jest.mock('../../../app_context', () => { + const http = jest.fn(); + return { + useAppDependencies: jest.fn(() => ({ + http, + })), + }; +}); + +describe('with_action_api_operations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('extends any component with Action Api methods', () => { + const ComponentToExtend = (props: ComponentOpts) => { + expect(typeof props.loadActionTypes).toEqual('function'); + return
; + }; + + const ExtendedComponent = withActionOperations(ComponentToExtend); + expect(shallow().type()).toEqual(ComponentToExtend); + }); + + it('loadActionTypes calls the loadActionTypes api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ loadActionTypes }: ComponentOpts) => { + return ; + }; + + const ExtendedComponent = withActionOperations(ComponentToExtend); + const component = mount(); + component.find('button').simulate('click'); + + expect(actionApis.loadActionTypes).toHaveBeenCalledTimes(1); + expect(actionApis.loadActionTypes).toHaveBeenCalledWith({ http }); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/with_actions_api_operations.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/with_actions_api_operations.tsx new file mode 100644 index 0000000000000..45e6c6b10532c --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/with_actions_api_operations.tsx @@ -0,0 +1,28 @@ +/* + * 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 from 'react'; + +import { ActionType } from '../../../../types'; +import { useAppDependencies } from '../../../app_context'; +import { loadActionTypes } from '../../../lib/action_connector_api'; + +export interface ComponentOpts { + loadActionTypes: () => Promise; +} + +export type PropsWithOptionalApiHandlers = Omit & Partial; + +export function withActionOperations( + WrappedComponent: React.ComponentType +): React.FunctionComponent> { + return (props: PropsWithOptionalApiHandlers) => { + const { http } = useAppDependencies(); + return ( + loadActionTypes({ http })} /> + ); + }; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx new file mode 100644 index 0000000000000..30a065479ce33 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx @@ -0,0 +1,269 @@ +/* + * 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 { shallow, mount } from 'enzyme'; +import uuid from 'uuid'; +import { withBulkAlertOperations, ComponentOpts } from './with_bulk_alert_api_operations'; +import * as alertApi from '../../../lib/alert_api'; +import { useAppDependencies } from '../../../app_context'; +import { Alert } from '../../../../types'; + +jest.mock('../../../lib/alert_api'); + +jest.mock('../../../app_context', () => { + const http = jest.fn(); + return { + useAppDependencies: jest.fn(() => ({ + http, + legacy: { + capabilities: { + get: jest.fn(() => ({})), + }, + }, + })), + }; +}); + +describe('with_bulk_alert_api_operations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('extends any component with AlertApi methods', () => { + const ComponentToExtend = (props: ComponentOpts) => { + expect(typeof props.muteAlerts).toEqual('function'); + expect(typeof props.unmuteAlerts).toEqual('function'); + expect(typeof props.enableAlerts).toEqual('function'); + expect(typeof props.disableAlerts).toEqual('function'); + expect(typeof props.deleteAlerts).toEqual('function'); + expect(typeof props.muteAlert).toEqual('function'); + expect(typeof props.unmuteAlert).toEqual('function'); + expect(typeof props.enableAlert).toEqual('function'); + expect(typeof props.disableAlert).toEqual('function'); + expect(typeof props.deleteAlert).toEqual('function'); + expect(typeof props.loadAlert).toEqual('function'); + expect(typeof props.loadAlertTypes).toEqual('function'); + return
; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + expect(shallow().type()).toEqual(ComponentToExtend); + }); + + // single alert + it('muteAlert calls the muteAlert api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ muteAlert, alert }: ComponentOpts & { alert: Alert }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const alert = mockAlert(); + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.muteAlert).toHaveBeenCalledTimes(1); + expect(alertApi.muteAlert).toHaveBeenCalledWith({ id: alert.id, http }); + }); + + it('unmuteAlert calls the unmuteAlert api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ unmuteAlert, alert }: ComponentOpts & { alert: Alert }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const alert = mockAlert({ muteAll: true }); + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.unmuteAlert).toHaveBeenCalledTimes(1); + expect(alertApi.unmuteAlert).toHaveBeenCalledWith({ id: alert.id, http }); + }); + + it('enableAlert calls the muteAlerts api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ enableAlert, alert }: ComponentOpts & { alert: Alert }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const alert = mockAlert({ enabled: false }); + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.enableAlert).toHaveBeenCalledTimes(1); + expect(alertApi.enableAlert).toHaveBeenCalledWith({ id: alert.id, http }); + }); + + it('disableAlert calls the disableAlert api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ disableAlert, alert }: ComponentOpts & { alert: Alert }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const alert = mockAlert(); + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.disableAlert).toHaveBeenCalledTimes(1); + expect(alertApi.disableAlert).toHaveBeenCalledWith({ id: alert.id, http }); + }); + + it('deleteAlert calls the deleteAlert api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ deleteAlert, alert }: ComponentOpts & { alert: Alert }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const alert = mockAlert(); + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.deleteAlert).toHaveBeenCalledTimes(1); + expect(alertApi.deleteAlert).toHaveBeenCalledWith({ id: alert.id, http }); + }); + + // bulk alerts + it('muteAlerts calls the muteAlerts api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ muteAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const alerts = [mockAlert(), mockAlert()]; + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.muteAlerts).toHaveBeenCalledTimes(1); + expect(alertApi.muteAlerts).toHaveBeenCalledWith({ ids: [alerts[0].id, alerts[1].id], http }); + }); + + it('unmuteAlerts calls the unmuteAlerts api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ unmuteAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const alerts = [mockAlert({ muteAll: true }), mockAlert({ muteAll: true })]; + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.unmuteAlerts).toHaveBeenCalledTimes(1); + expect(alertApi.unmuteAlerts).toHaveBeenCalledWith({ ids: [alerts[0].id, alerts[1].id], http }); + }); + + it('enableAlerts calls the muteAlertss api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ enableAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const alerts = [ + mockAlert({ enabled: false }), + mockAlert({ enabled: true }), + mockAlert({ enabled: false }), + ]; + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.enableAlerts).toHaveBeenCalledTimes(1); + expect(alertApi.enableAlerts).toHaveBeenCalledWith({ ids: [alerts[0].id, alerts[2].id], http }); + }); + + it('disableAlerts calls the disableAlerts api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ disableAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const alerts = [mockAlert(), mockAlert()]; + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.disableAlerts).toHaveBeenCalledTimes(1); + expect(alertApi.disableAlerts).toHaveBeenCalledWith({ + ids: [alerts[0].id, alerts[1].id], + http, + }); + }); + + it('deleteAlerts calls the deleteAlerts api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ deleteAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const alerts = [mockAlert(), mockAlert()]; + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.deleteAlerts).toHaveBeenCalledTimes(1); + expect(alertApi.deleteAlerts).toHaveBeenCalledWith({ ids: [alerts[0].id, alerts[1].id], http }); + }); + + it('loadAlert calls the loadAlert api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ + loadAlert, + alertId, + }: ComponentOpts & { alertId: Alert['id'] }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const alertId = uuid.v4(); + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.loadAlert).toHaveBeenCalledTimes(1); + expect(alertApi.loadAlert).toHaveBeenCalledWith({ alertId, http }); + }); + + it('loadAlertTypes calls the loadAlertTypes api', () => { + const { http } = useAppDependencies(); + const ComponentToExtend = ({ loadAlertTypes }: ComponentOpts) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.loadAlertTypes).toHaveBeenCalledTimes(1); + expect(alertApi.loadAlertTypes).toHaveBeenCalledWith({ http }); + }); +}); + +function mockAlert(overloads: Partial = {}): Alert { + return { + id: uuid.v4(), + enabled: true, + name: `alert-${uuid.v4()}`, + tags: [], + alertTypeId: '.noop', + consumer: 'consumer', + schedule: { interval: '1m' }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + ...overloads, + }; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/with_bulk_alert_api_operations.tsx new file mode 100644 index 0000000000000..c61ba631ab868 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -0,0 +1,103 @@ +/* + * 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 from 'react'; + +import { Alert, AlertType } from '../../../../types'; +import { useAppDependencies } from '../../../app_context'; +import { + deleteAlerts, + disableAlerts, + enableAlerts, + muteAlerts, + unmuteAlerts, + deleteAlert, + disableAlert, + enableAlert, + muteAlert, + unmuteAlert, + loadAlert, + loadAlertTypes, +} from '../../../lib/alert_api'; + +export interface ComponentOpts { + muteAlerts: (alerts: Alert[]) => Promise; + unmuteAlerts: (alerts: Alert[]) => Promise; + enableAlerts: (alerts: Alert[]) => Promise; + disableAlerts: (alerts: Alert[]) => Promise; + deleteAlerts: (alerts: Alert[]) => Promise; + muteAlert: (alert: Alert) => Promise; + unmuteAlert: (alert: Alert) => Promise; + enableAlert: (alert: Alert) => Promise; + disableAlert: (alert: Alert) => Promise; + deleteAlert: (alert: Alert) => Promise; + loadAlert: (id: Alert['id']) => Promise; + loadAlertTypes: () => Promise; +} + +export type PropsWithOptionalApiHandlers = Omit & Partial; + +export function withBulkAlertOperations( + WrappedComponent: React.ComponentType +): React.FunctionComponent> { + return (props: PropsWithOptionalApiHandlers) => { + const { http } = useAppDependencies(); + return ( + + muteAlerts({ http, ids: items.filter(item => !isAlertMuted(item)).map(item => item.id) }) + } + unmuteAlerts={async (items: Alert[]) => + unmuteAlerts({ http, ids: items.filter(isAlertMuted).map(item => item.id) }) + } + enableAlerts={async (items: Alert[]) => + enableAlerts({ http, ids: items.filter(isAlertDisabled).map(item => item.id) }) + } + disableAlerts={async (items: Alert[]) => + disableAlerts({ + http, + ids: items.filter(item => !isAlertDisabled(item)).map(item => item.id), + }) + } + deleteAlerts={async (items: Alert[]) => + deleteAlerts({ http, ids: items.map(item => item.id) }) + } + muteAlert={async (alert: Alert) => { + if (!isAlertMuted(alert)) { + return muteAlert({ http, id: alert.id }); + } + }} + unmuteAlert={async (alert: Alert) => { + if (isAlertMuted(alert)) { + return unmuteAlert({ http, id: alert.id }); + } + }} + enableAlert={async (alert: Alert) => { + if (isAlertDisabled(alert)) { + return enableAlert({ http, id: alert.id }); + } + }} + disableAlert={async (alert: Alert) => { + if (!isAlertDisabled(alert)) { + return disableAlert({ http, id: alert.id }); + } + }} + deleteAlert={async (alert: Alert) => deleteAlert({ http, id: alert.id })} + loadAlert={async (alertId: Alert['id']) => loadAlert({ http, alertId })} + loadAlertTypes={async () => loadAlertTypes({ http })} + /> + ); + }; +} + +function isAlertDisabled(alert: Alert) { + return alert.enabled === false; +} + +function isAlertMuted(alert: Alert) { + return alert.muteAll === true; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts index ed63ade903104..7fb7d0bf48e4d 100644 --- a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import { TypeRegistry } from './application/type_registry'; -import { SanitizedAlert as Alert } from '../../../alerting/common'; -export { SanitizedAlert as Alert, AlertAction } from '../../../alerting/common'; +import { SanitizedAlert as Alert, AlertAction } from '../../../alerting/common'; +import { ActionType } from '../../../../../plugins/actions/common'; + +export { Alert, AlertAction }; +export { ActionType }; export type ActionTypeIndex = Record; export type AlertTypeIndex = Record; @@ -47,11 +50,6 @@ export interface ValidationResult { errors: Record; } -export interface ActionType { - id: string; - name: string; -} - export interface ActionConnector { secrets: Record; id: string; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts new file mode 100644 index 0000000000000..e8ed54571c77c --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -0,0 +1,146 @@ +/* + * 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 expect from '@kbn/expect'; +import uuid from 'uuid'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header', 'alertDetailsUI']); + const browser = getService('browser'); + const alerting = getService('alerting'); + + describe('Alert Details', function() { + const testRunUuid = uuid.v4(); + + before(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + + const actions = await Promise.all([ + alerting.actions.createAction({ + name: `server-log-${testRunUuid}-${0}`, + actionTypeId: '.server-log', + config: {}, + secrets: {}, + }), + alerting.actions.createAction({ + name: `server-log-${testRunUuid}-${1}`, + actionTypeId: '.server-log', + config: {}, + secrets: {}, + }), + ]); + + const alert = await alerting.alerts.createAlwaysFiringWithActions( + `test-alert-${testRunUuid}`, + actions.map(action => ({ + id: action.id, + group: 'default', + params: { + message: 'from alert 1s', + level: 'warn', + }, + })) + ); + + // refresh to see alert + await browser.refresh(); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify content + await testSubjects.existOrFail('alertsList'); + + // click on first alert + await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name); + }); + + it('renders the alert details', async () => { + const headingText = await pageObjects.alertDetailsUI.getHeadingText(); + expect(headingText).to.be(`test-alert-${testRunUuid}`); + + const alertType = await pageObjects.alertDetailsUI.getAlertType(); + expect(alertType).to.be(`Always Firing`); + + const { actionType, actionCount } = await pageObjects.alertDetailsUI.getActionsLabels(); + expect(actionType).to.be(`Server log`); + expect(actionCount).to.be(`+1`); + }); + + it('should disable the alert', async () => { + const enableSwitch = await testSubjects.find('enableSwitch'); + + const isChecked = await enableSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); + + await enableSwitch.click(); + + const enabledSwitchAfterDisabling = await testSubjects.find('enableSwitch'); + const isCheckedAfterDisabling = await enabledSwitchAfterDisabling.getAttribute( + 'aria-checked' + ); + expect(isCheckedAfterDisabling).to.eql('false'); + }); + + it('shouldnt allow you to mute a disabled alert', async () => { + const disabledEnableSwitch = await testSubjects.find('enableSwitch'); + expect(await disabledEnableSwitch.getAttribute('aria-checked')).to.eql('false'); + + const muteSwitch = await testSubjects.find('muteSwitch'); + expect(await muteSwitch.getAttribute('aria-checked')).to.eql('false'); + + await muteSwitch.click(); + + const muteSwitchAfterTryingToMute = await testSubjects.find('muteSwitch'); + const isDisabledMuteAfterDisabling = await muteSwitchAfterTryingToMute.getAttribute( + 'aria-checked' + ); + expect(isDisabledMuteAfterDisabling).to.eql('false'); + }); + + it('should reenable a disabled the alert', async () => { + const enableSwitch = await testSubjects.find('enableSwitch'); + + const isChecked = await enableSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + + await enableSwitch.click(); + + const enabledSwitchAfterReenabling = await testSubjects.find('enableSwitch'); + const isCheckedAfterDisabling = await enabledSwitchAfterReenabling.getAttribute( + 'aria-checked' + ); + expect(isCheckedAfterDisabling).to.eql('true'); + }); + + it('should mute the alert', async () => { + const muteSwitch = await testSubjects.find('muteSwitch'); + + const isChecked = await muteSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + + await muteSwitch.click(); + + const muteSwitchAfterDisabling = await testSubjects.find('muteSwitch'); + const isCheckedAfterDisabling = await muteSwitchAfterDisabling.getAttribute('aria-checked'); + expect(isCheckedAfterDisabling).to.eql('true'); + }); + + it('should unmute the alert', async () => { + const muteSwitch = await testSubjects.find('muteSwitch'); + + const isChecked = await muteSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); + + await muteSwitch.click(); + + const muteSwitchAfterUnmuting = await testSubjects.find('muteSwitch'); + const isCheckedAfterDisabling = await muteSwitchAfterUnmuting.getAttribute('aria-checked'); + expect(isCheckedAfterDisabling).to.eql('false'); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts index 13f50a505b0b6..307f39382a236 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts @@ -12,6 +12,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); const log = getService('log'); const browser = getService('browser'); + const alerting = getService('alerting'); describe('Home page', function() { before(async () => { @@ -55,6 +56,43 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Verify content await testSubjects.existOrFail('alertsList'); }); + + it('navigates to an alert details page', async () => { + const action = await alerting.actions.createAction({ + name: `server-log-${Date.now()}`, + actionTypeId: '.server-log', + config: {}, + secrets: {}, + }); + + const alert = await alerting.alerts.createAlwaysFiringWithAction( + `test-alert-${Date.now()}`, + { + id: action.id, + group: 'default', + params: { + message: 'from alert 1s', + level: 'warn', + }, + } + ); + + // refresh to see alert + await browser.refresh(); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify content + await testSubjects.existOrFail('alertsList'); + + // click on first alert + await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name); + + // Verify url + expect(await browser.getCurrentUrl()).to.contain(`/alert/${alert.id}`); + + await alerting.alerts.deleteAlert(alert.id); + }); }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts index c76f477c8cfbe..a771fbf85e0b6 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts @@ -12,5 +12,6 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { loadTestFile(require.resolve('./home_page')); loadTestFile(require.resolve('./connectors')); loadTestFile(require.resolve('./alerts')); + loadTestFile(require.resolve('./details')); }); }; diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts index df651c67c2c28..43162e9256370 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts @@ -12,13 +12,42 @@ export default function(kibana: any) { require: ['alerting'], name: 'alerts', init(server: any) { - const noopAlertType: AlertType = { - id: 'test.noop', - name: 'Test: Noop', - actionGroups: ['default'], - async executor() {}, - }; - server.plugins.alerting.setup.registerType(noopAlertType); + createNoopAlertType(server.plugins.alerting.setup); + createAlwaysFiringAlertType(server.plugins.alerting.setup); }, }); } + +function createNoopAlertType(setupContract: any) { + const noopAlertType: AlertType = { + id: 'test.noop', + name: 'Test: Noop', + actionGroups: ['default'], + async executor() {}, + }; + setupContract.registerType(noopAlertType); +} + +function createAlwaysFiringAlertType(setupContract: any) { + // Alert types + const alwaysFiringAlertType: any = { + id: 'test.always-firing', + name: 'Always Firing', + actionGroups: ['default', 'other'], + async executor(alertExecutorOptions: any) { + const { services, state } = alertExecutorOptions; + + services + .alertInstanceFactory('1') + .replaceState({ instanceStateValue: true }) + .scheduleActions('default', { + instanceContextValue: true, + }); + return { + globalStateValue: true, + groupInSeriesIndex: (state.groupInSeriesIndex || 0) + 1, + }; + }, + }; + setupContract.registerType(alwaysFiringAlertType); +} diff --git a/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts b/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts new file mode 100644 index 0000000000000..6d2038a6ba04c --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/page_objects/alert_details.ts @@ -0,0 +1,26 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; + +export function AlertDetailsPageProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + + return { + async getHeadingText() { + return await testSubjects.getVisibleText('alertDetailsTitle'); + }, + async getAlertType() { + return await testSubjects.getVisibleText('alertTypeLabel'); + }, + async getActionsLabels() { + return { + actionType: await testSubjects.getVisibleText('actionTypeLabel'), + actionCount: await testSubjects.getVisibleText('actionCountLabel'), + }; + }, + }; +} diff --git a/x-pack/test/functional_with_es_ssl/page_objects/index.ts b/x-pack/test/functional_with_es_ssl/page_objects/index.ts index a068ba7dfe81d..cfc44221a9c17 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/index.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/index.ts @@ -6,8 +6,10 @@ import { pageObjects as xpackFunctionalPageObjects } from '../../functional/page_objects'; import { TriggersActionsPageProvider } from './triggers_actions_ui_page'; +import { AlertDetailsPageProvider } from './alert_details'; export const pageObjects = { ...xpackFunctionalPageObjects, triggersActionsUI: TriggersActionsPageProvider, + alertDetailsUI: AlertDetailsPageProvider, }; diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts index a04ecc969a7e1..ae66ac0ddddfb 100644 --- a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -92,6 +92,10 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext) }; }); }, + async clickOnAlertInAlertsList(name: string) { + await this.searchAlerts(name); + await find.clickDisplayedByCssSelector(`[data-test-subj="alertsList"] [title="${name}"]`); + }, async changeTabs(tab: 'alertsTab' | 'connectorsTab') { return await testSubjects.click(tab); }, diff --git a/x-pack/test/functional_with_es_ssl/services/alerting/actions.ts b/x-pack/test/functional_with_es_ssl/services/alerting/actions.ts new file mode 100644 index 0000000000000..9454a32757068 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/services/alerting/actions.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import axios, { AxiosInstance } from 'axios'; +import util from 'util'; +import { ToolingLog } from '@kbn/dev-utils'; + +export class Actions { + private log: ToolingLog; + private axios: AxiosInstance; + + constructor(url: string, log: ToolingLog) { + this.log = log; + this.axios = axios.create({ + headers: { 'kbn-xsrf': 'x-pack/ftr/services/alerting/actions' }, + baseURL: url, + maxRedirects: 0, + validateStatus: () => true, // we do our own validation below and throw better error messages + }); + } + + public async createAction(actionParams: { + name: string; + actionTypeId: string; + config: Record; + secrets: Record; + }) { + this.log.debug(`creating action ${actionParams.name}`); + + const { data: action, status: actionStatus, actionStatusText } = await this.axios.post( + `/api/action`, + actionParams + ); + if (actionStatus !== 200) { + throw new Error( + `Expected status code of 200, received ${actionStatus} ${actionStatusText}: ${util.inspect( + action + )}` + ); + } + + this.log.debug(`created action ${action.id}`); + return action; + } +} diff --git a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts new file mode 100644 index 0000000000000..1a31d4796d5bc --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts @@ -0,0 +1,79 @@ +/* + * 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 axios, { AxiosInstance } from 'axios'; +import util from 'util'; +import { ToolingLog } from '@kbn/dev-utils'; + +export class Alerts { + private log: ToolingLog; + private axios: AxiosInstance; + + constructor(url: string, log: ToolingLog) { + this.log = log; + this.axios = axios.create({ + headers: { 'kbn-xsrf': 'x-pack/ftr/services/alerting/alerts' }, + baseURL: url, + maxRedirects: 0, + validateStatus: () => true, // we do our own validation below and throw better error messages + }); + } + + public async createAlwaysFiringWithActions( + name: string, + actions: Array<{ + id: string; + group: string; + params: Record; + }> + ) { + this.log.debug(`creating alert ${name}`); + + const { data: alert, status, statusText } = await this.axios.post(`/api/alert`, { + enabled: true, + name, + tags: ['foo'], + alertTypeId: 'test.always-firing', + consumer: 'bar', + schedule: { interval: '1m' }, + throttle: '1m', + actions, + params: {}, + }); + if (status !== 200) { + throw new Error( + `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(alert)}` + ); + } + + this.log.debug(`created alert ${alert.id}`); + + return alert; + } + + public async createAlwaysFiringWithAction( + name: string, + action: { + id: string; + group: string; + params: Record; + } + ) { + return this.createAlwaysFiringWithActions(name, [action]); + } + + public async deleteAlert(id: string) { + this.log.debug(`deleting alert ${id}`); + + const { data: alert, status, statusText } = await this.axios.delete(`/api/alert/${id}`); + if (status !== 204) { + throw new Error( + `Expected status code of 204, received ${status} ${statusText}: ${util.inspect(alert)}` + ); + } + this.log.debug(`deleted alert ${alert.id}`); + } +} diff --git a/x-pack/test/functional_with_es_ssl/services/alerting/index.ts b/x-pack/test/functional_with_es_ssl/services/alerting/index.ts new file mode 100644 index 0000000000000..e0aa827316c01 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/services/alerting/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { format as formatUrl } from 'url'; + +import { Alerts } from './alerts'; +import { Actions } from './actions'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export function AlertsServiceProvider({ getService }: FtrProviderContext) { + const log = getService('log'); + const config = getService('config'); + const url = formatUrl(config.get('servers.kibana')); + + return new (class AlertingService { + actions = new Actions(url, log); + alerts = new Alerts(url, log); + })(); +} diff --git a/x-pack/test/functional_with_es_ssl/services/index.ts b/x-pack/test/functional_with_es_ssl/services/index.ts index 6e96921c25a31..f04c2c980055d 100644 --- a/x-pack/test/functional_with_es_ssl/services/index.ts +++ b/x-pack/test/functional_with_es_ssl/services/index.ts @@ -5,7 +5,9 @@ */ import { services as xpackFunctionalServices } from '../../functional/services'; +import { AlertsServiceProvider } from './alerting'; export const services = { ...xpackFunctionalServices, + alerting: AlertsServiceProvider, };