Skip to content

Commit

Permalink
[7.8] [SIEM][CASE] Persist callout when dismissed (elastic#68372) (el…
Browse files Browse the repository at this point in the history
…astic#70150)

# Conflicts:
#	x-pack/plugins/security_solution/package.json
#	x-pack/plugins/security_solution/public/alerts/components/no_write_alerts_callout/translations.ts
#	x-pack/plugins/security_solution/public/cases/components/callout/index.test.tsx
#	x-pack/plugins/security_solution/public/cases/components/callout/translations.ts
#	x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx
#	x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx
#	x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx
#	x-pack/plugins/security_solution/public/cases/pages/case.tsx
#	x-pack/plugins/security_solution/public/cases/pages/case_details.tsx
#	x-pack/plugins/security_solution/public/common/mock/kibana_react.ts
#	x-pack/plugins/security_solution/public/timelines/components/timeline/header/translations.ts
#	x-pack/plugins/siem/public/containers/local_storage/use_messages_storage.test.tsx
#	x-pack/plugins/siem/public/containers/local_storage/use_messages_storage.tsx
#	x-pack/plugins/siem/public/pages/case/components/callout/callout.test.tsx
#	x-pack/plugins/siem/public/pages/case/components/callout/callout.tsx
#	x-pack/plugins/siem/public/pages/case/components/callout/helpers.test.tsx
#	x-pack/plugins/siem/public/pages/case/components/callout/types.ts
  • Loading branch information
cnasikas authored Jun 27, 2020
1 parent b8090cd commit 950ef9d
Show file tree
Hide file tree
Showing 22 changed files with 654 additions and 154 deletions.
3 changes: 2 additions & 1 deletion x-pack/plugins/siem/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"cypress:run-as-ci": "node ../../../scripts/functional_tests --config ../../test/siem_cypress/config.ts"
},
"devDependencies": {
"@types/lodash": "^4.14.110"
"@types/lodash": "^4.14.110",
"@types/md5": "^2.2.0"
},
"dependencies": {
"lodash": "^4.17.15",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ export const CALL_OUT_UNAUTHORIZED_MSG = i18n.translate(
'xpack.siem.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.',
}
);
Original file line number Diff line number Diff line change
@@ -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<string, UseMessagesStorage>(() =>
useMessagesStorage()
);
await waitForNextUpdate();
const { getMessages } = result.current;
expect(getMessages('case')).toEqual([]);
});
});

it('should add a message', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() =>
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<string, UseMessagesStorage>(() =>
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<string, UseMessagesStorage>(() =>
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<string, UseMessagesStorage>(() =>
useMessagesStorage()
);
await waitForNextUpdate();
const { getMessages, addMessage, clearAllMessages } = result.current;
addMessage('case', 'id-1');
addMessage('case', 'id-2');
clearAllMessages('case');
expect(getMessages('case')).toEqual([]);
});
});
});
Original file line number Diff line number Diff line change
@@ -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,
};
};
3 changes: 3 additions & 0 deletions x-pack/plugins/siem/public/mock/kibana_react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
DEFAULT_INDEX_PATTERN,
} from '../../common/constants';
import { createKibanaCoreStartMock, createKibanaPluginsStartMock } from './kibana_core';
import { createSIEMStorageMock } from './mock_local_storage';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const mockUiSettings: Record<string, any> = {
Expand Down Expand Up @@ -74,6 +75,7 @@ export const createUseKibanaMock = () => {
const core = createKibanaCoreStartMock();
const plugins = createKibanaPluginsStartMock();
const useUiSetting = createUseUiSettingMock();
const { storage } = createSIEMStorageMock();

const services = {
...core,
Expand All @@ -82,6 +84,7 @@ export const createUseKibanaMock = () => {
...core.uiSettings,
get: useUiSetting,
},
storage,
};

return () => ({ services });
Expand Down
34 changes: 34 additions & 0 deletions x-pack/plugins/siem/public/mock/mock_local_storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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 { IStorage, Storage } from '../../../../../src/plugins/kibana_utils/public';

export const localStorageMock = (): IStorage => {
let store: Record<string, unknown> = {};

return {
getItem: (key: string) => {
return store[key] || null;
},
setItem: (key: string, value: unknown) => {
store[key] = value;
},
clear() {
store = {};
},
removeItem(key: string) {
delete store[key];
},
};
};

export const createSIEMStorageMock = () => {
const localStorage = localStorageMock();
return {
localStorage,
storage: new Storage(localStorage),
};
};
6 changes: 3 additions & 3 deletions x-pack/plugins/siem/public/pages/case/case.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useGetUserSavedObjectPermissions } from '../../lib/kibana';
import { SpyRoute } from '../../utils/route/spy_routes';
import { AllCases } from './components/all_cases';

import { savedObjectReadOnly, CaseCallOut } from './components/callout';
import { savedObjectReadOnlyErrorMessage, CaseCallOut } from './components/callout';
import { CaseSavedObjectNoPermissions } from './saved_object_no_permissions';

export const CasesPage = React.memo(() => {
Expand All @@ -22,8 +22,8 @@ export const CasesPage = React.memo(() => {
<WrapperPage>
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
<CaseCallOut
title={savedObjectReadOnly.title}
message={savedObjectReadOnly.description}
title={savedObjectReadOnlyErrorMessage.title}
messages={[{ ...savedObjectReadOnlyErrorMessage }]}
/>
)}
<AllCases userCanCrud={userPermissions?.crud ?? false} />
Expand Down
6 changes: 3 additions & 3 deletions x-pack/plugins/siem/public/pages/case/case_details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { SpyRoute } from '../../utils/route/spy_routes';
import { getCaseUrl } from '../../components/link_to';
import { navTabs } from '../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 userPermissions = useGetUserSavedObjectPermissions();
Expand All @@ -30,8 +30,8 @@ export const CaseDetailsPage = React.memo(() => {
<WrapperPage noPadding>
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
<CaseCallOut
title={savedObjectReadOnly.title}
message={savedObjectReadOnly.description}
title={savedObjectReadOnlyErrorMessage.title}
messages={[{ ...savedObjectReadOnlyErrorMessage }]}
/>
)}
<CaseView caseId={caseId} userCanCrud={userPermissions?.crud ?? false} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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: <p>{'error'}</p>,
},
],
showCallOut: true,
handleDismissCallout: jest.fn(),
};

beforeEach(() => {
jest.clearAllMocks();
});

it('It renders the callout', () => {
const wrapper = mount(<CallOut {...defaultProps} />);
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(<CallOut {...defaultProps} showCallOut={false} />);
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(<CallOut {...defaultProps} messages={[]} />);
expect(wrapper.find(`[data-test-subj="callout-messages-md5-hex"]`).exists()).toBeFalsy();
});

it('transform the button color correctly - primary', () => {
const wrapper = mount(<CallOut {...defaultProps} />);
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(<CallOut {...defaultProps} type={'success'} />);
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(<CallOut {...defaultProps} type={'warning'} />);
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(<CallOut {...defaultProps} type={'danger'} />);
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(<CallOut {...defaultProps} messages={[]} />);
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');
});
});
Original file line number Diff line number Diff line change
@@ -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<ErrorMessage['errorType']>;
title: string;
messages: ErrorMessage[];
showCallOut: boolean;
handleDismissCallout: (id: string, type: NonNullable<ErrorMessage['errorType']>) => void;
}

const CallOutComponent = ({
id,
type,
title,
messages,
showCallOut,
handleDismissCallout,
}: CallOutProps) => {
const handleCallOut = useCallback(() => handleDismissCallout(id, type), [
handleDismissCallout,
id,
type,
]);

return showCallOut ? (
<EuiCallOut title={title} color={type} iconType="gear" data-test-subj={`case-callout-${id}`}>
{!isEmpty(messages) && (
<EuiDescriptionList data-test-subj={`callout-messages-${id}`} listItems={messages} />
)}
<EuiButton
data-test-subj={`callout-dismiss-${id}`}
color={type === 'success' ? 'secondary' : type}
onClick={handleCallOut}
>
{i18n.DISMISS_CALLOUT}
</EuiButton>
</EuiCallOut>
) : null;
};

export const CallOut = memo(CallOutComponent);
Loading

0 comments on commit 950ef9d

Please sign in to comment.