From 0b50cdfc2135a70c6c1f0ea066af86bf8d3561de Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 5 Apr 2018 09:21:19 +0200 Subject: [PATCH] MOBILE-2348 quiz: Implement review page --- .../navigation-modal/navigation-modal.html | 12 +- .../navigation-modal/navigation-modal.ts | 3 + src/addon/mod/quiz/pages/player/player.ts | 15 +- src/addon/mod/quiz/pages/review/review.html | 102 ++++++ .../mod/quiz/pages/review/review.module.ts | 37 +++ src/addon/mod/quiz/pages/review/review.scss | 11 + src/addon/mod/quiz/pages/review/review.ts | 295 ++++++++++++++++++ .../multichoice/component/multichoice.html | 9 +- src/app/app.scss | 78 +++++ src/core/question/providers/helper.ts | 12 + src/theme/variables.scss | 13 + 11 files changed, 568 insertions(+), 19 deletions(-) create mode 100644 src/addon/mod/quiz/pages/review/review.html create mode 100644 src/addon/mod/quiz/pages/review/review.module.ts create mode 100644 src/addon/mod/quiz/pages/review/review.scss create mode 100644 src/addon/mod/quiz/pages/review/review.ts diff --git a/src/addon/mod/quiz/pages/navigation-modal/navigation-modal.html b/src/addon/mod/quiz/pages/navigation-modal/navigation-modal.html index c618530f1..c7c17aefd 100644 --- a/src/addon/mod/quiz/pages/navigation-modal/navigation-modal.html +++ b/src/addon/mod/quiz/pages/navigation-modal/navigation-modal.html @@ -12,12 +12,12 @@ diff --git a/src/addon/mod/quiz/pages/navigation-modal/navigation-modal.ts b/src/addon/mod/quiz/pages/navigation-modal/navigation-modal.ts index 1d09cd7ce..a01013fed 100644 --- a/src/addon/mod/quiz/pages/navigation-modal/navigation-modal.ts +++ b/src/addon/mod/quiz/pages/navigation-modal/navigation-modal.ts @@ -34,7 +34,10 @@ export class AddonModQuizNavigationModalPage { */ pageInstance: any; + isReview: boolean; // Whether the user is reviewing the attempt. + constructor(params: NavParams, protected viewCtrl: ViewController) { + this.isReview = !!params.get('isReview'); this.pageInstance = params.get('page'); } diff --git a/src/addon/mod/quiz/pages/player/player.ts b/src/addon/mod/quiz/pages/player/player.ts index 39cceb586..9224c089f 100644 --- a/src/addon/mod/quiz/pages/player/player.ts +++ b/src/addon/mod/quiz/pages/player/player.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit, OnDestroy, ViewChild, ElementRef, ChangeDetectorRef } from '@angular/core'; +import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef } from '@angular/core'; import { IonicPage, NavParams, Content, PopoverController, ModalController, Modal, NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreEventsProvider } from '@providers/events'; @@ -45,7 +45,6 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { component = AddonModQuizProvider.COMPONENT; // Component to link the files to. loaded: boolean; // Whether data has been loaded. quizAborted: boolean; // Whether the quiz was aborted due to an error. - preflightData: any = {}; // Preflight data to attempt the quiz. offline: boolean; // Whether the quiz is being attempted in offline mode. navigation: any[]; // List of questions to navigate them. questions: any[]; // Questions of the current page. @@ -57,11 +56,11 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { preventSubmitMessages: string[]; // List of messages explaining why the quiz cannot be submitted. endTime: number; // The time when the attempt must be finished. autoSaveError: boolean; // Whether there's been an error in auto-save. + navigationModal: Modal; // Modal to navigate through the questions. - protected element: HTMLElement; // Host element of the page. protected courseId: number; // The course ID the quiz belongs to. protected quizId: number; // Quiz ID to attempt. - protected isTimed: boolean; // Whether the quiz has a time limit. + protected preflightData: any = {}; // Preflight data to attempt the quiz. protected quizAccessInfo: any; // Quiz access information. protected attemptAccessInfo: any; // Attempt access info. protected lastAttempt: any; // Last user attempt before a new one is created (if needed). @@ -70,16 +69,15 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { protected timeUpCalled: boolean; // Whether the time up function has been called. protected autoSave: AddonModQuizAutoSave; // Class to auto-save answers every certain time. protected autoSaveErrorSubscription: Subscription; // To be notified when an error happens in auto-save. - protected navigationModal: Modal; // Modal to navigate through the questions. protected forceLeave = false; // If true, don't perform any check when leaving the view. - constructor(navParams: NavParams, element: ElementRef, logger: CoreLoggerProvider, protected translate: TranslateService, + constructor(navParams: NavParams, logger: CoreLoggerProvider, protected translate: TranslateService, protected eventsProvider: CoreEventsProvider, protected sitesProvider: CoreSitesProvider, protected syncProvider: CoreSyncProvider, protected domUtils: CoreDomUtilsProvider, popoverCtrl: PopoverController, protected timeUtils: CoreTimeUtilsProvider, protected quizProvider: AddonModQuizProvider, protected quizHelper: AddonModQuizHelperProvider, protected quizSync: AddonModQuizSyncProvider, protected questionHelper: CoreQuestionHelperProvider, protected cdr: ChangeDetectorRef, - protected modalCtrl: ModalController, protected navCtrl: NavController) { + modalCtrl: ModalController, protected navCtrl: NavController) { this.quizId = navParams.get('quizId'); this.courseId = navParams.get('courseId'); @@ -93,7 +91,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { logger, popoverCtrl, questionHelper, quizProvider); // Create the navigation modal. - this.navigationModal = this.modalCtrl.create('AddonModQuizNavigationModalPage', { + this.navigationModal = modalCtrl.create('AddonModQuizNavigationModalPage', { page: this }); } @@ -288,7 +286,6 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { this.offline = offlineMode; if (this.quiz.timelimit > 0) { - this.isTimed = true; this.quiz.readableTimeLimit = this.timeUtils.formatTime(this.quiz.timelimit); } diff --git a/src/addon/mod/quiz/pages/review/review.html b/src/addon/mod/quiz/pages/review/review.html new file mode 100644 index 000000000..6714b5e88 --- /dev/null +++ b/src/addon/mod/quiz/pages/review/review.html @@ -0,0 +1,102 @@ + + + {{ 'addon.mod_quiz.review' | translate }} + + + + + + + + + + + + + + + +

{{ 'addon.mod_quiz.reviewofpreview' | translate }}

+

{{ 'addon.mod_quiz.reviewofattempt' | translate:{$a: attempt.attempt} }}

+
+ + +

{{ 'addon.mod_quiz.startedon' | translate }}

+

{{ attempt.timestart * 1000 | coreFormatDate:"dfmediumdate" }}

+
+ +

{{ 'addon.mod_quiz.attemptstate' | translate }}

+

{{ attempt.readableState }}

+
+ +

{{ 'addon.mod_quiz.completedon' | translate }}

+

{{ attempt.timefinish * 1000 | coreFormatDate:"dfmediumdate" }}

+
+ +

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

+

{{ attempt.timeTaken }}

+
+ +

{{ 'addon.mod_quiz.overdue' | translate }}

+

{{ attempt.overTime }}

+
+ +

{{ 'addon.mod_quiz.marks' | translate }}

+

+
+ +

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

+

{{ attempt.readableGrade }}

+
+ +

{{ data.title }}

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

{{ 'core.question.questionno' | translate:{$a: question.number} }}

+

{{ 'core.question.information' | translate }}

+ +

{{question.status}}

+

+
+
+ + +
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + diff --git a/src/addon/mod/quiz/pages/review/review.module.ts b/src/addon/mod/quiz/pages/review/review.module.ts new file mode 100644 index 000000000..a0d03f60f --- /dev/null +++ b/src/addon/mod/quiz/pages/review/review.module.ts @@ -0,0 +1,37 @@ +// (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 { CoreQuestionComponentsModule } from '@core/question/components/components.module'; +import { AddonModQuizReviewPage } from './review'; + +@NgModule({ + declarations: [ + AddonModQuizReviewPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + CoreQuestionComponentsModule, + IonicPageModule.forChild(AddonModQuizReviewPage), + TranslateModule.forChild() + ], +}) +export class AddonModQuizReviewPageModule {} diff --git a/src/addon/mod/quiz/pages/review/review.scss b/src/addon/mod/quiz/pages/review/review.scss new file mode 100644 index 000000000..26a67cda3 --- /dev/null +++ b/src/addon/mod/quiz/pages/review/review.scss @@ -0,0 +1,11 @@ +page-addon-mod-quiz-review { + .item-radio-disabled, + .item-checkbox-disabled, + .text-input[disabled] { + opacity: 1; + + .label, .radio, .checkbox { + opacity: 1; + } + } +} diff --git a/src/addon/mod/quiz/pages/review/review.ts b/src/addon/mod/quiz/pages/review/review.ts new file mode 100644 index 000000000..2ce6411cb --- /dev/null +++ b/src/addon/mod/quiz/pages/review/review.ts @@ -0,0 +1,295 @@ +// (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, ViewChild } from '@angular/core'; +import { IonicPage, NavParams, Content, ModalController, Modal } from 'ionic-angular'; +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 { CoreQuestionHelperProvider } from '@core/question/providers/helper'; +import { AddonModQuizProvider } from '../../providers/quiz'; +import { AddonModQuizHelperProvider } from '../../providers/helper'; + +/** + * Page that allows reviewing a quiz attempt. + */ +@IonicPage({ segment: 'addon-mod-quiz-review' }) +@Component({ + selector: 'page-addon-mod-quiz-review', + templateUrl: 'review.html', +}) +export class AddonModQuizReviewPage implements OnInit { + @ViewChild(Content) content: Content; + + attempt: any; // The attempt being reviewed. + component = AddonModQuizProvider.COMPONENT; // Component to link the files to. + componentId: number; // ID to use in conjunction with the component. + showAll: boolean; // Whether to view all questions in the same page. + numPages: number; // Number of pages. + showCompleted: boolean; // Whether to show completed time. + additionalData: any[]; // Additional data to display for the attempt. + loaded: boolean; // Whether data has been loaded. + navigation: any[]; // List of questions to navigate them. + questions: any[]; // Questions of the current page. + nextPage: number; // Next page. + previousPage: number; // Previous page. + navigationModal: Modal; // Modal to navigate through the questions. + + protected quiz: any; // The quiz the attempt belongs to. + protected courseId: number; // The course ID the quiz belongs to. + protected quizId: number; // Quiz ID the attempt belongs to. + protected attemptId: number; // The attempt being reviewed. + protected currentPage: number; // The current page being reviewed. + protected options: any; // Review options. + + constructor(navParams: NavParams, modalCtrl: ModalController, protected translate: TranslateService, + protected domUtils: CoreDomUtilsProvider, protected timeUtils: CoreTimeUtilsProvider, + protected quizProvider: AddonModQuizProvider, protected quizHelper: AddonModQuizHelperProvider, + protected questionHelper: CoreQuestionHelperProvider, protected textUtils: CoreTextUtilsProvider) { + + this.quizId = navParams.get('quizId'); + this.courseId = navParams.get('courseId'); + this.attemptId = navParams.get('attemptId'); + this.currentPage = navParams.get('page') || -1; + this.showAll = this.currentPage == -1; + + // Create the navigation modal. + this.navigationModal = modalCtrl.create('AddonModQuizNavigationModalPage', { + isReview: true, + page: this + }); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.fetchData().then(() => { + this.quizProvider.logViewAttemptReview(this.attemptId); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Change the current page. If slot is supplied, try to scroll to that question. + * + * @param {number} page Page to load. -1 means all questions in same page. + * @param {boolean} [fromModal] Whether the page was selected using the navigation modal. + * @param {number} [slot] Slot of the question to scroll to. + */ + changePage(page: number, fromModal?: boolean, slot?: number): void { + if (typeof slot != 'undefined' && (this.attempt.currentpage == -1 || page == this.currentPage)) { + // Scrol to a certain question in the current page. + this.scrollToQuestion(slot); + + return; + } else if (page == this.currentPage) { + // If the user is navigating to the current page and no question specified, we do nothing. + return; + } + + this.loaded = false; + this.content.scrollToTop(); + + this.loadPage(page).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true); + }).finally(() => { + this.loaded = true; + + if (typeof slot != 'undefined') { + // Scroll to the question. Give some time to the questions to render. + setTimeout(() => { + this.scrollToQuestion(slot); + }, 2000); + } + }); + } + + /** + * Convenience function to get the quiz data. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchData(): Promise { + return this.quizProvider.getQuizById(this.courseId, this.quizId).then((quizData) => { + this.quiz = quizData; + this.componentId = this.quiz.coursemodule; + + return this.quizProvider.getCombinedReviewOptions(this.quizId).then((result) => { + this.options = result; + + // Load the navigation data. + return this.loadNavigation().then(() => { + // Load questions. + return this.loadPage(this.currentPage); + }); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true); + }); + } + + /** + * Load a page questions. + * + * @param {number} page The page to load. + * @return {Promise} Promise resolved when done. + */ + protected loadPage(page: number): Promise { + return this.quizProvider.getAttemptReview(this.attemptId, page).then((data) => { + this.attempt = data.attempt; + this.attempt.currentpage = page; + this.currentPage = page; + + // Set the summary data. + this.setSummaryCalculatedData(data); + + this.questions = data.questions; + this.nextPage = page == -1 ? undefined : page + 1; + this.previousPage = page - 1; + + this.questions.forEach((question) => { + // Get the readable mark for each question. + question.readableMark = this.quizHelper.getQuestionMarkFromHtml(question.html); + + // Extract the question info box. + this.questionHelper.extractQuestionInfoBox(question, '.info'); + + // Set the preferred behaviour. + question.preferredBehaviour = this.quiz.preferredbehaviour; + }); + }); + } + + /** + * Load data to navigate the questions using the navigation modal. + * + * @return {Promise} Promise resolved when done. + */ + protected loadNavigation(): Promise { + // Get all questions in single page to retrieve all the questions. + return this.quizProvider.getAttemptReview(this.attemptId, -1).then((data) => { + const lastQuestion = data.questions[data.questions.length - 1]; + + data.questions.forEach((question) => { + question.stateClass = this.questionHelper.getQuestionStateClass(question.state); + }); + + this.navigation = data.questions; + this.numPages = lastQuestion ? lastQuestion.page + 1 : 0; + }); + } + + /** + * Refreshes data. + * + * @param {any} refresher Refresher + */ + refreshData(refresher: any): void { + const promises = []; + + promises.push(this.quizProvider.invalidateQuizData(this.courseId)); + promises.push(this.quizProvider.invalidateCombinedReviewOptionsForUser(this.quizId)); + promises.push(this.quizProvider.invalidateAttemptReview(this.attemptId)); + + Promise.all(promises).finally(() => { + return this.fetchData(); + }).finally(() => { + refresher.complete(); + }); + } + + /** + * Scroll to a certain question. + * + * @param {number} slot Slot of the question to scroll to. + */ + protected scrollToQuestion(slot: number): void { + this.domUtils.scrollToElementBySelector(this.content, '#addon-mod_quiz-question-' + slot); + } + + /** + * Calculate review summary data. + * + * @param {any} data Result of getAttemptReview. + */ + protected setSummaryCalculatedData(data: any): void { + + this.attempt.readableState = this.quizProvider.getAttemptReadableStateName(this.attempt.state); + + if (this.attempt.state == AddonModQuizProvider.ATTEMPT_FINISHED) { + this.showCompleted = true; + this.additionalData = data.additionaldata; + + const timeTaken = this.attempt.timefinish - this.attempt.timestart; + if (timeTaken) { + // Format time taken. + this.attempt.timeTaken = this.timeUtils.formatTime(timeTaken); + + // Calculate overdue time. + if (this.quiz.timelimit && timeTaken > this.quiz.timelimit + 60) { + this.attempt.overTime = this.timeUtils.formatTime(timeTaken - this.quiz.timelimit); + } + } + + // Treat grade. + if (this.options.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX && + this.quizProvider.quizHasGrades(this.quiz)) { + + if (data.grade === null || typeof data.grade == 'undefined') { + this.attempt.readableGrade = this.quizProvider.formatGrade(data.grade, this.quiz.decimalpoints); + } else { + // Show raw marks only if they are different from the grade (like on the entry page). + if (this.quiz.grade != this.quiz.sumgrades) { + this.attempt.readableMark = this.translate.instant('addon.mod_quiz.outofshort', {$a: { + grade: this.quizProvider.formatGrade(this.attempt.sumgrades, this.quiz.decimalpoints), + maxgrade: this.quizProvider.formatGrade(this.quiz.sumgrades, this.quiz.decimalpoints) + }}); + } + + // Now the scaled grade. + const gradeObject: any = { + grade: this.quizProvider.formatGrade(data.grade, this.quiz.decimalpoints), + maxgrade: this.quizProvider.formatGrade(this.quiz.grade, this.quiz.decimalpoints) + }; + + if (this.quiz.grade != 100) { + gradeObject.percent = this.textUtils.roundToDecimals(this.attempt.sumgrades * 100 / this.quiz.sumgrades, 0); + this.attempt.readableGrade = this.translate.instant('addon.mod_quiz.outofpercent', {$a: gradeObject}); + } else { + this.attempt.readableGrade = this.translate.instant('addon.mod_quiz.outof', {$a: gradeObject}); + } + } + } + + // Treat additional data. + this.additionalData.forEach((data) => { + // Remove help links from additional data. + data.content = this.domUtils.removeElementFromHtml(data.content, '.helptooltip'); + }); + } + } + + /** + * Switch mode: all questions in same page OR one page at a time. + */ + switchMode(): void { + this.showAll = !this.showAll; + + // Load all questions or first page, depending on the mode. + this.loadPage(this.showAll ? -1 : 0); + } +} diff --git a/src/addon/qtype/multichoice/component/multichoice.html b/src/addon/qtype/multichoice/component/multichoice.html index e7282a9df..a7dd5baf2 100644 --- a/src/addon/qtype/multichoice/component/multichoice.html +++ b/src/addon/qtype/multichoice/component/multichoice.html @@ -21,11 +21,12 @@
- - - + + +

-
+ +
diff --git a/src/app/app.scss b/src/app/app.scss index 23f901078..c3e07cbfd 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -356,6 +356,84 @@ ion-select { } } +// Question. +// ------------------------- + +.core-question-answer-correct, +.core-question-comment { + color: $core-question-correct-color; + background-color: $core-question-correct-color-bg; + + .label, ion-label.label { + color: $core-question-correct-color; + } +} + +.core-question-answer-incorrect, +.core-question-incorrect { + color: $core-question-incorrect-color; + background-color: $core-question-incorrect-color-bg; + + .label, ion-label.label { + color: $core-question-incorrect-color; + } +} + +.core-question-feedback-container { + background-color: $core-question-feedback-color-bg; + color: $core-question-feedback-color; + + .specificfeedback, .rightanswer, .im-feedback, .feedback, .generalfeedback { + margin: 0 0 .5em; + } + + .correctness { + display: inline-block; + padding: 2px 4px; + font-weight: bold; + line-height: 14px; + color: $white; + text-shadow: 0 -1px 0 rgba(0,0,0,0.25); + background-color: $gray-dark; + -webkit-border-radius: 3px; + border-radius: 3px; + + &.incorrect { + background-color: $red; + } + &.correct { + background-color: $green; + } + } +} + +.core-question-feedback-inline { + display: inline-block; +} + +.core-question-feedback-padding { + padding: 8px 35px 8px 14px; +} + +.core-question-correct { + background-color: $core-question-state-correct-color; +} +.core-question-partiallycorrect { + background-color: $core-question-state-partial-color; +} +.core-question-notanswered, +.core-question-incorrect { + background-color: $core-question-state-incorrect-color; +} + +.core-question-warning { + color: $core-question-warning-color; +} + +.questioncorrectnessicon { + font-size: 20px; +} + // Atto styles // ------------------------- .atto_image_preview { diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts index 4d3ca91e4..fac0992b8 100644 --- a/src/core/question/providers/helper.ts +++ b/src/core/question/providers/helper.ts @@ -396,6 +396,18 @@ export class CoreQuestionHelperProvider { } } + /** + * Get the CSS class for a question based on its state. + * + * @param {string} name Question's state name. + * @return {string} State class. + */ + getQuestionStateClass(name: string): string { + const state = this.questionProvider.getState(name); + + return state ? state.class : ''; + } + /** * Get the validation error message from a question HTML if it's there. * diff --git a/src/theme/variables.scss b/src/theme/variables.scss index d80964743..59f3f2ff1 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -213,6 +213,19 @@ $core-timer-warn-color: $red !default; $core-timer-color: $white !default; $core-timer-iterations: 15 !default; +// Question variables. +$core-question-correct-color: $green-dark !default; +$core-question-correct-color-bg: $green-light !default; +$core-question-incorrect-color: $red !default; +$core-question-incorrect-color-bg: $red-light !default; +$core-question-feedback-color: $yellow-dark !default; +$core-question-feedback-color-bg: $yellow-light !default; +$core-question-warning-color: $red !default; + +$core-question-state-correct-color: $green-light !default; +$core-question-state-partial-color: $yellow-light !default; +$core-question-state-incorrect-color: $red-light !default; + // Mixins // ------------------------- @mixin core-transition($where: all, $time: 500ms) {