diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index ccabdc62c5e7a2..a4dec6339ec8c3 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -63,6 +63,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | | [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | | | [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | | +| [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.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 PluginInitializer. | @@ -95,6 +96,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [HttpStart](./kibana-plugin-public.httpstart.md) | | | [IContextHandler](./kibana-plugin-public.icontexthandler.md) | A function registered by a plugin to perform some action. | | [IContextProvider](./kibana-plugin-public.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | +| [OverlayBannerMount](./kibana-plugin-public.overlaybannermount.md) | A function that will mount the banner inside the provided element. | +| [OverlayBannerUnmount](./kibana-plugin-public.overlaybannerunmount.md) | A function that will unmount the banner from the element. | | [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The plugin export at the root of a plugin's public directory should conform to this interface. | | [PluginOpaqueId](./kibana-plugin-public.pluginopaqueid.md) | | | [RecursiveReadonly](./kibana-plugin-public.recursivereadonly.md) | | diff --git a/docs/development/core/public/kibana-plugin-public.overlaybannermount.md b/docs/development/core/public/kibana-plugin-public.overlaybannermount.md new file mode 100644 index 00000000000000..0fd0aca652cf0b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.overlaybannermount.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [OverlayBannerMount](./kibana-plugin-public.overlaybannermount.md) + +## OverlayBannerMount type + +A function that will mount the banner inside the provided element. + +Signature: + +```typescript +export declare type OverlayBannerMount = (element: HTMLElement) => OverlayBannerUnmount; +``` diff --git a/docs/development/core/public/kibana-plugin-public.overlaybannersstart.add.md b/docs/development/core/public/kibana-plugin-public.overlaybannersstart.add.md new file mode 100644 index 00000000000000..8c3e874804e082 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.overlaybannersstart.add.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) > [add](./kibana-plugin-public.overlaybannersstart.add.md) + +## OverlayBannersStart.add() method + +Add a new banner + +Signature: + +```typescript +add(mount: OverlayBannerMount, priority?: number): string; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| mount | OverlayBannerMount | | +| priority | number | | + +Returns: + +`string` + +a unique identifier for the given banner to be used with [OverlayBannersStart.remove()](./kibana-plugin-public.overlaybannersstart.remove.md) and [OverlayBannersStart.replace()](./kibana-plugin-public.overlaybannersstart.replace.md) + diff --git a/docs/development/core/public/kibana-plugin-public.overlaybannersstart.getcomponent.md b/docs/development/core/public/kibana-plugin-public.overlaybannersstart.getcomponent.md new file mode 100644 index 00000000000000..0ecb9862dee3dd --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.overlaybannersstart.getcomponent.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) > [getComponent](./kibana-plugin-public.overlaybannersstart.getcomponent.md) + +## OverlayBannersStart.getComponent() method + +Signature: + +```typescript +getComponent(): JSX.Element; +``` +Returns: + +`JSX.Element` + diff --git a/docs/development/core/public/kibana-plugin-public.overlaybannersstart.md b/docs/development/core/public/kibana-plugin-public.overlaybannersstart.md new file mode 100644 index 00000000000000..34e4ab85537924 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.overlaybannersstart.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) + +## OverlayBannersStart interface + + +Signature: + +```typescript +export interface OverlayBannersStart +``` + +## Methods + +| Method | Description | +| --- | --- | +| [add(mount, priority)](./kibana-plugin-public.overlaybannersstart.add.md) | Add a new banner | +| [getComponent()](./kibana-plugin-public.overlaybannersstart.getcomponent.md) | | +| [remove(id)](./kibana-plugin-public.overlaybannersstart.remove.md) | Remove a banner | +| [replace(id, mount, priority)](./kibana-plugin-public.overlaybannersstart.replace.md) | Replace a banner in place | + diff --git a/docs/development/core/public/kibana-plugin-public.overlaybannersstart.remove.md b/docs/development/core/public/kibana-plugin-public.overlaybannersstart.remove.md new file mode 100644 index 00000000000000..4fc5cfcd1c8d0b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.overlaybannersstart.remove.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) > [remove](./kibana-plugin-public.overlaybannersstart.remove.md) + +## OverlayBannersStart.remove() method + +Remove a banner + +Signature: + +```typescript +remove(id: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| id | string | | + +Returns: + +`boolean` + +if the banner was found or not + diff --git a/docs/development/core/public/kibana-plugin-public.overlaybannersstart.replace.md b/docs/development/core/public/kibana-plugin-public.overlaybannersstart.replace.md new file mode 100644 index 00000000000000..8f624c285b1800 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.overlaybannersstart.replace.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) > [replace](./kibana-plugin-public.overlaybannersstart.replace.md) + +## OverlayBannersStart.replace() method + +Replace a banner in place + +Signature: + +```typescript +replace(id: string | undefined, mount: OverlayBannerMount, priority?: number): string; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| id | string | undefined | | +| mount | OverlayBannerMount | | +| priority | number | | + +Returns: + +`string` + +a new identifier for the given banner to be used with [OverlayBannersStart.remove()](./kibana-plugin-public.overlaybannersstart.remove.md) and [OverlayBannersStart.replace()](./kibana-plugin-public.overlaybannersstart.replace.md) + diff --git a/docs/development/core/public/kibana-plugin-public.overlaybannerunmount.md b/docs/development/core/public/kibana-plugin-public.overlaybannerunmount.md new file mode 100644 index 00000000000000..c9a7c2b8fee929 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.overlaybannerunmount.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [OverlayBannerUnmount](./kibana-plugin-public.overlaybannerunmount.md) + +## OverlayBannerUnmount type + +A function that will unmount the banner from the element. + +Signature: + +```typescript +export declare type OverlayBannerUnmount = () => void; +``` diff --git a/docs/development/core/public/kibana-plugin-public.overlaystart.banners.md b/docs/development/core/public/kibana-plugin-public.overlaystart.banners.md new file mode 100644 index 00000000000000..60ecc4b873f0d8 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.overlaystart.banners.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [OverlayStart](./kibana-plugin-public.overlaystart.md) > [banners](./kibana-plugin-public.overlaystart.banners.md) + +## OverlayStart.banners property + +[OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) + +Signature: + +```typescript +banners: OverlayBannersStart; +``` diff --git a/docs/development/core/public/kibana-plugin-public.overlaystart.md b/docs/development/core/public/kibana-plugin-public.overlaystart.md index 1345beffbfb6ac..6bcf0a581df800 100644 --- a/docs/development/core/public/kibana-plugin-public.overlaystart.md +++ b/docs/development/core/public/kibana-plugin-public.overlaystart.md @@ -15,6 +15,7 @@ export interface OverlayStart | Property | Type | Description | | --- | --- | --- | +| [banners](./kibana-plugin-public.overlaystart.banners.md) | OverlayBannersStart | [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) | | [openFlyout](./kibana-plugin-public.overlaystart.openflyout.md) | (flyoutChildren: React.ReactNode, flyoutProps?: {
closeButtonAriaLabel?: string;
'data-test-subj'?: string;
}) => OverlayRef | | | [openModal](./kibana-plugin-public.overlaystart.openmodal.md) | (modalChildren: React.ReactNode, modalProps?: {
className?: string;
closeButtonAriaLabel?: string;
'data-test-subj'?: string;
}) => OverlayRef | | diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 895fc785b11b1c..36a1393501ea3d 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -275,6 +275,7 @@ describe('#start()', () => { application: expect.any(Object), chrome: expect.any(Object), injectedMetadata: expect.any(Object), + overlays: expect.any(Object), targetDomElement: expect.any(HTMLElement), }); }); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 4eb16572d8fec1..7b9ed50f095910 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -190,6 +190,7 @@ export class CoreSystem { public async start() { try { const injectedMetadata = await this.injectedMetadata.start(); + const uiSettings = await this.uiSettings.start(); const docLinks = await this.docLinks.start({ injectedMetadata }); const http = await this.http.start({ injectedMetadata, fatalErrors: this.fatalErrorsSetup }); const savedObjects = await this.savedObjects.start({ http }); @@ -208,7 +209,11 @@ export class CoreSystem { this.rootDomElement.appendChild(notificationsTargetDomElement); this.rootDomElement.appendChild(overlayTargetDomElement); - const overlays = this.overlay.start({ i18n, targetDomElement: overlayTargetDomElement }); + const overlays = this.overlay.start({ + i18n, + targetDomElement: overlayTargetDomElement, + uiSettings, + }); const notifications = await this.notifications.start({ i18n, overlays, @@ -221,7 +226,6 @@ export class CoreSystem { injectedMetadata, notifications, }); - const uiSettings = await this.uiSettings.start(); application.registerMountContext(this.coreContext.coreId, 'core', () => ({ application: pick(application, ['capabilities', 'navigateToApp']), @@ -252,6 +256,7 @@ export class CoreSystem { application, chrome, injectedMetadata, + overlays, targetDomElement: coreUiTargetDomElement, }); diff --git a/src/core/public/fatal_errors/fatal_errors_service.tsx b/src/core/public/fatal_errors/fatal_errors_service.tsx index 6e9b9e8c825eac..5c6a7bb322ae1f 100644 --- a/src/core/public/fatal_errors/fatal_errors_service.tsx +++ b/src/core/public/fatal_errors/fatal_errors_service.tsx @@ -101,6 +101,8 @@ export class FatalErrorsService { }, }; + this.setupGlobalErrorHandlers(fatalErrorsSetup); + return fatalErrorsSetup; } @@ -123,4 +125,12 @@ export class FatalErrorsService { container ); } + + private setupGlobalErrorHandlers(fatalErrorsSetup: FatalErrorsSetup) { + if (window.addEventListener) { + window.addEventListener('unhandledrejection', function(e) { + console.log(`Detected an unhandled Promise rejection.\n${e.reason}`); // eslint-disable-line no-console + }); + } + } } diff --git a/src/core/public/index.scss b/src/core/public/index.scss index 42a25aa686696a..86f2efdff77020 100644 --- a/src/core/public/index.scss +++ b/src/core/public/index.scss @@ -8,3 +8,4 @@ @import '@elastic/eui/src/global_styling/mixins/index'; @import './chrome/index'; +@import './overlays/index'; diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 89c309d8427d76..35d08bace111e2 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -61,7 +61,7 @@ import { ToastInput, ToastsApi, } from './notifications'; -import { OverlayRef, OverlayStart } from './overlays'; +import { OverlayStart } from './overlays'; import { Plugin, PluginInitializer, PluginInitializerContext, PluginOpaqueId } from './plugins'; import { UiSettingsClient, UiSettingsState, UiSettingsClientContract } from './ui_settings'; import { ApplicationSetup, Capabilities, ApplicationStart } from './application'; @@ -107,6 +107,14 @@ export { HttpBody, } from './http'; +export { + OverlayStart, + OverlayBannerMount, + OverlayBannerUnmount, + OverlayBannersStart, + OverlayRef, +} from './overlays'; + /** * Core services exposed to the `Plugin` setup lifecycle * @@ -221,8 +229,6 @@ export { LegacyNavLink, NotificationsSetup, NotificationsStart, - OverlayRef, - OverlayStart, Plugin, PluginInitializer, PluginInitializerContext, diff --git a/src/legacy/ui/public/notify/_index.scss b/src/core/public/overlays/_index.scss similarity index 100% rename from src/legacy/ui/public/notify/_index.scss rename to src/core/public/overlays/_index.scss diff --git a/src/legacy/ui/public/notify/banners/_global_banner_list.scss b/src/core/public/overlays/banners/_banners_list.scss similarity index 100% rename from src/legacy/ui/public/notify/banners/_global_banner_list.scss rename to src/core/public/overlays/banners/_banners_list.scss diff --git a/src/core/public/overlays/banners/_index.scss b/src/core/public/overlays/banners/_index.scss new file mode 100644 index 00000000000000..c0c8056ff5d7d5 --- /dev/null +++ b/src/core/public/overlays/banners/_index.scss @@ -0,0 +1 @@ +@import './banners_list'; diff --git a/src/core/public/overlays/banners/banners_list.test.tsx b/src/core/public/overlays/banners/banners_list.test.tsx new file mode 100644 index 00000000000000..dbee20790fa94e --- /dev/null +++ b/src/core/public/overlays/banners/banners_list.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; + +import { BannersList } from './banners_list'; +import { BehaviorSubject } from 'rxjs'; +import { OverlayBanner } from './banners_service'; + +describe('BannersList', () => { + test('renders null if no banners', () => { + expect(mount().html()).toEqual(null); + }); + + test('renders a list of banners', () => { + const banners$ = new BehaviorSubject([ + { + id: '1', + mount: (el: HTMLElement) => { + el.innerHTML = '

Hello!

'; + return () => (el.innerHTML = ''); + }, + priority: 0, + }, + ]); + + expect(mount().html()).toMatchInlineSnapshot( + `"

Hello!

"` + ); + }); + + test('updates banners', () => { + const unmount = jest.fn(); + const banners$ = new BehaviorSubject([ + { + id: '1', + mount: (el: HTMLElement) => { + el.innerHTML = '

Hello!

'; + return unmount; + }, + priority: 0, + }, + ]); + + const component = mount(); + + act(() => { + banners$.next([ + { + id: '1', + mount: (el: HTMLElement) => { + el.innerHTML = '

First Banner!

'; + return () => (el.innerHTML = ''); + }, + priority: 1, + }, + { + id: '2', + mount: (el: HTMLElement) => { + el.innerHTML = '

Second banner!

'; + return () => (el.innerHTML = ''); + }, + priority: 0, + }, + ]); + }); + + // Two new banners should be rendered + expect(component.html()).toMatchInlineSnapshot( + `"

First Banner!

Second banner!

"` + ); + // Original banner should be unmounted + expect(unmount).toHaveBeenCalled(); + }); + + test('unsubscribe on unmount', () => { + const banners$ = new BehaviorSubject([]); + const subscribe = jest.spyOn(banners$, 'subscribe'); + const component = mount(); + // Grab the returned subscription and spy its `unsubscribe` method + const subscription = subscribe.mock.results[0].value; + const unsubscribe = jest.spyOn(subscription, 'unsubscribe'); + + component.unmount(); + expect(unsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/src/core/public/overlays/banners/banners_list.tsx b/src/core/public/overlays/banners/banners_list.tsx new file mode 100644 index 00000000000000..ee7aa73dc34a6b --- /dev/null +++ b/src/core/public/overlays/banners/banners_list.tsx @@ -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 React, { useEffect, useRef, useState } from 'react'; +import { Observable } from 'rxjs'; + +import { OverlayBanner } from './banners_service'; + +interface Props { + banners$: Observable; +} + +/** + * BannersList 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. + */ +export const BannersList: React.FunctionComponent = ({ banners$ }) => { + const [banners, setBanners] = useState([]); + useEffect(() => { + const subscription = banners$.subscribe(setBanners); + return () => subscription.unsubscribe(); + }, [banners$]); // Only un/re-subscribe if the Observable changes + + if (banners.length === 0) { + return null; + } + + return ( +
+ {banners.map(banner => ( + + ))} +
+ ); +}; + +const BannerItem: React.FunctionComponent<{ banner: OverlayBanner }> = ({ banner }) => { + const element = useRef(null); + useEffect(() => banner.mount(element.current!), [banner]); // Only unmount / remount if banner object changed. + + return ( +
+ ); +}; diff --git a/src/core/public/overlays/banners/banners_service.mock.ts b/src/core/public/overlays/banners/banners_service.mock.ts new file mode 100644 index 00000000000000..14041b2720877f --- /dev/null +++ b/src/core/public/overlays/banners/banners_service.mock.ts @@ -0,0 +1,45 @@ +/* + * 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 { OverlayBannersStart, OverlayBannersService } from './banners_service'; + +const createStartContractMock = () => { + const startContract: jest.Mocked = { + add: jest.fn(), + remove: jest.fn(), + replace: jest.fn(), + get$: jest.fn(), + getComponent: jest.fn(), + }; + return startContract; +}; + +const createMock = () => { + const mocked: jest.Mocked> = { + start: jest.fn(), + stop: jest.fn(), + }; + mocked.start.mockReturnValue(createStartContractMock()); + return mocked; +}; + +export const overlayBannersServiceMock = { + create: createMock, + createStartContract: createStartContractMock, +}; diff --git a/src/core/public/overlays/banners/banners_service.test.ts b/src/core/public/overlays/banners/banners_service.test.ts new file mode 100644 index 00000000000000..f11a5d6b88bc23 --- /dev/null +++ b/src/core/public/overlays/banners/banners_service.test.ts @@ -0,0 +1,124 @@ +/* + * 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 { OverlayBannersService, OverlayBannersStart } from './banners_service'; +import { take } from 'rxjs/operators'; +import { i18nServiceMock } from '../../i18n/i18n_service.mock'; +import { uiSettingsServiceMock } from '../../ui_settings/ui_settings_service.mock'; + +describe('OverlayBannersService', () => { + let service: OverlayBannersStart; + beforeEach(() => { + service = new OverlayBannersService().start({ + i18n: i18nServiceMock.createStartContract(), + uiSettings: uiSettingsServiceMock.createStartContract(), + }); + }); + + const currentBanners = () => + service + .get$() + .pipe(take(1)) + .toPromise(); + + describe('adding banners', () => { + test('adds a single banner', async () => { + const mount = jest.fn(); + const banner = service.add(mount); + expect(await currentBanners()).toEqual([{ id: banner, mount, priority: 0 }]); + }); + + test('sorts banners by priority', async () => { + const mount1 = jest.fn(); + const banner1 = service.add(mount1); + const mount2 = jest.fn(); + const banner2 = service.add(mount2, 10); + const mount3 = jest.fn(); + const banner3 = service.add(mount3, 5); + expect(await currentBanners()).toEqual([ + { id: banner2, mount: mount2, priority: 10 }, + { id: banner3, mount: mount3, priority: 5 }, + { id: banner1, mount: mount1, priority: 0 }, + ]); + }); + }); + + describe('removing banners', () => { + test('removes a single banner', async () => { + const mount = jest.fn(); + const banner = service.add(mount); + expect(service.remove(banner)).toBe(true); + expect(await currentBanners()).toEqual([]); + expect(service.remove(banner)).toBe(false); + }); + + test('preserves priority order', async () => { + const mount1 = jest.fn(); + const banner1 = service.add(mount1); + const mount2 = jest.fn(); + const banner2 = service.add(mount2, 10); + const mount3 = jest.fn(); + const banner3 = service.add(mount3, 5); + service.remove(banner2); + expect(await currentBanners()).toEqual([ + { id: banner3, mount: mount3, priority: 5 }, + { id: banner1, mount: mount1, priority: 0 }, + ]); + }); + }); + + describe('replacing banners', () => { + test('replaces mount function', async () => { + const mount1 = jest.fn(); + const banner = service.add(mount1); + const mount2 = jest.fn(); + const updatedBanner = service.replace(banner, mount2); + expect(await currentBanners()).toEqual([{ id: updatedBanner, mount: mount2, priority: 0 }]); + }); + + test('updates priority', async () => { + const mount1 = jest.fn(); + const banner1 = service.add(mount1); + const mount2 = jest.fn(); + const banner2 = service.add(mount2, 10); + const mount3 = jest.fn(); + const banner3 = service.add(mount3, 5); + const updatedBanner2 = service.replace(banner2, mount2, -10); + expect(await currentBanners()).toEqual([ + { id: banner3, mount: mount3, priority: 5 }, + { id: banner1, mount: mount1, priority: 0 }, + { id: updatedBanner2, mount: mount2, priority: -10 }, + ]); + }); + + test('can be replaced multiple times using new id', async () => { + const mount1 = jest.fn(); + const banner = service.add(mount1); + const mount2 = jest.fn(); + const updatedBanner = service.replace(banner, mount2); + expect(banner).not.toEqual(updatedBanner); + // Make sure we can use the new id to replace again + const mount3 = jest.fn(); + const updatedBanner2 = service.replace(updatedBanner, mount3); + expect(updatedBanner2).not.toEqual(updatedBanner); + // Should only be a single banner + expect(await currentBanners()).toEqual([{ id: updatedBanner2, mount: mount3, priority: 0 }]); + }); + }); +}); diff --git a/src/core/public/overlays/banners/banners_service.tsx b/src/core/public/overlays/banners/banners_service.tsx new file mode 100644 index 00000000000000..799ca43c7fa93b --- /dev/null +++ b/src/core/public/overlays/banners/banners_service.tsx @@ -0,0 +1,148 @@ +/* + * 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 { BehaviorSubject, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { PriorityMap } from './priority_map'; +import { BannersList } from './banners_list'; +import { UiSettingsClientContract } from '../../ui_settings'; +import { I18nStart } from '../../i18n'; +import { UserBannerService } from './user_banner_service'; + +/** + * A function that will unmount the banner from the element. + * @public + */ +export type OverlayBannerUnmount = () => void; + +/** + * A function that will mount the banner inside the provided element. + * @param element an element to render into + * @returns a {@link OverlayBannerUnmount} + * @public + */ +export type OverlayBannerMount = (element: HTMLElement) => OverlayBannerUnmount; + +/** @public */ +export interface OverlayBannersStart { + /** + * Add a new banner + * + * @param mount {@link OverlayBannerMount} + * @param priority optional priority order to display this banner. Higher priority values are shown first. + * @returns a unique identifier for the given banner to be used with {@link OverlayBannersStart.remove} and + * {@link OverlayBannersStart.replace} + */ + add(mount: OverlayBannerMount, priority?: number): string; + + /** + * Remove a banner + * + * @param id the unique identifier for the banner returned by {@link OverlayBannersStart.add} + * @returns if the banner was found or not + */ + remove(id: string): boolean; + + /** + * Replace a banner in place + * + * @param id the unique identifier for the banner returned by {@link OverlayBannersStart.add} + * @param mount {@link OverlayBannerMount} + * @param priority optional priority order to display this banner. Higher priority values are shown first. + * @returns a new identifier for the given banner to be used with {@link OverlayBannersStart.remove} and + * {@link OverlayBannersStart.replace} + */ + replace(id: string | undefined, mount: OverlayBannerMount, priority?: number): string; + + /** @internal */ + get$(): Observable; + getComponent(): JSX.Element; +} + +/** @internal */ +export interface OverlayBanner { + readonly id: string; + readonly mount: OverlayBannerMount; + readonly priority: number; +} + +interface StartDeps { + i18n: I18nStart; + uiSettings: UiSettingsClientContract; +} + +/** @internal */ +export class OverlayBannersService { + private readonly userBanner = new UserBannerService(); + + public start({ i18n, uiSettings }: StartDeps): OverlayBannersStart { + let uniqueId = 0; + const genId = () => `${uniqueId++}`; + const banners$ = new BehaviorSubject(new PriorityMap()); + + const service: OverlayBannersStart = { + add: (mount, priority = 0) => { + const id = genId(); + const nextBanner: OverlayBanner = { id, mount, priority }; + banners$.next(banners$.value.add(id, nextBanner)); + return id; + }, + + remove: (id: string) => { + if (!banners$.value.has(id)) { + return false; + } + + banners$.next(banners$.value.remove(id)); + + return true; + }, + + replace(id: string | undefined, mount: OverlayBannerMount, priority = 0) { + if (!id || !banners$.value.has(id)) { + return this.add(mount, priority); + } + + const nextId = genId(); + const nextBanner = { id: nextId, mount, priority }; + + banners$.next(banners$.value.remove(id).add(nextId, nextBanner)); + return nextId; + }, + + get$() { + return banners$.pipe(map(bannerMap => [...bannerMap.values()])); + }, + + getComponent() { + return ; + }, + }; + + this.userBanner.start({ banners: service, i18n, uiSettings }); + + return service; + } + + public stop() { + this.userBanner.stop(); + } +} diff --git a/src/core/public/overlays/banners/index.ts b/src/core/public/overlays/banners/index.ts new file mode 100644 index 00000000000000..9e908bd6280038 --- /dev/null +++ b/src/core/public/overlays/banners/index.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +export { + OverlayBannerMount, + OverlayBannerUnmount, + OverlayBannersStart, + OverlayBannersService, +} from './banners_service'; diff --git a/src/core/public/overlays/banners/priority_map.test.ts b/src/core/public/overlays/banners/priority_map.test.ts new file mode 100644 index 00000000000000..13d81989417f1a --- /dev/null +++ b/src/core/public/overlays/banners/priority_map.test.ts @@ -0,0 +1,59 @@ +/* + * 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 { PriorityMap } from './priority_map'; + +interface MyPrioType { + readonly priority: number; +} + +describe('PriorityMap', () => { + it('sorts added keys by priority', () => { + let map = new PriorityMap(); + map = map.add('a', { priority: 1 }); + map = map.add('b', { priority: 3 }); + map = map.add('c', { priority: 2 }); + expect([...map]).toEqual([ + ['b', { priority: 3 }], + ['c', { priority: 2 }], + ['a', { priority: 1 }], + ]); + }); + + it('retains sort order when keys are removed', () => { + let map = new PriorityMap(); + map = map.add('a', { priority: 1 }); + map = map.add('b', { priority: 3 }); + map = map.add('c', { priority: 2 }); + map = map.remove('c'); + expect([...map]).toEqual([['b', { priority: 3 }], ['a', { priority: 1 }]]); + }); + + it('adds duplicate priorities to end', () => { + let map = new PriorityMap(); + map = map.add('a', { priority: 1 }); + map = map.add('b', { priority: 1 }); + map = map.add('c', { priority: 1 }); + expect([...map]).toEqual([ + ['a', { priority: 1 }], + ['b', { priority: 1 }], + ['c', { priority: 1 }], + ]); + }); +}); diff --git a/src/core/public/overlays/banners/priority_map.ts b/src/core/public/overlays/banners/priority_map.ts new file mode 100644 index 00000000000000..c6d493b380b4cd --- /dev/null +++ b/src/core/public/overlays/banners/priority_map.ts @@ -0,0 +1,61 @@ +/* + * 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 { sortBy } from 'lodash'; + +interface PriorityValue { + readonly priority: number; +} + +/** + * Immutable map that ensures entries are always in descending order based on + * the values 'priority' property. + */ +export class PriorityMap implements Iterable<[K, V]> { + private readonly map: ReadonlyMap; + + constructor(map?: ReadonlyMap) { + this.map = map ? new Map(sortEntries(map)) : new Map(); + } + + public add(key: K, value: V) { + return new PriorityMap(new Map(sortEntries([...this.map, [key, value]]))); + } + + public remove(key: K) { + return new PriorityMap( + new Map([...this.map].filter(([itemKey]) => itemKey !== key)) + ); + } + + public has(key: K) { + return this.map.has(key); + } + + public [Symbol.iterator]() { + return this.map[Symbol.iterator](); + } + + public values() { + return this.map.values(); + } +} + +const sortEntries = (map: Iterable<[K, V]>): Iterable<[K, V]> => + sortBy([...map] as Array<[K, V]>, '1.priority').reverse(); diff --git a/src/core/public/overlays/banners/user_banner_service.test.ts b/src/core/public/overlays/banners/user_banner_service.test.ts new file mode 100644 index 00000000000000..e8842ddeef3e50 --- /dev/null +++ b/src/core/public/overlays/banners/user_banner_service.test.ts @@ -0,0 +1,132 @@ +/* + * 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 { uiSettingsServiceMock } from '../../ui_settings/ui_settings_service.mock'; +import { UserBannerService } from './user_banner_service'; +import { overlayBannersServiceMock } from './banners_service.mock'; +import { i18nServiceMock } from '../../i18n/i18n_service.mock'; +import { Subject } from 'rxjs'; + +describe('OverlayBannersService', () => { + let bannerContent: string | undefined; + let service: UserBannerService; + let uiSettings: ReturnType; + let banners: ReturnType; + + const startService = (content?: string) => { + bannerContent = content; + uiSettings = uiSettingsServiceMock.createStartContract(); + uiSettings.get.mockImplementation((key: string) => { + if (key === 'notifications:banner') { + return bannerContent; + } else if (key === 'notifications:lifetime:banner') { + return 1000; + } + }); + + banners = overlayBannersServiceMock.createStartContract(); + service = new UserBannerService(); + service.start({ + banners, + i18n: i18nServiceMock.createStartContract(), + uiSettings, + }); + }; + + afterEach(() => service.stop()); + + it('does not add banner if setting is unspecified', () => { + startService(); + expect(banners.replace).not.toHaveBeenCalled(); + }); + + it('adds banner if setting is specified', () => { + startService('testing banner!'); + expect(banners.replace).toHaveBeenCalled(); + + const mount = banners.replace.mock.calls[0][1]; + const div = document.createElement('div'); + mount(div); + expect(div.querySelector('.euiCallOut')).toBeInstanceOf(HTMLDivElement); + }); + + it('dismisses banner after timeout', async () => { + jest.useFakeTimers(); + startService('testing banner!'); + expect(banners.remove).not.toHaveBeenCalled(); + + // Must mount in order for timer to start + const mount = banners.replace.mock.calls[0][1]; + mount(document.createElement('div')); + // Process all timers + jest.runAllTimers(); + expect(banners.remove).toHaveBeenCalled(); + }); + + it('updates banner on change', () => { + startService(); + expect(banners.replace).toHaveBeenCalledTimes(0); + + const update$ = (uiSettings.getUpdate$() as any) as Subject<{ + key: string; + }>; + + bannerContent = 'update 1'; + update$.next({ key: 'notifications:banner' }); + expect(banners.replace).toHaveBeenCalledTimes(1); + + bannerContent = 'update 2'; + update$.next({ key: 'notifications:banner' }); + expect(banners.replace).toHaveBeenCalledTimes(2); + }); + + it('removes banner when changed to empty string', () => { + startService('remove me!'); + const update$ = (uiSettings.getUpdate$() as any) as Subject<{ + key: string; + }>; + + bannerContent = ''; + update$.next({ key: 'notifications:banner' }); + expect(banners.remove).toHaveBeenCalled(); + }); + + it('removes banner when changed to undefined', () => { + startService('remove me!'); + const update$ = (uiSettings.getUpdate$() as any) as Subject<{ + key: string; + }>; + + bannerContent = undefined; + update$.next({ key: 'notifications:banner' }); + expect(banners.remove).toHaveBeenCalled(); + }); + + it('does not update banner if other settings change', () => { + startService('initial banner!'); + expect(banners.replace).toHaveBeenCalledTimes(1); + + const update$ = (uiSettings.getUpdate$() as any) as Subject<{ + key: string; + }>; + + update$.next({ key: 'other:setting' }); + expect(banners.replace).toHaveBeenCalledTimes(1); // still only the initial call + }); +}); diff --git a/src/core/public/overlays/banners/user_banner_service.tsx b/src/core/public/overlays/banners/user_banner_service.tsx new file mode 100644 index 00000000000000..b258e2127883da --- /dev/null +++ b/src/core/public/overlays/banners/user_banner_service.tsx @@ -0,0 +1,116 @@ +/* + * 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, { Fragment } from 'react'; +import ReactDOM from 'react-dom'; +import ReactMarkdown from 'react-markdown'; +import { filter } from 'rxjs/operators'; +import { Subscription } from 'rxjs'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCallOut, EuiButton } from '@elastic/eui'; + +import { I18nStart } from '../../i18n'; +import { UiSettingsClientContract } from '../../ui_settings'; +import { OverlayBannersStart } from './banners_service'; + +interface StartDeps { + banners: OverlayBannersStart; + i18n: I18nStart; + uiSettings: UiSettingsClientContract; +} + +/** + * Sets up the custom banner that can be specified in advanced settings. + * @internal + */ +export class UserBannerService { + private settingsSubscription?: Subscription; + + public start({ banners, i18n, uiSettings }: StartDeps) { + let id: string | undefined; + let timeout: any; + + const dismiss = () => { + banners.remove(id!); + clearTimeout(timeout); + }; + + const updateBanner = () => { + const content = uiSettings.get('notifications:banner'); + const lifetime = uiSettings.get('notifications:lifetime:banner'); + + if (typeof content !== 'string' || content.length === 0 || typeof lifetime !== 'number') { + dismiss(); + return; + } + + id = banners.replace( + id, + el => { + ReactDOM.render( + + + } + iconType="help" + > + {content.trim()} + + banners.remove(id!)}> + + + + , + el + ); + + timeout = setTimeout(dismiss, lifetime); + + return () => ReactDOM.unmountComponentAtNode(el); + }, + 100 + ); + }; + + updateBanner(); + this.settingsSubscription = uiSettings + .getUpdate$() + .pipe( + filter( + ({ key }) => key === 'notifications:banner' || key === 'notifications:lifetime:banner' + ) + ) + .subscribe(() => updateBanner()); + } + + public stop() { + if (this.settingsSubscription) { + this.settingsSubscription.unsubscribe(); + this.settingsSubscription = undefined; + } + } +} diff --git a/src/core/public/overlays/index.ts b/src/core/public/overlays/index.ts index 6e92b0e84f8a0a..c49548abee0df3 100644 --- a/src/core/public/overlays/index.ts +++ b/src/core/public/overlays/index.ts @@ -17,4 +17,5 @@ * under the License. */ +export { OverlayBannerMount, OverlayBannerUnmount, OverlayBannersStart } from './banners'; export { OverlayService, OverlayStart, OverlayRef } from './overlay_service'; diff --git a/src/core/public/overlays/overlay_service.mock.ts b/src/core/public/overlays/overlay_service.mock.ts index da62d2a42b29a1..39abc6f765b975 100644 --- a/src/core/public/overlays/overlay_service.mock.ts +++ b/src/core/public/overlays/overlay_service.mock.ts @@ -17,11 +17,13 @@ * under the License. */ import { OverlayService, OverlayStart } from './overlay_service'; +import { overlayBannersServiceMock } from './banners/banners_service.mock'; const createStartContractMock = () => { - const startContract: jest.Mocked> = { + const startContract: DeeplyMockedKeys = { openFlyout: jest.fn(), openModal: jest.fn(), + banners: overlayBannersServiceMock.createStartContract(), }; startContract.openModal.mockReturnValue({ close: jest.fn(), diff --git a/src/core/public/overlays/overlay_service.ts b/src/core/public/overlays/overlay_service.ts index a9c44f63013c79..ca7b1a0d4ef9db 100644 --- a/src/core/public/overlays/overlay_service.ts +++ b/src/core/public/overlays/overlay_service.ts @@ -22,6 +22,8 @@ import React from 'react'; import { FlyoutService } from './flyout'; import { ModalService } from './modal'; import { I18nStart } from '../i18n'; +import { OverlayBannersStart, OverlayBannersService } from './banners'; +import { UiSettingsClientContract } from '../ui_settings'; export interface OverlayRef { /** @@ -43,30 +45,32 @@ export interface OverlayRef { interface StartDeps { i18n: I18nStart; targetDomElement: HTMLElement; + uiSettings: UiSettingsClientContract; } /** @internal */ export class OverlayService { - private flyoutService?: FlyoutService; - private modalService?: ModalService; - - public start({ i18n, targetDomElement }: StartDeps): OverlayStart { + public start({ i18n, targetDomElement, uiSettings }: StartDeps): OverlayStart { const flyoutElement = document.createElement('div'); const modalElement = document.createElement('div'); targetDomElement.appendChild(flyoutElement); targetDomElement.appendChild(modalElement); - this.flyoutService = new FlyoutService(flyoutElement); - this.modalService = new ModalService(modalElement); + const flyoutService = new FlyoutService(flyoutElement); + const modalService = new ModalService(modalElement); + const bannersService = new OverlayBannersService(); return { - openFlyout: this.flyoutService.openFlyout.bind(this.flyoutService, i18n), - openModal: this.modalService.openModal.bind(this.modalService, i18n), + banners: bannersService.start({ i18n, uiSettings }), + openFlyout: flyoutService.openFlyout.bind(flyoutService, i18n), + openModal: modalService.openModal.bind(modalService, i18n), }; } } /** @public */ export interface OverlayStart { + /** {@link OverlayBannersStart} */ + banners: OverlayBannersStart; openFlyout: ( flyoutChildren: React.ReactNode, flyoutProps?: { diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 552476425d8d88..a4f3e3ed189bcb 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -596,6 +596,25 @@ export interface NotificationsStart { toasts: ToastsStart; } +// @public +export type OverlayBannerMount = (element: HTMLElement) => OverlayBannerUnmount; + +// @public (undocumented) +export interface OverlayBannersStart { + add(mount: OverlayBannerMount, priority?: number): string; + // Warning: (ae-forgotten-export) The symbol "OverlayBanner" needs to be exported by the entry point index.d.ts + // + // @internal (undocumented) + get$(): Observable; + // (undocumented) + getComponent(): JSX.Element; + remove(id: string): boolean; + replace(id: string | undefined, mount: OverlayBannerMount, priority?: number): string; +} + +// @public +export type OverlayBannerUnmount = () => void; + // Warning: (ae-missing-release-tag) "OverlayRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -606,6 +625,8 @@ export interface OverlayRef { // @public (undocumented) export interface OverlayStart { + // (undocumented) + banners: OverlayBannersStart; // (undocumented) openFlyout: (flyoutChildren: React.ReactNode, flyoutProps?: { closeButtonAriaLabel?: string; diff --git a/src/core/public/rendering/rendering_service.test.tsx b/src/core/public/rendering/rendering_service.test.tsx index 9a4b46c657f081..317ab5cc8b855b 100644 --- a/src/core/public/rendering/rendering_service.test.tsx +++ b/src/core/public/rendering/rendering_service.test.tsx @@ -23,6 +23,7 @@ import { chromeServiceMock } from '../chrome/chrome_service.mock'; import { RenderingService } from './rendering_service'; import { InternalApplicationStart } from '../application'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; +import { overlayServiceMock } from '../overlays/overlay_service.mock'; describe('RenderingService#start', () => { const getService = ({ legacyMode = false }: { legacyMode?: boolean } = {}) => { @@ -32,10 +33,19 @@ describe('RenderingService#start', () => { } as InternalApplicationStart; const chrome = chromeServiceMock.createStartContract(); chrome.getHeaderComponent.mockReturnValue(
Hello chrome!
); + const overlays = overlayServiceMock.createStartContract(); + overlays.banners.getComponent.mockReturnValue(
I'm a banner!
); + const injectedMetadata = injectedMetadataServiceMock.createStartContract(); injectedMetadata.getLegacyMode.mockReturnValue(legacyMode); const targetDomElement = document.createElement('div'); - const start = rendering.start({ application, chrome, injectedMetadata, targetDomElement }); + const start = rendering.start({ + application, + chrome, + injectedMetadata, + overlays, + targetDomElement, + }); return { start, targetDomElement }; }; @@ -58,6 +68,19 @@ describe('RenderingService#start', () => { expect(targetDomElement.querySelector('div.app-wrapper-pannel')).toBeDefined(); }); + it('renders the banner UI', () => { + const { targetDomElement } = getService(); + expect(targetDomElement.querySelector('#globalBannerList')).toMatchInlineSnapshot(` +
+
+ I'm a banner! +
+
+ `); + }); + describe('legacyMode', () => { it('renders into provided DOM element', () => { const { targetDomElement } = getService({ legacyMode: true }); diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index 2e066feca8bf35..7a747faa2673f5 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -24,11 +24,13 @@ import { I18nProvider } from '@kbn/i18n/react'; import { InternalChromeStart } from '../chrome'; import { InternalApplicationStart } from '../application'; import { InjectedMetadataStart } from '../injected_metadata'; +import { OverlayStart } from '../overlays'; interface StartDeps { application: InternalApplicationStart; chrome: InternalChromeStart; injectedMetadata: InjectedMetadataStart; + overlays: OverlayStart; targetDomElement: HTMLDivElement; } @@ -43,9 +45,16 @@ interface StartDeps { * @internal */ export class RenderingService { - start({ application, chrome, injectedMetadata, targetDomElement }: StartDeps): RenderingStart { + start({ + application, + chrome, + injectedMetadata, + overlays, + targetDomElement, + }: StartDeps): RenderingStart { const chromeUi = chrome.getHeaderComponent(); const appUi = application.getComponent(); + const bannerUi = overlays.banners.getComponent(); const legacyMode = injectedMetadata.getLegacyMode(); const legacyRef = legacyMode ? React.createRef() : null; @@ -58,6 +67,7 @@ export class RenderingService { {!legacyMode && (
+
{bannerUi}
{appUi}
diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.test.tsx b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.test.tsx index c48a3ea7198137..c78e6cb11003f1 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.test.tsx +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.test.tsx @@ -100,6 +100,7 @@ describe('QueryBarTopRowTopRow', () => { { { { { { { { void; customSubmitButton?: any; isDirty: boolean; + toasts: CoreStart['notifications']['toasts']; uiSettings: UiSettingsClientContract; savedObjectsClient: SavedObjectsClientContract; http: HttpServiceBase; @@ -304,7 +306,7 @@ export class QueryBarTopRowUI extends Component { private handleLuceneSyntaxWarning() { if (!this.props.query) return; - const { intl, store } = this.props; + const { intl, store, toasts } = this.props; const { query, language } = this.props.query; if ( language === 'kuery' && @@ -312,7 +314,7 @@ export class QueryBarTopRowUI extends Component { (!store || !store.get('kibana.luceneSyntaxWarningOptOut')) && doesKueryExpressionHaveLuceneSyntaxError(query) ) { - const toast = toastNotifications.addWarning({ + const toast = toasts.addWarning({ title: intl.formatMessage({ id: 'data.query.queryBar.luceneSyntaxWarningTitle', defaultMessage: 'Lucene syntax warning', @@ -355,7 +357,7 @@ export class QueryBarTopRowUI extends Component { private onLuceneSyntaxWarningOptOut(toast: Toast) { if (!this.props.store) return; this.props.store.set('kibana.luceneSyntaxWarningOptOut', true); - toastNotifications.remove(toast); + this.props.toasts.remove(toast); } } diff --git a/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx index f4de88c25dd850..1b73531ec41e3f 100644 --- a/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx @@ -98,6 +98,7 @@ describe('SearchBar', () => { { { { { { { { if (this.shouldRenderQueryBar()) { queryBar = ( { return { @@ -26,6 +26,11 @@ jest.doMock('ui/new_platform', () => { notifications: notificationServiceMock.createSetupContract(), }, }, + npStart: { + core: { + overlays: overlayServiceMock.createStartContract(), + }, + }, }; }); diff --git a/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.test.tsx b/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.test.tsx index 3e31cbd0d84c18..6f26f43a805bb1 100644 --- a/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.test.tsx @@ -57,6 +57,7 @@ describe('TopNavMenu', () => { const component = shallowWithIntl( @@ -69,6 +70,7 @@ describe('TopNavMenu', () => { const component = shallowWithIntl( { const component = shallowWithIntl( { const component = shallowWithIntl( & { name: string; uiSettings: UiSettingsClientContract; savedObjectsClient: SavedObjectsClientContract; + toasts: CoreStart['notifications']['toasts']; config?: TopNavMenuData[]; showSearchBar?: boolean; }; @@ -64,6 +65,7 @@ export function TopNavMenu(props: Props) { http={props.http} query={props.query} filters={props.filters} + toasts={props.toasts} uiSettings={props.uiSettings} showQueryBar={props.showQueryBar} showQueryInput={props.showQueryInput} diff --git a/src/legacy/core_plugins/status_page/public/lib/load_status.test.mocks.js b/src/legacy/core_plugins/status_page/public/lib/load_status.test.mocks.js index 7e1da4f2eb42df..3cecb2cfc33d38 100644 --- a/src/legacy/core_plugins/status_page/public/lib/load_status.test.mocks.js +++ b/src/legacy/core_plugins/status_page/public/lib/load_status.test.mocks.js @@ -17,7 +17,7 @@ * under the License. */ -import { fatalErrorsServiceMock, notificationServiceMock } from '../../../../../core/public/mocks'; +import { fatalErrorsServiceMock, notificationServiceMock, overlayServiceMock } from '../../../../../core/public/mocks'; jest.doMock('ui/new_platform', () => ({ npSetup: { @@ -26,4 +26,9 @@ jest.doMock('ui/new_platform', () => ({ notifications: notificationServiceMock.createSetupContract(), } }, + npStart: { + core: { + overlays: overlayServiceMock.createStartContract(), + }, + }, })); diff --git a/src/legacy/ui/public/_index.scss b/src/legacy/ui/public/_index.scss index e7d85b8cc3f8e4..2ce9a0a8aa06f1 100644 --- a/src/legacy/ui/public/_index.scss +++ b/src/legacy/ui/public/_index.scss @@ -18,7 +18,6 @@ @import './error_url_overflow/index'; @import './exit_full_screen/index'; @import './field_editor/index'; -@import './notify/index'; @import './saved_objects/index'; @import './share/index'; @import './style_compile/index'; diff --git a/src/legacy/ui/public/chrome/directives/kbn_chrome.js b/src/legacy/ui/public/chrome/directives/kbn_chrome.js index 755cb8b42d3637..2a715330018915 100644 --- a/src/legacy/ui/public/chrome/directives/kbn_chrome.js +++ b/src/legacy/ui/public/chrome/directives/kbn_chrome.js @@ -24,11 +24,6 @@ import $ from 'jquery'; import { uiModules } from '../../modules'; import template from './kbn_chrome.html'; -import { - GlobalBannerList, - banners, -} from '../../notify'; - import { I18nContext } from '../../i18n'; import { npStart } from '../../new_platform'; import { chromeHeaderNavControlsRegistry, NavControlSide } from '../../registry/chrome_header_nav_controls'; @@ -78,21 +73,16 @@ export function kbnChromeProvider(chrome, internals) { // Banners const bannerListContainer = document.getElementById('globalBannerList'); - // Banners not supported in New Platform yet - // https://github.com/elastic/kibana/issues/41986 if (bannerListContainer) { + // This gets rendered manually by the legacy platform because this component must be inside the .app-wrapper ReactDOM.render( - + {npStart.core.overlays.banners.getComponent()} , bannerListContainer ); } - return chrome; } }; diff --git a/src/legacy/ui/public/notify/banners/__snapshots__/global_banner_list.test.js.snap b/src/legacy/ui/public/notify/banners/__snapshots__/global_banner_list.test.js.snap deleted file mode 100644 index fd1407670b452d..00000000000000 --- a/src/legacy/ui/public/notify/banners/__snapshots__/global_banner_list.test.js.snap +++ /dev/null @@ -1,22 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`GlobalBannerList is rendered 1`] = `null`; - -exports[`GlobalBannerList props banners is rendered 1`] = ` -
-
- a component -
-
- b good -
-
-`; diff --git a/src/legacy/ui/public/notify/banners/_index.scss b/src/legacy/ui/public/notify/banners/_index.scss deleted file mode 100644 index 96b9a42385e86e..00000000000000 --- a/src/legacy/ui/public/notify/banners/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './global_banner_list'; diff --git a/src/legacy/ui/public/notify/banners/banners.test.js b/src/legacy/ui/public/notify/banners/banners.test.js deleted file mode 100644 index 69b046bcb4d8fc..00000000000000 --- a/src/legacy/ui/public/notify/banners/banners.test.js +++ /dev/null @@ -1,161 +0,0 @@ -/* - * 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 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); - }); - - }); - - }); -}); diff --git a/src/legacy/ui/public/notify/banners/banners.js b/src/legacy/ui/public/notify/banners/banners.tsx similarity index 56% rename from src/legacy/ui/public/notify/banners/banners.js rename to src/legacy/ui/public/notify/banners/banners.tsx index 3f8dd08771d433..777e408b8dfc40 100644 --- a/src/legacy/ui/public/notify/banners/banners.js +++ b/src/legacy/ui/public/notify/banners/banners.tsx @@ -17,47 +17,23 @@ * under the License. */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { npStart } from 'ui/new_platform'; +import { I18nProvider } from '@kbn/i18n/react'; + +const npBanners = npStart.core.overlays.banners; + +/** compatibility layer for new platform */ +const mountForComponent = (component: React.ReactElement) => (element: HTMLElement) => { + ReactDOM.render({component}, element); + return () => ReactDOM.unmountComponentAtNode(element); +}; + /** * 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. * @@ -65,25 +41,9 @@ export class Banners { * @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; - } + add = ({ component, priority }: { component: React.ReactElement; priority?: number }) => { + return npBanners.add(mountForComponent(component), priority); + }; /** * Remove an existing banner. @@ -91,15 +51,9 @@ export class Banners { * @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; - } + remove = (id: string): boolean => { + return npBanners.remove(id); + }; /** * Replace an existing banner by removing it, if it exists, and adding a new one in its place. @@ -112,12 +66,17 @@ export class Banners { * @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 }); - } - + set = ({ + component, + id, + priority = 0, + }: { + component: React.ReactElement; + id: string; + priority?: number; + }): string => { + return npBanners.replace(id, mountForComponent(component), priority); + }; } /** diff --git a/src/legacy/ui/public/notify/banners/global_banner_list.js b/src/legacy/ui/public/notify/banners/global_banner_list.js deleted file mode 100644 index 9a6f120968b0ca..00000000000000 --- a/src/legacy/ui/public/notify/banners/global_banner_list.js +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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, { Component } from 'react'; -import PropTypes from 'prop-types'; - -/** - * 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 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 flexBanners = this.props.banners.map(banner => { - const { id, component, priority, ...rest } = banner; - - return ( -
- {component} -
- ); - }); - - return
{flexBanners}
; - } -} diff --git a/src/legacy/ui/public/notify/banners/global_banner_list.test.js b/src/legacy/ui/public/notify/banners/global_banner_list.test.js deleted file mode 100644 index 5a008f33c57d94..00000000000000 --- a/src/legacy/ui/public/notify/banners/global_banner_list.test.js +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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 { render } from 'enzyme'; -import { GlobalBannerList } from './global_banner_list'; - -describe('GlobalBannerList', () => { - - test('is rendered', () => { - const component = render( - - ); - - 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( - - ); - - expect(component) - .toMatchSnapshot(); - }); - - }); - - }); - -}); diff --git a/src/legacy/ui/public/notify/banners/index.js b/src/legacy/ui/public/notify/banners/index.js index ad2fcd650e5c0a..9221f95074cd96 100644 --- a/src/legacy/ui/public/notify/banners/index.js +++ b/src/legacy/ui/public/notify/banners/index.js @@ -17,5 +17,4 @@ * under the License. */ -export { GlobalBannerList } from './global_banner_list'; export { banners } from './banners'; diff --git a/src/legacy/ui/public/notify/index.js b/src/legacy/ui/public/notify/index.js index da4a5af6b20ad9..70e6c635d43b95 100644 --- a/src/legacy/ui/public/notify/index.js +++ b/src/legacy/ui/public/notify/index.js @@ -19,5 +19,5 @@ export { fatalError, addFatalErrorCallback } from './fatal_error'; export { toastNotifications } from './toasts'; -export { GlobalBannerList, banners } from './banners'; +export { banners } from './banners'; export { addAppRedirectMessageToUrl, showAppRedirectNotification } from './app_redirect'; diff --git a/src/legacy/ui/public/notify/notify.js b/src/legacy/ui/public/notify/notify.js deleted file mode 100644 index 5afbf1111f6cea..00000000000000 --- a/src/legacy/ui/public/notify/notify.js +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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 { MarkdownSimple } from '../../../core_plugins/kibana_react/public'; -import chrome from '../chrome'; -import { fatalError } from './fatal_error'; -import { banners } from './banners'; -import './filters/markdown'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { - EuiCallOut, - EuiButton, -} from '@elastic/eui'; - -const config = chrome.getUiSettingsClient(); - -config.getUpdate$().subscribe(() => { - applyConfig(config); -}); - -let bannerId; -let bannerTimeoutId; - -function applyConfig(config) { - // Show user-defined banner. - const bannerContent = config.get('notifications:banner'); - const bannerLifetime = config.get('notifications:lifetime:banner'); - - if (typeof bannerContent === 'string' && bannerContent.trim()) { - const BANNER_PRIORITY = 100; - - const dismissBanner = () => { - banners.remove(bannerId); - clearTimeout(bannerTimeoutId); - }; - - const banner = ( - - )} - iconType="help" - > - - {bannerContent} - - - - - - - ); - - bannerId = banners.set({ - component: banner, - id: bannerId, - priority: BANNER_PRIORITY, - }); - - bannerTimeoutId = setTimeout(() => { - dismissBanner(); - }, bannerLifetime); - } -} - -window.onerror = function (err, url, line) { - fatalError(new Error(`${err} (${url}:${line})`)); - return true; -}; - -if (window.addEventListener) { - window.addEventListener('unhandledrejection', function (e) { - console.log(`Detected an unhandled Promise rejection.\n${e.reason}`); // eslint-disable-line no-console - }); -} - diff --git a/src/plugins/kibana_react/public/context/context.test.tsx b/src/plugins/kibana_react/public/context/context.test.tsx index 8deac36c43099a..d7dce3f69239dd 100644 --- a/src/plugins/kibana_react/public/context/context.test.tsx +++ b/src/plugins/kibana_react/public/context/context.test.tsx @@ -20,7 +20,7 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { context, createKibanaReactContext, useKibana, KibanaContextProvider } from './context'; -import { coreMock } from '../../../../core/public/mocks'; +import { coreMock, overlayServiceMock } from '../../../../core/public/mocks'; import { CoreStart } from './types'; let container: HTMLDivElement | null; @@ -165,17 +165,11 @@ test('overlays wrapper uses the closest overlays service', () => { }; const core1 = { - overlays: { - openFlyout: jest.fn(), - openModal: jest.fn(), - }, + overlays: overlayServiceMock.createStartContract(), } as Partial; const core2 = { - overlays: { - openFlyout: jest.fn(), - openModal: jest.fn(), - }, + overlays: overlayServiceMock.createStartContract(), } as Partial; ReactDOM.render( @@ -237,10 +231,7 @@ test('overlays wrapper uses available overlays service, higher up in { const overlays = createReactOverlays({}); @@ -29,10 +30,7 @@ test('throws if no overlays service provided', () => { test('creates wrapped overlays service', () => { const overlays = createReactOverlays({ - overlays: { - openFlyout: jest.fn(), - openModal: jest.fn(), - }, + overlays: overlayServiceMock.createStartContract(), }); expect(typeof overlays.openFlyout).toBe('function'); @@ -40,20 +38,17 @@ test('creates wrapped overlays service', () => { }); test('can open flyout with React element', () => { - const openFlyout = jest.fn(); + const coreOverlays = overlayServiceMock.createStartContract(); const overlays = createReactOverlays({ - overlays: { - openFlyout, - openModal: jest.fn(), - }, + overlays: coreOverlays, }); - expect(openFlyout).toHaveBeenCalledTimes(0); + expect(coreOverlays.openFlyout).toHaveBeenCalledTimes(0); overlays.openFlyout(
foo
); - expect(openFlyout).toHaveBeenCalledTimes(1); - expect(openFlyout.mock.calls[0][0]).toMatchInlineSnapshot(` + expect(coreOverlays.openFlyout).toHaveBeenCalledTimes(1); + expect(coreOverlays.openFlyout.mock.calls[0][0]).toMatchInlineSnapshot(`
foo @@ -63,21 +58,17 @@ test('can open flyout with React element', () => { }); test('can open modal with React element', () => { - const openFlyout = jest.fn(); - const openModal = jest.fn(); + const coreOverlays = overlayServiceMock.createStartContract(); const overlays = createReactOverlays({ - overlays: { - openFlyout, - openModal, - }, + overlays: coreOverlays, }); - expect(openModal).toHaveBeenCalledTimes(0); + expect(coreOverlays.openModal).toHaveBeenCalledTimes(0); overlays.openModal(
bar
); - expect(openModal).toHaveBeenCalledTimes(1); - expect(openModal.mock.calls[0][0]).toMatchInlineSnapshot(` + expect(coreOverlays.openModal).toHaveBeenCalledTimes(1); + expect(coreOverlays.openModal.mock.calls[0][0]).toMatchInlineSnapshot(`
bar @@ -87,12 +78,9 @@ test('can open modal with React element', () => { }); test('passes through flyout options when opening flyout', () => { - const openFlyout = jest.fn(); + const coreOverlays = overlayServiceMock.createStartContract(); const overlays = createReactOverlays({ - overlays: { - openFlyout, - openModal: jest.fn(), - }, + overlays: coreOverlays, }); overlays.openFlyout(<>foo, { @@ -100,19 +88,16 @@ test('passes through flyout options when opening flyout', () => { closeButtonAriaLabel: 'bar', }); - expect(openFlyout.mock.calls[0][1]).toEqual({ + expect(coreOverlays.openFlyout.mock.calls[0][1]).toEqual({ 'data-test-subj': 'foo', closeButtonAriaLabel: 'bar', }); }); test('passes through modal options when opening modal', () => { - const openModal = jest.fn(); + const coreOverlays = overlayServiceMock.createStartContract(); const overlays = createReactOverlays({ - overlays: { - openFlyout: jest.fn(), - openModal, - }, + overlays: coreOverlays, }); overlays.openModal(<>foo, { @@ -120,7 +105,7 @@ test('passes through modal options when opening modal', () => { closeButtonAriaLabel: 'bar2', }); - expect(openModal.mock.calls[0][1]).toEqual({ + expect(coreOverlays.openModal.mock.calls[0][1]).toEqual({ 'data-test-subj': 'foo2', closeButtonAriaLabel: 'bar2', }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 96610264ce03f1..14a5db5be5eb26 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -484,8 +484,8 @@ "common.ui.management.editIndexPattern.createIndex.defaultTypeName": "インデックスパターン", "common.ui.management.nav.menu": "管理メニュー", "common.ui.modals.cancelButtonLabel": "キャンセル", - "common.ui.notify.banner.attentionTitle": "注意", - "common.ui.notify.banner.closeButtonLabel": "閉じる", + "core.ui.overlays.banner.attentionTitle": "注意", + "core.ui.overlays.banner.closeButtonLabel": "閉じる", "common.ui.notify.fatalError.errorStatusMessage": "エラー {errStatus} {errStatusText}: {errMessage}", "common.ui.notify.fatalError.unavailableServerErrorMessage": "HTTP リクエストが接続に失敗しました。Kibana サーバーが実行されていて、ご使用のブラウザの接続が正常に動作していることを確認するか、システム管理者にお問い合わせください。", "common.ui.notify.toaster.errorMessage": "エラー: {errorMessage}\n {errorStack}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3c1312ac72c3bf..e8ac311c20ff42 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -484,8 +484,8 @@ "common.ui.management.editIndexPattern.createIndex.defaultTypeName": "索引模式", "common.ui.management.nav.menu": "管理菜单", "common.ui.modals.cancelButtonLabel": "取消", - "common.ui.notify.banner.attentionTitle": "注意", - "common.ui.notify.banner.closeButtonLabel": "关闭", + "core.ui.overlays.banner.attentionTitle": "注意", + "core.ui.overlays.banner.closeButtonLabel": "关闭", "common.ui.notify.fatalError.errorStatusMessage": "错误 {errStatus} {errStatusText}:{errMessage}", "common.ui.notify.fatalError.unavailableServerErrorMessage": "HTTP 请求无法连接。请检查 Kibana 服务器是否正在运行以及您的浏览器是否具有有效的连接,或请联系您的系统管理员。", "common.ui.notify.toaster.errorMessage": "错误:{errorMessage}\n {errorStack}",