MOBILE-3817 dashboard: Create observable methods for getDashboardBlocks

main
Dani Palou 2022-06-23 10:42:20 +02:00
parent 89ba05dd3e
commit 3e462979f7
5 changed files with 240 additions and 80 deletions

View File

@ -59,6 +59,7 @@ import {
} from '@services/database/sites';
import { Observable, Subject } from 'rxjs';
import { finalize, map } from 'rxjs/operators';
import { firstValueFrom } from '../utils/observables';
/**
* QR Code type enumeration.
@ -494,7 +495,7 @@ export class CoreSite {
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
read<T = unknown>(method: string, data: any, preSets?: CoreSiteWSPreSets): Promise<T> {
return this.readObservable<T>(method, data, preSets).toPromise();
return firstValueFrom(this.readObservable<T>(method, data, preSets));
}
/**
@ -525,7 +526,7 @@ export class CoreSite {
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
write<T = unknown>(method: string, data: any, preSets?: CoreSiteWSPreSets): Promise<T> {
return this.writeObservable<T>(method, data, preSets).toPromise();
return firstValueFrom(this.writeObservable<T>(method, data, preSets));
}
/**
@ -556,7 +557,7 @@ export class CoreSite {
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async request<T = unknown>(method: string, data: any, preSets: CoreSiteWSPreSets): Promise<T> {
return this.requestObservable<T>(method, data, preSets).toPromise();
return firstValueFrom(this.requestObservable<T>(method, data, preSets));
}
/**
@ -1640,7 +1641,7 @@ export class CoreSite {
// Check for an ongoing identical request if we're not ignoring cache.
if (cachePreSets.getFromCache && this.ongoingRequests[cacheId] !== undefined) {
return await this.ongoingRequests[cacheId].toPromise();
return await firstValueFrom(this.ongoingRequests[cacheId]);
}
const subject = new Subject<CoreSitePublicConfigResponse>();
@ -1698,7 +1699,7 @@ export class CoreSite {
subject.error(error);
});
return observable.toPromise();
return firstValueFrom(observable);
}
/**

View File

@ -13,12 +13,15 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites';
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { CoreCourseBlock } from '@features/course/services/course';
import { CoreStatusWithWarningsWSResponse } from '@services/ws';
import { makeSingleton } from '@singletons';
import { CoreError } from '@classes/errors/error';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { asyncObservable, firstValueFrom } from '@/core/utils/observables';
const ROOT_CACHE_KEY = 'CoreCoursesDashboard:';
@ -51,40 +54,66 @@ export class CoreCoursesDashboardProvider {
* @return Promise resolved with the list of blocks.
* @since 3.6
*/
async getDashboardBlocksFromWS(
getDashboardBlocksFromWS(
myPage = CoreCoursesDashboardProvider.MY_PAGE_DEFAULT,
userId?: number,
siteId?: string,
): Promise<CoreCourseBlock[]> {
const site = await CoreSites.getSite(siteId);
return firstValueFrom(this.getDashboardBlocksFromWSObservable({
myPage,
userId,
siteId,
}));
}
const params: CoreBlockGetDashboardBlocksWSParams = {
returncontents: true,
};
if (CoreSites.getRequiredCurrentSite().isVersionGreaterEqualThan('4.0')) {
params.mypage = myPage;
} else if (myPage != CoreCoursesDashboardProvider.MY_PAGE_DEFAULT) {
throw new CoreError('mypage param is no accessible on core_block_get_dashboard_blocks');
}
/**
* Get dashboard blocks from WS.
*
* @param options Options.
* @return Observable that returns the list of blocks.
* @since 3.6
*/
getDashboardBlocksFromWSObservable(options: GetDashboardBlocksOptions = {}): Observable<CoreCourseBlock[]> {
return asyncObservable(async () => {
const site = await CoreSites.getSite(options.siteId);
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getDashboardBlocksCacheKey(myPage, userId),
updateFrequency: CoreSite.FREQUENCY_RARELY,
};
if (userId) {
params.userid = userId;
}
const result = await site.read<CoreBlockGetDashboardBlocksWSResponse>('core_block_get_dashboard_blocks', params, preSets);
const myPage = options.myPage ?? CoreCoursesDashboardProvider.MY_PAGE_DEFAULT;
const params: CoreBlockGetDashboardBlocksWSParams = {
returncontents: true,
};
if (CoreSites.getRequiredCurrentSite().isVersionGreaterEqualThan('4.0')) {
params.mypage = myPage;
} else if (myPage != CoreCoursesDashboardProvider.MY_PAGE_DEFAULT) {
throw new CoreError('mypage param is no accessible on core_block_get_dashboard_blocks');
}
if (site.isVersionGreaterEqualThan('4.0')) {
// Temporary hack to have course overview on 3.9.5 but not on 4.0 onwards.
// To be removed in a near future.
// Remove myoverview when is forced. See MDL-72092.
result.blocks = result.blocks.filter((block) =>
block.instanceid != 0 || block.name != 'myoverview' || block.region != 'forced');
}
const preSets: CoreSiteWSPreSets = {
cacheKey: this.getDashboardBlocksCacheKey(myPage, options.userId),
updateFrequency: CoreSite.FREQUENCY_RARELY,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy),
};
if (options.userId) {
params.userid = options.userId;
}
return result.blocks || [];
const observable = site.readObservable<CoreBlockGetDashboardBlocksWSResponse>(
'core_block_get_dashboard_blocks',
params,
preSets,
);
return observable.pipe(map(result => {
if (site.isVersionGreaterEqualThan('4.0')) {
// Temporary hack to have course overview on 3.9.5 but not on 4.0 onwards.
// To be removed in a near future.
// Remove myoverview when is forced. See MDL-72092.
result.blocks = result.blocks.filter((block) =>
block.instanceid != 0 || block.name != 'myoverview' || block.region != 'forced');
}
return result.blocks || [];
}));
});
}
/**
@ -95,39 +124,52 @@ export class CoreCoursesDashboardProvider {
* @param myPage What my page to return blocks of. Default MY_PAGE_DEFAULT.
* @return Promise resolved with the list of blocks.
*/
async getDashboardBlocks(
getDashboardBlocks(
userId?: number,
siteId?: string,
myPage = CoreCoursesDashboardProvider.MY_PAGE_DEFAULT,
): Promise<CoreCoursesDashboardBlocks> {
const blocks = await this.getDashboardBlocksFromWS(myPage, userId, siteId);
return firstValueFrom(this.getDashboardBlocksObservable({
myPage,
userId,
siteId,
}));
}
let mainBlocks: CoreCourseBlock[] = [];
let sideBlocks: CoreCourseBlock[] = [];
blocks.forEach((block) => {
if (block.region == 'content' || block.region == 'main') {
mainBlocks.push(block);
} else {
sideBlocks.push(block);
}
});
if (mainBlocks.length == 0) {
mainBlocks = [];
sideBlocks = [];
/**
* Get dashboard blocks.
*
* @param options Options.
* @return observable that returns the list of blocks.
*/
getDashboardBlocksObservable(options: GetDashboardBlocksOptions = {}): Observable<CoreCoursesDashboardBlocks> {
return this.getDashboardBlocksFromWSObservable(options).pipe(map(blocks => {
let mainBlocks: CoreCourseBlock[] = [];
let sideBlocks: CoreCourseBlock[] = [];
blocks.forEach((block) => {
if (block.region.match('side')) {
sideBlocks.push(block);
} else {
if (block.region == 'content' || block.region == 'main') {
mainBlocks.push(block);
} else {
sideBlocks.push(block);
}
});
}
return { mainBlocks, sideBlocks };
if (mainBlocks.length == 0) {
mainBlocks = [];
sideBlocks = [];
blocks.forEach((block) => {
if (block.region.match('side')) {
sideBlocks.push(block);
} else {
mainBlocks.push(block);
}
});
}
return { mainBlocks, sideBlocks };
}));
}
/**
@ -194,6 +236,14 @@ export type CoreCoursesDashboardBlocks = {
sideBlocks: CoreCourseBlock[];
};
/**
* Options for some get dashboard blocks calls.
*/
export type GetDashboardBlocksOptions = CoreSitesCommonWSOptions & {
userId?: number; // User ID. If not defined, current user.
myPage?: string; // Page to get. If not defined, CoreCoursesDashboardProvider.MY_PAGE_DEFAULT.
};
/**
* Params of core_block_get_dashboard_blocks WS.
*/

View File

@ -13,6 +13,7 @@
// limitations under the License.
import { EventEmitter } from '@angular/core';
import { CoreUtils } from '@services/utils/utils';
import { Observable, Subscription } from 'rxjs';
/**
@ -31,37 +32,46 @@ export class CoreSubscriptions {
* @param subscribable Subscribable to listen to.
* @param onSuccess Callback to run when the subscription is updated.
* @param onError Callback to run when the an error happens.
* @param onComplete Callback to run when the observable completes.
* @return A function to unsubscribe.
*/
static once<T>(
subscribable: Subscribable<T>,
onSuccess: (value: T) => unknown,
onError?: (error: unknown) => unknown,
onComplete?: () => void,
): () => void {
let unsubscribe = false;
let callbackCalled = false;
let subscription: Subscription | null = null;
const runCallback = (callback) => {
if (!callbackCalled) {
callbackCalled = true;
callback();
}
};
const unsubscribe = async () => {
// Subscription variable might not be set because we can receive a value immediately. Wait for next tick.
await CoreUtils.nextTick();
subscription?.unsubscribe();
};
subscription = subscribable.subscribe(
value => {
// Subscription variable might not be set because we can receive a value immediately.
unsubscribe = true;
subscription?.unsubscribe();
onSuccess(value);
unsubscribe();
runCallback(() => onSuccess(value));
},
error => {
// Subscription variable might not be set because we can receive a value immediately.
unsubscribe = true;
subscription?.unsubscribe();
onError && onError(error);
unsubscribe();
runCallback(() => onError?.(error));
},
() => {
unsubscribe();
runCallback(() => onComplete?.());
},
);
if (unsubscribe) {
subscription.unsubscribe();
}
return () => subscription?.unsubscribe();
}

View File

@ -17,12 +17,20 @@ import { BehaviorSubject, Subject } from 'rxjs';
describe('CoreSubscriptions singleton', () => {
it('calls callbacks only once', async () => {
// Test call success function.
let subject = new Subject();
let success = jest.fn();
let error = jest.fn();
CoreSubscriptions.once(subject, success, error);
let subject: Subject<unknown>;
let success: jest.Mock;
let error: jest.Mock;
let complete: jest.Mock;
beforeEach(() => {
subject = new Subject();
success = jest.fn();
error = jest.fn();
complete = jest.fn();
});
it('calls success callback only once', async () => {
CoreSubscriptions.once(subject, success, error, complete);
subject.next('foo');
expect(success).toHaveBeenCalledTimes(1);
@ -32,11 +40,11 @@ describe('CoreSubscriptions singleton', () => {
subject.error('foo');
expect(success).toHaveBeenCalledTimes(1);
expect(error).not.toHaveBeenCalled();
expect(complete).not.toHaveBeenCalled();
});
// Test call error function.
subject = new Subject(); // Create a new Subject because the previous one already has an error.
success = jest.fn();
CoreSubscriptions.once(subject, success, error);
it('calls error callback only once', async () => {
CoreSubscriptions.once(subject, success, error, complete);
subject.error('foo');
expect(error).toHaveBeenCalledWith('foo');
@ -45,11 +53,27 @@ describe('CoreSubscriptions singleton', () => {
subject.error('bar');
expect(error).toHaveBeenCalledTimes(1);
expect(success).not.toHaveBeenCalled();
expect(complete).not.toHaveBeenCalled();
});
it('calls complete callback only once', async () => {
CoreSubscriptions.once(subject, success, error, complete);
subject.complete();
expect(complete).toHaveBeenCalled();
subject.next('foo');
subject.error('bar');
subject.complete();
expect(complete).toHaveBeenCalledTimes(1);
expect(success).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});
it('calls success callback only once with behaviour subject', async () => {
// Test with behaviour subject (success callback called immediately).
const beaviourSubject = new BehaviorSubject('foo');
error = jest.fn();
CoreSubscriptions.once(beaviourSubject, success, error);
CoreSubscriptions.once(beaviourSubject, success, error, complete);
expect(success).toHaveBeenCalledWith('foo');
@ -57,6 +81,7 @@ describe('CoreSubscriptions singleton', () => {
beaviourSubject.error('foo');
expect(success).toHaveBeenCalledTimes(1);
expect(error).not.toHaveBeenCalled();
expect(complete).not.toHaveBeenCalled();
});
it('allows unsubscribing from outside the once function', async () => {

View File

@ -0,0 +1,74 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed 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 { CoreError } from '@classes/errors/error';
import { CoreSubscriptions } from '@singletons/subscriptions';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
/**
* Convert to an Observable a Promise that resolves to an Observable.
*
* @param createObservable A function returning a promise that resolves to an Observable.
* @returns Observable.
*/
export function asyncObservable<T>(createObservable: () => Promise<Observable<T>>): Observable<T> {
const promise = createObservable();
return new Observable(subscriber => {
promise
.then(observable => observable.subscribe(
value => subscriber.next(value),
error => subscriber.error(error),
() => subscriber.complete(),
))
.catch(error => subscriber.error(error));
});
}
/**
* Create a Promise that resolved with the first value returned from an observable.
* This function can be removed when the app starts using rxjs v7.
*
* @param observable Observable.
* @returns Promise resolved with the first value returned.
*/
export function firstValueFrom<T>(observable: Observable<T>): Promise<T> {
return new Promise((resolve, reject) => {
CoreSubscriptions.once(observable, resolve, reject, () => {
// Subscription is completed, check if we can get its value.
if (observable instanceof BehaviorSubject) {
resolve(observable.getValue());
}
reject(new CoreError('Couldn\'t get first value from observable because it\'s already completed'));
});
});
}
/**
* Ignore errors from an observable, returning a certain value instead.
*
* @param observable Observable to ignore errors.
* @param fallback Value to return if the observer errors.
* @return Observable with ignored errors, returning the fallback result if provided.
*/
export function ignoreErrors<Result>(observable: Observable<Result>): Observable<Result | undefined>;
export function ignoreErrors<Result, Fallback>(observable: Observable<Result>, fallback: Fallback): Observable<Result | Fallback>;
export function ignoreErrors<Result, Fallback>(
observable: Observable<Result>,
fallback?: Fallback,
): Observable<Result | Fallback | undefined> {
return observable.pipe(catchError(() => of(fallback)));
}