This is consistent with the LMS which has a "return to attempt" button. It also fixes the behaviour that users would expect, given that the buttons in the side navigation for the current page are not disabled in the summary.
946 lines
33 KiB
TypeScript
946 lines
33 KiB
TypeScript
// (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, OnDestroy, ViewChild, ChangeDetectorRef, ViewChildren, QueryList, ElementRef } from '@angular/core';
|
|
import { IonContent } from '@ionic/angular';
|
|
import { Subscription } from 'rxjs';
|
|
|
|
import { CoreIonLoadingElement } from '@classes/ion-loading';
|
|
import { CoreQuestionComponent } from '@features/question/components/question/question';
|
|
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
|
|
import { CoreQuestionBehaviourButton, CoreQuestionHelper } from '@features/question/services/question-helper';
|
|
import { CoreNavigator } from '@services/navigator';
|
|
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
|
import { CoreSync } from '@services/sync';
|
|
import { CoreDomUtils } from '@services/utils/dom';
|
|
import { CoreUtils } from '@services/utils/utils';
|
|
import { ModalController, Translate } from '@singletons';
|
|
import { CoreEvents } from '@singletons/events';
|
|
import { AddonModQuizAutoSave } from '../../classes/auto-save';
|
|
import {
|
|
AddonModQuizNavigationModalComponent,
|
|
AddonModQuizNavigationModalReturn,
|
|
AddonModQuizNavigationQuestion,
|
|
} from '../../components/navigation-modal/navigation-modal';
|
|
import {
|
|
AddonModQuiz,
|
|
AddonModQuizAttemptWSData,
|
|
AddonModQuizGetAttemptAccessInformationWSResponse,
|
|
AddonModQuizGetQuizAccessInformationWSResponse,
|
|
AddonModQuizProvider,
|
|
AddonModQuizQuizWSData,
|
|
} from '../../services/quiz';
|
|
import { AddonModQuizAttempt, AddonModQuizHelper } from '../../services/quiz-helper';
|
|
import { AddonModQuizSync } from '../../services/quiz-sync';
|
|
import { CanLeave } from '@guards/can-leave';
|
|
import { CoreForms } from '@singletons/form';
|
|
import { CoreDom } from '@singletons/dom';
|
|
import { CoreTime } from '@singletons/time';
|
|
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
|
|
import { CoreWSError } from '@classes/errors/wserror';
|
|
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
|
|
|
|
/**
|
|
* Page that allows attempting a quiz.
|
|
*/
|
|
@Component({
|
|
selector: 'page-addon-mod-quiz-player',
|
|
templateUrl: 'player.html',
|
|
styleUrls: ['player.scss'],
|
|
})
|
|
export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
|
|
|
|
@ViewChild(IonContent) content?: IonContent;
|
|
@ViewChildren(CoreQuestionComponent) questionComponents?: QueryList<CoreQuestionComponent>;
|
|
@ViewChild('quizForm') formElement?: ElementRef;
|
|
|
|
quiz?: AddonModQuizQuizWSData; // The quiz the attempt belongs to.
|
|
attempt?: AddonModQuizAttempt; // The attempt being attempted.
|
|
moduleUrl?: string; // URL to the module in the site.
|
|
component = AddonModQuizProvider.COMPONENT; // Component to link the files to.
|
|
loaded = false; // Whether data has been loaded.
|
|
quizAborted = false; // Whether the quiz was aborted due to an error.
|
|
offline = false; // Whether the quiz is being attempted in offline mode.
|
|
navigation: AddonModQuizNavigationQuestion[] = []; // List of questions to navigate them.
|
|
questions: QuizQuestion[] = []; // Questions of the current page.
|
|
nextPage = -2; // Next page.
|
|
previousPage = -1; // Previous page.
|
|
showSummary = false; // Whether the attempt summary should be displayed.
|
|
summaryQuestions: CoreQuestionQuestionParsed[] = []; // The questions to display in the summary.
|
|
canReturn = false; // Whether the user can return to a page after seeing the summary.
|
|
preventSubmitMessages: string[] = []; // List of messages explaining why the quiz cannot be submitted.
|
|
endTime?: number; // The time when the attempt must be finished.
|
|
autoSaveError = false; // Whether there's been an error in auto-save.
|
|
isSequential = false; // Whether quiz navigation is sequential.
|
|
readableTimeLimit?: string; // Time limit in a readable format.
|
|
dueDateWarning?: string; // Warning about due date.
|
|
courseId!: number; // The course ID the quiz belongs to.
|
|
cmId!: number; // Course module ID.
|
|
|
|
protected preflightData: Record<string, string> = {}; // Preflight data to attempt the quiz.
|
|
protected quizAccessInfo?: AddonModQuizGetQuizAccessInformationWSResponse; // Quiz access information.
|
|
protected attemptAccessInfo?: AddonModQuizGetAttemptAccessInformationWSResponse; // Attempt access info.
|
|
protected lastAttempt?: AddonModQuizAttemptWSData; // Last user attempt before a new one is created (if needed).
|
|
protected newAttempt = false; // Whether the user is starting a new attempt.
|
|
protected quizDataLoaded = false; // Whether the quiz data has been loaded.
|
|
protected timeUpCalled = false; // 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 forceLeave = false; // If true, don't perform any check when leaving the view.
|
|
protected reloadNavigation = false; // Whether navigation needs to be reloaded because some data was sent to server.
|
|
|
|
constructor(
|
|
protected changeDetector: ChangeDetectorRef,
|
|
protected elementRef: ElementRef,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ngOnInit(): void {
|
|
try {
|
|
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
|
|
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
|
|
this.moduleUrl = CoreNavigator.getRouteParam('moduleUrl');
|
|
} catch (error) {
|
|
CoreDomUtils.showErrorModal(error);
|
|
|
|
CoreNavigator.back();
|
|
|
|
return;
|
|
}
|
|
|
|
// Create the auto save instance.
|
|
this.autoSave = new AddonModQuizAutoSave(
|
|
'addon-mod_quiz-player-form',
|
|
'#addon-mod_quiz-connection-error-button',
|
|
);
|
|
|
|
// Start the player when the page is loaded.
|
|
this.start();
|
|
|
|
// Listen for errors on auto-save.
|
|
this.autoSaveErrorSubscription = this.autoSave.onError().subscribe((error) => {
|
|
this.autoSaveError = error;
|
|
this.changeDetector.detectChanges();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ngOnDestroy(): void {
|
|
this.stopAutoSave();
|
|
|
|
if (this.quiz) {
|
|
// Unblock the quiz so it can be synced.
|
|
CoreSync.unblockOperation(AddonModQuizProvider.COMPONENT, this.quiz.id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if we can leave the page or not.
|
|
*
|
|
* @returns Resolved if we can leave it, rejected if not.
|
|
*/
|
|
async canLeave(): Promise<boolean> {
|
|
if (this.forceLeave || this.quizAborted || !this.questions.length || this.showSummary) {
|
|
return true;
|
|
}
|
|
|
|
// Save answers.
|
|
const modal = await CoreDomUtils.showModalLoading('core.sending', true);
|
|
|
|
try {
|
|
await this.processAttempt(false, false);
|
|
} catch (error) {
|
|
// Save attempt failed. Show confirmation.
|
|
modal.dismiss();
|
|
|
|
await CoreDomUtils.showConfirm(Translate.instant('addon.mod_quiz.confirmleavequizonerror'));
|
|
|
|
CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId());
|
|
} finally {
|
|
modal.dismiss();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Runs when the page is about to leave and no longer be the active page.
|
|
*/
|
|
async ionViewWillLeave(): Promise<void> {
|
|
// Close any modal if present.
|
|
const modal = await ModalController.getTop();
|
|
|
|
modal?.dismiss();
|
|
}
|
|
|
|
/**
|
|
* Abort the quiz.
|
|
*/
|
|
abortQuiz(): void {
|
|
this.quizAborted = true;
|
|
}
|
|
|
|
/**
|
|
* A behaviour button in a question was clicked (Check, Redo, ...).
|
|
*
|
|
* @param button Clicked button.
|
|
*/
|
|
async behaviourButtonClicked(button: CoreQuestionBehaviourButton): Promise<void> {
|
|
if (!this.quiz || !this.attempt) {
|
|
return;
|
|
}
|
|
|
|
let modal: CoreIonLoadingElement | undefined;
|
|
|
|
try {
|
|
// Confirm that the user really wants to do it.
|
|
await CoreDomUtils.showConfirm(Translate.instant('core.areyousure'));
|
|
|
|
modal = await CoreDomUtils.showModalLoading('core.sending', true);
|
|
|
|
// Get the answers.
|
|
const answers = await this.prepareAnswers(this.quiz.coursemodule);
|
|
|
|
// Add the clicked button data.
|
|
answers[button.name] = button.value;
|
|
|
|
// Behaviour checks are always in online.
|
|
await AddonModQuiz.processAttempt(this.quiz, this.attempt, answers, this.preflightData);
|
|
|
|
this.reloadNavigation = true; // Data sent to server, navigation should be reloaded.
|
|
|
|
// Reload the current page.
|
|
const scrollElement = await this.content?.getScrollElement();
|
|
const scrollTop = scrollElement?.scrollTop || -1;
|
|
|
|
this.loaded = false;
|
|
this.content?.scrollToTop(); // Scroll top so the spinner is seen.
|
|
|
|
try {
|
|
await this.loadPage(this.attempt.currentpage ?? 0);
|
|
} finally {
|
|
this.loaded = true;
|
|
if (scrollTop != -1) {
|
|
// Wait for content to be rendered.
|
|
setTimeout(() => {
|
|
this.content?.scrollToPoint(0, scrollTop);
|
|
}, 50);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
CoreDomUtils.showErrorModalDefault(error, 'Error performing action.');
|
|
} finally {
|
|
modal?.dismiss();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Change the current page. If slot is supplied, try to scroll to that question.
|
|
*
|
|
* @param page Page to load. -1 means summary.
|
|
* @param fromModal Whether the page was selected using the navigation modal.
|
|
* @param slot Slot of the question to scroll to.
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
async changePage(page: number, fromModal?: boolean, slot?: number): Promise<void> {
|
|
if (!this.attempt) {
|
|
return;
|
|
}
|
|
|
|
if (page != -1 && (this.attempt.state == AddonModQuizProvider.ATTEMPT_OVERDUE || this.attempt.finishedOffline)) {
|
|
// We can't load a page if overdue or the local attempt is finished.
|
|
return;
|
|
} else if (page == this.attempt.currentpage && !this.showSummary && slot !== undefined) {
|
|
// Navigating to a question in the current page.
|
|
await this.scrollToQuestion(slot);
|
|
|
|
return;
|
|
} else if (
|
|
(page == this.attempt.currentpage && !this.showSummary) ||
|
|
(fromModal && this.isSequential && page != this.attempt.currentpage)
|
|
) {
|
|
// If the user is navigating to the current page we do nothing.
|
|
// Also, in sequential quizzes we can only navigate to the current page.
|
|
return;
|
|
} else if (page === -1 && this.showSummary) {
|
|
// Summary already shown.
|
|
return;
|
|
}
|
|
|
|
this.content?.scrollToTop();
|
|
|
|
// First try to save the attempt data. We only save it if we're not seeing the summary.
|
|
if (!this.showSummary) {
|
|
const modal = await CoreDomUtils.showModalLoading('core.sending', true);
|
|
|
|
try {
|
|
await this.processAttempt(false, false);
|
|
|
|
modal.dismiss();
|
|
} catch (error) {
|
|
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true);
|
|
modal.dismiss();
|
|
|
|
return;
|
|
}
|
|
|
|
this.reloadNavigation = true; // Data sent to server, navigation should be reloaded.
|
|
}
|
|
|
|
this.loaded = false;
|
|
|
|
try {
|
|
// Attempt data successfully saved, load the page or summary.
|
|
// Stop checking for changes during page change.
|
|
this.autoSave.stopCheckChangesProcess();
|
|
|
|
if (page === -1) {
|
|
await this.loadSummary();
|
|
} else {
|
|
await this.loadPage(page);
|
|
}
|
|
} catch (error) {
|
|
// If the user isn't seeing the summary, start the check again.
|
|
if (!this.showSummary && this.quiz) {
|
|
this.autoSave.startCheckChangesProcess(this.quiz, this.attempt, this.preflightData, this.offline);
|
|
}
|
|
|
|
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true);
|
|
} finally {
|
|
this.loaded = true;
|
|
|
|
if (slot !== undefined) {
|
|
// Scroll to the question.
|
|
await this.scrollToQuestion(slot);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convenience function to get the quiz data.
|
|
*
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
protected async fetchData(): Promise<void> {
|
|
this.quiz = await AddonModQuiz.getQuiz(this.courseId, this.cmId);
|
|
|
|
// Block the quiz so it cannot be synced.
|
|
CoreSync.blockOperation(AddonModQuizProvider.COMPONENT, this.quiz.id);
|
|
|
|
// Wait for any ongoing sync to finish. We won't sync a quiz while it's being played.
|
|
await AddonModQuizSync.waitForSync(this.quiz.id);
|
|
|
|
this.isSequential = AddonModQuiz.isNavigationSequential(this.quiz);
|
|
|
|
if (AddonModQuiz.isQuizOffline(this.quiz)) {
|
|
// Quiz supports offline.
|
|
this.offline = true;
|
|
} else {
|
|
// Quiz doesn't support offline right now, but maybe it did and then the setting was changed.
|
|
// If we have an unfinished offline attempt then we'll use offline mode.
|
|
this.offline = await AddonModQuiz.isLastAttemptOfflineUnfinished(this.quiz);
|
|
}
|
|
|
|
if (this.quiz.timelimit && this.quiz.timelimit > 0) {
|
|
this.readableTimeLimit = CoreTime.formatTime(this.quiz.timelimit);
|
|
}
|
|
|
|
// Get access information for the quiz.
|
|
this.quizAccessInfo = await AddonModQuiz.getQuizAccessInformation(this.quiz.id, {
|
|
cmId: this.quiz.coursemodule,
|
|
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
|
|
});
|
|
|
|
// Get user attempts to determine last attempt.
|
|
const attempts = await AddonModQuiz.getUserAttempts(this.quiz.id, {
|
|
cmId: this.quiz.coursemodule,
|
|
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
|
|
});
|
|
|
|
if (!attempts.length) {
|
|
// There are no attempts, start a new one.
|
|
this.newAttempt = true;
|
|
|
|
return;
|
|
}
|
|
|
|
// Get the last attempt. If it's finished, start a new one.
|
|
this.lastAttempt = await AddonModQuizHelper.setAttemptCalculatedData(
|
|
this.quiz,
|
|
attempts[attempts.length - 1],
|
|
false,
|
|
undefined,
|
|
true,
|
|
);
|
|
|
|
this.newAttempt = AddonModQuiz.isAttemptFinished(this.lastAttempt.state);
|
|
}
|
|
|
|
/**
|
|
* Finish an attempt, either by timeup or because the user clicked to finish it.
|
|
*
|
|
* @param userFinish Whether the user clicked to finish the attempt.
|
|
* @param timeUp Whether the quiz time is up.
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
async finishAttempt(userFinish?: boolean, timeUp?: boolean): Promise<void> {
|
|
if (!this.quiz || !this.attempt) {
|
|
return;
|
|
}
|
|
|
|
let modal: CoreIonLoadingElement | undefined;
|
|
|
|
try {
|
|
// Show confirm if the user clicked the finish button and the quiz is in progress.
|
|
if (!timeUp && this.attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS) {
|
|
let message = Translate.instant('addon.mod_quiz.confirmclose');
|
|
|
|
const unansweredCount = this.summaryQuestions
|
|
.filter(question => AddonModQuiz.isQuestionUnanswered(question))
|
|
.length;
|
|
|
|
if (!this.isSequential && unansweredCount > 0) {
|
|
const warning = Translate.instant(
|
|
'addon.mod_quiz.submission_confirmation_unanswered',
|
|
{ $a: unansweredCount },
|
|
);
|
|
|
|
message += `
|
|
<ion-card class="core-warning-card">
|
|
<ion-item>
|
|
<ion-label>
|
|
${ warning }
|
|
</ion-label>
|
|
</ion-item>
|
|
</ion-card>
|
|
`;
|
|
}
|
|
|
|
await CoreDomUtils.showConfirm(
|
|
message,
|
|
Translate.instant('addon.mod_quiz.submitallandfinish'),
|
|
Translate.instant('core.submit'),
|
|
);
|
|
}
|
|
|
|
modal = await CoreDomUtils.showModalLoading('core.sending', true);
|
|
|
|
await this.processAttempt(userFinish, timeUp);
|
|
|
|
// Trigger an event to notify the attempt was finished.
|
|
CoreEvents.trigger(AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, {
|
|
quizId: this.quiz.id,
|
|
attemptId: this.attempt.id,
|
|
synced: !this.offline,
|
|
}, CoreSites.getCurrentSiteId());
|
|
|
|
CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'quiz' });
|
|
|
|
if (!timeUp || !this.quiz.graceperiod) {
|
|
// Leave the player.
|
|
this.forceLeave = true;
|
|
CoreNavigator.back();
|
|
} else {
|
|
// Stay in player to show summary.
|
|
this.stopAutoSave();
|
|
this.clearTimer();
|
|
|
|
await this.refreshAttempt();
|
|
await this.loadSummary();
|
|
}
|
|
} catch (error) {
|
|
// eslint-disable-next-line promise/catch-or-return
|
|
CoreDomUtils
|
|
.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true)
|
|
.then(async alert => {
|
|
await alert?.onWillDismiss();
|
|
|
|
if (error instanceof CoreWSError && error.errorcode === 'attemptalreadyclosed') {
|
|
CoreNavigator.back();
|
|
}
|
|
|
|
return;
|
|
});
|
|
} finally {
|
|
modal?.dismiss();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fix sequence checks of current page.
|
|
*
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
protected async fixSequenceChecks(): Promise<void> {
|
|
if (!this.attempt) {
|
|
return;
|
|
}
|
|
|
|
// Get current page data again to get the latest sequencechecks.
|
|
const data = await AddonModQuiz.getAttemptData(this.attempt.id, this.attempt.currentpage ?? 0, this.preflightData, {
|
|
cmId: this.quiz?.coursemodule,
|
|
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
|
|
});
|
|
|
|
const newSequenceChecks: Record<number, { name: string; value: string }> = {};
|
|
|
|
data.questions.forEach((question) => {
|
|
const sequenceCheck = CoreQuestionHelper.getQuestionSequenceCheckFromHtml(question.html);
|
|
if (sequenceCheck) {
|
|
newSequenceChecks[question.slot] = sequenceCheck;
|
|
}
|
|
});
|
|
|
|
// Notify the new sequence checks to the components.
|
|
this.questionComponents?.forEach((component) => {
|
|
component.updateSequenceCheck(newSequenceChecks);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the input answers.
|
|
*
|
|
* @returns Object with the answers.
|
|
*/
|
|
protected getAnswers(): CoreQuestionsAnswers {
|
|
return CoreQuestionHelper.getAnswersFromForm(document.forms['addon-mod_quiz-player-form']);
|
|
}
|
|
|
|
/**
|
|
* Initializes the timer if enabled.
|
|
*/
|
|
protected initTimer(): void {
|
|
if (!this.quizAccessInfo || !this.attempt || !this.attemptAccessInfo?.endtime || this.attemptAccessInfo.endtime < 0) {
|
|
return;
|
|
}
|
|
|
|
// Quiz has an end time. Check if time left should be shown.
|
|
const shouldShowTime = AddonModQuiz.shouldShowTimeLeft(
|
|
this.quizAccessInfo.activerulenames,
|
|
this.attempt,
|
|
this.attemptAccessInfo.endtime,
|
|
);
|
|
|
|
if (shouldShowTime) {
|
|
this.endTime = this.attemptAccessInfo.endtime;
|
|
} else {
|
|
delete this.endTime;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove timer info.
|
|
*/
|
|
protected clearTimer(): void {
|
|
delete this.endTime;
|
|
}
|
|
|
|
/**
|
|
* Load a page questions.
|
|
*
|
|
* @param page The page to load.
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
protected async loadPage(page: number): Promise<void> {
|
|
if (!this.quiz || !this.attempt) {
|
|
return;
|
|
}
|
|
|
|
if (this.isSequential) {
|
|
await this.logViewPage(page);
|
|
}
|
|
|
|
const data = await AddonModQuiz.getAttemptData(this.attempt.id, page, this.preflightData, {
|
|
cmId: this.quiz.coursemodule,
|
|
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
|
|
});
|
|
|
|
// Update attempt, status could change during the execution.
|
|
this.attempt = data.attempt;
|
|
this.attempt.currentpage = page;
|
|
|
|
this.questions = data.questions;
|
|
this.nextPage = data.nextpage;
|
|
this.previousPage = this.isSequential ? -1 : page - 1;
|
|
this.showSummary = false;
|
|
|
|
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');
|
|
|
|
// Check if the question is blocked. If it is, treat it as a description question.
|
|
if (AddonModQuiz.isQuestionBlocked(question)) {
|
|
question.type = 'description';
|
|
}
|
|
});
|
|
|
|
// Mark the page as viewed.
|
|
if (!this.isSequential) {
|
|
await this.logViewPage(page);
|
|
}
|
|
|
|
// Start looking for changes.
|
|
this.autoSave.startCheckChangesProcess(this.quiz, this.attempt, this.preflightData, this.offline);
|
|
}
|
|
|
|
/**
|
|
* Log view a page.
|
|
*
|
|
* @param page Page viewed.
|
|
*/
|
|
protected async logViewPage(page: number): Promise<void> {
|
|
if (!this.quiz || !this.attempt) {
|
|
return;
|
|
}
|
|
|
|
await CoreUtils.ignoreErrors(AddonModQuiz.logViewAttempt(this.attempt.id, page, this.preflightData, this.offline));
|
|
|
|
CoreAnalytics.logEvent({
|
|
type: CoreAnalyticsEventType.VIEW_ITEM,
|
|
ws: 'mod_quiz_view_attempt',
|
|
name: this.quiz.name,
|
|
data: { id: this.attempt.id, quizid: this.quiz.id, page, category: 'quiz' },
|
|
url: `/mod/quiz/attempt.php?attempt=${this.attempt.id}&cmid=${this.cmId}` + (page > 0 ? `&page=${page}` : ''),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Log view summary.
|
|
*/
|
|
protected async logViewSummary(): Promise<void> {
|
|
if (!this.quiz || !this.attempt) {
|
|
return;
|
|
}
|
|
|
|
await CoreUtils.ignoreErrors(
|
|
AddonModQuiz.logViewAttemptSummary(this.attempt.id, this.preflightData, this.quiz.id),
|
|
);
|
|
|
|
CoreAnalytics.logEvent({
|
|
type: CoreAnalyticsEventType.VIEW_ITEM,
|
|
ws: 'mod_quiz_view_attempt_summary',
|
|
name: this.quiz.name,
|
|
data: { id: this.attempt.id, quizid: this.quiz.id, category: 'quiz' },
|
|
url: `/mod/quiz/summary.php?attempt=${this.attempt.id}&cmid=${this.cmId}`,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Refresh attempt data.
|
|
*/
|
|
protected async refreshAttempt(): Promise<void> {
|
|
if (!this.quiz) {
|
|
return;
|
|
}
|
|
|
|
const attempts = await AddonModQuiz.getUserAttempts(this.quiz.id, {
|
|
cmId: this.quiz.coursemodule,
|
|
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
|
|
});
|
|
|
|
this.attempt = attempts.find(attempt => attempt.id === this.attempt?.id);
|
|
}
|
|
|
|
/**
|
|
* Load attempt summary.
|
|
*
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
protected async loadSummary(): Promise<void> {
|
|
if (!this.quiz || !this.attempt) {
|
|
return;
|
|
}
|
|
|
|
this.summaryQuestions = [];
|
|
|
|
this.summaryQuestions = await AddonModQuiz.getAttemptSummary(this.attempt.id, this.preflightData, {
|
|
cmId: this.quiz.coursemodule,
|
|
loadLocal: this.offline,
|
|
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
|
|
});
|
|
|
|
this.showSummary = true;
|
|
this.canReturn = this.attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS && !this.attempt.finishedOffline;
|
|
this.preventSubmitMessages = AddonModQuiz.getPreventSubmitMessages(this.summaryQuestions);
|
|
|
|
this.dueDateWarning = AddonModQuiz.getAttemptDueDateWarning(this.quiz, this.attempt);
|
|
|
|
this.logViewSummary();
|
|
}
|
|
|
|
/**
|
|
* Load data to navigate the questions using the navigation modal.
|
|
*
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
protected async loadNavigation(): Promise<void> {
|
|
if (!this.attempt) {
|
|
return;
|
|
}
|
|
|
|
// We use the attempt summary to build the navigation because it contains all the questions.
|
|
this.navigation = await AddonModQuiz.getAttemptSummary(this.attempt.id, this.preflightData, {
|
|
cmId: this.quiz?.coursemodule,
|
|
loadLocal: this.offline,
|
|
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
|
|
});
|
|
|
|
this.navigation.forEach((question) => {
|
|
question.stateClass = CoreQuestionHelper.getQuestionStateClass(question.state || '');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Open the navigation modal.
|
|
*
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
async openNavigation(): Promise<void> {
|
|
|
|
if (this.reloadNavigation) {
|
|
// Some data has changed, reload the navigation.
|
|
const modal = await CoreDomUtils.showModalLoading();
|
|
|
|
await CoreUtils.ignoreErrors(this.loadNavigation());
|
|
|
|
modal.dismiss();
|
|
this.reloadNavigation = false;
|
|
}
|
|
|
|
// Create the navigation modal.
|
|
const modalData = await CoreDomUtils.openSideModal<AddonModQuizNavigationModalReturn>({
|
|
component: AddonModQuizNavigationModalComponent,
|
|
componentProps: {
|
|
navigation: this.navigation,
|
|
summaryShown: this.showSummary,
|
|
currentPage: this.attempt?.currentpage,
|
|
isReview: false,
|
|
isSequential: this.isSequential,
|
|
},
|
|
});
|
|
|
|
if (!modalData) {
|
|
return;
|
|
}
|
|
|
|
this.changePage(modalData.page, true, modalData.slot);
|
|
}
|
|
|
|
/**
|
|
* Prepare the answers to be sent for the attempt.
|
|
*
|
|
* @param componentId Component ID.
|
|
* @returns Promise resolved with the answers.
|
|
*/
|
|
protected prepareAnswers(componentId: number): Promise<CoreQuestionsAnswers> {
|
|
return CoreQuestionHelper.prepareAnswers(
|
|
this.questions,
|
|
this.getAnswers(),
|
|
this.offline,
|
|
this.component,
|
|
componentId,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Process attempt.
|
|
*
|
|
* @param userFinish Whether the user clicked to finish the attempt.
|
|
* @param timeUp Whether the quiz time is up.
|
|
* @param retrying Whether we're retrying the change.
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
protected async processAttempt(userFinish?: boolean, timeUp?: boolean, retrying?: boolean): Promise<void> {
|
|
if (!this.quiz || !this.attempt) {
|
|
return;
|
|
}
|
|
|
|
// Get the answers to send.
|
|
let answers: CoreQuestionsAnswers = {};
|
|
|
|
if (!this.showSummary) {
|
|
answers = await this.prepareAnswers(this.quiz.coursemodule);
|
|
}
|
|
|
|
try {
|
|
// Send the answers.
|
|
await AddonModQuiz.processAttempt(
|
|
this.quiz,
|
|
this.attempt,
|
|
answers,
|
|
this.preflightData,
|
|
userFinish,
|
|
timeUp,
|
|
this.offline,
|
|
);
|
|
} catch (error) {
|
|
if (!error || error.errorcode != 'submissionoutofsequencefriendlymessage') {
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
// There was an error with the sequence check. Try to ammend it.
|
|
await this.fixSequenceChecks();
|
|
} catch {
|
|
throw error;
|
|
}
|
|
|
|
if (retrying) {
|
|
// We're already retrying, don't send the data again because it could cause an infinite loop.
|
|
throw error;
|
|
}
|
|
|
|
// Sequence checks updated, try to send the data again.
|
|
return this.processAttempt(userFinish, timeUp, true);
|
|
}
|
|
|
|
// Answers saved, cancel auto save.
|
|
this.autoSave.cancelAutoSave();
|
|
this.autoSave.hideAutoSaveError();
|
|
|
|
if (this.formElement) {
|
|
CoreForms.triggerFormSubmittedEvent(this.formElement, !this.offline, CoreSites.getCurrentSiteId());
|
|
}
|
|
|
|
return CoreQuestionHelper.clearTmpData(this.questions, this.component, this.quiz.coursemodule);
|
|
}
|
|
|
|
/**
|
|
* Scroll to a certain question.
|
|
*
|
|
* @param slot Slot of the question to scroll to.
|
|
*/
|
|
protected async scrollToQuestion(slot: number): Promise<void> {
|
|
await CoreUtils.nextTick();
|
|
await CoreDirectivesRegistry.waitDirectivesReady(this.elementRef.nativeElement, 'core-question');
|
|
await CoreDom.scrollToElement(
|
|
this.elementRef.nativeElement,
|
|
'#addon-mod_quiz-question-' + slot,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Show connection error.
|
|
*
|
|
* @param ev Click event.
|
|
*/
|
|
showConnectionError(ev: Event): void {
|
|
this.autoSave.showAutoSaveError(ev);
|
|
}
|
|
|
|
/**
|
|
* Convenience function to start the player.
|
|
*/
|
|
async start(): Promise<void> {
|
|
try {
|
|
this.loaded = false;
|
|
|
|
if (!this.quizDataLoaded) {
|
|
// Fetch data.
|
|
await this.fetchData();
|
|
|
|
this.quizDataLoaded = true;
|
|
}
|
|
|
|
// Quiz data has been loaded, try to start or continue.
|
|
await this.startOrContinueAttempt();
|
|
} catch (error) {
|
|
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true);
|
|
} finally {
|
|
this.loaded = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start or continue an attempt.
|
|
*
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
protected async startOrContinueAttempt(): Promise<void> {
|
|
if (!this.quiz || !this.quizAccessInfo) {
|
|
return;
|
|
}
|
|
|
|
let attempt = this.newAttempt ? undefined : this.lastAttempt;
|
|
|
|
// Get the preflight data and start attempt if needed.
|
|
attempt = await AddonModQuizHelper.getAndCheckPreflightData(
|
|
this.quiz,
|
|
this.quizAccessInfo,
|
|
this.preflightData,
|
|
attempt,
|
|
this.offline,
|
|
false,
|
|
'addon.mod_quiz.startattempt',
|
|
);
|
|
|
|
// Re-fetch attempt access information with the right attempt (might have changed because a new attempt was created).
|
|
this.attemptAccessInfo = await AddonModQuiz.getAttemptAccessInformation(this.quiz.id, attempt.id, {
|
|
cmId: this.quiz.coursemodule,
|
|
readingStrategy: this.offline ? CoreSitesReadingStrategy.PREFER_CACHE : CoreSitesReadingStrategy.ONLY_NETWORK,
|
|
});
|
|
|
|
this.attempt = attempt;
|
|
|
|
await this.loadNavigation();
|
|
|
|
if (this.attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !this.attempt.finishedOffline) {
|
|
// Attempt not overdue and not finished in offline, load page.
|
|
await this.loadPage(this.attempt.currentpage ?? 0);
|
|
|
|
this.initTimer();
|
|
} else {
|
|
// Attempt is overdue or finished in offline, we can only load the summary.
|
|
await this.loadSummary();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Quiz time has finished.
|
|
*/
|
|
timeUp(): void {
|
|
if (this.timeUpCalled) {
|
|
return;
|
|
}
|
|
|
|
this.timeUpCalled = true;
|
|
this.finishAttempt(false, true);
|
|
}
|
|
|
|
/**
|
|
* Stop auto-saving answers.
|
|
*/
|
|
protected stopAutoSave(): void {
|
|
this.autoSave.cancelAutoSave();
|
|
this.autoSave.stopCheckChangesProcess();
|
|
this.autoSaveErrorSubscription?.unsubscribe();
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Question with some calculated data for the view.
|
|
*/
|
|
type QuizQuestion = CoreQuestionQuestionParsed & {
|
|
readableMark?: string;
|
|
};
|