diff --git a/src/addon/mod/quiz/pages/preflight-modal/preflight-modal.html b/src/addon/mod/quiz/pages/preflight-modal/preflight-modal.html new file mode 100644 index 000000000..db49a795e --- /dev/null +++ b/src/addon/mod/quiz/pages/preflight-modal/preflight-modal.html @@ -0,0 +1,27 @@ + + + {{ title | translate }} + + + + + + + +
+ + + +

Couldn't find the directive to render this access rule.

+
+ +
+ + +
+
+
diff --git a/src/addon/mod/quiz/pages/preflight-modal/preflight-modal.module.ts b/src/addon/mod/quiz/pages/preflight-modal/preflight-modal.module.ts new file mode 100644 index 000000000..740c8f325 --- /dev/null +++ b/src/addon/mod/quiz/pages/preflight-modal/preflight-modal.module.ts @@ -0,0 +1,31 @@ +// (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 { AddonModQuizPreflightModalPage } from './preflight-modal'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; + +@NgModule({ + declarations: [ + AddonModQuizPreflightModalPage + ], + imports: [ + CoreComponentsModule, + IonicPageModule.forChild(AddonModQuizPreflightModalPage), + TranslateModule.forChild() + ] +}) +export class AddonModQuizPreflightModalModule {} diff --git a/src/addon/mod/quiz/pages/preflight-modal/preflight-modal.ts b/src/addon/mod/quiz/pages/preflight-modal/preflight-modal.ts new file mode 100644 index 000000000..48e704f54 --- /dev/null +++ b/src/addon/mod/quiz/pages/preflight-modal/preflight-modal.ts @@ -0,0 +1,122 @@ +// (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, Injector, ViewChild } from '@angular/core'; +import { IonicPage, ViewController, NavParams, Content } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { AddonModQuizAccessRuleDelegate } from '../../providers/access-rules-delegate'; + +/** + * Modal that renders the access rules for a quiz. + */ +@IonicPage({ segment: 'addon-mod-quiz-preflight-modal' }) +@Component({ + selector: 'page-addon-mod-quiz-preflight-modal', + templateUrl: 'preflight-modal.html', +}) +export class AddonModQuizPreflightModalPage implements OnInit { + + @ViewChild(Content) content: Content; + + preflightForm: FormGroup; + title: string; + accessRulesComponent: any[] = []; + data: any; + loaded: boolean; + + protected quiz: any; + protected attempt: any; + protected prefetch: boolean; + protected siteId: string; + protected rules: string[]; + protected renderedRules: string[] = []; + + constructor(params: NavParams, fb: FormBuilder, translate: TranslateService, sitesProvider: CoreSitesProvider, + protected viewCtrl: ViewController, protected accessRuleDelegate: AddonModQuizAccessRuleDelegate, + protected injector: Injector, protected domUtils: CoreDomUtilsProvider) { + + this.title = params.get('title') || translate.instant('addon.mod_quiz.startattempt'); + this.quiz = params.get('quiz'); + this.attempt = params.get('attempt'); + this.prefetch = params.get('prefetch'); + this.siteId = params.get('siteId') || sitesProvider.getCurrentSiteId(); + this.rules = params.get('rules') || []; + + // Create an empty form group. The controls will be added by the access rules components. + this.preflightForm = fb.group({}); + + // Create the data to pass to the access rules components. + this.data = { + quiz: this.quiz, + attempt: this.attempt, + prefetch: this.prefetch, + form: this.preflightForm, + siteId: this.siteId + }; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + const promises = []; + + this.rules.forEach((rule) => { + // Check if preflight is required for rule and, if so, get the component to render it. + promises.push(this.accessRuleDelegate.isPreflightCheckRequiredForRule(rule, this.quiz, this.attempt, this.prefetch, + this.siteId).then((required) => { + + if (required) { + return this.accessRuleDelegate.getPreflightComponent(rule, this.injector).then((component) => { + if (component) { + this.renderedRules.push(rule); + this.accessRulesComponent.push(component); + } + }); + } + })); + }); + + Promise.all(promises).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error loading rules'); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Check that the data is valid and send it back. + */ + sendData(): void { + if (!this.preflightForm.valid) { + // Form not valid. Scroll to the first element with errors. + if (!this.domUtils.scrollToInputError(this.content)) { + // Input not found, show an error modal. + this.domUtils.showErrorModal('core.errorinvalidform', true); + } + } else { + this.viewCtrl.dismiss(this.preflightForm.value); + } + } + + /** + * Close modal. + */ + closeModal(): void { + this.viewCtrl.dismiss(); + } +} diff --git a/src/addon/mod/quiz/providers/access-rules-delegate.ts b/src/addon/mod/quiz/providers/access-rules-delegate.ts index 730023106..ace7c6c69 100644 --- a/src/addon/mod/quiz/providers/access-rules-delegate.ts +++ b/src/addon/mod/quiz/providers/access-rules-delegate.ts @@ -34,24 +34,24 @@ export interface AddonModQuizAccessRuleHandler extends CoreDelegateHandler { * Whether the rule requires a preflight check when prefetch/start/continue an attempt. * * @param {any} quiz The quiz the rule belongs to. - * @param {any} attempt The attempt started/continued. + * @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt. * @param {boolean} [prefetch] Whether the user is prefetching the quiz. * @param {string} [siteId] Site ID. If not defined, current site. * @return {boolean|Promise} Whether the rule requires a preflight check. */ - isPreflightCheckRequired(quiz: any, attempt: any, prefetch?: boolean, siteId?: string): boolean | Promise; + isPreflightCheckRequired(quiz: any, attempt?: any, prefetch?: boolean, siteId?: string): boolean | Promise; /** * Add preflight data that doesn't require user interaction. The data should be added to the preflightData param. * * @param {any} quiz The quiz the rule belongs to. - * @param {any} attempt The attempt started/continued. * @param {any} preflightData Object where to add the preflight data. + * @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt. * @param {boolean} [prefetch] Whether the user is prefetching the quiz. * @param {string} [siteId] Site ID. If not defined, current site. * @return {void|Promise} Promise resolved when done if async, void if it's synchronous. */ - getFixedPreflightData?(quiz: any, attempt: any, preflightData: any, prefetch?: boolean, siteId?: string): void | Promise; + getFixedPreflightData?(quiz: any, preflightData: any, attempt?: any, prefetch?: boolean, siteId?: string): void | Promise; /** * Return the Component to use to display the access rule preflight. @@ -128,20 +128,20 @@ export class AddonModQuizAccessRuleDelegate extends CoreDelegate { * * @param {string[]} rules List of active rules names. * @param {any} quiz Quiz. - * @param {any} attempt Attempt. * @param {any} preflightData Object where to store the preflight data. + * @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt. * @param {boolean} [prefetch] Whether the user is prefetching the quiz. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when all the data has been gathered. */ - getFixedPreflightData(rules: string[], quiz: any, attempt: any, preflightData: any, prefetch?: boolean, siteId?: string) + getFixedPreflightData(rules: string[], quiz: any, preflightData: any, attempt?: any, prefetch?: boolean, siteId?: string) : Promise { rules = rules || []; const promises = []; rules.forEach((rule) => { promises.push(Promise.resolve( - this.executeFunctionOnEnabled(rule, 'getFixedPreflightData', [quiz, attempt, preflightData, prefetch, siteId]) + this.executeFunctionOnEnabled(rule, 'getFixedPreflightData', [quiz, preflightData, attempt, prefetch, siteId]) )); }); @@ -150,6 +150,16 @@ export class AddonModQuizAccessRuleDelegate extends CoreDelegate { }); } + /** + * Get the Component to use to display the access rule preflight. + * + * @param {Injector} injector Injector. + * @return {Promise} Promise resolved with the component to use, undefined if not found. + */ + getPreflightComponent(rule: string, injector: Injector): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(rule, 'getPreflightComponent', [injector])); + } + /** * Check if an access rule is supported. * @@ -165,7 +175,7 @@ export class AddonModQuizAccessRuleDelegate extends CoreDelegate { * * @param {string[]} rules List of active rules names. * @param {any} quiz Quiz. - * @param {any} attempt Attempt. + * @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt. * @param {boolean} [prefetch] Whether the user is prefetching the quiz. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with boolean: whether it's required. @@ -177,9 +187,7 @@ export class AddonModQuizAccessRuleDelegate extends CoreDelegate { let isRequired = false; rules.forEach((rule) => { - promises.push(Promise.resolve( - this.executeFunctionOnEnabled(rule, 'isPreflightCheckRequired', [quiz, attempt, prefetch, siteId]) - ).then((required) => { + promises.push(this.isPreflightCheckRequiredForRule(rule, quiz, attempt, prefetch, siteId).then((required) => { if (required) { isRequired = true; } @@ -194,6 +202,20 @@ export class AddonModQuizAccessRuleDelegate extends CoreDelegate { }); } + /** + * Check if preflight check is required for a certain rule. + * + * @param {string} rule Rule name. + * @param {any} quiz Quiz. + * @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt. + * @param {boolean} [prefetch] Whether the user is prefetching the quiz. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether it's required. + */ + isPreflightCheckRequiredForRule(rule: string, quiz: any, attempt: any, prefetch?: boolean, siteId?: string): Promise { + return Promise.resolve(this.executeFunctionOnEnabled(rule, 'isPreflightCheckRequired', [quiz, attempt, prefetch, siteId])); + } + /** * Notify all rules that the preflight check has passed. * diff --git a/src/addon/mod/quiz/providers/helper.ts b/src/addon/mod/quiz/providers/helper.ts new file mode 100644 index 000000000..4404a6703 --- /dev/null +++ b/src/addon/mod/quiz/providers/helper.ts @@ -0,0 +1,270 @@ +// (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 { Injectable } from '@angular/core'; +import { ModalController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonModQuizProvider } from './quiz'; +import { AddonModQuizOfflineProvider } from './quiz-offline'; +import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate'; + +/** + * Helper service that provides some features for quiz. + */ +@Injectable() +export class AddonModQuizHelperProvider { + + protected div = document.createElement('div'); // A div element to search in HTML code. + + constructor(private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private utils: CoreUtilsProvider, + private accessRuleDelegate: AddonModQuizAccessRuleDelegate, private quizProvider: AddonModQuizProvider, + private modalCtrl: ModalController, private quizOfflineProvider: AddonModQuizOfflineProvider) { } + + /** + * Validate a preflight data or show a modal to input the preflight data if required. + * It calls AddonModQuizProvider.startAttempt if a new attempt is needed. + * + * @param {any} quiz Quiz. + * @param {any} accessInfo Quiz access info returned by AddonModQuizProvider.getQuizAccessInformation. + * @param {any} preflightData Object where to store the preflight data. + * @param {any} [attempt] Attempt to continue. Don't pass any value if the user needs to start a new attempt. + * @param {boolean} [offline] Whether the attempt is offline. + * @param {boolean} [prefetch] Whether user is prefetching. + * @param {string} [title] The title to display in the modal and in the submit button. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {boolean} [retrying] Whether we're retrying after a failure. + * @return {Promise} Promise resolved when the preflight data is validated. The resolve param is the attempt. + */ + getAndCheckPreflightData(quiz: any, accessInfo: any, preflightData: any, attempt: any, offline?: boolean, prefetch?: boolean, + title?: string, siteId?: string, retrying?: boolean): Promise { + + const rules = accessInfo.activerulenames; + let isPreflightCheckRequired = false; + + // Check if the user needs to input preflight data. + return this.accessRuleDelegate.isPreflightCheckRequired(rules, quiz, attempt, prefetch, siteId).then((required) => { + isPreflightCheckRequired = required; + + if (required) { + // Preflight check is required but no preflightData has been sent. Show a modal with the preflight form. + return this.getPreflightData(quiz, accessInfo, attempt, prefetch, title, siteId).then((data) => { + // Data entered by the user, add it to preflight data and check it again. + Object.assign(preflightData, data); + }); + } + }).then(() => { + // Get some fixed preflight data from access rules (data that doesn't require user interaction). + return this.accessRuleDelegate.getFixedPreflightData(rules, quiz, preflightData, attempt, prefetch, siteId); + }).then(() => { + + // All the preflight data is gathered, now validate it. + return this.validatePreflightData(quiz, accessInfo, preflightData, attempt, offline, prefetch, siteId) + .catch((error) => { + + if (prefetch) { + return Promise.reject(error); + } else if (retrying && !isPreflightCheckRequired) { + // We're retrying after a failure, but the preflight check wasn't required. + // If this happens it means there's something wrong with some access rule. + // Don't retry again because it would lead to an infinite loop. + return Promise.reject(error); + } else { + // Show error and ask for the preflight again. + // Wait to show the error because we want it to be shown over the preflight modal. + setTimeout(() => { + this.domUtils.showErrorModalDefault(error, 'core.error', true); + }, 100); + + return this.getAndCheckPreflightData(quiz, accessInfo, preflightData, attempt, offline, prefetch, + title, siteId, true); + } + }); + }); + } + + /** + * Get the preflight data from the user using a modal. + * + * @param {any} quiz Quiz. + * @param {any} accessInfo Quiz access info returned by AddonModQuizProvider.getQuizAccessInformation. + * @param {any} [attempt] The attempt started/continued. If not supplied, user is starting a new attempt. + * @param {boolean} [prefetch] Whether the user is prefetching the quiz. + * @param {string} [title] The title to display in the modal and in the submit button. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the preflight data. Rejected if user cancels. + */ + getPreflightData(quiz: any, accessInfo: any, attempt: any, prefetch?: boolean, title?: string, siteId?: string): Promise { + const notSupported: string[] = []; + + // Check if there is any unsupported rule. + accessInfo.activerulenames.forEach((rule) => { + if (!this.accessRuleDelegate.isAccessRuleSupported(rule)) { + notSupported.push(rule); + } + }); + + if (notSupported.length) { + return Promise.reject(this.translate.instant('addon.mod_quiz.errorrulesnotsupported') + ' ' + + JSON.stringify(notSupported)); + } + + // Create and show the modal. + const modal = this.modalCtrl.create('AddonModQuizPreflightModalPage', { + title: title, + quiz: quiz, + attempt: attempt, + prefetch: !!prefetch, + siteId: siteId, + rules: accessInfo.activerulenames + }); + + modal.present(); + + // Wait for modal to be dismissed. + return new Promise((resolve, reject): void => { + modal.onDidDismiss((data) => { + if (typeof data != 'undefined') { + resolve(data); + } else { + reject(null); + } + }); + }); + } + + /** + * Gets the mark string from a question HTML. + * Example result: "Marked out of 1.00". + * + * @param {string} html Question's HTML. + * @return {string} Question's mark. + */ + getQuestionMarkFromHtml(html: string): string { + this.div.innerHTML = html; + + return this.domUtils.getContentsOfElement(this.div, '.grade'); + } + + /** + * Add some calculated data to the attempt. + * + * @param {any} quiz Quiz. + * @param {any} attempt Attempt. + * @param {boolean} highlight Whether we should check if attempt should be highlighted. + * @param {number} [bestGrade] Quiz's best grade (formatted). Required if highlight=true. + */ + setAttemptCalculatedData(quiz: any, attempt: any, highlight?: boolean, bestGrade?: string): void { + + attempt.rescaledGrade = this.quizProvider.rescaleGrade(attempt.sumgrades, quiz, false); + attempt.finished = this.quizProvider.isAttemptFinished(attempt.state); + attempt.readableState = this.quizProvider.getAttemptReadableState(quiz, attempt); + + if (quiz.showMarkColumn && attempt.finished) { + attempt.readableMark = this.quizProvider.formatGrade(attempt.sumgrades, quiz.decimalpoints); + } else { + attempt.readableMark = ''; + } + + if (quiz.showGradeColumn && attempt.finished) { + attempt.readableGrade = this.quizProvider.formatGrade(attempt.rescaledGrade, quiz.decimalpoints); + + // Highlight the highest grade if appropriate. + attempt.highlightGrade = highlight && !attempt.preview && attempt.state == AddonModQuizProvider.ATTEMPT_FINISHED && + attempt.readableGrade == bestGrade; + } else { + attempt.readableGrade = ''; + } + } + + /** + * Add some calculated data to the quiz. + * + * @param {any} quiz Quiz. + * @param {any} options Options returned by AddonModQuizProvider.getCombinedReviewOptions. + */ + setQuizCalculatedData(quiz: any, options: any): void { + quiz.sumGradesFormatted = this.quizProvider.formatGrade(quiz.sumgrades, quiz.decimalpoints); + quiz.gradeFormatted = this.quizProvider.formatGrade(quiz.grade, quiz.decimalpoints); + + quiz.showAttemptColumn = quiz.attempts != 1; + quiz.showGradeColumn = options.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX && + this.quizProvider.quizHasGrades(quiz); + quiz.showMarkColumn = quiz.showGradeColumn && quiz.grade != quiz.sumgrades; + quiz.showFeedbackColumn = quiz.hasfeedback && options.alloptions.overallfeedback; + } + + /** + * Validate the preflight data. It calls AddonModQuizProvider.startAttempt if a new attempt is needed. + * + * @param {any} quiz Quiz. + * @param {any} accessInfo Quiz access info returned by AddonModQuizProvider.getQuizAccessInformation. + * @param {any} preflightData Object where to store the preflight data. + * @param {any} [attempt] Attempt to continue. Don't pass any value if the user needs to start a new attempt. + * @param {boolean} [offline] Whether the attempt is offline. + * @param {boolean} [sent] Whether preflight data has been entered by the user. + * @param {boolean} [prefetch] Whether user is prefetching. + * @param {string} [title] The title to display in the modal and in the submit button. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the preflight data is validated. + */ + validatePreflightData(quiz: any, accessInfo: any, preflightData: any, attempt: any, offline?: boolean, prefetch?: boolean, + siteId?: string): Promise { + + const rules = accessInfo.activerulenames; + let promise; + + if (attempt) { + if (attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !attempt.finishedOffline) { + // We're continuing an attempt. Call getAttemptData to validate the preflight data. + const page = attempt.currentpage; + + promise = this.quizProvider.getAttemptData(attempt.id, page, preflightData, offline, true, siteId).then(() => { + if (offline) { + // Get current page stored in local. + return this.quizOfflineProvider.getAttemptById(attempt.id).then((localAttempt) => { + attempt.currentpage = localAttempt.currentpage; + }).catch(() => { + // No local data. + }); + } + }); + } else { + // Attempt is overdue or finished in offline, we can only see the summary. + // Call getAttemptSummary to validate the preflight data. + promise = this.quizProvider.getAttemptSummary(attempt.id, preflightData, offline, true, false, siteId); + } + } else { + // We're starting a new attempt, call startAttempt. + promise = this.quizProvider.startAttempt(quiz.id, preflightData, false, siteId).then((att) => { + attempt = att; + }); + } + + return promise.then(() => { + // Preflight data validated. + this.accessRuleDelegate.notifyPreflightCheckPassed(rules, quiz, attempt, preflightData, prefetch, siteId); + + return attempt; + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // The WebService returned an error, assume the preflight failed. + this.accessRuleDelegate.notifyPreflightCheckFailed(rules, quiz, attempt, preflightData, prefetch, siteId); + } + + return Promise.reject(error); + }); + } +}