Skip to content
Merged
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';
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: [
Copy link
Contributor 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
Contributor 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