diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index 5a8a9000ea5b9..0654488939566 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -13,7 +13,7 @@ import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock'; import { AlertsList } from './alerts_list'; -import { ValidationResult } from '../../../../types'; +import { AlertTypeModel, ValidationResult } from '../../../../types'; import { AlertExecutionStatusErrorReasons, ALERTS_FEATURE_ID, @@ -44,6 +44,12 @@ jest.mock('react-router-dom', () => ({ pathname: '/triggersActions/alerts/', }), })); +jest.mock('../../../lib/capabilities', () => ({ + hasAllPrivilege: jest.fn(() => true), + hasSaveAlertsCapability: jest.fn(() => true), + hasShowActionsCapability: jest.fn(() => true), + hasExecuteActionsCapability: jest.fn(() => true), +})); const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); const { loadActionTypes, loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); const actionTypeRegistry = actionTypeRegistryMock.create(); @@ -264,7 +270,7 @@ describe('alerts_list component with items', () => { }, ]; - async function setup() { + async function setup(editable: boolean = true) { loadAlerts.mockResolvedValue({ page: 1, perPage: 10000, @@ -284,7 +290,20 @@ describe('alerts_list component with items', () => { loadAlertTypes.mockResolvedValue([alertTypeFromApi]); loadAllActions.mockResolvedValue([]); + const ruleTypeMock: AlertTypeModel = { + id: 'test_alert_type', + iconClass: 'test', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + alertParamsExpression: jest.fn(), + requiresAppContext: !editable, + }; + ruleTypeRegistry.has.mockReturnValue(true); + ruleTypeRegistry.get.mockReturnValue(ruleTypeMock); // eslint-disable-next-line react-hooks/rules-of-hooks useKibanaMock().services.ruleTypeRegistry = ruleTypeRegistry; @@ -408,6 +427,18 @@ describe('alerts_list component with items', () => { }) ); }); + + it('renders edit and delete buttons when user can manage rules', async () => { + await setup(); + expect(wrapper.find('[data-test-subj="alertSidebarEditAction"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="alertSidebarDeleteAction"]').exists()).toBeTruthy(); + }); + + it('does not render edit button when rule type does not allow editing in rules management', async () => { + await setup(false); + expect(wrapper.find('[data-test-subj="alertSidebarEditAction"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="alertSidebarDeleteAction"]').exists()).toBeTruthy(); + }); }); describe('alerts_list component empty with show only capability', () => { 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 3625dc07a1181..9b488922a3cf6 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 @@ -50,7 +50,7 @@ import { deleteAlerts, } from '../../../lib/alert_api'; import { loadActionTypes } from '../../../lib/action_connector_api'; -import { hasExecuteActionsCapability } from '../../../lib/capabilities'; +import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; import { routeToRuleDetails, DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; import { EmptyPrompt } from '../../../components/prompts/empty_prompt'; @@ -60,7 +60,6 @@ import { ALERTS_FEATURE_ID, AlertExecutionStatusErrorReasons, } from '../../../../../../alerting/common'; -import { hasAllPrivilege } from '../../../lib/capabilities'; import { alertsStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; import { useKibana } from '../../../../common/lib/kibana'; import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../common/constants'; @@ -143,6 +142,9 @@ export const AlertsList: React.FunctionComponent = () => { setCurrentRuleToEdit(ruleItem); }; + const isRuleTypeEditableInContext = (ruleTypeId: string) => + ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; + useEffect(() => { loadAlertsData(); }, [ @@ -466,23 +468,25 @@ export const AlertsList: React.FunctionComponent = () => { - - onRuleEdit(item)} - iconType={'pencil'} - aria-label={i18n.translate( - 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.editAriaLabel', - { defaultMessage: 'Edit' } - )} - /> - - + {item.isEditable && isRuleTypeEditableInContext(item.alertTypeId) && ( + + onRuleEdit(item)} + iconType={'pencil'} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.editAriaLabel', + { defaultMessage: 'Edit' } + )} + /> + + )} + + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +describe('CollapsedItemActions', () => { + async function setup(editable: boolean = true) { + const ruleTypeRegistry = ruleTypeRegistryMock.create(); + ruleTypeRegistry.has.mockReturnValue(true); + const alertTypeR: AlertTypeModel = { + id: 'my-alert-type', + iconClass: 'test', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + alertParamsExpression: jest.fn(), + requiresAppContext: !editable, + }; + ruleTypeRegistry.get.mockReturnValue(alertTypeR); + const useKibanaMock = useKibana as jest.Mocked; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.ruleTypeRegistry = ruleTypeRegistry; + } + + const getPropsWithRule = (overrides = {}, editable = false) => { + const rule: AlertTableItem = { + id: '1', + enabled: true, + name: 'test rule', + tags: ['tag1'], + alertTypeId: 'test_rule_type', + consumer: 'alerts', + schedule: { interval: '5d' }, + actions: [ + { id: 'test', actionTypeId: 'the_connector', group: 'rule', params: { message: 'test' } }, + ], + params: { name: 'test rule type name' }, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, + throttle: '1m', + notifyWhen: 'onActiveAlert', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + actionsCount: 1, + tagsText: 'tag1', + alertType: 'Test Alert Type', + isEditable: true, + enabledInLicense: true, + ...overrides, + }; + + return { + item: rule, + onAlertChanged, + onEditAlert, + setAlertsToDelete, + disableAlert, + enableAlert, + unmuteAlert, + muteAlert, + }; + }; + + test('renders closed popover initially and opens on click with all actions enabled', async () => { + await setup(); + const wrapper = mountWithIntl(); + + expect(wrapper.find('[data-test-subj="selectActionButton"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="collapsedActionPanel"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="muteButton"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="disableButton"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="editAlert"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="deleteAlert"]').exists()).toBeFalsy(); + + wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="collapsedActionPanel"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="muteButton"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="disableButton"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="editAlert"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="deleteAlert"]').exists()).toBeTruthy(); + + expect(wrapper.find(`[data-test-subj="muteButton"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="muteButton"] button`).text()).toEqual('Mute'); + expect(wrapper.find(`[data-test-subj="disableButton"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="disableButton"] button`).text()).toEqual('Disable'); + expect(wrapper.find(`[data-test-subj="editAlert"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="editAlert"] button`).text()).toEqual('Edit rule'); + expect(wrapper.find(`[data-test-subj="deleteAlert"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="deleteAlert"] button`).text()).toEqual('Delete rule'); + }); + + test('handles case when rule is unmuted and enabled and mute is clicked', async () => { + await setup(); + const wrapper = mountWithIntl(); + wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + wrapper.find('button[data-test-subj="muteButton"]').simulate('click'); + await act(async () => { + await tick(10); + wrapper.update(); + }); + expect(muteAlert).toHaveBeenCalled(); + }); + + test('handles case when rule is unmuted and enabled and disable is clicked', async () => { + await setup(); + const wrapper = mountWithIntl(); + wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + wrapper.find('button[data-test-subj="disableButton"]').simulate('click'); + await act(async () => { + await tick(10); + wrapper.update(); + }); + expect(disableAlert).toHaveBeenCalled(); + }); + + test('handles case when rule is muted and enabled and unmute is clicked', async () => { + await setup(); + const wrapper = mountWithIntl( + + ); + wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + wrapper.find('button[data-test-subj="muteButton"]').simulate('click'); + await act(async () => { + await tick(10); + wrapper.update(); + }); + expect(unmuteAlert).toHaveBeenCalled(); + }); + + test('handles case when rule is unmuted and disabled and enable is clicked', async () => { + await setup(); + const wrapper = mountWithIntl( + + ); + wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + wrapper.find('button[data-test-subj="disableButton"]').simulate('click'); + await act(async () => { + await tick(10); + wrapper.update(); + }); + expect(enableAlert).toHaveBeenCalled(); + }); + + test('handles case when edit rule is clicked', async () => { + await setup(); + const wrapper = mountWithIntl(); + wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + wrapper.find('button[data-test-subj="editAlert"]').simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect(onEditAlert).toHaveBeenCalled(); + }); + + test('handles case when delete rule is clicked', async () => { + await setup(); + const wrapper = mountWithIntl(); + wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + wrapper.find('button[data-test-subj="deleteAlert"]').simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect(setAlertsToDelete).toHaveBeenCalled(); + }); + + test('renders actions correctly when rule is disabled', async () => { + await setup(); + const wrapper = mountWithIntl( + + ); + wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(`[data-test-subj="muteButton"] button`).prop('disabled')).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="muteButton"] button`).text()).toEqual('Mute'); + expect(wrapper.find(`[data-test-subj="disableButton"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="disableButton"] button`).text()).toEqual('Enable'); + expect(wrapper.find(`[data-test-subj="editAlert"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="editAlert"] button`).text()).toEqual('Edit rule'); + expect(wrapper.find(`[data-test-subj="deleteAlert"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="deleteAlert"] button`).text()).toEqual('Delete rule'); + }); + + test('renders actions correctly when rule is not editable', async () => { + await setup(); + const wrapper = mountWithIntl( + + ); + wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect( + wrapper.find(`[data-test-subj="selectActionButton"] button`).prop('disabled') + ).toBeTruthy(); + }); + + test('renders actions correctly when rule is not enabled due to license', async () => { + await setup(); + const wrapper = mountWithIntl( + + ); + wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(`[data-test-subj="muteButton"] button`).prop('disabled')).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="muteButton"] button`).text()).toEqual('Mute'); + expect(wrapper.find(`[data-test-subj="disableButton"] button`).prop('disabled')).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="disableButton"] button`).text()).toEqual('Disable'); + expect(wrapper.find(`[data-test-subj="editAlert"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="editAlert"] button`).text()).toEqual('Edit rule'); + expect(wrapper.find(`[data-test-subj="deleteAlert"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="deleteAlert"] button`).text()).toEqual('Delete rule'); + }); + + test('renders actions correctly when rule is muted', async () => { + await setup(); + const wrapper = mountWithIntl( + + ); + wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(`[data-test-subj="muteButton"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="muteButton"] button`).text()).toEqual('Unmute'); + expect(wrapper.find(`[data-test-subj="disableButton"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="disableButton"] button`).text()).toEqual('Disable'); + expect(wrapper.find(`[data-test-subj="editAlert"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="editAlert"] button`).text()).toEqual('Edit rule'); + expect(wrapper.find(`[data-test-subj="deleteAlert"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="deleteAlert"] button`).text()).toEqual('Delete rule'); + }); + + test('renders actions correctly when rule type is not editable in this context', async () => { + await setup(false); + const wrapper = mountWithIntl(); + wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(`[data-test-subj="muteButton"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="muteButton"] button`).text()).toEqual('Mute'); + expect(wrapper.find(`[data-test-subj="disableButton"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="disableButton"] button`).text()).toEqual('Disable'); + expect(wrapper.find(`[data-test-subj="editAlert"] button`).prop('disabled')).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="editAlert"] button`).text()).toEqual('Edit rule'); + expect(wrapper.find(`[data-test-subj="deleteAlert"] button`).prop('disabled')).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="deleteAlert"] button`).text()).toEqual('Delete rule'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/collapsed_item_actions.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/collapsed_item_actions.tsx index b4bf4e786bca3..e2870e8097946 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/collapsed_item_actions.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/collapsed_item_actions.tsx @@ -10,6 +10,7 @@ import { asyncScheduler } from 'rxjs'; import React, { useEffect, useState } from 'react'; import { EuiButtonIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; +import { useKibana } from '../../../../common/lib/kibana'; import { AlertTableItem } from '../../../../types'; import { ComponentOpts as BulkOperationsComponentOpts, @@ -22,7 +23,7 @@ export type ComponentOpts = { onAlertChanged: () => void; setAlertsToDelete: React.Dispatch>; onEditAlert: (item: AlertTableItem) => void; -} & BulkOperationsComponentOpts; +} & Pick; export const CollapsedItemActions: React.FunctionComponent = ({ item, @@ -34,6 +35,8 @@ export const CollapsedItemActions: React.FunctionComponent = ({ setAlertsToDelete, onEditAlert, }: ComponentOpts) => { + const { ruleTypeRegistry } = useKibana().services; + const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isDisabled, setIsDisabled] = useState(!item.enabled); const [isMuted, setIsMuted] = useState(item.muteAll); @@ -42,9 +45,14 @@ export const CollapsedItemActions: React.FunctionComponent = ({ setIsMuted(item.muteAll); }, [item.enabled, item.muteAll]); + const isRuleTypeEditableInContext = ruleTypeRegistry.has(item.alertTypeId) + ? !ruleTypeRegistry.get(item.alertTypeId).requiresAppContext + : false; + const button = ( setIsPopoverOpen(!isPopoverOpen)} aria-label={i18n.translate( @@ -112,7 +120,7 @@ export const CollapsedItemActions: React.FunctionComponent = ({ ), }, { - disabled: !item.isEditable, + disabled: !item.isEditable || !isRuleTypeEditableInContext, 'data-test-subj': 'editAlert', onClick: () => { setIsPopoverOpen(!isPopoverOpen); @@ -148,7 +156,12 @@ export const CollapsedItemActions: React.FunctionComponent = ({ panelPaddingSize="none" data-test-subj="collapsedItemActions" > - + ); };