diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 8793ef6754ad..d867c8f49b6a 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -20,6 +20,7 @@ import { FatalErrorsService } from './fatal_errors'; import { InjectedMetadataService } from './injected_metadata'; import { LegacyPlatformService } from './legacy_platform'; +import { LoadingCountService } from './loading_count'; import { NotificationsService } from './notifications'; const MockLegacyPlatformService = jest.fn( @@ -65,6 +66,16 @@ jest.mock('./notifications', () => ({ NotificationsService: MockNotificationsService, })); +const mockLoadingCountContract = {}; +const MockLoadingCountService = jest.fn(function _MockNotificationsService( + this: any +) { + this.start = jest.fn().mockReturnValue(mockLoadingCountContract); +}); +jest.mock('./loading_count', () => ({ + LoadingCountService: MockLoadingCountService, +})); + import { CoreSystem } from './core_system'; jest.spyOn(CoreSystem.prototype, 'stop'); @@ -89,6 +100,7 @@ describe('constructor', () => { expect(MockLegacyPlatformService).toHaveBeenCalledTimes(1); expect(MockFatalErrorsService).toHaveBeenCalledTimes(1); expect(MockNotificationsService).toHaveBeenCalledTimes(1); + expect(MockLoadingCountService).toHaveBeenCalledTimes(1); }); it('passes injectedMetadata param to InjectedMetadataService', () => { @@ -200,6 +212,15 @@ describe('#start()', () => { expect(mockInstance.start).toHaveBeenCalledWith(); }); + it('calls loadingCount#start()', () => { + startCore(); + const [mockInstance] = MockLoadingCountService.mock.instances; + expect(mockInstance.start).toHaveBeenCalledTimes(1); + expect(mockInstance.start).toHaveBeenCalledWith({ + fatalErrors: mockFatalErrorsStartContract, + }); + }); + it('calls fatalErrors#start()', () => { startCore(); const [mockInstance] = MockFatalErrorsService.mock.instances; diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 775edd6c8325..db5500cb2ffd 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -21,6 +21,7 @@ import './core.css'; import { FatalErrorsService } from './fatal_errors'; import { InjectedMetadataParams, InjectedMetadataService } from './injected_metadata'; import { LegacyPlatformParams, LegacyPlatformService } from './legacy_platform'; +import { LoadingCountService } from './loading_count'; import { NotificationsService } from './notifications'; interface Params { @@ -41,6 +42,7 @@ export class CoreSystem { private readonly injectedMetadata: InjectedMetadataService; private readonly legacyPlatform: LegacyPlatformService; private readonly notifications: NotificationsService; + private readonly loadingCount: LoadingCountService; private readonly rootDomElement: HTMLElement; private readonly notificationsTargetDomElement: HTMLDivElement; @@ -68,6 +70,8 @@ export class CoreSystem { targetDomElement: this.notificationsTargetDomElement, }); + this.loadingCount = new LoadingCountService(); + this.legacyPlatformTargetDomElement = document.createElement('div'); this.legacyPlatform = new LegacyPlatformService({ targetDomElement: this.legacyPlatformTargetDomElement, @@ -87,7 +91,8 @@ export class CoreSystem { const notifications = this.notifications.start(); const injectedMetadata = this.injectedMetadata.start(); const fatalErrors = this.fatalErrors.start(); - this.legacyPlatform.start({ injectedMetadata, fatalErrors, notifications }); + const loadingCount = this.loadingCount.start({ fatalErrors }); + this.legacyPlatform.start({ injectedMetadata, fatalErrors, notifications, loadingCount }); } catch (error) { this.fatalErrors.add(error); } diff --git a/src/core/public/legacy_platform/legacy_platform_service.test.ts b/src/core/public/legacy_platform/legacy_platform_service.test.ts index 3a5d98140a75..c6c7924525e0 100644 --- a/src/core/public/legacy_platform/legacy_platform_service.test.ts +++ b/src/core/public/legacy_platform/legacy_platform_service.test.ts @@ -18,6 +18,7 @@ */ import angular from 'angular'; +import * as Rx from 'rxjs'; const mockLoadOrder: string[] = []; @@ -61,6 +62,14 @@ jest.mock('ui/notify/toasts', () => { }; }); +const mockLoadingCountInit = jest.fn(); +jest.mock('ui/chrome/api/loading_count', () => { + mockLoadOrder.push('ui/chrome/api/loading_count'); + return { + __newPlatformInit__: mockLoadingCountInit, + }; +}); + import { LegacyPlatformService } from './legacy_platform_service'; const fatalErrorsStartContract = {} as any; @@ -72,6 +81,11 @@ const injectedMetadataStartContract = { getLegacyMetadata: jest.fn(), }; +const loadingCountStartContract = { + add: jest.fn(), + getCount$: jest.fn().mockImplementation(() => new Rx.Observable(observer => observer.next(0))), +}; + const defaultParams = { targetDomElement: document.createElement('div'), requireLegacyFiles: jest.fn(() => { @@ -79,6 +93,13 @@ const defaultParams = { }), }; +const defaultStartDeps = { + fatalErrors: fatalErrorsStartContract, + injectedMetadata: injectedMetadataStartContract, + notifications: notificationsStartContract, + loadingCount: loadingCountStartContract, +}; + afterEach(() => { jest.clearAllMocks(); injectedMetadataStartContract.getLegacyMetadata.mockReset(); @@ -96,11 +117,7 @@ describe('#start()', () => { ...defaultParams, }); - legacyPlatform.start({ - fatalErrors: fatalErrorsStartContract, - injectedMetadata: injectedMetadataStartContract, - notifications: notificationsStartContract, - }); + legacyPlatform.start(defaultStartDeps); expect(mockUiMetadataInit).toHaveBeenCalledTimes(1); expect(mockUiMetadataInit).toHaveBeenCalledWith(legacyMetadata); @@ -111,11 +128,7 @@ describe('#start()', () => { ...defaultParams, }); - legacyPlatform.start({ - fatalErrors: fatalErrorsStartContract, - injectedMetadata: injectedMetadataStartContract, - notifications: notificationsStartContract, - }); + legacyPlatform.start(defaultStartDeps); expect(mockFatalErrorInit).toHaveBeenCalledTimes(1); expect(mockFatalErrorInit).toHaveBeenCalledWith(fatalErrorsStartContract); @@ -126,27 +139,30 @@ describe('#start()', () => { ...defaultParams, }); - legacyPlatform.start({ - fatalErrors: fatalErrorsStartContract, - injectedMetadata: injectedMetadataStartContract, - notifications: notificationsStartContract, - }); + legacyPlatform.start(defaultStartDeps); expect(mockNotifyToastsInit).toHaveBeenCalledTimes(1); expect(mockNotifyToastsInit).toHaveBeenCalledWith(notificationsStartContract.toasts); }); + it('passes loadingCount service to ui/chrome/api/loading_count', () => { + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + }); + + legacyPlatform.start(defaultStartDeps); + + expect(mockLoadingCountInit).toHaveBeenCalledTimes(1); + expect(mockLoadingCountInit).toHaveBeenCalledWith(loadingCountStartContract); + }); + describe('useLegacyTestHarness = false', () => { it('passes the targetDomElement to ui/chrome', () => { const legacyPlatform = new LegacyPlatformService({ ...defaultParams, }); - legacyPlatform.start({ - fatalErrors: fatalErrorsStartContract, - injectedMetadata: injectedMetadataStartContract, - notifications: notificationsStartContract, - }); + legacyPlatform.start(defaultStartDeps); expect(mockUiTestHarnessBootstrap).not.toHaveBeenCalled(); expect(mockUiChromeBootstrap).toHaveBeenCalledTimes(1); @@ -160,11 +176,7 @@ describe('#start()', () => { useLegacyTestHarness: true, }); - legacyPlatform.start({ - fatalErrors: fatalErrorsStartContract, - injectedMetadata: injectedMetadataStartContract, - notifications: notificationsStartContract, - }); + legacyPlatform.start(defaultStartDeps); expect(mockUiChromeBootstrap).not.toHaveBeenCalled(); expect(mockUiTestHarnessBootstrap).toHaveBeenCalledTimes(1); @@ -182,16 +194,13 @@ describe('#start()', () => { expect(mockLoadOrder).toEqual([]); - legacyPlatform.start({ - fatalErrors: fatalErrorsStartContract, - injectedMetadata: injectedMetadataStartContract, - notifications: notificationsStartContract, - }); + legacyPlatform.start(defaultStartDeps); expect(mockLoadOrder).toEqual([ 'ui/metadata', 'ui/notify/fatal_error', 'ui/notify/toasts', + 'ui/chrome/api/loading_count', 'ui/chrome', 'legacy files', ]); @@ -207,16 +216,13 @@ describe('#start()', () => { expect(mockLoadOrder).toEqual([]); - legacyPlatform.start({ - fatalErrors: fatalErrorsStartContract, - injectedMetadata: injectedMetadataStartContract, - notifications: notificationsStartContract, - }); + legacyPlatform.start(defaultStartDeps); expect(mockLoadOrder).toEqual([ 'ui/metadata', 'ui/notify/fatal_error', 'ui/notify/toasts', + 'ui/chrome/api/loading_count', 'ui/test_harness', 'legacy files', ]); diff --git a/src/core/public/legacy_platform/legacy_platform_service.ts b/src/core/public/legacy_platform/legacy_platform_service.ts index 9ecc7a1ef81f..52d2534c8b8e 100644 --- a/src/core/public/legacy_platform/legacy_platform_service.ts +++ b/src/core/public/legacy_platform/legacy_platform_service.ts @@ -20,12 +20,14 @@ import angular from 'angular'; import { FatalErrorsStartContract } from '../fatal_errors'; import { InjectedMetadataStartContract } from '../injected_metadata'; +import { LoadingCountStartContract } from '../loading_count'; import { NotificationsStartContract } from '../notifications'; interface Deps { injectedMetadata: InjectedMetadataStartContract; fatalErrors: FatalErrorsStartContract; notifications: NotificationsStartContract; + loadingCount: LoadingCountStartContract; } export interface LegacyPlatformParams { @@ -44,12 +46,13 @@ export interface LegacyPlatformParams { export class LegacyPlatformService { constructor(private readonly params: LegacyPlatformParams) {} - public start({ injectedMetadata, fatalErrors, notifications }: Deps) { + public start({ injectedMetadata, fatalErrors, notifications, loadingCount }: Deps) { // Inject parts of the new platform into parts of the legacy platform // so that legacy APIs/modules can mimic their new platform counterparts require('ui/metadata').__newPlatformInit__(injectedMetadata.getLegacyMetadata()); require('ui/notify/fatal_error').__newPlatformInit__(fatalErrors); require('ui/notify/toasts').__newPlatformInit__(notifications.toasts); + require('ui/chrome/api/loading_count').__newPlatformInit__(loadingCount); // Load the bootstrap module before loading the legacy platform files so that // the bootstrap module can modify the environment a bit first diff --git a/src/core/public/loading_count/__snapshots__/loading_count_service.test.ts.snap b/src/core/public/loading_count/__snapshots__/loading_count_service.test.ts.snap new file mode 100644 index 000000000000..d46a25062e36 --- /dev/null +++ b/src/core/public/loading_count/__snapshots__/loading_count_service.test.ts.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`adds a fatal error if count observable emits a negative number 1`] = ` +Array [ + Array [ + [Error: Observables passed to loadingCount.add() must only emit positive numbers], + ], +] +`; + +exports[`adds a fatal error if count observables emit an error 1`] = ` +Array [ + Array [ + [Error: foo bar], + ], +] +`; + +exports[`emits 0 initially, the right count when sources emit their own count, and ends with zero 1`] = ` +Array [ + 0, + 100, + 110, + 111, + 11, + 21, + 20, + 0, +] +`; + +exports[`only emits when loading count changes 1`] = ` +Array [ + 0, + 1, + 0, +] +`; diff --git a/src/core/public/loading_count/index.ts b/src/core/public/loading_count/index.ts new file mode 100644 index 000000000000..e6ab96a832bf --- /dev/null +++ b/src/core/public/loading_count/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { LoadingCountService, LoadingCountStartContract } from './loading_count_service'; diff --git a/src/core/public/loading_count/loading_count_service.test.ts b/src/core/public/loading_count/loading_count_service.test.ts new file mode 100644 index 000000000000..43d79c0f6a25 --- /dev/null +++ b/src/core/public/loading_count/loading_count_service.test.ts @@ -0,0 +1,119 @@ +/* + * 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 * as Rx from 'rxjs'; +import { toArray } from 'rxjs/operators'; + +import { LoadingCountService } from './loading_count_service'; + +function setup() { + const service = new LoadingCountService(); + const fatalErrors: any = { + add: jest.fn(), + }; + const startContract = service.start({ fatalErrors }); + + return { service, fatalErrors, startContract }; +} + +it('subscribes to sources passed to add(), unsubscribes on stop', () => { + const { service, startContract } = setup(); + + const unsubA = jest.fn(); + const subA = jest.fn().mockReturnValue(unsubA); + startContract.add(new Rx.Observable(subA)); + expect(subA).toHaveBeenCalledTimes(1); + expect(unsubA).not.toHaveBeenCalled(); + + const unsubB = jest.fn(); + const subB = jest.fn().mockReturnValue(unsubB); + startContract.add(new Rx.Observable(subB)); + expect(subB).toHaveBeenCalledTimes(1); + expect(unsubB).not.toHaveBeenCalled(); + + service.stop(); + + expect(subA).toHaveBeenCalledTimes(1); + expect(unsubA).toHaveBeenCalledTimes(1); + expect(subB).toHaveBeenCalledTimes(1); + expect(unsubB).toHaveBeenCalledTimes(1); +}); + +it('emits 0 initially, the right count when sources emit their own count, and ends with zero', async () => { + const { service, startContract } = setup(); + + const countA$ = new Rx.Subject(); + const countB$ = new Rx.Subject(); + const countC$ = new Rx.Subject(); + const promise = startContract + .getCount$() + .pipe(toArray()) + .toPromise(); + + startContract.add(countA$); + startContract.add(countB$); + startContract.add(countC$); + + countA$.next(100); + countB$.next(10); + countC$.next(1); + countA$.complete(); + countB$.next(20); + countC$.complete(); + countB$.next(0); + + service.stop(); + expect(await promise).toMatchSnapshot(); +}); + +it('only emits when loading count changes', async () => { + const { service, startContract } = setup(); + + const count$ = new Rx.Subject(); + const promise = startContract + .getCount$() + .pipe(toArray()) + .toPromise(); + + startContract.add(count$); + count$.next(0); + count$.next(0); + count$.next(0); + count$.next(0); + count$.next(0); + count$.next(1); + count$.next(1); + service.stop(); + + expect(await promise).toMatchSnapshot(); +}); + +it('adds a fatal error if count observables emit an error', async () => { + const { startContract, fatalErrors } = setup(); + + startContract.add(Rx.throwError(new Error('foo bar'))); + expect(fatalErrors.add.mock.calls).toMatchSnapshot(); +}); + +it('adds a fatal error if count observable emits a negative number', async () => { + const { startContract, fatalErrors } = setup(); + + startContract.add(Rx.of(1, 2, 3, 4, -9)); + expect(fatalErrors.add.mock.calls).toMatchSnapshot(); +}); diff --git a/src/core/public/loading_count/loading_count_service.ts b/src/core/public/loading_count/loading_count_service.ts new file mode 100644 index 000000000000..d54a84e31b1d --- /dev/null +++ b/src/core/public/loading_count/loading_count_service.ts @@ -0,0 +1,86 @@ +/* + * 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 * as Rx from 'rxjs'; +import { + distinctUntilChanged, + endWith, + map, + pairwise, + startWith, + takeUntil, + tap, +} from 'rxjs/operators'; + +import { FatalErrorsStartContract } from '../fatal_errors'; + +interface Deps { + fatalErrors: FatalErrorsStartContract; +} + +export class LoadingCountService { + private readonly total$ = new Rx.BehaviorSubject(0); + private readonly stop$ = new Rx.Subject(); + + public start({ fatalErrors }: Deps) { + return { + add: (count$: Rx.Observable) => { + count$ + .pipe( + distinctUntilChanged(), + + tap(count => { + if (count < 0) { + throw new Error( + 'Observables passed to loadingCount.add() must only emit positive numbers' + ); + } + }), + + // use takeUntil() so that we can finish each stream on stop() the same way we do when they complete, + // by removing the previous count from the total + takeUntil(this.stop$), + endWith(0), + startWith(0), + pairwise(), + map(([prev, next]) => next - prev) + ) + .subscribe({ + next: delta => { + this.total$.next(this.total$.getValue() + delta); + }, + error: error => { + fatalErrors.add(error); + }, + }); + }, + + getCount$: () => { + return this.total$.pipe(distinctUntilChanged()); + }, + }; + } + + public stop() { + this.stop$.next(); + this.total$.complete(); + } +} + +export type LoadingCountStartContract = ReturnType; diff --git a/src/ui/public/chrome/api/loading_count.js b/src/ui/public/chrome/api/loading_count.js index 9bd3d659a4df..67124bec5bd5 100644 --- a/src/ui/public/chrome/api/loading_count.js +++ b/src/ui/public/chrome/api/loading_count.js @@ -17,43 +17,43 @@ * under the License. */ +import * as Rx from 'rxjs'; + import { isSystemApiRequest } from '../../system_api'; +let newPlatformLoadingCount; + +export function __newPlatformInit__(instance) { + if (newPlatformLoadingCount) { + throw new Error('ui/chrome/api/loading_count already initialized with new platform apis'); + } + newPlatformLoadingCount = instance; +} + export function initLoadingCountApi(chrome, internals) { - const counts = { angular: 0, manual: 0 }; - const handlers = new Set(); - - function getCount() { - return counts.angular + counts.manual; - } - - // update counts and call handlers with sum if there is a change - function update(name, count) { - if (counts[name] === count) { - return; - } - - counts[name] = count; - for (const handler of handlers) { - handler(getCount()); - } - } - /** * Injected into angular module by ui/chrome angular integration * and adds a root-level watcher that will capture the count of - * active $http requests on each digest loop + * active $http requests on each digest loop and expose the count to + * the core.loadingCount api * @param {Angular.Scope} $rootScope * @param {HttpService} $http * @return {undefined} */ internals.capture$httpLoadingCount = function ($rootScope, $http) { - $rootScope.$watch(() => { - const reqs = $http.pendingRequests || []; - update('angular', reqs.filter(req => !isSystemApiRequest(req)).length); - }); + newPlatformLoadingCount.add(new Rx.Observable(observer => { + const unwatch = $rootScope.$watch(() => { + const reqs = $http.pendingRequests || []; + observer.next(reqs.filter(req => !isSystemApiRequest(req)).length); + }); + + return unwatch; + })); }; + const manualCount$ = new Rx.BehaviorSubject(0); + newPlatformLoadingCount.add(manualCount$); + chrome.loadingCount = new class ChromeLoadingCountApi { /** * Call to add a subscriber to for the loading count that @@ -63,13 +63,14 @@ export function initLoadingCountApi(chrome, internals) { * @return {Function} unsubscribe */ subscribe(handler) { - handlers.add(handler); - - // send the current count to the handler - handler(getCount()); + const subscription = newPlatformLoadingCount.getCount$().subscribe({ + next(count) { + handler(count); + } + }); return () => { - handlers.delete(handler); + subscription.unsubscribe(); }; } @@ -78,7 +79,7 @@ export function initLoadingCountApi(chrome, internals) { * @return {undefined} */ increment() { - update('manual', counts.manual + 1); + manualCount$.next(manualCount$.getValue() + 1); } /** @@ -86,7 +87,7 @@ export function initLoadingCountApi(chrome, internals) { * @return {undefined} */ decrement() { - update('manual', counts.manual - 1); + manualCount$.next(manualCount$.getValue() - 1); } }; }