From e2e0a14567bd0c9eb57d02e437584d4cadde407d Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 10 Jun 2020 19:02:14 -0400 Subject: [PATCH] [SIEM][Exceptions] - ExceptionsViewer cleanup (#68739) (#68815) ### Summary - Adds missing unit tests for relevant files missing them - Changes filter search to fire request on 'Enter' - Breaks out the main ExceptionViewer component into smaller components to make more readable and better tested - Updates utility bar to have the specific list description text next to it as proposed by @spong in #68294 (comment) - Adds loading state any time async request occurs - Now fetches list on list type toggle (if user selects to view either only detections or endpoint items), before was simply filtering already fetched items --- .../lists/public/exceptions/__mocks__/api.ts | 15 + .../public/exceptions/hooks/use_api.test.tsx | 257 ++++++++++++++++++ .../hooks/use_exception_list.test.tsx | 16 +- .../exceptions/hooks/use_exception_list.tsx | 10 +- .../plugins/lists/public/exceptions/types.ts | 16 +- .../detection_engine/rules/details/index.tsx | 1 + .../exceptions_search.stories.tsx | 70 ----- .../components/exceptions/translations.ts | 2 +- .../exception_item/exception_details.tsx | 6 +- .../exception_item/exception_entries.test.tsx | 35 ++- .../exception_item/exception_entries.tsx | 24 +- .../exception_item/index.stories.tsx} | 53 ++-- .../viewer/exception_item/index.test.tsx | 7 +- .../viewer/exceptions_pagination.tsx | 21 +- .../viewer/exceptions_utility.test.tsx | 174 ++++++++++++ .../exceptions/viewer/exceptions_utility.tsx | 111 ++++++++ .../exceptions_viewer_header.stories.tsx | 67 +++++ .../viewer/exceptions_viewer_header.test.tsx | 54 +--- .../viewer/exceptions_viewer_header.tsx | 5 +- .../viewer/exceptions_viewer_item.test.tsx | 147 ++++++++++ .../viewer/exceptions_viewer_items.tsx | 100 +++++++ .../exceptions/viewer/index.test.tsx | 2 +- .../components/exceptions/viewer/index.tsx | 217 ++++----------- .../components/exceptions/viewer/reducer.ts | 24 +- 24 files changed, 1083 insertions(+), 351 deletions(-) create mode 100644 x-pack/plugins/lists/public/exceptions/hooks/use_api.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exceptions_search.stories.tsx rename x-pack/plugins/security_solution/public/common/components/exceptions/{__examples__/exception_item.stories.tsx => viewer/exception_item/index.stories.tsx} (66%) create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.stories.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_item.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.tsx diff --git a/x-pack/plugins/lists/public/exceptions/__mocks__/api.ts b/x-pack/plugins/lists/public/exceptions/__mocks__/api.ts index ecc771279b3ab9..9651be0d04e8c8 100644 --- a/x-pack/plugins/lists/public/exceptions/__mocks__/api.ts +++ b/x-pack/plugins/lists/public/exceptions/__mocks__/api.ts @@ -54,3 +54,18 @@ export const fetchExceptionListItemById = async ({ signal, }: ApiCallByIdProps): Promise => Promise.resolve(getExceptionListItemSchemaMock()); + +export const deleteExceptionListById = async ({ + http, + id, + namespaceType, + signal, +}: ApiCallByIdProps): Promise => Promise.resolve(getExceptionListSchemaMock()); + +export const deleteExceptionListItemById = async ({ + http, + id, + namespaceType, + signal, +}: ApiCallByIdProps): Promise => + Promise.resolve(getExceptionListItemSchemaMock()); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.tsx new file mode 100644 index 00000000000000..edf65839c07cfd --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_api.test.tsx @@ -0,0 +1,257 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import * as api from '../api'; +import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core'; +import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; +import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock'; +import { HttpStart } from '../../../../../../src/core/public'; +import { ApiCallByIdProps } from '../types'; + +import { ExceptionsApi, useApi } from './use_api'; + +jest.mock('../api'); + +const mockKibanaHttpService = createKibanaCoreStartMock().http; + +describe('useApi', () => { + const onErrorMock = jest.fn(); + + afterEach(() => { + onErrorMock.mockClear(); + jest.clearAllMocks(); + }); + + test('it invokes "deleteExceptionListItemById" when "deleteExceptionItem" used', async () => { + const payload = getExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnDeleteExceptionListItemById = jest + .spyOn(api, 'deleteExceptionListItemById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.deleteExceptionItem({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnDeleteExceptionListItemById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); + }); + }); + + test('invokes "onError" callback if "deleteExceptionListItemById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'deleteExceptionListItemById').mockRejectedValue(mockError); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = getExceptionListItemSchemaMock(); + + await result.current.deleteExceptionItem({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); + + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); + }); + + test('it invokes "deleteExceptionListById" when "deleteExceptionList" used', async () => { + const payload = getExceptionListSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnDeleteExceptionListById = jest + .spyOn(api, 'deleteExceptionListById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.deleteExceptionList({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnDeleteExceptionListById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); + }); + }); + + test('invokes "onError" callback if "deleteExceptionListById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'deleteExceptionListById').mockRejectedValue(mockError); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); + + await result.current.deleteExceptionList({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); + + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); + }); + + test('it invokes "fetchExceptionListItemById" when "getExceptionItem" used', async () => { + const payload = getExceptionListItemSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListItemById = jest + .spyOn(api, 'fetchExceptionListItemById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.getExceptionItem({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnFetchExceptionListItemById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); + }); + }); + + test('invokes "onError" callback if "fetchExceptionListItemById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'fetchExceptionListItemById').mockRejectedValue(mockError); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); + + await result.current.getExceptionItem({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); + + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); + }); + + test('it invokes "fetchExceptionListById" when "getExceptionList" used', async () => { + const payload = getExceptionListSchemaMock(); + const onSuccessMock = jest.fn(); + const spyOnFetchExceptionListById = jest + .spyOn(api, 'fetchExceptionListById') + .mockResolvedValue(payload); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = payload; + + await result.current.getExceptionList({ + id, + namespaceType, + onError: jest.fn(), + onSuccess: onSuccessMock, + }); + + const expected: ApiCallByIdProps = { + http: mockKibanaHttpService, + id, + namespaceType, + signal: new AbortController().signal, + }; + + expect(spyOnFetchExceptionListById).toHaveBeenCalledWith(expected); + expect(onSuccessMock).toHaveBeenCalled(); + }); + }); + + test('invokes "onError" callback if "fetchExceptionListById" fails', async () => { + const mockError = new Error('failed to delete item'); + jest.spyOn(api, 'fetchExceptionListById').mockRejectedValue(mockError); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useApi(mockKibanaHttpService) + ); + await waitForNextUpdate(); + + const { id, namespace_type: namespaceType } = getExceptionListSchemaMock(); + + await result.current.getExceptionList({ + id, + namespaceType, + onError: onErrorMock, + onSuccess: jest.fn(), + }); + + expect(onErrorMock).toHaveBeenCalledWith(mockError); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx index fbd43787a822ea..eeb3ac63ee3181 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx @@ -11,7 +11,7 @@ import { createKibanaCoreStartMock } from '../../common/mocks/kibana_core'; import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock'; import { ExceptionListItemSchema } from '../../../common/schemas'; -import { ExceptionList, UseExceptionListProps } from '../types'; +import { ExceptionList, UseExceptionListProps, UseExceptionListSuccess } from '../types'; import { ReturnExceptionListAndItems, useExceptionList } from './use_exception_list'; @@ -57,6 +57,7 @@ describe('useExceptionList', () => { test('fetch exception list and items', async () => { await act(async () => { + const onSuccessMock = jest.fn(); const { result, waitForNextUpdate } = renderHook< UseExceptionListProps, ReturnExceptionListAndItems @@ -65,6 +66,7 @@ describe('useExceptionList', () => { http: mockKibanaHttpService, lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, + onSuccess: onSuccessMock, }) ); await waitForNextUpdate(); @@ -78,6 +80,12 @@ describe('useExceptionList', () => { { ...getExceptionListItemSchemaMock() }, ]; + const expectedResult: UseExceptionListSuccess = { + exceptions: expectedListItemsResult, + lists: expectedListResult, + pagination: { page: 1, perPage: 20, total: 1 }, + }; + expect(result.current).toEqual([ false, expectedListResult, @@ -89,6 +97,7 @@ describe('useExceptionList', () => { }, result.current[4], ]); + expect(onSuccessMock).toHaveBeenCalledWith(expectedResult); }); }); @@ -100,13 +109,14 @@ describe('useExceptionList', () => { UseExceptionListProps, ReturnExceptionListAndItems >( - ({ filterOptions, http, lists, pagination, onError }) => - useExceptionList({ filterOptions, http, lists, onError, pagination }), + ({ filterOptions, http, lists, pagination, onError, onSuccess }) => + useExceptionList({ filterOptions, http, lists, onError, onSuccess, pagination }), { initialProps: { http: mockKibanaHttpService, lists: [{ id: 'myListId', namespaceType: 'single' }], onError: onErrorMock, + onSuccess: jest.fn(), }, } ); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx index 1d7a63ba880bfb..9595cb7b7558ec 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.tsx @@ -23,9 +23,9 @@ export type ReturnExceptionListAndItems = [ * Hook for using to get an ExceptionList and it's ExceptionListItems * * @param http Kibana http service - * @param id desired ExceptionList ID (not list_id) - * @param namespaceType list namespaceType determines list space + * @param lists array of ExceptionIdentifiers for all lists to fetch * @param onError error callback + * @param onSuccess callback when all lists fetched successfully * @param filterOptions optional - filter by fields or tags * @param pagination optional * @@ -43,7 +43,7 @@ export const useExceptionList = ({ tags: [], }, onError, - dispatchListsInReducer, + onSuccess, }: UseExceptionListProps): ReturnExceptionListAndItems => { const [exceptionLists, setExceptionLists] = useState([]); const [exceptionItems, setExceptionListItems] = useState([]); @@ -116,8 +116,8 @@ export const useExceptionList = ({ exceptions = [...exceptions, ...fetchListItemsResult.data]; setExceptionListItems(exceptions); - if (dispatchListsInReducer != null) { - dispatchListsInReducer({ + if (onSuccess != null) { + onSuccess({ exceptions, lists: exceptionListsReturned, pagination: { diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index 286eb0570ebb82..013788cddc0760 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -37,21 +37,19 @@ export interface ExceptionList extends ExceptionListSchema { totalItems: number; } +export interface UseExceptionListSuccess { + lists: ExceptionList[]; + exceptions: ExceptionListItemSchema[]; + pagination: Pagination; +} + export interface UseExceptionListProps { http: HttpStart; lists: ExceptionIdentifiers[]; onError: (arg: Error) => void; filterOptions?: FilterExceptionsOptions; pagination?: Pagination; - dispatchListsInReducer?: ({ - lists, - exceptions, - pagination, - }: { - lists: ExceptionList[]; - exceptions: ExceptionListItemSchema[]; - pagination: Pagination; - }) => void; + onSuccess?: (arg: UseExceptionListSuccess) => void; } export interface ExceptionIdentifiers { diff --git a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx index 0e527bf4dfc72c..0021cd2f20a4bc 100644 --- a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/rules/details/index.tsx @@ -6,6 +6,7 @@ /* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable complexity */ +// TODO: Disabling complexity is temporary till this component is refactored as part of lists UI integration import { EuiButton, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exceptions_search.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exceptions_search.stories.tsx deleted file mode 100644 index 29cded8f691650..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exceptions_search.stories.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { storiesOf } from '@storybook/react'; -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; - -import { ExceptionsViewerHeader } from '../viewer/exceptions_viewer_header'; -import { ExceptionListType } from '../types'; - -storiesOf('ExceptionsViewerHeader', module) - .add('loading', () => { - return ( - ({ eui: euiLightVars, darkMode: false })}> - {}} - onAddExceptionClick={() => {}} - /> - - ); - }) - .add('all lists', () => { - return ( - ({ eui: euiLightVars, darkMode: false })}> - {}} - onAddExceptionClick={() => {}} - /> - - ); - }) - .add('endpoint only', () => { - return ( - ({ eui: euiLightVars, darkMode: false })}> - {}} - onAddExceptionClick={() => {}} - /> - - ); - }) - .add('detections only', () => { - return ( - ({ eui: euiLightVars, darkMode: false })}> - {}} - onAddExceptionClick={() => {}} - /> - - ); - }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 23e9f64caf695a..27dab7cf9db291 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -135,5 +135,5 @@ export const REFRESH = i18n.translate('xpack.securitySolution.exceptions.utility export const SHOWING_EXCEPTIONS = (items: number) => i18n.translate('xpack.securitySolution.exceptions.utilityNumberExceptionsLabel', { values: { items }, - defaultMessage: 'Showing {items} exceptions', + defaultMessage: 'Showing {items} {items, plural, =1 {exception} other {exceptions}}', }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx index 12287f7cd0fa9b..ce7d1d499de6ea 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx @@ -19,7 +19,7 @@ import { DescriptionListItem, ExceptionListItemSchema } from '../../types'; import { getDescriptionListContent } from '../../helpers'; import * as i18n from '../../translations'; -const StyledExceptionDetails = styled(EuiFlexItem)` +const MyExceptionDetails = styled(EuiFlexItem)` ${({ theme }) => css` background-color: ${theme.eui.euiColorLightestShade}; padding: ${theme.eui.euiSize}; @@ -68,7 +68,7 @@ const ExceptionDetailsComponent = ({ }, [showComments, onCommentsClick, exceptionItem]); return ( - + @@ -82,7 +82,7 @@ const ExceptionDetailsComponent = ({ {commentsSection} - + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx index b2408a654b1c68..2d022591d99800 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.test.tsx @@ -44,7 +44,7 @@ describe('ExceptionEntries', () => { expect(wrapper.find('[data-test-subj="exceptionsViewerAndBadge"]')).toHaveLength(1); }); - test('it invokes "handlEdit" when edit button clicked', () => { + test('it invokes "onEdit" when edit button clicked', () => { const mockOnEdit = jest.fn(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -80,6 +80,39 @@ describe('ExceptionEntries', () => { expect(mockOnDelete).toHaveBeenCalledTimes(1); }); + test('it renders edit button disabled if "disableDelete" is "true"', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const editBtn = wrapper.find('[data-test-subj="exceptionsViewerEditBtn"] button').at(0); + + expect(editBtn.prop('disabled')).toBeTruthy(); + }); + + test('it renders delete button in loading state if "disableDelete" is "true"', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const deleteBtn = wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button').at(0); + + expect(deleteBtn.prop('disabled')).toBeTruthy(); + expect(deleteBtn.find('.euiLoadingSpinner')).toBeTruthy(); + }); + test('it renders nested entry', () => { const parentEntry = getFormattedEntryMock(); parentEntry.operator = null; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.tsx index 8c758e3b84f42a..58667f1f78b0d3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.tsx @@ -22,11 +22,11 @@ import { getEmptyValue } from '../../../empty_value'; import * as i18n from '../../translations'; import { FormattedEntry } from '../../types'; -const EntriesDetails = styled(EuiFlexItem)` +const MyEntriesDetails = styled(EuiFlexItem)` padding: ${({ theme }) => theme.eui.euiSize}; `; -const StyledEditButton = styled(EuiButton)` +const MyEditButton = styled(EuiButton)` ${({ theme }) => css` background-color: ${transparentize(0.9, theme.eui.euiColorPrimary)}; border: none; @@ -34,7 +34,7 @@ const StyledEditButton = styled(EuiButton)` `} `; -const StyledRemoveButton = styled(EuiButton)` +const MyRemoveButton = styled(EuiButton)` ${({ theme }) => css` background-color: ${transparentize(0.9, theme.eui.euiColorDanger)}; border: none; @@ -42,7 +42,7 @@ const StyledRemoveButton = styled(EuiButton)` `} `; -const AndOrBadgeContainer = styled(EuiFlexItem)` +const MyAndOrBadgeContainer = styled(EuiFlexItem)` padding-top: ${({ theme }) => theme.eui.euiSizeXL}; `; @@ -118,19 +118,19 @@ const ExceptionEntriesComponent = ({ ); return ( - + {entries.length > 1 && ( - + - + )} @@ -147,7 +147,7 @@ const ExceptionEntriesComponent = ({ - {i18n.EDIT} - + - {i18n.REMOVE} - + - + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exception_item.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.stories.tsx similarity index 66% rename from x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exception_item.stories.tsx rename to x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.stories.tsx index 5f2b0b93e9df02..de214b098d4ea3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/exception_item.stories.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.stories.tsx @@ -3,22 +3,22 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { storiesOf } from '@storybook/react'; -import React, { ReactNode } from 'react'; +import { storiesOf, addDecorator } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React from 'react'; import { ThemeProvider } from 'styled-components'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; -import { ExceptionItem } from '../viewer/exception_item'; -import { Operator } from '../types'; -import { getExceptionItemMock } from '../mocks'; +import { ExceptionItem } from './'; +import { Operator } from '../../types'; +import { getExceptionItemMock } from '../../mocks'; -const withTheme = (storyFn: () => ReactNode) => ( +addDecorator((storyFn) => ( ({ eui: euiLightVars, darkMode: false })}>{storyFn()} -); +)); -storiesOf('ExceptionItem', module) - .addDecorator(withTheme) - .add('ExceptionItem/with os', () => { +storiesOf('Components|ExceptionItem', module) + .add('with os', () => { const payload = getExceptionItemMock(); payload.description = ''; payload.comment = []; @@ -36,8 +36,8 @@ storiesOf('ExceptionItem', module) loadingItemIds={[]} commentsAccordionId={'accordion--comments'} exceptionItem={payload} - onDeleteException={() => {}} - onEditException={() => {}} + onDeleteException={action('onClick')} + onEditException={action('onClick')} /> ); }) @@ -59,8 +59,8 @@ storiesOf('ExceptionItem', module) loadingItemIds={[]} commentsAccordionId={'accordion--comments'} exceptionItem={payload} - onDeleteException={() => {}} - onEditException={() => {}} + onDeleteException={action('onClick')} + onEditException={action('onClick')} /> ); }) @@ -82,8 +82,8 @@ storiesOf('ExceptionItem', module) loadingItemIds={[]} commentsAccordionId={'accordion--comments'} exceptionItem={payload} - onDeleteException={() => {}} - onEditException={() => {}} + onDeleteException={action('onClick')} + onEditException={action('onClick')} /> ); }) @@ -98,8 +98,8 @@ storiesOf('ExceptionItem', module) loadingItemIds={[]} commentsAccordionId={'accordion--comments'} exceptionItem={payload} - onDeleteException={() => {}} - onEditException={() => {}} + onDeleteException={action('onClick')} + onEditException={action('onClick')} /> ); }) @@ -111,8 +111,21 @@ storiesOf('ExceptionItem', module) loadingItemIds={[]} commentsAccordionId={'accordion--comments'} exceptionItem={payload} - onDeleteException={() => {}} - onEditException={() => {}} + onDeleteException={action('onClick')} + onEditException={action('onClick')} + /> + ); + }) + .add('with loadingItemIds', () => { + const { id, namespace_type, ...rest } = getExceptionItemMock(); + + return ( + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.test.tsx index dca3afe4f90691..b4de3639944b1a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.test.tsx @@ -51,7 +51,7 @@ describe('ExceptionItem', () => { const editBtn = wrapper.find('[data-test-subj="exceptionsViewerEditBtn"] button').at(0); editBtn.simulate('click'); - expect(mockOnEditException).toHaveBeenCalledTimes(1); + expect(mockOnEditException).toHaveBeenCalledWith(getExceptionItemMock()); }); it('it invokes "onDeleteException" when delete button clicked', () => { @@ -73,7 +73,10 @@ describe('ExceptionItem', () => { const editBtn = wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button').at(0); editBtn.simulate('click'); - expect(mockOnDeleteException).toHaveBeenCalledTimes(1); + expect(mockOnDeleteException).toHaveBeenCalledWith({ + id: 'uuid_here', + namespaceType: 'single', + }); }); it('it renders comment accordion closed to begin with', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.tsx index 0953a5c666c5da..2920f1a85eee71 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_pagination.tsx @@ -29,13 +29,14 @@ const ExceptionsViewerPaginationComponent = ({ }: ExceptionsViewerPaginationProps): JSX.Element => { const [isOpen, setIsOpen] = useState(false); - const closePerPageMenu = useCallback((): void => setIsOpen(false), [setIsOpen]); + const handleClosePerPageMenu = useCallback((): void => setIsOpen(false), [setIsOpen]); - const onPerPageMenuClick = useCallback((): void => setIsOpen((isPopoverOpen) => !isPopoverOpen), [ - setIsOpen, - ]); + const handlePerPageMenuClick = useCallback( + (): void => setIsOpen((isPopoverOpen) => !isPopoverOpen), + [setIsOpen] + ); - const onPageClick = useCallback( + const handlePageClick = useCallback( (pageIndex: number): void => { onPaginationChange({ filter: {}, @@ -63,14 +64,14 @@ const ExceptionsViewerPaginationComponent = ({ totalItemCount: pagination.totalItemCount, }, }); - closePerPageMenu(); + handleClosePerPageMenu(); }} data-test-subj="exceptionsPerPageItem" > {i18n.NUMBER_OF_ITEMS(rows)} )); - }, [pagination, onPaginationChange, closePerPageMenu]); + }, [pagination, onPaginationChange, handleClosePerPageMenu]); const totalPages = useMemo((): number => { if (pagination.totalItemCount > 0) { @@ -90,14 +91,14 @@ const ExceptionsViewerPaginationComponent = ({ color="text" iconType="arrowDown" iconSide="right" - onClick={onPerPageMenuClick} + onClick={handlePerPageMenuClick} data-test-subj="exceptionsPerPageBtn" > {i18n.ITEMS_PER_PAGE(pagination.pageSize)} } isOpen={isOpen} - closePopover={closePerPageMenu} + closePopover={handleClosePerPageMenu} panelPaddingSize="none" > @@ -108,7 +109,7 @@ const ExceptionsViewerPaginationComponent = ({ diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx new file mode 100644 index 00000000000000..d697023b2ced4e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { ExceptionsViewerUtility } from './exceptions_utility'; + +describe('ExceptionsViewerUtility', () => { + it('it renders correct pluralized text when more than one exception exists', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsShowing"]').at(0).text()).toEqual( + 'Showing 2 exceptions' + ); + }); + + it('it renders correct singular text when less than two exceptions exists', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsShowing"]').at(0).text()).toEqual( + 'Showing 1 exception' + ); + }); + + it('it invokes "onRefreshClick" when refresh button clicked', () => { + const mockOnRefreshClick = jest.fn(); + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="exceptionsRefresh"] button').simulate('click'); + + expect(mockOnRefreshClick).toHaveBeenCalledTimes(1); + }); + + it('it does not render any messages when "showEndpointList" and "showDetectionsList" are "false"', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsEndpointMessage"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="exceptionsDetectionsMessage"]').exists()).toBeFalsy(); + }); + + it('it does render detections messages when "showDetectionsList" is "true"', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsEndpointMessage"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="exceptionsDetectionsMessage"]').exists()).toBeTruthy(); + }); + + it('it does render endpoint messages when "showEndpointList" is "true"', () => { + const wrapper = mountWithIntl( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsEndpointMessage"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="exceptionsDetectionsMessage"]').exists()).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.tsx new file mode 100644 index 00000000000000..9ab4e170f4090f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiText, EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { FormattedMessage } from 'react-intl'; +import styled from 'styled-components'; + +import * as i18n from '../translations'; +import { ExceptionsPagination, FilterOptions } from '../types'; +import { + UtilityBar, + UtilityBarSection, + UtilityBarGroup, + UtilityBarText, + UtilityBarAction, +} from '../../utility_bar'; + +const StyledText = styled(EuiText)` + font-style: italic; +`; + +const MyUtilities = styled(EuiFlexGroup)` + height: 50px; +`; + +interface ExceptionsViewerUtilityProps { + pagination: ExceptionsPagination; + filterOptions: FilterOptions; + ruleSettingsUrl: string; + onRefreshClick: () => void; +} + +const ExceptionsViewerUtilityComponent: React.FC = ({ + pagination, + filterOptions, + ruleSettingsUrl, + onRefreshClick, +}): JSX.Element => ( + + + + + + + {i18n.SHOWING_EXCEPTIONS(pagination.totalItemCount ?? 0)} + + + + + + {i18n.REFRESH} + + + + + + + + {filterOptions.showEndpointList && ( + + + + ), + }} + /> + )} + {filterOptions.showDetectionsList && ( + + + + ), + }} + /> + )} + + + +); + +ExceptionsViewerUtilityComponent.displayName = 'ExceptionsViewerUtilityComponent'; + +export const ExceptionsViewerUtility = React.memo(ExceptionsViewerUtilityComponent); + +ExceptionsViewerUtility.displayName = 'ExceptionsViewerUtility'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.stories.tsx new file mode 100644 index 00000000000000..796af7cd760e25 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.stories.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { storiesOf, addDecorator } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { ExceptionsViewerHeader } from './exceptions_viewer_header'; +import { ExceptionListType } from '../types'; + +addDecorator((storyFn) => ( + ({ eui: euiLightVars, darkMode: false })}>{storyFn()} +)); + +storiesOf('Components|ExceptionsViewerHeader', module) + .add('loading', () => { + return ( + + ); + }) + .add('all lists', () => { + return ( + + ); + }) + .add('endpoint only', () => { + return ( + + ); + }) + .add('detections only', () => { + return ( + + ); + }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.test.tsx index bdc99370a6293e..c609a2296b83d8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.test.tsx @@ -269,7 +269,7 @@ describe('ExceptionsViewerHeader', () => { expect(mockOnAddExceptionClick).toHaveBeenCalledTimes(1); }); - it('it invokes "onFilterChange" with filter value when search used', () => { + it('it invokes "onFilterChange" when search used and "Enter" pressed', () => { const mockOnFilterChange = jest.fn(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -284,54 +284,12 @@ describe('ExceptionsViewerHeader', () => { ); - wrapper - .find('input[data-test-subj="exceptionsHeaderSearch"]') - .at(0) - .simulate('change', { - target: { value: 'host' }, - }); - - expect(mockOnFilterChange).toHaveBeenCalledWith({ - filter: { - filter: 'host', - showDetectionsList: false, - showEndpointList: false, - tags: [], - }, - pagination: {}, + wrapper.find('EuiFieldSearch').at(0).simulate('keyup', { + charCode: 13, + code: 'Enter', + key: 'Enter', }); - }); - - it('it invokes "onFilterChange" with tags values when search value includes "tags:..."', () => { - const mockOnFilterChange = jest.fn(); - const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - ); - - wrapper - .find('input[data-test-subj="exceptionsHeaderSearch"]') - .at(0) - .simulate('change', { - target: { value: 'tags:malware' }, - }); - expect(mockOnFilterChange).toHaveBeenCalledWith({ - filter: { - filter: '', - showDetectionsList: false, - showEndpointList: false, - tags: ['malware'], - }, - pagination: {}, - }); + expect(mockOnFilterChange).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.tsx index 92a8830310b516..0a630414e32674 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_header.tsx @@ -69,8 +69,7 @@ const ExceptionsViewerHeaderComponent = ({ }, [showEndpointList, setShowEndpointList, setShowDetectionsList]); const handleOnSearch = useCallback( - (event: React.ChangeEvent): void => { - const searchValue = event.target.value; + (searchValue: string): void => { const tagsRegex = /(tags:[^\s]*)/i; const tagsMatch = searchValue.match(tagsRegex); const foundTags: string = tagsMatch != null ? tagsMatch[0].split(':')[1] : ''; @@ -121,7 +120,7 @@ const ExceptionsViewerHeaderComponent = ({ data-test-subj="exceptionsHeaderSearch" aria-label={i18n.SEARCH_DEFAULT} placeholder={i18n.SEARCH_DEFAULT} - onChange={handleOnSearch} + onSearch={handleOnSearch} disabled={isInitLoading} incremental={false} fullWidth diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_item.test.tsx new file mode 100644 index 00000000000000..dbcae20eb1385f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_item.test.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { getExceptionItemMock } from '../mocks'; +import { ExceptionsViewerItems } from './exceptions_viewer_items'; + +describe('ExceptionsViewerItems', () => { + it('it renders empty prompt if "showEmpty" is "true"', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsEmptyPrompt"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="exceptionsContainer"]').exists()).toBeFalsy(); + }); + + it('it renders exceptions if "showEmpty" and "isInitLoading" is "false", and exceptions exist', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsContainer"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="exceptionsEmptyPrompt"]').exists()).toBeFalsy(); + }); + + it('it does not render exceptions if "isInitLoading" is "true"', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsContainer"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="exceptionsEmptyPrompt"]').exists()).toBeTruthy(); + }); + + it('it does not render or badge for first exception displayed', () => { + const exception1 = getExceptionItemMock(); + const exception2 = getExceptionItemMock(); + exception2.id = 'newId'; + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + const firstExceptionItem = wrapper.find('[data-test-subj="exceptionItemContainer"]').at(0); + + expect(firstExceptionItem.find('[data-test-subj="exceptionItemOrBadge"]').exists()).toBeFalsy(); + }); + + it('it does render or badge with exception displayed', () => { + const exception1 = getExceptionItemMock(); + const exception2 = getExceptionItemMock(); + exception2.id = 'newId'; + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + const notFirstExceptionItem = wrapper.find('[data-test-subj="exceptionItemContainer"]').at(1); + + expect( + notFirstExceptionItem.find('[data-test-subj="exceptionItemOrBadge"]').exists() + ).toBeFalsy(); + }); + + it('it invokes "onDeleteException" when delete button is clicked', () => { + const mockOnDeleteException = jest.fn(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button').at(0).simulate('click'); + + expect(mockOnDeleteException).toHaveBeenCalledWith({ + id: 'uuid_here', + namespaceType: 'single', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.tsx new file mode 100644 index 00000000000000..e1ef3c10188b31 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import styled from 'styled-components'; + +import * as i18n from '../translations'; +import { ExceptionListItemSchema, ApiProps } from '../types'; +import { ExceptionItem } from './exception_item'; +import { AndOrBadge } from '../../and_or_badge'; + +const MyFlexItem = styled(EuiFlexItem)` + margin: ${({ theme }) => `${theme.eui.euiSize} 0`}; + + &:first-child { + margin: ${({ theme }) => `${theme.eui.euiSizeXS} 0 ${theme.eui.euiSize}`}; + } +`; + +const MyExceptionsContainer = styled(EuiFlexGroup)` + height: 600px; + overflow: hidden; +`; + +const MyExceptionItemContainer = styled(EuiFlexGroup)` + margin: ${({ theme }) => `0 ${theme.eui.euiSize} ${theme.eui.euiSize} 0`}; +`; + +interface ExceptionsViewerItemsProps { + showEmpty: boolean; + isInitLoading: boolean; + exceptions: ExceptionListItemSchema[]; + loadingItemIds: ApiProps[]; + commentsAccordionId: string; + onDeleteException: (arg: ApiProps) => void; + onEditExceptionItem: (item: ExceptionListItemSchema) => void; +} + +const ExceptionsViewerItemsComponent: React.FC = ({ + showEmpty, + isInitLoading, + exceptions, + loadingItemIds, + commentsAccordionId, + onDeleteException, + onEditExceptionItem, +}): JSX.Element => ( + + {showEmpty || isInitLoading ? ( + + {i18n.EXCEPTION_EMPTY_PROMPT_TITLE}} + body={

{i18n.EXCEPTION_EMPTY_PROMPT_BODY}

} + data-test-subj="exceptionsEmptyPrompt" + /> +
+ ) : ( + + + {!isInitLoading && + exceptions.length > 0 && + exceptions.map((exception, index) => ( + + {index !== 0 ? ( + <> + + + + ) : ( + + )} + + + ))} + + + )} +
+); + +ExceptionsViewerItemsComponent.displayName = 'ExceptionsViewerItemsComponent'; + +export const ExceptionsViewerItems = React.memo(ExceptionsViewerItemsComponent); + +ExceptionsViewerItems.displayName = 'ExceptionsViewerItems'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx index cc8e8111064bc0..b77b8380c39f1a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx @@ -46,7 +46,7 @@ describe('ExceptionsViewer', () => { ]); }); - it('it renders loader if "initLoading" is true', () => { + it('it renders loader if "loadingList" is true', () => { (useExceptionList as jest.Mock).mockReturnValue([ true, [], diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index 3cf59c7dda023e..10519628522193 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -4,21 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useState, useMemo, useEffect, useReducer } from 'react'; -import { - EuiEmptyPrompt, - EuiText, - EuiLink, - EuiOverlayMask, - EuiModal, - EuiModalBody, - EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, -} from '@elastic/eui'; -import { FormattedMessage } from 'react-intl'; -import styled from 'styled-components'; +import React, { useCallback, useMemo, useEffect, useReducer } from 'react'; +import { EuiOverlayMask, EuiModal, EuiModalBody, EuiCodeBlock, EuiSpacer } from '@elastic/eui'; import uuid from 'uuid'; import * as i18n from '../translations'; @@ -40,25 +27,9 @@ import { ExceptionIdentifiers, useApi, } from '../../../../../public/lists_plugin_deps'; -import { ExceptionItem } from './exception_item'; -import { AndOrBadge } from '../../and_or_badge'; import { ExceptionsViewerPagination } from './exceptions_pagination'; -import { - UtilityBar, - UtilityBarSection, - UtilityBarGroup, - UtilityBarText, - UtilityBarAction, -} from '../../utility_bar'; - -const StyledText = styled(EuiText)` - font-style: italic; -`; - -const MyExceptionsContainer = styled.div` - height: 600px; - overflow: hidden; -`; +import { ExceptionsViewerUtility } from './exceptions_utility'; +import { ExceptionsViewerItems } from './exceptions_viewer_items'; const initialState: State = { filterOptions: { filter: '', showEndpointList: false, showDetectionsList: false, tags: [] }, @@ -73,7 +44,9 @@ const initialState: State = { allExceptions: [], exceptions: [], exceptionToEdit: null, + loadingLists: [], loadingItemIds: [], + isInitLoading: true, isModalOpen: false, }; @@ -99,7 +72,6 @@ const ExceptionsViewerComponent = ({ }: ExceptionsViewerProps): JSX.Element => { const { services } = useKibana(); const [, dispatchToaster] = useStateToaster(); - const [initLoading, setInitLoading] = useState(true); const onDispatchToaster = useCallback( ({ title, color, iconType }) => (): void => { dispatchToaster({ @@ -114,7 +86,6 @@ const ExceptionsViewerComponent = ({ }, [dispatchToaster] ); - const { deleteExceptionItem } = useApi(services.http); const [ { endpointList, @@ -122,13 +93,15 @@ const ExceptionsViewerComponent = ({ exceptions, filterOptions, pagination, + loadingLists, loadingItemIds, + isInitLoading, isModalOpen, }, dispatch, - ] = useReducer(allExceptionItemsReducer(), initialState); + ] = useReducer(allExceptionItemsReducer(), { ...initialState, loadingLists: exceptionListsMeta }); + const { deleteExceptionItem } = useApi(services.http); - // TODO: Update icky typing once api updated const setExceptions = useCallback( ({ lists: newLists, @@ -146,14 +119,14 @@ const ExceptionsViewerComponent = ({ ); const [loadingList, , , , fetchList] = useExceptionList({ http: services.http, - lists: exceptionListsMeta, + lists: loadingLists, filterOptions, pagination: { page: pagination.pageIndex + 1, perPage: pagination.pageSize, total: pagination.totalItemCount, }, - dispatchListsInReducer: setExceptions, + onSuccess: setExceptions, onError: onDispatchToaster({ color: 'danger', title: i18n.FETCH_LIST_ERROR, @@ -171,24 +144,25 @@ const ExceptionsViewerComponent = ({ [dispatch] ); - const onFetchList = useCallback((): void => { + const handleFetchList = useCallback((): void => { if (fetchList != null) { fetchList(); } }, [fetchList]); - const onFiltersChange = useCallback( + const handleFilterChange = useCallback( ({ filter, pagination: pag }: Filter): void => { dispatch({ type: 'updateFilterOptions', filterOptions: filter, pagination: pag, + allLists: exceptionListsMeta, }); }, - [dispatch] + [dispatch, exceptionListsMeta] ); - const onAddException = useCallback( + const handleAddException = useCallback( (type: ExceptionListType): void => { setIsModalOpen(true); }, @@ -219,9 +193,9 @@ const ExceptionsViewerComponent = ({ onAssociateList(listId); } - onFetchList(); + handleFetchList(); }, - [setIsModalOpen, onFetchList, onAssociateList] + [setIsModalOpen, handleFetchList, onAssociateList] ); const setLoadingItemIds = useCallback( @@ -236,12 +210,14 @@ const ExceptionsViewerComponent = ({ const handleDeleteException = useCallback( ({ id, namespaceType }: ApiProps) => { + setLoadingItemIds([{ id, namespaceType }]); + deleteExceptionItem({ id, namespaceType, onSuccess: () => { setLoadingItemIds(loadingItemIds.filter((t) => t.id !== id)); - onFetchList(); + handleFetchList(); }, onError: () => { const dispatchToasterError = onDispatchToaster({ @@ -255,65 +231,29 @@ const ExceptionsViewerComponent = ({ }, }); }, - [setLoadingItemIds, deleteExceptionItem, loadingItemIds, onFetchList, onDispatchToaster] + [setLoadingItemIds, deleteExceptionItem, loadingItemIds, handleFetchList, onDispatchToaster] ); // Logic for initial render useEffect((): void => { - if (initLoading && !loadingList && (exceptions.length === 0 || exceptions != null)) { - setInitLoading(false); + if (isInitLoading && !loadingList && (exceptions.length === 0 || exceptions != null)) { + dispatch({ + type: 'updateIsInitLoading', + loading: false, + }); } - }, [initLoading, exceptions, loadingList]); + }, [isInitLoading, exceptions, loadingList, dispatch]); + // Used in utility bar info text const ruleSettingsUrl = useMemo((): string => { return services.application.getUrlForApp( `security#/detections/rules/id/${encodeURI(ruleId)}/edit` ); }, [ruleId, services.application]); - const exceptionsSubtext = useMemo((): JSX.Element => { - if (filterOptions.showEndpointList) { - return ( - - - - ), - }} - /> - ); - } else if (filterOptions.showDetectionsList) { - return ( - - - - ), - }} - /> - ); - } else { - return <>; - } - }, [filterOptions.showEndpointList, filterOptions.showDetectionsList, ruleSettingsUrl]); - const showEmpty = useMemo((): boolean => { - return !initLoading && !loadingList && exceptions.length === 0; - }, [initLoading, exceptions.length, loadingList]); + return !isInitLoading && !loadingList && exceptions.length === 0; + }, [isInitLoading, exceptions.length, loadingList]); return ( <> @@ -329,80 +269,43 @@ const ExceptionsViewerComponent = ({ )} - - {initLoading && } + + {(isInitLoading || loadingList) && ( + + )} - {(filterOptions.showEndpointList || filterOptions.showDetectionsList) && ( - <> - - {exceptionsSubtext} - - )} - - - - - - {i18n.SHOWING_EXCEPTIONS(pagination.totalItemCount ?? 0)} - - - - - - {i18n.REFRESH} - - - - - - - - - {showEmpty && ( - {i18n.EXCEPTION_EMPTY_PROMPT_TITLE}} - body={

{i18n.EXCEPTION_EMPTY_PROMPT_BODY}

} - data-test-subj="exceptionsEmptyPrompt" - /> - )} - - - - - {!initLoading && - exceptions.length > 0 && - exceptions.map((exception, index) => ( - - {index !== 0 && ( - <> - - - - )} - - - ))} - -
- + + + + +
); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts index 40d5bb5f0a2978..538207458f0edb 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts @@ -10,7 +10,7 @@ import { ExceptionListItemSchema, Pagination, } from '../types'; -import { ExceptionList } from '../../../../../public/lists_plugin_deps'; +import { ExceptionList, ExceptionIdentifiers } from '../../../../../public/lists_plugin_deps'; export interface State { filterOptions: FilterOptions; @@ -20,7 +20,9 @@ export interface State { allExceptions: ExceptionListItemSchema[]; exceptions: ExceptionListItemSchema[]; exceptionToEdit: ExceptionListItemSchema | null; + loadingLists: ExceptionIdentifiers[]; loadingItemIds: ApiProps[]; + isInitLoading: boolean; isModalOpen: boolean; } @@ -35,7 +37,9 @@ export type Action = type: 'updateFilterOptions'; filterOptions: Partial; pagination: Partial; + allLists: ExceptionIdentifiers[]; } + | { type: 'updateIsInitLoading'; loading: boolean } | { type: 'updateModalOpen'; isOpen: boolean } | { type: 'updateExceptionToEdit'; exception: ExceptionListItemSchema } | { type: 'updateLoadingItemIds'; items: ApiProps[] }; @@ -78,26 +82,34 @@ export const allExceptionItemsReducer = () => (state: State, action: Action): St }; if (action.filterOptions.showEndpointList) { - const exceptions = state.allExceptions.filter((t) => t._tags.includes('endpoint')); + const list = action.allLists.filter((t) => t.type === 'endpoint'); return { ...returnState, - exceptions, + loadingLists: list, + exceptions: list.length === 0 ? [] : [...state.exceptions], }; } else if (action.filterOptions.showDetectionsList) { - const exceptions = state.allExceptions.filter((t) => t._tags.includes('detection')); + const list = action.allLists.filter((t) => t.type === 'detection'); return { ...returnState, - exceptions, + loadingLists: list, + exceptions: list.length === 0 ? [] : [...state.exceptions], }; } else { return { ...returnState, - exceptions: state.allExceptions, + loadingLists: action.allLists, }; } } + case 'updateIsInitLoading': { + return { + ...state, + isInitLoading: action.loading, + }; + } case 'updateLoadingItemIds': { return { ...state,