diff --git a/src/addons/block/myoverview/components/myoverview/myoverview.ts b/src/addons/block/myoverview/components/myoverview/myoverview.ts index 53105110d..0a6a6d6ce 100644 --- a/src/addons/block/myoverview/components/myoverview/myoverview.ts +++ b/src/addons/block/myoverview/components/myoverview/myoverview.ts @@ -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 { + 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 { - 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 { + 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 { + 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(',')); - await this.loadFilters(config); - - this.isDirty = false; } /** @@ -279,9 +333,12 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem * Load filters. * * @param config Block configuration. + * @param loadWatcher To manage the requests. + * @return Promise resolved when done. */ protected async loadFilters( config?: Record, + loadWatcher?: PageLoadWatcher, ): Promise { if (!this.hasCourses) { return; @@ -320,7 +377,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem 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 { if (data.action == CoreCoursesProvider.ACTION_ENROL) { // Always update if user enrolled in a course. - this.loaded = false; - - 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. - this.loaded = false; - - return this.refreshContent(); + return this.refreshContent(true); } 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 (!course) { // Not found, use WS update. - this.loaded = false; - - return this.refreshContent(); + return this.refreshContent(true); } course.lastaccess = CoreTimeUtils.timestamp(); @@ -457,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 { + protected async filterCourses(loadWatcher?: PageLoadWatcher): Promise { let timeFilter = this.filters.timeFilterSelected; this.filteredCourses = this.allCourses; @@ -473,7 +527,15 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem 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); @@ -642,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 */ diff --git a/src/addons/notifications/pages/list/list.html b/src/addons/notifications/pages/list/list.html index 1a2cd5589..c0ccfd14d 100644 --- a/src/addons/notifications/pages/list/list.html +++ b/src/addons/notifications/pages/list/list.html @@ -71,7 +71,7 @@
+ color="info" class="clickable fab-chip" (click)="markAllNotificationsAsRead()"> diff --git a/src/addons/notifications/pages/list/list.scss b/src/addons/notifications/pages/list/list.scss index e58fa6b12..28bc96fc3 100644 --- a/src/addons/notifications/pages/list/list.scss +++ b/src/addons/notifications/pages/list/list.scss @@ -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; diff --git a/src/core/classes/page-load-watcher.ts b/src/core/classes/page-load-watcher.ts new file mode 100644 index 000000000..a76594db2 --- /dev/null +++ b/src/core/classes/page-load-watcher.ts @@ -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(); + 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 { + 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( + observable: WSObservable, + hasMeaningfulChanges?: (previousValue: T, newValue: T) => boolean, + ): Promise { + const promisedValue = new CorePromisedValue(); + 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); + } + +} diff --git a/src/core/classes/page-loads-manager.ts b/src/core/classes/page-loads-manager.ts new file mode 100644 index 000000000..0d1bf7225 --- /dev/null +++ b/src/core/classes/page-loads-manager.ts @@ -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(); + + protected initialPath?: string; + protected currentLoadWatcher?: PageLoadWatcher; + protected ongoingLoadWatchers = new Set(); + + /** + * 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 { + await CoreDomUtils.openModal({ + component: CoreRefreshButtonModalComponent, + cssClass: 'core-modal-no-background', + closeOnNavigate: true, + }); + + this.onRefreshPage.next(); + } + +} diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index cc05dd2b4..846aee4e6 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -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 {} diff --git a/src/core/components/refresh-button-modal/refresh-button-modal.html b/src/core/components/refresh-button-modal/refresh-button-modal.html new file mode 100644 index 000000000..c18d9761e --- /dev/null +++ b/src/core/components/refresh-button-modal/refresh-button-modal.html @@ -0,0 +1,4 @@ + + + {{ 'core.refresh' | translate }} + diff --git a/src/core/components/refresh-button-modal/refresh-button-modal.scss b/src/core/components/refresh-button-modal/refresh-button-modal.scss new file mode 100644 index 000000000..5541fba16 --- /dev/null +++ b/src/core/components/refresh-button-modal/refresh-button-modal.scss @@ -0,0 +1,7 @@ +@import "~theme/globals"; + +:host { + ion-chip { + @include margin(auto, auto, calc(12px + var(--bottom-tabs-size, 0px)), auto); + } +} diff --git a/src/core/components/refresh-button-modal/refresh-button-modal.ts b/src/core/components/refresh-button-modal/refresh-button-modal.ts new file mode 100644 index 000000000..af0c7078a --- /dev/null +++ b/src/core/components/refresh-button-modal/refresh-button-modal.ts @@ -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(); + } + +} diff --git a/src/core/features/block/classes/base-block-component.ts b/src/core/features/block/classes/base-block-component.ts index 58d3878ab..d31b7cac7 100644 --- a/src/core/features/block/classes/base-block-component.ts +++ b/src/core/features/block/classes/base-block-component.ts @@ -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(); 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 { + protected async refreshContent(showLoading?: boolean): Promise { + 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 { + if (!this.loaded) { + // Content being loaded, don't do anything. + return; + } + + this.loaded = false; + await this.loadContent(); + } + + /** + * @inheritdoc + */ + async ready(): Promise { + return await this.onReadyPromise; + } + } /** diff --git a/src/core/features/courses/pages/my/my.ts b/src/core/features/courses/pages/my/my.ts index 5bc3723d3..3f8b321a0 100644 --- a/src/core/features/courses/pages/my/my.ts +++ b/src/core/features/courses/pages/my/my.ts @@ -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(); + 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 { + protected async loadContent(firstLoad = false): Promise { + 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 { + return await this.onReadyPromise; } } diff --git a/src/core/features/courses/services/courses-helper.ts b/src/core/features/courses/services/courses-helper.ts index a4231cec5..4e81eac5c 100644 --- a/src/core/features/courses/services/courses-helper.ts +++ b/src/core/features/courses/services/courses-helper.ts @@ -227,7 +227,7 @@ export class CoreCoursesHelperProvider { filter?: string, loadCategoryNames: boolean = false, options: CoreSitesCommonWSOptions = {}, - ): Promise { + ): Promise { return firstValueFrom(this.getUserCoursesWithOptionsObservable({ sort, slice, @@ -245,7 +245,8 @@ export class CoreCoursesHelperProvider { */ getUserCoursesWithOptionsObservable( options: CoreCoursesGetWithOptionsOptions = {}, - ): Observable { + ): Observable { + return CoreCourses.getUserCoursesObservable(options).pipe( chainRequests(options.readingStrategy, (courses, newReadingStrategy) => { if (courses.length <= 0) { diff --git a/src/core/singletons/object.ts b/src/core/singletons/object.ts index 5e5571451..a26584d60 100644 --- a/src/core/singletons/object.ts +++ b/src/core/singletons/object.ts @@ -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(obj: T, keys: K[]): Pick; + static only(obj: T, regex: RegExp): Partial; + static only(obj: T, keysOrRegex: K[] | RegExp): Pick | Partial { + const newObject: Partial = {}; + + 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. * diff --git a/src/core/singletons/tests/object.test.ts b/src/core/singletons/tests/object.test.ts index a3b5409b7..c0ce0d302 100644 --- a/src/core/singletons/tests/object.test.ts +++ b/src/core/singletons/tests/object.test.ts @@ -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, diff --git a/src/core/utils/rxjs.ts b/src/core/utils/rxjs.ts index d284b971a..463b9b332 100644 --- a/src/core/utils/rxjs.ts +++ b/src/core/utils/rxjs.ts @@ -126,6 +126,7 @@ type GetObservablesReturnTypes = { [key in keyof T]: T[key] extends Observabl */ type ZipObservableData = { values: T[]; + hasValueForIndex: boolean[]; completed: boolean; subscription?: Subscription; }; @@ -159,7 +160,7 @@ export function zipIncludingComplete[]>( } // 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) { return; } @@ -177,15 +178,22 @@ export function zipIncludingComplete[]>( } }; + // Before subscribing, initialize the data for all observables. observables.forEach((observable, obsIndex) => { - const observableData: ZipObservableData = { + observablesData[obsIndex] = { values: [], + hasValueForIndex: [], completed: false, }; + }); + + observables.forEach((observable, obsIndex) => { + const observableData = observablesData[obsIndex]; observableData.subscription = observable.subscribe({ next: (value) => { observableData.values.push(value); + observableData.hasValueForIndex.push(true); treatEmitted(); }, error: (error) => { @@ -198,8 +206,6 @@ export function zipIncludingComplete[]>( treatEmitted(true); }, }); - - observablesData[obsIndex] = observableData; }); // When unsubscribing, unsubscribe from all observables. diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 9e6103704..4360e0529 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -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; + } +}