// (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 } from '@ionic/angular'; import { CoreNavigator } from '@services/navigator'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreDom } from '@singletons/dom'; import { CoreTime } from '@singletons/time'; import { AddonModQuizNavigationModalComponent, AddonModQuizNavigationModalReturn, AddonModQuizNavigationQuestion, } from '../../components/navigation-modal/navigation-modal'; import { AddonModQuiz, AddonModQuizAttemptWSData, AddonModQuizCombinedReviewOptions, AddonModQuizGetAttemptReviewResponse, AddonModQuizProvider, AddonModQuizQuizWSData, AddonModQuizWSAdditionalData, } from '../../services/quiz'; import { AddonModQuizHelper } from '../../services/quiz-helper'; import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; /** * 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. showAll = false; // Whether to view all questions in the same page. numPages = 1; // 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. cmId!: number; // Course module 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. protected logView: () => void; constructor( protected elementRef: ElementRef, ) { this.logView = CoreTime.once(() => this.performLogView(true, { showAllDisabled: !this.showAll, page: this.currentPage, })); } /** * @inheritdoc */ async ngOnInit(): Promise { try { this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); this.attemptId = CoreNavigator.getRequiredRouteNumberParam('attemptId'); this.currentPage = CoreNavigator.getRouteNumberParam('page') || -1; this.showAll = this.currentPage == -1; } catch (error) { CoreDomUtils.showErrorModal(error); CoreNavigator.back(); return; } try { await this.fetchData(); } 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 slot Slot of the question to scroll to. */ async changePage(page: number, slot?: number): Promise { if (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); this.performLogView(false, { page }); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true); } finally { this.loaded = true; if (slot !== undefined) { // Scroll to the question. this.scrollToQuestion(slot); } } } /** * Convenience function to get the quiz data. * * @returns Promise resolved when done. */ protected async fetchData(): Promise { try { this.quiz = await AddonModQuiz.getQuiz(this.courseId, this.cmId); this.options = await AddonModQuiz.getCombinedReviewOptions(this.quiz.id, { cmId: this.cmId }); // Load the navigation data. await this.loadNavigation(); // Load questions. await this.loadPage(this.currentPage); this.logView(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true); } } /** * Load a page questions. * * @param page The page to load. * @returns Promise resolved when done. */ protected async loadPage(page: number): Promise { const data = await AddonModQuiz.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; this.previousPage = page - 1; this.questions.forEach((question) => { // Get the readable mark for each question. question.readableMark = AddonModQuizHelper.getQuestionMarkFromHtml(question.html); // Extract the question info box. CoreQuestionHelper.extractQuestionInfoBox(question, '.info'); }); } /** * Load data to navigate the questions using the navigation modal. * * @returns Promise resolved when done. */ protected async loadNavigation(): Promise { // Get all questions in single page to retrieve all the questions. const data = await AddonModQuiz.getAttemptReview(this.attemptId, { page: -1, cmId: this.quiz?.coursemodule }); this.navigation = data.questions; this.navigation.forEach((question) => { question.stateClass = CoreQuestionHelper.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: HTMLIonRefresherElement): Promise { const promises: Promise[] = []; promises.push(AddonModQuiz.invalidateQuizData(this.courseId)); promises.push(AddonModQuiz.invalidateAttemptReview(this.attemptId)); if (this.quiz) { promises.push(AddonModQuiz.invalidateCombinedReviewOptionsForUser(this.quiz.id)); } await CoreUtils.ignoreErrors(Promise.all(promises)); 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 { CoreDom.scrollToElement( this.elementRef.nativeElement, `#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.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 = CoreTime.formatTime(timeTaken); // Calculate overdue time. if (this.quiz.timelimit && timeTaken > this.quiz.timelimit + 60) { this.overTime = CoreTime.formatTime(timeTaken - this.quiz.timelimit); } } else { this.timeTaken = undefined; } // Treat grade. if (this.options && this.options.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX && AddonModQuiz.quizHasGrades(this.quiz)) { if (data.grade === null || data.grade === undefined) { this.readableGrade = AddonModQuiz.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.instant('addon.mod_quiz.outofshort', { $a: { grade: AddonModQuiz.formatGrade(this.attempt.sumgrades, this.quiz.decimalpoints), maxgrade: AddonModQuiz.formatGrade(this.quiz.sumgrades, this.quiz.decimalpoints), } }); } // Now the scaled grade. const gradeObject: Record = { grade: AddonModQuiz.formatGrade(Number(data.grade), this.quiz.decimalpoints), maxgrade: AddonModQuiz.formatGrade(this.quiz.grade, this.quiz.decimalpoints), }; if (this.quiz.grade != 100) { gradeObject.percent = AddonModQuiz.formatGrade( (this.attempt.sumgrades ?? 0) * 100 / (this.quiz.sumgrades ?? 1), this.quiz.decimalpoints, ); this.readableGrade = Translate.instant('addon.mod_quiz.outofpercent', { $a: gradeObject }); } else { this.readableGrade = Translate.instant('addon.mod_quiz.outof', { $a: gradeObject }); } } } // Treat additional data. this.additionalData.forEach((data) => { // Remove help links from additional data. data.content = CoreDomUtils.removeElementFromHtml(data.content, '.helptooltip'); }); } /** * Switch mode: all questions in same page OR one page at a time. */ async switchMode(): Promise { this.showAll = !this.showAll; // Load all questions or first page, depending on the mode. await this.loadPage(this.showAll ? -1 : 0); this.performLogView(false, { showAllDisabled: !this.showAll }); } async openNavigation(): Promise { // Create the navigation modal. const modalData = await CoreDomUtils.openSideModal({ component: AddonModQuizNavigationModalComponent, componentProps: { navigation: this.navigation, summaryShown: false, currentPage: this.attempt?.currentpage, isReview: true, isSequential: this.quiz && AddonModQuiz.isNavigationSequential(this.quiz), }, }); if (!modalData) { return; } this.changePage(modalData.page, modalData.slot); } /** * Perform log view. * * @param logInLMS Whether to log in LMS too or only in analytics. * @param options Other options. */ protected async performLogView(logInLMS = false, options: LogViewOptions = {}): Promise { if (!this.quiz) { return; } if (logInLMS) { await CoreUtils.ignoreErrors(AddonModQuiz.logViewAttemptReview(this.attemptId, this.quiz.id)); } let url = `/mod/quiz/review.php?attempt=${this.attemptId}&cmid=${this.cmId}`; if (options.showAllDisabled) { url += '&showall=0'; } else if (options.page && options.page > 0) { url += `&page=${ options.page}`; } CoreAnalytics.logEvent({ type: CoreAnalyticsEventType.VIEW_ITEM, ws: 'mod_quiz_view_attempt_review', name: this.quiz.name, data: { id: this.attemptId, quizid: this.quiz.id, page: options.page, category: 'quiz' }, url: url, }); } } /** * Question with some calculated data for the view. */ type QuizQuestion = CoreQuestionQuestionParsed & { readableMark?: string; }; type LogViewOptions = { page?: number; // Page being viewed (if viewing pages); showAllDisabled?: boolean; // Whether the showAll option has just been disabled. };