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

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Xavier
Mouligneau","email":"xavier.mouligneau@elastic.co"},"sourceCommit":{"committedDate":"2024-01-24T23:01:53Z","message":"[RAM]
Stack Management::Rules loses user selections when navigating back
(#174954)\n\n## Summary\r\n\r\nFIX =>
dd6a2b83-7460-4d90-8ee4-39c40534c247)\r\n\r\n\r\n###
Checklist\r\n\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Kibana Machine
<42973632+kibanamachine@users.noreply.github.com>","sha":"9357bd4714ef0af35d75534b5d19d33684e0aaaf","branchLabelMapping":{"^v8.13.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["bug","release_note:enhancement","Team:ResponseOps","Feature:Alerting/RulesManagement","v8.12.1","v8.13.0"],"title":"[RAM]
Stack Management::Rules loses user selections when navigating
back","number":174954,"url":"https://github.com/elastic/kibana/pull/174954","mergeCommit":{"message":"[RAM]
Stack Management::Rules loses user selections when navigating back
(#174954)\n\n## Summary\r\n\r\nFIX =>
dd6a2b83-7460-4d90-8ee4-39c40534c247)\r\n\r\n\r\n###
Checklist\r\n\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Kibana Machine
<42973632+kibanamachine@users.noreply.github.com>","sha":"9357bd4714ef0af35d75534b5d19d33684e0aaaf"}},"sourceBranch":"main","suggestedTargetBranches":["8.12"],"targetPullRequestStates":[{"branch":"8.12","label":"v8.12.1","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.13.0","branchLabelMappingKey":"^v8.13.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/174954","number":174954,"mergeCommit":{"message":"[RAM]
Stack Management::Rules loses user selections when navigating back
(#174954)\n\n## Summary\r\n\r\nFIX =>
dd6a2b83-7460-4d90-8ee4-39c40534c247)\r\n\r\n\r\n###
Checklist\r\n\r\n- [ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Kibana Machine
<42973632+kibanamachine@users.noreply.github.com>","sha":"9357bd4714ef0af35d75534b5d19d33684e0aaaf"}}]}]
BACKPORT-->

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
This commit is contained in:
Kibana Machine 2024-01-24 19:26:55 -05:00 committed by GitHub
parent de22341254
commit 5c7f3729fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 619 additions and 56 deletions

View file

@ -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);
});
});

View file

@ -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<string, string | number | object>;
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<FilterParameters>(
`${RULES_LIST_FILTERS_KEY}_filters`,
{}
);
const hasFilterFromLocalStorage = useMemo(
() =>
rulesListFilterLocal
? !Object.values(rulesListFilterLocal).every((filters) => isEmpty(filters))
: false,
[rulesListFilterLocal]
);
const rulesListFilterUrl = useMemo(
() => urlStateStorage.get<FilterParameters>('_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<RulesListFilters>({
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]
);
};

View file

@ -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');

View file

@ -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<boolean>(false);
const [page, setPage] = useState<Pagination>({ index: 0, size: DEFAULT_SEARCH_PAGE_SIZE });
const [inputText, setInputText] = useState<string>(searchFilter);
const [filters, setFilters] = useState<RulesListFilters>({
actionTypes: [],
ruleExecutionStatuses: lastResponseFilter || [],
ruleLastRunOutcomes: lastRunOutcomeFilter || [],
ruleParams: ruleParamFilter || {},
ruleStatuses: statusFilter || [],
searchText: searchFilter || '',
tags: [],
types: typeFilter || [],
kueryNode: undefined,
});
const [ruleFlyoutVisible, setRuleFlyoutVisibility] = useState<boolean>(false);
const [editFlyoutVisible, setEditFlyoutVisibility] = useState<boolean>(false);
const [currentRuleToEdit, setCurrentRuleToEdit] = useState<RuleTableItem | null>(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 && (
<ManageLicenseModal

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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<number>(-1);
@ -844,34 +849,56 @@ export const RulesListTable = (props: RulesListTableProps) => {
return (
<EuiFlexGroup gutterSize="none" direction="column">
<EuiFlexGroup gutterSize="none" alignItems="center">
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="none" alignItems="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="flexStart" gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
{numberOfSelectedRules > 0 ? (
renderSelectAllDropdown?.()
) : (
<EuiText
size="xs"
style={{ fontWeight: euiTheme.font.weight.semiBold }}
data-test-subj="totalRulesCount"
>
{TOTAL_RULES(formattedTotalRules, rulesState.totalItemCount)}
</EuiText>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{numberOfSelectedRules > 0 && authorizedToModifyAllRules && (
<EuiButtonEmpty
size="xs"
aria-label={SELECT_ALL_ARIA_LABEL}
data-test-subj="selectAllRulesButton"
iconType={isAllSelected ? 'cross' : 'pagesSelect'}
onClick={onSelectAll}
>
{selectAllButtonText}
</EuiButtonEmpty>
)}
</EuiFlexItem>
{numberOfFilters > 0 && (
<EuiFlexItem
css={{
borderLeft: euiTheme.border.thin,
paddingLeft: euiTheme.size.m,
}}
>
<EuiButtonEmpty
onClick={resetFilters}
size="xs"
iconSide="left"
flush="left"
data-test-subj="rules-list-clear-filter"
>
{CLEAR_FILTERS(numberOfFilters)}
</EuiButtonEmpty>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>{ColumnSelector}</EuiFlexItem>
<EuiFlexItem grow={false}>
{numberOfSelectedRules > 0 ? (
renderSelectAllDropdown?.()
) : (
<EuiText
size="xs"
style={{ fontWeight: euiTheme.font.weight.semiBold }}
data-test-subj="totalRulesCount"
>
{TOTAL_RULES(formattedTotalRules, rulesState.totalItemCount)}
</EuiText>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{numberOfSelectedRules > 0 && authorizedToModifyAllRules && (
<EuiButtonEmpty
size="xs"
aria-label={SELECT_ALL_ARIA_LABEL}
data-test-subj="selectAllRulesButton"
iconType={isAllSelected ? 'cross' : 'pagesSelect'}
onClick={onSelectAll}
>
{selectAllButtonText}
</EuiButtonEmpty>
)}
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem>
<EuiBasicTable

View file

@ -340,6 +340,13 @@ export const CANCEL_BUTTON_TEXT = i18n.translate(
}
);
export const CLEAR_FILTERS = (numberOfFilters: number) => {
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,

View file

@ -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');
}

View file

@ -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');
});