From fbe46ee89530d388b488432f08944d6e25416b31 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 1 Jul 2022 14:48:55 +0200 Subject: [PATCH] MOBILE-3817 courses: Implement getUserCoursesWithOptionsObservable --- .../services/coursecompletion.ts | 69 ++- src/core/classes/site.ts | 68 ++- .../courses/services/courses-helper.ts | 203 ++++++--- src/core/features/courses/services/courses.ts | 405 +++++++++++------- 4 files changed, 518 insertions(+), 227 deletions(-) diff --git a/src/addons/coursecompletion/services/coursecompletion.ts b/src/addons/coursecompletion/services/coursecompletion.ts index 0fa9d22ec..162747136 100644 --- a/src/addons/coursecompletion/services/coursecompletion.ts +++ b/src/addons/coursecompletion/services/coursecompletion.ts @@ -14,13 +14,16 @@ import { Injectable } from '@angular/core'; import { CoreLogger } from '@singletons/logger'; -import { CoreSites } from '@services/sites'; +import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { CoreCourses } from '@features/courses/services/courses'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws'; import { makeSingleton } from '@singletons'; import { CoreError } from '@classes/errors/error'; +import { asyncObservable, firstValueFrom } from '@/core/utils/observables'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; const ROOT_CACHE_KEY = 'mmaCourseCompletion:'; @@ -93,33 +96,55 @@ export class AddonCourseCompletionProvider { * @param siteId Site ID. If not defined, use current site. * @return Promise to be resolved when the completion is retrieved. */ - async getCompletion( + getCompletion( courseId: number, userId?: number, preSets: CoreSiteWSPreSets = {}, siteId?: string, ): Promise { + return firstValueFrom(this.getCompletionObservable(courseId, { + userId, + preSets, + siteId, + })); + } - const site = await CoreSites.getSite(siteId); - userId = userId || site.getUserId(); - this.logger.debug('Get completion for course ' + courseId + ' and user ' + userId); + /** + * Get course completion status for a certain course and user. + * + * @param courseId Course ID. + * @param options Options. + * @return Observable returning the completion. + */ + getCompletionObservable( + courseId: number, + options: AddonCourseCompletionGetCompletionOptions = {}, + ): Observable { + return asyncObservable(async () => { + const site = await CoreSites.getSite(options.siteId); - const data: AddonCourseCompletionGetCourseCompletionStatusWSParams = { - courseid: courseId, - userid: userId, - }; + const userId = options.userId || site.getUserId(); + this.logger.debug('Get completion for course ' + courseId + ' and user ' + userId); - preSets.cacheKey = this.getCompletionCacheKey(courseId, userId); - preSets.updateFrequency = preSets.updateFrequency || CoreSite.FREQUENCY_SOMETIMES; - preSets.cacheErrors = ['notenroled']; + const data: AddonCourseCompletionGetCourseCompletionStatusWSParams = { + courseid: courseId, + userid: userId, + }; - const result: AddonCourseCompletionGetCourseCompletionStatusWSResponse = - await site.read('core_completion_get_course_completion_status', data, preSets); - if (result.completionstatus) { - return result.completionstatus; - } + const preSets = { + ...(options.preSets ?? {}), + cacheKey: this.getCompletionCacheKey(courseId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + cacheErrors: ['notenroled'], + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), + }; - throw new CoreError('Cannot fetch course completion status'); + return site.readObservable( + 'core_completion_get_course_completion_status', + data, + preSets, + ).pipe(map(result => result.completionstatus)); + }); } /** @@ -312,3 +337,11 @@ export type AddonCourseCompletionGetCourseCompletionStatusWSResponse = { export type AddonCourseCompletionMarkCourseSelfCompletedWSParams = { courseid: number; // Course ID. }; + +/** + * Options for getCompletionObservable. + */ +export type AddonCourseCompletionGetCompletionOptions = CoreSitesCommonWSOptions & { + userId?: number; // Id of the user, default to current user. + preSets?: CoreSiteWSPreSets; // Presets to use when calling the WebService. +}; diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 894d27868..a18a554f9 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -57,8 +57,8 @@ import { WSGroups, WS_CACHE_TABLES_PREFIX, } from '@services/database/sites'; -import { Observable, Subject } from 'rxjs'; -import { finalize, map } from 'rxjs/operators'; +import { Observable, ObservableInput, ObservedValueOf, OperatorFunction, Subject } from 'rxjs'; +import { finalize, map, mergeMap } from 'rxjs/operators'; import { firstValueFrom } from '../utils/observables'; /** @@ -2379,6 +2379,70 @@ export class CoreSite { } +/** + * Operator to chain requests when using observables. + * + * @param readingStrategy Reading strategy used for the current request. + * @param callback Callback called with the result of current request and the reading strategy to use in next requests. + * @return Operator. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function chainRequests>( + readingStrategy: CoreSitesReadingStrategy | undefined, + callback: (data: T, readingStrategy?: CoreSitesReadingStrategy) => O, +): OperatorFunction> { + return (source: Observable) => new Observable<{ data: T; readingStrategy?: CoreSitesReadingStrategy }>(subscriber => { + let firstValue = true; + let isCompleted = false; + + return source.subscribe({ + next: async (value) => { + if (readingStrategy !== CoreSitesReadingStrategy.UPDATE_IN_BACKGROUND) { + // Just use same strategy. + subscriber.next({ data: value, readingStrategy }); + + return; + } + + if (!firstValue) { + // Second (last) value. Chained requests should have used cached data already, just return 1 value now. + subscriber.next({ + data: value, + }); + + return; + } + + firstValue = false; + + // Wait to see if the observable is completed (no more values). + await CoreUtils.nextTick(); + + if (isCompleted) { + // Current request only returns cached data. Let chained requests update in background. + subscriber.next({ data: value, readingStrategy }); + } else { + // Current request will update in background. Prefer cached data in the chained requests. + subscriber.next({ + data: value, + readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE, + }); + } + }, + error: (error) => subscriber.error(error), + complete: async () => { + isCompleted = true; + + await CoreUtils.nextTick(); + + subscriber.complete(); + }, + }); + }).pipe( + mergeMap(({ data, readingStrategy }) => callback(data, readingStrategy)), + ); +} + /** * PreSets accepted by the WS call. */ diff --git a/src/core/features/courses/services/courses-helper.ts b/src/core/features/courses/services/courses-helper.ts index 7b19a6945..546a38f3d 100644 --- a/src/core/features/courses/services/courses-helper.ts +++ b/src/core/features/courses/services/courses-helper.ts @@ -26,6 +26,10 @@ import { makeSingleton, Translate } from '@singletons'; import { CoreWSExternalFile } from '@services/ws'; import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion'; import moment from 'moment-timezone'; +import { Observable, of } from 'rxjs'; +import { firstValueFrom, zipIncudingComplete } from '@/core/utils/observables'; +import { catchError, map } from 'rxjs/operators'; +import { chainRequests } from '@classes/site'; /** * Helper to gather some common courses functions. @@ -111,29 +115,47 @@ export class CoreCoursesHelperProvider { * @param loadCategoryNames Whether load category names or not. * @return Promise resolved when done. */ - async loadCoursesExtraInfo(courses: CoreEnrolledCourseDataWithExtraInfo[], loadCategoryNames: boolean = false): Promise { - if (!courses.length ) { - // No courses or cannot get the data, stop. - return; + loadCoursesExtraInfo( + courses: CoreEnrolledCourseDataWithExtraInfo[], + loadCategoryNames: boolean = false, + ): Promise { + return firstValueFrom(this.loadCoursesExtraInfoObservable(courses, loadCategoryNames)); + } + + /** + * Given a list of courses returned by core_enrol_get_users_courses, load some extra data using the WebService + * core_course_get_courses_by_field if available. + * + * @param courses List of courses. + * @param loadCategoryNames Whether load category names or not. + * @return Promise resolved when done. + */ + loadCoursesExtraInfoObservable( + courses: CoreEnrolledCourseDataWithExtraInfo[], + loadCategoryNames: boolean = false, + options: CoreSitesCommonWSOptions = {}, + ): Observable { + if (!courses.length) { + return of([]); } - let coursesInfo = {}; - let courseInfoAvailable = false; - - if (loadCategoryNames || (courses[0].overviewfiles === undefined && courses[0].displayname === undefined)) { - const courseIds = courses.map((course) => course.id).join(','); - - courseInfoAvailable = true; - - // Get the extra data for the courses. - const coursesInfosArray = await CoreCourses.getCoursesByField('ids', courseIds); - - coursesInfo = CoreUtils.arrayToObject(coursesInfosArray, 'id'); + if (!loadCategoryNames && (courses[0].overviewfiles !== undefined || courses[0].displayname !== undefined)) { + // No need to load more data. + return of(courses); } - courses.forEach((course) => { - this.loadCourseExtraInfo(course, courseInfoAvailable ? coursesInfo[course.id] : course, loadCategoryNames); - }); + const courseIds = courses.map((course) => course.id).join(','); + + // Get the extra data for the courses. + return CoreCourses.getCoursesByFieldObservable('ids', courseIds, options).pipe(map(coursesInfosArray => { + const coursesInfo = CoreUtils.arrayToObject(coursesInfosArray, 'id'); + + courses.forEach((course) => { + this.loadCourseExtraInfo(course, coursesInfo[course.id], loadCategoryNames); + }); + + return courses; + })); } /** @@ -196,43 +218,76 @@ export class CoreCoursesHelperProvider { * @param slice Slice results to get the X first one. If slice > 0 it will be done after sorting. * @param filter Filter using some field. * @param loadCategoryNames Whether load category names or not. + * @param options Options. * @return Courses filled with options. */ - async getUserCoursesWithOptions( + getUserCoursesWithOptions( sort: string = 'fullname', slice: number = 0, filter?: string, loadCategoryNames: boolean = false, options: CoreSitesCommonWSOptions = {}, ): Promise { - - let courses: CoreEnrolledCourseDataWithOptions[] = await CoreCourses.getUserCourses( - false, - options.siteId, - options.readingStrategy, - ); - if (courses.length <= 0) { - return []; - } - - const promises: Promise[] = []; - const courseIds = courses.map((course) => course.id); - - // Load course options of the course. - promises.push(CoreCourses.getCoursesAdminAndNavOptions(courseIds, options.siteId).then((options) => { - courses.forEach((course) => { - course.navOptions = options.navOptions[course.id]; - course.admOptions = options.admOptions[course.id]; - }); - - return; + return firstValueFrom(this.getUserCoursesWithOptionsObservable({ + sort, + slice, + filter, + loadCategoryNames, + ...options, })); + } - promises.push(this.loadCoursesExtraInfo(courses, loadCategoryNames)); + /** + * Get user courses with admin and nav options. + * + * @param options Options. + * @return Courses filled with options. + */ + getUserCoursesWithOptionsObservable( + options: CoreCoursesGetWithOptionsOptions = {}, + ): Observable { + return CoreCourses.getUserCoursesObservable(options).pipe( + chainRequests(options.readingStrategy, (courses, newReadingStrategy) => { + if (courses.length <= 0) { + return of([]); + } - await Promise.all(promises); + const courseIds = courses.map((course) => course.id); // Use all courses to get options, to use cache. + const newOptions = { + ...options, + readingStrategy: newReadingStrategy, + }; + courses = this.filterAndSortCoursesWithOptions(courses, options); - switch (filter) { + return zipIncudingComplete( + this.loadCoursesExtraInfoObservable(courses, options.loadCategoryNames, newOptions), + CoreCourses.getCoursesAdminAndNavOptionsObservable(courseIds, newOptions).pipe(map(courseOptions => { + courses.forEach((course: CoreEnrolledCourseDataWithOptions) => { + course.navOptions = courseOptions.navOptions[course.id]; + course.admOptions = courseOptions.admOptions[course.id]; + }); + })), + ...courses.map(course => this.loadCourseCompletedStatus(course, newOptions)), + ).pipe(map(() => courses)); + }), + ); + } + + /** + * Filter and sort some courses. + * + * @param courses Courses. + * @param options Options + * @return Courses filtered and sorted. + */ + protected filterAndSortCoursesWithOptions( + courses: CoreEnrolledCourseData[], + options: CoreCoursesGetWithOptionsOptions = {}, + ): CoreEnrolledCourseData[] { + const sort = options.sort ?? 'fullname'; + const slice = options.slice ?? -1; + + switch (options.filter) { case 'isfavourite': courses = courses.filter((course) => !!course.isfavourite); break; @@ -270,28 +325,42 @@ export class CoreCoursesHelperProvider { courses = slice > 0 ? courses.slice(0, slice) : courses; - return Promise.all(courses.map(async (course) => { - if (course.completed !== undefined) { - // The WebService already returns the completed status, no need to fetch it. + return courses; + } + + /** + * Given a course object, fetch and set its completed status if not present already. + * + * @param course Course. + * @return Observable. + */ + protected loadCourseCompletedStatus( + course: CoreEnrolledCourseDataWithExtraInfo, + options: CoreSitesCommonWSOptions = {}, + ): Observable { + if (course.completed !== undefined) { + // The WebService already returns the completed status, no need to fetch it. + return of(course); + } + + if (course.enablecompletion !== undefined && !course.enablecompletion) { + // Completion is disabled for this course, there is no need to fetch the completion status. + return of(course); + } + + return AddonCourseCompletion.getCompletionObservable(course.id, options).pipe( + map(completion => { + course.completed = completion.completed; + return course; - } - - if (course.enablecompletion !== undefined && !course.enablecompletion) { - // Completion is disabled for this course, there is no need to fetch the completion status. - return course; - } - - try { - const completion = await AddonCourseCompletion.getCompletion(course.id, undefined, undefined, options.siteId); - - course.completed = completion?.completed; - } catch { + }), + catchError(() => { // Ignore error, maybe course completion is disabled or user has no permission. course.completed = false; - } - return course; - })); + return of(course); + }), + ); } /** @@ -402,3 +471,13 @@ export type CoreCourseSearchedDataWithExtraInfoAndOptions = CoreCourseWithImageA export type CoreCourseAnyCourseDataWithExtraInfoAndOptions = CoreCourseWithImageAndColor & CoreCourseAnyCourseDataWithOptions & { categoryname?: string; // Category name, }; + +/** + * Options for getUserCoursesWithOptionsObservable. + */ +export type CoreCoursesGetWithOptionsOptions = CoreSitesCommonWSOptions & { + sort?: string; // Sort courses after get them. Defaults to 'fullname'. + slice?: number; // Slice results to get the X first one. If slice > 0 it will be done after sorting. + filter?: string; // Filter using some field. + loadCategoryNames?: boolean; // Whether load category names or not. +}; diff --git a/src/core/features/courses/services/courses.ts b/src/core/features/courses/services/courses.ts index 240e882cd..0f2d8c4a4 100644 --- a/src/core/features/courses/services/courses.ts +++ b/src/core/features/courses/services/courses.ts @@ -20,7 +20,9 @@ import { CoreStatusWithWarningsWSResponse, CoreWarningsWSResponse, CoreWSExterna import { CoreEvents } from '@singletons/events'; import { CoreWSError } from '@classes/errors/wserror'; import { CoreCourseAnyCourseDataWithExtraInfoAndOptions, CoreCourseWithImageAndColor } from './courses-helper'; -import { CoreUtils } from '@services/utils/utils'; +import { asyncObservable, firstValueFrom, ignoreErrors, zipIncudingComplete } from '@/core/utils/observables'; +import { of, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; const ROOT_CACHE_KEY = 'mmCourses:'; @@ -63,8 +65,7 @@ export class CoreCoursesProvider { static readonly STATE_HIDDEN = 'hidden'; static readonly STATE_FAVOURITE = 'favourite'; - protected userCoursesIds: { [id: number]: boolean } = {}; // Use an object to make it faster to search. - + protected userCoursesIds?: Set; protected downloadOptionsEnabled = false; /** @@ -484,60 +485,91 @@ export class CoreCoursesProvider { * @param siteId Site ID. If not defined, use current site. * @return Promise resolved with the courses. */ - async getCoursesByField( + getCoursesByField( field: string = '', value: string | number = '', siteId?: string, ): Promise { - siteId = siteId || CoreSites.getCurrentSiteId(); + return firstValueFrom(this.getCoursesByFieldObservable(field, value, { siteId })); + } - const originalValue = value; + /** + * Get courses. They can be filtered by field. + * + * @param field The field to search. Can be left empty for all courses or: + * id: course id. + * ids: comma separated course ids. + * shortname: course short name. + * idnumber: course id number. + * category: category id the course belongs to. + * @param value The value to match. + * @param options Other options. + * @return Observable that returns the courses. + */ + getCoursesByFieldObservable( + field: string = '', + value: string | number = '', + options: CoreSitesCommonWSOptions = {}, + ): Observable { + return asyncObservable(async () => { + const siteId = options.siteId || CoreSites.getCurrentSiteId(); + const originalValue = value; - const site = await CoreSites.getSite(siteId); + const site = await CoreSites.getSite(siteId); - const fieldParams = await this.fixCoursesByFieldParams(field, value, siteId); + // Fix params. Tries to use cached data, no need to use observer. + const fieldParams = await this.fixCoursesByFieldParams(field, value, siteId); - const hasChanged = fieldParams.field != field || fieldParams.value != value; - field = fieldParams.field; - value = fieldParams.value; - const data: CoreCourseGetCoursesByFieldWSParams = { - field: field, - value: field ? value : '', - }; - const preSets: CoreSiteWSPreSets = { - cacheKey: this.getCoursesByFieldCacheKey(field, value), - updateFrequency: CoreSite.FREQUENCY_RARELY, - }; + const hasChanged = fieldParams.field != field || fieldParams.value != value; + field = fieldParams.field; + value = fieldParams.value; + const data: CoreCourseGetCoursesByFieldWSParams = { + field: field, + value: field ? value : '', + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCoursesByFieldCacheKey(field, value), + updateFrequency: CoreSite.FREQUENCY_RARELY, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), + }; - const response = await site.read('core_course_get_courses_by_field', data, preSets); - if (!response.courses) { - throw Error('WS core_course_get_courses_by_field failed'); - } + const observable = site.readObservable( + 'core_course_get_courses_by_field', + data, + preSets, + ); - if (field == 'ids' && hasChanged) { - // The list of courses requestes was changed to optimize it. - // Return only the ones that were being requested. - const courseIds = String(originalValue).split(',').map((id) => parseInt(id, 10)); + return observable.pipe(map(response => { + if (!response.courses) { + throw Error('WS core_course_get_courses_by_field failed'); + } - // Only courses from the original selection. - response.courses = response.courses.filter((course) => courseIds.indexOf(course.id) >= 0); - } + if (field == 'ids' && hasChanged) { + // The list of courses requestes was changed to optimize it. + // Return only the ones that were being requested. + const courseIds = String(originalValue).split(',').map((id) => parseInt(id, 10)); - // Courses will be sorted using sortorder if available. - return response.courses.sort((a, b) => { - if (a.sortorder === undefined && b.sortorder === undefined) { - return b.id - a.id; - } + // Only courses from the original selection. + response.courses = response.courses.filter((course) => courseIds.indexOf(course.id) >= 0); + } - if (a.sortorder === undefined) { - return 1; - } + // Courses will be sorted using sortorder if available. + return response.courses.sort((a, b) => { + if (a.sortorder === undefined && b.sortorder === undefined) { + return b.id - a.id; + } - if (b.sortorder === undefined) { - return -1; - } + if (a.sortorder === undefined) { + return 1; + } - return a.sortorder - b.sortorder; + if (b.sortorder === undefined) { + return -1; + } + + return a.sortorder - b.sortorder; + }); + })); }); } @@ -614,25 +646,45 @@ export class CoreCoursesProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the options for each course. */ - async getCoursesAdminAndNavOptions( + getCoursesAdminAndNavOptions( courseIds: number[], siteId?: string, ): Promise<{ navOptions: CoreCourseUserAdminOrNavOptionCourseIndexed; admOptions: CoreCourseUserAdminOrNavOptionCourseIndexed; }> { - siteId = siteId || CoreSites.getCurrentSiteId(); + return firstValueFrom(this.getCoursesAdminAndNavOptionsObservable(courseIds, { siteId })); + } - // Get the list of courseIds to use based on the param. - courseIds = await this.getCourseIdsForAdminAndNavOptions(courseIds, siteId); + /** + * Get the navigation and administration options for the given courses. + * + * @param courseIds IDs of courses to get. + * @param options Options. + * @return Observable that returns the options for each course. + */ + getCoursesAdminAndNavOptionsObservable( + courseIds: number[], + options: CoreSitesCommonWSOptions = {}, + ): Observable<{ + navOptions: CoreCourseUserAdminOrNavOptionCourseIndexed; + admOptions: CoreCourseUserAdminOrNavOptionCourseIndexed; + }> { - // Get user navigation and administration options. - const [navOptions, admOptions] = await Promise.all([ - CoreUtils.ignoreErrors(this.getUserNavigationOptions(courseIds, siteId), {}), - CoreUtils.ignoreErrors(this.getUserAdministrationOptions(courseIds, siteId), {}), - ]); + return asyncObservable(async () => { + const siteId = options.siteId || CoreSites.getCurrentSiteId(); - return { navOptions: navOptions, admOptions: admOptions }; + // Get the list of courseIds to use based on the param. Tries to use cached data, no need to use observer. + courseIds = await this.getCourseIdsForAdminAndNavOptions(courseIds, siteId); + + // Get user navigation and administration options. + return zipIncudingComplete( + ignoreErrors(this.getUserNavigationOptionsObservable(courseIds, options), {}), + ignoreErrors(this.getUserAdministrationOptionsObservable(courseIds, options), {}), + ).pipe( + map(([navOptions, admOptions]) => ({ navOptions, admOptions })), + ); + }); } /** @@ -695,26 +747,46 @@ export class CoreCoursesProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved with administration options for each course. */ - async getUserAdministrationOptions(courseIds: number[], siteId?: string): Promise { + getUserAdministrationOptions(courseIds: number[], siteId?: string): Promise { + return firstValueFrom(this.getUserAdministrationOptionsObservable(courseIds, { siteId })); + } + + /** + * Get user administration options for a set of courses. + * + * @param courseIds IDs of courses to get. + * @param options Options. + * @return Observable that returns administration options for each course. + */ + getUserAdministrationOptionsObservable( + courseIds: number[], + options: CoreSitesCommonWSOptions = {}, + ): Observable { if (!courseIds || courseIds.length == 0) { - return {}; + return of({}); } - const site = await CoreSites.getSite(siteId); + return asyncObservable(async () => { + const site = await CoreSites.getSite(options.siteId); - const params: CoreCourseGetUserAdminOrNavOptionsWSParams = { - courseids: courseIds, - }; - const preSets: CoreSiteWSPreSets = { - cacheKey: this.getUserAdministrationOptionsCacheKey(courseIds), - updateFrequency: CoreSite.FREQUENCY_RARELY, - }; + const params: CoreCourseGetUserAdminOrNavOptionsWSParams = { + courseids: courseIds, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserAdministrationOptionsCacheKey(courseIds), + updateFrequency: CoreSite.FREQUENCY_RARELY, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), + }; - const response: CoreCourseGetUserAdminOrNavOptionsWSResponse = - await site.read('core_course_get_user_administration_options', params, preSets); + const observable = site.readObservable( + 'core_course_get_user_administration_options', + params, + preSets, + ); - // Format returned data. - return this.formatUserAdminOrNavOptions(response.courses); + // Format returned data. + return observable.pipe(map(response => this.formatUserAdminOrNavOptions(response.courses))); + }); } /** @@ -743,25 +815,45 @@ export class CoreCoursesProvider { * @return Promise resolved with navigation options for each course. */ async getUserNavigationOptions(courseIds: number[], siteId?: string): Promise { + return firstValueFrom(this.getUserNavigationOptionsObservable(courseIds, { siteId })); + } + + /** + * Get user navigation options for a set of courses. + * + * @param courseIds IDs of courses to get. + * @param options Options. + * @return Observable that returns navigation options for each course. + */ + getUserNavigationOptionsObservable( + courseIds: number[], + options: CoreSitesCommonWSOptions = {}, + ): Observable { if (!courseIds || courseIds.length == 0) { - return {}; + return of({}); } - const site = await CoreSites.getSite(siteId); + return asyncObservable(async () => { + const site = await CoreSites.getSite(options.siteId); - const params: CoreCourseGetUserAdminOrNavOptionsWSParams = { - courseids: courseIds, - }; - const preSets: CoreSiteWSPreSets = { - cacheKey: this.getUserNavigationOptionsCacheKey(courseIds), - updateFrequency: CoreSite.FREQUENCY_RARELY, - }; + const params: CoreCourseGetUserAdminOrNavOptionsWSParams = { + courseids: courseIds, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserNavigationOptionsCacheKey(courseIds), + updateFrequency: CoreSite.FREQUENCY_RARELY, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), + }; - const response: CoreCourseGetUserAdminOrNavOptionsWSResponse = - await site.read('core_course_get_user_navigation_options', params, preSets); + const observable = site.readObservable( + 'core_course_get_user_navigation_options', + params, + preSets, + ); - // Format returned data. - return this.formatUserAdminOrNavOptions(response.courses); + // Format returned data. + return observable.pipe(map(response => this.formatUserAdminOrNavOptions(response.courses))); + }); } /** @@ -818,89 +910,112 @@ export class CoreCoursesProvider { * * @param preferCache True if shouldn't call WS if data is cached, false otherwise. * @param siteId Site to get the courses from. If not defined, use current site. + * @param strategy Reading strategy. * @return Promise resolved with the courses. */ - async getUserCourses( + getUserCourses( preferCache: boolean = false, siteId?: string, strategy?: CoreSitesReadingStrategy, ): Promise { - const site = await CoreSites.getSite(siteId); + strategy = strategy ?? (preferCache ? CoreSitesReadingStrategy.PREFER_CACHE : undefined); - const userId = site.getUserId(); - const wsParams: CoreEnrolGetUsersCoursesWSParams = { - userid: userId, - }; - const strategyPreSets = strategy - ? CoreSites.getReadingStrategyPreSets(strategy) - : { omitExpires: !!preferCache }; + return this.getUserCoursesObservable({ + readingStrategy: strategy, + siteId, + }).toPromise(); + } - const preSets = { - cacheKey: this.getUserCoursesCacheKey(), - getCacheUsingCacheKey: true, - updateFrequency: CoreSite.FREQUENCY_RARELY, - ...strategyPreSets, - }; + /** + * Get user courses. + * + * @param options Options. + * @return Observable that returns the courses. + */ + getUserCoursesObservable(options: CoreSitesCommonWSOptions = {}): Observable { + return asyncObservable(async () => { + const site = await CoreSites.getSite(options.siteId); - if (site.isVersionGreaterEqualThan('3.7')) { - wsParams.returnusercount = false; - } + const userId = site.getUserId(); + const wsParams: CoreEnrolGetUsersCoursesWSParams = { + userid: userId, + }; - const courses = await site.read('core_enrol_get_users_courses', wsParams, preSets); + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserCoursesCacheKey(), + getCacheUsingCacheKey: true, + updateFrequency: CoreSite.FREQUENCY_RARELY, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), + }; - if (this.userCoursesIds) { - // Check if the list of courses has changed. - const added: number[] = []; - const removed: number[] = []; - const previousIds = Object.keys(this.userCoursesIds); - const currentIds = {}; // Use an object to make it faster to search. + if (site.isVersionGreaterEqualThan('3.7')) { + wsParams.returnusercount = false; + } - courses.forEach((course) => { - // Move category field to categoryid on a course. - course.categoryid = course.category; - delete course.category; + const observable = site.readObservable( + 'core_enrol_get_users_courses', + wsParams, + preSets, + ); - currentIds[course.id] = true; + return observable.pipe(map(courses => { + if (this.userCoursesIds) { + // Check if the list of courses has changed. + const added: number[] = []; + const removed: number[] = []; + const previousIds = this.userCoursesIds; + const currentIds = new Set(); - if (!this.userCoursesIds[course.id]) { - // Course added. - added.push(course.id); - } - }); + courses.forEach((course) => { + // Move category field to categoryid on a course. + course.categoryid = course.category; + delete course.category; - if (courses.length - added.length != previousIds.length) { - // A course was removed, check which one. - previousIds.forEach((id) => { - if (!currentIds[id]) { - // Course removed. - removed.push(Number(id)); + currentIds.add(course.id); + + if (!previousIds.has(course.id)) { + // Course added. + added.push(course.id); + } + }); + + if (courses.length - added.length !== previousIds.size) { + // A course was removed, check which one. + previousIds.forEach((id) => { + if (!currentIds.has(id)) { + // Course removed. + removed.push(Number(id)); + } + }); } - }); - } - if (added.length || removed.length) { - // At least 1 course was added or removed, trigger the event. - CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_CHANGED, { - added: added, - removed: removed, - }, site.getId()); - } + if (added.length || removed.length) { + // At least 1 course was added or removed, trigger the event. + CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_CHANGED, { + added: added, + removed: removed, + }, site.getId()); + } - this.userCoursesIds = currentIds; - } else { - this.userCoursesIds = {}; + this.userCoursesIds = currentIds; + } else { + const coursesIds = new Set(); - // Store the list of courses. - courses.forEach((course) => { - // Move category field to categoryid on a course. - course.categoryid = course.category; - delete course.category; + // Store the list of courses. + courses.forEach((course) => { + coursesIds.add(course.id); - this.userCoursesIds[course.id] = true; - }); - } + // Move category field to categoryid on a course. + course.categoryid = course.category; + delete course.category; + }); - return courses; + this.userCoursesIds = coursesIds; + } + + return courses; + })); + }); } /** @@ -1312,12 +1427,12 @@ export type CoreEnrolledCourseData = CoreEnrolledCourseBasicData & { completionhascriteria?: boolean; // If completion criteria is set. completionusertracked?: boolean; // If the user is completion tracked. progress?: number | null; // Progress percentage. - completed?: boolean; // Whether the course is completed. - marker?: number; // Course section marker. - lastaccess?: number; // Last access to the course (timestamp). + completed?: boolean; // @since 3.6. Whether the course is completed. + marker?: number; // @since 3.6. Course section marker. + lastaccess?: number; // @since 3.6. Last access to the course (timestamp). isfavourite?: boolean; // If the user marked this course a favourite. hidden?: boolean; // If the user hide the course from the dashboard. - overviewfiles?: CoreWSExternalFile[]; + overviewfiles?: CoreWSExternalFile[]; // @since 3.6. showactivitydates?: boolean; // @since 3.11. Whether the activity dates are shown or not. showcompletionconditions?: boolean; // @since 3.11. Whether the activity completion conditions are shown or not. timemodified?: number; // @since 4.0. Last time course settings were updated (timestamp).