[Security Solution][Endpoint] User can edit existing event filters from the list (#98898)

* Makes width 100% to allow multilang

* Removes state/index types and move those types into the parent types file

* Allows fill form from existing exception by id. Adds unit tests. Fixes wrong comments display when there is more than one comment.

* Allows user update an existing event filter. Adds unit tests. Fixes some wrong behaviours when opening the flyout after create/update action

* Fixes typo

* Fixes wrong entry type

* Uses selectors when it's possible instead of accessing directly to state object

* Fixes typechecks

* Allows edit from the card edit button. Removes unused imports and fixes some types

* Reverts type name

* Changes reducer to don't add entry to the list manually after creation, list will be reloaded with api call. Also check always if data exists to display the add new entry button at the first time
This commit is contained in:
David Sánchez 2021-05-06 18:13:55 +02:00 committed by GitHub
parent beaa4e78fb
commit 35f4be4387
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 844 additions and 226 deletions

View file

@ -39,4 +39,4 @@ export {
UseExceptionListsSuccess,
} from './exceptions/types';
export * as ExceptionBuilder from './exceptions/components/builder/index';
export { transformNewItemOutput } from './exceptions/transforms';
export { transformNewItemOutput, transformOutput } from './exceptions/transforms';

View file

@ -24,8 +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';
import { EventFiltersPageLocation } from '../pages/event_filters/types';
// Taken from: https://github.com/microsoft/TypeScript/issues/12936#issuecomment-559034150
type ExactKeys<T1, T2> = Exclude<keyof T1, keyof T2> extends never ? T1 : never;
@ -215,9 +214,7 @@ export const extractEventFiltetrsPageLocation = (
};
};
export const getEventFiltersListPath = (
location?: Partial<EventFiltersListPageUrlSearchParams>
): string => {
export const getEventFiltersListPath = (location?: Partial<EventFiltersPageLocation>): string => {
const path = generatePath(MANAGEMENT_ROUTING_EVENT_FILTERS_PATH, {
tabName: AdministrationSubTab.eventFilters,
});

View file

@ -7,10 +7,13 @@
import { HttpStart } from 'kibana/public';
import {
ExceptionListItemSchema,
CreateExceptionListItemSchema,
ENDPOINT_EVENT_FILTERS_LIST_ID,
ExceptionListItemSchema,
UpdateExceptionListItemSchema,
} from '../../../../shared_imports';
import { Immutable } from '../../../../../common/endpoint/types';
import { EVENT_FILTER_LIST, EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '../constants';
import { FoundExceptionListItemSchema } from '../../../../../../lists/common/schemas';
import { EventFiltersService } from '../types';
@ -69,4 +72,21 @@ export class EventFiltersHttpService implements EventFiltersService {
body: JSON.stringify(exception),
});
}
async getOne(id: string) {
return (await this.httpWrapper()).get<ExceptionListItemSchema>(EXCEPTION_LIST_ITEM_URL, {
query: {
id,
namespace_type: 'agnostic',
},
});
}
async updateOne(
exception: Immutable<UpdateExceptionListItemSchema>
): Promise<ExceptionListItemSchema> {
return (await this.httpWrapper()).put<ExceptionListItemSchema>(EXCEPTION_LIST_ITEM_URL, {
body: JSON.stringify(exception),
});
}
}

View file

@ -5,29 +5,18 @@
* 2.0.
*/
import { ExceptionListItemSchema, CreateExceptionListItemSchema } from '../../../../shared_imports';
import { ExceptionListItemSchema } from '../../../../shared_imports';
import { AsyncResourceState } from '../../../state/async_resource_state';
import { FoundExceptionListItemSchema } from '../../../../../../lists/common/schemas';
import { EventFiltersServiceGetListOptions } from '../types';
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;
}
import {
EventFiltersForm,
EventFiltersPageLocation,
EventFiltersServiceGetListOptions,
} from '../types';
export interface EventFiltersListPageState {
entries: ExceptionListItemSchema[];
form: {
entry: CreateExceptionListItemSchema | ExceptionListItemSchema | undefined;
hasNameError: boolean;
hasItemsError: boolean;
hasOSError: boolean;
submissionResourceState: AsyncResourceState<ExceptionListItemSchema>;
};
form: EventFiltersForm;
location: EventFiltersPageLocation;
/** State for the Event Filters List page */
listPage: {

View file

@ -6,7 +6,11 @@
*/
import { Action } from 'redux';
import { ExceptionListItemSchema, CreateExceptionListItemSchema } from '../../../../shared_imports';
import {
ExceptionListItemSchema,
CreateExceptionListItemSchema,
UpdateExceptionListItemSchema,
} from '../../../../shared_imports';
import { AsyncResourceState } from '../../../state/async_resource_state';
import { EventFiltersListPageState } from '../state';
@ -24,25 +28,30 @@ export type EventFiltersListPageDataExistsChanged = Action<'eventFiltersListPage
export type EventFiltersInitForm = Action<'eventFiltersInitForm'> & {
payload: {
entry: ExceptionListItemSchema | CreateExceptionListItemSchema;
entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema;
};
};
export type EventFiltersInitFromId = Action<'eventFiltersInitFromId'> & {
payload: {
id: string;
};
};
export type EventFiltersChangeForm = Action<'eventFiltersChangeForm'> & {
payload: {
entry: ExceptionListItemSchema | CreateExceptionListItemSchema;
entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema;
hasNameError?: boolean;
hasItemsError?: boolean;
hasOSError?: boolean;
newComment?: string;
};
};
export type EventFiltersUpdateStart = Action<'eventFiltersUpdateStart'>;
export type EventFiltersUpdateSuccess = Action<'eventFiltersUpdateSuccess'>;
export type EventFiltersCreateStart = Action<'eventFiltersCreateStart'>;
export type EventFiltersCreateSuccess = Action<'eventFiltersCreateSuccess'> & {
payload: {
exception: ExceptionListItemSchema;
};
};
export type EventFiltersCreateSuccess = Action<'eventFiltersCreateSuccess'>;
export type EventFiltersCreateError = Action<'eventFiltersCreateError'>;
export type EventFiltersFormStateChanged = Action<'eventFiltersFormStateChanged'> & {
@ -53,9 +62,12 @@ export type EventFiltersPageAction =
| EventFiltersListPageStateChanged
| EventFiltersListPageDataChanged
| EventFiltersListPageDataExistsChanged
| EventFiltersCreateStart
| EventFiltersInitForm
| EventFiltersInitFromId
| EventFiltersChangeForm
| EventFiltersUpdateStart
| EventFiltersUpdateSuccess
| EventFiltersCreateStart
| EventFiltersCreateSuccess
| EventFiltersCreateError
| EventFiltersFormStateChanged;

View file

@ -15,6 +15,7 @@ export const initialEventFiltersPageState = (): EventFiltersListPageState => ({
hasNameError: false,
hasItemsError: false,
hasOSError: false,
newComment: '',
submissionResourceState: { type: 'UninitialisedResourceState' },
},
location: {

View file

@ -14,6 +14,7 @@ import {
import { AppAction } from '../../../../common/store/actions';
import { createEventFiltersPageMiddleware } from './middleware';
import { eventFiltersPageReducer } from './reducer';
import { EventFiltersListPageState } from '../state';
import { initialEventFiltersPageState } from './builders';
import { getInitialExceptionFromEvent } from './utils';
@ -25,6 +26,8 @@ const initialState: EventFiltersListPageState = initialEventFiltersPageState();
const createEventFiltersServiceMock = (): jest.Mocked<EventFiltersService> => ({
addEventFilters: jest.fn(),
getList: jest.fn(),
getOne: jest.fn(),
updateOne: jest.fn(),
});
const createStoreSetup = (eventFiltersService: EventFiltersService) => {
@ -51,18 +54,18 @@ describe('middleware', () => {
});
});
let service: jest.Mocked<EventFiltersService>;
let store: Store<EventFiltersListPageState>;
let spyMiddleware: MiddlewareActionSpyHelper<EventFiltersListPageState, AppAction>;
beforeEach(() => {
service = createEventFiltersServiceMock();
const storeSetup = createStoreSetup(service);
store = storeSetup.store as Store<EventFiltersListPageState>;
spyMiddleware = storeSetup.spyMiddleware;
});
describe('submit creation event filter', () => {
let service: jest.Mocked<EventFiltersService>;
let store: Store<EventFiltersListPageState>;
let spyMiddleware: MiddlewareActionSpyHelper<EventFiltersListPageState, AppAction>;
beforeEach(() => {
service = createEventFiltersServiceMock();
const storeSetup = createStoreSetup(service);
store = storeSetup.store as Store<EventFiltersListPageState>;
spyMiddleware = storeSetup.spyMiddleware;
});
it('does not submit when entry is undefined', async () => {
store.dispatch({ type: 'eventFiltersCreateStart' });
expect(store.getState()).toStrictEqual({
@ -87,7 +90,6 @@ describe('middleware', () => {
await spyMiddleware.waitForAction('eventFiltersFormStateChanged');
expect(store.getState()).toStrictEqual({
...initialState,
entries: [createdEventFilterEntryMock()],
form: {
...store.getState().form,
submissionResourceState: {
@ -110,6 +112,108 @@ describe('middleware', () => {
store.dispatch({ type: 'eventFiltersCreateStart' });
await spyMiddleware.waitForAction('eventFiltersFormStateChanged');
expect(store.getState()).toStrictEqual({
...initialState,
form: {
...store.getState().form,
submissionResourceState: {
type: 'FailedResourceState',
lastLoadedState: undefined,
error: {
error: 'Internal Server Error',
message: 'error message',
statusCode: 500,
},
},
},
});
});
});
describe('load event filterby id', () => {
it('init form with an entry loaded by id from API', async () => {
service.getOne.mockResolvedValue(createdEventFilterEntryMock());
store.dispatch({ type: 'eventFiltersInitFromId', payload: { id: 'id' } });
await spyMiddleware.waitForAction('eventFiltersInitForm');
expect(store.getState()).toStrictEqual({
...initialState,
form: {
...store.getState().form,
entry: createdEventFilterEntryMock(),
},
});
});
it('does throw error when getting by id', async () => {
service.getOne.mockRejectedValue({
body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' },
});
store.dispatch({ type: 'eventFiltersInitFromId', payload: { id: 'id' } });
await spyMiddleware.waitForAction('eventFiltersFormStateChanged');
expect(store.getState()).toStrictEqual({
...initialState,
form: {
...store.getState().form,
submissionResourceState: {
type: 'FailedResourceState',
lastLoadedState: undefined,
error: {
error: 'Internal Server Error',
message: 'error message',
statusCode: 500,
},
},
},
});
});
});
describe('submit update event filter', () => {
it('does not submit when entry is undefined', async () => {
store.dispatch({ type: 'eventFiltersUpdateStart' });
expect(store.getState()).toStrictEqual({
...initialState,
form: {
...store.getState().form,
submissionResourceState: { type: 'UninitialisedResourceState' },
},
});
});
it('does submit when entry is not undefined', async () => {
service.updateOne.mockResolvedValue(createdEventFilterEntryMock());
store.dispatch({
type: 'eventFiltersInitForm',
payload: { entry: createdEventFilterEntryMock() },
});
store.dispatch({ type: 'eventFiltersUpdateStart' });
await spyMiddleware.waitForAction('eventFiltersFormStateChanged');
expect(store.getState()).toStrictEqual({
...initialState,
form: {
...store.getState().form,
submissionResourceState: {
type: 'LoadedResourceState',
data: createdEventFilterEntryMock(),
},
},
});
});
it('does throw error when creating', async () => {
service.updateOne.mockRejectedValue({
body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' },
});
const entry = getInitialExceptionFromEvent(ecsEventMock());
store.dispatch({
type: 'eventFiltersInitForm',
payload: { entry },
});
store.dispatch({ type: 'eventFiltersUpdateStart' });
await spyMiddleware.waitForAction('eventFiltersFormStateChanged');
expect(store.getState()).toStrictEqual({
...initialState,

View file

@ -16,7 +16,13 @@ import { EventFiltersHttpService } from '../service';
import { EventFiltersListPageState } from '../state';
import { getLastLoadedResourceState } from '../../../state/async_resource_state';
import { CreateExceptionListItemSchema, transformNewItemOutput } from '../../../../shared_imports';
import {
CreateExceptionListItemSchema,
transformNewItemOutput,
transformOutput,
UpdateExceptionListItemSchema,
} from '../../../../shared_imports';
import {
getCurrentListPageDataState,
getCurrentLocation,
@ -24,9 +30,23 @@ import {
getListPageDataExistsState,
getListPageIsActive,
listDataNeedsRefresh,
getFormEntry,
getSubmissionResource,
getNewComment,
} from './selector';
import { EventFiltersService, EventFiltersServiceGetListOptions } from '../types';
const addNewComments = (
entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema,
newComment: string
): UpdateExceptionListItemSchema | CreateExceptionListItemSchema => {
if (newComment) {
if (!entry.comments) entry.comments = [];
entry.comments.push({ comment: newComment });
}
return entry;
};
type MiddlewareActionHandler = (
store: ImmutableMiddlewareAPI<EventFiltersListPageState, AppAction>,
eventFiltersService: EventFiltersService
@ -35,7 +55,7 @@ type MiddlewareActionHandler = (
const eventFiltersCreate: MiddlewareActionHandler = async (store, eventFiltersService) => {
const submissionResourceState = store.getState().form.submissionResourceState;
try {
const formEntry = store.getState().form.entry;
const formEntry = getFormEntry(store.getState());
if (!formEntry) return;
store.dispatch({
type: 'eventFiltersFormStateChanged',
@ -46,14 +66,83 @@ const eventFiltersCreate: MiddlewareActionHandler = async (store, eventFiltersSe
});
const sanitizedEntry = transformNewItemOutput(formEntry as CreateExceptionListItemSchema);
const updatedCommentsEntry = addNewComments(
sanitizedEntry,
getNewComment(store.getState())
) as CreateExceptionListItemSchema;
const exception = await eventFiltersService.addEventFilters(updatedCommentsEntry);
const exception = await eventFiltersService.addEventFilters(sanitizedEntry);
store.dispatch({
type: 'eventFiltersCreateSuccess',
});
store.dispatch({
type: 'eventFiltersFormStateChanged',
payload: {
exception,
type: 'LoadedResourceState',
data: exception,
},
});
} catch (error) {
store.dispatch({
type: 'eventFiltersFormStateChanged',
payload: {
type: 'FailedResourceState',
error: error.body || error,
lastLoadedState: getLastLoadedResourceState(submissionResourceState),
},
});
}
};
const eventFiltersUpdate = async (
store: ImmutableMiddlewareAPI<EventFiltersListPageState, AppAction>,
eventFiltersService: EventFiltersService
) => {
const submissionResourceState = getSubmissionResource(store.getState());
try {
const formEntry = getFormEntry(store.getState());
if (!formEntry) return;
store.dispatch({
type: 'eventFiltersFormStateChanged',
payload: {
type: 'LoadingResourceState',
previousState: { type: 'UninitialisedResourceState' },
},
});
const sanitizedEntry: UpdateExceptionListItemSchema = transformOutput(
formEntry as UpdateExceptionListItemSchema
);
const updatedCommentsEntry = addNewComments(
sanitizedEntry,
getNewComment(store.getState())
) as UpdateExceptionListItemSchema;
// Clean unnecessary fields for update action
[
'created_at',
'created_by',
'created_at',
'created_by',
'list_id',
'tie_breaker_id',
'updated_at',
'updated_by',
].forEach((field) => {
delete updatedCommentsEntry[field as keyof UpdateExceptionListItemSchema];
});
updatedCommentsEntry.comments = updatedCommentsEntry.comments?.map((comment) => ({
comment: comment.comment,
id: comment.id,
}));
const exception = await eventFiltersService.updateOne(updatedCommentsEntry);
store.dispatch({
type: 'eventFiltersUpdateSuccess',
});
store.dispatch({
type: 'eventFiltersFormStateChanged',
payload: {
@ -73,6 +162,30 @@ const eventFiltersCreate: MiddlewareActionHandler = async (store, eventFiltersSe
}
};
const eventFiltersLoadById = async (
store: ImmutableMiddlewareAPI<EventFiltersListPageState, AppAction>,
eventFiltersService: EventFiltersService,
id: string
) => {
const submissionResourceState = getSubmissionResource(store.getState());
try {
const entry = await eventFiltersService.getOne(id);
store.dispatch({
type: 'eventFiltersInitForm',
payload: { entry },
});
} catch (error) {
store.dispatch({
type: 'eventFiltersFormStateChanged',
payload: {
type: 'FailedResourceState',
error: error.body || error,
lastLoadedState: getLastLoadedResourceState(submissionResourceState),
},
});
}
};
const checkIfEventFilterDataExist: MiddlewareActionHandler = async (
{ dispatch, getState },
eventFiltersService: EventFiltersService
@ -146,6 +259,14 @@ const refreshListDataIfNeeded: MiddlewareActionHandler = async (store, eventFilt
},
});
dispatch({
type: 'eventFiltersListPageDataExistsChanged',
payload: {
type: 'LoadedResourceState',
data: Boolean(results.total),
},
});
// If no results were returned, then just check to make sure data actually exists for
// event filters. This is used to drive the UI between showing "empty state" and "no items found"
// messages to the user
@ -172,11 +293,19 @@ export const createEventFiltersPageMiddleware = (
if (action.type === 'eventFiltersCreateStart') {
await eventFiltersCreate(store, eventFiltersService);
} else if (action.type === 'eventFiltersInitFromId') {
await eventFiltersLoadById(store, eventFiltersService, action.payload.id);
} else if (action.type === 'eventFiltersUpdateStart') {
await eventFiltersUpdate(store, eventFiltersService);
}
// Middleware that only applies to the List Page for Event Filters
if (getListPageIsActive(store.getState())) {
if (action.type === 'userChangedUrl' || action.type === 'eventFiltersCreateSuccess') {
if (
action.type === 'userChangedUrl' ||
action.type === 'eventFiltersCreateSuccess' ||
action.type === 'eventFiltersUpdateSuccess'
) {
refreshListDataIfNeeded(store, eventFiltersService);
}
}

View file

@ -37,9 +37,10 @@ describe('reducer', () => {
it('change form values', () => {
const entry = getInitialExceptionFromEvent(ecsEventMock());
const nameChanged = 'name changed';
const newComment = 'new comment';
const result = eventFiltersPageReducer(initialState, {
type: 'eventFiltersChangeForm',
payload: { entry: { ...entry, name: nameChanged } },
payload: { entry: { ...entry, name: nameChanged }, newComment },
});
expect(result).toStrictEqual({
@ -50,6 +51,7 @@ describe('reducer', () => {
...entry,
name: nameChanged,
},
newComment,
hasNameError: false,
submissionResourceState: {
type: 'UninitialisedResourceState',
@ -79,34 +81,22 @@ describe('reducer', () => {
});
});
it('create is success when there is no entry on entries list', () => {
const result = eventFiltersPageReducer(initialState, {
it('create is success and force list refresh', () => {
const initialStateWithListPageActive = {
...initialState,
listPage: { ...initialState.listPage, active: true },
};
const result = eventFiltersPageReducer(initialStateWithListPageActive, {
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: {} },
...initialStateWithListPageActive,
listPage: {
...initialStateWithListPageActive.listPage,
forceRefresh: true,
},
});
expect(result.entries).toHaveLength(3);
expect(result.entries[0]!.meta).not.toBeUndefined();
});
});
describe('UserChangedUrl', () => {

View file

@ -14,12 +14,14 @@ 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 { isUninitialisedResourceState } from '../../../state/async_resource_state';
import {
EventFiltersInitForm,
EventFiltersChangeForm,
EventFiltersFormStateChanged,
EventFiltersCreateSuccess,
EventFiltersUpdateSuccess,
EventFiltersListPageStateChanged,
EventFiltersListPageDataChanged,
EventFiltersListPageDataExistsChanged,
@ -113,6 +115,8 @@ const eventFiltersChangeForm: CaseReducer<EventFiltersChangeForm> = (state, acti
: state.form.hasNameError,
hasOSError:
action.payload.hasOSError !== undefined ? action.payload.hasOSError : state.form.hasOSError,
newComment:
action.payload.newComment !== undefined ? action.payload.newComment : state.form.newComment,
},
};
};
@ -122,6 +126,8 @@ const eventFiltersFormStateChanged: CaseReducer<EventFiltersFormStateChanged> =
...state,
form: {
...state.form,
entry: isUninitialisedResourceState(action.payload) ? undefined : state.form.entry,
newComment: isUninitialisedResourceState(action.payload) ? '' : state.form.newComment,
submissionResourceState: action.payload,
},
};
@ -130,7 +136,19 @@ const eventFiltersFormStateChanged: CaseReducer<EventFiltersFormStateChanged> =
const eventFiltersCreateSuccess: CaseReducer<EventFiltersCreateSuccess> = (state, action) => {
return {
...state,
entries: [action.payload.exception, ...state.entries],
// If we are on the List page, then force a refresh of data
listPage: getListPageIsActive(state)
? {
...state.listPage,
forceRefresh: true,
}
: state.listPage,
};
};
const eventFiltersUpdateSuccess: CaseReducer<EventFiltersUpdateSuccess> = (state, action) => {
return {
...state,
// If we are on the List page, then force a refresh of data
listPage: getListPageIsActive(state)
? {
@ -180,6 +198,8 @@ export const eventFiltersPageReducer: StateReducer = (
return eventFiltersFormStateChanged(state, action);
case 'eventFiltersCreateSuccess':
return eventFiltersCreateSuccess(state, action);
case 'eventFiltersUpdateSuccess':
return eventFiltersUpdateSuccess(state, action);
case 'userChangedUrl':
return userChangedUrl(state, action);
}

View file

@ -4,16 +4,19 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createSelector } from 'reselect';
import { Pagination } from '@elastic/eui';
import { EventFiltersServiceGetListOptions } from '../types';
import { EventFiltersListPageState } from '../state';
import { ExceptionListItemSchema, CreateExceptionListItemSchema } from '../../../../shared_imports';
import { ExceptionListItemSchema } from '../../../../shared_imports';
import { ServerApiError } from '../../../../common/types';
import {
isLoadingResourceState,
isLoadedResourceState,
isFailedResourceState,
isUninitialisedResourceState,
} from '../../../state/async_resource_state';
import { FoundExceptionListItemSchema } from '../../../../../../lists/common/schemas';
import {
@ -21,7 +24,6 @@ import {
MANAGEMENT_PAGE_SIZE_OPTIONS,
} from '../../../common/constants';
import { Immutable } from '../../../../../common/endpoint/types';
import { EventFiltersServiceGetListOptions } from '../types';
type StoreState = Immutable<EventFiltersListPageState>;
type EventFiltersSelector<T> = (state: StoreState) => T;
@ -121,11 +123,29 @@ export const getListPageDoesDataExist: EventFiltersSelector<boolean> = createSel
}
);
export const getFormEntry = (
state: EventFiltersListPageState
): CreateExceptionListItemSchema | ExceptionListItemSchema | undefined => {
export const getFormEntryState: EventFiltersSelector<StoreState['form']['entry']> = (state) => {
return state.form.entry;
};
// Needed for form component as we modify the existing entry on exceptuionBuilder component
export const getFormEntryStateMutable = (
state: EventFiltersListPageState
): EventFiltersListPageState['form']['entry'] => {
return state.form.entry;
};
export const getFormEntry = createSelector(getFormEntryState, (entry) => entry);
export const getNewCommentState: EventFiltersSelector<StoreState['form']['newComment']> = (
state
) => {
return state.form.newComment;
};
export const getNewComment = createSelector(getNewCommentState, (newComment) => newComment);
export const getHasNameError = (state: EventFiltersListPageState): boolean => {
return state.form.hasNameError;
};
export const getFormHasError = (state: EventFiltersListPageState): boolean => {
return state.form.hasItemsError || state.form.hasNameError || state.form.hasOSError;
@ -139,12 +159,27 @@ export const isCreationSuccessful = (state: EventFiltersListPageState): boolean
return isLoadedResourceState(state.form.submissionResourceState);
};
export const getCreationError = (state: EventFiltersListPageState): ServerApiError | undefined => {
export const isUninitialisedForm = (state: EventFiltersListPageState): boolean => {
return isUninitialisedResourceState(state.form.submissionResourceState);
};
export const getActionError = (state: EventFiltersListPageState): ServerApiError | undefined => {
const submissionResourceState = state.form.submissionResourceState;
return isFailedResourceState(submissionResourceState) ? submissionResourceState.error : undefined;
};
export const getSubmissionResourceState: EventFiltersSelector<
StoreState['form']['submissionResourceState']
> = (state) => {
return state.form.submissionResourceState;
};
export const getSubmissionResource = createSelector(
getSubmissionResourceState,
(submissionResourceState) => submissionResourceState
);
export const getCurrentLocation: EventFiltersSelector<StoreState['location']> = (state) =>
state.location;

View file

@ -10,6 +10,8 @@ import {
getFormEntry,
getFormHasError,
getCurrentLocation,
getNewComment,
getHasNameError,
getCurrentListPageState,
getListPageIsActive,
getCurrentListPageDataState,
@ -24,7 +26,8 @@ import {
} from './selector';
import { ecsEventMock } from '../test_utils';
import { getInitialExceptionFromEvent } from './utils';
import { EventFiltersListPageState, EventFiltersPageLocation } from '../state';
import { EventFiltersPageLocation } from '../types';
import { EventFiltersListPageState } from '../state';
import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../../common/constants';
import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock';
import {
@ -252,6 +255,31 @@ describe('event filters selectors', () => {
expect(getFormEntry(state)).toBe(entry);
});
});
describe('getHasNameError()', () => {
it('returns false when there is no entry', () => {
expect(getHasNameError(initialState)).toBeFalsy();
});
it('returns true when entry with name error', () => {
const state = {
...initialState,
form: {
...initialState.form,
hasNameError: true,
},
};
expect(getHasNameError(state)).toBeTruthy();
});
it('returns false when entry with no name error', () => {
const state = {
...initialState,
form: {
...initialState.form,
hasNameError: false,
},
};
expect(getHasNameError(state)).toBeFalsy();
});
});
describe('getFormHasError()', () => {
it('returns false when there is no entry', () => {
expect(getFormHasError(initialState)).toBeFalsy();
@ -327,4 +355,23 @@ describe('event filters selectors', () => {
expect(getCurrentLocation(state)).toBe(expectedLocation);
});
});
describe('getNewComment()', () => {
it('returns new comment', () => {
const newComment = 'this is a new comment';
const state = {
...initialState,
form: {
...initialState.form,
newComment,
},
};
expect(getNewComment(state)).toBe(newComment);
});
it('returns empty comment', () => {
const state = {
...initialState,
};
expect(getNewComment(state)).toBe('');
});
});
});

View file

@ -83,7 +83,7 @@ export const createdEventFilterEntryMock = (): ExceptionListItemSchema => ({
name: 'Test',
namespace_type: 'agnostic',
os_types: ['windows'],
tags: [],
tags: ['policy:all'],
tie_breaker_id: 'c42f3dbd-292f-49e8-83ab-158d024a4d8b',
type: 'simple',
updated_at: '2021-04-19T10:30:36.428Z',

View file

@ -5,16 +5,31 @@
* 2.0.
*/
import { Immutable } from '../../../../common/endpoint/types';
import {
UpdateExceptionListItemSchema,
CreateExceptionListItemSchema,
ExceptionListItemSchema,
} from '../../../../../lists/common';
} from '../../../shared_imports';
import { AsyncResourceState } from '../../state/async_resource_state';
import { Immutable } from '../../../../common/endpoint/types';
import { FoundExceptionListItemSchema } from '../../../../../lists/common/schemas';
export interface EventFiltersListPageUrlSearchParams {
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 EventFiltersForm {
entry: UpdateExceptionListItemSchema | CreateExceptionListItemSchema | undefined;
newComment: string;
hasNameError: boolean;
hasItemsError: boolean;
hasOSError: boolean;
submissionResourceState: AsyncResourceState<ExceptionListItemSchema>;
}
export type EventFiltersServiceGetListOptions = Partial<{
@ -30,4 +45,6 @@ export interface EventFiltersService {
): Promise<ExceptionListItemSchema>;
getList(options?: EventFiltersServiceGetListOptions): Promise<FoundExceptionListItemSchema>;
getOne(id: string): Promise<ExceptionListItemSchema>;
updateOne(exception: Immutable<UpdateExceptionListItemSchema>): Promise<ExceptionListItemSchema>;
}

View file

@ -11,8 +11,8 @@ import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
const EmptyPrompt = styled(EuiEmptyPrompt)`
${({ theme }) => css`
max-width: ${theme.eui.euiBreakpoints.m};
${() => css`
max-width: 100%;
`}
`;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import { EventFiltersFlyout } from '.';
import { EventFiltersFlyout, EventFiltersFlyoutProps } from '.';
import * as reactTestingLibrary from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import {
@ -18,8 +18,14 @@ import {
CreateExceptionListItemSchema,
ExceptionListItemSchema,
} from '../../../../../../shared_imports';
import { EventFiltersHttpService } from '../../../service';
import { createdEventFilterEntryMock } from '../../../test_utils';
import { getFormEntryState, isUninitialisedForm } from '../../../store/selector';
import { EventFiltersListPageState } from '../../../state';
jest.mock('../form');
jest.mock('../../../service');
jest.mock('../../hooks', () => {
const originalModule = jest.requireActual('../../hooks');
const useEventFiltersNotification = jest.fn().mockImplementation(() => {});
@ -32,16 +38,31 @@ jest.mock('../../hooks', () => {
let mockedContext: AppContextTestRender;
let waitForAction: MiddlewareActionSpyHelper['waitForAction'];
let render: () => ReturnType<AppContextTestRender['render']>;
let render: (
props?: Partial<EventFiltersFlyoutProps>
) => ReturnType<AppContextTestRender['render']>;
const act = reactTestingLibrary.act;
let onCancelMock: jest.Mock;
const EventFiltersHttpServiceMock = EventFiltersHttpService as jest.Mock;
let getState: () => EventFiltersListPageState;
describe('Event filter flyout', () => {
beforeAll(() => {
EventFiltersHttpServiceMock.mockImplementation(() => {
return {
getOne: () => createdEventFilterEntryMock(),
addEventFilters: () => createdEventFilterEntryMock(),
updateOne: () => createdEventFilterEntryMock(),
};
});
});
beforeEach(() => {
mockedContext = createAppRootMockRenderer();
waitForAction = mockedContext.middlewareSpy.waitForAction;
onCancelMock = jest.fn();
render = () => mockedContext.render(<EventFiltersFlyout onCancel={onCancelMock} />);
getState = () => mockedContext.store.getState().management.eventFilters;
render = (props) =>
mockedContext.render(<EventFiltersFlyout {...props} onCancel={onCancelMock} />);
});
afterEach(() => reactTestingLibrary.cleanup());
@ -59,10 +80,8 @@ describe('Event filter flyout', () => {
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('');
expect(getFormEntryState(getState())).not.toBeUndefined();
expect(getFormEntryState(getState())!.entries[0].field).toBe('');
});
it('should confirm form when button is disabled', () => {
@ -71,9 +90,7 @@ describe('Event filter flyout', () => {
act(() => {
fireEvent.click(confirmButton);
});
expect(
mockedContext.store.getState().management.eventFilters.form.submissionResourceState.type
).toBe('UninitialisedResourceState');
expect(isUninitialisedForm(getState())).toBeTruthy();
});
it('should confirm form when button is enabled', async () => {
@ -82,8 +99,7 @@ describe('Event filter flyout', () => {
type: 'eventFiltersChangeForm',
payload: {
entry: {
...(mockedContext.store.getState().management.eventFilters.form!
.entry as CreateExceptionListItemSchema),
...(getState().form!.entry as CreateExceptionListItemSchema),
name: 'test',
os_types: ['windows'],
},
@ -97,9 +113,7 @@ describe('Event filter flyout', () => {
fireEvent.click(confirmButton);
await waitForAction('eventFiltersCreateSuccess');
});
expect(
mockedContext.store.getState().management.eventFilters.form.submissionResourceState.type
).toBe('UninitialisedResourceState');
expect(isUninitialisedForm(getState())).toBeTruthy();
expect(confirmButton.hasAttribute('disabled')).toBeFalsy();
});
@ -112,8 +126,7 @@ describe('Event filter flyout', () => {
type: 'eventFiltersFormStateChanged',
payload: {
type: 'LoadedResourceState',
data: mockedContext.store.getState().management.eventFilters.form!
.entry as ExceptionListItemSchema,
data: getState().form!.entry as ExceptionListItemSchema,
},
});
});
@ -166,4 +179,22 @@ describe('Event filter flyout', () => {
expect(onCancelMock).toHaveBeenCalledTimes(0);
});
it('should renders correctly when id and edit type', () => {
const component = render({ id: 'fakeId', type: 'edit' });
expect(component.getAllByText('Update 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 with id', async () => {
await act(async () => {
render({ id: 'fakeId', type: 'edit' });
await waitForAction('eventFiltersInitFromId');
});
expect(getFormEntryState(getState())).not.toBeUndefined();
expect(getFormEntryState(getState())!.item_id).toBe(createdEventFilterEntryMock().item_id);
});
});

View file

@ -22,7 +22,6 @@ import {
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 {
@ -33,97 +32,125 @@ import {
import { getInitialExceptionFromEvent } from '../../../store/utils';
export interface EventFiltersFlyoutProps {
data?: Ecs;
type?: 'create' | 'edit';
id?: string;
onCancel(): void;
}
export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo(({ data, onCancel }) => {
useEventFiltersNotification();
const dispatch = useDispatch<Dispatch<AppAction>>();
const formHasError = useEventFiltersSelector(getFormHasError);
const creationInProgress = useEventFiltersSelector(isCreationInProgress);
const creationSuccessful = useEventFiltersSelector(isCreationSuccessful);
export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo(
({ onCancel, id, type = 'create' }) => {
useEventFiltersNotification();
const dispatch = useDispatch<Dispatch<AppAction>>();
const formHasError = useEventFiltersSelector(getFormHasError);
const creationInProgress = useEventFiltersSelector(isCreationInProgress);
const creationSuccessful = useEventFiltersSelector(isCreationSuccessful);
useEffect(() => {
if (creationSuccessful) {
useEffect(() => {
if (creationSuccessful) {
onCancel();
dispatch({
type: 'eventFiltersFormStateChanged',
payload: {
type: 'UninitialisedResourceState',
},
});
}
}, [creationSuccessful, onCancel, dispatch]);
// Initialize the store with the id passed as prop to allow render the form. It acts as componentDidMount
useEffect(() => {
if (type === 'edit' && !!id) {
dispatch({
type: 'eventFiltersInitFromId',
payload: { id },
});
} else {
dispatch({
type: 'eventFiltersInitForm',
payload: { entry: getInitialExceptionFromEvent() },
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleOnCancel = useCallback(() => {
if (creationInProgress) return;
onCancel();
dispatch({
type: 'eventFiltersFormStateChanged',
payload: {
type: 'UninitialisedResourceState',
},
});
}
}, [creationSuccessful, onCancel, dispatch]);
}, [creationInProgress, onCancel]);
// 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(
() => (
<EuiButton
data-test-subj="add-exception-confirm-button"
fill
disabled={formHasError || creationInProgress}
onClick={() => dispatch({ type: 'eventFiltersCreateStart' })}
isLoading={creationInProgress}
>
<FormattedMessage
id="xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm"
defaultMessage="Add Endpoint Event Filter"
/>
</EuiButton>
),
[dispatch, formHasError, creationInProgress]
);
return (
<EuiFlyout size="l" onClose={handleOnCancel}>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
const confirmButtonMemo = useMemo(
() => (
<EuiButton
data-test-subj="add-exception-confirm-button"
fill
disabled={formHasError || creationInProgress}
onClick={() =>
id
? dispatch({ type: 'eventFiltersUpdateStart' })
: dispatch({ type: 'eventFiltersCreateStart' })
}
isLoading={creationInProgress}
>
{id ? (
<FormattedMessage
id="xpack.securitySolution.eventFilters.eventFiltersFlyout.title"
defaultMessage="Endpoint Security"
id="xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.update"
defaultMessage="Update Endpoint Event Filter"
/>
</h2>
</EuiTitle>
<FormattedMessage
id="xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle"
defaultMessage="Add Endpoint Event Filter"
/>
</EuiFlyoutHeader>
) : (
<FormattedMessage
id="xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.confirm.create"
defaultMessage="Add Endpoint Event Filter"
/>
)}
</EuiButton>
),
[formHasError, creationInProgress, id, dispatch]
);
<EuiFlyoutBody>
<EventFiltersForm allowSelectOs />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="cancelExceptionAddButton" onClick={handleOnCancel}>
return (
<EuiFlyout size="l" onClose={handleOnCancel}>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
<FormattedMessage
id="xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.cancel"
defaultMessage="cancel"
id="xpack.securitySolution.eventFilters.eventFiltersFlyout.title"
defaultMessage="Endpoint Security"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>{confirmButtonMemo}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
});
</h2>
</EuiTitle>
{id ? (
<FormattedMessage
id="xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.update"
defaultMessage="Update Endpoint Event Filter"
/>
) : (
<FormattedMessage
id="xpack.securitySolution.eventFilters.eventFiltersFlyout.subtitle.create"
defaultMessage="Add Endpoint Event Filter"
/>
)}
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EventFiltersForm allowSelectOs />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="cancelExceptionAddButton" onClick={handleOnCancel}>
<FormattedMessage
id="xpack.securitySolution.eventFilters.eventFiltersFlyout.actions.cancel"
defaultMessage="cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>{confirmButtonMemo}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
}
);
EventFiltersFlyout.displayName = 'EventFiltersFlyout';

View file

@ -111,8 +111,6 @@ describe('Event filter form', () => {
});
});
expect(store.getState()!.management!.eventFilters!.form!.entry!.comments![0].comment).toBe(
'Exception comment'
);
expect(store.getState()!.management!.eventFilters!.form!.newComment).toBe('Exception comment');
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { memo, useMemo, useCallback, useState } from 'react';
import React, { memo, useMemo, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { Dispatch } from 'redux';
import {
@ -18,7 +18,7 @@ import {
EuiText,
} from '@elastic/eui';
import { isEmpty } from 'lodash';
import { isEmpty } from 'lodash/fp';
import { OperatingSystem } from '../../../../../../../common/endpoint/types';
import { AddExceptionComments } from '../../../../../../common/components/exceptions/add_exception_comments';
import { filterIndexPatterns } from '../../../../../../common/components/exceptions/helpers';
@ -29,7 +29,7 @@ import { AppAction } from '../../../../../../common/store/actions';
import { ExceptionListItemSchema, ExceptionBuilder } from '../../../../../../shared_imports';
import { useEventFiltersSelector } from '../../hooks';
import { getFormEntry } from '../../../store/selector';
import { getFormEntryStateMutable, getHasNameError, getNewComment } from '../../../store/selector';
import {
FORM_DESCRIPTION,
NAME_LABEL,
@ -54,7 +54,9 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
({ allowSelectOs = false }) => {
const { http, data } = useKibana().services;
const dispatch = useDispatch<Dispatch<AppAction>>();
const exception = useEventFiltersSelector(getFormEntry);
const exception = useEventFiltersSelector(getFormEntryStateMutable);
const hasNameError = useEventFiltersSelector(getHasNameError);
const newComment = useEventFiltersSelector(getNewComment);
// This value has to be memoized to avoid infinite useEffect loop on useFetchIndex
const indexNames = useMemo(() => ['logs-endpoint.events.*'], []);
@ -65,9 +67,6 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
[]
);
const [hasNameError, setHasNameError] = useState(!exception || !exception.name);
const [comment, setComment] = useState<string>('');
const handleOnBuilderChange = useCallback(
(arg: ExceptionBuilder.OnChangeProps) => {
if (isEmpty(arg.exceptionItems)) return;
@ -90,7 +89,6 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
const handleOnChangeName = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (!exception) return;
setHasNameError(!e.target.value);
dispatch({
type: 'eventFiltersChangeForm',
payload: {
@ -104,16 +102,16 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
const handleOnChangeComment = useCallback(
(value: string) => {
setComment(value);
if (!exception) return;
dispatch({
type: 'eventFiltersChangeForm',
payload: {
entry: { ...exception, comments: [{ comment: value }] },
entry: exception,
newComment: value,
},
});
},
[dispatch, exception, setComment]
[dispatch, exception]
);
const exceptionBuilderComponentMemo = useMemo(
@ -189,11 +187,12 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
const commentsInputMemo = useMemo(
() => (
<AddExceptionComments
newCommentValue={comment}
exceptionItemComments={(exception as ExceptionListItemSchema)?.comments}
newCommentValue={newComment}
newCommentOnChange={handleOnChangeComment}
/>
),
[comment, handleOnChangeComment]
[exception, handleOnChangeComment, newComment]
);
return !isIndexPatternLoading && exception ? (

View file

@ -5,12 +5,19 @@
* 2.0.
*/
import React, { memo, useCallback } from 'react';
import React, { memo, useCallback, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { Dispatch } from 'redux';
import { useHistory } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiButton } from '@elastic/eui';
import styled from 'styled-components';
import { AppAction } from '../../../../common/store/actions';
import { getEventFiltersListPath } from '../../../common/routing';
import { AdministrationListPage as _AdministrationListPage } from '../../../components/administration_list_page';
import { EventFiltersListEmptyState } from './components/empty';
import { useEventFiltersNavigateCallback, useEventFiltersSelector } from './hooks';
import { EventFiltersFlyout } from './components/flyout';
@ -21,6 +28,8 @@ import {
getListPagination,
getCurrentLocation,
getListPageDoesDataExist,
getActionError,
getFormEntry,
} from '../store/selector';
import { PaginatedContent, PaginatedContentProps } from '../../../components/paginated_content';
import { ExceptionListItemSchema } from '../../../../../../lists/common';
@ -46,6 +55,10 @@ const AdministrationListPage = styled(_AdministrationListPage)`
`;
export const EventFiltersListPage = memo(() => {
const history = useHistory();
const dispatch = useDispatch<Dispatch<AppAction>>();
const isActionError = useEventFiltersSelector(getActionError);
const formEntry = useEventFiltersSelector(getFormEntry);
const listItems = useEventFiltersSelector(getListItems);
const pagination = useEventFiltersSelector(getListPagination);
const isLoading = useEventFiltersSelector(getListIsLoading);
@ -56,6 +69,35 @@ export const EventFiltersListPage = memo(() => {
const navigateCallback = useEventFiltersNavigateCallback();
const showFlyout = !!location.show;
// Clean url params if wrong
useEffect(() => {
if ((location.show === 'edit' && !location.id) || (location.show === 'create' && !!location.id))
navigateCallback({
show: 'create',
id: undefined,
});
}, [location, navigateCallback]);
// Catch fetch error -> actionError + empty entry in form
useEffect(() => {
if (isActionError && !formEntry) {
// Replace the current URL route so that user does not keep hitting this page via browser back/fwd buttons
history.replace(
getEventFiltersListPath({
...location,
show: undefined,
id: undefined,
})
);
dispatch({
type: 'eventFiltersFormStateChanged',
payload: {
type: 'UninitialisedResourceState',
},
});
}
}, [dispatch, formEntry, history, isActionError, location, navigateCallback]);
const handleAddButtonClick = useCallback(
() =>
navigateCallback({
@ -65,7 +107,7 @@ export const EventFiltersListPage = memo(() => {
[navigateCallback]
);
const handleAddCancelButtonClick = useCallback(
const handleCancelButtonClick = useCallback(
() =>
navigateCallback({
show: undefined,
@ -74,9 +116,15 @@ export const EventFiltersListPage = memo(() => {
[navigateCallback]
);
const handleItemEdit: ExceptionItemProps['onEditException'] = useCallback((item) => {
// TODO: implement edit item
}, []);
const handleItemEdit: ExceptionItemProps['onEditException'] = useCallback(
(item: ExceptionListItemSchema) => {
navigateCallback({
show: 'edit',
id: item.id,
});
},
[navigateCallback]
);
const handleItemDelete: ExceptionItemProps['onDeleteException'] = useCallback((args) => {
// TODO: implement delete item
@ -136,7 +184,13 @@ export const EventFiltersListPage = memo(() => {
)
}
>
{showFlyout && <EventFiltersFlyout onCancel={handleAddCancelButtonClick} />}
{showFlyout && (
<EventFiltersFlyout
onCancel={handleCancelButtonClick}
id={location.id}
type={location.show}
/>
)}
<PaginatedContent<Immutable<ExceptionListItemSchema>, typeof ExceptionItem>
items={listItems}

View file

@ -11,16 +11,23 @@ import { useHistory } from 'react-router-dom';
import {
isCreationSuccessful,
getFormEntry,
getCreationError,
getFormEntryStateMutable,
getActionError,
getCurrentLocation,
} from '../store/selector';
import { useToasts } from '../../../../common/lib/kibana';
import { getCreationSuccessMessage, getCreationErrorMessage } from './translations';
import {
getCreationSuccessMessage,
getUpdateSuccessMessage,
getCreationErrorMessage,
getUpdateErrorMessage,
getGetErrorMessage,
} from './translations';
import { State } from '../../../../common/store';
import { EventFiltersListPageState, EventFiltersPageLocation } from '../state';
import { EventFiltersListPageState } from '../state';
import { EventFiltersPageLocation } from '../types';
import { getEventFiltersListPath } from '../../../common/routing';
import {
@ -36,17 +43,27 @@ export function useEventFiltersSelector<R>(selector: (state: EventFiltersListPag
export const useEventFiltersNotification = () => {
const creationSuccessful = useEventFiltersSelector(isCreationSuccessful);
const creationError = useEventFiltersSelector(getCreationError);
const formEntry = useEventFiltersSelector(getFormEntry);
const actionError = useEventFiltersSelector(getActionError);
const formEntry = useEventFiltersSelector(getFormEntryStateMutable);
const toasts = useToasts();
const [wasAlreadyHandled] = useState(new WeakSet());
if (creationSuccessful && formEntry && !wasAlreadyHandled.has(formEntry)) {
wasAlreadyHandled.add(formEntry);
toasts.addSuccess(getCreationSuccessMessage(formEntry));
} else if (creationError && !wasAlreadyHandled.has(creationError)) {
wasAlreadyHandled.add(creationError);
toasts.addDanger(getCreationErrorMessage(creationError));
if (formEntry.item_id) {
toasts.addSuccess(getUpdateSuccessMessage(formEntry));
} else {
toasts.addSuccess(getCreationSuccessMessage(formEntry));
}
} else if (actionError && !wasAlreadyHandled.has(actionError)) {
wasAlreadyHandled.add(actionError);
if (formEntry && formEntry.item_id) {
toasts.addDanger(getUpdateErrorMessage(actionError));
} else if (formEntry) {
toasts.addDanger(getCreationErrorMessage(actionError));
} else {
toasts.addWarning(getGetErrorMessage(actionError));
}
}
};

View file

@ -7,21 +7,47 @@
import { i18n } from '@kbn/i18n';
import { ExceptionListItemSchema, CreateExceptionListItemSchema } from '../../../../shared_imports';
import {
CreateExceptionListItemSchema,
UpdateExceptionListItemSchema,
} from '../../../../shared_imports';
import { ServerApiError } from '../../../../common/types';
export const getCreationSuccessMessage = (
entry: CreateExceptionListItemSchema | ExceptionListItemSchema | undefined
entry: CreateExceptionListItemSchema | UpdateExceptionListItemSchema | undefined
) => {
return i18n.translate('xpack.securitySolution.eventFilter.form.successToastTitle', {
return i18n.translate('xpack.securitySolution.eventFilter.form.creationSuccessToastTitle', {
defaultMessage: '"{name}" has been added to the event exceptions list.',
values: { name: entry?.name },
});
};
export const getUpdateSuccessMessage = (
entry: CreateExceptionListItemSchema | UpdateExceptionListItemSchema | undefined
) => {
return i18n.translate('xpack.securitySolution.eventFilter.form.updateSuccessToastTitle', {
defaultMessage: '"{name}" has been updated successfully.',
values: { name: entry?.name },
});
};
export const getCreationErrorMessage = (creationError: ServerApiError) => {
return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle', {
return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle.create', {
defaultMessage: 'There was an error creating the new exception: "{error}"',
values: { error: creationError.message },
});
};
export const getUpdateErrorMessage = (updateError: ServerApiError) => {
return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle.update', {
defaultMessage: 'There was an error updating the exception: "{error}"',
values: { error: updateError.message },
});
};
export const getGetErrorMessage = (getError: ServerApiError) => {
return i18n.translate('xpack.securitySolution.eventFilter.form.failedToastTitle.get', {
defaultMessage: 'Unable to edit trusted application: "{error}"',
values: { error: getError.message },
});
};

View file

@ -14,9 +14,19 @@ import { coreMock } from '../../../../../../../../src/core/public/mocks';
import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public/context';
import { CreateExceptionListItemSchema, ExceptionListItemSchema } from '../../../../shared_imports';
import { createGlobalNoMiddlewareStore, ecsEventMock } from '../test_utils';
import {
createdEventFilterEntryMock,
createGlobalNoMiddlewareStore,
ecsEventMock,
} from '../test_utils';
import { useEventFiltersNotification } from './hooks';
import { getCreationErrorMessage, getCreationSuccessMessage } from './translations';
import {
getCreationErrorMessage,
getCreationSuccessMessage,
getGetErrorMessage,
getUpdateSuccessMessage,
getUpdateErrorMessage,
} from './translations';
import { getInitialExceptionFromEvent } from '../store/utils';
import {
getLastLoadedResourceState,
@ -79,6 +89,37 @@ describe('EventFiltersNotification', () => {
expect(notifications.toasts.addDanger).not.toBeCalled();
});
it('shows success notification when update successful', () => {
const store = createGlobalNoMiddlewareStore();
const notifications = mockNotifications();
renderNotifications(store, notifications);
act(() => {
store.dispatch({
type: 'eventFiltersInitForm',
payload: { entry: createdEventFilterEntryMock() },
});
});
act(() => {
store.dispatch({
type: 'eventFiltersFormStateChanged',
payload: {
type: 'LoadedResourceState',
data: store.getState()!.management!.eventFilters!.form!.entry as ExceptionListItemSchema,
},
});
});
expect(notifications.toasts.addSuccess).toBeCalledWith(
getUpdateSuccessMessage(
store.getState()!.management!.eventFilters!.form!.entry as CreateExceptionListItemSchema
)
);
expect(notifications.toasts.addDanger).not.toBeCalled();
});
it('shows error notification when creation fails', () => {
const store = createGlobalNoMiddlewareStore();
const notifications = mockNotifications();
@ -114,4 +155,67 @@ describe('EventFiltersNotification', () => {
)
);
});
it('shows error notification when update fails', () => {
const store = createGlobalNoMiddlewareStore();
const notifications = mockNotifications();
renderNotifications(store, notifications);
act(() => {
store.dispatch({
type: 'eventFiltersInitForm',
payload: { entry: createdEventFilterEntryMock() },
});
});
act(() => {
store.dispatch({
type: 'eventFiltersFormStateChanged',
payload: {
type: 'FailedResourceState',
error: { message: 'error message', statusCode: 500, error: 'error' },
lastLoadedState: getLastLoadedResourceState(
store.getState()!.management!.eventFilters!.form!.submissionResourceState
),
},
});
});
expect(notifications.toasts.addSuccess).not.toBeCalled();
expect(notifications.toasts.addDanger).toBeCalledWith(
getUpdateErrorMessage(
(store.getState()!.management!.eventFilters!.form!
.submissionResourceState as FailedResourceState).error
)
);
});
it('shows error notification when get fails', () => {
const store = createGlobalNoMiddlewareStore();
const notifications = mockNotifications();
renderNotifications(store, notifications);
act(() => {
store.dispatch({
type: 'eventFiltersFormStateChanged',
payload: {
type: 'FailedResourceState',
error: { message: 'error message', statusCode: 500, error: 'error' },
lastLoadedState: getLastLoadedResourceState(
store.getState()!.management!.eventFilters!.form!.submissionResourceState
),
},
});
});
expect(notifications.toasts.addSuccess).not.toBeCalled();
expect(notifications.toasts.addWarning).toBeCalledWith(
getGetErrorMessage(
(store.getState()!.management!.eventFilters!.form!
.submissionResourceState as FailedResourceState).error
)
);
});
});

View file

@ -60,4 +60,5 @@ export {
withOptionalSignal,
ExceptionBuilder,
transformNewItemOutput,
transformOutput,
} from '../../lists/public';