Skip to content

Commit

Permalink
[Cases] UI validation for total number of comment characters (#161357)
Browse files Browse the repository at this point in the history
## Summary

This PR adds UI validation for comments maximum length. 
It shows error message and disables save button when the comment exceeds
30k characters while

- **Adding a new comment**


![image](https://github.com/elastic/kibana/assets/117571355/42cafdfc-6e88-4bf9-ab93-9fb61de6eb78)

- **Updating an existing comment**


![image](https://github.com/elastic/kibana/assets/117571355/1d8408d1-c1cd-404c-b1ba-f4ecb94c4225)


### Checklist

Delete any items that are not applicable to this PR.
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
js-jankisalvi and kibanamachine authored Jul 10, 2023
1 parent ac8d73a commit d8c8b7b
Show file tree
Hide file tree
Showing 19 changed files with 329 additions and 145 deletions.
3 changes: 2 additions & 1 deletion x-pack/plugins/cases/public/common/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ export const SELECT_CASE_TITLE = i18n.translate('xpack.cases.common.allCases.cas
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}.',
defaultMessage:
'The length of the {field} is too long. The maximum length is {length} characters.',
});

export const MAX_TAGS_ERROR = (length: number) =>
Expand Down
217 changes: 118 additions & 99 deletions x-pack/plugins/cases/public/components/add_comment/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
*/

import React from 'react';
import { mount } from 'enzyme';
import { waitFor, act, fireEvent } from '@testing-library/react';
import { waitFor, act, fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { noop } from 'lodash/fp';

import { noCreateCasesPermissions, TestProviders, createAppMockRenderer } from '../../common/mock';

import { CommentType } from '../../../common/api';
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import { SECURITY_SOLUTION_OWNER, MAX_COMMENT_LENGTH } from '../../../common/constants';
import { useCreateAttachments } from '../../containers/use_create_attachments';
import type { AddCommentProps, AddCommentRefObject } from '.';
import { AddComment } from '.';
Expand Down Expand Up @@ -52,31 +52,59 @@ const appId = 'testAppId';
const draftKey = `cases.${appId}.${addCommentProps.caseId}.${addCommentProps.id}.markdownEditor`;

describe('AddComment ', () => {
let appMockRender: AppMockRenderer;

beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
useCreateAttachmentsMock.mockImplementation(() => defaultResponse);
});

afterEach(() => {
sessionStorage.removeItem(draftKey);
});

it('should post comment on submit click', async () => {
const wrapper = mount(
<TestProviders>
<AddComment {...addCommentProps} />
it('renders correctly', () => {
appMockRender.render(<AddComment {...addCommentProps} />);

expect(screen.getByTestId('add-comment')).toBeInTheDocument();
});

it('should render spinner and disable submit when loading', () => {
useCreateAttachmentsMock.mockImplementation(() => ({
...defaultResponse,
isLoading: true,
}));
appMockRender.render(<AddComment {...{ ...addCommentProps, showLoading: true }} />);

expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
expect(screen.getByTestId('submit-comment')).toHaveAttribute('disabled');
});

it('should hide the component when the user does not have create permissions', () => {
useCreateAttachmentsMock.mockImplementation(() => ({
...defaultResponse,
isLoading: true,
}));

appMockRender.render(
<TestProviders permissions={noCreateCasesPermissions()}>
<AddComment {...{ ...addCommentProps }} />
</TestProviders>
);

wrapper
.find(`[data-test-subj="add-comment"] textarea`)
.first()
.simulate('change', { target: { value: sampleData.comment } });
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
});

it('should post comment on submit click', async () => {
appMockRender.render(<AddComment {...addCommentProps} />);

const markdown = screen.getByTestId('euiMarkdownEditorTextArea');

expect(wrapper.find(`[data-test-subj="add-comment"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="loading-spinner"]`).exists()).toBeFalsy();
userEvent.type(markdown, sampleData.comment);

userEvent.click(screen.getByTestId('submit-comment'));

wrapper.find(`button[data-test-subj="submit-comment"]`).first().simulate('click');
await waitFor(() => {
expect(onCommentSaving).toBeCalled();
expect(createAttachments).toBeCalledWith(
Expand All @@ -94,105 +122,49 @@ describe('AddComment ', () => {
});

await waitFor(() => {
expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe('');
expect(screen.getByTestId('euiMarkdownEditorTextArea')).toHaveTextContent('');
});
});

it('should render spinner and disable submit when loading', () => {
useCreateAttachmentsMock.mockImplementation(() => ({
...defaultResponse,
isLoading: true,
}));
const wrapper = mount(
<TestProviders>
<AddComment {...{ ...addCommentProps, showLoading: true }} />
</TestProviders>
);

expect(wrapper.find(`[data-test-subj="loading-spinner"]`).exists()).toBeTruthy();
expect(
wrapper.find(`[data-test-subj="submit-comment"]`).first().prop('isDisabled')
).toBeTruthy();
});

it('should disable submit button when isLoading is true', () => {
useCreateAttachmentsMock.mockImplementation(() => ({
...defaultResponse,
isLoading: true,
}));
const wrapper = mount(
<TestProviders>
<AddComment {...addCommentProps} />
</TestProviders>
);

expect(
wrapper.find(`[data-test-subj="submit-comment"]`).first().prop('isDisabled')
).toBeTruthy();
});

it('should hide the component when the user does not have create permissions', () => {
useCreateAttachmentsMock.mockImplementation(() => ({
...defaultResponse,
isLoading: true,
}));
const wrapper = mount(
<TestProviders permissions={noCreateCasesPermissions()}>
<AddComment {...{ ...addCommentProps }} />
</TestProviders>
);

expect(wrapper.find(`[data-test-subj="add-comment"]`).exists()).toBeFalsy();
});

it('should insert a quote', async () => {
const sampleQuote = 'what a cool quote \n with new lines';
const ref = React.createRef<AddCommentRefObject>();
const wrapper = mount(
<TestProviders>
<AddComment {...addCommentProps} ref={ref} />
</TestProviders>
);

wrapper
.find(`[data-test-subj="add-comment"] textarea`)
.first()
.simulate('change', { target: { value: sampleData.comment } });
appMockRender.render(<AddComment {...addCommentProps} ref={ref} />);

userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), sampleData.comment);

await act(async () => {
ref.current!.addQuote(sampleQuote);
});

expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe(
`${sampleData.comment}\n\n> what a cool quote \n> with new lines \n\n`
);
await waitFor(() => {
expect(screen.getByTestId('euiMarkdownEditorTextArea').textContent).toContain(
`${sampleData.comment}\n\n> what a cool quote \n> with new lines \n\n`
);
});
});

it('should call onFocus when adding a quote', async () => {
const ref = React.createRef<AddCommentRefObject>();

mount(
<TestProviders>
<AddComment {...addCommentProps} ref={ref} />
</TestProviders>
);
appMockRender.render(<AddComment {...addCommentProps} ref={ref} />);

ref.current!.editor!.textarea!.focus = jest.fn();

await act(async () => {
ref.current!.addQuote('a comment');
});

expect(ref.current!.editor!.textarea!.focus).toHaveBeenCalled();
await waitFor(() => {
expect(ref.current!.editor!.textarea!.focus).toHaveBeenCalled();
});
});

it('should NOT call onFocus on mount', async () => {
const ref = React.createRef<AddCommentRefObject>();

mount(
<TestProviders>
<AddComment {...addCommentProps} ref={ref} />
</TestProviders>
);
appMockRender.render(<AddComment {...addCommentProps} ref={ref} />);

ref.current!.editor!.textarea!.focus = jest.fn();
expect(ref.current!.editor!.textarea!.focus).not.toHaveBeenCalled();
Expand All @@ -208,20 +180,67 @@ describe('AddComment ', () => {
const mockTimelineIntegration = { ...timelineIntegrationMock };
mockTimelineIntegration.hooks.useInsertTimeline = useInsertTimelineMock;

const wrapper = mount(
<TestProviders>
<CasesTimelineIntegrationProvider timelineIntegration={mockTimelineIntegration}>
<AddComment {...addCommentProps} />
</CasesTimelineIntegrationProvider>
</TestProviders>
appMockRender.render(
<CasesTimelineIntegrationProvider timelineIntegration={mockTimelineIntegration}>
<AddComment {...addCommentProps} />
</CasesTimelineIntegrationProvider>
);

act(() => {
attachTimeline('[title](url)');
});

await waitFor(() => {
expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe('[title](url)');
expect(screen.getByTestId('euiMarkdownEditorTextArea')).toHaveTextContent('[title](url)');
});
});

describe('errors', () => {
it('shows an error when comment is empty', async () => {
appMockRender.render(<AddComment {...addCommentProps} />);

const markdown = screen.getByTestId('euiMarkdownEditorTextArea');

userEvent.type(markdown, 'test');
userEvent.clear(markdown);

await waitFor(() => {
expect(screen.getByText('Empty comments are not allowed.')).toBeInTheDocument();
expect(screen.getByTestId('submit-comment')).toHaveAttribute('disabled');
});
});

it('shows an error when comment is of empty characters', async () => {
appMockRender.render(<AddComment {...addCommentProps} />);

const markdown = screen.getByTestId('euiMarkdownEditorTextArea');

userEvent.clear(markdown);
userEvent.type(markdown, ' ');

await waitFor(() => {
expect(screen.getByText('Empty comments are not allowed.')).toBeInTheDocument();
expect(screen.getByTestId('submit-comment')).toHaveAttribute('disabled');
});
});

it('shows an error when comment is too long', async () => {
const longComment = 'a'.repeat(MAX_COMMENT_LENGTH + 1);

appMockRender.render(<AddComment {...addCommentProps} />);

const markdown = screen.getByTestId('euiMarkdownEditorTextArea');

userEvent.paste(markdown, longComment);

await waitFor(() => {
expect(
screen.getByText(
'The length of the comment is too long. The maximum length is 30000 characters.'
)
).toBeInTheDocument();
expect(screen.getByTestId('submit-comment')).toHaveAttribute('disabled');
});
});
});
});
Expand All @@ -247,9 +266,9 @@ describe('draft comment ', () => {
});

it('should clear session storage on submit', async () => {
const result = appMockRenderer.render(<AddComment {...addCommentProps} />);
appMockRenderer.render(<AddComment {...addCommentProps} />);

fireEvent.change(result.getByLabelText('caseComment'), {
fireEvent.change(screen.getByLabelText('caseComment'), {
target: { value: sampleData.comment },
});

Expand All @@ -258,10 +277,10 @@ describe('draft comment ', () => {
});

await waitFor(() => {
expect(result.getByLabelText('caseComment')).toHaveValue(sessionStorage.getItem(draftKey));
expect(screen.getByLabelText('caseComment')).toHaveValue(sessionStorage.getItem(draftKey));
});

fireEvent.click(result.getByTestId('submit-comment'));
fireEvent.click(screen.getByTestId('submit-comment'));

await waitFor(() => {
expect(onCommentSaving).toBeCalled();
Expand All @@ -280,7 +299,7 @@ describe('draft comment ', () => {
});

await waitFor(() => {
expect(result.getByLabelText('caseComment').textContent).toBe('');
expect(screen.getByLabelText('caseComment').textContent).toBe('');
expect(sessionStorage.getItem(draftKey)).toBe('');
});
});
Expand All @@ -295,9 +314,9 @@ describe('draft comment ', () => {
});

it('should have draft comment same as existing session storage', async () => {
const result = appMockRenderer.render(<AddComment {...addCommentProps} />);
appMockRenderer.render(<AddComment {...addCommentProps} />);

expect(result.getByLabelText('caseComment')).toHaveValue('value set in storage');
expect(screen.getByLabelText('caseComment')).toHaveValue('value set in storage');
});
});
});
6 changes: 5 additions & 1 deletion x-pack/plugins/cases/public/components/add_comment/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type { AddCommentFormSchema } from './schema';
import { schema } from './schema';
import { InsertTimeline } from '../insert_timeline';
import { useCasesContext } from '../cases_context/use_cases_context';
import { MAX_COMMENT_LENGTH } from '../../../common/constants';

const MySpinner = styled(EuiLoadingSpinner)`
position: absolute;
Expand Down Expand Up @@ -174,6 +175,9 @@ export const AddComment = React.memo(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusOnContext]);

const isDisabled =
isLoading || !comment?.trim().length || comment.trim().length > MAX_COMMENT_LENGTH;

return (
<span id="add-comment-permLink">
{isLoading && showLoading && <MySpinner data-test-subj="loading-spinner" size="xl" />}
Expand All @@ -200,7 +204,7 @@ export const AddComment = React.memo(
data-test-subj="submit-comment"
fill
iconType="plusInCircle"
isDisabled={!comment || isLoading}
isDisabled={isDisabled}
isLoading={isLoading}
onClick={onSubmit}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form
import { FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import type { CommentRequestUserType } from '../../../common/api';
import { MAX_COMMENT_LENGTH } from '../../../common/constants';

import * as i18n from './translations';

const { emptyField } = fieldValidators;
const { emptyField, maxLengthField } = fieldValidators;

export interface AddCommentFormSchema {
comment: CommentRequestUserType['comment'];
Expand All @@ -25,6 +26,12 @@ export const schema: FormSchema<AddCommentFormSchema> = {
{
validator: emptyField(i18n.EMPTY_COMMENTS_NOT_ALLOWED),
},
{
validator: maxLengthField({
length: MAX_COMMENT_LENGTH,
message: i18n.MAX_LENGTH_ERROR('comment', MAX_COMMENT_LENGTH),
}),
},
],
},
};
Loading

0 comments on commit d8c8b7b

Please sign in to comment.