Skip to content

Commit

Permalink
[Enterprise Search] Add reusable FlashMessages helper (#75901)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
Constance and byronhulcher committed Aug 27, 2020
1 parent 1bd8f41 commit a7b0f7a
Show file tree
Hide file tree
Showing 14 changed files with 434 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -88,6 +89,7 @@ export const EngineOverview: React.FC = () => {

<EngineOverviewHeader />
<EuiPageContent panelPaddingSize="s" className="engineOverview">
<FlashMessages />
<EuiPageContentHeader>
<EuiTitle size="s">
<h2>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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
>;
Loading

0 comments on commit a7b0f7a

Please sign in to comment.