Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Enterprise Search] Add reusable FlashMessages helper #75901

Merged
merged 10 commits into from
Aug 27, 2020
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">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice thought.

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' },
Copy link
Contributor

@byronhulcher byronhulcher Aug 26, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 I see you referencing a type via the property of another type

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';
cee-chen marked this conversation as resolved.
Show resolved Hide resolved
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: [
cee-chen marked this conversation as resolved.
Show resolved Hide resolved
[],
{
setFlashMessages: (_, { messages }) => messages,
clearFlashMessages: () => [],
},
],
queuedMessages: [
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also want to quickly highlight here - the concept of queuedMessages is something @byronhulcher and I came up with a while back for App Search here.

It's most useful for deletion scenarios, where the UX flow goes as such:

  • User "deletes" an item which is represented by the current page they're on (e.g. a role mapping)
  • User is redirected to the parent page which shows a list of all items
  • A "Deletion successful" flash message appears on the new page

In that flow, the logic/code in this new Kibana logic would look something like this:

  • API call to delete item
  • On success, FlashMessagesLogic.actions.setQueuedMessage({ type: 'success', message: 'Successfully deleted item' })
  • Redirect user (in Kibana, navigateToUrl('/app/enterprise_search/app_search/))
  • The <FlashMessages /> component now automatically handles clearing and displaying queued messages for you.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the explicit distinction.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad this has continued to prove useful!

[],
{
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(() => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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