Merge pull request #2755 from dpalou/MOBILE-3757

Mobile 3757
main
Pau Ferrer Ocaña 2021-05-12 09:35:18 +02:00 committed by GitHub
commit 7353bfac79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 982 additions and 197 deletions

View File

@ -408,7 +408,6 @@
"addon.mod_assign.ungroupedusersoptional": "assign",
"addon.mod_assign.unlimitedattempts": "assign",
"addon.mod_assign.userswhoneedtosubmit": "assign",
"addon.mod_assign.userwithid": "local_moodlemobileapp",
"addon.mod_assign.viewsubmission": "assign",
"addon.mod_assign.warningsubmissiongrademodified": "local_moodlemobileapp",
"addon.mod_assign.warningsubmissionmodified": "local_moodlemobileapp",
@ -1041,7 +1040,6 @@
"addon.notes.personalnotes": "notes",
"addon.notes.publishstate": "notes",
"addon.notes.sitenotes": "notes",
"addon.notes.userwithid": "local_moodlemobileapp",
"addon.notes.warningnotenotsent": "local_moodlemobileapp",
"addon.notifications.errorgetnotifications": "local_moodlemobileapp",
"addon.notifications.markallread": "moodle",
@ -1447,6 +1445,17 @@
"core.course.askadmintosupport": "local_moodlemobileapp",
"core.course.availablespace": "local_moodlemobileapp",
"core.course.cannotdeletewhiledownloading": "local_moodlemobileapp",
"core.course.completion_automatic:done": "course",
"core.course.completion_automatic:failed": "course",
"core.course.completion_automatic:todo": "course",
"core.course.completion_manual:aria:done": "course",
"core.course.completion_manual:aria:markdone": "course",
"core.course.completion_manual:markdone": "course",
"core.course.completion_setby:auto:done": "course",
"core.course.completion_setby:auto:todo": "course",
"core.course.completion_setby:manual:done": "course",
"core.course.completion_setby:manual:markdone": "course",
"core.course.completionrequirements": "course",
"core.course.confirmdeletemodulefiles": "local_moodlemobileapp",
"core.course.confirmdeletestoreddata": "local_moodlemobileapp",
"core.course.confirmdownload": "local_moodlemobileapp",
@ -1470,6 +1479,7 @@
"core.course.nocontentavailable": "local_moodlemobileapp",
"core.course.overriddennotice": "grades",
"core.course.refreshcourse": "local_moodlemobileapp",
"core.course.section": "moodle",
"core.course.sections": "moodle",
"core.course.useactivityonbrowser": "local_moodlemobileapp",
"core.course.warningmanualcompletionmodified": "local_moodlemobileapp",
@ -2198,6 +2208,7 @@
"core.user.sendemail": "local_moodlemobileapp",
"core.user.student": "moodle/defaultcoursestudent",
"core.user.teacher": "moodle/noneditingteacher",
"core.user.userwithid": "local_moodlemobileapp",
"core.user.webpage": "moodle",
"core.userdeleted": "moodle",
"core.userdetails": "moodle",

View File

@ -30,6 +30,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<!-- Description and intro attachments. -->
<ion-card *ngIf="description">
<ion-item class="ion-text-wrap">
@ -141,5 +146,4 @@
<addon-mod-assign-submission *ngIf="loaded && !canViewAllSubmissions && canViewOwnSubmission" [courseId]="courseId"
[moduleId]="module.id">
</addon-mod-assign-submission>
</core-loading>

View File

@ -120,7 +120,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo
AddonModAssignProvider.SUBMITTED_FOR_GRADING_EVENT,
(data) => {
if (this.assign && data.assignmentId == this.assign.id && data.userId == this.currentUserId) {
// Assignment submitted, check completion.
// Assignment submitted, check completion.
CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata);
// Reload data since it can have offline data now.

View File

@ -54,7 +54,7 @@
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="fromDate && !isSubmittedForGrading">
<ion-item class="ion-text-wrap" *ngIf="showDates && fromDate && !isSubmittedForGrading">
<ion-label>
<p *ngIf="assign!.intro"
[innerHTML]="'addon.mod_assign.allowsubmissionsfromdatesummary' | translate: {'$a': fromDate}">
@ -66,7 +66,7 @@
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="assign!.duedate && !isSubmittedForGrading">
<ion-item class="ion-text-wrap" *ngIf="showDates && assign!.duedate && !isSubmittedForGrading">
<ion-label>
<h2>{{ 'addon.mod_assign.duedate' | translate }}</h2>
<p *ngIf="assign!.duedate" >{{ assign!.duedate * 1000 | coreFormatDate }}</p>

View File

@ -122,6 +122,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy, Can
allowAddAttempt = false; // Allow adding a new attempt when grading.
gradeUrl?: string; // URL to grade in browser.
isPreviousAttemptEmpty = true; // Whether the previous attempt contains an empty submission.
showDates = false; // Whether to show some dates.
// Some constants.
statusNew = AddonModAssignProvider.SUBMISSION_STATUS_NEW;
@ -181,6 +182,7 @@ export class AddonModAssignSubmissionComponent implements OnInit, OnDestroy, Can
*/
ngOnInit(): void {
this.isSubmittedForGrading = !!this.submitId;
this.showDates = !CoreSites.getCurrentSite()?.isVersionGreaterEqualThan('3.11');
this.loadData(true);
}

View File

@ -95,10 +95,9 @@
"ungroupedusers": "The setting 'Require group to make submission' is enabled and some users are either not a member of any group, or are a member of more than one group, so are unable to make submissions.",
"ungroupedusersoptional": "The setting 'Students submit in groups' is enabled and some users are either not a member of any group, or are a member of more than one group. Please be aware that these students will submit as members of the 'Default group'.",
"unlimitedattempts": "Unlimited",
"userwithid": "User with ID {{id}}",
"userswhoneedtosubmit": "Users who need to submit: {{$a}}",
"viewsubmission": "View submission",
"warningsubmissionmodified": "The user submission was modified on the site.",
"warningsubmissiongrademodified": "The submission grade was modified on the site.",
"wordlimit": "Word limit"
}
}

View File

@ -23,6 +23,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId"></core-course-module-description>

View File

@ -26,6 +26,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center safe-area-page">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -28,6 +28,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -39,6 +39,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -28,6 +28,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -25,6 +25,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -42,6 +42,11 @@
</ion-refresher>
<core-loading [hideUntil]="discussions.loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description *ngIf="forum && forum.type != 'single'"
[description]="description" [component]="component" [componentId]="componentId" [note]="descriptionNote"
contextLevel="module" [contextInstanceId]="module && module.id" [courseId]="courseId">

View File

@ -50,6 +50,11 @@
</core-search-box>
<core-loading [hideUntil]="entries.loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -32,6 +32,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center safe-area-page">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -28,6 +28,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center safe-area-page">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<ion-card class="core-warning-card" *ngIf="warning">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start" aria-hidden="true"></ion-icon>

View File

@ -71,5 +71,12 @@ export class AddonModLabelModuleHandlerService implements CoreCourseModuleHandle
return;
}
/**
* @inheritdoc
*/
manualCompletionAlwaysShown(): boolean {
return true;
}
}
export const AddonModLabelModuleHandler = makeSingleton(AddonModLabelModuleHandlerService);

View File

@ -31,6 +31,12 @@
<!-- Index/Preview tab. -->
<core-tab [title]="'addon.mod_lesson.preview' | translate" (ionSelect)="indexSelected()">
<ng-template>
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -18,6 +18,12 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center safe-area-page">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description *ngIf="lti && lti.showdescriptionlaunch" [description]="description" [component]="component"
[componentId]="componentId" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -25,6 +25,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center safe-area-page">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description *ngIf="displayDescription" [description]="description" [component]="component"
[componentId]="componentId" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -27,6 +27,12 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -20,6 +20,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center safe-area-page">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description *ngIf="mode != 'iframe' && (mode != 'embedded' || displayDescription)"
[description]="description" [component]="component" [componentId]="componentId" contextLevel="module"
[contextInstanceId]="module.id" [courseId]="courseId">

View File

@ -28,6 +28,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -29,6 +29,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center safe-area-page">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description *ngIf="survey && !survey.surveydone && !hasOffline" [description]="description"
[component]="component" [componentId]="componentId" contextLevel="module" [contextInstanceId]="module.id"
[courseId]="courseId">

View File

@ -15,6 +15,11 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<core-course-module-description *ngIf="displayDescription" [description]="description" [component]="component"
[componentId]="componentId" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-course-module-description>

View File

@ -47,6 +47,12 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<div *ngIf="description || pageIsOffline || hasOffline || pageWarning">
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">

View File

@ -27,6 +27,12 @@
<!-- Content. -->
<core-loading [hideUntil]="loaded" class="core-loading-center">
<!-- Activity info. -->
<core-course-module-info *ngIf="showCompletion" [module]="module" [showManualCompletion]="true"
(completionChanged)="onCompletionChange()">
</core-course-module-info>
<ion-card class="with-borders" *ngIf="phases">
<ion-item button (click)="viewPhaseInfo()" detail="true">
<ion-label>

View File

@ -10,6 +10,5 @@
"personalnotes": "Personal notes",
"publishstate": "Context",
"sitenotes": "Site notes",
"userwithid": "User with ID {{id}}",
"warningnotenotsent": "Couldn't add note(s) to course {{course}}. {{error}}"
}
}

View File

@ -370,7 +370,7 @@ export class AddonNotesProvider {
return;
}).catch(() => {
note.userfullname = Translate.instant('addon.notes.userwithid', { id: note.userid });
note.userfullname = Translate.instant('core.user.userwithid', { id: note.userid });
}));
await Promise.all(promises);

View File

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

View File

@ -122,7 +122,14 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
this.syncIcon = CoreConstants.ICON_LOADING;
try {
await CoreUtils.ignoreErrors(this.invalidateContent());
await CoreUtils.ignoreErrors(Promise.all([
this.invalidateContent(),
this.showCompletion ? CoreCourse.invalidateModule(this.module.id) : undefined,
]));
if (this.showCompletion) {
this.module = await CoreCourse.getModule(this.module.id, this.courseId);
}
await this.loadContent(true, sync, showErrors);
} finally {

View File

@ -70,13 +70,16 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
isDestroyed = false; // Whether the component is destroyed, used when calling fillContextMenu.
contextMenuStatusObserver?: CoreEventObserver; // Observer of package status, used when calling fillContextMenu.
contextFileStatusObserver?: CoreEventObserver; // Observer of file status, used when calling fillContextMenu.
showCompletion = false; // Whether to show completion inside the activity.
protected fetchContentDefaultError = 'core.course.errorgetmodule'; // Default error to show when loading contents.
protected isCurrentView = false; // Whether the component is in the current view.
protected siteId?: string; // Current Site ID.
protected statusObserver?: CoreEventObserver; // Observer of package status. Only if setStatusListener is called.
protected currentStatus?: string; // The current status of the module. Only if setStatusListener is called.
protected completionObserver?: CoreEventObserver;
protected logger: CoreLogger;
protected debouncedUpdateModule?: () => void; // Update the module after a certain time.
constructor(
@Optional() @Inject('') loggerName: string = 'CoreCourseModuleMainResourceComponent',
@ -94,6 +97,22 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
this.componentId = this.module.id;
this.externalUrl = this.module.url;
this.courseId = this.courseId || this.module.course!;
this.showCompletion = !!CoreSites.getCurrentSite()?.isVersionGreaterEqualThan('3.11');
if (this.showCompletion) {
this.completionObserver = CoreEvents.on(CoreEvents.COMPLETION_MODULE_VIEWED, async (data) => {
if (data && data.cmId == this.module.id) {
await CoreCourse.invalidateModule(this.module.id);
this.fetchModule();
}
});
this.debouncedUpdateModule = CoreUtils.debounce(() => {
this.fetchModule();
}, 10000);
}
this.blog = await AddonBlog.isPluginEnabled();
}
@ -140,7 +159,14 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
this.refreshIcon = CoreConstants.ICON_LOADING;
try {
await CoreUtils.ignoreErrors(this.invalidateContent());
await CoreUtils.ignoreErrors(Promise.all([
this.invalidateContent(),
this.showCompletion ? CoreCourse.invalidateModule(this.module.id) : undefined,
]));
if (this.showCompletion) {
this.fetchModule();
}
await this.loadContent(true);
} finally {
@ -376,6 +402,31 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
return result;
}
/**
* The completion of the modules has changed.
*
* @return Promise resolved when done.
*/
async onCompletionChange(): Promise<void> {
// Update the module data after a while.
this.debouncedUpdateModule?.();
}
/**
* Fetch module.
*
* @return Promise resolved when done.
*/
protected async fetchModule(): Promise<void> {
const module = await CoreCourse.getModule(this.module.id, this.courseId);
CoreCourseHelper.calculateModuleCompletionData(module, this.courseId);
await CoreCourseHelper.loadModuleOfflineCompletion(this.courseId, module);
this.module = module;
}
/**
* Component being destroyed.
*/
@ -384,6 +435,7 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
this.contextMenuStatusObserver?.off();
this.contextFileStatusObserver?.off();
this.statusObserver?.off();
this.completionObserver?.off();
}
/**

View File

@ -0,0 +1,48 @@
// (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 { 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<CoreCourseModuleCompletionData>(); // Notify when completion changes.
/**
* @inheritdoc
*/
ngOnChanges(changes: { [name: string]: SimpleChange }): void {
if (changes.completion && this.completion) {
this.calculateData();
}
}
/**
* Calculate data to render the completion.
*/
protected calculateData(): void {
return;
}
}

View File

@ -23,13 +23,19 @@ 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';
import { CoreCourseModuleInfoComponent } from './module-info/module-info';
import { CoreCourseModuleManualCompletionComponent } from './module-manual-completion/module-manual-completion';
@NgModule({
declarations: [
CoreCourseFormatComponent,
CoreCourseModuleComponent,
CoreCourseModuleCompletionComponent,
CoreCourseModuleCompletionLegacyComponent,
CoreCourseModuleDescriptionComponent,
CoreCourseModuleInfoComponent,
CoreCourseModuleManualCompletionComponent,
CoreCourseSectionSelectorComponent,
CoreCourseTagAreaComponent,
CoreCourseUnsupportedModuleComponent,
@ -42,7 +48,10 @@ import { CoreCourseUnsupportedModuleComponent } from './unsupported-module/unsup
CoreCourseFormatComponent,
CoreCourseModuleComponent,
CoreCourseModuleCompletionComponent,
CoreCourseModuleCompletionLegacyComponent,
CoreCourseModuleDescriptionComponent,
CoreCourseModuleInfoComponent,
CoreCourseModuleManualCompletionComponent,
CoreCourseSectionSelectorComponent,
CoreCourseTagAreaComponent,
CoreCourseUnsupportedModuleComponent,

View File

@ -146,7 +146,8 @@
<ng-container *ngFor="let module of section.modules">
<core-course-module *ngIf="module.visibleoncoursepage !== 0" [module]="module" [courseId]="course?.id"
[downloadEnabled]="downloadEnabled" [section]="section" (completionChanged)="onCompletionChange($event)"
(statusChanged)="onModuleStatusChange()">
(statusChanged)="onModuleStatusChange()" [showActivityDates]="course?.showactivitydates"
[showCompletionConditions]="course?.showcompletionconditions">
</core-course-module>
</ng-container>
</section>

View File

@ -0,0 +1,9 @@
<img *ngIf="completion && completion.tracking !== 1" [src]="completionImage" [alt]="completionDescription">
<ion-button
fill="clear"
*ngIf="completion && completion.tracking === 1"
(click)="completionClicked($event)"
[title]="completionDescription">
<img [src]="completionImage" role="presentation" alt="">
</ion-button>

View File

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

View File

@ -0,0 +1,136 @@
// (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';
import { CoreCourseHelper } from '@features/course/services/course-helper';
/**
* 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:
*
* <core-course-module-completion-legacy [completion]="module.completiondata" [moduleName]="module.name"
* (completionChanged)="completionChanged()"></core-course-module-completion-legacy>
*/
@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<void> {
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<string, unknown> = {
$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);
}
/**
* Completion clicked.
*
* @param event The click event.
*/
async completionClicked(event: Event): Promise<void> {
if (!this.completion) {
return;
}
await CoreCourseHelper.changeManualCompletion(this.completion, event);
this.calculateData();
this.completionChanged.emit(this.completion);
}
}

View File

@ -1,9 +1,32 @@
<img *ngIf="completion && completion.tracking !== 1" [src]="completionImage" [alt]="completionDescription">
<div *ngIf="showCompletionConditions && completion && completion.isautomatic" class="core-module-automatic-completion-conditions"
role="list" [attr.aria-label]="'core.course.completionrequirements' | translate:{ $a: moduleName }">
<ion-button
fill="clear"
*ngIf="completion && completion.tracking === 1"
(click)="completionClicked($event)"
[title]="completionDescription">
<img [src]="completionImage" role="presentation" alt="">
</ion-button>
<ng-container *ngIf="completion.istrackeduser">
<ng-container *ngFor="let rule of details">
<ion-badge *ngIf="rule.statuscomplete" color="success" role="listitem"
[attr.aria-label]="rule.accessibleDescription">
<strong>{{ 'core.course.completion_automatic:done' | translate }}</strong> {{ rule.rulevalue.description }}
</ion-badge>
<ion-badge *ngIf="rule.statuscompletefail" color="danger" role="listitem"
[attr.aria-label]="rule.accessibleDescription">
<strong>{{ 'core.course.completion_automatic:failed' | translate }}</strong> {{ rule.rulevalue.description }}
</ion-badge>
<ion-badge *ngIf="rule.statusincomplete" color="medium" role="listitem"
[attr.aria-label]="rule.accessibleDescription">
<strong>{{ 'core.course.completion_automatic:todo' | translate }}</strong> {{ rule.rulevalue.description }}
</ion-badge>
</ng-container>
</ng-container>
<ng-container *ngIf="!completion.istrackeduser">
<ion-badge *ngFor="let rule of details" color="light" role="listitem">
{{ rule.rulevalue.description }}
</ion-badge>
</ng-container>
</div>
<core-course-module-manual-completion *ngIf="showManualCompletion" [completion]="completion" [moduleName]="moduleName"
(completionChanged)="completionChanged.emit($event)">
</core-course-module-manual-completion>

View File

@ -1,20 +1,12 @@
: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;
}
ion-button {
--padding-top: 0;
--padding-start: 0;
--padding-end: 0;
--padding-bottom: 0;
&[color="medium"] {
color: black;
}
}
}
}

View File

@ -12,13 +12,11 @@
// 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 { CoreCourseModuleCompletionBaseComponent } from '@features/course/classes/module-completion';
import { CoreCourseModuleWSRuleDetails, CoreCourseProvider } from '@features/course/services/course';
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 { Translate } from '@singletons';
/**
@ -35,142 +33,53 @@ 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<CoreCourseModuleCompletionData>(); // Notify when completion changes.
@Input() showCompletionConditions = false; // Whether to show activity completion conditions.
@Input() showManualCompletion = false; // Whether to show manual completion.
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<void> {
if (!this.completion) {
protected async calculateData(): Promise<void> {
if (!this.completion?.details) {
return;
}
if (typeof this.completion.cmid == 'undefined' || this.completion.tracking !== 1) {
return;
}
// Format rules.
this.details = await Promise.all(this.completion.details.map(async (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;
e.preventDefault();
e.stopPropagation();
if (this.completion!.overrideby) {
const fullName = await CoreUser.getUserFullNameWithDefault(this.completion!.overrideby, this.completion!.courseId);
const modal = await CoreDomUtils.showModalLoading();
this.completion.state = this.completion.state === 1 ? 0 : 1;
const setByData = {
$a: {
condition: rule.rulevalue.description,
setby: fullName,
},
};
const overrideStatus = rule.statuscomplete ? 'done' : 'todo';
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;
}
rule.accessibleDescription = Translate.instant('core.course.completion_setby:auto:' + overrideStatus, setByData);
}
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<void> {
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<string, unknown> = {
$a: result.text,
};
if (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);
return rule;
}));
}
}
type CompletionRule = CoreCourseModuleWSRuleDetails & {
statuscomplete?: boolean;
statuscompletefail?: boolean;
statusincomplete?: boolean;
accessibleDescription?: string | null;
};

View File

@ -0,0 +1,18 @@
<ion-card *ngIf="module.dates?.length || (module.completiondata && (module.completiondata.isautomatic || showManualCompletion))">
<ion-item class="ion-text-wrap">
<ion-label>
<!-- Activity dates. -->
<div *ngIf="module.dates && module.dates.length" class="core-module-dates">
<p *ngFor="let date of module.dates">
<strong>{{ date.label }}</strong> {{ date.timestamp * 1000 | coreFormatDate:'strftimedatetime' }}
</p>
</div>
<!-- Module completion. -->
<core-course-module-completion *ngIf="module.completiondata" [completion]="module.completiondata"
[moduleName]="module.name" [moduleId]="module.id" [showCompletionConditions]="true"
[showManualCompletion]="showManualCompletion" (completionChanged)="completionChanged.emit($event)">
</core-course-module-completion>
</ion-label>
</ion-item>
</ion-card>

View File

@ -0,0 +1,31 @@
// (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, EventEmitter, Input, Output } from '@angular/core';
import { CoreCourseModule, CoreCourseModuleCompletionData } from '@features/course/services/course-helper';
/**
* Display info about a module: dates and completion.
*/
@Component({
selector: 'core-course-module-info',
templateUrl: 'core-course-module-info.html',
})
export class CoreCourseModuleInfoComponent {
@Input() module!: CoreCourseModule; // The module to render.
@Input() showManualCompletion = false; // Whether to show manual completion.
@Output() completionChanged = new EventEmitter<CoreCourseModuleCompletionData>(); // Notify when completion changes.
}

View File

@ -0,0 +1,23 @@
<div *ngIf="completion && !completion.isautomatic" class="core-module-manual-completion">
<ng-container *ngIf="completion.istrackeduser">
<ng-container *ngIf="completion.state">
<ion-button color="success" fill="outline" [attr.aria-label]="accessibleDescription"
(click)="completionClicked($event)">
<ion-icon name="fas-check" slot="start" aria-hidden="true"></ion-icon>
{{ 'core.course.completion_manual:done' | translate }}
</ion-button>
</ng-container>
<ng-container *ngIf="!completion.state">
<ion-button color="light" [attr.aria-label]="accessibleDescription" (click)="completionClicked($event)">
{{ 'core.course.completion_manual:markdone' | translate }}
</ion-button>
</ng-container>
</ng-container>
<ng-container *ngIf="!completion.istrackeduser">
<ion-button disabled="true" color="light">
{{ 'core.course.completion_manual:markdone' | translate }}
</ion-button>
</ng-container>
</div>

View File

@ -0,0 +1,5 @@
:host {
ion-button {
text-transform: none;
}
}

View File

@ -0,0 +1,112 @@
// (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, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChange } from '@angular/core';
import { CoreCourseHelper, CoreCourseModuleCompletionData } from '@features/course/services/course-helper';
import { CoreUser } from '@features/user/services/user';
import { Translate } from '@singletons';
import { CoreEventObserver, CoreEvents } from '@singletons/events';
/**
* Component to display a button for manual completion.
*/
@Component({
selector: 'core-course-module-manual-completion',
templateUrl: 'core-course-module-manual-completion.html',
styleUrls: ['module-manual-completion.scss'],
})
export class CoreCourseModuleManualCompletionComponent implements OnInit, OnChanges, OnDestroy {
@Input() completion?: CoreCourseModuleCompletionData; // The completion status.
@Input() moduleName?: string; // The name of the module this completion affects.
@Output() completionChanged = new EventEmitter<CoreCourseModuleCompletionData>(); // Notify when completion changes.
accessibleDescription: string | null = null;
protected manualChangedObserver?: CoreEventObserver;
/**
* @inheritdoc
*/
ngOnInit(): void {
this.manualChangedObserver = CoreEvents.on(CoreEvents.MANUAL_COMPLETION_CHANGED, (data) => {
if (!this.completion || this.completion.cmid != data.completion.cmid) {
return;
}
this.completion = data.completion;
this.calculateData();
this.completionChanged.emit(this.completion);
});
}
/**
* @inheritdoc
*/
ngOnChanges(changes: { [name: string]: SimpleChange }): void {
if (changes.completion && this.completion) {
this.calculateData();
}
}
/**
* @inheritdoc
*/
protected async calculateData(): Promise<void> {
if (!this.completion || this.completion.isautomatic) {
return;
}
// Set an accessible description for manual completions with overridden completion state.
if (this.completion.overrideby) {
const fullName = await CoreUser.getUserFullNameWithDefault(this.completion.overrideby, this.completion.courseId);
const setByData = {
$a: {
activityname: this.moduleName,
setby: fullName,
},
};
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 });
}
}
/**
* Completion clicked.
*
* @param event The click event.
*/
async completionClicked(event: Event): Promise<void> {
if (!this.completion) {
return;
}
await CoreCourseHelper.changeManualCompletion(this.completion, event);
CoreEvents.trigger(CoreEvents.MANUAL_COMPLETION_CHANGED, { completion: this.completion });
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.manualChangedObserver?.off();
}
}

View File

@ -6,7 +6,7 @@
(click)="moduleClicked($event)"
[attr.aria-label]="module.handlerData.a11yTitle"
[ngClass]="{
'has-module-description': module.description,
'has-module-info': hasInfo,
'item-media': module.handlerData.icon,
'item-dimmed': module.visible === 0 || module.uservisible === false
}"
@ -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}"
>
<!-- Module completion. -->
<core-course-module-completion *ngIf="module.completiondata" [completion]="module.completiondata"
[moduleName]="module.name" [moduleId]="module.id" (completionChanged)="completionChanged.emit($event)">
</core-course-module-completion>
<!-- Module completion (legacy). -->
<core-course-module-completion-legacy *ngIf="module.completiondata && showLegacyCompletion"
[completion]="module.completiondata" [moduleName]="module.name" [moduleId]="module.id"
(completionChanged)="completionChanged.emit($event)">
</core-course-module-completion-legacy>
<div class="core-module-buttons-more">
<core-download-refresh [status]="downloadStatus" [enabled]="downloadEnabled"
@ -74,9 +75,9 @@
</div>
</ion-item>
<ion-item
*ngIf="module.description"
*ngIf="hasInfo"
id="core-course-module-{{module.id}}-info"
class="ion-text-wrap core-course-module-handler core-module-module-description {{module.handlerData.class}}"
class="ion-text-wrap core-course-module-handler core-module-module-info {{module.handlerData.class}}"
[ngClass]="{
'item-media': module.handlerData.icon,
'item-dimmed': module.visible === 0 || module.uservisible === false
@ -84,6 +85,19 @@
detail="false"
>
<ion-label>
<!-- Activity dates. -->
<div *ngIf="showActivityDates && module.dates && module.dates.length" class="core-module-dates">
<p *ngFor="let date of module.dates">
<strong>{{ date.label }}</strong> {{ date.timestamp * 1000 | coreFormatDate:'strftimedatetime' }}
</p>
</div>
<!-- Module completion. -->
<core-course-module-completion *ngIf="module.completiondata" [completion]="module.completiondata"
[moduleName]="module.name" [moduleId]="module.id" [showCompletionConditions]="showCompletionConditions"
[showManualCompletion]="showManualCompletion" (completionChanged)="completionChanged.emit($event)">
</core-course-module-completion>
<core-format-text class="core-module-description" *ngIf="module.description" maxHeight="80" [text]="module.description"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-format-text>

View File

@ -31,7 +31,7 @@
}
}
.core-module-module-description {
.core-module-module-info {
ion-badge {
text-align: start;
}
@ -51,15 +51,15 @@
clear: both;
}
.core-module-main-item + .core-module-module-description ion-label {
.core-module-main-item + .core-module-module-info ion-label {
margin-top: 0px;
}
.core-module-main-item.has-module-description {
.core-module-main-item.has-module-info {
--inner-border-width: 0;
}
.core-module-module-description ion-label {
.core-module-module-info ion-label {
margin-inline-start: 50px;
}

View File

@ -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,
@ -47,6 +47,8 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy {
@Input() module!: CoreCourseModule; // The module to render.
@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.
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('downloadEnabled') set enabled(value: boolean) {
this.downloadEnabled = value;
@ -61,7 +63,7 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy {
// Get current status to decide which icon should be shown.
this.calculateAndShowStatus();
};
}
@Output() completionChanged = new EventEmitter<CoreCourseModuleCompletionData>(); // Notify when module completion changes.
@Output() statusChanged = new EventEmitter<CoreCourseModuleStatusChangedData>(); // Notify when the download status changes.
@ -71,6 +73,9 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy {
spinner?: boolean; // Whether to display a loading spinner.
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;
@ -83,12 +88,20 @@ 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;
}
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.module.completiondata
);
if (this.module.handlerData.showDownloadButton) {
// Listen for changes on this module status, even if download isn't enabled.

View File

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

View File

@ -81,6 +81,8 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy {
protected courseStatusObserver?: CoreEventObserver;
protected syncObserver?: CoreEventObserver;
protected isDestroyed = false;
protected modulesHaveCompletion = false;
protected debouncedUpdateCachedCompletion?: () => void; // Update the cached completion after a certain time.
/**
* Component being initialized.
@ -104,6 +106,21 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy {
CoreCourseFormatDelegate.displayEnableDownload(this.course);
this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
this.debouncedUpdateCachedCompletion = CoreUtils.debounce(() => {
if (this.modulesHaveCompletion) {
CoreUtils.ignoreErrors(CoreCourse.getSections(this.course.id, false, true));
} else {
CoreUtils.ignoreErrors(CoreCourse.getActivitiesCompletionStatus(
this.course.id,
undefined,
undefined,
false,
false,
false,
));
}
}, 30000);
this.initListeners();
await this.loadData(false, true);
@ -254,6 +271,8 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy {
if (sectionWithModules && typeof sectionWithModules.modules[0].completion != 'undefined') {
// The module already has completion (3.6 onwards). Load the offline completion.
this.modulesHaveCompletion = true;
await CoreUtils.ignoreErrors(CoreCourseHelper.loadOfflineCompletion(this.course.id, sections));
} else {
const fetchedData = await CoreUtils.ignoreErrors(
@ -353,6 +372,11 @@ 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));
this.debouncedUpdateCachedCompletion?.();
return;
}

View File

@ -67,6 +67,7 @@ import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreSiteHome } from '@features/sitehome/services/sitehome';
import { CoreNavigator } from '@services/navigator';
import { CoreSiteHomeHomeHandlerService } from '@features/sitehome/services/handlers/sitehome-home';
import { CoreStatusWithWarningsWSResponse } from '@services/ws';
/**
* Prefetch info of a module.
@ -194,11 +195,8 @@ export class CoreCourseHelperProvider {
forCoursePage,
);
if (module.completiondata && module.completion && module.completion > 0) {
module.completiondata.courseId = courseId;
module.completiondata.courseName = courseName;
module.completiondata.tracking = module.completion;
module.completiondata.cmid = module.id;
if (module.completiondata) {
this.calculateModuleCompletionData(module, courseId, courseName);
} else if (completionStatus && typeof completionStatus[module.id] != 'undefined') {
// Should not happen on > 3.6. Check if activity has completions and if it's marked.
const activityStatus = completionStatus[module.id];
@ -223,6 +221,24 @@ export class CoreCourseHelperProvider {
return { hasContent, sections: formattedSections };
}
/**
* Calculate completion data of a module.
*
* @param module Module.
* @param courseId Course ID of the module.
* @param courseName Course name.
*/
calculateModuleCompletionData(module: CoreCourseModule, courseId: number, courseName?: string): void {
if (!module.completiondata || !module.completion) {
return;
}
module.completiondata.courseId = courseId;
module.completiondata.courseName = courseName;
module.completiondata.tracking = module.completion;
module.completiondata.cmid = module.id;
}
/**
* Calculate the status of a section.
*
@ -1176,6 +1192,31 @@ export class CoreCourseHelperProvider {
}
}
/**
* Load offline completion for a certain module.
* This should be used in 3.6 sites or higher, where the course contents already include the completion.
*
* @param courseId The course to get the completion.
* @param mmodule The module.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async loadModuleOfflineCompletion(courseId: number, module: CoreCourseModule, siteId?: string): Promise<void> {
if (!module.completiondata) {
return;
}
const offlineCompletions = await CoreCourseOffline.getCourseManualCompletions(courseId, siteId);
const offlineCompletion = offlineCompletions.find(completion => completion.cmid == module.id);
if (offlineCompletion && offlineCompletion.timecompleted >= module.completiondata.timecompleted * 1000) {
// The module has offline completion. Load it.
module.completiondata.state = offlineCompletion.completed;
module.completiondata.offline = true;
}
}
/**
* Prefetch all the courses in the array.
*
@ -1890,6 +1931,52 @@ export class CoreCourseHelperProvider {
await Promise.all(promises);
}
/**
* Completion clicked.
*
* @param completion The completion.
* @param event The click event.
* @return Promise resolved with the result.
*/
async changeManualCompletion(
completion: CoreCourseModuleCompletionData,
event?: Event,
): Promise<CoreStatusWithWarningsWSResponse | void> {
if (!completion) {
return;
}
if (typeof completion.cmid == 'undefined' || completion.tracking !== 1) {
return;
}
event?.preventDefault();
event?.stopPropagation();
const modal = await CoreDomUtils.showModalLoading();
completion.state = completion.state === 1 ? 0 : 1;
try {
const response = await CoreCourse.markCompletedManually(
completion.cmid,
completion.state === 1,
completion.courseId!,
completion.courseName,
);
if (response.offline) {
completion.offline = true;
}
return response;
} catch (error) {
completion.state = completion.state === 1 ? 0 : 1;
CoreDomUtils.showErrorModalDefault(error, 'core.errorchangecompletion', true);
} finally {
modal.dismiss();
}
}
}
export const CoreCourseHelper = makeSingleton(CoreCourseHelperProvider);

View File

@ -150,9 +150,12 @@ export class CoreCourseProvider {
* @param completion Completion status of the module.
*/
checkModuleCompletion(courseId: number, completion?: CoreCourseModuleCompletionData): void {
if (completion && completion.tracking === 2 && completion.state === 0) {
if (completion && completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && completion.state === 0) {
this.invalidateSections(courseId).finally(() => {
CoreEvents.trigger(CoreEvents.COMPLETION_MODULE_VIEWED, { courseId: courseId });
CoreEvents.trigger(CoreEvents.COMPLETION_MODULE_VIEWED, {
courseId: courseId,
cmId: completion.cmid,
});
});
}
}
@ -969,6 +972,9 @@ export class CoreCourseProvider {
// Ignore errors, shouldn't happen.
}
// Invalidate module now, completion has changed.
await this.invalidateModule(cmId, siteId);
return result;
} catch (error) {
if (CoreUtils.isWebServiceError(error) || !courseId) {
@ -1262,6 +1268,8 @@ export type CoreCourseSummary = {
timeaccess?: number; // @since 3.6. Timeaccess.
showshortname: boolean; // @since 3.6. Showshortname.
coursecategory: string; // @since 3.7. Coursecategory.
showactivitydates: boolean | null; // @since 3.11. Whether the activity dates are shown or not.
showcompletionconditions: boolean | null; // @since 3.11. Whether the activity completion conditions are shown or not.
};
/**
@ -1294,14 +1302,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.
};
@ -1442,6 +1461,10 @@ export type CoreCourseWSModule = {
completion?: number; // Type of completion tracking: 0 means none, 1 manual, 2 automatic.
completiondata?: CoreCourseModuleWSCompletionData; // Module completion data.
contents: CoreCourseModuleContentFile[];
dates?: {
label: string;
timestamp: number;
}[]; // @since 3.11. Activity dates.
contentsinfo?: { // Contents summary information.
filescount: number; // Total number of files.
filessize: number; // Total files size.
@ -1457,8 +1480,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 = {

View File

@ -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<CoreCourseModu
return result ?? defaultValue;
}
/**
* 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 {
return !!this.executeFunctionOnEnabled<boolean>(module.modname, 'manualCompletionAlwaysShown', [module]);
}
}
export const CoreCourseModuleDelegate = makeSingleton(CoreCourseModuleDelegateService);

View File

@ -1264,6 +1264,8 @@ export type CoreEnrolledCourseData = CoreEnrolledCourseBasicData & {
isfavourite?: boolean; // If the user marked this course a favourite.
hidden?: boolean; // If the user hide the course from the dashboard.
overviewfiles?: CoreWSExternalFile[];
showactivitydates?: boolean; // @since 3.11. Whether the activity dates are shown or not.
showcompletionconditions?: boolean; // @since 3.11. Whether the activity completion conditions are shown or not.
};
/**
@ -1281,6 +1283,8 @@ export type CoreCourseBasicSearchedData = CoreCourseBasicData & {
}[];
enrollmentmethods: string[]; // Enrollment methods list.
customfields?: CoreCourseCustomField[]; // Custom fields and associated values.
showactivitydates?: boolean; // @since 3.11. Whether the activity dates are shown or not.
showcompletionconditions?: boolean; // @since 3.11. Whether the activity completion conditions are shown or not.
};
export type CoreCourseSearchedData = CoreCourseBasicSearchedData & {
@ -1342,6 +1346,8 @@ export type CoreCourseGetCoursesData = CoreEnrolledCourseBasicData & {
forcetheme?: string; // Name of the force theme.
courseformatoptions?: CoreCourseFormatOption[]; // Additional options for particular course format.
customfields?: CoreCourseCustomField[]; // Custom fields and associated values.
showactivitydates?: boolean; // @since 3.11. Whether the activity dates are shown or not.
showcompletionconditions?: boolean; // @since 3.11. Whether the activity completion conditions are shown or not.
};
/**

View File

@ -189,4 +189,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;
}
}

View File

@ -848,6 +848,7 @@ export type CoreSitePluginsCourseModuleHandlerData = CoreSitePluginsHandlerCommo
coursepagemethod?: string;
ptrenabled?: boolean;
supportedfeatures?: Record<string, unknown>;
manualcompletionalwaysshown?: boolean;
};
/**

View File

@ -23,5 +23,6 @@
"sendemail": "Email",
"student": "Student",
"teacher": "Non-editing teacher",
"userwithid": "User with ID {{id}}",
"webpage": "Web page"
}
}

View File

@ -21,7 +21,7 @@ import { CoreUtils } from '@services/utils/utils';
import { CoreUserOffline } from './user-offline';
import { CoreLogger } from '@singletons/logger';
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
import { makeSingleton } from '@singletons';
import { makeSingleton, Translate } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws';
import { CoreError } from '@classes/errors/error';
@ -305,6 +305,25 @@ export class CoreUserProvider {
return site.getDb().getRecord(USERS_TABLE_NAME, { id: userId });
}
/**
* Get a user fullname, using a default text if user not found.
*
* @param userId User ID.
* @param courseId Course ID.
* @param siteId Site ID.
* @return Promise resolved with user name.
*/
async getUserFullNameWithDefault(userId: number, courseId?: number, siteId?: string): Promise<string> {
try {
const user = await CoreUser.getProfile(userId, courseId, true, siteId);
return user.fullname;
} catch {
return Translate.instant('core.user.userwithid', { id: userId });
}
}
/**
* Get user profile from WS.
*

View File

@ -18,6 +18,7 @@ import { CoreLogger } from '@singletons/logger';
import { CoreSite, CoreSiteInfoResponse, CoreSitePublicConfigResponse } from '@classes/site';
import { CoreFilepoolComponentFileEventData } from '@services/filepool';
import { CoreNavigationOptions } from '@services/navigator';
import { CoreCourseModuleCompletionData } from '@features/course/services/course-helper';
/**
* Observer instance to stop listening to an event.
@ -45,6 +46,7 @@ export interface CoreEventsData {
[CoreEvents.NOTIFICATION_SOUND_CHANGED]: CoreEventNotificationSoundChangedData;
[CoreEvents.SELECT_COURSE_TAB]: CoreEventSelectCourseTabData;
[CoreEvents.COMPLETION_MODULE_VIEWED]: CoreEventCompletionModuleViewedData;
[CoreEvents.MANUAL_COMPLETION_CHANGED]: CoreEventManualCompletionChangedData;
[CoreEvents.SECTION_STATUS_CHANGED]: CoreEventSectionStatusChangedData;
[CoreEvents.ACTIVITY_DATA_SENT]: CoreEventActivityDataSentData;
[CoreEvents.IAB_LOAD_START]: InAppBrowserEvent;
@ -53,7 +55,7 @@ export interface CoreEventsData {
[CoreEvents.COMPONENT_FILE_ACTION]: CoreFilepoolComponentFileEventData;
[CoreEvents.FILE_SHARED]: CoreEventFileSharedData;
[CoreEvents.APP_LAUNCHED_URL]: CoreEventAppLaunchedData;
};
}
/*
* Service to send and listen to events.
@ -72,6 +74,7 @@ export class CoreEvents {
static readonly SITE_UPDATED = 'site_updated';
static readonly SITE_DELETED = 'site_deleted';
static readonly COMPLETION_MODULE_VIEWED = 'completion_module_viewed';
static readonly MANUAL_COMPLETION_CHANGED = 'manual_completion_changed';
static readonly USER_DELETED = 'user_deleted';
static readonly PACKAGE_STATUS_CHANGED = 'package_status_changed';
static readonly COURSE_STATUS_CHANGED = 'course_status_changed';
@ -330,7 +333,15 @@ export type CoreEventSelectCourseTabData = {
* Data passed to COMPLETION_MODULE_VIEWED event.
*/
export type CoreEventCompletionModuleViewedData = {
courseId?: number;
courseId: number;
cmId?: number;
};
/**
* Data passed to MANUAL_COMPLETION_CHANGED event.
*/
export type CoreEventManualCompletionChangedData = {
completion: CoreCourseModuleCompletionData;
};
/**