From 5c7f3729fd2ac388437c80f3a3181bf764c07a32 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 24 Jan 2024 19:26:55 -0500 Subject: [PATCH] [8.12] [RAM] Stack Management::Rules loses user selections when navigating back (#174954) (#175494) # Backport This will backport the following commits from `main` to `8.12`: - [[RAM] Stack Management::Rules loses user selections when navigating back (#174954)](https://github.com/elastic/kibana/pull/174954) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) Co-authored-by: Xavier Mouligneau --- .../use_rules_list_filter_store.test.tsx | 267 ++++++++++++++++++ .../hooks/use_rules_list_filter_store.tsx | 188 ++++++++++++ .../rules_list/components/rules_list.test.tsx | 13 + .../rules_list/components/rules_list.tsx | 36 ++- .../rules_list_bulk_disable.test.tsx | 11 + .../components/rules_list_bulk_edit.test.tsx | 11 + .../rules_list_bulk_enable.test.tsx | 11 + .../components/rules_list_table.tsx | 79 ++++-- .../sections/rules_list/translations.ts | 7 + .../rules_list/rules_list.ts | 8 + .../observability/rules/rules_list.ts | 42 ++- 11 files changed, 618 insertions(+), 55 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/hooks/use_rules_list_filter_store.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/hooks/use_rules_list_filter_store.tsx diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/hooks/use_rules_list_filter_store.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/hooks/use_rules_list_filter_store.test.tsx new file mode 100644 index 0000000000000..89b5e56aa33dd --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/hooks/use_rules_list_filter_store.test.tsx @@ -0,0 +1,267 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import * as useLocalStorage from 'react-use/lib/useLocalStorage'; +import { useRulesListFilterStore } from './use_rules_list_filter_store'; + +jest.mock('@kbn/kibana-utils-plugin/public'); +const { createKbnUrlStateStorage } = jest.requireMock('@kbn/kibana-utils-plugin/public'); + +const useUrlStateStorageGetMock = jest.fn(); +const useUrlStateStorageSetMock = jest.fn(); +const setRulesListFilterLocalMock = jest.fn(); +const LOCAL_STORAGE_KEY = 'test_local'; +describe('useRulesListFilterStore', () => { + beforeAll(() => { + createKbnUrlStateStorage.mockReturnValue({ + get: useUrlStateStorageGetMock, + set: useUrlStateStorageSetMock, + }); + }); + + beforeEach(() => { + jest + .spyOn(useLocalStorage, 'default') + .mockImplementation(() => [null, setRulesListFilterLocalMock, () => {}]); + useUrlStateStorageGetMock.mockReturnValue(null); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Should return empty filter when url query param and local storage and props are empty', async () => { + const { result } = renderHook(() => + useRulesListFilterStore({ + rulesListKey: LOCAL_STORAGE_KEY, + }) + ); + expect(result.current.filters).toEqual({ + actionTypes: [], + kueryNode: undefined, + ruleExecutionStatuses: [], + ruleLastRunOutcomes: [], + ruleParams: {}, + ruleStatuses: [], + searchText: '', + tags: [], + types: [], + }); + expect(result.current.numberOfFiltersStore).toEqual(0); + }); + + it('Should return the props as filter when url query param and local storage are empty', () => { + const { result } = renderHook(() => + useRulesListFilterStore({ + lastResponseFilter: ['props-lastResponse-filter'], + lastRunOutcomeFilter: ['props-lastRunOutcome-filter'], + rulesListKey: LOCAL_STORAGE_KEY, + ruleParamFilter: { propsRuleParams: 'props-ruleParams-filter' }, + statusFilter: ['enabled'], + searchFilter: 'props-search-filter', + typeFilter: ['props-ruleType-filter'], + }) + ); + expect(result.current.filters).toEqual({ + actionTypes: [], + kueryNode: undefined, + ruleExecutionStatuses: ['props-lastResponse-filter'], + ruleLastRunOutcomes: ['props-lastRunOutcome-filter'], + ruleParams: { + propsRuleParams: 'props-ruleParams-filter', + }, + ruleStatuses: ['enabled'], + searchText: 'props-search-filter', + tags: [], + types: ['props-ruleType-filter'], + }); + expect(result.current.numberOfFiltersStore).toEqual(6); + }); + + it('Should return the local storage params as filter when url query param is empty', () => { + jest.spyOn(useLocalStorage, 'default').mockImplementation(() => [ + { + actionTypes: ['localStorage-actionType-filter'], + lastResponse: ['localStorage-lastResponse-filter'], + params: { localStorageRuleParams: 'localStorage-ruleParams-filter' }, + search: 'localStorage-search-filter', + status: ['disabled'], + tags: ['localStorage-tag-filter'], + type: ['localStorage-ruleType-filter'], + }, + () => null, + () => {}, + ]); + const { result } = renderHook(() => + useRulesListFilterStore({ + lastResponseFilter: ['props-lastResponse-filter'], + lastRunOutcomeFilter: ['props-lastRunOutcome-filter'], + rulesListKey: LOCAL_STORAGE_KEY, + ruleParamFilter: { propsRuleParams: 'props-ruleParams-filter' }, + statusFilter: ['enabled'], + searchFilter: 'props-search-filter', + typeFilter: ['ruleType-filter'], + }) + ); + expect(result.current.filters).toEqual({ + actionTypes: ['localStorage-actionType-filter'], + kueryNode: undefined, + // THIS is valid because we are not using this param in local storage + ruleExecutionStatuses: ['props-lastResponse-filter'], + ruleLastRunOutcomes: ['localStorage-lastResponse-filter'], + ruleParams: { + localStorageRuleParams: 'localStorage-ruleParams-filter', + }, + ruleStatuses: ['disabled'], + searchText: 'localStorage-search-filter', + tags: ['localStorage-tag-filter'], + types: ['localStorage-ruleType-filter'], + }); + expect(result.current.numberOfFiltersStore).toEqual(8); + }); + + it('Should return the url params as filter when url query param is empty', () => { + jest.spyOn(useLocalStorage, 'default').mockImplementation(() => [ + { + actionTypes: ['localStorage-actionType-filter'], + lastResponse: ['localStorage-lastResponse-filter'], + params: { localStorageRuleParams: 'localStorage-ruleParams-filter' }, + search: 'localStorage-search-filter', + status: ['disabled'], + tags: ['localStorage-tag-filter'], + type: ['localStorage-ruleType-filter'], + }, + () => null, + () => {}, + ]); + useUrlStateStorageGetMock.mockReturnValue({ + actionTypes: ['urlQueryParams-actionType-filter'], + lastResponse: ['urlQueryParams-lastResponse-filter'], + params: { urlQueryParamsRuleParams: 'urlQueryParams-ruleParams-filter' }, + search: 'urlQueryParams-search-filter', + status: ['snoozed'], + tags: ['urlQueryParams-tag-filter'], + type: ['urlQueryParams-ruleType-filter'], + }); + const { result } = renderHook(() => + useRulesListFilterStore({ + lastResponseFilter: ['props-lastResponse-filter'], + lastRunOutcomeFilter: ['props-lastRunOutcome-filter'], + rulesListKey: LOCAL_STORAGE_KEY, + ruleParamFilter: { propsRuleParams: 'props-ruleParams-filter' }, + statusFilter: ['enabled'], + searchFilter: 'props-search-filter', + typeFilter: ['ruleType-filter'], + }) + ); + expect(result.current.filters).toEqual({ + actionTypes: ['urlQueryParams-actionType-filter'], + kueryNode: undefined, + // THIS is valid because we are not using this param in url query params + ruleExecutionStatuses: ['props-lastResponse-filter'], + ruleLastRunOutcomes: ['urlQueryParams-lastResponse-filter'], + ruleParams: { + urlQueryParamsRuleParams: 'urlQueryParams-ruleParams-filter', + }, + ruleStatuses: ['snoozed'], + searchText: 'urlQueryParams-search-filter', + tags: ['urlQueryParams-tag-filter'], + types: ['urlQueryParams-ruleType-filter'], + }); + expect(result.current.numberOfFiltersStore).toEqual(8); + }); + + it('Should clear filter when resetFiltersStore has been called', async () => { + useUrlStateStorageGetMock.mockReturnValue({ + actionTypes: ['urlQueryParams-actionType-filter'], + lastResponse: ['urlQueryParams-lastResponse-filter'], + params: { urlQueryParamsRuleParams: 'urlQueryParams-ruleParams-filter' }, + search: 'urlQueryParams-search-filter', + status: ['snoozed'], + tags: ['urlQueryParams-tag-filter'], + type: ['urlQueryParams-ruleType-filter'], + }); + const { result } = renderHook(() => + useRulesListFilterStore({ + rulesListKey: LOCAL_STORAGE_KEY, + }) + ); + expect(result.current.filters).toEqual({ + actionTypes: ['urlQueryParams-actionType-filter'], + kueryNode: undefined, + ruleExecutionStatuses: [], + ruleLastRunOutcomes: ['urlQueryParams-lastResponse-filter'], + ruleParams: { + urlQueryParamsRuleParams: 'urlQueryParams-ruleParams-filter', + }, + ruleStatuses: ['snoozed'], + searchText: 'urlQueryParams-search-filter', + tags: ['urlQueryParams-tag-filter'], + types: ['urlQueryParams-ruleType-filter'], + }); + expect(result.current.numberOfFiltersStore).toEqual(7); + + act(() => { + result.current.resetFiltersStore(); + }); + + expect(result.current.filters).toEqual({ + actionTypes: [], + kueryNode: undefined, + ruleExecutionStatuses: [], + ruleLastRunOutcomes: [], + ruleParams: {}, + ruleStatuses: [], + searchText: '', + tags: [], + types: [], + }); + expect(result.current.numberOfFiltersStore).toEqual(0); + expect(useUrlStateStorageSetMock).toBeCalledTimes(1); + expect(setRulesListFilterLocalMock).toBeCalledTimes(1); + }); + + it('Should set filter when setFiltersStore has been called', async () => { + const { result } = renderHook(() => + useRulesListFilterStore({ + rulesListKey: LOCAL_STORAGE_KEY, + }) + ); + expect(result.current.filters).toEqual({ + actionTypes: [], + kueryNode: undefined, + ruleExecutionStatuses: [], + ruleLastRunOutcomes: [], + ruleParams: {}, + ruleStatuses: [], + searchText: '', + tags: [], + types: [], + }); + expect(result.current.numberOfFiltersStore).toEqual(0); + + act(() => { + result.current.setFiltersStore({ filter: 'tags', value: ['my-tags'] }); + }); + + expect(result.current.filters).toEqual({ + actionTypes: [], + kueryNode: undefined, + ruleExecutionStatuses: [], + ruleLastRunOutcomes: [], + ruleParams: {}, + ruleStatuses: [], + searchText: '', + tags: ['my-tags'], + types: [], + }); + expect(result.current.numberOfFiltersStore).toEqual(1); + expect(useUrlStateStorageSetMock).toBeCalledTimes(1); + expect(setRulesListFilterLocalMock).toBeCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/hooks/use_rules_list_filter_store.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/hooks/use_rules_list_filter_store.tsx new file mode 100644 index 0000000000000..02798de45c644 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/hooks/use_rules_list_filter_store.tsx @@ -0,0 +1,188 @@ +/* + * 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 { useHistory } from 'react-router-dom'; +import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { isEmpty } from 'lodash'; +import { RuleStatus } from '../../../../../common'; +import { RulesListFilters, RulesListProps, UpdateFiltersProps } from '../../../../../types'; + +type FilterStoreProps = Pick< + RulesListProps, + | 'lastResponseFilter' + | 'lastRunOutcomeFilter' + | 'rulesListKey' + | 'ruleParamFilter' + | 'statusFilter' + | 'searchFilter' + | 'typeFilter' +>; +const RULES_LIST_FILTERS_KEY = 'triggersActionsUi_rulesList'; + +interface FilterParameters { + actionTypes?: string[]; + lastResponse?: string[]; + params?: Record; + search?: string; + status?: RuleStatus[]; + tags?: string[]; + type?: string[]; +} + +export const convertRulesListFiltersToFilterAttributes = ( + rulesListFilter: RulesListFilters +): FilterParameters => { + return { + actionTypes: rulesListFilter.actionTypes, + lastResponse: rulesListFilter.ruleLastRunOutcomes, + params: rulesListFilter.ruleParams, + search: rulesListFilter.searchText, + status: rulesListFilter.ruleStatuses, + tags: rulesListFilter.tags, + type: rulesListFilter.types, + }; +}; + +export const useRulesListFilterStore = ({ + lastResponseFilter, + lastRunOutcomeFilter, + rulesListKey = RULES_LIST_FILTERS_KEY, + ruleParamFilter, + statusFilter, + searchFilter, + typeFilter, +}: FilterStoreProps): { + filters: RulesListFilters; + setFiltersStore: (params: UpdateFiltersProps) => void; + numberOfFiltersStore: number; + resetFiltersStore: () => void; +} => { + const history = useHistory(); + const urlStateStorage = createKbnUrlStateStorage({ + history, + useHash: false, + useHashQuery: false, + }); + + const [rulesListFilterLocal, setRulesListFilterLocal] = useLocalStorage( + `${RULES_LIST_FILTERS_KEY}_filters`, + {} + ); + const hasFilterFromLocalStorage = useMemo( + () => + rulesListFilterLocal + ? !Object.values(rulesListFilterLocal).every((filters) => isEmpty(filters)) + : false, + [rulesListFilterLocal] + ); + + const rulesListFilterUrl = useMemo( + () => urlStateStorage.get('_a') ?? {}, + [urlStateStorage] + ); + + const hasFilterFromUrl = useMemo( + () => + rulesListFilterUrl + ? !Object.values(rulesListFilterUrl).every((filters) => isEmpty(filters)) + : false, + [rulesListFilterUrl] + ); + + const filtersStore = useMemo( + () => + hasFilterFromUrl ? rulesListFilterUrl : hasFilterFromLocalStorage ? rulesListFilterLocal : {}, + [hasFilterFromLocalStorage, hasFilterFromUrl, rulesListFilterLocal, rulesListFilterUrl] + ); + const [filters, setFilters] = useState({ + actionTypes: filtersStore?.actionTypes ?? [], + ruleExecutionStatuses: lastResponseFilter ?? [], + ruleLastRunOutcomes: filtersStore?.lastResponse ?? lastRunOutcomeFilter ?? [], + ruleParams: filtersStore?.params ?? ruleParamFilter ?? {}, + ruleStatuses: filtersStore?.status ?? statusFilter ?? [], + searchText: filtersStore?.search ?? searchFilter ?? '', + tags: filtersStore?.tags ?? [], + types: filtersStore?.type ?? typeFilter ?? [], + kueryNode: undefined, + }); + + const updateUrlFilters = useCallback( + (updatedParams: RulesListFilters) => { + urlStateStorage.set('_a', convertRulesListFiltersToFilterAttributes(updatedParams)); + }, + [urlStateStorage] + ); + + const updateLocalFilters = useCallback( + (updatedParams: RulesListFilters) => { + setRulesListFilterLocal(convertRulesListFiltersToFilterAttributes(updatedParams)); + }, + [setRulesListFilterLocal] + ); + + const setFiltersStore = useCallback( + (updateFiltersProps: UpdateFiltersProps) => { + const { filter, value } = updateFiltersProps; + setFilters((prev) => { + const newFilters = { + ...prev, + [filter]: value, + }; + updateUrlFilters(newFilters); + updateLocalFilters(newFilters); + return newFilters; + }); + }, + [updateLocalFilters, updateUrlFilters] + ); + + const resetFiltersStore = useCallback(() => { + const resetFilter = { + actionTypes: [], + ruleExecutionStatuses: [], + ruleLastRunOutcomes: [], + ruleParams: {}, + ruleStatuses: [], + searchText: '', + tags: [], + types: [], + kueryNode: undefined, + }; + setFilters(resetFilter); + updateUrlFilters(resetFilter); + updateLocalFilters(resetFilter); + }, [updateLocalFilters, updateUrlFilters]); + + useEffect(() => { + if (hasFilterFromUrl || hasFilterFromLocalStorage) { + setFilters({ + actionTypes: filtersStore?.actionTypes ?? [], + ruleExecutionStatuses: lastResponseFilter ?? [], + ruleLastRunOutcomes: filtersStore?.lastResponse ?? lastRunOutcomeFilter ?? [], + ruleParams: filtersStore?.params ?? ruleParamFilter ?? {}, + ruleStatuses: filtersStore?.status ?? statusFilter ?? [], + searchText: filtersStore?.search ?? searchFilter ?? '', + tags: filtersStore?.tags ?? [], + types: filtersStore?.type ?? typeFilter ?? [], + kueryNode: undefined, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return useMemo( + () => ({ + filters, + setFiltersStore, + numberOfFiltersStore: Object.values(filters).filter((filter) => !isEmpty(filter)).length, + resetFiltersStore, + }), + [filters, resetFiltersStore, setFiltersStore] + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index a8dee15e26104..cd5e66c703533 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -123,6 +123,19 @@ jest.mock('../../../../common/get_experimental_features', () => ({ getIsExperimentalFeatureEnabled: jest.fn(), })); +jest.mock('@kbn/kibana-utils-plugin/public', () => { + const originalModule = jest.requireActual('@kbn/kibana-utils-plugin/public'); + return { + ...originalModule, + createKbnUrlStateStorage: jest.fn(() => ({ + get: jest.fn(() => null), + set: jest.fn(() => null), + })), + }; +}); + +jest.mock('react-use/lib/useLocalStorage', () => jest.fn(() => [null, () => null])); + const ruleTags = ['a', 'b', 'c', 'd']; const { loadRuleTypes } = jest.requireMock('../../../lib/rule_api/rule_types'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index 61d9fb7133f65..67d475ae2689e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -54,7 +54,6 @@ import { Pagination, Percentiles, SnoozeSchedule, - RulesListFilters, UpdateFiltersProps, BulkEditActions, UpdateRulesToBulkEditProps, @@ -107,6 +106,7 @@ import { import { useBulkOperationToast } from '../../../hooks/use_bulk_operation_toast'; import { RulesSettingsLink } from '../../../components/rules_setting/rules_settings_link'; import { useRulesListUiState as useUiState } from '../../../hooks/use_rules_list_ui_state'; +import { useRulesListFilterStore } from './hooks/use_rules_list_filter_store'; // Directly lazy import the flyouts because the suspendedComponentWithProps component // cause a visual hitch due to the loading spinner @@ -190,23 +190,12 @@ export const RulesList = ({ notifications: { toasts }, ruleTypeRegistry, } = kibanaServices; + const canExecuteActions = hasExecuteActionsCapability(capabilities); const [isPerformingAction, setIsPerformingAction] = useState(false); const [page, setPage] = useState({ index: 0, size: DEFAULT_SEARCH_PAGE_SIZE }); const [inputText, setInputText] = useState(searchFilter); - const [filters, setFilters] = useState({ - actionTypes: [], - ruleExecutionStatuses: lastResponseFilter || [], - ruleLastRunOutcomes: lastRunOutcomeFilter || [], - ruleParams: ruleParamFilter || {}, - ruleStatuses: statusFilter || [], - searchText: searchFilter || '', - tags: [], - types: typeFilter || [], - kueryNode: undefined, - }); - const [ruleFlyoutVisible, setRuleFlyoutVisibility] = useState(false); const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); const [currentRuleToEdit, setCurrentRuleToEdit] = useState(null); @@ -259,6 +248,17 @@ export const RulesList = ({ // Fetch action types const { actionTypes } = useLoadActionTypesQuery(); + const { filters, setFiltersStore, numberOfFiltersStore, resetFiltersStore } = + useRulesListFilterStore({ + lastResponseFilter, + lastRunOutcomeFilter, + rulesListKey, + ruleParamFilter, + statusFilter, + searchFilter, + typeFilter, + }); + const rulesTypesFilter = isEmpty(filters.types) ? authorizedRuleTypes.map((art) => art.id) : filters.types; @@ -406,14 +406,10 @@ export const RulesList = ({ const updateFilters = useCallback( (updateFiltersProps: UpdateFiltersProps) => { - const { filter, value } = updateFiltersProps; - setFilters((prev) => ({ - ...prev, - [filter]: value, - })); + setFiltersStore(updateFiltersProps); handleUpdateFiltersEffect(updateFiltersProps); }, - [setFilters, handleUpdateFiltersEffect] + [setFiltersStore, handleUpdateFiltersEffect] ); const handleClearRuleParamFilter = () => updateFilters({ filter: 'ruleParams', value: {} }); @@ -982,6 +978,8 @@ export const RulesList = ({ rulesListKey={rulesListKey} config={config} visibleColumns={visibleColumns} + numberOfFilters={numberOfFiltersStore} + resetFilters={resetFiltersStore} /> {manageLicenseModalOpts && ( ({ loadRuleAggregationsWithKueryFilter: jest.fn(), })); jest.mock('@kbn/alerts-ui-shared', () => ({ MaintenanceWindowCallout: jest.fn(() => <>) })); +jest.mock('@kbn/kibana-utils-plugin/public', () => { + const originalModule = jest.requireActual('@kbn/kibana-utils-plugin/public'); + return { + ...originalModule, + createKbnUrlStateStorage: jest.fn(() => ({ + get: jest.fn(() => null), + set: jest.fn(() => null), + })), + }; +}); +jest.mock('react-use/lib/useLocalStorage', () => jest.fn(() => [null, () => null])); const { loadRuleAggregationsWithKueryFilter } = jest.requireMock( '../../../lib/rule_api/aggregate_kuery_filter' diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_bulk_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_bulk_edit.test.tsx index 80f74e02acc69..44d6e11d7ab95 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_bulk_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_bulk_edit.test.tsx @@ -91,6 +91,17 @@ jest.mock('../../../lib/rule_api/aggregate_kuery_filter', () => ({ loadRuleAggregationsWithKueryFilter: jest.fn(), })); jest.mock('@kbn/alerts-ui-shared', () => ({ MaintenanceWindowCallout: jest.fn(() => <>) })); +jest.mock('@kbn/kibana-utils-plugin/public', () => { + const originalModule = jest.requireActual('@kbn/kibana-utils-plugin/public'); + return { + ...originalModule, + createKbnUrlStateStorage: jest.fn(() => ({ + get: jest.fn(() => null), + set: jest.fn(() => null), + })), + }; +}); +jest.mock('react-use/lib/useLocalStorage', () => jest.fn(() => [null, () => null])); const { loadRuleAggregationsWithKueryFilter } = jest.requireMock( '../../../lib/rule_api/aggregate_kuery_filter' diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_bulk_enable.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_bulk_enable.test.tsx index 51227392ea216..e8e1bd8b99899 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_bulk_enable.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_bulk_enable.test.tsx @@ -92,6 +92,17 @@ jest.mock('../../../lib/rule_api/aggregate_kuery_filter', () => ({ loadRuleAggregationsWithKueryFilter: jest.fn(), })); jest.mock('@kbn/alerts-ui-shared', () => ({ MaintenanceWindowCallout: jest.fn(() => <>) })); +jest.mock('@kbn/kibana-utils-plugin/public', () => { + const originalModule = jest.requireActual('@kbn/kibana-utils-plugin/public'); + return { + ...originalModule, + createKbnUrlStateStorage: jest.fn(() => ({ + get: jest.fn(() => null), + set: jest.fn(() => null), + })), + }; +}); +jest.mock('react-use/lib/useLocalStorage', () => jest.fn(() => [null, () => null])); const { loadRuleAggregationsWithKueryFilter } = jest.requireMock( '../../../lib/rule_api/aggregate_kuery_filter' diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx index 0f7420a926d6f..9933a52e3ac1f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_table.tsx @@ -39,6 +39,7 @@ import { CLEAR_SELECTION, TOTAL_RULES, SELECT_ALL_ARIA_LABEL, + CLEAR_FILTERS, } from '../translations'; import { Rule, @@ -140,6 +141,8 @@ export interface RulesListTableProps { ) => React.ReactNode; renderRuleError?: (rule: RuleTableItem) => React.ReactNode; visibleColumns?: string[]; + numberOfFilters: number; + resetFilters: () => void; } interface ConvertRulesToTableItemsOpts { @@ -205,6 +208,8 @@ export const RulesListTable = (props: RulesListTableProps) => { renderSelectAllDropdown, renderRuleError = EMPTY_RENDER, visibleColumns, + resetFilters, + numberOfFilters, } = props; const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); @@ -844,34 +849,56 @@ export const RulesListTable = (props: RulesListTableProps) => { return ( - - {ColumnSelector} + - {numberOfSelectedRules > 0 ? ( - renderSelectAllDropdown?.() - ) : ( - - {TOTAL_RULES(formattedTotalRules, rulesState.totalItemCount)} - - )} - - - {numberOfSelectedRules > 0 && authorizedToModifyAllRules && ( - - {selectAllButtonText} - - )} + + + {numberOfSelectedRules > 0 ? ( + renderSelectAllDropdown?.() + ) : ( + + {TOTAL_RULES(formattedTotalRules, rulesState.totalItemCount)} + + )} + + + {numberOfSelectedRules > 0 && authorizedToModifyAllRules && ( + + {selectAllButtonText} + + )} + + {numberOfFilters > 0 && ( + + + {CLEAR_FILTERS(numberOfFilters)} + + + )} + + {ColumnSelector} { + return i18n.translate('xpack.triggersActionsUI.sections.rulesList.clearFilterLink', { + values: { numberOfFilters }, + defaultMessage: 'Clear {numberOfFilters, plural, =1 {filter} other {filters}}', + }); +}; + export const getConfirmDeletionModalText = ( numIdsToDelete: number, singleTitle: string, diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list/rules_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list/rules_list.ts index 41303ca4b028d..1a169c3bd69bf 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list/rules_list.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list/rules_list.ts @@ -27,6 +27,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const objectRemover = new ObjectRemover(supertest); async function refreshAlertsList() { + const existsClearFilter = await testSubjects.exists('rules-list-clear-filter'); + const existsRefreshButton = await testSubjects.exists('refreshRulesButton'); + if (existsClearFilter) { + await testSubjects.click('rules-list-clear-filter'); + } else if (existsRefreshButton) { + await testSubjects.click('refreshRulesButton'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); + } await testSubjects.click('logsTab'); await testSubjects.click('rulesTab'); } diff --git a/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts b/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts index 917e953cb2aec..d664d36e99f1e 100644 --- a/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts +++ b/x-pack/test_serverless/functional/test_suites/observability/rules/rules_list.ts @@ -30,6 +30,11 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const toasts = getService('toasts'); async function refreshRulesList() { + const existsClearFilter = await testSubjects.exists('rules-list-clear-filter'); + if (existsClearFilter) { + await testSubjects.click('rules-list-clear-filter'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); + } await svlCommonNavigation.sidenav.clickLink({ text: 'Alerts' }); await testSubjects.click('manageRulesPageButton'); } @@ -525,6 +530,10 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { expect(filterErrorOnlyResults[0].status).toEqual('Failed'); expect(filterErrorOnlyResults[0].duration).toMatch(/\d{2,}:\d{2}/); }); + + // Clear it again because it is still selected + await refreshRulesList(); + await assertRulesLength(2); }); it.skip('should display total rules by status and error banner only when exists rules with status error', async () => { @@ -673,6 +682,11 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { expect(filterInventoryRuleOnlyResults[0].interval).toEqual('1 min'); expect(filterInventoryRuleOnlyResults[0].duration).toMatch(/\d{2,}:\d{2}/); }); + + // Clear it again because it is still selected + await testSubjects.click('rules-list-clear-filter'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); + await assertRulesLength(2); }); it('should filter rules by the rule status', async () => { @@ -746,6 +760,11 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.click('ruleStatusFilterOption-enabled'); await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); await assertRulesLength(4); + + // Clear it again because it is still selected + await testSubjects.click('rules-list-clear-filter'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); + await assertRulesLength(4); }); it('should filter rules by the tag', async () => { @@ -804,6 +823,11 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await testSubjects.click('ruleTagFilterOption-c'); await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); await assertRulesLength(2); + + // Clear it again because it is still selected + await testSubjects.click('rules-list-clear-filter'); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading'); + await assertRulesLength(5); }); it('should not prevent rules with action execution capabilities from being edited', async () => { @@ -835,12 +859,11 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { ruleIdList = [rule1.id]; await refreshRulesList(); + await assertRulesLength(1); - await retry.try(async () => { - const actionButton = await testSubjects.find('selectActionButton'); - const disabled = await actionButton.getAttribute('disabled'); - expect(disabled).toEqual(null); - }); + const actionButton = await testSubjects.find('selectActionButton'); + const disabled = await actionButton.getAttribute('disabled'); + expect(disabled).toEqual(null); }); it('should allow rules to be snoozed using the right side dropdown', async () => { @@ -851,7 +874,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { ruleIdList = [rule1.id]; await refreshRulesList(); - await svlTriggersActionsUI.searchRules(rule1.name); + await assertRulesLength(1); await testSubjects.click('collapsedItemActions'); await testSubjects.click('snoozeButton'); @@ -871,7 +894,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { ruleIdList = [rule1.id]; await refreshRulesList(); - await svlTriggersActionsUI.searchRules(rule1.name); + await assertRulesLength(1); + await testSubjects.click('collapsedItemActions'); await testSubjects.click('snoozeButton'); await testSubjects.click('ruleSnoozeIndefiniteApply'); @@ -895,8 +919,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); await refreshRulesList(); + await assertRulesLength(1); - await svlTriggersActionsUI.searchRules(rule1.name); await testSubjects.click('collapsedItemActions'); await testSubjects.click('snoozeButton'); await testSubjects.click('ruleSnoozeCancel'); @@ -910,8 +934,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { expect(toastText).toEqual('Rules notification successfully unsnoozed'); }); - await svlTriggersActionsUI.searchRules(rule1.name); - await testSubjects.missingOrFail('rulesListNotifyBadge-snoozed'); await testSubjects.missingOrFail('rulesListNotifyBadge-snoozedIndefinitely'); });