[Enterprise Search] Add toasts support to FlashMessages component (#95981)

* Add toasts to FlashMessagesLogic

+ Tests cleanup:

- Group actions by their reducer blocks (since flashMessages has such specific logic) - recommend viewing with whitespace changes off for this
- Do not reset context between each test, but instead by mount(), which allows tests to maintain state between adding/removing/resetting
- Remove '()' from test names (feedback from previous PRs)

* Add toast message helpers

+ refactor FLASH_MESSAGE_TYPES to constants, so that both callouts & toasts can use it effectively

* Update FlashMessages to display toasts as well as callouts

- This means we can automatically use toasts alongside callouts in all views that already have FlashMessages

+ a11y enhancement! update callouts to also announce new messages to screenreaders

* [Example] Update ApiLogsLogic to flash an error toast on poll

+ update copy to better match EUI guidelines (shorter)

* Fix test caused by new FlashMessages structure

* PR suggestion - destructure

Co-authored-by: Scotty Bollinger <scotty.bollinger@elastic.co>

* PR feedback: implicit return

* Fix color types

- adding our own string enum fixes the typescript errors that both EuiCallout & EuiToast emit when passing color props to the base EUI types

* PR feedback: Update flashToast API to match callout helper API

- accepts a string title with optional args, creates a unique ID automatically if missing

Co-authored-by: Scotty Bollinger <scotty.bollinger@elastic.co>
This commit is contained in:
Constance 2021-04-02 09:50:39 -07:00 committed by GitHub
parent fb681d9062
commit 1d58266559
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 356 additions and 104 deletions

View file

@ -24,6 +24,8 @@ export const mockFlashMessageHelpers = {
setQueuedSuccessMessage: jest.fn(),
setQueuedErrorMessage: jest.fn(),
clearFlashMessages: jest.fn(),
flashSuccessToast: jest.fn(),
flashErrorToast: jest.fn(),
};
jest.mock('../shared/flash_messages', () => ({

View file

@ -12,14 +12,12 @@ import { nextTick } from '@kbn/test/jest';
import { DEFAULT_META } from '../../../shared/constants';
import { POLLING_ERROR_MESSAGE } from './constants';
import { ApiLogsLogic } from './';
describe('ApiLogsLogic', () => {
const { mount, unmount } = new LogicMounter(ApiLogsLogic);
const { http } = mockHttpValues;
const { flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers;
const { flashAPIErrors, flashErrorToast } = mockFlashMessageHelpers;
const DEFAULT_VALUES = {
dataLoading: true,
@ -213,7 +211,10 @@ describe('ApiLogsLogic', () => {
ApiLogsLogic.actions.fetchApiLogs({ isPoll: true });
await nextTick();
expect(setErrorMessage).toHaveBeenCalledWith(POLLING_ERROR_MESSAGE);
expect(flashErrorToast).toHaveBeenCalledWith('Could not refresh API log data', {
text: expect.stringContaining('Please check your connection'),
toastLifeTimeMs: 3750,
});
});
});

View file

@ -8,12 +8,12 @@
import { kea, MakeLogicType } from 'kea';
import { DEFAULT_META } from '../../../shared/constants';
import { flashAPIErrors, setErrorMessage } from '../../../shared/flash_messages';
import { flashAPIErrors, flashErrorToast } from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
import { updateMetaPageIndex } from '../../../shared/table_pagination';
import { EngineLogic } from '../engine';
import { POLLING_DURATION, POLLING_ERROR_MESSAGE } from './constants';
import { POLLING_DURATION, POLLING_ERROR_TITLE, POLLING_ERROR_TEXT } from './constants';
import { ApiLogsData, ApiLog } from './types';
import { getDateString } from './utils';
@ -117,14 +117,21 @@ export const ApiLogsLogic = kea<MakeLogicType<ApiLogsValues, ApiLogsActions>>({
// while polls are stored in-state until the user manually triggers the 'Refresh' action
if (isPoll) {
actions.onPollInterval(response);
flashErrorToast(POLLING_ERROR_TITLE, {
text: POLLING_ERROR_TEXT,
toastLifeTimeMs: POLLING_DURATION * 0.75,
});
} else {
actions.updateView(response);
}
} catch (e) {
if (isPoll) {
// If polling fails, it will typically be due due to http connection -
// If polling fails, it will typically be due to http connection -
// we should send a more human-readable message if so
setErrorMessage(POLLING_ERROR_MESSAGE);
flashErrorToast(POLLING_ERROR_TITLE, {
text: POLLING_ERROR_TEXT,
toastLifeTimeMs: POLLING_DURATION * 0.75,
});
} else {
flashAPIErrors(e);
}

View file

@ -19,10 +19,11 @@ export const RECENT_API_EVENTS = i18n.translate(
export const POLLING_DURATION = 5000;
export const POLLING_ERROR_MESSAGE = i18n.translate(
export const POLLING_ERROR_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.apiLogs.pollingErrorMessage',
{
defaultMessage:
'Could not automatically refresh API logs data. Please check your connection or manually refresh the page.',
}
{ defaultMessage: 'Could not refresh API log data' }
);
export const POLLING_ERROR_TEXT = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.apiLogs.pollingErrorDescription',
{ defaultMessage: 'Please check your connection or manually reload the page.' }
);

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FlashMessageColors } from './types';
export const FLASH_MESSAGE_TYPES = {
success: { color: 'success' as FlashMessageColors, iconType: 'check' },
info: { color: 'primary' as FlashMessageColors, iconType: 'iInCircle' },
warning: { color: 'warning' as FlashMessageColors, iconType: 'alert' },
error: { color: 'danger' as FlashMessageColors, iconType: 'alert' },
};
// This is the default amount of time (5 seconds) a toast will last before disappearing
// It can be overridden per-toast by passing the `toastLifetimeMs` property - @see types.ts
export const DEFAULT_TOAST_TIMEOUT = 5000;

View file

@ -5,62 +5,96 @@
* 2.0.
*/
import { setMockValues } from '../../__mocks__/kea.mock';
import { setMockValues, setMockActions } from '../../__mocks__/kea.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiCallOut } from '@elastic/eui';
import { EuiCallOut, EuiGlobalToastList } from '@elastic/eui';
import { FlashMessages } from './flash_messages';
import { FlashMessages, Callouts, Toasts } from './flash_messages';
describe('FlashMessages', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('does not render if no messages exist', () => {
setMockValues({ messages: [] });
it('renders callout and toast flash messages', () => {
const wrapper = shallow(<FlashMessages />);
expect(wrapper.isEmptyRender()).toBe(true);
expect(wrapper.find(Callouts)).toHaveLength(1);
expect(wrapper.find(Toasts)).toHaveLength(1);
});
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' },
];
setMockValues({ messages: mockMessages });
describe('callouts', () => {
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' },
];
setMockValues({ messages: mockMessages });
const wrapper = shallow(<FlashMessages />);
const wrapper = shallow(<Callouts />);
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');
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', () => {
setMockValues({ messages: [{ type: 'success' }] });
const wrapper = shallow(
<Callouts>
<button data-test-subj="testing">
Some action - you could even clear flash messages here
</button>
</Callouts>
);
expect(wrapper.find('[data-test-subj="testing"]').text()).toContain('Some action');
});
});
it('renders any children', () => {
setMockValues({ messages: [{ type: 'success' }] });
describe('toasts', () => {
const actions = { dismissToastMessage: jest.fn() };
beforeAll(() => setMockActions(actions));
const wrapper = shallow(
<FlashMessages>
<button data-test-subj="testing">
Some action - you could even clear flash messages here
</button>
</FlashMessages>
);
it('renders an EUI toast list', () => {
const mockToasts = [
{ id: 'test', title: 'Hello world!!' },
{
color: 'success',
iconType: 'check',
title: 'Success!',
toastLifeTimeMs: 500,
id: 'successToastId',
},
{
color: 'danger',
iconType: 'alert',
title: 'Oh no!',
text: <div data-test-subj="error">Something went wrong</div>,
id: 'errorToastId',
},
];
setMockValues({ toastMessages: mockToasts });
expect(wrapper.find('[data-test-subj="testing"]').text()).toContain('Some action');
const wrapper = shallow(<Toasts />);
const euiToastList = wrapper.find(EuiGlobalToastList);
expect(euiToastList).toHaveLength(1);
expect(euiToastList.prop('toasts')).toEqual(mockToasts);
expect(euiToastList.prop('dismissToast')).toEqual(actions.dismissToastMessage);
expect(euiToastList.prop('toastLifeTimeMs')).toEqual(5000);
});
});
});

View file

@ -7,32 +7,30 @@
import React, { Fragment } from 'react';
import { useValues } from 'kea';
import { useValues, useActions } from 'kea';
import { EuiCallOut, EuiCallOutProps, EuiSpacer } from '@elastic/eui';
import { EuiCallOut, EuiSpacer, EuiGlobalToastList } from '@elastic/eui';
import { FLASH_MESSAGE_TYPES, DEFAULT_TOAST_TIMEOUT } from './constants';
import { FlashMessagesLogic } 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: 'alert' },
};
export const FlashMessages: React.FC = ({ children }) => (
<>
<Callouts>{children}</Callouts>
<Toasts />
</>
);
export const FlashMessages: React.FC = ({ children }) => {
export const Callouts: React.FC = ({ children }) => {
const { messages } = useValues(FlashMessagesLogic);
// 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">
<div aria-live="polite" role="region" 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}
iconType={FLASH_MESSAGE_TYPES[type].iconType}
title={message}
>
{description}
@ -44,3 +42,16 @@ export const FlashMessages: React.FC = ({ children }) => {
</div>
);
};
export const Toasts: React.FC = () => {
const { toastMessages } = useValues(FlashMessagesLogic);
const { dismissToastMessage } = useActions(FlashMessagesLogic);
return (
<EuiGlobalToastList
toasts={toastMessages}
dismissToast={dismissToastMessage}
toastLifeTimeMs={DEFAULT_TOAST_TIMEOUT}
/>
);
};

View file

@ -15,11 +15,13 @@ import { FlashMessagesLogic, mountFlashMessagesLogic } from './flash_messages_lo
import { IFlashMessage } from './types';
describe('FlashMessagesLogic', () => {
const mount = () => mountFlashMessagesLogic();
const mount = () => {
resetContext({});
return mountFlashMessagesLogic();
};
beforeEach(() => {
jest.clearAllMocks();
resetContext({});
});
it('has default values', () => {
@ -27,67 +29,112 @@ describe('FlashMessagesLogic', () => {
expect(FlashMessagesLogic.values).toEqual({
messages: [],
queuedMessages: [],
toastMessages: [],
historyListener: expect.any(Function),
});
});
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' },
];
describe('messages', () => {
beforeAll(() => {
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;
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' },
];
mount();
FlashMessagesLogic.actions.setFlashMessages(message);
FlashMessagesLogic.actions.setFlashMessages(messages);
expect(FlashMessagesLogic.values.messages).toEqual([message]);
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.actions.setFlashMessages(message);
expect(FlashMessagesLogic.values.messages).toEqual([message]);
});
});
describe('clearFlashMessages', () => {
it('resets messages back to an empty array', () => {
FlashMessagesLogic.actions.clearFlashMessages();
expect(FlashMessagesLogic.values.messages).toEqual([]);
});
});
});
describe('clearFlashMessages()', () => {
it('sets messages back to an empty array', () => {
describe('queuedMessages', () => {
beforeAll(() => {
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.actions.setQueuedMessages(queuedMessage);
expect(FlashMessagesLogic.values.queuedMessages).toEqual([queuedMessage]);
});
});
describe('clearQueuedMessages', () => {
it('resets queued messages back to an empty array', () => {
FlashMessagesLogic.actions.clearQueuedMessages();
expect(FlashMessagesLogic.values.queuedMessages).toEqual([]);
});
});
});
describe('setQueuedMessages()', () => {
it('sets an array of messages', () => {
const queuedMessage: IFlashMessage = { type: 'error', message: 'You deleted a thing' };
describe('toastMessages', () => {
beforeAll(() => {
mount();
FlashMessagesLogic.actions.setQueuedMessages(queuedMessage);
expect(FlashMessagesLogic.values.queuedMessages).toEqual([queuedMessage]);
});
});
describe('clearQueuedMessages()', () => {
it('sets queued messages back to an empty array', () => {
mount();
FlashMessagesLogic.actions.setQueuedMessages('test' as any);
FlashMessagesLogic.actions.clearQueuedMessages();
describe('addToastMessage', () => {
it('appends a toast message to the current toasts array', () => {
FlashMessagesLogic.actions.addToastMessage({ id: 'hello' });
FlashMessagesLogic.actions.addToastMessage({ id: 'world' });
FlashMessagesLogic.actions.addToastMessage({ id: 'lorem ipsum' });
expect(FlashMessagesLogic.values.queuedMessages).toEqual([]);
expect(FlashMessagesLogic.values.toastMessages).toEqual([
{ id: 'hello' },
{ id: 'world' },
{ id: 'lorem ipsum' },
]);
});
});
describe('dismissToastMessage', () => {
it('removes a specific toast ID from the current toasts array', () => {
FlashMessagesLogic.actions.dismissToastMessage({ id: 'world' });
expect(FlashMessagesLogic.values.toastMessages).toEqual([
{ id: 'hello' },
{ id: 'lorem ipsum' },
]);
});
});
describe('clearToastMessages', () => {
it('resets toast messages back to an empty array', () => {
FlashMessagesLogic.actions.clearToastMessages();
expect(FlashMessagesLogic.values.toastMessages).toEqual([]);
});
});
});
describe('history listener logic', () => {
describe('setHistoryListener()', () => {
describe('setHistoryListener', () => {
it('sets the historyListener value', () => {
mount();
FlashMessagesLogic.actions.setHistoryListener('test' as any);

View file

@ -7,6 +7,8 @@
import { kea, MakeLogicType } from 'kea';
import { EuiGlobalToastListToast as IToast } from '@elastic/eui';
import { KibanaLogic } from '../kibana';
import { IFlashMessage } from './types';
@ -14,6 +16,7 @@ import { IFlashMessage } from './types';
interface FlashMessagesValues {
messages: IFlashMessage[];
queuedMessages: IFlashMessage[];
toastMessages: IToast[];
historyListener: Function | null;
}
interface FlashMessagesActions {
@ -21,6 +24,9 @@ interface FlashMessagesActions {
clearFlashMessages(): void;
setQueuedMessages(messages: IFlashMessage | IFlashMessage[]): { messages: IFlashMessage[] };
clearQueuedMessages(): void;
addToastMessage(newToast: IToast): { newToast: IToast };
dismissToastMessage(removedToast: IToast): { removedToast: IToast };
clearToastMessages(): void;
setHistoryListener(historyListener: Function): { historyListener: Function };
}
@ -34,6 +40,9 @@ export const FlashMessagesLogic = kea<MakeLogicType<FlashMessagesValues, FlashMe
clearFlashMessages: () => null,
setQueuedMessages: (messages) => ({ messages: convertToArray(messages) }),
clearQueuedMessages: () => null,
addToastMessage: (newToast) => ({ newToast }),
dismissToastMessage: (removedToast) => ({ removedToast }),
clearToastMessages: () => null,
setHistoryListener: (historyListener) => ({ historyListener }),
},
reducers: {
@ -51,6 +60,15 @@ export const FlashMessagesLogic = kea<MakeLogicType<FlashMessagesValues, FlashMe
clearQueuedMessages: () => [],
},
],
toastMessages: [
[],
{
addToastMessage: (toasts, { newToast }) => [...toasts, newToast],
dismissToastMessage: (toasts, { removedToast }) =>
toasts.filter(({ id }) => id !== removedToast.id),
clearToastMessages: () => [],
},
],
historyListener: [
null,
{

View file

@ -14,5 +14,7 @@ export {
setErrorMessage,
setQueuedSuccessMessage,
setQueuedErrorMessage,
flashSuccessToast,
flashErrorToast,
clearFlashMessages,
} from './set_message_helpers';

View file

@ -14,6 +14,8 @@ import {
setQueuedSuccessMessage,
setQueuedErrorMessage,
clearFlashMessages,
flashSuccessToast,
flashErrorToast,
} from './set_message_helpers';
describe('Flash Message Helpers', () => {
@ -72,4 +74,82 @@ describe('Flash Message Helpers', () => {
expect(FlashMessagesLogic.values.messages).toEqual([]);
});
describe('toast helpers', () => {
afterEach(() => {
FlashMessagesLogic.actions.clearToastMessages();
});
describe('without optional args', () => {
beforeEach(() => {
jest.spyOn(global.Date, 'now').mockReturnValueOnce(1234567890);
});
it('flashSuccessToast', () => {
flashSuccessToast('You did a thing!');
expect(FlashMessagesLogic.values.toastMessages).toEqual([
{
color: 'success',
iconType: 'check',
title: 'You did a thing!',
id: 'successToast-1234567890',
},
]);
});
it('flashErrorToast', () => {
flashErrorToast('Something went wrong');
expect(FlashMessagesLogic.values.toastMessages).toEqual([
{
color: 'danger',
iconType: 'alert',
title: 'Something went wrong',
id: 'errorToast-1234567890',
},
]);
});
});
describe('with optional args', () => {
it('flashSuccessToast', () => {
flashSuccessToast('You did a thing!', {
text: '<button>View new thing</button>',
toastLifeTimeMs: 50,
id: 'customId',
});
expect(FlashMessagesLogic.values.toastMessages).toEqual([
{
color: 'success',
iconType: 'check',
title: 'You did a thing!',
text: '<button>View new thing</button>',
toastLifeTimeMs: 50,
id: 'customId',
},
]);
});
it('flashErrorToast', () => {
flashErrorToast('Something went wrong', {
text: "Here's some helpful advice on what to do",
toastLifeTimeMs: 50000,
id: 'specificErrorId',
});
expect(FlashMessagesLogic.values.toastMessages).toEqual([
{
color: 'danger',
iconType: 'alert',
title: 'Something went wrong',
text: "Here's some helpful advice on what to do",
toastLifeTimeMs: 50000,
id: 'specificErrorId',
},
]);
});
});
});
});

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import { FLASH_MESSAGE_TYPES } from './constants';
import { FlashMessagesLogic } from './flash_messages_logic';
import { ToastOptions } from './types';
export const setSuccessMessage = (message: string) => {
FlashMessagesLogic.actions.setFlashMessages({
@ -38,3 +40,21 @@ export const setQueuedErrorMessage = (message: string) => {
export const clearFlashMessages = () => {
FlashMessagesLogic.actions.clearFlashMessages();
};
export const flashSuccessToast = (message: string, toastOptions: ToastOptions = {}) => {
FlashMessagesLogic.actions.addToastMessage({
...FLASH_MESSAGE_TYPES.success,
...toastOptions,
title: message,
id: toastOptions?.id || `successToast-${Date.now()}`,
});
};
export const flashErrorToast = (message: string, toastOptions: ToastOptions = {}) => {
FlashMessagesLogic.actions.addToastMessage({
...FLASH_MESSAGE_TYPES.error,
...toastOptions,
title: message,
id: toastOptions?.id || `errorToast-${Date.now()}`,
});
};

View file

@ -5,10 +5,20 @@
* 2.0.
*/
import { ReactNode } from 'react';
import { ReactNode, ReactChild } from 'react';
export type FlashMessageTypes = 'success' | 'info' | 'warning' | 'error';
export type FlashMessageColors = 'success' | 'primary' | 'warning' | 'danger';
export interface IFlashMessage {
type: 'success' | 'info' | 'warning' | 'error';
type: FlashMessageTypes;
message: ReactNode;
description?: ReactNode;
}
// @see EuiGlobalToastListToast for more props
export interface ToastOptions {
text?: ReactChild; // Additional text below the message/title, same as IFlashMessage['description']
toastLifeTimeMs?: number; // Allows customing per-toast timeout
id?: string;
}

View file

@ -98,7 +98,7 @@ describe('GroupOverview', () => {
messages: [mockSuccessMessage],
});
const wrapper = shallow(<Groups />);
const flashMessages = wrapper.find(FlashMessages).dive().shallow();
const flashMessages = wrapper.find(FlashMessages).dive().childAt(0).dive();
expect(flashMessages.find('[data-test-subj="NewGroupManageButton"]')).toHaveLength(1);
});