[new-platform] migrate ui/chrome/loading_count API to new platform (#21967)

Part of #20696, required for #20697

This migrates the `chrome.loadingCount` API to the new platform, which was not planned to happen before #20697 but is required as the UiSettingsClient uses the loading count to activate the global loading indicator in Kibana. This service is pretty simple, it allows adding an observable with `core.loadingCount.add(observable)` that will be subscribed to in order to contribute to the current "loading count", which can be retrieved with `core.loadingCount.get$()`.

The `ui/chrome/api/loading_count` module is taking the start contract from the service and re-exposing it via the existing `chrome.loadingCount` api that we have today, including `increment()`, `decrement()`, `subscribe()`, and the automatic watching of the loading count exposed by the angular `$http` service.
This commit is contained in:
Spencer 2018-08-17 11:25:42 -07:00 committed by GitHub
parent 34b140b2ba
commit 191ea1ffd8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 367 additions and 68 deletions

View file

@ -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<LegacyPlatformService>(
@ -65,6 +66,16 @@ jest.mock('./notifications', () => ({
NotificationsService: MockNotificationsService,
}));
const mockLoadingCountContract = {};
const MockLoadingCountService = jest.fn<LoadingCountService>(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;

View file

@ -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);
}

View file

@ -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',
]);

View file

@ -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

View file

@ -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,
]
`;

View file

@ -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';

View file

@ -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<number>();
const countB$ = new Rx.Subject<number>();
const countC$ = new Rx.Subject<number>();
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<number>();
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();
});

View file

@ -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<number>) => {
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<LoadingCountService['start']>;

View file

@ -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);
}
};
}