From 3f825db799f5eac0333794e1c9be0fd8dc37073c Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 6 May 2021 12:35:32 +0200 Subject: [PATCH] MOBILE-3757 course: Display completion in course page --- .../mod/label/services/handlers/module.ts | 7 + src/core/classes/site.ts | 6 +- .../course/classes/module-completion.ts | 93 +++++++++ .../course/components/components.module.ts | 3 + .../core-course-module-completion-legacy.html | 9 + .../module-completion-legacy.scss | 20 ++ .../module-completion-legacy.ts | 118 ++++++++++++ .../core-course-module-completion.html | 60 +++++- .../module-completion/module-completion.scss | 26 ++- .../module-completion/module-completion.ts | 179 +++++------------- .../components/module/core-course-module.html | 19 +- .../course/components/module/module.ts | 12 +- src/core/features/course/lang.json | 12 ++ .../course/pages/contents/contents.ts | 3 + src/core/features/course/services/course.ts | 33 +++- .../course/services/module-delegate.ts | 20 ++ .../classes/handlers/module-handler.ts | 16 ++ .../siteplugins/services/siteplugins.ts | 1 + 18 files changed, 472 insertions(+), 165 deletions(-) create mode 100644 src/core/features/course/classes/module-completion.ts create mode 100644 src/core/features/course/components/module-completion-legacy/core-course-module-completion-legacy.html create mode 100644 src/core/features/course/components/module-completion-legacy/module-completion-legacy.scss create mode 100644 src/core/features/course/components/module-completion-legacy/module-completion-legacy.ts diff --git a/src/addons/mod/label/services/handlers/module.ts b/src/addons/mod/label/services/handlers/module.ts index 1bd7161e6..e93f13b26 100644 --- a/src/addons/mod/label/services/handlers/module.ts +++ b/src/addons/mod/label/services/handlers/module.ts @@ -71,5 +71,12 @@ export class AddonModLabelModuleHandlerService implements CoreCourseModuleHandle return; } + /** + * @inheritdoc + */ + manualCompletionAlwaysShown(): boolean { + return true; + } + } export const AddonModLabelModuleHandler = makeSingleton(AddonModLabelModuleHandlerService); diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 7fe336b26..2f86521f9 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -1826,15 +1826,15 @@ export class CoreSite { * @return Object with major and minor. Returns false if invalid version. */ protected getMajorAndMinor(version: string): {major: string; minor: number} | false { - const match = version.match(/(\d)+(?:\.(\d)+)?(?:\.(\d)+)?/); + const match = version.match(/^(\d+)(\.(\d+)(\.\d+)?)?/); if (!match || !match[1]) { // Invalid version. return false; } return { - major: match[1] + '.' + (match[2] || '0'), - minor: parseInt(match[3], 10) || 0, + major: match[1] + '.' + (match[3] || '0'), + minor: parseInt(match[5], 10) || 0, }; } diff --git a/src/core/features/course/classes/module-completion.ts b/src/core/features/course/classes/module-completion.ts new file mode 100644 index 000000000..c78de3695 --- /dev/null +++ b/src/core/features/course/classes/module-completion.ts @@ -0,0 +1,93 @@ +// (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, Input, Output, EventEmitter, OnChanges, SimpleChange } from '@angular/core'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseModuleCompletionData } from '@features/course/services/course-helper'; + +/** + * Base class for completion components. + */ +@Component({ + template: '', +}) +export class CoreCourseModuleCompletionBaseComponent implements OnChanges { + + @Input() completion?: CoreCourseModuleCompletionData; // The completion status. + @Input() moduleId?: number; // The name of the module this completion affects. + @Input() moduleName?: string; // The name of the module this completion affects. + @Output() completionChanged = new EventEmitter(); // Notify when completion changes. + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes.completion && this.completion) { + this.calculateData(); + } + } + + /** + * Calculate data to render the completion. + */ + protected calculateData(): void { + return; + } + + /** + * Completion clicked. + * + * @param e The click event. + */ + async completionClicked(e: Event): Promise { + if (!this.completion) { + return; + } + + if (typeof this.completion.cmid == 'undefined' || this.completion.tracking !== 1) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const modal = await CoreDomUtils.showModalLoading(); + this.completion.state = this.completion.state === 1 ? 0 : 1; + + try { + const response = await CoreCourse.markCompletedManually( + this.completion.cmid, + this.completion.state === 1, + this.completion.courseId!, + this.completion.courseName, + ); + + if (this.completion.valueused === false) { + this.calculateData(); + if (response.offline) { + this.completion.offline = true; + } + } + this.completionChanged.emit(this.completion); + } catch (error) { + this.completion.state = this.completion.state === 1 ? 0 : 1; + CoreDomUtils.showErrorModalDefault(error, 'core.errorchangecompletion', true); + } finally { + modal.dismiss(); + } + } + +} diff --git a/src/core/features/course/components/components.module.ts b/src/core/features/course/components/components.module.ts index e8fa6f23d..29e90f3cc 100644 --- a/src/core/features/course/components/components.module.ts +++ b/src/core/features/course/components/components.module.ts @@ -23,12 +23,14 @@ import { CoreCourseModuleDescriptionComponent } from './module-description/modul import { CoreCourseSectionSelectorComponent } from './section-selector/section-selector'; import { CoreCourseTagAreaComponent } from './tag-area/tag-area'; import { CoreCourseUnsupportedModuleComponent } from './unsupported-module/unsupported-module'; +import { CoreCourseModuleCompletionLegacyComponent } from './module-completion-legacy/module-completion-legacy'; @NgModule({ declarations: [ CoreCourseFormatComponent, CoreCourseModuleComponent, CoreCourseModuleCompletionComponent, + CoreCourseModuleCompletionLegacyComponent, CoreCourseModuleDescriptionComponent, CoreCourseSectionSelectorComponent, CoreCourseTagAreaComponent, @@ -42,6 +44,7 @@ import { CoreCourseUnsupportedModuleComponent } from './unsupported-module/unsup CoreCourseFormatComponent, CoreCourseModuleComponent, CoreCourseModuleCompletionComponent, + CoreCourseModuleCompletionLegacyComponent, CoreCourseModuleDescriptionComponent, CoreCourseSectionSelectorComponent, CoreCourseTagAreaComponent, diff --git a/src/core/features/course/components/module-completion-legacy/core-course-module-completion-legacy.html b/src/core/features/course/components/module-completion-legacy/core-course-module-completion-legacy.html new file mode 100644 index 000000000..32a0dacae --- /dev/null +++ b/src/core/features/course/components/module-completion-legacy/core-course-module-completion-legacy.html @@ -0,0 +1,9 @@ + + + + + diff --git a/src/core/features/course/components/module-completion-legacy/module-completion-legacy.scss b/src/core/features/course/components/module-completion-legacy/module-completion-legacy.scss new file mode 100644 index 000000000..a0fd0020d --- /dev/null +++ b/src/core/features/course/components/module-completion-legacy/module-completion-legacy.scss @@ -0,0 +1,20 @@ +:host { + min-width: var(--a11y-min-target-size); + min-height: var(--a11y-min-target-size); + --size: 30px; + + img { + padding: 5px; + width: var(--size); + vertical-align: middle; + max-width: none; + margin: 7px; + } + + ion-button { + --padding-top: 0; + --padding-start: 0; + --padding-end: 0; + --padding-bottom: 0; + } +} diff --git a/src/core/features/course/components/module-completion-legacy/module-completion-legacy.ts b/src/core/features/course/components/module-completion-legacy/module-completion-legacy.ts new file mode 100644 index 000000000..a44dced24 --- /dev/null +++ b/src/core/features/course/components/module-completion-legacy/module-completion-legacy.ts @@ -0,0 +1,118 @@ +// (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 { CoreUser } from '@features/user/services/user'; +import { CoreCourseProvider } from '@features/course/services/course'; +import { CoreFilterHelper } from '@features/filter/services/filter-helper'; +import { Translate } from '@singletons'; +import { CoreCourseModuleCompletionBaseComponent } from '@features/course/classes/module-completion'; + +/** + * Component to handle activity completion in sites previous to 3.11. + * It shows a checkbox with the current status, and allows manually changing the completion if it's allowed. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-course-module-completion-legacy', + templateUrl: 'core-course-module-completion-legacy.html', + styleUrls: ['module-completion-legacy.scss'], +}) +export class CoreCourseModuleCompletionLegacyComponent extends CoreCourseModuleCompletionBaseComponent { + + completionImage?: string; + completionDescription?: string; + + /** + * @inheritdoc + */ + protected async calculateData(): Promise { + if (!this.completion) { + return; + } + + const moduleName = this.moduleName || ''; + let langKey: string | undefined; + let image: string | undefined; + + if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_MANUAL && + this.completion.state === CoreCourseProvider.COMPLETION_INCOMPLETE) { + image = 'completion-manual-n'; + langKey = 'core.completion-alt-manual-n'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_MANUAL && + this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE) { + image = 'completion-manual-y'; + langKey = 'core.completion-alt-manual-y'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && + this.completion.state === CoreCourseProvider.COMPLETION_INCOMPLETE) { + image = 'completion-auto-n'; + langKey = 'core.completion-alt-auto-n'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && + this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE) { + image = 'completion-auto-y'; + langKey = 'core.completion-alt-auto-y'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && + this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE_PASS) { + image = 'completion-auto-pass'; + langKey = 'core.completion-alt-auto-pass'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && + this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE_FAIL) { + image = 'completion-auto-fail'; + langKey = 'core.completion-alt-auto-fail'; + } + + if (image) { + if (this.completion.overrideby && this.completion.overrideby > 0) { + image += '-override'; + } + this.completionImage = 'assets/img/completion/' + image + '.svg'; + } + + if (!moduleName || !this.moduleId || !langKey) { + return; + } + + const result = await CoreFilterHelper.getFiltersAndFormatText( + moduleName, + 'module', + this.moduleId, + { clean: true, singleLine: true, shortenLength: 50, courseId: this.completion.courseId }, + ); + + let translateParams: Record = { + $a: result.text, + }; + + if (this.completion.overrideby && this.completion.overrideby > 0) { + langKey += '-override'; + + const profile = await CoreUser.getProfile(this.completion.overrideby, this.completion.courseId, true); + + translateParams = { + $a: { + overrideuser: profile.fullname, + modname: result.text, + }, + }; + } + + this.completionDescription = Translate.instant(langKey, translateParams); + } + +} diff --git a/src/core/features/course/components/module-completion/core-course-module-completion.html b/src/core/features/course/components/module-completion/core-course-module-completion.html index 32a0dacae..4951f341d 100644 --- a/src/core/features/course/components/module-completion/core-course-module-completion.html +++ b/src/core/features/course/components/module-completion/core-course-module-completion.html @@ -1,9 +1,53 @@ - +
- - - + + + + {{ 'core.course.completion_automatic:done' | translate }} {{ rule.rulevalue.description }} + + + + {{ 'core.course.completion_automatic:failed' | translate }} {{ rule.rulevalue.description }} + + + + {{ 'core.course.completion_automatic:todo' | translate }} {{ rule.rulevalue.description }} + + + + + + + {{ rule.rulevalue.description }} + + +
+ +
+ + + + + + {{ 'core.course.completion_manual:done' | translate }} + + + + + {{ 'core.course.completion_manual:markdone' | translate }} + + + + + + + {{ 'core.course.completion_manual:markdone' | translate }} + + +
diff --git a/src/core/features/course/components/module-completion/module-completion.scss b/src/core/features/course/components/module-completion/module-completion.scss index a0fd0020d..eeded5aaa 100644 --- a/src/core/features/course/components/module-completion/module-completion.scss +++ b/src/core/features/course/components/module-completion/module-completion.scss @@ -1,20 +1,18 @@ :host { - min-width: var(--a11y-min-target-size); - min-height: var(--a11y-min-target-size); - --size: 30px; + .core-module-automatic-completion-conditions { + ion-badge { + font-weight: normal; + margin-right: 5px; - img { - padding: 5px; - width: var(--size); - vertical-align: middle; - max-width: none; - margin: 7px; + &[color="medium"] { + color: black; + } + } } - ion-button { - --padding-top: 0; - --padding-start: 0; - --padding-end: 0; - --padding-bottom: 0; + .core-module-manual-completion { + ion-button { + text-transform: none; + } } } diff --git a/src/core/features/course/components/module-completion/module-completion.ts b/src/core/features/course/components/module-completion/module-completion.ts index 4646dc0dc..4fa8ffdcb 100644 --- a/src/core/features/course/components/module-completion/module-completion.ts +++ b/src/core/features/course/components/module-completion/module-completion.ts @@ -12,13 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, Output, EventEmitter, OnChanges, SimpleChange } from '@angular/core'; +import { Component, Input } from '@angular/core'; -import { CoreDomUtils } from '@services/utils/dom'; -import { CoreUser } from '@features/user/services/user'; -import { CoreCourse, CoreCourseProvider } from '@features/course/services/course'; -import { CoreFilterHelper } from '@features/filter/services/filter-helper'; -import { CoreCourseModuleCompletionData } from '@features/course/services/course-helper'; +import { CoreCourseModuleCompletionBaseComponent } from '@features/course/classes/module-completion'; +import { CoreCourseModuleWSRuleDetails, CoreCourseProvider } from '@features/course/services/course'; import { Translate } from '@singletons'; /** @@ -35,142 +32,66 @@ import { Translate } from '@singletons'; templateUrl: 'core-course-module-completion.html', styleUrls: ['module-completion.scss'], }) -export class CoreCourseModuleCompletionComponent implements OnChanges { +export class CoreCourseModuleCompletionComponent extends CoreCourseModuleCompletionBaseComponent { - @Input() completion?: CoreCourseModuleCompletionData; // The completion status. - @Input() moduleId?: number; // The name of the module this completion affects. - @Input() moduleName?: string; // The name of the module this completion affects. - @Output() completionChanged = new EventEmitter(); // Notify when completion changes. + @Input() showCompletionConditions = false; // Whether to show activity completion conditions. + @Input() showManualCompletion = false; // Whether to show manual completion when completion conditions are disabled. - completionImage?: string; - completionDescription?: string; + details?: CompletionRule[]; + accessibleDescription: string | null = null; /** - * Detect changes on input properties. + * @inheritdoc */ - ngOnChanges(changes: { [name: string]: SimpleChange }): void { - if (changes.completion && this.completion) { - this.showStatus(); - } - } - - /** - * Completion clicked. - * - * @param e The click event. - */ - async completionClicked(e: Event): Promise { - if (!this.completion) { + protected calculateData(): void { + if (!this.completion?.details) { return; } - if (typeof this.completion.cmid == 'undefined' || this.completion.tracking !== 1) { - return; - } - - e.preventDefault(); - e.stopPropagation(); - - const modal = await CoreDomUtils.showModalLoading(); - this.completion.state = this.completion.state === 1 ? 0 : 1; - - try { - const response = await CoreCourse.markCompletedManually( - this.completion.cmid, - this.completion.state === 1, - this.completion.courseId!, - this.completion.courseName, - ); - - if (this.completion.valueused === false) { - this.showStatus(); - if (response.offline) { - this.completion.offline = true; - } - } - this.completionChanged.emit(this.completion); - } catch (error) { - this.completion.state = this.completion.state === 1 ? 0 : 1; - CoreDomUtils.showErrorModalDefault(error, 'core.errorchangecompletion', true); - } finally { - modal.dismiss(); - } - } - - /** - * Set image and description to show as completion icon. - */ - protected async showStatus(): Promise { - if (!this.completion) { - return; - } - - const moduleName = this.moduleName || ''; - let langKey: string | undefined; - let image: string | undefined; - - if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_MANUAL && - this.completion.state === CoreCourseProvider.COMPLETION_INCOMPLETE) { - image = 'completion-manual-n'; - langKey = 'core.completion-alt-manual-n'; - } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_MANUAL && - this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE) { - image = 'completion-manual-y'; - langKey = 'core.completion-alt-manual-y'; - } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && - this.completion.state === CoreCourseProvider.COMPLETION_INCOMPLETE) { - image = 'completion-auto-n'; - langKey = 'core.completion-alt-auto-n'; - } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && - this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE) { - image = 'completion-auto-y'; - langKey = 'core.completion-alt-auto-y'; - } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && - this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE_PASS) { - image = 'completion-auto-pass'; - langKey = 'core.completion-alt-auto-pass'; - } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && - this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE_FAIL) { - image = 'completion-auto-fail'; - langKey = 'core.completion-alt-auto-fail'; - } - - if (image) { - if (this.completion.overrideby > 0) { - image += '-override'; - } - this.completionImage = 'assets/img/completion/' + image + '.svg'; - } - - if (!moduleName || !this.moduleId || !langKey) { - return; - } - - const result = await CoreFilterHelper.getFiltersAndFormatText( - moduleName, - 'module', - this.moduleId, - { clean: true, singleLine: true, shortenLength: 50, courseId: this.completion.courseId }, - ); - - let translateParams: Record = { - $a: result.text, - }; - - if (this.completion.overrideby > 0) { - langKey += '-override'; - - const profile = await CoreUser.getProfile(this.completion.overrideby, this.completion.courseId, true); - - translateParams = { + // Set an accessible description for manual completions with overridden completion state. + if (!this.completion.isautomatic && this.completion.overrideby) { + const setByData = { $a: { - overrideuser: profile.fullname, - modname: result.text, + activityname: this.moduleName, + setby: this.completion.overrideby, }, }; + const setByLangKey = this.completion.state ? 'completion_setby:manual:done' : 'completion_setby:manual:markdone'; + this.accessibleDescription = Translate.instant('core.course.' + setByLangKey, setByData); + } else { + const langKey = this.completion.state ? 'completion_manual:aria:done' : 'completion_manual:aria:markdone'; + this.accessibleDescription = Translate.instant('core.course.' + langKey, { $a: this.moduleName }); } - this.completionDescription = Translate.instant(langKey, translateParams); + // Format rules. + this.details = this.completion.details.map((rule: CompletionRule) => { + rule.statuscomplete = rule.rulevalue.status == CoreCourseProvider.COMPLETION_COMPLETE || + rule.rulevalue.status == CoreCourseProvider.COMPLETION_COMPLETE_PASS; + rule.statuscompletefail = rule.rulevalue.status == CoreCourseProvider.COMPLETION_COMPLETE_FAIL; + rule.statusincomplete = rule.rulevalue.status == CoreCourseProvider.COMPLETION_INCOMPLETE; + rule.accessibleDescription = null; + + if (this.completion!.overrideby) { + const setByData = { + $a: { + condition: rule.rulevalue.description, + setby: this.completion!.overrideby, + }, + }; + const overrideStatus = rule.statuscomplete ? 'done' : 'todo'; + + rule.accessibleDescription = Translate.instant('core.course.completion_setby:auto:' + overrideStatus, setByData); + } + + return rule; + }); } } + +type CompletionRule = CoreCourseModuleWSRuleDetails & { + statuscomplete?: boolean; + statuscompletefail?: boolean; + statusincomplete?: boolean; + accessibleDescription?: string | null; +}; diff --git a/src/core/features/course/components/module/core-course-module.html b/src/core/features/course/components/module/core-course-module.html index 9d19cb47c..4035c3865 100644 --- a/src/core/features/course/components/module/core-course-module.html +++ b/src/core/features/course/components/module/core-course-module.html @@ -50,12 +50,13 @@ slot="end" *ngIf="module.uservisible !== false" class="buttons core-module-buttons" - [ngClass]="{'core-button-completion': module.completiondata}" + [ngClass]="{'core-button-completion': module.completiondata && showLegacyCompletion}" > - - - + + +
+

{{ date.label }} {{ date.timestamp * 1000 | coreFormatDate:'strftimedatetime' }}

+ + + + + diff --git a/src/core/features/course/components/module/module.ts b/src/core/features/course/components/module/module.ts index 9a19926bc..8c7201bd9 100644 --- a/src/core/features/course/components/module/module.ts +++ b/src/core/features/course/components/module/module.ts @@ -24,7 +24,7 @@ import { CoreCourseSection, } from '@features/course/services/course-helper'; import { CoreCourse } from '@features/course/services/course'; -import { CoreCourseModuleHandlerButton } from '@features/course/services/module-delegate'; +import { CoreCourseModuleDelegate, CoreCourseModuleHandlerButton } from '@features/course/services/module-delegate'; import { CoreCourseModulePrefetchDelegate, CoreCourseModulePrefetchHandler, @@ -48,7 +48,7 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { @Input() courseId?: number; // The course the module belongs to. @Input() section?: CoreCourseSection; // The section the module belongs to. @Input() showActivityDates = false; // Whether to show activity dates. - @Input() showCompletionConditions = false; // Whether to show activity completion conditions. + @Input() showCompletionConditions = false; // Whether to show activity completion conditions. // eslint-disable-next-line @angular-eslint/no-input-rename @Input('downloadEnabled') set enabled(value: boolean) { this.downloadEnabled = value; @@ -74,6 +74,8 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. modNameTranslated = ''; hasInfo = false; + showLegacyCompletion = false; // Whether to show module completion in the old format. + showManualCompletion = false; // Whether to show manual completion when completion conditions are disabled. protected prefetchHandler?: CoreCourseModulePrefetchHandler; protected statusObserver?: CoreEventObserver; @@ -86,6 +88,9 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { ngOnInit(): void { this.courseId = this.courseId || this.module.course; this.modNameTranslated = CoreCourse.translateModuleName(this.module.modname) || ''; + this.showLegacyCompletion = !CoreSites.getCurrentSite()?.isVersionGreaterEqualThan('3.11'); + this.showManualCompletion = + this.showCompletionConditions || CoreCourseModuleDelegate.manualCompletionAlwaysShown(this.module); if (!this.module.handlerData) { return; @@ -94,7 +99,8 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { this.module.handlerData.a11yTitle = this.module.handlerData.a11yTitle ?? this.module.handlerData.title; this.hasInfo = !!( this.module.description || - (this.showActivityDates && this.module.dates && this.module.dates.length) + (this.showActivityDates && this.module.dates && this.module.dates.length) || + this.module.completiondata ); if (this.module.handlerData.showDownloadButton) { diff --git a/src/core/features/course/lang.json b/src/core/features/course/lang.json index a01cbcf86..54dac5574 100644 --- a/src/core/features/course/lang.json +++ b/src/core/features/course/lang.json @@ -6,6 +6,18 @@ "askadmintosupport": "Contact the site administrator and tell them you want to use this activity with the Moodle Mobile app.", "availablespace": " You currently have about {{available}} free space.", "cannotdeletewhiledownloading": "Files cannot be deleted while the activity is being downloaded. Please wait for the download to finish.", + "completion_automatic:done": "Done:", + "completion_automatic:failed": "Failed:", + "completion_automatic:todo": "To do:", + "completion_manual:aria:done": "{{$a}} is marked as done. Press to undo.", + "completion_manual:aria:markdone": "Mark {{$a}} as done", + "completion_manual:done": "Done", + "completion_manual:markdone": "Mark as done", + "completion_setby:auto:done": "Done: {{$a.condition}} (set by {{$a.setby}})", + "completion_setby:auto:todo": "To do: {{$a.condition}} (set by {{$a.setby}})", + "completion_setby:manual:done": "{{$a.activityname}} is marked by {{$a.setby}} as done. Press to undo.", + "completion_setby:manual:markdone": "{{$a.activityname}} is marked by {{$a.setby}} as not done. Press to mark as done.", + "completionrequirements": "Completion requirements for {{$a}}", "confirmdeletemodulefiles": "Are you sure you want to delete these files?", "confirmdeletestoreddata": "Are you sure you want to delete the stored data?", "confirmdownload": "You are about to download {{size}}.{{availableSpace}} Are you sure you want to continue?", diff --git a/src/core/features/course/pages/contents/contents.ts b/src/core/features/course/pages/contents/contents.ts index e09f8b038..9a9c2fb75 100644 --- a/src/core/features/course/pages/contents/contents.ts +++ b/src/core/features/course/pages/contents/contents.ts @@ -353,6 +353,9 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { const shouldReload = typeof completionData.valueused == 'undefined' || completionData.valueused; if (!shouldReload) { + // Invalidate the completion. + await CoreUtils.ignoreErrors(CoreCourse.invalidateSections(this.course.id)); + return; } diff --git a/src/core/features/course/services/course.ts b/src/core/features/course/services/course.ts index f0548ec0e..2c67cb4f5 100644 --- a/src/core/features/course/services/course.ts +++ b/src/core/features/course/services/course.ts @@ -1296,14 +1296,25 @@ export type CoreCourseCompletionActivityStatusWSResponse = { * Activity status. */ export type CoreCourseCompletionActivityStatus = { - cmid: number; // Comment ID. + cmid: number; // Course module ID. modname: string; // Activity module name. instance: number; // Instance ID. state: number; // Completion state value: 0 means incomplete, 1 complete, 2 complete pass, 3 complete fail. timecompleted: number; // Timestamp for completed activity. tracking: number; // Type of tracking: 0 means none, 1 manual, 2 automatic. - overrideby?: number; // The user id who has overriden the status, or null. + overrideby?: number | null; // The user id who has overriden the status, or null. valueused?: boolean; // Whether the completion status affects the availability of another activity. + hascompletion?: boolean; // @since 3.11. Whether this activity module has completion enabled. + isautomatic?: boolean; // @since 3.11. Whether this activity module instance tracks completion automatically. + istrackeduser?: boolean; // @since 3.11. Whether completion is being tracked for this user. + uservisible?: boolean; // @since 3.11. Whether this activity is visible to the user. + details?: { // @since 3.11. An array of completion details containing the description and status. + rulename: string; // Rule name. + rulevalue: { + status: number; // Completion status. + description: string; // Completion description. + }; + }[]; offline?: boolean; // Whether the completions is offline and not yet synced. }; @@ -1463,8 +1474,24 @@ export type CoreCourseWSModule = { export type CoreCourseModuleWSCompletionData = { state: number; // Completion state value: 0 means incomplete, 1 complete, 2 complete pass, 3 complete fail. timecompleted: number; // Timestamp for completion status. - overrideby: number; // The user id who has overriden the status. + overrideby: number | null; // The user id who has overriden the status. valueused?: boolean; // Whether the completion status affects the availability of another activity. + hascompletion?: boolean; // @since 3.11. Whether this activity module has completion enabled. + isautomatic?: boolean; // @since 3.11. Whether this activity module instance tracks completion automatically. + istrackeduser?: boolean; // @since 3.11. Whether completion is being tracked for this user. + uservisible?: boolean; // @since 3.11. Whether this activity is visible to the user. + details?: CoreCourseModuleWSRuleDetails[]; // @since 3.11. An array of completion details. +}; + +/** + * Module completion rule details. + */ +export type CoreCourseModuleWSRuleDetails = { + rulename: string; // Rule name. + rulevalue: { + status: number; // Completion status. + description: string; // Completion description. + }; }; export type CoreCourseModuleContentFile = { diff --git a/src/core/features/course/services/module-delegate.ts b/src/core/features/course/services/module-delegate.ts index 547ff1c1a..0930be852 100644 --- a/src/core/features/course/services/module-delegate.ts +++ b/src/core/features/course/services/module-delegate.ts @@ -92,6 +92,15 @@ export interface CoreCourseModuleHandler extends CoreDelegateHandler { * @return The result of the supports check. */ supportsFeature?(feature: string): unknown; + + /** + * Return true to show the manual completion regardless of the course's showcompletionconditions setting. + * Returns false by default. + * + * @param module Module. + * @return Whether the manual completion should always be displayed. + */ + manualCompletionAlwaysShown?(module: CoreCourseModule): boolean; } /** @@ -366,6 +375,17 @@ export class CoreCourseModuleDelegateService extends CoreDelegate(module.modname, 'manualCompletionAlwaysShown', [module]); + } + } export const CoreCourseModuleDelegate = makeSingleton(CoreCourseModuleDelegateService); diff --git a/src/core/features/siteplugins/classes/handlers/module-handler.ts b/src/core/features/siteplugins/classes/handlers/module-handler.ts index 11009129f..20f2434bf 100644 --- a/src/core/features/siteplugins/classes/handlers/module-handler.ts +++ b/src/core/features/siteplugins/classes/handlers/module-handler.ts @@ -165,4 +165,20 @@ export class CoreSitePluginsModuleHandler extends CoreSitePluginsBaseHandler imp return CoreSitePluginsModuleIndexComponent; } + /** + * @inheritdoc + */ + manualCompletionAlwaysShown(module: CoreCourseModule): boolean { + if (this.handlerSchema.manualcompletionalwaysshown !== undefined) { + return this.handlerSchema.manualcompletionalwaysshown; + } + + if (this.initResult?.jsResult && this.initResult.jsResult.manualCompletionAlwaysShown) { + // The init result defines a function to check if a feature is supported, use it. + return this.initResult.jsResult.manualCompletionAlwaysShown(module); + } + + return false; + } + } diff --git a/src/core/features/siteplugins/services/siteplugins.ts b/src/core/features/siteplugins/services/siteplugins.ts index 892b9de56..f4903cb16 100644 --- a/src/core/features/siteplugins/services/siteplugins.ts +++ b/src/core/features/siteplugins/services/siteplugins.ts @@ -848,6 +848,7 @@ export type CoreSitePluginsCourseModuleHandlerData = CoreSitePluginsHandlerCommo coursepagemethod?: string; ptrenabled?: boolean; supportedfeatures?: Record; + manualcompletionalwaysshown?: boolean; }; /**