mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
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:
parent
d056e2d625
commit
005c1ab64d
19 changed files with 858 additions and 60 deletions
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -31,7 +31,3 @@ body { overflow-x: hidden; }
|
|||
.app-wrapper-panel {
|
||||
.flex-parent(@shrink: 0);
|
||||
}
|
||||
|
||||
.noIndicesMessage {
|
||||
margin: 12px;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
360
src/ui/public/notify/banners/BANNERS.md
Normal file
360
src/ui/public/notify/banners/BANNERS.md
Normal 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"
|
||||
/>
|
||||
),
|
||||
});
|
||||
```
|
|
@ -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>
|
||||
`;
|
107
src/ui/public/notify/banners/banners.js
Normal file
107
src/ui/public/notify/banners/banners.js
Normal 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();
|
142
src/ui/public/notify/banners/banners.test.js
Normal file
142
src/ui/public/notify/banners/banners.test.js
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
});
|
83
src/ui/public/notify/banners/global_banner_list.js
Normal file
83
src/ui/public/notify/banners/global_banner_list.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
46
src/ui/public/notify/banners/global_banner_list.test.js
Normal file
46
src/ui/public/notify/banners/global_banner_list.test.js
Normal 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();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
2
src/ui/public/notify/banners/index.js
Normal file
2
src/ui/public/notify/banners/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { GlobalBannerList } from './global_banner_list';
|
||||
export { banners } from './banners';
|
|
@ -1,15 +0,0 @@
|
|||
class CreateFirstIndexPatternPrompt {
|
||||
constructor() {
|
||||
this.isVisible = false;
|
||||
}
|
||||
|
||||
show = () => {
|
||||
this.isVisible = true;
|
||||
}
|
||||
|
||||
hide = () => {
|
||||
this.isVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const createFirstIndexPatternPrompt = new CreateFirstIndexPatternPrompt();
|
|
@ -1 +0,0 @@
|
|||
export { createFirstIndexPatternPrompt } from './create_first_index_pattern_prompt';
|
|
@ -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';
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue