diff --git a/src/addons/addons.module.ts b/src/addons/addons.module.ts index 680edb1dd..e03732e71 100644 --- a/src/addons/addons.module.ts +++ b/src/addons/addons.module.ts @@ -26,6 +26,7 @@ import { AddonBlockCompletionStatusModule } from './block/completionstatus/compl import { AddonBlockGlossaryRandomModule } from './block/glossaryrandom/glossaryrandom.module'; import { AddonBlockHtmlModule } from './block/html/html.module'; import { AddonBlockLearningPlansModule } from './block/learningplans/learningplans.module'; +import { AddonBlockMyOverviewModule } from './block/myoverview/myoverview.module'; import { AddonBlockNewsItemsModule } from './block/newsitems/newsitems.module'; import { AddonBlockOnlineUsersModule } from './block/onlineusers/onlineusers.module'; import { AddonBlockPrivateFilesModule } from './block/privatefiles/privatefiles.module'; @@ -51,6 +52,7 @@ import { AddonUserProfileFieldModule } from './userprofilefield/userprofilefield AddonBlockCompletionStatusModule, AddonBlockGlossaryRandomModule, AddonBlockHtmlModule, + AddonBlockMyOverviewModule, AddonBlockLearningPlansModule, AddonBlockNewsItemsModule, AddonBlockOnlineUsersModule, diff --git a/src/addons/block/myoverview/components/components.module.ts b/src/addons/block/myoverview/components/components.module.ts new file mode 100644 index 000000000..29199d738 --- /dev/null +++ b/src/addons/block/myoverview/components/components.module.ts @@ -0,0 +1,47 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreCoursesComponentsModule } from '@features/courses/components/components.module'; + +import { AddonBlockMyOverviewComponent } from './myoverview/myoverview'; + +@NgModule({ + declarations: [ + AddonBlockMyOverviewComponent, + ], + imports: [ + CommonModule, + IonicModule, + FormsModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCoursesComponentsModule, + ], + exports: [ + AddonBlockMyOverviewComponent, + ], + entryComponents: [ + AddonBlockMyOverviewComponent, + ], +}) +export class AddonBlockMyOverviewComponentsModule {} diff --git a/src/addons/block/myoverview/components/myoverview/addon-block-myoverview.html b/src/addons/block/myoverview/components/myoverview/addon-block-myoverview.html new file mode 100644 index 000000000..1532ecda2 --- /dev/null +++ b/src/addons/block/myoverview/components/myoverview/addon-block-myoverview.html @@ -0,0 +1,93 @@ + + +

{{ 'addon.block_myoverview.pluginname' | translate }}

+
+ +
+ + + + + + {{prefetchCoursesData[selectedFilter].badge}} + + +
+ + + + + + + + + +
+ +
+ + + + {{ 'addon.block_myoverview.allincludinghidden' | translate }} + + + {{ 'addon.block_myoverview.all' | translate }} + + + {{ 'addon.block_myoverview.inprogress' | translate }} + + + {{ 'addon.block_myoverview.future' | translate }} + + + {{ 'addon.block_myoverview.past' | translate }} + + + + {{ customOption.name }} + + + + {{ 'addon.block_myoverview.favourites' | translate }} + + + {{ 'addon.block_myoverview.hiddencourses' | translate }} + + +
+ + + + + + + + + +
+ + + + + + + + +
+
diff --git a/src/addons/block/myoverview/components/myoverview/myoverview.ts b/src/addons/block/myoverview/components/myoverview/myoverview.ts new file mode 100644 index 000000000..63a6956c8 --- /dev/null +++ b/src/addons/block/myoverview/components/myoverview/myoverview.ts @@ -0,0 +1,586 @@ +// (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, OnInit, Input, OnDestroy, ViewChild, OnChanges, SimpleChange } from '@angular/core'; +import { IonSearchbar } from '@ionic/angular'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreSites } from '@services/sites'; +import { CoreCoursesProvider, CoreCoursesMyCoursesUpdatedEventData, CoreCourses } from '@features/courses/services/courses'; +import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; +import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper'; +import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; +import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; +import { CoreSite } from '@classes/site'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { AddonCourseCompletion } from '@/addons/coursecompletion/services/coursecompletion'; + +const FILTER_PRIORITY = ['all', 'allincludinghidden', 'inprogress', 'future', 'past', 'favourite', 'hidden', 'custom']; + +/** + * Component to render a my overview block. + */ +@Component({ + selector: 'addon-block-myoverview', + templateUrl: 'addon-block-myoverview.html', +}) +export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implements OnInit, OnChanges, OnDestroy { + + @ViewChild('searchbar') searchbar?: IonSearchbar; + @Input() downloadEnabled = false; + + courses = { + filter: '', + all: [], + allincludinghidden: [], + past: [], + inprogress: [], + future: [], + favourite: [], + hidden: [], + custom: [], // Leave it empty to avoid download all those courses. + }; + + customFilter: { + name: string; + value: string; + }[] = []; + + selectedFilter = 'inprogress'; + sort = 'fullname'; + currentSite?: CoreSite; + filteredCourses: CoreEnrolledCourseDataWithOptions[] = []; + prefetchCoursesData = { + all: { + icon: '', + statusTranslatable: 'core.loading', + status: '', + loading: true, + }, + allincludinghidden: { + icon: '', + statusTranslatable: 'core.loading', + status: '', + loading: true, + }, + inprogress: { + icon: '', + statusTranslatable: 'core.loading', + status: '', + loading: true, + }, + past: { + icon: '', + statusTranslatable: 'core.loading', + status: '', + loading: true, + }, + future: { + icon: '', + statusTranslatable: 'core.loading', + status: '', + loading: true, + }, + favourite: { + icon: '', + statusTranslatable: 'core.loading', + status: '', + loading: true, + }, + hidden: { + icon: '', + statusTranslatable: 'core.loading', + status: '', + loading: true, + }, + custom: { + icon: '', + statusTranslatable: '', + status: '', + loading: false, + }, // Leave it empty to avoid download all those courses. + }; + + showFilters = { // Options are show, disabled, hidden. + all: 'show', + allincludinghidden: 'show', + past: 'show', + inprogress: 'show', + future: 'show', + favourite: 'show', + hidden: 'show', + custom: 'hidden', + }; + + showFilter = false; + showSelectorFilter = false; + showSortFilter = false; + downloadCourseEnabled = false; + downloadCoursesEnabled = false; + showSortByShortName = false; + + protected prefetchIconsInitialized = false; + protected isDestroyed = false; + protected coursesObserver?: CoreEventObserver; + protected updateSiteObserver?: CoreEventObserver; + protected courseIds: number[] = []; + protected fetchContentDefaultError = 'Error getting my overview data.'; + + constructor() { + super('AddonBlockMyOverviewComponent'); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + // Refresh the enabled flags if enabled. + this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); + this.downloadCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite(); + + // Refresh the enabled flags if site is updated. + this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { + this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); + this.downloadCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite(); + + }, CoreSites.instance.getCurrentSiteId()); + + this.coursesObserver = CoreEvents.on( + CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, + (data: CoreCoursesMyCoursesUpdatedEventData) => { + + if (data.action == CoreCoursesProvider.ACTION_ENROL || data.action == CoreCoursesProvider.ACTION_STATE_CHANGED) { + this.refreshCourseList(); + } + }, + CoreSites.instance.getCurrentSiteId(), + ); + + this.currentSite = CoreSites.instance.getCurrentSite(); + + const promises: Promise[] = []; + if (this.currentSite) { + promises.push(this.currentSite.getLocalSiteConfig('AddonBlockMyOverviewSort', this.sort).then((value) => { + this.sort = value; + + return; + })); + promises.push(this.currentSite.getLocalSiteConfig('AddonBlockMyOverviewFilter', this.selectedFilter).then((value) => { + this.selectedFilter = value; + + return; + })); + } + + Promise.all(promises).finally(() => { + super.ngOnInit(); + }); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}): void { + if (changes.downloadEnabled && !changes.downloadEnabled.previousValue && this.downloadEnabled && this.loaded) { + // Download all courses is enabled now, initialize it. + this.initPrefetchCoursesIcons(); + } + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + protected async invalidateContent(): Promise { + const promises: Promise[] = []; + + // Invalidate course completion data. + promises.push(CoreCourses.instance.invalidateUserCourses().finally(() => + CoreUtils.instance.allPromises(this.courseIds.map((courseId) => + AddonCourseCompletion.instance.invalidateCourseCompletion(courseId))))); + + promises.push(CoreCourseOptionsDelegate.instance.clearAndInvalidateCoursesOptions()); + if (this.courseIds.length > 0) { + promises.push(CoreCourses.instance.invalidateCoursesByField('ids', this.courseIds.join(','))); + } + + await CoreUtils.instance.allPromises(promises).finally(() => { + this.prefetchIconsInitialized = false; + }); + } + + /** + * Fetch the courses for my overview. + * + * @return Promise resolved when done. + */ + protected async fetchContent(): Promise { + const config = this.block.configsRecord || {}; + + const showCategories = config && config.displaycategories && config.displaycategories.value == '1'; + + const courses = await CoreCoursesHelper.instance.getUserCoursesWithOptions(this.sort, undefined, undefined, showCategories); + + // Check to show sort by short name only if the text is visible. + if (courses.length > 0) { + const sampleCourse = courses[0]; + this.showSortByShortName = !!sampleCourse.displayname && !!sampleCourse.shortname && + sampleCourse.fullname != sampleCourse.displayname; + } + + // Rollback to sort by full name if user is sorting by short name then Moodle web change the config. + if (!this.showSortByShortName && this.sort === 'shortname') { + this.switchSort('fullname'); + } + + this.courseIds = courses.map((course) => course.id); + + this.showSortFilter = courses.length > 0 && typeof courses[0].lastaccess != 'undefined'; + + this.initCourseFilters(courses); + + this.courses.filter = ''; + this.showFilter = false; + + this.showFilters.all = this.getShowFilterValue( + !config || config.displaygroupingall.value == '1', + this.courses.all.length === 0, + ); + // Do not show allincludinghiddenif config it's not present (before 3.8). + this.showFilters.allincludinghidden = + this.getShowFilterValue( + config.displaygroupingallincludinghidden.value == '1', + this.courses.allincludinghidden.length === 0, + ); + + this.showFilters.inprogress = this.getShowFilterValue( + !config || config.displaygroupinginprogress.value == '1', + this.courses.inprogress.length === 0, + ); + this.showFilters.past = this.getShowFilterValue( + !config || config.displaygroupingpast.value == '1', + this.courses.past.length === 0, + ); + this.showFilters.future = this.getShowFilterValue( + !config || config.displaygroupingfuture.value == '1', + this.courses.future.length === 0, + ); + + this.showSelectorFilter = courses.length > 0 && (this.courses.past.length > 0 || this.courses.future.length > 0 || + typeof courses[0].enddate != 'undefined'); + + this.showFilters.hidden = this.getShowFilterValue( + this.showSelectorFilter && typeof courses[0].hidden != 'undefined' && + (!config || config.displaygroupinghidden.value == '1'), + this.courses.hidden.length === 0, + ); + + this.showFilters.favourite = this.getShowFilterValue( + this.showSelectorFilter && typeof courses[0].isfavourite != 'undefined' && + (!config || (config.displaygroupingstarred && config.displaygroupingstarred.value == '1') || + (config.displaygroupingfavourites && config.displaygroupingfavourites.value == '1')), + this.courses.favourite.length === 0, + ); + + this.showFilters.custom = this.getShowFilterValue( + this.showSelectorFilter && config?.displaygroupingcustomfield.value == '1' && + !!config?.customfieldsexport && !!config?.customfieldsexport.value, + false, + ); + if (this.showFilters.custom == 'show') { + this.customFilter = CoreTextUtils.instance.parseJSON(config.customfieldsexport.value, []); + } else { + this.customFilter = []; + } + + if (this.showSelectorFilter) { + // Check if any selector is shown and not disabled. + this.showSelectorFilter = Object.keys(this.showFilters).some((key) => this.showFilters[key] == 'show'); + + if (!this.showSelectorFilter) { + // All filters disabled, display all the courses. + this.showFilters.all = 'show'; + } + } + + if (!this.showSelectorFilter) { + // No selector, display all the courses. + this.selectedFilter = 'all'; + } + this.setCourseFilter(this.selectedFilter); + + this.initPrefetchCoursesIcons(); + } + + /** + * Helper function to help with filter values. + * + * @param showCondition If true, filter will be shown. + * @param disabledCondition If true, and showCondition is also met, it will be shown as disabled. + * @return show / disabled / hidden value. + */ + protected getShowFilterValue(showCondition: boolean, disabledCondition: boolean): string { + return showCondition ? (disabledCondition ? 'disabled' : 'show') : 'hidden'; + } + + /** + * The filter has changed. + * + * @param Received Event. + */ + filterChanged(event: Event): void { + const target = event?.target || null; + + const newValue = target?.value.trim().toLowerCase(); + if (!newValue || this.courses.allincludinghidden.length <= 0) { + this.filteredCourses = this.courses.allincludinghidden; + } else { + // Use displayname if avalaible, or fullname if not. + if (this.courses.allincludinghidden.length > 0 && + typeof this.courses.allincludinghidden[0].displayname != 'undefined') { + this.filteredCourses = this.courses.allincludinghidden.filter((course) => + course.displayname && course.displayname.toLowerCase().indexOf(newValue) > -1); + } else { + this.filteredCourses = this.courses.allincludinghidden.filter((course) => + course.fullname.toLowerCase().indexOf(newValue) > -1); + } + } + } + + /** + * Initialize the prefetch icon for selected courses. + */ + protected initPrefetchCoursesIcons(): void { + if (this.prefetchIconsInitialized || !this.downloadEnabled) { + // Already initialized. + return; + } + + this.prefetchIconsInitialized = true; + + Object.keys(this.prefetchCoursesData).forEach(async (filter) => { + this.prefetchCoursesData[filter] = + await CoreCourseHelper.instance.initPrefetchCoursesIcons(this.courses[filter], this.prefetchCoursesData[filter]); + }); + } + + /** + * Prefetch all the shown courses. + * + * @return Promise resolved when done. + */ + async prefetchCourses(): Promise { + const selected = this.selectedFilter; + const initialIcon = this.prefetchCoursesData[selected].icon; + + try { + await CoreCourseHelper.instance.prefetchCourses(this.courses[selected], this.prefetchCoursesData[selected]); + } catch (error) { + if (!this.isDestroyed) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + this.prefetchCoursesData[selected].icon = initialIcon; + } + } + } + + /** + * Refresh the list of courses. + * + * @return Promise resolved when done. + */ + protected async refreshCourseList(): Promise { + CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_REFRESHED); + + try { + await CoreCourses.instance.invalidateUserCourses(); + } catch (error) { + // Ignore errors. + } + + await this.loadContent(true); + } + + /** + * The selected courses filter have changed. + */ + selectedChanged(): void { + this.setCourseFilter(this.selectedFilter); + } + + /** + * Set selected courses filter. + * + * @param filter Filter name to set. + */ + protected async setCourseFilter(filter: string): Promise { + this.selectedFilter = filter; + + if (this.showFilters.custom == 'show' && filter.startsWith('custom-') && + typeof this.customFilter[filter.substr(7)] != 'undefined') { + + const filterName = this.block.configsRecord!.customfiltergrouping.value; + const filterValue = this.customFilter[filter.substr(7)].value; + + this.loaded = false; + try { + const courses = await CoreCourses.instance.getEnrolledCoursesByCustomField(filterName, filterValue); + + // Get the courses information from allincludinghidden to get the max info about the course. + const courseIds = courses.map((course) => course.id); + + this.filteredCourses = this.courses.allincludinghidden.filter((allCourse) => + courseIds.indexOf(allCourse.id) !== -1); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, this.fetchContentDefaultError); + } finally { + this.loaded = true; + } + + return; + } + + // Only save the filter if not a custom one. + this.currentSite?.setLocalSiteConfig('AddonBlockMyOverviewFilter', filter); + + if (this.showFilters[filter] == 'show') { + this.filteredCourses = this.courses[filter]; + } else { + const activeFilter = FILTER_PRIORITY.find((name) => this.showFilters[name] == 'show'); + + if (activeFilter) { + this.setCourseFilter(activeFilter); + } + } + } + + /** + * Init courses filters. + * + * @param courses Courses to filter. + */ + initCourseFilters(courses: CoreEnrolledCourseDataWithOptions[]): void { + this.courses.allincludinghidden = courses; + + if (this.showSortFilter) { + if (this.sort == 'lastaccess') { + courses.sort((a, b) => (b.lastaccess || 0) - (a.lastaccess || 0)); + } else if (this.sort == 'fullname') { + courses.sort((a, b) => { + const compareA = a.fullname.toLowerCase(); + const compareB = b.fullname.toLowerCase(); + + return compareA.localeCompare(compareB); + }); + } else if (this.sort == 'shortname') { + courses.sort((a, b) => { + const compareA = a.shortname.toLowerCase(); + const compareB = b.shortname.toLowerCase(); + + return compareA.localeCompare(compareB); + }); + } + } + + this.courses.all = []; + this.courses.past = []; + this.courses.inprogress = []; + this.courses.future = []; + this.courses.favourite = []; + this.courses.hidden = []; + + const today = CoreTimeUtils.instance.timestamp(); + courses.forEach((course) => { + if (course.hidden) { + this.courses.hidden.push(course); + } else { + this.courses.all.push(course); + + if ((course.enddate && course.enddate < today) || course.completed) { + // Courses that have already ended. + this.courses.past.push(course); + } else if (course.startdate && course.startdate > today) { + // Courses that have not started yet. + this.courses.future.push(course); + } else { + // Courses still in progress. + this.courses.inprogress.push(course); + } + + if (course.isfavourite) { + this.courses.favourite.push(course); + } + } + }); + + this.setCourseFilter(this.selectedFilter); + } + + /** + * The selected courses sort filter have changed. + * + * @param sort New sorting. + */ + switchSort(sort: string): void { + this.sort = sort; + this.currentSite?.setLocalSiteConfig('AddonBlockMyOverviewSort', this.sort); + this.initCourseFilters(this.courses.allincludinghidden); + } + + /** + * Show or hide the filter. + */ + switchFilter(): void { + this.showFilter = !this.showFilter; + this.courses.filter = ''; + + if (this.showFilter) { + this.filteredCourses = this.courses.allincludinghidden; + } else { + this.setCourseFilter(this.selectedFilter); + } + } + + /** + * Popover closed after clicking switch filter. + */ + switchFilterClosed(): void { + if (this.showFilter) { + setTimeout(() => { + this.searchbar?.setFocus(); + }); + } + } + + /** + * If switch button that enables the filter input is shown or not. + * + * @return If switch button that enables the filter input is shown or not. + */ + showFilterSwitchButton(): boolean { + return this.loaded && this.courses.allincludinghidden && this.courses.allincludinghidden.length > 5; + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + this.coursesObserver?.off(); + this.updateSiteObserver?.off(); + } + +} diff --git a/src/addons/block/myoverview/lang.json b/src/addons/block/myoverview/lang.json new file mode 100644 index 000000000..7bca82636 --- /dev/null +++ b/src/addons/block/myoverview/lang.json @@ -0,0 +1,14 @@ +{ + "all": "All (except removed from view)", + "allincludinghidden": "All", + "favourites": "Starred", + "future": "Future", + "hiddencourses": "Removed from view", + "inprogress": "In progress", + "lastaccessed": "Last accessed", + "nocourses": "No courses", + "past": "Past", + "pluginname": "Course overview", + "shortname": "Short name", + "title": "Course name" +} diff --git a/src/addons/block/myoverview/myoverview.module.ts b/src/addons/block/myoverview/myoverview.module.ts new file mode 100644 index 000000000..483e1e36e --- /dev/null +++ b/src/addons/block/myoverview/myoverview.module.ts @@ -0,0 +1,38 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@features/block/services/block-delegate'; +import { AddonBlockMyOverviewComponentsModule } from './components/components.module'; +import { AddonBlockMyOverviewHandler } from './services/block-handler'; + +@NgModule({ + imports: [ + IonicModule, + AddonBlockMyOverviewComponentsModule, + TranslateModule.forChild(), + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + useValue: () => { + CoreBlockDelegate.instance.registerHandler(AddonBlockMyOverviewHandler.instance); + }, + }, + ], +}) +export class AddonBlockMyOverviewModule {} diff --git a/src/addons/block/myoverview/services/block-handler.ts b/src/addons/block/myoverview/services/block-handler.ts new file mode 100644 index 000000000..e95e322f9 --- /dev/null +++ b/src/addons/block/myoverview/services/block-handler.ts @@ -0,0 +1,58 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreSites } from '@services/sites'; +import { CoreBlockHandlerData } from '@features/block/services/block-delegate'; +import { CoreCourses } from '@features/courses/services/courses'; +import { AddonBlockMyOverviewComponent } from '../components/myoverview/myoverview'; +import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Block handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonBlockMyOverviewHandlerService extends CoreBlockBaseHandler { + + name = 'AddonBlockMyOverview'; + blockName = 'myoverview'; + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return (CoreSites.instance.getCurrentSite()?.isVersionGreaterEqualThan('3.6')) || + !CoreCourses.instance.isMyCoursesDisabledInSite(); + } + + /** + * Returns the data needed to render the block. + * + * @return Data or promise resolved with the data. + */ + getDisplayData(): CoreBlockHandlerData { + + return { + title: 'addon.block_myoverview.pluginname', + class: 'addon-block-myoverview', + component: AddonBlockMyOverviewComponent, + }; + } + +} + +export class AddonBlockMyOverviewHandler extends makeSingleton(AddonBlockMyOverviewHandlerService) {} diff --git a/src/addons/coursecompletion/coursecompletion.module.ts b/src/addons/coursecompletion/coursecompletion.module.ts new file mode 100644 index 000000000..ccc8af630 --- /dev/null +++ b/src/addons/coursecompletion/coursecompletion.module.ts @@ -0,0 +1,44 @@ +// (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 { NgModule } from '@angular/core'; +// @todo import { AddonCourseCompletionCourseOptionHandler } from './services/course-option-handler'; +// @todo import { AddonCourseCompletionUserHandler } from './services/user-handler'; +// @todo import { AddonCourseCompletionComponentsModule } from './components/components.module'; +// @todo import { CoreCourseOptionsDelegate } from '@features/course/services/options-delegate'; +// @todo import { CoreUserDelegate } from '@features/user/services/user-delegate'; + +@NgModule({ + imports: [ + // AddonCourseCompletionComponentsModule, + ], + providers: [ + // AddonCourseCompletionCourseOptionHandler, + // AddonCourseCompletionUserHandler, + ], +}) +export class AddonCourseCompletionModule { + + /* @todo constructor( + courseOptionsDelegate: CoreCourseOptionsDelegate, + courseOptionHandler: AddonCourseCompletionCourseOptionHandler, + userDelegate: CoreUserDelegate, + userHandler: AddonCourseCompletionUserHandler, + ) { + // Register handlers. + courseOptionsDelegate.registerHandler(courseOptionHandler); + userDelegate.registerHandler(userHandler); + }*/ + +} diff --git a/src/addons/coursecompletion/lang.json b/src/addons/coursecompletion/lang.json new file mode 100644 index 000000000..81ef0272e --- /dev/null +++ b/src/addons/coursecompletion/lang.json @@ -0,0 +1,23 @@ +{ + "complete": "Complete", + "completecourse": "Complete course", + "completed": "Completed", + "completiondate": "Completion date", + "completionmenuitem": "Completion", + "couldnotloadreport": "Could not load the course completion report. Please try again later.", + "coursecompletion": "Course completion", + "criteria": "Criteria", + "criteriagroup": "Criteria group", + "criteriarequiredall": "All criteria below are required", + "criteriarequiredany": "Any criteria below are required", + "inprogress": "In progress", + "manualselfcompletion": "Manual self completion", + "nottracked": "You are currently not being tracked by completion in this course", + "notyetstarted": "Not yet started", + "pending": "Pending", + "required": "Required", + "requiredcriteria": "Required criteria", + "requirement": "Requirement", + "status": "Status", + "viewcoursereport": "View course report" +} \ No newline at end of file diff --git a/src/addons/coursecompletion/services/coursecompletion.ts b/src/addons/coursecompletion/services/coursecompletion.ts new file mode 100644 index 000000000..6dc4e6811 --- /dev/null +++ b/src/addons/coursecompletion/services/coursecompletion.ts @@ -0,0 +1,313 @@ +// (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 { Injectable } from '@angular/core'; +import { CoreLogger } from '@singletons/logger'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreCourses } from '@features/courses/services/courses'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton } from '@singletons'; + +const ROOT_CACHE_KEY = 'mmaCourseCompletion:'; + +/** + * Service to handle course completion. + */ +@Injectable({ providedIn: 'root' }) +export class AddonCourseCompletionProvider { + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('AddonCourseCompletion'); + } + + /** + * Returns whether or not the user can mark a course as self completed. + * It can if it's configured in the course and it hasn't been completed yet. + * + * @param userId User ID. + * @param completion Course completion. + * @return True if user can mark course as self completed, false otherwise. + */ + canMarkSelfCompleted(userId: number, completion: AddonCourseCompletionCourseCompletionStatus): boolean { + if (CoreSites.instance.getCurrentSiteUserId() != userId) { + return false; + } + + let selfCompletionActive = false; + let alreadyMarked = false; + + completion.completions.forEach((criteria) => { + if (criteria.type === 1) { + // Self completion criteria found. + selfCompletionActive = true; + alreadyMarked = criteria.complete; + } + }); + + return selfCompletionActive && !alreadyMarked; + } + + /** + * Get completed status text. The language code returned is meant to be translated. + * + * @param completion Course completion. + * @return Language code of the text to show. + */ + getCompletedStatusText(completion: AddonCourseCompletionCourseCompletionStatus): string { + if (completion.completed) { + return 'addon.coursecompletion.completed'; + } + + // Let's calculate status. + const hasStarted = completion.completions.some((criteria) => criteria.timecompleted || criteria.complete); + + if (hasStarted) { + return 'addon.coursecompletion.inprogress'; + } + + return 'addon.coursecompletion.notyetstarted'; + } + + /** + * Get course completion status for a certain course and user. + * + * @param courseId Course ID. + * @param userId User ID. If not defined, use current user. + * @param preSets Presets to use when calling the WebService. + * @param siteId Site ID. If not defined, use current site. + * @return Promise to be resolved when the completion is retrieved. + */ + async getCompletion( + courseId: number, + userId?: number, + preSets: CoreSiteWSPreSets = {}, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + this.logger.debug('Get completion for course ' + courseId + ' and user ' + userId); + + const data: CoreCompletionGetCourseCompletionStatusWSParams = { + courseid: courseId, + userid: userId, + }; + + preSets.cacheKey = this.getCompletionCacheKey(courseId, userId); + preSets.updateFrequency = preSets.updateFrequency || CoreSite.FREQUENCY_SOMETIMES; + preSets.cacheErrors = ['notenroled']; + + const result: CoreCompletionGetCourseCompletionStatusWSResponse = + await site.read('core_completion_get_course_completion_status', data, preSets); + if (result.completionstatus) { + return result.completionstatus; + } + + throw null; + } + + /** + * Get cache key for get completion WS calls. + * + * @param courseId Course ID. + * @param useIid User ID. + * @return Cache key. + */ + protected getCompletionCacheKey(courseId: number, userId: number): string { + return ROOT_CACHE_KEY + 'view:' + courseId + ':' + userId; + } + + /** + * Invalidates view course completion WS call. + * + * @param courseId Course ID. + * @param userId User ID. If not defined, use current user. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved when the list is invalidated. + */ + async invalidateCourseCompletion(courseId: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + await site.invalidateWsCacheForKey(this.getCompletionCacheKey(courseId, userId)); + } + + /** + * Returns whether or not the view course completion plugin is enabled for the current site. + * + * @return True if plugin enabled, false otherwise. + */ + isPluginViewEnabled(): boolean { + return CoreSites.instance.isLoggedIn(); + } + + /** + * Returns whether or not the view course completion plugin is enabled for a certain course. + * + * @param courseId Course ID. + * @param preferCache True if shouldn't call WS if data is cached, false otherwise. + * @return Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + */ + async isPluginViewEnabledForCourse(courseId: number, preferCache: boolean = true): Promise { + if (!courseId) { + throw null; + } + + const course = await CoreCourses.instance.getUserCourse(courseId, preferCache); + + if (course) { + if (typeof course.enablecompletion != 'undefined' && !course.enablecompletion) { + // Completion not enabled for the course. + return false; + } + + if (typeof course.completionhascriteria != 'undefined' && !course.completionhascriteria) { + // No criteria, cannot view completion. + return false; + } + } + + return true; + } + + /** + * Returns whether or not the view course completion plugin is enabled for a certain user. + * + * @param courseId Course ID. + * @param userId User ID. If not defined, use current user. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + */ + async isPluginViewEnabledForUser(courseId: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const currentUserId = site.getUserId(); + + // Check if user wants to view his own completion. + try { + if (!userId || userId == currentUserId) { + // Viewing own completion. Get the course to check if it has completion criteria. + const course = await CoreCourses.instance.getUserCourse(courseId, true); + + // If the site is returning the completionhascriteria then the user can view his own completion. + // We already checked the value in isPluginViewEnabledForCourse. + if (course && typeof course.completionhascriteria != 'undefined') { + return true; + } + } + } catch { + // Ignore errors. + } + + // User not viewing own completion or the site doesn't tell us if the course has criteria. + // The only way to know if completion can be viewed is to call the WS. + // Disable emergency cache to be able to detect that the plugin has been disabled (WS will fail). + const preSets: CoreSiteWSPreSets = { + emergencyCache: false, + }; + + try { + await this.getCompletion(courseId, userId, preSets); + + return true; + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // The WS returned an error, plugin is not enabled. + return false; + } + } + + try { + // Not a WS error. Check if we have a cached value. + preSets.omitExpires = true; + + await this.getCompletion(courseId, userId, preSets); + + return true; + } catch { + return false; + } + } + + /** + * Mark a course as self completed. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved on success. + */ + async markCourseAsSelfCompleted(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreCompletionMarkCourseSelfCompletedWSParams = { + courseid: courseId, + }; + + const response = await site.write('core_completion_mark_course_self_completed', params); + + if (!response.status) { + throw null; + } + } + +} + +export class AddonCourseCompletion extends makeSingleton(AddonCourseCompletionProvider) {} + +/** + * Completion status returned by core_completion_get_course_completion_status. + */ +export type AddonCourseCompletionCourseCompletionStatus = { + completed: boolean; // True if the course is complete, false otherwise. + aggregation: number; // Aggregation method 1 means all, 2 means any. + completions: { + type: number; // Completion criteria type. + title: string; // Completion criteria Title. + status: string; // Completion status (Yes/No) a % or number. + complete: boolean; // Completion status (true/false). + timecompleted: number; // Timestamp for criteria completetion. + details: { + type: string; // Type description. + criteria: string; // Criteria description. + requirement: string; // Requirement description. + status: string; // Status description, can be anything. + }; // Details. + }[]; +}; + +/** + * Params of core_completion_get_course_completion_status WS. + */ +export type CoreCompletionGetCourseCompletionStatusWSParams = { + courseid: number; // Course ID. + userid: number; // User ID. +}; + +/** + * Data returned by core_completion_get_course_completion_status WS. + */ +export type CoreCompletionGetCourseCompletionStatusWSResponse = { + completionstatus: AddonCourseCompletionCourseCompletionStatus; // Course status. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of core_completion_mark_course_self_completed WS. + */ +export type CoreCompletionMarkCourseSelfCompletedWSParams = { + courseid: number; // Course ID. +}; diff --git a/src/assets/img/icons/courses.svg b/src/assets/img/icons/courses.svg new file mode 100644 index 000000000..7bd9cb672 --- /dev/null +++ b/src/assets/img/icons/courses.svg @@ -0,0 +1,257 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/core/components/empty-box/empty-box.scss b/src/core/components/empty-box/empty-box.scss index 9fea08738..b81dc6ae7 100644 --- a/src/core/components/empty-box/empty-box.scss +++ b/src/core/components/empty-box/empty-box.scss @@ -62,6 +62,12 @@ } } } + + &.core-empty-inline .core-empty-box { + position: relative; + z-index: initial; + height: auto; + } } :host-context(core-block-course-blocks) .core-empty-box { diff --git a/src/core/components/loading/loading.scss b/src/core/components/loading/loading.scss index 2a054b7a3..53746da0e 100644 --- a/src/core/components/loading/loading.scss +++ b/src/core/components/loading/loading.scss @@ -40,4 +40,13 @@ &.core-loading-loaded { position: unset; } + + &.core-loading-center { + display: block; + + .core-loading-container { + margin-top: 10px; + position: relative; + } + } } diff --git a/src/core/features/block/components/block/block.scss b/src/core/features/block/components/block/block.scss index 3acf40fdd..43be2ef93 100644 --- a/src/core/features/block/components/block/block.scss +++ b/src/core/features/block/components/block/block.scss @@ -1,30 +1,11 @@ :host { - // @todo - position: relative; - display: block; - - core-loading.core-loading-center { + position: relative; display: block; - .core-loading-container { - margin-top: 10px; - position: relative; + ion-item-divider { + min-height: 60px; + .core-button-spinner { + margin: 0; + } } - } - - core-empty-box .core-empty-box { - position: relative; - z-index: initial; - //@include position(initial, initial, null, initial); - height: auto; - } - - ion-item-divider { - //@include padding-horizontal(null, 0px); - min-height: 60px; - } - - ion-item-divider .core-button-spinner { - margin: 0; - } } diff --git a/src/core/features/course/services/course-helper.ts b/src/core/features/course/services/course-helper.ts index 81f85e2cb..6b31a738f 100644 --- a/src/core/features/course/services/course-helper.ts +++ b/src/core/features/course/services/course-helper.ts @@ -35,6 +35,11 @@ import { CoreArray } from '@singletons/array'; import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreCourseOffline } from './course-offline'; import { CoreNavHelper, CoreNavHelperService } from '@services/nav-helper'; +import { + CoreCourseOptionsDelegate, + CoreCourseOptionsHandlerToDisplay, + CoreCourseOptionsMenuHandlerToDisplay, +} from './course-options-delegate'; /** * Prefetch info of a module. @@ -197,8 +202,8 @@ export class CoreCourseHelperProvider { const promises = courses.map((course) => { const subPromises: Promise[] = []; let sections: CoreCourseSection[]; - let handlers: any; - let menuHandlers: any; + let handlers: CoreCourseOptionsHandlerToDisplay[] = []; + let menuHandlers: CoreCourseOptionsMenuHandlerToDisplay[] = []; let success = true; // Get the sections and the handlers. @@ -208,15 +213,16 @@ export class CoreCourseHelperProvider { return; })); - /** - * @todo - subPromises.push(this.courseOptionsDelegate.getHandlersToDisplay(this.injector, course).then((cHandlers: any) => { + subPromises.push(CoreCourseOptionsDelegate.instance.getHandlersToDisplay(course).then((cHandlers) => { handlers = cHandlers; + + return; })); - subPromises.push(this.courseOptionsDelegate.getMenuHandlersToDisplay(this.injector, course).then((mHandlers: any) => { + subPromises.push(CoreCourseOptionsDelegate.instance.getMenuHandlersToDisplay(course).then((mHandlers) => { menuHandlers = mHandlers; + + return; })); - */ return Promise.all(subPromises).then(() => this.prefetchCourse(course, sections, handlers, menuHandlers, siteId)) .catch((error) => { @@ -777,8 +783,8 @@ export class CoreCourseHelperProvider { async prefetchCourse( course: CoreEnrolledCourseDataWithExtraInfoAndOptions, sections: CoreCourseSection[], - courseHandlers: any[], // @todo CoreCourseOptionsHandlerToDisplay[], - courseMenuHandlers: any[], // @todo CoreCourseOptionsMenuHandlerToDisplay[], + courseHandlers: CoreCourseOptionsHandlerToDisplay[], + courseMenuHandlers: CoreCourseOptionsMenuHandlerToDisplay[], siteId?: string, ): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); @@ -797,16 +803,15 @@ export class CoreCourseHelperProvider { siteId, ).then(async () => { - const promises: Promise[] = []; + const promises: Promise[] = []; + /* @todo // Prefetch all the sections. If the first section is "All sections", use it. Otherwise, use a fake "All sections". - /* - * @todo - let allSectionsSection = sections[0]; - if (sections[0].id != CoreCourseProvider.ALL_SECTIONS_ID) { + let allSectionsSection: Partial = sections[0]; + if (sections[0].id != CoreCourseProvider.ALL_SECTIONS_ID) { allSectionsSection = { id: CoreCourseProvider.ALL_SECTIONS_ID }; } - promises.push(this.prefetchSection(allSectionsSection, course.id, sections)); + promises.push(this.prefetchSection(allSectionsSection, course.id, sections));*/ // Prefetch course options. courseHandlers.forEach((handler) => { @@ -818,7 +823,7 @@ export class CoreCourseHelperProvider { if (handler.prefetch) { promises.push(handler.prefetch(course)); } - });*/ + }); // Prefetch other data needed to render the course. if (CoreCourses.instance.isGetCoursesByFieldAvailable()) { @@ -832,10 +837,11 @@ export class CoreCourseHelperProvider { // @todo promises.push(this.filterHelper.getFilters('course', course.id)); - return CoreUtils.instance.allPromises(promises); - }).then(() => + await CoreUtils.instance.allPromises(promises); + // Download success, mark the course as downloaded. - CoreCourse.instance.setCourseStatus(course.id, CoreConstants.DOWNLOADED, siteId)).catch(async (error) => { + return CoreCourse.instance.setCourseStatus(course.id, CoreConstants.DOWNLOADED, siteId); + }).catch(async (error) => { // Error, restore previous status. await CoreCourse.instance.setCoursePreviousStatus(course.id, siteId); diff --git a/src/core/features/course/services/course-options-delegate.ts b/src/core/features/course/services/course-options-delegate.ts new file mode 100644 index 000000000..9c28d0071 --- /dev/null +++ b/src/core/features/course/services/course-options-delegate.ts @@ -0,0 +1,674 @@ +// (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. +// @todo test delegate + +import { Injectable, Type } from '@angular/core'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreEvents } from '@singletons/events'; +import { CoreSites } from '@services/sites'; +import { CoreUtils, PromiseDefer } from '@services/utils/utils'; +import { CoreCourses, CoreCoursesProvider, CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses'; +import { CoreCourseProvider } from './course'; +import { Params } from '@angular/router'; +import { makeSingleton } from '@singletons'; +import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses-helper'; + +/** + * Interface that all course options handlers must implement. + */ +export interface CoreCourseOptionsHandler extends CoreDelegateHandler { + /** + * The highest priority is displayed first. + */ + priority: number; + + /** + * True if this handler should appear in menu rather than as a tab. + */ + isMenuHandler?: boolean; + + /** + * Whether or not the handler is enabled for a certain course. + * + * @param courseId The course ID. + * @param accessData Access type and data. Default, guest, ... + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return True or promise resolved with true if enabled. + */ + isEnabledForCourse(courseId: number, + accessData: any, // @todo: define type. + navOptions?: CoreCourseUserAdminOrNavOptionIndexed, + admOptions?: CoreCourseUserAdminOrNavOptionIndexed, + ): boolean | Promise; + + /** + * Returns the data needed to render the handler. + * + * @param course The course. // @todo: define type in the whole file. + * @return Data or promise resolved with the data. + */ + getDisplayData?( + course: CoreEnrolledCourseDataWithExtraInfoAndOptions, + ): CoreCourseOptionsHandlerData | Promise; + + /** + * Should invalidate the data to determine if the handler is enabled for a certain course. + * + * @param courseId The course ID. + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return Promise resolved when done. + */ + invalidateEnabledForCourse?( + courseId: number, + navOptions?: CoreCourseUserAdminOrNavOptionIndexed, + admOptions?: CoreCourseUserAdminOrNavOptionIndexed, + ): Promise; + + /** + * Called when a course is downloaded. It should prefetch all the data to be able to see the addon in offline. + * + * @param course The course. + * @return Promise resolved when done. + */ + prefetch?(course: CoreEnrolledCourseDataWithExtraInfoAndOptions): Promise; +} + +/** + * Interface that course options handlers implement if they appear in the menu rather than as a tab. + */ +export interface CoreCourseOptionsMenuHandler extends CoreCourseOptionsHandler { + /** + * Returns the data needed to render the handler. + * + * @param course The course. + * @return Data or promise resolved with data. + */ + getMenuDisplayData( + course: CoreEnrolledCourseDataWithExtraInfoAndOptions, + ): CoreCourseOptionsMenuHandlerData | Promise; +} + +/** + * Data needed to render a course handler. It's returned by the handler. + */ +export interface CoreCourseOptionsHandlerData { + /** + * Title to display for the handler. + */ + title: string; + + /** + * Class to add to the displayed handler. + */ + class?: string; + + /** + * The component to render the handler. It must be the component class, not the name or an instance. + * When the component is created, it will receive the courseId as input. + */ + component: Type; + + /** + * Data to pass to the component. All the properties in this object will be passed to the component as inputs. + */ + componentData?: Record; +} + +/** + * Data needed to render a course menu handler. It's returned by the handler. + */ +export interface CoreCourseOptionsMenuHandlerData { + /** + * Title to display for the handler. + */ + title: string; + + /** + * Class to add to the displayed handler. + */ + class?: string; + + /** + * Name of the page to load for the handler. + */ + page: string; + + /** + * Params to pass to the page (other than 'course' which is always sent). + */ + pageParams?: Params; + + /** + * Name of the icon to display for the handler. + */ + icon: string; // Name of the icon to display in the tab. +} + +/** + * Data returned by the delegate for each handler. + */ +export interface CoreCourseOptionsHandlerToDisplay { + /** + * Data to display. + */ + data: CoreCourseOptionsHandlerData; + + /** + * Name of the handler, or name and sub context (AddonMessages, AddonMessages:blockContact, ...). + */ + name: string; + + /** + * The highest priority is displayed first. + */ + priority?: number; + + /** + * Called when a course is downloaded. It should prefetch all the data to be able to see the addon in offline. + * + * @param course The course. + * @return Promise resolved when done. + */ + prefetch?(course: CoreEnrolledCourseDataWithExtraInfoAndOptions): Promise; +} + +/** + * Additional data returned if it is a menu item. + */ +export interface CoreCourseOptionsMenuHandlerToDisplay { + /** + * Data to display. + */ + data: CoreCourseOptionsMenuHandlerData; + + /** + * Name of the handler, or name and sub context (AddonMessages, AddonMessages:blockContact, ...). + */ + name: string; + + /** + * The highest priority is displayed first. + */ + priority?: number; + + /** + * Called when a course is downloaded. It should prefetch all the data to be able to see the addon in offline. + * + * @param course The course. + * @return Promise resolved when done. + */ + prefetch?(course: CoreEnrolledCourseDataWithExtraInfoAndOptions): Promise; +} + +/** + * Service to interact with plugins to be shown in each course (participants, learning plans, ...). + */ +@Injectable( { providedIn: 'root' }) +export class CoreCourseOptionsDelegateService extends CoreDelegate { + + protected loaded: { [courseId: number]: boolean } = {}; + protected lastUpdateHandlersForCoursesStart: { + [courseId: number]: number; + } = {}; + + protected coursesHandlers: { + [courseId: number]: { + access: any; + navOptions?: CoreCourseUserAdminOrNavOptionIndexed; + admOptions?: CoreCourseUserAdminOrNavOptionIndexed; + deferred: PromiseDefer; + enabledHandlers: CoreCourseOptionsHandler[]; + enabledMenuHandlers: CoreCourseOptionsMenuHandler[]; + }; + } = {}; + + protected featurePrefix = 'CoreCourseOptionsDelegate_'; + + constructor() { + super('CoreCourseOptionsDelegate'); + + CoreEvents.on(CoreEvents.LOGOUT, () => { + this.clearCoursesHandlers(); + }); + } + + /** + * Check if handlers are loaded for a certain course. + * + * @param courseId The course ID to check. + * @return True if handlers are loaded, false otherwise. + */ + areHandlersLoaded(courseId: number): boolean { + return !!this.loaded[courseId]; + } + + /** + * Clear all course options handlers. + * + * @param courseId The course ID. If not defined, all handlers will be cleared. + */ + protected clearCoursesHandlers(courseId?: number): void { + if (courseId) { + if (!this.loaded[courseId]) { + // Don't clear if not loaded, it's probably an ongoing load and it could cause JS errors. + return; + } + + this.loaded[courseId] = false; + delete this.coursesHandlers[courseId]; + } else { + for (const courseId in this.coursesHandlers) { + this.clearCoursesHandlers(Number(courseId)); + } + } + } + + /** + * Clear all courses handlers and invalidate its options. + * + * @param courseId The course ID. If not defined, all handlers will be cleared. + * @return Promise resolved when done. + */ + async clearAndInvalidateCoursesOptions(courseId?: number): Promise { + const promises: Promise[] = []; + + CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_REFRESHED); + + // Invalidate course enabled data for the handlers that are enabled at site level. + if (courseId) { + // Invalidate only options for this course. + promises.push(CoreCourses.instance.invalidateCoursesAdminAndNavOptions([courseId])); + promises.push(this.invalidateCourseHandlers(courseId)); + } else { + // Invalidate all options. + promises.push(CoreCourses.instance.invalidateUserNavigationOptions()); + promises.push(CoreCourses.instance.invalidateUserAdministrationOptions()); + + for (const cId in this.coursesHandlers) { + promises.push(this.invalidateCourseHandlers(parseInt(cId, 10))); + } + } + + this.clearCoursesHandlers(courseId); + + await Promise.all(promises); + } + + /** + * Get the handlers for a course using a certain access type. + * + * @param courseId The course ID. + * @param refresh True if it should refresh the list. + * @param accessData Access type and data. Default, guest, ... + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return Promise resolved with array of handlers. + */ + protected async getHandlersForAccess( + courseId: number, + refresh: boolean, + accessData: any, + navOptions?: CoreCourseUserAdminOrNavOptionIndexed, + admOptions?: CoreCourseUserAdminOrNavOptionIndexed, + ): Promise { + + // If the handlers aren't loaded, do not refresh. + if (!this.loaded[courseId]) { + refresh = false; + } + + if (refresh || !this.coursesHandlers[courseId] || this.coursesHandlers[courseId].access.type != accessData.type) { + if (!this.coursesHandlers[courseId]) { + this.coursesHandlers[courseId] = { + access: accessData, + navOptions, + admOptions, + deferred: CoreUtils.instance.promiseDefer(), + enabledHandlers: [], + enabledMenuHandlers: [], + }; + } else { + this.coursesHandlers[courseId].access = accessData; + this.coursesHandlers[courseId].navOptions = navOptions; + this.coursesHandlers[courseId].admOptions = admOptions; + this.coursesHandlers[courseId].deferred = CoreUtils.instance.promiseDefer(); + } + + this.updateHandlersForCourse(courseId, accessData, navOptions, admOptions); + } + + await this.coursesHandlers[courseId].deferred.promise; + + return this.coursesHandlers[courseId].enabledHandlers; + } + + /** + * Get the list of handlers that should be displayed for a course. + * This function should be called only when the handlers need to be displayed, since it can call several WebServices. + * + * @param course The course object. + * @param refresh True if it should refresh the list. + * @param isGuest Whether it's guest. + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return Promise resolved with array of handlers. + */ + getHandlersToDisplay( + course: CoreEnrolledCourseDataWithExtraInfoAndOptions, + refresh = false, + isGuest = false, + navOptions?: CoreCourseUserAdminOrNavOptionIndexed, + admOptions?: CoreCourseUserAdminOrNavOptionIndexed, + ): Promise { + return this.getHandlersToDisplayInternal(false, course, refresh, isGuest, navOptions, admOptions) as + Promise; + } + + /** + * Get the list of menu handlers that should be displayed for a course. + * This function should be called only when the handlers need to be displayed, since it can call several WebServices. + * + * @param course The course object. + * @param refresh True if it should refresh the list. + * @param isGuest Whether it's guest. + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return Promise resolved with array of handlers. + */ + getMenuHandlersToDisplay( + course: CoreEnrolledCourseDataWithExtraInfoAndOptions, + refresh = false, + isGuest = false, + navOptions?: CoreCourseUserAdminOrNavOptionIndexed, + admOptions?: CoreCourseUserAdminOrNavOptionIndexed, + ): Promise { + return this.getHandlersToDisplayInternal(true, course, refresh, isGuest, navOptions, admOptions) as + Promise; + } + + /** + * Get the list of menu handlers that should be displayed for a course. + * This function should be called only when the handlers need to be displayed, since it can call several WebServices. + * + * @param menu If true, gets menu handlers; false, gets tab handlers + * @param course The course object. + * @param refresh True if it should refresh the list. + * @param isGuest Whether it's guest. + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return Promise resolved with array of handlers. + */ + protected async getHandlersToDisplayInternal( + menu: boolean, + course: CoreEnrolledCourseDataWithExtraInfoAndOptions, + refresh = false, + isGuest = false, + navOptions?: CoreCourseUserAdminOrNavOptionIndexed, + admOptions?: CoreCourseUserAdminOrNavOptionIndexed, + ): Promise { + + const accessData = { + type: isGuest ? CoreCourseProvider.ACCESS_GUEST : CoreCourseProvider.ACCESS_DEFAULT, + }; + const handlersToDisplay: CoreCourseOptionsHandlerToDisplay[] | CoreCourseOptionsMenuHandlerToDisplay[] = []; + + if (navOptions) { + course.navOptions = navOptions; + } + if (admOptions) { + course.admOptions = admOptions; + } + + await this.loadCourseOptions(course, refresh); + // Call getHandlersForAccess to make sure the handlers have been loaded. + await this.getHandlersForAccess(course.id, refresh, accessData, course.navOptions, course.admOptions); + const promises: Promise[] = []; + + let handlerList: CoreCourseOptionsMenuHandler[] | CoreCourseOptionsHandler[]; + if (menu) { + handlerList = this.coursesHandlers[course.id].enabledMenuHandlers; + } else { + handlerList = this.coursesHandlers[course.id].enabledHandlers; + } + + handlerList.forEach((handler: CoreCourseOptionsMenuHandler | CoreCourseOptionsHandler) => { + const getFunction = menu + ? (handler as CoreCourseOptionsMenuHandler).getMenuDisplayData + : (handler as CoreCourseOptionsHandler).getDisplayData; + + promises.push(Promise.resolve(getFunction!.call(handler, course)).then((data) => { + handlersToDisplay.push({ + data: data, + priority: handler.priority, + prefetch: handler.prefetch && handler.prefetch.bind(handler), + name: handler.name, + }); + + return; + }).catch((err) => { + this.logger.error('Error getting data for handler', handler.name, err); + })); + }); + + await Promise.all(promises); + + // Sort them by priority. + handlersToDisplay.sort(( + a: CoreCourseOptionsHandlerToDisplay | CoreCourseOptionsMenuHandlerToDisplay, + b: CoreCourseOptionsHandlerToDisplay | CoreCourseOptionsMenuHandlerToDisplay, + ) => (b.priority || 0) - (a.priority || 0)); + + return handlersToDisplay; + } + + /** + * Check if a course has any handler enabled for default access, using course object. + * + * @param course The course object. + * @param refresh True if it should refresh the list. + * @return Promise resolved with boolean: true if it has handlers, false otherwise. + */ + async hasHandlersForCourse(course: CoreEnrolledCourseDataWithExtraInfoAndOptions, refresh = false): Promise { + // Load course options if missing. + await this.loadCourseOptions(course, refresh); + + return await this.hasHandlersForDefault(course.id, refresh, course.navOptions, course.admOptions); + } + + /** + * Check if a course has any handler enabled for default access. + * + * @param courseId The course ID. + * @param refresh True if it should refresh the list. + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return Promise resolved with boolean: true if it has handlers, false otherwise. + */ + async hasHandlersForDefault( + courseId: number, + refresh = false, + navOptions?: CoreCourseUserAdminOrNavOptionIndexed, + admOptions?: CoreCourseUserAdminOrNavOptionIndexed, + ): Promise { + // Default access. + const accessData = { + type: CoreCourseProvider.ACCESS_DEFAULT, + }; + + const handlers = await this.getHandlersForAccess(courseId, refresh, accessData, navOptions, admOptions); + + return !!(handlers && handlers.length); + } + + /** + * Check if a course has any handler enabled for guest access. + * + * @param courseId The course ID. + * @param refresh True if it should refresh the list. + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return Promise resolved with boolean: true if it has handlers, false otherwise. + */ + async hasHandlersForGuest( + courseId: number, + refresh = false, + navOptions?: CoreCourseUserAdminOrNavOptionIndexed, + admOptions?: CoreCourseUserAdminOrNavOptionIndexed, + ): Promise { + // Guest access. + const accessData = { + type: CoreCourseProvider.ACCESS_GUEST, + }; + + const handlers = await this.getHandlersForAccess(courseId, refresh, accessData, navOptions, admOptions); + + return !!(handlers && handlers.length); + } + + /** + * Invalidate the data to be able to determine if handlers are enabled for a certain course. + * + * @param courseId Course ID. + * @return Promise resolved when done. + */ + async invalidateCourseHandlers(courseId: number): Promise { + const promises: Promise[] = []; + const courseData = this.coursesHandlers[courseId]; + + if (!courseData || !courseData.enabledHandlers) { + return; + } + + courseData.enabledHandlers.forEach((handler) => { + if (handler?.invalidateEnabledForCourse) { + promises.push( + handler.invalidateEnabledForCourse(courseId, courseData.navOptions, courseData.admOptions), + ); + } + }); + + await CoreUtils.instance.allPromises(promises); + } + + /** + * Check if a time belongs to the last update handlers for course call. + * This is to handle the cases where updateHandlersForCourse don't finish in the same order as they're called. + * + * @param courseId Course ID. + * @param time Time to check. + * @return Whether it's the last call. + */ + isLastUpdateCourseCall(courseId: number, time: number): boolean { + if (!this.lastUpdateHandlersForCoursesStart[courseId]) { + return true; + } + + return time == this.lastUpdateHandlersForCoursesStart[courseId]; + } + + /** + * Load course options if missing. + * + * @param course The course object. + * @param refresh True if it should refresh the list. + * @return Promise resolved when done. + */ + protected async loadCourseOptions(course: CoreEnrolledCourseDataWithExtraInfoAndOptions, refresh = false): Promise { + if (CoreCourses.instance.canGetAdminAndNavOptions() && + (typeof course.navOptions == 'undefined' || typeof course.admOptions == 'undefined' || refresh)) { + + const options = await CoreCourses.instance.getCoursesAdminAndNavOptions([course.id]); + course.navOptions = options.navOptions[course.id]; + course.admOptions = options.admOptions[course.id]; + } + } + + /** + * Update handlers for each course. + */ + updateData(): void { + // Update handlers for all courses. + for (const courseId in this.coursesHandlers) { + const handler = this.coursesHandlers[courseId]; + this.updateHandlersForCourse(parseInt(courseId, 10), handler.access, handler.navOptions, handler.admOptions); + } + } + + /** + * Update the handlers for a certain course. + * + * @param courseId The course ID. + * @param accessData Access type and data. Default, guest, ... + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return Resolved when updated. + */ + async updateHandlersForCourse( + courseId: number, + accessData: any, + navOptions?: CoreCourseUserAdminOrNavOptionIndexed, + admOptions?: CoreCourseUserAdminOrNavOptionIndexed, + ): Promise { + const promises: Promise[] = []; + const enabledForCourse: CoreCourseOptionsHandler[] = []; + const enabledForCourseMenu: CoreCourseOptionsMenuHandler[] = []; + const siteId = CoreSites.instance.getCurrentSiteId(); + const now = Date.now(); + + this.lastUpdateHandlersForCoursesStart[courseId] = now; + + for (const name in this.enabledHandlers) { + const handler = this.enabledHandlers[name]; + + // Checks if the handler is enabled for the user. + promises.push(Promise.resolve(handler.isEnabledForCourse(courseId, accessData, navOptions, admOptions)) + .then((enabled) => { + if (enabled) { + if (handler.isMenuHandler) { + enabledForCourseMenu.push( handler); + } else { + enabledForCourse.push(handler); + } + } + + return; + }).catch(() => { + // Nothing to do here, it is not enabled for this user. + })); + } + + try { + await Promise.all(promises); + } catch { + // Never fails. + } + + // Verify that this call is the last one that was started. + // Check that site hasn't changed since the check started. + if (this.isLastUpdateCourseCall(courseId, now) && CoreSites.instance.getCurrentSiteId() === siteId) { + // Update the coursesHandlers array with the new enabled addons. + this.coursesHandlers[courseId].enabledHandlers = enabledForCourse; + this.coursesHandlers[courseId].enabledMenuHandlers = enabledForCourseMenu; + this.loaded[courseId] = true; + + // Resolve the promise. + this.coursesHandlers[courseId].deferred.resolve(); + } + } + +} + +export class CoreCourseOptionsDelegate extends makeSingleton(CoreCourseOptionsDelegateService) {} diff --git a/src/core/features/courses/pages/course-preview/course-preview.ts b/src/core/features/courses/pages/course-preview/course-preview.ts index 57da1fc46..2bde270cd 100644 --- a/src/core/features/courses/pages/course-preview/course-preview.ts +++ b/src/core/features/courses/pages/course-preview/course-preview.ts @@ -27,7 +27,7 @@ import { CoreCoursesProvider, CoreEnrolledCourseData, } from '@features/courses/services/courses'; -// import { CoreCourseOptionsDelegate } from '@features/course/services/options-delegate'; +import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; import { CoreCourse, CoreCourseProvider } from '@features/course/services/course'; import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper'; import { Translate } from '@singletons'; @@ -77,7 +77,6 @@ export class CoreCoursesCoursePreviewPage implements OnInit, OnDestroy { constructor( protected modalCtrl: ModalController, - // protected courseOptionsDelegate: CoreCourseOptionsDelegate, protected zone: NgZone, protected route: ActivatedRoute, protected navCtrl: NavController, @@ -389,7 +388,7 @@ export class CoreCoursesCoursePreviewPage implements OnInit, OnDestroy { promises.push(CoreCourses.instance.invalidateUserCourses()); promises.push(CoreCourses.instance.invalidateCourse(this.course!.id)); promises.push(CoreCourses.instance.invalidateCourseEnrolmentMethods(this.course!.id)); - // @todo promises.push(this.courseOptionsDelegate.clearAndInvalidateCoursesOptions(this.course!.id)); + promises.push(CoreCourseOptionsDelegate.instance.clearAndInvalidateCoursesOptions(this.course!.id)); if (CoreSites.instance.getCurrentSite() && !CoreSites.instance.getCurrentSite()!.isVersionGreaterEqualThan('3.7')) { promises.push(CoreCourses.instance.invalidateCoursesByField('id', this.course!.id)); } diff --git a/src/core/features/courses/pages/my-courses/my-courses.ts b/src/core/features/courses/pages/my-courses/my-courses.ts index 0799240c8..015dc4212 100644 --- a/src/core/features/courses/pages/my-courses/my-courses.ts +++ b/src/core/features/courses/pages/my-courses/my-courses.ts @@ -25,7 +25,7 @@ import { import { CoreCoursesHelper, CoreEnrolledCourseDataWithExtraInfoAndOptions } from '../../services/courses-helper'; import { CoreCourseHelper } from '@features/course/services/course-helper'; import { CoreConstants } from '@/core/constants'; -// import { CoreCourseOptionsDelegate } from '@core/course/services/options-delegate'; +import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; /** * Page that displays the list of courses the user is enrolled in. @@ -128,7 +128,7 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { const promises: Promise[] = []; promises.push(CoreCourses.instance.invalidateUserCourses()); - // @todo promises.push(this.courseOptionsDelegate.clearAndInvalidateCoursesOptions()); + promises.push(CoreCourseOptionsDelegate.instance.clearAndInvalidateCoursesOptions()); if (this.courseIds) { promises.push(CoreCourses.instance.invalidateCoursesByField('ids', this.courseIds)); } diff --git a/src/core/features/courses/services/courses-helper.ts b/src/core/features/courses/services/courses-helper.ts index b1e7ef96f..d06f7b814 100644 --- a/src/core/features/courses/services/courses-helper.ts +++ b/src/core/features/courses/services/courses-helper.ts @@ -19,7 +19,7 @@ import { CoreSites } from '@services/sites'; import { CoreCourses, CoreCourseSearchedData, CoreCourseUserAdminOrNavOptionIndexed, CoreEnrolledCourseData } from './courses'; import { makeSingleton } from '@singletons'; import { CoreWSExternalFile } from '@services/ws'; -// import { AddonCourseCompletionProvider } from '@addon/coursecompletion/providers/coursecompletion'; +import { AddonCourseCompletion } from '@/addons/coursecompletion/services/coursecompletion'; // import { CoreCoursePickerMenuPopoverComponent } from '@components/course-picker-menu/course-picker-menu-popover'; /** @@ -161,8 +161,97 @@ export class CoreCoursesHelperProvider { * @param loadCategoryNames Whether load category names or not. * @return Courses filled with options. */ - async getUserCoursesWithOptions(): Promise { - // @todo params and logic + async getUserCoursesWithOptions( + sort: string = 'fullname', + slice: number = 0, + filter?: string, + loadCategoryNames: boolean = false, + ): Promise { + + let courses: CoreEnrolledCourseDataWithOptions[] = await CoreCourses.instance.getUserCourses(); + if (courses.length <= 0) { + return []; + } + + const promises: Promise[] = []; + const courseIds = courses.map((course) => course.id); + + if (CoreCourses.instance.canGetAdminAndNavOptions()) { + // Load course options of the course. + promises.push(CoreCourses.instance.getCoursesAdminAndNavOptions(courseIds).then((options) => { + courses.forEach((course) => { + course.navOptions = options.navOptions[course.id]; + course.admOptions = options.admOptions[course.id]; + }); + + return; + })); + } + + promises.push(this.loadCoursesExtraInfo(courses, loadCategoryNames)); + + await Promise.all(promises); + + switch (filter) { + case 'isfavourite': + courses = courses.filter((course) => !!course.isfavourite); + break; + default: + // Filter not implemented. + } + + switch (sort) { + case 'fullname': + courses.sort((a, b) => { + const compareA = a.fullname.toLowerCase(); + const compareB = b.fullname.toLowerCase(); + + return compareA.localeCompare(compareB); + }); + break; + case 'lastaccess': + courses.sort((a, b) => (b.lastaccess || 0) - (a.lastaccess || 0)); + break; + // @todo Time modified property is not defined in CoreEnrolledCourseDataWithOptions, so it won't do nothing. + // case 'timemodified': + // courses.sort((a, b) => b.timemodified - a.timemodified); + // break; + case 'shortname': + courses.sort((a, b) => { + const compareA = a.shortname.toLowerCase(); + const compareB = b.shortname.toLowerCase(); + + return compareA.localeCompare(compareB); + }); + break; + default: + // Sort not implemented. Do not sort. + } + + courses = slice > 0 ? courses.slice(0, slice) : courses; + + return Promise.all(courses.map(async (course) => { + if (typeof course.completed != 'undefined') { + // The WebService already returns the completed status, no need to fetch it. + return course; + } + + if (typeof 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.instance.getCompletion(course.id); + + course.completed = completion?.completed; + } catch { + // Ignore error, maybe course completion is disabled or user has no permission. + course.completed = false; + } + + return course; + })); } /** diff --git a/src/core/services/utils/text.ts b/src/core/services/utils/text.ts index 4530ff07a..facc5b870 100644 --- a/src/core/services/utils/text.ts +++ b/src/core/services/utils/text.ts @@ -650,7 +650,7 @@ export class CoreTextUtilsProvider { * @param logErrorFn An error to call with the exception to log the error. If not supplied, no error. * @return JSON parsed as object or what it gets. */ - parseJSON(json: string, defaultValue?: T, logErrorFn?: (error?: Error) => void): T | string { + parseJSON(json: string, defaultValue?: T, logErrorFn?: (error?: Error) => void): T { try { return JSON.parse(json); } catch (error) { @@ -661,7 +661,11 @@ export class CoreTextUtilsProvider { } // Error parsing, return the default value or the original value. - return typeof defaultValue != 'undefined' ? defaultValue : json; + if (typeof defaultValue != 'undefined') { + return defaultValue; + } + + throw new CoreError('JSON cannot be parsed and not default value has been provided') ; } /** diff --git a/src/theme/app.scss b/src/theme/app.scss index 07a599401..a3f55ee7a 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -81,7 +81,7 @@ ion-item-divider { // Ionic list. ion-list.list-md { - padding-bottom: 0; + padding: 0; } // Header. @@ -248,10 +248,15 @@ ion-avatar ion-img, ion-avatar img { // Select. ion-select.core-button-select, .core-button-select { - background-color: var(--ion-color-primary-contrast); + background: var(--ion-color-primary-contrast); color: var(--ion-color-primary); white-space: normal; min-height: 45px; + margin: 8px; + box-shadow: 0 3px 1px -2px rgba(0, 0, 0, .2), 0 2px 2px 0 rgba(0, 0, 0, .14), 0 1px 5px 0 rgba(0, 0, 0, .12); + &::part(icon) { + margin: 0 8px; + } } // File uploader.