[7.x] [SIEM] Apply Toast guidelines from EUI (#38578) (#41193)

## Summary

Apply toast guidelines from EUI (https://elastic.github.io/eui/#/guidelines/toasts) to SIEM applications;

### Checklist

Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR.

- [x] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)
- ~~[ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)~~
- ~~[ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~~
- [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios
- ~~[ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~~

### For maintainers

- ~~[ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~
- ~~[ ] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~
This commit is contained in:
Frank Hassanabad 2019-07-16 07:09:41 -06:00 committed by GitHub
parent 1daf91b7e3
commit 96e713c15b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 821 additions and 203 deletions

View file

@ -17,11 +17,12 @@ import { BehaviorSubject } from 'rxjs';
import { pluck } from 'rxjs/operators';
import { I18nContext } from 'ui/i18n';
import { ErrorToast } from '../components/error_toast';
import { ErrorToastDispatcher } from '../components/error_toast_dispatcher';
import { KibanaConfigContext } from '../lib/adapters/framework/kibana_framework_adapter';
import { AppFrontendLibs } from '../lib/lib';
import { PageRouter } from '../routes';
import { createStore } from '../store/store';
import { GlobalToaster, ManageGlobalToaster } from '../components/toasters';
import { MlCapabilitiesProvider } from '../components/ml/permissions/ml_capabilities_provider';
export const startApp = async (libs: AppFrontendLibs) => {
@ -34,23 +35,26 @@ export const startApp = async (libs: AppFrontendLibs) => {
libs.framework.render(
<EuiErrorBoundary>
<I18nContext>
<ReduxStoreProvider store={store}>
<ApolloProvider client={libs.apolloClient}>
<ThemeProvider
theme={() => ({
eui: libs.framework.darkMode ? euiDarkVars : euiLightVars,
darkMode: libs.framework.darkMode,
})}
>
<KibanaConfigContext.Provider value={libs.framework}>
<MlCapabilitiesProvider>
<PageRouter history={history} />
</MlCapabilitiesProvider>
</KibanaConfigContext.Provider>
</ThemeProvider>
<ErrorToast />
</ApolloProvider>
</ReduxStoreProvider>
<ManageGlobalToaster>
<ReduxStoreProvider store={store}>
<ApolloProvider client={libs.apolloClient}>
<ThemeProvider
theme={() => ({
eui: libs.framework.darkMode ? euiDarkVars : euiLightVars,
darkMode: libs.framework.darkMode,
})}
>
<KibanaConfigContext.Provider value={libs.framework}>
<MlCapabilitiesProvider>
<PageRouter history={history} />
</MlCapabilitiesProvider>
</KibanaConfigContext.Provider>
</ThemeProvider>
<ErrorToastDispatcher />
<GlobalToaster />
</ApolloProvider>
</ReduxStoreProvider>
</ManageGlobalToaster>
</I18nContext>
</EuiErrorBoundary>
);

View file

@ -1,7 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Error Toast rendering it renders the default Authentication table 1`] = `
<Connect(pure(Component))
toastLifeTimeMs={9999999999}
/>
`;

View file

@ -1,70 +0,0 @@
/*
* 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 { EuiGlobalToastList, EuiGlobalToastListToast as Toast } from '@elastic/eui';
import React from 'react';
import { connect } from 'react-redux';
import { pure } from 'recompose';
import { ActionCreator } from 'typescript-fsa';
import { appModel, appSelectors, State } from '../../store';
import { appActions } from '../../store/app';
interface OwnProps {
toastLifeTimeMs?: number;
}
interface ReduxProps {
errors?: appModel.Error[];
}
interface DispatchProps {
addError?: ActionCreator<{ id: string; title: string; message: string }>;
removeError?: ActionCreator<{ id: string }>;
}
type Props = OwnProps & ReduxProps & DispatchProps;
const ErrorToastComponent = pure<Props>(({ toastLifeTimeMs = 10000, errors = [], removeError }) =>
globalListFromToasts(errorsToToasts(errors), removeError!, toastLifeTimeMs)
);
export const globalListFromToasts = (
toasts: Toast[],
removeError: ActionCreator<{ id: string }>,
toastLifeTimeMs: number
) =>
toasts.length !== 0 ? (
<EuiGlobalToastList
toasts={toasts}
dismissToast={({ id }) => removeError({ id })}
toastLifeTimeMs={toastLifeTimeMs}
/>
) : null;
export const errorsToToasts = (errors: appModel.Error[]): Toast[] =>
errors.map(({ id, title, message }) => {
const toast: Toast = {
id,
title,
color: 'danger',
iconType: 'alert',
text: <p>{message}</p>,
};
return toast;
});
const makeMapStateToProps = () => {
const getErrorSelector = appSelectors.errorsSelector();
return (state: State) => getErrorSelector(state);
};
export const ErrorToast = connect(
makeMapStateToProps,
{
removeError: appActions.removeError,
}
)(ErrorToastComponent);

View file

@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Error Toast Dispatcher rendering it renders 1`] = `
<Connect(ErrorToastDispatcherComponent)
toastLifeTimeMs={9999999999}
/>
`;

View file

@ -12,10 +12,10 @@ import { Provider } from 'react-redux';
import { apolloClientObservable, mockGlobalState } from '../../mock';
import { createStore } from '../../store/store';
import { ErrorToast } from '.';
import { ErrorToastDispatcher } from '.';
import { State } from '../../store/reducer';
describe('Error Toast', () => {
describe('Error Toast Dispatcher', () => {
const state: State = mockGlobalState;
let store = createStore(state, apolloClientObservable);
@ -24,10 +24,10 @@ describe('Error Toast', () => {
});
describe('rendering', () => {
test('it renders the default Authentication table', () => {
test('it renders', () => {
const wrapper = shallow(
<Provider store={store}>
<ErrorToast toastLifeTimeMs={9999999999} />
<ErrorToastDispatcher toastLifeTimeMs={9999999999} />
</Provider>
);
expect(toJson(wrapper)).toMatchSnapshot();

View file

@ -0,0 +1,66 @@
/*
* 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 { useEffect } from 'react';
import { connect } from 'react-redux';
import { ActionCreator } from 'typescript-fsa';
import { appModel, appSelectors, State } from '../../store';
import { appActions } from '../../store/app';
import { useStateToaster } from '../toasters';
interface OwnProps {
toastLifeTimeMs?: number;
}
interface ReduxProps {
errors?: appModel.Error[];
}
interface DispatchProps {
removeError: ActionCreator<{ id: string }>;
}
type Props = OwnProps & ReduxProps & DispatchProps;
const ErrorToastDispatcherComponent = ({
toastLifeTimeMs = 5000,
errors = [],
removeError,
}: Props) => {
const [{ toasts }, dispatchToaster] = useStateToaster();
useEffect(() => {
errors.forEach(({ id, title, message }) => {
if (!toasts.some(toast => toast.id === id)) {
dispatchToaster({
type: 'addToaster',
toast: {
color: 'danger',
id,
iconType: 'alert',
title,
errors: message,
toastLifeTimeMs,
},
});
}
removeError({ id });
});
});
return null;
};
const makeMapStateToProps = () => {
const getErrorSelector = appSelectors.errorsSelector();
return (state: State) => getErrorSelector(state);
};
export const ErrorToastDispatcher = connect(
makeMapStateToProps,
{
removeError: appActions.removeError,
}
)(ErrorToastDispatcherComponent);

View file

@ -4,7 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiGlobalToastList } from '@elastic/eui';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiGlobalToastListToast as Toast,
} from '@elastic/eui';
import { getOr } from 'lodash/fp';
import { pure } from 'recompose';
import * as React from 'react';
@ -18,6 +23,7 @@ import { TimelineModel } from '../../../store/timeline/model';
import * as i18n from './translations';
import { timelineActions } from '../../../store/timeline';
import { AutoSavedWarningMsg } from '../../../store/timeline/types';
import { useStateToaster } from '../../toasters';
interface ReduxProps {
timelineId: string | null;
@ -48,49 +54,46 @@ const AutoSaveWarningMsgComponent = pure<OwnProps>(
timelineId,
updateAutoSaveMsg,
updateTimeline,
}) => (
<EuiGlobalToastList
toasts={
timelineId != null && newTimelineModel != null
? [
{
id: 'AutoSaveWarningMsg',
title: i18n.TITLE,
color: 'warning',
iconType: 'alert',
toastLifeTimeMs: 15000,
text: (
<>
<p>{i18n.DESCRIPTION}</p>
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton
size="s"
onClick={() => {
updateTimeline({ id: timelineId, timeline: newTimelineModel });
updateAutoSaveMsg({ timelineId: null, newTimelineModel: null });
setTimelineRangeDatePicker({
from: getOr(0, 'dateRange.start', newTimelineModel),
to: getOr(0, 'dateRange.end', newTimelineModel),
});
}}
>
{i18n.REFRESH_TIMELINE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
),
},
]
: []
}
dismissToast={() => {
updateAutoSaveMsg({ timelineId: null, newTimelineModel: null });
}}
toastLifeTimeMs={6000}
/>
)
}) => {
const dispatchToaster = useStateToaster()[1];
if (timelineId != null && newTimelineModel != null) {
const toast: Toast = {
id: 'AutoSaveWarningMsg',
title: i18n.TITLE,
color: 'warning',
iconType: 'alert',
toastLifeTimeMs: 10000,
text: (
<>
<p>{i18n.DESCRIPTION}</p>
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton
size="s"
onClick={() => {
updateTimeline({ id: timelineId, timeline: newTimelineModel });
updateAutoSaveMsg({ timelineId: null, newTimelineModel: null });
setTimelineRangeDatePicker({
from: getOr(0, 'dateRange.start', newTimelineModel),
to: getOr(0, 'dateRange.end', newTimelineModel),
});
}}
>
{i18n.REFRESH_TIMELINE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
),
};
dispatchToaster({
type: 'addToaster',
toast,
});
}
return null;
}
);
const mapStateToProps = (state: State) => {

View file

@ -0,0 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Modal all errors rendering it renders the default all errors modal when isShowing is positive 1`] = `
<EuiOverlayMask>
<EuiModal
maxWidth={true}
onClose={[Function]}
>
<EuiModalHeader>
<EuiModalHeaderTitle>
Your visualization has error(s)
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiCallOut
color="danger"
iconType="alert"
size="s"
title="Test & Test"
/>
<EuiSpacer
size="s"
/>
<EuiAccordion
buttonContent="Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt u ..."
data-test-subj="modal-all-errors-accordion"
id="accordion1"
initialIsOpen={true}
key="id-super-id-0"
paddingSize="none"
>
<Styled(EuiCodeBlock)>
Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</Styled(EuiCodeBlock)>
</EuiAccordion>
</EuiModalBody>
<EuiModalFooter>
<EuiButton
color="primary"
data-test-subj="modal-all-errors-close"
fill={true}
iconSide="left"
onClick={[Function]}
size="m"
type="button"
>
Close
</EuiButton>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
`;

View file

@ -0,0 +1,301 @@
/*
* 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 { cloneDeep, set } from 'lodash/fp';
import { mount } from 'enzyme';
import React, { useEffect } from 'react';
import { wait } from 'react-testing-library';
import { AppToast, useStateToaster, ManageGlobalToaster, GlobalToaster } from '.';
const mockToast: AppToast = {
color: 'danger',
id: 'id-super-id',
iconType: 'alert',
title: 'Test & Test',
toastLifeTimeMs: 100,
text:
'Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
};
describe('Toaster', () => {
describe('Manage Global Toaster Reducer', () => {
test('we can add a toast in the reducer', () => {
const AddToaster = () => {
const [{ toasts }, dispatch] = useStateToaster();
return (
<>
<button
data-test-subj="add-toast"
type="button"
onClick={() => dispatch({ type: 'addToaster', toast: mockToast })}
/>
{toasts.map(toast => (
<span
data-test-subj={`add-toaster-${toast.id}`}
key={`add-toaster-${toast.id}`}
>{`${toast.title} ${toast.text}`}</span>
))}
</>
);
};
const wrapper = mount(
<ManageGlobalToaster>
<AddToaster />
</ManageGlobalToaster>
);
wrapper.find('[data-test-subj="add-toast"]').simulate('click');
wrapper.update();
expect(wrapper.find('[data-test-subj="add-toaster-id-super-id"]').exists()).toBe(true);
});
test('we can delete a toast in the reducer', () => {
const DeleteToaster = () => {
const [{ toasts }, dispatch] = useStateToaster();
useEffect(() => {
if (toasts.length === 0) {
dispatch({ type: 'addToaster', toast: mockToast });
}
});
return (
<>
<button
data-test-subj="add-toast"
type="button"
onClick={() => dispatch({ type: 'deleteToaster', id: mockToast.id })}
/>
{toasts.map(toast => (
<span
data-test-subj={`delete-toaster-${toast.id}`}
key={`delete-toaster-${toast.id}`}
>{`${toast.title} ${toast.text}`}</span>
))}
</>
);
};
const wrapper = mount(
<ManageGlobalToaster>
<DeleteToaster />
</ManageGlobalToaster>
);
wrapper.update();
expect(wrapper.find('[data-test-subj="delete-toaster-id-super-id"]').exists()).toBe(true);
wrapper.find('[data-test-subj="add-toast"]').simulate('click');
wrapper.update();
wait(() => {
expect(wrapper.find('[data-test-subj="delete-toaster-id-super-id"]').exists()).toBe(false);
});
});
});
describe('Global Toaster', () => {
test('Render a basic toaster', () => {
const AddToaster = () => {
const [{ toasts }, dispatch] = useStateToaster();
return (
<>
<button
data-test-subj="add-toast"
type="button"
onClick={() => dispatch({ type: 'addToaster', toast: mockToast })}
/>
{toasts.map(toast => (
<span key={`add-toaster-${toast.id}`}>{`${toast.title} ${toast.text}`}</span>
))}
</>
);
};
const wrapper = mount(
<ManageGlobalToaster>
<AddToaster />
<GlobalToaster />
</ManageGlobalToaster>
);
wrapper.find('[data-test-subj="add-toast"]').simulate('click');
wrapper.update();
expect(wrapper.find('.euiGlobalToastList').exists()).toBe(true);
expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test');
});
test('Render an error toaster', () => {
let mockErrorToast: AppToast = cloneDeep(mockToast);
mockErrorToast.title = 'Test & Test ERROR';
mockErrorToast = set('errors', [mockErrorToast.text], mockErrorToast);
const AddToaster = () => {
const [{ toasts }, dispatch] = useStateToaster();
return (
<>
<button
data-test-subj="add-toast"
type="button"
onClick={() => dispatch({ type: 'addToaster', toast: mockErrorToast })}
/>
{toasts.map(toast => (
<span key={`add-toaster-${toast.id}`}>{`${toast.title} ${toast.text}`}</span>
))}
</>
);
};
const wrapper = mount(
<ManageGlobalToaster>
<AddToaster />
<GlobalToaster />
</ManageGlobalToaster>
);
wrapper.find('[data-test-subj="add-toast"]').simulate('click');
wrapper.update();
expect(wrapper.find('.euiGlobalToastList').exists()).toBe(true);
expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test ERROR');
expect(wrapper.find('button[data-test-subj="toaster-show-all-error-modal"]').exists()).toBe(
true
);
});
test('Only show one toast at the time', () => {
const mockOneMoreToast: AppToast = cloneDeep(mockToast);
mockOneMoreToast.id = 'id-super-id-II';
mockOneMoreToast.title = 'Test & Test II';
const AddToaster = () => {
const [{ toasts }, dispatch] = useStateToaster();
return (
<>
<button
data-test-subj="add-toast"
type="button"
onClick={() => {
dispatch({ type: 'addToaster', toast: mockToast });
dispatch({ type: 'addToaster', toast: mockOneMoreToast });
}}
/>
{toasts.map(toast => (
<span key={`add-toaster-${toast.id}`}>{`${toast.title} ${toast.text}`}</span>
))}
</>
);
};
const wrapper = mount(
<ManageGlobalToaster>
<AddToaster />
<GlobalToaster />
</ManageGlobalToaster>
);
wrapper.find('[data-test-subj="add-toast"]').simulate('click');
wrapper.update();
expect(wrapper.find('.euiToast').length).toBe(1);
expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test');
wait(
() => {
expect(wrapper.find('.euiToast').length).toBe(1);
expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test II');
},
{ timeout: 110 }
);
});
test('Do not show anymore toaster when modal error is open', () => {
let mockErrorToast: AppToast = cloneDeep(mockToast);
mockErrorToast.id = 'id-super-id-error';
mockErrorToast = set('errors', [mockErrorToast.text], mockErrorToast);
const AddToaster = () => {
const [{ toasts }, dispatch] = useStateToaster();
return (
<>
<button
data-test-subj="add-toast"
type="button"
onClick={() => {
dispatch({ type: 'addToaster', toast: mockErrorToast });
dispatch({ type: 'addToaster', toast: mockToast });
}}
/>
{toasts.map(toast => (
<span key={`add-toaster-${toast.id}`}>{`${toast.title} ${toast.text}`}</span>
))}
</>
);
};
const wrapper = mount(
<ManageGlobalToaster>
<AddToaster />
<GlobalToaster />
</ManageGlobalToaster>
);
wrapper.find('[data-test-subj="add-toast"]').simulate('click');
wrapper.update();
wrapper.find('button[data-test-subj="toaster-show-all-error-modal"]').simulate('click');
wrapper.update();
wait(
() => {
expect(wrapper.find('.euiToast').length).toBe(0);
},
{ timeout: 110 }
);
});
test('Show new toaster when modal error is closing', () => {
let mockErrorToast: AppToast = cloneDeep(mockToast);
mockErrorToast.id = 'id-super-id-error';
mockErrorToast = set('errors', [mockErrorToast.text], mockErrorToast);
const AddToaster = () => {
const [{ toasts }, dispatch] = useStateToaster();
return (
<>
<button
data-test-subj="add-toast"
type="button"
onClick={() => {
dispatch({ type: 'addToaster', toast: mockErrorToast });
dispatch({ type: 'addToaster', toast: mockToast });
}}
/>
{toasts.map(toast => (
<span key={`add-toaster-${toast.id}`}>{`${toast.title} ${toast.text}`}</span>
))}
</>
);
};
const wrapper = mount(
<ManageGlobalToaster>
<AddToaster />
<GlobalToaster />
</ManageGlobalToaster>
);
wrapper.find('[data-test-subj="add-toast"]').simulate('click');
wrapper.update();
wrapper.find('button[data-test-subj="toaster-show-all-error-modal"]').simulate('click');
wrapper.update();
wait(
() => {
expect(wrapper.find('.euiToast').length).toBe(0);
wrapper.find('[data-test-subj="modal-all-errors-close"]').simulate('click');
wrapper.update();
expect(wrapper.find('.euiToast').length).toBe(1);
expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test II');
},
{ timeout: 110 }
);
});
});
});

View file

@ -0,0 +1,122 @@
/*
* 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 { EuiGlobalToastList, EuiGlobalToastListToast as Toast, EuiButton } from '@elastic/eui';
import { noop } from 'lodash/fp';
import React, { createContext, Dispatch, useReducer, useContext, useState } from 'react';
import styled from 'styled-components';
import { ModalAllErrors } from './modal_all_errors';
import * as i18n from './translations';
export interface AppToast extends Toast {
errors?: string[];
}
interface ToastState {
toasts: AppToast[];
}
const initialToasterState: ToastState = {
toasts: [],
};
export type ActionToaster =
| { type: 'addToaster'; toast: AppToast }
| { type: 'deleteToaster'; id: string }
| { type: 'toggleWaitToShowNextToast' };
export const StateToasterContext = createContext<[ToastState, Dispatch<ActionToaster>]>([
initialToasterState,
() => noop,
]);
export const useStateToaster = () => useContext(StateToasterContext);
interface ManageGlobalToasterProps {
children: React.ReactNode;
}
export const ManageGlobalToaster = ({ children }: ManageGlobalToasterProps) => {
const reducerToaster = (state: ToastState, action: ActionToaster) => {
switch (action.type) {
case 'addToaster':
return { ...state, toasts: [...state.toasts, action.toast] };
case 'deleteToaster':
return { ...state, toasts: state.toasts.filter(msg => msg.id !== action.id) };
default:
return state;
}
};
return (
<StateToasterContext.Provider value={useReducer(reducerToaster, initialToasterState)}>
{children}
</StateToasterContext.Provider>
);
};
interface GlobalToasterProps {
toastLifeTimeMs?: number;
}
export const GlobalToaster = ({ toastLifeTimeMs = 5000 }: GlobalToasterProps) => {
const [{ toasts }, dispatch] = useStateToaster();
const [isShowing, setIsShowing] = useState(false);
const [toastInModal, setToastInModal] = useState<AppToast | null>(null);
const toggle = (toast: AppToast) => {
if (isShowing) {
dispatch({ type: 'deleteToaster', id: toast.id });
setToastInModal(null);
} else {
setToastInModal(toast);
}
setIsShowing(!isShowing);
};
return (
<>
{toasts.length > 0 && !isShowing && (
<EuiGlobalToastList
toasts={[formatToErrorToastIfNeeded(toasts[0], toggle)]}
dismissToast={({ id }) => {
dispatch({ type: 'deleteToaster', id });
}}
toastLifeTimeMs={toastLifeTimeMs}
/>
)}
{toastInModal != null && (
<ModalAllErrors isShowing={isShowing} toast={toastInModal} toggle={toggle} />
)}
</>
);
};
const formatToErrorToastIfNeeded = (
toast: AppToast,
toggle: (toast: AppToast) => void
): AppToast => {
if (toast != null && toast.errors != null && toast.errors.length > 0) {
toast.text = (
<ErrorToastContainer>
<EuiButton
data-test-subj="toaster-show-all-error-modal"
size="s"
color="danger"
onClick={() => toast != null && toggle(toast)}
>
{i18n.SEE_ALL_ERRORS}
</EuiButton>
</ErrorToastContainer>
);
}
return toast;
};
const ErrorToastContainer = styled.div`
text-align: right;
`;

View file

@ -0,0 +1,70 @@
/*
* 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 { shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { ModalAllErrors } from './modal_all_errors';
import { AppToast } from '.';
import { cloneDeep } from 'lodash/fp';
const mockToast: AppToast = {
color: 'danger',
id: 'id-super-id',
iconType: 'alert',
title: 'Test & Test',
errors: [
'Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
],
};
describe('Modal all errors', () => {
const toggle = jest.fn();
describe('rendering', () => {
test('it renders the default all errors modal when isShowing is positive', () => {
const wrapper = shallow(
<ModalAllErrors isShowing={true} toast={mockToast} toggle={toggle} />
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('it renders null when isShowing is negative', () => {
const wrapper = shallow(
<ModalAllErrors isShowing={false} toast={mockToast} toggle={toggle} />
);
expect(wrapper.html()).toEqual(null);
});
test('it renders multiple errors in modal', () => {
const mockToastWithTwoError = cloneDeep(mockToast);
mockToastWithTwoError.errors = [
'Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
'Error 2, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
'Error 3, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
];
const wrapper = shallow(
<ModalAllErrors isShowing={true} toast={mockToastWithTwoError} toggle={toggle} />
);
expect(wrapper.find('[data-test-subj="modal-all-errors-accordion"]').length).toBe(
mockToastWithTwoError.errors.length
);
});
});
describe('events', () => {
test('Make sure that toggle function has been called when you click on the close button', () => {
const wrapper = shallow(
<ModalAllErrors isShowing={true} toast={mockToast} toggle={toggle} />
);
wrapper.find('[data-test-subj="modal-all-errors-close"]').simulate('click');
wrapper.update();
expect(toggle).toHaveBeenCalledWith(mockToast);
});
});
});

View file

@ -0,0 +1,68 @@
/*
* 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 {
EuiButton,
EuiOverlayMask,
EuiModal,
EuiModalHeader,
EuiModalHeaderTitle,
EuiModalBody,
EuiCallOut,
EuiSpacer,
EuiCodeBlock,
EuiModalFooter,
EuiAccordion,
} from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
import { AppToast } from '.';
import * as i18n from './translations';
interface FullErrorProps {
isShowing: boolean;
toast: AppToast;
toggle: (toast: AppToast) => void;
}
export const ModalAllErrors = ({ isShowing, toast, toggle }: FullErrorProps) =>
isShowing && toast != null ? (
<EuiOverlayMask>
<EuiModal onClose={() => toggle(toast)}>
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.TITLE_ERROR_MODAL}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiCallOut title={toast.title} color="danger" size="s" iconType="alert" />
<EuiSpacer size="s" />
{toast.errors != null &&
toast.errors.map((error, index) => (
<EuiAccordion
key={`${toast.id}-${index}`}
id="accordion1"
initialIsOpen={index === 0 ? true : false}
buttonContent={error.length > 100 ? `${error.substring(0, 100)} ...` : error}
data-test-subj="modal-all-errors-accordion"
>
<MyEuiCodeBlock>{error}</MyEuiCodeBlock>
</EuiAccordion>
))}
</EuiModalBody>
<EuiModalFooter>
<EuiButton onClick={() => toggle(toast)} fill data-test-subj="modal-all-errors-close">
{i18n.CLOSE_ERROR_MODAL}
</EuiButton>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
) : null;
const MyEuiCodeBlock = styled(EuiCodeBlock)`
margin-top: 4px;
`;

View file

@ -0,0 +1,19 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const SEE_ALL_ERRORS = i18n.translate('xpack.siem.modalAllErrors.seeAllErrors.button', {
defaultMessage: 'See the full error(s)',
});
export const TITLE_ERROR_MODAL = i18n.translate('xpack.siem.modalAllErrors.title', {
defaultMessage: 'Your visualization has error(s)',
});
export const CLOSE_ERROR_MODAL = i18n.translate('xpack.siem.modalAllErrors.close.button', {
defaultMessage: 'Close',
});

View file

@ -5,7 +5,7 @@
*/
import { onError, ErrorLink } from 'apollo-link-error';
import { get } from 'lodash/fp';
import { get, throttle, noop } from 'lodash/fp';
import uuid from 'uuid';
import * as i18n from './translations';
@ -15,21 +15,24 @@ import { appActions } from '../../store/actions';
export const errorLinkHandler: ErrorLink.ErrorHandler = ({ graphQLErrors, networkError }) => {
const store = getStore();
const dispatch = throttle(50, store != null ? store.dispatch : noop);
if (graphQLErrors != null && store != null) {
graphQLErrors.forEach(({ message }) =>
store.dispatch(
appActions.addError({ id: uuid.v4(), title: i18n.DATA_FETCH_FAILURE, message })
)
dispatch(
appActions.addError({
id: uuid.v4(),
title: i18n.DATA_FETCH_FAILURE,
message: graphQLErrors.map(({ message }) => message),
})
);
}
if (networkError != null && store != null) {
store.dispatch(
dispatch(
appActions.addError({
id: uuid.v4(),
title: i18n.NETWORK_FAILURE,
message: networkError.message,
message: [networkError.message],
})
);
}

View file

@ -4,12 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiGlobalToastList, EuiGlobalToastListToast as Toast } from '@elastic/eui';
import { EuiGlobalToastListToast as Toast, EuiButtonIcon } from '@elastic/eui';
import copy from 'copy-to-clipboard';
import * as React from 'react';
import uuid from 'uuid';
import * as i18n from './translations';
import { useStateToaster } from '../../components/toasters';
export type OnCopy = ({
content,
@ -38,44 +39,9 @@ interface Props {
toastLifeTimeMs?: number;
}
interface State {
toasts: Toast[];
}
export class Clipboard extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
toasts: [],
};
}
public render() {
const { toastLifeTimeMs = 5000 } = this.props;
// TODO: 1 error is: Visible, non-interactive elements with click handlers must have at least one keyboard listener
// TODO: 2 error is: Elements with the 'button' interactive role must be focusable
// TODO: Investigate this error
/* eslint-disable */
return (
<>
<div role="button" data-test-subj="clipboard" onClick={this.onClick}>
{this.props.children}
</div>
<EuiGlobalToastList
toasts={this.state.toasts}
dismissToast={this.removeToast}
toastLifeTimeMs={toastLifeTimeMs}
/>
</>
);
/* eslint-enable */
}
private onClick = (event: React.MouseEvent<HTMLDivElement>) => {
const { content, onCopy, titleSummary } = this.props;
export const Clipboard = ({ children, content, onCopy, titleSummary, toastLifeTimeMs }: Props) => {
const dispatchToaster = useStateToaster()[1];
const onClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
@ -86,15 +52,23 @@ export class Clipboard extends React.PureComponent<Props, State> {
}
if (isSuccess) {
this.setState(prevState => ({
toasts: [...prevState.toasts, getSuccessToast({ titleSummary })],
}));
dispatchToaster({
type: 'addToaster',
toast: { toastLifeTimeMs, ...getSuccessToast({ titleSummary }) },
});
}
};
private removeToast = (removedToast: Toast): void => {
this.setState(prevState => ({
toasts: prevState.toasts.filter(toast => toast.id !== removedToast.id),
}));
};
}
return (
<EuiButtonIcon
aria-label={i18n.COPY_TO_THE_CLIPBOARD}
color="subdued"
data-test-subj="clipboard"
iconSize="s"
iconType="copyClipboard"
onClick={onClick}
>
{children}
</EuiButtonIcon>
);
};

View file

@ -17,3 +17,7 @@ export const COPIED = i18n.translate('xpack.siem.clipboard.copied', {
export const TO_THE_CLIPBOARD = i18n.translate('xpack.siem.clipboard.to.the.clipboard', {
defaultMessage: 'to the clipboard',
});
export const COPY_TO_THE_CLIPBOARD = i18n.translate('xpack.siem.clipboard.copy.to.the.clipboard', {
defaultMessage: 'Copy to the clipboard',
});

View file

@ -27,7 +27,7 @@ export const WithCopyToClipboard = pure<{ text: string; titleSummary?: string }>
({ text, titleSummary, children }) => (
<WithCopyToClipboardContainer>
<>{children}</>
<Clipboard content={text} titleSummary={titleSummary}>
<Clipboard content={text} titleSummary={titleSummary} toastLifeTimeMs={800}>
<EuiIcon
color="text"
type="copyClipboard"

View file

@ -24,8 +24,8 @@ export const mockGlobalState: State = {
app: {
notesById: {},
errors: [
{ id: 'error-id-1', title: 'title-1', message: 'error-message-1' },
{ id: 'error-id-2', title: 'title-2', message: 'error-message-2' },
{ id: 'error-id-1', title: 'title-1', message: ['error-message-1'] },
{ id: 'error-id-2', title: 'title-2', message: ['error-message-2'] },
],
},
hosts: {

View file

@ -14,6 +14,8 @@ export const updateNote = actionCreator<{ note: Note }>('UPDATE_NOTE');
export const addNotes = actionCreator<{ notes: Note[] }>('ADD_NOTE');
export const addError = actionCreator<{ id: string; title: string; message: string }>('ADD_ERRORS');
export const addError = actionCreator<{ id: string; title: string; message: string[] }>(
'ADD_ERRORS'
);
export const removeError = actionCreator<{ id: string }>('REMOVE_ERRORS');

View file

@ -15,7 +15,7 @@ export interface NotesById {
export interface Error {
id: string;
title: string;
message: string;
message: string[];
}
export type ErrorModel = Error[];