MOBILE-3817 courses: Apply update in background to My Courses

main
Dani Palou 2022-07-18 14:07:05 +02:00
parent ce9c086819
commit 01df501cad
16 changed files with 690 additions and 61 deletions

View File

@ -12,12 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, Optional, OnChanges, SimpleChanges } from '@angular/core';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreCoursesProvider, CoreCourses, CoreCoursesMyCoursesUpdatedEventData } from '@features/courses/services/courses';
import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper';
import {
CoreCoursesProvider,
CoreCourses,
CoreCoursesMyCoursesUpdatedEventData,
CoreCourseSummaryData,
} from '@features/courses/services/courses';
import { CoreCoursesHelper, CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses-helper';
import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper';
import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate';
import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
@ -28,6 +33,8 @@ import { CoreTextUtils } from '@services/utils/text';
import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion';
import { IonRefresher, IonSearchbar } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { PageLoadWatcher } from '@classes/page-load-watcher';
import { PageLoadsManager } from '@classes/page-loads-manager';
const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] =
['all', 'inprogress', 'future', 'past', 'favourite', 'allincludinghidden', 'hidden'];
@ -40,9 +47,9 @@ const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] =
templateUrl: 'addon-block-myoverview.html',
styleUrls: ['myoverview.scss'],
})
export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy {
export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy, OnChanges {
filteredCourses: CoreEnrolledCourseDataWithOptions[] = [];
filteredCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = [];
prefetchCoursesData: CorePrefetchStatusInfo = {
icon: '',
@ -84,7 +91,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
searchEnabled = false;
protected currentSite!: CoreSite;
protected allCourses: CoreEnrolledCourseDataWithOptions[] = [];
protected allCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = [];
protected prefetchIconsInitialized = false;
protected isDirty = false;
protected isDestroyed = false;
@ -94,15 +101,21 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
protected gradePeriodAfter = 0;
protected gradePeriodBefore = 0;
protected today = 0;
protected firstLoadWatcher?: PageLoadWatcher;
protected loadsManager: PageLoadsManager;
constructor() {
constructor(@Optional() loadsManager?: PageLoadsManager) {
super('AddonBlockMyOverviewComponent');
this.loadsManager = loadsManager ?? new PageLoadsManager();
}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.firstLoadWatcher = this.loadsManager.startComponentLoad(this);
// Refresh the enabled flags if enabled.
this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
@ -159,6 +172,16 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
});
}
/**
* @inheritdoc
*/
ngOnChanges(changes: SimpleChanges): void {
if (this.loaded && changes.block) {
// Block was re-fetched, load content.
this.reloadContent();
}
}
/**
* Refresh the data.
*
@ -226,35 +249,66 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
* @inheritdoc
*/
protected async fetchContent(): Promise<void> {
const config = this.block.configsRecord;
const loadWatcher = this.firstLoadWatcher ?? this.loadsManager.startComponentLoad(this);
this.firstLoadWatcher = undefined;
const showCategories = config?.displaycategories?.value == '1';
await Promise.all([
this.loadAllCourses(loadWatcher),
this.loadGracePeriod(loadWatcher),
]);
this.allCourses = await CoreCoursesHelper.getUserCoursesWithOptions(
this.sort.selected,
undefined,
undefined,
showCategories,
{
readingStrategy: this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : undefined,
},
this.loadSort();
this.loadLayouts(this.block.configsRecord?.layouts?.value.split(','));
await this.loadFilters(this.block.configsRecord, loadWatcher);
this.isDirty = false;
}
/**
* Load all courses.
*
* @param loadWatcher To manage the requests.
* @return Promise resolved when done.
*/
protected async loadAllCourses(loadWatcher: PageLoadWatcher): Promise<void> {
const showCategories = this.block.configsRecord?.displaycategories?.value === '1';
this.allCourses = await loadWatcher.watchRequest(
CoreCoursesHelper.getUserCoursesWithOptionsObservable({
sort: this.sort.selected,
loadCategoryNames: showCategories,
readingStrategy: this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : loadWatcher.getReadingStrategy(),
}),
this.coursesHaveMeaningfulChanges.bind(this),
);
this.hasCourses = this.allCourses.length > 0;
}
/**
* Load grace period.
*
* @param loadWatcher To manage the requests.
* @return Promise resolved when done.
*/
protected async loadGracePeriod(loadWatcher: PageLoadWatcher): Promise<void> {
this.hasCourses = this.allCourses.length > 0;
try {
this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter', this.isDirty), 10);
this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore', this.isDirty), 10);
const siteConfig = await loadWatcher.watchRequest(
this.currentSite.getConfigObservable(
undefined,
this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : loadWatcher.getReadingStrategy(),
),
);
this.gradePeriodAfter = parseInt(siteConfig.coursegraceperiodafter, 10);
this.gradePeriodBefore = parseInt(siteConfig.coursegraceperiodbefore, 10);
} catch {
this.gradePeriodAfter = 0;
this.gradePeriodBefore = 0;
}
this.loadSort();
this.loadLayouts(config?.layouts?.value.split(','));
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<string, { name: string; value: string; type: string }>,
loadWatcher?: PageLoadWatcher,
): Promise<void> {
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<void> {
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<void> {
protected async filterCourses(loadWatcher?: PageLoadWatcher): Promise<void> {
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
*/

View File

@ -71,7 +71,7 @@
<div class="mark-all-as-read" slot="fixed" collapsible-footer appearOnBottom>
<ion-chip *ngIf="notifications.loaded && canMarkAllNotificationsAsRead" [disabled]="loadingMarkAllNotificationsAsRead"
color="primary" (click)="markAllNotificationsAsRead()">
color="info" class="clickable fab-chip" (click)="markAllNotificationsAsRead()">
<ion-icon name="fas-eye" aria-hidden="true" *ngIf="!loadingMarkAllNotificationsAsRead"></ion-icon>
<ion-spinner [attr.aria-label]="'core.loading' | translate" *ngIf="loadingMarkAllNotificationsAsRead">
</ion-spinner>

View File

@ -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;

View File

@ -0,0 +1,159 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreSitesReadingStrategy } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { Subscription } from 'rxjs';
import { AsyncComponent } from './async-component';
import { PageLoadsManager } from './page-loads-manager';
import { CorePromisedValue } from './promised-value';
import { WSObservable } from './site';
/**
* Class to watch requests from a page load (including requests from page sub-components).
*/
export class PageLoadWatcher {
protected hasChanges = false;
protected ongoingRequests = 0;
protected components = new Set<AsyncComponent>();
protected loadedTimeout?: number;
constructor(
protected loadsManager: PageLoadsManager,
protected updateInBackground: boolean,
) { }
/**
* Whether this load watcher can update data in background.
*
* @return Whether this load watcher can update data in background.
*/
canUpdateInBackground(): boolean {
return this.updateInBackground;
}
/**
* Whether this load watcher had meaningful changes received in background.
*
* @return Whether this load watcher had meaningful changes received in background.
*/
hasMeaningfulChanges(): boolean {
return this.hasChanges;
}
/**
* Set has meaningful changes to true.
*/
markMeaningfulChanges(): void {
this.hasChanges = true;
}
/**
* Watch a component, waiting for it to be ready.
*
* @param component Component instance.
*/
async watchComponent(component: AsyncComponent): Promise<void> {
this.components.add(component);
clearTimeout(this.loadedTimeout);
try {
await component.ready();
} finally {
this.components.delete(component);
this.checkHasLoaded();
}
}
/**
* Get the reading strategy to use.
*
* @return Reading strategy to use.
*/
getReadingStrategy(): CoreSitesReadingStrategy | undefined {
return this.updateInBackground ? CoreSitesReadingStrategy.STALE_WHILE_REVALIDATE : undefined;
}
/**
* Watch a WS request, handling the different values it can return, calling the hasMeaningfulChanges callback if needed to
* detect if there are new meaningful changes in the page load, and completing the page load when all requests have
* finished and all components are ready.
*
* @param observable Observable of the request.
* @param hasMeaningfulChanges Callback to check if there are meaningful changes if data was updated in background.
* @return First value of the observable.
*/
watchRequest<T>(
observable: WSObservable<T>,
hasMeaningfulChanges?: (previousValue: T, newValue: T) => boolean,
): Promise<T> {
const promisedValue = new CorePromisedValue<T>();
let subscription: Subscription | null = null;
let firstValue: T | undefined;
this.ongoingRequests++;
clearTimeout(this.loadedTimeout);
const complete = async () => {
this.ongoingRequests--;
this.checkHasLoaded();
// Subscription variable might not be set because the observable completed immediately. Wait for next tick.
await CoreUtils.nextTick();
subscription?.unsubscribe();
};
subscription = observable.subscribe({
next: value => {
if (!firstValue) {
firstValue = value;
promisedValue.resolve(value);
return;
}
// Second value, it means data was updated in background. Compare data.
if (hasMeaningfulChanges?.(firstValue, value)) {
this.hasChanges = true;
}
},
error: (error) => {
promisedValue.reject(error);
complete();
},
complete: () => complete(),
});
return promisedValue;
}
/**
* Check if the load has finished.
*/
protected checkHasLoaded(): void {
if (this.ongoingRequests !== 0 || this.components.size !== 0) {
// Load not finished.
return;
}
// It seems load has finished. Wait to make sure no new component has been rendered and started loading.
// If a new component or a new request starts the timeout will be cancelled, no need to double check it.
clearTimeout(this.loadedTimeout);
this.loadedTimeout = window.setTimeout(() => {
// Loading finished.
this.loadsManager.onPageLoaded(this);
}, 100);
}
}

View File

@ -0,0 +1,141 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreRefreshButtonModalComponent } from '@components/refresh-button-modal/refresh-button-modal';
import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom';
import { Subject } from 'rxjs';
import { AsyncComponent } from './async-component';
import { PageLoadWatcher } from './page-load-watcher';
/**
* Class to manage requests in a page and its components.
*/
export class PageLoadsManager {
onRefreshPage = new Subject<void>();
protected initialPath?: string;
protected currentLoadWatcher?: PageLoadWatcher;
protected ongoingLoadWatchers = new Set<PageLoadWatcher>();
/**
* Start a page load, creating a new load watcher and watching the page.
*
* @param page Page instance.
* @param staleWhileRevalidate Whether to use stale while revalidate strategy.
* @return Load watcher to use.
*/
startPageLoad(page: AsyncComponent, staleWhileRevalidate: boolean): PageLoadWatcher {
this.initialPath = this.initialPath ?? CoreNavigator.getCurrentPath();
this.currentLoadWatcher = new PageLoadWatcher(this, staleWhileRevalidate);
this.ongoingLoadWatchers.add(this.currentLoadWatcher);
this.currentLoadWatcher.watchComponent(page);
return this.currentLoadWatcher;
}
/**
* Start a component load, adding it to currrent load watcher (if it exists) and watching the component.
*
* @param component Component instance.
* @return Load watcher to use.
*/
startComponentLoad(component: AsyncComponent): PageLoadWatcher {
// If a component is loading data without the page loading data, probably the component is reloading/refreshing.
// In that case, create a load watcher instance but don't store it in currentLoadWatcher because it's not a page load.
const loadWatcher = this.currentLoadWatcher ?? new PageLoadWatcher(this, false);
loadWatcher.watchComponent(component);
return loadWatcher;
}
/**
* A load has finished, remove its watcher from ongoing watchers and notify if needed.
*
* @param loadWatcher Load watcher related to the load that finished.
*/
onPageLoaded(loadWatcher: PageLoadWatcher): void {
if (!this.ongoingLoadWatchers.has(loadWatcher)) {
// Watcher not in list, it probably finished already.
return;
}
this.removeLoadWatcher(loadWatcher);
if (!loadWatcher.hasMeaningfulChanges()) {
// No need to notify.
return;
}
// Check if there is another ongoing load watcher using update in background.
// If so, wait for the other one to finish before notifying to prevent being notified twice.
const ongoingLoadWatcher = this.getAnotherOngoingUpdateInBackgroundWatcher(loadWatcher);
if (ongoingLoadWatcher) {
ongoingLoadWatcher.markMeaningfulChanges();
return;
}
if (this.initialPath === CoreNavigator.getCurrentPath()) {
// User hasn't changed page, notify them.
this.notifyUser();
} else {
// User left the page, just update the data.
this.onRefreshPage.next();
}
}
/**
* Get an ongoing load watcher that supports updating in background and is not the one passed as a parameter.
*
* @param loadWatcher Load watcher to ignore.
* @return Ongoing load watcher, undefined if none found.
*/
protected getAnotherOngoingUpdateInBackgroundWatcher(loadWatcher: PageLoadWatcher): PageLoadWatcher | undefined {
for (const ongoingLoadWatcher of this.ongoingLoadWatchers) {
if (ongoingLoadWatcher.canUpdateInBackground() && loadWatcher !== ongoingLoadWatcher) {
return ongoingLoadWatcher;
}
}
}
/**
* Remove a load watcher from the list.
*
* @param loadWatcher Load watcher to remove.
*/
protected removeLoadWatcher(loadWatcher: PageLoadWatcher): void {
this.ongoingLoadWatchers.delete(loadWatcher);
if (loadWatcher === this.currentLoadWatcher) {
delete this.currentLoadWatcher;
}
}
/**
* Notify the user, asking him if he wants to update the data.
*/
protected async notifyUser(): Promise<void> {
await CoreDomUtils.openModal<boolean>({
component: CoreRefreshButtonModalComponent,
cssClass: 'core-modal-no-background',
closeOnNavigate: true,
});
this.onRefreshPage.next();
}
}

View File

@ -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 {}

View File

@ -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>

View File

@ -0,0 +1,7 @@
@import "~theme/globals";
:host {
ion-chip {
@include margin(auto, auto, calc(12px + var(--bottom-tabs-size, 0px)), auto);
}
}

View File

@ -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();
}
}

View File

@ -21,6 +21,8 @@ import { CoreCourseBlock } from '../../course/services/course';
import { Params } from '@angular/router';
import { ContextLevel } from '@/core/constants';
import { CoreNavigationOptions } from '@services/navigator';
import { AsyncComponent } from '@classes/async-component';
import { CorePromisedValue } from '@classes/promised-value';
/**
* Template class to easily create components for blocks.
@ -28,7 +30,7 @@ import { CoreNavigationOptions } from '@services/navigator';
@Component({
template: '',
})
export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent {
export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent, AsyncComponent {
@Input() title!: string; // The block title.
@Input() block!: CoreCourseBlock; // The block to render.
@ -38,8 +40,9 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon
@Input() linkParams?: Params; // Link params to go when clicked.
@Input() navOptions?: CoreNavigationOptions; // Navigation options.
loaded = false; // If the component has been loaded.
loaded = false; // If false, the UI should display a loading.
protected fetchContentDefaultError = ''; // Default error to show when loading contents.
protected onReadyPromise = new CorePromisedValue<void>();
protected logger: CoreLogger;
@ -65,9 +68,14 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon
/**
* Perform the refresh content function.
*
* @param showLoading Whether to show loading.
* @return Resolved when done.
*/
protected async refreshContent(): Promise<void> {
protected async refreshContent(showLoading?: boolean): Promise<void> {
if (showLoading) {
this.loaded = false;
}
// Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs.
try {
await this.invalidateContent();
@ -102,6 +110,7 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon
}
this.loaded = true;
this.onReadyPromise.resolve();
}
/**
@ -113,6 +122,28 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon
return;
}
/**
* Reload content without invalidating data.
*
* @return Promise resolved when done.
*/
async reloadContent(): Promise<void> {
if (!this.loaded) {
// Content being loaded, don't do anything.
return;
}
this.loaded = false;
await this.loadContent();
}
/**
* @inheritdoc
*/
async ready(): Promise<void> {
return await this.onReadyPromise;
}
}
/**

View File

@ -14,6 +14,9 @@
import { AddonBlockMyOverviewComponent } from '@addons/block/myoverview/components/myoverview/myoverview';
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { AsyncComponent } from '@classes/async-component';
import { PageLoadsManager } from '@classes/page-loads-manager';
import { CorePromisedValue } from '@classes/promised-value';
import { CoreBlockComponent } from '@features/block/components/block/block';
import { CoreCourseBlock } from '@features/course/services/course';
import { CoreCoursesDashboard, CoreCoursesDashboardProvider } from '@features/courses/services/dashboard';
@ -23,6 +26,7 @@ import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { Subscription } from 'rxjs';
import { CoreCourses } from '../../services/courses';
/**
@ -32,8 +36,12 @@ import { CoreCourses } from '../../services/courses';
selector: 'page-core-courses-my',
templateUrl: 'my.html',
styleUrls: ['my.scss'],
providers: [{
provide: PageLoadsManager,
useClass: PageLoadsManager,
}],
})
export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy, AsyncComponent {
@ViewChild(CoreBlockComponent) block!: CoreBlockComponent;
@ -47,8 +55,10 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
hasSideBlocks = false;
protected updateSiteObserver: CoreEventObserver;
protected onReadyPromise = new CorePromisedValue<void>();
protected loadsManagerSubscription: Subscription;
constructor() {
constructor(protected loadsManager: PageLoadsManager) {
// Refresh the enabled flags if site is updated.
this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
@ -57,6 +67,11 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
}, CoreSites.getCurrentSiteId());
this.userId = CoreSites.getCurrentSiteUserId();
this.loadsManagerSubscription = this.loadsManager.onRefreshPage.subscribe(() => {
this.loaded = false;
this.loadContent();
});
}
/**
@ -70,19 +85,27 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
this.loadSiteName();
this.loadContent();
this.loadContent(true);
}
/**
* Load my overview block instance.
* Load data.
*
* @param firstLoad Whether it's the first load.
*/
protected async loadContent(): Promise<void> {
protected async loadContent(firstLoad = false): Promise<void> {
const loadWatcher = this.loadsManager.startPageLoad(this, !!firstLoad);
const available = await CoreCoursesDashboard.isAvailable();
const disabled = await CoreCourses.isMyCoursesDisabled();
if (available && !disabled) {
try {
const blocks = await CoreCoursesDashboard.getDashboardBlocks(undefined, undefined, this.myPageCourses);
const blocks = await loadWatcher.watchRequest(
CoreCoursesDashboard.getDashboardBlocksObservable({
myPage: this.myPageCourses,
readingStrategy: loadWatcher.getReadingStrategy(),
}),
);
// My overview block should always be in main blocks, but check side blocks too just in case.
this.loadedBlock = blocks.mainBlocks.concat(blocks.sideBlocks).find((block) => block.name == 'myoverview');
@ -106,6 +129,7 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
}
this.loaded = true;
this.onReadyPromise.resolve();
}
/**
@ -138,7 +162,7 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
// Invalidate the blocks.
if (this.myOverviewBlock) {
promises.push(CoreUtils.ignoreErrors(this.myOverviewBlock.doRefresh()));
promises.push(CoreUtils.ignoreErrors(this.myOverviewBlock.invalidateContent()));
}
Promise.all(promises).finally(() => {
@ -153,6 +177,14 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
*/
ngOnDestroy(): void {
this.updateSiteObserver?.off();
this.loadsManagerSubscription.unsubscribe();
}
/**
* @inheritdoc
*/
async ready(): Promise<void> {
return await this.onReadyPromise;
}
}

View File

@ -227,7 +227,7 @@ export class CoreCoursesHelperProvider {
filter?: string,
loadCategoryNames: boolean = false,
options: CoreSitesCommonWSOptions = {},
): Promise<CoreEnrolledCourseDataWithOptions[]> {
): Promise<CoreEnrolledCourseDataWithExtraInfoAndOptions[]> {
return firstValueFrom(this.getUserCoursesWithOptionsObservable({
sort,
slice,
@ -245,7 +245,8 @@ export class CoreCoursesHelperProvider {
*/
getUserCoursesWithOptionsObservable(
options: CoreCoursesGetWithOptionsOptions = {},
): Observable<CoreEnrolledCourseDataWithOptions[]> {
): Observable<CoreEnrolledCourseDataWithExtraInfoAndOptions[]> {
return CoreCourses.getUserCoursesObservable(options).pipe(
chainRequests(options.readingStrategy, (courses, newReadingStrategy) => {
if (courses.length <= 0) {

View File

@ -65,6 +65,35 @@ export class CoreObject {
return Object.keys(object).length === 0;
}
/**
* Return an object including only certain keys.
*
* @param obj Object.
* @param keysOrRegex If array is supplied, keys to include. Otherwise, regular expression used to filter keys.
* @return New object with only the specified keys.
*/
static only<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K>;
static only<T>(obj: T, regex: RegExp): Partial<T>;
static only<T, K extends keyof T>(obj: T, keysOrRegex: K[] | RegExp): Pick<T, K> | Partial<T> {
const newObject: Partial<T> = {};
if (Array.isArray(keysOrRegex)) {
for (const key of keysOrRegex) {
newObject[key] = obj[key];
}
} else {
const originalKeys = Object.keys(obj);
for (const key of originalKeys) {
if (key.match(keysOrRegex)) {
newObject[key] = obj[key];
}
}
}
return newObject;
}
/**
* Create a new object without the specified keys.
*

View File

@ -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,

View File

@ -126,6 +126,7 @@ type GetObservablesReturnTypes<T> = { [key in keyof T]: T[key] extends Observabl
*/
type ZipObservableData<T = unknown> = {
values: T[];
hasValueForIndex: boolean[];
completed: boolean;
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.
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<T extends Observable<unknown>[]>(
}
};
// 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<T extends Observable<unknown>[]>(
treatEmitted(true);
},
});
observablesData[obsIndex] = observableData;
});
// When unsubscribing, unsubscribe from all observables.

View File

@ -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;
}
}