[Ram] create bulk delete on rules front (#144101)

issue: https://github.com/elastic/kibana/issues/143826

## Summary

In this PR I am enabling `Delete` in menu when Select all is chosen. And
trigger new bulk delete API when `Delete` option will be chosen.

<img width="453" alt="Screenshot 2022-10-23 at 16 34 09"
src="https://user-images.githubusercontent.com/26089545/198290071-e7d6be54-286c-4a7c-a579-1d07ac23d3db.png">


### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Julia 2022-11-14 17:08:37 +01:00 committed by GitHub
parent 9fda59f512
commit 2b2e1d19d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 732 additions and 142 deletions

View file

@ -6,10 +6,16 @@
*/
import { EuiCallOut, EuiConfirmModal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useState } from 'react';
import { HttpSetup } from '@kbn/core/public';
import { useKibana } from '../../common/lib/kibana';
import {
getSuccessfulDeletionNotificationText,
getFailedDeletionNotificationText,
getConfirmDeletionButtonText,
getConfirmDeletionModalText,
CANCEL_BUTTON_TEXT,
} from '../sections/rules_list/translations';
export const DeleteModalConfirmation = ({
idsToDelete,
@ -54,33 +60,12 @@ export const DeleteModalConfirmation = ({
if (!deleteModalFlyoutVisible) {
return null;
}
const confirmModalText = i18n.translate(
'xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.descriptionText',
{
defaultMessage:
"You won't be able to recover {numIdsToDelete, plural, one {a deleted {singleTitle}} other {deleted {multipleTitle}}}.",
values: { numIdsToDelete, singleTitle, multipleTitle },
}
);
const confirmButtonText = i18n.translate(
'xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.deleteButtonLabel',
{
defaultMessage:
'Delete {numIdsToDelete, plural, one {{singleTitle}} other {# {multipleTitle}}} ',
values: { numIdsToDelete, singleTitle, multipleTitle },
}
);
const cancelButtonText = i18n.translate(
'xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
);
return (
<EuiConfirmModal
buttonColor="danger"
data-test-subj="deleteIdsConfirmation"
title={confirmButtonText}
title={getConfirmDeletionButtonText(numIdsToDelete, singleTitle, multipleTitle)}
onCancel={() => {
setDeleteModalVisibility(false);
onCancel();
@ -95,36 +80,22 @@ export const DeleteModalConfirmation = ({
const numErrors = errors.length;
if (numSuccesses > 0) {
toasts.addSuccess(
i18n.translate(
'xpack.triggersActionsUI.components.deleteSelectedIdsSuccessNotification.descriptionText',
{
defaultMessage:
'Deleted {numSuccesses, number} {numSuccesses, plural, one {{singleTitle}} other {{multipleTitle}}}',
values: { numSuccesses, singleTitle, multipleTitle },
}
)
getSuccessfulDeletionNotificationText(numSuccesses, singleTitle, multipleTitle)
);
}
if (numErrors > 0) {
toasts.addDanger(
i18n.translate(
'xpack.triggersActionsUI.components.deleteSelectedIdsErrorNotification.descriptionText',
{
defaultMessage:
'Failed to delete {numErrors, number} {numErrors, plural, one {{singleTitle}} other {{multipleTitle}}}',
values: { numErrors, singleTitle, multipleTitle },
}
)
getFailedDeletionNotificationText(numErrors, singleTitle, multipleTitle)
);
await onErrors();
}
await onDeleted(successes);
}}
cancelButtonText={cancelButtonText}
confirmButtonText={confirmButtonText}
cancelButtonText={CANCEL_BUTTON_TEXT}
confirmButtonText={getConfirmDeletionButtonText(numIdsToDelete, singleTitle, multipleTitle)}
>
<p>{confirmModalText}</p>
<p>{getConfirmDeletionModalText(numIdsToDelete, singleTitle, multipleTitle)}</p>
{showWarningText && (
<EuiCallOut title={<>{warningText}</>} color="warning" iconType="alert" />
)}

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiCallOut, EuiConfirmModal } from '@elastic/eui';
import React from 'react';
import { CANCEL_BUTTON_TEXT } from '../sections/rules_list/translations';
export const RulesDeleteModalConfirmation = ({
confirmButtonText,
confirmModalText,
onCancel,
onConfirm,
showWarningText,
warningText,
}: {
confirmButtonText: string;
confirmModalText: string;
onConfirm: () => Promise<void>;
onCancel: () => void;
showWarningText?: boolean;
warningText?: string;
}) => (
<EuiConfirmModal
buttonColor="danger"
data-test-subj="rulesDeleteConfirmation"
title={confirmButtonText}
onCancel={onCancel}
onConfirm={onConfirm}
cancelButtonText={CANCEL_BUTTON_TEXT}
confirmButtonText={confirmButtonText}
>
<p>{confirmModalText}</p>
{showWarningText && <EuiCallOut title={<>{warningText}</>} color="warning" iconType="alert" />}
</EuiConfirmModal>
);

View file

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { useKibana } from '../../common/lib/kibana';
import { BulkDeleteResponse } from '../../types';
import {
getSuccessfulDeletionNotificationText,
getFailedDeletionNotificationText,
getPartialSuccessDeletionNotificationText,
SINGLE_RULE_TITLE,
MULTIPLE_RULE_TITLE,
} from '../sections/rules_list/translations';
export const useBulkDeleteResponse = ({
onSearchPopulate,
}: {
onSearchPopulate?: (filter: string) => void;
}) => {
const {
notifications: { toasts },
} = useKibana().services;
const onSearchPopulateInternal = useCallback(
(response: BulkDeleteResponse) => {
if (!onSearchPopulate) {
return;
}
const filter = response.errors.map((error) => error.rule.name).join(',');
onSearchPopulate(filter);
},
[onSearchPopulate]
);
const renderToastErrorBody = useCallback(
(response: BulkDeleteResponse, messageType: 'warning' | 'danger') => {
return (
<EuiFlexGroup justifyContent="flexEnd" gutterSize="xs">
{onSearchPopulate && (
<EuiFlexItem grow={false}>
<EuiButton
color={messageType}
size="s"
onClick={() => onSearchPopulateInternal(response)}
data-test-subj="bulkDeleteResponseFilterErrors"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.ruleApi.bulkEditResponse.filterByErrors"
defaultMessage="Filter by errored rules"
/>
</EuiButton>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
},
[onSearchPopulate, onSearchPopulateInternal]
);
const showToast = useCallback(
(response: BulkDeleteResponse) => {
const { errors, total } = response;
const numberOfSuccess = total - errors.length;
const numberOfErrors = errors.length;
// All success
if (!numberOfErrors) {
toasts.addSuccess(
getSuccessfulDeletionNotificationText(
numberOfSuccess,
SINGLE_RULE_TITLE,
MULTIPLE_RULE_TITLE
)
);
return;
}
// All failure
if (numberOfErrors === total) {
toasts.addDanger({
title: getFailedDeletionNotificationText(
numberOfErrors,
SINGLE_RULE_TITLE,
MULTIPLE_RULE_TITLE
),
text: toMountPoint(renderToastErrorBody(response, 'danger')),
});
return;
}
// Some failure
toasts.addWarning({
title: getPartialSuccessDeletionNotificationText(
numberOfSuccess,
numberOfErrors,
SINGLE_RULE_TITLE,
MULTIPLE_RULE_TITLE
),
text: toMountPoint(renderToastErrorBody(response, 'warning')),
});
},
[toasts, renderToastErrorBody]
);
return useMemo(() => {
return {
showToast,
};
}, [showToast]);
};

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { HttpSetup } from '@kbn/core/public';
import { KueryNode } from '@kbn/es-query';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants';
import { BulkDeleteResponse } from '../../../types';
export const bulkDeleteRules = async ({
filter,
ids,
http,
}: {
filter?: KueryNode | null;
ids: string[];
http: HttpSetup;
}): Promise<BulkDeleteResponse> => {
try {
const body = JSON.stringify({
ids: ids?.length ? ids : undefined,
...(filter ? { filter: JSON.stringify(filter) } : {}),
});
return http.patch(`${INTERNAL_BASE_ALERTING_API_PATH}/rules/_bulk_delete`, { body });
} catch (e) {
throw new Error(`Unable to parse bulk delete params: ${e}`);
}
};

View file

@ -45,3 +45,4 @@ export { unsnoozeRule, bulkUnsnoozeRules } from './unsnooze';
export type { BulkUpdateAPIKeyProps } from './update_api_key';
export { updateAPIKey, bulkUpdateAPIKey } from './update_api_key';
export { runSoon } from './run_soon';
export { bulkDeleteRules } from './bulk_delete';

View file

@ -48,6 +48,7 @@ describe('rule_quick_edit_buttons', () => {
onPerformingAction={() => {}}
onActionPerformed={() => {}}
setRulesToDelete={() => {}}
setRulesToDeleteFilter={() => {}}
setRulesToUpdateAPIKey={() => {}}
setRulesToSnooze={() => {}}
setRulesToUnsnooze={() => {}}
@ -64,7 +65,7 @@ describe('rule_quick_edit_buttons', () => {
expect(wrapper.find('[data-test-subj="enableAll"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="disableAll"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="updateAPIKeys"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="deleteAll"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="bulkDelete"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="bulkSnooze"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="bulkUnsnooze"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="bulkSnoozeSchedule"]').exists()).toBeTruthy();
@ -85,6 +86,7 @@ describe('rule_quick_edit_buttons', () => {
onPerformingAction={() => {}}
onActionPerformed={() => {}}
setRulesToDelete={() => {}}
setRulesToDeleteFilter={() => {}}
setRulesToUpdateAPIKey={() => {}}
setRulesToSnooze={() => {}}
setRulesToUnsnooze={() => {}}
@ -116,6 +118,7 @@ describe('rule_quick_edit_buttons', () => {
onPerformingAction={() => {}}
onActionPerformed={() => {}}
setRulesToDelete={() => {}}
setRulesToDeleteFilter={() => {}}
setRulesToUpdateAPIKey={() => {}}
setRulesToSnooze={() => {}}
setRulesToUnsnooze={() => {}}
@ -130,7 +133,7 @@ describe('rule_quick_edit_buttons', () => {
);
expect(wrapper.find('[data-test-subj="disableAll"]').first().prop('isDisabled')).toBeTruthy();
expect(wrapper.find('[data-test-subj="deleteAll"]').first().prop('isDisabled')).toBeTruthy();
expect(wrapper.find('[data-test-subj="bulkDelete"]').first().prop('isDisabled')).toBeFalsy();
expect(wrapper.find('[data-test-subj="updateAPIKeys"]').first().prop('isDisabled')).toBeFalsy();
expect(wrapper.find('[data-test-subj="bulkSnooze"]').first().prop('isDisabled')).toBeFalsy();
expect(wrapper.find('[data-test-subj="bulkUnsnooze"]').first().prop('isDisabled')).toBeFalsy();
@ -157,6 +160,7 @@ describe('rule_quick_edit_buttons', () => {
onPerformingAction={() => {}}
onActionPerformed={() => {}}
setRulesToDelete={() => {}}
setRulesToDeleteFilter={() => {}}
setRulesToSnooze={setRulesToSnooze}
setRulesToUnsnooze={setRulesToUnsnooze}
setRulesToSchedule={setRulesToSchedule}
@ -207,6 +211,7 @@ describe('rule_quick_edit_buttons', () => {
onPerformingAction={() => {}}
onActionPerformed={() => {}}
setRulesToDelete={() => {}}
setRulesToDeleteFilter={() => {}}
setRulesToSnooze={setRulesToSnooze}
setRulesToUnsnooze={setRulesToUnsnooze}
setRulesToSchedule={setRulesToSchedule}

View file

@ -25,12 +25,14 @@ export type ComponentOpts = {
getFilter: () => KueryNode | null;
onPerformingAction?: () => void;
onActionPerformed?: () => void;
isDeletingRules?: boolean;
isSnoozingRules?: boolean;
isUnsnoozingRules?: boolean;
isSchedulingRules?: boolean;
isUnschedulingRules?: boolean;
isUpdatingRuleAPIKeys?: boolean;
setRulesToDelete: React.Dispatch<React.SetStateAction<string[]>>;
setRulesToDeleteFilter: React.Dispatch<React.SetStateAction<KueryNode | null | undefined>>;
setRulesToUpdateAPIKey: React.Dispatch<React.SetStateAction<string[]>>;
setRulesToSnooze: React.Dispatch<React.SetStateAction<RuleTableItem[]>>;
setRulesToUnsnooze: React.Dispatch<React.SetStateAction<RuleTableItem[]>>;
@ -71,6 +73,7 @@ export const RuleQuickEditButtons: React.FunctionComponent<ComponentOpts> = ({
getFilter,
onPerformingAction = noop,
onActionPerformed = noop,
isDeletingRules = false,
isSnoozingRules = false,
isUnsnoozingRules = false,
isSchedulingRules = false,
@ -79,6 +82,7 @@ export const RuleQuickEditButtons: React.FunctionComponent<ComponentOpts> = ({
enableRules,
disableRules,
setRulesToDelete,
setRulesToDeleteFilter,
setRulesToUpdateAPIKey,
setRulesToSnooze,
setRulesToUnsnooze,
@ -96,7 +100,6 @@ export const RuleQuickEditButtons: React.FunctionComponent<ComponentOpts> = ({
const [isEnablingRules, setIsEnablingRules] = useState<boolean>(false);
const [isDisablingRules, setIsDisablingRules] = useState<boolean>(false);
const [isDeletingRules, setIsDeletingRules] = useState<boolean>(false);
const isPerformingAction =
isEnablingRules ||
@ -169,13 +172,13 @@ export const RuleQuickEditButtons: React.FunctionComponent<ComponentOpts> = ({
}
async function deleteSelectedItems() {
if (isAllSelected) {
return;
}
onPerformingAction();
setIsDeletingRules(true);
try {
setRulesToDelete(selectedItems.map((selected: any) => selected.id));
if (isAllSelected) {
setRulesToDeleteFilter(getFilter());
} else {
setRulesToDelete(selectedItems.map((selected) => selected.id));
}
} catch (e) {
toasts.addDanger({
title: i18n.translate(
@ -186,7 +189,6 @@ export const RuleQuickEditButtons: React.FunctionComponent<ComponentOpts> = ({
),
});
} finally {
setIsDeletingRules(false);
onActionPerformed();
}
}
@ -416,30 +418,20 @@ export const RuleQuickEditButtons: React.FunctionComponent<ComponentOpts> = ({
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<ButtonWithTooltip
showTooltip={isAllSelected}
tooltip={i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.bulkActionPopover.deleteUnsupported',
{
defaultMessage: 'Bulk delete is unsupported when selecting all rules.',
}
)}
<EuiButtonEmpty
onClick={deleteSelectedItems}
isLoading={isDeletingRules}
iconType="trash"
color="danger"
isDisabled={isPerformingAction || hasDisabledByLicenseRuleTypes}
data-test-subj="bulkDelete"
className="actBulkActionPopover__deleteAll"
>
<EuiButtonEmpty
onClick={deleteSelectedItems}
isLoading={isDeletingRules}
iconType="trash"
color="danger"
isDisabled={isPerformingAction || isAllSelected}
data-test-subj="deleteAll"
className="actBulkActionPopover__deleteAll"
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.bulkActionPopover.deleteAllTitle"
defaultMessage="Delete"
/>
</EuiButtonEmpty>
</ButtonWithTooltip>
<FormattedMessage
id="xpack.triggersActionsUI.sections.rulesList.bulkActionPopover.deleteAllTitle"
defaultMessage="Delete"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -44,6 +44,7 @@ jest.mock('../../../lib/rule_api', () => ({
updateAPIKey: jest.fn(),
loadRuleTags: jest.fn(),
bulkSnoozeRules: jest.fn(),
bulkDeleteRules: jest.fn().mockResolvedValue({ errors: [], total: 10 }),
bulkUnsnoozeRules: jest.fn(),
bulkUpdateAPIKey: jest.fn(),
alertingFrameworkHealth: jest.fn(() => ({
@ -102,6 +103,10 @@ beforeEach(() => {
});
// This entire test suite is flaky/timing out and has been skipped.
afterEach(() => {
jest.clearAllMocks();
});
// FLAKY: https://github.com/elastic/kibana/issues/134922
// FLAKY: https://github.com/elastic/kibana/issues/134923
// FLAKY: https://github.com/elastic/kibana/issues/134924

View file

@ -64,13 +64,12 @@ import {
enableRule,
snoozeRule,
unsnoozeRule,
deleteRules,
bulkUpdateAPIKey,
} from '../../../lib/rule_api';
import { loadActionTypes } from '../../../lib/action_connector_api';
import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities';
import { routeToRuleDetails, DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants';
import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation';
import { RulesDeleteModalConfirmation } from '../../../components/rules_delete_modal_confirmation';
import { EmptyPrompt } from '../../../components/prompts/empty_prompt';
import { ALERT_STATUS_LICENSE_ERROR } from '../translations';
import { useKibana } from '../../../../common/lib/kibana';
@ -92,6 +91,14 @@ import { BulkSnoozeModalWithApi as BulkSnoozeModal } from './bulk_snooze_modal';
import { BulkSnoozeScheduleModalWithApi as BulkSnoozeScheduleModal } from './bulk_snooze_schedule_modal';
import { useBulkEditSelect } from '../../../hooks/use_bulk_edit_select';
import { runRule } from '../../../lib/run_rule';
import { bulkDeleteRules } from '../../../lib/rule_api';
import {
getConfirmDeletionButtonText,
getConfirmDeletionModalText,
SINGLE_RULE_TITLE,
MULTIPLE_RULE_TITLE,
} from '../translations';
import { useBulkDeleteResponse } from '../../../hooks/use_bulk_delete_response';
const ENTER_KEY = 13;
@ -206,6 +213,8 @@ export const RulesList = ({
});
const [rulesToDelete, setRulesToDelete] = useState<string[]>([]);
const [rulesToDeleteFilter, setRulesToDeleteFilter] = useState<KueryNode | null | undefined>();
const [isDeletingRules, setIsDeletingRules] = useState<boolean>(false);
// TODO - tech debt: Right now we're using null and undefined to determine if we should
// render the bulk edit modal. Refactor this to only keep track of 1 set of rules and types
@ -256,11 +265,11 @@ export const RulesList = ({
);
const [rulesTypesFilter, hasDefaultRuleTypesFiltersOn] = useMemo(() => {
if (isEmpty(typesFilter) && !isEmpty(filteredRuleTypes)) {
if (isEmpty(typesFilter)) {
return [authorizedRuleTypes.map((art) => art.id), true];
}
return [typesFilter, false];
}, [typesFilter, filteredRuleTypes, authorizedRuleTypes]);
}, [typesFilter, authorizedRuleTypes]);
const { rulesState, setRulesState, loadRules, noData, initialLoad } = useLoadRules({
page,
@ -302,7 +311,7 @@ export const RulesList = ({
const isRuleTypeEditableInContext = (ruleTypeId: string) =>
ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false;
const loadData = useCallback(async () => {
const refreshRules = useCallback(async () => {
if (!ruleTypesState || !hasAnyAuthorizedRuleType) {
return;
}
@ -323,8 +332,8 @@ export const RulesList = ({
]);
useEffect(() => {
loadData();
}, [loadData, refresh, percentileOptions]);
refreshRules();
}, [refreshRules, refresh, percentileOptions]);
useEffect(() => {
(async () => {
@ -621,6 +630,11 @@ export const RulesList = ({
setRulesToSnoozeFilter(undefined);
};
const clearRulesToDelete = () => {
setRulesToDelete([]);
setRulesToDeleteFilter(undefined);
};
const clearRulesToUnsnooze = () => {
setRulesToUnsnooze([]);
setRulesToUnsnoozeFilter(undefined);
@ -646,6 +660,7 @@ export const RulesList = ({
rulesState.isLoading ||
ruleTypesState.isLoading ||
isPerformingAction ||
isDeletingRules ||
isSnoozingRules ||
isUnsnoozingRules ||
isSchedulingRules ||
@ -656,6 +671,7 @@ export const RulesList = ({
rulesState,
ruleTypesState,
isPerformingAction,
isDeletingRules,
isSnoozingRules,
isUnsnoozingRules,
isSchedulingRules,
@ -746,7 +762,7 @@ export const RulesList = ({
iconType="refresh"
onClick={() => {
onClearSelection();
loadData();
refreshRules();
}}
name="refresh"
color="primary"
@ -824,7 +840,7 @@ export const RulesList = ({
/>
</EuiHealth>
</EuiFlexItem>
<RulesListAutoRefresh lastUpdate={lastUpdate} onRefresh={loadData} />
<RulesListAutoRefresh lastUpdate={lastUpdate} onRefresh={refreshRules} />
</EuiFlexGroup>
</EuiFlexItem>
{rulesStatusesTotal.error > 0 && (
@ -868,7 +884,7 @@ export const RulesList = ({
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
onSort={setSort}
onPage={setPage}
onRuleChanged={() => loadData()}
onRuleChanged={() => refreshRules()}
onRuleClick={(rule) => {
const detailsRoute = ruleDetailsRoute ? ruleDetailsRoute : routeToRuleDetails;
history.push(detailsRoute.replace(`:ruleId`, rule.id));
@ -899,7 +915,7 @@ export const RulesList = ({
key={rule.id}
item={rule}
onLoading={onLoading}
onRuleChanged={() => loadData()}
onRuleChanged={() => refreshRules()}
setRulesToDelete={setRulesToDelete}
onEditRule={() => onRuleEdit(rule)}
onUpdateAPIKey={setRulesToUpdateAPIKey}
@ -937,15 +953,17 @@ export const RulesList = ({
getFilter={getFilter}
onPerformingAction={() => setIsPerformingAction(true)}
onActionPerformed={() => {
loadData();
refreshRules();
setIsPerformingAction(false);
}}
isDeletingRules={isDeletingRules}
isSnoozingRules={isSnoozingRules}
isUnsnoozingRules={isUnsnoozingRules}
isSchedulingRules={isSchedulingRules}
isUnschedulingRules={isUnschedulingRules}
isUpdatingRuleAPIKeys={isUpdatingRuleAPIKeys}
setRulesToDelete={setRulesToDelete}
setRulesToDeleteFilter={setRulesToDeleteFilter}
setRulesToUpdateAPIKey={setRulesToUpdateAPIKey}
setRulesToSnooze={setRulesToSnooze}
setRulesToUnsnooze={setRulesToUnsnooze}
@ -997,34 +1015,54 @@ export const RulesList = ({
return table;
};
const [isDeleteModalFlyoutVisible, setIsDeleteModalVisibility] = useState<boolean>(false);
useEffect(() => {
setIsDeleteModalVisibility(rulesToDelete.length > 0 || Boolean(rulesToDeleteFilter));
}, [rulesToDelete, rulesToDeleteFilter]);
const { showToast } = useBulkDeleteResponse({ onSearchPopulate });
const onDeleteCancel = () => {
setIsDeleteModalVisibility(false);
clearRulesToDelete();
};
const onDeleteConfirm = useCallback(async () => {
setIsDeleteModalVisibility(false);
setIsDeletingRules(true);
const { errors, total } = await bulkDeleteRules({
filter: rulesToDeleteFilter,
ids: rulesToDelete,
http,
});
setIsDeletingRules(false);
showToast({ errors, total });
await refreshRules();
clearRulesToDelete();
onClearSelection();
}, [http, rulesToDelete, rulesToDeleteFilter, setIsDeletingRules, toasts]);
const numberRulesToDelete = rulesToDelete.length || numberOfSelectedItems;
return (
<section data-test-subj="rulesList">
<DeleteModalConfirmation
onDeleted={async () => {
setRulesToDelete([]);
onClearSelection();
await loadData();
}}
onErrors={async () => {
// Refresh the rules from the server, some rules may have beend deleted
await loadData();
setRulesToDelete([]);
}}
onCancel={() => {
setRulesToDelete([]);
}}
apiDeleteCall={deleteRules}
idsToDelete={rulesToDelete}
singleTitle={i18n.translate('xpack.triggersActionsUI.sections.rulesList.singleTitle', {
defaultMessage: 'rule',
})}
multipleTitle={i18n.translate('xpack.triggersActionsUI.sections.rulesList.multipleTitle', {
defaultMessage: 'rules',
})}
setIsLoadingState={(isLoading: boolean) => {
setRulesState({ ...rulesState, isLoading });
}}
/>
{isDeleteModalFlyoutVisible && (
<RulesDeleteModalConfirmation
onConfirm={onDeleteConfirm}
onCancel={onDeleteCancel}
confirmButtonText={getConfirmDeletionButtonText(
numberRulesToDelete,
SINGLE_RULE_TITLE,
MULTIPLE_RULE_TITLE
)}
confirmModalText={getConfirmDeletionModalText(
numberRulesToDelete,
SINGLE_RULE_TITLE,
MULTIPLE_RULE_TITLE
)}
/>
)}
<BulkSnoozeModal
rulesToSnooze={rulesToSnooze}
rulesToUnsnooze={rulesToUnsnooze}
@ -1041,7 +1079,7 @@ export const RulesList = ({
clearRulesToSnooze();
clearRulesToUnsnooze();
onClearSelection();
await loadData();
await refreshRules();
}}
onSearchPopulate={onSearchPopulate}
/>
@ -1061,7 +1099,7 @@ export const RulesList = ({
clearRulesToSchedule();
clearRulesToUnschedule();
onClearSelection();
await loadData();
await refreshRules();
}}
onSearchPopulate={onSearchPopulate}
/>
@ -1080,7 +1118,7 @@ export const RulesList = ({
onUpdated={async () => {
clearRulesToUpdateAPIKey();
onClearSelection();
await loadData();
await refreshRules();
}}
onSearchPopulate={onSearchPopulate}
/>
@ -1095,7 +1133,7 @@ export const RulesList = ({
actionTypeRegistry={actionTypeRegistry}
ruleTypeRegistry={ruleTypeRegistry}
ruleTypeIndex={ruleTypesState.data}
onSave={loadData}
onSave={refreshRules}
/>
)}
{editFlyoutVisible && currentRuleToEdit && (
@ -1109,7 +1147,7 @@ export const RulesList = ({
ruleType={
ruleTypesState.data.get(currentRuleToEdit.ruleTypeId) as RuleType<string, string>
}
onSave={loadData}
onSave={refreshRules}
/>
)}
</section>

View file

@ -0,0 +1,274 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as React from 'react';
import { ReactWrapper } from 'enzyme';
import { act } from '@testing-library/react';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { actionTypeRegistryMock } from '../../../action_type_registry.mock';
import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock';
import { RulesList } from './rules_list';
import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features';
import { useKibana } from '../../../../common/lib/kibana';
import {
mockedRulesData,
ruleTypeFromApi,
getDisabledByLicenseRuleTypeFromApi,
ruleType,
} from './test_helpers';
import { IToasts } from '@kbn/core/public';
jest.mock('../../../../common/lib/kibana');
jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({
useUiSetting: jest.fn(() => false),
useUiSetting$: jest.fn((value: string) => ['0,0']),
}));
jest.mock('../../../lib/action_connector_api', () => ({
loadActionTypes: jest.fn(),
loadAllActions: jest.fn(),
}));
jest.mock('../../../lib/rule_api', () => ({
loadRulesWithKueryFilter: jest.fn(),
loadRuleTypes: jest.fn(),
loadRuleAggregationsWithKueryFilter: jest.fn(),
updateAPIKey: jest.fn(),
loadRuleTags: jest.fn(),
bulkDeleteRules: jest.fn().mockResolvedValue({ errors: [], total: 10 }),
alertingFrameworkHealth: jest.fn(() => ({
isSufficientlySecure: true,
hasPermanentEncryptionKey: true,
})),
}));
jest.mock('../../../lib/rule_api/aggregate_kuery_filter');
jest.mock('../../../lib/rule_api/rules_kuery_filter');
jest.mock('../../../../common/lib/health_api', () => ({
triggersActionsUiHealth: jest.fn(() => ({ isRulesAvailable: true })),
}));
jest.mock('../../../../common/lib/config_api', () => ({
triggersActionsUiConfig: jest
.fn()
.mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }),
}));
jest.mock('react-router-dom', () => ({
useHistory: () => ({
push: jest.fn(),
}),
useLocation: () => ({
pathname: '/triggersActions/rules/',
}),
}));
jest.mock('../../../lib/capabilities', () => ({
hasAllPrivilege: jest.fn(() => true),
hasSaveRulesCapability: jest.fn(() => true),
hasShowActionsCapability: jest.fn(() => true),
hasExecuteActionsCapability: jest.fn(() => true),
}));
jest.mock('../../../../common/get_experimental_features', () => ({
getIsExperimentalFeatureEnabled: jest.fn(),
}));
const { loadRuleTypes, bulkDeleteRules } = jest.requireMock('../../../lib/rule_api');
const { loadRulesWithKueryFilter } = jest.requireMock('../../../lib/rule_api/rules_kuery_filter');
const { loadActionTypes, loadAllActions } = jest.requireMock('../../../lib/action_connector_api');
const actionTypeRegistry = actionTypeRegistryMock.create();
const ruleTypeRegistry = ruleTypeRegistryMock.create();
ruleTypeRegistry.list.mockReturnValue([ruleType]);
actionTypeRegistry.list.mockReturnValue([]);
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
beforeEach(() => {
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => false);
});
// Test are too slow. It's breaking the build. So we skipp it now and waiting for improvment according this ticket:
// https://github.com/elastic/kibana/issues/145122
describe.skip('Rules list bulk delete', () => {
let wrapper: ReactWrapper<any>;
async function setup(authorized: boolean = true) {
loadRulesWithKueryFilter.mockResolvedValue({
page: 1,
perPage: 10000,
total: 6,
data: mockedRulesData,
});
loadActionTypes.mockResolvedValue([
{
id: 'test',
name: 'Test',
},
{
id: 'test2',
name: 'Test2',
},
]);
loadRuleTypes.mockResolvedValue([
ruleTypeFromApi,
getDisabledByLicenseRuleTypeFromApi(authorized),
]);
loadAllActions.mockResolvedValue([]);
// eslint-disable-next-line react-hooks/rules-of-hooks
useKibanaMock().services.ruleTypeRegistry = ruleTypeRegistry;
// eslint-disable-next-line react-hooks/rules-of-hooks
useKibanaMock().services.actionTypeRegistry = actionTypeRegistry;
wrapper = mountWithIntl(<RulesList />);
await act(async () => {
await nextTick();
wrapper.update();
});
}
afterEach(() => {
jest.clearAllMocks();
});
beforeAll(async () => {
await setup();
useKibanaMock().services.notifications.toasts = {
addSuccess: jest.fn(),
addError: jest.fn(),
addDanger: jest.fn(),
addWarning: jest.fn(),
} as unknown as IToasts;
});
beforeEach(() => {
wrapper.find('[data-test-subj="checkboxSelectRow-1"]').at(1).simulate('change');
wrapper.find('[data-test-subj="selectAllRulesButton"]').at(1).simulate('click');
// Unselect something to test filtering
wrapper.find('[data-test-subj="checkboxSelectRow-2"]').at(1).simulate('change');
wrapper.find('[data-test-subj="showBulkActionButton"]').first().simulate('click');
});
it('can bulk delete', async () => {
wrapper.find('button[data-test-subj="bulkDelete"]').first().simulate('click');
expect(wrapper.find('[data-test-subj="rulesDeleteConfirmation"]').exists()).toBeTruthy();
wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
const filter = bulkDeleteRules.mock.calls[0][0].filter;
expect(filter.function).toEqual('and');
expect(filter.arguments[0].function).toEqual('or');
expect(filter.arguments[1].function).toEqual('not');
expect(filter.arguments[1].arguments[0].arguments[0].value).toEqual('alert.id');
expect(filter.arguments[1].arguments[0].arguments[1].value).toEqual('alert:2');
expect(bulkDeleteRules).toHaveBeenCalledWith(
expect.objectContaining({
ids: [],
})
);
});
it('can cancel bulk delete', async () => {
wrapper.find('[data-test-subj="bulkDelete"]').first().simulate('click');
expect(wrapper.find('[data-test-subj="rulesDeleteConfirmation"]').exists()).toBeTruthy();
wrapper.find('[data-test-subj="confirmModalCancelButton"]').first().simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(bulkDeleteRules).not.toBeCalled();
});
describe('Toast', () => {
it('should have success toast message', async () => {
wrapper.find('button[data-test-subj="bulkDelete"]').first().simulate('click');
expect(wrapper.find('[data-test-subj="rulesDeleteConfirmation"]').exists()).toBeTruthy();
wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(useKibanaMock().services.notifications.toasts.addSuccess).toHaveBeenCalledTimes(1);
expect(useKibanaMock().services.notifications.toasts.addSuccess).toHaveBeenCalledWith(
'Deleted 10 rules'
);
});
it('should have warning toast message', async () => {
bulkDeleteRules.mockResolvedValue({
errors: [
{
message: 'string',
rule: {
id: 'string',
name: 'string',
},
},
],
total: 10,
});
wrapper.find('button[data-test-subj="bulkDelete"]').first().simulate('click');
expect(wrapper.find('[data-test-subj="rulesDeleteConfirmation"]').exists()).toBeTruthy();
wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(useKibanaMock().services.notifications.toasts.addWarning).toHaveBeenCalledTimes(1);
expect(useKibanaMock().services.notifications.toasts.addWarning).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Deleted 9 rules, 1 rule encountered errors',
})
);
});
it('should have danger toast message', async () => {
bulkDeleteRules.mockResolvedValue({
errors: [
{
message: 'string',
rule: {
id: 'string',
name: 'string',
},
},
],
total: 1,
});
wrapper.find('button[data-test-subj="bulkDelete"]').first().simulate('click');
expect(wrapper.find('[data-test-subj="rulesDeleteConfirmation"]').exists()).toBeTruthy();
wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(useKibanaMock().services.notifications.toasts.addDanger).toHaveBeenCalledTimes(1);
expect(useKibanaMock().services.notifications.toasts.addDanger).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Failed to delete 1 rule',
})
);
});
});
});

View file

@ -171,7 +171,7 @@ describe('Rules list bulk actions', () => {
expect(wrapper.find('[data-test-subj="ruleQuickEditButton"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="disableAll"]').first().prop('isDisabled')).toBeTruthy();
expect(wrapper.find('[data-test-subj="deleteAll"]').first().prop('isDisabled')).toBeTruthy();
expect(wrapper.find('[data-test-subj="bulkDelete"]').exists()).toBeTruthy();
});
describe('bulk actions', () => {
@ -198,9 +198,11 @@ describe('Rules list bulk actions', () => {
});
const filter = bulkSnoozeRules.mock.calls[0][0].filter;
expect(filter.function).toEqual('not');
expect(filter.arguments[0].arguments[0].value).toEqual('alert.id');
expect(filter.arguments[0].arguments[1].value).toEqual('alert:2');
expect(filter.function).toEqual('and');
expect(filter.arguments[0].function).toEqual('or');
expect(filter.arguments[1].function).toEqual('not');
expect(filter.arguments[1].arguments[0].arguments[0].value).toEqual('alert.id');
expect(filter.arguments[1].arguments[0].arguments[1].value).toEqual('alert:2');
expect(bulkSnoozeRules).toHaveBeenCalledWith(
expect.objectContaining({
@ -229,9 +231,11 @@ describe('Rules list bulk actions', () => {
const filter = bulkUnsnoozeRules.mock.calls[0][0].filter;
expect(filter.function).toEqual('not');
expect(filter.arguments[0].arguments[0].value).toEqual('alert.id');
expect(filter.arguments[0].arguments[1].value).toEqual('alert:2');
expect(filter.function).toEqual('and');
expect(filter.arguments[0].function).toEqual('or');
expect(filter.arguments[1].function).toEqual('not');
expect(filter.arguments[1].arguments[0].arguments[0].value).toEqual('alert.id');
expect(filter.arguments[1].arguments[0].arguments[1].value).toEqual('alert:2');
expect(bulkUnsnoozeRules).toHaveBeenCalledWith(
expect.objectContaining({
@ -255,9 +259,11 @@ describe('Rules list bulk actions', () => {
const filter = bulkSnoozeRules.mock.calls[0][0].filter;
expect(filter.function).toEqual('not');
expect(filter.arguments[0].arguments[0].value).toEqual('alert.id');
expect(filter.arguments[0].arguments[1].value).toEqual('alert:2');
expect(filter.function).toEqual('and');
expect(filter.arguments[0].function).toEqual('or');
expect(filter.arguments[1].function).toEqual('not');
expect(filter.arguments[1].arguments[0].arguments[0].value).toEqual('alert.id');
expect(filter.arguments[1].arguments[0].arguments[1].value).toEqual('alert:2');
expect(bulkSnoozeRules).toHaveBeenCalledWith(
expect.objectContaining({
@ -288,9 +294,11 @@ describe('Rules list bulk actions', () => {
const filter = bulkUnsnoozeRules.mock.calls[0][0].filter;
expect(filter.function).toEqual('not');
expect(filter.arguments[0].arguments[0].value).toEqual('alert.id');
expect(filter.arguments[0].arguments[1].value).toEqual('alert:2');
expect(filter.function).toEqual('and');
expect(filter.arguments[0].function).toEqual('or');
expect(filter.arguments[1].function).toEqual('not');
expect(filter.arguments[1].arguments[0].arguments[0].value).toEqual('alert.id');
expect(filter.arguments[1].arguments[0].arguments[1].value).toEqual('alert:2');
expect(bulkUnsnoozeRules).toHaveBeenCalledWith(
expect.objectContaining({
@ -315,9 +323,11 @@ describe('Rules list bulk actions', () => {
const filter = bulkUpdateAPIKey.mock.calls[0][0].filter;
expect(filter.function).toEqual('not');
expect(filter.arguments[0].arguments[0].value).toEqual('alert.id');
expect(filter.arguments[0].arguments[1].value).toEqual('alert:2');
expect(filter.function).toEqual('and');
expect(filter.arguments[0].function).toEqual('or');
expect(filter.arguments[1].function).toEqual('not');
expect(filter.arguments[1].arguments[0].arguments[0].value).toEqual('alert.id');
expect(filter.arguments[1].arguments[0].arguments[1].value).toEqual('alert:2');
expect(bulkUpdateAPIKey).toHaveBeenCalledWith(
expect.objectContaining({

View file

@ -198,3 +198,108 @@ export const CLEAR_SELECTION = i18n.translate(
defaultMessage: 'Clear selection',
}
);
export const SINGLE_RULE_TITLE = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.singleTitle',
{
defaultMessage: 'rule',
}
);
export const MULTIPLE_RULE_TITLE = i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.multipleTitle',
{
defaultMessage: 'rules',
}
);
export const CANCEL_BUTTON_TEXT = i18n.translate(
'xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
);
export const getConfirmDeletionModalText = (
numIdsToDelete: number,
singleTitle: string,
multipleTitle: string
) =>
i18n.translate('xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.descriptionText', {
defaultMessage:
"You won't be able to recover {numIdsToDelete, plural, one {a deleted {singleTitle}} other {deleted {multipleTitle}}}.",
values: {
numIdsToDelete,
singleTitle,
multipleTitle,
},
});
export const getConfirmDeletionButtonText = (
numIdsToDelete: number,
singleTitle: string,
multipleTitle: string
) =>
i18n.translate('xpack.triggersActionsUI.deleteSelectedIdsConfirmModal.deleteButtonLabel', {
defaultMessage:
'Delete {numIdsToDelete, plural, one {{singleTitle}} other {# {multipleTitle}}} ',
values: {
numIdsToDelete,
singleTitle,
multipleTitle,
},
});
export const getSuccessfulDeletionNotificationText = (
numSuccesses: number,
singleTitle: string,
multipleTitle: string
) =>
i18n.translate(
'xpack.triggersActionsUI.components.deleteSelectedIdsSuccessNotification.descriptionText',
{
defaultMessage:
'Deleted {numSuccesses, number} {numSuccesses, plural, one {{singleTitle}} other {{multipleTitle}}}',
values: {
numSuccesses,
singleTitle,
multipleTitle,
},
}
);
export const getFailedDeletionNotificationText = (
numErrors: number,
singleTitle: string,
multipleTitle: string
) =>
i18n.translate(
'xpack.triggersActionsUI.components.deleteSelectedIdsErrorNotification.descriptionText',
{
defaultMessage:
'Failed to delete {numErrors, number} {numErrors, plural, one {{singleTitle}} other {{multipleTitle}}}',
values: {
numErrors,
singleTitle,
multipleTitle,
},
}
);
export const getPartialSuccessDeletionNotificationText = (
numberOfSuccess: number,
numberOfErrors: number,
singleTitle: string,
multipleTitle: string
) =>
i18n.translate(
'xpack.triggersActionsUI.components.deleteSelectedIdsPartialSuccessNotification.descriptionText',
{
defaultMessage:
'Deleted {numberOfSuccess, number} {numberOfSuccess, plural, one {{singleTitle}} other {{multipleTitle}}}, {numberOfErrors, number} {numberOfErrors, plural, one {{singleTitle}} other {{multipleTitle}}} encountered errors',
values: {
numberOfSuccess,
numberOfErrors,
singleTitle,
multipleTitle,
},
}
);

View file

@ -167,6 +167,11 @@ export enum ActionConnectorMode {
ActionForm = 'actionForm',
}
export interface BulkDeleteResponse {
errors: BulkOperationError[];
total: number;
}
export interface ActionParamsProps<TParams> {
actionParams: Partial<TParams>;
index: number;

View file

@ -223,11 +223,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await pageObjects.triggersActionsUI.searchAlerts(secondAlert.name);
await testSubjects.click('collapsedItemActions');
await testSubjects.click('deleteRule');
await testSubjects.existOrFail('deleteIdsConfirmation');
await testSubjects.click('deleteIdsConfirmation > confirmModalConfirmButton');
await testSubjects.missingOrFail('deleteIdsConfirmation');
await testSubjects.exists('rulesDeleteIdsConfirmation');
await testSubjects.click('confirmModalConfirmButton');
await retry.try(async () => {
const toastTitle = await pageObjects.common.closeToast();
@ -348,10 +346,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await testSubjects.click('bulkAction');
await testSubjects.click('deleteAll');
await testSubjects.existOrFail('deleteIdsConfirmation');
await testSubjects.click('deleteIdsConfirmation > confirmModalConfirmButton');
await testSubjects.missingOrFail('deleteIdsConfirmation');
await testSubjects.click('bulkDelete');
await testSubjects.exists('rulesDeleteIdsConfirmation');
await testSubjects.click('confirmModalConfirmButton');
await retry.try(async () => {
const toastTitle = await pageObjects.common.closeToast();