forked from EVOgeek/Vmeda.Online
		
	
						commit
						57f10bff4a
					
				| @ -276,6 +276,7 @@ testsConfig['rules']['padded-blocks'] = [ | ||||
|     }, | ||||
| ]; | ||||
| testsConfig['rules']['jest/expect-expect'] = 'off'; | ||||
| testsConfig['rules']['jest/no-done-callback'] = 'off'; | ||||
| testsConfig['plugins'].push('jest'); | ||||
| testsConfig['extends'].push('plugin:jest/recommended'); | ||||
| 
 | ||||
|  | ||||
| @ -12,12 +12,17 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnInit, OnDestroy } from '@angular/core'; | ||||
| import { Component, OnInit, OnDestroy, Optional, OnChanges, SimpleChanges } from '@angular/core'; | ||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; | ||||
| import { CoreCoursesProvider, CoreCourses, CoreCoursesMyCoursesUpdatedEventData } from '@features/courses/services/courses'; | ||||
| import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; | ||||
| import { | ||||
|     CoreCoursesProvider, | ||||
|     CoreCourses, | ||||
|     CoreCoursesMyCoursesUpdatedEventData, | ||||
|     CoreCourseSummaryData, | ||||
| } from '@features/courses/services/courses'; | ||||
| import { CoreCoursesHelper, CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses-helper'; | ||||
| import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper'; | ||||
| import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; | ||||
| import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; | ||||
| @ -28,6 +33,8 @@ import { CoreTextUtils } from '@services/utils/text'; | ||||
| import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion'; | ||||
| import { IonRefresher, IonSearchbar } from '@ionic/angular'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { PageLoadWatcher } from '@classes/page-load-watcher'; | ||||
| import { PageLoadsManager } from '@classes/page-loads-manager'; | ||||
| 
 | ||||
| const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] = | ||||
|     ['all', 'inprogress', 'future', 'past', 'favourite', 'allincludinghidden', 'hidden']; | ||||
| @ -40,9 +47,9 @@ const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] = | ||||
|     templateUrl: 'addon-block-myoverview.html', | ||||
|     styleUrls: ['myoverview.scss'], | ||||
| }) | ||||
| export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy { | ||||
| export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy, OnChanges { | ||||
| 
 | ||||
|     filteredCourses: CoreEnrolledCourseDataWithOptions[] = []; | ||||
|     filteredCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = []; | ||||
| 
 | ||||
|     prefetchCoursesData: CorePrefetchStatusInfo = { | ||||
|         icon: '', | ||||
| @ -84,7 +91,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|     searchEnabled = false; | ||||
| 
 | ||||
|     protected currentSite!: CoreSite; | ||||
|     protected allCourses: CoreEnrolledCourseDataWithOptions[] = []; | ||||
|     protected allCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = []; | ||||
|     protected prefetchIconsInitialized = false; | ||||
|     protected isDirty = false; | ||||
|     protected isDestroyed = false; | ||||
| @ -94,15 +101,21 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|     protected gradePeriodAfter = 0; | ||||
|     protected gradePeriodBefore = 0; | ||||
|     protected today = 0; | ||||
|     protected firstLoadWatcher?: PageLoadWatcher; | ||||
|     protected loadsManager: PageLoadsManager; | ||||
| 
 | ||||
|     constructor() { | ||||
|     constructor(@Optional() loadsManager?: PageLoadsManager) { | ||||
|         super('AddonBlockMyOverviewComponent'); | ||||
| 
 | ||||
|         this.loadsManager = loadsManager ?? new PageLoadsManager(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         this.firstLoadWatcher = this.loadsManager.startComponentLoad(this); | ||||
| 
 | ||||
|         // Refresh the enabled flags if enabled.
 | ||||
|         this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite(); | ||||
|         this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite(); | ||||
| @ -159,6 +172,16 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngOnChanges(changes: SimpleChanges): void { | ||||
|         if (this.loaded && changes.block) { | ||||
|             // Block was re-fetched, load content.
 | ||||
|             this.reloadContent(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Refresh the data. | ||||
|      * | ||||
| @ -226,35 +249,66 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected async fetchContent(): Promise<void> { | ||||
|         const config = this.block.configsRecord; | ||||
|         const loadWatcher = this.firstLoadWatcher ?? this.loadsManager.startComponentLoad(this); | ||||
|         this.firstLoadWatcher = undefined; | ||||
| 
 | ||||
|         const showCategories = config?.displaycategories?.value == '1'; | ||||
|         await Promise.all([ | ||||
|             this.loadAllCourses(loadWatcher), | ||||
|             this.loadGracePeriod(loadWatcher), | ||||
|         ]); | ||||
| 
 | ||||
|         this.allCourses = await CoreCoursesHelper.getUserCoursesWithOptions( | ||||
|             this.sort.selected, | ||||
|             undefined, | ||||
|             undefined, | ||||
|             showCategories, | ||||
|             { | ||||
|                 readingStrategy: this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : undefined, | ||||
|             }, | ||||
|         this.loadSort(); | ||||
|         this.loadLayouts(this.block.configsRecord?.layouts?.value.split(',')); | ||||
| 
 | ||||
|         await this.loadFilters(this.block.configsRecord, loadWatcher); | ||||
| 
 | ||||
|         this.isDirty = false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load all courses. | ||||
|      * | ||||
|      * @param loadWatcher To manage the requests. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async loadAllCourses(loadWatcher: PageLoadWatcher): Promise<void> { | ||||
|         const showCategories = this.block.configsRecord?.displaycategories?.value === '1'; | ||||
| 
 | ||||
|         this.allCourses = await loadWatcher.watchRequest( | ||||
|             CoreCoursesHelper.getUserCoursesWithOptionsObservable({ | ||||
|                 sort: this.sort.selected, | ||||
|                 loadCategoryNames: showCategories, | ||||
|                 readingStrategy: this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : loadWatcher.getReadingStrategy(), | ||||
|             }), | ||||
|             this.coursesHaveMeaningfulChanges.bind(this), | ||||
|         ); | ||||
| 
 | ||||
|         this.hasCourses = this.allCourses.length > 0; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load grace period. | ||||
|      * | ||||
|      * @param loadWatcher To manage the requests. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async loadGracePeriod(loadWatcher: PageLoadWatcher): Promise<void> { | ||||
|         this.hasCourses = this.allCourses.length > 0; | ||||
| 
 | ||||
|         try { | ||||
|             this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter', this.isDirty), 10); | ||||
|             this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore', this.isDirty), 10); | ||||
|             const siteConfig = await loadWatcher.watchRequest( | ||||
|                 this.currentSite.getConfigObservable( | ||||
|                     undefined, | ||||
|                     this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : loadWatcher.getReadingStrategy(), | ||||
|                 ), | ||||
|             ); | ||||
| 
 | ||||
|             this.gradePeriodAfter = parseInt(siteConfig.coursegraceperiodafter, 10); | ||||
|             this.gradePeriodBefore = parseInt(siteConfig.coursegraceperiodbefore, 10); | ||||
|         } catch { | ||||
|             this.gradePeriodAfter = 0; | ||||
|             this.gradePeriodBefore = 0; | ||||
|         } | ||||
| 
 | ||||
|         this.loadSort(); | ||||
|         this.loadLayouts(config?.layouts?.value.split(',')); | ||||
|         this.loadFilters(config); | ||||
| 
 | ||||
|         this.isDirty = false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -279,10 +333,13 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|      * Load filters. | ||||
|      * | ||||
|      * @param config Block configuration. | ||||
|      * @param loadWatcher To manage the requests. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected loadFilters( | ||||
|     protected async loadFilters( | ||||
|         config?: Record<string, { name: string; value: string; type: string }>, | ||||
|     ): void { | ||||
|         loadWatcher?: PageLoadWatcher, | ||||
|     ): Promise<void> { | ||||
|         if (!this.hasCourses) { | ||||
|             return; | ||||
|         } | ||||
| @ -320,7 +377,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|             this.saveFilters('all'); | ||||
|         } | ||||
| 
 | ||||
|         this.filterCourses(); | ||||
|         await this.filterCourses(loadWatcher); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -369,14 +426,14 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|     protected async refreshCourseList(data: CoreCoursesMyCoursesUpdatedEventData): Promise<void> { | ||||
|         if (data.action == CoreCoursesProvider.ACTION_ENROL) { | ||||
|             // Always update if user enrolled in a course.
 | ||||
|             return this.refreshContent(); | ||||
|             return this.refreshContent(true); | ||||
|         } | ||||
| 
 | ||||
|         const course = this.allCourses.find((course) => course.id == data.courseId); | ||||
|         if (data.action == CoreCoursesProvider.ACTION_STATE_CHANGED) { | ||||
|             if (!course) { | ||||
|                 // Not found, use WS update.
 | ||||
|                 return this.refreshContent(); | ||||
|                 return this.refreshContent(true); | ||||
|             } | ||||
| 
 | ||||
|             if (data.state == CoreCoursesProvider.STATE_FAVOURITE) { | ||||
| @ -394,7 +451,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|         if (data.action == CoreCoursesProvider.ACTION_VIEW && data.courseId != CoreSites.getCurrentSiteHomeId()) { | ||||
|             if (!course) { | ||||
|                 // Not found, use WS update.
 | ||||
|                 return this.refreshContent(); | ||||
|                 return this.refreshContent(true); | ||||
|             } | ||||
| 
 | ||||
|             course.lastaccess = CoreTimeUtils.timestamp(); | ||||
| @ -451,8 +508,11 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
| 
 | ||||
|     /** | ||||
|      * Set selected courses filter. | ||||
|      * | ||||
|      * @param loadWatcher To manage the requests. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async filterCourses(): Promise<void> { | ||||
|     protected async filterCourses(loadWatcher?: PageLoadWatcher): Promise<void> { | ||||
|         let timeFilter = this.filters.timeFilterSelected; | ||||
| 
 | ||||
|         this.filteredCourses = this.allCourses; | ||||
| @ -463,18 +523,36 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|             const customFilterValue = this.filters.customFilters[timeFilter.substring(7)]?.value; | ||||
| 
 | ||||
|             if (customFilterName !== undefined && customFilterValue !== undefined) { | ||||
|                 const alreadyLoading = this.loaded === false; | ||||
|                 this.loaded = false; | ||||
| 
 | ||||
|                 try { | ||||
|                     const courses = await CoreCourses.getEnrolledCoursesByCustomField(customFilterName, customFilterValue); | ||||
|                     const courses = loadWatcher ? | ||||
|                         await loadWatcher.watchRequest( | ||||
|                             CoreCourses.getEnrolledCoursesByCustomFieldObservable(customFilterName, customFilterValue, { | ||||
|                                 readingStrategy: loadWatcher.getReadingStrategy(), | ||||
|                             }), | ||||
|                             this.customFilterCoursesHaveMeaningfulChanges.bind(this), | ||||
|                         ) | ||||
|                         : | ||||
|                         await CoreCourses.getEnrolledCoursesByCustomField(customFilterName, customFilterValue); | ||||
| 
 | ||||
|                     // Get the courses information from allincludinghidden to get the max info about the course.
 | ||||
|                     const courseIds = courses.map((course) => course.id); | ||||
| 
 | ||||
|                     this.filteredCourses = this.filteredCourses.filter((course) => courseIds.includes(course.id)); | ||||
|                     this.saveFilters(timeFilter); | ||||
|                 } catch (error) { | ||||
|                     if (alreadyLoading) { | ||||
|                         throw error; // Pass the error to the caller so it's treated there.
 | ||||
|                     } | ||||
| 
 | ||||
|                     CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError); | ||||
|                 } finally { | ||||
|                     this.loaded = true; | ||||
|                     if (!alreadyLoading) { | ||||
|                         // Only set loaded to true if there was no other data being loaded.
 | ||||
|                         this.loaded = true; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } else { | ||||
| @ -626,6 +704,62 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|         CoreNavigator.navigateToSitePath('courses/list', { params : { mode: 'search' } }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Compare if the WS data has meaningful changes for the user. | ||||
|      * | ||||
|      * @param previousCourses Previous courses. | ||||
|      * @param newCourses New courses. | ||||
|      * @return Whether it has meaningful changes. | ||||
|      */ | ||||
|     protected coursesHaveMeaningfulChanges( | ||||
|         previousCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[], | ||||
|         newCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[], | ||||
|     ): boolean { | ||||
|         if (previousCourses.length !== newCourses.length) { | ||||
|             return true; | ||||
|         } | ||||
| 
 | ||||
|         previousCourses = Array.from(previousCourses) | ||||
|             .sort((a, b) => a.fullname.toLowerCase().localeCompare(b.fullname.toLowerCase())); | ||||
|         newCourses = Array.from(newCourses).sort((a, b) => a.fullname.toLowerCase().localeCompare(b.fullname.toLowerCase())); | ||||
| 
 | ||||
|         for (let i = 0; i < previousCourses.length; i++) { | ||||
|             const prevCourse = previousCourses[i]; | ||||
|             const newCourse = newCourses[i]; | ||||
| 
 | ||||
|             if ( | ||||
|                 prevCourse.progress !== newCourse.progress || | ||||
|                 prevCourse.categoryname !== newCourse.categoryname || | ||||
|                 (prevCourse.displayname ?? prevCourse.fullname) !== (newCourse.displayname ?? newCourse.fullname) | ||||
|             ) { | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Compare if the WS data has meaningful changes for the user. | ||||
|      * | ||||
|      * @param previousCourses Previous courses. | ||||
|      * @param newCourses New courses. | ||||
|      * @return Whether it has meaningful changes. | ||||
|      */ | ||||
|     protected customFilterCoursesHaveMeaningfulChanges( | ||||
|         previousCourses: CoreCourseSummaryData[], | ||||
|         newCourses: CoreCourseSummaryData[], | ||||
|     ): boolean { | ||||
|         if (previousCourses.length !== newCourses.length) { | ||||
|             return true; | ||||
|         } | ||||
| 
 | ||||
|         const previousIds = previousCourses.map(course => course.id).sort(); | ||||
|         const newIds = newCourses.map(course => course.id).sort(); | ||||
| 
 | ||||
|         return previousIds.some((previousId, index) => previousId !== newIds[index]); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|  | ||||
| @ -14,13 +14,15 @@ | ||||
| 
 | ||||
| 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 { CoreSite, CoreSiteWSPreSets, WSObservable } 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/rxjs'; | ||||
| import { map } from 'rxjs/operators'; | ||||
| 
 | ||||
| const ROOT_CACHE_KEY = 'mmaCourseCompletion:'; | ||||
| 
 | ||||
| @ -93,33 +95,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<AddonCourseCompletionCourseCompletionStatus> { | ||||
|         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 = {}, | ||||
|     ): WSObservable<AddonCourseCompletionCourseCompletionStatus> { | ||||
|         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<AddonCourseCompletionGetCourseCompletionStatusWSResponse>( | ||||
|                 'core_completion_get_course_completion_status', | ||||
|                 data, | ||||
|                 preSets, | ||||
|             ).pipe(map(result => result.completionstatus)); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -312,3 +336,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.
 | ||||
| }; | ||||
|  | ||||
| @ -71,7 +71,7 @@ | ||||
| 
 | ||||
|         <div class="mark-all-as-read" slot="fixed" collapsible-footer appearOnBottom> | ||||
|             <ion-chip *ngIf="notifications.loaded && canMarkAllNotificationsAsRead" [disabled]="loadingMarkAllNotificationsAsRead" | ||||
|                 color="primary" (click)="markAllNotificationsAsRead()"> | ||||
|                 color="info" class="clickable fab-chip" (click)="markAllNotificationsAsRead()"> | ||||
|                 <ion-icon name="fas-eye" aria-hidden="true" *ngIf="!loadingMarkAllNotificationsAsRead"></ion-icon> | ||||
|                 <ion-spinner [attr.aria-label]="'core.loading' | translate" *ngIf="loadingMarkAllNotificationsAsRead"> | ||||
|                 </ion-spinner> | ||||
|  | ||||
| @ -56,9 +56,7 @@ ion-item { | ||||
|     pointer-events: none; | ||||
| 
 | ||||
|     ion-chip.ion-color { | ||||
|         pointer-events: all; | ||||
|         margin: 0 auto; | ||||
|         box-shadow: 0 2px 4px rgba(0, 0, 0, .4); | ||||
| 
 | ||||
|         ion-spinner { | ||||
|             width: 16px; | ||||
|  | ||||
							
								
								
									
										159
									
								
								src/core/classes/page-load-watcher.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								src/core/classes/page-load-watcher.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,159 @@ | ||||
| // (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 { CoreSitesReadingStrategy } from '@services/sites'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| import { AsyncComponent } from './async-component'; | ||||
| import { PageLoadsManager } from './page-loads-manager'; | ||||
| import { CorePromisedValue } from './promised-value'; | ||||
| import { WSObservable } from './site'; | ||||
| 
 | ||||
| /** | ||||
|  * Class to watch requests from a page load (including requests from page sub-components). | ||||
|  */ | ||||
| export class PageLoadWatcher { | ||||
| 
 | ||||
|     protected hasChanges = false; | ||||
|     protected ongoingRequests = 0; | ||||
|     protected components = new Set<AsyncComponent>(); | ||||
|     protected loadedTimeout?: number; | ||||
| 
 | ||||
|     constructor( | ||||
|         protected loadsManager: PageLoadsManager, | ||||
|         protected updateInBackground: boolean, | ||||
|     ) { } | ||||
| 
 | ||||
|     /** | ||||
|      * Whether this load watcher can update data in background. | ||||
|      * | ||||
|      * @return Whether this load watcher can update data in background. | ||||
|      */ | ||||
|     canUpdateInBackground(): boolean { | ||||
|         return this.updateInBackground; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Whether this load watcher had meaningful changes received in background. | ||||
|      * | ||||
|      * @return Whether this load watcher had meaningful changes received in background. | ||||
|      */ | ||||
|     hasMeaningfulChanges(): boolean { | ||||
|         return this.hasChanges; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Set has meaningful changes to true. | ||||
|      */ | ||||
|     markMeaningfulChanges(): void { | ||||
|         this.hasChanges = true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Watch a component, waiting for it to be ready. | ||||
|      * | ||||
|      * @param component Component instance. | ||||
|      */ | ||||
|     async watchComponent(component: AsyncComponent): Promise<void> { | ||||
|         this.components.add(component); | ||||
|         clearTimeout(this.loadedTimeout); | ||||
| 
 | ||||
|         try { | ||||
|             await component.ready(); | ||||
|         } finally { | ||||
|             this.components.delete(component); | ||||
|             this.checkHasLoaded(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the reading strategy to use. | ||||
|      * | ||||
|      * @return Reading strategy to use. | ||||
|      */ | ||||
|     getReadingStrategy(): CoreSitesReadingStrategy | undefined { | ||||
|         return this.updateInBackground ? CoreSitesReadingStrategy.STALE_WHILE_REVALIDATE : undefined; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Watch a WS request, handling the different values it can return, calling the hasMeaningfulChanges callback if needed to | ||||
|      * detect if there are new meaningful changes in the page load, and completing the page load when all requests have | ||||
|      * finished and all components are ready. | ||||
|      * | ||||
|      * @param observable Observable of the request. | ||||
|      * @param hasMeaningfulChanges Callback to check if there are meaningful changes if data was updated in background. | ||||
|      * @return First value of the observable. | ||||
|      */ | ||||
|     watchRequest<T>( | ||||
|         observable: WSObservable<T>, | ||||
|         hasMeaningfulChanges?: (previousValue: T, newValue: T) => boolean, | ||||
|     ): Promise<T> { | ||||
|         const promisedValue = new CorePromisedValue<T>(); | ||||
|         let subscription: Subscription | null = null; | ||||
|         let firstValue: T | undefined; | ||||
|         this.ongoingRequests++; | ||||
|         clearTimeout(this.loadedTimeout); | ||||
| 
 | ||||
|         const complete = async () => { | ||||
|             this.ongoingRequests--; | ||||
|             this.checkHasLoaded(); | ||||
| 
 | ||||
|             // Subscription variable might not be set because the observable completed immediately. Wait for next tick.
 | ||||
|             await CoreUtils.nextTick(); | ||||
|             subscription?.unsubscribe(); | ||||
|         }; | ||||
| 
 | ||||
|         subscription = observable.subscribe({ | ||||
|             next: value => { | ||||
|                 if (!firstValue) { | ||||
|                     firstValue = value; | ||||
|                     promisedValue.resolve(value); | ||||
| 
 | ||||
|                     return; | ||||
|                 } | ||||
| 
 | ||||
|                 // Second value, it means data was updated in background. Compare data.
 | ||||
|                 if (hasMeaningfulChanges?.(firstValue, value)) { | ||||
|                     this.hasChanges = true; | ||||
|                 } | ||||
|             }, | ||||
|             error: (error) => { | ||||
|                 promisedValue.reject(error); | ||||
|                 complete(); | ||||
|             }, | ||||
|             complete: () => complete(), | ||||
|         }); | ||||
| 
 | ||||
|         return promisedValue; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check if the load has finished. | ||||
|      */ | ||||
|     protected checkHasLoaded(): void { | ||||
|         if (this.ongoingRequests !== 0 || this.components.size !== 0) { | ||||
|             // Load not finished.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // It seems load has finished. Wait to make sure no new component has been rendered and started loading.
 | ||||
|         // If a new component or a new request starts the timeout will be cancelled, no need to double check it.
 | ||||
|         clearTimeout(this.loadedTimeout); | ||||
|         this.loadedTimeout = window.setTimeout(() => { | ||||
|             // Loading finished.
 | ||||
|             this.loadsManager.onPageLoaded(this); | ||||
|         }, 100); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										141
									
								
								src/core/classes/page-loads-manager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/core/classes/page-loads-manager.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,141 @@ | ||||
| // (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 { CoreRefreshButtonModalComponent } from '@components/refresh-button-modal/refresh-button-modal'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { Subject } from 'rxjs'; | ||||
| import { AsyncComponent } from './async-component'; | ||||
| import { PageLoadWatcher } from './page-load-watcher'; | ||||
| 
 | ||||
| /** | ||||
|  * Class to manage requests in a page and its components. | ||||
|  */ | ||||
| export class PageLoadsManager { | ||||
| 
 | ||||
|     onRefreshPage = new Subject<void>(); | ||||
| 
 | ||||
|     protected initialPath?: string; | ||||
|     protected currentLoadWatcher?: PageLoadWatcher; | ||||
|     protected ongoingLoadWatchers = new Set<PageLoadWatcher>(); | ||||
| 
 | ||||
|     /** | ||||
|      * Start a page load, creating a new load watcher and watching the page. | ||||
|      * | ||||
|      * @param page Page instance. | ||||
|      * @param staleWhileRevalidate Whether to use stale while revalidate strategy. | ||||
|      * @return Load watcher to use. | ||||
|      */ | ||||
|     startPageLoad(page: AsyncComponent, staleWhileRevalidate: boolean): PageLoadWatcher { | ||||
|         this.initialPath = this.initialPath ?? CoreNavigator.getCurrentPath(); | ||||
|         this.currentLoadWatcher = new PageLoadWatcher(this, staleWhileRevalidate); | ||||
|         this.ongoingLoadWatchers.add(this.currentLoadWatcher); | ||||
| 
 | ||||
|         this.currentLoadWatcher.watchComponent(page); | ||||
| 
 | ||||
|         return this.currentLoadWatcher; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Start a component load, adding it to currrent load watcher (if it exists) and watching the component. | ||||
|      * | ||||
|      * @param component Component instance. | ||||
|      * @return Load watcher to use. | ||||
|      */ | ||||
|     startComponentLoad(component: AsyncComponent): PageLoadWatcher { | ||||
|         // If a component is loading data without the page loading data, probably the component is reloading/refreshing.
 | ||||
|         // In that case, create a load watcher instance but don't store it in currentLoadWatcher because it's not a page load.
 | ||||
|         const loadWatcher = this.currentLoadWatcher ?? new PageLoadWatcher(this, false); | ||||
| 
 | ||||
|         loadWatcher.watchComponent(component); | ||||
| 
 | ||||
|         return loadWatcher; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * A load has finished, remove its watcher from ongoing watchers and notify if needed. | ||||
|      * | ||||
|      * @param loadWatcher Load watcher related to the load that finished. | ||||
|      */ | ||||
|     onPageLoaded(loadWatcher: PageLoadWatcher): void { | ||||
|         if (!this.ongoingLoadWatchers.has(loadWatcher)) { | ||||
|             // Watcher not in list, it probably finished already.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.removeLoadWatcher(loadWatcher); | ||||
| 
 | ||||
|         if (!loadWatcher.hasMeaningfulChanges()) { | ||||
|             // No need to notify.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Check if there is another ongoing load watcher using update in background.
 | ||||
|         // If so, wait for the other one to finish before notifying to prevent being notified twice.
 | ||||
|         const ongoingLoadWatcher = this.getAnotherOngoingUpdateInBackgroundWatcher(loadWatcher); | ||||
|         if (ongoingLoadWatcher) { | ||||
|             ongoingLoadWatcher.markMeaningfulChanges(); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (this.initialPath === CoreNavigator.getCurrentPath()) { | ||||
|             // User hasn't changed page, notify them.
 | ||||
|             this.notifyUser(); | ||||
|         } else { | ||||
|             // User left the page, just update the data.
 | ||||
|             this.onRefreshPage.next(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get an ongoing load watcher that supports updating in background and is not the one passed as a parameter. | ||||
|      * | ||||
|      * @param loadWatcher Load watcher to ignore. | ||||
|      * @return Ongoing load watcher, undefined if none found. | ||||
|      */ | ||||
|     protected getAnotherOngoingUpdateInBackgroundWatcher(loadWatcher: PageLoadWatcher): PageLoadWatcher | undefined { | ||||
|         for (const ongoingLoadWatcher of this.ongoingLoadWatchers) { | ||||
|             if (ongoingLoadWatcher.canUpdateInBackground() && loadWatcher !== ongoingLoadWatcher) { | ||||
|                 return ongoingLoadWatcher; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Remove a load watcher from the list. | ||||
|      * | ||||
|      * @param loadWatcher Load watcher to remove. | ||||
|      */ | ||||
|     protected removeLoadWatcher(loadWatcher: PageLoadWatcher): void { | ||||
|         this.ongoingLoadWatchers.delete(loadWatcher); | ||||
|         if (loadWatcher === this.currentLoadWatcher) { | ||||
|             delete this.currentLoadWatcher; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Notify the user, asking him if he wants to update the data. | ||||
|      */ | ||||
|     protected async notifyUser(): Promise<void> { | ||||
|         await CoreDomUtils.openModal<boolean>({ | ||||
|             component: CoreRefreshButtonModalComponent, | ||||
|             cssClass: 'core-modal-no-background', | ||||
|             closeOnNavigate: true, | ||||
|         }); | ||||
| 
 | ||||
|         this.onRefreshPage.next(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -57,6 +57,9 @@ import { | ||||
|     WSGroups, | ||||
|     WS_CACHE_TABLES_PREFIX, | ||||
| } from '@services/database/sites'; | ||||
| import { Observable, ObservableInput, ObservedValueOf, OperatorFunction, Subject } from 'rxjs'; | ||||
| import { finalize, map, mergeMap } from 'rxjs/operators'; | ||||
| import { firstValueFrom } from '../utils/rxjs'; | ||||
| 
 | ||||
| /** | ||||
|  * QR Code type enumeration. | ||||
| @ -122,7 +125,7 @@ export class CoreSite { | ||||
|     protected lastAutoLogin = 0; | ||||
|     protected offlineDisabled = false; | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     protected ongoingRequests: { [cacheId: string]: Promise<any> } = {}; | ||||
|     protected ongoingRequests: { [cacheId: string]: WSObservable<any> } = {}; | ||||
|     protected requestQueue: RequestQueueItem[] = []; | ||||
|     protected requestQueueTimeout: number | null = null; | ||||
|     protected tokenPluginFileWorks?: boolean; | ||||
| @ -492,18 +495,25 @@ export class CoreSite { | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     read<T = unknown>(method: string, data: any, preSets?: CoreSiteWSPreSets): Promise<T> { | ||||
|         preSets = preSets || {}; | ||||
|         if (preSets.getFromCache === undefined) { | ||||
|             preSets.getFromCache = true; | ||||
|         } | ||||
|         if (preSets.saveToCache === undefined) { | ||||
|             preSets.saveToCache = true; | ||||
|         } | ||||
|         if (preSets.reusePending === undefined) { | ||||
|             preSets.reusePending = true; | ||||
|         } | ||||
|         return firstValueFrom(this.readObservable<T>(method, data, preSets)); | ||||
|     } | ||||
| 
 | ||||
|         return this.request(method, data, preSets); | ||||
|     /** | ||||
|      * Read some data from the Moodle site using WS. Requests are cached by default. | ||||
|      * | ||||
|      * @param method WS method to use. | ||||
|      * @param data Data to send to the WS. | ||||
|      * @param preSets Extra options. | ||||
|      * @return Observable returning the WS data. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     readObservable<T = unknown>(method: string, data: any, preSets?: CoreSiteWSPreSets): WSObservable<T> { | ||||
|         preSets = preSets || {}; | ||||
|         preSets.getFromCache = preSets.getFromCache ?? true; | ||||
|         preSets.saveToCache = preSets.saveToCache ?? true; | ||||
|         preSets.reusePending = preSets.reusePending ?? true; | ||||
| 
 | ||||
|         return this.requestObservable<T>(method, data, preSets); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -516,18 +526,25 @@ export class CoreSite { | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     write<T = unknown>(method: string, data: any, preSets?: CoreSiteWSPreSets): Promise<T> { | ||||
|         preSets = preSets || {}; | ||||
|         if (preSets.getFromCache === undefined) { | ||||
|             preSets.getFromCache = false; | ||||
|         } | ||||
|         if (preSets.saveToCache === undefined) { | ||||
|             preSets.saveToCache = false; | ||||
|         } | ||||
|         if (preSets.emergencyCache === undefined) { | ||||
|             preSets.emergencyCache = false; | ||||
|         } | ||||
|         return firstValueFrom(this.writeObservable<T>(method, data, preSets)); | ||||
|     } | ||||
| 
 | ||||
|         return this.request(method, data, preSets); | ||||
|     /** | ||||
|      * Sends some data to the Moodle site using WS. Requests are NOT cached by default. | ||||
|      * | ||||
|      * @param method WS method to use. | ||||
|      * @param data Data to send to the WS. | ||||
|      * @param preSets Extra options. | ||||
|      * @return Observable returning the WS data. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     writeObservable<T = unknown>(method: string, data: any, preSets?: CoreSiteWSPreSets): WSObservable<T> { | ||||
|         preSets = preSets || {}; | ||||
|         preSets.getFromCache = preSets.getFromCache ?? false; | ||||
|         preSets.saveToCache = preSets.saveToCache ?? false; | ||||
|         preSets.emergencyCache = preSets.emergencyCache ?? false; | ||||
| 
 | ||||
|         return this.requestObservable<T>(method, data, preSets); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -536,8 +553,20 @@ export class CoreSite { | ||||
|      * @param method The WebService method to be called. | ||||
|      * @param data Arguments to pass to the method. | ||||
|      * @param preSets Extra options. | ||||
|      * @param retrying True if we're retrying the call for some reason. This is to prevent infinite loops. | ||||
|      * @return Promise resolved with the response, rejected with CoreWSError if it fails. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     async request<T = unknown>(method: string, data: any, preSets: CoreSiteWSPreSets): Promise<T> { | ||||
|         return firstValueFrom(this.requestObservable<T>(method, data, preSets)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * WS request to the site. | ||||
|      * | ||||
|      * @param method The WebService method to be called. | ||||
|      * @param data Arguments to pass to the method. | ||||
|      * @param preSets Extra options. | ||||
|      * @return Observable returning the WS data. | ||||
|      * @description | ||||
|      * | ||||
|      * Sends a webservice request to the site. This method will automatically add the | ||||
| @ -547,7 +576,7 @@ export class CoreSite { | ||||
|      * data hasn't expired. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|     async request<T = unknown>(method: string, data: any, preSets: CoreSiteWSPreSets, retrying?: boolean): Promise<T> { | ||||
|     requestObservable<T = unknown>(method: string, data: any, preSets: CoreSiteWSPreSets): WSObservable<T> { | ||||
|         if (this.isLoggedOut() && !ALLOWED_LOGGEDOUT_WS.includes(method)) { | ||||
|             // Site is logged out, it cannot call WebServices.
 | ||||
|             CoreEvents.trigger(CoreEvents.SESSION_EXPIRED, {}, this.id); | ||||
| @ -556,7 +585,6 @@ export class CoreSite { | ||||
|             throw new CoreSilentError(Translate.instant('core.lostconnection')); | ||||
|         } | ||||
| 
 | ||||
|         const initialToken = this.token || ''; | ||||
|         data = data || {}; | ||||
| 
 | ||||
|         if (!CoreNetwork.isOnline() && this.offlineDisabled) { | ||||
| @ -610,172 +638,305 @@ export class CoreSite { | ||||
| 
 | ||||
|         // Check for an ongoing identical request if we're not ignoring cache.
 | ||||
|         if (preSets.getFromCache && this.ongoingRequests[cacheId] !== undefined) { | ||||
|             const response = await this.ongoingRequests[cacheId]; | ||||
| 
 | ||||
|             // Clone the data, this may prevent errors if in the callback the object is modified.
 | ||||
|             return CoreUtils.clone(response); | ||||
|             return this.ongoingRequests[cacheId]; | ||||
|         } | ||||
| 
 | ||||
|         const promise = this.getFromCache<T>(method, data, preSets, false).catch(async () => { | ||||
|             if (preSets.forceOffline) { | ||||
|                 // Don't call the WS, just fail.
 | ||||
|                 throw new CoreError( | ||||
|                     Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), | ||||
|                 ); | ||||
|             } | ||||
|         const observable = this.performRequest<T>(method, data, preSets, wsPreSets).pipe( | ||||
|             // Return a clone of the original object, this may prevent errors if in the callback the object is modified.
 | ||||
|             map((data) => CoreUtils.clone(data)), | ||||
|         ); | ||||
| 
 | ||||
|             // Call the WS.
 | ||||
|         this.ongoingRequests[cacheId] = observable; | ||||
| 
 | ||||
|         return observable.pipe( | ||||
|             finalize(() => { | ||||
|                 // Clear the ongoing request unless it has changed (e.g. a new request that ignores cache).
 | ||||
|                 if (this.ongoingRequests[cacheId] === observable) { | ||||
|                     delete this.ongoingRequests[cacheId]; | ||||
|                 } | ||||
|             }), | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Perform a request, getting the response either from cache or WebService. | ||||
|      * | ||||
|      * @param method The WebService method to be called. | ||||
|      * @param data Arguments to pass to the method. | ||||
|      * @param preSets Extra options related to the site. | ||||
|      * @param wsPreSets Extra options related to the WS call. | ||||
|      * @return Observable returning the WS data. | ||||
|      */ | ||||
|     protected performRequest<T = unknown>( | ||||
|         method: string, | ||||
|         data: unknown, | ||||
|         preSets: CoreSiteWSPreSets, | ||||
|         wsPreSets: CoreWSPreSets, | ||||
|     ): WSObservable<T> { | ||||
|         const subject = new Subject<T>(); | ||||
| 
 | ||||
|         const run = async () => { | ||||
|             try { | ||||
|                 if (method !== 'core_webservice_get_site_info') { | ||||
|                     // Send the language to use. Do it after checking cache to prevent losing offline data when changing language.
 | ||||
|                     // Don't send it to core_webservice_get_site_info, that WS is used to check if Moodle version is supported.
 | ||||
|                     data.moodlewssettinglang = preSets.lang ?? await CoreLang.getCurrentLanguage(); | ||||
|                     // Moodle uses underscore instead of dash.
 | ||||
|                     data.moodlewssettinglang = data.moodlewssettinglang.replace('-', '_'); | ||||
|                 } | ||||
| 
 | ||||
|                 const response = await this.callOrEnqueueRequest<T>(method, data, preSets, wsPreSets); | ||||
| 
 | ||||
|                 if (preSets.saveToCache) { | ||||
|                     delete data.moodlewssettinglang; | ||||
|                     this.saveToCache(method, data, response, preSets); | ||||
|                 } | ||||
| 
 | ||||
|                 return response; | ||||
|             } catch (error) { | ||||
|                 let useSilentError = false; | ||||
| 
 | ||||
|                 if (CoreUtils.isExpiredTokenError(error)) { | ||||
|                     if (initialToken !== this.token && !retrying) { | ||||
|                         // Token has changed, retry with the new token.
 | ||||
|                         preSets.getFromCache = false; // Don't check cache now. Also, it will skip ongoingRequests.
 | ||||
| 
 | ||||
|                         return this.request<T>(method, data, preSets, true); | ||||
|                     } else if (CoreApp.isSSOAuthenticationOngoing()) { | ||||
|                         // There's an SSO authentication ongoing, wait for it to finish and try again.
 | ||||
|                         await CoreApp.waitForSSOAuthentication(); | ||||
| 
 | ||||
|                         return this.request<T>(method, data, preSets, true); | ||||
|                     } | ||||
| 
 | ||||
|                     // Session expired, trigger event.
 | ||||
|                     CoreEvents.trigger(CoreEvents.SESSION_EXPIRED, {}, this.id); | ||||
|                     // Change error message. Try to get data from cache, the event will handle the error.
 | ||||
|                     error.message = Translate.instant('core.lostconnection'); | ||||
|                     useSilentError = true; // Use a silent error, the SESSION_EXPIRED event will display a message if needed.
 | ||||
|                 } else if (error.errorcode === 'userdeleted' || error.errorcode === 'wsaccessuserdeleted') { | ||||
|                     // User deleted, trigger event.
 | ||||
|                     CoreEvents.trigger(CoreEvents.USER_DELETED, { params: data }, this.id); | ||||
|                     error.message = Translate.instant('core.userdeleted'); | ||||
| 
 | ||||
|                     throw new CoreWSError(error); | ||||
|                 } else if (error.errorcode === 'wsaccessusersuspended') { | ||||
|                     // User suspended, trigger event.
 | ||||
|                     CoreEvents.trigger(CoreEvents.USER_SUSPENDED, { params: data }, this.id); | ||||
|                     error.message = Translate.instant('core.usersuspended'); | ||||
| 
 | ||||
|                     throw new CoreWSError(error); | ||||
|                 } else if (error.errorcode === 'wsaccessusernologin') { | ||||
|                     // User suspended, trigger event.
 | ||||
|                     CoreEvents.trigger(CoreEvents.USER_NO_LOGIN, { params: data }, this.id); | ||||
|                     error.message = Translate.instant('core.usernologin'); | ||||
| 
 | ||||
|                     throw new CoreWSError(error); | ||||
|                 } else if (error.errorcode === 'forcepasswordchangenotice') { | ||||
|                     // Password Change Forced, trigger event. Try to get data from cache, the event will handle the error.
 | ||||
|                     CoreEvents.trigger(CoreEvents.PASSWORD_CHANGE_FORCED, {}, this.id); | ||||
|                     error.message = Translate.instant('core.forcepasswordchangenotice'); | ||||
|                     useSilentError = true; // Use a silent error, the change password page already displays the appropiate info.
 | ||||
|                 } else if (error.errorcode === 'usernotfullysetup') { | ||||
|                     // User not fully setup, trigger event. Try to get data from cache, the event will handle the error.
 | ||||
|                     CoreEvents.trigger(CoreEvents.USER_NOT_FULLY_SETUP, {}, this.id); | ||||
|                     error.message = Translate.instant('core.usernotfullysetup'); | ||||
|                     useSilentError = true; // Use a silent error, the complete profile page already displays the appropiate info.
 | ||||
|                 } else if (error.errorcode === 'sitepolicynotagreed') { | ||||
|                     // Site policy not agreed, trigger event.
 | ||||
|                     CoreEvents.trigger(CoreEvents.SITE_POLICY_NOT_AGREED, {}, this.id); | ||||
|                     error.message = Translate.instant('core.login.sitepolicynotagreederror'); | ||||
| 
 | ||||
|                     throw new CoreWSError(error); | ||||
|                 } else if (error.errorcode === 'dmlwriteexception' && CoreTextUtils.hasUnicodeData(data)) { | ||||
|                     if (!this.cleanUnicode) { | ||||
|                         // Try again cleaning unicode.
 | ||||
|                         this.cleanUnicode = true; | ||||
| 
 | ||||
|                         return this.request<T>(method, data, preSets); | ||||
|                     } | ||||
|                     // This should not happen.
 | ||||
|                     error.message = Translate.instant('core.unicodenotsupported'); | ||||
| 
 | ||||
|                     throw new CoreWSError(error); | ||||
|                 } else if (error.exception === 'required_capability_exception' || error.errorcode === 'nopermission' || | ||||
|                         error.errorcode === 'notingroup') { | ||||
|                     // Translate error messages with missing strings.
 | ||||
|                     if (error.message === 'error/nopermission') { | ||||
|                         error.message = Translate.instant('core.nopermissionerror'); | ||||
|                     } else if (error.message === 'error/notingroup') { | ||||
|                         error.message = Translate.instant('core.notingroup'); | ||||
|                     } | ||||
| 
 | ||||
|                     // Save the error instead of deleting the cache entry so the same content is displayed in offline.
 | ||||
|                     this.saveToCache(method, data, error, preSets); | ||||
| 
 | ||||
|                     throw new CoreWSError(error); | ||||
|                 } else if (preSets.cacheErrors && preSets.cacheErrors.indexOf(error.errorcode) != -1) { | ||||
|                     // Save the error instead of deleting the cache entry so the same content is displayed in offline.
 | ||||
|                     this.saveToCache(method, data, error, preSets); | ||||
| 
 | ||||
|                     throw new CoreWSError(error); | ||||
|                 } else if (preSets.emergencyCache !== undefined && !preSets.emergencyCache) { | ||||
|                     this.logger.debug(`WS call '${method}' failed. Emergency cache is forbidden, rejecting.`); | ||||
| 
 | ||||
|                     throw new CoreWSError(error); | ||||
|                 } | ||||
| 
 | ||||
|                 if (preSets.deleteCacheIfWSError && CoreUtils.isWebServiceError(error)) { | ||||
|                     // Delete the cache entry and return the entry. Don't block the user with the delete.
 | ||||
|                     CoreUtils.ignoreErrors(this.deleteFromCache(method, data, preSets)); | ||||
| 
 | ||||
|                     throw new CoreWSError(error); | ||||
|                 } | ||||
| 
 | ||||
|                 this.logger.debug(`WS call '${method}' failed. Trying to use the emergency cache.`); | ||||
|                 preSets.omitExpires = true; | ||||
|                 preSets.getFromCache = true; | ||||
|                 let response: T | WSCachedError; | ||||
|                 let cachedData: WSCachedData<T> | undefined; | ||||
| 
 | ||||
|                 try { | ||||
|                     return await this.getFromCache<T>(method, data, preSets, true); | ||||
|                     cachedData = await this.getFromCache<T>(method, data, preSets, false); | ||||
|                     response = cachedData.response; | ||||
|                 } catch { | ||||
|                     if (useSilentError) { | ||||
|                         throw new CoreSilentError(error.message); | ||||
|                     } | ||||
| 
 | ||||
|                     throw new CoreWSError(error); | ||||
|                     // Not found or expired, call WS.
 | ||||
|                     response = await this.getFromWS<T>(method, data, preSets, wsPreSets); | ||||
|                 } | ||||
| 
 | ||||
|                 if ( | ||||
|                     typeof response === 'object' && response !== null && | ||||
|                     ( | ||||
|                         ('exception' in response && response.exception !== undefined) || | ||||
|                         ('errorcode' in response && response.errorcode !== undefined) | ||||
|                     ) | ||||
|                 ) { | ||||
|                     subject.error(new CoreWSError(response)); | ||||
|                 } else { | ||||
|                     subject.next(<T> response); | ||||
|                 } | ||||
| 
 | ||||
|                 if ( | ||||
|                     preSets.updateInBackground && | ||||
|                     !CoreConstants.CONFIG.disableCallWSInBackground && | ||||
|                     cachedData && | ||||
|                     !cachedData.expirationIgnored && | ||||
|                     cachedData.expirationTime !== undefined && | ||||
|                     Date.now() > cachedData.expirationTime | ||||
|                 ) { | ||||
|                     // Update the data in background.
 | ||||
|                     setTimeout(async () => { | ||||
|                         try { | ||||
|                             preSets = { | ||||
|                                 ...preSets, | ||||
|                                 emergencyCache: false, | ||||
|                             }; | ||||
| 
 | ||||
|                             const newData = await this.getFromWS<T>(method, data, preSets, wsPreSets); | ||||
| 
 | ||||
|                             subject.next(newData); | ||||
|                         } catch (error) { | ||||
|                             // Ignore errors when updating in background.
 | ||||
|                             this.logger.error('Error updating WS data in background', error); | ||||
|                         } finally { | ||||
|                             subject.complete(); | ||||
|                         } | ||||
|                     }); | ||||
|                 } else { | ||||
|                     // No need to update in background, complete the observable.
 | ||||
|                     subject.complete(); | ||||
|                 } | ||||
|             } catch (error) { | ||||
|                 subject.error(error); | ||||
|             } | ||||
|         // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|         }).then((response: any) => { | ||||
|             // Check if the response is an error, this happens if the error was stored in the cache.
 | ||||
|             if (response && (response.exception !== undefined || response.errorcode !== undefined)) { | ||||
|                 throw new CoreWSError(response); | ||||
|         }; | ||||
| 
 | ||||
|         run(); | ||||
| 
 | ||||
|         return subject; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get a request response from WS, if it fails it might try to get it from emergency cache. | ||||
|      * | ||||
|      * @param method The WebService method to be called. | ||||
|      * @param data Arguments to pass to the method. | ||||
|      * @param preSets Extra options related to the site. | ||||
|      * @param wsPreSets Extra options related to the WS call. | ||||
|      * @return Promise resolved with the response. | ||||
|      */ | ||||
|     protected async getFromWS<T = unknown>( | ||||
|         method: string, | ||||
|         data: any, // eslint-disable-line @typescript-eslint/no-explicit-any
 | ||||
|         preSets: CoreSiteWSPreSets, | ||||
|         wsPreSets: CoreWSPreSets, | ||||
|     ): Promise<T> { | ||||
|         if (preSets.forceOffline) { | ||||
|             // Don't call the WS, just fail.
 | ||||
|             throw new CoreError( | ||||
|                 Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             const response = await this.callOrEnqueueWS<T>(method, data, preSets, wsPreSets); | ||||
| 
 | ||||
|             if (preSets.saveToCache) { | ||||
|                 this.saveToCache(method, data, response, preSets); | ||||
|             } | ||||
| 
 | ||||
|             return response; | ||||
|         }); | ||||
|         } catch (error) { | ||||
|             let useSilentError = false; | ||||
| 
 | ||||
|         this.ongoingRequests[cacheId] = promise; | ||||
|             if (CoreUtils.isExpiredTokenError(error)) { | ||||
|                 // Session expired, trigger event.
 | ||||
|                 CoreEvents.trigger(CoreEvents.SESSION_EXPIRED, {}, this.id); | ||||
|                 // Change error message. Try to get data from cache, the event will handle the error.
 | ||||
|                 error.message = Translate.instant('core.lostconnection'); | ||||
|                 useSilentError = true; // Use a silent error, the SESSION_EXPIRED event will display a message if needed.
 | ||||
|             } else if (error.errorcode === 'userdeleted' || error.errorcode === 'wsaccessuserdeleted') { | ||||
|                 // User deleted, trigger event.
 | ||||
|                 CoreEvents.trigger(CoreEvents.USER_DELETED, { params: data }, this.id); | ||||
|                 error.message = Translate.instant('core.userdeleted'); | ||||
| 
 | ||||
|         // Clear ongoing request after setting the promise (just in case it's already resolved).
 | ||||
|         try { | ||||
|             const response = await promise; | ||||
|                 throw new CoreWSError(error); | ||||
|             } else if (error.errorcode === 'wsaccessusersuspended') { | ||||
|                 // User suspended, trigger event.
 | ||||
|                 CoreEvents.trigger(CoreEvents.USER_SUSPENDED, { params: data }, this.id); | ||||
|                 error.message = Translate.instant('core.usersuspended'); | ||||
| 
 | ||||
|             // We pass back a clone of the original object, this may prevent errors if in the callback the object is modified.
 | ||||
|             return CoreUtils.clone(response); | ||||
|         } finally { | ||||
|             // Make sure we don't clear the promise of a newer request that ignores the cache.
 | ||||
|             if (this.ongoingRequests[cacheId] === promise) { | ||||
|                 delete this.ongoingRequests[cacheId]; | ||||
|                 throw new CoreWSError(error); | ||||
|             } else if (error.errorcode === 'wsaccessusernologin') { | ||||
|                 // User suspended, trigger event.
 | ||||
|                 CoreEvents.trigger(CoreEvents.USER_NO_LOGIN, { params: data }, this.id); | ||||
|                 error.message = Translate.instant('core.usernologin'); | ||||
| 
 | ||||
|                 throw new CoreWSError(error); | ||||
|             } else if (error.errorcode === 'forcepasswordchangenotice') { | ||||
|                 // Password Change Forced, trigger event. Try to get data from cache, the event will handle the error.
 | ||||
|                 CoreEvents.trigger(CoreEvents.PASSWORD_CHANGE_FORCED, {}, this.id); | ||||
|                 error.message = Translate.instant('core.forcepasswordchangenotice'); | ||||
|                 useSilentError = true; // Use a silent error, the change password page already displays the appropiate info.
 | ||||
|             } else if (error.errorcode === 'usernotfullysetup') { | ||||
|                 // User not fully setup, trigger event. Try to get data from cache, the event will handle the error.
 | ||||
|                 CoreEvents.trigger(CoreEvents.USER_NOT_FULLY_SETUP, {}, this.id); | ||||
|                 error.message = Translate.instant('core.usernotfullysetup'); | ||||
|                 useSilentError = true; // Use a silent error, the complete profile page already displays the appropiate info.
 | ||||
|             } else if (error.errorcode === 'sitepolicynotagreed') { | ||||
|                 // Site policy not agreed, trigger event.
 | ||||
|                 CoreEvents.trigger(CoreEvents.SITE_POLICY_NOT_AGREED, {}, this.id); | ||||
|                 error.message = Translate.instant('core.login.sitepolicynotagreederror'); | ||||
| 
 | ||||
|                 throw new CoreWSError(error); | ||||
|             } else if (error.errorcode === 'dmlwriteexception' && CoreTextUtils.hasUnicodeData(data)) { | ||||
|                 if (!this.cleanUnicode) { | ||||
|                     // Try again cleaning unicode.
 | ||||
|                     this.cleanUnicode = true; | ||||
| 
 | ||||
|                     return this.request<T>(method, data, preSets); | ||||
|                 } | ||||
|                 // This should not happen.
 | ||||
|                 error.message = Translate.instant('core.unicodenotsupported'); | ||||
| 
 | ||||
|                 throw new CoreWSError(error); | ||||
|             } else if (error.exception === 'required_capability_exception' || error.errorcode === 'nopermission' || | ||||
|                     error.errorcode === 'notingroup') { | ||||
|                 // Translate error messages with missing strings.
 | ||||
|                 if (error.message === 'error/nopermission') { | ||||
|                     error.message = Translate.instant('core.nopermissionerror'); | ||||
|                 } else if (error.message === 'error/notingroup') { | ||||
|                     error.message = Translate.instant('core.notingroup'); | ||||
|                 } | ||||
| 
 | ||||
|                 if (preSets.saveToCache) { | ||||
|                     // Save the error instead of deleting the cache entry so the same content is displayed in offline.
 | ||||
|                     this.saveToCache(method, data, error, preSets); | ||||
|                 } | ||||
| 
 | ||||
|                 throw new CoreWSError(error); | ||||
|             } else if (preSets.cacheErrors && preSets.cacheErrors.indexOf(error.errorcode) != -1) { | ||||
|                 // Save the error instead of deleting the cache entry so the same content is displayed in offline.
 | ||||
|                 this.saveToCache(method, data, error, preSets); | ||||
| 
 | ||||
|                 throw new CoreWSError(error); | ||||
|             } else if (preSets.emergencyCache !== undefined && !preSets.emergencyCache) { | ||||
|                 this.logger.debug(`WS call '${method}' failed. Emergency cache is forbidden, rejecting.`); | ||||
| 
 | ||||
|                 throw new CoreWSError(error); | ||||
|             } | ||||
| 
 | ||||
|             if (preSets.deleteCacheIfWSError && CoreUtils.isWebServiceError(error)) { | ||||
|                 // Delete the cache entry and return the entry. Don't block the user with the delete.
 | ||||
|                 CoreUtils.ignoreErrors(this.deleteFromCache(method, data, preSets)); | ||||
| 
 | ||||
|                 throw new CoreWSError(error); | ||||
|             } | ||||
| 
 | ||||
|             this.logger.debug(`WS call '${method}' failed. Trying to use the emergency cache.`); | ||||
|             preSets = { | ||||
|                 ...preSets, | ||||
|                 omitExpires: true, | ||||
|                 getFromCache: true, | ||||
|             }; | ||||
| 
 | ||||
|             try { | ||||
|                 const cachedData = await this.getFromCache<T>(method, data, preSets, true); | ||||
| 
 | ||||
|                 if ( | ||||
|                     typeof cachedData.response === 'object' && cachedData.response !== null && | ||||
|                     ( | ||||
|                         ('exception' in cachedData.response && cachedData.response.exception !== undefined) || | ||||
|                         ('errorcode' in cachedData.response && cachedData.response.errorcode !== undefined) | ||||
|                     ) | ||||
|                 ) { | ||||
|                     throw new CoreWSError(cachedData.response); | ||||
|                 } | ||||
| 
 | ||||
|                 return <T> cachedData.response; | ||||
|             } catch { | ||||
|                 if (useSilentError) { | ||||
|                     throw new CoreSilentError(error.message); | ||||
|                 } | ||||
| 
 | ||||
|                 throw new CoreWSError(error); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get a request response from WS. | ||||
|      * | ||||
|      * @param method The WebService method to be called. | ||||
|      * @param data Arguments to pass to the method. | ||||
|      * @param preSets Extra options related to the site. | ||||
|      * @param wsPreSets Extra options related to the WS call. | ||||
|      * @return Promise resolved with the response. | ||||
|      */ | ||||
|     protected async callOrEnqueueWS<T = unknown>( | ||||
|         method: string, | ||||
|         data: any, // eslint-disable-line @typescript-eslint/no-explicit-any
 | ||||
|         preSets: CoreSiteWSPreSets, | ||||
|         wsPreSets: CoreWSPreSets, | ||||
|     ): Promise<T> { | ||||
|         // Call the WS.
 | ||||
|         const initialToken = this.token ?? ''; | ||||
| 
 | ||||
|         // Call the WS.
 | ||||
|         if (method !== 'core_webservice_get_site_info') { | ||||
|             // Send the language to use. Do it after checking cache to prevent losing offline data when changing language.
 | ||||
|             // Don't send it to core_webservice_get_site_info, that WS is used to check if Moodle version is supported.
 | ||||
|             data = { | ||||
|                 ...data, | ||||
|                 moodlewssettinglang: preSets.lang ?? await CoreLang.getCurrentLanguage(), | ||||
|             }; | ||||
|             // Moodle uses underscore instead of dash.
 | ||||
|             data.moodlewssettinglang = data.moodlewssettinglang.replace('-', '_'); | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             return await this.callOrEnqueueRequest<T>(method, data, preSets, wsPreSets); | ||||
|         } catch (error) { | ||||
|             if (CoreUtils.isExpiredTokenError(error)) { | ||||
|                 if (initialToken !== this.token) { | ||||
|                     // Token has changed, retry with the new token.
 | ||||
|                     wsPreSets.wsToken = this.token ?? ''; | ||||
| 
 | ||||
|                     return await this.callOrEnqueueRequest<T>(method, data, preSets, wsPreSets); | ||||
|                 } else if (CoreApp.isSSOAuthenticationOngoing()) { | ||||
|                     // There's an SSO authentication ongoing, wait for it to finish and try again.
 | ||||
|                     await CoreApp.waitForSSOAuthentication(); | ||||
| 
 | ||||
|                     return await this.callOrEnqueueRequest<T>(method, data, preSets, wsPreSets); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @ -977,14 +1138,14 @@ export class CoreSite { | ||||
|      * @param preSets Extra options. | ||||
|      * @param emergency Whether it's an "emergency" cache call (WS call failed). | ||||
|      * @param originalData Arguments to pass to the method before being converted to strings. | ||||
|      * @return Promise resolved with the WS response. | ||||
|      * @return Cached data. | ||||
|      */ | ||||
|     protected async getFromCache<T = unknown>( | ||||
|         method: string, | ||||
|         data: any, // eslint-disable-line @typescript-eslint/no-explicit-any
 | ||||
|         preSets: CoreSiteWSPreSets, | ||||
|         emergency?: boolean, | ||||
|     ): Promise<T> { | ||||
|     ): Promise<WSCachedData<T>> { | ||||
|         if (!this.db || !preSets.getFromCache) { | ||||
|             throw new CoreError('Get from cache is disabled.'); | ||||
|         } | ||||
| @ -1020,12 +1181,22 @@ export class CoreSite { | ||||
|         const now = Date.now(); | ||||
|         let expirationTime: number | undefined; | ||||
| 
 | ||||
|         preSets.omitExpires = preSets.omitExpires || preSets.forceOffline || !CoreNetwork.isOnline(); | ||||
|         const forceCache = preSets.omitExpires || preSets.forceOffline || !CoreNetwork.isOnline(); | ||||
| 
 | ||||
|         if (!preSets.omitExpires) { | ||||
|         if (!forceCache) { | ||||
|             expirationTime = entry.expirationTime + this.getExpirationDelay(preSets.updateFrequency); | ||||
| 
 | ||||
|             if (now > expirationTime) { | ||||
|             if (preSets.updateInBackground && !CoreConstants.CONFIG.disableCallWSInBackground) { | ||||
|                 // Use a extended expiration time.
 | ||||
|                 const extendedTime = entry.expirationTime + | ||||
|                     (CoreConstants.CONFIG.callWSInBackgroundExpirationTime ?? CoreConstants.SECONDS_WEEK * 1000); | ||||
| 
 | ||||
|                 if (now > extendedTime) { | ||||
|                     this.logger.debug('Cached element found, but it is expired even for call WS in background.'); | ||||
| 
 | ||||
|                     throw new CoreError('Cache entry is expired.'); | ||||
|                 } | ||||
|             } else if (now > expirationTime) { | ||||
|                 this.logger.debug('Cached element found, but it is expired'); | ||||
| 
 | ||||
|                 throw new CoreError('Cache entry is expired.'); | ||||
| @ -1040,7 +1211,11 @@ export class CoreSite { | ||||
|                 this.logger.info(`Cached element found, id: ${id}. Expires in expires in ${expires} seconds`); | ||||
|             } | ||||
| 
 | ||||
|             return <T> CoreTextUtils.parseJSON(entry.data, {}); | ||||
|             return { | ||||
|                 response: <T> CoreTextUtils.parseJSON(entry.data, {}), | ||||
|                 expirationIgnored: forceCache, | ||||
|                 expirationTime, | ||||
|             }; | ||||
|         } | ||||
| 
 | ||||
|         throw new CoreError('Cache entry not valid.'); | ||||
| @ -1466,54 +1641,65 @@ export class CoreSite { | ||||
| 
 | ||||
|         // Check for an ongoing identical request if we're not ignoring cache.
 | ||||
|         if (cachePreSets.getFromCache && this.ongoingRequests[cacheId] !== undefined) { | ||||
|             const response = await this.ongoingRequests[cacheId]; | ||||
| 
 | ||||
|             return response; | ||||
|             return await firstValueFrom(this.ongoingRequests[cacheId]); | ||||
|         } | ||||
| 
 | ||||
|         const promise = this.getFromCache<CoreSitePublicConfigResponse>(method, {}, cachePreSets, false).catch(async () => { | ||||
|             if (cachePreSets.forceOffline) { | ||||
|                 // Don't call the WS, just fail.
 | ||||
|                 throw new CoreError( | ||||
|                     Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), | ||||
|                 ); | ||||
|             } | ||||
|         const subject = new Subject<CoreSitePublicConfigResponse>(); | ||||
|         const observable = subject.pipe( | ||||
|             // Return a clone of the original object, this may prevent errors if in the callback the object is modified.
 | ||||
|             map((data) => CoreUtils.clone(data)), | ||||
|             finalize(() => { | ||||
|                 // Clear the ongoing request unless it has changed (e.g. a new request that ignores cache).
 | ||||
|                 if (this.ongoingRequests[cacheId] === observable) { | ||||
|                     delete this.ongoingRequests[cacheId]; | ||||
|                 } | ||||
|             }), | ||||
|         ); | ||||
| 
 | ||||
|             // Call the WS.
 | ||||
|             try { | ||||
|                 const config = await this.requestPublicConfig(); | ||||
|         this.ongoingRequests[cacheId] = observable; | ||||
| 
 | ||||
|                 if (cachePreSets.saveToCache) { | ||||
|                     this.saveToCache(method, {}, config, cachePreSets); | ||||
|         this.getFromCache<CoreSitePublicConfigResponse>(method, {}, cachePreSets, false) | ||||
|             .then(cachedData => cachedData.response) | ||||
|             .catch(async () => { | ||||
|                 if (cachePreSets.forceOffline) { | ||||
|                     // Don't call the WS, just fail.
 | ||||
|                     throw new CoreError( | ||||
|                         Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), | ||||
|                     ); | ||||
|                 } | ||||
| 
 | ||||
|                 return config; | ||||
|             } catch (error) { | ||||
|                 cachePreSets.omitExpires = true; | ||||
|                 cachePreSets.getFromCache = true; | ||||
| 
 | ||||
|                 // Call the WS.
 | ||||
|                 try { | ||||
|                     return await this.getFromCache<CoreSitePublicConfigResponse>(method, {}, cachePreSets, true); | ||||
|                 } catch { | ||||
|                     throw error; | ||||
|                     const config = await this.requestPublicConfig(); | ||||
| 
 | ||||
|                     if (cachePreSets.saveToCache) { | ||||
|                         this.saveToCache(method, {}, config, cachePreSets); | ||||
|                     } | ||||
| 
 | ||||
|                     return config; | ||||
|                 } catch (error) { | ||||
|                     cachePreSets.omitExpires = true; | ||||
|                     cachePreSets.getFromCache = true; | ||||
| 
 | ||||
|                     try { | ||||
|                         const cachedData = await this.getFromCache<CoreSitePublicConfigResponse>(method, {}, cachePreSets, true); | ||||
| 
 | ||||
|                         return cachedData.response; | ||||
|                     } catch { | ||||
|                         throw error; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|             }).then((response) => { | ||||
|                 // The app doesn't store exceptions for this call, it's safe to assume type CoreSitePublicConfigResponse.
 | ||||
|                 subject.next(<CoreSitePublicConfigResponse> response); | ||||
|                 subject.complete(); | ||||
| 
 | ||||
|         this.ongoingRequests[cacheId] = promise; | ||||
|                 return; | ||||
|             }).catch((error) => { | ||||
|                 subject.error(error); | ||||
|             }); | ||||
| 
 | ||||
|         // Clear ongoing request after setting the promise (just in case it's already resolved).
 | ||||
|         try { | ||||
|             const response = await promise; | ||||
| 
 | ||||
|             // We pass back a clone of the original object, this may prevent errors if in the callback the object is modified.
 | ||||
|             return response; | ||||
|         } finally { | ||||
|             // Make sure we don't clear the promise of a newer request that ignores the cache.
 | ||||
|             if (this.ongoingRequests[cacheId] === promise) { | ||||
|                 delete this.ongoingRequests[cacheId]; | ||||
|             } | ||||
|         } | ||||
|         return firstValueFrom(observable); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -1704,16 +1890,28 @@ export class CoreSite { | ||||
|     getConfig(name?: undefined, ignoreCache?: boolean): Promise<CoreSiteConfig>; | ||||
|     getConfig(name: string, ignoreCache?: boolean): Promise<string>; | ||||
|     getConfig(name?: string, ignoreCache?: boolean): Promise<string | CoreSiteConfig> { | ||||
|         return firstValueFrom( | ||||
|             this.getConfigObservable(<string> name, ignoreCache ? CoreSitesReadingStrategy.ONLY_NETWORK : undefined), | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the config of this site. | ||||
|      * It is recommended to use getStoredConfig instead since it's faster and doesn't use network. | ||||
|      * | ||||
|      * @param name Name of the setting to get. If not set or false, all settings will be returned. | ||||
|      * @param readingStrategy Reading strategy. | ||||
|      * @return Observable returning site config. | ||||
|      */ | ||||
|     getConfigObservable(name?: undefined, readingStrategy?: CoreSitesReadingStrategy): WSObservable<CoreSiteConfig>; | ||||
|     getConfigObservable(name: string, readingStrategy?: CoreSitesReadingStrategy): WSObservable<string>; | ||||
|     getConfigObservable(name?: string, readingStrategy?: CoreSitesReadingStrategy): WSObservable<string | CoreSiteConfig> { | ||||
|         const preSets: CoreSiteWSPreSets = { | ||||
|             cacheKey: this.getConfigCacheKey(), | ||||
|             ...CoreSites.getReadingStrategyPreSets(readingStrategy), | ||||
|         }; | ||||
| 
 | ||||
|         if (ignoreCache) { | ||||
|             preSets.getFromCache = false; | ||||
|             preSets.emergencyCache = false; | ||||
|         } | ||||
| 
 | ||||
|         return this.read('tool_mobile_get_config', {}, preSets).then((config: CoreSiteConfigResponse) => { | ||||
|         return this.readObservable<CoreSiteConfigResponse>('tool_mobile_get_config', {}, preSets).pipe(map(config => { | ||||
|             if (name) { | ||||
|                 // Return the requested setting.
 | ||||
|                 for (const x in config.settings) { | ||||
| @ -1732,7 +1930,7 @@ export class CoreSite { | ||||
| 
 | ||||
|                 return settings; | ||||
|             } | ||||
|         }); | ||||
|         })); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -2193,6 +2391,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<T, O extends ObservableInput<any>>( | ||||
|     readingStrategy: CoreSitesReadingStrategy | undefined, | ||||
|     callback: (data: T, readingStrategy?: CoreSitesReadingStrategy) => O, | ||||
| ): OperatorFunction<T, ObservedValueOf<O>> { | ||||
|     return (source: WSObservable<T>) => new Observable<{ data: T; readingStrategy?: CoreSitesReadingStrategy }>(subscriber => { | ||||
|         let firstValue = true; | ||||
|         let isCompleted = false; | ||||
| 
 | ||||
|         return source.subscribe({ | ||||
|             next: async (value) => { | ||||
|                 if (readingStrategy !== CoreSitesReadingStrategy.STALE_WHILE_REVALIDATE) { | ||||
|                     // 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. | ||||
|  */ | ||||
| @ -2311,6 +2573,12 @@ export type CoreSiteWSPreSets = { | ||||
|      * can cause the request to fail (see PHP's max_input_vars). | ||||
|      */ | ||||
|     splitRequest?: CoreWSPreSetsSplitRequest; | ||||
| 
 | ||||
|     /** | ||||
|      * If true, the app will return cached data even if it's expired and then it'll call the WS in the background. | ||||
|      * Only enabled if CoreConstants.CONFIG.disableCallWSInBackground isn't true. | ||||
|      */ | ||||
|     updateInBackground?: boolean; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
| @ -2511,3 +2779,28 @@ export type CoreSiteStoreLastViewedOptions = { | ||||
|     data?: string; // Other data.
 | ||||
|     timeaccess?: number; // Accessed time. If not set, current time.
 | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Info about cached data. | ||||
|  */ | ||||
| type WSCachedData<T> = { | ||||
|     response: T | WSCachedError; // The WS response data, or an error if the WS returned an error and it was cached.
 | ||||
|     expirationIgnored: boolean; // Whether the expiration time was ignored.
 | ||||
|     expirationTime?: number; // Entry expiration time (only if not ignored).
 | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Error data stored in cache. | ||||
|  */ | ||||
| type WSCachedError = { | ||||
|     exception?: string; | ||||
|     errorcode?: string; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Observable returned when calling WebServices. | ||||
|  * If the request uses the "update in background" feature, it will return 2 values: first the cached one, and then the one | ||||
|  * coming from the server. After this, it will complete. | ||||
|  * Otherwise, it will only return 1 value, either coming from cache or from the server. After this, it will complete. | ||||
|  */ | ||||
| export type WSObservable<T> = Observable<T>; | ||||
|  | ||||
| @ -63,6 +63,7 @@ import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides'; | ||||
| import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-navigation-tour'; | ||||
| import { CoreMessageComponent } from './message/message'; | ||||
| import { CoreGroupSelectorComponent } from './group-selector/group-selector'; | ||||
| import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-button-modal'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     declarations: [ | ||||
| @ -108,6 +109,7 @@ import { CoreGroupSelectorComponent } from './group-selector/group-selector'; | ||||
|         CoreSpacerComponent, | ||||
|         CoreHorizontalScrollControlsComponent, | ||||
|         CoreSwipeNavigationTourComponent, | ||||
|         CoreRefreshButtonModalComponent, | ||||
|     ], | ||||
|     imports: [ | ||||
|         CommonModule, | ||||
| @ -160,6 +162,7 @@ import { CoreGroupSelectorComponent } from './group-selector/group-selector'; | ||||
|         CoreSpacerComponent, | ||||
|         CoreHorizontalScrollControlsComponent, | ||||
|         CoreSwipeNavigationTourComponent, | ||||
|         CoreRefreshButtonModalComponent, | ||||
|     ], | ||||
| }) | ||||
| export class CoreComponentsModule {} | ||||
|  | ||||
| @ -0,0 +1,4 @@ | ||||
| <ion-chip class="clickable fab-chip" color="info" (click)="closeModal()"> | ||||
|     <ion-icon name="fas-sync" aria-hidden="true"></ion-icon> | ||||
|     {{ 'core.refresh' | translate }} | ||||
| </ion-chip> | ||||
| @ -0,0 +1,7 @@ | ||||
| @import "~theme/globals"; | ||||
| 
 | ||||
| :host { | ||||
|     ion-chip { | ||||
|         @include margin(auto, auto, calc(12px + var(--bottom-tabs-size, 0px)), auto); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,34 @@ | ||||
| // (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 { Component } from '@angular/core'; | ||||
| import { ModalController } from '@singletons'; | ||||
| 
 | ||||
| /** | ||||
|  * Modal that displays a refresh button. | ||||
|  */ | ||||
| @Component({ | ||||
|     templateUrl: 'refresh-button-modal.html', | ||||
|     styleUrls: ['refresh-button-modal.scss'], | ||||
| }) | ||||
| export class CoreRefreshButtonModalComponent { | ||||
| 
 | ||||
|     /** | ||||
|      * Close modal. | ||||
|      */ | ||||
|     closeModal(): void { | ||||
|         ModalController.dismiss(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -21,6 +21,8 @@ import { CoreCourseBlock } from '../../course/services/course'; | ||||
| import { Params } from '@angular/router'; | ||||
| import { ContextLevel } from '@/core/constants'; | ||||
| import { CoreNavigationOptions } from '@services/navigator'; | ||||
| import { AsyncComponent } from '@classes/async-component'; | ||||
| import { CorePromisedValue } from '@classes/promised-value'; | ||||
| 
 | ||||
| /** | ||||
|  * Template class to easily create components for blocks. | ||||
| @ -28,7 +30,7 @@ import { CoreNavigationOptions } from '@services/navigator'; | ||||
| @Component({ | ||||
|     template: '', | ||||
| }) | ||||
| export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent { | ||||
| export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent, AsyncComponent { | ||||
| 
 | ||||
|     @Input() title!: string; // The block title.
 | ||||
|     @Input() block!: CoreCourseBlock; // The block to render.
 | ||||
| @ -38,8 +40,9 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon | ||||
|     @Input() linkParams?: Params; // Link params to go when clicked.
 | ||||
|     @Input() navOptions?: CoreNavigationOptions; // Navigation options.
 | ||||
| 
 | ||||
|     loaded = false; // If the component has been loaded.
 | ||||
|     loaded = false; // If false, the UI should display a loading.
 | ||||
|     protected fetchContentDefaultError = ''; // Default error to show when loading contents.
 | ||||
|     protected onReadyPromise = new CorePromisedValue<void>(); | ||||
| 
 | ||||
|     protected logger: CoreLogger; | ||||
| 
 | ||||
| @ -65,9 +68,14 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon | ||||
|     /** | ||||
|      * Perform the refresh content function. | ||||
|      * | ||||
|      * @param showLoading Whether to show loading. | ||||
|      * @return Resolved when done. | ||||
|      */ | ||||
|     protected async refreshContent(): Promise<void> { | ||||
|     protected async refreshContent(showLoading?: boolean): Promise<void> { | ||||
|         if (showLoading) { | ||||
|             this.loaded = false; | ||||
|         } | ||||
| 
 | ||||
|         // Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs.
 | ||||
|         try { | ||||
|             await this.invalidateContent(); | ||||
| @ -102,6 +110,7 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon | ||||
|         } | ||||
| 
 | ||||
|         this.loaded = true; | ||||
|         this.onReadyPromise.resolve(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -113,6 +122,28 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Reload content without invalidating data. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async reloadContent(): Promise<void> { | ||||
|         if (!this.loaded) { | ||||
|             // Content being loaded, don't do anything.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.loaded = false; | ||||
|         await this.loadContent(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async ready(): Promise<void> { | ||||
|         return await this.onReadyPromise; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | ||||
| @ -12,7 +12,7 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, Input, OnInit, ViewChild, OnDestroy, DoCheck, KeyValueDiffers, KeyValueDiffer, Type } from '@angular/core'; | ||||
| import { Component, Input, ViewChild, OnDestroy, Type, OnChanges, SimpleChanges } from '@angular/core'; | ||||
| import { CoreBlockDelegate } from '../../services/block-delegate'; | ||||
| import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| @ -27,7 +27,7 @@ import type { ICoreBlockComponent } from '@features/block/classes/base-block-com | ||||
|     templateUrl: 'core-block.html', | ||||
|     styleUrls: ['block.scss'], | ||||
| }) | ||||
| export class CoreBlockComponent implements OnInit, OnDestroy, DoCheck { | ||||
| export class CoreBlockComponent implements OnChanges, OnDestroy { | ||||
| 
 | ||||
|     @ViewChild(CoreDynamicComponent) dynamicComponent?: CoreDynamicComponent<ICoreBlockComponent>; | ||||
| 
 | ||||
| @ -40,52 +40,26 @@ export class CoreBlockComponent implements OnInit, OnDestroy, DoCheck { | ||||
|     data: Record<string, unknown> = {}; // Data to pass to the component.
 | ||||
|     class?: string; // CSS class to apply to the block.
 | ||||
|     loaded = false; | ||||
| 
 | ||||
|     blockSubscription?: Subscription; | ||||
| 
 | ||||
|     protected differ: KeyValueDiffer<unknown, unknown>; // To detect changes in the data input.
 | ||||
| 
 | ||||
|     constructor( | ||||
|         differs: KeyValueDiffers, | ||||
|     ) { | ||||
|         this.differ = differs.find([]).create(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         if (!this.block) { | ||||
|             this.loaded = true; | ||||
| 
 | ||||
|             return; | ||||
|     ngOnChanges(changes: SimpleChanges): void { | ||||
|         if (changes.block && this.block?.visible) { | ||||
|             this.updateBlock(); | ||||
|         } | ||||
| 
 | ||||
|         if (this.block.visible) { | ||||
|             // Get the data to render the block.
 | ||||
|             this.initBlock(); | ||||
|         if (this.data && changes.extraData) { | ||||
|             this.data = Object.assign(this.data, this.extraData || {}); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Detect and act upon changes that Angular can’t or won’t detect on its own (objects and arrays). | ||||
|      * Get block display data and initialises or updates the block. If the block is not supported at the moment, try again if the | ||||
|      * available blocks are updated (because it comes from a site plugin). | ||||
|      */ | ||||
|     ngDoCheck(): void { | ||||
|         if (this.data) { | ||||
|             // Check if there's any change in the extraData object.
 | ||||
|             const changes = this.differ.diff(this.extraData); | ||||
|             if (changes) { | ||||
|                 this.data = Object.assign(this.data, this.extraData || {}); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get block display data and initialises the block once this is available. If the block is not | ||||
|      * supported at the moment, try again if the available blocks are updated (because it comes | ||||
|      * from a site plugin). | ||||
|      */ | ||||
|     async initBlock(): Promise<void> { | ||||
|     async updateBlock(): Promise<void> { | ||||
|         try { | ||||
|             const data = await CoreBlockDelegate.getBlockDisplayData(this.block, this.contextLevel, this.instanceId); | ||||
| 
 | ||||
| @ -97,7 +71,7 @@ export class CoreBlockComponent implements OnInit, OnDestroy, DoCheck { | ||||
|                     (): void => { | ||||
|                         this.blockSubscription?.unsubscribe(); | ||||
|                         delete this.blockSubscription; | ||||
|                         this.initBlock(); | ||||
|                         this.updateBlock(); | ||||
|                     }, | ||||
|                 ); | ||||
| 
 | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| <!-- Only render the block if it's supported. --> | ||||
| <ion-card *ngIf="loaded && componentClass && block.visible" class="{{class}}"> | ||||
| <ion-card *ngIf="loaded && componentClass && block && block.visible" class="{{class}}"> | ||||
|     <core-dynamic-component [component]="componentClass" [data]="data"></core-dynamic-component> | ||||
| </ion-card> | ||||
|  | ||||
| @ -21,7 +21,7 @@ import { CoreLogger } from '@singletons/logger'; | ||||
| import { CoreSitesCommonWSOptions, CoreSites, CoreSitesReadingStrategy } from '@services/sites'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreSiteWSPreSets, CoreSite } from '@classes/site'; | ||||
| import { CoreSiteWSPreSets, CoreSite, WSObservable } from '@classes/site'; | ||||
| import { CoreConstants } from '@/core/constants'; | ||||
| import { makeSingleton, Translate } from '@singletons'; | ||||
| import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; | ||||
| @ -54,6 +54,8 @@ import { CoreDatabaseCachingStrategy } from '@classes/database/database-table-pr | ||||
| import { SQLiteDB } from '@classes/sqlitedb'; | ||||
| import { CorePlatform } from '@services/platform'; | ||||
| import { CoreTime } from '@singletons/time'; | ||||
| import { asyncObservable, firstValueFrom } from '@/core/utils/rxjs'; | ||||
| import { map } from 'rxjs/operators'; | ||||
| 
 | ||||
| const ROOT_CACHE_KEY = 'mmCourse:'; | ||||
| 
 | ||||
| @ -402,19 +404,36 @@ export class CoreCourseProvider { | ||||
|      * @return Promise resolved with the list of blocks. | ||||
|      * @since 3.7 | ||||
|      */ | ||||
|     async getCourseBlocks(courseId: number, siteId?: string): Promise<CoreCourseBlock[]> { | ||||
|         const site = await CoreSites.getSite(siteId); | ||||
|         const params: CoreBlockGetCourseBlocksWSParams = { | ||||
|             courseid: courseId, | ||||
|             returncontents: true, | ||||
|         }; | ||||
|         const preSets: CoreSiteWSPreSets = { | ||||
|             cacheKey: this.getCourseBlocksCacheKey(courseId), | ||||
|             updateFrequency: CoreSite.FREQUENCY_RARELY, | ||||
|         }; | ||||
|         const result = await site.read<CoreCourseBlocksWSResponse>('core_block_get_course_blocks', params, preSets); | ||||
|     getCourseBlocks(courseId: number, siteId?: string): Promise<CoreCourseBlock[]> { | ||||
|         return firstValueFrom(this.getCourseBlocksObservable(courseId, { siteId })); | ||||
|     } | ||||
| 
 | ||||
|         return result.blocks || []; | ||||
|     /** | ||||
|      * Get course blocks. | ||||
|      * | ||||
|      * @param courseId Course ID. | ||||
|      * @param options Options. | ||||
|      * @return Observable that returns the blocks. | ||||
|      * @since 3.7 | ||||
|      */ | ||||
|     getCourseBlocksObservable(courseId: number, options: CoreSitesCommonWSOptions = {}): WSObservable<CoreCourseBlock[]> { | ||||
|         return asyncObservable(async () => { | ||||
|             const site = await CoreSites.getSite(options.siteId); | ||||
| 
 | ||||
|             const params: CoreBlockGetCourseBlocksWSParams = { | ||||
|                 courseid: courseId, | ||||
|                 returncontents: true, | ||||
|             }; | ||||
|             const preSets: CoreSiteWSPreSets = { | ||||
|                 cacheKey: this.getCourseBlocksCacheKey(courseId), | ||||
|                 updateFrequency: CoreSite.FREQUENCY_RARELY, | ||||
|                 ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), | ||||
|             }; | ||||
| 
 | ||||
|             return site.readObservable<CoreCourseBlocksWSResponse>('core_block_get_course_blocks', params, preSets).pipe( | ||||
|                 map(result => result.blocks), | ||||
|             ); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -908,7 +927,7 @@ export class CoreCourseProvider { | ||||
|      * @param includeStealthModules Whether to include stealth modules. Defaults to true. | ||||
|      * @return The reject contains the error message, else contains the sections. | ||||
|      */ | ||||
|     async getSections( | ||||
|     getSections( | ||||
|         courseId: number, | ||||
|         excludeModules: boolean = false, | ||||
|         excludeContents: boolean = false, | ||||
| @ -916,63 +935,83 @@ export class CoreCourseProvider { | ||||
|         siteId?: string, | ||||
|         includeStealthModules: boolean = true, | ||||
|     ): Promise<CoreCourseWSSection[]> { | ||||
| 
 | ||||
|         const site = await CoreSites.getSite(siteId); | ||||
|         preSets = preSets || {}; | ||||
|         preSets.cacheKey = this.getSectionsCacheKey(courseId); | ||||
|         preSets.updateFrequency = preSets.updateFrequency || CoreSite.FREQUENCY_RARELY; | ||||
| 
 | ||||
|         const params: CoreCourseGetContentsParams = { | ||||
|             courseid: courseId, | ||||
|         }; | ||||
|         params.options = [ | ||||
|             { | ||||
|                 name: 'excludemodules', | ||||
|                 value: excludeModules, | ||||
|             }, | ||||
|             { | ||||
|                 name: 'excludecontents', | ||||
|                 value: excludeContents, | ||||
|             }, | ||||
|         ]; | ||||
| 
 | ||||
|         if (this.canRequestStealthModules(site)) { | ||||
|             params.options.push({ | ||||
|                 name: 'includestealthmodules', | ||||
|                 value: includeStealthModules, | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         let sections: CoreCourseGetContentsWSSection[]; | ||||
|         try { | ||||
|             sections = await site.read('core_course_get_contents', params, preSets); | ||||
|         } catch { | ||||
|             // Error getting the data, it could fail because we added a new parameter and the call isn't cached.
 | ||||
|             // Retry without the new parameter and forcing cache.
 | ||||
|             preSets.omitExpires = true; | ||||
|             params.options.splice(-1, 1); | ||||
|             sections = await site.read('core_course_get_contents', params, preSets); | ||||
|         } | ||||
| 
 | ||||
|         const siteHomeId = site.getSiteHomeId(); | ||||
|         let showSections = true; | ||||
|         if (courseId == siteHomeId) { | ||||
|             const storedNumSections = site.getStoredConfig('numsections'); | ||||
|             showSections = storedNumSections !== undefined && !!storedNumSections; | ||||
|         } | ||||
| 
 | ||||
|         if (showSections !== undefined && !showSections && sections.length > 0) { | ||||
|             // Get only the last section (Main menu block section).
 | ||||
|             sections.pop(); | ||||
|         } | ||||
| 
 | ||||
|         // Add course to all modules.
 | ||||
|         return sections.map((section) => ({ | ||||
|             ...section, | ||||
|             modules: section.modules.map((module) => this.addAdditionalModuleData(module, courseId, section.id)), | ||||
|         return firstValueFrom(this.getSectionsObservable(courseId, { | ||||
|             excludeModules, | ||||
|             excludeContents, | ||||
|             includeStealthModules, | ||||
|             preSets, | ||||
|             siteId, | ||||
|         })); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the course sections. | ||||
|      * | ||||
|      * @param courseId The course ID. | ||||
|      * @param options Options. | ||||
|      * @return Observable that returns the sections. | ||||
|      */ | ||||
|     getSectionsObservable( | ||||
|         courseId: number, | ||||
|         options: CoreCourseGetSectionsOptions = {}, | ||||
|     ): WSObservable<CoreCourseWSSection[]> { | ||||
|         options.includeStealthModules = options.includeStealthModules ?? true; | ||||
| 
 | ||||
|         return asyncObservable(async () => { | ||||
|             const site = await CoreSites.getSite(options.siteId); | ||||
| 
 | ||||
|             const preSets: CoreSiteWSPreSets = { | ||||
|                 ...options.preSets, | ||||
|                 cacheKey: this.getSectionsCacheKey(courseId), | ||||
|                 updateFrequency: CoreSite.FREQUENCY_RARELY, | ||||
|                 ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), | ||||
|             }; | ||||
| 
 | ||||
|             const params: CoreCourseGetContentsParams = { | ||||
|                 courseid: courseId, | ||||
|             }; | ||||
|             params.options = [ | ||||
|                 { | ||||
|                     name: 'excludemodules', | ||||
|                     value: !!options.excludeModules, | ||||
|                 }, | ||||
|                 { | ||||
|                     name: 'excludecontents', | ||||
|                     value: !!options.excludeContents, | ||||
|                 }, | ||||
|             ]; | ||||
| 
 | ||||
|             if (this.canRequestStealthModules(site)) { | ||||
|                 params.options.push({ | ||||
|                     name: 'includestealthmodules', | ||||
|                     value: !!options.includeStealthModules, | ||||
|                 }); | ||||
|             } | ||||
| 
 | ||||
|             return site.readObservable<CoreCourseGetContentsWSSection[]>('core_course_get_contents', params, preSets).pipe( | ||||
|                 map(sections => { | ||||
|                     const siteHomeId = site.getSiteHomeId(); | ||||
|                     let showSections = true; | ||||
|                     if (courseId == siteHomeId) { | ||||
|                         const storedNumSections = site.getStoredConfig('numsections'); | ||||
|                         showSections = storedNumSections !== undefined && !!storedNumSections; | ||||
|                     } | ||||
| 
 | ||||
|                     if (showSections !== undefined && !showSections && sections.length > 0) { | ||||
|                         // Get only the last section (Main menu block section).
 | ||||
|                         sections.pop(); | ||||
|                     } | ||||
| 
 | ||||
|                     // Add course to all modules.
 | ||||
|                     return sections.map((section) => ({ | ||||
|                         ...section, | ||||
|                         modules: section.modules.map((module) => this.addAdditionalModuleData(module, courseId, section.id)), | ||||
|                     })); | ||||
|                 }), | ||||
|             ); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get cache key for section WS call. | ||||
|      * | ||||
| @ -1933,3 +1972,13 @@ export type CoreCourseStoreModuleViewedOptions = { | ||||
|     timeaccess?: number; | ||||
|     siteId?: string; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Options for getSections. | ||||
|  */ | ||||
| export type CoreCourseGetSectionsOptions = CoreSitesCommonWSOptions & { | ||||
|     excludeModules?: boolean; | ||||
|     excludeContents?: boolean; | ||||
|     includeStealthModules?: boolean; // Defaults to true.
 | ||||
|     preSets?: CoreSiteWSPreSets; | ||||
| }; | ||||
|  | ||||
| @ -14,6 +14,9 @@ | ||||
| 
 | ||||
| import { AddonBlockMyOverviewComponent } from '@addons/block/myoverview/components/myoverview/myoverview'; | ||||
| import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; | ||||
| import { AsyncComponent } from '@classes/async-component'; | ||||
| import { PageLoadsManager } from '@classes/page-loads-manager'; | ||||
| import { CorePromisedValue } from '@classes/promised-value'; | ||||
| import { CoreBlockComponent } from '@features/block/components/block/block'; | ||||
| import { CoreCourseBlock } from '@features/course/services/course'; | ||||
| import { CoreCoursesDashboard, CoreCoursesDashboardProvider } from '@features/courses/services/dashboard'; | ||||
| @ -23,6 +26,7 @@ import { CoreSites } from '@services/sites'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| import { CoreCourses } from '../../services/courses'; | ||||
| 
 | ||||
| /** | ||||
| @ -32,8 +36,12 @@ import { CoreCourses } from '../../services/courses'; | ||||
|     selector: 'page-core-courses-my', | ||||
|     templateUrl: 'my.html', | ||||
|     styleUrls: ['my.scss'], | ||||
|     providers: [{ | ||||
|         provide: PageLoadsManager, | ||||
|         useClass: PageLoadsManager, | ||||
|     }], | ||||
| }) | ||||
| export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { | ||||
| export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy, AsyncComponent { | ||||
| 
 | ||||
|     @ViewChild(CoreBlockComponent) block!: CoreBlockComponent; | ||||
| 
 | ||||
| @ -47,8 +55,10 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { | ||||
|     hasSideBlocks = false; | ||||
| 
 | ||||
|     protected updateSiteObserver: CoreEventObserver; | ||||
|     protected onReadyPromise = new CorePromisedValue<void>(); | ||||
|     protected loadsManagerSubscription: Subscription; | ||||
| 
 | ||||
|     constructor() { | ||||
|     constructor(protected loadsManager: PageLoadsManager) { | ||||
|         // Refresh the enabled flags if site is updated.
 | ||||
|         this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { | ||||
|             this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite(); | ||||
| @ -57,6 +67,11 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { | ||||
|         }, CoreSites.getCurrentSiteId()); | ||||
| 
 | ||||
|         this.userId = CoreSites.getCurrentSiteUserId(); | ||||
| 
 | ||||
|         this.loadsManagerSubscription = this.loadsManager.onRefreshPage.subscribe(() => { | ||||
|             this.loaded = false; | ||||
|             this.loadContent(); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -70,19 +85,27 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|         this.loadSiteName(); | ||||
| 
 | ||||
|         this.loadContent(); | ||||
|         this.loadContent(true); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load my overview block instance. | ||||
|      * Load data. | ||||
|      * | ||||
|      * @param firstLoad Whether it's the first load. | ||||
|      */ | ||||
|     protected async loadContent(): Promise<void> { | ||||
|     protected async loadContent(firstLoad = false): Promise<void> { | ||||
|         const loadWatcher = this.loadsManager.startPageLoad(this, !!firstLoad); | ||||
|         const available = await CoreCoursesDashboard.isAvailable(); | ||||
|         const disabled = await CoreCourses.isMyCoursesDisabled(); | ||||
| 
 | ||||
|         if (available && !disabled) { | ||||
|             try { | ||||
|                 const blocks = await CoreCoursesDashboard.getDashboardBlocks(undefined, undefined, this.myPageCourses); | ||||
|                 const blocks = await loadWatcher.watchRequest( | ||||
|                     CoreCoursesDashboard.getDashboardBlocksObservable({ | ||||
|                         myPage: this.myPageCourses, | ||||
|                         readingStrategy: loadWatcher.getReadingStrategy(), | ||||
|                     }), | ||||
|                 ); | ||||
| 
 | ||||
|                 // My overview block should always be in main blocks, but check side blocks too just in case.
 | ||||
|                 this.loadedBlock = blocks.mainBlocks.concat(blocks.sideBlocks).find((block) => block.name == 'myoverview'); | ||||
| @ -106,6 +129,7 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { | ||||
|         } | ||||
| 
 | ||||
|         this.loaded = true; | ||||
|         this.onReadyPromise.resolve(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -138,7 +162,7 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|         // Invalidate the blocks.
 | ||||
|         if (this.myOverviewBlock) { | ||||
|             promises.push(CoreUtils.ignoreErrors(this.myOverviewBlock.doRefresh())); | ||||
|             promises.push(CoreUtils.ignoreErrors(this.myOverviewBlock.invalidateContent())); | ||||
|         } | ||||
| 
 | ||||
|         Promise.all(promises).finally(() => { | ||||
| @ -153,6 +177,14 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.updateSiteObserver?.off(); | ||||
|         this.loadsManagerSubscription.unsubscribe(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async ready(): Promise<void> { | ||||
|         return await this.onReadyPromise; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -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 { of } from 'rxjs'; | ||||
| import { firstValueFrom, zipIncludingComplete } from '@/core/utils/rxjs'; | ||||
| import { catchError, map } from 'rxjs/operators'; | ||||
| import { chainRequests, WSObservable } 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<void> { | ||||
|         if (!courses.length ) { | ||||
|             // No courses or cannot get the data, stop.
 | ||||
|             return; | ||||
|     loadCoursesExtraInfo( | ||||
|         courses: CoreEnrolledCourseDataWithExtraInfo[], | ||||
|         loadCategoryNames: boolean = false, | ||||
|     ): Promise<CoreEnrolledCourseDataWithExtraInfo[]> { | ||||
|         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 = {}, | ||||
|     ): WSObservable<CoreEnrolledCourseDataWithExtraInfo[]> { | ||||
|         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,77 @@ 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<CoreEnrolledCourseDataWithOptions[]> { | ||||
| 
 | ||||
|         let courses: CoreEnrolledCourseDataWithOptions[] = await CoreCourses.getUserCourses( | ||||
|             false, | ||||
|             options.siteId, | ||||
|             options.readingStrategy, | ||||
|         ); | ||||
|         if (courses.length <= 0) { | ||||
|             return []; | ||||
|         } | ||||
| 
 | ||||
|         const promises: Promise<void>[] = []; | ||||
|         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; | ||||
|     ): Promise<CoreEnrolledCourseDataWithExtraInfoAndOptions[]> { | ||||
|         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 = {}, | ||||
|     ): WSObservable<CoreEnrolledCourseDataWithExtraInfoAndOptions[]> { | ||||
| 
 | ||||
|         await Promise.all(promises); | ||||
|         return CoreCourses.getUserCoursesObservable(options).pipe( | ||||
|             chainRequests(options.readingStrategy, (courses, newReadingStrategy) => { | ||||
|                 if (courses.length <= 0) { | ||||
|                     return of([]); | ||||
|                 } | ||||
| 
 | ||||
|         switch (filter) { | ||||
|                 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); | ||||
| 
 | ||||
|                 return zipIncludingComplete( | ||||
|                     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 +326,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 = {}, | ||||
|     ): WSObservable<CoreEnrolledCourseDataWithExtraInfo> { | ||||
|         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 +472,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.
 | ||||
| }; | ||||
|  | ||||
| @ -14,13 +14,15 @@ | ||||
| 
 | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; | ||||
| import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; | ||||
| import { CoreSite, CoreSiteWSPreSets, WSObservable } from '@classes/site'; | ||||
| import { makeSingleton } from '@singletons'; | ||||
| import { CoreStatusWithWarningsWSResponse, CoreWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; | ||||
| 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, zipIncludingComplete } from '@/core/utils/rxjs'; | ||||
| import { of } 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<number>; | ||||
|     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<CoreCourseSearchedData[]> { | ||||
|         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 = {}, | ||||
|     ): WSObservable<CoreCourseSearchedData[]> { | ||||
|         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<CoreCourseGetCoursesByFieldWSResponse>('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<CoreCourseGetCoursesByFieldWSResponse>( | ||||
|                 '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; | ||||
|                 }); | ||||
|             })); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| @ -553,7 +585,7 @@ export class CoreCoursesProvider { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get courses matching the given custom field. Only works in online. | ||||
|      * Get courses matching the given custom field. By default it will try not to use cache. | ||||
|      * | ||||
|      * @param customFieldName Custom field name. | ||||
|      * @param customFieldValue Custom field value. | ||||
| @ -561,30 +593,49 @@ export class CoreCoursesProvider { | ||||
|      * @return Promise resolved with the list of courses. | ||||
|      * @since 3.8 | ||||
|      */ | ||||
|     async getEnrolledCoursesByCustomField( | ||||
|     getEnrolledCoursesByCustomField( | ||||
|         customFieldName: string, | ||||
|         customFieldValue: string, | ||||
|         siteId?: string, | ||||
|     ): Promise<CoreCourseSummaryData[]> { | ||||
|         const site = await CoreSites.getSite(siteId); | ||||
|         const params: CoreCourseGetEnrolledCoursesByTimelineClassificationWSParams = { | ||||
|             classification: 'customfield', | ||||
|             customfieldname: customFieldName, | ||||
|             customfieldvalue: customFieldValue, | ||||
|         }; | ||||
|         const preSets: CoreSiteWSPreSets = { | ||||
|             getFromCache: false, | ||||
|         }; | ||||
|         const courses = await site.read<CoreCourseGetEnrolledCoursesByTimelineClassificationWSResponse>( | ||||
|             'core_course_get_enrolled_courses_by_timeline_classification', | ||||
|             params, | ||||
|             preSets, | ||||
|         ); | ||||
|         if (courses.courses) { | ||||
|             return courses.courses; | ||||
|         } | ||||
|         return firstValueFrom(this.getEnrolledCoursesByCustomFieldObservable(customFieldName, customFieldValue, { | ||||
|             readingStrategy: CoreSitesReadingStrategy.PREFER_NETWORK, | ||||
|             siteId, | ||||
|         })); | ||||
|     } | ||||
| 
 | ||||
|         throw Error('WS core_course_get_enrolled_courses_by_timeline_classification failed'); | ||||
|     /** | ||||
|      * Get courses matching the given custom field. | ||||
|      * | ||||
|      * @param customFieldName Custom field name. | ||||
|      * @param customFieldValue Custom field value. | ||||
|      * @param options Common options. | ||||
|      * @return Promise resolved with the list of courses. | ||||
|      * @since 3.8 | ||||
|      */ | ||||
|     getEnrolledCoursesByCustomFieldObservable( | ||||
|         customFieldName: string, | ||||
|         customFieldValue: string, | ||||
|         options: CoreSitesCommonWSOptions, | ||||
|     ): WSObservable<CoreCourseSummaryData[]> { | ||||
|         return asyncObservable(async () => { | ||||
|             const site = await CoreSites.getSite(options. siteId); | ||||
| 
 | ||||
|             const params: CoreCourseGetEnrolledCoursesByTimelineClassificationWSParams = { | ||||
|                 classification: 'customfield', | ||||
|                 customfieldname: customFieldName, | ||||
|                 customfieldvalue: customFieldValue, | ||||
|             }; | ||||
|             const preSets: CoreSiteWSPreSets = { | ||||
|                 ...CoreSites.getReadingStrategyPreSets(options.readingStrategy ?? CoreSitesReadingStrategy.PREFER_NETWORK), | ||||
|             }; | ||||
| 
 | ||||
|             return site.readObservable<CoreCourseGetEnrolledCoursesByTimelineClassificationWSResponse>( | ||||
|                 'core_course_get_enrolled_courses_by_timeline_classification', | ||||
|                 params, | ||||
|                 preSets, | ||||
|             ).pipe(map(response => response.courses)); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -614,25 +665,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 = {}, | ||||
|     ): WSObservable<{ | ||||
|             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 zipIncludingComplete( | ||||
|                 ignoreErrors(this.getUserNavigationOptionsObservable(courseIds, options), {}), | ||||
|                 ignoreErrors(this.getUserAdministrationOptionsObservable(courseIds, options), {}), | ||||
|             ).pipe( | ||||
|                 map(([navOptions, admOptions]) => ({ navOptions, admOptions })), | ||||
|             ); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -695,26 +766,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<CoreCourseUserAdminOrNavOptionCourseIndexed> { | ||||
|     getUserAdministrationOptions(courseIds: number[], siteId?: string): Promise<CoreCourseUserAdminOrNavOptionCourseIndexed> { | ||||
|         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 = {}, | ||||
|     ): WSObservable<CoreCourseUserAdminOrNavOptionCourseIndexed> { | ||||
|         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<CoreCourseGetUserAdminOrNavOptionsWSResponse>( | ||||
|                 '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 +834,45 @@ export class CoreCoursesProvider { | ||||
|      * @return Promise resolved with navigation options for each course. | ||||
|      */ | ||||
|     async getUserNavigationOptions(courseIds: number[], siteId?: string): Promise<CoreCourseUserAdminOrNavOptionCourseIndexed> { | ||||
|         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 = {}, | ||||
|     ): WSObservable<CoreCourseUserAdminOrNavOptionCourseIndexed> { | ||||
|         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<CoreCourseGetUserAdminOrNavOptionsWSResponse>( | ||||
|                 '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 +929,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<CoreEnrolledCourseData[]> { | ||||
|         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 firstValueFrom(this.getUserCoursesObservable({ | ||||
|             readingStrategy: strategy, | ||||
|             siteId, | ||||
|         })); | ||||
|     } | ||||
| 
 | ||||
|         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 = {}): WSObservable<CoreEnrolledCourseData[]> { | ||||
|         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<CoreEnrolGetUsersCoursesWSResponse>('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<CoreEnrolGetUsersCoursesWSResponse>( | ||||
|                 '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<number>(); | ||||
| 
 | ||||
|                 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<number>(); | ||||
| 
 | ||||
|             // 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 +1446,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).
 | ||||
|  | ||||
| @ -13,12 +13,14 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; | ||||
| import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites'; | ||||
| import { CoreSite, CoreSiteWSPreSets, WSObservable } 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 { map } from 'rxjs/operators'; | ||||
| import { asyncObservable, firstValueFrom } from '@/core/utils/rxjs'; | ||||
| 
 | ||||
| const ROOT_CACHE_KEY = 'CoreCoursesDashboard:'; | ||||
| 
 | ||||
| @ -51,40 +53,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 = {}): WSObservable<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 +123,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 = {}): WSObservable<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 +235,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. | ||||
|  */ | ||||
|  | ||||
| @ -15,7 +15,7 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| 
 | ||||
| import { CoreNetwork } from '@services/network'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; | ||||
| import { CoreFilterDelegate } from './filter-delegate'; | ||||
| import { | ||||
|     CoreFilter, | ||||
| @ -31,6 +31,7 @@ import { CoreEvents, CoreEventSiteData } from '@singletons/events'; | ||||
| import { CoreLogger } from '@singletons/logger'; | ||||
| import { CoreSite } from '@classes/site'; | ||||
| import { CoreCourseHelper } from '@features/course/services/course-helper'; | ||||
| import { firstValueFrom } from '@/core/utils/rxjs'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper service to provide filter functionalities. | ||||
| @ -75,7 +76,11 @@ export class CoreFilterHelperProvider { | ||||
|      * @return Promise resolved with the contexts. | ||||
|      */ | ||||
|     async getBlocksContexts(courseId: number, siteId?: string): Promise<CoreFiltersGetAvailableInContextWSParamContext[]> { | ||||
|         const blocks = await CoreCourse.getCourseBlocks(courseId, siteId); | ||||
|         // Use stale while revalidate, but always use the first value. If data is updated it will be stored in DB.
 | ||||
|         const blocks = await firstValueFrom(CoreCourse.getCourseBlocksObservable(courseId, { | ||||
|             readingStrategy: CoreSitesReadingStrategy.STALE_WHILE_REVALIDATE, | ||||
|             siteId, | ||||
|         })); | ||||
| 
 | ||||
|         const contexts: CoreFiltersGetAvailableInContextWSParamContext[] = []; | ||||
| 
 | ||||
| @ -153,7 +158,12 @@ export class CoreFilterHelperProvider { | ||||
|      * @return Promise resolved with the contexts. | ||||
|      */ | ||||
|     async getCourseModulesContexts(courseId: number, siteId?: string): Promise<CoreFiltersGetAvailableInContextWSParamContext[]> { | ||||
|         const sections = await CoreCourse.getSections(courseId, false, true, undefined, siteId); | ||||
|         // Use stale while revalidate, but always use the first value. If data is updated it will be stored in DB.
 | ||||
|         const sections = await firstValueFrom(CoreCourse.getSectionsObservable(courseId, { | ||||
|             excludeContents: true, | ||||
|             readingStrategy: CoreSitesReadingStrategy.STALE_WHILE_REVALIDATE, | ||||
|             siteId, | ||||
|         })); | ||||
| 
 | ||||
|         const contexts: CoreFiltersGetAvailableInContextWSParamContext[] = []; | ||||
| 
 | ||||
|  | ||||
| @ -15,8 +15,8 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| 
 | ||||
| import { CoreNetwork } from '@services/network'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreSite } from '@classes/site'; | ||||
| import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; | ||||
| import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; | ||||
| import { CoreWSExternalWarning } from '@services/ws'; | ||||
| import { CoreTextUtils } from '@services/utils/text'; | ||||
| import { CoreFilterDelegate } from './filter-delegate'; | ||||
| @ -284,13 +284,15 @@ export class CoreFilterProvider { | ||||
|         const data: CoreFiltersGetAvailableInContextWSParams = { | ||||
|             contexts: contextsToSend, | ||||
|         }; | ||||
|         const preSets = { | ||||
|         const preSets: CoreSiteWSPreSets = { | ||||
|             cacheKey: this.getAvailableInContextsCacheKey(contextsToSend), | ||||
|             updateFrequency: CoreSite.FREQUENCY_RARELY, | ||||
|             splitRequest: { | ||||
|                 param: 'contexts', | ||||
|                 maxLength: 300, | ||||
|             }, | ||||
|             // Use stale while revalidate, but always use the first value. If data is updated it will be stored in DB.
 | ||||
|             ...CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.STALE_WHILE_REVALIDATE), | ||||
|         }; | ||||
| 
 | ||||
|         const result = await site.read<CoreFilterGetAvailableInContextResult>( | ||||
|  | ||||
| @ -1796,6 +1796,12 @@ export class CoreSitesProvider { | ||||
|                     getFromCache: false, | ||||
|                     emergencyCache: false, | ||||
|                 }; | ||||
|             case CoreSitesReadingStrategy.STALE_WHILE_REVALIDATE: | ||||
|                 return { | ||||
|                     updateInBackground: true, | ||||
|                     getFromCache: true, | ||||
|                     saveToCache: true, | ||||
|                 }; | ||||
|             default: | ||||
|                 return {}; | ||||
|         } | ||||
| @ -2017,6 +2023,7 @@ export const enum CoreSitesReadingStrategy { | ||||
|     PREFER_CACHE, | ||||
|     ONLY_NETWORK, | ||||
|     PREFER_NETWORK, | ||||
|     STALE_WHILE_REVALIDATE, | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | ||||
| @ -65,6 +65,35 @@ export class CoreObject { | ||||
|         return Object.keys(object).length === 0; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Return an object including only certain keys. | ||||
|      * | ||||
|      * @param obj Object. | ||||
|      * @param keysOrRegex If array is supplied, keys to include. Otherwise, regular expression used to filter keys. | ||||
|      * @return New object with only the specified keys. | ||||
|      */ | ||||
|     static only<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K>; | ||||
|     static only<T>(obj: T, regex: RegExp): Partial<T>; | ||||
|     static only<T, K extends keyof T>(obj: T, keysOrRegex: K[] | RegExp): Pick<T, K> | Partial<T> { | ||||
|         const newObject: Partial<T> = {}; | ||||
| 
 | ||||
|         if (Array.isArray(keysOrRegex)) { | ||||
|             for (const key of keysOrRegex) { | ||||
|                 newObject[key] = obj[key]; | ||||
|             } | ||||
|         } else { | ||||
|             const originalKeys = Object.keys(obj); | ||||
| 
 | ||||
|             for (const key of originalKeys) { | ||||
|                 if (key.match(keysOrRegex)) { | ||||
|                     newObject[key] = obj[key]; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return newObject; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Create a new object without the specified keys. | ||||
|      * | ||||
|  | ||||
| @ -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(); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -99,6 +99,53 @@ describe('CoreObject singleton', () => { | ||||
|         expect(CoreObject.isEmpty({ foo: 1 })).toEqual(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('creates a copy of an object with certain properties (using a list)', () => { | ||||
|         const originalObject = { | ||||
|             foo: 1, | ||||
|             bar: 2, | ||||
|             baz: 3, | ||||
|         }; | ||||
| 
 | ||||
|         expect(CoreObject.only(originalObject, [])).toEqual({}); | ||||
|         expect(CoreObject.only(originalObject, ['foo'])).toEqual({ | ||||
|             foo: 1, | ||||
|         }); | ||||
|         expect(CoreObject.only(originalObject, ['foo', 'baz'])).toEqual({ | ||||
|             foo: 1, | ||||
|             baz: 3, | ||||
|         }); | ||||
|         expect(CoreObject.only(originalObject, ['foo', 'bar', 'baz'])).toEqual(originalObject); | ||||
|         expect(originalObject).toEqual({ | ||||
|             foo: 1, | ||||
|             bar: 2, | ||||
|             baz: 3, | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     it('creates a copy of an object with certain properties (using a regular expression)', () => { | ||||
|         const originalObject = { | ||||
|             foo: 1, | ||||
|             bar: 2, | ||||
|             baz: 3, | ||||
|         }; | ||||
| 
 | ||||
|         expect(CoreObject.only(originalObject, /.*/)).toEqual(originalObject); | ||||
|         expect(CoreObject.only(originalObject, /^ba.*/)).toEqual({ | ||||
|             bar: 2, | ||||
|             baz: 3, | ||||
|         }); | ||||
|         expect(CoreObject.only(originalObject, /(foo|bar)/)).toEqual({ | ||||
|             foo: 1, | ||||
|             bar: 2, | ||||
|         }); | ||||
|         expect(CoreObject.only(originalObject, /notfound/)).toEqual({}); | ||||
|         expect(originalObject).toEqual({ | ||||
|             foo: 1, | ||||
|             bar: 2, | ||||
|             baz: 3, | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     it('creates a copy of an object without certain properties', () => { | ||||
|         const originalObject = { | ||||
|             foo: 1, | ||||
|  | ||||
| @ -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 () => { | ||||
|  | ||||
| @ -13,8 +13,10 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { FormControl } from '@angular/forms'; | ||||
| import { Observable, OperatorFunction } from 'rxjs'; | ||||
| import { filter } from 'rxjs/operators'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
| import { CoreSubscriptions } from '@singletons/subscriptions'; | ||||
| import { BehaviorSubject, Observable, of, OperatorFunction, Subscription } from 'rxjs'; | ||||
| import { catchError, filter } from 'rxjs/operators'; | ||||
| 
 | ||||
| /** | ||||
|  * Create an observable that emits the current form control value. | ||||
| @ -60,3 +62,164 @@ export function startWithOnSubscribed<T>(onSubscribed: () => T): OperatorFunctio | ||||
|         return source.subscribe(value => subscriber.next(value)); | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 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(subscriber)) // rxjs will automatically handle unsubscribes.
 | ||||
|             .catch(error => subscriber.error(error)); | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Create a Promise resolved with the first value returned from an observable. The difference with toPromise is that | ||||
|  * this function returns the value as soon as it's emitted, it doesn't wait until the observable completes. | ||||
|  * 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))); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Get return types of a list of observables. | ||||
|  */ | ||||
| type GetObservablesReturnTypes<T> = { [key in keyof T]: T[key] extends Observable<infer R> ? R : never }; | ||||
| 
 | ||||
| /** | ||||
|  * Data for an observable when zipping. | ||||
|  */ | ||||
| type ZipObservableData<T = unknown> = { | ||||
|     values: T[]; | ||||
|     completed: boolean; | ||||
|     subscription?: Subscription; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Same as the built-in zip operator, but once an observable completes it'll continue to emit the last value as long | ||||
|  * as the other observables continue to emit values. | ||||
|  * | ||||
|  * @param observables Observables to zip. | ||||
|  * @return Observable that emits the zipped values. | ||||
|  */ | ||||
| export function zipIncludingComplete<T extends Observable<unknown>[]>( | ||||
|     ...observables: T | ||||
| ): Observable<GetObservablesReturnTypes<T>> { | ||||
|     return new Observable(subscriber => { | ||||
|         let nextIndex = 0; | ||||
|         let hasErrored = false; | ||||
|         let hasCompleted = false; | ||||
| 
 | ||||
|         // Before subscribing, initialize the data for all observables.
 | ||||
|         const observablesData = observables.map(() => <ZipObservableData> { | ||||
|             values: [], | ||||
|             completed: false, | ||||
|         }); | ||||
| 
 | ||||
|         // Treat an emitted event.
 | ||||
|         const treatEmitted = (completed = false) => { | ||||
|             if (hasErrored || hasCompleted) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             if (completed) { | ||||
|                 // Check if all observables have completed.
 | ||||
|                 const numCompleted = observablesData.reduce((total, data) => total + (data.completed ? 1 : 0), 0); | ||||
|                 if (numCompleted === observablesData.length) { | ||||
|                     hasCompleted = true; | ||||
| 
 | ||||
|                     // Emit all pending values.
 | ||||
|                     const maxValues = observablesData.reduce((maxValues, data) => Math.max(maxValues, data.values.length), 0); | ||||
|                     while (nextIndex < maxValues) { | ||||
|                         emitNextValue(); | ||||
|                         nextIndex++; | ||||
|                     } | ||||
| 
 | ||||
|                     subscriber.complete(); | ||||
| 
 | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             // Check if any observable still doesn't have data for the index.
 | ||||
|             const notReady = observablesData.some(data => !data.completed && !(nextIndex in data.values)); | ||||
|             if (notReady) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             emitNextValue(); | ||||
|             nextIndex++; | ||||
| 
 | ||||
|             if (completed) { | ||||
|                 // An observable was completed, there might be other values to emit.
 | ||||
|                 treatEmitted(true); | ||||
|             } | ||||
|         }; | ||||
|         const emitNextValue = () => { | ||||
|             // For each observable, get the value for the next index, or last value if not present (completed).
 | ||||
|             const valueToEmit = observablesData.map(observableData => | ||||
|                 observableData.values[nextIndex] ?? observableData.values[observableData.values.length - 1]); | ||||
| 
 | ||||
|             subscriber.next(<GetObservablesReturnTypes<T>> valueToEmit); | ||||
|         }; | ||||
| 
 | ||||
|         observables.forEach((observable, obsIndex) => { | ||||
|             const observableData = observablesData[obsIndex]; | ||||
| 
 | ||||
|             observableData.subscription = observable.subscribe({ | ||||
|                 next: (value) => { | ||||
|                     observableData.values.push(value); | ||||
|                     treatEmitted(); | ||||
|                 }, | ||||
|                 error: (error) => { | ||||
|                     hasErrored = true; | ||||
|                     subscriber.error(error); | ||||
|                 }, | ||||
|                 complete: () => { | ||||
|                     observableData.completed = true; | ||||
|                     treatEmitted(true); | ||||
|                 }, | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         // When unsubscribing, unsubscribe from all observables.
 | ||||
|         return () => { | ||||
|             observablesData.forEach(observableData => observableData.subscription?.unsubscribe()); | ||||
|         }; | ||||
|     }); | ||||
| } | ||||
|  | ||||
| @ -12,14 +12,23 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { formControlValue, resolved, startWithOnSubscribed } from '@/core/utils/rxjs'; | ||||
| import { | ||||
|     asyncObservable, | ||||
|     firstValueFrom, | ||||
|     formControlValue, | ||||
|     ignoreErrors, | ||||
|     resolved, | ||||
|     startWithOnSubscribed, | ||||
|     zipIncludingComplete, | ||||
| } from '@/core/utils/rxjs'; | ||||
| import { mock } from '@/testing/utils'; | ||||
| import { FormControl } from '@angular/forms'; | ||||
| import { of, Subject } from 'rxjs'; | ||||
| import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; | ||||
| import { TestScheduler } from 'rxjs/testing'; | ||||
| 
 | ||||
| describe('RXJS Utils', () => { | ||||
| 
 | ||||
|     it('Emits filtered form values', () => { | ||||
|     it('formControlValue emits filtered form values', () => { | ||||
|         // Arrange.
 | ||||
|         let value = 'one'; | ||||
|         const emited: string[] = []; | ||||
| @ -48,7 +57,7 @@ describe('RXJS Utils', () => { | ||||
|         expect(emited).toEqual(['two', 'three']); | ||||
|     }); | ||||
| 
 | ||||
|     it('Emits resolved values', async () => { | ||||
|     it('resolved emits resolved values', async () => { | ||||
|         // Arrange.
 | ||||
|         const emited: string[] = []; | ||||
|         const promises = [ | ||||
| @ -67,7 +76,7 @@ describe('RXJS Utils', () => { | ||||
|         expect(emited).toEqual(['one', 'two', 'three']); | ||||
|     }); | ||||
| 
 | ||||
|     it('Adds starting values on subscription', () => { | ||||
|     it('startWithOnSubscribed adds starting values on subscription', () => { | ||||
|         // Arrange.
 | ||||
|         let store = 'one'; | ||||
|         const emited: string[] = []; | ||||
| @ -86,4 +95,168 @@ describe('RXJS Utils', () => { | ||||
|         expect(emited).toEqual(['two', 'final', 'three', 'final']); | ||||
|     }); | ||||
| 
 | ||||
|     it('asyncObservable emits values', (done) => { | ||||
|         const subject = new Subject(); | ||||
|         const promise = new Promise<Observable<unknown>>((resolve) => { | ||||
|             resolve(subject); | ||||
|         }); | ||||
| 
 | ||||
|         asyncObservable(() => promise).subscribe({ | ||||
|             next: (value) => { | ||||
|                 expect(value).toEqual('foo'); | ||||
|                 done(); | ||||
|             }, | ||||
|         }); | ||||
| 
 | ||||
|         // Wait for the promise callback to be called before emitting the value.
 | ||||
|         setTimeout(() => subject.next('foo')); | ||||
|     }); | ||||
| 
 | ||||
|     it('asyncObservable emits errors', (done) => { | ||||
|         const subject = new Subject(); | ||||
|         const promise = new Promise<Observable<unknown>>((resolve) => { | ||||
|             resolve(subject); | ||||
|         }); | ||||
| 
 | ||||
|         asyncObservable(() => promise).subscribe({ | ||||
|             error: (value) => { | ||||
|                 expect(value).toEqual('foo'); | ||||
|                 done(); | ||||
|             }, | ||||
|         }); | ||||
| 
 | ||||
|         // Wait for the promise callback to be called before emitting the value.
 | ||||
|         setTimeout(() => subject.error('foo')); | ||||
|     }); | ||||
| 
 | ||||
|     it('asyncObservable emits complete', (done) => { | ||||
|         const subject = new Subject(); | ||||
|         const promise = new Promise<Observable<unknown>>((resolve) => { | ||||
|             resolve(subject); | ||||
|         }); | ||||
| 
 | ||||
|         asyncObservable(() => promise).subscribe({ | ||||
|             complete: () => done(), | ||||
|         }); | ||||
| 
 | ||||
|         // Wait for the promise callback to be called before emitting the value.
 | ||||
|         setTimeout(() => subject.complete()); | ||||
|     }); | ||||
| 
 | ||||
|     it('asyncObservable emits error if promise is rejected', (done) => { | ||||
|         const promise = new Promise<Observable<unknown>>((resolve, reject) => { | ||||
|             reject('Custom error'); | ||||
|         }); | ||||
| 
 | ||||
|         asyncObservable(() => promise).subscribe({ | ||||
|             error: (error) => { | ||||
|                 expect(error).toEqual('Custom error'); | ||||
|                 done(); | ||||
|             }, | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     it('firstValueFrom returns first value emitted by an observable', async () => { | ||||
|         const subject = new Subject(); | ||||
|         setTimeout(() => subject.next('foo'), 10); | ||||
| 
 | ||||
|         await expect(firstValueFrom(subject)).resolves.toEqual('foo'); | ||||
| 
 | ||||
|         // Check that running it again doesn't get last value, it gets the new one.
 | ||||
|         setTimeout(() => subject.next('bar'), 10); | ||||
|         await expect(firstValueFrom(subject)).resolves.toEqual('bar'); | ||||
| 
 | ||||
|         // Check we cannot get first value if a subject is already completed.
 | ||||
|         subject.complete(); | ||||
|         await expect(firstValueFrom(subject)).rejects.toThrow(); | ||||
| 
 | ||||
|         // Check that we get last value when using BehaviourSubject.
 | ||||
|         const behaviorSubject = new BehaviorSubject('baz'); | ||||
|         await expect(firstValueFrom(behaviorSubject)).resolves.toEqual('baz'); | ||||
| 
 | ||||
|         // Check we get last value even if behaviour subject is completed.
 | ||||
|         behaviorSubject.complete(); | ||||
|         await expect(firstValueFrom(behaviorSubject)).resolves.toEqual('baz'); | ||||
| 
 | ||||
|         // Check that Promise is rejected if the observable emits an error.
 | ||||
|         const errorSubject = new Subject(); | ||||
|         setTimeout(() => errorSubject.error('foo error'), 10); | ||||
| 
 | ||||
|         await expect(firstValueFrom(errorSubject)).rejects.toMatch('foo error'); | ||||
|     }); | ||||
| 
 | ||||
|     it('ignoreErrors ignores observable errors', (done) => { | ||||
|         const subject = new Subject(); | ||||
| 
 | ||||
|         ignoreErrors(subject, 'default value').subscribe({ | ||||
|             next: (value) => { | ||||
|                 expect(value).toEqual('default value'); | ||||
|                 done(); | ||||
|             }, | ||||
|         }); | ||||
| 
 | ||||
|         subject.error('foo'); | ||||
|     }); | ||||
| 
 | ||||
|     it('zipIncludingComplete zips observables including complete events', () => { | ||||
|         const scheduler = new TestScheduler((actual, expected) => { | ||||
|             expect(actual).toEqual(expected); | ||||
|         }); | ||||
| 
 | ||||
|         scheduler.run(({ expectObservable, cold }) => { | ||||
|             expectObservable(zipIncludingComplete( | ||||
|                 cold('-a-b---|', { | ||||
|                     a: 'A1', | ||||
|                     b: 'A2', | ||||
|                 }), | ||||
|                 cold('-a----b-c--|', { | ||||
|                     a: 'B1', | ||||
|                     b: 'B2', | ||||
|                     c: 'B3', | ||||
|                 }), | ||||
|                 cold('-a-b-c--de-----|', { | ||||
|                     a: 'C1', | ||||
|                     b: 'C2', | ||||
|                     c: 'C3', | ||||
|                     d: 'C4', | ||||
|                     e: 'C5', | ||||
|                 }), | ||||
|             )).toBe( | ||||
|                 '-a----b-c--(de)|', | ||||
|                 { | ||||
|                     a: ['A1','B1','C1'], | ||||
|                     b: ['A2','B2','C2'], | ||||
|                     c: ['A2','B3','C3'], | ||||
|                     d: ['A2','B3','C4'], | ||||
|                     e: ['A2','B3','C5'], | ||||
|                 }, | ||||
|             ); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     it('zipIncludingComplete emits all pending values when last observable completes', () => { | ||||
|         const scheduler = new TestScheduler((actual, expected) => { | ||||
|             expect(actual).toEqual(expected); | ||||
|         }); | ||||
| 
 | ||||
|         scheduler.run(({ expectObservable, cold }) => { | ||||
|             expectObservable(zipIncludingComplete( | ||||
|                 cold('-a-b-|', { | ||||
|                     a: 'A1', | ||||
|                     b: 'A2', | ||||
|                     c: 'A3', | ||||
|                 }), | ||||
|                 cold('-a-----|', { | ||||
|                     a: 'B1', | ||||
|                 }), | ||||
|             )).toBe( | ||||
|                 '-a-----(b|)', | ||||
|                 { | ||||
|                     a: ['A1','B1'], | ||||
|                     b: ['A2','B1'], | ||||
|                 }, | ||||
|             ); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
| }); | ||||
|  | ||||
| @ -1116,17 +1116,23 @@ ion-button.chip { | ||||
| } | ||||
| 
 | ||||
| ion-chip { | ||||
|     line-height: 1.1; | ||||
|     font-size: 12px; | ||||
|     padding: 4px 8px; | ||||
|     min-height: 24px; | ||||
|     height: auto; | ||||
| 
 | ||||
|     // Chips are not currently clickable. | ||||
|     &.ion-activatable { | ||||
|     // Chips are not currently clickable, only if specified explicitly. | ||||
|     &.ion-activatable:not(.clickable) { | ||||
|         cursor: auto; | ||||
|         pointer-events: none; | ||||
|     } | ||||
|     &.clickable { | ||||
|         cursor: pointer; | ||||
|         pointer-events: auto; | ||||
|     } | ||||
| 
 | ||||
|     &.fab-chip { | ||||
|         padding: 8px 12px; | ||||
|         box-shadow: 0 2px 4px rgba(0, 0, 0, .4); | ||||
|     } | ||||
| 
 | ||||
|     &.ion-color { | ||||
|         background: var(--ion-color-tint); | ||||
| @ -1135,6 +1141,10 @@ ion-chip { | ||||
|             border-color: var(--ion-color-base); | ||||
|             color: var(--ion-color-base); | ||||
|         } | ||||
|         &.fab-chip { | ||||
|             background: var(--ion-color); | ||||
|             color: var(--ion-color-contrast); | ||||
|         } | ||||
| 
 | ||||
|         &.ion-color-light, | ||||
|         &.ion-color-medium, | ||||
| @ -1739,3 +1749,12 @@ ion-header.no-title { | ||||
| video::-webkit-media-text-track-display { | ||||
|     white-space: normal !important; | ||||
| } | ||||
| 
 | ||||
| ion-modal.core-modal-no-background { | ||||
|     --background: transparent; | ||||
|     pointer-events: none; | ||||
| 
 | ||||
|     ion-backdrop { | ||||
|         display: none; | ||||
|     } | ||||
| } | ||||
|  | ||||
							
								
								
									
										2
									
								
								src/types/config.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								src/types/config.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -71,4 +71,6 @@ export interface EnvironmentConfig { | ||||
|     removeaccountonlogout?: boolean; // True to remove the account when the user clicks logout. Doesn't affect switch account.
 | ||||
|     uselegacycompletion?: boolean; // Whether to use legacy completion by default in all course formats.
 | ||||
|     toastDurations: Record<ToastDuration, number>; | ||||
|     disableCallWSInBackground?: boolean; // If true, disable calling WS in background.
 | ||||
|     callWSInBackgroundExpirationTime?: number; // Ms to consider an entry expired when calling WS in background. Default: 1 week.
 | ||||
| } | ||||
|  | ||||
| @ -6,6 +6,7 @@ information provided here is intended especially for developers. | ||||
| - Zoom levels changed from "normal / low / high" to " none / medium / high". | ||||
| - --addon-messages-* CSS3 variables have been renamed to --core-messages-* | ||||
| - The database constants in CoreSite (WS_CACHE_TABLE, CONFIG_TABLE, LAST_VIEWED_TABLE) and the DBRecord types have been moved to src/core/services/database. | ||||
| - The component <core-block> will no longer detect changes of properties inside the extraData object, it will only detect changes if the object itself changes. | ||||
| 
 | ||||
| === 4.0.0 === | ||||
| 
 | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user