diff --git a/src/core/compile/providers/compile.ts b/src/core/compile/providers/compile.ts
index de77dd370..bbd988366 100644
--- a/src/core/compile/providers/compile.ts
+++ b/src/core/compile/providers/compile.ts
@@ -67,6 +67,7 @@ import { CoreCoursesComponentsModule } from '@core/courses/components/components
import { CoreSitePluginsDirectivesModule } from '@core/siteplugins/directives/directives.module';
import { CoreSiteHomeComponentsModule } from '@core/sitehome/components/components.module';
import { CoreUserComponentsModule } from '@core/user/components/components.module';
+import { CoreQuestionComponentsModule } from '@core/question/components/components.module';
// Import some components listed in entryComponents so they can be injected dynamically.
import { CoreCourseUnsupportedModuleComponent } from '@core/course/components/unsupported-module/unsupported-module';
@@ -93,7 +94,7 @@ export class CoreCompileProvider {
protected IMPORTS = [
IonicModule, TranslateModule.forChild(), CoreComponentsModule, CoreDirectivesModule, CorePipesModule,
CoreCourseComponentsModule, CoreCoursesComponentsModule, CoreSiteHomeComponentsModule, CoreUserComponentsModule,
- CoreCourseDirectivesModule, CoreSitePluginsDirectivesModule
+ CoreCourseDirectivesModule, CoreSitePluginsDirectivesModule, CoreQuestionComponentsModule
];
constructor(protected injector: Injector, logger: CoreLoggerProvider, protected compiler: Compiler) {
diff --git a/src/core/question/components/components.module.ts b/src/core/question/components/components.module.ts
new file mode 100644
index 000000000..77d33aa74
--- /dev/null
+++ b/src/core/question/components/components.module.ts
@@ -0,0 +1,40 @@
+// (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 { CommonModule } from '@angular/common';
+import { IonicModule } from 'ionic-angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreQuestionComponent } from './question/question';
+import { CoreComponentsModule } from '@components/components.module';
+import { CoreDirectivesModule } from '@directives/directives.module';
+
+@NgModule({
+ declarations: [
+ CoreQuestionComponent
+ ],
+ imports: [
+ CommonModule,
+ IonicModule,
+ TranslateModule.forChild(),
+ CoreComponentsModule,
+ CoreDirectivesModule
+ ],
+ providers: [
+ ],
+ exports: [
+ CoreQuestionComponent
+ ]
+})
+export class CoreQuestionComponentsModule {}
diff --git a/src/core/question/components/question/question.html b/src/core/question/components/question/question.html
new file mode 100644
index 000000000..33fc72525
--- /dev/null
+++ b/src/core/question/components/question/question.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
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/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(/