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