From b1469987e27020c4b28ed46a9831b58cde584d4a Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 4 May 2018 10:45:46 +0200 Subject: [PATCH] MOBILE-2345 lesson: Implement user retake page --- .../lesson/pages/user-retake/user-retake.html | 159 ++++++++++++ .../pages/user-retake/user-retake.module.ts | 35 +++ .../lesson/pages/user-retake/user-retake.scss | 8 + .../lesson/pages/user-retake/user-retake.ts | 228 ++++++++++++++++++ src/addon/mod/lesson/providers/helper.ts | 128 +++++++++- .../multichoice/component/multichoice.html | 2 +- 6 files changed, 558 insertions(+), 2 deletions(-) create mode 100644 src/addon/mod/lesson/pages/user-retake/user-retake.html create mode 100644 src/addon/mod/lesson/pages/user-retake/user-retake.module.ts create mode 100644 src/addon/mod/lesson/pages/user-retake/user-retake.scss create mode 100644 src/addon/mod/lesson/pages/user-retake/user-retake.ts diff --git a/src/addon/mod/lesson/pages/user-retake/user-retake.html b/src/addon/mod/lesson/pages/user-retake/user-retake.html new file mode 100644 index 000000000..008f7499c --- /dev/null +++ b/src/addon/mod/lesson/pages/user-retake/user-retake.html @@ -0,0 +1,159 @@ + + + {{ '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 }}

+

{{ retake.userstats.timetakenReadable }}

+
+ +

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

+

{{ retake.userstats.completed * 1000 | coreFormatDate:"dfmediumdate" }}

+
+
+ + + + {{ '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].value }}

+ + + +
+ + + + + +

+
+ +

{{answer[0].value}}

+ + + +
+
+
+ + + +

+ + + +
+
+ + + +

+ + + +
+
+ + +

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

+

+
+ +

{{page.answerdata.score}}

+
+
+
+
+
+
+
diff --git a/src/addon/mod/lesson/pages/user-retake/user-retake.module.ts b/src/addon/mod/lesson/pages/user-retake/user-retake.module.ts new file mode 100644 index 000000000..82f5f106e --- /dev/null +++ b/src/addon/mod/lesson/pages/user-retake/user-retake.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { AddonModLessonUserRetakePage } from './user-retake'; + +@NgModule({ + declarations: [ + AddonModLessonUserRetakePage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + IonicPageModule.forChild(AddonModLessonUserRetakePage), + TranslateModule.forChild() + ], +}) +export class AddonModLessonUserRetakePageModule {} diff --git a/src/addon/mod/lesson/pages/user-retake/user-retake.scss b/src/addon/mod/lesson/pages/user-retake/user-retake.scss new file mode 100644 index 000000000..ed414081f --- /dev/null +++ b/src/addon/mod/lesson/pages/user-retake/user-retake.scss @@ -0,0 +1,8 @@ +page-addon-mod-lesson-user-retake { + .addon-mod_lesson-highlight { + background: $blue-light; + .label, .label p { + color: $blue-dark; + } + } +} diff --git a/src/addon/mod/lesson/pages/user-retake/user-retake.ts b/src/addon/mod/lesson/pages/user-retake/user-retake.ts new file mode 100644 index 000000000..8a69c73de --- /dev/null +++ b/src/addon/mod/lesson/pages/user-retake/user-retake.ts @@ -0,0 +1,228 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPage, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModLessonProvider } from '../../providers/lesson'; +import { AddonModLessonHelperProvider } from '../../providers/helper'; + +/** + * Page that displays a retake made by a certain user. + */ +@IonicPage({ segment: 'addon-mod-lesson-user-retake' }) +@Component({ + selector: 'page-addon-mod-lesson-user-retake', + templateUrl: 'user-retake.html', +}) +export class AddonModLessonUserRetakePage implements OnInit { + + component = AddonModLessonProvider.COMPONENT; + lesson: any; // The lesson the retake belongs to. + courseId: number; // Course ID the lesson belongs to. + selectedRetake: number; // The retake to see. + student: any; // Data about the student and his retakes. + retake: any; // Data about the retake. + loaded: boolean; // Whether the data has been loaded. + + 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. + + constructor(navParams: NavParams, sitesProvider: CoreSitesProvider, protected textUtils: CoreTextUtilsProvider, + protected translate: TranslateService, protected domUtils: CoreDomUtilsProvider, + protected userProvider: CoreUserProvider, protected timeUtils: CoreTimeUtilsProvider, + protected lessonProvider: AddonModLessonProvider, protected lessonHelper: AddonModLessonHelperProvider) { + + this.lessonId = navParams.get('lessonId'); + this.courseId = navParams.get('courseId'); + this.userId = navParams.get('userId') || sitesProvider.getCurrentSiteUserId(); + this.retakeNumber = navParams.get('retake'); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + // Fetch the data. + this.fetchData().finally(() => { + this.loaded = true; + }); + } + + /** + * Change the retake displayed. + * + * @param {number} retakeNumber The new retake number. + */ + changeRetake(retakeNumber: number): void { + this.loaded = false; + + this.setRetake(retakeNumber).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting attempt.'); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Pull to refresh. + * + * @param {any} refresher Refresher. + */ + doRefresh(refresher: any): void { + this.refreshData().finally(() => { + refresher.complete(); + }); + } + + /** + * Get lesson and retake data. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchData(): Promise { + return this.lessonProvider.getLessonById(this.courseId, this.lessonId).then((lessonData) => { + this.lesson = lessonData; + + // Get the retakes overview for all participants. + return this.lessonProvider.getRetakesOverview(this.lesson.id); + }).then((data) => { + // Search the student. + let student; + + if (data && data.students) { + for (let i = 0; i < data.students.length; i++) { + if (data.students[i].id == this.userId) { + student = data.students[i]; + break; + } + } + } + + if (!student) { + // Student not found. + return Promise.reject(this.translate.instant('addon.mod_lesson.cannotfinduser')); + } + + if (!student.attempts || !student.attempts.length) { + // No retakes. + return Promise.reject(this.translate.instant('addon.mod_lesson.cannotfindattempt')); + } + + student.bestgrade = this.textUtils.roundToDecimals(student.bestgrade, 2); + student.attempts.forEach((retake) => { + if (this.retakeNumber == retake.try) { + // The retake specified as parameter exists. Use it. + this.selectedRetake = this.retakeNumber; + } + + retake.label = this.lessonHelper.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. + return this.userProvider.getProfile(student.id, this.courseId, true).then((user) => { + student.profileimageurl = user.profileimageurl; + + return student; + }).catch(() => { + // Error getting profile, resolve promise without adding any extra data. + return student; + }); + }).then((student) => { + this.student = student; + + return this.setRetake(this.selectedRetake); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting data.', true); + }); + } + + /** + * Refreshes data. + * + * @return {Promise} Promise resolved when done. + */ + protected refreshData(): Promise { + const promises = []; + + promises.push(this.lessonProvider.invalidateLessonData(this.courseId)); + if (this.lesson) { + promises.push(this.lessonProvider.invalidateRetakesOverview(this.lesson.id)); + promises.push(this.lessonProvider.invalidateUserRetakesForUser(this.lesson.id, this.userId)); + } + + return Promise.all(promises).catch(() => { + // Ignore errors. + }).then(() => { + return this.fetchData(); + }); + } + + /** + * Set the retake to view and load its data. + * + * @param {number}retakeNumber Retake number to set. + * @return {Promise} Promise resolved when done. + */ + protected setRetake(retakeNumber: number): Promise { + this.selectedRetake = retakeNumber; + + return this.lessonProvider.getUserRetake(this.lessonId, retakeNumber, this.userId).then((data) => { + + if (data && data.completed != -1) { + // Completed. + data.userstats.grade = this.textUtils.roundToDecimals(data.userstats.grade, 2); + data.userstats.timetakenReadable = this.timeUtils.formatTime(data.userstats.timetotake); + } + + if (data && data.answerpages) { + // Format pages data. + data.answerpages.forEach((page) => { + if (this.lessonProvider.answerPageIsContent(page)) { + page.isContent = true; + + if (page.answerdata && page.answerdata.answers) { + page.answerdata.answers.forEach((answer) => { + // Content pages only have 1 valid field in the answer array. + answer[0] = this.lessonHelper.getContentPageAnswerDataFromHtml(answer[0]); + }); + } + } else if (this.lessonProvider.answerPageIsQuestion(page)) { + page.isQuestion = true; + + if (page.answerdata && page.answerdata.answers) { + page.answerdata.answers.forEach((answer) => { + // Only the first field of the answer array requires to be parsed. + answer[0] = this.lessonHelper.getQuestionPageAnswerDataFromHtml(answer[0]); + }); + } + } + }); + } + + this.retake = data; + }); + } +} diff --git a/src/addon/mod/lesson/providers/helper.ts b/src/addon/mod/lesson/providers/helper.ts index 231ed5b3d..617462a49 100644 --- a/src/addon/mod/lesson/providers/helper.ts +++ b/src/addon/mod/lesson/providers/helper.ts @@ -17,7 +17,9 @@ import { FormBuilder, FormGroup } from '@angular/forms'; import { TranslateService } from '@ngx-translate/core'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { AddonModLessonProvider } from './lesson'; +import * as moment from 'moment'; /** * Helper service that provides some features for quiz. @@ -28,7 +30,7 @@ export class AddonModLessonHelperProvider { protected div = document.createElement('div'); // A div element to search in HTML code. constructor(private domUtils: CoreDomUtilsProvider, private fb: FormBuilder, private translate: TranslateService, - private textUtils: CoreTextUtilsProvider) { } + private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider) { } /** * Given the HTML of next activity link, format it to extract the href and the text. @@ -55,6 +57,33 @@ export class AddonModLessonHelperProvider { }; } + /** + * Given the HTML of an answer from a content page, extract the data to render the answer. + * + * @param {String} html Answer's HTML. + * @return {{buttonText: string, content: string}} Data to render the answer. + */ + getContentPageAnswerDataFromHtml(html: string): {buttonText: string, content: string} { + const data = { + buttonText: '', + content: '' + }; + + // Search the input button. + this.div.innerHTML = html; + const button = this.div.querySelector('input[type="button"]'); + + if (button) { + // Extract the button content and remove it from the HTML. + data.buttonText = button.value; + button.remove(); + } + + data.content = this.div.innerHTML.trim(); + + return data; + } + /** * Get the buttons to change pages. * @@ -321,6 +350,103 @@ export class AddonModLessonHelperProvider { return question; } + /** + * Given the HTML of an answer from a question page, extract the data to render the answer. + * + * @param {string} html Answer's HTML. + * @return {any} Object with the data to render the answer. If the answer doesn't require any parsing, return a string with + * the HTML. + */ + getQuestionPageAnswerDataFromHtml(html: string): any { + const data: any = {}; + + this.div.innerHTML = html; + + // Check if it has a checkbox. + let input = this.div.querySelector('input[type="checkbox"][name*="answer"]'); + + if (input) { + // Truefalse or multichoice. + data.isCheckbox = true; + data.checked = !!input.checked; + data.name = input.name; + data.highlight = !!this.div.querySelector('.highlight'); + + input.remove(); + data.content = this.div.innerHTML.trim(); + + return data; + } + + // Check if it has an input text or number. + input = this.div.querySelector('input[type="number"],input[type="text"]'); + if (input) { + // Short answer or numeric. + data.isText = true; + data.value = input.value; + + return data; + } + + // Check if it has a select. + const select = this.div.querySelector('select'); + if (select && select.options) { + // Matching. + const selectedOption = select.options[select.selectedIndex]; + data.isSelect = true; + data.id = select.id; + if (selectedOption) { + data.value = selectedOption.value; + } else { + data.value = ''; + } + + select.remove(); + data.content = this.div.innerHTML.trim(); + + return data; + } + + // The answer doesn't need any parsing, return the HTML as it is. + return html; + } + + /** + * Get a label to identify a retake (lesson attempt). + * + * @param {any} retake Retake object. + * @param {boolean} [includeDuration] Whether to include the duration of the retake. + * @return {string} Retake label. + */ + getRetakeLabel(retake: any, includeDuration?: boolean): string { + const data = { + retake: retake.try + 1, + grade: '', + timestart: '', + duration: '' + }, + hasGrade = retake.grade != null; + + if (hasGrade || retake.end) { + // Retake finished with or without grade (if the lesson only has content pages, it has no grade). + if (hasGrade) { + data.grade = this.translate.instant('core.percentagenumber', {$a: retake.grade}); + } + data.timestart = moment(retake.timestart * 1000).format('LLL'); + if (includeDuration) { + data.duration = this.timeUtils.formatTime(retake.timeend - retake.timestart); + } + } else { + // The user has not completed the retake. + data.grade = this.translate.instant('addon.mod_lesson.notcompleted'); + if (retake.timestart) { + data.timestart = moment(retake.timestart * 1000).format('LLL'); + } + } + + return this.translate.instant('addon.mod_lesson.retakelabel' + (includeDuration ? 'full' : 'short'), data); + } + /** * Prepare the question data to be sent to server. * diff --git a/src/addon/qtype/multichoice/component/multichoice.html b/src/addon/qtype/multichoice/component/multichoice.html index a7dd5baf2..a39aaaa2e 100644 --- a/src/addon/qtype/multichoice/component/multichoice.html +++ b/src/addon/qtype/multichoice/component/multichoice.html @@ -12,7 +12,7 @@

- +