Skip to content

Commit

Permalink
Persist dismissed callouts to localStorage
Browse files Browse the repository at this point in the history
  • Loading branch information
cnasikas committed Jun 7, 2020
1 parent b87eefc commit dbe23c3
Show file tree
Hide file tree
Showing 7 changed files with 171 additions and 53 deletions.
3 changes: 2 additions & 1 deletion x-pack/plugins/security_solution/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
"lodash": "^4.17.15",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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';

interface CallOutProps {
id: string;
type: NonNullable<ErrorMessage['errorType']>;
title: string;
messages: ErrorMessage[];
showCallOut: boolean;
handleDismissCallout: (id: string) => void;
}

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

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

export const CallOut = memo(CallOutComponent);
Original file line number Diff line number Diff line change
Expand Up @@ -4,79 +4,102 @@
* 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 md5 from 'md5';
import { EuiSpacer } from '@elastic/eui';
import React, { memo, useCallback, useState } from 'react';

import * as i18n from './translations';
import { useSecurityLocalStorage } from '../../../common/containers/use_local_storage';
import { CallOut } from './callout';
import { ErrorMessage } from './types';

export * from './helpers';

interface ErrorMessage {
title: string;
description: JSX.Element;
errorType?: 'primary' | 'success' | 'warning' | 'danger';
}

interface CaseCallOutProps {
title: string;
message?: string;
messages?: ErrorMessage[];
}

type GroupByTypeMessages = {
[key in NonNullable<ErrorMessage['errorType']>]: {
messagesId: string[];
messages: ErrorMessage[];
};
};

interface CalloutVisibility {
[index: string]: boolean;
}

const CaseCallOutComponent = ({ title, message, messages }: CaseCallOutProps) => {
const [showCallOut, setShowCallOut] = useState(true);
const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]);
const { getCallouts, persistDismissCallout } = useSecurityLocalStorage();
const dismissedCallouts = getCallouts('case').reduce<CalloutVisibility>(
(acc, id) => ({
...acc,
[id]: false,
}),
{}
);

const [calloutVisibility, setCalloutVisibility] = useState(dismissedCallouts);
const handleCallOut = useCallback(
(id) => {
setCalloutVisibility((prevState) => ({ ...prevState, [id]: false }));
persistDismissCallout('case', id);
},
[setCalloutVisibility]
);

let callOutMessages = messages ?? [];

if (message) {
callOutMessages = [
...callOutMessages,
{
id: 'generic-message-error',
title: '',
description: <p data-test-subj="callout-message-primary">{message}</p>,
errorType: 'primary',
},
];
}

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 groupedByTypeErrorMessages = callOutMessages.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
);

return showCallOut ? (
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 = md5(groupedByTypeErrorMessages[type].messagesId.join('|'));
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);
Original file line number Diff line number Diff line change
@@ -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';
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +31,7 @@ export const getLicenseError = () => ({
});

export const getKibanaConfigError = () => ({
id: 'kibana-config-error',
title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE,
description: (
<FormattedMessage
Expand All @@ -45,9 +48,7 @@ export const getKibanaConfigError = () => ({
),
});

export const getActionLicenseError = (
actionLicense: ActionLicense | null
): Array<{ title: string; description: JSX.Element }> => {
export const getActionLicenseError = (actionLicense: ActionLicense | null): ErrorMessage[] => {
let errors: Array<{ title: string; description: JSX.Element }> = [];
if (actionLicense != null && !actionLicense.enabledInLicense) {
errors = [...errors, getLicenseError()];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { getLicenseError, getKibanaConfigError } from './helpers';
import * as i18n from './translations';
import { Connector } from '../../../../../case/common/api/cases';
import { CaseServices } from '../../containers/use_get_case_user_actions';
import { ErrorMessage } from '../callout/types';

export interface UsePushToService {
caseId: string;
Expand Down Expand Up @@ -67,18 +68,15 @@ export const usePushToService = ({
}, [caseId, caseServices, caseConnectorId, caseConnectorName, postPushToService, updateCase]);

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()];
}
if (connectors.length === 0 && caseConnectorId === 'none' && !loadingLicense) {
errors = [
...errors,
{
id: 'connector-missing-error',
title: i18n.PUSH_DISABLE_BY_NO_CONFIG_TITLE,
description: (
<FormattedMessage
Expand All @@ -99,6 +97,7 @@ export const usePushToService = ({
errors = [
...errors,
{
id: 'connector-not-selected-error',
title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE,
description: (
<FormattedMessage
Expand All @@ -112,6 +111,7 @@ export const usePushToService = ({
errors = [
...errors,
{
id: 'connector-deleted-error',
title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE,
description: (
<FormattedMessage
Expand All @@ -127,6 +127,7 @@ export const usePushToService = ({
errors = [
...errors,
{
id: 'closed-case-push-error',
title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE,
description: (
<FormattedMessage
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* 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 { Storage } from '../../../../../../src/plugins/kibana_utils/public';

interface UseSecurityLocalStorage {
getCallouts: (plugin: string) => string[];
persistDismissCallout: (plugin: string, id: string) => void;
}

export const useSecurityLocalStorage = (): UseSecurityLocalStorage => {
const storage = new Storage(localStorage);

const getCallouts = useCallback((plugin: string): string[] => {
return storage.get(plugin)?.callouts ?? [];
}, []);

const persistDismissCallout = useCallback((plugin: string, id: string) => {
const pluginStorage = storage.get(plugin) ?? { callouts: [] };
storage.set(plugin, { ...pluginStorage, callouts: [...pluginStorage.callouts, id] });
}, []);

return {
persistDismissCallout,
getCallouts,
};
};

0 comments on commit dbe23c3

Please sign in to comment.