diff --git a/src/addons/mod/quiz/components/index/index.ts b/src/addons/mod/quiz/components/index/index.ts index fb50b8ebf..c08c5e18f 100644 --- a/src/addons/mod/quiz/components/index/index.ts +++ b/src/addons/mod/quiz/components/index/index.ts @@ -413,7 +413,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp try { await AddonModQuiz.instance.getAttemptReview(attemptId, { page: -1, cmId: this.module!.id }); - // @todo this.navCtrl.push('AddonModQuizReviewPage', { courseId: this.courseId, quizId: quiz!.id, attemptId }); + CoreNavigator.instance.navigate(`../../review/${this.courseId}/${this.quiz!.id}/${attemptId}`); } catch { // Ignore errors. } diff --git a/src/addons/mod/quiz/pages/attempt/attempt.ts b/src/addons/mod/quiz/pages/attempt/attempt.ts index ede88d001..ccf7ebd30 100644 --- a/src/addons/mod/quiz/pages/attempt/attempt.ts +++ b/src/addons/mod/quiz/pages/attempt/attempt.ts @@ -191,7 +191,7 @@ export class AddonModQuizAttemptPage implements OnInit { * @return Promise resolved when done. */ async reviewAttempt(): Promise { - // @todo navPush="AddonModQuizReviewPage" [navParams]="{courseId: courseId, quizId: quiz.id, attemptId: attempt.id}" + CoreNavigator.instance.navigate(`../../../../review/${this.courseId}/${this.quiz!.id}/${this.attempt!.id}`); } } diff --git a/src/addons/mod/quiz/pages/review/review.html b/src/addons/mod/quiz/pages/review/review.html new file mode 100644 index 000000000..b740c07c9 --- /dev/null +++ b/src/addons/mod/quiz/pages/review/review.html @@ -0,0 +1,137 @@ + + + + + + {{ '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 }}

+
+
+ + +

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

+

{{ readableState }}

+
+
+ + +

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

+

{{ attempt.timefinish! * 1000 | coreFormatDate }}

+
+
+ + +

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

+

{{ timeTaken }}

+
+
+ + +

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

+

{{ overTime }}

+
+
+ + +

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

+

{{ readableMark }}

+
+
+ + +

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

+

{{ readableGrade }}

+
+
+ + +

{{ data.title }}

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

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

+

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

+
+
+

{{question.status}}

+

{{question.readableMark}}

+
+
+ + + + +
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/quiz/pages/review/review.module.ts b/src/addons/mod/quiz/pages/review/review.module.ts new file mode 100644 index 000000000..afb547520 --- /dev/null +++ b/src/addons/mod/quiz/pages/review/review.module.ts @@ -0,0 +1,40 @@ +// (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 { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionComponentsModule } from '@features/question/components/components.module'; +import { AddonModQuizReviewPage } from './review'; + +const routes: Routes = [ + { + path: '', + component: AddonModQuizReviewPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + CoreQuestionComponentsModule, + ], + declarations: [ + AddonModQuizReviewPage, + ], + exports: [RouterModule], +}) +export class AddonModQuizReviewPageModule {} diff --git a/src/addons/mod/quiz/pages/review/review.scss b/src/addons/mod/quiz/pages/review/review.scss new file mode 100644 index 000000000..506efd71b --- /dev/null +++ b/src/addons/mod/quiz/pages/review/review.scss @@ -0,0 +1,6 @@ +:host { + .addon-mod_quiz-question-note p { + margin-top: 2px; + margin-bottom: 2px; + } +} diff --git a/src/addons/mod/quiz/pages/review/review.ts b/src/addons/mod/quiz/pages/review/review.ts new file mode 100644 index 000000000..f02a67fcd --- /dev/null +++ b/src/addons/mod/quiz/pages/review/review.ts @@ -0,0 +1,366 @@ +// (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, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { CoreQuestionQuestionParsed } from '@features/question/services/question'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { IonContent, IonRefresher } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController, Translate } from '@singletons'; +import { + AddonModQuizNavigationModalComponent, + AddonModQuizNavigationQuestion, +} from '../../components/navigation-modal/navigation-modal'; +import { + AddonModQuiz, + AddonModQuizAttemptWSData, + AddonModQuizCombinedReviewOptions, + AddonModQuizGetAttemptReviewResponse, + AddonModQuizProvider, + AddonModQuizQuizWSData, + AddonModQuizWSAdditionalData, +} from '../../services/quiz'; +import { AddonModQuizHelper } from '../../services/quiz-helper'; + +/** + * Page that allows reviewing a quiz attempt. + */ +@Component({ + selector: 'page-addon-mod-quiz-review', + templateUrl: 'review.html', + styleUrls: ['review.scss'], +}) +export class AddonModQuizReviewPage implements OnInit { + + @ViewChild(IonContent) content?: IonContent; + + attempt?: AddonModQuizAttemptWSData; // The attempt being reviewed. + component = AddonModQuizProvider.COMPONENT; // Component to link the files to. + componentId?: number; // ID to use in conjunction with the component. + showAll = false; // Whether to view all questions in the same page. + numPages?: number; // Number of pages. + showCompleted = false; // Whether to show completed time. + additionalData?: AddonModQuizWSAdditionalData[]; // Additional data to display for the attempt. + loaded = false; // Whether data has been loaded. + navigation: AddonModQuizNavigationQuestion[] = []; // List of questions to navigate them. + questions: QuizQuestion[] = []; // Questions of the current page. + nextPage = -2; // Next page. + previousPage = -2; // Previous page. + readableState?: string; + readableGrade?: string; + readableMark?: string; + timeTaken?: string; + overTime?: string; + quiz?: AddonModQuizQuizWSData; // The quiz the attempt belongs to. + 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?: AddonModQuizCombinedReviewOptions; // Review options. + + constructor( + protected elementRef: ElementRef, + ) { + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + this.quizId = CoreNavigator.instance.getRouteNumberParam('quizId')!; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + this.attemptId = CoreNavigator.instance.getRouteNumberParam('attemptId')!; + this.currentPage = CoreNavigator.instance.getRouteNumberParam('page') || -1; + this.showAll = this.currentPage == -1; + + try { + await this.fetchData(); + + CoreUtils.instance.ignoreErrors( + AddonModQuiz.instance.logViewAttemptReview(this.attemptId, this.quizId, this.quiz!.name), + ); + } finally { + this.loaded = true; + } + } + + /** + * Change the current page. If slot is supplied, try to scroll to that question. + * + * @param page Page to load. -1 means all questions in same page. + * @param fromModal Whether the page was selected using the navigation modal. + * @param slot Slot of the question to scroll to. + */ + async changePage(page: number, fromModal?: boolean, slot?: number): Promise { + 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(); + + try { + await this.loadPage(page); + } catch (error) { + CoreDomUtils.instance.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 resolved when done. + */ + protected async fetchData(): Promise { + try { + this.quiz = await AddonModQuiz.instance.getQuizById(this.courseId, this.quizId); + + this.componentId = this.quiz.coursemodule; + + this.options = await AddonModQuiz.instance.getCombinedReviewOptions(this.quizId, { cmId: this.quiz.coursemodule }); + + // Load the navigation data. + await this.loadNavigation(); + + // Load questions. + await this.loadPage(this.currentPage); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true); + } + } + + /** + * Load a page questions. + * + * @param page The page to load. + * @return Promise resolved when done. + */ + protected async loadPage(page: number): Promise { + const data = await AddonModQuiz.instance.getAttemptReview(this.attemptId, { page, cmId: this.quiz!.coursemodule }); + + 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 ? -2 : page + 1; + this.previousPage = page - 1; + + this.questions.forEach((question) => { + // Get the readable mark for each question. + question.readableMark = AddonModQuizHelper.instance.getQuestionMarkFromHtml(question.html); + + // Extract the question info box. + CoreQuestionHelper.instance.extractQuestionInfoBox(question, '.info'); + }); + } + + /** + * Load data to navigate the questions using the navigation modal. + * + * @return Promise resolved when done. + */ + protected async loadNavigation(): Promise { + // Get all questions in single page to retrieve all the questions. + const data = await AddonModQuiz.instance.getAttemptReview(this.attemptId, { page: -1, cmId: this.quiz!.coursemodule }); + + this.navigation = data.questions; + + this.navigation.forEach((question) => { + question.stateClass = CoreQuestionHelper.instance.getQuestionStateClass(question.state || ''); + }); + + const lastQuestion = data.questions[data.questions.length - 1]; + this.numPages = lastQuestion ? lastQuestion.page + 1 : 0; + } + + /** + * Refreshes data. + * + * @param refresher Refresher + */ + async refreshData(refresher: IonRefresher): Promise { + await CoreUtils.instance.ignoreErrors(Promise.all([ + AddonModQuiz.instance.invalidateQuizData(this.courseId), + AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quizId), + AddonModQuiz.instance.invalidateAttemptReview(this.attemptId), + ])); + + try { + await this.fetchData(); + } finally { + refresher.complete(); + } + } + + /** + * Scroll to a certain question. + * + * @param slot Slot of the question to scroll to. + */ + protected scrollToQuestion(slot: number): void { + CoreDomUtils.instance.scrollToElementBySelector( + this.elementRef.nativeElement, + this.content, + `#addon-mod_quiz-question-${slot}`, + ); + } + + /** + * Calculate review summary data. + * + * @param data Result of getAttemptReview. + */ + protected setSummaryCalculatedData(data: AddonModQuizGetAttemptReviewResponse): void { + if (!this.attempt || !this.quiz) { + return; + } + + this.readableState = AddonModQuiz.instance.getAttemptReadableStateName(this.attempt!.state || ''); + + if (this.attempt.state != AddonModQuizProvider.ATTEMPT_FINISHED) { + return; + } + + this.showCompleted = true; + this.additionalData = data.additionaldata; + + const timeTaken = (this.attempt.timefinish || 0) - (this.attempt.timestart || 0); + if (timeTaken > 0) { + // Format time taken. + this.timeTaken = CoreTimeUtils.instance.formatTime(timeTaken); + + // Calculate overdue time. + if (this.quiz.timelimit && timeTaken > this.quiz.timelimit + 60) { + this.overTime = CoreTimeUtils.instance.formatTime(timeTaken - this.quiz.timelimit); + } + } else { + this.timeTaken = undefined; + } + + // Treat grade. + if (this.options!.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX && + AddonModQuiz.instance.quizHasGrades(this.quiz)) { + + if (data.grade === null || typeof data.grade == 'undefined') { + this.readableGrade = AddonModQuiz.instance.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.readableMark = Translate.instance.instant('addon.mod_quiz.outofshort', { $a: { + grade: AddonModQuiz.instance.formatGrade(this.attempt.sumgrades, this.quiz.decimalpoints), + maxgrade: AddonModQuiz.instance.formatGrade(this.quiz.sumgrades, this.quiz.decimalpoints), + } }); + } + + // Now the scaled grade. + const gradeObject: Record = { + grade: AddonModQuiz.instance.formatGrade(Number(data.grade), this.quiz.decimalpoints), + maxgrade: AddonModQuiz.instance.formatGrade(this.quiz.grade, this.quiz.decimalpoints), + }; + + if (this.quiz.grade != 100) { + gradeObject.percent = CoreTextUtils.instance.roundToDecimals( + this.attempt.sumgrades! * 100 / this.quiz.sumgrades!, + 0, + ); + this.readableGrade = Translate.instance.instant('addon.mod_quiz.outofpercent', { $a: gradeObject }); + } else { + this.readableGrade = Translate.instance.instant('addon.mod_quiz.outof', { $a: gradeObject }); + } + } + } + + // Treat additional data. + this.additionalData.forEach((data) => { + // Remove help links from additional data. + data.content = CoreDomUtils.instance.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); + } + + async openNavigation(): Promise { + // Create the navigation modal. + const modal = await ModalController.instance.create({ + component: AddonModQuizNavigationModalComponent, + componentProps: { + navigation: this.navigation, + summaryShown: false, + currentPage: this.attempt?.currentpage, + isReview: true, + numPages: this.numPages, + showAll: this.showAll, + }, + cssClass: 'core-modal-lateral', + showBackdrop: true, + backdropDismiss: true, + // @todo enterAnimation: 'core-modal-lateral-transition', + // @todo leaveAnimation: 'core-modal-lateral-transition', + }); + + await modal.present(); + + const result = await modal.onWillDismiss(); + + if (!result.data) { + return; + } + + if (result.data.action == AddonModQuizNavigationModalComponent.CHANGE_PAGE) { + this.changePage(result.data.page, true, result.data.slot); + } else if (result.data.action == AddonModQuizNavigationModalComponent.SWITCH_MODE) { + this.switchMode(); + } + } + +} + +/** + * Question with some calculated data for the view. + */ +type QuizQuestion = CoreQuestionQuestionParsed & { + readableMark?: string; +}; diff --git a/src/addons/mod/quiz/quiz-lazy.module.ts b/src/addons/mod/quiz/quiz-lazy.module.ts index 1b44928d9..1cd5e8998 100644 --- a/src/addons/mod/quiz/quiz-lazy.module.ts +++ b/src/addons/mod/quiz/quiz-lazy.module.ts @@ -28,6 +28,10 @@ const routes: Routes = [ path: 'attempt/:courseId/:quizId/:attemptId', loadChildren: () => import('./pages/attempt/attempt.module').then( m => m.AddonModQuizAttemptPageModule), }, + { + path: 'review/:courseId/:quizId/:attemptId', + loadChildren: () => import('./pages/review/review.module').then( m => m.AddonModQuizReviewPageModule), + }, ]; @NgModule({ diff --git a/src/core/features/question/classes/base-question-component.ts b/src/core/features/question/classes/base-question-component.ts index b6f21aef4..b538aa870 100644 --- a/src/core/features/question/classes/base-question-component.ts +++ b/src/core/features/question/classes/base-question-component.ts @@ -423,15 +423,15 @@ export class CoreQuestionBaseComponent { // Check if question is marked as correct. if (input.classList.contains('incorrect')) { question.input.correctClass = 'core-question-incorrect'; - question.input.correctIcon = 'fa-remove'; + question.input.correctIcon = 'fas-times'; question.input.correctIconColor = 'danger'; } else if (input.classList.contains('correct')) { question.input.correctClass = 'core-question-correct'; - question.input.correctIcon = 'fa-check'; + question.input.correctIcon = 'fas-check'; question.input.correctIconColor = 'success'; } else if (input.classList.contains('partiallycorrect')) { question.input.correctClass = 'core-question-partiallycorrect'; - question.input.correctIcon = 'fa-check-square'; + question.input.correctIcon = 'fas-check-square'; question.input.correctIconColor = 'warning'; } else { question.input.correctClass = ''; diff --git a/src/core/features/question/question.scss b/src/core/features/question/question.scss index 74f9d06ad..b6e4f4b80 100644 --- a/src/core/features/question/question.scss +++ b/src/core/features/question/question.scss @@ -2,7 +2,7 @@ $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA520, #FFD700, #F0E68C !default; -:host { +:host ::ng-deep { --core-question-correct-color: var(--green-dark); --core-question-correct-color-bg: var(--green-light); --core-question-incorrect-color: var(--red); @@ -22,64 +22,19 @@ $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA52 --core-dd-question-selected-shadow: 2px 2px 4px var(--gray-dark); - // .core-correct-icon { - // padding: 0 ($content-padding / 2); - // position: absolute; - // @include position(null, 0, $content-padding / 2, null); - // margin-top: 0; - // margin-bottom: 0; - // } + .core-question-answer-correct { + color: var(--core-question-correct-color); + } - - // .core-question-answer-correct { - // color: $core-question-correct-color; - // } - - // .core-question-answer-incorrect { - // color: $core-question-incorrect-color; - // } - - // input, select { - // &.core-question-answer-correct, &.core-question-answer-incorrect { - // background-color: $gray-lighter; - // color: $text-color; - // } - // } - - // .core-question-correct, - // .core-question-comment { - // color: $core-question-correct-color; - // background-color: $core-question-correct-color-bg; - - // .label, ion-label.label, .select-text, .select-icon .select-icon-inner { - // color: $core-question-correct-color; - // } - // .radio-icon { - // border-color: $core-question-correct-color; - // } - // .radio-inner { - // background-color: $core-question-correct-color; - // } - // } - - // .core-question-incorrect { - // color: $core-question-incorrect-color; - // background-color: $core-question-incorrect-color-bg; - - // .label, ion-label.label, .select-text, .select-icon .select-icon-inner { - // color: $core-question-incorrect-color; - // } - // .radio-icon { - // border-color: $core-question-incorrect-color; - // } - // .radio-inner { - // background-color: $core-question-incorrect-color; - // } - // } + .core-question-answer-incorrect { + color: var(--core-question-incorrect-color); + } .core-question-feedback-container ::ng-deep { --color: var(--core-question-feedback-color); --background: var(--core-question-feedback-background-color); + color: var(--core-question-feedback-color); + background-color: var(--core-question-feedback-background-color); .specificfeedback, .rightanswer, .im-feedback, .feedback, .generalfeedback { margin: 0 0 .5em; @@ -100,7 +55,7 @@ $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA52 background-color: var(--red); } &.correct { - background-color: var(--green); + background-color: var(--green); } } } @@ -115,8 +70,11 @@ $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA52 padding-bottom: 8px; } - .core-question-correct { - background-color: var(--core-question-state-correct-color); + .core-question-correct, + .core-question-comment { + --background: var(--core-question-correct-color-bg); + background-color: var(--core-question-correct-color-bg); + color: var(--core-question-correct-color); } .core-question-partiallycorrect { background-color: var(--core-question-state-partial-color); @@ -139,4 +97,10 @@ $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA52 .fa.icon.questioncorrectnessicon { font-size: 20px; } + + .item.item-interactive.item-interactive-disabled ::ng-deep { + ion-label, ion-select, ion-checkbox { + opacity: 0.7; + } + } }