[Security Solution][Detections] adds confirmation modal window for bulk exporting action (#136418)

## Summary

- addresses https://github.com/elastic/kibana/issues/127746
- when users select rules for bulk export confirmation dialog is displayed, that shows how many rules can be exported. Only custom rules are exportable
- if no rules can be exported, dialog will show users, that action is not available
- changes successful export message, by showing note that prebuilt rules are excluded, only when rule have been excluded

### Modal windows

#### no rules can be exported
<img width="1293" alt="Screenshot 2022-07-18 at 14 01 36" src="https://user-images.githubusercontent.com/92328789/179517392-913f3dd9-4118-46eb-ba35-77d46906efd2.png">

#### some rules can be exported
<img width="1267" alt="Screenshot 2022-07-18 at 14 02 30" src="https://user-images.githubusercontent.com/92328789/179517376-cff64ee2-af9a-448b-aa2a-ce19e1542d6b.png">


### Implementation details
- we won't need dry run action here, because export doesn't mutate state
- once user click on export, we download file in browser, read it, and display message to user how many rules can/can't be exported
- since all failed export are immutable rules, we can safely display immutable message error to users in modal window
- user can proceed with download of exported rules OR cancel export action, thus experience will become consistent with bulk edit

### Checklist

Delete any items that are not applicable to this PR.

- [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

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)


### Release note
improves user experience for bulk export of security rules, by displaying confirmation modal, that show how many rules can be exported
This commit is contained in:
Vitalii Dmyterko 2022-07-25 10:47:12 +01:00 committed by GitHub
parent 12259ce281
commit 0cc899cdb0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 679 additions and 266 deletions

View file

@ -10,7 +10,6 @@ import {
CUSTOM_RULES_BTN,
MODAL_CONFIRMATION_BTN,
SELECT_ALL_RULES_ON_PAGE_CHECKBOX,
LOAD_PREBUILT_RULES_ON_PAGE_HEADER_BTN,
RULES_TAGS_FILTER_BTN,
RULE_CHECKBOX,
RULES_TAGS_POPOVER_BTN,
@ -29,11 +28,12 @@ import {
waitForRulesTableToBeLoaded,
selectAllRules,
goToTheRuleDetailsOf,
waitForRulesTableToBeRefreshed,
selectNumberOfRules,
testAllTagsBadges,
testTagsBadge,
testMultipleSelectedRulesLabel,
loadPrebuiltDetectionRulesFromHeaderBtn,
switchToElasticRules,
} from '../../tasks/alerts_detection_rules';
import {
@ -105,14 +105,11 @@ describe('Detection rules, bulk edit', () => {
it('should show warning modal windows when some of the selected rules cannot be edited', () => {
createMachineLearningRule(getMachineLearningRule(), '7');
cy.get(LOAD_PREBUILT_RULES_ON_PAGE_HEADER_BTN)
.pipe(($el) => $el.trigger('click'))
.should('not.exist');
loadPrebuiltDetectionRulesFromHeaderBtn();
// select few Elastic rules, check if we can't proceed further, as ELastic rules are not editable
// filter rules, only Elastic rule to show
cy.get(ELASTIC_RULES_BTN).click();
waitForRulesTableToBeRefreshed();
switchToElasticRules();
// check modal window for few selected rules
selectNumberOfRules(numberOfRulesPerPage);

View file

@ -5,13 +5,24 @@
* 2.0.
*/
import { expectedExportedRule, getNewRule } from '../../objects/rule';
import { expectedExportedRule, getNewRule, totalNumberOfPrebuiltRules } from '../../objects/rule';
import { TOASTER_BODY } from '../../screens/alerts_detection_rules';
import {
TOASTER_BODY,
MODAL_CONFIRMATION_BODY,
MODAL_CONFIRMATION_BTN,
} from '../../screens/alerts_detection_rules';
import { exportFirstRule } from '../../tasks/alerts_detection_rules';
import {
exportFirstRule,
loadPrebuiltDetectionRulesFromHeaderBtn,
switchToElasticRules,
selectNumberOfRules,
bulkExportRules,
selectAllRules,
} from '../../tasks/alerts_detection_rules';
import { createCustomRule } from '../../tasks/api_calls/rules';
import { cleanKibana } from '../../tasks/common';
import { cleanKibana, deleteAlertsAndRules } from '../../tasks/common';
import { login, visitWithoutDateRange } from '../../tasks/login';
import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation';
@ -23,6 +34,7 @@ describe('Export rules', () => {
});
beforeEach(() => {
deleteAlertsAndRules();
// Rules get exported via _bulk_action endpoint
cy.intercept('POST', '/api/detection_engine/rules/_bulk_action').as('bulk_action');
visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL);
@ -33,10 +45,45 @@ describe('Export rules', () => {
exportFirstRule();
cy.wait('@bulk_action').then(({ response }) => {
cy.wrap(response?.body).should('eql', expectedExportedRule(this.ruleResponse));
cy.get(TOASTER_BODY).should(
'have.text',
'Successfully exported 1 of 1 rule. Prebuilt rules were excluded from the resulting file.'
);
cy.get(TOASTER_BODY).should('have.text', 'Successfully exported 1 of 1 rule.');
});
});
it('shows a modal saying that no rules can be exported if all the selected rules are prebuilt', function () {
const expectedElasticRulesCount = 7;
loadPrebuiltDetectionRulesFromHeaderBtn();
switchToElasticRules();
selectNumberOfRules(expectedElasticRulesCount);
bulkExportRules();
cy.get(MODAL_CONFIRMATION_BODY).contains(
`${expectedElasticRulesCount} prebuilt Elastic rules (exporting prebuilt rules is not supported)`
);
});
it('exports only custom rules', function () {
const expectedNumberCustomRulesToBeExported = 1;
const totalNumberOfRules = expectedNumberCustomRulesToBeExported + totalNumberOfPrebuiltRules;
loadPrebuiltDetectionRulesFromHeaderBtn();
selectAllRules();
bulkExportRules();
cy.get(MODAL_CONFIRMATION_BODY).contains(
`${totalNumberOfPrebuiltRules} prebuilt Elastic rules (exporting prebuilt rules is not supported)`
);
// proceed with exporting only custom rules
cy.get(MODAL_CONFIRMATION_BTN)
.should('have.text', `Export ${expectedNumberCustomRulesToBeExported} Custom rule`)
.click();
cy.get(TOASTER_BODY).should(
'contain',
`Successfully exported ${expectedNumberCustomRulesToBeExported} of ${totalNumberOfRules} rules. Prebuilt rules were excluded from the resulting file.`
);
});
});

View file

@ -35,6 +35,8 @@ export const ELASTIC_RULES_BTN = '[data-test-subj="showElasticRulesFilterButton"
export const EXPORT_ACTION_BTN = '[data-test-subj="exportRuleAction"]';
export const BULK_EXPORT_ACTION_BTN = '[data-test-subj="exportRuleBulk"]';
export const FIRST_RULE = 0;
export const FOURTH_RULE = 3;

View file

@ -14,6 +14,7 @@ import {
DELETE_RULE_ACTION_BTN,
DELETE_RULE_BULK_BTN,
LOAD_PREBUILT_RULES_BTN,
LOAD_PREBUILT_RULES_ON_PAGE_HEADER_BTN,
RULES_TABLE_INITIAL_LOADING_INDICATOR,
RULES_TABLE_REFRESH_INDICATOR,
RULES_TABLE_AUTOREFRESH_INDICATOR,
@ -50,6 +51,8 @@ import {
SELECTED_RULES_NUMBER_LABEL,
REFRESH_SETTINGS_POPOVER,
REFRESH_SETTINGS_SWITCH,
ELASTIC_RULES_BTN,
BULK_EXPORT_ACTION_BTN,
} from '../screens/alerts_detection_rules';
import { ALL_ACTIONS } from '../screens/rule_details';
import { LOADING_INDICATOR } from '../screens/security_header';
@ -168,6 +171,12 @@ export const loadPrebuiltDetectionRules = () => {
.should('be.disabled');
};
export const loadPrebuiltDetectionRulesFromHeaderBtn = () => {
cy.get(LOAD_PREBUILT_RULES_ON_PAGE_HEADER_BTN)
.pipe(($el) => $el.trigger('click'))
.should('not.exist');
};
export const openIntegrationsPopover = () => {
cy.get(INTEGRATIONS_POPOVER).click();
};
@ -328,3 +337,13 @@ export const mockGlobalClock = () => {
cy.clock(Date.now(), ['setInterval', 'clearInterval', 'Date']);
};
export const switchToElasticRules = () => {
cy.get(ELASTIC_RULES_BTN).click();
waitForRulesTableToBeRefreshed();
};
export const bulkExportRules = () => {
cy.get(BULK_ACTIONS_BTN).click();
cy.get(BULK_EXPORT_ACTION_BTN).click();
};

View file

@ -28,6 +28,7 @@ import type { Rule } from '../../../containers/detection_engine/rules';
import {
executeRulesBulkAction,
goToRuleEditPage,
bulkExportRules,
} from '../../../pages/detection_engine/rules/all/actions';
import * as i18nActions from '../../../pages/detection_engine/rules/translations';
import * as i18n from './translations';
@ -108,7 +109,7 @@ const RuleActionsOverflowComponent = ({
onClick={async () => {
startTransaction({ name: SINGLE_RULE_ACTIONS.EXPORT });
closePopover();
await executeRulesBulkAction({
await bulkExportRules({
action: BulkAction.export,
onSuccess: noop,
search: { ids: [rule.id] },

View file

@ -16,8 +16,8 @@ import type { UseAppToasts } from '../../../../../common/hooks/use_app_toasts';
import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../../../common/lib/telemetry';
import { downloadBlob } from '../../../../../common/utils/download_blob';
import type {
BulkActionResponse,
BulkActionSummary,
BulkActionResponse,
} from '../../../../containers/detection_engine/rules';
import { performBulkAction } from '../../../../containers/detection_engine/rules';
import * as i18n from '../translations';
@ -34,19 +34,39 @@ export const goToRuleEditPage = (
});
};
interface ExecuteRulesBulkActionArgs {
type OnActionSuccessCallback = (
toasts: UseAppToasts,
action: BulkAction,
summary: BulkActionSummary
) => void;
type OnActionErrorCallback = (toasts: UseAppToasts, action: BulkAction, error: HTTPError) => void;
interface BaseRulesBulkActionArgs {
visibleRuleIds?: string[];
action: BulkAction;
toasts: UseAppToasts;
search: { query: string } | { ids: string[] };
payload?: { edit?: BulkActionEditPayload[] };
onSuccess?: (toasts: UseAppToasts, action: BulkAction, summary: BulkActionSummary) => void;
onError?: (toasts: UseAppToasts, action: BulkAction, error: HTTPError) => void;
onError?: OnActionErrorCallback;
onFinish?: () => void;
onSuccess?: OnActionSuccessCallback;
setLoadingRules?: RulesTableActions['setLoadingRules'];
}
export const executeRulesBulkAction = async ({
interface RulesBulkActionArgs extends BaseRulesBulkActionArgs {
action: Exclude<BulkAction, BulkAction.export>;
}
interface ExportRulesBulkActionArgs extends BaseRulesBulkActionArgs {
action: BulkAction.export;
}
// export bulk actions API returns blob, the rest of actions returns BulkActionResponse object
// hence method overloading to make type safe calls
export async function executeRulesBulkAction(args: ExportRulesBulkActionArgs): Promise<Blob | null>;
export async function executeRulesBulkAction(
args: RulesBulkActionArgs
): Promise<BulkActionResponse | null>;
export async function executeRulesBulkAction({
visibleRuleIds = [],
action,
setLoadingRules,
@ -56,20 +76,18 @@ export const executeRulesBulkAction = async ({
onSuccess = defaultSuccessHandler,
onError = defaultErrorHandler,
onFinish,
}: ExecuteRulesBulkActionArgs) => {
}: RulesBulkActionArgs | ExportRulesBulkActionArgs) {
let response: Blob | BulkActionResponse | null = null;
try {
setLoadingRules?.({ ids: visibleRuleIds, action });
if (action === BulkAction.export) {
const response = await performBulkAction({ ...search, action });
downloadBlob(response, `${i18n.EXPORT_FILENAME}.ndjson`);
onSuccess(toasts, action, await getExportedRulesCounts(response));
// on successToast for export handles separately outside of action execution method
response = await performBulkAction({ ...search, action });
} else {
const response = await performBulkAction({ ...search, action, edit: payload?.edit });
response = await performBulkAction({ ...search, action, edit: payload?.edit });
sendTelemetry(action, response);
onSuccess(toasts, action, response.attributes.summary);
return response;
}
} catch (error) {
onError(toasts, action, error);
@ -77,7 +95,47 @@ export const executeRulesBulkAction = async ({
setLoadingRules?.({ ids: [], action: null });
onFinish?.();
}
};
return response;
}
/**
* downloads exported rules, received from export action
* @param params.response - Blob results with exported rules
* @param params.toasts - {@link UseAppToasts} toasts service
* @param params.onSuccess - {@link OnActionSuccessCallback} optional toast to display when action successful
* @param params.onError - {@link OnActionErrorCallback} optional toast to display when action failed
*/
export async function downloadExportedRules({
response,
toasts,
onSuccess = defaultSuccessHandler,
onError = defaultErrorHandler,
}: {
response: Blob;
toasts: UseAppToasts;
onSuccess?: OnActionSuccessCallback;
onError?: OnActionErrorCallback;
}) {
try {
downloadBlob(response, `${i18n.EXPORT_FILENAME}.ndjson`);
onSuccess(toasts, BulkAction.export, await getExportedRulesCounts(response));
} catch (error) {
onError(toasts, BulkAction.export, error);
}
}
/**
* executes bulk export action and downloads exported rules
* @param params - {@link ExportRulesBulkActionArgs}
*/
export async function bulkExportRules(params: ExportRulesBulkActionArgs) {
const response = await executeRulesBulkAction(params);
// if response null, likely network error happened and export rules haven't been received
if (response) {
await downloadExportedRules({ response, toasts: params.toasts, onSuccess: params.onSuccess });
}
}
function defaultErrorHandler(toasts: UseAppToasts, action: BulkAction, error: HTTPError) {
// if response doesn't have number of failed rules, it means the whole bulk action failed
@ -130,6 +188,18 @@ function defaultErrorHandler(toasts: UseAppToasts, action: BulkAction, error: HT
toasts.addError(error, { title, toastMessage });
}
const getExportSuccessToastMessage = (succeeded: number, total: number) => {
const message = [i18n.RULES_BULK_EXPORT_SUCCESS_DESCRIPTION(succeeded, total)];
// if not all rules are successfully exported it means there included prebuilt rules
// display message to users that prebuilt rules were excluded
if (total > succeeded) {
message.push(i18n.RULES_BULK_EXPORT_PREBUILT_RULES_EXCLUDED_DESCRIPTION);
}
return message.join(' ');
};
async function defaultSuccessHandler(
toasts: UseAppToasts,
action: BulkAction,
@ -141,7 +211,7 @@ async function defaultSuccessHandler(
switch (action) {
case BulkAction.export:
title = i18n.RULES_BULK_EXPORT_SUCCESS;
text = i18n.RULES_BULK_EXPORT_SUCCESS_DESCRIPTION(summary.succeeded, summary.total);
text = getExportSuccessToastMessage(summary.succeeded, summary.total);
break;
case BulkAction.duplicate:
title = i18n.RULES_BULK_DUPLICATE_SUCCESS;

View file

@ -0,0 +1,95 @@
/*
* 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 from 'react';
import { EuiConfirmModal } from '@elastic/eui';
import * as i18n from '../../translations';
import { BulkActionRuleErrorsList } from './bulk_action_rule_errors_list';
import { BulkAction } from '../../../../../../../common/detection_engine/schemas/common/schemas';
import { assertUnreachable } from '../../../../../../../common/utility_types';
import type { BulkActionForConfirmation, DryRunResult } from './types';
const getActionRejectedTitle = (
bulkAction: BulkActionForConfirmation,
failedRulesCount: number
) => {
switch (bulkAction) {
case BulkAction.edit:
return i18n.BULK_EDIT_CONFIRMATION_REJECTED_TITLE(failedRulesCount);
case BulkAction.export:
return i18n.BULK_EXPORT_CONFIRMATION_REJECTED_TITLE(failedRulesCount);
default:
assertUnreachable(bulkAction);
}
};
const getActionConfirmLabel = (
bulkAction: BulkActionForConfirmation,
succeededRulesCount: number
) => {
switch (bulkAction) {
case BulkAction.edit:
return i18n.BULK_EDIT_CONFIRMATION_CONFIRM(succeededRulesCount);
case BulkAction.export:
return i18n.BULK_EXPORT_CONFIRMATION_CONFIRM(succeededRulesCount);
default:
assertUnreachable(bulkAction);
}
};
interface BulkEditDryRunConfirmationProps {
bulkAction: BulkActionForConfirmation;
result?: DryRunResult;
onCancel: () => void;
onConfirm: () => void;
}
const BulkActionDryRunConfirmationComponent = ({
onCancel,
onConfirm,
result,
bulkAction,
}: BulkEditDryRunConfirmationProps) => {
const { failedRulesCount = 0, succeededRulesCount = 0, ruleErrors = [] } = result ?? {};
// if no rule can be edited, modal window that denies bulk edit action will be displayed
if (succeededRulesCount === 0) {
return (
<EuiConfirmModal
title={getActionRejectedTitle(bulkAction, failedRulesCount)}
onCancel={onCancel}
onConfirm={onCancel}
confirmButtonText={i18n.BULK_ACTION_CONFIRMATION_CLOSE}
defaultFocusedButton="confirm"
data-test-subj="bulkActionRejectModal"
>
<BulkActionRuleErrorsList bulkAction={bulkAction} ruleErrors={ruleErrors} />
</EuiConfirmModal>
);
}
// if there are rules that can and cannot be edited, modal window that propose edit of some the rules will be displayed
return (
<EuiConfirmModal
title={i18n.BULK_ACTION_CONFIRMATION_PARTLY_TITLE(succeededRulesCount)}
onCancel={onCancel}
onConfirm={onConfirm}
confirmButtonText={getActionConfirmLabel(bulkAction, succeededRulesCount)}
cancelButtonText={i18n.BULK_EDIT_CONFIRMATION_CANCEL}
defaultFocusedButton="confirm"
data-test-subj="bulkActionConfirmationModal"
>
<BulkActionRuleErrorsList bulkAction={bulkAction} ruleErrors={ruleErrors} />
</EuiConfirmModal>
);
};
export const BulkActionDryRunConfirmation = React.memo(BulkActionDryRunConfirmationComponent);
BulkActionDryRunConfirmation.displayName = 'BulkActionDryRunConfirmation';

View file

@ -10,9 +10,10 @@ import React from 'react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { render, screen } from '@testing-library/react';
import type { DryRunResult } from './use_bulk_actions_dry_run';
import { BulkEditRuleErrorsList } from './bulk_edit_rule_errors_list';
import { BulkActionRuleErrorsList } from './bulk_action_rule_errors_list';
import { BulkActionsDryRunErrCode } from '../../../../../../../common/constants';
import type { DryRunResult } from './types';
import { BulkAction } from '../../../../../../../common/detection_engine/schemas/common/schemas';
const Wrapper: FC = ({ children }) => {
return (
@ -24,7 +25,12 @@ const Wrapper: FC = ({ children }) => {
describe('Component BulkEditRuleErrorsList', () => {
test('should not render component if no errors present', () => {
const { container } = render(<BulkEditRuleErrorsList ruleErrors={[]} />, { wrapper: Wrapper });
const { container } = render(
<BulkActionRuleErrorsList bulkAction={BulkAction.edit} ruleErrors={[]} />,
{
wrapper: Wrapper,
}
);
expect(container.childElementCount).toEqual(0);
});
@ -40,7 +46,9 @@ describe('Component BulkEditRuleErrorsList', () => {
ruleIds: ['rule:1'],
},
];
render(<BulkEditRuleErrorsList ruleErrors={ruleErrors} />, { wrapper: Wrapper });
render(<BulkActionRuleErrorsList bulkAction={BulkAction.edit} ruleErrors={ruleErrors} />, {
wrapper: Wrapper,
});
expect(screen.getByText("2 rules can't be edited (test failure)")).toBeInTheDocument();
expect(screen.getByText("1 rule can't be edited (another failure)")).toBeInTheDocument();
@ -68,7 +76,9 @@ describe('Component BulkEditRuleErrorsList', () => {
ruleIds: ['rule:1', 'rule:2'],
},
];
render(<BulkEditRuleErrorsList ruleErrors={ruleErrors} />, { wrapper: Wrapper });
render(<BulkActionRuleErrorsList bulkAction={BulkAction.edit} ruleErrors={ruleErrors} />, {
wrapper: Wrapper,
});
expect(screen.getByText(value)).toBeInTheDocument();
});

View file

@ -0,0 +1,154 @@
/*
* 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 from 'react';
import { EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { BulkActionsDryRunErrCode } from '../../../../../../../common/constants';
import { BulkAction } from '../../../../../../../common/detection_engine/schemas/common/schemas';
import type { DryRunResult, BulkActionForConfirmation } from './types';
interface BulkActionRuleErrorItemProps {
errorCode: BulkActionsDryRunErrCode | undefined;
message: string;
rulesCount: number;
}
const BulkEditRuleErrorItem = ({
errorCode,
message,
rulesCount,
}: BulkActionRuleErrorItemProps) => {
switch (errorCode) {
case BulkActionsDryRunErrCode.IMMUTABLE:
return (
<li key={message}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.elasticRulesEditDescription"
defaultMessage="{rulesCount, plural, =1 {# prebuilt Elastic rule} other {# prebuilt Elastic rules}} (editing prebuilt rules is not supported)"
values={{ rulesCount }}
/>
</li>
);
case BulkActionsDryRunErrCode.MACHINE_LEARNING_INDEX_PATTERN:
return (
<li key={message}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.machineLearningRulesIndexEditDescription"
defaultMessage="{rulesCount, plural, =1 {# custom Machine Learning rule} other {# custom Machine Learning rules}} (these rules don't have index patterns)"
values={{ rulesCount }}
/>
</li>
);
case BulkActionsDryRunErrCode.MACHINE_LEARNING_AUTH:
return (
<li key={message}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.machineLearningRulesAuthDescription"
defaultMessage="{rulesCount, plural, =1 {# Machine Learning rule} other {# Machine Learning rules}} can't be edited ({message})"
values={{ rulesCount, message }}
/>
</li>
);
default:
return (
<li key={message}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.defaultRulesEditFailureDescription"
defaultMessage="{rulesCount, plural, =1 {# rule} other {# rules}} can't be edited ({message})"
values={{ rulesCount, message }}
/>
</li>
);
}
};
const BulkExportRuleErrorItem = ({
errorCode,
message,
rulesCount,
}: BulkActionRuleErrorItemProps) => {
switch (errorCode) {
case BulkActionsDryRunErrCode.IMMUTABLE:
return (
<li key={message}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.elasticRulesExportDescription"
defaultMessage="{rulesCount, plural, =1 {# prebuilt Elastic rule} other {# prebuilt Elastic rules}} (exporting prebuilt rules is not supported)"
values={{ rulesCount }}
/>
</li>
);
default:
return (
<li key={message}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.defaultRulesExportFailureDescription"
defaultMessage="{rulesCount, plural, =1 {# rule} other {# rules}} can't be exported ({message})"
values={{ rulesCount, message }}
/>
</li>
);
}
};
interface BulkActionRuleErrorsListProps {
ruleErrors: DryRunResult['ruleErrors'];
bulkAction: BulkActionForConfirmation;
}
const BulkActionRuleErrorsListComponent = ({
ruleErrors = [],
bulkAction,
}: BulkActionRuleErrorsListProps) => {
if (ruleErrors.length === 0) {
return null;
}
return (
<>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.actionRejectionDescription"
defaultMessage="This action can't be applied to the following rules:"
/>
<EuiSpacer />
<ul>
{ruleErrors.map(({ message, errorCode, ruleIds }) => {
const rulesCount = ruleIds.length;
switch (bulkAction) {
case BulkAction.edit:
return (
<BulkEditRuleErrorItem
message={message}
errorCode={errorCode}
rulesCount={rulesCount}
/>
);
case BulkAction.export:
return (
<BulkExportRuleErrorItem
message={message}
errorCode={errorCode}
rulesCount={rulesCount}
/>
);
default:
return null;
}
})}
</ul>
</>
);
};
export const BulkActionRuleErrorsList = React.memo(BulkActionRuleErrorsListComponent);
BulkActionRuleErrorsList.displayName = 'BulkActionRuleErrorsList';

View file

@ -1,62 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiConfirmModal } from '@elastic/eui';
import * as i18n from '../../translations';
import type { DryRunResult } from './use_bulk_actions_dry_run';
import { BulkEditRuleErrorsList } from './bulk_edit_rule_errors_list';
interface BulkEditDryRunConfirmationProps {
result?: DryRunResult;
onCancel: () => void;
onConfirm: () => void;
}
const BulkEditDryRunConfirmationComponent = ({
onCancel,
onConfirm,
result,
}: BulkEditDryRunConfirmationProps) => {
const { failedRulesCount = 0, succeededRulesCount = 0, ruleErrors = [] } = result ?? {};
// if no rule can be edited, modal window that denies bulk edit action will be displayed
if (succeededRulesCount === 0) {
return (
<EuiConfirmModal
title={i18n.BULK_EDIT_CONFIRMATION_DENIED_TITLE(failedRulesCount)}
onCancel={onCancel}
onConfirm={onCancel}
confirmButtonText={i18n.BULK_EDIT_CONFIRMATION_CLOSE}
defaultFocusedButton="confirm"
data-test-subj="bulkEditRejectModal"
>
<BulkEditRuleErrorsList ruleErrors={ruleErrors} />
</EuiConfirmModal>
);
}
// if there are rules that can and cannot be edited, modal window that propose edit of some the rules will be displayed
return (
<EuiConfirmModal
title={i18n.BULK_EDIT_CONFIRMATION_PARTLY_TITLE(succeededRulesCount)}
onCancel={onCancel}
onConfirm={onConfirm}
confirmButtonText={i18n.BULK_EDIT_CONFIRMATION_CONFIRM(succeededRulesCount)}
cancelButtonText={i18n.BULK_EDIT_CONFIRMATION_CANCEL}
defaultFocusedButton="confirm"
data-test-subj="bulkEditConfirmationModal"
>
<BulkEditRuleErrorsList ruleErrors={ruleErrors} />
</EuiConfirmModal>
);
};
export const BulkEditDryRunConfirmation = React.memo(BulkEditDryRunConfirmationComponent);
BulkEditDryRunConfirmation.displayName = 'BulkEditDryRunConfirmation';

View file

@ -1,84 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { DryRunResult } from './use_bulk_actions_dry_run';
import { BulkActionsDryRunErrCode } from '../../../../../../../common/constants';
interface BulkEditRuleErrorsListProps {
ruleErrors: DryRunResult['ruleErrors'];
}
const BulkEditRuleErrorsListComponent = ({ ruleErrors = [] }: BulkEditRuleErrorsListProps) => {
if (ruleErrors.length === 0) {
return null;
}
return (
<>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.actionRejectionDescription"
defaultMessage="This action can't be applied to the following rules:"
/>
<EuiSpacer />
<ul>
{ruleErrors.map(({ message, errorCode, ruleIds }) => {
const rulesCount = ruleIds.length;
switch (errorCode) {
case BulkActionsDryRunErrCode.IMMUTABLE:
return (
<li key={message}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.elasticRulesEditDescription"
defaultMessage="{rulesCount, plural, =1 {# prebuilt Elastic rule} other {# prebuilt Elastic rules}} (editing prebuilt rules is not supported)"
values={{ rulesCount }}
/>
</li>
);
case BulkActionsDryRunErrCode.MACHINE_LEARNING_INDEX_PATTERN:
return (
<li key={message}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.machineLearningRulesIndexEditDescription"
defaultMessage="{rulesCount, plural, =1 {# custom Machine Learning rule} other {# custom Machine Learning rules}} (these rules don't have index patterns)"
values={{ rulesCount }}
/>
</li>
);
case BulkActionsDryRunErrCode.MACHINE_LEARNING_AUTH:
return (
<li key={message}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.machineLearningRulesAuthDescription"
defaultMessage="{rulesCount, plural, =1 {# Machine Learning rule} other {# Machine Learning rules}} can't be edited ({message})"
values={{ rulesCount, message }}
/>
</li>
);
default:
return (
<li key={message}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.defaultRulesEditFailureDescription"
defaultMessage="{rulesCount, plural, =1 {# rule} other {# rules}} can't be edited ({message})"
values={{ rulesCount, message }}
/>
</li>
);
}
})}
</ul>
</>
);
};
export const BulkEditRuleErrorsList = React.memo(BulkEditRuleErrorsListComponent);
BulkEditRuleErrorsList.displayName = 'BulkEditRuleErrorsList';

View file

@ -0,0 +1,38 @@
/*
* 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 type { BulkActionsDryRunErrCode } from '../../../../../../../common/constants';
import type { BulkAction } from '../../../../../../../common/detection_engine/schemas/common/schemas';
/**
* Only 2 bulk actions are supported for for confirmation dry run modal:
* * export
* * edit
*/
export type BulkActionForConfirmation = BulkAction.export | BulkAction.edit;
/**
* transformed results of dry run
*/
export interface DryRunResult {
/**
* total number of rules that succeeded validation in dry run
*/
succeededRulesCount?: number;
/**
* total number of rules that failed validation in dry run
*/
failedRulesCount?: number;
/**
* rule failures errors(message and error code) and ids of rules, that failed
*/
ruleErrors: Array<{
message: string;
errorCode?: BulkActionsDryRunErrCode;
ruleIds: string[];
}>;
}

View file

@ -24,9 +24,11 @@ import { canEditRuleWithActions } from '../../../../../../common/utils/privilege
import { useRulesTableContext } from '../rules_table/rules_table_context';
import * as detectionI18n from '../../../translations';
import * as i18n from '../../translations';
import { executeRulesBulkAction } from '../actions';
import { executeRulesBulkAction, downloadExportedRules } from '../actions';
import { getExportedRulesDetails } from '../helpers';
import { useHasActionsPrivileges } from '../use_has_actions_privileges';
import { useHasMlPermissions } from '../use_has_ml_permissions';
import { transformExportDetailsToDryRunResult } from './utils/dry_run_result';
import type { ExecuteBulkActionsDryRun } from './use_bulk_actions_dry_run';
import { useAppToasts } from '../../../../../../common/hooks/use_app_toasts';
import { convertRulesFilterToKQL } from '../../../../../containers/detection_engine/rules/utils';
@ -40,10 +42,15 @@ import { BULK_RULE_ACTIONS } from '../../../../../../common/lib/apm/user_actions
import { useStartTransaction } from '../../../../../../common/lib/apm/use_start_transaction';
import { useInvalidatePrePackagedRulesStatus } from '../../../../../containers/detection_engine/rules/use_pre_packaged_rules_status';
import type { DryRunResult, BulkActionForConfirmation } from './types';
interface UseBulkActionsArgs {
filterOptions: FilterOptions;
confirmDeletion: () => Promise<boolean>;
confirmBulkEdit: () => Promise<boolean>;
showBulkActionConfirmation: (
result: DryRunResult | undefined,
action: BulkActionForConfirmation
) => Promise<boolean>;
completeBulkEditForm: (
bulkActionEditType: BulkActionEditType
) => Promise<BulkActionEditPayload | null>;
@ -54,7 +61,7 @@ interface UseBulkActionsArgs {
export const useBulkActions = ({
filterOptions,
confirmDeletion,
confirmBulkEdit,
showBulkActionConfirmation,
completeBulkEditForm,
reFetchTags,
executeBulkActionsDryRun,
@ -193,13 +200,32 @@ export const useBulkActions = ({
closePopover();
startTransaction({ name: BULK_RULE_ACTIONS.EXPORT });
await executeRulesBulkAction({
const response = await executeRulesBulkAction({
visibleRuleIds: selectedRuleIds,
action: BulkAction.export,
setLoadingRules,
toasts,
search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds },
});
// if response null, likely network error happened and export rules haven't been received
if (!response) {
return;
}
const details = await getExportedRulesDetails(response);
// if there are failed exported rules, show modal window to users.
// they can either cancel action or proceed with export of succeeded rules
const hasActionBeenConfirmed = await showBulkActionConfirmation(
transformExportDetailsToDryRunResult(details),
BulkAction.export
);
if (hasActionBeenConfirmed === false) {
return;
}
await downloadExportedRules({ response, toasts });
};
const handleBulkEdit = (bulkEditActionType: BulkActionEditType) => async () => {
@ -217,11 +243,12 @@ export const useBulkActions = ({
: { ids: selectedRuleIds },
});
// show bulk edit confirmation window only if there is at least one failed rule
const hasFailedRules = (dryRunResult?.failedRulesCount ?? 0) > 0;
if (hasFailedRules && (await confirmBulkEdit()) === false) {
// User has cancelled edit action or there are no custom rules to proceed
// User has cancelled edit action or there are no custom rules to proceed
const hasActionBeenConfirmed = await showBulkActionConfirmation(
dryRunResult,
BulkAction.edit
);
if (hasActionBeenConfirmed === false) {
return;
}
@ -347,10 +374,7 @@ export const useBulkActions = ({
key: i18n.BULK_ACTION_EXPORT,
name: i18n.BULK_ACTION_EXPORT,
'data-test-subj': 'exportRuleBulk',
disabled:
(containsImmutable && !isAllSelected) ||
containsLoading ||
selectedRuleIds.length === 0,
disabled: containsLoading || selectedRuleIds.length === 0,
onClick: handleExportAction,
icon: undefined,
},
@ -446,17 +470,17 @@ export const useBulkActions = ({
setLoadingRules,
toasts,
filterQuery,
updateRulesCache,
invalidateRules,
invalidatePrePackagedRulesStatus,
clearRulesSelection,
confirmDeletion,
confirmBulkEdit,
completeBulkEditForm,
showBulkActionConfirmation,
executeBulkActionsDryRun,
filterOptions,
completeBulkEditForm,
getIsMounted,
resolveTagsRefetch,
updateRulesCache,
clearRulesSelection,
executeBulkActionsDryRun,
]
);

View file

@ -0,0 +1,56 @@
/*
* 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 { useState, useCallback } from 'react';
import { useAsyncConfirmation } from '../rules_table/use_async_confirmation';
import { useBoolState } from '../../../../../../common/hooks/use_bool_state';
import type { DryRunResult, BulkActionForConfirmation } from './types';
/**
* hook that controls bulk actions confirmation modal window and its content
*/
export const useBulkActionsConfirmation = () => {
const [bulkAction, setBulkAction] = useState<BulkActionForConfirmation>();
const [dryRunResult, setDryRunResult] = useState<DryRunResult>();
const [isBulkActionConfirmationVisible, showModal, hideModal] = useBoolState();
const [confirmForm, onConfirm, onCancel] = useAsyncConfirmation({
onInit: showModal,
onFinish: hideModal,
});
const showBulkActionConfirmation = useCallback(
async (result: DryRunResult | undefined, action: BulkActionForConfirmation) => {
setBulkAction(action);
setDryRunResult(result);
// show bulk action confirmation window only if there is at least one failed rule, otherwise return early
const hasFailedRules = (result?.failedRulesCount ?? 0) > 0;
if (!hasFailedRules) {
return true;
}
const confirmation = await confirmForm();
if (confirmation) {
onConfirm();
}
return confirmation;
},
[confirmForm, onConfirm]
);
return {
bulkActionsDryRunResult: dryRunResult,
bulkAction,
isBulkActionConfirmationVisible,
showBulkActionConfirmation,
cancelBulkActionConfirmation: onCancel,
approveBulkActionConfirmation: onConfirm,
};
};

View file

@ -8,8 +8,6 @@
import type { UseMutateAsyncFunction } from 'react-query';
import { useMutation } from 'react-query';
import type { BulkActionsDryRunErrCode } from '../../../../../../../common/constants';
import type {
BulkAction,
BulkActionEditType,
@ -17,48 +15,12 @@ import type {
import type { BulkActionResponse } from '../../../../../containers/detection_engine/rules';
import { performBulkAction } from '../../../../../containers/detection_engine/rules';
import { computeDryRunPayload } from './utils/compute_dry_run_payload';
import { processDryRunResult } from './utils/dry_run_result';
import type { DryRunResult } from './types';
const BULK_ACTIONS_DRY_RUN_QUERY_KEY = 'bulkActionsDryRun';
export interface DryRunResult {
/**
* total number of rules that succeeded validation in dry run
*/
succeededRulesCount?: number;
/**
* total number of rules that failed validation in dry run
*/
failedRulesCount?: number;
/**
* rule failures errors(message and error code) and ids of rules, that failed
*/
ruleErrors: Array<{
message: string;
errorCode?: BulkActionsDryRunErrCode;
ruleIds: string[];
}>;
}
/**
* helper utility that transforms raw BulkActionResponse response to DryRunResult format
* @param response - raw bulk_actions API response ({@link BulkActionResponse})
* @returns dry run result ({@link DryRunResult})
*/
const processDryRunResult = (response: BulkActionResponse | undefined): DryRunResult => {
const processed = {
succeededRulesCount: response?.attributes.summary.succeeded,
failedRulesCount: response?.attributes.summary.failed,
ruleErrors:
response?.attributes.errors?.map(({ message, err_code: errorCode, rules }) => ({
message,
errorCode,
ruleIds: rules.map(({ id }) => id),
})) ?? [],
};
return processed;
};
export type ExecuteBulkActionsDryRun = UseMutateAsyncFunction<
DryRunResult | undefined,
unknown,

View file

@ -0,0 +1,54 @@
/*
* 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 { BulkActionsDryRunErrCode } from '../../../../../../../../common/constants';
import type { ExportRulesDetails } from '../../../../../../../../common/detection_engine/schemas/response/export_rules_details_schema';
import type { BulkActionResponse } from '../../../../../../containers/detection_engine/rules';
import type { DryRunResult } from '../types';
/**
* helper utility that transforms raw BulkActionResponse response to DryRunResult format
* @param response - raw bulk_actions API response ({@link BulkActionResponse})
* @returns dry run result ({@link DryRunResult})
*/
export const processDryRunResult = (response: BulkActionResponse | undefined): DryRunResult => {
const processed = {
succeededRulesCount: response?.attributes.summary.succeeded,
failedRulesCount: response?.attributes.summary.failed,
ruleErrors:
response?.attributes.errors?.map(({ message, err_code: errorCode, rules }) => ({
message,
errorCode,
ruleIds: rules.map(({ id }) => id),
})) ?? [],
};
return processed;
};
/**
* transform rules export details {@link ExportRulesDetails} to dry run result format {@link DryRunResult}
* @param details - {@link ExportRulesDetails} rules export details
* @returns transformed to {@link DryRunResult}
*/
export const transformExportDetailsToDryRunResult = (details: ExportRulesDetails): DryRunResult => {
return {
succeededRulesCount: details.exported_count,
failedRulesCount: details.missing_rules_count,
// if there are rules that can't be exported, it means they are immutable. So we can safely put error code as immutable
ruleErrors: details.missing_rules.length
? [
{
errorCode: BulkActionsDryRunErrCode.IMMUTABLE,
message: "Prebuilt rules can't be exported.",
ruleIds: details.missing_rules.map(({ rule_id: ruleId }) => ruleId),
},
]
: [],
};
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { DryRunResult } from '../use_bulk_actions_dry_run';
import type { DryRunResult } from '../types';
import type { FilterOptions } from '../../../../../../containers/detection_engine/rules/types';
import { convertRulesFilterToKQL } from '../../../../../../containers/detection_engine/rules/utils';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { DryRunResult } from '../use_bulk_actions_dry_run';
import type { DryRunResult } from '../types';
import type { FilterOptions } from '../../../../../../containers/detection_engine/rules/types';
import { convertRulesFilterToKQL } from '../../../../../../containers/detection_engine/rules/utils';

View file

@ -14,7 +14,7 @@ import type { UseAppToasts } from '../../../../../common/hooks/use_app_toasts';
import { canEditRuleWithActions } from '../../../../../common/utils/privileges';
import type { Rule } from '../../../../containers/detection_engine/rules';
import * as i18n from '../translations';
import { executeRulesBulkAction, goToRuleEditPage } from './actions';
import { executeRulesBulkAction, goToRuleEditPage, bulkExportRules } from './actions';
import type { RulesTableActions } from './rules_table/rules_table_context';
import type { useStartTransaction } from '../../../../../common/lib/apm/use_start_transaction';
import { SINGLE_RULE_ACTIONS } from '../../../../../common/lib/apm/user_actions';
@ -91,7 +91,7 @@ export const getRulesTableActions = ({
name: i18n.EXPORT_RULE,
onClick: async (rule: Rule) => {
startTransaction({ name: SINGLE_RULE_ACTIONS.EXPORT });
await executeRulesBulkAction({
await bulkExportRules({
action: BulkAction.export,
setLoadingRules,
visibleRuleIds: [rule.id],

View file

@ -5,6 +5,8 @@
* 2.0.
*/
/* eslint-disable complexity */
import {
EuiBasicTable,
EuiConfirmModal,
@ -33,8 +35,9 @@ import { useAsyncConfirmation } from './rules_table/use_async_confirmation';
import { RulesTableFilters } from './rules_table_filters/rules_table_filters';
import { AllRulesUtilityBar } from './utility_bar';
import { useBulkActionsDryRun } from './bulk_actions/use_bulk_actions_dry_run';
import { useBulkActionsConfirmation } from './bulk_actions/use_bulk_actions_confirmation';
import { useBulkEditFormFlyout } from './bulk_actions/use_bulk_edit_form_flyout';
import { BulkEditDryRunConfirmation } from './bulk_actions/bulk_edit_dry_run_confirmation';
import { BulkActionDryRunConfirmation } from './bulk_actions/bulk_action_dry_run_confirmation';
import { BulkEditFlyout } from './bulk_actions/bulk_edit_flyout';
import { useBulkActions } from './bulk_actions/use_bulk_actions';
@ -126,13 +129,14 @@ export const RulesTables = React.memo<RulesTableProps>(
onFinish: hideDeleteConfirmation,
});
const [isBulkEditConfirmationVisible, showBulkEditConfirmation, hideBulkEditConfirmation] =
useBoolState();
const [confirmBulkEdit, handleBulkEditConfirm, handleBulkEditCancel] = useAsyncConfirmation({
onInit: showBulkEditConfirmation,
onFinish: hideBulkEditConfirmation,
});
const {
bulkActionsDryRunResult,
bulkAction,
isBulkActionConfirmationVisible,
showBulkActionConfirmation,
cancelBulkActionConfirmation,
approveBulkActionConfirmation,
} = useBulkActionsConfirmation();
const {
bulkEditActionType,
@ -145,13 +149,12 @@ export const RulesTables = React.memo<RulesTableProps>(
const selectedItemsCount = isAllSelected ? pagination.total : selectedRuleIds.length;
const hasPagination = pagination.total > pagination.perPage;
const { bulkActionsDryRunResult, isBulkActionsDryRunLoading, executeBulkActionsDryRun } =
useBulkActionsDryRun();
const { isBulkActionsDryRunLoading, executeBulkActionsDryRun } = useBulkActionsDryRun();
const getBulkItemsPopoverContent = useBulkActions({
filterOptions,
confirmDeletion,
confirmBulkEdit,
showBulkActionConfirmation,
completeBulkEditForm,
reFetchTags,
executeBulkActionsDryRun,
@ -312,11 +315,12 @@ export const RulesTables = React.memo<RulesTableProps>(
<p>{i18n.DELETE_CONFIRMATION_BODY}</p>
</EuiConfirmModal>
)}
{isBulkEditConfirmationVisible && (
<BulkEditDryRunConfirmation
{isBulkActionConfirmationVisible && bulkAction && (
<BulkActionDryRunConfirmation
bulkAction={bulkAction}
result={bulkActionsDryRunResult}
onCancel={handleBulkEditCancel}
onConfirm={handleBulkEditConfirm}
onCancel={cancelBulkActionConfirmation}
onConfirm={approveBulkActionConfirmation}
/>
)}
{isBulkEditFlyoutVisible && bulkEditActionType !== undefined && (

View file

@ -210,7 +210,16 @@ export const BULK_EDIT_WARNING_TOAST_NOTIFY = i18n.translate(
}
);
export const BULK_EDIT_CONFIRMATION_DENIED_TITLE = (rulesCount: number) =>
export const BULK_EXPORT_CONFIRMATION_REJECTED_TITLE = (rulesCount: number) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkExportConfirmationDeniedTitle',
{
values: { rulesCount },
defaultMessage: '{rulesCount, plural, =1 {# rule} other {# rules}} cannot be exported',
}
);
export const BULK_EDIT_CONFIRMATION_REJECTED_TITLE = (rulesCount: number) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationDeniedTitle',
{
@ -219,9 +228,9 @@ export const BULK_EDIT_CONFIRMATION_DENIED_TITLE = (rulesCount: number) =>
}
);
export const BULK_EDIT_CONFIRMATION_PARTLY_TITLE = (customRulesCount: number) =>
export const BULK_ACTION_CONFIRMATION_PARTLY_TITLE = (customRulesCount: number) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationPartlyTitle',
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkActionConfirmationPartlyTitle',
{
values: { customRulesCount },
defaultMessage:
@ -236,8 +245,8 @@ export const BULK_EDIT_CONFIRMATION_CANCEL = i18n.translate(
}
);
export const BULK_EDIT_CONFIRMATION_CLOSE = i18n.translate(
'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditConfirmationCloseButtonLabel',
export const BULK_ACTION_CONFIRMATION_CLOSE = i18n.translate(
'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkActionConfirmationCloseButtonLabel',
{
defaultMessage: 'Close',
}
@ -252,6 +261,16 @@ export const BULK_EDIT_CONFIRMATION_CONFIRM = (customRulesCount: number) =>
}
);
export const BULK_EXPORT_CONFIRMATION_CONFIRM = (customRulesCount: number) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkExportConfirmation.confirmButtonLabel',
{
values: { customRulesCount },
defaultMessage:
'Export {customRulesCount, plural, =1 {# Custom rule} other {# Custom rules}}',
}
);
export const BULK_EDIT_FLYOUT_FORM_SAVE = i18n.translate(
'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.saveButtonLabel',
{
@ -843,10 +862,17 @@ export const RULES_BULK_EXPORT_SUCCESS_DESCRIPTION = (exportedRules: number, tot
{
values: { totalRules, exportedRules },
defaultMessage:
'Successfully exported {exportedRules} of {totalRules} {totalRules, plural, =1 {rule} other {rules}}. Prebuilt rules were excluded from the resulting file.',
'Successfully exported {exportedRules} of {totalRules} {totalRules, plural, =1 {rule} other {rules}}.',
}
);
export const RULES_BULK_EXPORT_PREBUILT_RULES_EXCLUDED_DESCRIPTION = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.prebuiltRulesExcludedToastDescription',
{
defaultMessage: 'Prebuilt rules were excluded from the resulting file.',
}
);
export const RULES_BULK_EXPORT_FAILURE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.errorToastTitle',
{