Create Global Banner Area (#16340)

This is a follow up to work done to add a `GlobalToastList` and it adds a `GlobalBannerList` that serves a similar role: a singleton class that can be modified as needed to display useful information to the user.

The major difference between this area and the toast notifications is that this is completely self-managed. A good example of this is the `createFirstIndexPatternPrompt` banner, which this PR fits into the global banner list.

This area is not intended to be a common area to display information, but it is useful for the rare, globally useful information.
This commit is contained in:
Chris Earle 2018-02-02 10:30:56 -05:00 committed by Chris Earle
parent d056e2d625
commit 005c1ab64d
19 changed files with 858 additions and 60 deletions

View file

@ -4,6 +4,7 @@ In this directory you'll find various UI systems you can use to craft effective
## ui/notify
* [banners](notify/banners/BANNERS.md)
* [toastNotifications](notify/toasts/TOAST_NOTIFICATIONS.md)
## ui/vislib

View file

@ -14,19 +14,7 @@
list="notifList"
></kbn-notifications>
<div ng-if="createFirstIndexPatternPrompt.isVisible" class="euiCallOut euiCallOut--warning noIndicesMessage">
<div class="euiCallOutHeader">
<svg class="euiIcon euiCallOutHeader__icon euiIcon--medium" aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M8.3 10.717H6.7v-4h1.6v4zm-1.6-5.71a.83.83 0 0 1 .207-.578c.137-.153.334-.229.59-.229.256 0 .454.076.594.23.14.152.209.345.209.576 0 .228-.07.417-.21.568-.14.15-.337.226-.593.226-.256 0-.453-.075-.59-.226a.81.81 0 0 1-.207-.568zM7.5 13A5.506 5.506 0 0 1 2 7.5C2 4.467 4.467 2 7.5 2S13 4.467 13 7.5 10.533 13 7.5 13m0-12a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13"
fill-rule="evenodd" />
</svg>
<span class="euiCallOutHeader__title">
In order to visualize and explore data in Kibana, you'll need to create an index pattern to retrieve data from Elasticsearch.
</span>
</div>
</div>
<div id="globalBannerList"></div>
<div id="globalToastList"></div>
<kbn-loading-indicator></kbn-loading-indicator>

View file

@ -10,7 +10,13 @@ import {
getUnhashableStatesProvider,
unhashUrl,
} from 'ui/state_management/state_hashing';
import { notify, GlobalToastList, toastNotifications, createFirstIndexPatternPrompt } from 'ui/notify';
import {
notify,
GlobalToastList,
toastNotifications,
GlobalBannerList,
banners,
} from 'ui/notify';
import { SubUrlRouteFilterProvider } from './sub_url_route_filter';
export function kbnChromeProvider(chrome, internals) {
@ -72,24 +78,28 @@ export function kbnChromeProvider(chrome, internals) {
// Notifications
$scope.notifList = notify._notifs;
const globalToastList = instance => {
toastNotifications.onChange(() => {
instance.forceUpdate();
});
};
// Non-scope based code (e.g., React)
// Banners
ReactDOM.render(
<GlobalBannerList
banners={banners.list}
subscribe={banners.onChange}
/>,
document.getElementById('globalBannerList')
);
// Toast Notifications
ReactDOM.render(
<GlobalToastList
ref={globalToastList}
toasts={toastNotifications.list}
dismissToast={toastNotifications.remove}
toastLifeTimeMs={6000}
subscribe={toastNotifications.onChange}
/>,
document.getElementById('globalToastList')
);
$scope.createFirstIndexPatternPrompt = createFirstIndexPatternPrompt;
return chrome;
}
};

View file

@ -31,7 +31,3 @@ body { overflow-x: hidden; }
.app-wrapper-panel {
.flex-parent(@shrink: 0);
}
.noIndicesMessage {
margin: 12px;
}

View file

@ -1,8 +1,42 @@
import _ from 'lodash';
import { createFirstIndexPatternPrompt } from 'ui/notify';
import React from 'react';
import { banners } from 'ui/notify';
import { NoDefaultIndexPattern } from 'ui/errors';
import { IndexPatternsGetProvider } from '../_get';
import uiRoutes from 'ui/routes';
import {
EuiCallOut,
} from '@elastic/eui';
import { clearTimeout } from 'timers';
let bannerId;
let timeoutId;
function displayBanner() {
clearTimeout(timeoutId);
// Avoid being hostile to new users who don't have an index pattern setup yet
// give them a friendly info message instead of a terse error message
bannerId = banners.set({
id: bannerId, // initially undefined, but reused after first set
component: (
<EuiCallOut
color="warning"
iconType="iInCircle"
title={
`In order to visualize and explore data in Kibana,
you'll need to create an index pattern to retrieve data from Elasticsearch.`
}
/>
)
});
// hide the message after the user has had a chance to acknowledge it -- so it doesn't permanently stick around
timeoutId = setTimeout(() => {
banners.remove(bannerId);
timeoutId = undefined;
}, 15000);
}
// eslint-disable-next-line @elastic/kibana-custom/no-default-export
export default function (opts) {
@ -47,10 +81,7 @@ export default function (opts) {
kbnUrl.change(whenMissingRedirectTo);
// Avoid being hostile to new users who don't have an index pattern setup yet
// give them a friendly info message instead of a terse error message
createFirstIndexPatternPrompt.show();
setTimeout(createFirstIndexPatternPrompt.hide, 15000);
displayBanner();
}
);
}

View file

@ -0,0 +1,360 @@
# Banners
Use this service to surface banners at the top of the screen. The expectation is that the banner will used an
`<EuiCallOut />` to render, but that is not a requirement. See [the EUI docs](https://elastic.github.io/eui/) for
more information on banners and their role within the UI.
Banners should be considered with respect to their lifecycle. Most banners are best served by using the `add` and
`remove` functions.
## Importing the module
```js
import { banners } from 'ui/notify';
```
## Interface
There are three methods defined to manipulate the list of banners: `add`, `set`, and `remove`. A fourth method,
`onChange` exists to listen to changes made via `add`, `set`, and `remove`.
### `add()`
This is the preferred way to add banners because it implies the best usage of the banner: added once during a page's
lifecycle. For other usages, consider *not* using a banner.
#### Syntax
```js
const bannerId = banners.add({
// required:
component,
// optional:
priority,
});
```
##### Parameters
| Field | Type | Description |
|-------|------|-------------|
| `component` | Any | The value displayed as the banner. |
| `priority` | Number | Optional priority, which defaults to `0` used to place the banner. |
To add a banner, you only need to define the `component` field.
The `priority` sorts in descending order. Items sharing the same priority are sorted from oldest to newest. For example:
```js
const banner1 = banners.add({ component: <EuiCallOut title="fake1" /> });
const banner2 = banners.add({ component: <EuiCallOut title="fake2" />, priority: 0 });
const banner3 = banners.add({ component: <EuiCallOut title="fake3" />, priority: 1 });
```
That would be displayed as:
```
[ fake3 ]
[ fake1 ]
[ fake2 ]
```
##### Returns
| Type | Description |
|------|-------------|
| String | A newly generated ID. |
#### Example
This example includes buttons that allow the user to remove the banner. In some cases, you may not want any buttons
and in other cases you will want an action to proceed the banner's removal (e.g., apply an Advanced Setting).
This makes the most sense to use when a banner is added at the beginning of the page life cycle and not expected to
be touched, except by its own buttons triggering an action or navigating away.
```js
const bannerId = banners.add({
component: (
<EuiCallOut
iconType="iInCircle"
title="In order to visualize and explore data in Kibana, you'll need to create an index pattern to retrieve data from Elasticsearch."
>
<EuiFlexGroup
gutterSize="s"
alignItems="center"
>
<EuiFlexItem
grow={false}
>
<EuiButton
size="s"
fill
onClick={() => banners.remove(bannerId)}
>
Dismiss
</EuiButton>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
size="s"
onClick={() => window.alert('Do Something Else')}
>
Do Something Else
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiCallOut>
),
});
```
### `remove()`
Unlike toast notifications, banners stick around until they are explicitly removed. Using the `add` example above,you can remove it by calling `remove`.
Note: They will stick around as long as the scope is remembered by whatever set it; navigating away won't remove it
unless the scope is forgotten (e.g., when the "app" changes)!
#### Syntax
```js
const removed = banners.remove(bannerId);
```
##### Parameters
| Field | Type | Description |
|-------|------|-------------|
| `id` | String | ID of a banner. |
##### Returns
| Type | Description |
|------|-------------|
| Boolean | `true` if the ID was recognized and the banner was removed. `false` otherwise. |
#### Example
To remove a banner, you need to pass the `id` of the banner.
```js
if (banners.remove(bannerId)) {
// removed; otherwise it didn't exist (maybe it was already removed)
}
```
#### Scheduled removal
Like toast notifications do automatically, you can have a banner automatically removed after a set of time, by
setting a timer:
```js
setTimeout(() => banners.remove(bannerId), 15000);
```
Note: It is safe to remove a banner more than once as unknown IDs will be ignored.
### `set()`
Banners can be replaced once added by supplying their `id`. If one is supplied, then the ID will be used to replace
any banner with the same ID and a **new** `id` will be returned.
You should only consider using `set` when the banner is manipulated frequently in the lifecycle of the page, where
maintaining the banner's `id` can be a burden. It is easier to allow `banners` to create the ID for you in most
situations where a banner is useful (e.g., set once), which safely avoids any chance to have an ID-based collision,
which happens automatically with `add`.
Usage of `set` can imply that your use case is abusing the banner system.
Note: `set` will only trigger the callback once for both the implicit remove and add operation.
#### Syntax
```js
const id = banners.set({
// required:
component,
// optional:
id,
priority,
});
```
##### Parameters
| Field | Type | Description |
|-------|------|-------------|
| `component` | Any | The value displayed as the banner. |
| `id` | String | Optional ID used to remove an existing banner. |
| `priority` | Number | Optional priority, which defaults to `0` used to place the banner. |
The `id` is optional because it follows the same semantics as the `remove` method: unknown IDs are ignored. This
is useful when first creating a banner so that you do not have to call `add` instead.
##### Returns
| Type | Description |
|------|-------------|
| String | A newly generated ID. |
#### Example
This example does not include any way for the user to clear the banner directly. Instead, it is cleared based on
time. Related to it being cleared by time, it can also reappear within the same page life cycle by navigating between
different paths that need it displayed. Instead of adding a new banner for every navigation, you should replace any
existing banner.
```js
let bannerId;
let timeoutId;
function displayBanner() {
clearTimeout(timeoutId);
bannerId = banners.set({
id: bannerId, // the first time it will be undefined, but reused as long as this is in the same lifecycle
component: (
<EuiCallOut
color="warning"
iconType="iInCircle"
title={
`In order to visualize and explore data in Kibana,
you'll need to create an index pattern to retrieve data from Elasticsearch.`
}
/>
)
});
// hide the message after the user has had a chance to acknowledge it -- so it doesn't permanently stick around
banner.timeoutId = setTimeout(() => {
banners.remove(bannerId);
timeoutId = undefined;
}, 6000);
}
```
### `onChange()`
For React components that intend to display the banners, it is not enough to simply `render` the `banners.list`
values. Because they can change after being rendered, the React component that renders the list must be alerted
to changes to the list.
#### Syntax
```js
// inside your React component
banners.onChange(() => this.forceUpdate());
```
##### Parameters
| Field | Type | Description |
|-------|------|-------------|
| `callback` | Function | The function to invoke whenever the internal banner list is changed. |
Every new `callback` replaces the previous callback. So calling this with `null` or `undefined` will unset the
callback.
##### Returns
Nothing.
#### Example
This can be used inside of a React component to trigger a re-`render` of the banners.
```js
import { GlobalBannerList } from 'ui/notify';
<GlobalBannerList
banners={banners.list}
subscribe={banners.onChange}
/>
```
### `list`
For React components that intend to display the banners, it is not enough to simply `render` the `banners.list`
values. Because they can change after being rendered, the React component that renders the list must be alerted
to changes to the list.
#### Syntax
```js
<GlobalBannerList
banners={banners.list}
subscribe={banners.onChange}
/>
```
##### Returns
| Type | Description |
|------|-------------|
| Array | The array of banner objects. |
Banner objects are sorted in descending order based on their `priority`, in the form:
```js
{
id: 'banner-123',
component: <EuiCallOut />,
priority: 12,
}
```
| Field | Type | Description |
|-------|------|-------------|
| `component` | Any | The value displayed as the banner. |
| `id` | String | The ID of the banner, which can be used as a React "key". |
| `priority` | Number | The priority of the banner. |
#### Example
This can be used to supply the banners to the `GlobalBannerList` React component (which is done for you).
```js
import { GlobalBannerList } from 'ui/notify';
<GlobalBannerList
banners={banners.list}
subscribe={banners.onChange}
/>
```
## Use in functional tests
Functional tests are commonly used to verify that an action yielded a sucessful outcome. You can place a
`data-test-subj` attribute on the banner and use it to check if the banner exists inside of your functional test.
This acts as a proxy for verifying the sucessful outcome. Any unrecognized field will be added as a property of the
containing element.
```js
banners.add({
component: (
<EuiCallOut
title="Look at me!"
/>
),
data-test-subj: 'my-tested-banner',
});
```
This will apply the `data-test-subj` to the element containing the `component`, so the inner HTML of that element
will exclusively be the specified `component`.
Given that `component` is expected to be a React component, you could also add the `data-test-subj` directly to it:
```js
banners.add({
component: (
<EuiCallOut
title="Look at me!"
data-test-subj="my-tested-banner"
/>
),
});
```

View file

@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GlobalBannerList is rendered 1`] = `null`;
exports[`GlobalBannerList props banners is rendered 1`] = `
<div
class="euiPanel euiPanel--paddingMedium"
style="border:none"
>
<div
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--responsive"
style="flex-direction:column"
>
<div
class="euiFlexItem"
data-test-priority="1"
>
a component
</div>
<div
class="euiFlexItem"
data-test-subj="b"
>
b good
</div>
</div>
</div>
`;

View file

@ -0,0 +1,107 @@
/**
* Banners represents a prioritized list of displayed components.
*/
export class Banners {
constructor() {
// sorted in descending order (100, 99, 98...) so that higher priorities are in front
this.list = [];
this.uniqueId = 0;
this.onChangeCallback = null;
}
_changed = () => {
if (this.onChangeCallback) {
this.onChangeCallback();
}
}
_remove = id => {
const index = this.list.findIndex(details => details.id === id);
if (index !== -1) {
this.list.splice(index, 1);
return true;
}
return false;
}
/**
* Set the {@code callback} to invoke whenever changes are made to the banner list.
*
* Use {@code null} or {@code undefined} to unset it.
*
* @param {Function} callback The callback to use.
*/
onChange = callback => {
this.onChangeCallback = callback;
}
/**
* Add a new banner.
*
* @param {Object} component The React component to display.
* @param {Number} priority The optional priority order to display this banner. Higher priority values are shown first.
* @return {String} A newly generated ID. This value can be used to remove/replace the banner.
*/
add = ({ component, priority = 0 }) => {
const id = `${++this.uniqueId}`;
const bannerDetails = { id, component, priority };
// find the lowest priority item to put this banner in front of
const index = this.list.findIndex(details => priority > details.priority);
if (index !== -1) {
// we found something with a lower priority; so stick it in front of that item
this.list.splice(index, 0, bannerDetails);
} else {
// nothing has a lower priority, so put it at the end
this.list.push(bannerDetails);
}
this._changed();
return id;
}
/**
* Remove an existing banner.
*
* @param {String} id The ID of the banner to remove.
* @return {Boolean} {@code true} if the ID is recognized and the banner is removed. {@code false} otherwise.
*/
remove = id => {
const removed = this._remove(id);
if (removed) {
this._changed();
}
return removed;
}
/**
* Replace an existing banner by removing it, if it exists, and adding a new one in its place.
*
* This is similar to calling banners.remove, followed by banners.add, except that it only notifies the listener
* after adding.
*
* @param {Object} component The React component to display.
* @param {String} id The ID of the Banner to remove.
* @param {Number} priority The optional priority order to display this banner. Higher priority values are shown first.
* @return {String} A newly generated ID. This value can be used to remove/replace the banner.
*/
set = ({ component, id, priority = 0 }) => {
this._remove(id);
return this.add({ component, priority });
}
}
/**
* A singleton instance meant to represent all Kibana banners.
*/
export const banners = new Banners();

View file

@ -0,0 +1,142 @@
import sinon from 'sinon';
import {
Banners,
} from './banners';
describe('Banners', () => {
describe('interface', () => {
let banners;
beforeEach(() => {
banners = new Banners();
});
describe('onChange method', () => {
test('callback is called when a banner is added', () => {
const onChangeSpy = sinon.spy();
banners.onChange(onChangeSpy);
banners.add({ component: 'bruce-banner' });
expect(onChangeSpy.callCount).toBe(1);
});
test('callback is called when a banner is removed', () => {
const onChangeSpy = sinon.spy();
banners.onChange(onChangeSpy);
banners.remove(banners.add({ component: 'bruce-banner' }));
expect(onChangeSpy.callCount).toBe(2);
});
test('callback is not called when remove is ignored', () => {
const onChangeSpy = sinon.spy();
banners.onChange(onChangeSpy);
banners.remove('hulk'); // should not invoke callback
expect(onChangeSpy.callCount).toBe(0);
});
test('callback is called once when banner is replaced', () => {
const onChangeSpy = sinon.spy();
banners.onChange(onChangeSpy);
const addBannerId = banners.add({ component: 'bruce-banner' });
banners.set({ id: addBannerId, component: 'hulk' });
expect(onChangeSpy.callCount).toBe(2);
});
});
describe('add method', () => {
test('adds a banner', () => {
const id = banners.add({});
expect(banners.list.length).toBe(1);
expect(id).toEqual(expect.stringMatching(/^\d+$/));
});
test('adds a banner and ignores an ID property', () => {
const bannerId = banners.add({ id: 'bruce-banner' });
expect(banners.list[0].id).toBe(bannerId);
expect(bannerId).not.toBe('bruce-banner');
});
test('sorts banners based on priority', () => {
const test0 = banners.add({ });
// the fact that it was set explicitly is irrelevant; that it was added second means it should be after test0
const test0Explicit = banners.add({ priority: 0 });
const test1 = banners.add({ priority: 1 });
const testMinus1 = banners.add({ priority: -1 });
const test1000 = banners.add({ priority: 1000 });
expect(banners.list.length).toBe(5);
expect(banners.list[0].id).toBe(test1000);
expect(banners.list[1].id).toBe(test1);
expect(banners.list[2].id).toBe(test0);
expect(banners.list[3].id).toBe(test0Explicit);
expect(banners.list[4].id).toBe(testMinus1);
});
});
describe('remove method', () => {
test('removes a banner', () => {
const bannerId = banners.add({ component: 'bruce-banner' });
banners.remove(bannerId);
expect(banners.list.length).toBe(0);
});
test('ignores unknown id', () => {
banners.add({ component: 'bruce-banner' });
banners.remove('hulk');
expect(banners.list.length).toBe(1);
});
});
describe('set method', () => {
test('replaces banners', () => {
const addBannerId = banners.add({ component: 'bruce-banner' });
const setBannerId = banners.set({ id: addBannerId, component: 'hulk' });
expect(banners.list.length).toBe(1);
expect(banners.list[0].component).toBe('hulk');
expect(banners.list[0].id).toBe(setBannerId);
expect(addBannerId).not.toBe(setBannerId);
});
test('ignores unknown id', () => {
const id = banners.set({ id: 'fake', component: 'hulk' });
expect(banners.list.length).toBe(1);
expect(banners.list[0].component).toBe('hulk');
expect(banners.list[0].id).toBe(id);
});
test('replaces a banner with the same ID property', () => {
const test0 = banners.add({ });
const test0Explicit = banners.add({ priority: 0 });
let test1 = banners.add({ priority: 1, component: 'old' });
const testMinus1 = banners.add({ priority: -1 });
let test1000 = banners.add({ priority: 1000, component: 'old' });
// change one with the same priority
test1 = banners.set({ id: test1, priority: 1, component: 'new' });
// change one with a different priority
test1000 = banners.set({ id: test1000, priority: 1, component: 'new' });
expect(banners.list.length).toBe(5);
expect(banners.list[0].id).toBe(test1);
expect(banners.list[0].component).toBe('new');
expect(banners.list[1].id).toBe(test1000); // priority became 1, so it goes after the other "1"
expect(banners.list[1].component).toBe('new');
expect(banners.list[2].id).toBe(test0);
expect(banners.list[3].id).toBe(test0Explicit);
expect(banners.list[4].id).toBe(testMinus1);
});
});
});
});

View file

@ -0,0 +1,83 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
} from '@elastic/eui';
/**
* GlobalBannerList is a list of "banners". A banner something that is displayed at the top of Kibana that may or may not disappear.
*
* Whether or not a banner can be closed is completely up to the author of the banner. Some banners make sense to be static, such as
* banners meant to indicate the sensitivity (e.g., classification) of the information being represented.
*
* Banners are currently expected to be <EuiCallout /> instances, but that is not required.
*
* @param {Array} banners The array of banners represented by objects in the form of { id, component }.
*/
export class GlobalBannerList extends Component {
static propTypes = {
banners: PropTypes.array,
subscribe: PropTypes.func,
};
static defaultProps = {
banners: [],
};
constructor(props) {
super(props);
if (this.props.subscribe) {
this.props.subscribe(() => this.forceUpdate());
}
}
render() {
if (this.props.banners.length === 0) {
return null;
}
const panelStyle = {
border: 'none'
};
const flexStyle = {
flexDirection: 'column'
};
const flexBanners = this.props.banners.map(banner => {
const {
id,
component,
priority,
...rest
} = banner;
return (
<EuiFlexItem
grow={true}
key={id}
data-test-priority={priority}
{...rest}
>
{ component }
</EuiFlexItem>
);
});
return (
<EuiPanel
style={panelStyle}
>
<EuiFlexGroup
style={flexStyle}
gutterSize="s"
>
{flexBanners}
</EuiFlexGroup>
</EuiPanel>
);
}
}

View file

@ -0,0 +1,46 @@
import React from 'react';
import { render } from 'enzyme';
import { GlobalBannerList } from './global_banner_list';
describe('GlobalBannerList', () => {
test('is rendered', () => {
const component = render(
<GlobalBannerList />
);
expect(component)
.toMatchSnapshot();
});
describe('props', () => {
describe('banners', () => {
test('is rendered', () => {
const banners = [{
id: 'a',
component: 'a component',
priority: 1,
}, {
'data-test-subj': 'b',
id: 'b',
component: 'b good',
}];
const component = render(
<GlobalBannerList
banners={banners}
/>
);
expect(component)
.toMatchSnapshot();
});
});
});
});

View file

@ -0,0 +1,2 @@
export { GlobalBannerList } from './global_banner_list';
export { banners } from './banners';

View file

@ -1,15 +0,0 @@
class CreateFirstIndexPatternPrompt {
constructor() {
this.isVisible = false;
}
show = () => {
this.isVisible = true;
}
hide = () => {
this.isVisible = false;
}
}
export const createFirstIndexPatternPrompt = new CreateFirstIndexPatternPrompt();

View file

@ -1 +0,0 @@
export { createFirstIndexPatternPrompt } from './create_first_index_pattern_prompt';

View file

@ -2,4 +2,4 @@ export { notify } from './notify';
export { Notifier } from './notifier';
export { fatalError, fatalErrorInternals, addFatalErrorCallback } from './fatal_error';
export { GlobalToastList, toastNotifications } from './toasts';
export { createFirstIndexPatternPrompt } from './create_first_index_pattern_prompt';
export { GlobalBannerList, banners } from './banners';

View file

@ -80,7 +80,7 @@ toastNotifications.add({
Only you have access to this document. <a href="/documents">Edit permissions.</a>
</p>
<button onClick={() => deleteDocument()}}>
<button onClick={() => deleteDocument()}>
Delete document
</button>
</div>
@ -90,7 +90,7 @@ toastNotifications.add({
## Use in functional tests
Functional tests are commonly used to verify that a user action yielded a sucessful outcome. if you surface a toast to notify the user of this successful outcome, you can place a `data-test-subj` attribute on the toast and use it to check if the toast exists inside of your functional test. This acts as a proxy for verifying the sucessful outcome.
Functional tests are commonly used to verify that a user action yielded a sucessful outcome. If you surface a toast to notify the user of this successful outcome, you can place a `data-test-subj` attribute on the toast and use it to check if the toast exists inside of your functional test. This acts as a proxy for verifying the sucessful outcome.
```js
toastNotifications.addSuccess({

View file

@ -21,10 +21,15 @@ export class GlobalToastList extends Component {
this.timeoutIds = [];
this.toastIdToScheduledForDismissalMap = {};
if (this.props.subscribe) {
this.props.subscribe(() => this.forceUpdate());
}
}
static propTypes = {
toasts: PropTypes.array,
subscribe: PropTypes.func,
dismissToast: PropTypes.func.isRequired,
toastLifeTimeMs: PropTypes.number.isRequired,
};

View file

@ -8,16 +8,21 @@ const normalizeToast = toastOrTitle => {
return toastOrTitle;
};
let onChangeCallback;
export class ToastNotifications {
constructor() {
this.list = [];
this.idCounter = 0;
this.onChangeCallback = null;
}
_changed = () => {
if (this.onChangeCallback) {
this.onChangeCallback();
}
}
onChange = callback => {
onChangeCallback = callback;
this.onChangeCallback = callback;
};
add = toastOrTitle => {
@ -27,20 +32,17 @@ export class ToastNotifications {
};
this.list.push(toast);
if (onChangeCallback) {
onChangeCallback();
}
this._changed();
return toast;
};
remove = toast => {
const index = this.list.indexOf(toast);
this.list.splice(index, 1);
if (onChangeCallback) {
onChangeCallback();
if (index !== -1) {
this.list.splice(index, 1);
this._changed();
}
};

View file

@ -41,6 +41,12 @@ describe('ToastNotifications', () => {
toastNotifications.remove(toast);
expect(toastNotifications.list.length).toBe(0);
});
test('ignores unknown toast', () => {
toastNotifications.add('Test');
toastNotifications.remove({});
expect(toastNotifications.list.length).toBe(1);
});
});
describe('onChange method', () => {
@ -58,6 +64,13 @@ describe('ToastNotifications', () => {
toastNotifications.remove(toast);
expect(onChangeSpy.callCount).toBe(2);
});
test('callback is not called when remove is ignored', () => {
const onChangeSpy = sinon.spy();
toastNotifications.onChange(onChangeSpy);
toastNotifications.remove({});
expect(onChangeSpy.callCount).toBe(0);
});
});
describe('addSuccess method', () => {