diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 0fbc8c42d..894d27868 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -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(method: string, data: any, preSets?: CoreSiteWSPreSets): Promise { - return this.readObservable(method, data, preSets).toPromise(); + return firstValueFrom(this.readObservable(method, data, preSets)); } /** @@ -525,7 +526,7 @@ export class CoreSite { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any write(method: string, data: any, preSets?: CoreSiteWSPreSets): Promise { - return this.writeObservable(method, data, preSets).toPromise(); + return firstValueFrom(this.writeObservable(method, data, preSets)); } /** @@ -556,7 +557,7 @@ export class CoreSite { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async request(method: string, data: any, preSets: CoreSiteWSPreSets): Promise { - return this.requestObservable(method, data, preSets).toPromise(); + return firstValueFrom(this.requestObservable(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(); @@ -1698,7 +1699,7 @@ export class CoreSite { subject.error(error); }); - return observable.toPromise(); + return firstValueFrom(observable); } /** diff --git a/src/core/features/courses/services/dashboard.ts b/src/core/features/courses/services/dashboard.ts index 6e687c0e1..982c1a5cd 100644 --- a/src/core/features/courses/services/dashboard.ts +++ b/src/core/features/courses/services/dashboard.ts @@ -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 { - 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 { + 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('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( + '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 { - 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 { + 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. */ diff --git a/src/core/singletons/subscriptions.ts b/src/core/singletons/subscriptions.ts index 88e9dc1dd..f5c23b795 100644 --- a/src/core/singletons/subscriptions.ts +++ b/src/core/singletons/subscriptions.ts @@ -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( subscribable: Subscribable, 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(); } diff --git a/src/core/singletons/tests/subscriptions.test.ts b/src/core/singletons/tests/subscriptions.test.ts index 5ea863a8a..0ab324d89 100644 --- a/src/core/singletons/tests/subscriptions.test.ts +++ b/src/core/singletons/tests/subscriptions.test.ts @@ -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; + 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 () => { diff --git a/src/core/utils/observables.ts b/src/core/utils/observables.ts new file mode 100644 index 000000000..718fc884c --- /dev/null +++ b/src/core/utils/observables.ts @@ -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(createObservable: () => Promise>): Observable { + 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(observable: Observable): Promise { + 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(observable: Observable): Observable; +export function ignoreErrors(observable: Observable, fallback: Fallback): Observable; +export function ignoreErrors( + observable: Observable, + fallback?: Fallback, +): Observable { + return observable.pipe(catchError(() => of(fallback))); +}