Skip to content

Commit 12579fb

Browse files
Constancebyronhulcher
andauthored
[Enterprise Search] Add reusable FlashMessages helper (#75901) (#76139)
* 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 <[email protected]> Co-authored-by: Byron Hulcher <[email protected]> Co-authored-by: Byron Hulcher <[email protected]>
1 parent d2fd65c commit 12579fb

File tree

14 files changed

+434
-29
lines changed

14 files changed

+434
-29
lines changed

x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import React from 'react';
88
import { act } from 'react-dom/test-utils';
99
import { mount, ReactWrapper } from 'enzyme';
1010

11+
import { Provider } from 'react-redux';
12+
import { Store } from 'redux';
13+
import { getContext, resetContext } from 'kea';
14+
1115
import { I18nProvider } from '@kbn/i18n/react';
1216
import { KibanaContext } from '../';
1317
import { mockKibanaContext } from './kibana_context.mock';
@@ -24,11 +28,14 @@ import { mockLicenseContext } from './license_context.mock';
2428
* const wrapper = mountWithContext(<Component />, { config: { host: 'someOverride' } });
2529
*/
2630
export const mountWithContext = (children: React.ReactNode, context?: object) => {
31+
resetContext({ createStore: true });
32+
const store = getContext().store as Store;
33+
2734
return mount(
2835
<I18nProvider>
2936
<KibanaContext.Provider value={{ ...mockKibanaContext, ...context }}>
3037
<LicenseContext.Provider value={{ ...mockLicenseContext, ...context }}>
31-
{children}
38+
<Provider store={store}>{children}</Provider>
3239
</LicenseContext.Provider>
3340
</KibanaContext.Provider>
3441
</I18nProvider>

x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
1616

1717
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
1818
import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
19+
import { FlashMessages } from '../../../shared/flash_messages';
1920
import { LicenseContext, ILicenseContext, hasPlatinumLicense } from '../../../shared/licensing';
2021
import { KibanaContext, IKibanaContext } from '../../../index';
2122

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

8990
<EngineOverviewHeader />
9091
<EuiPageContent panelPaddingSize="s" className="engineOverview">
92+
<FlashMessages />
9193
<EuiPageContentHeader>
9294
<EuiTitle size="s">
9395
<h2>

x-pack/plugins/enterprise_search/public/applications/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from 'src/core/public';
2323
import { ClientConfigType, ClientData, PluginsSetup } from '../plugin';
2424
import { LicenseProvider } from './shared/licensing';
25+
import { FlashMessagesProvider } from './shared/flash_messages';
2526
import { HttpProvider } from './shared/http';
2627
import { IExternalUrl } from './shared/enterprise_search_url';
2728
import { IInitialAppData } from '../../common/types';
@@ -69,6 +70,7 @@ export const renderApp = (
6970
<LicenseProvider license$={plugins.licensing.license$}>
7071
<Provider store={store}>
7172
<HttpProvider http={core.http} errorConnecting={errorConnecting} />
73+
<FlashMessagesProvider history={params.history} />
7274
<Router history={params.history}>
7375
<App {...initialData} />
7476
</Router>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import '../../__mocks__/kea.mock';
8+
9+
import { useValues } from 'kea';
10+
import React from 'react';
11+
import { shallow } from 'enzyme';
12+
import { EuiCallOut } from '@elastic/eui';
13+
14+
import { FlashMessages } from './';
15+
16+
describe('FlashMessages', () => {
17+
beforeEach(() => {
18+
jest.clearAllMocks();
19+
});
20+
21+
it('does not render if no messages exist', () => {
22+
(useValues as jest.Mock).mockImplementationOnce(() => ({ messages: [] }));
23+
24+
const wrapper = shallow(<FlashMessages />);
25+
26+
expect(wrapper.isEmptyRender()).toBe(true);
27+
});
28+
29+
it('renders an array of flash messages & types', () => {
30+
const mockMessages = [
31+
{ type: 'success', message: 'Hello world!!' },
32+
{
33+
type: 'error',
34+
message: 'Whoa nelly!',
35+
description: <div data-test-subj="error">Something went wrong</div>,
36+
},
37+
{ type: 'info', message: 'Everything is fine, nothing is ruined' },
38+
{ type: 'warning', message: 'Uh oh' },
39+
{ type: 'info', message: 'Testing multiples of same type' },
40+
];
41+
(useValues as jest.Mock).mockImplementationOnce(() => ({ messages: mockMessages }));
42+
43+
const wrapper = shallow(<FlashMessages />);
44+
45+
expect(wrapper.find(EuiCallOut)).toHaveLength(5);
46+
expect(wrapper.find(EuiCallOut).first().prop('color')).toEqual('success');
47+
expect(wrapper.find('[data-test-subj="error"]')).toHaveLength(1);
48+
expect(wrapper.find(EuiCallOut).last().prop('iconType')).toEqual('iInCircle');
49+
});
50+
51+
it('renders any children', () => {
52+
(useValues as jest.Mock).mockImplementationOnce(() => ({ messages: [{ type: 'success' }] }));
53+
54+
const wrapper = shallow(
55+
<FlashMessages>
56+
<button data-test-subj="testing">
57+
Some action - you could even clear flash messages here
58+
</button>
59+
</FlashMessages>
60+
);
61+
62+
expect(wrapper.find('[data-test-subj="testing"]').text()).toContain('Some action');
63+
});
64+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import React, { Fragment } from 'react';
8+
import { useValues } from 'kea';
9+
import { EuiCallOut, EuiCallOutProps, EuiSpacer } from '@elastic/eui';
10+
11+
import { FlashMessagesLogic, IFlashMessagesValues } from './flash_messages_logic';
12+
13+
const FLASH_MESSAGE_TYPES = {
14+
success: { color: 'success' as EuiCallOutProps['color'], icon: 'check' },
15+
info: { color: 'primary' as EuiCallOutProps['color'], icon: 'iInCircle' },
16+
warning: { color: 'warning' as EuiCallOutProps['color'], icon: 'alert' },
17+
error: { color: 'danger' as EuiCallOutProps['color'], icon: 'cross' },
18+
};
19+
20+
export const FlashMessages: React.FC = ({ children }) => {
21+
const { messages } = useValues(FlashMessagesLogic) as IFlashMessagesValues;
22+
23+
// If we have no messages to display, do not render the element at all
24+
if (!messages.length) return null;
25+
26+
return (
27+
<div data-test-subj="FlashMessages">
28+
{messages.map(({ type, message, description }, index) => (
29+
<Fragment key={index}>
30+
<EuiCallOut
31+
color={FLASH_MESSAGE_TYPES[type].color}
32+
iconType={FLASH_MESSAGE_TYPES[type].icon}
33+
title={message}
34+
>
35+
{description}
36+
</EuiCallOut>
37+
<EuiSpacer />
38+
</Fragment>
39+
))}
40+
{children}
41+
</div>
42+
);
43+
};
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { resetContext } from 'kea';
8+
9+
import { FlashMessagesLogic, IFlashMessage } from './flash_messages_logic';
10+
11+
describe('FlashMessagesLogic', () => {
12+
const DEFAULT_VALUES = {
13+
messages: [],
14+
queuedMessages: [],
15+
historyListener: null,
16+
};
17+
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
resetContext({});
21+
});
22+
23+
it('has expected default values', () => {
24+
FlashMessagesLogic.mount();
25+
expect(FlashMessagesLogic.values).toEqual(DEFAULT_VALUES);
26+
});
27+
28+
describe('setFlashMessages()', () => {
29+
it('sets an array of messages', () => {
30+
const messages: IFlashMessage[] = [
31+
{ type: 'success', message: 'Hello world!!' },
32+
{ type: 'error', message: 'Whoa nelly!', description: 'Uh oh' },
33+
{ type: 'info', message: 'Everything is fine, nothing is ruined' },
34+
];
35+
36+
FlashMessagesLogic.mount();
37+
FlashMessagesLogic.actions.setFlashMessages(messages);
38+
39+
expect(FlashMessagesLogic.values.messages).toEqual(messages);
40+
});
41+
42+
it('automatically converts to an array if a single message obj is passed in', () => {
43+
const message = { type: 'success', message: 'I turn into an array!' } as IFlashMessage;
44+
45+
FlashMessagesLogic.mount();
46+
FlashMessagesLogic.actions.setFlashMessages(message);
47+
48+
expect(FlashMessagesLogic.values.messages).toEqual([message]);
49+
});
50+
});
51+
52+
describe('clearFlashMessages()', () => {
53+
it('sets messages back to an empty array', () => {
54+
FlashMessagesLogic.mount();
55+
FlashMessagesLogic.actions.setFlashMessages('test' as any);
56+
FlashMessagesLogic.actions.clearFlashMessages();
57+
58+
expect(FlashMessagesLogic.values.messages).toEqual([]);
59+
});
60+
});
61+
62+
describe('setQueuedMessages()', () => {
63+
it('sets an array of messages', () => {
64+
const queuedMessage: IFlashMessage = { type: 'error', message: 'You deleted a thing' };
65+
66+
FlashMessagesLogic.mount();
67+
FlashMessagesLogic.actions.setQueuedMessages(queuedMessage);
68+
69+
expect(FlashMessagesLogic.values.queuedMessages).toEqual([queuedMessage]);
70+
});
71+
});
72+
73+
describe('clearQueuedMessages()', () => {
74+
it('sets queued messages back to an empty array', () => {
75+
FlashMessagesLogic.mount();
76+
FlashMessagesLogic.actions.setQueuedMessages('test' as any);
77+
FlashMessagesLogic.actions.clearQueuedMessages();
78+
79+
expect(FlashMessagesLogic.values.queuedMessages).toEqual([]);
80+
});
81+
});
82+
83+
describe('history listener logic', () => {
84+
describe('setHistoryListener()', () => {
85+
it('sets the historyListener value', () => {
86+
FlashMessagesLogic.mount();
87+
FlashMessagesLogic.actions.setHistoryListener('test' as any);
88+
89+
expect(FlashMessagesLogic.values.historyListener).toEqual('test');
90+
});
91+
});
92+
93+
describe('listenToHistory()', () => {
94+
it('listens for history changes and clears messages on change', () => {
95+
FlashMessagesLogic.mount();
96+
FlashMessagesLogic.actions.setQueuedMessages(['queuedMessages'] as any);
97+
jest.spyOn(FlashMessagesLogic.actions, 'clearFlashMessages');
98+
jest.spyOn(FlashMessagesLogic.actions, 'setFlashMessages');
99+
jest.spyOn(FlashMessagesLogic.actions, 'clearQueuedMessages');
100+
jest.spyOn(FlashMessagesLogic.actions, 'setHistoryListener');
101+
102+
const mockListener = jest.fn(() => jest.fn());
103+
const history = { listen: mockListener } as any;
104+
FlashMessagesLogic.actions.listenToHistory(history);
105+
106+
expect(mockListener).toHaveBeenCalled();
107+
expect(FlashMessagesLogic.actions.setHistoryListener).toHaveBeenCalled();
108+
109+
const mockHistoryChange = (mockListener.mock.calls[0] as any)[0];
110+
mockHistoryChange();
111+
expect(FlashMessagesLogic.actions.clearFlashMessages).toHaveBeenCalled();
112+
expect(FlashMessagesLogic.actions.setFlashMessages).toHaveBeenCalledWith([
113+
'queuedMessages',
114+
]);
115+
expect(FlashMessagesLogic.actions.clearQueuedMessages).toHaveBeenCalled();
116+
});
117+
});
118+
119+
describe('beforeUnmount', () => {
120+
it('removes history listener on unmount', () => {
121+
const mockUnlistener = jest.fn();
122+
const unmount = FlashMessagesLogic.mount();
123+
124+
FlashMessagesLogic.actions.setHistoryListener(mockUnlistener);
125+
unmount();
126+
127+
expect(mockUnlistener).toHaveBeenCalled();
128+
});
129+
130+
it('does not crash if no listener exists', () => {
131+
const unmount = FlashMessagesLogic.mount();
132+
unmount();
133+
});
134+
});
135+
});
136+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { kea } from 'kea';
8+
import { ReactNode } from 'react';
9+
import { History } from 'history';
10+
11+
import { IKeaLogic, TKeaReducers, IKeaParams } from '../types';
12+
13+
export interface IFlashMessage {
14+
type: 'success' | 'info' | 'warning' | 'error';
15+
message: ReactNode;
16+
description?: ReactNode;
17+
}
18+
19+
export interface IFlashMessagesValues {
20+
messages: IFlashMessage[];
21+
queuedMessages: IFlashMessage[];
22+
historyListener: Function | null;
23+
}
24+
export interface IFlashMessagesActions {
25+
setFlashMessages(messages: IFlashMessage | IFlashMessage[]): void;
26+
clearFlashMessages(): void;
27+
setQueuedMessages(messages: IFlashMessage | IFlashMessage[]): void;
28+
clearQueuedMessages(): void;
29+
listenToHistory(history: History): void;
30+
setHistoryListener(historyListener: Function): void;
31+
}
32+
33+
const convertToArray = (messages: IFlashMessage | IFlashMessage[]) =>
34+
!Array.isArray(messages) ? [messages] : messages;
35+
36+
export const FlashMessagesLogic = kea({
37+
actions: (): IFlashMessagesActions => ({
38+
setFlashMessages: (messages) => ({ messages: convertToArray(messages) }),
39+
clearFlashMessages: () => null,
40+
setQueuedMessages: (messages) => ({ messages: convertToArray(messages) }),
41+
clearQueuedMessages: () => null,
42+
listenToHistory: (history) => history,
43+
setHistoryListener: (historyListener) => ({ historyListener }),
44+
}),
45+
reducers: (): TKeaReducers<IFlashMessagesValues, IFlashMessagesActions> => ({
46+
messages: [
47+
[],
48+
{
49+
setFlashMessages: (_, { messages }) => messages,
50+
clearFlashMessages: () => [],
51+
},
52+
],
53+
queuedMessages: [
54+
[],
55+
{
56+
setQueuedMessages: (_, { messages }) => messages,
57+
clearQueuedMessages: () => [],
58+
},
59+
],
60+
historyListener: [
61+
null,
62+
{
63+
setHistoryListener: (_, { historyListener }) => historyListener,
64+
},
65+
],
66+
}),
67+
listeners: ({ values, actions }): Partial<IFlashMessagesActions> => ({
68+
listenToHistory: (history) => {
69+
// On React Router navigation, clear previous flash messages and load any queued messages
70+
const unlisten = history.listen(() => {
71+
actions.clearFlashMessages();
72+
actions.setFlashMessages(values.queuedMessages);
73+
actions.clearQueuedMessages();
74+
});
75+
actions.setHistoryListener(unlisten);
76+
},
77+
}),
78+
events: ({ values }) => ({
79+
beforeUnmount: () => {
80+
const { historyListener: removeHistoryListener } = values;
81+
if (removeHistoryListener) removeHistoryListener();
82+
},
83+
}),
84+
} as IKeaParams<IFlashMessagesValues, IFlashMessagesActions>) as IKeaLogic<
85+
IFlashMessagesValues,
86+
IFlashMessagesActions
87+
>;

0 commit comments

Comments
 (0)