[Enterprise Search] Add reusable FlashMessages helper (#75901)

* Set up basic shared FlashMessages & FlashMessagesLogic

* Add top-level FlashMessagesProvider and history listener

- This ensures that:
  - Our FlashMessagesLogic is a global state that persists throughout the entire app and only unmounts when the app itself does (allowing for persistent messages if needed)
  - history.listen enables the same behavior as previously, where flash messages would be cleared between page views

* Set up queued messages that appear on page nav/load

* [AS] Add FlashMessages component to Engines Overview

+ add Kea/Redux context/state to mountWithContext (in order for tests to pass)

* Fix missing type exports, replace previous IFlashMessagesProps

* [WS] Remove flashMessages state in OverviewLogic

- in favor of either connecting it or using FlashMessagesLogic directly in the future

* PR feedback: DRY out EUI callout color type def

* PR Feedback: make flashMessages method names more explicit

* PR Feedback: Shorter FlashMessagesLogic type names

* PR feedback: Typing

Co-authored-by: Byron Hulcher <byronhulcher@gmail.com>

Co-authored-by: Byron Hulcher <byronhulcher@gmail.com>
This commit is contained in:
Constance 2020-08-27 12:03:20 -07:00 committed by GitHub
parent 1bd8f41275
commit a7b0f7a102
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 434 additions and 29 deletions

View file

@ -8,6 +8,10 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { mount, ReactWrapper } from 'enzyme';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { getContext, resetContext } from 'kea';
import { I18nProvider } from '@kbn/i18n/react';
import { KibanaContext } from '../';
import { mockKibanaContext } from './kibana_context.mock';
@ -24,11 +28,14 @@ import { mockLicenseContext } from './license_context.mock';
* const wrapper = mountWithContext(<Component />, { config: { host: 'someOverride' } });
*/
export const mountWithContext = (children: React.ReactNode, context?: object) => {
resetContext({ createStore: true });
const store = getContext().store as Store;
return mount(
<I18nProvider>
<KibanaContext.Provider value={{ ...mockKibanaContext, ...context }}>
<LicenseContext.Provider value={{ ...mockLicenseContext, ...context }}>
{children}
<Provider store={store}>{children}</Provider>
</LicenseContext.Provider>
</KibanaContext.Provider>
</I18nProvider>

View file

@ -16,6 +16,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
import { FlashMessages } from '../../../shared/flash_messages';
import { LicenseContext, ILicenseContext, hasPlatinumLicense } from '../../../shared/licensing';
import { KibanaContext, IKibanaContext } from '../../../index';
@ -88,6 +89,7 @@ export const EngineOverview: React.FC = () => {
<EngineOverviewHeader />
<EuiPageContent panelPaddingSize="s" className="engineOverview">
<FlashMessages />
<EuiPageContentHeader>
<EuiTitle size="s">
<h2>

View file

@ -22,6 +22,7 @@ import {
} from 'src/core/public';
import { ClientConfigType, ClientData, PluginsSetup } from '../plugin';
import { LicenseProvider } from './shared/licensing';
import { FlashMessagesProvider } from './shared/flash_messages';
import { HttpProvider } from './shared/http';
import { IExternalUrl } from './shared/enterprise_search_url';
import { IInitialAppData } from '../../common/types';
@ -69,6 +70,7 @@ export const renderApp = (
<LicenseProvider license$={plugins.licensing.license$}>
<Provider store={store}>
<HttpProvider http={core.http} errorConnecting={errorConnecting} />
<FlashMessagesProvider history={params.history} />
<Router history={params.history}>
<App {...initialData} />
</Router>

View file

@ -0,0 +1,64 @@
/*
* 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 '../../__mocks__/kea.mock';
import { useValues } from 'kea';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiCallOut } from '@elastic/eui';
import { FlashMessages } from './';
describe('FlashMessages', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('does not render if no messages exist', () => {
(useValues as jest.Mock).mockImplementationOnce(() => ({ messages: [] }));
const wrapper = shallow(<FlashMessages />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('renders an array of flash messages & types', () => {
const mockMessages = [
{ type: 'success', message: 'Hello world!!' },
{
type: 'error',
message: 'Whoa nelly!',
description: <div data-test-subj="error">Something went wrong</div>,
},
{ type: 'info', message: 'Everything is fine, nothing is ruined' },
{ type: 'warning', message: 'Uh oh' },
{ type: 'info', message: 'Testing multiples of same type' },
];
(useValues as jest.Mock).mockImplementationOnce(() => ({ messages: mockMessages }));
const wrapper = shallow(<FlashMessages />);
expect(wrapper.find(EuiCallOut)).toHaveLength(5);
expect(wrapper.find(EuiCallOut).first().prop('color')).toEqual('success');
expect(wrapper.find('[data-test-subj="error"]')).toHaveLength(1);
expect(wrapper.find(EuiCallOut).last().prop('iconType')).toEqual('iInCircle');
});
it('renders any children', () => {
(useValues as jest.Mock).mockImplementationOnce(() => ({ messages: [{ type: 'success' }] }));
const wrapper = shallow(
<FlashMessages>
<button data-test-subj="testing">
Some action - you could even clear flash messages here
</button>
</FlashMessages>
);
expect(wrapper.find('[data-test-subj="testing"]').text()).toContain('Some action');
});
});

View file

@ -0,0 +1,43 @@
/*
* 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, { Fragment } from 'react';
import { useValues } from 'kea';
import { EuiCallOut, EuiCallOutProps, EuiSpacer } from '@elastic/eui';
import { FlashMessagesLogic, IFlashMessagesValues } from './flash_messages_logic';
const FLASH_MESSAGE_TYPES = {
success: { color: 'success' as EuiCallOutProps['color'], icon: 'check' },
info: { color: 'primary' as EuiCallOutProps['color'], icon: 'iInCircle' },
warning: { color: 'warning' as EuiCallOutProps['color'], icon: 'alert' },
error: { color: 'danger' as EuiCallOutProps['color'], icon: 'cross' },
};
export const FlashMessages: React.FC = ({ children }) => {
const { messages } = useValues(FlashMessagesLogic) as IFlashMessagesValues;
// If we have no messages to display, do not render the element at all
if (!messages.length) return null;
return (
<div data-test-subj="FlashMessages">
{messages.map(({ type, message, description }, index) => (
<Fragment key={index}>
<EuiCallOut
color={FLASH_MESSAGE_TYPES[type].color}
iconType={FLASH_MESSAGE_TYPES[type].icon}
title={message}
>
{description}
</EuiCallOut>
<EuiSpacer />
</Fragment>
))}
{children}
</div>
);
};

View file

@ -0,0 +1,136 @@
/*
* 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 { resetContext } from 'kea';
import { FlashMessagesLogic, IFlashMessage } from './flash_messages_logic';
describe('FlashMessagesLogic', () => {
const DEFAULT_VALUES = {
messages: [],
queuedMessages: [],
historyListener: null,
};
beforeEach(() => {
jest.clearAllMocks();
resetContext({});
});
it('has expected default values', () => {
FlashMessagesLogic.mount();
expect(FlashMessagesLogic.values).toEqual(DEFAULT_VALUES);
});
describe('setFlashMessages()', () => {
it('sets an array of messages', () => {
const messages: IFlashMessage[] = [
{ type: 'success', message: 'Hello world!!' },
{ type: 'error', message: 'Whoa nelly!', description: 'Uh oh' },
{ type: 'info', message: 'Everything is fine, nothing is ruined' },
];
FlashMessagesLogic.mount();
FlashMessagesLogic.actions.setFlashMessages(messages);
expect(FlashMessagesLogic.values.messages).toEqual(messages);
});
it('automatically converts to an array if a single message obj is passed in', () => {
const message = { type: 'success', message: 'I turn into an array!' } as IFlashMessage;
FlashMessagesLogic.mount();
FlashMessagesLogic.actions.setFlashMessages(message);
expect(FlashMessagesLogic.values.messages).toEqual([message]);
});
});
describe('clearFlashMessages()', () => {
it('sets messages back to an empty array', () => {
FlashMessagesLogic.mount();
FlashMessagesLogic.actions.setFlashMessages('test' as any);
FlashMessagesLogic.actions.clearFlashMessages();
expect(FlashMessagesLogic.values.messages).toEqual([]);
});
});
describe('setQueuedMessages()', () => {
it('sets an array of messages', () => {
const queuedMessage: IFlashMessage = { type: 'error', message: 'You deleted a thing' };
FlashMessagesLogic.mount();
FlashMessagesLogic.actions.setQueuedMessages(queuedMessage);
expect(FlashMessagesLogic.values.queuedMessages).toEqual([queuedMessage]);
});
});
describe('clearQueuedMessages()', () => {
it('sets queued messages back to an empty array', () => {
FlashMessagesLogic.mount();
FlashMessagesLogic.actions.setQueuedMessages('test' as any);
FlashMessagesLogic.actions.clearQueuedMessages();
expect(FlashMessagesLogic.values.queuedMessages).toEqual([]);
});
});
describe('history listener logic', () => {
describe('setHistoryListener()', () => {
it('sets the historyListener value', () => {
FlashMessagesLogic.mount();
FlashMessagesLogic.actions.setHistoryListener('test' as any);
expect(FlashMessagesLogic.values.historyListener).toEqual('test');
});
});
describe('listenToHistory()', () => {
it('listens for history changes and clears messages on change', () => {
FlashMessagesLogic.mount();
FlashMessagesLogic.actions.setQueuedMessages(['queuedMessages'] as any);
jest.spyOn(FlashMessagesLogic.actions, 'clearFlashMessages');
jest.spyOn(FlashMessagesLogic.actions, 'setFlashMessages');
jest.spyOn(FlashMessagesLogic.actions, 'clearQueuedMessages');
jest.spyOn(FlashMessagesLogic.actions, 'setHistoryListener');
const mockListener = jest.fn(() => jest.fn());
const history = { listen: mockListener } as any;
FlashMessagesLogic.actions.listenToHistory(history);
expect(mockListener).toHaveBeenCalled();
expect(FlashMessagesLogic.actions.setHistoryListener).toHaveBeenCalled();
const mockHistoryChange = (mockListener.mock.calls[0] as any)[0];
mockHistoryChange();
expect(FlashMessagesLogic.actions.clearFlashMessages).toHaveBeenCalled();
expect(FlashMessagesLogic.actions.setFlashMessages).toHaveBeenCalledWith([
'queuedMessages',
]);
expect(FlashMessagesLogic.actions.clearQueuedMessages).toHaveBeenCalled();
});
});
describe('beforeUnmount', () => {
it('removes history listener on unmount', () => {
const mockUnlistener = jest.fn();
const unmount = FlashMessagesLogic.mount();
FlashMessagesLogic.actions.setHistoryListener(mockUnlistener);
unmount();
expect(mockUnlistener).toHaveBeenCalled();
});
it('does not crash if no listener exists', () => {
const unmount = FlashMessagesLogic.mount();
unmount();
});
});
});
});

View file

@ -0,0 +1,87 @@
/*
* 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 { kea } from 'kea';
import { ReactNode } from 'react';
import { History } from 'history';
import { IKeaLogic, TKeaReducers, IKeaParams } from '../types';
export interface IFlashMessage {
type: 'success' | 'info' | 'warning' | 'error';
message: ReactNode;
description?: ReactNode;
}
export interface IFlashMessagesValues {
messages: IFlashMessage[];
queuedMessages: IFlashMessage[];
historyListener: Function | null;
}
export interface IFlashMessagesActions {
setFlashMessages(messages: IFlashMessage | IFlashMessage[]): void;
clearFlashMessages(): void;
setQueuedMessages(messages: IFlashMessage | IFlashMessage[]): void;
clearQueuedMessages(): void;
listenToHistory(history: History): void;
setHistoryListener(historyListener: Function): void;
}
const convertToArray = (messages: IFlashMessage | IFlashMessage[]) =>
!Array.isArray(messages) ? [messages] : messages;
export const FlashMessagesLogic = kea({
actions: (): IFlashMessagesActions => ({
setFlashMessages: (messages) => ({ messages: convertToArray(messages) }),
clearFlashMessages: () => null,
setQueuedMessages: (messages) => ({ messages: convertToArray(messages) }),
clearQueuedMessages: () => null,
listenToHistory: (history) => history,
setHistoryListener: (historyListener) => ({ historyListener }),
}),
reducers: (): TKeaReducers<IFlashMessagesValues, IFlashMessagesActions> => ({
messages: [
[],
{
setFlashMessages: (_, { messages }) => messages,
clearFlashMessages: () => [],
},
],
queuedMessages: [
[],
{
setQueuedMessages: (_, { messages }) => messages,
clearQueuedMessages: () => [],
},
],
historyListener: [
null,
{
setHistoryListener: (_, { historyListener }) => historyListener,
},
],
}),
listeners: ({ values, actions }): Partial<IFlashMessagesActions> => ({
listenToHistory: (history) => {
// On React Router navigation, clear previous flash messages and load any queued messages
const unlisten = history.listen(() => {
actions.clearFlashMessages();
actions.setFlashMessages(values.queuedMessages);
actions.clearQueuedMessages();
});
actions.setHistoryListener(unlisten);
},
}),
events: ({ values }) => ({
beforeUnmount: () => {
const { historyListener: removeHistoryListener } = values;
if (removeHistoryListener) removeHistoryListener();
},
}),
} as IKeaParams<IFlashMessagesValues, IFlashMessagesActions>) as IKeaLogic<
IFlashMessagesValues,
IFlashMessagesActions
>;

View file

@ -0,0 +1,46 @@
/*
* 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 '../../__mocks__/shallow_usecontext.mock';
import '../../__mocks__/kea.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { useValues, useActions } from 'kea';
import { mockHistory } from '../../__mocks__';
import { FlashMessagesProvider } from './';
describe('FlashMessagesProvider', () => {
const props = { history: mockHistory as any };
const listenToHistory = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useActions as jest.Mock).mockImplementationOnce(() => ({ listenToHistory }));
});
it('does not render', () => {
const wrapper = shallow(<FlashMessagesProvider {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('listens to history on mount', () => {
shallow(<FlashMessagesProvider {...props} />);
expect(listenToHistory).toHaveBeenCalledWith(mockHistory);
});
it('does not add another history listener if one already exists', () => {
(useValues as jest.Mock).mockImplementationOnce(() => ({ historyListener: 'exists' as any }));
shallow(<FlashMessagesProvider {...props} />);
expect(listenToHistory).not.toHaveBeenCalledWith(props);
});
});

View file

@ -0,0 +1,30 @@
/*
* 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, { useEffect } from 'react';
import { useValues, useActions } from 'kea';
import { History } from 'history';
import {
FlashMessagesLogic,
IFlashMessagesValues,
IFlashMessagesActions,
} from './flash_messages_logic';
interface IFlashMessagesProviderProps {
history: History;
}
export const FlashMessagesProvider: React.FC<IFlashMessagesProviderProps> = ({ history }) => {
const { historyListener } = useValues(FlashMessagesLogic) as IFlashMessagesValues;
const { listenToHistory } = useActions(FlashMessagesLogic) as IFlashMessagesActions;
useEffect(() => {
if (!historyListener) listenToHistory(history);
}, []);
return null;
};

View file

@ -0,0 +1,14 @@
/*
* 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 { FlashMessages } from './flash_messages';
export {
FlashMessagesLogic,
IFlashMessage,
IFlashMessagesValues,
IFlashMessagesActions,
} from './flash_messages_logic';
export { FlashMessagesProvider } from './flash_messages_provider';

View file

@ -4,14 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export interface IFlashMessagesProps {
info?: string[];
warning?: string[];
error?: string[];
success?: string[];
isWrapped?: boolean;
children?: React.ReactNode;
}
export { IFlashMessage } from './flash_messages';
export interface IKeaLogic<IKeaValues, IKeaActions> {
mount(): Function;

View file

@ -22,7 +22,6 @@ export const mockLogicValues = {
personalSourcesCount: 0,
sourcesCount: 0,
dataLoading: true,
flashMessages: {},
} as IOverviewValues;
export const mockLogicActions = {

View file

@ -76,15 +76,6 @@ describe('OverviewLogic', () => {
});
});
describe('setFlashMessages', () => {
it('will set `flashMessages`', () => {
const flashMessages = { error: ['error'] };
OverviewLogic.actions.setFlashMessages(flashMessages);
expect(OverviewLogic.values.flashMessages).toEqual(flashMessages);
});
});
describe('initializeOverview', () => {
it('calls API and sets values', async () => {
const setServerDataSpy = jest.spyOn(OverviewLogic.actions, 'setServerData');

View file

@ -8,7 +8,7 @@ import { kea } from 'kea';
import { HttpLogic } from '../../../shared/http';
import { IAccount, IOrganization } from '../../types';
import { IFlashMessagesProps, IKeaLogic, TKeaReducers, IKeaParams } from '../../../shared/types';
import { IKeaLogic, TKeaReducers, IKeaParams } from '../../../shared/types';
import { IFeedActivity } from './recent_activity';
@ -30,19 +30,16 @@ export interface IOverviewServerData {
export interface IOverviewActions {
setServerData(serverData: IOverviewServerData): void;
setFlashMessages(flashMessages: IFlashMessagesProps): void;
initializeOverview(): void;
}
export interface IOverviewValues extends IOverviewServerData {
dataLoading: boolean;
flashMessages: IFlashMessagesProps;
}
export const OverviewLogic = kea({
actions: (): IOverviewActions => ({
setServerData: (serverData) => serverData,
setFlashMessages: (flashMessages) => ({ flashMessages }),
initializeOverview: () => null,
}),
reducers: (): TKeaReducers<IOverviewValues, IOverviewActions> => ({
@ -70,12 +67,6 @@ export const OverviewLogic = kea({
setServerData: (_, { canCreateInvitations }) => canCreateInvitations,
},
],
flashMessages: [
{},
{
setFlashMessages: (_, { flashMessages }) => flashMessages,
},
],
hasUsers: [
false,
{