+
diff --git a/src/core/question/components/question/question.ts b/src/core/question/components/question/question.ts
new file mode 100644
index 000000000..ddfb7ecea
--- /dev/null
+++ b/src/core/question/components/question/question.ts
@@ -0,0 +1,149 @@
+// (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, Input, Output, OnInit, Injector, EventEmitter } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreDomUtilsProvider } from '@providers/utils/dom';
+import { CoreUtilsProvider } from '@providers/utils/utils';
+import { CoreQuestionProvider } from '../../providers/question';
+import { CoreQuestionDelegate } from '../../providers/delegate';
+import { CoreQuestionBehaviourDelegate } from '../../providers/behaviour-delegate';
+import { CoreQuestionHelperProvider } from '../../providers/helper';
+
+/**
+ * Component to render a question.
+ */
+@Component({
+ selector: 'core-question',
+ templateUrl: 'question.html'
+})
+export class CoreQuestionComponent implements OnInit {
+ @Input() question: any; // The question to render.
+ @Input() component: string; // The component the question belongs to.
+ @Input() componentId: number; // ID of the component the question belongs to.
+ @Input() attemptId: number; // Attempt ID.
+ @Input() offlineEnabled?: boolean | string; // Whether the question can be answered in offline.
+ @Output() buttonClicked: EventEmitter; // Will emit an event when a behaviour button is clicked.
+ @Output() onAbort: EventEmitter; // Will emit an event if the question should be aborted.
+
+ componentClass: any; // The class of the component to render.
+ data: any = {}; // Data to pass to the component.
+ seqCheck: {name: string, value: string}; // Sequenche check name and value (if any).
+ behaviourComponents: any[] = []; // Components to render the question behaviour.
+ loaded = false;
+
+ protected logger;
+
+ constructor(logger: CoreLoggerProvider, protected injector: Injector, protected questionDelegate: CoreQuestionDelegate,
+ protected utils: CoreUtilsProvider, protected behaviourDelegate: CoreQuestionBehaviourDelegate,
+ protected questionHelper: CoreQuestionHelperProvider, protected translate: TranslateService,
+ protected questionProvider: CoreQuestionProvider, protected domUtils: CoreDomUtilsProvider) {
+ logger = logger.getInstance('CoreQuestionComponent');
+
+ this.buttonClicked = new EventEmitter();
+ this.onAbort = new EventEmitter();
+ }
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ this.offlineEnabled = this.utils.isTrueOrOne(this.offlineEnabled);
+
+ if (!this.question) {
+ this.loaded = true;
+
+ return;
+ }
+
+ // Get the component to render the question.
+ this.questionDelegate.getComponentForQuestion(this.injector, this.question).then((componentClass) => {
+ this.componentClass = componentClass;
+
+ if (componentClass) {
+ // Set up the data needed by the question and behaviour components.
+ this.data = {
+ question: this.question,
+ component: this.component,
+ componentId: this.componentId,
+ attemptId: this.attemptId,
+ offlineEnabled: this.offlineEnabled,
+ buttonClicked: this.buttonClicked,
+ onAbort: this.onAbort
+ };
+
+ // Treat the question.
+ this.questionHelper.extractQuestionScripts(this.question);
+
+ // Handle question behaviour.
+ const behaviour = this.questionDelegate.getBehaviourForQuestion(this.question, this.question.preferredBehaviour);
+ if (!this.behaviourDelegate.isBehaviourSupported(behaviour)) {
+ // Behaviour not supported, abort.
+ this.logger.warn('Aborting question because the behaviour is not supported.', this.question.name);
+ this.questionHelper.showComponentError(this.onAbort,
+ this.translate.instant('addon.mod_quiz.errorbehaviournotsupported') + ' ' + behaviour);
+
+ return;
+ }
+
+ // Get the sequence check (hidden input). This is required.
+ this.seqCheck = this.questionHelper.getQuestionSequenceCheckFromHtml(this.question.html);
+ if (!this.seqCheck) {
+ this.logger.warn('Aborting question because couldn\'t retrieve sequence check.', this.question.name);
+ this.questionHelper.showComponentError(this.onAbort);
+
+ return;
+ }
+
+ // Load local answers if offline is enabled.
+ let promise;
+ if (this.offlineEnabled) {
+ promise = this.questionProvider.getQuestionAnswers(this.component, this.attemptId, this.question.slot)
+ .then((answers) => {
+ this.question.localAnswers = this.questionProvider.convertAnswersArrayToObject(answers, true);
+ }).catch(() => {
+ this.question.localAnswers = {};
+ });
+ } else {
+ this.question.localAnswers = {};
+ promise = Promise.resolve();
+ }
+
+ promise.then(() => {
+ // Handle behaviour.
+ this.behaviourDelegate.handleQuestion(this.question, this.question.preferredBehaviour).then((comps) => {
+ this.behaviourComponents = comps;
+ });
+ this.questionHelper.extractQbehaviourRedoButton(this.question);
+ this.question.html = this.domUtils.removeElementFromHtml(this.question.html, '.im-controls');
+
+ // Extract the validation error of the question.
+ this.question.validationError = this.questionHelper.getValidationErrorFromHtml(this.question.html);
+
+ // Load the local answers in the HTML.
+ this.questionHelper.loadLocalAnswersInHtml(this.question);
+
+ // Try to extract the feedback and comment for the question.
+ this.questionHelper.extractQuestionFeedback(this.question);
+ this.questionHelper.extractQuestionComment(this.question);
+
+ this.loaded = true;
+ });
+ }
+ }).catch(() => {
+ // Ignore errors.
+ });
+ }
+}
diff --git a/src/core/question/lang/en.json b/src/core/question/lang/en.json
new file mode 100644
index 000000000..fa363ebfd
--- /dev/null
+++ b/src/core/question/lang/en.json
@@ -0,0 +1,22 @@
+{
+ "answer": "Answer",
+ "answersaved": "Answer saved",
+ "certainty": "Certainty",
+ "complete": "Complete",
+ "correct": "Correct",
+ "errorattachmentsnotsupported": "The application doesn't support attaching files to answers yet.",
+ "errorinlinefilesnotsupported": "The application doesn't support editing inline files yet.",
+ "errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.",
+ "feedback": "Feedback",
+ "howtodraganddrop": "Tap to select then tap to drop.",
+ "incorrect": "Incorrect",
+ "information": "Information",
+ "invalidanswer": "Incomplete answer",
+ "notanswered": "Not answered",
+ "notyetanswered": "Not yet answered",
+ "partiallycorrect": "Partially correct",
+ "questionmessage": "Question {{$a}}: {{$b}}",
+ "questionno": "Question {{$a}}",
+ "requiresgrading": "Requires grading",
+ "unknown": "Cannot determine status"
+}
\ No newline at end of file
diff --git a/src/core/question/providers/behaviour-delegate.ts b/src/core/question/providers/behaviour-delegate.ts
new file mode 100644
index 000000000..3ec8e6249
--- /dev/null
+++ b/src/core/question/providers/behaviour-delegate.ts
@@ -0,0 +1,112 @@
+// (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 { CoreLoggerProvider } from '@providers/logger';
+import { CoreEventsProvider } from '@providers/events';
+import { CoreSitesProvider } from '@providers/sites';
+import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
+import { CoreQuestionState } from './question';
+import { CoreQuestionDelegate } from './delegate';
+import { CoreQuestionBehaviourDefaultHandler } from './default-behaviour-handler';
+
+/**
+ * Interface that all question behaviour handlers must implement.
+ */
+export interface CoreQuestionBehaviourHandler extends CoreDelegateHandler {
+ /**
+ * Type of the behaviour the handler supports. E.g. 'adaptive'.
+ * @type {string}
+ */
+ type: string;
+
+ /**
+ * Determine a question new state based on its answer(s).
+ *
+ * @param {string} component Component the question belongs to.
+ * @param {number} attemptId Attempt ID the question belongs to.
+ * @param {any} question The question.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {CoreQuestionState|Promise} State (or promise resolved with state).
+ */
+ determineNewState?(component: string, attemptId: number, question: any, siteId?: string)
+ : CoreQuestionState | Promise;
+
+ /**
+ * Handle a question behaviour.
+ * If the behaviour requires a submit button, it should add it to question.behaviourButtons.
+ * If the behaviour requires to show some extra data, it should return the components to render it.
+ *
+ * @param {any} question The question.
+ * @return {any[]|Promise} Components (or promise resolved with components) to render some extra data in the question
+ * (e.g. certainty options). Don't return anything if no extra data is required.
+ */
+ handleQuestion?(question: any): any[] | Promise;
+}
+
+/**
+ * Delegate to register question behaviour handlers.
+ */
+@Injectable()
+export class CoreQuestionBehaviourDelegate extends CoreDelegate {
+
+ protected handlerNameProperty = 'type';
+
+ constructor(logger: CoreLoggerProvider, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider,
+ protected questionDelegate: CoreQuestionDelegate, protected defaultHandler: CoreQuestionBehaviourDefaultHandler) {
+ super('CoreQuestionBehaviourDelegate', logger, sitesProvider, eventsProvider);
+ }
+
+ /**
+ * Determine a question new state based on its answer(s).
+ *
+ * @param {string} component Component the question belongs to.
+ * @param {number} attemptId Attempt ID the question belongs to.
+ * @param {any} question The question.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {Promise} Promise resolved with state.
+ */
+ determineNewState(behaviour: string, component: string, attemptId: number, question: any, siteId?: string)
+ : Promise {
+ behaviour = this.questionDelegate.getBehaviourForQuestion(question, behaviour);
+
+ return Promise.resolve(this.executeFunctionOnEnabled(behaviour, 'determineNewState',
+ [component, attemptId, question, siteId]));
+ }
+
+ /**
+ * Handle a question behaviour.
+ * If the behaviour requires a submit button, it should add it to question.behaviourButtons.
+ * If the behaviour requires to show some extra data, it should return a directive to render it.
+ *
+ * @param {string} behaviour Default behaviour.
+ * @param {any} question The question.
+ * @return {Promise} Promise resolved with components to render some extra data in the question.
+ */
+ handleQuestion(behaviour: string, question: any): Promise {
+ behaviour = this.questionDelegate.getBehaviourForQuestion(question, behaviour);
+
+ return Promise.resolve(this.executeFunctionOnEnabled(behaviour, 'handleQuestion', [question]));
+ }
+
+ /**
+ * Check if a question behaviour is supported.
+ *
+ * @param {string} behaviour Name of the behaviour.
+ * @return {boolean} Whether it's supported.
+ */
+ isBehaviourSupported(behaviour: string): boolean {
+ return this.hasHandler(behaviour, true);
+ }
+}
diff --git a/src/core/question/providers/default-behaviour-handler.ts b/src/core/question/providers/default-behaviour-handler.ts
new file mode 100644
index 000000000..1eb790924
--- /dev/null
+++ b/src/core/question/providers/default-behaviour-handler.ts
@@ -0,0 +1,66 @@
+// (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 { CoreQuestionBehaviourHandler } from './behaviour-delegate';
+import { CoreQuestionProvider, CoreQuestionState } from '@core/question/providers/question';
+
+/**
+ * Default handler used when the question behaviour doesn't have a specific implementation.
+ */
+@Injectable()
+export class CoreQuestionBehaviourDefaultHandler implements CoreQuestionBehaviourHandler {
+ name = 'CoreQuestionBehaviourDefault';
+ type = 'default';
+
+ constructor(private questionProvider: CoreQuestionProvider) { }
+
+ /**
+ * Determine a question new state based on its answer(s).
+ *
+ * @param {string} component Component the question belongs to.
+ * @param {number} attemptId Attempt ID the question belongs to.
+ * @param {any} question The question.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {CoreQuestionState|Promise} New state (or promise resolved with state).
+ */
+ determineNewState(component: string, attemptId: number, question: any, siteId?: string)
+ : CoreQuestionState | Promise {
+ // Return the current state.
+ return this.questionProvider.getState(question.state);
+ }
+
+ /**
+ * Handle a question behaviour.
+ * If the behaviour requires a submit button, it should add it to question.behaviourButtons.
+ * If the behaviour requires to show some extra data, it should return the components to render it.
+ *
+ * @param {any} question The question.
+ * @return {any[]|Promise} Components (or promise resolved with components) to render some extra data in the question
+ * (e.g. certainty options). Don't return anything if no extra data is required.
+ */
+ handleQuestion(question: any): any[] | Promise {
+ // Nothing to do.
+ return;
+ }
+
+ /**
+ * Whether or not the handler is enabled on a site level.
+ *
+ * @return {boolean|Promise} True or promise resolved with true if enabled.
+ */
+ isEnabled(): boolean | Promise {
+ return true;
+ }
+}
diff --git a/src/core/question/providers/default-question-handler.ts b/src/core/question/providers/default-question-handler.ts
new file mode 100644
index 000000000..e6688eaa0
--- /dev/null
+++ b/src/core/question/providers/default-question-handler.ts
@@ -0,0 +1,134 @@
+// (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, Injector } from '@angular/core';
+import { CoreQuestionHandler } from './delegate';
+
+/**
+ * Default handler used when the question type doesn't have a specific implementation.
+ */
+@Injectable()
+export class CoreQuestionDefaultHandler implements CoreQuestionHandler {
+ name = 'CoreQuestionDefault';
+ type = 'default';
+
+ constructor() {
+ // Nothing to do.
+ }
+
+ /**
+ * Whether or not the handler is enabled on a site level.
+ *
+ * @return {boolean|Promise} True or promise resolved with true if enabled.
+ */
+ isEnabled(): boolean | Promise {
+ return true;
+ }
+
+ /**
+ * Return the Component to use to display the question.
+ * It's recommended to return the class of the component, but you can also return an instance of the component.
+ *
+ * @param {Injector} injector Injector.
+ * @param {any} question The question to render.
+ * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found.
+ */
+ getComponent(injector: Injector, question: any): any | Promise {
+ // There is no default component for questions.
+ }
+
+ /**
+ * Return the name of the behaviour to use for the question.
+ * If the question should use the default behaviour you shouldn't implement this function.
+ *
+ * @param {any} question The question.
+ * @param {string} behaviour The default behaviour.
+ * @return {string} The behaviour to use.
+ */
+ getBehaviour(question: any, behaviour: string): string {
+ return behaviour;
+ }
+
+ /**
+ * Check if a question can be submitted.
+ * If a question cannot be submitted it should return a message explaining why (translated or not).
+ *
+ * @param {any} question The question.
+ * @return {string} Prevent submit message. Undefined or empty if can be submitted.
+ */
+ getPreventSubmitMessage(question: any): string {
+ // Never prevent by default.
+ return '';
+ }
+
+ /**
+ * Check if a response is complete.
+ *
+ * @param {any} question The question.
+ * @param {any} answers Object with the question answers (without prefix).
+ * @return {number} 1 if complete, 0 if not complete, -1 if cannot determine.
+ */
+ isCompleteResponse(question: any, answers: any): number {
+ return -1;
+ }
+
+ /**
+ * Check if a student has provided enough of an answer for the question to be graded automatically,
+ * or whether it must be considered aborted.
+ *
+ * @param {any} question The question.
+ * @param {any} answers Object with the question answers (without prefix).
+ * @return {number} 1 if gradable, 0 if not gradable, -1 if cannot determine.
+ */
+ isGradableResponse(question: any, answers: any): number {
+ return -1;
+ }
+
+ /**
+ * Check if two responses are the same.
+ *
+ * @param {any} question Question.
+ * @param {any} prevAnswers Object with the previous question answers.
+ * @param {any} newAnswers Object with the new question answers.
+ * @return {boolean} Whether they're the same.
+ */
+ isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
+ return false;
+ }
+
+ /**
+ * Prepare and add to answers the data to send to server based in the input. Return promise if async.
+ *
+ * @param {any} question Question.
+ * @param {any} answers The answers retrieved from the form. Prepared answers must be stored in this object.
+ * @param {boolean} [offline] Whether the data should be saved in offline.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {void|Promise} Return a promise resolved when done if async, void if sync.
+ */
+ prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise {
+ // Nothing to do.
+ }
+
+ /**
+ * Validate if an offline sequencecheck is valid compared with the online one.
+ * This function only needs to be implemented if a specific compare is required.
+ *
+ * @param {any} question The question.
+ * @param {string} offlineSequenceCheck Sequence check stored in offline.
+ * @return {boolean} Whether sequencecheck is valid.
+ */
+ validateSequenceCheck(question: any, offlineSequenceCheck: string): boolean {
+ return question.sequencecheck == offlineSequenceCheck;
+ }
+}
diff --git a/src/core/question/providers/delegate.ts b/src/core/question/providers/delegate.ts
new file mode 100644
index 000000000..54637e90c
--- /dev/null
+++ b/src/core/question/providers/delegate.ts
@@ -0,0 +1,264 @@
+// (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, Injector } from '@angular/core';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreEventsProvider } from '@providers/events';
+import { CoreSitesProvider } from '@providers/sites';
+import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate';
+import { CoreQuestionDefaultHandler } from './default-question-handler';
+
+/**
+ * Interface that all question type handlers must implement.
+ */
+export interface CoreQuestionHandler extends CoreDelegateHandler {
+ /**
+ * Type of the question the handler supports. E.g. 'qtype_calculated'.
+ * @type {string}
+ */
+ type: string;
+
+ /**
+ * Return the Component to use to display the question.
+ * It's recommended to return the class of the component, but you can also return an instance of the component.
+ *
+ * @param {Injector} injector Injector.
+ * @param {any} question The question to render.
+ * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found.
+ */
+ getComponent(injector: Injector, question: any): any | Promise;
+
+ /**
+ * Return the name of the behaviour to use for the question.
+ * If the question should use the default behaviour you shouldn't implement this function.
+ *
+ * @param {any} question The question.
+ * @param {string} behaviour The default behaviour.
+ * @return {string} The behaviour to use.
+ */
+ getBehaviour?(question: any, behaviour: string): string;
+
+ /**
+ * Check if a question can be submitted.
+ * If a question cannot be submitted it should return a message explaining why (translated or not).
+ *
+ * @param {any} question The question.
+ * @return {string} Prevent submit message. Undefined or empty if can be submitted.
+ */
+ getPreventSubmitMessage?(question: any): string;
+
+ /**
+ * Check if a response is complete.
+ *
+ * @param {any} question The question.
+ * @param {any} answers Object with the question answers (without prefix).
+ * @return {number} 1 if complete, 0 if not complete, -1 if cannot determine.
+ */
+ isCompleteResponse?(question: any, answers: any): number;
+
+ /**
+ * Check if a student has provided enough of an answer for the question to be graded automatically,
+ * or whether it must be considered aborted.
+ *
+ * @param {any} question The question.
+ * @param {any} answers Object with the question answers (without prefix).
+ * @return {number} 1 if gradable, 0 if not gradable, -1 if cannot determine.
+ */
+ isGradableResponse?(question: any, answers: any): number;
+
+ /**
+ * Check if two responses are the same.
+ *
+ * @param {any} question Question.
+ * @param {any} prevAnswers Object with the previous question answers.
+ * @param {any} newAnswers Object with the new question answers.
+ * @return {boolean} Whether they're the same.
+ */
+ isSameResponse?(question: any, prevAnswers: any, newAnswers: any): boolean;
+
+ /**
+ * Prepare and add to answers the data to send to server based in the input. Return promise if async.
+ *
+ * @param {any} question Question.
+ * @param {any} answers The answers retrieved from the form. Prepared answers must be stored in this object.
+ * @param {boolean} [offline] Whether the data should be saved in offline.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {void|Promise} Return a promise resolved when done if async, void if sync.
+ */
+ prepareAnswers?(question: any, answers: any, offline: boolean, siteId?: string): void | Promise;
+
+ /**
+ * Validate if an offline sequencecheck is valid compared with the online one.
+ * This function only needs to be implemented if a specific compare is required.
+ *
+ * @param {any} question The question.
+ * @param {string} offlineSequenceCheck Sequence check stored in offline.
+ * @return {boolean} Whether sequencecheck is valid.
+ */
+ validateSequenceCheck?(question: any, offlineSequenceCheck: string): boolean;
+}
+
+/**
+ * Delegate to register question handlers.
+ */
+@Injectable()
+export class CoreQuestionDelegate extends CoreDelegate {
+
+ protected handlerNameProperty = 'type';
+
+ constructor(logger: CoreLoggerProvider, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider,
+ protected defaultHandler: CoreQuestionDefaultHandler) {
+ super('CoreQuestionDelegate', logger, sitesProvider, eventsProvider);
+ }
+
+ /**
+ * Get the behaviour to use for a certain question type.
+ * E.g. 'qtype_essay' uses 'manualgraded'.
+ *
+ * @param {any} question The question.
+ * @param {string} behaviour The default behaviour.
+ * @return {string} The behaviour to use.
+ */
+ getBehaviourForQuestion(question: any, behaviour: string): string {
+ const type = this.getTypeName(question),
+ questionBehaviour = this.executeFunctionOnEnabled(type, 'getBehaviour', [question, behaviour]);
+
+ return questionBehaviour || behaviour;
+ }
+
+ /**
+ * Get the directive to use for a certain question type.
+ *
+ * @param {Injector} injector Injector.
+ * @param {any} question The question to render.
+ * @return {Promise} Promise resolved with component to use, undefined if not found.
+ */
+ getComponentForQuestion(injector: Injector, question: any): Promise {
+ const type = this.getTypeName(question);
+
+ return Promise.resolve(this.executeFunctionOnEnabled(type, 'getComponent', [injector, question]));
+ }
+
+ /**
+ * Check if a question can be submitted.
+ * If a question cannot be submitted it should return a message explaining why (translated or not).
+ *
+ * @param {any} question Question.
+ * @return {string} Prevent submit message. Undefined or empty if can be submitted.
+ */
+ getPreventSubmitMessage(question: any): string {
+ const type = this.getTypeName(question);
+
+ return this.executeFunctionOnEnabled(type, 'getPreventSubmitMessage', [question]);
+ }
+
+ /**
+ * Given a type name, return the full name of that type. E.g. 'calculated' -> 'qtype_calculated'.
+ *
+ * @param {string} type Type to treat.
+ * @return {string} Type full name.
+ */
+ protected getFullTypeName(type: string): string {
+ return 'qtype_' + type;
+ }
+
+ /**
+ * Given a question, return the full name of its question type.
+ *
+ * @param {any} question Question.
+ * @return {string} Type name.
+ */
+ protected getTypeName(question: any): string {
+ return this.getFullTypeName(question.type);
+ }
+
+ /**
+ * Check if a response is complete.
+ *
+ * @param {any} question The question.
+ * @param {any} answers Object with the question answers (without prefix).
+ * @return {number} 1 if complete, 0 if not complete, -1 if cannot determine.
+ */
+ isCompleteResponse(question: any, answers: any): number {
+ const type = this.getTypeName(question);
+
+ return this.executeFunctionOnEnabled(type, 'isCompleteResponse', [question, answers]);
+ }
+
+ /**
+ * Check if a student has provided enough of an answer for the question to be graded automatically,
+ * or whether it must be considered aborted.
+ *
+ * @param {any} question The question.
+ * @param {any} answers Object with the question answers (without prefix).
+ * @return {number} 1 if gradable, 0 if not gradable, -1 if cannot determine.
+ */
+ isGradableResponse(question: any, answers: any): number {
+ const type = this.getTypeName(question);
+
+ return this.executeFunctionOnEnabled(type, 'isGradableResponse', [question, answers]);
+ }
+
+ /**
+ * Check if two responses are the same.
+ *
+ * @param {any} question Question.
+ * @param {any} prevAnswers Object with the previous question answers.
+ * @param {any} newAnswers Object with the new question answers.
+ * @return {boolean} Whether they're the same.
+ */
+ isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean {
+ const type = this.getTypeName(question);
+
+ return this.executeFunctionOnEnabled(type, 'isSameResponse', [question, prevAnswers, newAnswers]);
+ }
+
+ /**
+ * Check if a question type is supported.
+ *
+ * @param {string} type Question type.
+ * @return {boolean} Whether it's supported.
+ */
+ isQuestionSupported(type: string): boolean {
+ return this.hasHandler(this.getFullTypeName(type), true);
+ }
+
+ /**
+ * Prepare the answers for a certain question.
+ *
+ * @param {any} question Question.
+ * @param {any} answers The answers retrieved from the form. Prepared answers must be stored in this object.
+ * @param {boolean} [offline] Whether the data should be saved in offline.
+ * @param {string} [siteId] Site ID. If not defined, current site.
+ * @return {Promise} Promise resolved when data has been prepared.
+ */
+ prepareAnswersForQuestion(question: any, answers: any, offline: boolean, siteId?: string): Promise {
+ const type = this.getTypeName(question);
+
+ return Promise.resolve(this.executeFunctionOnEnabled(type, 'prepareAnswers', [question, answers, offline, siteId]));
+ }
+
+ /**
+ * Validate if an offline sequencecheck is valid compared with the online one.
+ *
+ * @param {any} question The question.
+ * @param {string} offlineSequenceCheck Sequence check stored in offline.
+ * @return {boolean} Whether sequencecheck is valid.
+ */
+ validateSequenceCheck(question: any, offlineSequenceCheck: string): boolean {
+ const type = this.getTypeName(question);
+
+ return this.executeFunctionOnEnabled(type, 'validateSequenceCheck', [question, offlineSequenceCheck]);
+ }
+}
diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts
new file mode 100644
index 000000000..5216fb5fd
--- /dev/null
+++ b/src/core/question/providers/helper.ts
@@ -0,0 +1,306 @@
+// (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, EventEmitter } from '@angular/core';
+import { CoreDomUtilsProvider } from '@providers/utils/dom';
+import { CoreTextUtilsProvider } from '@providers/utils/text';
+import { CoreQuestionProvider } from './question';
+
+/**
+ * Service with some common functions to handle questions.
+ */
+@Injectable()
+export class CoreQuestionHelperProvider {
+ protected lastErrorShown = 0;
+ protected div = document.createElement('div'); // A div element to search in HTML code.
+
+ constructor(private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider,
+ private questionProvider: CoreQuestionProvider) { }
+
+ /**
+ * Add a behaviour button to the question's "behaviourButtons" property.
+ *
+ * @param {any} question Question.
+ * @param {HTMLInputElement} button Behaviour button (DOM element).
+ */
+ protected addBehaviourButton(question: any, button: HTMLInputElement): void {
+ if (!button || !question) {
+ return;
+ }
+
+ if (!question.behaviourButtons) {
+ question.behaviourButtons = [];
+ }
+
+ // Extract the data we want.
+ question.behaviourButtons.push({
+ id: button.id,
+ name: button.name,
+ value: button.value,
+ disabled: button.disabled
+ });
+ }
+
+ /**
+ * Check if the question has a redo button and, if so, add it to "behaviourButtons" property
+ * and remove it from the HTML.
+ *
+ * @param {any} question Question to treat.
+ */
+ extractQbehaviourRedoButton(question: any): void {
+ // Create a fake div element so we can search using querySelector.
+ const redoSelector = 'input[type="submit"][name*=redoslot], input[type="submit"][name*=tryagain]';
+
+ // Search redo button in feedback.
+ if (!this.searchBehaviourButton(question, 'html', '.outcome ' + redoSelector)) {
+ // Not found in question HTML.
+ if (question.feedbackHtml) {
+ // We extracted the feedback already, search it in there.
+ if (this.searchBehaviourButton(question, 'feedbackHtml', redoSelector)) {
+ // Button found, stop.
+ return;
+ }
+ }
+
+ // Button still not found. Now search in the info box if it exists.
+ if (question.infoHtml) {
+ this.searchBehaviourButton(question, 'infoHtml', redoSelector);
+ }
+ }
+ }
+
+ /**
+ * Removes the comment from the question HTML code and adds it in a new "commentHtml" property.
+ *
+ * @param {any} question Question.
+ */
+ extractQuestionComment(question: any): void {
+ this.extractQuestionLastElementNotInContent(question, '.comment', 'commentHtml');
+ }
+
+ /**
+ * Removes the feedback from the question HTML code and adds it in a new "feedbackHtml" property.
+ *
+ * @param {any} question Question.
+ */
+ extractQuestionFeedback(question: any): void {
+ this.extractQuestionLastElementNotInContent(question, '.outcome', 'feedbackHtml');
+ }
+
+ /**
+ * Extracts the info box from a question and add it to an "infoHtml" property.
+ *
+ * @param {any} question Question.
+ * @param {string} selector Selector to search the element.
+ */
+ extractQuestionInfoBox(question: any, selector: string): void {
+ this.extractQuestionLastElementNotInContent(question, selector, 'infoHtml');
+ }
+
+ /**
+ * Searches the last occurrence of a certain element and check it's not in the question contents.
+ * If found, removes it from the question HTML and adds it to a new property inside question.
+ *
+ * @param {any} question Question.
+ * @param {string} selector Selector to search the element.
+ * @param {string} attrName Name of the attribute to store the HTML in.
+ */
+ protected extractQuestionLastElementNotInContent(question: any, selector: string, attrName: string): void {
+ this.div.innerHTML = question.html;
+
+ const matches = Array.from(this.div.querySelectorAll(selector));
+
+ // Get the last element and check it's not in the question contents.
+ let last = matches.pop();
+ while (last) {
+ if (!this.domUtils.closest(last, '.formulation')) {
+ // Not in question contents. Add it to a separate attribute and remove it from the HTML.
+ question[attrName] = last.innerHTML;
+ last.parentElement.removeChild(last);
+ question.html = this.div.innerHTML;
+
+ return;
+ }
+
+ // It's inside the question content, treat next element.
+ last = matches.pop();
+ }
+ }
+
+ /**
+ * Removes the scripts from a question's HTML and adds it in a new 'scriptsCode' property.
+ * It will also search for init_question functions of the question type and add the object to an 'initObjects' property.
+ *
+ * @param {any} question Question.
+ */
+ extractQuestionScripts(question: any): void {
+ question.scriptsCode = '';
+ question.initObjects = [];
+
+ if (question.html) {
+ // Search the scripts.
+ const matches = question.html.match(/