diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts index c2b8ed24202e18..df7fad5443062d 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts @@ -7,7 +7,7 @@ import moment from 'moment-timezone'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import { i18n } from '@kbn/i18n'; import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../common/constants'; @@ -51,57 +51,66 @@ export interface AuthenticatedElasticUser { } export const useCurrentUser = (): AuthenticatedElasticUser | null => { + const isMounted = useRef(false); const [user, setUser] = useState(null); const [, dispatchToaster] = useStateToaster(); const { security } = useKibana().services; - const fetchUser = useCallback(() => { - let didCancel = false; - const fetchData = async () => { - try { - if (security != null) { - const response = await security.authc.getCurrentUser(); + const fetchUser = useCallback( + () => { + let didCancel = false; + const fetchData = async () => { + try { + if (security != null) { + const response = await security.authc.getCurrentUser(); + if (!isMounted.current) return; + if (!didCancel) { + setUser(convertToCamelCase(response)); + } + } else { + setUser({ + username: i18n.translate('xpack.securitySolution.getCurrentUser.unknownUser', { + defaultMessage: 'Unknown', + }), + email: '', + fullName: '', + roles: [], + enabled: false, + authenticationRealm: { name: '', type: '' }, + lookupRealm: { name: '', type: '' }, + authenticationProvider: '', + }); + } + } catch (error) { if (!didCancel) { - setUser(convertToCamelCase(response)); + errorToToaster({ + title: i18n.translate('xpack.securitySolution.getCurrentUser.Error', { + defaultMessage: 'Error getting user', + }), + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setUser(null); } - } else { - setUser({ - username: i18n.translate('xpack.securitySolution.getCurrentUser.unknownUser', { - defaultMessage: 'Unknown', - }), - email: '', - fullName: '', - roles: [], - enabled: false, - authenticationRealm: { name: '', type: '' }, - lookupRealm: { name: '', type: '' }, - authenticationProvider: '', - }); - } - } catch (error) { - if (!didCancel) { - errorToToaster({ - title: i18n.translate('xpack.securitySolution.getCurrentUser.Error', { - defaultMessage: 'Error getting user', - }), - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); - setUser(null); } - } - }; - fetchData(); - return () => { - didCancel = true; - }; + }; + fetchData(); + return () => { + didCancel = true; + }; + }, // eslint-disable-next-line react-hooks/exhaustive-deps - }, [security]); + [security] + ); useEffect(() => { + isMounted.current = true; fetchUser(); + return () => { + isMounted.current = false; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return user; diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index d9da1d95dce964..c97f3e8bf8d42d 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -24,6 +24,7 @@ import { AdministrationSubTab } from '../types'; import { appendSearch } from '../../common/components/link_to/helpers'; import { EndpointIndexUIQueryParams } from '../pages/endpoint_hosts/types'; import { TrustedAppsListPageLocation } from '../pages/trusted_apps/state'; +import { EventFiltersPageLocation } from '../pages/event_filters/state'; import { EventFiltersListPageUrlSearchParams } from '../pages/event_filters/types'; // Taken from: https://github.com/microsoft/TypeScript/issues/12936#issuecomment-559034150 @@ -118,6 +119,26 @@ const normalizeTrustedAppsPageLocation = ( } }; +const normalizeEventFiltersPageLocation = ( + location?: Partial +): Partial => { + if (location) { + return { + ...(!isDefaultOrMissing(location.page_index, MANAGEMENT_DEFAULT_PAGE) + ? { page_index: location.page_index } + : {}), + ...(!isDefaultOrMissing(location.page_size, MANAGEMENT_DEFAULT_PAGE_SIZE) + ? { page_size: location.page_size } + : {}), + ...(!isDefaultOrMissing(location.show, undefined) ? { show: location.show } : {}), + ...(!isDefaultOrMissing(location.id, undefined) ? { id: location.id } : {}), + ...(!isDefaultOrMissing(location.filter, '') ? { filter: location.filter } : ''), + }; + } else { + return {}; + } +}; + /** * Given an object with url params, and a given key, return back only the first param value (case multiples were defined) * @param query @@ -181,6 +202,19 @@ export const getTrustedAppsListPath = (location?: Partial { + const showParamValue = extractFirstParamValue(query, 'show') as EventFiltersPageLocation['show']; + + return { + ...extractListPaginationParams(query), + show: + showParamValue && ['edit', 'create'].includes(showParamValue) ? showParamValue : undefined, + id: extractFirstParamValue(query, 'id'), + }; +}; + export const getEventFiltersListPath = ( location?: Partial ): string => { @@ -188,5 +222,7 @@ export const getEventFiltersListPath = ( tabName: AdministrationSubTab.eventFilters, }); - return `${path}${appendSearch(querystring.stringify(location))}`; + return `${path}${appendSearch( + querystring.stringify(normalizeEventFiltersPageLocation(location)) + )}`; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/state/index.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/state/index.ts index b5e867e64888de..caaa48ddf1987e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/state/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/state/index.ts @@ -7,12 +7,23 @@ import { ExceptionListItemSchema, CreateExceptionListItemSchema } from '../../../../shared_imports'; import { AsyncResourceState } from '../../../state/async_resource_state'; + +export interface EventFiltersPageLocation { + page_index: number; + page_size: number; + show?: 'create' | 'edit'; + /** Used for editing. The ID of the selected event filter */ + id?: string; + filter: string; +} export interface EventFiltersListPageState { entries: ExceptionListItemSchema[]; form: { entry: CreateExceptionListItemSchema | ExceptionListItemSchema | undefined; hasNameError: boolean; hasItemsError: boolean; + hasOSError: boolean; submissionResourceState: AsyncResourceState; }; + location: EventFiltersPageLocation; } diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts index de2a74ed6f72d0..79ef5cbc4e42cd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/action.ts @@ -20,6 +20,7 @@ export type EventFiltersChangeForm = Action<'eventFiltersChangeForm'> & { entry: ExceptionListItemSchema | CreateExceptionListItemSchema; hasNameError?: boolean; hasItemsError?: boolean; + hasOSError?: boolean; }; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts index 86ba9b1e49c38c..92290de4a24ed3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/builders.ts @@ -6,6 +6,7 @@ */ import { EventFiltersListPageState } from '../state'; +import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../../common/constants'; export const initialEventFiltersPageState = (): EventFiltersListPageState => ({ entries: [], @@ -13,6 +14,12 @@ export const initialEventFiltersPageState = (): EventFiltersListPageState => ({ entry: undefined, hasNameError: false, hasItemsError: false, + hasOSError: false, submissionResourceState: { type: 'UninitialisedResourceState' }, }, + location: { + page_index: MANAGEMENT_DEFAULT_PAGE, + page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, + filter: '', + }, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts index afb82ebe011ff1..b0b47bc1d6fa04 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.test.ts @@ -86,6 +86,7 @@ describe('middleware', () => { await spyMiddleware.waitForAction('eventFiltersFormStateChanged'); expect(store.getState()).toStrictEqual({ ...initialState, + entries: [createdEventFilterEntryMock()], form: { ...store.getState().form, submissionResourceState: { diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts index 2f2c7a9e22661f..b379d0893c1334 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts @@ -37,6 +37,12 @@ const eventFiltersCreate = async ( const sanitizedEntry = transformNewItemOutput(formEntry as CreateExceptionListItemSchema); const exception = await eventFiltersService.addEventFilters(sanitizedEntry); + store.dispatch({ + type: 'eventFiltersCreateSuccess', + payload: { + exception, + }, + }); store.dispatch({ type: 'eventFiltersFormStateChanged', payload: { diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts index 5ee956ddac3e9f..f14f8ecc5f8ad8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.test.ts @@ -78,5 +78,52 @@ describe('reducer', () => { }, }); }); + + it('create is success when there is no entry on entries list', () => { + const result = eventFiltersPageReducer(initialState, { + type: 'eventFiltersCreateSuccess', + payload: { + exception: createdEventFilterEntryMock(), + }, + }); + + expect(result).toStrictEqual({ + ...initialState, + entries: [createdEventFilterEntryMock()], + }); + }); + + it('create is success when there there are entries on entries list', () => { + const customizedInitialState = { + ...initialState, + entries: [createdEventFilterEntryMock(), createdEventFilterEntryMock()], + }; + const result = eventFiltersPageReducer(customizedInitialState, { + type: 'eventFiltersCreateSuccess', + payload: { + exception: { ...createdEventFilterEntryMock(), meta: {} }, + }, + }); + + expect(result.entries).toHaveLength(3); + expect(result.entries[0]!.meta).not.toBeUndefined(); + }); + }); + describe('UserChangedUrl', () => { + it('receives a url change with show=create', () => { + const result = eventFiltersPageReducer(initialState, { + type: 'userChangedUrl', + payload: { search: '?show=create', pathname: '/event_filters', hash: '' }, + }); + + expect(result).toStrictEqual({ + ...initialState, + location: { + ...initialState.location, + id: undefined, + show: 'create', + }, + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts index c8f80eaee18524..a52de492f9a66c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/reducer.ts @@ -5,14 +5,21 @@ * 2.0. */ +// eslint-disable-next-line import/no-nodejs-modules +import { parse } from 'querystring'; +import { matchPath } from 'react-router-dom'; import { ImmutableReducer } from '../../../../common/store'; -import { Immutable } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; +import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; +import { UserChangedUrl } from '../../../../common/store/routing/action'; +import { MANAGEMENT_ROUTING_EVENT_FILTERS_PATH } from '../../../common/constants'; +import { extractEventFiltetrsPageLocation } from '../../../common/routing'; import { EventFiltersInitForm, EventFiltersChangeForm, EventFiltersFormStateChanged, + EventFiltersCreateSuccess, } from './action'; import { EventFiltersListPageState } from '../state'; @@ -24,6 +31,15 @@ type CaseReducer = ( action: Immutable ) => Immutable; +const isEventFiltersPageLocation = (location: Immutable) => { + return ( + matchPath(location.pathname ?? '', { + path: MANAGEMENT_ROUTING_EVENT_FILTERS_PATH, + exact: true, + }) !== null + ); +}; + const eventFiltersInitForm: CaseReducer = (state, action) => { return { ...state, @@ -31,6 +47,7 @@ const eventFiltersInitForm: CaseReducer = (state, action) ...state.form, entry: action.payload.entry, hasNameError: !action.payload.entry.name, + hasOSError: !action.payload.entry.os_types?.length, submissionResourceState: { type: 'UninitialisedResourceState', }, @@ -52,6 +69,8 @@ const eventFiltersChangeForm: CaseReducer = (state, acti action.payload.hasNameError !== undefined ? action.payload.hasNameError : state.form.hasNameError, + hasOSError: + action.payload.hasOSError !== undefined ? action.payload.hasOSError : state.form.hasOSError, }, }; }; @@ -66,6 +85,22 @@ const eventFiltersFormStateChanged: CaseReducer = }; }; +const eventFiltersCreateSuccess: CaseReducer = (state, action) => { + return { + ...state, + entries: [action.payload.exception, ...state.entries], + }; +}; + +const userChangedUrl: CaseReducer = (state, action) => { + if (isEventFiltersPageLocation(action.payload)) { + const location = extractEventFiltetrsPageLocation(parse(action.payload.search.slice(1))); + return { ...state, location }; + } else { + return state; + } +}; + export const eventFiltersPageReducer: StateReducer = ( state = initialEventFiltersPageState(), action @@ -77,6 +112,10 @@ export const eventFiltersPageReducer: StateReducer = ( return eventFiltersChangeForm(state, action); case 'eventFiltersFormStateChanged': return eventFiltersFormStateChanged(state, action); + case 'eventFiltersCreateSuccess': + return eventFiltersCreateSuccess(state, action); + case 'userChangedUrl': + return userChangedUrl(state, action); } return state; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts index ece754f71b3183..dca16be0b1668a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selector.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { EventFiltersListPageState } from '../state'; +import { EventFiltersListPageState, EventFiltersPageLocation } from '../state'; import { ExceptionListItemSchema, CreateExceptionListItemSchema } from '../../../../shared_imports'; import { ServerApiError } from '../../../../common/types'; import { @@ -21,7 +21,7 @@ export const getFormEntry = ( }; export const getFormHasError = (state: EventFiltersListPageState): boolean => { - return state.form.hasItemsError || state.form.hasNameError; + return state.form.hasItemsError || state.form.hasNameError || state.form.hasOSError; }; export const isCreationInProgress = (state: EventFiltersListPageState): boolean => { @@ -37,3 +37,6 @@ export const getCreationError = (state: EventFiltersListPageState): ServerApiErr return isFailedResourceState(submissionResourceState) ? submissionResourceState.error : undefined; }; + +export const getCurrentLocation = (state: EventFiltersListPageState): EventFiltersPageLocation => + state.location; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts index 94f15907fb58ed..dbc962f1beaf10 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/selectors.test.ts @@ -6,9 +6,11 @@ */ import { initialEventFiltersPageState } from './builders'; -import { getFormEntry, getFormHasError } from './selector'; +import { getFormEntry, getFormHasError, getCurrentLocation } from './selector'; import { ecsEventMock } from '../test_utils'; import { getInitialExceptionFromEvent } from './utils'; +import { EventFiltersPageLocation } from '../state'; +import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../../common/constants'; const initialState = initialEventFiltersPageState(); @@ -53,13 +55,24 @@ describe('selectors', () => { }; expect(getFormHasError(state)).toBeTruthy(); }); - it('returns true when entry with item error and name error', () => { + it('returns true when entry with os error', () => { + const state = { + ...initialState, + form: { + ...initialState.form, + hasOSError: true, + }, + }; + expect(getFormHasError(state)).toBeTruthy(); + }); + it('returns true when entry with item error, name error and os error', () => { const state = { ...initialState, form: { ...initialState.form, hasItemsError: true, hasNameError: true, + hasOSError: true, }, }; expect(getFormHasError(state)).toBeTruthy(); @@ -72,9 +85,25 @@ describe('selectors', () => { ...initialState.form, hasItemsError: false, hasNameError: false, + hasOSError: false, }, }; expect(getFormHasError(state)).toBeFalsy(); }); }); + describe('getCurrentLocation()', () => { + it('returns current locations', () => { + const expectedLocation: EventFiltersPageLocation = { + show: 'create', + page_index: MANAGEMENT_DEFAULT_PAGE, + page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, + filter: 'filter', + }; + const state = { + ...initialState, + location: expectedLocation, + }; + expect(getCurrentLocation(state)).toBe(expectedLocation); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts index 251aaef0897e4e..35ba7ce5853a63 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/utils.ts @@ -10,11 +10,11 @@ import { CreateExceptionListItemSchema } from '../../../../shared_imports'; import { Ecs } from '../../../../../common/ecs'; import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '../constants'; -export const getInitialExceptionFromEvent = (data: Ecs): CreateExceptionListItemSchema => ({ +export const getInitialExceptionFromEvent = (data?: Ecs): CreateExceptionListItemSchema => ({ comments: [], description: '', entries: - data.event && data.process + data && data.event && data.process ? [ { field: 'event.category', @@ -29,7 +29,14 @@ export const getInitialExceptionFromEvent = (data: Ecs): CreateExceptionListItem value: (data.process.executable ?? [])[0], }, ] - : [], + : [ + { + field: '', + operator: 'included', + type: 'match', + value: '', + }, + ], item_id: undefined, list_id: ENDPOINT_EVENT_FILTERS_LIST_ID, meta: { @@ -37,8 +44,13 @@ export const getInitialExceptionFromEvent = (data: Ecs): CreateExceptionListItem }, name: '', namespace_type: 'agnostic', - tags: [], + tags: ['policy:all'], type: 'simple', // TODO: Try to fix this type casting - os_types: [(data.host ? data.host.os?.family ?? [] : [])[0] as 'windows' | 'linux' | 'macos'], + os_types: [ + (data && data.host ? data.host.os?.family ?? ['windows'] : ['windows'])[0] as + | 'windows' + | 'linux' + | 'macos', + ], }); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx new file mode 100644 index 00000000000000..5298578c38e17e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/empty/index.tsx @@ -0,0 +1,59 @@ +/* + * 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, { memo } from 'react'; +import styled, { css } from 'styled-components'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +const EmptyPrompt = styled(EuiEmptyPrompt)` + ${({ theme }) => css` + max-width: ${theme.eui.euiBreakpoints.m}; + `} +`; + +export const EventFiltersListEmptyState = memo<{ + onAdd: () => void; + /** Should the Add button be disabled */ + isAddDisabled?: boolean; +}>(({ onAdd, isAddDisabled = false }) => { + return ( + + + + } + body={ + + } + actions={ + + + + } + /> + ); +}); + +EventFiltersListEmptyState.displayName = 'EventFiltersListEmptyState'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx new file mode 100644 index 00000000000000..045497e33e294c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.test.tsx @@ -0,0 +1,169 @@ +/* + * 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 { EventFiltersFlyout } from '.'; +import * as reactTestingLibrary from '@testing-library/react'; +import { fireEvent } from '@testing-library/dom'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import { MiddlewareActionSpyHelper } from '../../../../../../common/store/test_utils'; + +import { + CreateExceptionListItemSchema, + ExceptionListItemSchema, +} from '../../../../../../shared_imports'; + +jest.mock('../form'); +jest.mock('../../hooks', () => { + const originalModule = jest.requireActual('../../hooks'); + const useEventFiltersNotification = jest.fn().mockImplementation(() => {}); + + return { + ...originalModule, + useEventFiltersNotification, + }; +}); + +let mockedContext: AppContextTestRender; +let waitForAction: MiddlewareActionSpyHelper['waitForAction']; +let render: () => ReturnType; +const act = reactTestingLibrary.act; +let onCancelMock: jest.Mock; + +describe('Event filter flyout', () => { + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + waitForAction = mockedContext.middlewareSpy.waitForAction; + onCancelMock = jest.fn(); + render = () => mockedContext.render(); + }); + + afterEach(() => reactTestingLibrary.cleanup()); + + it('should renders correctly', () => { + const component = render(); + expect(component.getAllByText('Add Endpoint Event Filter')).not.toBeNull(); + expect(component.getByText('cancel')).not.toBeNull(); + expect(component.getByText('Endpoint Security')).not.toBeNull(); + }); + + it('should dispatch action to init form store on mount', async () => { + await act(async () => { + render(); + await waitForAction('eventFiltersInitForm'); + }); + + expect(mockedContext.store.getState().management.eventFilters.form.entry).not.toBeUndefined(); + expect( + mockedContext.store.getState().management.eventFilters.form.entry!.entries[0].field + ).toBe(''); + }); + + it('should confirm form when button is disabled', () => { + const component = render(); + const confirmButton = component.getByTestId('add-exception-confirm-button'); + act(() => { + fireEvent.click(confirmButton); + }); + expect( + mockedContext.store.getState().management.eventFilters.form.submissionResourceState.type + ).toBe('UninitialisedResourceState'); + }); + + it('should confirm form when button is enabled', async () => { + const component = render(); + mockedContext.store.dispatch({ + type: 'eventFiltersChangeForm', + payload: { + entry: { + ...(mockedContext.store.getState().management.eventFilters.form! + .entry as CreateExceptionListItemSchema), + name: 'test', + os_types: ['windows'], + }, + hasNameError: false, + hasOSError: false, + }, + }); + const confirmButton = component.getByTestId('add-exception-confirm-button'); + + await act(async () => { + fireEvent.click(confirmButton); + await waitForAction('eventFiltersCreateSuccess'); + }); + expect( + mockedContext.store.getState().management.eventFilters.form.submissionResourceState.type + ).toBe('UninitialisedResourceState'); + expect(confirmButton.hasAttribute('disabled')).toBeFalsy(); + }); + + it('should close when exception has been submitted correctly', () => { + render(); + expect(onCancelMock).toHaveBeenCalledTimes(0); + + act(() => { + mockedContext.store.dispatch({ + type: 'eventFiltersFormStateChanged', + payload: { + type: 'LoadedResourceState', + data: mockedContext.store.getState().management.eventFilters.form! + .entry as ExceptionListItemSchema, + }, + }); + }); + + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + + it('should close when click on cancel button', () => { + const component = render(); + const cancelButton = component.getByText('cancel'); + expect(onCancelMock).toHaveBeenCalledTimes(0); + + act(() => { + fireEvent.click(cancelButton); + }); + + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + + it('should close when close flyout', () => { + const component = render(); + const flyoutCloseButton = component.getByTestId('euiFlyoutCloseButton'); + expect(onCancelMock).toHaveBeenCalledTimes(0); + + act(() => { + fireEvent.click(flyoutCloseButton); + }); + + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + + it('should prevent close when is loading action', () => { + const component = render(); + act(() => { + mockedContext.store.dispatch({ + type: 'eventFiltersFormStateChanged', + payload: { + type: 'LoadingResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }); + }); + + const cancelButton = component.getByText('cancel'); + expect(onCancelMock).toHaveBeenCalledTimes(0); + + act(() => { + fireEvent.click(cancelButton); + }); + + expect(onCancelMock).toHaveBeenCalledTimes(0); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx new file mode 100644 index 00000000000000..b7f760cba4b0c6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx @@ -0,0 +1,129 @@ +/* + * 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, { memo, useMemo, useEffect, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { Dispatch } from 'redux'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { AppAction } from '../../../../../../common/store/actions'; +import { Ecs } from '../../../../../../../common/ecs'; +import { EventFiltersForm } from '../form'; +import { useEventFiltersSelector, useEventFiltersNotification } from '../../hooks'; +import { + getFormHasError, + isCreationInProgress, + isCreationSuccessful, +} from '../../../store/selector'; +import { getInitialExceptionFromEvent } from '../../../store/utils'; + +export interface EventFiltersFlyoutProps { + data?: Ecs; + onCancel(): void; +} + +export const EventFiltersFlyout: React.FC = memo(({ data, onCancel }) => { + useEventFiltersNotification(); + const dispatch = useDispatch>(); + const formHasError = useEventFiltersSelector(getFormHasError); + const creationInProgress = useEventFiltersSelector(isCreationInProgress); + const creationSuccessful = useEventFiltersSelector(isCreationSuccessful); + + useEffect(() => { + if (creationSuccessful) { + onCancel(); + dispatch({ + type: 'eventFiltersFormStateChanged', + payload: { + type: 'UninitialisedResourceState', + }, + }); + } + }, [creationSuccessful, onCancel, dispatch]); + + // Initialize the store with the event passed as prop to allow render the form. It acts as componentDidMount + useEffect(() => { + dispatch({ + type: 'eventFiltersInitForm', + payload: { entry: getInitialExceptionFromEvent(data) }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleOnCancel = useCallback(() => { + if (creationInProgress) return; + onCancel(); + }, [creationInProgress, onCancel]); + + const confirmButtonMemo = useMemo( + () => ( + dispatch({ type: 'eventFiltersCreateStart' })} + isLoading={creationInProgress} + > + + + ), + [dispatch, formHasError, creationInProgress] + ); + + return ( + + + +

+ +

+
+ +
+ + + + + + + + + + + + + {confirmButtonMemo} + + +
+ ); +}); + +EventFiltersFlyout.displayName = 'EventFiltersFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx index 2aeb53ed5bcb2b..89832840a09d89 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx @@ -56,7 +56,9 @@ export const EventFiltersForm: React.FC = memo( const dispatch = useDispatch>(); const exception = useEventFiltersSelector(getFormEntry); - const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex(['logs-endpoint.events.*']); + // This value has to be memoized to avoid infinite useEffect loop on useFetchIndex + const indexNames = useMemo(() => ['logs-endpoint.events.*'], []); + const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex(indexNames); const osOptions: Array> = useMemo( () => OPERATING_SYSTEMS.map((os) => ({ value: os, inputDisplay: OS_TITLES[os] })), @@ -76,12 +78,13 @@ export const EventFiltersForm: React.FC = memo( ...arg.exceptionItems[0], name: exception?.name ?? '', comments: exception?.comments ?? [], + os_types: exception?.os_types ?? [OperatingSystem.WINDOWS], }, - hasItemsError: arg.errorExists, + hasItemsError: arg.errorExists || !arg.exceptionItems[0].entries.length, }, }); }, - [dispatch, exception?.name, exception?.comments] + [dispatch, exception?.name, exception?.comments, exception?.os_types] ); const handleOnChangeName = useCallback( @@ -125,7 +128,7 @@ export const EventFiltersForm: React.FC = memo( listNamespaceType={'agnostic'} ruleName={RULE_NAME} indexPatterns={indexPatterns} - isOrDisabled={false} + isOrDisabled={true} // TODO: pending to be validated isAndDisabled={false} isNestedDisabled={false} data-test-subj="alert-exception-builder" @@ -161,15 +164,26 @@ export const EventFiltersForm: React.FC = memo( { + if (!exception) return; + dispatch({ + type: 'eventFiltersChangeForm', + payload: { + entry: { + ...exception, + os_types: [value as 'windows' | 'linux' | 'macos'], + }, + }, + }); + }} /> ), - [exception?.os_types, osOptions] + [dispatch, exception, osOptions] ); const commentsInputMemo = useMemo( @@ -187,7 +201,7 @@ export const EventFiltersForm: React.FC = memo( {FORM_DESCRIPTION} {nameInputMemo} - + {allowSelectOs ? ( <> {osInputMemo} @@ -195,7 +209,7 @@ export const EventFiltersForm: React.FC = memo( ) : null} {exceptionBuilderComponentMemo} - + {commentsInputMemo} ) : ( diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts index 79cf296928e842..086f2298d2c1af 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts @@ -30,7 +30,7 @@ export const NAME_ERROR = i18n.translate('xpack.securitySolution.eventFilter.for }); export const OS_LABEL = i18n.translate('xpack.securitySolution.eventFilter.form.os.label', { - defaultMessage: 'Seelct OS', + defaultMessage: 'Select operating system', }); export const RULE_NAME = i18n.translate('xpack.securitySolution.eventFilter.form.rule.name', { diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx index a1a31f69274905..ac38b57fdb6352 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx @@ -5,12 +5,37 @@ * 2.0. */ -import React, { memo } from 'react'; +import React, { memo, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AdministrationListPage } from '../../../components/administration_list_page'; +import { EventFiltersListEmptyState } from './components/empty'; +import { useEventFiltersNavigateCallback, useEventFiltersSelector } from './hooks'; +import { getCurrentLocation } from '../store/selector'; +import { EventFiltersFlyout } from './components/flyout'; export const EventFiltersListPage = memo(() => { + const location = useEventFiltersSelector(getCurrentLocation); + const navigateCallback = useEventFiltersNavigateCallback(); + const showFlyout = !!location.show; + + const handleAddButtonClick = useCallback( + () => + navigateCallback({ + show: 'create', + id: undefined, + }), + [navigateCallback] + ); + + const handleCancelButtonClick = useCallback( + () => + navigateCallback({ + show: undefined, + id: undefined, + }), + [navigateCallback] + ); return ( { })} > {/* */} + {/* TODO: Display this only when list is empty (there are no endpoint events) */} + + {showFlyout ? : null} ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts index 407dee896f5ac4..df87d150891c19 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/hooks.ts @@ -5,16 +5,23 @@ * 2.0. */ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; -import { isCreationSuccessful, getFormEntry, getCreationError } from '../store/selector'; +import { + isCreationSuccessful, + getFormEntry, + getCreationError, + getCurrentLocation, +} from '../store/selector'; import { useToasts } from '../../../../common/lib/kibana'; import { getCreationSuccessMessage, getCreationErrorMessage } from './translations'; import { State } from '../../../../common/store'; import { EventFiltersListPageState } from '../state'; +import { getEventFiltersListPath } from '../../../common/routing'; import { MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE as EVENT_FILTER_NS, @@ -42,3 +49,13 @@ export const useEventFiltersNotification = () => { toasts.addDanger(getCreationErrorMessage(creationError)); } }; + +export function useEventFiltersNavigateCallback() { + const location = useEventFiltersSelector(getCurrentLocation); + const history = useHistory(); + + return useCallback((args) => history.push(getEventFiltersListPath({ ...location, ...args })), [ + history, + location, + ]); +}