diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index d46734524cb25..ead5a92bd6c7c 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -311,6 +311,13 @@ export const ADD_TAG_CUSTOM_OPTION_LABEL = (searchValue: string) => values: { searchValue }, }); +export const VERSION_CONFLICT_WARNING = (markdownId: string) => + i18n.translate('xpack.cases.configure.commentVersionConflictWarning', { + defaultMessage: + 'This {markdownId} was updated. Saving your changes will overwrite the updated value.', + values: { markdownId }, + }); + /** * EUI checkbox replace {searchValue} with the current * search value. We need to put the template variable diff --git a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx index f0f3c88f1a7e0..9e6de10cf380a 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx @@ -7,10 +7,10 @@ import React from 'react'; import { mount } from 'enzyme'; -import { waitFor, act } from '@testing-library/react'; +import { waitFor, act, fireEvent } from '@testing-library/react'; import { noop } from 'lodash/fp'; -import { noCreateCasesPermissions, TestProviders } from '../../common/mock'; +import { noCreateCasesPermissions, TestProviders, createAppMockRenderer } from '../../common/mock'; import { CommentType } from '../../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; @@ -20,6 +20,7 @@ import { AddComment } from '.'; import { CasesTimelineIntegrationProvider } from '../timeline_context'; import { timelineIntegrationMock } from '../__mock__/timeline'; import type { CaseAttachmentWithoutOwner } from '../../types'; +import type { AppMockRenderer } from '../../common/mock'; jest.mock('../../containers/use_create_attachments'); @@ -47,6 +48,8 @@ const sampleData: CaseAttachmentWithoutOwner = { comment: 'what a cool comment', type: CommentType.user as const, }; +const appId = 'testAppId'; +const draftKey = `cases.${appId}.${addCommentProps.caseId}.${addCommentProps.id}.markdownEditor`; describe('AddComment ', () => { beforeEach(() => { @@ -54,6 +57,10 @@ describe('AddComment ', () => { useCreateAttachmentsMock.mockImplementation(() => defaultResponse); }); + afterEach(() => { + sessionStorage.removeItem(draftKey); + }); + it('should post comment on submit click', async () => { const wrapper = mount( @@ -209,3 +216,70 @@ describe('AddComment ', () => { }); }); }); + +describe('draft comment ', () => { + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + appMockRenderer = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should clear session storage on submit', async () => { + const result = appMockRenderer.render(); + + fireEvent.change(result.getByLabelText('caseComment'), { + target: { value: sampleData.comment }, + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(result.getByLabelText('caseComment')).toHaveValue(sessionStorage.getItem(draftKey)); + }); + + fireEvent.click(result.getByTestId('submit-comment')); + + await waitFor(() => { + expect(onCommentSaving).toBeCalled(); + expect(createAttachments).toBeCalledWith({ + caseId: addCommentProps.caseId, + caseOwner: SECURITY_SOLUTION_OWNER, + data: [sampleData], + updateCase: onCommentPosted, + }); + expect(result.getByLabelText('caseComment').textContent).toBe(''); + expect(sessionStorage.getItem(draftKey)).toBe(''); + }); + }); + + describe('existing storage key', () => { + beforeEach(() => { + sessionStorage.setItem(draftKey, 'value set in storage'); + }); + + afterEach(() => { + sessionStorage.removeItem(draftKey); + }); + + it('should have draft comment same as existing session storage', async () => { + const result = appMockRenderer.render(); + + expect(result.getByLabelText('caseComment')).toHaveValue('value set in storage'); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/add_comment/index.tsx b/x-pack/plugins/cases/public/components/add_comment/index.tsx index 57dccbbfebe4e..d8e63da8158f2 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx @@ -28,6 +28,8 @@ import { useCreateAttachments } from '../../containers/use_create_attachments'; import type { Case } from '../../containers/types'; import type { EuiMarkdownEditorRef } from '../markdown_editor'; import { MarkdownEditorForm } from '../markdown_editor'; +import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; +import { removeItemFromSessionStorage } from '../utils'; import * as i18n from './translations'; import type { AddCommentFormSchema } from './schema'; @@ -68,8 +70,9 @@ export const AddComment = React.memo( ) => { const editorRef = useRef(null); const [focusOnContext, setFocusOnContext] = useState(false); - const { permissions, owner } = useCasesContext(); + const { permissions, owner, appId } = useCasesContext(); const { isLoading, createAttachments } = useCreateAttachments(); + const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, id); const { form } = useForm({ defaultValue: initialCommentValue, @@ -116,9 +119,21 @@ export const AddComment = React.memo( data: [{ ...data, type: CommentType.user }], updateCase: onCommentPosted, }); - reset(); + + removeItemFromSessionStorage(draftStorageKey); + + reset({ defaultValue: {} }); } - }, [submit, onCommentSaving, createAttachments, caseId, owner, onCommentPosted, reset]); + }, [ + submit, + onCommentSaving, + createAttachments, + caseId, + owner, + onCommentPosted, + reset, + draftStorageKey, + ]); /** * Focus on the text area when a quote has been added. @@ -163,6 +178,7 @@ export const AddComment = React.memo( componentProps={{ ref: editorRef, id, + draftStorageKey, idAria: 'caseComment', isDisabled: isLoading, dataTestSubj: 'add-comment', diff --git a/x-pack/plugins/cases/public/components/create/description.test.tsx b/x-pack/plugins/cases/public/components/create/description.test.tsx index 3e9adb8961cc2..e7128572d2935 100644 --- a/x-pack/plugins/cases/public/components/create/description.test.tsx +++ b/x-pack/plugins/cases/public/components/create/description.test.tsx @@ -22,6 +22,11 @@ jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment'); describe('Description', () => { let globalForm: FormHook; let appMockRender: AppMockRenderer; + const draftStorageKey = `cases.caseView.createCase.description.markdownEditor`; + const defaultProps = { + draftStorageKey, + isLoading: false, + }; const MockHookWrapperComponent: React.FC = ({ children }) => { const { form } = useForm({ @@ -44,7 +49,7 @@ describe('Description', () => { it('it renders', async () => { const result = appMockRender.render( - + ); @@ -54,7 +59,7 @@ describe('Description', () => { it('it changes the description', async () => { const result = appMockRender.render( - + ); diff --git a/x-pack/plugins/cases/public/components/create/description.tsx b/x-pack/plugins/cases/public/components/create/description.tsx index 437ff5c3751c1..48ae59f9e6e7e 100644 --- a/x-pack/plugins/cases/public/components/create/description.tsx +++ b/x-pack/plugins/cases/public/components/create/description.tsx @@ -17,11 +17,12 @@ import { ID as LensPluginId } from '../markdown_editor/plugins/lens/constants'; interface Props { isLoading: boolean; + draftStorageKey: string; } export const fieldName = 'description'; -const DescriptionComponent: React.FC = ({ isLoading }) => { +const DescriptionComponent: React.FC = ({ isLoading, draftStorageKey }) => { const { draftComment, hasIncomingLensState, openLensModal, clearDraftComment } = useLensDraftComment(); const { setFieldValue } = useFormContext(); @@ -62,6 +63,7 @@ const DescriptionComponent: React.FC = ({ isLoading }) => { caseTitle: title, caseTags: tags, disabledUiPlugins, + draftStorageKey, }} /> ); diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index 0f05f7a25ad21..d95f264089643 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -7,7 +7,8 @@ import React from 'react'; import { mount } from 'enzyme'; -import { act, render, within } from '@testing-library/react'; +import { act, render, within, fireEvent } from '@testing-library/react'; +import { waitFor } from '@testing-library/dom'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { NONE_CONNECTOR_ID } from '../../../common/api'; @@ -53,6 +54,7 @@ const casesFormProps: CreateCaseFormProps = { describe('CreateCaseForm', () => { let globalForm: FormHook; + const draftStorageKey = `cases.caseView.createCase.description.markdownEditor`; const MockHookWrapperComponent: React.FC<{ testProviderProps?: unknown }> = ({ children, @@ -80,6 +82,10 @@ describe('CreateCaseForm', () => { useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); }); + afterEach(() => { + sessionStorage.removeItem(draftStorageKey); + }); + it('renders with steps', async () => { const wrapper = mount( @@ -212,4 +218,46 @@ describe('CreateCaseForm', () => { expect(titleInput).toHaveValue('title'); expect(descriptionInput).toHaveValue('description'); }); + + describe('draft comment ', () => { + it('should clear session storage key on cancel', () => { + const result = render( + + + + ); + + const cancelBtn = result.getByTestId('create-case-cancel'); + + fireEvent.click(cancelBtn); + + fireEvent.click(result.getByTestId('confirmModalConfirmButton')); + + expect(casesFormProps.onCancel).toHaveBeenCalled(); + expect(sessionStorage.getItem(draftStorageKey)).toBe(null); + }); + + it('should clear session storage key on submit', () => { + const result = render( + + + + ); + + const submitBtn = result.getByTestId('create-case-submit'); + + fireEvent.click(submitBtn); + + waitFor(() => { + expect(casesFormProps.onSuccess).toHaveBeenCalled(); + expect(sessionStorage.getItem(draftStorageKey)).toBe(null); + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index 68ec55bbc956b..7be18f167ab1f 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -28,7 +28,9 @@ import type { Case } from '../../containers/types'; import type { CasesTimelineIntegration } from '../timeline_context'; import { CasesTimelineIntegrationProvider } from '../timeline_context'; import { InsertTimeline } from '../insert_timeline'; +import { removeItemFromSessionStorage } from '../utils'; import type { UseCreateAttachments } from '../../containers/use_create_attachments'; +import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; import { SubmitCaseButton } from './submit_button'; import { FormContext } from './form_context'; import { useCasesFeatures } from '../../common/use_cases_features'; @@ -62,6 +64,8 @@ export interface CreateCaseFormFieldsProps { connectors: ActionConnector[]; isLoadingConnectors: boolean; withSteps: boolean; + owner: string[]; + draftStorageKey: string; } export interface CreateCaseFormProps extends Pick, 'withSteps'> { onCancel: () => void; @@ -77,12 +81,10 @@ export interface CreateCaseFormProps extends Pick = React.memo( - ({ connectors, isLoadingConnectors, withSteps }) => { + ({ connectors, isLoadingConnectors, withSteps, owner, draftStorageKey }) => { const { isSubmitting } = useFormContext(); const { isSyncAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures(); - const { owner } = useCasesContext(); - const availableOwners = useAvailableCasesOwners(); const canShowCaseSolutionSelection = !owner.length && availableOwners.length; @@ -112,13 +114,19 @@ export const CreateCaseFormFields: React.FC = React.m )} - + ), }), - [isSubmitting, caseAssignmentAuthorized, canShowCaseSolutionSelection, availableOwners] + [ + isSubmitting, + caseAssignmentAuthorized, + canShowCaseSolutionSelection, + availableOwners, + draftStorageKey, + ] ); const secondStep = useMemo( @@ -187,16 +195,29 @@ export const CreateCaseForm: React.FC = React.memo( attachments, initialValue, }) => { + const { owner, appId } = useCasesContext(); + const draftStorageKey = getMarkdownEditorStorageKey(appId, 'createCase', 'description'); + + const handleOnConfirmationCallback = (): void => { + onCancel(); + removeItemFromSessionStorage(draftStorageKey); + }; + const { showConfirmationModal, onOpenModal, onConfirmModal, onCancelModal } = useCancelCreationAction({ - onConfirmationCallback: onCancel, + onConfirmationCallback: handleOnConfirmationCallback, }); + const handleOnSuccess = (theCase: Case): Promise => { + removeItemFromSessionStorage(draftStorageKey); + return onSuccess(theCase); + }; + return ( @@ -204,6 +225,8 @@ export const CreateCaseForm: React.FC = React.memo( connectors={empty} isLoadingConnectors={false} withSteps={withSteps} + owner={owner} + draftStorageKey={draftStorageKey} /> { jest.clearAllMocks(); }); + afterEach(() => { + sessionStorage.removeItem(defaultCreateCaseForm.draftStorageKey); + }); + describe('Step 1 - Case Fields', () => { it('renders correctly', async () => { mockedContext.render( @@ -780,4 +786,64 @@ describe('Create case', () => { expect(screen.queryByTestId('createCaseAssigneesComboBox')).toBeNull(); }); }); + + describe('draft comment', () => { + describe('existing storage key', () => { + beforeEach(() => { + sessionStorage.setItem(defaultCreateCaseForm.draftStorageKey, 'value set in storage'); + }); + + afterEach(() => { + sessionStorage.removeItem(defaultCreateCaseForm.draftStorageKey); + }); + + it('should have session storage value same as draft comment', async () => { + mockedContext.render( + + + + + ); + + await waitForFormToRender(screen); + const descriptionInput = within(screen.getByTestId('caseDescription')).getByTestId( + 'euiMarkdownEditorTextArea' + ); + + jest.useFakeTimers(); + + act(() => jest.advanceTimersByTime(1000)); + + await waitFor(() => expect(descriptionInput).toHaveValue('value set in storage')); + + jest.useRealTimers(); + }); + }); + + describe('set storage key', () => { + afterEach(() => { + sessionStorage.removeItem(defaultCreateCaseForm.draftStorageKey); + }); + + it('should have session storage value same as draft comment', async () => { + mockedContext.render( + + + + + ); + + await waitForFormToRender(screen); + const descriptionInput = within(screen.getByTestId('caseDescription')).getByTestId( + 'euiMarkdownEditorTextArea' + ); + + await waitFor(() => { + expect(descriptionInput).toHaveValue( + sessionStorage.getItem(defaultCreateCaseForm.draftStorageKey) + ); + }); + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx index ff073f7eef08a..4e00b0f8b9b19 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx @@ -11,9 +11,11 @@ import type { EuiMarkdownEditorProps } from '@elastic/eui'; import { EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { getFieldValidityAndErrorMessage } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import * as i18n from '../../common/translations'; import type { MarkdownEditorRef } from './editor'; import { MarkdownEditor } from './editor'; import { CommentEditorContext } from './context'; +import { useMarkdownSessionStorage } from './use_markdown_session_storage'; type MarkdownEditorFormProps = EuiMarkdownEditorProps & { id: string; @@ -24,7 +26,9 @@ type MarkdownEditorFormProps = EuiMarkdownEditorProps & { bottomRightContent?: React.ReactNode; caseTitle?: string; caseTags?: string[]; + draftStorageKey: string; disabledUiPlugins?: string[]; + initialValue?: string; }; const BottomContentWrapper = styled(EuiFlexGroup)` @@ -44,11 +48,22 @@ export const MarkdownEditorForm = React.memo( bottomRightContent, caseTitle, caseTags, + draftStorageKey, disabledUiPlugins, + initialValue, }, ref ) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + const { hasConflicts } = useMarkdownSessionStorage({ + field, + sessionKey: draftStorageKey, + initialValue, + }); + + const conflictWarningText = i18n.VERSION_CONFLICT_WARNING( + id === 'description' ? id : 'comment' + ); const commentEditorContextValue = useMemo( () => ({ @@ -67,7 +82,7 @@ export const MarkdownEditorForm = React.memo( describedByIds={idAria ? [idAria] : undefined} fullWidth error={errorMessage} - helpText={field.helpText} + helpText={hasConflicts ? conflictWarningText : field.helpText} isInvalid={isInvalid} label={field.label} labelAppend={field.labelAppend} diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx new file mode 100644 index 0000000000000..7de2e83cf234d --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.test.tsx @@ -0,0 +1,218 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { SessionStorageType } from './use_markdown_session_storage'; +import { useMarkdownSessionStorage } from './use_markdown_session_storage'; +import { waitForComponentToUpdate } from '../../common/test_utils'; + +describe('useMarkdownSessionStorage', () => { + const field = { + label: '', + helpText: '', + type: '', + value: 'test', + errors: [], + onChange: jest.fn(), + setValue: jest.fn(), + setErrors: jest.fn(), + reset: jest.fn(), + } as unknown as FieldHook; + + const sessionKey = 'testKey'; + const initialValue = ''; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + sessionStorage.removeItem(sessionKey); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return hasConflicts as false', async () => { + const { result, waitFor } = renderHook(() => + useMarkdownSessionStorage({ field, sessionKey, initialValue }) + ); + + await waitFor(() => { + expect(result.current.hasConflicts).toBe(false); + }); + }); + + it('should update the session value with field value when it is first render', async () => { + const { waitFor } = renderHook( + (props) => { + return useMarkdownSessionStorage(props); + }, + { + initialProps: { field, sessionKey, initialValue }, + } + ); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(sessionStorage.getItem(sessionKey)).toBe(field.value); + }); + }); + + it('should set session storage when field has value and session key is not created yet', async () => { + const specialCharsValue = '!{tooltip[Hello again](This is tooltip!)}'; + const { waitFor, result } = renderHook( + (props) => { + return useMarkdownSessionStorage(props); + }, + { + initialProps: { field: { ...field, value: specialCharsValue }, sessionKey, initialValue }, + } + ); + + expect(sessionStorage.getItem(sessionKey)).toBe(''); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + await waitForComponentToUpdate(); + + await waitFor(() => { + expect(result.current.hasConflicts).toBe(false); + expect(sessionStorage.getItem(sessionKey)).toBe(specialCharsValue); + }); + }); + + it('should update session value ', async () => { + const { result, rerender, waitFor } = renderHook( + (props) => { + return useMarkdownSessionStorage(props); + }, + { + initialProps: { field, sessionKey, initialValue }, + } + ); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + rerender({ field: { ...field, value: 'new value' }, sessionKey, initialValue }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + await waitForComponentToUpdate(); + + await waitFor(() => { + expect(result.current.hasConflicts).toBe(false); + expect(sessionStorage.getItem(sessionKey)).toBe('new value'); + }); + }); + + it('should return has conflict true', async () => { + const { result, rerender, waitFor } = renderHook( + (props) => { + return useMarkdownSessionStorage(props); + }, + { initialProps: { field, sessionKey, initialValue } } + ); + + rerender({ field, sessionKey, initialValue: 'updated' }); + + await waitFor(() => { + expect(result.current.hasConflicts).toBe(true); + }); + }); + + describe('existing session key', () => { + beforeEach(() => { + sessionStorage.setItem(sessionKey, 'existing session storage value'); + }); + + afterEach(() => { + sessionStorage.removeItem(sessionKey); + }); + + it('should set field value if session already exists and it is a first render', async () => { + const { waitFor, result } = renderHook( + (props) => { + return useMarkdownSessionStorage(props); + }, + { + initialProps: { field, sessionKey, initialValue }, + } + ); + + await waitForComponentToUpdate(); + + await waitFor(() => { + expect(field.setValue).toHaveBeenCalled(); + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + await waitForComponentToUpdate(); + + await waitFor(() => { + expect(result.current.hasConflicts).toBe(false); + expect(field.value).toBe(sessionStorage.getItem(sessionKey)); + }); + }); + + it('should update existing session key if field value changed', async () => { + const { waitFor, rerender, result } = renderHook< + SessionStorageType, + { hasConflicts: boolean } + >( + (props) => { + return useMarkdownSessionStorage(props); + }, + { + initialProps: { field, sessionKey, initialValue }, + } + ); + + await waitForComponentToUpdate(); + + await waitFor(() => { + expect(field.setValue).toHaveBeenCalled(); + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + rerender({ field: { ...field, value: 'new value' }, sessionKey, initialValue }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + await waitForComponentToUpdate(); + + await waitFor(() => { + expect(result.current.hasConflicts).toBe(false); + expect(sessionStorage.getItem(sessionKey)).toBe('new value'); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx new file mode 100644 index 0000000000000..e33fed6729858 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/use_markdown_session_storage.tsx @@ -0,0 +1,55 @@ +/* + * 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 { useRef, useState } from 'react'; +import { isEmpty } from 'lodash'; +import useDebounce from 'react-use/lib/useDebounce'; +import useSessionStorage from 'react-use/lib/useSessionStorage'; +import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; + +const STORAGE_DEBOUNCE_TIME = 500; + +export interface SessionStorageType { + field: FieldHook; + sessionKey: string; + initialValue: string | undefined; +} + +export const useMarkdownSessionStorage = ({ + field, + sessionKey, + initialValue, +}: SessionStorageType) => { + const [hasConflicts, setHasConflicts] = useState(false); + const isFirstRender = useRef(true); + const initialValueRef = useRef(initialValue); + + const [sessionValue, setSessionValue] = useSessionStorage(sessionKey, '', true); + + if (!isEmpty(sessionValue) && isFirstRender.current) { + field.setValue(sessionValue); + } + + if (isFirstRender.current) { + isFirstRender.current = false; + } + + if (initialValue !== initialValueRef.current && initialValue !== field.value) { + initialValueRef.current = initialValue; + setHasConflicts(true); + } + + useDebounce( + () => { + setSessionValue(field.value); + }, + STORAGE_DEBOUNCE_TIME, + [field.value] + ); + + return { hasConflicts }; +}; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/utils.test.ts b/x-pack/plugins/cases/public/components/markdown_editor/utils.test.ts new file mode 100644 index 0000000000000..ef1de4a1bc327 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/utils.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { getMarkdownEditorStorageKey } from './utils'; + +describe('getMarkdownEditorStorageKey', () => { + it('should return correct session key', () => { + const appId = 'security-solution'; + const caseId = 'case-id'; + const commentId = 'comment-id'; + const sessionKey = getMarkdownEditorStorageKey(appId, caseId, commentId); + expect(sessionKey).toEqual(`cases.${appId}.${caseId}.${commentId}.markdownEditor`); + }); + + it('should return default key when comment id is empty ', () => { + const appId = 'security-solution'; + const caseId = 'case-id'; + const commentId = ''; + const sessionKey = getMarkdownEditorStorageKey(appId, caseId, commentId); + expect(sessionKey).toEqual(`cases.${appId}.${caseId}.comment.markdownEditor`); + }); + + it('should return default key when case id is empty ', () => { + const appId = 'security-solution'; + const caseId = ''; + const commentId = 'comment-id'; + const sessionKey = getMarkdownEditorStorageKey(appId, caseId, commentId); + expect(sessionKey).toEqual(`cases.${appId}.case.${commentId}.markdownEditor`); + }); + + it('should return default key when app id is empty ', () => { + const appId = ''; + const caseId = 'case-id'; + const commentId = 'comment-id'; + const sessionKey = getMarkdownEditorStorageKey(appId, caseId, commentId); + expect(sessionKey).toEqual(`cases.cases.${caseId}.${commentId}.markdownEditor`); + }); +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/utils.ts b/x-pack/plugins/cases/public/components/markdown_editor/utils.ts new file mode 100644 index 0000000000000..1fb81875b926b --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/utils.ts @@ -0,0 +1,18 @@ +/* + * 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 getMarkdownEditorStorageKey = ( + appId: string, + caseId: string, + commentId: string +): string => { + const appIdKey = appId !== '' ? appId : 'cases'; + const caseIdKey = caseId !== '' ? caseId : 'case'; + const commentIdKey = commentId !== '' ? commentId : 'comment'; + + return `cases.${appIdKey}.${caseIdKey}.${commentIdKey}.markdownEditor`; +}; diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx index 0de6fcb91a5e8..920f1eff3950e 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx @@ -96,6 +96,7 @@ const getCreateCommentUserAction = ({ isEdit: manageMarkdownEditIds.includes(comment.id), commentRefs, isLoading: loadingCommentIds.includes(comment.id), + caseId: caseData.id, handleManageMarkdownEditId, handleSaveComment, handleManageQuote, diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx index d7bdcc9c543b0..d3c39bc568e7d 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx @@ -28,6 +28,7 @@ type BuilderArgs = Pick< | 'userProfiles' > & { comment: SnakeToCamelCase; + caseId: string; outlined: boolean; isEdit: boolean; isLoading: boolean; @@ -40,6 +41,7 @@ export const createUserAttachmentUserActionBuilder = ({ isEdit, isLoading, commentRefs, + caseId, handleManageMarkdownEditId, handleSaveComment, handleManageQuote, @@ -65,6 +67,7 @@ export const createUserAttachmentUserActionBuilder = ({ id={comment.id} content={comment.comment} isEditable={isEdit} + caseId={caseId} onChangeEditable={handleManageMarkdownEditId} onSaveContent={handleSaveComment.bind(null, { id: comment.id, diff --git a/x-pack/plugins/cases/public/components/user_actions/description.tsx b/x-pack/plugins/cases/public/components/user_actions/description.tsx index 19874dc05f2d0..754ce7bfd1f34 100644 --- a/x-pack/plugins/cases/public/components/user_actions/description.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/description.tsx @@ -54,6 +54,7 @@ export const getDescriptionUserAction = ({ (commentRefs.current[DESCRIPTION_ID] = element)} + caseId={caseData.id} id={DESCRIPTION_ID} content={caseData.description} isEditable={isEditable} diff --git a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx index 631772ef768d0..3ff81914fecc1 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx @@ -386,6 +386,127 @@ describe(`UserActions`, () => { }); }); + it('it should persist the draft of new comment while existing old comment is updated', async () => { + const editedComment = 'it is an edited comment'; + const newComment = 'another cool comment'; + const ourActions = [getUserAction('comment', Actions.create)]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + const wrapper = mount( + + + + ); + + // type new comment in text area + wrapper + .find(`[data-test-subj="add-comment"] textarea`) + .first() + .simulate('change', { target: { value: newComment } }); + + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]` + ) + .first() + .simulate('click'); + + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]` + ) + .first() + .simulate('click'); + + wrapper + .find(`.euiMarkdownEditorTextArea`) + .first() + .simulate('change', { + target: { value: editedComment }, + }); + + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] button[data-test-subj="user-action-save-markdown"]` + ) + .first() + .simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); + expect(patchComment).toBeCalledWith({ + commentUpdate: editedComment, + caseId: 'case-id', + commentId: props.data.comments[0].id, + version: props.data.comments[0].version, + }); + }); + + expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe(newComment); + }); + + it('it should persist the draft of new comment while description is updated', async () => { + const newComment = 'another cool comment'; + const wrapper = mount( + + + + ); + + // type new comment in text area + wrapper + .find(`[data-test-subj="add-comment"] textarea`) + .first() + .simulate('change', { target: { value: newComment } }); + + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`) + .first() + .simulate('click'); + + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-pencil"]`) + .first() + .simulate('click'); + + wrapper + .find(`.euiMarkdownEditorTextArea`) + .first() + .simulate('change', { + target: { value: sampleData.content }, + }); + + wrapper + .find( + `[data-test-subj="description-action"] button[data-test-subj="user-action-save-markdown"]` + ) + .first() + .simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect( + wrapper + .find( + `[data-test-subj="description-action"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); + expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); + }); + + expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe(newComment); + }); + describe('Host isolation action', () => { it('renders in the cases details view', async () => { const isolateAction = [getHostIsolationUserAction()]; diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx index c6b6c0e59f004..972a520d2085c 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx @@ -7,10 +7,13 @@ import React from 'react'; import { mount } from 'enzyme'; +import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import type { Content } from './schema'; +import { schema } from './schema'; import { UserActionMarkdown } from './markdown_form'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer, TestProviders } from '../../common/mock'; -import { waitFor } from '@testing-library/react'; +import { waitFor, fireEvent, render, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; const onChangeEditable = jest.fn(); const onSaveContent = jest.fn(); @@ -18,10 +21,13 @@ const onSaveContent = jest.fn(); const newValue = 'Hello from Tehas'; const emptyValue = ''; const hyperlink = `[hyperlink](http://elastic.co)`; +const draftStorageKey = `cases.testAppId.caseId.markdown-id.markdownEditor`; const defaultProps = { content: `A link to a timeline ${hyperlink}`, id: 'markdown-id', + caseId: 'caseId', isEditable: true, + draftStorageKey, onChangeEditable, onSaveContent, }; @@ -31,6 +37,10 @@ describe('UserActionMarkdown ', () => { jest.clearAllMocks(); }); + afterEach(() => { + sessionStorage.removeItem(draftStorageKey); + }); + it('Renders markdown correctly when not in edit mode', async () => { const wrapper = mount( @@ -221,4 +231,112 @@ describe('UserActionMarkdown ', () => { expect(result.container.querySelector('textarea')!.value).not.toEqual(oldContent); }); }); + + describe('draft comment ', () => { + const content = 'test content'; + const initialState = { content }; + const MockHookWrapperComponent: React.FC<{ testProviderProps?: unknown }> = ({ + children, + testProviderProps = {}, + }) => { + const { form } = useForm({ + defaultValue: initialState, + options: { stripEmptyFields: false }, + schema, + }); + + return ( + +
{children}
+
+ ); + }; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + sessionStorage.removeItem(draftStorageKey); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Save button click clears session storage', async () => { + const result = render( + + + + ); + + fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { + target: { value: newValue }, + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); + + fireEvent.click(result.getByTestId(`user-action-save-markdown`)); + + await waitFor(() => { + expect(onSaveContent).toHaveBeenCalledWith(newValue); + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + expect(sessionStorage.getItem(draftStorageKey)).toBe(null); + }); + }); + + it('Cancel button click clears session storage', async () => { + const result = render( + + + + ); + + expect(sessionStorage.getItem(draftStorageKey)).toBe(''); + + fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { + target: { value: newValue }, + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); + }); + + fireEvent.click(result.getByTestId('user-action-cancel-markdown')); + + await waitFor(() => { + expect(sessionStorage.getItem(draftStorageKey)).toBe(null); + }); + }); + + describe('existing storage key', () => { + beforeEach(() => { + sessionStorage.setItem(draftStorageKey, 'value set in storage'); + }); + + it('should have session storage value same as draft comment', async () => { + const result = render( + + + + ); + + expect(result.getByText('value set in storage')).toBeInTheDocument(); + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx b/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx index 42abc55f336c9..60b1a2bda3b6a 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx @@ -12,6 +12,9 @@ import { Form, useForm, UseField } from '@kbn/es-ui-shared-plugin/static/forms/h import type { Content } from './schema'; import { schema } from './schema'; import { MarkdownRenderer, MarkdownEditorForm } from '../markdown_editor'; +import { removeItemFromSessionStorage } from '../utils'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; import { UserActionMarkdownFooter } from './markdown_form_footer'; export const ContentWrapper = styled.div` @@ -21,6 +24,7 @@ export const ContentWrapper = styled.div` interface UserActionMarkdownProps { content: string; id: string; + caseId: string; isEditable: boolean; onChangeEditable: (id: string) => void; onSaveContent: (content: string) => void; @@ -33,7 +37,7 @@ export interface UserActionMarkdownRefObject { const UserActionMarkdownComponent = forwardRef< UserActionMarkdownRefObject, UserActionMarkdownProps ->(({ id, content, isEditable, onChangeEditable, onSaveContent }, ref) => { +>(({ id, content, caseId, isEditable, onChangeEditable, onSaveContent }, ref) => { const editorRef = useRef(); const initialState = { content }; const { form } = useForm({ @@ -43,11 +47,14 @@ const UserActionMarkdownComponent = forwardRef< }); const fieldName = 'content'; + const { appId } = useCasesContext(); + const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, id); const { setFieldValue, submit } = form; const handleCancelAction = useCallback(() => { onChangeEditable(id); - }, [id, onChangeEditable]); + removeItemFromSessionStorage(draftStorageKey); + }, [id, onChangeEditable, draftStorageKey]); const handleSaveAction = useCallback(async () => { const { isValid, data } = await submit(); @@ -56,7 +63,8 @@ const UserActionMarkdownComponent = forwardRef< onSaveContent(data.content); } onChangeEditable(id); - }, [content, id, onChangeEditable, onSaveContent, submit]); + removeItemFromSessionStorage(draftStorageKey); + }, [content, id, onChangeEditable, onSaveContent, submit, draftStorageKey]); const setComment = useCallback( (newComment) => { @@ -80,12 +88,14 @@ const UserActionMarkdownComponent = forwardRef< 'aria-label': 'Cases markdown editor', value: content, id, + draftStorageKey, bottomRightContent: ( ), + initialValue: content, }} /> diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts index 99ec0213ff4ad..faf9cb2af727b 100644 --- a/x-pack/plugins/cases/public/components/utils.test.ts +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -7,7 +7,12 @@ import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; -import { connectorDeprecationValidator, getConnectorIcon, isDeprecatedConnector } from './utils'; +import { + connectorDeprecationValidator, + getConnectorIcon, + isDeprecatedConnector, + removeItemFromSessionStorage, +} from './utils'; describe('Utils', () => { const connector = { @@ -85,4 +90,29 @@ describe('Utils', () => { ).toBe(false); }); }); + + describe('removeItemFromSessionStorage', () => { + const sessionKey = 'testKey'; + const sessionValue = 'test value'; + + afterEach(() => { + sessionStorage.removeItem(sessionKey); + }); + + it('successfully removes key from session storage', () => { + sessionStorage.setItem(sessionKey, sessionValue); + + expect(sessionStorage.getItem(sessionKey)).toBe(sessionValue); + + removeItemFromSessionStorage(sessionKey); + + expect(sessionStorage.getItem(sessionKey)).toBe(null); + }); + + it('is null if key is not in session storage', () => { + removeItemFromSessionStorage(sessionKey); + + expect(sessionStorage.getItem(sessionKey)).toBe(null); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 1a5f8134563ff..b026dce662340 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -93,3 +93,7 @@ export const getConnectorIcon = ( export const isDeprecatedConnector = (connector?: CaseActionConnector): boolean => { return connector?.isDeprecated ?? false; }; + +export const removeItemFromSessionStorage = (key: string) => { + window.sessionStorage.removeItem(key); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts index 5a3862d5ac781..a08b46328efd2 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts @@ -181,6 +181,95 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { ); }); }); + + describe('draft comments', () => { + createOneCaseBeforeDeleteAllAfter(getPageObject, getService); + + it('persists new comment when status is updated in dropdown', async () => { + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + + await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']); + // validate user action + await find.byCssSelector( + '[data-test-subj*="status-update-action"] [data-test-subj="case-status-badge-in-progress"]' + ); + // validates dropdown tag + await testSubjects.existOrFail( + 'case-view-status-dropdown > case-status-badge-popover-button-in-progress' + ); + + await testSubjects.click('submit-comment'); + + // validate user action + const newComment = await find.byCssSelector( + '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + ); + expect(await newComment.getVisibleText()).equal('Test comment from automation'); + }); + + it('persists new comment when case is closed the close case button', async () => { + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + + await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']); + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('case-view-status-action-button'); + await header.waitUntilLoadingHasFinished(); + + await testSubjects.existOrFail( + 'header-page-supplements > case-status-badge-popover-button-closed', + { + timeout: 5000, + } + ); + + // validate user action + await find.byCssSelector( + '[data-test-subj*="status-update-action"] [data-test-subj="case-status-badge-closed"]' + ); + // validates dropdown tag + await testSubjects.existOrFail( + 'case-view-status-dropdown >case-status-badge-popover-button-closed' + ); + + await testSubjects.click('submit-comment'); + + // validate user action + const newComment = await find.byCssSelector( + '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + ); + expect(await newComment.getVisibleText()).equal('Test comment from automation'); + }); + + it('persists new comment to the case when user goes to case list table and comes back to the case', async () => { + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + + await testSubjects.click('backToCases'); + + const caseLink = await find.byCssSelector('[data-test-subj="case-details-link"'); + + caseLink.click(); + + await testSubjects.click('submit-comment'); + + // validate user action + const newComment = await find.byCssSelector( + '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + ); + expect(await newComment.getVisibleText()).equal('Test comment from automation'); + }); + }); }); describe('actions', () => {