-
Notifications
You must be signed in to change notification settings - Fork 8.2k
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
Changes from all commits
03fee53
0d8215c
d83d23a
d9eeb2a
5dc0a24
4150a84
28275c1
ec50b60
adc814e
6481a01
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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' }, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: [ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I also want to quickly highlight here - the concept of It's most useful for deletion scenarios, where the UX flow goes as such:
In that flow, the logic/code in this new Kibana logic would look something like this:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like the explicit distinction. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. API doc for future reference: https://github.com/ReactTraining/history/blob/master/docs/api-reference.md#historylistenlistener-listener |
||
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 | ||
>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice thought.