commit
57f10bff4a
|
@ -276,6 +276,7 @@ testsConfig['rules']['padded-blocks'] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
testsConfig['rules']['jest/expect-expect'] = 'off';
|
testsConfig['rules']['jest/expect-expect'] = 'off';
|
||||||
|
testsConfig['rules']['jest/no-done-callback'] = 'off';
|
||||||
testsConfig['plugins'].push('jest');
|
testsConfig['plugins'].push('jest');
|
||||||
testsConfig['extends'].push('plugin:jest/recommended');
|
testsConfig['extends'].push('plugin:jest/recommended');
|
||||||
|
|
||||||
|
|
|
@ -12,12 +12,17 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit, OnDestroy, Optional, OnChanges, SimpleChanges } from '@angular/core';
|
||||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||||
import { CoreTimeUtils } from '@services/utils/time';
|
import { CoreTimeUtils } from '@services/utils/time';
|
||||||
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||||
import { CoreCoursesProvider, CoreCourses, CoreCoursesMyCoursesUpdatedEventData } from '@features/courses/services/courses';
|
import {
|
||||||
import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper';
|
CoreCoursesProvider,
|
||||||
|
CoreCourses,
|
||||||
|
CoreCoursesMyCoursesUpdatedEventData,
|
||||||
|
CoreCourseSummaryData,
|
||||||
|
} from '@features/courses/services/courses';
|
||||||
|
import { CoreCoursesHelper, CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses-helper';
|
||||||
import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper';
|
import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper';
|
||||||
import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate';
|
import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate';
|
||||||
import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
|
import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
|
||||||
|
@ -28,6 +33,8 @@ import { CoreTextUtils } from '@services/utils/text';
|
||||||
import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion';
|
import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion';
|
||||||
import { IonRefresher, IonSearchbar } from '@ionic/angular';
|
import { IonRefresher, IonSearchbar } from '@ionic/angular';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
import { CoreNavigator } from '@services/navigator';
|
||||||
|
import { PageLoadWatcher } from '@classes/page-load-watcher';
|
||||||
|
import { PageLoadsManager } from '@classes/page-loads-manager';
|
||||||
|
|
||||||
const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] =
|
const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] =
|
||||||
['all', 'inprogress', 'future', 'past', 'favourite', 'allincludinghidden', 'hidden'];
|
['all', 'inprogress', 'future', 'past', 'favourite', 'allincludinghidden', 'hidden'];
|
||||||
|
@ -40,9 +47,9 @@ const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] =
|
||||||
templateUrl: 'addon-block-myoverview.html',
|
templateUrl: 'addon-block-myoverview.html',
|
||||||
styleUrls: ['myoverview.scss'],
|
styleUrls: ['myoverview.scss'],
|
||||||
})
|
})
|
||||||
export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy {
|
export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
|
|
||||||
filteredCourses: CoreEnrolledCourseDataWithOptions[] = [];
|
filteredCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = [];
|
||||||
|
|
||||||
prefetchCoursesData: CorePrefetchStatusInfo = {
|
prefetchCoursesData: CorePrefetchStatusInfo = {
|
||||||
icon: '',
|
icon: '',
|
||||||
|
@ -84,7 +91,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
|
||||||
searchEnabled = false;
|
searchEnabled = false;
|
||||||
|
|
||||||
protected currentSite!: CoreSite;
|
protected currentSite!: CoreSite;
|
||||||
protected allCourses: CoreEnrolledCourseDataWithOptions[] = [];
|
protected allCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[] = [];
|
||||||
protected prefetchIconsInitialized = false;
|
protected prefetchIconsInitialized = false;
|
||||||
protected isDirty = false;
|
protected isDirty = false;
|
||||||
protected isDestroyed = false;
|
protected isDestroyed = false;
|
||||||
|
@ -94,15 +101,21 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
|
||||||
protected gradePeriodAfter = 0;
|
protected gradePeriodAfter = 0;
|
||||||
protected gradePeriodBefore = 0;
|
protected gradePeriodBefore = 0;
|
||||||
protected today = 0;
|
protected today = 0;
|
||||||
|
protected firstLoadWatcher?: PageLoadWatcher;
|
||||||
|
protected loadsManager: PageLoadsManager;
|
||||||
|
|
||||||
constructor() {
|
constructor(@Optional() loadsManager?: PageLoadsManager) {
|
||||||
super('AddonBlockMyOverviewComponent');
|
super('AddonBlockMyOverviewComponent');
|
||||||
|
|
||||||
|
this.loadsManager = loadsManager ?? new PageLoadsManager();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
|
this.firstLoadWatcher = this.loadsManager.startComponentLoad(this);
|
||||||
|
|
||||||
// Refresh the enabled flags if enabled.
|
// Refresh the enabled flags if enabled.
|
||||||
this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
|
this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
|
||||||
this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
|
this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
|
||||||
|
@ -159,6 +172,16 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (this.loaded && changes.block) {
|
||||||
|
// Block was re-fetched, load content.
|
||||||
|
this.reloadContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh the data.
|
* Refresh the data.
|
||||||
*
|
*
|
||||||
|
@ -226,35 +249,66 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
protected async fetchContent(): Promise<void> {
|
protected async fetchContent(): Promise<void> {
|
||||||
const config = this.block.configsRecord;
|
const loadWatcher = this.firstLoadWatcher ?? this.loadsManager.startComponentLoad(this);
|
||||||
|
this.firstLoadWatcher = undefined;
|
||||||
|
|
||||||
const showCategories = config?.displaycategories?.value == '1';
|
await Promise.all([
|
||||||
|
this.loadAllCourses(loadWatcher),
|
||||||
|
this.loadGracePeriod(loadWatcher),
|
||||||
|
]);
|
||||||
|
|
||||||
this.allCourses = await CoreCoursesHelper.getUserCoursesWithOptions(
|
this.loadSort();
|
||||||
this.sort.selected,
|
this.loadLayouts(this.block.configsRecord?.layouts?.value.split(','));
|
||||||
undefined,
|
|
||||||
undefined,
|
await this.loadFilters(this.block.configsRecord, loadWatcher);
|
||||||
showCategories,
|
|
||||||
{
|
this.isDirty = false;
|
||||||
readingStrategy: this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : undefined,
|
}
|
||||||
},
|
|
||||||
|
/**
|
||||||
|
* Load all courses.
|
||||||
|
*
|
||||||
|
* @param loadWatcher To manage the requests.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async loadAllCourses(loadWatcher: PageLoadWatcher): Promise<void> {
|
||||||
|
const showCategories = this.block.configsRecord?.displaycategories?.value === '1';
|
||||||
|
|
||||||
|
this.allCourses = await loadWatcher.watchRequest(
|
||||||
|
CoreCoursesHelper.getUserCoursesWithOptionsObservable({
|
||||||
|
sort: this.sort.selected,
|
||||||
|
loadCategoryNames: showCategories,
|
||||||
|
readingStrategy: this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : loadWatcher.getReadingStrategy(),
|
||||||
|
}),
|
||||||
|
this.coursesHaveMeaningfulChanges.bind(this),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.hasCourses = this.allCourses.length > 0;
|
this.hasCourses = this.allCourses.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load grace period.
|
||||||
|
*
|
||||||
|
* @param loadWatcher To manage the requests.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async loadGracePeriod(loadWatcher: PageLoadWatcher): Promise<void> {
|
||||||
|
this.hasCourses = this.allCourses.length > 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter', this.isDirty), 10);
|
const siteConfig = await loadWatcher.watchRequest(
|
||||||
this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore', this.isDirty), 10);
|
this.currentSite.getConfigObservable(
|
||||||
|
undefined,
|
||||||
|
this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : loadWatcher.getReadingStrategy(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.gradePeriodAfter = parseInt(siteConfig.coursegraceperiodafter, 10);
|
||||||
|
this.gradePeriodBefore = parseInt(siteConfig.coursegraceperiodbefore, 10);
|
||||||
} catch {
|
} catch {
|
||||||
this.gradePeriodAfter = 0;
|
this.gradePeriodAfter = 0;
|
||||||
this.gradePeriodBefore = 0;
|
this.gradePeriodBefore = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loadSort();
|
|
||||||
this.loadLayouts(config?.layouts?.value.split(','));
|
|
||||||
this.loadFilters(config);
|
|
||||||
|
|
||||||
this.isDirty = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -279,10 +333,13 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
|
||||||
* Load filters.
|
* Load filters.
|
||||||
*
|
*
|
||||||
* @param config Block configuration.
|
* @param config Block configuration.
|
||||||
|
* @param loadWatcher To manage the requests.
|
||||||
|
* @return Promise resolved when done.
|
||||||
*/
|
*/
|
||||||
protected loadFilters(
|
protected async loadFilters(
|
||||||
config?: Record<string, { name: string; value: string; type: string }>,
|
config?: Record<string, { name: string; value: string; type: string }>,
|
||||||
): void {
|
loadWatcher?: PageLoadWatcher,
|
||||||
|
): Promise<void> {
|
||||||
if (!this.hasCourses) {
|
if (!this.hasCourses) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -320,7 +377,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
|
||||||
this.saveFilters('all');
|
this.saveFilters('all');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.filterCourses();
|
await this.filterCourses(loadWatcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -369,14 +426,14 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
|
||||||
protected async refreshCourseList(data: CoreCoursesMyCoursesUpdatedEventData): Promise<void> {
|
protected async refreshCourseList(data: CoreCoursesMyCoursesUpdatedEventData): Promise<void> {
|
||||||
if (data.action == CoreCoursesProvider.ACTION_ENROL) {
|
if (data.action == CoreCoursesProvider.ACTION_ENROL) {
|
||||||
// Always update if user enrolled in a course.
|
// Always update if user enrolled in a course.
|
||||||
return this.refreshContent();
|
return this.refreshContent(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const course = this.allCourses.find((course) => course.id == data.courseId);
|
const course = this.allCourses.find((course) => course.id == data.courseId);
|
||||||
if (data.action == CoreCoursesProvider.ACTION_STATE_CHANGED) {
|
if (data.action == CoreCoursesProvider.ACTION_STATE_CHANGED) {
|
||||||
if (!course) {
|
if (!course) {
|
||||||
// Not found, use WS update.
|
// Not found, use WS update.
|
||||||
return this.refreshContent();
|
return this.refreshContent(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.state == CoreCoursesProvider.STATE_FAVOURITE) {
|
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 (data.action == CoreCoursesProvider.ACTION_VIEW && data.courseId != CoreSites.getCurrentSiteHomeId()) {
|
||||||
if (!course) {
|
if (!course) {
|
||||||
// Not found, use WS update.
|
// Not found, use WS update.
|
||||||
return this.refreshContent();
|
return this.refreshContent(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
course.lastaccess = CoreTimeUtils.timestamp();
|
course.lastaccess = CoreTimeUtils.timestamp();
|
||||||
|
@ -451,8 +508,11 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set selected courses filter.
|
* Set selected courses filter.
|
||||||
|
*
|
||||||
|
* @param loadWatcher To manage the requests.
|
||||||
|
* @return Promise resolved when done.
|
||||||
*/
|
*/
|
||||||
protected async filterCourses(): Promise<void> {
|
protected async filterCourses(loadWatcher?: PageLoadWatcher): Promise<void> {
|
||||||
let timeFilter = this.filters.timeFilterSelected;
|
let timeFilter = this.filters.timeFilterSelected;
|
||||||
|
|
||||||
this.filteredCourses = this.allCourses;
|
this.filteredCourses = this.allCourses;
|
||||||
|
@ -463,18 +523,36 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
|
||||||
const customFilterValue = this.filters.customFilters[timeFilter.substring(7)]?.value;
|
const customFilterValue = this.filters.customFilters[timeFilter.substring(7)]?.value;
|
||||||
|
|
||||||
if (customFilterName !== undefined && customFilterValue !== undefined) {
|
if (customFilterName !== undefined && customFilterValue !== undefined) {
|
||||||
|
const alreadyLoading = this.loaded === false;
|
||||||
this.loaded = false;
|
this.loaded = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const courses = await CoreCourses.getEnrolledCoursesByCustomField(customFilterName, customFilterValue);
|
const courses = loadWatcher ?
|
||||||
|
await loadWatcher.watchRequest(
|
||||||
|
CoreCourses.getEnrolledCoursesByCustomFieldObservable(customFilterName, customFilterValue, {
|
||||||
|
readingStrategy: loadWatcher.getReadingStrategy(),
|
||||||
|
}),
|
||||||
|
this.customFilterCoursesHaveMeaningfulChanges.bind(this),
|
||||||
|
)
|
||||||
|
:
|
||||||
|
await CoreCourses.getEnrolledCoursesByCustomField(customFilterName, customFilterValue);
|
||||||
|
|
||||||
// Get the courses information from allincludinghidden to get the max info about the course.
|
// Get the courses information from allincludinghidden to get the max info about the course.
|
||||||
const courseIds = courses.map((course) => course.id);
|
const courseIds = courses.map((course) => course.id);
|
||||||
|
|
||||||
this.filteredCourses = this.filteredCourses.filter((course) => courseIds.includes(course.id));
|
this.filteredCourses = this.filteredCourses.filter((course) => courseIds.includes(course.id));
|
||||||
|
this.saveFilters(timeFilter);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (alreadyLoading) {
|
||||||
|
throw error; // Pass the error to the caller so it's treated there.
|
||||||
|
}
|
||||||
|
|
||||||
CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError);
|
CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError);
|
||||||
} finally {
|
} finally {
|
||||||
this.loaded = true;
|
if (!alreadyLoading) {
|
||||||
|
// Only set loaded to true if there was no other data being loaded.
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -626,6 +704,62 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
|
||||||
CoreNavigator.navigateToSitePath('courses/list', { params : { mode: 'search' } });
|
CoreNavigator.navigateToSitePath('courses/list', { params : { mode: 'search' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare if the WS data has meaningful changes for the user.
|
||||||
|
*
|
||||||
|
* @param previousCourses Previous courses.
|
||||||
|
* @param newCourses New courses.
|
||||||
|
* @return Whether it has meaningful changes.
|
||||||
|
*/
|
||||||
|
protected coursesHaveMeaningfulChanges(
|
||||||
|
previousCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[],
|
||||||
|
newCourses: CoreEnrolledCourseDataWithExtraInfoAndOptions[],
|
||||||
|
): boolean {
|
||||||
|
if (previousCourses.length !== newCourses.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousCourses = Array.from(previousCourses)
|
||||||
|
.sort((a, b) => a.fullname.toLowerCase().localeCompare(b.fullname.toLowerCase()));
|
||||||
|
newCourses = Array.from(newCourses).sort((a, b) => a.fullname.toLowerCase().localeCompare(b.fullname.toLowerCase()));
|
||||||
|
|
||||||
|
for (let i = 0; i < previousCourses.length; i++) {
|
||||||
|
const prevCourse = previousCourses[i];
|
||||||
|
const newCourse = newCourses[i];
|
||||||
|
|
||||||
|
if (
|
||||||
|
prevCourse.progress !== newCourse.progress ||
|
||||||
|
prevCourse.categoryname !== newCourse.categoryname ||
|
||||||
|
(prevCourse.displayname ?? prevCourse.fullname) !== (newCourse.displayname ?? newCourse.fullname)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare if the WS data has meaningful changes for the user.
|
||||||
|
*
|
||||||
|
* @param previousCourses Previous courses.
|
||||||
|
* @param newCourses New courses.
|
||||||
|
* @return Whether it has meaningful changes.
|
||||||
|
*/
|
||||||
|
protected customFilterCoursesHaveMeaningfulChanges(
|
||||||
|
previousCourses: CoreCourseSummaryData[],
|
||||||
|
newCourses: CoreCourseSummaryData[],
|
||||||
|
): boolean {
|
||||||
|
if (previousCourses.length !== newCourses.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousIds = previousCourses.map(course => course.id).sort();
|
||||||
|
const newIds = newCourses.map(course => course.id).sort();
|
||||||
|
|
||||||
|
return previousIds.some((previousId, index) => previousId !== newIds[index]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -14,13 +14,15 @@
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { CoreLogger } from '@singletons/logger';
|
import { CoreLogger } from '@singletons/logger';
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { CoreCourses } from '@features/courses/services/courses';
|
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 { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws';
|
||||||
import { makeSingleton } from '@singletons';
|
import { makeSingleton } from '@singletons';
|
||||||
import { CoreError } from '@classes/errors/error';
|
import { CoreError } from '@classes/errors/error';
|
||||||
|
import { asyncObservable, firstValueFrom } from '@/core/utils/rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
const ROOT_CACHE_KEY = 'mmaCourseCompletion:';
|
const ROOT_CACHE_KEY = 'mmaCourseCompletion:';
|
||||||
|
|
||||||
|
@ -93,33 +95,55 @@ export class AddonCourseCompletionProvider {
|
||||||
* @param siteId Site ID. If not defined, use current site.
|
* @param siteId Site ID. If not defined, use current site.
|
||||||
* @return Promise to be resolved when the completion is retrieved.
|
* @return Promise to be resolved when the completion is retrieved.
|
||||||
*/
|
*/
|
||||||
async getCompletion(
|
getCompletion(
|
||||||
courseId: number,
|
courseId: number,
|
||||||
userId?: number,
|
userId?: number,
|
||||||
preSets: CoreSiteWSPreSets = {},
|
preSets: CoreSiteWSPreSets = {},
|
||||||
siteId?: string,
|
siteId?: string,
|
||||||
): Promise<AddonCourseCompletionCourseCompletionStatus> {
|
): Promise<AddonCourseCompletionCourseCompletionStatus> {
|
||||||
|
return firstValueFrom(this.getCompletionObservable(courseId, {
|
||||||
|
userId,
|
||||||
|
preSets,
|
||||||
|
siteId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const site = await CoreSites.getSite(siteId);
|
/**
|
||||||
userId = userId || site.getUserId();
|
* Get course completion status for a certain course and user.
|
||||||
this.logger.debug('Get completion for course ' + courseId + ' and user ' + userId);
|
*
|
||||||
|
* @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 = {
|
const userId = options.userId || site.getUserId();
|
||||||
courseid: courseId,
|
this.logger.debug('Get completion for course ' + courseId + ' and user ' + userId);
|
||||||
userid: userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
preSets.cacheKey = this.getCompletionCacheKey(courseId, userId);
|
const data: AddonCourseCompletionGetCourseCompletionStatusWSParams = {
|
||||||
preSets.updateFrequency = preSets.updateFrequency || CoreSite.FREQUENCY_SOMETIMES;
|
courseid: courseId,
|
||||||
preSets.cacheErrors = ['notenroled'];
|
userid: userId,
|
||||||
|
};
|
||||||
|
|
||||||
const result: AddonCourseCompletionGetCourseCompletionStatusWSResponse =
|
const preSets = {
|
||||||
await site.read('core_completion_get_course_completion_status', data, preSets);
|
...(options.preSets ?? {}),
|
||||||
if (result.completionstatus) {
|
cacheKey: this.getCompletionCacheKey(courseId, userId),
|
||||||
return result.completionstatus;
|
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 = {
|
export type AddonCourseCompletionMarkCourseSelfCompletedWSParams = {
|
||||||
courseid: number; // Course ID.
|
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>
|
<div class="mark-all-as-read" slot="fixed" collapsible-footer appearOnBottom>
|
||||||
<ion-chip *ngIf="notifications.loaded && canMarkAllNotificationsAsRead" [disabled]="loadingMarkAllNotificationsAsRead"
|
<ion-chip *ngIf="notifications.loaded && canMarkAllNotificationsAsRead" [disabled]="loadingMarkAllNotificationsAsRead"
|
||||||
color="primary" (click)="markAllNotificationsAsRead()">
|
color="info" class="clickable fab-chip" (click)="markAllNotificationsAsRead()">
|
||||||
<ion-icon name="fas-eye" aria-hidden="true" *ngIf="!loadingMarkAllNotificationsAsRead"></ion-icon>
|
<ion-icon name="fas-eye" aria-hidden="true" *ngIf="!loadingMarkAllNotificationsAsRead"></ion-icon>
|
||||||
<ion-spinner [attr.aria-label]="'core.loading' | translate" *ngIf="loadingMarkAllNotificationsAsRead">
|
<ion-spinner [attr.aria-label]="'core.loading' | translate" *ngIf="loadingMarkAllNotificationsAsRead">
|
||||||
</ion-spinner>
|
</ion-spinner>
|
||||||
|
|
|
@ -56,9 +56,7 @@ ion-item {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
ion-chip.ion-color {
|
ion-chip.ion-color {
|
||||||
pointer-events: all;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, .4);
|
|
||||||
|
|
||||||
ion-spinner {
|
ion-spinner {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
|
|
|
@ -0,0 +1,159 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { CoreSitesReadingStrategy } from '@services/sites';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { AsyncComponent } from './async-component';
|
||||||
|
import { PageLoadsManager } from './page-loads-manager';
|
||||||
|
import { CorePromisedValue } from './promised-value';
|
||||||
|
import { WSObservable } from './site';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class to watch requests from a page load (including requests from page sub-components).
|
||||||
|
*/
|
||||||
|
export class PageLoadWatcher {
|
||||||
|
|
||||||
|
protected hasChanges = false;
|
||||||
|
protected ongoingRequests = 0;
|
||||||
|
protected components = new Set<AsyncComponent>();
|
||||||
|
protected loadedTimeout?: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected loadsManager: PageLoadsManager,
|
||||||
|
protected updateInBackground: boolean,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this load watcher can update data in background.
|
||||||
|
*
|
||||||
|
* @return Whether this load watcher can update data in background.
|
||||||
|
*/
|
||||||
|
canUpdateInBackground(): boolean {
|
||||||
|
return this.updateInBackground;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this load watcher had meaningful changes received in background.
|
||||||
|
*
|
||||||
|
* @return Whether this load watcher had meaningful changes received in background.
|
||||||
|
*/
|
||||||
|
hasMeaningfulChanges(): boolean {
|
||||||
|
return this.hasChanges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set has meaningful changes to true.
|
||||||
|
*/
|
||||||
|
markMeaningfulChanges(): void {
|
||||||
|
this.hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch a component, waiting for it to be ready.
|
||||||
|
*
|
||||||
|
* @param component Component instance.
|
||||||
|
*/
|
||||||
|
async watchComponent(component: AsyncComponent): Promise<void> {
|
||||||
|
this.components.add(component);
|
||||||
|
clearTimeout(this.loadedTimeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await component.ready();
|
||||||
|
} finally {
|
||||||
|
this.components.delete(component);
|
||||||
|
this.checkHasLoaded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the reading strategy to use.
|
||||||
|
*
|
||||||
|
* @return Reading strategy to use.
|
||||||
|
*/
|
||||||
|
getReadingStrategy(): CoreSitesReadingStrategy | undefined {
|
||||||
|
return this.updateInBackground ? CoreSitesReadingStrategy.STALE_WHILE_REVALIDATE : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch a WS request, handling the different values it can return, calling the hasMeaningfulChanges callback if needed to
|
||||||
|
* detect if there are new meaningful changes in the page load, and completing the page load when all requests have
|
||||||
|
* finished and all components are ready.
|
||||||
|
*
|
||||||
|
* @param observable Observable of the request.
|
||||||
|
* @param hasMeaningfulChanges Callback to check if there are meaningful changes if data was updated in background.
|
||||||
|
* @return First value of the observable.
|
||||||
|
*/
|
||||||
|
watchRequest<T>(
|
||||||
|
observable: WSObservable<T>,
|
||||||
|
hasMeaningfulChanges?: (previousValue: T, newValue: T) => boolean,
|
||||||
|
): Promise<T> {
|
||||||
|
const promisedValue = new CorePromisedValue<T>();
|
||||||
|
let subscription: Subscription | null = null;
|
||||||
|
let firstValue: T | undefined;
|
||||||
|
this.ongoingRequests++;
|
||||||
|
clearTimeout(this.loadedTimeout);
|
||||||
|
|
||||||
|
const complete = async () => {
|
||||||
|
this.ongoingRequests--;
|
||||||
|
this.checkHasLoaded();
|
||||||
|
|
||||||
|
// Subscription variable might not be set because the observable completed immediately. Wait for next tick.
|
||||||
|
await CoreUtils.nextTick();
|
||||||
|
subscription?.unsubscribe();
|
||||||
|
};
|
||||||
|
|
||||||
|
subscription = observable.subscribe({
|
||||||
|
next: value => {
|
||||||
|
if (!firstValue) {
|
||||||
|
firstValue = value;
|
||||||
|
promisedValue.resolve(value);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second value, it means data was updated in background. Compare data.
|
||||||
|
if (hasMeaningfulChanges?.(firstValue, value)) {
|
||||||
|
this.hasChanges = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
promisedValue.reject(error);
|
||||||
|
complete();
|
||||||
|
},
|
||||||
|
complete: () => complete(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return promisedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the load has finished.
|
||||||
|
*/
|
||||||
|
protected checkHasLoaded(): void {
|
||||||
|
if (this.ongoingRequests !== 0 || this.components.size !== 0) {
|
||||||
|
// Load not finished.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// It seems load has finished. Wait to make sure no new component has been rendered and started loading.
|
||||||
|
// If a new component or a new request starts the timeout will be cancelled, no need to double check it.
|
||||||
|
clearTimeout(this.loadedTimeout);
|
||||||
|
this.loadedTimeout = window.setTimeout(() => {
|
||||||
|
// Loading finished.
|
||||||
|
this.loadsManager.onPageLoaded(this);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,141 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { CoreRefreshButtonModalComponent } from '@components/refresh-button-modal/refresh-button-modal';
|
||||||
|
import { CoreNavigator } from '@services/navigator';
|
||||||
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { AsyncComponent } from './async-component';
|
||||||
|
import { PageLoadWatcher } from './page-load-watcher';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class to manage requests in a page and its components.
|
||||||
|
*/
|
||||||
|
export class PageLoadsManager {
|
||||||
|
|
||||||
|
onRefreshPage = new Subject<void>();
|
||||||
|
|
||||||
|
protected initialPath?: string;
|
||||||
|
protected currentLoadWatcher?: PageLoadWatcher;
|
||||||
|
protected ongoingLoadWatchers = new Set<PageLoadWatcher>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a page load, creating a new load watcher and watching the page.
|
||||||
|
*
|
||||||
|
* @param page Page instance.
|
||||||
|
* @param staleWhileRevalidate Whether to use stale while revalidate strategy.
|
||||||
|
* @return Load watcher to use.
|
||||||
|
*/
|
||||||
|
startPageLoad(page: AsyncComponent, staleWhileRevalidate: boolean): PageLoadWatcher {
|
||||||
|
this.initialPath = this.initialPath ?? CoreNavigator.getCurrentPath();
|
||||||
|
this.currentLoadWatcher = new PageLoadWatcher(this, staleWhileRevalidate);
|
||||||
|
this.ongoingLoadWatchers.add(this.currentLoadWatcher);
|
||||||
|
|
||||||
|
this.currentLoadWatcher.watchComponent(page);
|
||||||
|
|
||||||
|
return this.currentLoadWatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a component load, adding it to currrent load watcher (if it exists) and watching the component.
|
||||||
|
*
|
||||||
|
* @param component Component instance.
|
||||||
|
* @return Load watcher to use.
|
||||||
|
*/
|
||||||
|
startComponentLoad(component: AsyncComponent): PageLoadWatcher {
|
||||||
|
// If a component is loading data without the page loading data, probably the component is reloading/refreshing.
|
||||||
|
// In that case, create a load watcher instance but don't store it in currentLoadWatcher because it's not a page load.
|
||||||
|
const loadWatcher = this.currentLoadWatcher ?? new PageLoadWatcher(this, false);
|
||||||
|
|
||||||
|
loadWatcher.watchComponent(component);
|
||||||
|
|
||||||
|
return loadWatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A load has finished, remove its watcher from ongoing watchers and notify if needed.
|
||||||
|
*
|
||||||
|
* @param loadWatcher Load watcher related to the load that finished.
|
||||||
|
*/
|
||||||
|
onPageLoaded(loadWatcher: PageLoadWatcher): void {
|
||||||
|
if (!this.ongoingLoadWatchers.has(loadWatcher)) {
|
||||||
|
// Watcher not in list, it probably finished already.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeLoadWatcher(loadWatcher);
|
||||||
|
|
||||||
|
if (!loadWatcher.hasMeaningfulChanges()) {
|
||||||
|
// No need to notify.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there is another ongoing load watcher using update in background.
|
||||||
|
// If so, wait for the other one to finish before notifying to prevent being notified twice.
|
||||||
|
const ongoingLoadWatcher = this.getAnotherOngoingUpdateInBackgroundWatcher(loadWatcher);
|
||||||
|
if (ongoingLoadWatcher) {
|
||||||
|
ongoingLoadWatcher.markMeaningfulChanges();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.initialPath === CoreNavigator.getCurrentPath()) {
|
||||||
|
// User hasn't changed page, notify them.
|
||||||
|
this.notifyUser();
|
||||||
|
} else {
|
||||||
|
// User left the page, just update the data.
|
||||||
|
this.onRefreshPage.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an ongoing load watcher that supports updating in background and is not the one passed as a parameter.
|
||||||
|
*
|
||||||
|
* @param loadWatcher Load watcher to ignore.
|
||||||
|
* @return Ongoing load watcher, undefined if none found.
|
||||||
|
*/
|
||||||
|
protected getAnotherOngoingUpdateInBackgroundWatcher(loadWatcher: PageLoadWatcher): PageLoadWatcher | undefined {
|
||||||
|
for (const ongoingLoadWatcher of this.ongoingLoadWatchers) {
|
||||||
|
if (ongoingLoadWatcher.canUpdateInBackground() && loadWatcher !== ongoingLoadWatcher) {
|
||||||
|
return ongoingLoadWatcher;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a load watcher from the list.
|
||||||
|
*
|
||||||
|
* @param loadWatcher Load watcher to remove.
|
||||||
|
*/
|
||||||
|
protected removeLoadWatcher(loadWatcher: PageLoadWatcher): void {
|
||||||
|
this.ongoingLoadWatchers.delete(loadWatcher);
|
||||||
|
if (loadWatcher === this.currentLoadWatcher) {
|
||||||
|
delete this.currentLoadWatcher;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify the user, asking him if he wants to update the data.
|
||||||
|
*/
|
||||||
|
protected async notifyUser(): Promise<void> {
|
||||||
|
await CoreDomUtils.openModal<boolean>({
|
||||||
|
component: CoreRefreshButtonModalComponent,
|
||||||
|
cssClass: 'core-modal-no-background',
|
||||||
|
closeOnNavigate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onRefreshPage.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -57,6 +57,9 @@ import {
|
||||||
WSGroups,
|
WSGroups,
|
||||||
WS_CACHE_TABLES_PREFIX,
|
WS_CACHE_TABLES_PREFIX,
|
||||||
} from '@services/database/sites';
|
} 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.
|
* QR Code type enumeration.
|
||||||
|
@ -122,7 +125,7 @@ export class CoreSite {
|
||||||
protected lastAutoLogin = 0;
|
protected lastAutoLogin = 0;
|
||||||
protected offlineDisabled = false;
|
protected offlineDisabled = false;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 requestQueue: RequestQueueItem[] = [];
|
||||||
protected requestQueueTimeout: number | null = null;
|
protected requestQueueTimeout: number | null = null;
|
||||||
protected tokenPluginFileWorks?: boolean;
|
protected tokenPluginFileWorks?: boolean;
|
||||||
|
@ -492,18 +495,25 @@ export class CoreSite {
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
read<T = unknown>(method: string, data: any, preSets?: CoreSiteWSPreSets): Promise<T> {
|
read<T = unknown>(method: string, data: any, preSets?: CoreSiteWSPreSets): Promise<T> {
|
||||||
preSets = preSets || {};
|
return firstValueFrom(this.readObservable<T>(method, data, preSets));
|
||||||
if (preSets.getFromCache === undefined) {
|
}
|
||||||
preSets.getFromCache = true;
|
|
||||||
}
|
|
||||||
if (preSets.saveToCache === undefined) {
|
|
||||||
preSets.saveToCache = true;
|
|
||||||
}
|
|
||||||
if (preSets.reusePending === undefined) {
|
|
||||||
preSets.reusePending = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
write<T = unknown>(method: string, data: any, preSets?: CoreSiteWSPreSets): Promise<T> {
|
write<T = unknown>(method: string, data: any, preSets?: CoreSiteWSPreSets): Promise<T> {
|
||||||
preSets = preSets || {};
|
return firstValueFrom(this.writeObservable<T>(method, data, preSets));
|
||||||
if (preSets.getFromCache === undefined) {
|
}
|
||||||
preSets.getFromCache = false;
|
|
||||||
}
|
|
||||||
if (preSets.saveToCache === undefined) {
|
|
||||||
preSets.saveToCache = false;
|
|
||||||
}
|
|
||||||
if (preSets.emergencyCache === undefined) {
|
|
||||||
preSets.emergencyCache = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 method The WebService method to be called.
|
||||||
* @param data Arguments to pass to the method.
|
* @param data Arguments to pass to the method.
|
||||||
* @param preSets Extra options.
|
* @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.
|
* @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
|
* @description
|
||||||
*
|
*
|
||||||
* Sends a webservice request to the site. This method will automatically add the
|
* Sends a webservice request to the site. This method will automatically add the
|
||||||
|
@ -547,7 +576,7 @@ export class CoreSite {
|
||||||
* data hasn't expired.
|
* data hasn't expired.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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)) {
|
if (this.isLoggedOut() && !ALLOWED_LOGGEDOUT_WS.includes(method)) {
|
||||||
// Site is logged out, it cannot call WebServices.
|
// Site is logged out, it cannot call WebServices.
|
||||||
CoreEvents.trigger(CoreEvents.SESSION_EXPIRED, {}, this.id);
|
CoreEvents.trigger(CoreEvents.SESSION_EXPIRED, {}, this.id);
|
||||||
|
@ -556,7 +585,6 @@ export class CoreSite {
|
||||||
throw new CoreSilentError(Translate.instant('core.lostconnection'));
|
throw new CoreSilentError(Translate.instant('core.lostconnection'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialToken = this.token || '';
|
|
||||||
data = data || {};
|
data = data || {};
|
||||||
|
|
||||||
if (!CoreNetwork.isOnline() && this.offlineDisabled) {
|
if (!CoreNetwork.isOnline() && this.offlineDisabled) {
|
||||||
|
@ -610,172 +638,305 @@ export class CoreSite {
|
||||||
|
|
||||||
// Check for an ongoing identical request if we're not ignoring cache.
|
// Check for an ongoing identical request if we're not ignoring cache.
|
||||||
if (preSets.getFromCache && this.ongoingRequests[cacheId] !== undefined) {
|
if (preSets.getFromCache && this.ongoingRequests[cacheId] !== undefined) {
|
||||||
const response = await this.ongoingRequests[cacheId];
|
return this.ongoingRequests[cacheId];
|
||||||
|
|
||||||
// Clone the data, this may prevent errors if in the callback the object is modified.
|
|
||||||
return CoreUtils.clone(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const promise = this.getFromCache<T>(method, data, preSets, false).catch(async () => {
|
const observable = this.performRequest<T>(method, data, preSets, wsPreSets).pipe(
|
||||||
if (preSets.forceOffline) {
|
// Return a clone of the original object, this may prevent errors if in the callback the object is modified.
|
||||||
// Don't call the WS, just fail.
|
map((data) => CoreUtils.clone(data)),
|
||||||
throw new CoreError(
|
);
|
||||||
Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
try {
|
||||||
if (method !== 'core_webservice_get_site_info') {
|
let response: T | WSCachedError;
|
||||||
// Send the language to use. Do it after checking cache to prevent losing offline data when changing language.
|
let cachedData: WSCachedData<T> | undefined;
|
||||||
// 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;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.getFromCache<T>(method, data, preSets, true);
|
cachedData = await this.getFromCache<T>(method, data, preSets, false);
|
||||||
|
response = cachedData.response;
|
||||||
} catch {
|
} catch {
|
||||||
if (useSilentError) {
|
// Not found or expired, call WS.
|
||||||
throw new CoreSilentError(error.message);
|
response = await this.getFromWS<T>(method, data, preSets, wsPreSets);
|
||||||
}
|
|
||||||
|
|
||||||
throw new CoreWSError(error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
run();
|
||||||
if (response && (response.exception !== undefined || response.errorcode !== undefined)) {
|
|
||||||
throw new CoreWSError(response);
|
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;
|
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).
|
throw new CoreWSError(error);
|
||||||
try {
|
} else if (error.errorcode === 'wsaccessusersuspended') {
|
||||||
const response = await promise;
|
// 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.
|
throw new CoreWSError(error);
|
||||||
return CoreUtils.clone(response);
|
} else if (error.errorcode === 'wsaccessusernologin') {
|
||||||
} finally {
|
// User suspended, trigger event.
|
||||||
// Make sure we don't clear the promise of a newer request that ignores the cache.
|
CoreEvents.trigger(CoreEvents.USER_NO_LOGIN, { params: data }, this.id);
|
||||||
if (this.ongoingRequests[cacheId] === promise) {
|
error.message = Translate.instant('core.usernologin');
|
||||||
delete this.ongoingRequests[cacheId];
|
|
||||||
|
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 preSets Extra options.
|
||||||
* @param emergency Whether it's an "emergency" cache call (WS call failed).
|
* @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.
|
* @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>(
|
protected async getFromCache<T = unknown>(
|
||||||
method: string,
|
method: string,
|
||||||
data: any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
data: any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
preSets: CoreSiteWSPreSets,
|
preSets: CoreSiteWSPreSets,
|
||||||
emergency?: boolean,
|
emergency?: boolean,
|
||||||
): Promise<T> {
|
): Promise<WSCachedData<T>> {
|
||||||
if (!this.db || !preSets.getFromCache) {
|
if (!this.db || !preSets.getFromCache) {
|
||||||
throw new CoreError('Get from cache is disabled.');
|
throw new CoreError('Get from cache is disabled.');
|
||||||
}
|
}
|
||||||
|
@ -1020,12 +1181,22 @@ export class CoreSite {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let expirationTime: number | undefined;
|
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);
|
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');
|
this.logger.debug('Cached element found, but it is expired');
|
||||||
|
|
||||||
throw new CoreError('Cache entry 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`);
|
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.');
|
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.
|
// Check for an ongoing identical request if we're not ignoring cache.
|
||||||
if (cachePreSets.getFromCache && this.ongoingRequests[cacheId] !== undefined) {
|
if (cachePreSets.getFromCache && this.ongoingRequests[cacheId] !== undefined) {
|
||||||
const response = await this.ongoingRequests[cacheId];
|
return await firstValueFrom(this.ongoingRequests[cacheId]);
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const promise = this.getFromCache<CoreSitePublicConfigResponse>(method, {}, cachePreSets, false).catch(async () => {
|
const subject = new Subject<CoreSitePublicConfigResponse>();
|
||||||
if (cachePreSets.forceOffline) {
|
const observable = subject.pipe(
|
||||||
// Don't call the WS, just fail.
|
// Return a clone of the original object, this may prevent errors if in the callback the object is modified.
|
||||||
throw new CoreError(
|
map((data) => CoreUtils.clone(data)),
|
||||||
Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
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.
|
this.ongoingRequests[cacheId] = observable;
|
||||||
try {
|
|
||||||
const config = await this.requestPublicConfig();
|
|
||||||
|
|
||||||
if (cachePreSets.saveToCache) {
|
this.getFromCache<CoreSitePublicConfigResponse>(method, {}, cachePreSets, false)
|
||||||
this.saveToCache(method, {}, config, cachePreSets);
|
.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;
|
// Call the WS.
|
||||||
} catch (error) {
|
|
||||||
cachePreSets.omitExpires = true;
|
|
||||||
cachePreSets.getFromCache = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.getFromCache<CoreSitePublicConfigResponse>(method, {}, cachePreSets, true);
|
const config = await this.requestPublicConfig();
|
||||||
} catch {
|
|
||||||
throw error;
|
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).
|
return firstValueFrom(observable);
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1704,16 +1890,28 @@ export class CoreSite {
|
||||||
getConfig(name?: undefined, ignoreCache?: boolean): Promise<CoreSiteConfig>;
|
getConfig(name?: undefined, ignoreCache?: boolean): Promise<CoreSiteConfig>;
|
||||||
getConfig(name: string, ignoreCache?: boolean): Promise<string>;
|
getConfig(name: string, ignoreCache?: boolean): Promise<string>;
|
||||||
getConfig(name?: string, ignoreCache?: boolean): Promise<string | CoreSiteConfig> {
|
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 = {
|
const preSets: CoreSiteWSPreSets = {
|
||||||
cacheKey: this.getConfigCacheKey(),
|
cacheKey: this.getConfigCacheKey(),
|
||||||
|
...CoreSites.getReadingStrategyPreSets(readingStrategy),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (ignoreCache) {
|
return this.readObservable<CoreSiteConfigResponse>('tool_mobile_get_config', {}, preSets).pipe(map(config => {
|
||||||
preSets.getFromCache = false;
|
|
||||||
preSets.emergencyCache = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.read('tool_mobile_get_config', {}, preSets).then((config: CoreSiteConfigResponse) => {
|
|
||||||
if (name) {
|
if (name) {
|
||||||
// Return the requested setting.
|
// Return the requested setting.
|
||||||
for (const x in config.settings) {
|
for (const x in config.settings) {
|
||||||
|
@ -1732,7 +1930,7 @@ export class CoreSite {
|
||||||
|
|
||||||
return settings;
|
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.
|
* 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).
|
* can cause the request to fail (see PHP's max_input_vars).
|
||||||
*/
|
*/
|
||||||
splitRequest?: CoreWSPreSetsSplitRequest;
|
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.
|
data?: string; // Other data.
|
||||||
timeaccess?: number; // Accessed time. If not set, current time.
|
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 { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-navigation-tour';
|
||||||
import { CoreMessageComponent } from './message/message';
|
import { CoreMessageComponent } from './message/message';
|
||||||
import { CoreGroupSelectorComponent } from './group-selector/group-selector';
|
import { CoreGroupSelectorComponent } from './group-selector/group-selector';
|
||||||
|
import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-button-modal';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
|
@ -108,6 +109,7 @@ import { CoreGroupSelectorComponent } from './group-selector/group-selector';
|
||||||
CoreSpacerComponent,
|
CoreSpacerComponent,
|
||||||
CoreHorizontalScrollControlsComponent,
|
CoreHorizontalScrollControlsComponent,
|
||||||
CoreSwipeNavigationTourComponent,
|
CoreSwipeNavigationTourComponent,
|
||||||
|
CoreRefreshButtonModalComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
@ -160,6 +162,7 @@ import { CoreGroupSelectorComponent } from './group-selector/group-selector';
|
||||||
CoreSpacerComponent,
|
CoreSpacerComponent,
|
||||||
CoreHorizontalScrollControlsComponent,
|
CoreHorizontalScrollControlsComponent,
|
||||||
CoreSwipeNavigationTourComponent,
|
CoreSwipeNavigationTourComponent,
|
||||||
|
CoreRefreshButtonModalComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreComponentsModule {}
|
export class CoreComponentsModule {}
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
<ion-chip class="clickable fab-chip" color="info" (click)="closeModal()">
|
||||||
|
<ion-icon name="fas-sync" aria-hidden="true"></ion-icon>
|
||||||
|
{{ 'core.refresh' | translate }}
|
||||||
|
</ion-chip>
|
|
@ -0,0 +1,7 @@
|
||||||
|
@import "~theme/globals";
|
||||||
|
|
||||||
|
:host {
|
||||||
|
ion-chip {
|
||||||
|
@include margin(auto, auto, calc(12px + var(--bottom-tabs-size, 0px)), auto);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { ModalController } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal that displays a refresh button.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
templateUrl: 'refresh-button-modal.html',
|
||||||
|
styleUrls: ['refresh-button-modal.scss'],
|
||||||
|
})
|
||||||
|
export class CoreRefreshButtonModalComponent {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close modal.
|
||||||
|
*/
|
||||||
|
closeModal(): void {
|
||||||
|
ModalController.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -21,6 +21,8 @@ import { CoreCourseBlock } from '../../course/services/course';
|
||||||
import { Params } from '@angular/router';
|
import { Params } from '@angular/router';
|
||||||
import { ContextLevel } from '@/core/constants';
|
import { ContextLevel } from '@/core/constants';
|
||||||
import { CoreNavigationOptions } from '@services/navigator';
|
import { CoreNavigationOptions } from '@services/navigator';
|
||||||
|
import { AsyncComponent } from '@classes/async-component';
|
||||||
|
import { CorePromisedValue } from '@classes/promised-value';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template class to easily create components for blocks.
|
* Template class to easily create components for blocks.
|
||||||
|
@ -28,7 +30,7 @@ import { CoreNavigationOptions } from '@services/navigator';
|
||||||
@Component({
|
@Component({
|
||||||
template: '',
|
template: '',
|
||||||
})
|
})
|
||||||
export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent {
|
export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent, AsyncComponent {
|
||||||
|
|
||||||
@Input() title!: string; // The block title.
|
@Input() title!: string; // The block title.
|
||||||
@Input() block!: CoreCourseBlock; // The block to render.
|
@Input() block!: CoreCourseBlock; // The block to render.
|
||||||
|
@ -38,8 +40,9 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon
|
||||||
@Input() linkParams?: Params; // Link params to go when clicked.
|
@Input() linkParams?: Params; // Link params to go when clicked.
|
||||||
@Input() navOptions?: CoreNavigationOptions; // Navigation options.
|
@Input() navOptions?: CoreNavigationOptions; // Navigation options.
|
||||||
|
|
||||||
loaded = false; // If the component has been loaded.
|
loaded = false; // If false, the UI should display a loading.
|
||||||
protected fetchContentDefaultError = ''; // Default error to show when loading contents.
|
protected fetchContentDefaultError = ''; // Default error to show when loading contents.
|
||||||
|
protected onReadyPromise = new CorePromisedValue<void>();
|
||||||
|
|
||||||
protected logger: CoreLogger;
|
protected logger: CoreLogger;
|
||||||
|
|
||||||
|
@ -65,9 +68,14 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon
|
||||||
/**
|
/**
|
||||||
* Perform the refresh content function.
|
* Perform the refresh content function.
|
||||||
*
|
*
|
||||||
|
* @param showLoading Whether to show loading.
|
||||||
* @return Resolved when done.
|
* @return Resolved when done.
|
||||||
*/
|
*/
|
||||||
protected async refreshContent(): Promise<void> {
|
protected async refreshContent(showLoading?: boolean): Promise<void> {
|
||||||
|
if (showLoading) {
|
||||||
|
this.loaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs.
|
// Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs.
|
||||||
try {
|
try {
|
||||||
await this.invalidateContent();
|
await this.invalidateContent();
|
||||||
|
@ -102,6 +110,7 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
|
this.onReadyPromise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -113,6 +122,28 @@ export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockCompon
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload content without invalidating data.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async reloadContent(): Promise<void> {
|
||||||
|
if (!this.loaded) {
|
||||||
|
// Content being loaded, don't do anything.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loaded = false;
|
||||||
|
await this.loadContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
async ready(): Promise<void> {
|
||||||
|
return await this.onReadyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Component, 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 { CoreBlockDelegate } from '../../services/block-delegate';
|
||||||
import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component';
|
import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
|
@ -27,7 +27,7 @@ import type { ICoreBlockComponent } from '@features/block/classes/base-block-com
|
||||||
templateUrl: 'core-block.html',
|
templateUrl: 'core-block.html',
|
||||||
styleUrls: ['block.scss'],
|
styleUrls: ['block.scss'],
|
||||||
})
|
})
|
||||||
export class CoreBlockComponent implements OnInit, OnDestroy, DoCheck {
|
export class CoreBlockComponent implements OnChanges, OnDestroy {
|
||||||
|
|
||||||
@ViewChild(CoreDynamicComponent) dynamicComponent?: CoreDynamicComponent<ICoreBlockComponent>;
|
@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.
|
data: Record<string, unknown> = {}; // Data to pass to the component.
|
||||||
class?: string; // CSS class to apply to the block.
|
class?: string; // CSS class to apply to the block.
|
||||||
loaded = false;
|
loaded = false;
|
||||||
|
|
||||||
blockSubscription?: Subscription;
|
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 {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
if (!this.block) {
|
if (changes.block && this.block?.visible) {
|
||||||
this.loaded = true;
|
this.updateBlock();
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.block.visible) {
|
if (this.data && changes.extraData) {
|
||||||
// Get the data to render the block.
|
this.data = Object.assign(this.data, this.extraData || {});
|
||||||
this.initBlock();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 {
|
async updateBlock(): Promise<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> {
|
|
||||||
try {
|
try {
|
||||||
const data = await CoreBlockDelegate.getBlockDisplayData(this.block, this.contextLevel, this.instanceId);
|
const data = await CoreBlockDelegate.getBlockDisplayData(this.block, this.contextLevel, this.instanceId);
|
||||||
|
|
||||||
|
@ -97,7 +71,7 @@ export class CoreBlockComponent implements OnInit, OnDestroy, DoCheck {
|
||||||
(): void => {
|
(): void => {
|
||||||
this.blockSubscription?.unsubscribe();
|
this.blockSubscription?.unsubscribe();
|
||||||
delete this.blockSubscription;
|
delete this.blockSubscription;
|
||||||
this.initBlock();
|
this.updateBlock();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<!-- Only render the block if it's supported. -->
|
<!-- 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>
|
<core-dynamic-component [component]="componentClass" [data]="data"></core-dynamic-component>
|
||||||
</ion-card>
|
</ion-card>
|
||||||
|
|
|
@ -21,7 +21,7 @@ import { CoreLogger } from '@singletons/logger';
|
||||||
import { CoreSitesCommonWSOptions, CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
import { CoreSitesCommonWSOptions, CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||||
import { CoreTimeUtils } from '@services/utils/time';
|
import { CoreTimeUtils } from '@services/utils/time';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
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 { CoreConstants } from '@/core/constants';
|
||||||
import { makeSingleton, Translate } from '@singletons';
|
import { makeSingleton, Translate } from '@singletons';
|
||||||
import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws';
|
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 { SQLiteDB } from '@classes/sqlitedb';
|
||||||
import { CorePlatform } from '@services/platform';
|
import { CorePlatform } from '@services/platform';
|
||||||
import { CoreTime } from '@singletons/time';
|
import { CoreTime } from '@singletons/time';
|
||||||
|
import { asyncObservable, firstValueFrom } from '@/core/utils/rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
const ROOT_CACHE_KEY = 'mmCourse:';
|
const ROOT_CACHE_KEY = 'mmCourse:';
|
||||||
|
|
||||||
|
@ -402,19 +404,36 @@ export class CoreCourseProvider {
|
||||||
* @return Promise resolved with the list of blocks.
|
* @return Promise resolved with the list of blocks.
|
||||||
* @since 3.7
|
* @since 3.7
|
||||||
*/
|
*/
|
||||||
async getCourseBlocks(courseId: number, siteId?: string): Promise<CoreCourseBlock[]> {
|
getCourseBlocks(courseId: number, siteId?: string): Promise<CoreCourseBlock[]> {
|
||||||
const site = await CoreSites.getSite(siteId);
|
return firstValueFrom(this.getCourseBlocksObservable(courseId, { 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);
|
|
||||||
|
|
||||||
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.
|
* @param includeStealthModules Whether to include stealth modules. Defaults to true.
|
||||||
* @return The reject contains the error message, else contains the sections.
|
* @return The reject contains the error message, else contains the sections.
|
||||||
*/
|
*/
|
||||||
async getSections(
|
getSections(
|
||||||
courseId: number,
|
courseId: number,
|
||||||
excludeModules: boolean = false,
|
excludeModules: boolean = false,
|
||||||
excludeContents: boolean = false,
|
excludeContents: boolean = false,
|
||||||
|
@ -916,63 +935,83 @@ export class CoreCourseProvider {
|
||||||
siteId?: string,
|
siteId?: string,
|
||||||
includeStealthModules: boolean = true,
|
includeStealthModules: boolean = true,
|
||||||
): Promise<CoreCourseWSSection[]> {
|
): Promise<CoreCourseWSSection[]> {
|
||||||
|
return firstValueFrom(this.getSectionsObservable(courseId, {
|
||||||
const site = await CoreSites.getSite(siteId);
|
excludeModules,
|
||||||
preSets = preSets || {};
|
excludeContents,
|
||||||
preSets.cacheKey = this.getSectionsCacheKey(courseId);
|
includeStealthModules,
|
||||||
preSets.updateFrequency = preSets.updateFrequency || CoreSite.FREQUENCY_RARELY;
|
preSets,
|
||||||
|
siteId,
|
||||||
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)),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Get cache key for section WS call.
|
||||||
*
|
*
|
||||||
|
@ -1933,3 +1972,13 @@ export type CoreCourseStoreModuleViewedOptions = {
|
||||||
timeaccess?: number;
|
timeaccess?: number;
|
||||||
siteId?: string;
|
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 { AddonBlockMyOverviewComponent } from '@addons/block/myoverview/components/myoverview/myoverview';
|
||||||
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||||
|
import { AsyncComponent } from '@classes/async-component';
|
||||||
|
import { PageLoadsManager } from '@classes/page-loads-manager';
|
||||||
|
import { CorePromisedValue } from '@classes/promised-value';
|
||||||
import { CoreBlockComponent } from '@features/block/components/block/block';
|
import { CoreBlockComponent } from '@features/block/components/block/block';
|
||||||
import { CoreCourseBlock } from '@features/course/services/course';
|
import { CoreCourseBlock } from '@features/course/services/course';
|
||||||
import { CoreCoursesDashboard, CoreCoursesDashboardProvider } from '@features/courses/services/dashboard';
|
import { CoreCoursesDashboard, CoreCoursesDashboardProvider } from '@features/courses/services/dashboard';
|
||||||
|
@ -23,6 +26,7 @@ import { CoreSites } from '@services/sites';
|
||||||
import { CoreDomUtils } from '@services/utils/dom';
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
import { CoreCourses } from '../../services/courses';
|
import { CoreCourses } from '../../services/courses';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -32,8 +36,12 @@ import { CoreCourses } from '../../services/courses';
|
||||||
selector: 'page-core-courses-my',
|
selector: 'page-core-courses-my',
|
||||||
templateUrl: 'my.html',
|
templateUrl: 'my.html',
|
||||||
styleUrls: ['my.scss'],
|
styleUrls: ['my.scss'],
|
||||||
|
providers: [{
|
||||||
|
provide: PageLoadsManager,
|
||||||
|
useClass: PageLoadsManager,
|
||||||
|
}],
|
||||||
})
|
})
|
||||||
export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
|
export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy, AsyncComponent {
|
||||||
|
|
||||||
@ViewChild(CoreBlockComponent) block!: CoreBlockComponent;
|
@ViewChild(CoreBlockComponent) block!: CoreBlockComponent;
|
||||||
|
|
||||||
|
@ -47,8 +55,10 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
|
||||||
hasSideBlocks = false;
|
hasSideBlocks = false;
|
||||||
|
|
||||||
protected updateSiteObserver: CoreEventObserver;
|
protected updateSiteObserver: CoreEventObserver;
|
||||||
|
protected onReadyPromise = new CorePromisedValue<void>();
|
||||||
|
protected loadsManagerSubscription: Subscription;
|
||||||
|
|
||||||
constructor() {
|
constructor(protected loadsManager: PageLoadsManager) {
|
||||||
// Refresh the enabled flags if site is updated.
|
// Refresh the enabled flags if site is updated.
|
||||||
this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
|
this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => {
|
||||||
this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
|
this.downloadCoursesEnabled = !CoreCourses.isDownloadCoursesDisabledInSite();
|
||||||
|
@ -57,6 +67,11 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
|
||||||
}, CoreSites.getCurrentSiteId());
|
}, CoreSites.getCurrentSiteId());
|
||||||
|
|
||||||
this.userId = CoreSites.getCurrentSiteUserId();
|
this.userId = CoreSites.getCurrentSiteUserId();
|
||||||
|
|
||||||
|
this.loadsManagerSubscription = this.loadsManager.onRefreshPage.subscribe(() => {
|
||||||
|
this.loaded = false;
|
||||||
|
this.loadContent();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -70,19 +85,27 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
|
||||||
|
|
||||||
this.loadSiteName();
|
this.loadSiteName();
|
||||||
|
|
||||||
this.loadContent();
|
this.loadContent(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load my overview block instance.
|
* Load data.
|
||||||
|
*
|
||||||
|
* @param firstLoad Whether it's the first load.
|
||||||
*/
|
*/
|
||||||
protected async loadContent(): Promise<void> {
|
protected async loadContent(firstLoad = false): Promise<void> {
|
||||||
|
const loadWatcher = this.loadsManager.startPageLoad(this, !!firstLoad);
|
||||||
const available = await CoreCoursesDashboard.isAvailable();
|
const available = await CoreCoursesDashboard.isAvailable();
|
||||||
const disabled = await CoreCourses.isMyCoursesDisabled();
|
const disabled = await CoreCourses.isMyCoursesDisabled();
|
||||||
|
|
||||||
if (available && !disabled) {
|
if (available && !disabled) {
|
||||||
try {
|
try {
|
||||||
const blocks = await CoreCoursesDashboard.getDashboardBlocks(undefined, undefined, this.myPageCourses);
|
const blocks = await loadWatcher.watchRequest(
|
||||||
|
CoreCoursesDashboard.getDashboardBlocksObservable({
|
||||||
|
myPage: this.myPageCourses,
|
||||||
|
readingStrategy: loadWatcher.getReadingStrategy(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// My overview block should always be in main blocks, but check side blocks too just in case.
|
// My overview block should always be in main blocks, but check side blocks too just in case.
|
||||||
this.loadedBlock = blocks.mainBlocks.concat(blocks.sideBlocks).find((block) => block.name == 'myoverview');
|
this.loadedBlock = blocks.mainBlocks.concat(blocks.sideBlocks).find((block) => block.name == 'myoverview');
|
||||||
|
@ -106,6 +129,7 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
|
this.onReadyPromise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -138,7 +162,7 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
|
||||||
|
|
||||||
// Invalidate the blocks.
|
// Invalidate the blocks.
|
||||||
if (this.myOverviewBlock) {
|
if (this.myOverviewBlock) {
|
||||||
promises.push(CoreUtils.ignoreErrors(this.myOverviewBlock.doRefresh()));
|
promises.push(CoreUtils.ignoreErrors(this.myOverviewBlock.invalidateContent()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.all(promises).finally(() => {
|
Promise.all(promises).finally(() => {
|
||||||
|
@ -153,6 +177,14 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy {
|
||||||
*/
|
*/
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.updateSiteObserver?.off();
|
this.updateSiteObserver?.off();
|
||||||
|
this.loadsManagerSubscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
async ready(): Promise<void> {
|
||||||
|
return await this.onReadyPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,10 @@ import { makeSingleton, Translate } from '@singletons';
|
||||||
import { CoreWSExternalFile } from '@services/ws';
|
import { CoreWSExternalFile } from '@services/ws';
|
||||||
import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion';
|
import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion';
|
||||||
import moment from 'moment-timezone';
|
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.
|
* Helper to gather some common courses functions.
|
||||||
|
@ -111,29 +115,47 @@ export class CoreCoursesHelperProvider {
|
||||||
* @param loadCategoryNames Whether load category names or not.
|
* @param loadCategoryNames Whether load category names or not.
|
||||||
* @return Promise resolved when done.
|
* @return Promise resolved when done.
|
||||||
*/
|
*/
|
||||||
async loadCoursesExtraInfo(courses: CoreEnrolledCourseDataWithExtraInfo[], loadCategoryNames: boolean = false): Promise<void> {
|
loadCoursesExtraInfo(
|
||||||
if (!courses.length ) {
|
courses: CoreEnrolledCourseDataWithExtraInfo[],
|
||||||
// No courses or cannot get the data, stop.
|
loadCategoryNames: boolean = false,
|
||||||
return;
|
): 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 = {};
|
if (!loadCategoryNames && (courses[0].overviewfiles !== undefined || courses[0].displayname !== undefined)) {
|
||||||
let courseInfoAvailable = false;
|
// No need to load more data.
|
||||||
|
return of(courses);
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
courses.forEach((course) => {
|
const courseIds = courses.map((course) => course.id).join(',');
|
||||||
this.loadCourseExtraInfo(course, courseInfoAvailable ? coursesInfo[course.id] : course, loadCategoryNames);
|
|
||||||
});
|
// 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 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 filter Filter using some field.
|
||||||
* @param loadCategoryNames Whether load category names or not.
|
* @param loadCategoryNames Whether load category names or not.
|
||||||
|
* @param options Options.
|
||||||
* @return Courses filled with options.
|
* @return Courses filled with options.
|
||||||
*/
|
*/
|
||||||
async getUserCoursesWithOptions(
|
getUserCoursesWithOptions(
|
||||||
sort: string = 'fullname',
|
sort: string = 'fullname',
|
||||||
slice: number = 0,
|
slice: number = 0,
|
||||||
filter?: string,
|
filter?: string,
|
||||||
loadCategoryNames: boolean = false,
|
loadCategoryNames: boolean = false,
|
||||||
options: CoreSitesCommonWSOptions = {},
|
options: CoreSitesCommonWSOptions = {},
|
||||||
): Promise<CoreEnrolledCourseDataWithOptions[]> {
|
): Promise<CoreEnrolledCourseDataWithExtraInfoAndOptions[]> {
|
||||||
|
return firstValueFrom(this.getUserCoursesWithOptionsObservable({
|
||||||
let courses: CoreEnrolledCourseDataWithOptions[] = await CoreCourses.getUserCourses(
|
sort,
|
||||||
false,
|
slice,
|
||||||
options.siteId,
|
filter,
|
||||||
options.readingStrategy,
|
loadCategoryNames,
|
||||||
);
|
...options,
|
||||||
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;
|
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
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':
|
case 'isfavourite':
|
||||||
courses = courses.filter((course) => !!course.isfavourite);
|
courses = courses.filter((course) => !!course.isfavourite);
|
||||||
break;
|
break;
|
||||||
|
@ -270,28 +326,42 @@ export class CoreCoursesHelperProvider {
|
||||||
|
|
||||||
courses = slice > 0 ? courses.slice(0, slice) : courses;
|
courses = slice > 0 ? courses.slice(0, slice) : courses;
|
||||||
|
|
||||||
return Promise.all(courses.map(async (course) => {
|
return courses;
|
||||||
if (course.completed !== undefined) {
|
}
|
||||||
// The WebService already returns the completed status, no need to fetch it.
|
|
||||||
|
/**
|
||||||
|
* 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;
|
return course;
|
||||||
}
|
}),
|
||||||
|
catchError(() => {
|
||||||
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 {
|
|
||||||
// Ignore error, maybe course completion is disabled or user has no permission.
|
// Ignore error, maybe course completion is disabled or user has no permission.
|
||||||
course.completed = false;
|
course.completed = false;
|
||||||
}
|
|
||||||
|
|
||||||
return course;
|
return of(course);
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -402,3 +472,13 @@ export type CoreCourseSearchedDataWithExtraInfoAndOptions = CoreCourseWithImageA
|
||||||
export type CoreCourseAnyCourseDataWithExtraInfoAndOptions = CoreCourseWithImageAndColor & CoreCourseAnyCourseDataWithOptions & {
|
export type CoreCourseAnyCourseDataWithExtraInfoAndOptions = CoreCourseWithImageAndColor & CoreCourseAnyCourseDataWithOptions & {
|
||||||
categoryname?: string; // Category name,
|
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 { Injectable } from '@angular/core';
|
||||||
import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites';
|
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 { makeSingleton } from '@singletons';
|
||||||
import { CoreStatusWithWarningsWSResponse, CoreWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws';
|
import { CoreStatusWithWarningsWSResponse, CoreWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws';
|
||||||
import { CoreEvents } from '@singletons/events';
|
import { CoreEvents } from '@singletons/events';
|
||||||
import { CoreWSError } from '@classes/errors/wserror';
|
import { CoreWSError } from '@classes/errors/wserror';
|
||||||
import { CoreCourseAnyCourseDataWithExtraInfoAndOptions, CoreCourseWithImageAndColor } from './courses-helper';
|
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:';
|
const ROOT_CACHE_KEY = 'mmCourses:';
|
||||||
|
|
||||||
|
@ -63,8 +65,7 @@ export class CoreCoursesProvider {
|
||||||
static readonly STATE_HIDDEN = 'hidden';
|
static readonly STATE_HIDDEN = 'hidden';
|
||||||
static readonly STATE_FAVOURITE = 'favourite';
|
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;
|
protected downloadOptionsEnabled = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -484,60 +485,91 @@ export class CoreCoursesProvider {
|
||||||
* @param siteId Site ID. If not defined, use current site.
|
* @param siteId Site ID. If not defined, use current site.
|
||||||
* @return Promise resolved with the courses.
|
* @return Promise resolved with the courses.
|
||||||
*/
|
*/
|
||||||
async getCoursesByField(
|
getCoursesByField(
|
||||||
field: string = '',
|
field: string = '',
|
||||||
value: string | number = '',
|
value: string | number = '',
|
||||||
siteId?: string,
|
siteId?: string,
|
||||||
): Promise<CoreCourseSearchedData[]> {
|
): 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;
|
const hasChanged = fieldParams.field != field || fieldParams.value != value;
|
||||||
field = fieldParams.field;
|
field = fieldParams.field;
|
||||||
value = fieldParams.value;
|
value = fieldParams.value;
|
||||||
const data: CoreCourseGetCoursesByFieldWSParams = {
|
const data: CoreCourseGetCoursesByFieldWSParams = {
|
||||||
field: field,
|
field: field,
|
||||||
value: field ? value : '',
|
value: field ? value : '',
|
||||||
};
|
};
|
||||||
const preSets: CoreSiteWSPreSets = {
|
const preSets: CoreSiteWSPreSets = {
|
||||||
cacheKey: this.getCoursesByFieldCacheKey(field, value),
|
cacheKey: this.getCoursesByFieldCacheKey(field, value),
|
||||||
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
||||||
};
|
...CoreSites.getReadingStrategyPreSets(options.readingStrategy),
|
||||||
|
};
|
||||||
|
|
||||||
const response = await site.read<CoreCourseGetCoursesByFieldWSResponse>('core_course_get_courses_by_field', data, preSets);
|
const observable = site.readObservable<CoreCourseGetCoursesByFieldWSResponse>(
|
||||||
if (!response.courses) {
|
'core_course_get_courses_by_field',
|
||||||
throw Error('WS core_course_get_courses_by_field failed');
|
data,
|
||||||
}
|
preSets,
|
||||||
|
);
|
||||||
|
|
||||||
if (field == 'ids' && hasChanged) {
|
return observable.pipe(map(response => {
|
||||||
// The list of courses requestes was changed to optimize it.
|
if (!response.courses) {
|
||||||
// Return only the ones that were being requested.
|
throw Error('WS core_course_get_courses_by_field failed');
|
||||||
const courseIds = String(originalValue).split(',').map((id) => parseInt(id, 10));
|
}
|
||||||
|
|
||||||
// Only courses from the original selection.
|
if (field == 'ids' && hasChanged) {
|
||||||
response.courses = response.courses.filter((course) => courseIds.indexOf(course.id) >= 0);
|
// 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.
|
// Only courses from the original selection.
|
||||||
return response.courses.sort((a, b) => {
|
response.courses = response.courses.filter((course) => courseIds.indexOf(course.id) >= 0);
|
||||||
if (a.sortorder === undefined && b.sortorder === undefined) {
|
}
|
||||||
return b.id - a.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (a.sortorder === undefined) {
|
// Courses will be sorted using sortorder if available.
|
||||||
return 1;
|
return response.courses.sort((a, b) => {
|
||||||
}
|
if (a.sortorder === undefined && b.sortorder === undefined) {
|
||||||
|
return b.id - a.id;
|
||||||
|
}
|
||||||
|
|
||||||
if (b.sortorder === undefined) {
|
if (a.sortorder === undefined) {
|
||||||
return -1;
|
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 customFieldName Custom field name.
|
||||||
* @param customFieldValue Custom field value.
|
* @param customFieldValue Custom field value.
|
||||||
|
@ -561,30 +593,49 @@ export class CoreCoursesProvider {
|
||||||
* @return Promise resolved with the list of courses.
|
* @return Promise resolved with the list of courses.
|
||||||
* @since 3.8
|
* @since 3.8
|
||||||
*/
|
*/
|
||||||
async getEnrolledCoursesByCustomField(
|
getEnrolledCoursesByCustomField(
|
||||||
customFieldName: string,
|
customFieldName: string,
|
||||||
customFieldValue: string,
|
customFieldValue: string,
|
||||||
siteId?: string,
|
siteId?: string,
|
||||||
): Promise<CoreCourseSummaryData[]> {
|
): Promise<CoreCourseSummaryData[]> {
|
||||||
const site = await CoreSites.getSite(siteId);
|
return firstValueFrom(this.getEnrolledCoursesByCustomFieldObservable(customFieldName, customFieldValue, {
|
||||||
const params: CoreCourseGetEnrolledCoursesByTimelineClassificationWSParams = {
|
readingStrategy: CoreSitesReadingStrategy.PREFER_NETWORK,
|
||||||
classification: 'customfield',
|
siteId,
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
* @param siteId Site ID. If not defined, current site.
|
||||||
* @return Promise resolved with the options for each course.
|
* @return Promise resolved with the options for each course.
|
||||||
*/
|
*/
|
||||||
async getCoursesAdminAndNavOptions(
|
getCoursesAdminAndNavOptions(
|
||||||
courseIds: number[],
|
courseIds: number[],
|
||||||
siteId?: string,
|
siteId?: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
navOptions: CoreCourseUserAdminOrNavOptionCourseIndexed;
|
navOptions: CoreCourseUserAdminOrNavOptionCourseIndexed;
|
||||||
admOptions: 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.
|
return asyncObservable(async () => {
|
||||||
const [navOptions, admOptions] = await Promise.all([
|
const siteId = options.siteId || CoreSites.getCurrentSiteId();
|
||||||
CoreUtils.ignoreErrors(this.getUserNavigationOptions(courseIds, siteId), {}),
|
|
||||||
CoreUtils.ignoreErrors(this.getUserAdministrationOptions(courseIds, siteId), {}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
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.
|
* @param siteId Site ID. If not defined, current site.
|
||||||
* @return Promise resolved with administration options for each course.
|
* @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) {
|
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 = {
|
const params: CoreCourseGetUserAdminOrNavOptionsWSParams = {
|
||||||
courseids: courseIds,
|
courseids: courseIds,
|
||||||
};
|
};
|
||||||
const preSets: CoreSiteWSPreSets = {
|
const preSets: CoreSiteWSPreSets = {
|
||||||
cacheKey: this.getUserAdministrationOptionsCacheKey(courseIds),
|
cacheKey: this.getUserAdministrationOptionsCacheKey(courseIds),
|
||||||
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
||||||
};
|
...CoreSites.getReadingStrategyPreSets(options.readingStrategy),
|
||||||
|
};
|
||||||
|
|
||||||
const response: CoreCourseGetUserAdminOrNavOptionsWSResponse =
|
const observable = site.readObservable<CoreCourseGetUserAdminOrNavOptionsWSResponse>(
|
||||||
await site.read('core_course_get_user_administration_options', params, preSets);
|
'core_course_get_user_administration_options',
|
||||||
|
params,
|
||||||
|
preSets,
|
||||||
|
);
|
||||||
|
|
||||||
// Format returned data.
|
// Format returned data.
|
||||||
return this.formatUserAdminOrNavOptions(response.courses);
|
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.
|
* @return Promise resolved with navigation options for each course.
|
||||||
*/
|
*/
|
||||||
async getUserNavigationOptions(courseIds: number[], siteId?: string): Promise<CoreCourseUserAdminOrNavOptionCourseIndexed> {
|
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) {
|
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 = {
|
const params: CoreCourseGetUserAdminOrNavOptionsWSParams = {
|
||||||
courseids: courseIds,
|
courseids: courseIds,
|
||||||
};
|
};
|
||||||
const preSets: CoreSiteWSPreSets = {
|
const preSets: CoreSiteWSPreSets = {
|
||||||
cacheKey: this.getUserNavigationOptionsCacheKey(courseIds),
|
cacheKey: this.getUserNavigationOptionsCacheKey(courseIds),
|
||||||
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
||||||
};
|
...CoreSites.getReadingStrategyPreSets(options.readingStrategy),
|
||||||
|
};
|
||||||
|
|
||||||
const response: CoreCourseGetUserAdminOrNavOptionsWSResponse =
|
const observable = site.readObservable<CoreCourseGetUserAdminOrNavOptionsWSResponse>(
|
||||||
await site.read('core_course_get_user_navigation_options', params, preSets);
|
'core_course_get_user_navigation_options',
|
||||||
|
params,
|
||||||
|
preSets,
|
||||||
|
);
|
||||||
|
|
||||||
// Format returned data.
|
// Format returned data.
|
||||||
return this.formatUserAdminOrNavOptions(response.courses);
|
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 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 siteId Site to get the courses from. If not defined, use current site.
|
||||||
|
* @param strategy Reading strategy.
|
||||||
* @return Promise resolved with the courses.
|
* @return Promise resolved with the courses.
|
||||||
*/
|
*/
|
||||||
async getUserCourses(
|
getUserCourses(
|
||||||
preferCache: boolean = false,
|
preferCache: boolean = false,
|
||||||
siteId?: string,
|
siteId?: string,
|
||||||
strategy?: CoreSitesReadingStrategy,
|
strategy?: CoreSitesReadingStrategy,
|
||||||
): Promise<CoreEnrolledCourseData[]> {
|
): Promise<CoreEnrolledCourseData[]> {
|
||||||
const site = await CoreSites.getSite(siteId);
|
strategy = strategy ?? (preferCache ? CoreSitesReadingStrategy.PREFER_CACHE : undefined);
|
||||||
|
|
||||||
const userId = site.getUserId();
|
return firstValueFrom(this.getUserCoursesObservable({
|
||||||
const wsParams: CoreEnrolGetUsersCoursesWSParams = {
|
readingStrategy: strategy,
|
||||||
userid: userId,
|
siteId,
|
||||||
};
|
}));
|
||||||
const strategyPreSets = strategy
|
}
|
||||||
? CoreSites.getReadingStrategyPreSets(strategy)
|
|
||||||
: { omitExpires: !!preferCache };
|
|
||||||
|
|
||||||
const preSets = {
|
/**
|
||||||
cacheKey: this.getUserCoursesCacheKey(),
|
* Get user courses.
|
||||||
getCacheUsingCacheKey: true,
|
*
|
||||||
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
* @param options Options.
|
||||||
...strategyPreSets,
|
* @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')) {
|
const userId = site.getUserId();
|
||||||
wsParams.returnusercount = false;
|
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) {
|
if (site.isVersionGreaterEqualThan('3.7')) {
|
||||||
// Check if the list of courses has changed.
|
wsParams.returnusercount = false;
|
||||||
const added: number[] = [];
|
}
|
||||||
const removed: number[] = [];
|
|
||||||
const previousIds = Object.keys(this.userCoursesIds);
|
|
||||||
const currentIds = {}; // Use an object to make it faster to search.
|
|
||||||
|
|
||||||
courses.forEach((course) => {
|
const observable = site.readObservable<CoreEnrolGetUsersCoursesWSResponse>(
|
||||||
// Move category field to categoryid on a course.
|
'core_enrol_get_users_courses',
|
||||||
course.categoryid = course.category;
|
wsParams,
|
||||||
delete course.category;
|
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]) {
|
courses.forEach((course) => {
|
||||||
// Course added.
|
// Move category field to categoryid on a course.
|
||||||
added.push(course.id);
|
course.categoryid = course.category;
|
||||||
}
|
delete course.category;
|
||||||
});
|
|
||||||
|
|
||||||
if (courses.length - added.length != previousIds.length) {
|
currentIds.add(course.id);
|
||||||
// A course was removed, check which one.
|
|
||||||
previousIds.forEach((id) => {
|
if (!previousIds.has(course.id)) {
|
||||||
if (!currentIds[id]) {
|
// Course added.
|
||||||
// Course removed.
|
added.push(course.id);
|
||||||
removed.push(Number(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) {
|
if (added.length || removed.length) {
|
||||||
// At least 1 course was added or removed, trigger the event.
|
// At least 1 course was added or removed, trigger the event.
|
||||||
CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_CHANGED, {
|
CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_CHANGED, {
|
||||||
added: added,
|
added: added,
|
||||||
removed: removed,
|
removed: removed,
|
||||||
}, site.getId());
|
}, site.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
this.userCoursesIds = currentIds;
|
this.userCoursesIds = currentIds;
|
||||||
} else {
|
} else {
|
||||||
this.userCoursesIds = {};
|
const coursesIds = new Set<number>();
|
||||||
|
|
||||||
// Store the list of courses.
|
// Store the list of courses.
|
||||||
courses.forEach((course) => {
|
courses.forEach((course) => {
|
||||||
// Move category field to categoryid on a course.
|
coursesIds.add(course.id);
|
||||||
course.categoryid = course.category;
|
|
||||||
delete course.category;
|
|
||||||
|
|
||||||
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.
|
completionhascriteria?: boolean; // If completion criteria is set.
|
||||||
completionusertracked?: boolean; // If the user is completion tracked.
|
completionusertracked?: boolean; // If the user is completion tracked.
|
||||||
progress?: number | null; // Progress percentage.
|
progress?: number | null; // Progress percentage.
|
||||||
completed?: boolean; // Whether the course is completed.
|
completed?: boolean; // @since 3.6. Whether the course is completed.
|
||||||
marker?: number; // Course section marker.
|
marker?: number; // @since 3.6. Course section marker.
|
||||||
lastaccess?: number; // Last access to the course (timestamp).
|
lastaccess?: number; // @since 3.6. Last access to the course (timestamp).
|
||||||
isfavourite?: boolean; // If the user marked this course a favourite.
|
isfavourite?: boolean; // If the user marked this course a favourite.
|
||||||
hidden?: boolean; // If the user hide the course from the dashboard.
|
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.
|
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.
|
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).
|
timemodified?: number; // @since 4.0. Last time course settings were updated (timestamp).
|
||||||
|
|
|
@ -13,12 +13,14 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites';
|
||||||
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
|
import { CoreSite, CoreSiteWSPreSets, WSObservable } from '@classes/site';
|
||||||
import { CoreCourseBlock } from '@features/course/services/course';
|
import { CoreCourseBlock } from '@features/course/services/course';
|
||||||
import { CoreStatusWithWarningsWSResponse } from '@services/ws';
|
import { CoreStatusWithWarningsWSResponse } from '@services/ws';
|
||||||
import { makeSingleton } from '@singletons';
|
import { makeSingleton } from '@singletons';
|
||||||
import { CoreError } from '@classes/errors/error';
|
import { CoreError } from '@classes/errors/error';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
import { asyncObservable, firstValueFrom } from '@/core/utils/rxjs';
|
||||||
|
|
||||||
const ROOT_CACHE_KEY = 'CoreCoursesDashboard:';
|
const ROOT_CACHE_KEY = 'CoreCoursesDashboard:';
|
||||||
|
|
||||||
|
@ -51,40 +53,66 @@ export class CoreCoursesDashboardProvider {
|
||||||
* @return Promise resolved with the list of blocks.
|
* @return Promise resolved with the list of blocks.
|
||||||
* @since 3.6
|
* @since 3.6
|
||||||
*/
|
*/
|
||||||
async getDashboardBlocksFromWS(
|
getDashboardBlocksFromWS(
|
||||||
myPage = CoreCoursesDashboardProvider.MY_PAGE_DEFAULT,
|
myPage = CoreCoursesDashboardProvider.MY_PAGE_DEFAULT,
|
||||||
userId?: number,
|
userId?: number,
|
||||||
siteId?: string,
|
siteId?: string,
|
||||||
): Promise<CoreCourseBlock[]> {
|
): Promise<CoreCourseBlock[]> {
|
||||||
const site = await CoreSites.getSite(siteId);
|
return firstValueFrom(this.getDashboardBlocksFromWSObservable({
|
||||||
|
myPage,
|
||||||
|
userId,
|
||||||
|
siteId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const params: CoreBlockGetDashboardBlocksWSParams = {
|
/**
|
||||||
returncontents: true,
|
* Get dashboard blocks from WS.
|
||||||
};
|
*
|
||||||
if (CoreSites.getRequiredCurrentSite().isVersionGreaterEqualThan('4.0')) {
|
* @param options Options.
|
||||||
params.mypage = myPage;
|
* @return Observable that returns the list of blocks.
|
||||||
} else if (myPage != CoreCoursesDashboardProvider.MY_PAGE_DEFAULT) {
|
* @since 3.6
|
||||||
throw new CoreError('mypage param is no accessible on core_block_get_dashboard_blocks');
|
*/
|
||||||
}
|
getDashboardBlocksFromWSObservable(options: GetDashboardBlocksOptions = {}): WSObservable<CoreCourseBlock[]> {
|
||||||
|
return asyncObservable(async () => {
|
||||||
|
const site = await CoreSites.getSite(options.siteId);
|
||||||
|
|
||||||
const preSets: CoreSiteWSPreSets = {
|
const myPage = options.myPage ?? CoreCoursesDashboardProvider.MY_PAGE_DEFAULT;
|
||||||
cacheKey: this.getDashboardBlocksCacheKey(myPage, userId),
|
const params: CoreBlockGetDashboardBlocksWSParams = {
|
||||||
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
returncontents: true,
|
||||||
};
|
};
|
||||||
if (userId) {
|
if (CoreSites.getRequiredCurrentSite().isVersionGreaterEqualThan('4.0')) {
|
||||||
params.userid = userId;
|
params.mypage = myPage;
|
||||||
}
|
} else if (myPage != CoreCoursesDashboardProvider.MY_PAGE_DEFAULT) {
|
||||||
const result = await site.read<CoreBlockGetDashboardBlocksWSResponse>('core_block_get_dashboard_blocks', params, preSets);
|
throw new CoreError('mypage param is no accessible on core_block_get_dashboard_blocks');
|
||||||
|
}
|
||||||
|
|
||||||
if (site.isVersionGreaterEqualThan('4.0')) {
|
const preSets: CoreSiteWSPreSets = {
|
||||||
// Temporary hack to have course overview on 3.9.5 but not on 4.0 onwards.
|
cacheKey: this.getDashboardBlocksCacheKey(myPage, options.userId),
|
||||||
// To be removed in a near future.
|
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
||||||
// Remove myoverview when is forced. See MDL-72092.
|
...CoreSites.getReadingStrategyPreSets(options.readingStrategy),
|
||||||
result.blocks = result.blocks.filter((block) =>
|
};
|
||||||
block.instanceid != 0 || block.name != 'myoverview' || block.region != 'forced');
|
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.
|
* @param myPage What my page to return blocks of. Default MY_PAGE_DEFAULT.
|
||||||
* @return Promise resolved with the list of blocks.
|
* @return Promise resolved with the list of blocks.
|
||||||
*/
|
*/
|
||||||
async getDashboardBlocks(
|
getDashboardBlocks(
|
||||||
userId?: number,
|
userId?: number,
|
||||||
siteId?: string,
|
siteId?: string,
|
||||||
myPage = CoreCoursesDashboardProvider.MY_PAGE_DEFAULT,
|
myPage = CoreCoursesDashboardProvider.MY_PAGE_DEFAULT,
|
||||||
): Promise<CoreCoursesDashboardBlocks> {
|
): Promise<CoreCoursesDashboardBlocks> {
|
||||||
const blocks = await this.getDashboardBlocksFromWS(myPage, userId, siteId);
|
return firstValueFrom(this.getDashboardBlocksObservable({
|
||||||
|
myPage,
|
||||||
|
userId,
|
||||||
|
siteId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
let mainBlocks: CoreCourseBlock[] = [];
|
/**
|
||||||
let sideBlocks: CoreCourseBlock[] = [];
|
* Get dashboard blocks.
|
||||||
|
*
|
||||||
blocks.forEach((block) => {
|
* @param options Options.
|
||||||
if (block.region == 'content' || block.region == 'main') {
|
* @return observable that returns the list of blocks.
|
||||||
mainBlocks.push(block);
|
*/
|
||||||
} else {
|
getDashboardBlocksObservable(options: GetDashboardBlocksOptions = {}): WSObservable<CoreCoursesDashboardBlocks> {
|
||||||
sideBlocks.push(block);
|
return this.getDashboardBlocksFromWSObservable(options).pipe(map(blocks => {
|
||||||
}
|
let mainBlocks: CoreCourseBlock[] = [];
|
||||||
});
|
let sideBlocks: CoreCourseBlock[] = [];
|
||||||
|
|
||||||
if (mainBlocks.length == 0) {
|
|
||||||
mainBlocks = [];
|
|
||||||
sideBlocks = [];
|
|
||||||
|
|
||||||
blocks.forEach((block) => {
|
blocks.forEach((block) => {
|
||||||
if (block.region.match('side')) {
|
if (block.region == 'content' || block.region == 'main') {
|
||||||
sideBlocks.push(block);
|
|
||||||
} else {
|
|
||||||
mainBlocks.push(block);
|
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[];
|
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.
|
* Params of core_block_get_dashboard_blocks WS.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
import { CoreNetwork } from '@services/network';
|
import { CoreNetwork } from '@services/network';
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||||
import { CoreFilterDelegate } from './filter-delegate';
|
import { CoreFilterDelegate } from './filter-delegate';
|
||||||
import {
|
import {
|
||||||
CoreFilter,
|
CoreFilter,
|
||||||
|
@ -31,6 +31,7 @@ import { CoreEvents, CoreEventSiteData } from '@singletons/events';
|
||||||
import { CoreLogger } from '@singletons/logger';
|
import { CoreLogger } from '@singletons/logger';
|
||||||
import { CoreSite } from '@classes/site';
|
import { CoreSite } from '@classes/site';
|
||||||
import { CoreCourseHelper } from '@features/course/services/course-helper';
|
import { CoreCourseHelper } from '@features/course/services/course-helper';
|
||||||
|
import { firstValueFrom } from '@/core/utils/rxjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper service to provide filter functionalities.
|
* Helper service to provide filter functionalities.
|
||||||
|
@ -75,7 +76,11 @@ export class CoreFilterHelperProvider {
|
||||||
* @return Promise resolved with the contexts.
|
* @return Promise resolved with the contexts.
|
||||||
*/
|
*/
|
||||||
async getBlocksContexts(courseId: number, siteId?: string): Promise<CoreFiltersGetAvailableInContextWSParamContext[]> {
|
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[] = [];
|
const contexts: CoreFiltersGetAvailableInContextWSParamContext[] = [];
|
||||||
|
|
||||||
|
@ -153,7 +158,12 @@ export class CoreFilterHelperProvider {
|
||||||
* @return Promise resolved with the contexts.
|
* @return Promise resolved with the contexts.
|
||||||
*/
|
*/
|
||||||
async getCourseModulesContexts(courseId: number, siteId?: string): Promise<CoreFiltersGetAvailableInContextWSParamContext[]> {
|
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[] = [];
|
const contexts: CoreFiltersGetAvailableInContextWSParamContext[] = [];
|
||||||
|
|
||||||
|
|
|
@ -15,8 +15,8 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
import { CoreNetwork } from '@services/network';
|
import { CoreNetwork } from '@services/network';
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||||
import { CoreSite } from '@classes/site';
|
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
|
||||||
import { CoreWSExternalWarning } from '@services/ws';
|
import { CoreWSExternalWarning } from '@services/ws';
|
||||||
import { CoreTextUtils } from '@services/utils/text';
|
import { CoreTextUtils } from '@services/utils/text';
|
||||||
import { CoreFilterDelegate } from './filter-delegate';
|
import { CoreFilterDelegate } from './filter-delegate';
|
||||||
|
@ -284,13 +284,15 @@ export class CoreFilterProvider {
|
||||||
const data: CoreFiltersGetAvailableInContextWSParams = {
|
const data: CoreFiltersGetAvailableInContextWSParams = {
|
||||||
contexts: contextsToSend,
|
contexts: contextsToSend,
|
||||||
};
|
};
|
||||||
const preSets = {
|
const preSets: CoreSiteWSPreSets = {
|
||||||
cacheKey: this.getAvailableInContextsCacheKey(contextsToSend),
|
cacheKey: this.getAvailableInContextsCacheKey(contextsToSend),
|
||||||
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
updateFrequency: CoreSite.FREQUENCY_RARELY,
|
||||||
splitRequest: {
|
splitRequest: {
|
||||||
param: 'contexts',
|
param: 'contexts',
|
||||||
maxLength: 300,
|
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>(
|
const result = await site.read<CoreFilterGetAvailableInContextResult>(
|
||||||
|
|
|
@ -1796,6 +1796,12 @@ export class CoreSitesProvider {
|
||||||
getFromCache: false,
|
getFromCache: false,
|
||||||
emergencyCache: false,
|
emergencyCache: false,
|
||||||
};
|
};
|
||||||
|
case CoreSitesReadingStrategy.STALE_WHILE_REVALIDATE:
|
||||||
|
return {
|
||||||
|
updateInBackground: true,
|
||||||
|
getFromCache: true,
|
||||||
|
saveToCache: true,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
@ -2017,6 +2023,7 @@ export const enum CoreSitesReadingStrategy {
|
||||||
PREFER_CACHE,
|
PREFER_CACHE,
|
||||||
ONLY_NETWORK,
|
ONLY_NETWORK,
|
||||||
PREFER_NETWORK,
|
PREFER_NETWORK,
|
||||||
|
STALE_WHILE_REVALIDATE,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -65,6 +65,35 @@ export class CoreObject {
|
||||||
return Object.keys(object).length === 0;
|
return Object.keys(object).length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return an object including only certain keys.
|
||||||
|
*
|
||||||
|
* @param obj Object.
|
||||||
|
* @param keysOrRegex If array is supplied, keys to include. Otherwise, regular expression used to filter keys.
|
||||||
|
* @return New object with only the specified keys.
|
||||||
|
*/
|
||||||
|
static only<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K>;
|
||||||
|
static only<T>(obj: T, regex: RegExp): Partial<T>;
|
||||||
|
static only<T, K extends keyof T>(obj: T, keysOrRegex: K[] | RegExp): Pick<T, K> | Partial<T> {
|
||||||
|
const newObject: Partial<T> = {};
|
||||||
|
|
||||||
|
if (Array.isArray(keysOrRegex)) {
|
||||||
|
for (const key of keysOrRegex) {
|
||||||
|
newObject[key] = obj[key];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const originalKeys = Object.keys(obj);
|
||||||
|
|
||||||
|
for (const key of originalKeys) {
|
||||||
|
if (key.match(keysOrRegex)) {
|
||||||
|
newObject[key] = obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newObject;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new object without the specified keys.
|
* Create a new object without the specified keys.
|
||||||
*
|
*
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { EventEmitter } from '@angular/core';
|
import { EventEmitter } from '@angular/core';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { Observable, Subscription } from 'rxjs';
|
import { Observable, Subscription } from 'rxjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -31,37 +32,46 @@ export class CoreSubscriptions {
|
||||||
* @param subscribable Subscribable to listen to.
|
* @param subscribable Subscribable to listen to.
|
||||||
* @param onSuccess Callback to run when the subscription is updated.
|
* @param onSuccess Callback to run when the subscription is updated.
|
||||||
* @param onError Callback to run when the an error happens.
|
* @param onError Callback to run when the an error happens.
|
||||||
|
* @param onComplete Callback to run when the observable completes.
|
||||||
* @return A function to unsubscribe.
|
* @return A function to unsubscribe.
|
||||||
*/
|
*/
|
||||||
static once<T>(
|
static once<T>(
|
||||||
subscribable: Subscribable<T>,
|
subscribable: Subscribable<T>,
|
||||||
onSuccess: (value: T) => unknown,
|
onSuccess: (value: T) => unknown,
|
||||||
onError?: (error: unknown) => unknown,
|
onError?: (error: unknown) => unknown,
|
||||||
|
onComplete?: () => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
let unsubscribe = false;
|
let callbackCalled = false;
|
||||||
let subscription: Subscription | null = null;
|
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(
|
subscription = subscribable.subscribe(
|
||||||
value => {
|
value => {
|
||||||
// Subscription variable might not be set because we can receive a value immediately.
|
unsubscribe();
|
||||||
unsubscribe = true;
|
runCallback(() => onSuccess(value));
|
||||||
subscription?.unsubscribe();
|
|
||||||
|
|
||||||
onSuccess(value);
|
|
||||||
},
|
},
|
||||||
error => {
|
error => {
|
||||||
// Subscription variable might not be set because we can receive a value immediately.
|
unsubscribe();
|
||||||
unsubscribe = true;
|
runCallback(() => onError?.(error));
|
||||||
subscription?.unsubscribe();
|
},
|
||||||
|
() => {
|
||||||
onError && onError(error);
|
unsubscribe();
|
||||||
|
runCallback(() => onComplete?.());
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (unsubscribe) {
|
|
||||||
subscription.unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => subscription?.unsubscribe();
|
return () => subscription?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -99,6 +99,53 @@ describe('CoreObject singleton', () => {
|
||||||
expect(CoreObject.isEmpty({ foo: 1 })).toEqual(false);
|
expect(CoreObject.isEmpty({ foo: 1 })).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates a copy of an object with certain properties (using a list)', () => {
|
||||||
|
const originalObject = {
|
||||||
|
foo: 1,
|
||||||
|
bar: 2,
|
||||||
|
baz: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(CoreObject.only(originalObject, [])).toEqual({});
|
||||||
|
expect(CoreObject.only(originalObject, ['foo'])).toEqual({
|
||||||
|
foo: 1,
|
||||||
|
});
|
||||||
|
expect(CoreObject.only(originalObject, ['foo', 'baz'])).toEqual({
|
||||||
|
foo: 1,
|
||||||
|
baz: 3,
|
||||||
|
});
|
||||||
|
expect(CoreObject.only(originalObject, ['foo', 'bar', 'baz'])).toEqual(originalObject);
|
||||||
|
expect(originalObject).toEqual({
|
||||||
|
foo: 1,
|
||||||
|
bar: 2,
|
||||||
|
baz: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a copy of an object with certain properties (using a regular expression)', () => {
|
||||||
|
const originalObject = {
|
||||||
|
foo: 1,
|
||||||
|
bar: 2,
|
||||||
|
baz: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(CoreObject.only(originalObject, /.*/)).toEqual(originalObject);
|
||||||
|
expect(CoreObject.only(originalObject, /^ba.*/)).toEqual({
|
||||||
|
bar: 2,
|
||||||
|
baz: 3,
|
||||||
|
});
|
||||||
|
expect(CoreObject.only(originalObject, /(foo|bar)/)).toEqual({
|
||||||
|
foo: 1,
|
||||||
|
bar: 2,
|
||||||
|
});
|
||||||
|
expect(CoreObject.only(originalObject, /notfound/)).toEqual({});
|
||||||
|
expect(originalObject).toEqual({
|
||||||
|
foo: 1,
|
||||||
|
bar: 2,
|
||||||
|
baz: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('creates a copy of an object without certain properties', () => {
|
it('creates a copy of an object without certain properties', () => {
|
||||||
const originalObject = {
|
const originalObject = {
|
||||||
foo: 1,
|
foo: 1,
|
||||||
|
|
|
@ -17,12 +17,20 @@ import { BehaviorSubject, Subject } from 'rxjs';
|
||||||
|
|
||||||
describe('CoreSubscriptions singleton', () => {
|
describe('CoreSubscriptions singleton', () => {
|
||||||
|
|
||||||
it('calls callbacks only once', async () => {
|
let subject: Subject<unknown>;
|
||||||
// Test call success function.
|
let success: jest.Mock;
|
||||||
let subject = new Subject();
|
let error: jest.Mock;
|
||||||
let success = jest.fn();
|
let complete: jest.Mock;
|
||||||
let error = jest.fn();
|
|
||||||
CoreSubscriptions.once(subject, success, error);
|
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');
|
subject.next('foo');
|
||||||
expect(success).toHaveBeenCalledTimes(1);
|
expect(success).toHaveBeenCalledTimes(1);
|
||||||
|
@ -32,11 +40,11 @@ describe('CoreSubscriptions singleton', () => {
|
||||||
subject.error('foo');
|
subject.error('foo');
|
||||||
expect(success).toHaveBeenCalledTimes(1);
|
expect(success).toHaveBeenCalledTimes(1);
|
||||||
expect(error).not.toHaveBeenCalled();
|
expect(error).not.toHaveBeenCalled();
|
||||||
|
expect(complete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
// Test call error function.
|
it('calls error callback only once', async () => {
|
||||||
subject = new Subject(); // Create a new Subject because the previous one already has an error.
|
CoreSubscriptions.once(subject, success, error, complete);
|
||||||
success = jest.fn();
|
|
||||||
CoreSubscriptions.once(subject, success, error);
|
|
||||||
|
|
||||||
subject.error('foo');
|
subject.error('foo');
|
||||||
expect(error).toHaveBeenCalledWith('foo');
|
expect(error).toHaveBeenCalledWith('foo');
|
||||||
|
@ -45,11 +53,27 @@ describe('CoreSubscriptions singleton', () => {
|
||||||
subject.error('bar');
|
subject.error('bar');
|
||||||
expect(error).toHaveBeenCalledTimes(1);
|
expect(error).toHaveBeenCalledTimes(1);
|
||||||
expect(success).not.toHaveBeenCalled();
|
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).
|
// Test with behaviour subject (success callback called immediately).
|
||||||
const beaviourSubject = new BehaviorSubject('foo');
|
const beaviourSubject = new BehaviorSubject('foo');
|
||||||
error = jest.fn();
|
CoreSubscriptions.once(beaviourSubject, success, error, complete);
|
||||||
CoreSubscriptions.once(beaviourSubject, success, error);
|
|
||||||
|
|
||||||
expect(success).toHaveBeenCalledWith('foo');
|
expect(success).toHaveBeenCalledWith('foo');
|
||||||
|
|
||||||
|
@ -57,6 +81,7 @@ describe('CoreSubscriptions singleton', () => {
|
||||||
beaviourSubject.error('foo');
|
beaviourSubject.error('foo');
|
||||||
expect(success).toHaveBeenCalledTimes(1);
|
expect(success).toHaveBeenCalledTimes(1);
|
||||||
expect(error).not.toHaveBeenCalled();
|
expect(error).not.toHaveBeenCalled();
|
||||||
|
expect(complete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows unsubscribing from outside the once function', async () => {
|
it('allows unsubscribing from outside the once function', async () => {
|
||||||
|
|
|
@ -13,8 +13,10 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { FormControl } from '@angular/forms';
|
import { FormControl } from '@angular/forms';
|
||||||
import { Observable, OperatorFunction } from 'rxjs';
|
import { CoreError } from '@classes/errors/error';
|
||||||
import { filter } from 'rxjs/operators';
|
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.
|
* 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));
|
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
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// 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 { mock } from '@/testing/utils';
|
||||||
import { FormControl } from '@angular/forms';
|
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', () => {
|
describe('RXJS Utils', () => {
|
||||||
|
|
||||||
it('Emits filtered form values', () => {
|
it('formControlValue emits filtered form values', () => {
|
||||||
// Arrange.
|
// Arrange.
|
||||||
let value = 'one';
|
let value = 'one';
|
||||||
const emited: string[] = [];
|
const emited: string[] = [];
|
||||||
|
@ -48,7 +57,7 @@ describe('RXJS Utils', () => {
|
||||||
expect(emited).toEqual(['two', 'three']);
|
expect(emited).toEqual(['two', 'three']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Emits resolved values', async () => {
|
it('resolved emits resolved values', async () => {
|
||||||
// Arrange.
|
// Arrange.
|
||||||
const emited: string[] = [];
|
const emited: string[] = [];
|
||||||
const promises = [
|
const promises = [
|
||||||
|
@ -67,7 +76,7 @@ describe('RXJS Utils', () => {
|
||||||
expect(emited).toEqual(['one', 'two', 'three']);
|
expect(emited).toEqual(['one', 'two', 'three']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Adds starting values on subscription', () => {
|
it('startWithOnSubscribed adds starting values on subscription', () => {
|
||||||
// Arrange.
|
// Arrange.
|
||||||
let store = 'one';
|
let store = 'one';
|
||||||
const emited: string[] = [];
|
const emited: string[] = [];
|
||||||
|
@ -86,4 +95,168 @@ describe('RXJS Utils', () => {
|
||||||
expect(emited).toEqual(['two', 'final', 'three', 'final']);
|
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 {
|
ion-chip {
|
||||||
line-height: 1.1;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
min-height: 24px;
|
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
||||||
// Chips are not currently clickable.
|
// Chips are not currently clickable, only if specified explicitly.
|
||||||
&.ion-activatable {
|
&.ion-activatable:not(.clickable) {
|
||||||
cursor: auto;
|
cursor: auto;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fab-chip {
|
||||||
|
padding: 8px 12px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, .4);
|
||||||
|
}
|
||||||
|
|
||||||
&.ion-color {
|
&.ion-color {
|
||||||
background: var(--ion-color-tint);
|
background: var(--ion-color-tint);
|
||||||
|
@ -1135,6 +1141,10 @@ ion-chip {
|
||||||
border-color: var(--ion-color-base);
|
border-color: var(--ion-color-base);
|
||||||
color: var(--ion-color-base);
|
color: var(--ion-color-base);
|
||||||
}
|
}
|
||||||
|
&.fab-chip {
|
||||||
|
background: var(--ion-color);
|
||||||
|
color: var(--ion-color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
&.ion-color-light,
|
&.ion-color-light,
|
||||||
&.ion-color-medium,
|
&.ion-color-medium,
|
||||||
|
@ -1739,3 +1749,12 @@ ion-header.no-title {
|
||||||
video::-webkit-media-text-track-display {
|
video::-webkit-media-text-track-display {
|
||||||
white-space: normal !important;
|
white-space: normal !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ion-modal.core-modal-no-background {
|
||||||
|
--background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
ion-backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -71,4 +71,6 @@ export interface EnvironmentConfig {
|
||||||
removeaccountonlogout?: boolean; // True to remove the account when the user clicks logout. Doesn't affect switch account.
|
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.
|
uselegacycompletion?: boolean; // Whether to use legacy completion by default in all course formats.
|
||||||
toastDurations: Record<ToastDuration, number>;
|
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".
|
- Zoom levels changed from "normal / low / high" to " none / medium / high".
|
||||||
- --addon-messages-* CSS3 variables have been renamed to --core-messages-*
|
- --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 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 ===
|
=== 4.0.0 ===
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue