diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx index 573419676a086..0449147b93b40 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import type { ReactWrapper } from 'enzyme'; import { mount, shallow } from 'enzyme'; -import { waitFor, render } from '@testing-library/react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock'; import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; @@ -35,6 +35,7 @@ import { import type { AlertData } from '../../utils/types'; import { useFindRules } from '../../../rule_management/logic/use_find_rules'; import { useFindExceptionListReferences } from '../../logic/use_find_references'; +import { MAX_COMMENT_LENGTH } from '../constants'; jest.mock('../../../../detections/containers/detection_engine/alerts/use_signal_index'); jest.mock('../../../../common/lib/kibana'); @@ -1305,5 +1306,46 @@ describe('When the add exception modal is opened', () => { wrapper.find('button[data-test-subj="addExceptionConfirmButton"]').getDOMNode() ).toBeDisabled(); }); + + test('when there is a comment error has submit button disabled', async () => { + const { getByLabelText, queryByText, getByTestId } = render( + + + + ); + + const commentInput = getByLabelText('Comment Input'); + + const commentErrorMessage = `The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH} characters.`; + expect(queryByText(commentErrorMessage)).toBeNull(); + + // Put comment with the length above maximum allowed + act(() => { + fireEvent.change(commentInput, { + target: { + value: [...new Array(MAX_COMMENT_LENGTH + 1).keys()].map((_) => 'a').join(''), + }, + }); + fireEvent.blur(commentInput); + }); + expect(queryByText(commentErrorMessage)).not.toBeNull(); + expect(getByTestId('addExceptionConfirmButton')).toBeDisabled(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx index d4108c3eddede..9eefb96be62c9 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx @@ -157,6 +157,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ selectedRulesToAddTo, exceptionListsToAddTo, newComment, + commentErrorExists, itemConditionValidationErrorExists, errorSubmitting, expireTime, @@ -267,6 +268,16 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ [dispatch] ); + const setCommentError = useCallback( + (errorExists: boolean): void => { + dispatch({ + type: 'setCommentError', + errorExists, + }); + }, + [dispatch] + ); + const setBulkCloseIndex = useCallback( (index: string[] | undefined): void => { dispatch({ @@ -445,6 +456,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ exceptionItemName.trim() === '' || exceptionItems.every((item) => item.entries.length === 0) || itemConditionValidationErrorExists || + commentErrorExists || expireErrorExists || (addExceptionToRadioSelection === 'add_to_lists' && isEmpty(exceptionListsToAddTo)) || (addExceptionToRadioSelection === 'select_rules_to_add_to' && @@ -462,6 +474,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ expireErrorExists, selectedRulesToAddTo, listType, + commentErrorExists, ] ); @@ -555,6 +568,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ initialIsOpen={!!newComment} newCommentValue={newComment} newCommentOnChange={setComment} + setCommentError={setCommentError} /> {listType !== ExceptionListTypeEnum.ENDPOINT && ( <> diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts index 04d13c3a1b4e9..ec8040d1fe7cc 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts @@ -21,6 +21,7 @@ export interface State { initialItems: ExceptionsBuilderExceptionItem[]; exceptionItems: ExceptionsBuilderReturnExceptionItem[]; newComment: string; + commentErrorExists: boolean; addExceptionToRadioSelection: string; itemConditionValidationErrorExists: boolean; closeSingleAlert: boolean; @@ -40,6 +41,7 @@ export const initialState: State = { exceptionItems: [], exceptionItemMeta: { name: '' }, newComment: '', + commentErrorExists: false, itemConditionValidationErrorExists: false, closeSingleAlert: false, bulkCloseAlerts: false, @@ -76,6 +78,10 @@ export type Action = type: 'setComment'; comment: string; } + | { + type: 'setCommentError'; + errorExists: boolean; + } | { type: 'setCloseSingleAlert'; close: boolean; @@ -127,6 +133,7 @@ export type Action = export const createExceptionItemsReducer = () => + /* eslint complexity: ["error", 21]*/ (state: State, action: Action): State => { switch (action.type) { case 'setExceptionItemMeta': { @@ -172,6 +179,14 @@ export const createExceptionItemsReducer = newComment: comment, }; } + case 'setCommentError': { + const { errorExists } = action; + + return { + ...state, + commentErrorExists: errorExists, + }; + } case 'setCloseSingleAlert': { const { close } = action; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/constants.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/constants.ts new file mode 100644 index 0000000000000..4fea306862d34 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/constants.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export const MAX_COMMENT_LENGTH = 30000 as const; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.test.tsx index faa7c1385142c..ec50eda940cae 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { waitFor } from '@testing-library/react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { ThemeProvider } from 'styled-components'; import type { ReactWrapper } from 'enzyme'; import { mount } from 'enzyme'; @@ -34,6 +34,7 @@ import { useFetchIndexPatterns } from '../../logic/use_exception_flyout_data'; import { useCreateOrUpdateException } from '../../logic/use_create_update_exception'; import { useFindExceptionListReferences } from '../../logic/use_find_references'; import * as i18n from './translations'; +import { MAX_COMMENT_LENGTH } from '../constants'; const mockTheme = getMockTheme({ eui: { @@ -693,5 +694,60 @@ describe('When the edit exception modal is opened', () => { wrapper.find('button[data-test-subj="editExceptionConfirmButton"]').getDOMNode() ).toBeDisabled(); }); + + test('when there is a comment error has submit button disabled', async () => { + const { getByLabelText, queryByText, getByTestId } = render( + + + + ); + + const commentInput = getByLabelText('Comment Input'); + + const commentErrorMessage = `The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH} characters.`; + expect(queryByText(commentErrorMessage)).toBeNull(); + + // Put comment with the length above maximum allowed + act(() => { + fireEvent.change(commentInput, { + target: { + value: [...new Array(MAX_COMMENT_LENGTH + 1).keys()].map((_) => 'a').join(''), + }, + }); + fireEvent.blur(commentInput); + }); + expect(queryByText(commentErrorMessage)).not.toBeNull(); + expect(getByTestId('editExceptionConfirmButton')).toBeDisabled(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx index 4d9e7c3bbc4ef..6d2526cdbf239 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx @@ -117,6 +117,7 @@ const EditExceptionFlyoutComponent: React.FC = ({ exceptionItems, exceptionItemMeta: { name: exceptionItemName }, newComment, + commentErrorExists, bulkCloseAlerts, disableBulkClose, bulkCloseIndex, @@ -129,6 +130,7 @@ const EditExceptionFlyoutComponent: React.FC = ({ exceptionItems: [itemToEdit], exceptionItemMeta: { name: itemToEdit.name }, newComment: '', + commentErrorExists: false, bulkCloseAlerts: false, disableBulkClose: true, bulkCloseIndex: undefined, @@ -197,6 +199,16 @@ const EditExceptionFlyoutComponent: React.FC = ({ [dispatch] ); + const setCommentError = useCallback( + (errorExists: boolean): void => { + dispatch({ + type: 'setCommentError', + errorExists, + }); + }, + [dispatch] + ); + const setBulkCloseAlerts = useCallback( (bulkClose: boolean): void => { dispatch({ @@ -337,8 +349,17 @@ const EditExceptionFlyoutComponent: React.FC = ({ exceptionItems.every((item) => item.entries.length === 0) || isLoading || entryErrorExists || + expireErrorExists || + commentErrorExists, + [ + isLoading, + entryErrorExists, + exceptionItems, + isSubmitting, + isClosingAlerts, expireErrorExists, - [isLoading, entryErrorExists, exceptionItems, isSubmitting, isClosingAlerts, expireErrorExists] + commentErrorExists, + ] ); return ( @@ -398,6 +419,7 @@ const EditExceptionFlyoutComponent: React.FC = ({ exceptionItemComments={itemToEdit.comments} newCommentValue={newComment} newCommentOnChange={setComment} + setCommentError={setCommentError} /> {listType !== ExceptionListTypeEnum.ENDPOINT && ( <> diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts index e08b3c8d135c0..e6dee3af16572 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts @@ -12,6 +12,7 @@ export interface State { exceptionItems: ExceptionsBuilderReturnExceptionItem[]; exceptionItemMeta: { name: string }; newComment: string; + commentErrorExists: boolean; bulkCloseAlerts: boolean; disableBulkClose: boolean; bulkCloseIndex: string[] | undefined; @@ -29,6 +30,10 @@ export type Action = type: 'setComment'; comment: string; } + | { + type: 'setCommentError'; + errorExists: boolean; + } | { type: 'setBulkCloseAlerts'; bulkClose: boolean; @@ -81,6 +86,14 @@ export const createExceptionItemsReducer = newComment: comment, }; } + case 'setCommentError': { + const { errorExists } = action; + + return { + ...state, + commentErrorExists: errorExists, + }; + } case 'setBulkCloseAlerts': { const { bulkClose } = action; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx index 47933db0b3522..60c0d3f244217 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { EuiTextArea } from '@elastic/eui'; @@ -13,6 +14,7 @@ import { ExceptionItemComments } from '.'; import { TestProviders } from '../../../../common/mock'; import { useCurrentUser } from '../../../../common/lib/kibana'; import { shallow } from 'enzyme'; +import { MAX_COMMENT_LENGTH } from '../constants'; jest.mock('../../../../common/lib/kibana'); @@ -38,6 +40,7 @@ describe('ExceptionItemComments', () => { ); @@ -65,6 +68,7 @@ describe('ExceptionItemComments', () => { ); @@ -92,6 +96,7 @@ describe('ExceptionItemComments', () => { ); @@ -106,6 +111,7 @@ describe('ExceptionItemComments', () => { ); @@ -122,6 +128,7 @@ describe('ExceptionItemComments', () => { ); @@ -152,10 +159,53 @@ describe('ExceptionItemComments', () => { ]} newCommentValue={''} newCommentOnChange={mockOnCommentChange} + setCommentError={jest.fn()} /> ); expect(wrapper.find('[data-test-subj="exceptionItemCommentsAccordion"]').exists()).toBeTruthy(); }); + + it('it calls setCommentError on comment error update change', async () => { + const mockSetCommentError = jest.fn(); + const { getByLabelText, queryByText } = render( + + + + ); + + const commentInput = getByLabelText('Comment Input'); + + const commentErrorMessage = `The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH} characters.`; + expect(queryByText(commentErrorMessage)).toBeNull(); + + // Put comment with the length above maximum allowed + act(() => { + fireEvent.change(commentInput, { + target: { + value: [...new Array(MAX_COMMENT_LENGTH + 1).keys()].map((_) => 'a').join(''), + }, + }); + fireEvent.blur(commentInput); + }); + expect(queryByText(commentErrorMessage)).not.toBeNull(); + expect(mockSetCommentError).toHaveBeenCalledWith(true); + + // Put comment with the allowed length + act(() => { + fireEvent.change(commentInput, { + target: { + value: 'Updating my new comment', + }, + }); + fireEvent.blur(commentInput); + }); + expect(queryByText(commentErrorMessage)).toBeNull(); + expect(mockSetCommentError).toHaveBeenCalledWith(false); + }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx index 0f32e2b4d1ab8..6291097d06941 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx @@ -5,13 +5,14 @@ * 2.0. */ -import React, { memo, useState, useCallback, useMemo } from 'react'; +import React, { memo, useState, useCallback, useMemo, useEffect } from 'react'; import styled, { css } from 'styled-components'; import type { EuiCommentProps } from '@elastic/eui'; import { EuiTextArea, EuiFlexGroup, EuiFlexItem, + EuiFormRow, EuiAvatar, EuiAccordion, EuiCommentList, @@ -21,6 +22,7 @@ import type { Comment } from '@kbn/securitysolution-io-ts-list-types'; import * as i18n from './translations'; import { useCurrentUser } from '../../../../common/lib/kibana'; import { getFormattedComments } from '../../utils/helpers'; +import { MAX_COMMENT_LENGTH } from '../constants'; interface ExceptionItemCommentsProps { exceptionItemComments?: Comment[]; @@ -28,6 +30,7 @@ interface ExceptionItemCommentsProps { accordionTitle?: JSX.Element; initialIsOpen?: boolean; newCommentOnChange: (value: string) => void; + setCommentError: (errorExists: boolean) => void; } const COMMENT_ACCORDION_BUTTON_CLASS_NAME = 'exceptionCommentAccordionButton'; @@ -53,8 +56,11 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({ accordionTitle, initialIsOpen = false, newCommentOnChange, + setCommentError, }: ExceptionItemCommentsProps) { + const [errorExists, setErrorExists] = useState(false); const [shouldShowComments, setShouldShowComments] = useState(false); + const currentUser = useCurrentUser(); const fullName = currentUser?.fullName; const userName = currentUser?.username; @@ -73,9 +79,14 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({ return userName && userName.length > 0 ? userName : i18n.UNKNOWN_AVATAR_NAME; }, [fullName, userEmail, userName]); + useEffect(() => { + setCommentError(errorExists); + }, [errorExists, setCommentError]); + const handleOnChange = useCallback( (event: React.ChangeEvent) => { newCommentOnChange(event.target.value); + setErrorExists(event.target.value.length > MAX_COMMENT_LENGTH); }, [newCommentOnChange] ); @@ -121,14 +132,20 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({ - + + + diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/translations.ts index afe20c6aada98..90c3d9bd0bc48 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/translations.ts @@ -32,3 +32,10 @@ export const COMMENTS_HIDE = (comments: number) => values: { comments }, defaultMessage: 'Hide ({comments}) {comments, plural, =1 {Comment} other {Comments}}', }); + +export const COMMENT_MAX_LENGTH_ERROR = (length: number) => + i18n.translate('xpack.securitySolution.rule_exceptions.itemComments.maxLengthError', { + values: { length }, + defaultMessage: + 'The length of the comment is too long. The maximum length is {length} characters.', + }); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx index be028512cb0a8..380f709ed25f2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.test.tsx @@ -25,6 +25,7 @@ import { OperatingSystem } from '@kbn/securitysolution-utils'; import { EventFiltersForm } from './form'; import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data'; import type { PolicyData } from '../../../../../../common/endpoint/types'; +import { MAX_COMMENT_LENGTH } from '../../../../../detection_engine/rule_exceptions/components/constants'; jest.mock('../../../../../common/lib/kibana'); jest.mock('../../../../../common/containers/source'); @@ -466,4 +467,35 @@ describe('Event filter form', () => { expect(renderResult.findByTestId('duplicate-fields-warning-message')).not.toBeNull(); }); }); + + describe('Errors', () => { + beforeEach(() => { + render(); + }); + + it('should not show warning text when unique fields are added', async () => { + rerender(); + + const commentInput = renderResult.getByLabelText('Comment Input'); + + expect( + renderResult.queryByText( + `The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH} characters.` + ) + ).toBeNull(); + act(() => { + fireEvent.change(commentInput, { + target: { + value: [...new Array(MAX_COMMENT_LENGTH + 1).keys()].map((_) => 'a').join(''), + }, + }); + fireEvent.blur(commentInput); + }); + expect( + renderResult.queryByText( + `The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH} characters.` + ) + ).not.toBeNull(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx index 3b4ff4e394a82..e4e1fa7e14638 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form.tsx @@ -133,6 +133,7 @@ export const EventFiltersForm: React.FC(!exception.name); const [newComment, setNewComment] = useState(''); + const [hasCommentError, setHasCommentError] = useState(false); const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false); const [selectedPolicies, setSelectedPolicies] = useState([]); const isPlatinumPlus = useLicense().isPlatinumPlus(); @@ -173,10 +174,11 @@ export const EventFiltersForm: React.FC e.value !== '' || e.value.length) ); - }, [hasNameError, exception.entries]); + }, [hasCommentError, hasNameError, exception.entries]); const processChanged = useCallback( (updatedItem?: Partial) => { @@ -340,6 +342,7 @@ export const EventFiltersForm: React.FC ), [existingComments, handleOnChangeComment, newComment]