Add addError function to toastNotifications (#32187)

This commit is contained in:
Tim Roes 2019-05-29 20:24:36 +02:00 committed by Josh Dover
parent 3f78d29ee5
commit 58ef3a3c49
44 changed files with 658 additions and 103 deletions

View file

@ -0,0 +1,19 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md)
## ErrorToastOptions interface
<b>Signature:</b>
```typescript
export interface ErrorToastOptions
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [title](./kibana-plugin-public.errortoastoptions.title.md) | <code>string</code> | The title of the toast and the dialog when expanding the message. |
| [toastMessage](./kibana-plugin-public.errortoastoptions.toastmessage.md) | <code>string</code> | The message to be shown in the toast. If this is not specified the error's message will be shown in the toast instead. Overwriting that message can be used to provide more user-friendly toasts. If you specify this, the error message will still be shown in the detailed error modal. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) &gt; [title](./kibana-plugin-public.errortoastoptions.title.md)
## ErrorToastOptions.title property
The title of the toast and the dialog when expanding the message.
<b>Signature:</b>
```typescript
title: string;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) &gt; [toastMessage](./kibana-plugin-public.errortoastoptions.toastmessage.md)
## ErrorToastOptions.toastMessage property
The message to be shown in the toast. If this is not specified the error's message will be shown in the toast instead. Overwriting that message can be used to provide more user-friendly toasts. If you specify this, the error message will still be shown in the detailed error modal.
<b>Signature:</b>
```typescript
toastMessage?: string;
```

View file

@ -30,12 +30,14 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [ChromeNavLink](./kibana-plugin-public.chromenavlink.md) | |
| [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the <code>Plugin</code> setup lifecycle |
| [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the <code>Plugin</code> start lifecycle |
| [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) | |
| [FatalErrorInfo](./kibana-plugin-public.fatalerrorinfo.md) | Represents the <code>message</code> and <code>stack</code> of a fatal Error |
| [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. |
| [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) | |
| [I18nSetup](./kibana-plugin-public.i18nsetup.md) | I18nSetup.Context is required by any localizable React component from @<!-- -->kbn/i18n and @<!-- -->elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. |
| [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | |
| [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | |
| [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | |
| [OverlayRef](./kibana-plugin-public.overlayref.md) | |
| [OverlayStart](./kibana-plugin-public.overlaystart.md) | |
| [Plugin](./kibana-plugin-public.plugin.md) | The interface that should be returned by a <code>PluginInitializer</code>. |
@ -52,7 +54,6 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [HttpSetup](./kibana-plugin-public.httpsetup.md) | |
| [HttpStart](./kibana-plugin-public.httpstart.md) | |
| [I18nStart](./kibana-plugin-public.i18nstart.md) | |
| [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | |
| [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The <code>plugin</code> export at the root of a plugin's <code>public</code> directory should conform to this interface. |
| [ToastInput](./kibana-plugin-public.toastinput.md) | |
| [UiSettingsSetup](./kibana-plugin-public.uisettingssetup.md) | |

View file

@ -15,5 +15,5 @@ export interface NotificationsSetup
| Property | Type | Description |
| --- | --- | --- |
| [toasts](./kibana-plugin-public.notificationssetup.toasts.md) | <code>ToastsApi</code> | |
| [toasts](./kibana-plugin-public.notificationssetup.toasts.md) | <code>ToastsSetup</code> | |

View file

@ -7,5 +7,5 @@
<b>Signature:</b>
```typescript
toasts: ToastsApi;
toasts: ToastsSetup;
```

View file

@ -2,11 +2,18 @@
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [NotificationsStart](./kibana-plugin-public.notificationsstart.md)
## NotificationsStart type
## NotificationsStart interface
<b>Signature:</b>
```typescript
export declare type NotificationsStart = NotificationsSetup;
export interface NotificationsStart
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [toasts](./kibana-plugin-public.notificationsstart.toasts.md) | <code>ToastsStart</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [NotificationsStart](./kibana-plugin-public.notificationsstart.md) &gt; [toasts](./kibana-plugin-public.notificationsstart.toasts.md)
## NotificationsStart.toasts property
<b>Signature:</b>
```typescript
toasts: ToastsStart;
```

View file

@ -8,5 +8,5 @@
<b>Signature:</b>
```typescript
export declare type ToastInput = string | Pick<Toast, Exclude<keyof Toast, 'id'>>;
export declare type ToastInput = string | ToastInputFields | Promise<ToastInputFields>;
```

View file

@ -0,0 +1,23 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ToastsApi](./kibana-plugin-public.toastsapi.md) &gt; [addError](./kibana-plugin-public.toastsapi.adderror.md)
## ToastsApi.addError() method
<b>Signature:</b>
```typescript
addError(error: Error, options: ErrorToastOptions): Toast;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| error | <code>Error</code> | |
| options | <code>ErrorToastOptions</code> | |
<b>Returns:</b>
`Toast`

View file

@ -17,8 +17,10 @@ export declare class ToastsApi
| --- | --- | --- |
| [add(toastOrTitle)](./kibana-plugin-public.toastsapi.add.md) | | |
| [addDanger(toastOrTitle)](./kibana-plugin-public.toastsapi.adddanger.md) | | |
| [addError(error, options)](./kibana-plugin-public.toastsapi.adderror.md) | | |
| [addSuccess(toastOrTitle)](./kibana-plugin-public.toastsapi.addsuccess.md) | | |
| [addWarning(toastOrTitle)](./kibana-plugin-public.toastsapi.addwarning.md) | | |
| [get$()](./kibana-plugin-public.toastsapi.get$.md) | | |
| [registerOverlays(overlays)](./kibana-plugin-public.toastsapi.registeroverlays.md) | | |
| [remove(toast)](./kibana-plugin-public.toastsapi.remove.md) | | |

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [ToastsApi](./kibana-plugin-public.toastsapi.md) &gt; [registerOverlays](./kibana-plugin-public.toastsapi.registeroverlays.md)
## ToastsApi.registerOverlays() method
<b>Signature:</b>
```typescript
registerOverlays(overlays: OverlayStart): void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| overlays | <code>OverlayStart</code> | |
<b>Returns:</b>
`void`

View file

@ -235,6 +235,7 @@ describe('#start()', () => {
expect(MockNotificationsService.start).toHaveBeenCalledTimes(1);
expect(MockNotificationsService.start).toHaveBeenCalledWith({
i18n: expect.any(Object),
overlays: expect.any(Object),
targetDomElement: expect.any(HTMLElement),
});
});

View file

@ -116,7 +116,7 @@ export class CoreSystem {
this.fatalErrorsSetup = this.fatalErrors.setup({ injectedMetadata, i18n });
const http = this.http.setup({ injectedMetadata, fatalErrors: this.fatalErrorsSetup });
const uiSettings = this.uiSettings.setup({ http, injectedMetadata });
const notifications = this.notifications.setup({ uiSettings });
const notifications = this.notifications.setup({ uiSettings, i18n });
const application = this.application.setup();
const chrome = this.chrome.setup({ injectedMetadata, notifications });
@ -166,11 +166,12 @@ export class CoreSystem {
this.rootDomElement.appendChild(legacyPlatformTargetDomElement);
this.rootDomElement.appendChild(overlayTargetDomElement);
const overlays = this.overlay.start({ i18n, targetDomElement: overlayTargetDomElement });
const notifications = await this.notifications.start({
i18n,
overlays,
targetDomElement: notificationsTargetDomElement,
});
const overlays = this.overlay.start({ i18n, targetDomElement: overlayTargetDomElement });
const core: InternalCoreStart = {
application,

View file

@ -49,11 +49,12 @@ import { HttpServiceBase, HttpSetup, HttpStart } from './http';
import { I18nSetup, I18nStart } from './i18n';
import { InjectedMetadataSetup, InjectedMetadataStart, LegacyNavLink } from './injected_metadata';
import {
ErrorToastOptions,
NotificationsSetup,
NotificationsStart,
Toast,
ToastInput,
ToastsApi,
NotificationsStart,
} from './notifications';
import { OverlayRef, OverlayStart } from './overlays';
import { Plugin, PluginInitializer, PluginInitializerContext } from './plugins';
@ -128,6 +129,7 @@ export {
HttpServiceBase,
HttpSetup,
HttpStart,
ErrorToastOptions,
FatalErrorsSetup,
FatalErrorInfo,
Capabilities,

View file

@ -17,7 +17,7 @@
* under the License.
*/
export { Toast, ToastInput, ToastsApi } from './toasts';
export { ErrorToastOptions, Toast, ToastInput, ToastsApi } from './toasts';
export {
NotificationsService,
NotificationsSetup,

View file

@ -20,17 +20,19 @@
import { i18n } from '@kbn/i18n';
import { Subscription } from 'rxjs';
import { I18nStart } from '../i18n';
import { ToastsService } from './toasts';
import { ToastsApi } from './toasts/toasts_api';
import { I18nStart, I18nSetup } from '../i18n';
import { ToastsService, ToastsSetup, ToastsStart } from './toasts';
import { UiSettingsSetup } from '../ui_settings';
import { OverlayStart } from '../overlays';
interface SetupDeps {
i18n: I18nSetup;
uiSettings: UiSettingsSetup;
}
interface StartDeps {
i18n: I18nStart;
overlays: OverlayStart;
targetDomElement: HTMLElement;
}
@ -44,8 +46,8 @@ export class NotificationsService {
this.toasts = new ToastsService();
}
public setup({ uiSettings }: SetupDeps): NotificationsSetup {
const notificationSetup = { toasts: this.toasts.setup() };
public setup({ i18n: i18nSetup, uiSettings }: SetupDeps): NotificationsSetup {
const notificationSetup = { toasts: this.toasts.setup({ i18n: i18nSetup, uiSettings }) };
this.uiSettingsErrorSubscription = uiSettings.getUpdateErrors$().subscribe(error => {
notificationSetup.toasts.addDanger({
@ -59,13 +61,13 @@ export class NotificationsService {
return notificationSetup;
}
public start({ i18n: i18nDep, targetDomElement }: StartDeps): NotificationsStart {
public start({ i18n: i18nDep, overlays, targetDomElement }: StartDeps): NotificationsStart {
this.targetDomElement = targetDomElement;
const toastsContainer = document.createElement('div');
targetDomElement.appendChild(toastsContainer);
return {
toasts: this.toasts.start({ i18n: i18nDep, targetDomElement: toastsContainer }),
toasts: this.toasts.start({ i18n: i18nDep, overlays, targetDomElement: toastsContainer }),
};
}
@ -84,8 +86,10 @@ export class NotificationsService {
/** @public */
export interface NotificationsSetup {
toasts: ToastsApi;
toasts: ToastsSetup;
}
/** @public */
export type NotificationsStart = NotificationsSetup;
export interface NotificationsStart {
toasts: ToastsStart;
}

View file

@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders matching snapshot 1`] = `
<Fragment>
<p
data-test-subj="errorToastMessage"
>
This is the toast message
</p>
<div
className="eui-textRight"
>
<EuiButton
color="danger"
fill={false}
iconSide="left"
onClick={[Function]}
size="s"
type="button"
>
<FormattedMessage
defaultMessage="See the full error"
id="core.toasts.errorToast.seeFullError"
values={Object {}}
/>
</EuiButton>
</div>
</Fragment>
`;

View file

@ -2,8 +2,9 @@
exports[`renders matching snapshot 1`] = `
<EuiGlobalToastList
data-test-subj="globalToastList"
dismissToast={[MockFunction]}
toastLifeTimeMs={6000}
toastLifeTimeMs={Infinity}
toasts={Array []}
/>
`;

View file

@ -0,0 +1,64 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { ErrorToast } from './error_toast';
import { i18nServiceMock } from '../../i18n/i18n_service.mock';
interface ErrorToastProps {
error?: Error;
title?: string;
toastMessage?: string;
}
let openModal: jest.Mock;
beforeEach(() => (openModal = jest.fn()));
function render(props: ErrorToastProps = {}) {
return (
<ErrorToast
openModal={openModal}
error={props.error || new Error('error message')}
title={props.title || 'An error occured'}
toastMessage={props.toastMessage || 'This is the toast message'}
i18nContext={i18nServiceMock.createSetupContract().Context}
/>
);
}
it('renders matching snapshot', () => {
expect(shallow(render())).toMatchSnapshot();
});
it('should open a modal when clicking button', () => {
const wrapper = mountWithIntl(render());
expect(openModal).not.toHaveBeenCalled();
wrapper.find('button').simulate('click');
expect(openModal).toHaveBeenCalled();
});
afterAll(() => {
// Cleanup document.body to cleanup any modals which might be left over from tests.
document.body.innerHTML = '';
});

View file

@ -0,0 +1,106 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import {
EuiButton,
EuiCallOut,
EuiCodeBlock,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
} from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { I18nSetup } from '../../i18n';
import { OverlayStart } from '../../overlays';
interface ErrorToastProps {
title: string;
error: Error;
toastMessage: string;
i18nContext: I18nSetup['Context'];
openModal: OverlayStart['openModal'];
}
/**
* This should instead be replaced by the overlay service once it's available.
* This does not use React portals so that if the parent toast times out, this modal
* does not disappear. NOTE: this should use a global modal in the overlay service
* in the future.
*/
function showErrorDialog({
title,
error,
i18nContext: I18nContext,
openModal,
}: Pick<ErrorToastProps, 'error' | 'title' | 'i18nContext' | 'openModal'>) {
const modal = openModal(
<React.Fragment>
<EuiModalHeader>
<EuiModalHeaderTitle>{title}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiCallOut size="s" color="danger" iconType="alert" title={error.message} />
{error.stack && (
<React.Fragment>
<EuiSpacer size="s" />
<EuiCodeBlock isCopyable={true} paddingSize="s">
{error.stack}
</EuiCodeBlock>
</React.Fragment>
)}
</EuiModalBody>
<EuiModalFooter>
<EuiButton onClick={() => modal.close()} fill>
<FormattedMessage id="core.notifications.errorToast.closeModal" defaultMessage="Close" />
</EuiButton>
</EuiModalFooter>
</React.Fragment>
);
}
export function ErrorToast({
title,
error,
toastMessage,
i18nContext,
openModal,
}: ErrorToastProps) {
return (
<React.Fragment>
<p data-test-subj="errorToastMessage">{toastMessage}</p>
<div className="eui-textRight">
<EuiButton
size="s"
color="danger"
onClick={() => showErrorDialog({ title, error, openModal, i18nContext })}
>
<FormattedMessage
id="core.toasts.errorToast.seeFullError"
defaultMessage="See the full error"
/>
</EuiButton>
</div>
</React.Fragment>
);
}

View file

@ -53,9 +53,15 @@ export class GlobalToastList extends React.Component<Props, State> {
public render() {
return (
<EuiGlobalToastList
data-test-subj="globalToastList"
toasts={this.state.toasts}
dismissToast={this.props.dismissToast}
toastLifeTimeMs={6000}
/**
* This prop is overriden by the individual toasts that are added.
* Use `Infinity` here so that it's obvious a timeout hasn't been
* provided in development.
*/
toastLifeTimeMs={Infinity}
/>
);
}

View file

@ -17,6 +17,6 @@
* under the License.
*/
export { ToastsService } from './toasts_service';
export { ToastsApi, ToastInput } from './toasts_api';
export { ToastsService, ToastsSetup, ToastsStart } from './toasts_service';
export { ErrorToastOptions, ToastsApi, ToastInput } from './toasts_api';
export { Toast } from '@elastic/eui';

View file

@ -21,6 +21,9 @@ import { take } from 'rxjs/operators';
import { ToastsApi } from './toasts_api';
import { uiSettingsServiceMock } from '../../ui_settings/ui_settings_service.mock';
import { i18nServiceMock } from '../../i18n/i18n_service.mock';
async function getCurrentToasts(toasts: ToastsApi) {
return await toasts
.get$()
@ -28,9 +31,33 @@ async function getCurrentToasts(toasts: ToastsApi) {
.toPromise();
}
function uiSettingsMock() {
const mock = uiSettingsServiceMock.createSetupContract();
(mock.get as jest.Mock<typeof mock['get']>).mockImplementation(() => (config: string) => {
switch (config) {
case 'notifications:lifetime:info':
return 5000;
case 'notifications:lifetime:warning':
return 10000;
case 'notification:lifetime:error':
return 30000;
default:
throw new Error(`Accessing ${config} is not supported in the mock.`);
}
});
return mock;
}
function toastDeps() {
return {
uiSettings: uiSettingsMock(),
i18n: i18nServiceMock.createSetupContract(),
};
}
describe('#get$()', () => {
it('returns observable that emits NEW toast list when something added or removed', () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
const onToasts = jest.fn();
toasts.get$().subscribe(onToasts);
@ -57,7 +84,7 @@ describe('#get$()', () => {
});
it('does not emit a new toast list when unknown toast is passed to remove()', () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
const onToasts = jest.fn();
toasts.get$().subscribe(onToasts);
@ -71,14 +98,14 @@ describe('#get$()', () => {
describe('#add()', () => {
it('returns toast objects with auto assigned id', () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
const toast = toasts.add({ title: 'foo' });
expect(toast).toHaveProperty('id');
expect(toast).toHaveProperty('title', 'foo');
});
it('adds the toast to toasts list', async () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
const toast = toasts.add({});
const currentToasts = await getCurrentToasts(toasts);
@ -87,27 +114,27 @@ describe('#add()', () => {
});
it('increments the toast ID for each additional toast', () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
expect(toasts.add({})).toHaveProperty('id', '0');
expect(toasts.add({})).toHaveProperty('id', '1');
expect(toasts.add({})).toHaveProperty('id', '2');
});
it('accepts a string, uses it as the title', async () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
expect(toasts.add('foo')).toHaveProperty('title', 'foo');
});
});
describe('#remove()', () => {
it('removes a toast', async () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
toasts.remove(toasts.add('Test'));
expect(await getCurrentToasts(toasts)).toHaveLength(0);
});
it('ignores unknown toast', async () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
toasts.add('Test');
toasts.remove({ id: 'foo' });
@ -118,12 +145,12 @@ describe('#remove()', () => {
describe('#addSuccess()', () => {
it('adds a success toast', async () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
expect(toasts.addSuccess({})).toHaveProperty('color', 'success');
});
it('returns the created toast', async () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
const toast = toasts.addSuccess({});
const currentToasts = await getCurrentToasts(toasts);
expect(currentToasts[0]).toBe(toast);
@ -132,12 +159,12 @@ describe('#addSuccess()', () => {
describe('#addWarning()', () => {
it('adds a warning toast', async () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
expect(toasts.addWarning({})).toHaveProperty('color', 'warning');
});
it('returns the created toast', async () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
const toast = toasts.addWarning({});
const currentToasts = await getCurrentToasts(toasts);
expect(currentToasts[0]).toBe(toast);
@ -146,14 +173,30 @@ describe('#addWarning()', () => {
describe('#addDanger()', () => {
it('adds a danger toast', async () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
expect(toasts.addDanger({})).toHaveProperty('color', 'danger');
});
it('returns the created toast', async () => {
const toasts = new ToastsApi();
const toasts = new ToastsApi(toastDeps());
const toast = toasts.addDanger({});
const currentToasts = await getCurrentToasts(toasts);
expect(currentToasts[0]).toBe(toast);
});
});
describe('#addError', () => {
it('adds an error toast', async () => {
const toasts = new ToastsApi(toastDeps());
const toast = toasts.addError(new Error('unexpected error'), { title: 'Something went wrong' });
expect(toast).toHaveProperty('color', 'danger');
expect(toast).toHaveProperty('title', 'Something went wrong');
});
it('returns the created toast', async () => {
const toasts = new ToastsApi(toastDeps());
const toast = toasts.addError(new Error('unexpected error'), { title: 'Something went wrong' });
const currentToasts = await getCurrentToasts(toasts);
expect(currentToasts[0]).toBe(toast);
});
});

View file

@ -18,10 +18,32 @@
*/
import { Toast } from '@elastic/eui';
import React from 'react';
import * as Rx from 'rxjs';
import { ErrorToast } from './error_toast';
import { UiSettingsSetup } from '../../ui_settings';
import { I18nSetup } from '../../i18n';
import { OverlayStart } from '../../overlays';
type ToastInputFields = Pick<Toast, Exclude<keyof Toast, 'id'>>;
/** @public */
export type ToastInput = string | Pick<Toast, Exclude<keyof Toast, 'id'>>;
export type ToastInput = string | ToastInputFields | Promise<ToastInputFields>;
export interface ErrorToastOptions {
/**
* The title of the toast and the dialog when expanding the message.
*/
title: string;
/**
* The message to be shown in the toast. If this is not specified the error's
* message will be shown in the toast instead. Overwriting that message can
* be used to provide more user-friendly toasts. If you specify this, the error
* message will still be shown in the detailed error modal.
*/
toastMessage?: string;
}
const normalizeToast = (toastOrTitle: ToastInput) => {
if (typeof toastOrTitle === 'string') {
@ -37,6 +59,19 @@ const normalizeToast = (toastOrTitle: ToastInput) => {
export class ToastsApi {
private toasts$ = new Rx.BehaviorSubject<Toast[]>([]);
private idCounter = 0;
private uiSettings: UiSettingsSetup;
private i18n: I18nSetup;
private overlays?: OverlayStart;
constructor(deps: { uiSettings: UiSettingsSetup; i18n: I18nSetup }) {
this.uiSettings = deps.uiSettings;
this.i18n = deps.i18n;
}
public registerOverlays(overlays: OverlayStart) {
this.overlays = overlays;
}
public get$() {
return this.toasts$.asObservable();
@ -45,6 +80,7 @@ export class ToastsApi {
public add(toastOrTitle: ToastInput) {
const toast: Toast = {
id: String(this.idCounter++),
toastLifeTimeMs: this.uiSettings.get('notifications:lifetime:info'),
...normalizeToast(toastOrTitle),
};
@ -73,6 +109,7 @@ export class ToastsApi {
return this.add({
color: 'warning',
iconType: 'help',
toastLifeTimeMs: this.uiSettings.get('notifications:lifetime:warning'),
...normalizeToast(toastOrTitle),
});
}
@ -81,7 +118,39 @@ export class ToastsApi {
return this.add({
color: 'danger',
iconType: 'alert',
toastLifeTimeMs: this.uiSettings.get('notifications:lifetime:warning'),
...normalizeToast(toastOrTitle),
});
}
public addError(error: Error, options: ErrorToastOptions) {
const message = options.toastMessage || error.message;
return this.add({
color: 'danger',
iconType: 'alert',
title: options.title,
toastLifeTimeMs: this.uiSettings.get('notifications:lifetime:error'),
text: (
<ErrorToast
openModal={this.openModal.bind(this)}
error={error}
title={options.title}
toastMessage={message}
i18nContext={this.i18n.Context}
/>
),
});
}
private openModal(
...args: Parameters<OverlayStart['openModal']>
): ReturnType<OverlayStart['openModal']> {
if (!this.overlays) {
// This case should never happen because no rendering should be occurring
// before the ToastService is started.
throw new Error(`Modal opened before ToastService was started.`);
}
return this.overlays.openModal(...args);
}
}

View file

@ -16,16 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ToastsApi } from './toasts_api';
import { ToastsSetup } from './toasts_service';
const createToastsApiMock = () => {
const api: jest.Mocked<PublicMethodsOf<ToastsApi>> = {
const api: jest.Mocked<PublicMethodsOf<ToastsSetup>> = {
get$: jest.fn(),
add: jest.fn(),
remove: jest.fn(),
addSuccess: jest.fn(),
addWarning: jest.fn(),
addDanger: jest.fn(),
addError: jest.fn(),
};
return api;
};

View file

@ -21,6 +21,8 @@ import { mockReactDomRender, mockReactDomUnmount } from './toasts_service.test.m
import { ToastsService } from './toasts_service';
import { ToastsApi } from './toasts_api';
import { overlayServiceMock } from '../../overlays/overlay_service.mock';
import { uiSettingsServiceMock } from '../../ui_settings/ui_settings_service.mock';
const mockI18n: any = {
Context: function I18nContext() {
@ -28,11 +30,15 @@ const mockI18n: any = {
},
};
const mockOverlays = overlayServiceMock.createStartContract();
describe('#setup()', () => {
it('returns a ToastsApi', () => {
const toasts = new ToastsService();
expect(toasts.setup()).toBeInstanceOf(ToastsApi);
expect(
toasts.setup({ i18n: mockI18n, uiSettings: uiSettingsServiceMock.createSetupContract() })
).toBeInstanceOf(ToastsApi);
});
});
@ -43,8 +49,8 @@ describe('#start()', () => {
const toasts = new ToastsService();
expect(mockReactDomRender).not.toHaveBeenCalled();
toasts.setup();
toasts.start({ i18n: mockI18n, targetDomElement });
toasts.setup({ i18n: mockI18n, uiSettings: uiSettingsServiceMock.createSetupContract() });
toasts.start({ i18n: mockI18n, targetDomElement, overlays: mockOverlays });
expect(mockReactDomRender.mock.calls).toMatchSnapshot();
});
@ -52,8 +58,12 @@ describe('#start()', () => {
const targetDomElement = document.createElement('div');
const toasts = new ToastsService();
toasts.setup();
expect(toasts.start({ i18n: mockI18n, targetDomElement })).toBeInstanceOf(ToastsApi);
expect(
toasts.setup({ i18n: mockI18n, uiSettings: uiSettingsServiceMock.createSetupContract() })
).toBeInstanceOf(ToastsApi);
expect(
toasts.start({ i18n: mockI18n, targetDomElement, overlays: mockOverlays })
).toBeInstanceOf(ToastsApi);
});
});
@ -63,8 +73,8 @@ describe('#stop()', () => {
targetDomElement.setAttribute('test', 'target-dom-element');
const toasts = new ToastsService();
toasts.setup();
toasts.start({ i18n: mockI18n, targetDomElement });
toasts.setup({ i18n: mockI18n, uiSettings: uiSettingsServiceMock.createSetupContract() });
toasts.start({ i18n: mockI18n, targetDomElement, overlays: mockOverlays });
expect(mockReactDomUnmount).not.toHaveBeenCalled();
toasts.stop();
@ -82,8 +92,8 @@ describe('#stop()', () => {
const targetDomElement = document.createElement('div');
const toasts = new ToastsService();
toasts.setup();
toasts.start({ i18n: mockI18n, targetDomElement });
toasts.setup({ i18n: mockI18n, uiSettings: uiSettingsServiceMock.createSetupContract() });
toasts.start({ i18n: mockI18n, targetDomElement, overlays: mockOverlays });
toasts.stop();
expect(targetDomElement.childNodes).toHaveLength(0);
});

View file

@ -21,25 +21,40 @@ import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Toast } from '@elastic/eui';
import { I18nSetup } from '../../i18n';
import { I18nSetup, I18nStart } from '../../i18n';
import { UiSettingsSetup } from '../../ui_settings';
import { GlobalToastList } from './global_toast_list';
import { ToastsApi } from './toasts_api';
import { OverlayStart } from '../../overlays';
interface SetupDeps {
i18n: I18nSetup;
uiSettings: UiSettingsSetup;
}
interface StartDeps {
i18n: I18nSetup;
i18n: I18nStart;
overlays: OverlayStart;
targetDomElement: HTMLElement;
}
/** @public */
export type ToastsSetup = Pick<ToastsApi, Exclude<keyof ToastsApi, 'registerOverlays'>>;
/** @public */
export type ToastsStart = ToastsSetup;
export class ToastsService {
private api?: ToastsApi;
private targetDomElement?: HTMLElement;
public setup() {
this.api = new ToastsApi();
public setup({ i18n, uiSettings }: SetupDeps) {
this.api = new ToastsApi({ i18n, uiSettings });
return this.api!;
}
public start({ i18n, targetDomElement }: StartDeps) {
public start({ i18n, overlays, targetDomElement }: StartDeps) {
this.api!.registerOverlays(overlays);
this.targetDomElement = targetDomElement;
render(

View file

@ -152,6 +152,14 @@ export class CoreSystem {
stop(): void;
}
// Warning: (ae-missing-release-tag) "ErrorToastOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface ErrorToastOptions {
title: string;
toastMessage?: string;
}
// @public
export interface FatalErrorInfo {
// (undocumented)
@ -254,12 +262,19 @@ export interface LegacyNavLink {
// @public (undocumented)
export interface NotificationsSetup {
// Warning: (ae-forgotten-export) The symbol "ToastsSetup" needs to be exported by the entry point index.d.ts
//
// (undocumented)
toasts: ToastsApi;
toasts: ToastsSetup;
}
// @public (undocumented)
export type NotificationsStart = NotificationsSetup;
export interface NotificationsStart {
// Warning: (ae-forgotten-export) The symbol "ToastsStart" needs to be exported by the entry point index.d.ts
//
// (undocumented)
toasts: ToastsStart;
}
// Warning: (ae-missing-release-tag) "OverlayRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@ -304,22 +319,32 @@ export interface PluginInitializerContext {
export { Toast }
// Warning: (ae-forgotten-export) The symbol "ToastInputFields" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export type ToastInput = string | Pick<Toast, Exclude<keyof Toast, 'id'>>;
export type ToastInput = string | ToastInputFields | Promise<ToastInputFields>;
// @public (undocumented)
export class ToastsApi {
constructor(deps: {
uiSettings: UiSettingsSetup;
i18n: I18nSetup;
});
// (undocumented)
add(toastOrTitle: ToastInput): Toast;
// (undocumented)
addDanger(toastOrTitle: ToastInput): Toast;
// (undocumented)
addError(error: Error, options: ErrorToastOptions): Toast;
// (undocumented)
addSuccess(toastOrTitle: ToastInput): Toast;
// (undocumented)
addWarning(toastOrTitle: ToastInput): Toast;
// (undocumented)
get$(): Rx.Observable<Toast[]>;
// (undocumented)
registerOverlays(overlays: OverlayStart): void;
// (undocumented)
remove(toast: Toast): void;
}

View file

@ -185,7 +185,6 @@ function discoverController(
$timeout,
$window,
AppState,
Notifier,
Private,
Promise,
config,
@ -201,9 +200,6 @@ function discoverController(
const queryFilter = Private(FilterBarQueryFilterProvider);
const responseHandler = Private(VislibSeriesResponseHandlerProvider).handler;
const filterManager = Private(FilterManagerProvider);
const notify = new Notifier({
location: 'Discover'
});
const getUnhashableStates = Private(getUnhashableStatesProvider);
const shareContextMenuExtensions = Private(ShareContextMenuExtensionsRegistryProvider);
const inspectorAdapters = {
@ -718,7 +714,13 @@ function discoverController(
logInspectorRequest();
return courier.fetch();
})
.catch(notify.error);
.catch((error) => {
toastNotifications.addError(error, {
title: i18n.translate('kbn.discover.discoverError', {
defaultMessage: 'Discover error',
}),
});
});
};
$scope.updateQueryAndFetch = function ({ query, dateRange }) {
@ -799,7 +801,11 @@ function discoverController(
if (fetchError) {
$scope.fetchError = fetchError;
} else {
notify.error(error);
toastNotifications.addError(error, {
title: i18n.translate('kbn.discover.errorLoadingData', {
defaultMessage: 'Error loading data',
}),
});
}
// Restart. This enables auto-refresh functionality.

View file

@ -19,13 +19,35 @@
import sinon from 'sinon';
import { ToastsApi } from '../../../../../core/public';
import { uiSettingsServiceMock, i18nServiceMock } from '../../../../../core/public/mocks';
import { ToastNotifications } from './toast_notifications';
function toastDeps() {
const uiSettingsMock = uiSettingsServiceMock.createSetupContract();
(uiSettingsMock.get as jest.Mock<typeof uiSettingsMock['get']>).mockImplementation(
() => (config: string) => {
switch (config) {
case 'notifications:lifetime:info':
return 5000;
case 'notifications:lifetime:warning':
return 10000;
case 'notification:lifetime:error':
return 30000;
default:
throw new Error(`Accessing ${config} is not supported in the mock.`);
}
}
);
return {
uiSettings: uiSettingsMock,
i18n: i18nServiceMock.createSetupContract(),
};
}
describe('ToastNotifications', () => {
describe('interface', () => {
function setup() {
return { toastNotifications: new ToastNotifications(new ToastsApi()) };
return { toastNotifications: new ToastNotifications(new ToastsApi(toastDeps())) };
}
describe('add method', () => {

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { Toast, ToastInput, ToastsApi } from '../../../../../core/public';
import { ErrorToastOptions, Toast, ToastInput, ToastsApi } from '../../../../../core/public';
export { Toast, ToastInput };
@ -45,4 +45,6 @@ export class ToastNotifications {
public addSuccess = (toastOrTitle: ToastInput) => this.toasts.addSuccess(toastOrTitle);
public addWarning = (toastOrTitle: ToastInput) => this.toasts.addWarning(toastOrTitle);
public addDanger = (toastOrTitle: ToastInput) => this.toasts.addDanger(toastOrTitle);
public addError = (error: Error, options: ErrorToastOptions) =>
this.toasts.addError(error, options);
}

View file

@ -20,9 +20,6 @@
import Wreck from '@hapi/wreck';
import { get } from 'lodash';
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
export class KibanaServerUiSettings {
constructor(url, log, defaults, lifecycle) {
this._log = log;
@ -51,23 +48,6 @@ export class KibanaServerUiSettings {
return defaultIndex;
}
/**
* Sets the auto-hide timeout to 1 hour so that auto-hide is
* effectively disabled. This gives the tests more time to
* interact with the notifications without having to worry about
* them disappearing if the tests are too slow.
*
* @return {Promise<undefined>}
*/
async disableToastAutohide() {
await this.update({
'notifications:lifetime:banner': HOUR,
'notifications:lifetime:error': HOUR,
'notifications:lifetime:warning': HOUR,
'notifications:lifetime:info': HOUR,
});
}
async replace(doc) {
const { payload } = await this._wreck.get('/api/kibana/settings');

View file

@ -66,9 +66,7 @@ export default function ({ getService, getPageObjects, updateBaselines }) {
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.dashboard.setTimepickerInLogstashDataRange();
await dashboardAddPanel.addVisualization('Rendering Test: area with not filter');
await PageObjects.common.closeToast();
await PageObjects.dashboard.saveDashboard('area');
await PageObjects.common.closeToast();
await PageObjects.dashboard.clickFullScreenMode();
await dashboardPanelActions.openContextMenu();

View file

@ -25,7 +25,6 @@ import {
} from '../../../../src/legacy/core_plugins/kibana/public/visualize/visualize_constants';
export default function ({ getService, getPageObjects }) {
const kibanaServer = getService('kibanaServer');
const browser = getService('browser');
const dashboardPanelActions = getService('dashboardPanelActions');
const dashboardAddPanel = getService('dashboardAddPanel');
@ -35,7 +34,6 @@ export default function ({ getService, getPageObjects }) {
describe('dashboard panel controls', function viewEditModeTests() {
before(async function () {
await PageObjects.dashboard.initTests();
await kibanaServer.uiSettings.disableToastAutohide();
await browser.refresh();
// This flip between apps fixes the url so state is preserved when switching apps in test mode.

View file

@ -23,6 +23,7 @@ export default function ({ getService, getPageObjects }) {
const retry = getService('retry');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const toasts = getService('toasts');
const queryBar = getService('queryBar');
const PageObjects = getPageObjects(['common', 'header', 'discover', 'visualize', 'timePicker']);
@ -93,13 +94,13 @@ export default function ({ getService, getPageObjects }) {
});
it('a bad syntax query should show an error message', async function () {
const expectedError = 'Discover: Expected ":", "<", "<=", ">", ">=", AND, OR, end of input, ' +
const expectedError = 'Expected ":", "<", "<=", ">", ">=", AND, OR, end of input, ' +
'whitespace but "(" found.';
await queryBar.setQuery('xxx(yyy))');
await queryBar.submitQuery();
const toastMessage = await PageObjects.header.getToastMessage();
expect(toastMessage).to.contain(expectedError);
await PageObjects.header.clickToastOK();
const { message } = await toasts.getErrorToast();
expect(message).to.contain(expectedError);
await toasts.dismissToast();
});
});
});

View file

@ -24,7 +24,6 @@ export default function ({ getService, getPageObjects }) {
const browser = getService('browser');
const PageObjects = getPageObjects(['common', 'home', 'timePicker']);
const appsMenu = getService('appsMenu');
const kibanaServer = getService('kibanaServer');
const esArchiver = getService('esArchiver');
const retry = getService('retry');
const fromTime = '2015-09-19 06:31:44.000';
@ -34,7 +33,6 @@ export default function ({ getService, getPageObjects }) {
before(async () => {
await esArchiver.loadIfNeeded('makelogs');
await kibanaServer.uiSettings.disableToastAutohide();
await browser.refresh();
});

View file

@ -54,7 +54,6 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
'defaultIndex': defaultIndex,
});
await this.selectDefaultIndex(defaultIndex);
await kibanaServer.uiSettings.disableToastAutohide();
await PageObjects.common.navigateToApp('dashboard');
}

View file

@ -53,6 +53,7 @@ import { SnapshotsProvider } from './snapshots';
// @ts-ignore not TS yet
import { TableProvider } from './table';
import { TestSubjectsProvider } from './test_subjects';
import { ToastsProvider } from './toasts';
// @ts-ignore not TS yet
import { PieChartProvider } from './visualizations';
// @ts-ignore not TS yet
@ -83,4 +84,5 @@ export const services = {
inspector: InspectorProvider,
appsMenu: AppsMenuProvider,
globalNav: GlobalNavProvider,
toasts: ToastsProvider,
};

View file

@ -0,0 +1,67 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FtrProviderContext } from '../ftr_provider_context';
export function ToastsProvider({ getService }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
class Toasts {
/**
* Returns the title and message of a specific error toast.
* This method is specific to toasts created via `.addError` since they contain
* an additional button, that should not be part of the message.
*
* @param index The index of the toast (1-based, NOT 0-based!) of the toast. Use first by default.
* @returns The title and message of the specified error toast.https://github.com/elastic/kibana/issues/17087
*/
public async getErrorToast(index: number = 1) {
const toast = await this.getToastElement(index);
const titleElement = await testSubjects.findDescendant('euiToastHeader', toast);
const title: string = await titleElement.getVisibleText();
const messageElement = await testSubjects.findDescendant('errorToastMessage', toast);
const message: string = await messageElement.getVisibleText();
return { title, message };
}
/**
* Dismiss a specific toast from the toast list. Since toasts usually should time out themselves,
* you only need to call this for permanent toasts (e.g. error toasts).
*
* @param index The 1-based index of the toast to dismiss. Use first by default.
*/
public async dismissToast(index: number = 1) {
const toast = await this.getToastElement(index);
await toast.moveMouseTo();
const dismissButton = await testSubjects.findDescendant('toastCloseButton', toast);
await dismissButton.click();
}
private async getToastElement(index: number) {
const list = await this.getGlobalToastList();
return await list.findByCssSelector(`.euiToast:nth-child(${index})`);
}
private async getGlobalToastList() {
return await testSubjects.find('globalToastList');
}
}
return new Toasts();
}

View file

@ -39,7 +39,6 @@ export default function ({ getService, getPageObjects }) {
await kibanaServer.uiSettings.replace({
'defaultIndex': 'logstash-*'
});
await kibanaServer.uiSettings.disableToastAutohide();
browser.setWindowSize(1600, 1000);
await PageObjects.common.navigateToApp('discover');

View file

@ -17,7 +17,6 @@ export default function ({ loadTestFile, getService }) {
await kibanaServer.uiSettings.replace({
'defaultIndex': 'logstash-*'
});
await kibanaServer.uiSettings.disableToastAutohide();
browser.setWindowSize(1600, 1000);
});

View file

@ -12,7 +12,6 @@ export function SecurityPageProvider({ getService, getPageObjects }) {
const retry = getService('retry');
const find = getService('find');
const log = getService('log');
const kibanaServer = getService('kibanaServer');
const testSubjects = getService('testSubjects');
const esArchiver = getService('esArchiver');
const userMenu = getService('userMenu');
@ -76,7 +75,6 @@ export function SecurityPageProvider({ getService, getPageObjects }) {
async initTests() {
log.debug('SecurityPage:initTests');
await esArchiver.load('empty_kibana');
await kibanaServer.uiSettings.disableToastAutohide();
await esArchiver.loadIfNeeded('logstash_functional');
browser.setWindowSize(1600, 1000);
}

View file

@ -17,7 +17,6 @@ const REPORTS_FOLDER = path.resolve(__dirname, 'reports');
export default function ({ getService, getPageObjects }) {
const retry = getService('retry');
const kibanaServer = getService('kibanaServer');
const config = getService('config');
const PageObjects = getPageObjects(['reporting', 'common', 'dashboard', 'header', 'discover', 'visualize']);
const log = getService('log');
@ -25,7 +24,6 @@ export default function ({ getService, getPageObjects }) {
describe('Reporting', () => {
before('initialize tests', async () => {
await kibanaServer.uiSettings.disableToastAutohide();
await PageObjects.reporting.initTests();
});