diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts new file mode 100644 index 0000000000000..b00101da6be83 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.test.ts @@ -0,0 +1,111 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadRuleAggregations } from './use_load_rule_aggregations'; +import { RuleStatus } from '../../types'; + +const MOCK_TAGS = ['a', 'b', 'c']; + +const MOCK_AGGS = { + ruleEnabledStatus: { enabled: 2, disabled: 0 }, + ruleExecutionStatus: { ok: 1, active: 2, error: 3, pending: 4, unknown: 5, warning: 6 }, + ruleMutedStatus: { muted: 0, unmuted: 2 }, + ruleTags: MOCK_TAGS, +}; + +jest.mock('../lib/rule_api', () => ({ + loadRuleAggregations: jest.fn(), +})); + +const { loadRuleAggregations } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); + +describe('useLoadRuleAggregations', () => { + beforeEach(() => { + loadRuleAggregations.mockResolvedValue(MOCK_AGGS); + jest.clearAllMocks(); + }); + + it('should call loadRuleAggregations API and handle result', async () => { + const params = { + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + await waitForNextUpdate(); + }); + + expect(loadRuleAggregations).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesStatusesTotal).toEqual(MOCK_AGGS.ruleExecutionStatus); + }); + + it('should call loadRuleAggregation API with params and handle result', async () => { + const params = { + searchText: 'test', + typesFilter: ['type1', 'type2'], + actionTypesFilter: ['action1', 'action2'], + ruleExecutionStatusesFilter: ['status1', 'status2'], + ruleStatusesFilter: ['enabled', 'snoozed'] as RuleStatus[], + tagsFilter: ['tag1', 'tag2'], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + await waitForNextUpdate(); + }); + + expect(loadRuleAggregations).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesStatusesTotal).toEqual(MOCK_AGGS.ruleExecutionStatus); + }); + + it('should call onError if API fails', async () => { + loadRuleAggregations.mockRejectedValue(''); + const params = { + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result } = renderHook(() => + useLoadRuleAggregations({ + ...params, + onError, + }) + ); + + await act(async () => { + result.current.loadRuleAggregations(); + }); + + expect(onError).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts index ae5ad8eb225b1..75f9e18ec2328 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rule_aggregations.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { RuleExecutionStatusValues } from '@kbn/alerting-plugin/common'; import { loadRuleAggregations, LoadRuleAggregationsProps } from '../lib/rule_api'; import { useKibana } from '../../common/lib/kibana'; @@ -27,12 +27,11 @@ export function useLoadRuleAggregations({ const { http } = useKibana().services; const [rulesStatusesTotal, setRulesStatusesTotal] = useState>( - RuleExecutionStatusValues.reduce( - (prev: Record, status: string) => - ({ - ...prev, - [status]: 0, - } as Record), + RuleExecutionStatusValues.reduce>( + (prev: Record, status: string) => ({ + ...prev, + [status]: 0, + }), {} ) ); @@ -73,9 +72,12 @@ export function useLoadRuleAggregations({ setRulesStatusesTotal, ]); - return { - loadRuleAggregations: internalLoadRuleAggregations, - rulesStatusesTotal, - setRulesStatusesTotal, - }; + return useMemo( + () => ({ + loadRuleAggregations: internalLoadRuleAggregations, + rulesStatusesTotal, + setRulesStatusesTotal, + }), + [internalLoadRuleAggregations, rulesStatusesTotal, setRulesStatusesTotal] + ); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts new file mode 100644 index 0000000000000..a309beeca58aa --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.test.ts @@ -0,0 +1,378 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadRules } from './use_load_rules'; +import { + RuleExecutionStatusErrorReasons, + RuleExecutionStatusWarningReasons, +} from '@kbn/alerting-plugin/common'; +import { RuleStatus } from '../../types'; + +jest.mock('../lib/rule_api', () => ({ + loadRules: jest.fn(), +})); + +const { loadRules } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); +const onPage = jest.fn(); + +const mockedRulesData = [ + { + id: '1', + name: 'test rule', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '1s' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 1000000, + }, + { + success: true, + duration: 200000, + }, + { + success: false, + duration: 300000, + }, + ], + calculated_metrics: { + success_ratio: 0.66, + p50: 200000, + p95: 300000, + p99: 300000, + }, + }, + }, + }, + { + id: '2', + name: 'test rule ok', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'ok', + lastDuration: 61000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [ + { + success: true, + duration: 100000, + }, + { + success: true, + duration: 500000, + }, + ], + calculated_metrics: { + success_ratio: 1, + p50: 0, + p95: 100000, + p99: 500000, + }, + }, + }, + }, + { + id: '3', + name: 'test rule pending', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'pending', + lastDuration: 30234, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + monitoring: { + execution: { + history: [{ success: false, duration: 100 }], + calculated_metrics: { + success_ratio: 0, + }, + }, + }, + }, + { + id: '4', + name: 'test rule error', + tags: ['tag1'], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 122000, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.Unknown, + message: 'test', + }, + }, + }, + { + id: '5', + name: 'test rule license error', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: RuleExecutionStatusErrorReasons.License, + message: 'test', + }, + }, + }, + { + id: '6', + name: 'test rule warning', + tags: [], + enabled: true, + ruleTypeId: 'test_rule_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'rule', params: { message: 'test' } }], + params: { name: 'test rule type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'warning', + lastDuration: 500, + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'test', + }, + }, + }, +]; + +const MOCK_RULE_DATA = { + page: 1, + perPage: 10000, + total: 4, + data: mockedRulesData, +}; + +describe('useLoadRules', () => { + beforeEach(() => { + loadRules.mockResolvedValue(MOCK_RULE_DATA); + jest.clearAllMocks(); + }); + + it('should call loadRules API and handle result', async () => { + const params = { + page: { + index: 0, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + expect(result.current.initialLoad).toBeTruthy(); + expect(result.current.noData).toBeTruthy(); + expect(result.current.rulesState.isLoading).toBeFalsy(); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(result.current.initialLoad).toBeFalsy(); + expect(result.current.noData).toBeFalsy(); + expect(result.current.rulesState.isLoading).toBeFalsy(); + + expect(onPage).toBeCalledTimes(0); + expect(loadRules).toBeCalledWith(expect.objectContaining(params)); + expect(result.current.rulesState.data).toEqual(expect.arrayContaining(MOCK_RULE_DATA.data)); + expect(result.current.rulesState.totalItemCount).toEqual(MOCK_RULE_DATA.total); + }); + + it('should call loadRules API with params and handle result', async () => { + const params = { + page: { + index: 0, + size: 25, + }, + searchText: 'test', + typesFilter: ['type1', 'type2'], + actionTypesFilter: ['action1', 'action2'], + ruleExecutionStatusesFilter: ['status1', 'status2'], + ruleStatusesFilter: ['enabled', 'snoozed'] as RuleStatus[], + tagsFilter: ['tag1', 'tag2'], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(loadRules).toBeCalledWith(expect.objectContaining(params)); + }); + + it('should reset the page if the data is fetched while paged', async () => { + loadRules.mockResolvedValue({ + ...MOCK_RULE_DATA, + data: [], + }); + + const params = { + page: { + index: 1, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result, waitForNextUpdate } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + await waitForNextUpdate(); + }); + + expect(onPage).toHaveBeenCalledWith({ + index: 0, + size: 25, + }); + }); + + it('should call onError if API fails', async () => { + loadRules.mockRejectedValue(''); + const params = { + page: { + index: 0, + size: 25, + }, + searchText: '', + typesFilter: [], + actionTypesFilter: [], + ruleExecutionStatusesFilter: [], + ruleStatusesFilter: [], + tagsFilter: [], + }; + + const { result } = renderHook(() => + useLoadRules({ + ...params, + onPage, + onError, + }) + ); + + await act(async () => { + result.current.loadRules(); + }); + + expect(onError).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts index 7c4916c7d2c39..4afdfd4f26a72 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_rules.ts @@ -4,8 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { useMemo, useCallback, useReducer } from 'react'; import { i18n } from '@kbn/i18n'; -import { useState, useCallback } from 'react'; import { isEmpty } from 'lodash'; import { Rule, Pagination } from '../../types'; import { loadRules, LoadRulesProps } from '../lib/rule_api'; @@ -18,11 +18,69 @@ interface RuleState { } type UseLoadRulesProps = Omit & { - hasAnyAuthorizedRuleType: boolean; onPage: (pagination: Pagination) => void; onError: (message: string) => void; }; +interface UseLoadRulesState { + rulesState: RuleState; + noData: boolean; + initialLoad: boolean; +} + +enum ActionTypes { + SET_RULE_STATE = 'SET_RULE_STATE', + SET_LOADING = 'SET_LOADING', + SET_INITIAL_LOAD = 'SET_INITIAL_LOAD', + SET_NO_DATA = 'SET_NO_DATA', +} + +interface Action { + type: ActionTypes; + payload: boolean | RuleState; +} + +const initialState: UseLoadRulesState = { + rulesState: { + isLoading: false, + data: [], + totalItemCount: 0, + }, + noData: true, + initialLoad: true, +}; + +const reducer = (state: UseLoadRulesState, action: Action) => { + const { type, payload } = action; + switch (type) { + case ActionTypes.SET_RULE_STATE: + return { + ...state, + rulesState: payload as RuleState, + }; + case ActionTypes.SET_LOADING: + return { + ...state, + rulesState: { + ...state.rulesState, + isLoading: payload as boolean, + }, + }; + case ActionTypes.SET_INITIAL_LOAD: + return { + ...state, + initialLoad: payload as boolean, + }; + case ActionTypes.SET_NO_DATA: + return { + ...state, + noData: payload as boolean, + }; + default: + return state; + } +}; + export function useLoadRules({ page, searchText, @@ -32,26 +90,25 @@ export function useLoadRules({ ruleStatusesFilter, tagsFilter, sort, - hasAnyAuthorizedRuleType, onPage, onError, }: UseLoadRulesProps) { const { http } = useKibana().services; + const [state, dispatch] = useReducer(reducer, initialState); - const [rulesState, setRulesState] = useState({ - isLoading: false, - data: [], - totalItemCount: 0, - }); - - const [noData, setNoData] = useState(true); - const [initialLoad, setInitialLoad] = useState(true); + const setRulesState = useCallback( + (rulesState: RuleState) => { + dispatch({ + type: ActionTypes.SET_RULE_STATE, + payload: rulesState, + }); + }, + [dispatch] + ); const internalLoadRules = useCallback(async () => { - if (!hasAnyAuthorizedRuleType) { - return; - } - setRulesState((prevRuleState) => ({ ...prevRuleState, isLoading: true })); + dispatch({ type: ActionTypes.SET_LOADING, payload: true }); + try { const rulesResponse = await loadRules({ http, @@ -64,10 +121,14 @@ export function useLoadRules({ tagsFilter, sort, }); - setRulesState({ - isLoading: false, - data: rulesResponse.data, - totalItemCount: rulesResponse.total, + + dispatch({ + type: ActionTypes.SET_RULE_STATE, + payload: { + isLoading: false, + data: rulesResponse.data, + totalItemCount: rulesResponse.total, + }, }); if (!rulesResponse.data?.length && page.index > 0) { @@ -83,16 +144,19 @@ export function useLoadRules({ isEmpty(tagsFilter) ); - setNoData(rulesResponse.data.length === 0 && !isFilterApplied); + dispatch({ + type: ActionTypes.SET_NO_DATA, + payload: rulesResponse.data.length === 0 && !isFilterApplied, + }); } catch (e) { onError( i18n.translate('xpack.triggersActionsUI.sections.rulesList.unableToLoadRulesMessage', { defaultMessage: 'Unable to load rules', }) ); - setRulesState((prevRuleState) => ({ ...prevRuleState, isLoading: false })); + dispatch({ type: ActionTypes.SET_LOADING, payload: false }); } - setInitialLoad(false); + dispatch({ type: ActionTypes.SET_INITIAL_LOAD, payload: false }); }, [ http, page, @@ -103,19 +167,19 @@ export function useLoadRules({ ruleStatusesFilter, tagsFilter, sort, - hasAnyAuthorizedRuleType, - setRulesState, - setNoData, - setInitialLoad, + dispatch, onPage, onError, ]); - return { - rulesState, - setRulesState, - loadRules: internalLoadRules, - noData, - initialLoad, - }; + return useMemo( + () => ({ + rulesState: state.rulesState, + noData: state.noData, + initialLoad: state.initialLoad, + loadRules: internalLoadRules, + setRulesState, + }), + [state, setRulesState, internalLoadRules] + ); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts new file mode 100644 index 0000000000000..8973d869e0724 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useLoadTags } from './use_load_tags'; + +const MOCK_TAGS = ['a', 'b', 'c']; + +jest.mock('../lib/rule_api', () => ({ + loadRuleTags: jest.fn(), +})); + +const { loadRuleTags } = jest.requireMock('../lib/rule_api'); + +const onError = jest.fn(); + +describe('useLoadTags', () => { + beforeEach(() => { + loadRuleTags.mockResolvedValue({ + ruleTags: MOCK_TAGS, + }); + jest.clearAllMocks(); + }); + + it('should call loadRuleTags API and handle result', async () => { + const { result, waitForNextUpdate } = renderHook(() => useLoadTags({ onError })); + + await act(async () => { + result.current.loadTags(); + await waitForNextUpdate(); + }); + + expect(loadRuleTags).toBeCalled(); + expect(result.current.tags).toEqual(MOCK_TAGS); + }); + + it('should call onError if API fails', async () => { + loadRuleTags.mockRejectedValue(''); + + const { result } = renderHook(() => useLoadTags({ onError })); + + await act(async () => { + result.current.loadTags(); + }); + + expect(loadRuleTags).toBeCalled(); + expect(onError).toBeCalled(); + expect(result.current.tags).toEqual([]); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts index 02f811a9d48a3..3357f43a012f1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_load_tags.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { loadRuleTags } from '../lib/rule_api'; import { useKibana } from '../../common/lib/kibana'; @@ -34,9 +34,12 @@ export function useLoadTags(props: UseLoadTagsProps) { } }, [http, setTags, onError]); - return { - loadTags: internalLoadTags, - tags, - setTags, - }; + return useMemo( + () => ({ + tags, + loadTags: internalLoadTags, + setTags, + }), + [tags, internalLoadTags, setTags] + ); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts index 68b028ad226bf..057e5de3d0c4a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts @@ -7,7 +7,8 @@ export { alertingFrameworkHealth } from './health'; export { mapFiltersToKql } from './map_filters_to_kql'; -export { loadRuleAggregations, loadRuleTags, LoadRuleAggregationsProps } from './aggregate'; +export type { LoadRuleAggregationsProps } from './aggregate'; +export { loadRuleAggregations, loadRuleTags } from './aggregate'; export { createRule } from './create'; export { deleteRules } from './delete'; export { disableRule, disableRules } from './disable'; @@ -17,7 +18,8 @@ export { loadRuleSummary } from './rule_summary'; export { muteAlertInstance } from './mute_alert'; export { muteRule, muteRules } from './mute'; export { loadRuleTypes } from './rule_types'; -export { loadRules, LoadRulesProps } from './rules'; +export type { LoadRulesProps } from './rules'; +export { loadRules } from './rules'; export { loadRuleState } from './state'; export type { LoadExecutionLogAggregationsProps } from './load_execution_log_aggregations'; export { loadExecutionLogAggregations } from './load_execution_log_aggregations'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx index 0c97b5854ea83..38d1a62de699a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/action_type_filter.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; import { ActionType } from '../../../../types'; @@ -29,6 +29,20 @@ export const ActionTypeFilter: React.FunctionComponent = // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedValues]); + const onClick = useCallback( + (item: ActionType) => { + return () => { + const isPreviouslyChecked = selectedValues.includes(item.id); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item.id)); + } else { + setSelectedValues(selectedValues.concat(item.id)); + } + }; + }, + [selectedValues, setSelectedValues] + ); + return ( = {actionTypes.map((item) => ( { - const isPreviouslyChecked = selectedValues.includes(item.id); - if (isPreviouslyChecked) { - setSelectedValues(selectedValues.filter((val) => val !== item.id)); - } else { - setSelectedValues(selectedValues.concat(item.id)); - } - }} + onClick={onClick(item)} checked={selectedValues.includes(item.id) ? 'on' : undefined} data-test-subj={`actionType${item.id}FilterOption`} > diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx index 7cfd833a2b191..e5bb7ffd1b0e4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rule_execution_status_filter.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiPopover, EuiFilterButton, EuiFilterSelectItem, EuiHealth } from '@elastic/eui'; import { RuleExecutionStatuses, RuleExecutionStatusValues } from '@kbn/alerting-plugin/common'; @@ -16,6 +16,8 @@ interface RuleExecutionStatusFilterProps { onChange?: (selectedRuleStatusesIds: string[]) => void; } +const sortedRuleExecutionStatusValues = [...RuleExecutionStatusValues].sort(); + export const RuleExecutionStatusFilter: React.FunctionComponent = ({ selectedStatuses, onChange, @@ -23,6 +25,14 @@ export const RuleExecutionStatusFilter: React.FunctionComponent(selectedStatuses); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const onTogglePopover = useCallback(() => { + setIsPopoverOpen((prevIsPopoverOpen) => !prevIsPopoverOpen); + }, [setIsPopoverOpen]); + + const onClosePopover = useCallback(() => { + setIsPopoverOpen(false); + }, [setIsPopoverOpen]); + useEffect(() => { if (onChange) { onChange(selectedValues); @@ -37,14 +47,14 @@ export const RuleExecutionStatusFilter: React.FunctionComponent setIsPopoverOpen(false)} + closePopover={onClosePopover} button={ 0} numActiveFilters={selectedValues.length} numFilters={selectedValues.length} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} + onClick={onTogglePopover} data-test-subj="ruleExecutionStatusFilterButton" >
- {[...RuleExecutionStatusValues].sort().map((item: RuleExecutionStatuses) => { + {sortedRuleExecutionStatusValues.map((item: RuleExecutionStatuses) => { const healthColor = getHealthColor(item); return ( = ({ return ( <> - + { // eslint-disable-next-line react-hooks/rules-of-hooks useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; wrapper = mountWithIntl(); - await act(async () => { await nextTick(); wrapper.update(); @@ -472,7 +471,7 @@ describe('rules_list component with items', () => { .simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe('Start time of the last run.'); @@ -491,7 +490,7 @@ describe('rules_list component with items', () => { wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe( @@ -516,7 +515,7 @@ describe('rules_list component with items', () => { wrapper.find('[data-test-subj="rulesTableCell-durationTooltip"]').first().simulate('mouseOver'); // Run the timers so the EuiTooltip will be visible - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect(wrapper.find('.euiToolTipPopover').text()).toBe( @@ -538,7 +537,7 @@ describe('rules_list component with items', () => { wrapper.find('EuiButtonEmpty[data-test-subj="ruleStatus-error-license-fix"]').length ).toEqual(1); - expect(wrapper.find('[data-test-subj="refreshRulesButton"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="rulesListAutoRefresh"]').exists()).toBeTruthy(); expect(wrapper.find('EuiHealth[data-test-subj="ruleStatus-error"]').first().text()).toEqual( 'Error' @@ -635,7 +634,7 @@ describe('rules_list component with items', () => { .first() .simulate('click'); - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); // Percentile Selection @@ -651,7 +650,7 @@ describe('rules_list component with items', () => { // Select P95 percentileOptions.at(1).simulate('click'); - jest.runAllTimers(); + jest.runOnlyPendingTimers(); wrapper.update(); expect( @@ -706,18 +705,6 @@ describe('rules_list component with items', () => { jest.clearAllMocks(); }); - it('loads rules when refresh button is clicked', async () => { - await setup(); - wrapper.find('[data-test-subj="refreshRulesButton"]').first().simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(loadRules).toHaveBeenCalled(); - }); - it('renders license errors and manage license modal on click', async () => { global.open = jest.fn(); await setup(); @@ -765,7 +752,7 @@ describe('rules_list component with items', () => { it('sorts rules when clicking the status control column', async () => { await setup(); wrapper - .find('[data-test-subj="tableHeaderCell_enabled_8"] .euiTableHeaderButton') + .find('[data-test-subj="tableHeaderCell_enabled_9"] .euiTableHeaderButton') .first() .simulate('click'); @@ -834,21 +821,37 @@ describe('rules_list component with items', () => { loadRules.mockReset(); await setup(); - expect(loadRules.mock.calls[0][0].ruleStatusesFilter).toEqual([]); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: [], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterButton"] button').simulate('click'); wrapper.find('[data-test-subj="ruleStatusFilterOption-enabled"]').first().simulate('click'); - expect(loadRules.mock.calls[1][0].ruleStatusesFilter).toEqual(['enabled']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled'], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterOption-snoozed"]').first().simulate('click'); - expect(loadRules.mock.calls[2][0].ruleStatusesFilter).toEqual(['enabled', 'snoozed']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled', 'snoozed'], + }) + ); wrapper.find('[data-test-subj="ruleStatusFilterOption-snoozed"]').first().simulate('click'); - expect(loadRules.mock.calls[3][0].ruleStatusesFilter).toEqual(['enabled']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + ruleStatusesFilter: ['enabled'], + }) + ); }); it('does not render the tag filter is the feature flag is off', async () => { @@ -867,7 +870,11 @@ describe('rules_list component with items', () => { loadRules.mockReset(); await setup(); - expect(loadRules.mock.calls[0][0].tagsFilter).toEqual([]); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: [], + }) + ); wrapper.find('[data-test-subj="ruleTagFilterButton"] button').simulate('click'); @@ -878,11 +885,19 @@ describe('rules_list component with items', () => { tagFilterListItems.at(0).simulate('click'); - expect(loadRules.mock.calls[1][0].tagsFilter).toEqual(['a']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: ['a'], + }) + ); tagFilterListItems.at(1).simulate('click'); - expect(loadRules.mock.calls[2][0].tagsFilter).toEqual(['a', 'b']); + expect(loadRules).toHaveBeenLastCalledWith( + expect.objectContaining({ + tagsFilter: ['a', 'b'], + }) + ); }); }); @@ -1166,4 +1181,21 @@ describe('rules_list with disabled items', () => { wrapper.find('EuiIconTip[data-test-subj="ruleDisabledByLicenseTooltip"]').props().content ).toEqual('This rule type requires a Platinum license.'); }); + + it('clicking the notify badge shows the snooze panel', async () => { + await setup(); + + expect(wrapper.find('[data-test-subj="snoozePanel"]').exists()).toBeFalsy(); + + wrapper + .find('[data-test-subj="rulesTableCell-rulesListNotify"]') + .first() + .simulate('mouseenter'); + + expect(wrapper.find('[data-test-subj="rulesListNotifyBadge"]').exists()).toBeTruthy(); + + wrapper.find('[data-test-subj="rulesListNotifyBadge"]').first().simulate('click'); + + expect(wrapper.find('[data-test-subj="snoozePanel"]').exists()).toBeTruthy(); + }); }); 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 8b7a4f3561959..41e69ad477882 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 @@ -187,7 +187,6 @@ export const RulesList: React.FunctionComponent = () => { ruleStatusesFilter, tagsFilter, sort, - hasAnyAuthorizedRuleType, onPage: setPage, onError, }); @@ -215,13 +214,24 @@ export const RulesList: React.FunctionComponent = () => { ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; const loadData = useCallback(async () => { + if (!ruleTypesState || !hasAnyAuthorizedRuleType) { + return; + } await loadRules(); await loadRuleAggregations(); if (isRuleStatusFilterEnabled) { await loadTags(); } setLastUpdate(moment().format()); - }, [loadRules, loadTags, loadRuleAggregations, setLastUpdate, isRuleStatusFilterEnabled]); + }, [ + loadRules, + loadTags, + loadRuleAggregations, + setLastUpdate, + isRuleStatusFilterEnabled, + hasAnyAuthorizedRuleType, + ruleTypesState, + ]); useEffect(() => { loadData(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx new file mode 100644 index 0000000000000..9e17561ce652b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import moment from 'moment'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { act } from 'react-dom/test-utils'; +import { RulesListAutoRefresh } from './rules_list_auto_refresh'; + +const onRefresh = jest.fn(); + +describe('RulesListAutoRefresh', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the update text correctly', async () => { + jest.useFakeTimers('modern').setSystemTime(moment('1990-01-01').toDate()); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated a few seconds ago'); + + await act(async () => { + jest.advanceTimersByTime(1 * 60 * 1000); + }); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated a minute ago'); + + await act(async () => { + jest.advanceTimersByTime(1 * 60 * 1000); + }); + + expect( + wrapper.find('[data-test-subj="rulesListAutoRefresh-lastUpdateText"]').first().text() + ).toEqual('Updated 2 minutes ago'); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + }); + + it('calls onRefresh when it auto refreshes', async () => { + jest.useFakeTimers('modern').setSystemTime(moment('1990-01-01').toDate()); + + mountWithIntl( + + ); + + expect(onRefresh).toHaveBeenCalledTimes(0); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(2); + + await act(async () => { + jest.advanceTimersByTime(10 * 1000); + }); + + expect(onRefresh).toHaveBeenCalledTimes(12); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx index d05e35625ee30..eea8d8e5f1bbe 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_auto_refresh.tsx @@ -5,53 +5,108 @@ * 2.0. */ -import React, { useEffect, useState, useRef } from 'react'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; import moment from 'moment'; import { EuiFlexGroup, EuiFlexItem, EuiText, EuiAutoRefreshButton } from '@elastic/eui'; interface RulesListAutoRefreshProps { lastUpdate: string; + initialUpdateInterval?: number; onRefresh: () => void; } +const flexGroupStyle = { + marginLeft: 'auto', +}; + +const getLastUpdateText = (lastUpdate: string) => { + if (!moment(lastUpdate).isValid()) { + return ''; + } + return i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListAutoRefresh.lastUpdateText', + { + defaultMessage: 'Updated {lastUpdateText}', + values: { + lastUpdateText: moment(lastUpdate).fromNow(), + }, + } + ); +}; + +const TEXT_UPDATE_INTERVAL = 60 * 1000; +const DEFAULT_REFRESH_INTERVAL = 5 * 60 * 1000; +const MIN_REFRESH_INTERVAL = 1000; + export const RulesListAutoRefresh = (props: RulesListAutoRefreshProps) => { - const { lastUpdate, onRefresh } = props; + const { lastUpdate, initialUpdateInterval = DEFAULT_REFRESH_INTERVAL, onRefresh } = props; const [isPaused, setIsPaused] = useState(false); - const [refreshInterval, setRefreshInterval] = useState(5 * 60 * 1000); + const [refreshInterval, setRefreshInterval] = useState( + Math.max(initialUpdateInterval, MIN_REFRESH_INTERVAL) + ); + const [lastUpdateText, setLastUpdateText] = useState(''); + const cachedOnRefresh = useRef<() => void>(() => {}); - const timeout = useRef(undefined); + const textUpdateTimeout = useRef(); + const refreshTimeout = useRef(); useEffect(() => { cachedOnRefresh.current = onRefresh; }, [onRefresh]); + useEffect(() => { + setLastUpdateText(getLastUpdateText(lastUpdate)); + + const poll = () => { + textUpdateTimeout.current = window.setTimeout(() => { + setLastUpdateText(getLastUpdateText(lastUpdate)); + poll(); + }, TEXT_UPDATE_INTERVAL); + }; + poll(); + + return () => { + if (textUpdateTimeout.current) { + clearTimeout(textUpdateTimeout.current); + } + }; + }, [lastUpdate, setLastUpdateText]); + useEffect(() => { if (isPaused) { return; } const poll = () => { - timeout.current = window.setTimeout(() => { + refreshTimeout.current = window.setTimeout(() => { cachedOnRefresh.current(); poll(); }, refreshInterval); }; - poll(); return () => { - if (timeout.current) { - clearTimeout(timeout.current); + if (refreshTimeout.current) { + clearTimeout(refreshTimeout.current); } }; }, [isPaused, refreshInterval]); + const onRefreshChange = useCallback( + ({ isPaused: newIsPaused, refreshInterval: newRefreshInterval }) => { + setIsPaused(newIsPaused); + setRefreshInterval(newRefreshInterval); + }, + [setIsPaused, setRefreshInterval] + ); + return ( - - - - {lastUpdate && `Updated ${moment(lastUpdate).fromNow()}`} + + + + {lastUpdateText} @@ -59,10 +114,7 @@ export const RulesListAutoRefresh = (props: RulesListAutoRefreshProps) => { isPaused={isPaused} shortHand refreshInterval={refreshInterval} - onRefreshChange={({ isPaused: newIsPaused, refreshInterval: newRefreshInterval }) => { - setIsPaused(newIsPaused); - setRefreshInterval(newRefreshInterval); - }} + onRefreshChange={onRefreshChange} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx index d0d959e5c3ab7..1d069e5bc0e61 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list_notify_badge.tsx @@ -8,6 +8,7 @@ import React, { useCallback, useMemo } from 'react'; import moment from 'moment'; import { EuiButton, EuiButtonIcon, EuiPopover, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { isRuleSnoozed } from './rule_status_dropdown'; import { RuleTableItem } from '../../../../types'; import { @@ -28,6 +29,11 @@ export interface RulesListNotifyBadgeProps { unsnoozeRule: () => Promise; } +const openSnoozePanelAriaLabel = i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.openSnoozePanel', + { defaultMessage: 'Open snooze panel' } +); + export const RulesListNotifyBadge: React.FunctionComponent = (props) => { const { rule, @@ -69,6 +75,7 @@ export const RulesListNotifyBadge: React.FunctionComponent {}; +const EMPTY_RENDER = () => null; + interface ConvertRulesToTableItemsOpts { rules: Rule[]; ruleTypeIndex: RuleTypeIndex; @@ -154,23 +158,23 @@ export const RulesListTable = (props: RulesListTableProps) => { sort, page, percentileOptions, - itemIdToExpandedRowMap = {}, - onSort = () => {}, - onPage = () => {}, - onRuleClick = () => {}, - onRuleEditClick = () => {}, - onRuleDeleteClick = () => {}, - onManageLicenseClick = () => {}, - onSelectionChange = () => {}, - onPercentileOptionsChange = () => {}, - onRuleChanged = () => {}, - onEnableRule = () => {}, - onDisableRule = () => {}, - onSnoozeRule = () => {}, - onUnsnoozeRule = () => {}, - renderCollapsedItemActions = () => null, - renderRuleError = () => null, - config = {}, + itemIdToExpandedRowMap = EMPTY_OBJECT, + config = EMPTY_OBJECT as TriggersActionsUiConfig, + onSort = EMPTY_HANDLER, + onPage = EMPTY_HANDLER, + onRuleClick = EMPTY_HANDLER, + onRuleEditClick = EMPTY_HANDLER, + onRuleDeleteClick = EMPTY_HANDLER, + onManageLicenseClick = EMPTY_HANDLER, + onSelectionChange = EMPTY_HANDLER, + onPercentileOptionsChange = EMPTY_HANDLER, + onRuleChanged = EMPTY_HANDLER, + onEnableRule = EMPTY_HANDLER, + onDisableRule = EMPTY_HANDLER, + onSnoozeRule = EMPTY_HANDLER, + onUnsnoozeRule = EMPTY_HANDLER, + renderCollapsedItemActions = EMPTY_RENDER, + renderRuleError = EMPTY_RENDER, } = props; const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts index 0bab1a864a2b7..30baba0caaa08 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_list.ts @@ -10,7 +10,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); - const find = getService('find'); const PageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); const esArchiver = getService('esArchiver');