MOBILE-3817 courses: Apply update in background to My Courses
This commit is contained in:
		
							parent
							
								
									ce9c086819
								
							
						
					
					
						commit
						01df501cad
					
				| @ -12,12 +12,17 @@ | |||||||
| // See the License for the specific language governing permissions and
 | // See the License for the specific language governing permissions and
 | ||||||
| // limitations under the License.
 | // 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 { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||||
| import { CoreTimeUtils } from '@services/utils/time'; | import { CoreTimeUtils } from '@services/utils/time'; | ||||||
| import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; | import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; | ||||||
| import { CoreCoursesProvider, CoreCourses, CoreCoursesMyCoursesUpdatedEventData } from '@features/courses/services/courses'; | import { | ||||||
| import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; |     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 { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper'; | ||||||
| import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; | import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; | ||||||
| import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; | 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 { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion'; | ||||||
| import { IonRefresher, IonSearchbar } from '@ionic/angular'; | import { IonRefresher, IonSearchbar } from '@ionic/angular'; | ||||||
| import { CoreNavigator } from '@services/navigator'; | import { CoreNavigator } from '@services/navigator'; | ||||||
|  | import { PageLoadWatcher } from '@classes/page-load-watcher'; | ||||||
|  | import { PageLoadsManager } from '@classes/page-loads-manager'; | ||||||
| 
 | 
 | ||||||
| const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] = | const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] = | ||||||
|     ['all', 'inprogress', 'future', 'past', 'favourite', 'allincludinghidden', 'hidden']; |     ['all', 'inprogress', 'future', 'past', 'favourite', 'allincludinghidden', 'hidden']; | ||||||
| @ -40,9 +47,9 @@ const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] = | |||||||
|     templateUrl: 'addon-block-myoverview.html', |     templateUrl: 'addon-block-myoverview.html', | ||||||
|     styleUrls: ['myoverview.scss'], |     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 = { |     prefetchCoursesData: CorePrefetchStatusInfo = { | ||||||
|         icon: '', |         icon: '', | ||||||
| @ -84,7 +91,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | |||||||
|     searchEnabled = false; |     searchEnabled = false; | ||||||
| 
 | 
 | ||||||
|     protected currentSite!: CoreSite; |     protected currentSite!: CoreSite; | ||||||
|     protected allCourses: CoreEnrolledCourseDataWithOptions[] = []; |     protected allCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = []; | ||||||
|     protected prefetchIconsInitialized = false; |     protected prefetchIconsInitialized = false; | ||||||
|     protected isDirty = false; |     protected isDirty = false; | ||||||
|     protected isDestroyed = false; |     protected isDestroyed = false; | ||||||
| @ -94,15 +101,21 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | |||||||
|     protected gradePeriodAfter = 0; |     protected gradePeriodAfter = 0; | ||||||
|     protected gradePeriodBefore = 0; |     protected gradePeriodBefore = 0; | ||||||
|     protected today = 0; |     protected today = 0; | ||||||
|  |     protected firstLoadWatcher?: PageLoadWatcher; | ||||||
|  |     protected loadsManager: PageLoadsManager; | ||||||
| 
 | 
 | ||||||
|     constructor() { |     constructor(@Optional() loadsManager?: PageLoadsManager) { | ||||||
|         super('AddonBlockMyOverviewComponent'); |         super('AddonBlockMyOverviewComponent'); | ||||||
|  | 
 | ||||||
|  |         this.loadsManager = loadsManager ?? new PageLoadsManager(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     async ngOnInit(): Promise<void> { |     async ngOnInit(): Promise<void> { | ||||||
|  |         this.firstLoadWatcher = this.loadsManager.startComponentLoad(this); | ||||||
|  | 
 | ||||||
|         // Refresh the enabled flags if enabled.
 |         // Refresh the enabled flags if enabled.
 | ||||||
|         this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite(); |         this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite(); | ||||||
|         this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite(); |         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. |      * Refresh the data. | ||||||
|      * |      * | ||||||
| @ -226,35 +249,66 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | |||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     protected async fetchContent(): Promise<void> { |     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.loadSort(); | ||||||
|             this.sort.selected, |         this.loadLayouts(this.block.configsRecord?.layouts?.value.split(',')); | ||||||
|             undefined, | 
 | ||||||
|             undefined, |         await this.loadFilters(this.block.configsRecord, loadWatcher); | ||||||
|             showCategories, | 
 | ||||||
|             { |         this.isDirty = false; | ||||||
|                 readingStrategy: this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : undefined, |     } | ||||||
|             }, | 
 | ||||||
|  |     /** | ||||||
|  |      * 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; |         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 { |         try { | ||||||
|             this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter', this.isDirty), 10); |             const siteConfig = await loadWatcher.watchRequest( | ||||||
|             this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore', this.isDirty), 10); |                 this.currentSite.getConfigObservable( | ||||||
|  |                     undefined, | ||||||
|  |                     this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : loadWatcher.getReadingStrategy(), | ||||||
|  |                 ), | ||||||
|  |             ); | ||||||
|  | 
 | ||||||
|  |             this.gradePeriodAfter = parseInt(siteConfig.coursegraceperiodafter, 10); | ||||||
|  |             this.gradePeriodBefore = parseInt(siteConfig.coursegraceperiodbefore, 10); | ||||||
|         } catch { |         } catch { | ||||||
|             this.gradePeriodAfter = 0; |             this.gradePeriodAfter = 0; | ||||||
|             this.gradePeriodBefore = 0; |             this.gradePeriodBefore = 0; | ||||||
|         } |         } | ||||||
| 
 |  | ||||||
|         this.loadSort(); |  | ||||||
|         this.loadLayouts(config?.layouts?.value.split(',')); |  | ||||||
|         await this.loadFilters(config); |  | ||||||
| 
 |  | ||||||
|         this.isDirty = false; |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -279,9 +333,12 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | |||||||
|      * Load filters. |      * Load filters. | ||||||
|      * |      * | ||||||
|      * @param config Block configuration. |      * @param config Block configuration. | ||||||
|  |      * @param loadWatcher To manage the requests. | ||||||
|  |      * @return Promise resolved when done. | ||||||
|      */ |      */ | ||||||
|     protected async loadFilters( |     protected async loadFilters( | ||||||
|         config?: Record<string, { name: string; value: string; type: string }>, |         config?: Record<string, { name: string; value: string; type: string }>, | ||||||
|  |         loadWatcher?: PageLoadWatcher, | ||||||
|     ): Promise<void> { |     ): Promise<void> { | ||||||
|         if (!this.hasCourses) { |         if (!this.hasCourses) { | ||||||
|             return; |             return; | ||||||
| @ -320,7 +377,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | |||||||
|             this.saveFilters('all'); |             this.saveFilters('all'); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         await this.filterCourses(); |         await this.filterCourses(loadWatcher); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -369,18 +426,14 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | |||||||
|     protected async refreshCourseList(data: CoreCoursesMyCoursesUpdatedEventData): Promise<void> { |     protected async refreshCourseList(data: CoreCoursesMyCoursesUpdatedEventData): Promise<void> { | ||||||
|         if (data.action == CoreCoursesProvider.ACTION_ENROL) { |         if (data.action == CoreCoursesProvider.ACTION_ENROL) { | ||||||
|             // Always update if user enrolled in a course.
 |             // Always update if user enrolled in a course.
 | ||||||
|             this.loaded = false; |             return this.refreshContent(true); | ||||||
| 
 |  | ||||||
|             return this.refreshContent(); |  | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         const course = this.allCourses.find((course) => course.id == data.courseId); |         const course = this.allCourses.find((course) => course.id == data.courseId); | ||||||
|         if (data.action == CoreCoursesProvider.ACTION_STATE_CHANGED) { |         if (data.action == CoreCoursesProvider.ACTION_STATE_CHANGED) { | ||||||
|             if (!course) { |             if (!course) { | ||||||
|                 // Not found, use WS update.
 |                 // Not found, use WS update.
 | ||||||
|                 this.loaded = false; |                 return this.refreshContent(true); | ||||||
| 
 |  | ||||||
|                 return this.refreshContent(); |  | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             if (data.state == CoreCoursesProvider.STATE_FAVOURITE) { |             if (data.state == CoreCoursesProvider.STATE_FAVOURITE) { | ||||||
| @ -398,9 +451,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | |||||||
|         if (data.action == CoreCoursesProvider.ACTION_VIEW && data.courseId != CoreSites.getCurrentSiteHomeId()) { |         if (data.action == CoreCoursesProvider.ACTION_VIEW && data.courseId != CoreSites.getCurrentSiteHomeId()) { | ||||||
|             if (!course) { |             if (!course) { | ||||||
|                 // Not found, use WS update.
 |                 // Not found, use WS update.
 | ||||||
|                 this.loaded = false; |                 return this.refreshContent(true); | ||||||
| 
 |  | ||||||
|                 return this.refreshContent(); |  | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             course.lastaccess = CoreTimeUtils.timestamp(); |             course.lastaccess = CoreTimeUtils.timestamp(); | ||||||
| @ -457,8 +508,11 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | |||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Set selected courses filter. |      * 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; |         let timeFilter = this.filters.timeFilterSelected; | ||||||
| 
 | 
 | ||||||
|         this.filteredCourses = this.allCourses; |         this.filteredCourses = this.allCourses; | ||||||
| @ -473,7 +527,15 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | |||||||
|                 this.loaded = false; |                 this.loaded = false; | ||||||
| 
 | 
 | ||||||
|                 try { |                 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.
 |                     // Get the courses information from allincludinghidden to get the max info about the course.
 | ||||||
|                     const courseIds = courses.map((course) => course.id); |                     const courseIds = courses.map((course) => course.id); | ||||||
| @ -642,6 +704,62 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | |||||||
|         CoreNavigator.navigateToSitePath('courses/list', { params : { mode: 'search' } }); |         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 |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|  | |||||||
| @ -71,7 +71,7 @@ | |||||||
| 
 | 
 | ||||||
|         <div class="mark-all-as-read" slot="fixed" collapsible-footer appearOnBottom> |         <div class="mark-all-as-read" slot="fixed" collapsible-footer appearOnBottom> | ||||||
|             <ion-chip *ngIf="notifications.loaded && canMarkAllNotificationsAsRead" [disabled]="loadingMarkAllNotificationsAsRead" |             <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-icon name="fas-eye" aria-hidden="true" *ngIf="!loadingMarkAllNotificationsAsRead"></ion-icon> | ||||||
|                 <ion-spinner [attr.aria-label]="'core.loading' | translate" *ngIf="loadingMarkAllNotificationsAsRead"> |                 <ion-spinner [attr.aria-label]="'core.loading' | translate" *ngIf="loadingMarkAllNotificationsAsRead"> | ||||||
|                 </ion-spinner> |                 </ion-spinner> | ||||||
|  | |||||||
| @ -56,9 +56,7 @@ ion-item { | |||||||
|     pointer-events: none; |     pointer-events: none; | ||||||
| 
 | 
 | ||||||
|     ion-chip.ion-color { |     ion-chip.ion-color { | ||||||
|         pointer-events: all; |  | ||||||
|         margin: 0 auto; |         margin: 0 auto; | ||||||
|         box-shadow: 0 2px 4px rgba(0, 0, 0, .4); |  | ||||||
| 
 | 
 | ||||||
|         ion-spinner { |         ion-spinner { | ||||||
|             width: 16px; |             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(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -63,6 +63,7 @@ import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides'; | |||||||
| import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-navigation-tour'; | import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-navigation-tour'; | ||||||
| import { CoreMessageComponent } from './message/message'; | import { CoreMessageComponent } from './message/message'; | ||||||
| import { CoreGroupSelectorComponent } from './group-selector/group-selector'; | import { CoreGroupSelectorComponent } from './group-selector/group-selector'; | ||||||
|  | import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-button-modal'; | ||||||
| 
 | 
 | ||||||
| @NgModule({ | @NgModule({ | ||||||
|     declarations: [ |     declarations: [ | ||||||
| @ -108,6 +109,7 @@ import { CoreGroupSelectorComponent } from './group-selector/group-selector'; | |||||||
|         CoreSpacerComponent, |         CoreSpacerComponent, | ||||||
|         CoreHorizontalScrollControlsComponent, |         CoreHorizontalScrollControlsComponent, | ||||||
|         CoreSwipeNavigationTourComponent, |         CoreSwipeNavigationTourComponent, | ||||||
|  |         CoreRefreshButtonModalComponent, | ||||||
|     ], |     ], | ||||||
|     imports: [ |     imports: [ | ||||||
|         CommonModule, |         CommonModule, | ||||||
| @ -160,6 +162,7 @@ import { CoreGroupSelectorComponent } from './group-selector/group-selector'; | |||||||
|         CoreSpacerComponent, |         CoreSpacerComponent, | ||||||
|         CoreHorizontalScrollControlsComponent, |         CoreHorizontalScrollControlsComponent, | ||||||
|         CoreSwipeNavigationTourComponent, |         CoreSwipeNavigationTourComponent, | ||||||
|  |         CoreRefreshButtonModalComponent, | ||||||
|     ], |     ], | ||||||
| }) | }) | ||||||
| export class CoreComponentsModule {} | 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 { Params } from '@angular/router'; | ||||||
| import { ContextLevel } from '@/core/constants'; | import { ContextLevel } from '@/core/constants'; | ||||||
| import { CoreNavigationOptions } from '@services/navigator'; | 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. |  * Template class to easily create components for blocks. | ||||||
| @ -28,7 +30,7 @@ import { CoreNavigationOptions } from '@services/navigator'; | |||||||
| @Component({ | @Component({ | ||||||
|     template: '', |     template: '', | ||||||
| }) | }) | ||||||
| export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent { | export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent, AsyncComponent { | ||||||
| 
 | 
 | ||||||
|     @Input() title!: string; // The block title.
 |     @Input() title!: string; // The block title.
 | ||||||
|     @Input() block!: CoreCourseBlock; // The block to render.
 |     @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() linkParams?: Params; // Link params to go when clicked.
 | ||||||
|     @Input() navOptions?: CoreNavigationOptions; // Navigation options.
 |     @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 fetchContentDefaultError = ''; // Default error to show when loading contents.
 | ||||||
|  |     protected onReadyPromise = new CorePromisedValue<void>(); | ||||||
| 
 | 
 | ||||||
|     protected logger: CoreLogger; |     protected logger: CoreLogger; | ||||||
| 
 | 
 | ||||||
| @ -65,9 +68,14 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon | |||||||
|     /** |     /** | ||||||
|      * Perform the refresh content function. |      * Perform the refresh content function. | ||||||
|      * |      * | ||||||
|  |      * @param showLoading Whether to show loading. | ||||||
|      * @return Resolved when done. |      * @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.
 |         // Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs.
 | ||||||
|         try { |         try { | ||||||
|             await this.invalidateContent(); |             await this.invalidateContent(); | ||||||
| @ -102,6 +110,7 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon | |||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         this.loaded = true; |         this.loaded = true; | ||||||
|  |         this.onReadyPromise.resolve(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -113,6 +122,28 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon | |||||||
|         return; |         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; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  | |||||||
| @ -14,6 +14,9 @@ | |||||||
| 
 | 
 | ||||||
| import { AddonBlockMyOverviewComponent } from '@addons/block/myoverview/components/myoverview/myoverview'; | import { AddonBlockMyOverviewComponent } from '@addons/block/myoverview/components/myoverview/myoverview'; | ||||||
| import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; | 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 { CoreBlockComponent } from '@features/block/components/block/block'; | ||||||
| import { CoreCourseBlock } from '@features/course/services/course'; | import { CoreCourseBlock } from '@features/course/services/course'; | ||||||
| import { CoreCoursesDashboard, CoreCoursesDashboardProvider } from '@features/courses/services/dashboard'; | import { CoreCoursesDashboard, CoreCoursesDashboardProvider } from '@features/courses/services/dashboard'; | ||||||
| @ -23,6 +26,7 @@ import { CoreSites } from '@services/sites'; | |||||||
| import { CoreDomUtils } from '@services/utils/dom'; | import { CoreDomUtils } from '@services/utils/dom'; | ||||||
| import { CoreUtils } from '@services/utils/utils'; | import { CoreUtils } from '@services/utils/utils'; | ||||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||||
|  | import { Subscription } from 'rxjs'; | ||||||
| import { CoreCourses } from '../../services/courses'; | import { CoreCourses } from '../../services/courses'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
| @ -32,8 +36,12 @@ import { CoreCourses } from '../../services/courses'; | |||||||
|     selector: 'page-core-courses-my', |     selector: 'page-core-courses-my', | ||||||
|     templateUrl: 'my.html', |     templateUrl: 'my.html', | ||||||
|     styleUrls: ['my.scss'], |     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; |     @ViewChild(CoreBlockComponent) block!: CoreBlockComponent; | ||||||
| 
 | 
 | ||||||
| @ -47,8 +55,10 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { | |||||||
|     hasSideBlocks = false; |     hasSideBlocks = false; | ||||||
| 
 | 
 | ||||||
|     protected updateSiteObserver: CoreEventObserver; |     protected updateSiteObserver: CoreEventObserver; | ||||||
|  |     protected onReadyPromise = new CorePromisedValue<void>(); | ||||||
|  |     protected loadsManagerSubscription: Subscription; | ||||||
| 
 | 
 | ||||||
|     constructor() { |     constructor(protected loadsManager: PageLoadsManager) { | ||||||
|         // Refresh the enabled flags if site is updated.
 |         // Refresh the enabled flags if site is updated.
 | ||||||
|         this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { |         this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { | ||||||
|             this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite(); |             this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite(); | ||||||
| @ -57,6 +67,11 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { | |||||||
|         }, CoreSites.getCurrentSiteId()); |         }, CoreSites.getCurrentSiteId()); | ||||||
| 
 | 
 | ||||||
|         this.userId = CoreSites.getCurrentSiteUserId(); |         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.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 available = await CoreCoursesDashboard.isAvailable(); | ||||||
|         const disabled = await CoreCourses.isMyCoursesDisabled(); |         const disabled = await CoreCourses.isMyCoursesDisabled(); | ||||||
| 
 | 
 | ||||||
|         if (available && !disabled) { |         if (available && !disabled) { | ||||||
|             try { |             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.
 |                 // 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'); |                 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.loaded = true; | ||||||
|  |         this.onReadyPromise.resolve(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -138,7 +162,7 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { | |||||||
| 
 | 
 | ||||||
|         // Invalidate the blocks.
 |         // Invalidate the blocks.
 | ||||||
|         if (this.myOverviewBlock) { |         if (this.myOverviewBlock) { | ||||||
|             promises.push(CoreUtils.ignoreErrors(this.myOverviewBlock.doRefresh())); |             promises.push(CoreUtils.ignoreErrors(this.myOverviewBlock.invalidateContent())); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         Promise.all(promises).finally(() => { |         Promise.all(promises).finally(() => { | ||||||
| @ -153,6 +177,14 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { | |||||||
|      */ |      */ | ||||||
|     ngOnDestroy(): void { |     ngOnDestroy(): void { | ||||||
|         this.updateSiteObserver?.off(); |         this.updateSiteObserver?.off(); | ||||||
|  |         this.loadsManagerSubscription.unsubscribe(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * @inheritdoc | ||||||
|  |      */ | ||||||
|  |     async ready(): Promise<void> { | ||||||
|  |         return await this.onReadyPromise; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| } | } | ||||||
|  | |||||||
| @ -227,7 +227,7 @@ export class CoreCoursesHelperProvider { | |||||||
|         filter?: string, |         filter?: string, | ||||||
|         loadCategoryNames: boolean = false, |         loadCategoryNames: boolean = false, | ||||||
|         options: CoreSitesCommonWSOptions = {}, |         options: CoreSitesCommonWSOptions = {}, | ||||||
|     ): Promise<CoreEnrolledCourseDataWithOptions[]> { |     ): Promise<CoreEnrolledCourseDataWithExtraInfoAndOptions[]> { | ||||||
|         return firstValueFrom(this.getUserCoursesWithOptionsObservable({ |         return firstValueFrom(this.getUserCoursesWithOptionsObservable({ | ||||||
|             sort, |             sort, | ||||||
|             slice, |             slice, | ||||||
| @ -245,7 +245,8 @@ export class CoreCoursesHelperProvider { | |||||||
|      */ |      */ | ||||||
|     getUserCoursesWithOptionsObservable( |     getUserCoursesWithOptionsObservable( | ||||||
|         options: CoreCoursesGetWithOptionsOptions = {}, |         options: CoreCoursesGetWithOptionsOptions = {}, | ||||||
|     ): Observable<CoreEnrolledCourseDataWithOptions[]> { |     ): Observable<CoreEnrolledCourseDataWithExtraInfoAndOptions[]> { | ||||||
|  | 
 | ||||||
|         return CoreCourses.getUserCoursesObservable(options).pipe( |         return CoreCourses.getUserCoursesObservable(options).pipe( | ||||||
|             chainRequests(options.readingStrategy, (courses, newReadingStrategy) => { |             chainRequests(options.readingStrategy, (courses, newReadingStrategy) => { | ||||||
|                 if (courses.length <= 0) { |                 if (courses.length <= 0) { | ||||||
|  | |||||||
| @ -65,6 +65,35 @@ export class CoreObject { | |||||||
|         return Object.keys(object).length === 0; |         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. |      * Create a new object without the specified keys. | ||||||
|      * |      * | ||||||
|  | |||||||
| @ -99,6 +99,53 @@ describe('CoreObject singleton', () => { | |||||||
|         expect(CoreObject.isEmpty({ foo: 1 })).toEqual(false); |         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', () => { |     it('creates a copy of an object without certain properties', () => { | ||||||
|         const originalObject = { |         const originalObject = { | ||||||
|             foo: 1, |             foo: 1, | ||||||
|  | |||||||
| @ -126,6 +126,7 @@ type GetObservablesReturnTypes<T> = { [key in keyof T]: T[key] extends Observabl | |||||||
|  */ |  */ | ||||||
| type ZipObservableData<T = unknown> = { | type ZipObservableData<T = unknown> = { | ||||||
|     values: T[]; |     values: T[]; | ||||||
|  |     hasValueForIndex: boolean[]; | ||||||
|     completed: boolean; |     completed: boolean; | ||||||
|     subscription?: Subscription; |     subscription?: Subscription; | ||||||
| }; | }; | ||||||
| @ -159,7 +160,7 @@ export function zipIncludingComplete<T extends Observable<unknown>[]>( | |||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             // Check if any observable still doesn't have data for the index.
 |             // Check if any observable still doesn't have data for the index.
 | ||||||
|             const notReady = observablesData.some(data => !data.completed && data.values[nextIndex] === undefined); |             const notReady = observablesData.some(data => !data.completed && !data.hasValueForIndex[nextIndex]); | ||||||
|             if (notReady) { |             if (notReady) { | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
| @ -177,15 +178,22 @@ export function zipIncludingComplete<T extends Observable<unknown>[]>( | |||||||
|             } |             } | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|  |         // Before subscribing, initialize the data for all observables.
 | ||||||
|         observables.forEach((observable, obsIndex) => { |         observables.forEach((observable, obsIndex) => { | ||||||
|             const observableData: ZipObservableData = { |             observablesData[obsIndex] = { | ||||||
|                 values: [], |                 values: [], | ||||||
|  |                 hasValueForIndex: [], | ||||||
|                 completed: false, |                 completed: false, | ||||||
|             }; |             }; | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         observables.forEach((observable, obsIndex) => { | ||||||
|  |             const observableData = observablesData[obsIndex]; | ||||||
| 
 | 
 | ||||||
|             observableData.subscription = observable.subscribe({ |             observableData.subscription = observable.subscribe({ | ||||||
|                 next: (value) => { |                 next: (value) => { | ||||||
|                     observableData.values.push(value); |                     observableData.values.push(value); | ||||||
|  |                     observableData.hasValueForIndex.push(true); | ||||||
|                     treatEmitted(); |                     treatEmitted(); | ||||||
|                 }, |                 }, | ||||||
|                 error: (error) => { |                 error: (error) => { | ||||||
| @ -198,8 +206,6 @@ export function zipIncludingComplete<T extends Observable<unknown>[]>( | |||||||
|                     treatEmitted(true); |                     treatEmitted(true); | ||||||
|                 }, |                 }, | ||||||
|             }); |             }); | ||||||
| 
 |  | ||||||
|             observablesData[obsIndex] = observableData; |  | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         // When unsubscribing, unsubscribe from all observables.
 |         // When unsubscribing, unsubscribe from all observables.
 | ||||||
|  | |||||||
| @ -1116,17 +1116,23 @@ ion-button.chip { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| ion-chip { | ion-chip { | ||||||
|     line-height: 1.1; |  | ||||||
|     font-size: 12px; |  | ||||||
|     padding: 4px 8px; |     padding: 4px 8px; | ||||||
|     min-height: 24px; |  | ||||||
|     height: auto; |     height: auto; | ||||||
| 
 | 
 | ||||||
|     // Chips are not currently clickable. |     // Chips are not currently clickable, only if specified explicitly. | ||||||
|     &.ion-activatable { |     &.ion-activatable:not(.clickable) { | ||||||
|         cursor: auto; |         cursor: auto; | ||||||
|         pointer-events: none; |         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 { |     &.ion-color { | ||||||
|         background: var(--ion-color-tint); |         background: var(--ion-color-tint); | ||||||
| @ -1135,6 +1141,10 @@ ion-chip { | |||||||
|             border-color: var(--ion-color-base); |             border-color: var(--ion-color-base); | ||||||
|             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-light, | ||||||
|         &.ion-color-medium, |         &.ion-color-medium, | ||||||
| @ -1739,3 +1749,12 @@ ion-header.no-title { | |||||||
| video::-webkit-media-text-track-display { | video::-webkit-media-text-track-display { | ||||||
|     white-space: normal !important; |     white-space: normal !important; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | ion-modal.core-modal-no-background { | ||||||
|  |     --background: transparent; | ||||||
|  |     pointer-events: none; | ||||||
|  | 
 | ||||||
|  |     ion-backdrop { | ||||||
|  |         display: none; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user