[Detections] Truncate case title in toaster when attaching an alert to case (#103228)

This commit is contained in:
Christos Nasikas 2021-07-01 19:02:11 +03:00 committed by GitHub
parent b8747bde68
commit 644d2ce918
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 286 additions and 50 deletions

View file

@ -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;

View file

@ -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}.',
});

View file

@ -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<Case>
@ -145,10 +146,10 @@ export const useCasesColumns = ({
subCaseId={isSubCase(theCase) ? theCase.id : undefined}
title={theCase.title}
>
{theCase.title}
<TruncatedText text={theCase.title} />
</CaseDetailsLink>
) : (
<span>{theCase.title}</span>
<TruncatedText text={theCase.title} />
);
return theCase.status !== CaseStatuses.closed ? (
caseDetailsLinkComponent

View file

@ -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(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseForm {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
</TestProviders>
);
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,

View file

@ -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<FormProps> = {
{
validator: emptyField(i18n.TITLE_REQUIRED),
},
{
validator: maxLengthField({
length: MAX_TITLE_LENGTH,
message: i18n.MAX_LENGTH_ERROR('title', MAX_TITLE_LENGTH),
}),
},
],
},
description: {

View file

@ -7,7 +7,9 @@ exports[`Title it renders 1`] = `
<h1
data-test-subj="header-page-title"
>
Test title
<Memo(TruncatedTextComponent)
text="Test title"
/>
<StyledEuiBetaBadge
label="Beta"
@ -17,3 +19,17 @@ exports[`Title it renders 1`] = `
</h1>
</EuiTitle>
`;
exports[`Title it renders the title if is not a string 1`] = `
<EuiTitle
size="l"
>
<h1
data-test-subj="header-page-title"
>
<span>
Test title
</span>
</h1>
</EuiTitle>
`;

View file

@ -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(
<TestProviders>
<EditableTitle {...defaultProps} />
</TestProviders>
);
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
);
});
});

View file

@ -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<EditableTitleProps> = ({
title,
}) => {
const [editMode, setEditMode] = useState(false);
const [changedTitle, onTitleChange] = useState<string>(typeof title === 'string' ? title : '');
const [errors, setErrors] = useState<string[]>([]);
const [newTitle, setNewTitle] = useState<string>(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<HTMLInputElement>) => onTitleChange(e.target.value),
(e: ChangeEvent<HTMLInputElement>) => setNewTitle(e.target.value),
[]
);
const hasErrors = errors.length > 0;
return editMode ? (
<EuiFlexGroup alignItems="center" gutterSize="m" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiFieldText
onChange={handleOnChange}
value={`${changedTitle}`}
data-test-subj="editable-title-input-field"
/>
</EuiFlexItem>
<EuiFlexGroup gutterSize="none" responsive={false} wrap={true}>
<EuiFormRow isInvalid={hasErrors} error={errors} fullWidth>
<EuiFlexGroup alignItems="center" gutterSize="m" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButton
color="secondary"
data-test-subj="editable-title-submit-btn"
fill
iconType="save"
onClick={onClickSubmit}
size="s"
>
{i18n.SAVE}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="editable-title-cancel-btn"
iconType="cross"
onClick={onCancel}
size="s"
>
{i18n.CANCEL}
</EuiButtonEmpty>
<EuiFieldText
onChange={handleOnChange}
value={`${newTitle}`}
data-test-subj="editable-title-input-field"
/>
</EuiFlexItem>
<EuiFlexGroup gutterSize="none" responsive={false} wrap={true}>
<EuiFlexItem grow={false}>
<EuiButton
color="secondary"
data-test-subj="editable-title-submit-btn"
fill
iconType="save"
onClick={onClickSubmit}
size="s"
>
{i18n.SAVE}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="editable-title-cancel-btn"
iconType="cross"
onClick={onCancel}
size="s"
>
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem />
</EuiFlexGroup>
<EuiFlexItem />
</EuiFlexGroup>
</EuiFormRow>
) : (
<EuiFlexGroup alignItems="center" gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>

View file

@ -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(<Title title={<span>{'Test title'}</span>} />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -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 && (
<>
{' '}

View file

@ -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',
});

View file

@ -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>

View file

@ -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);

View file

@ -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();

View file

@ -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({

View file

@ -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>
`);
});
});
});

View file

@ -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)}</Title>,
text: (
<ToasterContent
caseId={theCase.id}

View file

@ -500,6 +500,26 @@ export default ({ getService }: FtrProviderContext): void => {
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', () => {

View file

@ -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', () => {