From e04c19596f11ef35d54bce2114ad012509f0707f Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 3 Feb 2021 15:59:12 +0100 Subject: [PATCH] MOBILE-3648 lesson: Implement user-retake page --- .../index/addon-mod-lesson-index.html | 3 +- .../mod/lesson/components/index/index.ts | 22 +- src/addons/mod/lesson/lesson-lazy.module.ts | 6 +- src/addons/mod/lesson/pages/player/player.ts | 14 +- .../lesson/pages/user-retake/user-retake.html | 235 +++++++++++++++ .../pages/user-retake/user-retake.module.ts | 46 +++ .../lesson/pages/user-retake/user-retake.scss | 17 ++ .../lesson/pages/user-retake/user-retake.ts | 275 ++++++++++++++++++ .../mod/lesson/services/handlers/module.ts | 2 +- src/addons/mod/lesson/services/lesson.ts | 17 +- src/theme/theme.light.scss | 2 + 11 files changed, 612 insertions(+), 27 deletions(-) create mode 100644 src/addons/mod/lesson/pages/user-retake/user-retake.html create mode 100644 src/addons/mod/lesson/pages/user-retake/user-retake.module.ts create mode 100644 src/addons/mod/lesson/pages/user-retake/user-retake.scss create mode 100644 src/addons/mod/lesson/pages/user-retake/user-retake.ts diff --git a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html index 59d07b5a6..ee5cadb64 100644 --- a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html +++ b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html @@ -300,7 +300,8 @@ {{ 'addon.mod_lesson.overview' | translate }} - + diff --git a/src/addons/mod/lesson/components/index/index.ts b/src/addons/mod/lesson/components/index/index.ts index b6afb0bb2..5b670b6a0 100644 --- a/src/addons/mod/lesson/components/index/index.ts +++ b/src/addons/mod/lesson/components/index/index.ts @@ -424,10 +424,8 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo pageId = continueLast ? this.accessInfo.lastpageseen : this.accessInfo.firstpageid; } - CoreNavigator.instance.navigate('../player', { + await CoreNavigator.instance.navigate(`../player/${this.courseId}/${this.lesson.id}`, { params: { - courseId: this.courseId, - lessonId: this.lesson.id, pageId: pageId, password: this.password, }, @@ -474,10 +472,8 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo return; } - CoreNavigator.instance.navigate('../player', { + CoreNavigator.instance.navigate(`../player/${this.courseId}/${this.lesson.id}`, { params: { - courseId: this.courseId, - lessonId: this.lesson.id, pageId: this.retakeToReview.pageid, password: this.password, review: true, @@ -692,6 +688,20 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo } } + /** + * Open a certain user retake. + * + * @param userId User ID to view. + * @return Promise resolved when done. + */ + async openRetake(userId: number): Promise { + await CoreNavigator.instance.navigate(`../user-retake/${this.courseId}/${this.lesson!.id}`, { + params: { + userId, + }, + }); + } + /** * Component being destroyed. */ diff --git a/src/addons/mod/lesson/lesson-lazy.module.ts b/src/addons/mod/lesson/lesson-lazy.module.ts index d58df67b4..4b9bb7f6f 100644 --- a/src/addons/mod/lesson/lesson-lazy.module.ts +++ b/src/addons/mod/lesson/lesson-lazy.module.ts @@ -26,9 +26,13 @@ const routes: Routes = [ loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModLessonIndexPageModule), }, { - path: 'player', + path: 'player/:courseId/:lessonId', loadChildren: () => import('./pages/player/player.module').then( m => m.AddonModLessonPlayerPageModule), }, + { + path: 'user-retake/:courseId/:lessonId', + loadChildren: () => import('./pages/user-retake/user-retake.module').then( m => m.AddonModLessonUserRetakePageModule), + }, ]; @NgModule({ diff --git a/src/addons/mod/lesson/pages/player/player.ts b/src/addons/mod/lesson/pages/player/player.ts index 3a256c6de..42d0d7a2a 100644 --- a/src/addons/mod/lesson/pages/player/player.ts +++ b/src/addons/mod/lesson/pages/player/player.ts @@ -118,18 +118,8 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy, CanLeave { * Component being initialized. */ async ngOnInit(): Promise { - const lessonId = CoreNavigator.instance.getRouteNumberParam('lessonId'); - const courseId = CoreNavigator.instance.getRouteNumberParam('courseId'); - if (!lessonId || !courseId) { - CoreDomUtils.instance.showErrorModal('No lesson ID or course ID supplied.'); - this.forceLeave = true; - CoreNavigator.instance.back(); - - return; - } - - this.lessonId = lessonId; - this.courseId = courseId; + this.lessonId = CoreNavigator.instance.getRouteNumberParam('lessonId')!; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; this.password = CoreNavigator.instance.getRouteParam('password'); this.review = !!CoreNavigator.instance.getRouteBooleanParam('review'); this.currentPage = CoreNavigator.instance.getRouteNumberParam('pageId'); diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.html b/src/addons/mod/lesson/pages/user-retake/user-retake.html new file mode 100644 index 000000000..9649820af --- /dev/null +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.html @@ -0,0 +1,235 @@ + + + + + + {{ 'addon.mod_lesson.detailedstats' | translate }} + + + + + + + + +
+ + + + + +

{{student.fullname}}

+ +
+
+ + + + {{ 'addon.mod_lesson.attemptheader' | translate }} + + + {{retake.label}} + + + + + + + + + +

{{ 'addon.mod_lesson.grade' | translate }}

+

{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}

+
+ + +

{{ 'addon.mod_lesson.rawgrade' | translate }}

+

{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}

+
+
+
+ + +

{{ 'addon.mod_lesson.timetaken' | translate }}

+

{{ timeTakenReadable }}

+
+
+ + +

{{ 'addon.mod_lesson.completed' | translate }}

+

{{ retake.userstats.completed * 1000 | coreFormatDate }}

+
+
+
+ + + + {{ 'addon.mod_lesson.notcompleted' | translate }} + + + + + + + + {{page.qtype}}: {{page.title}} + + + +

{{ 'addon.mod_lesson.question' | translate }}

+

+ + +

+
+
+ + +

{{ 'addon.mod_lesson.answer' | translate }}

+
+
+ + +

{{ 'addon.mod_lesson.didnotanswerquestion' | translate }}

+
+
+
+
+ + + + + {{ answer[0].buttonText }} + + +

+
+
+
+ +
+ + + + + +

+ + +

+ + + + +
+ + +
+ + + + +

{{ answer[0].value }}

+ + + + +
+
+ + + + + +

+ + +

+
+ +

{{answer[0].value}}

+ + + + +
+
+
+ + + + +

+ + +

+ + + + +
+
+
+ + + + +

+ + +

+ + + + +
+
+
+ + + +

{{ 'addon.mod_lesson.response' | translate }}

+

+ + +

+
+
+ +

{{page.answerdata.score}}

+
+
+
+
+
+
+
diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.module.ts b/src/addons/mod/lesson/pages/user-retake/user-retake.module.ts new file mode 100644 index 000000000..68e773092 --- /dev/null +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.module.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModLessonUserRetakePage } from './user-retake'; + +const routes: Routes = [ + { + path: '', + component: AddonModLessonUserRetakePage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + FormsModule, + CoreSharedModule, + ], + declarations: [ + AddonModLessonUserRetakePage, + ], + exports: [RouterModule], +}) +export class AddonModLessonUserRetakePageModule {} diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.scss b/src/addons/mod/lesson/pages/user-retake/user-retake.scss new file mode 100644 index 000000000..87b03856c --- /dev/null +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.scss @@ -0,0 +1,17 @@ +:host { + .button-disabled { + opacity: 0.4; + } + + .addon-mod_lesson-highlight { + --background: var(--blue-light); + + ion-label, ion-label p { + color: var(--blue-dark); + } + } + + .item-interactive-disabled ion-label { + opacity: 0.5; + } +} diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.ts b/src/addons/mod/lesson/pages/user-retake/user-retake.ts new file mode 100644 index 000000000..31c736f73 --- /dev/null +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.ts @@ -0,0 +1,275 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit } from '@angular/core'; +import { IonRefresher } from '@ionic/angular'; + +import { CoreError } from '@classes/errors/error'; +import { CoreUser } from '@features/user/services/user'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { + AddonModLesson, + AddonModLessonAttemptsOverviewsAttemptWSData, + AddonModLessonAttemptsOverviewsStudentWSData, + AddonModLessonGetUserAttemptWSResponse, + AddonModLessonLessonWSData, + AddonModLessonProvider, + AddonModLessonUserAttemptAnswerData, + AddonModLessonUserAttemptAnswerPageWSData, +} from '../../services/lesson'; +import { AddonModLessonAnswerData, AddonModLessonHelper } from '../../services/lesson-helper'; +import { CoreTimeUtils } from '@services/utils/time'; + +/** + * Page that displays a retake made by a certain user. + */ +@Component({ + selector: 'page-addon-mod-lesson-user-retake', + templateUrl: 'user-retake.html', + styleUrls: ['user-retake.scss'], +}) +export class AddonModLessonUserRetakePage implements OnInit { + + component = AddonModLessonProvider.COMPONENT; + lesson?: AddonModLessonLessonWSData; // The lesson the retake belongs to. + courseId!: number; // Course ID the lesson belongs to. + selectedRetake?: number; // The retake to see. + student?: StudentData; // Data about the student and his retakes. + retake?: RetakeToDisplay; // Data about the retake. + loaded?: boolean; // Whether the data has been loaded. + timeTakenReadable?: string; // Time taken in a readable format. + + protected lessonId!: number; // The lesson ID the retake belongs to. + protected userId?: number; // User ID to see the retakes. + protected retakeNumber?: number; // Number of the initial retake to see. + protected previousSelectedRetake?: number; // To be able to detect the previous selected retake when it has changed. + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.lessonId = CoreNavigator.instance.getRouteNumberParam('lessonId')!; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSiteUserId(); + this.retakeNumber = CoreNavigator.instance.getRouteNumberParam('retake'); + + // Fetch the data. + this.fetchData().finally(() => { + this.loaded = true; + }); + } + + /** + * Change the retake displayed. + * + * @param retakeNumber The new retake number. + */ + async changeRetake(retakeNumber: number): Promise { + this.loaded = false; + + try { + await this.setRetake(retakeNumber); + } catch (error) { + this.selectedRetake = this.previousSelectedRetake; + CoreDomUtils.instance.showErrorModal(CoreUtils.instance.addDataNotDownloadedError(error, 'Error getting attempt.')); + } finally { + this.loaded = true; + } + } + + /** + * Pull to refresh. + * + * @param refresher Refresher. + */ + doRefresh(refresher: CustomEvent): void { + this.refreshData().finally(() => { + refresher?.detail.complete(); + }); + } + + /** + * Get lesson and retake data. + * + * @return Promise resolved when done. + */ + protected async fetchData(): Promise { + try { + this.lesson = await AddonModLesson.instance.getLessonById(this.courseId, this.lessonId); + + // Get the retakes overview for all participants. + const data = await AddonModLesson.instance.getRetakesOverview(this.lesson.id, { + cmId: this.lesson.coursemodule, + }); + + // Search the student. + const student: StudentData | undefined = data?.students?.find(student => student.id == this.userId); + if (!student) { + // Student not found. + throw new CoreError(Translate.instance.instant('addon.mod_lesson.cannotfinduser')); + } + + if (!student.attempts.length) { + // No retakes. + throw new CoreError(Translate.instance.instant('addon.mod_lesson.cannotfindattempt')); + } + + student.bestgrade = CoreTextUtils.instance.roundToDecimals(student.bestgrade, 2); + student.attempts.forEach((retake) => { + if (!this.selectedRetake && this.retakeNumber == retake.try) { + // The retake specified as parameter exists. Use it. + this.selectedRetake = this.retakeNumber; + } + + retake.label = AddonModLessonHelper.instance.getRetakeLabel(retake); + }); + + if (!this.selectedRetake) { + // Retake number not specified or not valid, use the last retake. + this.selectedRetake = student.attempts[student.attempts.length - 1].try; + } + + // Get the profile image of the user. + const user = await CoreUtils.instance.ignoreErrors(CoreUser.instance.getProfile(student.id, this.courseId, true)); + + this.student = student; + this.student.profileimageurl = user?.profileimageurl; + + await this.setRetake(this.selectedRetake); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting data.', true); + } + } + + /** + * Refreshes data. + * + * @return Promise resolved when done. + */ + protected async refreshData(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModLesson.instance.invalidateLessonData(this.courseId)); + if (this.lesson) { + promises.push(AddonModLesson.instance.invalidateRetakesOverview(this.lesson.id)); + promises.push(AddonModLesson.instance.invalidateUserRetakesForUser(this.lesson.id, this.userId)); + } + + await CoreUtils.instance.ignoreErrors(Promise.all(promises)); + + await this.fetchData(); + } + + /** + * Set the retake to view and load its data. + * + * @param retakeNumber Retake number to set. + * @return Promise resolved when done. + */ + protected async setRetake(retakeNumber: number): Promise { + this.selectedRetake = retakeNumber; + + const retakeData = await AddonModLesson.instance.getUserRetake(this.lessonId, retakeNumber, { + cmId: this.lesson!.coursemodule, + userId: this.userId, + }); + + this.retake = this.formatRetake(retakeData); + this.previousSelectedRetake = this.selectedRetake; + } + + /** + * Format retake data, adding some calculated data. + * + * @param data Retake data. + * @return Formatted data. + */ + protected formatRetake(retakeData: AddonModLessonGetUserAttemptWSResponse): RetakeToDisplay { + const formattedData = retakeData; + + if (formattedData.userstats.gradeinfo) { + // Completed. + formattedData.userstats.grade = CoreTextUtils.instance.roundToDecimals(formattedData.userstats.grade, 2); + this.timeTakenReadable = CoreTimeUtils.instance.formatTime(formattedData.userstats.timetotake); + } + + // Format pages data. + formattedData.answerpages.forEach((page) => { + if (AddonModLesson.instance.answerPageIsContent(page)) { + page.isContent = true; + + if (page.answerdata?.answers) { + page.answerdata.answers.forEach((answer) => { + // Content pages only have 1 valid field in the answer array. + answer[0] = AddonModLessonHelper.instance.getContentPageAnswerDataFromHtml(answer[0]); + }); + } + } else if (AddonModLesson.instance.answerPageIsQuestion(page)) { + page.isQuestion = true; + + if (page.answerdata?.answers) { + page.answerdata.answers.forEach((answer) => { + // Only the first field of the answer array requires to be parsed. + answer[0] = AddonModLessonHelper.instance.getQuestionPageAnswerDataFromHtml(answer[0]); + }); + } + } + }); + + return formattedData; + } + +} + +/** + * Student data with some calculated data. + */ +type StudentData = Omit & { + profileimageurl?: string; + attempts: AttemptWithLabel[]; +}; + +/** + * Student attempt with a calculated label. + */ +type AttemptWithLabel = AddonModLessonAttemptsOverviewsAttemptWSData & { + label?: string; +}; +/** + * Retake with calculated data. + */ +type RetakeToDisplay = Omit & { + answerpages: AnswerPage[]; +}; + +/** + * Answer page with calculated data. + */ +type AnswerPage = Omit & { + isContent?: boolean; + isQuestion?: boolean; + answerdata?: AnswerData; +}; + +/** + * Answer data with calculated data. + */ +type AnswerData = Omit & { + answers?: (string[] | AddonModLessonAnswerData)[]; // User answers. +}; diff --git a/src/addons/mod/lesson/services/handlers/module.ts b/src/addons/mod/lesson/services/handlers/module.ts index e32e1af54..008da58cf 100644 --- a/src/addons/mod/lesson/services/handlers/module.ts +++ b/src/addons/mod/lesson/services/handlers/module.ts @@ -30,7 +30,7 @@ import { makeSingleton } from '@singletons'; @Injectable({ providedIn: 'root' }) export class AddonModLessonModuleHandlerService implements CoreCourseModuleHandler { - static readonly PAGE_NAME = 'lesson'; + static readonly PAGE_NAME = 'mod_lesson'; name = 'AddonModLesson'; modName = 'lesson'; diff --git a/src/addons/mod/lesson/services/lesson.ts b/src/addons/mod/lesson/services/lesson.ts index 315778b98..d6d787921 100644 --- a/src/addons/mod/lesson/services/lesson.ts +++ b/src/addons/mod/lesson/services/lesson.ts @@ -4070,12 +4070,17 @@ export type AddonModLessonUserAttemptAnswerPageWSData = { contents: string; // Page contents. qtype: string; // Identifies the page type of this page. grayout: number; // If is required to apply a grayout. - answerdata?: { - score: string; // The score (text version). - response: string; // The response text. - responseformat: number; // Response. format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). - answers?: string[][]; // User answers. - }; // Answer data (empty in content pages created in Moodle 1.x). + answerdata?: AddonModLessonUserAttemptAnswerData; // Answer data (empty in content pages created in Moodle 1.x). +}; + +/** + * Answer data of a user attempt answer page. + */ +export type AddonModLessonUserAttemptAnswerData = { + score: string; // The score (text version). + response: string; // The response text. + responseformat: number; // Response. format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + answers?: string[][]; // User answers. }; /** diff --git a/src/theme/theme.light.scss b/src/theme/theme.light.scss index 2539c4a8a..770cc7f96 100644 --- a/src/theme/theme.light.scss +++ b/src/theme/theme.light.scss @@ -17,6 +17,8 @@ --white: #{$white}; --blue: #{$blue}; + --blue-dark: #{$blue-dark}; + --blue-light: #{$blue-light}; --turquoise: #{$turquoise}; --green: #{$green}; --red: #{$red};