From 644d2ce9189e120554001f94020972629f2f1593 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 1 Jul 2021 19:02:11 +0300 Subject: [PATCH] [Detections] Truncate case title in toaster when attaching an alert to case (#103228) --- x-pack/plugins/cases/common/constants.ts | 6 ++ .../cases/public/common/translations.ts | 6 ++ .../public/components/all_cases/columns.tsx | 5 +- .../components/create/form_context.test.tsx | 30 ++++++ .../cases/public/components/create/schema.tsx | 10 +- .../__snapshots__/title.test.tsx.snap | 18 +++- .../header_page/editable_title.test.tsx | 29 ++++++ .../components/header_page/editable_title.tsx | 96 +++++++++++-------- .../components/header_page/title.test.tsx | 6 ++ .../public/components/header_page/title.tsx | 4 +- .../components/header_page/translations.ts | 2 + .../components/recent_cases/recent_cases.tsx | 3 +- .../components/truncated_text/index.tsx | 29 ++++++ .../cases/server/client/cases/create.ts | 7 ++ .../cases/server/client/cases/update.ts | 20 ++++ .../timeline_actions/helpers.test.tsx | 25 ++++- .../components/timeline_actions/helpers.tsx | 13 ++- .../tests/common/cases/patch_cases.ts | 20 ++++ .../tests/common/cases/post_case.ts | 7 ++ 19 files changed, 286 insertions(+), 50 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/truncated_text/index.tsx diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 5d7ee47bb8ea..fb3a0475d627 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -94,3 +94,9 @@ if (ENABLE_CASE_CONNECTOR) { export const MAX_DOCS_PER_PAGE = 10000; export const MAX_CONCURRENT_SEARCHES = 10; + +/** + * Validation + */ + +export const MAX_TITLE_LENGTH = 64; diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 020d301c8e30..c81ec1c25d84 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -228,3 +228,9 @@ export const SELECTABLE_MESSAGE_COLLECTIONS = i18n.translate( export const SELECT_CASE_TITLE = i18n.translate('xpack.cases.common.allCases.caseModal.title', { defaultMessage: 'Select case', }); + +export const MAX_LENGTH_ERROR = (field: string, length: number) => + i18n.translate('xpack.cases.createCase.maxLengthError', { + values: { field, length }, + defaultMessage: 'The length of the {field} is too long. The maximum length is {length}.', + }); diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index ad4447223837..140dbf2f53c2 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -34,6 +34,7 @@ import { useDeleteCases } from '../../containers/use_delete_cases'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; import { useKibana } from '../../common/lib/kibana'; import { StatusContextMenu } from '../case_action_bar/status_context_menu'; +import { TruncatedText } from '../truncated_text'; export type CasesColumns = | EuiTableActionsColumnType @@ -145,10 +146,10 @@ export const useCasesColumns = ({ subCaseId={isSubCase(theCase) ? theCase.id : undefined} title={theCase.title} > - {theCase.title} + ) : ( - {theCase.title} + ); return theCase.status !== CaseStatuses.closed ? ( caseDetailsLinkComponent diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index e083f11ced77..0ddab55c621d 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -183,6 +183,36 @@ describe('Create case', () => { await waitFor(() => expect(postCase).toBeCalledWith(sampleData)); }); + it('it does not submits the title when the length is longer than 64 characters', async () => { + const longTitle = + 'This is a title that should not be saved as it is longer than 64 characters.'; + + const wrapper = mount( + + + + + + + ); + + act(() => { + wrapper + .find(`[data-test-subj="caseTitle"] input`) + .first() + .simulate('change', { target: { value: longTitle } }); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + }); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find('[data-test-subj="caseTitle"] .euiFormErrorText').text()).toBe( + 'The length of the title is too long. The maximum length is 64.' + ); + }); + expect(postCase).not.toHaveBeenCalled(); + }); + it('should toggle sync settings', async () => { useConnectorsMock.mockReturnValue({ ...sampleConnectorData, diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index bea1a46d9376..41709a74d2fa 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -5,12 +5,12 @@ * 2.0. */ -import { CasePostRequest, ConnectorTypeFields } from '../../../common'; +import { CasePostRequest, ConnectorTypeFields, MAX_TITLE_LENGTH } from '../../../common'; import { FIELD_TYPES, fieldValidators, FormSchema } from '../../common/shared_imports'; import * as i18n from './translations'; import { OptionalFieldLabel } from './optional_field_label'; -const { emptyField } = fieldValidators; +const { emptyField, maxLengthField } = fieldValidators; export const schemaTags = { type: FIELD_TYPES.COMBO_BOX, @@ -33,6 +33,12 @@ export const schema: FormSchema = { { validator: emptyField(i18n.TITLE_REQUIRED), }, + { + validator: maxLengthField({ + length: MAX_TITLE_LENGTH, + message: i18n.MAX_LENGTH_ERROR('title', MAX_TITLE_LENGTH), + }), + }, ], }, description: { diff --git a/x-pack/plugins/cases/public/components/header_page/__snapshots__/title.test.tsx.snap b/x-pack/plugins/cases/public/components/header_page/__snapshots__/title.test.tsx.snap index 05af2fee2c2a..9ff9b0616c57 100644 --- a/x-pack/plugins/cases/public/components/header_page/__snapshots__/title.test.tsx.snap +++ b/x-pack/plugins/cases/public/components/header_page/__snapshots__/title.test.tsx.snap @@ -7,7 +7,9 @@ exports[`Title it renders 1`] = `

- Test title + `; + +exports[`Title it renders the title if is not a string 1`] = ` + +

+ + Test title + +

+
+`; diff --git a/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx b/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx index babfeb584677..19aea39f1f79 100644 --- a/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx +++ b/x-pack/plugins/cases/public/components/header_page/editable_title.test.tsx @@ -187,4 +187,33 @@ describe('EditableTitle', () => { expect(submitTitle.mock.calls[0][0]).toEqual(newTitle); expect(wrapper.find('[data-test-subj="editable-title-edit-icon"]').first().exists()).toBe(true); }); + + test('it does not submits the title when the length is longer than 64 characters', () => { + const longTitle = + 'This is a title that should not be saved as it is longer than 64 characters.'; + + const wrapper = mount( + + + + ); + + wrapper.find('button[data-test-subj="editable-title-edit-icon"]').simulate('click'); + wrapper.update(); + + wrapper + .find('input[data-test-subj="editable-title-input-field"]') + .simulate('change', { target: { value: longTitle } }); + + wrapper.find('button[data-test-subj="editable-title-submit-btn"]').simulate('click'); + wrapper.update(); + expect(wrapper.find('.euiFormErrorText').text()).toBe( + 'The length of the title is too long. The maximum length is 64.' + ); + + expect(submitTitle).not.toHaveBeenCalled(); + expect(wrapper.find('[data-test-subj="editable-title-edit-icon"]').first().exists()).toBe( + false + ); + }); }); diff --git a/x-pack/plugins/cases/public/components/header_page/editable_title.tsx b/x-pack/plugins/cases/public/components/header_page/editable_title.tsx index 7856a7733227..4dcfa9ad98fd 100644 --- a/x-pack/plugins/cases/public/components/header_page/editable_title.tsx +++ b/x-pack/plugins/cases/public/components/header_page/editable_title.tsx @@ -16,10 +16,11 @@ import { EuiFieldText, EuiButtonIcon, EuiLoadingSpinner, + EuiFormRow, } from '@elastic/eui'; +import { MAX_TITLE_LENGTH } from '../../../common'; import * as i18n from './translations'; - import { Title } from './title'; const MyEuiButtonIcon = styled(EuiButtonIcon)` @@ -37,7 +38,7 @@ const MySpinner = styled(EuiLoadingSpinner)` export interface EditableTitleProps { userCanCrud: boolean; isLoading: boolean; - title: string | React.ReactNode; + title: string; onSubmit: (title: string) => void; } @@ -48,57 +49,72 @@ const EditableTitleComponent: React.FC = ({ title, }) => { const [editMode, setEditMode] = useState(false); - const [changedTitle, onTitleChange] = useState(typeof title === 'string' ? title : ''); + const [errors, setErrors] = useState([]); + const [newTitle, setNewTitle] = useState(title); + + const onCancel = useCallback(() => { + setEditMode(false); + setErrors([]); + setNewTitle(title); + }, [title]); - const onCancel = useCallback(() => setEditMode(false), []); const onClickEditIcon = useCallback(() => setEditMode(true), []); - const onClickSubmit = useCallback((): void => { - if (changedTitle !== title) { - onSubmit(changedTitle); + if (newTitle.length > MAX_TITLE_LENGTH) { + setErrors([i18n.MAX_LENGTH_ERROR('title', MAX_TITLE_LENGTH)]); + return; + } + + if (newTitle !== title) { + onSubmit(newTitle); } setEditMode(false); - }, [changedTitle, onSubmit, title]); + }, [newTitle, onSubmit, title]); const handleOnChange = useCallback( - (e: ChangeEvent) => onTitleChange(e.target.value), + (e: ChangeEvent) => setNewTitle(e.target.value), [] ); + + const hasErrors = errors.length > 0; + return editMode ? ( - - - - - + + - - {i18n.SAVE} - - - - - {i18n.CANCEL} - + + + + + {i18n.SAVE} + + + + + {i18n.CANCEL} + + + + - - + ) : ( diff --git a/x-pack/plugins/cases/public/components/header_page/title.test.tsx b/x-pack/plugins/cases/public/components/header_page/title.test.tsx index 2423104eb881..063b21e4d890 100644 --- a/x-pack/plugins/cases/public/components/header_page/title.test.tsx +++ b/x-pack/plugins/cases/public/components/header_page/title.test.tsx @@ -36,4 +36,10 @@ describe('Title', () => { expect(wrapper.find('[data-test-subj="header-page-title"]').first().exists()).toBe(true); }); + + test('it renders the title if is not a string', () => { + const wrapper = shallow({'Test title'}</span>} />); + + expect(wrapper).toMatchSnapshot(); + }); }); diff --git a/x-pack/plugins/cases/public/components/header_page/title.tsx b/x-pack/plugins/cases/public/components/header_page/title.tsx index 3a0390a436e1..629aa612610e 100644 --- a/x-pack/plugins/cases/public/components/header_page/title.tsx +++ b/x-pack/plugins/cases/public/components/header_page/title.tsx @@ -6,10 +6,12 @@ */ import React from 'react'; +import { isString } from 'lodash'; import { EuiBetaBadge, EuiBadge, EuiTitle } from '@elastic/eui'; import styled from 'styled-components'; import { BadgeOptions, TitleProp } from './types'; +import { TruncatedText } from '../truncated_text'; const StyledEuiBetaBadge = styled(EuiBetaBadge)` vertical-align: middle; @@ -30,7 +32,7 @@ interface Props { const TitleComponent: React.FC<Props> = ({ title, badgeOptions }) => ( <EuiTitle size="l"> <h1 data-test-subj="header-page-title"> - {title} + {isString(title) ? <TruncatedText text={title} /> : title} {badgeOptions && ( <> {' '} diff --git a/x-pack/plugins/cases/public/components/header_page/translations.ts b/x-pack/plugins/cases/public/components/header_page/translations.ts index b24c347857a6..ba987d1f45f1 100644 --- a/x-pack/plugins/cases/public/components/header_page/translations.ts +++ b/x-pack/plugins/cases/public/components/header_page/translations.ts @@ -7,6 +7,8 @@ import { i18n } from '@kbn/i18n'; +export * from '../../common/translations'; + export const SAVE = i18n.translate('xpack.cases.header.editableTitle.save', { defaultMessage: 'Save', }); diff --git a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx index bfe44dda6c6e..e08c62991325 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx @@ -19,6 +19,7 @@ import { NoCases } from './no_cases'; import { isSubCase } from '../all_cases/helpers'; import { MarkdownRenderer } from '../markdown_editor'; import { FilterOptions } from '../../containers/types'; +import { TruncatedText } from '../truncated_text'; const MarkdownContainer = styled.div` max-height: 150px; @@ -80,7 +81,7 @@ export const RecentCasesComp = ({ title={c.title} subCaseId={isSubCase(c) ? c.id : undefined} > - {c.title} + <TruncatedText text={c.title} /> </CaseDetailsLink> </EuiText> diff --git a/x-pack/plugins/cases/public/components/truncated_text/index.tsx b/x-pack/plugins/cases/public/components/truncated_text/index.tsx new file mode 100644 index 000000000000..8a480ed9dbdd --- /dev/null +++ b/x-pack/plugins/cases/public/components/truncated_text/index.tsx @@ -0,0 +1,29 @@ +/* + * 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 styled from 'styled-components'; + +const LINE_CLAMP = 3; + +const Text = styled.span` + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: ${LINE_CLAMP}; + -webkit-box-orient: vertical; + overflow: hidden; +`; + +interface Props { + text: string; +} + +const TruncatedTextComponent: React.FC<Props> = ({ text }) => { + return <Text title={text}>{text}</Text>; +}; + +export const TruncatedText = React.memo(TruncatedTextComponent); diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index 0eebeb343e81..03ea76ede5c2 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -22,6 +22,7 @@ import { CaseType, OWNER_FIELD, ENABLE_CASE_CONNECTOR, + MAX_TITLE_LENGTH, } from '../../../common'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { getConnectorFromConfiguration } from '../utils'; @@ -72,6 +73,12 @@ export const create = async ( fold(throwErrors(Boom.badRequest), identity) ); + if (query.title.length > MAX_TITLE_LENGTH) { + throw Boom.badRequest( + `The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}.` + ); + } + try { const savedObjectID = SavedObjectsUtils.generateId(); diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index e5d9e1cddeee..afe43171563c 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -40,6 +40,7 @@ import { MAX_CONCURRENT_SEARCHES, SUB_CASE_SAVED_OBJECT, throwErrors, + MAX_TITLE_LENGTH, } from '../../../common'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { getCaseToUpdate } from '../utils'; @@ -181,6 +182,24 @@ async function throwIfInvalidUpdateOfTypeWithAlerts({ } } +/** + * Throws an error if any of the requests updates a title and the length is over MAX_TITLE_LENGTH. + */ +function throwIfTitleIsInvalid(requests: ESCasePatchRequest[]) { + const requestsInvalidTitle = requests.filter( + (req) => req.title !== undefined && req.title.length > MAX_TITLE_LENGTH + ); + + if (requestsInvalidTitle.length > 0) { + const ids = requestsInvalidTitle.map((req) => req.id); + throw Boom.badRequest( + `The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}, ids: [${ids.join( + ', ' + )}]` + ); + } +} + /** * Get the id from a reference in a comment for a specific type. */ @@ -477,6 +496,7 @@ export const update = async ( } throwIfUpdateOwner(updateFilterCases); + throwIfTitleIsInvalid(updateFilterCases); throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); await throwIfInvalidUpdateOfTypeWithAlerts({ diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx index 9722447b96ad..3e0aa17a3830 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx @@ -5,6 +5,9 @@ * 2.0. */ +import React from 'react'; +import { mount } from 'enzyme'; +import 'jest-styled-components'; import { createUpdateSuccessToaster } from './helpers'; import { Case } from '../../../../../cases/common'; @@ -23,12 +26,30 @@ describe('helpers', () => { it('creates the correct toast when the sync alerts is on', () => { // We remove the id as is randomly generated and the text as it is a React component // which is being test on toaster_content.test.tsx - const { id, text, ...toast } = createUpdateSuccessToaster(theCase, onViewCaseClick); + const { id, text, title, ...toast } = createUpdateSuccessToaster(theCase, onViewCaseClick); + const mountedTitle = mount(<>{title}</>); + expect(toast).toEqual({ color: 'success', iconType: 'check', - title: 'An alert has been added to "My case"', }); + expect(mountedTitle).toMatchInlineSnapshot(` + .c0 { + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + + <styled.span> + <span + className="c0" + > + An alert has been added to "My case" + </span> + </styled.span> + `); }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx index 8682b6680830..93e1f0499893 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx @@ -7,11 +7,22 @@ import React from 'react'; import uuid from 'uuid'; +import styled from 'styled-components'; import { AppToast } from '../../../common/components/toasters'; import { ToasterContent } from './toaster_content'; import * as i18n from './translations'; import { Case } from '../../../../../cases/common'; +const LINE_CLAMP = 3; + +const Title = styled.span` + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: ${LINE_CLAMP}; + -webkit-box-orient: vertical; + overflow: hidden; +`; + export const createUpdateSuccessToaster = ( theCase: Case, onViewCaseClick: (id: string) => void @@ -20,7 +31,7 @@ export const createUpdateSuccessToaster = ( id: uuid.v4(), color: 'success', iconType: 'check', - title: i18n.CASE_CREATED_SUCCESS_TOAST(theCase.title), + title: <Title>{i18n.CASE_CREATED_SUCCESS_TOAST(theCase.title)}, text: ( { expectedHttpCode: 400, }); }); + + it('400s if the title is too long', async () => { + const longTitle = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nulla enim, rutrum sit amet euismod venenatis, blandit et massa. Nulla id consectetur enim.'; + + const postedCase = await createCase(supertest, postCaseReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: longTitle, + }, + ], + }, + expectedHttpCode: 400, + }); + }); }); describe('alerts', () => { diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts index e8337fa9db50..2fe5a4c0165c 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts @@ -238,6 +238,13 @@ export default ({ getService }: FtrProviderContext): void => { .send({ ...req, status: CaseStatuses.open }) .expect(400); }); + + it('400s if the title is too long', async () => { + const longTitle = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nulla enim, rutrum sit amet euismod venenatis, blandit et massa. Nulla id consectetur enim.'; + + await createCase(supertest, getPostCaseRequest({ title: longTitle }), 400); + }); }); describe('rbac', () => {