diff --git a/src/addon/mod/quiz/classes/auto-save.ts b/src/addon/mod/quiz/classes/auto-save.ts new file mode 100644 index 000000000..9d31a9ab3 --- /dev/null +++ b/src/addon/mod/quiz/classes/auto-save.ts @@ -0,0 +1,227 @@ +// (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 { PopoverController, Popover } from 'ionic-angular'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; +import { AddonModQuizProvider } from '../providers/quiz'; +import { AddonModQuizConnectionErrorComponent } from '../components/connection-error/connection-error'; +import { BehaviorSubject } from 'rxjs'; + +/** + * Class to support auto-save in quiz. Every certain seconds, it will check if there are changes in the current page answers + * and, if so, it will save them automatically. + */ +export class AddonModQuizAutoSave { + protected CHECK_CHANGES_INTERVAL = 5000; + + protected logger; + protected checkChangesInterval; // Interval to check if there are changes in the answers. + protected loadPreviousAnswersTimeout; // Timeout to load previous answers. + protected autoSaveTimeout; // Timeout to auto-save the answers. + protected popover: Popover; // Popover to display there's been an error. + protected popoverShown = false; // Whether the popover is shown. + protected previousAnswers: any; // The previous answers. It is used to check if answers have changed. + protected errorObservable: BehaviorSubject; // An observable to notify if there's been an error. + + /** + * Constructor. + * + * @param {string} formName Name of the form where the answers are stored. + * @param {string} buttonSelector Selector to find the button to show the connection error. + * @param {CoreLoggerProvider} loggerProvider CoreLoggerProvider instance. + * @param {PopoverController} popoverCtrl PopoverController instance. + * @param {CoreQuestionHelperProvider} questionHelper CoreQuestionHelperProvider instance. + * @param {AddonModQuizProvider} quizProvider AddonModQuizProvider instance. + */ + constructor(protected formName: string, protected buttonSelector: string, loggerProvider: CoreLoggerProvider, + protected popoverCtrl: PopoverController, protected questionHelper: CoreQuestionHelperProvider, + protected quizProvider: AddonModQuizProvider) { + + this.logger = loggerProvider.getInstance('AddonModQuizAutoSave'); + + // Create the popover. + this.popover = this.popoverCtrl.create(AddonModQuizConnectionErrorComponent); + this.popover.onDidDismiss(() => { + this.popoverShown = false; + }); + + // Create the observable to notify if an error happened. + this.errorObservable = new BehaviorSubject(false); + } + + /** + * Cancel a pending auto save. + */ + cancelAutoSave(): void { + clearTimeout(this.autoSaveTimeout); + this.autoSaveTimeout = undefined; + } + + /** + * Check if the answers have changed in a page. + * + * @param {any} quiz Quiz. + * @param {any} attempt Attempt. + * @param {any} preflightData Preflight data. + * @param {boolean} [offline] Whether the quiz is being attempted in offline mode. + */ + checkChanges(quiz: any, attempt: any, preflightData: any, offline?: boolean): void { + if (this.autoSaveTimeout) { + // We already have an auto save pending, no need to check changes. + return; + } + + const answers = this.getAnswers(); + + if (!this.previousAnswers) { + // Previous answers isn't set, set it now. + this.previousAnswers = answers; + } else { + // Check if answers have changed. + let equal = true; + + for (const name in answers) { + if (this.previousAnswers[name] != answers[name]) { + equal = false; + break; + } + } + + if (!equal) { + this.setAutoSaveTimer(quiz, attempt, preflightData, offline); + } + + this.previousAnswers = answers; + } + } + + /** + * Get answers from a form. + * + * @return {any} Answers. + */ + protected getAnswers(): any { + return this.questionHelper.getAnswersFromForm(document.forms[this.formName]); + } + + /** + * Hide the auto save error. + */ + hideAutoSaveError(): void { + this.errorObservable.next(false); + this.popover.dismiss(); + } + + /** + * Returns an observable that will notify when an error happens or stops. + * It will send true when there's an error, and false when the error has been ammended. + * + * @return {BehaviorSubject} Observable. + */ + onError(): BehaviorSubject { + return this.errorObservable; + } + + /** + * Schedule an auto save process if it's not scheduled already. + * + * @param {any} quiz Quiz. + * @param {any} attempt Attempt. + * @param {any} preflightData Preflight data. + * @param {boolean} [offline] Whether the quiz is being attempted in offline mode. + */ + setAutoSaveTimer(quiz: any, attempt: any, preflightData: any, offline?: boolean): void { + // Don't schedule if already shceduled or quiz is almost closed. + if (quiz.autosaveperiod && !this.autoSaveTimeout && !this.quizProvider.isAttemptTimeNearlyOver(quiz, attempt)) { + + // Schedule save. + this.autoSaveTimeout = setTimeout(() => { + const answers = this.getAnswers(); + this.cancelAutoSave(); + this.previousAnswers = answers; // Update previous answers to match what we're sending to the server. + + this.quizProvider.saveAttempt(quiz, attempt, answers, preflightData, offline).then(() => { + // Save successful, we can hide the connection error if it was shown. + this.hideAutoSaveError(); + }).catch((error) => { + // Error auto-saving. Show error and set timer again. + this.logger.warn('Error auto-saving data.', error); + + // If there was no error already, show the error message. + if (!this.errorObservable.getValue()) { + this.errorObservable.next(true); + this.showAutoSaveError(); + } + + // Try again. + this.setAutoSaveTimer(quiz, attempt, preflightData, offline); + }); + }, quiz.autosaveperiod * 1000); + } + } + + /** + * Show an error popover due to an auto save error. + */ + showAutoSaveError(ev?: Event): void { + // Don't show popover if it was already shown. + if (!this.popoverShown) { + this.popoverShown = true; + + // If no event is provided, simulate it targeting the button. + this.popover.present({ + ev: ev || { target: document.querySelector(this.buttonSelector) } + }); + } + } + + /** + * Start a process to periodically check changes in answers. + * + * @param {any} quiz Quiz. + * @param {any} attempt Attempt. + * @param {any} preflightData Preflight data. + * @param {boolean} [offline] Whether the quiz is being attempted in offline mode. + */ + startCheckChangesProcess(quiz: any, attempt: any, preflightData: any, offline?: boolean): void { + if (this.checkChangesInterval || !quiz.autosaveperiod) { + // We already have the interval in place or the quiz has autosave disabled. + return; + } + + this.previousAnswers = undefined; + + // Load initial answers in 2.5 seconds so the first check interval finds them already loaded. + this.loadPreviousAnswersTimeout = setTimeout(() => { + this.checkChanges(quiz, attempt, preflightData, offline); + }, 2500); + + // Check changes every certain time. + this.checkChangesInterval = setInterval(() => { + this.checkChanges(quiz, attempt, preflightData, offline); + }, this.CHECK_CHANGES_INTERVAL); + } + + /** + * Stops the periodical check for changes. + */ + stopCheckChangesProcess(): void { + clearTimeout(this.loadPreviousAnswersTimeout); + clearInterval(this.checkChangesInterval); + + this.loadPreviousAnswersTimeout = undefined; + this.checkChangesInterval = undefined; + } +} diff --git a/src/addon/mod/quiz/components/components.module.ts b/src/addon/mod/quiz/components/components.module.ts index 33fbb3631..e08c92cd4 100644 --- a/src/addon/mod/quiz/components/components.module.ts +++ b/src/addon/mod/quiz/components/components.module.ts @@ -20,10 +20,12 @@ import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CoreCourseComponentsModule } from '@core/course/components/components.module'; import { AddonModQuizIndexComponent } from './index/index'; +import { AddonModQuizConnectionErrorComponent } from './connection-error/connection-error'; @NgModule({ declarations: [ - AddonModQuizIndexComponent + AddonModQuizIndexComponent, + AddonModQuizConnectionErrorComponent ], imports: [ CommonModule, @@ -36,10 +38,12 @@ import { AddonModQuizIndexComponent } from './index/index'; providers: [ ], exports: [ - AddonModQuizIndexComponent + AddonModQuizIndexComponent, + AddonModQuizConnectionErrorComponent ], entryComponents: [ - AddonModQuizIndexComponent + AddonModQuizIndexComponent, + AddonModQuizConnectionErrorComponent ] }) export class AddonModQuizComponentsModule {} diff --git a/src/addon/mod/quiz/components/connection-error/connection-error.scss b/src/addon/mod/quiz/components/connection-error/connection-error.scss new file mode 100644 index 000000000..334d83ebf --- /dev/null +++ b/src/addon/mod/quiz/components/connection-error/connection-error.scss @@ -0,0 +1,7 @@ +addon-mod-quiz-connection-error { + background-color: $red-light; + + .item { + background-color: $red-light; + } +} diff --git a/src/addon/mod/quiz/components/connection-error/connection-error.ts b/src/addon/mod/quiz/components/connection-error/connection-error.ts new file mode 100644 index 000000000..e6013e240 --- /dev/null +++ b/src/addon/mod/quiz/components/connection-error/connection-error.ts @@ -0,0 +1,29 @@ +// (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 } from '@angular/core'; + +/** + * Component that displays a quiz entry page. + */ +@Component({ + selector: 'addon-mod-quiz-connection-error', + template: '{{ "addon.mod_quiz.connectionerror" | translate }}', +}) +export class AddonModQuizConnectionErrorComponent { + + constructor() { + // Nothing to do. + } +} diff --git a/src/addon/mod/quiz/components/index/index.ts b/src/addon/mod/quiz/components/index/index.ts index 6b8536296..f21bcc1e0 100644 --- a/src/addon/mod/quiz/components/index/index.ts +++ b/src/addon/mod/quiz/components/index/index.ts @@ -65,6 +65,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp protected gradebookData: {grade: number, feedback?: string}; // The gradebook grade and feedback. protected overallStats: boolean; // Equivalent to overallstats in mod_quiz_view_object in Moodle. protected finishedObserver: any; // It will observe attempt finished events. + protected hasPlayed = false; // Whether the user has gone to the quiz player (attempted). constructor(injector: Injector, protected quizProvider: AddonModQuizProvider, @Optional() protected content: Content, protected quizHelper: AddonModQuizHelperProvider, protected quizOffline: AddonModQuizOfflineProvider, @@ -401,7 +402,36 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp ionViewDidEnter(): void { super.ionViewDidEnter(); - // @todo: Go to auto review if we're coming from player. + if (this.hasPlayed) { + this.hasPlayed = false; + + // Update data when we come back from the player since the attempt status could have changed. + let promise; + + // Check if we need to go to review an attempt automatically. + if (this.autoReview && this.autoReview.synced) { + promise = this.goToAutoReview(); + this.autoReview = undefined; + } else { + promise = Promise.resolve(); + } + + // Refresh data. + this.loaded = false; + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + this.content.scrollToTop(); + + promise.then(() => { + this.refreshContent().finally(() => { + this.loaded = true; + this.refreshIcon = 'refresh'; + this.syncIcon = 'sync'; + }); + }); + } else { + this.autoReview = undefined; + } } /** @@ -410,6 +440,10 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp ionViewDidLeave(): void { super.ionViewDidLeave(); this.autoReview = undefined; + + if (this.navCtrl.getActive().component.name == 'AddonModQuizPlayerPage') { + this.hasPlayed = true; + } } /** diff --git a/src/addon/mod/quiz/pages/player/player.html b/src/addon/mod/quiz/pages/player/player.html index 941f5840a..f37140cc8 100644 --- a/src/addon/mod/quiz/pages/player/player.html +++ b/src/addon/mod/quiz/pages/player/player.html @@ -1,13 +1,14 @@ + + + + + - - - - diff --git a/src/addon/mod/quiz/pages/player/player.ts b/src/addon/mod/quiz/pages/player/player.ts index eb0c26a33..3d0d02208 100644 --- a/src/addon/mod/quiz/pages/player/player.ts +++ b/src/addon/mod/quiz/pages/player/player.ts @@ -12,10 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; -import { IonicPage, NavParams, Content } from 'ionic-angular'; +import { Component, OnInit, OnDestroy, ViewChild, ElementRef, ChangeDetectorRef } from '@angular/core'; +import { IonicPage, NavParams, Content, PopoverController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreEventsProvider } from '@providers/events'; +import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreSyncProvider } from '@providers/sync'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; @@ -24,6 +25,8 @@ import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; import { AddonModQuizProvider } from '../../providers/quiz'; import { AddonModQuizSyncProvider } from '../../providers/quiz-sync'; import { AddonModQuizHelperProvider } from '../../providers/helper'; +import { AddonModQuizAutoSave } from '../../classes/auto-save'; +import { Subscription } from 'rxjs'; /** * Page that allows attempting a quiz. @@ -53,6 +56,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { canReturn: boolean; // 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: boolean; // Whether there's been an error in auto-save. protected element: HTMLElement; // Host element of the page. protected courseId: number; // The course ID the quiz belongs to. @@ -64,13 +68,15 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { protected newAttempt: boolean; // Whether the user is starting a new attempt. protected quizDataLoaded: boolean; // Whether the quiz data has been loaded. 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. - constructor(navParams: NavParams, element: ElementRef, protected translate: TranslateService, + constructor(navParams: NavParams, element: ElementRef, logger: CoreLoggerProvider, protected translate: TranslateService, protected eventsProvider: CoreEventsProvider, protected sitesProvider: CoreSitesProvider, - protected syncProvider: CoreSyncProvider, protected domUtils: CoreDomUtilsProvider, + protected syncProvider: CoreSyncProvider, protected domUtils: CoreDomUtilsProvider, popoverCtrl: PopoverController, protected timeUtils: CoreTimeUtilsProvider, protected quizProvider: AddonModQuizProvider, protected quizHelper: AddonModQuizHelperProvider, protected quizSync: AddonModQuizSyncProvider, - protected questionHelper: CoreQuestionHelperProvider) { + protected questionHelper: CoreQuestionHelperProvider, protected cdr: ChangeDetectorRef) { this.quizId = navParams.get('quizId'); this.courseId = navParams.get('courseId'); @@ -78,6 +84,10 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { // Block the quiz so it cannot be synced. this.syncProvider.blockOperation(AddonModQuizProvider.COMPONENT, this.quizId); + + // Create the auto save instance. + this.autoSave = new AddonModQuizAutoSave('addon-mod_quiz-player-form', '#addon-mod_quiz-connection-error-button', + logger, popoverCtrl, questionHelper, quizProvider); } /** @@ -86,6 +96,12 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { ngOnInit(): void { // 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.cdr.detectChanges(); + }); } /** @@ -93,8 +109,9 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { */ ngOnDestroy(): void { // Stop auto save. - // @todo $mmaModQuizAutoSave.stopAutoSaving(); - // @todo $mmaModQuizAutoSave.stopCheckChangesProcess(); + this.autoSave.cancelAutoSave(); + this.autoSave.stopCheckChangesProcess(); + this.autoSaveErrorSubscription && this.autoSaveErrorSubscription.unsubscribe(); // Unblock the quiz so it can be synced. this.syncProvider.unblockOperation(AddonModQuizProvider.COMPONENT, this.quizId); @@ -176,20 +193,28 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { promise.then(() => { // Attempt data successfully saved, load the page or summary. - if (page === -1) { - return this.loadSummary(); - } else { - // @todo $mmaModQuizAutoSave.stopCheckChangesProcess(); // Stop checking for changes during page change. + // Attempt data successfully saved, load the page or summary. + let subPromise; - return this.loadPage(page).catch((error) => { - // @todo $mmaModQuizAutoSave.startCheckChangesProcess($scope, quiz, attempt); // Start the check again. - this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true); - }); + // Stop checking for changes during page change. + this.autoSave.stopCheckChangesProcess(); + + if (page === -1) { + subPromise = this.loadSummary(); + } else { + subPromise = this.loadPage(page); } + + return subPromise.catch((error) => { + // If the user isn't seeing the summary, start the check again. + if (!this.showSummary) { + this.autoSave.startCheckChangesProcess(this.quiz, this.attempt, this.preflightData, this.offline); + } + + this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true); + }); }, (error) => { this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true); - }).catch((error) => { - this.domUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true); }).finally(() => { this.loaded = true; @@ -365,7 +390,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { this.quizProvider.logViewAttempt(this.attempt.id, page, this.preflightData, this.offline); // Start looking for changes. - // @todo $mmaModQuizAutoSave.startCheckChangesProcess($scope, quiz, attempt); + this.autoSave.startCheckChangesProcess(this.quiz, this.attempt, this.preflightData, this.offline); }); } @@ -423,8 +448,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { this.offline); }).then(() => { // Answers saved, cancel auto save. - // @todo $mmaModQuizAutoSave.cancelAutoSave(); - // @todo $mmaModQuizAutoSave.hideAutoSaveError($scope); + this.autoSave.cancelAutoSave(); + this.autoSave.hideAutoSaveError(); }); } @@ -443,7 +468,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { * @param {Event} ev Click event. */ showConnectionError(ev: Event): void { - // @todo + this.autoSave.showAutoSaveError(ev); } /**