[SIEM][CASE] Persist callout when dismissed (#68372)
This commit is contained in:
parent
e4043b736b
commit
6808903d57
|
@ -13,7 +13,8 @@
|
||||||
"test:generate": "ts-node --project scripts/endpoint/cli_tsconfig.json scripts/endpoint/resolver_generator.ts"
|
"test:generate": "ts-node --project scripts/endpoint/cli_tsconfig.json scripts/endpoint/resolver_generator.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lodash": "^4.14.110"
|
"@types/lodash": "^4.14.110",
|
||||||
|
"@types/md5": "^2.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/rbush": "^3.0.0",
|
"@types/rbush": "^3.0.0",
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n';
|
||||||
export const NO_WRITE_ALERTS_CALLOUT_TITLE = i18n.translate(
|
export const NO_WRITE_ALERTS_CALLOUT_TITLE = i18n.translate(
|
||||||
'xpack.securitySolution.detectionEngine.noWriteAlertsCallOutTitle',
|
'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',
|
'xpack.securitySolution.detectionEngine.noWriteAlertsCallOutMsg',
|
||||||
{
|
{
|
||||||
defaultMessage:
|
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.',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,10 +3,18 @@
|
||||||
* or more contributor license agreements. Licensed under the Elastic License;
|
* or more contributor license agreements. Licensed under the Elastic License;
|
||||||
* you may not use this file except in compliance with 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 * 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,
|
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));
|
||||||
|
|
|
@ -7,104 +7,210 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { mount } from 'enzyme';
|
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 = {
|
jest.mock('../../../common/containers/local_storage/use_messages_storage');
|
||||||
title: 'hey title',
|
|
||||||
|
const useSecurityLocalStorageMock = useMessagesStorage as jest.Mock;
|
||||||
|
const securityLocalStorageMock = {
|
||||||
|
getMessages: jest.fn(() => []),
|
||||||
|
addMessage: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('CaseCallOut ', () => {
|
describe('CaseCallOut ', () => {
|
||||||
it('Renders single message callout', () => {
|
beforeEach(() => {
|
||||||
const props = {
|
jest.clearAllMocks();
|
||||||
...defaultProps,
|
useSecurityLocalStorageMock.mockImplementation(() => securityLocalStorageMock);
|
||||||
message: 'we have one message',
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapper = mount(<CaseCallOut {...props} />);
|
|
||||||
|
|
||||||
expect(wrapper.find(`[data-test-subj="callout-message-primary"]`).last().exists()).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Renders multi message callout', () => {
|
it('renders a callout correctly', () => {
|
||||||
const props = {
|
const props: CaseCallOutProps = {
|
||||||
...defaultProps,
|
title: 'hey title',
|
||||||
messages: [
|
messages: [
|
||||||
{ ...defaultProps, description: <p>{'we have two messages'}</p> },
|
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
|
||||||
{ ...defaultProps, description: <p>{'for real'}</p> },
|
{ id: 'message-two', title: 'title', description: <p>{'for real'}</p> },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const wrapper = mount(<CaseCallOut {...props} />);
|
const wrapper = mount(
|
||||||
expect(wrapper.find(`[data-test-subj="callout-message-primary"]`).last().exists()).toBeFalsy();
|
<TestProviders>
|
||||||
expect(
|
<CaseCallOut {...props} />
|
||||||
wrapper.find(`[data-test-subj="callout-messages-primary"]`).last().exists()
|
</TestProviders>
|
||||||
).toBeTruthy();
|
);
|
||||||
|
|
||||||
|
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', () => {
|
it('groups the messages correctly', () => {
|
||||||
const props = {
|
const props: CaseCallOutProps = {
|
||||||
...defaultProps,
|
title: 'hey title',
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
...defaultProps,
|
id: 'message-one',
|
||||||
|
title: 'title one',
|
||||||
description: <p>{'we have two messages'}</p>,
|
description: <p>{'we have two messages'}</p>,
|
||||||
errorType: 'danger' as 'primary' | 'success' | 'warning' | 'danger',
|
errorType: 'danger',
|
||||||
},
|
},
|
||||||
{ ...defaultProps, description: <p>{'for real'}</p> },
|
{ id: 'message-two', title: 'title two', description: <p>{'for real'}</p> },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const wrapper = mount(<CaseCallOut {...props} />);
|
|
||||||
expect(wrapper.find(`[data-test-subj="callout-messages-danger"]`).last().exists()).toBeTruthy();
|
const wrapper = mount(
|
||||||
|
<TestProviders>
|
||||||
|
<CaseCallOut {...props} />
|
||||||
|
</TestProviders>
|
||||||
|
);
|
||||||
|
|
||||||
|
const idDanger = createCalloutId(['message-one']);
|
||||||
|
const idPrimary = createCalloutId(['message-two']);
|
||||||
|
|
||||||
expect(
|
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();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('it applies the correct color to button', () => {
|
it('dismisses the callout correctly', () => {
|
||||||
const props = {
|
const props: CaseCallOutProps = {
|
||||||
...defaultProps,
|
title: 'hey title',
|
||||||
|
messages: [
|
||||||
|
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const wrapper = mount(
|
||||||
|
<TestProviders>
|
||||||
|
<CaseCallOut {...props} />
|
||||||
|
</TestProviders>
|
||||||
|
);
|
||||||
|
|
||||||
|
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: <p>{'we have two messages'}</p> },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = mount(
|
||||||
|
<TestProviders>
|
||||||
|
<CaseCallOut {...props} />
|
||||||
|
</TestProviders>
|
||||||
|
);
|
||||||
|
|
||||||
|
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: <p>{'we have two messages'}</p> },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = createCalloutId(['message-one']);
|
||||||
|
|
||||||
|
useSecurityLocalStorageMock.mockImplementation(() => ({
|
||||||
|
...securityLocalStorageMock,
|
||||||
|
getMessages: jest.fn(() => [id]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const wrapper = mount(
|
||||||
|
<TestProviders>
|
||||||
|
<CaseCallOut {...props} />
|
||||||
|
</TestProviders>
|
||||||
|
);
|
||||||
|
|
||||||
|
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: [
|
messages: [
|
||||||
{
|
{
|
||||||
...defaultProps,
|
id: 'message-one',
|
||||||
description: <p>{'one'}</p>,
|
title: 'title one',
|
||||||
errorType: 'danger' as 'primary' | 'success' | 'warning' | 'danger',
|
description: <p>{'we have two messages'}</p>,
|
||||||
},
|
errorType: 'danger',
|
||||||
{
|
|
||||||
...defaultProps,
|
|
||||||
description: <p>{'two'}</p>,
|
|
||||||
errorType: 'success' as 'primary' | 'success' | 'warning' | 'danger',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...defaultProps,
|
|
||||||
description: <p>{'three'}</p>,
|
|
||||||
errorType: 'primary' as 'primary' | 'success' | 'warning' | 'danger',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapper = mount(<CaseCallOut {...props} />);
|
const wrapper = mount(
|
||||||
|
<TestProviders>
|
||||||
expect(wrapper.find(`[data-test-subj="callout-dismiss-danger"]`).first().prop('color')).toBe(
|
<CaseCallOut {...props} />
|
||||||
'danger'
|
</TestProviders>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(wrapper.find(`[data-test-subj="callout-dismiss-success"]`).first().prop('color')).toBe(
|
const id = createCalloutId(['message-one']);
|
||||||
'secondary'
|
wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click');
|
||||||
);
|
wrapper.update();
|
||||||
|
expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled();
|
||||||
expect(wrapper.find(`[data-test-subj="callout-dismiss-primary"]`).first().prop('color')).toBe(
|
|
||||||
'primary'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Dismisses callout', () => {
|
it('do not persist a callout of type warning', () => {
|
||||||
const props = {
|
const props: CaseCallOutProps = {
|
||||||
...defaultProps,
|
title: 'hey title',
|
||||||
message: 'we have one message',
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'message-one',
|
||||||
|
title: 'title one',
|
||||||
|
description: <p>{'we have two messages'}</p>,
|
||||||
|
errorType: 'warning',
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
const wrapper = mount(<CaseCallOut {...props} />);
|
|
||||||
expect(wrapper.find(`[data-test-subj="case-call-out-primary"]`).exists()).toBeTruthy();
|
const wrapper = mount(
|
||||||
wrapper.find(`[data-test-subj="callout-dismiss-primary"]`).last().simulate('click');
|
<TestProviders>
|
||||||
expect(wrapper.find(`[data-test-subj="case-call-out-primary"]`).exists()).toBeFalsy();
|
<CaseCallOut {...props} />
|
||||||
|
</TestProviders>
|
||||||
|
);
|
||||||
|
|
||||||
|
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: <p>{'we have two messages'}</p>,
|
||||||
|
errorType: 'success',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = mount(
|
||||||
|
<TestProviders>
|
||||||
|
<CaseCallOut {...props} />
|
||||||
|
</TestProviders>
|
||||||
|
);
|
||||||
|
|
||||||
|
const id = createCalloutId(['message-one']);
|
||||||
|
wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click');
|
||||||
|
wrapper.update();
|
||||||
|
expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,79 +4,99 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EuiCallOut, EuiButton, EuiDescriptionList, EuiSpacer } from '@elastic/eui';
|
import { EuiSpacer } from '@elastic/eui';
|
||||||
import { isEmpty } from 'lodash/fp';
|
import React, { memo, useCallback, useState, useMemo } from 'react';
|
||||||
import React, { memo, useCallback, useState } 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';
|
export * from './helpers';
|
||||||
|
|
||||||
interface ErrorMessage {
|
export interface CaseCallOutProps {
|
||||||
title: string;
|
title: string;
|
||||||
description: JSX.Element;
|
|
||||||
errorType?: 'primary' | 'success' | 'warning' | 'danger';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CaseCallOutProps {
|
|
||||||
title: string;
|
|
||||||
message?: string;
|
|
||||||
messages?: ErrorMessage[];
|
messages?: ErrorMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const CaseCallOutComponent = ({ title, message, messages }: CaseCallOutProps) => {
|
type GroupByTypeMessages = {
|
||||||
const [showCallOut, setShowCallOut] = useState(true);
|
[key in NonNullable<ErrorMessage['errorType']>]: {
|
||||||
const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]);
|
messagesId: string[];
|
||||||
let callOutMessages = messages ?? [];
|
messages: ErrorMessage[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
if (message) {
|
interface CalloutVisibility {
|
||||||
callOutMessages = [
|
[index: string]: boolean;
|
||||||
...callOutMessages,
|
}
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
description: <p data-test-subj="callout-message-primary">{message}</p>,
|
|
||||||
errorType: 'primary',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupedErrorMessages = callOutMessages.reduce((acc, currentMessage: ErrorMessage) => {
|
const CaseCallOutComponent = ({ title, messages = [] }: CaseCallOutProps) => {
|
||||||
const key = currentMessage.errorType == null ? 'primary' : currentMessage.errorType;
|
const { getMessages, addMessage } = useMessagesStorage();
|
||||||
return {
|
|
||||||
...acc,
|
|
||||||
[key]: [...(acc[key] || []), currentMessage],
|
|
||||||
};
|
|
||||||
}, {} as { [key in NonNullable<ErrorMessage['errorType']>]: ErrorMessage[] });
|
|
||||||
|
|
||||||
return showCallOut ? (
|
const caseMessages = useMemo(() => getMessages('case'), [getMessages]);
|
||||||
|
const dismissedCallouts = useMemo(
|
||||||
|
() =>
|
||||||
|
caseMessages.reduce<CalloutVisibility>(
|
||||||
|
(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<GroupByTypeMessages>(
|
||||||
|
(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<keyof ErrorMessage['errorType']>).map((key) => (
|
{(Object.keys(groupedByTypeErrorMessages) as Array<keyof ErrorMessage['errorType']>).map(
|
||||||
<React.Fragment key={key}>
|
(type: NonNullable<ErrorMessage['errorType']>) => {
|
||||||
<EuiCallOut
|
const id = createCalloutId(groupedByTypeErrorMessages[type].messagesId);
|
||||||
title={title}
|
return (
|
||||||
color={key}
|
<React.Fragment key={id}>
|
||||||
iconType="gear"
|
<CallOut
|
||||||
data-test-subj={`case-call-out-${key}`}
|
id={id}
|
||||||
>
|
type={type}
|
||||||
{!isEmpty(groupedErrorMessages[key]) && (
|
title={title}
|
||||||
<EuiDescriptionList
|
messages={groupedByTypeErrorMessages[type].messages}
|
||||||
data-test-subj={`callout-messages-${key}`}
|
showCallOut={calloutVisibility[id] ?? true}
|
||||||
listItems={groupedErrorMessages[key]}
|
handleDismissCallout={handleCallOut}
|
||||||
/>
|
/>
|
||||||
)}
|
<EuiSpacer />
|
||||||
<EuiButton
|
</React.Fragment>
|
||||||
data-test-subj={`callout-dismiss-${key}`}
|
);
|
||||||
color={key === 'success' ? 'secondary' : key}
|
}
|
||||||
onClick={handleCallOut}
|
)}
|
||||||
>
|
|
||||||
{i18n.DISMISS_CALLOUT}
|
|
||||||
</EuiButton>
|
|
||||||
</EuiCallOut>
|
|
||||||
<EuiSpacer />
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
) : null;
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CaseCallOut = memo(CaseCallOutComponent);
|
export const CaseCallOut = memo(CaseCallOutComponent);
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n';
|
||||||
export const READ_ONLY_SAVED_OBJECT_TITLE = i18n.translate(
|
export const READ_ONLY_SAVED_OBJECT_TITLE = i18n.translate(
|
||||||
'xpack.securitySolution.case.readOnlySavedObjectTitle',
|
'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',
|
'xpack.securitySolution.case.readOnlySavedObjectDescription',
|
||||||
{
|
{
|
||||||
defaultMessage:
|
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.',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
}
|
|
@ -10,8 +10,10 @@ import React from 'react';
|
||||||
|
|
||||||
import * as i18n from './translations';
|
import * as i18n from './translations';
|
||||||
import { ActionLicense } from '../../containers/types';
|
import { ActionLicense } from '../../containers/types';
|
||||||
|
import { ErrorMessage } from '../callout/types';
|
||||||
|
|
||||||
export const getLicenseError = () => ({
|
export const getLicenseError = () => ({
|
||||||
|
id: 'license-error',
|
||||||
title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE,
|
title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE,
|
||||||
description: (
|
description: (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
@ -29,6 +31,7 @@ export const getLicenseError = () => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getKibanaConfigError = () => ({
|
export const getKibanaConfigError = () => ({
|
||||||
|
id: 'kibana-config-error',
|
||||||
title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE,
|
title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE,
|
||||||
description: (
|
description: (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
@ -45,10 +48,8 @@ export const getKibanaConfigError = () => ({
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getActionLicenseError = (
|
export const getActionLicenseError = (actionLicense: ActionLicense | null): ErrorMessage[] => {
|
||||||
actionLicense: ActionLicense | null
|
let errors: ErrorMessage[] = [];
|
||||||
): Array<{ title: string; description: JSX.Element }> => {
|
|
||||||
let errors: Array<{ title: string; description: JSX.Element }> = [];
|
|
||||||
if (actionLicense != null && !actionLicense.enabledInLicense) {
|
if (actionLicense != null && !actionLicense.enabledInLicense) {
|
||||||
errors = [...errors, getLicenseError()];
|
errors = [...errors, getLicenseError()];
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,9 +10,7 @@ import { usePushToService, ReturnUsePushToService, UsePushToService } from '.';
|
||||||
import { TestProviders } from '../../../common/mock';
|
import { TestProviders } from '../../../common/mock';
|
||||||
import { usePostPushToService } from '../../containers/use_post_push_to_service';
|
import { usePostPushToService } from '../../containers/use_post_push_to_service';
|
||||||
import { basicPush, actionLicenses } from '../../containers/mock';
|
import { basicPush, actionLicenses } from '../../containers/mock';
|
||||||
import * as i18n from './translations';
|
|
||||||
import { useGetActionLicense } from '../../containers/use_get_action_license';
|
import { useGetActionLicense } from '../../containers/use_get_action_license';
|
||||||
import { getKibanaConfigError, getLicenseError } from './helpers';
|
|
||||||
import { connectorsMock } from '../../containers/configure/mock';
|
import { connectorsMock } from '../../containers/configure/mock';
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => {
|
jest.mock('react-router-dom', () => {
|
||||||
|
@ -110,7 +108,7 @@ describe('usePushToService', () => {
|
||||||
await waitForNextUpdate();
|
await waitForNextUpdate();
|
||||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||||
expect(errorsMsg).toHaveLength(1);
|
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();
|
await waitForNextUpdate();
|
||||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||||
expect(errorsMsg).toHaveLength(1);
|
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();
|
await waitForNextUpdate();
|
||||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||||
expect(errorsMsg).toHaveLength(1);
|
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();
|
await waitForNextUpdate();
|
||||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||||
expect(errorsMsg).toHaveLength(1);
|
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();
|
await waitForNextUpdate();
|
||||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||||
expect(errorsMsg).toHaveLength(1);
|
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();
|
await waitForNextUpdate();
|
||||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||||
expect(errorsMsg).toHaveLength(1);
|
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();
|
await waitForNextUpdate();
|
||||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||||
expect(errorsMsg).toHaveLength(1);
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { Connector } from '../../../../../case/common/api/cases';
|
||||||
import { CaseServices } from '../../containers/use_get_case_user_actions';
|
import { CaseServices } from '../../containers/use_get_case_user_actions';
|
||||||
import { LinkAnchor } from '../../../common/components/links';
|
import { LinkAnchor } from '../../../common/components/links';
|
||||||
import { SecurityPageName } from '../../../app/types';
|
import { SecurityPageName } from '../../../app/types';
|
||||||
|
import { ErrorMessage } from '../callout/types';
|
||||||
|
|
||||||
export interface UsePushToService {
|
export interface UsePushToService {
|
||||||
caseId: string;
|
caseId: string;
|
||||||
|
@ -76,11 +77,7 @@ export const usePushToService = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const errorsMsg = useMemo(() => {
|
const errorsMsg = useMemo(() => {
|
||||||
let errors: Array<{
|
let errors: ErrorMessage[] = [];
|
||||||
title: string;
|
|
||||||
description: JSX.Element;
|
|
||||||
errorType?: 'primary' | 'success' | 'warning' | 'danger';
|
|
||||||
}> = [];
|
|
||||||
if (actionLicense != null && !actionLicense.enabledInLicense) {
|
if (actionLicense != null && !actionLicense.enabledInLicense) {
|
||||||
errors = [...errors, getLicenseError()];
|
errors = [...errors, getLicenseError()];
|
||||||
}
|
}
|
||||||
|
@ -88,6 +85,7 @@ export const usePushToService = ({
|
||||||
errors = [
|
errors = [
|
||||||
...errors,
|
...errors,
|
||||||
{
|
{
|
||||||
|
id: 'connector-missing-error',
|
||||||
title: i18n.PUSH_DISABLE_BY_NO_CONFIG_TITLE,
|
title: i18n.PUSH_DISABLE_BY_NO_CONFIG_TITLE,
|
||||||
description: (
|
description: (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
@ -112,6 +110,7 @@ export const usePushToService = ({
|
||||||
errors = [
|
errors = [
|
||||||
...errors,
|
...errors,
|
||||||
{
|
{
|
||||||
|
id: 'connector-not-selected-error',
|
||||||
title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE,
|
title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE,
|
||||||
description: (
|
description: (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
@ -125,6 +124,7 @@ export const usePushToService = ({
|
||||||
errors = [
|
errors = [
|
||||||
...errors,
|
...errors,
|
||||||
{
|
{
|
||||||
|
id: 'connector-deleted-error',
|
||||||
title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE,
|
title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE,
|
||||||
description: (
|
description: (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
@ -140,6 +140,7 @@ export const usePushToService = ({
|
||||||
errors = [
|
errors = [
|
||||||
...errors,
|
...errors,
|
||||||
{
|
{
|
||||||
|
id: 'closed-case-push-error',
|
||||||
title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE,
|
title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE,
|
||||||
description: (
|
description: (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { useGetUserSavedObjectPermissions } from '../../common/lib/kibana';
|
||||||
import { SpyRoute } from '../../common/utils/route/spy_routes';
|
import { SpyRoute } from '../../common/utils/route/spy_routes';
|
||||||
import { AllCases } from '../components/all_cases';
|
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';
|
import { CaseSavedObjectNoPermissions } from './saved_object_no_permissions';
|
||||||
import { SecurityPageName } from '../../app/types';
|
import { SecurityPageName } from '../../app/types';
|
||||||
|
|
||||||
|
@ -23,8 +23,8 @@ export const CasesPage = React.memo(() => {
|
||||||
<WrapperPage>
|
<WrapperPage>
|
||||||
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
|
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
|
||||||
<CaseCallOut
|
<CaseCallOut
|
||||||
title={savedObjectReadOnly.title}
|
title={savedObjectReadOnlyErrorMessage.title}
|
||||||
message={savedObjectReadOnly.description}
|
messages={[{ ...savedObjectReadOnlyErrorMessage }]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<AllCases userCanCrud={userPermissions?.crud ?? false} />
|
<AllCases userCanCrud={userPermissions?.crud ?? false} />
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { useGetUserSavedObjectPermissions } from '../../common/lib/kibana';
|
||||||
import { getCaseUrl } from '../../common/components/link_to';
|
import { getCaseUrl } from '../../common/components/link_to';
|
||||||
import { navTabs } from '../../app/home/home_navigations';
|
import { navTabs } from '../../app/home/home_navigations';
|
||||||
import { CaseView } from '../components/case_view';
|
import { CaseView } from '../components/case_view';
|
||||||
import { savedObjectReadOnly, CaseCallOut } from '../components/callout';
|
import { savedObjectReadOnlyErrorMessage, CaseCallOut } from '../components/callout';
|
||||||
|
|
||||||
export const CaseDetailsPage = React.memo(() => {
|
export const CaseDetailsPage = React.memo(() => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
@ -33,8 +33,8 @@ export const CaseDetailsPage = React.memo(() => {
|
||||||
<WrapperPage noPadding>
|
<WrapperPage noPadding>
|
||||||
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
|
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
|
||||||
<CaseCallOut
|
<CaseCallOut
|
||||||
title={savedObjectReadOnly.title}
|
title={savedObjectReadOnlyErrorMessage.title}
|
||||||
message={savedObjectReadOnly.description}
|
messages={[{ ...savedObjectReadOnlyErrorMessage }]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CaseView caseId={caseId} userCanCrud={userPermissions?.crud ?? false} />
|
<CaseView caseId={caseId} userCanCrud={userPermissions?.crud ?? false} />
|
||||||
|
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -26,6 +26,7 @@ import {
|
||||||
DEFAULT_INDEX_PATTERN,
|
DEFAULT_INDEX_PATTERN,
|
||||||
} from '../../../common/constants';
|
} from '../../../common/constants';
|
||||||
import { createKibanaCoreStartMock, createKibanaPluginsStartMock } from './kibana_core';
|
import { createKibanaCoreStartMock, createKibanaPluginsStartMock } from './kibana_core';
|
||||||
|
import { createSecuritySolutionStorageMock } from './mock_local_storage';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export const mockUiSettings: Record<string, any> = {
|
export const mockUiSettings: Record<string, any> = {
|
||||||
|
@ -74,6 +75,7 @@ export const createUseKibanaMock = () => {
|
||||||
const core = createKibanaCoreStartMock();
|
const core = createKibanaCoreStartMock();
|
||||||
const plugins = createKibanaPluginsStartMock();
|
const plugins = createKibanaPluginsStartMock();
|
||||||
const useUiSetting = createUseUiSettingMock();
|
const useUiSetting = createUseUiSettingMock();
|
||||||
|
const { storage } = createSecuritySolutionStorageMock();
|
||||||
|
|
||||||
const services = {
|
const services = {
|
||||||
...core,
|
...core,
|
||||||
|
@ -82,6 +84,7 @@ export const createUseKibanaMock = () => {
|
||||||
...core.uiSettings,
|
...core.uiSettings,
|
||||||
get: useUiSetting,
|
get: useUiSetting,
|
||||||
},
|
},
|
||||||
|
storage,
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => ({ services });
|
return () => ({ services });
|
||||||
|
|
|
@ -10,6 +10,6 @@ export const CALL_OUT_UNAUTHORIZED_MSG = i18n.translate(
|
||||||
'xpack.securitySolution.timeline.callOut.unauthorized.message.description',
|
'xpack.securitySolution.timeline.callOut.unauthorized.message.description',
|
||||||
{
|
{
|
||||||
defaultMessage:
|
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.',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -5375,6 +5375,13 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/linkify-it" "*"
|
"@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":
|
"@types/memoize-one@^4.1.0":
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/memoize-one/-/memoize-one-4.1.1.tgz#41dd138a4335b5041f7d8fc038f9d593d88b3369"
|
resolved "https://registry.yarnpkg.com/@types/memoize-one/-/memoize-one-4.1.1.tgz#41dd138a4335b5041f7d8fc038f9d593d88b3369"
|
||||||
|
|
Loading…
Reference in a new issue