diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts index a162ca4a544d1..7084ff99b5881 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts @@ -13,7 +13,7 @@ import { FOURTH_RULE, RULES_TABLE, pageSelector, - RULES_TABLE_REFRESH_INDICATOR, + RULES_ROW, } from '../../screens/alerts_detection_rules'; import { goToManageAlertsDetectionRules, waitForAlertsPanelToBeLoaded } from '../../tasks/alerts'; @@ -90,14 +90,10 @@ describe('Alerts detection rules', () => { .invoke('text') .then((ruleNameFirstPage) => { goToPage(2); - cy.get(RULES_TABLE_REFRESH_INDICATOR).should('not.exist'); - cy.get(RULES_TABLE) - .find(RULE_NAME) - .first() - .invoke('text') - .should((ruleNameSecondPage) => { - expect(ruleNameFirstPage).not.to.eq(ruleNameSecondPage); - }); + // Check that the rules table shows at least one row + cy.get(RULES_TABLE).find(RULES_ROW).should('have.length.gte', 1); + // Check that the rules table doesn't show the rule from the first page + cy.get(RULES_TABLE).should('not.contain', ruleNameFirstPage); }); cy.get(RULES_TABLE) diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 8d125c242be35..afe3981219217 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -154,7 +154,7 @@ export const goToRuleDetails = () => { }; export const goToTheRuleDetailsOf = (ruleName: string) => { - cy.get(RULE_NAME).contains(ruleName).click(); + cy.get(RULE_NAME).should('contain', ruleName).contains(ruleName).click(); }; export const loadPrebuiltDetectionRules = () => { diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/filters/toggle_selected_group.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/filters/toggle_selected_group.tsx index d452aa9a311e7..4f7064afe642f 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/filters/toggle_selected_group.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/filters/toggle_selected_group.tsx @@ -5,12 +5,10 @@ * 2.0. */ -import { Dispatch, SetStateAction } from 'react'; - export const toggleSelectedGroup = ( group: string, selectedGroups: string[], - setSelectedGroups: Dispatch> + setSelectedGroups: (groups: string[]) => void ): void => { const selectedGroupIndex = selectedGroups.indexOf(group); const updatedSelectedGroups = [...selectedGroups]; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx index 3037a3c82f946..71fabef22c904 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx @@ -178,7 +178,7 @@ describe('RuleActionsOverflow', () => { ).toEqual(false); }); - test('it calls duplicateRulesAction when rules-details-duplicate-rule is clicked', () => { + test('it calls duplicate action when rules-details-duplicate-rule is clicked', () => { const wrapper = mount( { ); }); - test('it calls duplicateRulesAction with the rule and rule.id when rules-details-duplicate-rule is clicked', () => { + test('it calls duplicate action with the rule and rule.id when rules-details-duplicate-rule is clicked', () => { const rule = mockRule('id'); const wrapper = mount( @@ -210,7 +210,7 @@ describe('RuleActionsOverflow', () => { }); }); - test('it calls editRuleAction after the rule is duplicated', async () => { + test('it navigates to edit page after the rule is duplicated', async () => { const rule = mockRule('id'); const ruleDuplicate = mockRule('newRule'); executeRulesBulkActionMock.mockImplementation(() => diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts index ecfa98bfa3076..8e5df718e5aa9 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts @@ -142,7 +142,37 @@ describe('Detections Rules API', () => { expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_find', { method: 'GET', query: { - filter: 'alert.attributes.name: hello world', + filter: + '(alert.attributes.name: "hello world" OR alert.attributes.params.index: "hello world" OR alert.attributes.params.threat.tactic.id: "hello world" OR alert.attributes.params.threat.tactic.name: "hello world" OR alert.attributes.params.threat.technique.id: "hello world" OR alert.attributes.params.threat.technique.name: "hello world")', + page: 1, + per_page: 20, + sort_field: 'enabled', + sort_order: 'desc', + }, + signal: abortCtrl.signal, + }); + }); + + test('check parameter url, query with a filter get escaped correctly', async () => { + await fetchRules({ + filterOptions: { + filter: '" OR (foo:bar)', + showCustomRules: false, + showElasticRules: false, + tags: [], + }, + sortingOptions: { + field: 'enabled', + order: 'desc', + }, + signal: abortCtrl.signal, + }); + + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_find', { + method: 'GET', + query: { + filter: + '(alert.attributes.name: "\\" OR (foo:bar)" OR alert.attributes.params.index: "\\" OR (foo:bar)" OR alert.attributes.params.threat.tactic.id: "\\" OR (foo:bar)" OR alert.attributes.params.threat.tactic.name: "\\" OR (foo:bar)" OR alert.attributes.params.threat.technique.id: "\\" OR (foo:bar)" OR alert.attributes.params.threat.technique.name: "\\" OR (foo:bar)")', page: 1, per_page: 20, sort_field: 'enabled', @@ -226,7 +256,7 @@ describe('Detections Rules API', () => { expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_find', { method: 'GET', query: { - filter: 'alert.attributes.tags: "hello" AND alert.attributes.tags: "world"', + filter: 'alert.attributes.tags:("hello" AND "world")', page: 1, per_page: 20, sort_field: 'enabled', @@ -254,7 +284,7 @@ describe('Detections Rules API', () => { expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_find', { method: 'GET', query: { - filter: 'alert.attributes.tags: "hello" AND alert.attributes.tags: "world"', + filter: 'alert.attributes.tags:("hello" AND "world")', page: 1, per_page: 20, sort_field: 'updatedAt', @@ -353,7 +383,7 @@ describe('Detections Rules API', () => { method: 'GET', query: { filter: - 'alert.attributes.name: ruleName AND alert.attributes.tags: "__internal_immutable:false" AND alert.attributes.tags: "__internal_immutable:true" AND (alert.attributes.tags: "hello" AND alert.attributes.tags: "world")', + 'alert.attributes.tags: "__internal_immutable:false" AND alert.attributes.tags: "__internal_immutable:true" AND alert.attributes.tags:("hello" AND "world") AND (alert.attributes.name: "ruleName" OR alert.attributes.params.index: "ruleName" OR alert.attributes.params.threat.tactic.id: "ruleName" OR alert.attributes.params.threat.tactic.name: "ruleName" OR alert.attributes.params.threat.technique.id: "ruleName" OR alert.attributes.params.threat.technique.name: "ruleName")', page: 1, per_page: 20, sort_field: 'enabled', diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.test.ts index 7f69d07e83467..e3d2300972a51 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.test.ts @@ -26,7 +26,17 @@ describe('convertRulesFilterToKQL', () => { it('handles presence of "filter" properly', () => { const kql = convertRulesFilterToKQL({ ...filterOptions, filter: 'foo' }); - expect(kql).toBe('alert.attributes.name: foo'); + expect(kql).toBe( + '(alert.attributes.name: "foo" OR alert.attributes.params.index: "foo" OR alert.attributes.params.threat.tactic.id: "foo" OR alert.attributes.params.threat.tactic.name: "foo" OR alert.attributes.params.threat.technique.id: "foo" OR alert.attributes.params.threat.technique.name: "foo")' + ); + }); + + it('escapes "filter" value properly', () => { + const kql = convertRulesFilterToKQL({ ...filterOptions, filter: '" OR (foo: bar)' }); + + expect(kql).toBe( + '(alert.attributes.name: "\\" OR (foo: bar)" OR alert.attributes.params.index: "\\" OR (foo: bar)" OR alert.attributes.params.threat.tactic.id: "\\" OR (foo: bar)" OR alert.attributes.params.threat.tactic.name: "\\" OR (foo: bar)" OR alert.attributes.params.threat.technique.id: "\\" OR (foo: bar)" OR alert.attributes.params.threat.technique.name: "\\" OR (foo: bar)")' + ); }); it('handles presence of "showCustomRules" properly', () => { @@ -44,7 +54,7 @@ describe('convertRulesFilterToKQL', () => { it('handles presence of "tags" properly', () => { const kql = convertRulesFilterToKQL({ ...filterOptions, tags: ['tag1', 'tag2'] }); - expect(kql).toBe('alert.attributes.tags: "tag1" AND alert.attributes.tags: "tag2"'); + expect(kql).toBe('alert.attributes.tags:("tag1" AND "tag2")'); }); it('handles combination of different properties properly', () => { @@ -56,7 +66,7 @@ describe('convertRulesFilterToKQL', () => { }); expect(kql).toBe( - `alert.attributes.name: foo AND alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true" AND (alert.attributes.tags: "tag1" AND alert.attributes.tags: "tag2")` + `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true" AND alert.attributes.tags:(\"tag1\" AND \"tag2\") AND (alert.attributes.name: \"foo\" OR alert.attributes.params.index: \"foo\" OR alert.attributes.params.threat.tactic.id: \"foo\" OR alert.attributes.params.threat.tactic.name: \"foo\" OR alert.attributes.params.threat.technique.id: \"foo\" OR alert.attributes.params.threat.technique.name: \"foo\")` ); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.ts index 841b2adca09e0..f5e52fd6362c1 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.ts @@ -6,8 +6,18 @@ */ import { INTERNAL_IMMUTABLE_KEY } from '../../../../../common/constants'; +import { escapeKuery } from '../../../../common/lib/keury'; import { FilterOptions } from './types'; +const SEARCHABLE_RULE_PARAMS = [ + 'alert.attributes.name', + 'alert.attributes.params.index', + 'alert.attributes.params.threat.tactic.id', + 'alert.attributes.params.threat.tactic.name', + 'alert.attributes.params.threat.technique.id', + 'alert.attributes.params.threat.technique.name', +]; + /** * Convert rules filter options object to KQL query * @@ -15,27 +25,35 @@ import { FilterOptions } from './types'; * * @returns KQL string */ -export const convertRulesFilterToKQL = (filterOptions: FilterOptions): string => { - const showCustomRuleFilter = filterOptions.showCustomRules - ? [`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:false"`] - : []; - const showElasticRuleFilter = filterOptions.showElasticRules - ? [`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`] - : []; - const filtersWithoutTags = [ - ...(filterOptions.filter.length ? [`alert.attributes.name: ${filterOptions.filter}`] : []), - ...showCustomRuleFilter, - ...showElasticRuleFilter, - ].join(' AND '); - - const tags = filterOptions.tags - .map((t) => `alert.attributes.tags: "${t.replace(/"/g, '\\"')}"`) - .join(' AND '); - - const filterString = - filtersWithoutTags !== '' && tags !== '' - ? `${filtersWithoutTags} AND (${tags})` - : filtersWithoutTags + tags; - - return filterString; +export const convertRulesFilterToKQL = ({ + showCustomRules, + showElasticRules, + filter, + tags, +}: FilterOptions): string => { + const filters: string[] = []; + + if (showCustomRules) { + filters.push(`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:false"`); + } + + if (showElasticRules) { + filters.push(`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`); + } + + if (tags.length > 0) { + filters.push( + `alert.attributes.tags:(${tags.map((tag) => `"${escapeKuery(tag)}"`).join(' AND ')})` + ); + } + + if (filter.length) { + const searchQuery = SEARCHABLE_RULE_PARAMS.map( + (param) => `${param}: "${escapeKuery(filter)}"` + ).join(' OR '); + + filters.push(`(${searchQuery})`); + } + + return filters.join(' AND '); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.ts index 10c099e4bfcc8..488fd3625bf59 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.ts @@ -81,25 +81,6 @@ export const executeRulesBulkAction = async ({ } }; -export const initRulesBulkAction = (params: Omit) => { - const byQuery = (query: string) => - executeRulesBulkAction({ - ...params, - search: { query }, - }); - - const byIds = (ids: string[]) => - executeRulesBulkAction({ - ...params, - search: { ids }, - }); - - return { - byQuery, - byIds, - }; -}; - function defaultErrorHandler(toasts: UseAppToasts, action: BulkAction, error: HTTPError) { // if response doesn't have number of failed rules, it means the whole bulk action failed // and general error toast will be shown. Otherwise - error toast for partial failure diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx index 491b693a442ba..6bd7cfc67f308 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx @@ -30,7 +30,7 @@ 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, initRulesBulkAction } from '../actions'; +import { executeRulesBulkAction } from '../actions'; import { useHasActionsPrivileges } from '../use_has_actions_privileges'; import { useHasMlPermissions } from '../use_has_ml_permissions'; import { getCustomRulesCountFromCache } from './use_custom_rules_count'; @@ -239,26 +239,23 @@ export const useBulkActions = ({ ); }, 5 * 1000); - const rulesBulkAction = initRulesBulkAction({ + await executeRulesBulkAction({ visibleRuleIds: selectedRuleIds, action: BulkAction.edit, setLoadingRules, toasts, payload: { edit: [editPayload] }, onFinish: () => hideWarningToast(), + search: isAllSelected + ? { + query: convertRulesFilterToKQL({ + ...filterOptions, + showCustomRules: true, // only edit custom rules, as elastic rule are immutable + }), + } + : { ids: customSelectedRuleIds }, }); - // only edit custom rules, as elastic rule are immutable - if (isAllSelected) { - const customRulesOnlyFilterQuery = convertRulesFilterToKQL({ - ...filterOptions, - showCustomRules: true, - }); - await rulesBulkAction.byQuery(customRulesOnlyFilterQuery); - } else { - await rulesBulkAction.byIds(customSelectedRuleIds); - } - isBulkEditFinished = true; invalidateRules(); if (getIsMounted()) { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/rules_table_context.tsx index 2bf20acfb9334..130dd61c371ae 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/rules_table_context.tsx @@ -16,7 +16,7 @@ import { SortingOptions, } from '../../../../../containers/detection_engine/rules/types'; import { useFindRules } from './use_find_rules'; -import { getRulesComparator, getRulesPredicate } from './utils'; +import { getRulesComparator } from './utils'; export interface RulesTableState { /** @@ -114,7 +114,7 @@ export interface LoadingRules { export interface RulesTableActions { reFetchRules: ReturnType['refetch']; - setFilterOptions: React.Dispatch>; + setFilterOptions: (newFilter: Partial) => void; setIsAllSelected: React.Dispatch>; setIsInMemorySorting: (value: boolean) => void; setIsRefreshOn: React.Dispatch>; @@ -186,6 +186,13 @@ export const RulesTableContextProvider = ({ const pagination = useMemo(() => ({ page, perPage }), [page, perPage]); + const handleFilterOptionsChange = useCallback((newFilter: Partial) => { + setFilterOptions((currentFilter) => ({ ...currentFilter, ...newFilter })); + setPage(1); + setSelectedRuleIds([]); + setIsAllSelected(false); + }, []); + // Fetch rules const { data: { rules, total } = { rules: [], total: 0 }, @@ -210,15 +217,10 @@ export const RulesTableContextProvider = ({ } }, [isFetched, isRefetching, refetchPrePackagedRulesStatus]); - // Filter rules - const filteredRules = isInMemorySorting ? rules.filter(getRulesPredicate(filterOptions)) : rules; - // Paginate and sort rules const rulesToDisplay = isInMemorySorting - ? filteredRules - .sort(getRulesComparator(sortingOptions)) - .slice((page - 1) * perPage, page * perPage) - : filteredRules; + ? rules.sort(getRulesComparator(sortingOptions)).slice((page - 1) * perPage, page * perPage) + : rules; const providerValue = useMemo( () => ({ @@ -227,7 +229,7 @@ export const RulesTableContextProvider = ({ pagination: { page, perPage, - total: isInMemorySorting ? filteredRules.length : total, + total: isInMemorySorting ? rules.length : total, }, filterOptions, isActionInProgress, @@ -246,7 +248,7 @@ export const RulesTableContextProvider = ({ }, actions: { reFetchRules: refetch, - setFilterOptions, + setFilterOptions: handleFilterOptionsChange, setIsAllSelected, setIsInMemorySorting: toggleInMemorySorting, setIsRefreshOn, @@ -260,7 +262,7 @@ export const RulesTableContextProvider = ({ [ dataUpdatedAt, filterOptions, - filteredRules.length, + handleFilterOptionsChange, isActionInProgress, isAllSelected, isFetched, @@ -274,6 +276,7 @@ export const RulesTableContextProvider = ({ page, perPage, refetch, + rules.length, rulesToDisplay, selectedRuleIds, sortingOptions, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/use_find_rules.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/use_find_rules.ts index 47a2617dd2e25..6544df7e7ed2a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/use_find_rules.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/use_find_rules.ts @@ -31,8 +31,12 @@ export const useFindRules = (args: UseFindRulesArgs) => { // Use this query result when isInMemorySorting = true const allRules = useFindRulesQuery( ['all'], - { pagination: { page: 1, perPage: MAX_RULES_PER_PAGE } }, - { refetchInterval, enabled: isInMemorySorting } + { pagination: { page: 1, perPage: MAX_RULES_PER_PAGE }, filterOptions }, + { + refetchInterval, + enabled: isInMemorySorting, + keepPreviousData: true, // Use this option so that the state doesn't jump between "success" and "loading" on page change + } ); // Use this query result when isInMemorySorting = false diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/utils.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/utils.ts index 37deade0d1316..12d114cb3c990 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table/utils.ts @@ -6,11 +6,7 @@ */ import { get } from 'lodash'; -import { - FilterOptions, - Rule, - SortingOptions, -} from '../../../../../containers/detection_engine/rules/types'; +import { Rule, SortingOptions } from '../../../../../containers/detection_engine/rules/types'; /** * Returns a comparator function to be used with .sort() @@ -79,29 +75,3 @@ const compareNumbers = (a: number, b: number, direction: number) => { } return 0; }; - -/** - * Returns a predicate function to be used with .filter() - * - * @param filterOptions Current table filter - */ -export function getRulesPredicate(filterOptions: FilterOptions) { - return (rule: Rule) => { - if ( - filterOptions.filter && - !rule.name.toLowerCase().includes(filterOptions.filter.toLowerCase()) - ) { - return false; - } - if (filterOptions.showCustomRules && rule.immutable) { - return false; - } - if (filterOptions.showElasticRules && !rule.immutable) { - return false; - } - if (filterOptions.tags.length && !filterOptions.tags.every((tag) => rule.tags.includes(tag))) { - return false; - } - return true; - }; -} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx index e627ce3815e59..816ffdfa9dad6 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx @@ -7,65 +7,38 @@ import React from 'react'; import { mount } from 'enzyme'; -import { act } from '@testing-library/react'; import { RulesTableFilters } from './rules_table_filters'; -import { useAppToastsMock } from '../../../../../../common/hooks/use_app_toasts.mock'; -import { useAppToasts } from '../../../../../../common/hooks/use_app_toasts'; -jest.mock('../../../../../../common/hooks/use_app_toasts'); +import { TestProviders } from '../../../../../../common/mock'; -describe('RulesTableFilters', () => { - let appToastsMock: jest.Mocked>; - - beforeEach(() => { - jest.resetAllMocks(); - appToastsMock = useAppToastsMock.create(); - (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); - }); +jest.mock('../rules_table/rules_table_context'); +describe('RulesTableFilters', () => { it('renders no numbers next to rule type button filter if none exist', async () => { - await act(async () => { - const wrapper = mount( - ({})} - /> - ); - - expect(wrapper.find('[data-test-subj="showElasticRulesFilterButton"]').at(0).text()).toEqual( - 'Elastic rules' - ); - expect(wrapper.find('[data-test-subj="showCustomRulesFilterButton"]').at(0).text()).toEqual( - 'Custom rules' - ); - }); + const wrapper = mount( + , + { wrappingComponent: TestProviders } + ); + + expect(wrapper.find('[data-test-subj="showElasticRulesFilterButton"]').at(0).text()).toEqual( + 'Elastic rules' + ); + expect(wrapper.find('[data-test-subj="showCustomRulesFilterButton"]').at(0).text()).toEqual( + 'Custom rules' + ); }); it('renders number of custom and prepackaged rules', async () => { - await act(async () => { - const wrapper = mount( - ({})} - /> - ); - - expect(wrapper.find('[data-test-subj="showElasticRulesFilterButton"]').at(0).text()).toEqual( - 'Elastic rules (9)' - ); - expect(wrapper.find('[data-test-subj="showCustomRulesFilterButton"]').at(0).text()).toEqual( - 'Custom rules (10)' - ); - }); + const wrapper = mount( + , + { wrappingComponent: TestProviders } + ); + + expect(wrapper.find('[data-test-subj="showElasticRulesFilterButton"]').at(0).text()).toEqual( + 'Elastic rules (9)' + ); + expect(wrapper.find('[data-test-subj="showCustomRulesFilterButton"]').at(0).text()).toEqual( + 'Custom rules (10)' + ); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx index 5987cd75d303e..b4c81ae5a177d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx @@ -5,8 +5,6 @@ * 2.0. */ -import React, { useCallback, useEffect, useState } from 'react'; - import { EuiFieldSearch, EuiFilterButton, @@ -15,76 +13,63 @@ import { EuiFlexItem, } from '@elastic/eui'; import { isEqual } from 'lodash/fp'; - +import React, { useCallback } from 'react'; +import styled from 'styled-components'; import * as i18n from '../../translations'; - -import { FilterOptions } from '../../../../../containers/detection_engine/rules'; +import { useRulesTableContext } from '../rules_table/rules_table_context'; import { TagsFilterPopover } from './tags_filter_popover'; +const FilterWrapper = styled(EuiFlexGroup)` + margin-bottom: ${({ theme }) => theme.eui.euiSizeXS}; +`; + interface RulesTableFiltersProps { - onFilterChanged: (filterOptions: Partial) => void; rulesCustomInstalled: number | null; rulesInstalled: number | null; - currentFilterTags: string[]; - tags: string[]; - isLoadingTags: boolean; - reFetchTags: () => void; + allTags: string[]; } /** * Collection of filters for filtering data within the RulesTable. Contains search bar, Elastic/Custom * Rules filter button toggle, and tag selection - * - * @param onFilterChanged change listener to be notified on filter changes */ const RulesTableFiltersComponent = ({ - onFilterChanged, rulesCustomInstalled, rulesInstalled, - currentFilterTags, - tags, - isLoadingTags, - reFetchTags, + allTags, }: RulesTableFiltersProps) => { - const [filter, setFilter] = useState(''); - const [selectedTags, setSelectedTags] = useState([]); - const [showCustomRules, setShowCustomRules] = useState(false); - const [showElasticRules, setShowElasticRules] = useState(false); + const { + state: { filterOptions }, + actions: { setFilterOptions }, + } = useRulesTableContext(); - useEffect(() => { - reFetchTags(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rulesCustomInstalled, rulesInstalled]); + const { showCustomRules, showElasticRules, tags: selectedTags } = filterOptions; - // Propagate filter changes to parent - useEffect(() => { - onFilterChanged({ filter, showCustomRules, showElasticRules, tags: selectedTags }); - }, [filter, selectedTags, showCustomRules, showElasticRules, onFilterChanged]); - - const handleOnSearch = useCallback((filterString) => setFilter(filterString.trim()), [setFilter]); + const handleOnSearch = useCallback( + (filterString) => setFilterOptions({ filter: filterString.trim() }), + [setFilterOptions] + ); const handleElasticRulesClick = useCallback(() => { - setShowElasticRules(!showElasticRules); - setShowCustomRules(false); - }, [setShowElasticRules, showElasticRules, setShowCustomRules]); + setFilterOptions({ showElasticRules: !showElasticRules, showCustomRules: false }); + }, [setFilterOptions, showElasticRules]); const handleCustomRulesClick = useCallback(() => { - setShowCustomRules(!showCustomRules); - setShowElasticRules(false); - }, [setShowElasticRules, showCustomRules, setShowCustomRules]); + setFilterOptions({ showCustomRules: !showCustomRules, showElasticRules: false }); + }, [setFilterOptions, showCustomRules]); const handleSelectedTags = useCallback( - (newTags) => { + (newTags: string[]) => { if (!isEqual(newTags, selectedTags)) { - setSelectedTags(newTags); + setFilterOptions({ tags: newTags }); } }, - [selectedTags] + [selectedTags, setFilterOptions] ); return ( - - + + - @@ -123,14 +105,12 @@ const RulesTableFiltersComponent = ({ onClick={handleCustomRulesClick} data-test-subj="showCustomRulesFilterButton" > - <> - {i18n.CUSTOM_RULES} - {rulesCustomInstalled != null ? ` (${rulesCustomInstalled})` : ''} - + {i18n.CUSTOM_RULES} + {rulesCustomInstalled != null ? ` (${rulesCustomInstalled})` : ''} - + ); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.test.tsx index bb41c2d87cd5a..c8b5e78a94563 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.test.tsx @@ -13,13 +13,7 @@ import { TagsFilterPopover } from './tags_filter_popover'; describe('TagsFilterPopover', () => { it('renders correctly', () => { const wrapper = shallow( - + ); expect(wrapper.find('EuiPopover')).toHaveLength(1); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx index c5262caf6c776..ca2c2b4d00d30 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx @@ -5,15 +5,7 @@ * 2.0. */ -import React, { - ChangeEvent, - Dispatch, - SetStateAction, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; +import React, { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { EuiFilterButton, EuiFilterSelectItem, @@ -33,10 +25,7 @@ import { caseInsensitiveSort } from '../helpers'; interface TagsFilterPopoverProps { selectedTags: string[]; tags: string[]; - onSelectedTagsChanged: Dispatch>; - currentFilterTags: string[]; - // eslint-disable-next-line react/no-unused-prop-types - isLoading: boolean; // TO DO reimplement? + onSelectedTagsChanged: (newTags: string[]) => void; } const PopoverContentWrapper = styled.div` @@ -64,11 +53,10 @@ const TagsFilterPopoverComponent = ({ tags, selectedTags, onSelectedTagsChanged, - currentFilterTags, }: TagsFilterPopoverProps) => { const sortedTags = useMemo( - () => caseInsensitiveSort(Array.from(new Set([...tags, ...currentFilterTags]))), - [tags, currentFilterTags] + () => caseInsensitiveSort(Array.from(new Set([...tags, ...selectedTags]))), + [selectedTags, tags] ); const [isTagPopoverOpen, setIsTagPopoverOpen] = useState(false); const [searchInput, setSearchInput] = useState(''); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx index c38c8e48928f1..3962fa217ccfb 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -14,19 +14,16 @@ import { EuiLoadingContent, EuiProgress, } from '@elastic/eui'; -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { partition } from 'lodash/fp'; import { AllRulesTabs } from './rules_table_toolbar'; -import { HeaderSection } from '../../../../../common/components/header_section'; import { Loader } from '../../../../../common/components/loader'; import { useBoolState } from '../../../../../common/hooks/use_bool_state'; import { useValueChanged } from '../../../../../common/hooks/use_value_changed'; -import { useKibana } from '../../../../../common/lib/kibana'; import { PrePackagedRulesPrompt } from '../../../../components/rules/pre_packaged_rules/load_empty_prompt'; import { CreatePreBuiltRules, - FilterOptions, Rule, RulesSortingFields, } from '../../../../containers/detection_engine/rules'; @@ -85,7 +82,6 @@ export const RulesTables = React.memo( rulesNotUpdated, selectedTab, }) => { - const { timelines } = useKibana().services; const tableRef = useRef(null); const rulesTableContext = useRulesTableContext(); @@ -96,11 +92,9 @@ export const RulesTables = React.memo( isActionInProgress, isAllSelected, isFetched, - isFetching, isLoading, isRefetching, isRefreshOn, - lastUpdated, loadingRuleIds, loadingRulesAction, pagination, @@ -109,7 +103,6 @@ export const RulesTables = React.memo( }, actions: { reFetchRules, - setFilterOptions, setIsAllSelected, setIsRefreshOn, setPage, @@ -125,7 +118,12 @@ export const RulesTables = React.memo( rulesNotUpdated ); - const [isLoadingTags, tags, reFetchTags] = useTags(); + const [, allTags, reFetchTags] = useTags(); + + useEffect(() => { + reFetchTags(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rulesCustomInstalled, rulesInstalled]); const [isDeleteConfirmationVisible, showDeleteConfirmation, hideDeleteConfirmation] = useBoolState(); @@ -183,16 +181,6 @@ export const RulesTables = React.memo( [pagination] ); - const onFilterChangedCallback = useCallback( - (newFilter: Partial) => { - setFilterOptions((currentFilter) => ({ ...currentFilter, ...newFilter })); - setPage(1); - setSelectedRuleIds([]); - setIsAllSelected(false); - }, - [setFilterOptions, setIsAllSelected, setPage, setSelectedRuleIds] - ); - const tableOnChangeCallback = useCallback( ({ page, sort }: EuiBasicTableOnChange) => { setSortingOptions({ @@ -286,9 +274,11 @@ export const RulesTables = React.memo( } : { 'data-test-subj': 'monitoring-table', columns: monitoringColumns }; + const shouldShowLinearProgress = isFetched && isRefetching; + const shouldShowLoadingOverlay = (!isFetched && isRefetching) || isActionInProgress; return ( <> - {isFetched && isRefetching && ( + {shouldShowLinearProgress && ( ( color="accent" /> )} - {((!isFetched && isRefetching) || isActionInProgress) && ( + {shouldShowLoadingOverlay && ( )} - - {shouldShowRulesTable && ( - - )} - + {shouldShowRulesTable && ( + + )} {shouldShowPrepackagedRulesPrompt && ( ( editAction={bulkEditActionType} onClose={handleBulkEditFormCancel} onConfirm={handleBulkEditFormConfirm} - tags={tags} + tags={allTags} /> )} {shouldShowRulesTable && ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx index 6d9c2f92b214e..5513f70c42297 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx @@ -22,6 +22,8 @@ import { UtilityBarText, } from '../../../../../common/components/utility_bar'; import * as i18n from '../translations'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { useRulesTableContextOptional } from './rules_table/rules_table_context'; interface AllRulesUtilityBarProps { canBulkEdit: boolean; @@ -55,6 +57,9 @@ export const AllRulesUtilityBar = React.memo( isBulkActionInProgress, hasDisabledActions, }) => { + const { timelines } = useKibana().services; + const rulesTableContext = useRulesTableContextOptional(); + const handleGetBulkItemsPopoverContent = useCallback( (closePopover: () => void): JSX.Element | null => { if (onGetBulkItemsPopoverContent != null) { @@ -100,7 +105,7 @@ export const AllRulesUtilityBar = React.memo( ); return ( - + {hasBulkActions ? ( @@ -180,6 +185,14 @@ export const AllRulesUtilityBar = React.memo( )} + {rulesTableContext && ( + + {timelines.getLastUpdated({ + showUpdating: rulesTableContext.state.isFetching, + updatedAt: rulesTableContext.state.lastUpdated, + })} + + )} ); } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index f2f3ef2828e9b..597abc4427182 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -391,13 +391,6 @@ export const EXPORT_FILENAME = i18n.translate( } ); -export const ALL_RULES = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.tableTitle', - { - defaultMessage: 'All rules', - } -); - export const SEARCH_RULES = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.searchAriaLabel', { @@ -408,7 +401,7 @@ export const SEARCH_RULES = i18n.translate( export const SEARCH_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.searchPlaceholder', { - defaultMessage: 'e.g. rule name', + defaultMessage: 'Search by rule name, index pattern, or MITRE ATT&CK tactic or technique', } ); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 9056d2e6053b3..76442ad89a003 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -20836,14 +20836,12 @@ "xpack.securitySolution.detectionEngine.rules.allRules.inactiveRuleDescription": "inactive", "xpack.securitySolution.detectionEngine.rules.allRules.refreshTitle": "Actualiser", "xpack.securitySolution.detectionEngine.rules.allRules.searchAriaLabel": "Rechercher les règles", - "xpack.securitySolution.detectionEngine.rules.allRules.searchPlaceholder": "par ex. nom de règle", "xpack.securitySolution.detectionEngine.rules.allRules.selectAllRulesTitle": "Sélection totale de {totalRules} {totalRules, plural, =1 {règle} other {règles}} effectuée", "xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle": "Sélection de {selectedRules} {selectedRules, plural, =1 {règle} other {règles}} effectuée", "xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "Affichage de {totalLists} {totalLists, plural, =1 {liste} other {listes}}", "xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "Affichage de {totalRules} {totalRules, plural, =1 {règle} other {règles}}", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "Duplication réussie de {totalRules, plural, =1 {{totalRules} règle} other {{totalRules} règles}}", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription": "Exportation réussie de {exportedRules} sur {totalRules} {totalRules, plural, =1 {règle} other {règles}}. Les règles prédéfinies ont été exclues du fichier résultant.", - "xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "Toutes les règles", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "Listes d'exceptions", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "Monitoring des règles", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.rules": "Règles", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e2e106515d408..bd977bcf9673a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23846,14 +23846,12 @@ "xpack.securitySolution.detectionEngine.rules.allRules.inactiveRuleDescription": "非アクティブ", "xpack.securitySolution.detectionEngine.rules.allRules.refreshTitle": "更新", "xpack.securitySolution.detectionEngine.rules.allRules.searchAriaLabel": "ルールの検索", - "xpack.securitySolution.detectionEngine.rules.allRules.searchPlaceholder": "例:ルール名", "xpack.securitySolution.detectionEngine.rules.allRules.selectAllRulesTitle": "すべての{totalRules} {totalRules, plural, other {個のルール}}を選択", "xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle": "{selectedRules} {selectedRules, plural, other {ルール}}を選択しました", "xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "{totalLists} {totalLists, plural, other {件のリスト}}を表示しています。", "xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "{totalRules} {totalRules, plural, other {ルール}}を表示中", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "{totalRules, plural, other {{totalRules}ルール}}を正常に複製しました", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription": "{exportedRules}/{totalRules} {totalRules, plural, other {件のルール}}を正常にエクスポートしました事前構築済みルールは結果のファイルから除外されました。", - "xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "すべてのルール", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "例外リスト", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "ルール監視", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.rules": "ルール", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 0ddb9de4feb12..30c21a93165c1 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23873,14 +23873,12 @@ "xpack.securitySolution.detectionEngine.rules.allRules.inactiveRuleDescription": "非活动", "xpack.securitySolution.detectionEngine.rules.allRules.refreshTitle": "刷新", "xpack.securitySolution.detectionEngine.rules.allRules.searchAriaLabel": "搜索规则", - "xpack.securitySolution.detectionEngine.rules.allRules.searchPlaceholder": "例如,规则名", "xpack.securitySolution.detectionEngine.rules.allRules.selectAllRulesTitle": "选择所有 {totalRules} 个{totalRules, plural, other {规则}}", "xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle": "已选择 {selectedRules} 个{selectedRules, plural, other {规则}}", "xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "正在显示 {totalLists} 个{totalLists, plural, other {列表}}", "xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "正在显示 {totalRules} 个{totalRules, plural, other {规则}}", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "已成功复制 {totalRules, plural, other {{totalRules} 个规则}}", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription": "已成功导出 {exportedRules}/{totalRules} 个{totalRules, plural, other {规则}}。预置规则已从结果文件中排除。", - "xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "所有规则", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "例外列表", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "规则监测", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.rules": "规则",