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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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} />

View file

@ -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} />

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

View file

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

View file

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