[SIEM][CASE] Persist callout when dismissed (#68372)

This commit is contained in:
Christos Nasikas 2020-06-26 21:31:41 +03:00 committed by GitHub
parent e4043b736b
commit 6808903d57
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 621 additions and 157 deletions

View file

@ -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",

View file

@ -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.',
}
);

View file

@ -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');
});
});

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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));

View file

@ -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(<CaseCallOut {...props} />);
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: <p>{'we have two messages'}</p> },
{ ...defaultProps, description: <p>{'for real'}</p> },
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
{ id: 'message-two', title: 'title', description: <p>{'for real'}</p> },
],
};
const wrapper = mount(<CaseCallOut {...props} />);
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(
<TestProviders>
<CaseCallOut {...props} />
</TestProviders>
);
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: <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(
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: <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: [
{
...defaultProps,
description: <p>{'one'}</p>,
errorType: 'danger' as 'primary' | 'success' | 'warning' | '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',
id: 'message-one',
title: 'title one',
description: <p>{'we have two messages'}</p>,
errorType: 'danger',
},
],
};
const wrapper = mount(<CaseCallOut {...props} />);
expect(wrapper.find(`[data-test-subj="callout-dismiss-danger"]`).first().prop('color')).toBe(
'danger'
const wrapper = mount(
<TestProviders>
<CaseCallOut {...props} />
</TestProviders>
);
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: <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();
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(
<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();
});
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();
});
});

View file

@ -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<ErrorMessage['errorType']>]: {
messagesId: string[];
messages: ErrorMessage[];
};
};
if (message) {
callOutMessages = [
...callOutMessages,
{
title: '',
description: <p data-test-subj="callout-message-primary">{message}</p>,
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['errorType']>]: ErrorMessage[] });
const CaseCallOutComponent = ({ title, messages = [] }: CaseCallOutProps) => {
const { getMessages, addMessage } = useMessagesStorage();
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) => (
<React.Fragment key={key}>
<EuiCallOut
title={title}
color={key}
iconType="gear"
data-test-subj={`case-call-out-${key}`}
>
{!isEmpty(groupedErrorMessages[key]) && (
<EuiDescriptionList
data-test-subj={`callout-messages-${key}`}
listItems={groupedErrorMessages[key]}
{(Object.keys(groupedByTypeErrorMessages) as Array<keyof ErrorMessage['errorType']>).map(
(type: NonNullable<ErrorMessage['errorType']>) => {
const id = createCalloutId(groupedByTypeErrorMessages[type].messagesId);
return (
<React.Fragment key={id}>
<CallOut
id={id}
type={type}
title={title}
messages={groupedByTypeErrorMessages[type].messages}
showCallOut={calloutVisibility[id] ?? true}
handleDismissCallout={handleCallOut}
/>
)}
<EuiButton
data-test-subj={`callout-dismiss-${key}`}
color={key === 'success' ? 'secondary' : key}
onClick={handleCallOut}
>
{i18n.DISMISS_CALLOUT}
</EuiButton>
</EuiCallOut>
<EuiSpacer />
</React.Fragment>
))}
<EuiSpacer />
</React.Fragment>
);
}
)}
</>
) : null;
);
};
export const CaseCallOut = memo(CaseCallOutComponent);

View file

@ -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.',
}
);

View file

@ -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';
}

View file

@ -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: (
<FormattedMessage
@ -29,6 +31,7 @@ export const getLicenseError = () => ({
});
export const getKibanaConfigError = () => ({
id: 'kibana-config-error',
title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE,
description: (
<FormattedMessage
@ -45,10 +48,8 @@ export const getKibanaConfigError = () => ({
),
});
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()];
}

View file

@ -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');
});
});
});

View file

@ -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: (
<FormattedMessage
@ -112,6 +110,7 @@ export const usePushToService = ({
errors = [
...errors,
{
id: 'connector-not-selected-error',
title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE,
description: (
<FormattedMessage
@ -125,6 +124,7 @@ export const usePushToService = ({
errors = [
...errors,
{
id: 'connector-deleted-error',
title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE,
description: (
<FormattedMessage
@ -140,6 +140,7 @@ export const usePushToService = ({
errors = [
...errors,
{
id: 'closed-case-push-error',
title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE,
description: (
<FormattedMessage

View file

@ -11,7 +11,7 @@ import { useGetUserSavedObjectPermissions } from '../../common/lib/kibana';
import { SpyRoute } from '../../common/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';
import { SecurityPageName } from '../../app/types';
@ -23,8 +23,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} />

View file

@ -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(() => {
<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} />

View file

@ -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([]);
});
});
});

View file

@ -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,
};
};

View file

@ -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<string, any> = {
@ -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 });

View file

@ -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.',
}
);

View file

@ -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"