From 6808903d5704fa712f9a4170b3bf8fc877231545 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 26 Jun 2020 21:31:41 +0300 Subject: [PATCH] [SIEM][CASE] Persist callout when dismissed (#68372) --- x-pack/plugins/security_solution/package.json | 3 +- .../no_write_alerts_callout/translations.ts | 4 +- .../cases/components/callout/callout.test.tsx | 89 +++++++ .../cases/components/callout/callout.tsx | 53 ++++ .../cases/components/callout/helpers.test.tsx | 28 +++ .../cases/components/callout/helpers.tsx | 12 +- .../cases/components/callout/index.test.tsx | 238 +++++++++++++----- .../public/cases/components/callout/index.tsx | 138 +++++----- .../cases/components/callout/translations.ts | 4 +- .../public/cases/components/callout/types.ts | 12 + .../use_push_to_service/helpers.tsx | 9 +- .../use_push_to_service/index.test.tsx | 16 +- .../components/use_push_to_service/index.tsx | 11 +- .../public/cases/pages/case.tsx | 6 +- .../public/cases/pages/case_details.tsx | 6 +- .../use_messages_storage.test.tsx | 85 +++++++ .../local_storage/use_messages_storage.tsx | 52 ++++ .../public/common/mock/kibana_react.ts | 3 + .../timeline/header/translations.ts | 2 +- yarn.lock | 7 + 20 files changed, 621 insertions(+), 157 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/components/callout/callout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/callout/callout.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/callout/helpers.test.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/callout/types.ts create mode 100644 x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.tsx diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index 108ed6695885..1ce5243bf795 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -13,7 +13,8 @@ "test:generate": "ts-node --project scripts/endpoint/cli_tsconfig.json scripts/endpoint/resolver_generator.ts" }, "devDependencies": { - "@types/lodash": "^4.14.110" + "@types/lodash": "^4.14.110", + "@types/md5": "^2.2.0" }, "dependencies": { "@types/rbush": "^3.0.0", diff --git a/x-pack/plugins/security_solution/public/alerts/components/no_write_alerts_callout/translations.ts b/x-pack/plugins/security_solution/public/alerts/components/no_write_alerts_callout/translations.ts index d036c422b2fb..211bd21c915c 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/no_write_alerts_callout/translations.ts +++ b/x-pack/plugins/security_solution/public/alerts/components/no_write_alerts_callout/translations.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const NO_WRITE_ALERTS_CALLOUT_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.noWriteAlertsCallOutTitle', { - defaultMessage: 'Alerts index permissions required', + defaultMessage: 'You cannot change alert states', } ); @@ -17,7 +17,7 @@ export const NO_WRITE_ALERTS_CALLOUT_MSG = i18n.translate( 'xpack.securitySolution.detectionEngine.noWriteAlertsCallOutMsg', { defaultMessage: - 'You are currently missing the required permissions to update alerts. Please contact your administrator for further assistance.', + 'You only have permissions to view alerts. If you need to update alert states (open or close alerts), contact your Kibana administrator.', } ); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/callout.test.tsx b/x-pack/plugins/security_solution/public/cases/components/callout/callout.test.tsx new file mode 100644 index 000000000000..7a344d9360b7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/callout/callout.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { CallOut, CallOutProps } from './callout'; + +describe('Callout', () => { + const defaultProps: CallOutProps = { + id: 'md5-hex', + type: 'primary', + title: 'a tittle', + messages: [ + { + id: 'generic-error', + title: 'message-one', + description:

{'error'}

, + }, + ], + showCallOut: true, + handleDismissCallout: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('It renders the callout', () => { + const wrapper = mount(); + expect(wrapper.find(`[data-test-subj="case-callout-md5-hex"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="callout-messages-md5-hex"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="callout-dismiss-md5-hex"]`).exists()).toBeTruthy(); + }); + + it('hides the callout', () => { + const wrapper = mount(); + expect(wrapper.find(`[data-test-subj="case-callout-md5-hex"]`).exists()).toBeFalsy(); + }); + + it('does not shows any messages when the list is empty', () => { + const wrapper = mount(); + expect(wrapper.find(`[data-test-subj="callout-messages-md5-hex"]`).exists()).toBeFalsy(); + }); + + it('transform the button color correctly - primary', () => { + const wrapper = mount(); + const className = + wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ?? + ''; + expect(className.includes('euiButton--primary')).toBeTruthy(); + }); + + it('transform the button color correctly - success', () => { + const wrapper = mount(); + const className = + wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ?? + ''; + expect(className.includes('euiButton--secondary')).toBeTruthy(); + }); + + it('transform the button color correctly - warning', () => { + const wrapper = mount(); + const className = + wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ?? + ''; + expect(className.includes('euiButton--warning')).toBeTruthy(); + }); + + it('transform the button color correctly - danger', () => { + const wrapper = mount(); + const className = + wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ?? + ''; + expect(className.includes('euiButton--danger')).toBeTruthy(); + }); + + it('dismiss the callout correctly', () => { + const wrapper = mount(); + expect(wrapper.find(`[data-test-subj="callout-dismiss-md5-hex"]`).exists()).toBeTruthy(); + wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).simulate('click'); + wrapper.update(); + + expect(defaultProps.handleDismissCallout).toHaveBeenCalledWith('md5-hex', 'primary'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/callout.tsx b/x-pack/plugins/security_solution/public/cases/components/callout/callout.tsx new file mode 100644 index 000000000000..e1ebe5c5db17 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/callout/callout.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiButton, EuiDescriptionList } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import React, { memo, useCallback } from 'react'; + +import { ErrorMessage } from './types'; +import * as i18n from './translations'; + +export interface CallOutProps { + id: string; + type: NonNullable; + title: string; + messages: ErrorMessage[]; + showCallOut: boolean; + handleDismissCallout: (id: string, type: NonNullable) => void; +} + +const CallOutComponent = ({ + id, + type, + title, + messages, + showCallOut, + handleDismissCallout, +}: CallOutProps) => { + const handleCallOut = useCallback(() => handleDismissCallout(id, type), [ + handleDismissCallout, + id, + type, + ]); + + return showCallOut ? ( + + {!isEmpty(messages) && ( + + )} + + {i18n.DISMISS_CALLOUT} + + + ) : null; +}; + +export const CallOut = memo(CallOutComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/callout/helpers.test.tsx new file mode 100644 index 000000000000..c5fb7f3fa447 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/callout/helpers.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import md5 from 'md5'; +import { createCalloutId } from './helpers'; + +describe('createCalloutId', () => { + it('creates id correctly with one id', () => { + const digest = md5('one'); + const id = createCalloutId(['one']); + expect(id).toBe(digest); + }); + + it('creates id correctly with multiples ids', () => { + const digest = md5('one|two|three'); + const id = createCalloutId(['one', 'two', 'three']); + expect(id).toBe(digest); + }); + + it('creates id correctly with multiples ids and delimiter', () => { + const digest = md5('one,two,three'); + const id = createCalloutId(['one', 'two', 'three'], ','); + expect(id).toBe(digest); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx index 323710427447..23c1abda66a7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx @@ -3,10 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; +import md5 from 'md5'; import * as i18n from './translations'; +import { ErrorMessage } from './types'; -export const savedObjectReadOnly = { +export const savedObjectReadOnlyErrorMessage: ErrorMessage = { + id: 'read-only-privileges-error', title: i18n.READ_ONLY_SAVED_OBJECT_TITLE, - description: i18n.READ_ONLY_SAVED_OBJECT_MSG, + description: <>{i18n.READ_ONLY_SAVED_OBJECT_MSG}, + errorType: 'warning', }; + +export const createCalloutId = (ids: string[], delimiter: string = '|'): string => + md5(ids.join(delimiter)); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/callout/index.test.tsx index ee3faeb2ceeb..6d8917218c7c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/callout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/callout/index.test.tsx @@ -7,104 +7,210 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseCallOut } from '.'; +import { useMessagesStorage } from '../../../common/containers/local_storage/use_messages_storage'; +import { TestProviders } from '../../../common/mock'; +import { createCalloutId } from './helpers'; +import { CaseCallOut, CaseCallOutProps } from '.'; -const defaultProps = { - title: 'hey title', +jest.mock('../../../common/containers/local_storage/use_messages_storage'); + +const useSecurityLocalStorageMock = useMessagesStorage as jest.Mock; +const securityLocalStorageMock = { + getMessages: jest.fn(() => []), + addMessage: jest.fn(), }; describe('CaseCallOut ', () => { - it('Renders single message callout', () => { - const props = { - ...defaultProps, - message: 'we have one message', - }; - - const wrapper = mount(); - - expect(wrapper.find(`[data-test-subj="callout-message-primary"]`).last().exists()).toBeTruthy(); + beforeEach(() => { + jest.clearAllMocks(); + useSecurityLocalStorageMock.mockImplementation(() => securityLocalStorageMock); }); - it('Renders multi message callout', () => { - const props = { - ...defaultProps, + it('renders a callout correctly', () => { + const props: CaseCallOutProps = { + title: 'hey title', messages: [ - { ...defaultProps, description:

{'we have two messages'}

}, - { ...defaultProps, description:

{'for real'}

}, + { id: 'message-one', title: 'title', description:

{'we have two messages'}

}, + { id: 'message-two', title: 'title', description:

{'for real'}

}, ], }; - const wrapper = mount(); - expect(wrapper.find(`[data-test-subj="callout-message-primary"]`).last().exists()).toBeFalsy(); - expect( - wrapper.find(`[data-test-subj="callout-messages-primary"]`).last().exists() - ).toBeTruthy(); + const wrapper = mount( + + + + ); + + const id = createCalloutId(['message-one', 'message-two']); + expect(wrapper.find(`[data-test-subj="callout-messages-${id}"]`).last().exists()).toBeTruthy(); }); - it('it shows the correct type of callouts', () => { - const props = { - ...defaultProps, + it('groups the messages correctly', () => { + const props: CaseCallOutProps = { + title: 'hey title', messages: [ { - ...defaultProps, + id: 'message-one', + title: 'title one', description:

{'we have two messages'}

, - errorType: 'danger' as 'primary' | 'success' | 'warning' | 'danger', + errorType: 'danger', }, - { ...defaultProps, description:

{'for real'}

}, + { id: 'message-two', title: 'title two', description:

{'for real'}

}, ], }; - const wrapper = mount(); - expect(wrapper.find(`[data-test-subj="callout-messages-danger"]`).last().exists()).toBeTruthy(); + + const wrapper = mount( + + + + ); + + const idDanger = createCalloutId(['message-one']); + const idPrimary = createCalloutId(['message-two']); expect( - wrapper.find(`[data-test-subj="callout-messages-primary"]`).last().exists() + wrapper.find(`[data-test-subj="case-callout-${idPrimary}"]`).last().exists() + ).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="case-callout-${idDanger}"]`).last().exists() ).toBeTruthy(); }); - it('it applies the correct color to button', () => { - const props = { - ...defaultProps, + it('dismisses the callout correctly', () => { + const props: CaseCallOutProps = { + title: 'hey title', + messages: [ + { id: 'message-one', title: 'title', description:

{'we have two messages'}

}, + ], + }; + const wrapper = mount( + + + + ); + + const id = createCalloutId(['message-one']); + + expect(wrapper.find(`[data-test-subj="case-callout-${id}"]`).last().exists()).toBeTruthy(); + wrapper.find(`[data-test-subj="callout-dismiss-${id}"]`).last().simulate('click'); + expect(wrapper.find(`[data-test-subj="case-callout-${id}"]`).exists()).toBeFalsy(); + }); + + it('persist the callout of type primary when dismissed', () => { + const props: CaseCallOutProps = { + title: 'hey title', + messages: [ + { id: 'message-one', title: 'title', description:

{'we have two messages'}

}, + ], + }; + + const wrapper = mount( + + + + ); + + const id = createCalloutId(['message-one']); + expect(securityLocalStorageMock.getMessages).toHaveBeenCalledWith('case'); + wrapper.find(`[data-test-subj="callout-dismiss-${id}"]`).last().simulate('click'); + expect(securityLocalStorageMock.addMessage).toHaveBeenCalledWith('case', id); + }); + + it('do not show the callout if is in the localStorage', () => { + const props: CaseCallOutProps = { + title: 'hey title', + messages: [ + { id: 'message-one', title: 'title', description:

{'we have two messages'}

}, + ], + }; + + const id = createCalloutId(['message-one']); + + useSecurityLocalStorageMock.mockImplementation(() => ({ + ...securityLocalStorageMock, + getMessages: jest.fn(() => [id]), + })); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="case-callout-${id}"]`).last().exists()).toBeFalsy(); + }); + + it('do not persist a callout of type danger', () => { + const props: CaseCallOutProps = { + title: 'hey title', messages: [ { - ...defaultProps, - description:

{'one'}

, - errorType: 'danger' as 'primary' | 'success' | 'warning' | 'danger', - }, - { - ...defaultProps, - description:

{'two'}

, - errorType: 'success' as 'primary' | 'success' | 'warning' | 'danger', - }, - { - ...defaultProps, - description:

{'three'}

, - errorType: 'primary' as 'primary' | 'success' | 'warning' | 'danger', + id: 'message-one', + title: 'title one', + description:

{'we have two messages'}

, + errorType: 'danger', }, ], }; - const wrapper = mount(); - - expect(wrapper.find(`[data-test-subj="callout-dismiss-danger"]`).first().prop('color')).toBe( - 'danger' + const wrapper = mount( + + + ); - expect(wrapper.find(`[data-test-subj="callout-dismiss-success"]`).first().prop('color')).toBe( - 'secondary' - ); - - expect(wrapper.find(`[data-test-subj="callout-dismiss-primary"]`).first().prop('color')).toBe( - 'primary' - ); + const id = createCalloutId(['message-one']); + wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click'); + wrapper.update(); + expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled(); }); - it('Dismisses callout', () => { - const props = { - ...defaultProps, - message: 'we have one message', + it('do not persist a callout of type warning', () => { + const props: CaseCallOutProps = { + title: 'hey title', + messages: [ + { + id: 'message-one', + title: 'title one', + description:

{'we have two messages'}

, + errorType: 'warning', + }, + ], }; - const wrapper = mount(); - expect(wrapper.find(`[data-test-subj="case-call-out-primary"]`).exists()).toBeTruthy(); - wrapper.find(`[data-test-subj="callout-dismiss-primary"]`).last().simulate('click'); - expect(wrapper.find(`[data-test-subj="case-call-out-primary"]`).exists()).toBeFalsy(); + + const wrapper = mount( + + + + ); + + const id = createCalloutId(['message-one']); + wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click'); + wrapper.update(); + expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled(); + }); + + it('do not persist a callout of type success', () => { + const props: CaseCallOutProps = { + title: 'hey title', + messages: [ + { + id: 'message-one', + title: 'title one', + description:

{'we have two messages'}

, + errorType: 'success', + }, + ], + }; + + const wrapper = mount( + + + + ); + + const id = createCalloutId(['message-one']); + wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click'); + wrapper.update(); + expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/index.tsx b/x-pack/plugins/security_solution/public/cases/components/callout/index.tsx index 171c0508b9d9..cefaec6ad0b0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/callout/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/callout/index.tsx @@ -4,79 +4,99 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiCallOut, EuiButton, EuiDescriptionList, EuiSpacer } from '@elastic/eui'; -import { isEmpty } from 'lodash/fp'; -import React, { memo, useCallback, useState } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import React, { memo, useCallback, useState, useMemo } from 'react'; -import * as i18n from './translations'; +import { useMessagesStorage } from '../../../common/containers/local_storage/use_messages_storage'; +import { CallOut } from './callout'; +import { ErrorMessage } from './types'; +import { createCalloutId } from './helpers'; export * from './helpers'; -interface ErrorMessage { +export interface CaseCallOutProps { title: string; - description: JSX.Element; - errorType?: 'primary' | 'success' | 'warning' | 'danger'; -} - -interface CaseCallOutProps { - title: string; - message?: string; messages?: ErrorMessage[]; } -const CaseCallOutComponent = ({ title, message, messages }: CaseCallOutProps) => { - const [showCallOut, setShowCallOut] = useState(true); - const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); - let callOutMessages = messages ?? []; +type GroupByTypeMessages = { + [key in NonNullable]: { + messagesId: string[]; + messages: ErrorMessage[]; + }; +}; - if (message) { - callOutMessages = [ - ...callOutMessages, - { - title: '', - description:

{message}

, - errorType: 'primary', - }, - ]; - } +interface CalloutVisibility { + [index: string]: boolean; +} - const groupedErrorMessages = callOutMessages.reduce((acc, currentMessage: ErrorMessage) => { - const key = currentMessage.errorType == null ? 'primary' : currentMessage.errorType; - return { - ...acc, - [key]: [...(acc[key] || []), currentMessage], - }; - }, {} as { [key in NonNullable]: ErrorMessage[] }); +const CaseCallOutComponent = ({ title, messages = [] }: CaseCallOutProps) => { + const { getMessages, addMessage } = useMessagesStorage(); - return showCallOut ? ( + const caseMessages = useMemo(() => getMessages('case'), [getMessages]); + const dismissedCallouts = useMemo( + () => + caseMessages.reduce( + (acc, id) => ({ + ...acc, + [id]: false, + }), + {} + ), + [caseMessages] + ); + + const [calloutVisibility, setCalloutVisibility] = useState(dismissedCallouts); + const handleCallOut = useCallback( + (id, type) => { + setCalloutVisibility((prevState) => ({ ...prevState, [id]: false })); + if (type === 'primary') { + addMessage('case', id); + } + }, + [setCalloutVisibility, addMessage] + ); + + const groupedByTypeErrorMessages = useMemo( + () => + messages.reduce( + (acc: GroupByTypeMessages, currentMessage: ErrorMessage) => { + const type = currentMessage.errorType == null ? 'primary' : currentMessage.errorType; + return { + ...acc, + [type]: { + messagesId: [...(acc[type]?.messagesId ?? []), currentMessage.id], + messages: [...(acc[type]?.messages ?? []), currentMessage], + }, + }; + }, + {} as GroupByTypeMessages + ), + [messages] + ); + + return ( <> - {(Object.keys(groupedErrorMessages) as Array).map((key) => ( - - - {!isEmpty(groupedErrorMessages[key]) && ( - ).map( + (type: NonNullable) => { + const id = createCalloutId(groupedByTypeErrorMessages[type].messagesId); + return ( + + - )} - - {i18n.DISMISS_CALLOUT} - - - - - ))} + + + ); + } + )} - ) : null; + ); }; export const CaseCallOut = memo(CaseCallOutComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts b/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts index 01956ca94299..2ba3df82102e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const READ_ONLY_SAVED_OBJECT_TITLE = i18n.translate( 'xpack.securitySolution.case.readOnlySavedObjectTitle', { - defaultMessage: 'You have read-only feature privileges', + defaultMessage: 'You cannot open new or update existing cases', } ); @@ -17,7 +17,7 @@ export const READ_ONLY_SAVED_OBJECT_MSG = i18n.translate( 'xpack.securitySolution.case.readOnlySavedObjectDescription', { defaultMessage: - 'You are only allowed to view cases. If you need to open and update cases, contact your Kibana administrator', + 'You only have permissions to view cases. If you need to open and update cases, contact your Kibana administrator.', } ); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/types.ts b/x-pack/plugins/security_solution/public/cases/components/callout/types.ts new file mode 100644 index 000000000000..1f07ef1bd924 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/callout/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ErrorMessage { + id: string; + title: string; + description: JSX.Element; + errorType?: 'primary' | 'success' | 'warning' | 'danger'; +} diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx index 919231d2f603..43f2a2a6e12f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx @@ -10,8 +10,10 @@ import React from 'react'; import * as i18n from './translations'; import { ActionLicense } from '../../containers/types'; +import { ErrorMessage } from '../callout/types'; export const getLicenseError = () => ({ + id: 'license-error', title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE, description: ( ({ }); export const getKibanaConfigError = () => ({ + id: 'kibana-config-error', title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE, description: ( ({ ), }); -export const getActionLicenseError = ( - actionLicense: ActionLicense | null -): Array<{ title: string; description: JSX.Element }> => { - let errors: Array<{ title: string; description: JSX.Element }> = []; +export const getActionLicenseError = (actionLicense: ActionLicense | null): ErrorMessage[] => { + let errors: ErrorMessage[] = []; if (actionLicense != null && !actionLicense.enabledInLicense) { errors = [...errors, getLicenseError()]; } diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx index f2de830a7164..d17a2bd21591 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx @@ -10,9 +10,7 @@ import { usePushToService, ReturnUsePushToService, UsePushToService } from '.'; import { TestProviders } from '../../../common/mock'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { basicPush, actionLicenses } from '../../containers/mock'; -import * as i18n from './translations'; import { useGetActionLicense } from '../../containers/use_get_action_license'; -import { getKibanaConfigError, getLicenseError } from './helpers'; import { connectorsMock } from '../../containers/configure/mock'; jest.mock('react-router-dom', () => { @@ -110,7 +108,7 @@ describe('usePushToService', () => { await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); - expect(errorsMsg[0].title).toEqual(getLicenseError().title); + expect(errorsMsg[0].id).toEqual('license-error'); }); }); @@ -132,7 +130,7 @@ describe('usePushToService', () => { await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); - expect(errorsMsg[0].title).toEqual(getKibanaConfigError().title); + expect(errorsMsg[0].id).toEqual('kibana-config-error'); }); }); @@ -152,7 +150,7 @@ describe('usePushToService', () => { await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); - expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CONFIG_TITLE); + expect(errorsMsg[0].id).toEqual('connector-missing-error'); }); }); @@ -171,7 +169,7 @@ describe('usePushToService', () => { await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); - expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE); + expect(errorsMsg[0].id).toEqual('connector-not-selected-error'); }); }); @@ -191,7 +189,7 @@ describe('usePushToService', () => { await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); - expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE); + expect(errorsMsg[0].id).toEqual('connector-deleted-error'); }); }); @@ -212,7 +210,7 @@ describe('usePushToService', () => { await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); - expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE); + expect(errorsMsg[0].id).toEqual('connector-deleted-error'); }); }); @@ -231,7 +229,7 @@ describe('usePushToService', () => { await waitForNextUpdate(); const errorsMsg = result.current.pushCallouts?.props.messages; expect(errorsMsg).toHaveLength(1); - expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE); + expect(errorsMsg[0].id).toEqual('closed-case-push-error'); }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx index 45b515ccacac..7b4a29098bdd 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx @@ -20,6 +20,7 @@ import { Connector } from '../../../../../case/common/api/cases'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { LinkAnchor } from '../../../common/components/links'; import { SecurityPageName } from '../../../app/types'; +import { ErrorMessage } from '../callout/types'; export interface UsePushToService { caseId: string; @@ -76,11 +77,7 @@ export const usePushToService = ({ ); const errorsMsg = useMemo(() => { - let errors: Array<{ - title: string; - description: JSX.Element; - errorType?: 'primary' | 'success' | 'warning' | 'danger'; - }> = []; + let errors: ErrorMessage[] = []; if (actionLicense != null && !actionLicense.enabledInLicense) { errors = [...errors, getLicenseError()]; } @@ -88,6 +85,7 @@ export const usePushToService = ({ errors = [ ...errors, { + id: 'connector-missing-error', title: i18n.PUSH_DISABLE_BY_NO_CONFIG_TITLE, description: ( { {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( )} diff --git a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx index 43c51b32bce0..c3538f0c18ed 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx @@ -15,7 +15,7 @@ import { useGetUserSavedObjectPermissions } from '../../common/lib/kibana'; import { getCaseUrl } from '../../common/components/link_to'; import { navTabs } from '../../app/home/home_navigations'; import { CaseView } from '../components/case_view'; -import { savedObjectReadOnly, CaseCallOut } from '../components/callout'; +import { savedObjectReadOnlyErrorMessage, CaseCallOut } from '../components/callout'; export const CaseDetailsPage = React.memo(() => { const history = useHistory(); @@ -33,8 +33,8 @@ export const CaseDetailsPage = React.memo(() => { {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( )} diff --git a/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.test.tsx b/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.test.tsx new file mode 100644 index 000000000000..d52bc4b1a267 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useKibana } from '../../lib/kibana'; +import { createUseKibanaMock } from '../../mock/kibana_react'; +import { useMessagesStorage, UseMessagesStorage } from './use_messages_storage'; + +jest.mock('../../lib/kibana'); +const useKibanaMock = useKibana as jest.Mock; + +describe('useLocalStorage', () => { + beforeEach(() => { + const services = { ...createUseKibanaMock()().services }; + useKibanaMock.mockImplementation(() => ({ services })); + services.storage.store.clear(); + }); + + it('should return an empty array when there is no messages', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useMessagesStorage() + ); + await waitForNextUpdate(); + const { getMessages } = result.current; + expect(getMessages('case')).toEqual([]); + }); + }); + + it('should add a message', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useMessagesStorage() + ); + await waitForNextUpdate(); + const { getMessages, addMessage } = result.current; + addMessage('case', 'id-1'); + expect(getMessages('case')).toEqual(['id-1']); + }); + }); + + it('should add multiple messages', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useMessagesStorage() + ); + await waitForNextUpdate(); + const { getMessages, addMessage } = result.current; + addMessage('case', 'id-1'); + addMessage('case', 'id-2'); + expect(getMessages('case')).toEqual(['id-1', 'id-2']); + }); + }); + + it('should remove a message', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useMessagesStorage() + ); + await waitForNextUpdate(); + const { getMessages, addMessage, removeMessage } = result.current; + addMessage('case', 'id-1'); + addMessage('case', 'id-2'); + removeMessage('case', 'id-2'); + expect(getMessages('case')).toEqual(['id-1']); + }); + }); + + it('should clear all messages', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useMessagesStorage() + ); + await waitForNextUpdate(); + const { getMessages, addMessage, clearAllMessages } = result.current; + addMessage('case', 'id-1'); + addMessage('case', 'id-2'); + clearAllMessages('case'); + expect(getMessages('case')).toEqual([]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.tsx b/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.tsx new file mode 100644 index 000000000000..0c96712ad9c5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/local_storage/use_messages_storage.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { useKibana } from '../../lib/kibana'; + +export interface UseMessagesStorage { + getMessages: (plugin: string) => string[]; + addMessage: (plugin: string, id: string) => void; + removeMessage: (plugin: string, id: string) => void; + clearAllMessages: (plugin: string) => void; +} + +export const useMessagesStorage = (): UseMessagesStorage => { + const { storage } = useKibana().services; + + const getMessages = useCallback( + (plugin: string): string[] => storage.get(`${plugin}-messages`) ?? [], + [storage] + ); + + const addMessage = useCallback( + (plugin: string, id: string) => { + const pluginStorage = storage.get(`${plugin}-messages`) ?? []; + storage.set(`${plugin}-messages`, [...pluginStorage, id]); + }, + [storage] + ); + + const removeMessage = useCallback( + (plugin: string, id: string) => { + const pluginStorage = storage.get(`${plugin}-messages`) ?? []; + storage.set(`${plugin}-messages`, [...pluginStorage.filter((val: string) => val !== id)]); + }, + [storage] + ); + + const clearAllMessages = useCallback( + (plugin: string): string[] => storage.remove(`${plugin}-messages`), + [storage] + ); + + return { + getMessages, + addMessage, + clearAllMessages, + removeMessage, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts b/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts index cc8970d4df5b..2b639bfdc14f 100644 --- a/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts +++ b/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts @@ -26,6 +26,7 @@ import { DEFAULT_INDEX_PATTERN, } from '../../../common/constants'; import { createKibanaCoreStartMock, createKibanaPluginsStartMock } from './kibana_core'; +import { createSecuritySolutionStorageMock } from './mock_local_storage'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const mockUiSettings: Record = { @@ -74,6 +75,7 @@ export const createUseKibanaMock = () => { const core = createKibanaCoreStartMock(); const plugins = createKibanaPluginsStartMock(); const useUiSetting = createUseUiSettingMock(); + const { storage } = createSecuritySolutionStorageMock(); const services = { ...core, @@ -82,6 +84,7 @@ export const createUseKibanaMock = () => { ...core.uiSettings, get: useUiSetting, }, + storage, }; return () => ({ services }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts index 7c28f88a571d..c3c11289037a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts @@ -10,6 +10,6 @@ export const CALL_OUT_UNAUTHORIZED_MSG = i18n.translate( 'xpack.securitySolution.timeline.callOut.unauthorized.message.description', { defaultMessage: - 'You require permission to auto-save timelines within the SIEM application, though you may continue to use the timeline to search and filter security events', + 'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.', } ); diff --git a/yarn.lock b/yarn.lock index 53fef40b44c9..0a7899e4ac10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5375,6 +5375,13 @@ dependencies: "@types/linkify-it" "*" +"@types/md5@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@types/md5/-/md5-2.2.0.tgz#cd82e16b95973f94bb03dee40c5b6be4a7fb7fb4" + integrity sha512-JN8OVL/wiDlCWTPzplsgMPu0uE9Q6blwp68rYsfk2G8aokRUQ8XD9MEhZwihfAiQvoyE+m31m6i3GFXwYWomKQ== + dependencies: + "@types/node" "*" + "@types/memoize-one@^4.1.0": version "4.1.1" resolved "https://registry.yarnpkg.com/@types/memoize-one/-/memoize-one-4.1.1.tgz#41dd138a4335b5041f7d8fc038f9d593d88b3369"