MOBILE-3817 courses: Apply update in background to My Courses
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;
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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…
Reference in New Issue