diff --git a/src/addon/qtype/calculated/calculated.module.ts b/src/addon/qtype/calculated/calculated.module.ts
new file mode 100644
index 000000000..fed580b35
--- /dev/null
+++ b/src/addon/qtype/calculated/calculated.module.ts
@@ -0,0 +1,46 @@
+// (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 { IonicModule } from 'ionic-angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { CoreDirectivesModule } from '@directives/directives.module';
+import { AddonQtypeCalculatedHandler } from './providers/handler';
+import { AddonQtypeCalculatedComponent } from './component/calculated';
+
+@NgModule({
+ declarations: [
+ AddonQtypeCalculatedComponent
+ ],
+ imports: [
+ IonicModule,
+ TranslateModule.forChild(),
+ CoreDirectivesModule
+ ],
+ providers: [
+ AddonQtypeCalculatedHandler
+ ],
+ exports: [
+ AddonQtypeCalculatedComponent
+ ],
+ entryComponents: [
+ AddonQtypeCalculatedComponent
+ ]
+})
+export class AddonQtypeCalculatedModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeCalculatedHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/addon/qtype/calculated/component/calculated.html b/src/addon/qtype/calculated/component/calculated.html
new file mode 100644
index 000000000..af87387cc
--- /dev/null
+++ b/src/addon/qtype/calculated/component/calculated.html
@@ -0,0 +1,55 @@
+
+
+
+
diff --git a/src/addon/qtype/calculated/component/calculated.ts b/src/addon/qtype/calculated/component/calculated.ts
new file mode 100644
index 000000000..e42c6625c
--- /dev/null
+++ b/src/addon/qtype/calculated/component/calculated.ts
@@ -0,0 +1,38 @@
+// (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 } from '@angular/core';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
+
+/**
+ * Component to render a calculated question.
+ */
+@Component({
+ selector: 'addon-qtype-calculated',
+ templateUrl: 'calculated.html'
+})
+export class AddonQtypeCalculatedComponent extends CoreQuestionBaseComponent implements OnInit {
+
+ constructor(logger: CoreLoggerProvider, injector: Injector) {
+ super(logger, 'AddonQtypeCalculatedComponent', injector);
+ }
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ this.initCalculatedComponent();
+ }
+}
diff --git a/src/addon/qtype/calculated/providers/handler.ts b/src/addon/qtype/calculated/providers/handler.ts
new file mode 100644
index 000000000..da6f3d24c
--- /dev/null
+++ b/src/addon/qtype/calculated/providers/handler.ts
@@ -0,0 +1,128 @@
+
+// (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 { CoreUtilsProvider } from '@providers/utils/utils';
+import { CoreQuestionHandler } from '@core/question/providers/delegate';
+import { AddonQtypeNumericalHandler } from '@addon/qtype/numerical/providers/handler';
+import { AddonQtypeCalculatedComponent } from '../component/calculated';
+
+/**
+ * Handler to support calculated question type.
+ */
+@Injectable()
+export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeCalculated';
+ type = 'qtype_calculated';
+
+ constructor(private utils: CoreUtilsProvider, private numericalHandler: AddonQtypeNumericalHandler) { }
+
+ /**
+ * 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 AddonQtypeCalculatedComponent;
+ }
+
+ /**
+ * 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 {
+ // This question type depends on numerical.
+ if (this.isGradableResponse(question, answers) === 0 || !this.numericalHandler.validateUnits(answers['answer'])) {
+ return 0;
+ }
+
+ if (this.requiresUnits(question)) {
+ return this.isValidValue(answers['unit']) ? 1 : 0;
+ }
+
+ return -1;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 {
+ // This question type depends on numerical.
+ let isGradable = this.isValidValue(answers['answer']);
+ if (isGradable && this.requiresUnits(question)) {
+ // The question requires a unit.
+ isGradable = this.isValidValue(answers['unit']);
+ }
+
+ return isGradable ? 1 : 0;
+ }
+
+ /**
+ * 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 {
+ // This question type depends on numerical.
+ return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') &&
+ this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit');
+ }
+
+ /**
+ * Check if a value is valid (not empty).
+ *
+ * @param {string|number} value Value to check.
+ * @return {boolean} Whether the value is valid.
+ */
+ isValidValue(value: string | number): boolean {
+ return !!value || value === '0' || value === 0;
+ }
+
+ /**
+ * Check if a question requires units in a separate input.
+ *
+ * @param {any} question The question.
+ * @return {boolean} Whether the question requires units.
+ */
+ requiresUnits(question: any): boolean {
+ const div = document.createElement('div');
+ div.innerHTML = question.html;
+
+ return !!(div.querySelector('select[name*=unit]') || div.querySelector('input[type="radio"]'));
+ }
+}
diff --git a/src/addon/qtype/calculatedmulti/calculatedmulti.module.ts b/src/addon/qtype/calculatedmulti/calculatedmulti.module.ts
new file mode 100644
index 000000000..a5e7ee3eb
--- /dev/null
+++ b/src/addon/qtype/calculatedmulti/calculatedmulti.module.ts
@@ -0,0 +1,30 @@
+// (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 { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { AddonQtypeCalculatedMultiHandler } from './providers/handler';
+
+@NgModule({
+ declarations: [
+ ],
+ providers: [
+ AddonQtypeCalculatedMultiHandler
+ ]
+})
+export class AddonQtypeCalculatedMultiModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeCalculatedMultiHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/addon/qtype/calculatedmulti/providers/handler.ts b/src/addon/qtype/calculatedmulti/providers/handler.ts
new file mode 100644
index 000000000..3cbe18057
--- /dev/null
+++ b/src/addon/qtype/calculatedmulti/providers/handler.ts
@@ -0,0 +1,90 @@
+
+// (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 '@core/question/providers/delegate';
+import { AddonQtypeMultichoiceHandler } from '@addon/qtype/multichoice/providers/handler';
+import { AddonQtypeMultichoiceComponent } from '@addon/qtype/multichoice/component/multichoice';
+
+/**
+ * Handler to support calculated multi question type.
+ */
+@Injectable()
+export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeCalculatedMulti';
+ type = 'qtype_calculatedmulti';
+
+ constructor(private multichoiceHandler: AddonQtypeMultichoiceHandler) { }
+
+ /**
+ * 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 {
+ // Calculated multi behaves like a multichoice, use the same component.
+ return AddonQtypeMultichoiceComponent;
+ }
+
+ /**
+ * 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 {
+ // This question type depends on multichoice.
+ return this.multichoiceHandler.isCompleteResponseSingle(answers);
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 {
+ // This question type depends on multichoice.
+ return this.multichoiceHandler.isGradableResponseSingle(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 {
+ // This question type depends on multichoice.
+ return this.multichoiceHandler.isSameResponseSingle(prevAnswers, newAnswers);
+ }
+}
diff --git a/src/addon/qtype/calculatedsimple/calculatedsimple.module.ts b/src/addon/qtype/calculatedsimple/calculatedsimple.module.ts
new file mode 100644
index 000000000..f4948e05a
--- /dev/null
+++ b/src/addon/qtype/calculatedsimple/calculatedsimple.module.ts
@@ -0,0 +1,30 @@
+// (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 { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { AddonQtypeCalculatedSimpleHandler } from './providers/handler';
+
+@NgModule({
+ declarations: [
+ ],
+ providers: [
+ AddonQtypeCalculatedSimpleHandler
+ ],
+})
+export class AddonQtypeCalculatedSimpleModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeCalculatedSimpleHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/addon/qtype/calculatedsimple/providers/handler.ts b/src/addon/qtype/calculatedsimple/providers/handler.ts
new file mode 100644
index 000000000..2aaaf1c9c
--- /dev/null
+++ b/src/addon/qtype/calculatedsimple/providers/handler.ts
@@ -0,0 +1,90 @@
+
+// (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 '@core/question/providers/delegate';
+import { AddonQtypeCalculatedHandler } from '@addon/qtype/calculated/providers/handler';
+import { AddonQtypeCalculatedComponent } from '@addon/qtype/calculated/component/calculated';
+
+/**
+ * Handler to support calculated simple question type.
+ */
+@Injectable()
+export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeCalculatedSimple';
+ type = 'qtype_calculatedsimple';
+
+ constructor(private calculatedHandler: AddonQtypeCalculatedHandler) { }
+
+ /**
+ * 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 {
+ // Calculated simple behaves like a calculated, use the same component.
+ return AddonQtypeCalculatedComponent;
+ }
+
+ /**
+ * 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 {
+ // This question type depends on calculated.
+ return this.calculatedHandler.isCompleteResponse(question, answers);
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 {
+ // This question type depends on calculated.
+ return this.calculatedHandler.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 {
+ // This question type depends on calculated.
+ return this.calculatedHandler.isSameResponse(question, prevAnswers, newAnswers);
+ }
+}
diff --git a/src/addon/qtype/description/component/description.html b/src/addon/qtype/description/component/description.html
new file mode 100644
index 000000000..74d4f190d
--- /dev/null
+++ b/src/addon/qtype/description/component/description.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/addon/qtype/description/component/description.ts b/src/addon/qtype/description/component/description.ts
new file mode 100644
index 000000000..d54c64a05
--- /dev/null
+++ b/src/addon/qtype/description/component/description.ts
@@ -0,0 +1,48 @@
+// (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 } from '@angular/core';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
+
+/**
+ * Component to render a description question.
+ */
+@Component({
+ selector: 'addon-qtype-description',
+ templateUrl: 'description.html'
+})
+export class AddonQtypeDescriptionComponent extends CoreQuestionBaseComponent implements OnInit {
+
+ constructor(logger: CoreLoggerProvider, injector: Injector) {
+ super(logger, 'AddonQtypeDescriptionComponent', injector);
+ }
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ const questionDiv = this.initComponent();
+ if (questionDiv) {
+ // Get the "seen" hidden input.
+ const input = questionDiv.querySelector('input[type="hidden"][name*=seen]');
+ if (input) {
+ this.question.seenInput = {
+ name: input.name,
+ value: input.value
+ };
+ }
+ }
+ }
+}
diff --git a/src/addon/qtype/description/description.module.ts b/src/addon/qtype/description/description.module.ts
new file mode 100644
index 000000000..fa64bb94d
--- /dev/null
+++ b/src/addon/qtype/description/description.module.ts
@@ -0,0 +1,46 @@
+// (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 { IonicModule } from 'ionic-angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { CoreDirectivesModule } from '@directives/directives.module';
+import { AddonQtypeDescriptionHandler } from './providers/handler';
+import { AddonQtypeDescriptionComponent } from './component/description';
+
+@NgModule({
+ declarations: [
+ AddonQtypeDescriptionComponent
+ ],
+ imports: [
+ IonicModule,
+ TranslateModule.forChild(),
+ CoreDirectivesModule
+ ],
+ providers: [
+ AddonQtypeDescriptionHandler
+ ],
+ exports: [
+ AddonQtypeDescriptionComponent
+ ],
+ entryComponents: [
+ AddonQtypeDescriptionComponent
+ ]
+})
+export class AddonQtypeDescriptionModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeDescriptionHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/addon/qtype/description/providers/handler.ts b/src/addon/qtype/description/providers/handler.ts
new file mode 100644
index 000000000..49bba134c
--- /dev/null
+++ b/src/addon/qtype/description/providers/handler.ts
@@ -0,0 +1,77 @@
+
+// (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 '@core/question/providers/delegate';
+import { AddonQtypeDescriptionComponent } from '../component/description';
+
+/**
+ * Handler to support description question type.
+ */
+@Injectable()
+export class AddonQtypeDescriptionHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeDescription';
+ type = 'qtype_description';
+
+ constructor() {
+ // Nothing to do.
+ }
+
+ /**
+ * 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 'informationitem';
+ }
+
+ /**
+ * 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 AddonQtypeDescriptionComponent;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 {
+ // Descriptions don't have any answer so we'll always treat them as valid.
+ return true;
+ }
+}
diff --git a/src/addon/qtype/essay/component/essay.html b/src/addon/qtype/essay/component/essay.html
new file mode 100644
index 000000000..1098c8304
--- /dev/null
+++ b/src/addon/qtype/essay/component/essay.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/addon/qtype/essay/component/essay.ts b/src/addon/qtype/essay/component/essay.ts
new file mode 100644
index 000000000..c421fa629
--- /dev/null
+++ b/src/addon/qtype/essay/component/essay.ts
@@ -0,0 +1,38 @@
+// (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 } from '@angular/core';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
+
+/**
+ * Component to render an essay question.
+ */
+@Component({
+ selector: 'addon-qtype-essay',
+ templateUrl: 'essay.html'
+})
+export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implements OnInit {
+
+ constructor(logger: CoreLoggerProvider, injector: Injector) {
+ super(logger, 'AddonQtypeEssayComponent', injector);
+ }
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ this.initEssayComponent();
+ }
+}
diff --git a/src/addon/qtype/essay/essay.module.ts b/src/addon/qtype/essay/essay.module.ts
new file mode 100644
index 000000000..9c3d58ba6
--- /dev/null
+++ b/src/addon/qtype/essay/essay.module.ts
@@ -0,0 +1,48 @@
+// (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 { IonicModule } from 'ionic-angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { CoreComponentsModule } from '@components/components.module';
+import { CoreDirectivesModule } from '@directives/directives.module';
+import { AddonQtypeEssayHandler } from './providers/handler';
+import { AddonQtypeEssayComponent } from './component/essay';
+
+@NgModule({
+ declarations: [
+ AddonQtypeEssayComponent
+ ],
+ imports: [
+ IonicModule,
+ TranslateModule.forChild(),
+ CoreComponentsModule,
+ CoreDirectivesModule
+ ],
+ providers: [
+ AddonQtypeEssayHandler
+ ],
+ exports: [
+ AddonQtypeEssayComponent
+ ],
+ entryComponents: [
+ AddonQtypeEssayComponent
+ ]
+})
+export class AddonQtypeEssayModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeEssayHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/addon/qtype/essay/providers/handler.ts b/src/addon/qtype/essay/providers/handler.ts
new file mode 100644
index 000000000..ead384f00
--- /dev/null
+++ b/src/addon/qtype/essay/providers/handler.ts
@@ -0,0 +1,159 @@
+
+// (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 { CoreDomUtilsProvider } from '@providers/utils/dom';
+import { CoreTextUtilsProvider } from '@providers/utils/text';
+import { CoreUtilsProvider } from '@providers/utils/utils';
+import { CoreQuestionHandler } from '@core/question/providers/delegate';
+import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
+import { AddonQtypeEssayComponent } from '../component/essay';
+
+/**
+ * Handler to support essay question type.
+ */
+@Injectable()
+export class AddonQtypeEssayHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeEssay';
+ type = 'qtype_essay';
+
+ protected div = document.createElement('div'); // A div element to search in HTML code.
+
+ constructor(private utils: CoreUtilsProvider, private questionHelper: CoreQuestionHelperProvider,
+ private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider) { }
+
+ /**
+ * 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 'manualgraded';
+ }
+
+ /**
+ * 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 AddonQtypeEssayComponent;
+ }
+
+ /**
+ * 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 {
+ this.div.innerHTML = question.html;
+
+ if (this.div.querySelector('div[id*=filemanager]')) {
+ // The question allows attachments. Since the app cannot attach files yet we will prevent submitting the question.
+ return 'core.question.errorattachmentsnotsupported';
+ }
+
+ if (this.questionHelper.hasDraftFileUrls(this.div.innerHTML)) {
+ return 'core.question.errorinlinefilesnotsupported';
+ }
+ }
+
+ /**
+ * 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 {
+ this.div.innerHTML = question.html;
+
+ const hasInlineText = answers['answer'] && answers['answer'] !== '',
+ allowsAttachments = !!this.div.querySelector('div[id*=filemanager]');
+
+ if (!allowsAttachments) {
+ return hasInlineText ? 1 : 0;
+ }
+
+ // We can't know if the attachments are required or if the user added any in web.
+ return -1;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 0;
+ }
+
+ /**
+ * 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 this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
+ }
+
+ /**
+ * 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 {
+ this.div.innerHTML = question.html;
+
+ // Search the textarea to get its name.
+ const textarea = this.div.querySelector('textarea[name*=_answer]');
+
+ if (textarea && typeof answers[textarea.name] != 'undefined') {
+ return this.domUtils.isRichTextEditorEnabled().then((enabled) => {
+ if (!enabled) {
+ // Rich text editor not enabled, add some HTML to the text if needed.
+ answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]);
+ }
+ });
+ }
+ }
+}
diff --git a/src/addon/qtype/gapselect/component/gapselect.html b/src/addon/qtype/gapselect/component/gapselect.html
new file mode 100644
index 000000000..eaeb68661
--- /dev/null
+++ b/src/addon/qtype/gapselect/component/gapselect.html
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/addon/qtype/gapselect/component/gapselect.ts b/src/addon/qtype/gapselect/component/gapselect.ts
new file mode 100644
index 000000000..70a4622cf
--- /dev/null
+++ b/src/addon/qtype/gapselect/component/gapselect.ts
@@ -0,0 +1,38 @@
+// (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 } from '@angular/core';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
+
+/**
+ * Component to render a gap select question.
+ */
+@Component({
+ selector: 'addon-qtype-gapselect',
+ templateUrl: 'gapselect.html'
+})
+export class AddonQtypeGapSelectComponent extends CoreQuestionBaseComponent implements OnInit {
+
+ constructor(logger: CoreLoggerProvider, injector: Injector) {
+ super(logger, 'AddonQtypeGapSelectComponent', injector);
+ }
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ this.initOriginalTextComponent('.qtext');
+ }
+}
diff --git a/src/addon/qtype/gapselect/gapselect.module.ts b/src/addon/qtype/gapselect/gapselect.module.ts
new file mode 100644
index 000000000..f95b7b790
--- /dev/null
+++ b/src/addon/qtype/gapselect/gapselect.module.ts
@@ -0,0 +1,46 @@
+// (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 { IonicModule } from 'ionic-angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { CoreDirectivesModule } from '@directives/directives.module';
+import { AddonQtypeGapSelectHandler } from './providers/handler';
+import { AddonQtypeGapSelectComponent } from './component/gapselect';
+
+@NgModule({
+ declarations: [
+ AddonQtypeGapSelectComponent
+ ],
+ imports: [
+ IonicModule,
+ TranslateModule.forChild(),
+ CoreDirectivesModule
+ ],
+ providers: [
+ AddonQtypeGapSelectHandler
+ ],
+ exports: [
+ AddonQtypeGapSelectComponent
+ ],
+ entryComponents: [
+ AddonQtypeGapSelectComponent
+ ]
+})
+export class AddonQtypeGapSelectModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeGapSelectHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/addon/qtype/gapselect/providers/handler.ts b/src/addon/qtype/gapselect/providers/handler.ts
new file mode 100644
index 000000000..e18b357cd
--- /dev/null
+++ b/src/addon/qtype/gapselect/providers/handler.ts
@@ -0,0 +1,118 @@
+
+// (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 { CoreQuestionProvider } from '@core/question/providers/question';
+import { CoreQuestionHandler } from '@core/question/providers/delegate';
+import { AddonQtypeGapSelectComponent } from '../component/gapselect';
+
+/**
+ * Handler to support gapselect question type.
+ */
+@Injectable()
+export class AddonQtypeGapSelectHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeGapSelect';
+ type = 'qtype_gapselect';
+
+ constructor(private questionProvider: CoreQuestionProvider) { }
+
+ /**
+ * 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 {
+ if (behaviour === 'interactive') {
+ return 'interactivecountback';
+ }
+
+ return behaviour;
+ }
+
+ /**
+ * 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 AddonQtypeGapSelectComponent;
+ }
+
+ /**
+ * 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 {
+ // We should always get a value for each select so we can assume we receive all the possible answers.
+ for (const name in answers) {
+ const value = answers[name];
+ if (!value || value === '0') {
+ return 0;
+ }
+ }
+
+ return 1;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 {
+ // We should always get a value for each select so we can assume we receive all the possible answers.
+ for (const name in answers) {
+ const value = answers[name];
+ if (value) {
+ return 1;
+ }
+ }
+
+ return 0;
+ }
+
+ /**
+ * 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 this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
+ }
+}
diff --git a/src/addon/qtype/match/component/match.html b/src/addon/qtype/match/component/match.html
new file mode 100644
index 000000000..fb40981c6
--- /dev/null
+++ b/src/addon/qtype/match/component/match.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{option.label}}
+
+
+
+
+
+
diff --git a/src/addon/qtype/match/component/match.ts b/src/addon/qtype/match/component/match.ts
new file mode 100644
index 000000000..9a2eb4ff6
--- /dev/null
+++ b/src/addon/qtype/match/component/match.ts
@@ -0,0 +1,38 @@
+// (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 } from '@angular/core';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
+
+/**
+ * Component to render a match question.
+ */
+@Component({
+ selector: 'addon-qtype-match',
+ templateUrl: 'match.html'
+})
+export class AddonQtypeMatchComponent extends CoreQuestionBaseComponent implements OnInit {
+
+ constructor(logger: CoreLoggerProvider, injector: Injector) {
+ super(logger, 'AddonQtypeMatchComponent', injector);
+ }
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ this.initMatchComponent();
+ }
+}
diff --git a/src/addon/qtype/match/match.module.ts b/src/addon/qtype/match/match.module.ts
new file mode 100644
index 000000000..b4b56f334
--- /dev/null
+++ b/src/addon/qtype/match/match.module.ts
@@ -0,0 +1,46 @@
+// (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 { IonicModule } from 'ionic-angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { CoreDirectivesModule } from '@directives/directives.module';
+import { AddonQtypeMatchHandler } from './providers/handler';
+import { AddonQtypeMatchComponent } from './component/match';
+
+@NgModule({
+ declarations: [
+ AddonQtypeMatchComponent
+ ],
+ imports: [
+ IonicModule,
+ TranslateModule.forChild(),
+ CoreDirectivesModule
+ ],
+ providers: [
+ AddonQtypeMatchHandler
+ ],
+ exports: [
+ AddonQtypeMatchComponent
+ ],
+ entryComponents: [
+ AddonQtypeMatchComponent
+ ]
+})
+export class AddonQtypeMatchModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeMatchHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/addon/qtype/match/providers/handler.ts b/src/addon/qtype/match/providers/handler.ts
new file mode 100644
index 000000000..d9519ad04
--- /dev/null
+++ b/src/addon/qtype/match/providers/handler.ts
@@ -0,0 +1,118 @@
+
+// (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 { CoreQuestionProvider } from '@core/question/providers/question';
+import { CoreQuestionHandler } from '@core/question/providers/delegate';
+import { AddonQtypeMatchComponent } from '../component/match';
+
+/**
+ * Handler to support match question type.
+ */
+@Injectable()
+export class AddonQtypeMatchHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeMatch';
+ type = 'qtype_match';
+
+ constructor(private questionProvider: CoreQuestionProvider) { }
+
+ /**
+ * 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 {
+ if (behaviour === 'interactive') {
+ return 'interactivecountback';
+ }
+
+ return behaviour;
+ }
+
+ /**
+ * 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 AddonQtypeMatchComponent;
+ }
+
+ /**
+ * 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 {
+ // We should always get a value for each select so we can assume we receive all the possible answers.
+ for (const name in answers) {
+ const value = answers[name];
+ if (!value || value === '0') {
+ return 0;
+ }
+ }
+
+ return 1;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 {
+ // We should always get a value for each select so we can assume we receive all the possible answers.
+ for (const name in answers) {
+ const value = answers[name];
+ if (value && value !== '0') {
+ return 1;
+ }
+ }
+
+ return 0;
+ }
+
+ /**
+ * 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 this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
+ }
+}
diff --git a/src/addon/qtype/multianswer/component/multianswer.html b/src/addon/qtype/multianswer/component/multianswer.html
new file mode 100644
index 000000000..ae07a5f43
--- /dev/null
+++ b/src/addon/qtype/multianswer/component/multianswer.html
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/addon/qtype/multianswer/component/multianswer.ts b/src/addon/qtype/multianswer/component/multianswer.ts
new file mode 100644
index 000000000..79cdba492
--- /dev/null
+++ b/src/addon/qtype/multianswer/component/multianswer.ts
@@ -0,0 +1,38 @@
+// (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 } from '@angular/core';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
+
+/**
+ * Component to render a multianswer question.
+ */
+@Component({
+ selector: 'addon-qtype-multianswer',
+ templateUrl: 'multianswer.html'
+})
+export class AddonQtypeMultiAnswerComponent extends CoreQuestionBaseComponent implements OnInit {
+
+ constructor(logger: CoreLoggerProvider, injector: Injector) {
+ super(logger, 'AddonQtypeMultiAnswerComponent', injector);
+ }
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ this.initOriginalTextComponent('.formulation');
+ }
+}
diff --git a/src/addon/qtype/multianswer/multianswer.module.ts b/src/addon/qtype/multianswer/multianswer.module.ts
new file mode 100644
index 000000000..b1c37c051
--- /dev/null
+++ b/src/addon/qtype/multianswer/multianswer.module.ts
@@ -0,0 +1,46 @@
+// (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 { IonicModule } from 'ionic-angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { CoreDirectivesModule } from '@directives/directives.module';
+import { AddonQtypeMultiAnswerHandler } from './providers/handler';
+import { AddonQtypeMultiAnswerComponent } from './component/multianswer';
+
+@NgModule({
+ declarations: [
+ AddonQtypeMultiAnswerComponent
+ ],
+ imports: [
+ IonicModule,
+ TranslateModule.forChild(),
+ CoreDirectivesModule
+ ],
+ providers: [
+ AddonQtypeMultiAnswerHandler
+ ],
+ exports: [
+ AddonQtypeMultiAnswerComponent
+ ],
+ entryComponents: [
+ AddonQtypeMultiAnswerComponent
+ ]
+})
+export class AddonQtypeMultiAnswerModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeMultiAnswerHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/addon/qtype/multianswer/providers/handler.ts b/src/addon/qtype/multianswer/providers/handler.ts
new file mode 100644
index 000000000..b02a4eb70
--- /dev/null
+++ b/src/addon/qtype/multianswer/providers/handler.ts
@@ -0,0 +1,142 @@
+
+// (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 { CoreQuestionProvider } from '@core/question/providers/question';
+import { CoreQuestionHandler } from '@core/question/providers/delegate';
+import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
+import { AddonQtypeMultiAnswerComponent } from '../component/multianswer';
+
+/**
+ * Handler to support multianswer question type.
+ */
+@Injectable()
+export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeMultiAnswer';
+ type = 'qtype_multianswer';
+
+ constructor(private questionProvider: CoreQuestionProvider, private questionHelper: CoreQuestionHelperProvider) { }
+
+ /**
+ * 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 {
+ if (behaviour === 'interactive') {
+ return 'interactivecountback';
+ }
+
+ return behaviour;
+ }
+
+ /**
+ * 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 AddonQtypeMultiAnswerComponent;
+ }
+
+ /**
+ * 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 {
+ // Get all the inputs in the question to check if they've all been answered.
+ const names = this.questionProvider.getBasicAnswers(this.questionHelper.getAllInputNamesFromHtml(question.html));
+ for (const name in names) {
+ const value = answers[name];
+ if (!value && value !== false && value !== 0) {
+ return 0;
+ }
+ }
+
+ return 1;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 {
+ // We should always get a value for each select so we can assume we receive all the possible answers.
+ for (const name in answers) {
+ const value = answers[name];
+ if (value || value === false) {
+ return 1;
+ }
+ }
+
+ return 0;
+ }
+
+ /**
+ * 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 this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
+ }
+
+ /**
+ * 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 {
+ if (question.sequencecheck == offlineSequenceCheck) {
+ return true;
+ }
+
+ // For some reason, viewing a multianswer for the first time without answering it creates a new step "todo".
+ // We'll treat this case as valid.
+ if (question.sequencecheck == 2 && question.state == 'todo' && offlineSequenceCheck == '1') {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/addon/qtype/multichoice/component/multichoice.html b/src/addon/qtype/multichoice/component/multichoice.html
new file mode 100644
index 000000000..ee734dd99
--- /dev/null
+++ b/src/addon/qtype/multichoice/component/multichoice.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/addon/qtype/multichoice/component/multichoice.ts b/src/addon/qtype/multichoice/component/multichoice.ts
new file mode 100644
index 000000000..395c4eabd
--- /dev/null
+++ b/src/addon/qtype/multichoice/component/multichoice.ts
@@ -0,0 +1,38 @@
+// (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 } from '@angular/core';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
+
+/**
+ * Component to render a multichoice question.
+ */
+@Component({
+ selector: 'addon-qtype-multichoice',
+ templateUrl: 'multichoice.html'
+})
+export class AddonQtypeMultichoiceComponent extends CoreQuestionBaseComponent implements OnInit {
+
+ constructor(logger: CoreLoggerProvider, injector: Injector) {
+ super(logger, 'AddonQtypeMultichoiceComponent', injector);
+ }
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ this.initMultichoiceComponent();
+ }
+}
diff --git a/src/addon/qtype/multichoice/multichoice.module.ts b/src/addon/qtype/multichoice/multichoice.module.ts
new file mode 100644
index 000000000..c3d591ba7
--- /dev/null
+++ b/src/addon/qtype/multichoice/multichoice.module.ts
@@ -0,0 +1,46 @@
+// (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 { IonicModule } from 'ionic-angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { CoreDirectivesModule } from '@directives/directives.module';
+import { AddonQtypeMultichoiceHandler } from './providers/handler';
+import { AddonQtypeMultichoiceComponent } from './component/multichoice';
+
+@NgModule({
+ declarations: [
+ AddonQtypeMultichoiceComponent
+ ],
+ imports: [
+ IonicModule,
+ TranslateModule.forChild(),
+ CoreDirectivesModule
+ ],
+ providers: [
+ AddonQtypeMultichoiceHandler
+ ],
+ exports: [
+ AddonQtypeMultichoiceComponent
+ ],
+ entryComponents: [
+ AddonQtypeMultichoiceComponent
+ ]
+})
+export class AddonQtypeMultichoiceModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeMultichoiceHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/addon/qtype/multichoice/providers/handler.ts b/src/addon/qtype/multichoice/providers/handler.ts
new file mode 100644
index 000000000..8eded4917
--- /dev/null
+++ b/src/addon/qtype/multichoice/providers/handler.ts
@@ -0,0 +1,154 @@
+
+// (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 { CoreUtilsProvider } from '@providers/utils/utils';
+import { CoreQuestionHandler } from '@core/question/providers/delegate';
+import { AddonQtypeMultichoiceComponent } from '../component/multichoice';
+
+/**
+ * Handler to support multichoice question type.
+ */
+@Injectable()
+export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeMultichoice';
+ type = 'qtype_multichoice';
+
+ constructor(private utils: CoreUtilsProvider) { }
+
+ /**
+ * 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 AddonQtypeMultichoiceComponent;
+ }
+
+ /**
+ * 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 {
+ let isSingle = true,
+ isMultiComplete = false;
+
+ // To know if it's single or multi answer we need to search for answers with "choice" in the name.
+ for (const name in answers) {
+ if (name.indexOf('choice') != -1) {
+ isSingle = false;
+ if (answers[name]) {
+ isMultiComplete = true;
+ }
+ }
+ }
+
+ if (isSingle) {
+ // Single.
+ return this.isCompleteResponseSingle(answers);
+ } else {
+ // Multi.
+ return isMultiComplete ? 1 : 0;
+ }
+ }
+
+ /**
+ * Check if a response is complete. Only for single answer.
+ *
+ * @param {any} question The question.uestion answers (without prefix).
+ * @return {number} 1 if complete, 0 if not complete, -1 if cannot determine.
+ */
+ isCompleteResponseSingle(answers: any): number {
+ return (answers['answer'] && answers['answer'] !== '') ? 1 : 0;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 this.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. Only for single answer.
+ *
+ * @param {any} answers Object with the question answers (without prefix).
+ * @return {number} 1 if gradable, 0 if not gradable, -1 if cannot determine.
+ */
+ isGradableResponseSingle(answers: any): number {
+ return this.isCompleteResponseSingle(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 {
+ let isSingle = true,
+ isMultiSame = true;
+
+ // To know if it's single or multi answer we need to search for answers with "choice" in the name.
+ for (const name in newAnswers) {
+ if (name.indexOf('choice') != -1) {
+ isSingle = false;
+ if (!this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, name)) {
+ isMultiSame = false;
+ }
+ }
+ }
+
+ if (isSingle) {
+ return this.isSameResponseSingle(prevAnswers, newAnswers);
+ } else {
+ return isMultiSame ;
+ }
+ }
+
+ /**
+ * Check if two responses are the same. Only for single answer.
+ *
+ * @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.
+ */
+ isSameResponseSingle(prevAnswers: any, newAnswers: any): boolean {
+ return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
+ }
+}
diff --git a/src/addon/qtype/numerical/numerical.module.ts b/src/addon/qtype/numerical/numerical.module.ts
new file mode 100644
index 000000000..b3ed56bbf
--- /dev/null
+++ b/src/addon/qtype/numerical/numerical.module.ts
@@ -0,0 +1,30 @@
+// (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 { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { AddonQtypeNumericalHandler } from './providers/handler';
+
+@NgModule({
+ declarations: [
+ ],
+ providers: [
+ AddonQtypeNumericalHandler
+ ]
+})
+export class AddonQtypeNumericalModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeNumericalHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/addon/qtype/numerical/providers/handler.ts b/src/addon/qtype/numerical/providers/handler.ts
new file mode 100644
index 000000000..931a2250f
--- /dev/null
+++ b/src/addon/qtype/numerical/providers/handler.ts
@@ -0,0 +1,125 @@
+
+// (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 { CoreUtilsProvider } from '@providers/utils/utils';
+import { CoreQuestionHandler } from '@core/question/providers/delegate';
+import { AddonQtypeShortAnswerComponent } from '@addon/qtype/shortanswer/component/shortanswer';
+
+/**
+ * Handler to support numerical question type.
+ */
+@Injectable()
+export class AddonQtypeNumericalHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeNumerical';
+ type = 'qtype_numerical';
+
+ constructor(private utils: CoreUtilsProvider) { }
+
+ /**
+ * 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 {
+ // Numerical behaves like a short answer, use the same component.
+ return AddonQtypeShortAnswerComponent;
+ }
+
+ /**
+ * 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 {
+ if (this.isGradableResponse(question, answers) === 0 || !this.validateUnits(answers['answer'])) {
+ return 0;
+ }
+
+ return -1;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 (answers['answer'] || answers['answer'] === '0' || answers['answer'] === 0) ? 1 : 0;
+ }
+
+ /**
+ * 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 this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
+ }
+
+ /**
+ * Validate a number with units. We don't have the list of valid units and conversions, so we can't perform
+ * a full validation. If this function returns true it means we can't be sure it's valid.
+ *
+ * @param {string} answer Answer.
+ * @return {boolean} False if answer isn't valid, true if we aren't sure if it's valid.
+ */
+ validateUnits(answer: string): boolean {
+ if (!answer) {
+ return false;
+ }
+
+ const regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?';
+
+ // Strip spaces (which may be thousands separators) and change other forms of writing e to e.
+ answer = answer.replace(' ', '');
+ answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1');
+
+ // If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and stip it.
+ // Else assume it is a decimal separator, and change it to '.'.
+ if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) {
+ answer = answer.replace(',', '');
+ } else {
+ answer = answer.replace(',', '.');
+ }
+
+ // We don't know if units should be before or after so we check both.
+ if (answer.match(new RegExp('^' + regexString)) === null || answer.match(new RegExp(regexString + '$')) === null) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/addon/qtype/qtype.module.ts b/src/addon/qtype/qtype.module.ts
new file mode 100644
index 000000000..a8563022c
--- /dev/null
+++ b/src/addon/qtype/qtype.module.ts
@@ -0,0 +1,51 @@
+// (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 { AddonQtypeCalculatedModule } from './calculated/calculated.module';
+import { AddonQtypeCalculatedMultiModule } from './calculatedmulti/calculatedmulti.module';
+import { AddonQtypeCalculatedSimpleModule } from './calculatedsimple/calculatedsimple.module';
+import { AddonQtypeDescriptionModule } from './description/description.module';
+import { AddonQtypeEssayModule } from './essay/essay.module';
+import { AddonQtypeGapSelectModule } from './gapselect/gapselect.module';
+import { AddonQtypeMatchModule } from './match/match.module';
+import { AddonQtypeMultiAnswerModule } from './multianswer/multianswer.module';
+import { AddonQtypeMultichoiceModule } from './multichoice/multichoice.module';
+import { AddonQtypeNumericalModule } from './numerical/numerical.module';
+import { AddonQtypeRandomSaMatchModule } from './randomsamatch/randomsamatch.module';
+import { AddonQtypeShortAnswerModule } from './shortanswer/shortanswer.module';
+import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module';
+
+@NgModule({
+ declarations: [],
+ imports: [
+ AddonQtypeCalculatedModule,
+ AddonQtypeCalculatedMultiModule,
+ AddonQtypeCalculatedSimpleModule,
+ AddonQtypeDescriptionModule,
+ AddonQtypeEssayModule,
+ AddonQtypeGapSelectModule,
+ AddonQtypeMatchModule,
+ AddonQtypeMultiAnswerModule,
+ AddonQtypeMultichoiceModule,
+ AddonQtypeNumericalModule,
+ AddonQtypeRandomSaMatchModule,
+ AddonQtypeShortAnswerModule,
+ AddonQtypeTrueFalseModule
+ ],
+ providers: [
+ ],
+ exports: []
+})
+export class AddonQtypeModule { }
diff --git a/src/addon/qtype/randomsamatch/providers/handler.ts b/src/addon/qtype/randomsamatch/providers/handler.ts
new file mode 100644
index 000000000..eca943805
--- /dev/null
+++ b/src/addon/qtype/randomsamatch/providers/handler.ts
@@ -0,0 +1,90 @@
+
+// (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 '@core/question/providers/delegate';
+import { AddonQtypeMatchHandler } from '@addon/qtype/match/providers/handler';
+import { AddonQtypeMatchComponent } from '@addon/qtype/match/component/match';
+
+/**
+ * Handler to support random short-answer matching question type.
+ */
+@Injectable()
+export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeRandomSaMatch';
+ type = 'qtype_randomsamatch';
+
+ constructor(private matchHandler: AddonQtypeMatchHandler) { }
+
+ /**
+ * 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 {
+ // Random behaves like a match question, use the same component.
+ return AddonQtypeMatchComponent;
+ }
+
+ /**
+ * 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 {
+ // This question behaves like a match question.
+ return this.matchHandler.isCompleteResponse(question, answers);
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 {
+ // This question behaves like a match question.
+ return this.matchHandler.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 {
+ // This question behaves like a match question.
+ return this.matchHandler.isSameResponse(question, prevAnswers, newAnswers);
+ }
+}
diff --git a/src/addon/qtype/randomsamatch/randomsamatch.module.ts b/src/addon/qtype/randomsamatch/randomsamatch.module.ts
new file mode 100644
index 000000000..4920b0506
--- /dev/null
+++ b/src/addon/qtype/randomsamatch/randomsamatch.module.ts
@@ -0,0 +1,30 @@
+// (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 { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { AddonQtypeRandomSaMatchHandler } from './providers/handler';
+
+@NgModule({
+ declarations: [
+ ],
+ providers: [
+ AddonQtypeRandomSaMatchHandler
+ ]
+})
+export class AddonQtypeRandomSaMatchModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeRandomSaMatchHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/addon/qtype/shortanswer/component/shortanswer.html b/src/addon/qtype/shortanswer/component/shortanswer.html
new file mode 100644
index 000000000..104534d90
--- /dev/null
+++ b/src/addon/qtype/shortanswer/component/shortanswer.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/addon/qtype/shortanswer/component/shortanswer.ts b/src/addon/qtype/shortanswer/component/shortanswer.ts
new file mode 100644
index 000000000..152710d68
--- /dev/null
+++ b/src/addon/qtype/shortanswer/component/shortanswer.ts
@@ -0,0 +1,38 @@
+// (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 } from '@angular/core';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
+
+/**
+ * Component to render a short answer question.
+ */
+@Component({
+ selector: 'addon-qtype-shortanswer',
+ templateUrl: 'shortanswer.html'
+})
+export class AddonQtypeShortAnswerComponent extends CoreQuestionBaseComponent implements OnInit {
+
+ constructor(logger: CoreLoggerProvider, injector: Injector) {
+ super(logger, 'AddonQtypeShortAnswerComponent', injector);
+ }
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ this.initInputTextComponent();
+ }
+}
diff --git a/src/addon/qtype/shortanswer/providers/handler.ts b/src/addon/qtype/shortanswer/providers/handler.ts
new file mode 100644
index 000000000..abbb694dc
--- /dev/null
+++ b/src/addon/qtype/shortanswer/providers/handler.ts
@@ -0,0 +1,86 @@
+
+// (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 { CoreUtilsProvider } from '@providers/utils/utils';
+import { CoreQuestionHandler } from '@core/question/providers/delegate';
+import { AddonQtypeShortAnswerComponent } from '../component/shortanswer';
+
+/**
+ * Handler to support short answer question type.
+ */
+@Injectable()
+export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeShortAnswer';
+ type = 'qtype_shortanswer';
+
+ constructor(private utils: CoreUtilsProvider) { }
+
+ /**
+ * 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 AddonQtypeShortAnswerComponent;
+ }
+
+ /**
+ * 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 (answers['answer'] || answers['answer'] === 0) ? 1 : 0;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 this.isCompleteResponse(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 {
+ return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
+ }
+}
diff --git a/src/addon/qtype/shortanswer/shortanswer.module.ts b/src/addon/qtype/shortanswer/shortanswer.module.ts
new file mode 100644
index 000000000..58497545d
--- /dev/null
+++ b/src/addon/qtype/shortanswer/shortanswer.module.ts
@@ -0,0 +1,46 @@
+// (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 { IonicModule } from 'ionic-angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { CoreDirectivesModule } from '@directives/directives.module';
+import { AddonQtypeShortAnswerHandler } from './providers/handler';
+import { AddonQtypeShortAnswerComponent } from './component/shortanswer';
+
+@NgModule({
+ declarations: [
+ AddonQtypeShortAnswerComponent
+ ],
+ imports: [
+ IonicModule,
+ TranslateModule.forChild(),
+ CoreDirectivesModule
+ ],
+ providers: [
+ AddonQtypeShortAnswerHandler
+ ],
+ exports: [
+ AddonQtypeShortAnswerComponent
+ ],
+ entryComponents: [
+ AddonQtypeShortAnswerComponent
+ ]
+})
+export class AddonQtypeShortAnswerModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeShortAnswerHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/addon/qtype/truefalse/providers/handler.ts b/src/addon/qtype/truefalse/providers/handler.ts
new file mode 100644
index 000000000..59c47fdff
--- /dev/null
+++ b/src/addon/qtype/truefalse/providers/handler.ts
@@ -0,0 +1,87 @@
+
+// (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 { CoreUtilsProvider } from '@providers/utils/utils';
+import { CoreQuestionHandler } from '@core/question/providers/delegate';
+import { AddonQtypeMultichoiceComponent } from '@addon/qtype/multichoice/component/multichoice';
+
+/**
+ * Handler to support true/false question type.
+ */
+@Injectable()
+export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler {
+ name = 'AddonQtypeTrueFalse';
+ type = 'qtype_truefalse';
+
+ constructor(private utils: CoreUtilsProvider) { }
+
+ /**
+ * 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 {
+ // True/false behaves like a multichoice, use the same component.
+ return AddonQtypeMultichoiceComponent;
+ }
+
+ /**
+ * 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 answers['answer'] ? 1 : 0;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 this.isCompleteResponse(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 {
+ return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
+ }
+}
diff --git a/src/addon/qtype/truefalse/truefalse.module.ts b/src/addon/qtype/truefalse/truefalse.module.ts
new file mode 100644
index 000000000..641a26d15
--- /dev/null
+++ b/src/addon/qtype/truefalse/truefalse.module.ts
@@ -0,0 +1,30 @@
+// (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 { CoreQuestionDelegate } from '@core/question/providers/delegate';
+import { AddonQtypeTrueFalseHandler } from './providers/handler';
+
+@NgModule({
+ declarations: [
+ ],
+ providers: [
+ AddonQtypeTrueFalseHandler
+ ]
+})
+export class AddonQtypeTrueFalseModule {
+ constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeTrueFalseHandler) {
+ questionDelegate.registerHandler(handler);
+ }
+}
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index fbcf159f6..03f660c6a 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -87,6 +87,7 @@ import { AddonNotesModule } from '../addon/notes/notes.module';
import { AddonPushNotificationsModule } from '@addon/pushnotifications/pushnotifications.module';
import { AddonRemoteThemesModule } from '@addon/remotethemes/remotethemes.module';
import { AddonQbehaviourModule } from '@addon/qbehaviour/qbehaviour.module';
+import { AddonQtypeModule } from '@addon/qtype/qtype.module';
// For translate loader. AoT requires an exported function for factories.
export function createTranslateLoader(http: HttpClient): TranslateHttpLoader {
@@ -174,7 +175,8 @@ export const CORE_PROVIDERS: any[] = [
AddonNotesModule,
AddonPushNotificationsModule,
AddonRemoteThemesModule,
- AddonQbehaviourModule
+ AddonQbehaviourModule,
+ AddonQtypeModule
],
bootstrap: [IonicApp],
entryComponents: [
diff --git a/src/core/question/classes/base-question-component.ts b/src/core/question/classes/base-question-component.ts
new file mode 100644
index 000000000..5befc6391
--- /dev/null
+++ b/src/core/question/classes/base-question-component.ts
@@ -0,0 +1,488 @@
+// (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 { Input, EventEmitter, Injector } from '@angular/core';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreDomUtilsProvider } from '@providers/utils/dom';
+import { CoreTextUtilsProvider } from '@providers/utils/text';
+import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
+
+/**
+ * Base class for components to render a question.
+ */
+export class CoreQuestionBaseComponent {
+ @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.
+ @Input() buttonClicked: EventEmitter; // Should emit an event when a behaviour button is clicked.
+ @Input() onAbort: EventEmitter; // Should emit an event if the question should be aborted.
+
+ protected logger;
+ protected questionHelper: CoreQuestionHelperProvider;
+ protected domUtils: CoreDomUtilsProvider;
+ protected textUtils: CoreTextUtilsProvider;
+
+ constructor(logger: CoreLoggerProvider, logName: string, protected injector: Injector) {
+ this.logger = logger.getInstance(logName);
+
+ // Use an injector to get the providers to prevent having to modify all subclasses if a new provider is needed.
+ this.questionHelper = injector.get(CoreQuestionHelperProvider);
+ this.domUtils = injector.get(CoreDomUtilsProvider);
+ this.textUtils = injector.get(CoreTextUtilsProvider);
+ }
+
+ /**
+ * Initialize a question component of type calculated or calculated simple.
+ *
+ * @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
+ */
+ initCalculatedComponent(): void | HTMLElement {
+ // Treat the input text first.
+ const questionDiv = this.initInputTextComponent();
+ if (questionDiv) {
+
+ // Check if the question has a select for units.
+ const selectModel: any = {},
+ select = questionDiv.querySelector('select[name*=unit]'),
+ options = select && Array.from(select.querySelectorAll('option'));
+
+ if (select && options && options.length) {
+
+ selectModel.id = select.id;
+ selectModel.name = select.name;
+ selectModel.disabled = select.disabled;
+ selectModel.options = [];
+
+ // Treat each option.
+ for (const i in options) {
+ const optionEl = options[i];
+
+ if (typeof optionEl.value == 'undefined') {
+ this.logger.warn('Aborting because couldn\'t find input.', this.question.name);
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+
+ const option = {
+ value: optionEl.value,
+ label: optionEl.innerHTML
+ };
+
+ if (optionEl.selected) {
+ selectModel.selected = option.value;
+ selectModel.selectedLabel = option.label;
+ }
+
+ selectModel.options.push(option);
+ }
+
+ if (!selectModel.selected) {
+ // No selected option, select the first one.
+ selectModel.selected = selectModel.options[0].value;
+ selectModel.selectedLabel = selectModel.options[0].label;
+ }
+
+ // Get the accessibility label.
+ const accessibilityLabel = questionDiv.querySelector('label[for="' + select.id + '"]');
+ selectModel.accessibilityLabel = accessibilityLabel && accessibilityLabel.innerHTML;
+
+ this.question.select = selectModel;
+
+ // Check which one should be displayed first: the select or the input.
+ const input = questionDiv.querySelector('input[type="text"][name*=answer]');
+ this.question.selectFirst =
+ questionDiv.innerHTML.indexOf(input.outerHTML) > questionDiv.innerHTML.indexOf(select.outerHTML);
+
+ return questionDiv;
+ }
+
+ // Check if the question has radio buttons for units.
+ const radios = Array.from(questionDiv.querySelectorAll('input[type="radio"]'));
+ if (!radios.length) {
+ // No select and no radio buttons. The units need to be entered in the input text.
+ return questionDiv;
+ }
+
+ this.question.options = [];
+
+ for (const i in radios) {
+ const radioEl = radios[i],
+ option: any = {
+ id: radioEl.id,
+ name: radioEl.name,
+ value: radioEl.value,
+ checked: radioEl.checked,
+ disabled: radioEl.disabled
+ },
+ // Get the label with the question text.
+ label = questionDiv.querySelector('label[for="' + option.id + '"]');
+
+ this.question.optionsName = option.name;
+
+ if (label) {
+ option.text = label.innerText;
+
+ // Check that we were able to successfully extract options required data.
+ if (typeof option.name != 'undefined' && typeof option.value != 'undefined' &&
+ typeof option.text != 'undefined') {
+
+ if (radioEl.checked) {
+ // If the option is checked we use the model to select the one.
+ this.question.unit = option.value;
+ }
+
+ this.question.options.push(option);
+ continue;
+ }
+ }
+
+ // Something went wrong when extracting the questions data. Abort.
+ this.logger.warn('Aborting because of an error parsing options.', this.question.name, option.name);
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+
+ // Check which one should be displayed first: the options or the input.
+ const input = questionDiv.querySelector('input[type="text"][name*=answer]');
+ this.question.optionsFirst =
+ questionDiv.innerHTML.indexOf(input.outerHTML) > questionDiv.innerHTML.indexOf(options[0].outerHTML);
+ }
+ }
+
+ /**
+ * Initialize the component and the question text.
+ *
+ * @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
+ */
+ initComponent(): void | HTMLElement {
+ if (!this.question) {
+ this.logger.warn('Aborting because of no question received.');
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+
+ const div = document.createElement('div');
+ div.innerHTML = this.question.html;
+
+ // Extract question text.
+ this.question.text = this.domUtils.getContentsOfElement(div, '.qtext');
+ if (typeof this.question.text == 'undefined') {
+ this.logger.warn('Aborting because of an error parsing question.', this.question.name);
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+
+ return div;
+ }
+
+ /**
+ * Initialize a question component of type essay.
+ *
+ * @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
+ */
+ initEssayComponent(): void | HTMLElement {
+ const questionDiv = this.initComponent();
+
+ if (questionDiv) {
+ // First search the textarea.
+ const textarea = questionDiv.querySelector('textarea[name*=_answer]');
+ this.question.allowsAttachments = !!questionDiv.querySelector('div[id*=filemanager]');
+ this.question.isMonospaced = !!questionDiv.querySelector('.qtype_essay_monospaced');
+ this.question.isPlainText = this.question.isMonospaced || !!questionDiv.querySelector('.qtype_essay_plain');
+ this.question.hasDraftFiles = this.questionHelper.hasDraftFileUrls(questionDiv.innerHTML);
+
+ if (!textarea) {
+ // Textarea not found, we might be in review. Search the answer and the attachments.
+ this.question.answer = this.domUtils.getContentsOfElement(questionDiv, '.qtype_essay_response');
+ this.question.attachments = this.questionHelper.getQuestionAttachmentsFromHtml(
+ this.domUtils.getContentsOfElement(questionDiv, '.attachments'));
+ } else {
+ // Textarea found.
+ const input = questionDiv.querySelector('input[type="hidden"][name*=answerformat]'),
+ content = textarea.innerHTML;
+
+ this.question.textarea = {
+ id: textarea.id,
+ name: textarea.name,
+ text: content ? this.textUtils.decodeHTML(content) : ''
+ };
+
+ if (input) {
+ this.question.formatInput = {
+ name: input.name,
+ value: input.value
+ };
+ }
+ }
+ }
+ }
+
+ /**
+ * Initialize a question component that uses the original question text with some basic treatment.
+ *
+ * @param {string} contentSelector The selector to find the question content (text).
+ * @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
+ */
+ initOriginalTextComponent(contentSelector: string): void | HTMLElement {
+ if (!this.question) {
+ this.logger.warn('Aborting because of no question received.');
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+
+ const div = document.createElement('div');
+ div.innerHTML = this.question.html;
+
+ // Get question content.
+ const content = div.querySelector(contentSelector);
+ if (!content) {
+ this.logger.warn('Aborting because of an error parsing question.', this.question.name);
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+
+ // Remove sequencecheck and validation error.
+ this.domUtils.removeElement(content, 'input[name*=sequencecheck]');
+ this.domUtils.removeElement(content, '.validationerror');
+
+ // Replace Moodle's correct/incorrect and feedback classes with our own.
+ this.questionHelper.replaceCorrectnessClasses(div);
+ this.questionHelper.replaceFeedbackClasses(div);
+
+ // Treat the correct/incorrect icons.
+ this.questionHelper.treatCorrectnessIcons(div, this.component, this.componentId);
+
+ // Set the question text.
+ this.question.text = content.innerHTML;
+ }
+
+ /**
+ * Initialize a question component that has an input of type "text".
+ *
+ * @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
+ */
+ initInputTextComponent(): void | HTMLElement {
+ const questionDiv = this.initComponent();
+ if (questionDiv) {
+ // Get the input element.
+ const input = questionDiv.querySelector('input[type="text"][name*=answer]');
+ if (!input) {
+ this.logger.warn('Aborting because couldn\'t find input.', this.question.name);
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+
+ this.question.input = {
+ id: input.id,
+ name: input.name,
+ value: input.value,
+ readOnly: input.readOnly
+ };
+
+ // Check if question is marked as correct.
+ if (input.className.indexOf('incorrect') >= 0) {
+ this.question.input.isCorrect = 0;
+ } else if (input.className.indexOf('correct') >= 0) {
+ this.question.input.isCorrect = 1;
+ }
+ }
+
+ return questionDiv;
+ }
+
+ /**
+ * Initialize a question component with a "match" behaviour.
+ *
+ * @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
+ */
+ initMatchComponent(): void | HTMLElement {
+ const questionDiv = this.initComponent();
+
+ if (questionDiv) {
+ // Find rows.
+ const rows = Array.from(questionDiv.querySelectorAll('tr'));
+ if (!rows || !rows.length) {
+ this.logger.warn('Aborting because couldn\'t find any row.', this.question.name);
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+
+ this.question.rows = [];
+
+ for (const i in rows) {
+ const row = rows[i],
+ rowModel: any = {},
+ columns = Array.from(row.querySelectorAll('td'));
+
+ if (!columns || columns.length < 2) {
+ this.logger.warn('Aborting because couldn\'t the right columns.', this.question.name);
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+
+ // Get the row's text. It should be in the first column.
+ rowModel.text = columns[0].innerHTML;
+
+ // Get the select and the options.
+ const select = columns[1].querySelector('select'),
+ options = Array.from(columns[1].querySelectorAll('option'));
+
+ if (!select || !options || !options.length) {
+ this.logger.warn('Aborting because couldn\'t find select or options.', this.question.name);
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+
+ rowModel.id = select.id;
+ rowModel.name = select.name;
+ rowModel.disabled = select.disabled;
+ rowModel.selected = false;
+ rowModel.options = [];
+
+ // Check if answer is correct.
+ if (columns[1].className.indexOf('incorrect') >= 0) {
+ rowModel.isCorrect = 0;
+ } else if (columns[1].className.indexOf('correct') >= 0) {
+ rowModel.isCorrect = 1;
+ }
+
+ // Treat each option.
+ for (const j in options) {
+ const optionEl = options[j];
+
+ if (typeof optionEl.value == 'undefined') {
+ this.logger.warn('Aborting because couldn\'t find the value of an option.', this.question.name);
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+
+ const option = {
+ value: optionEl.value,
+ label: optionEl.innerHTML,
+ selected: optionEl.selected
+ };
+
+ if (option.selected) {
+ rowModel.selected = option;
+ }
+
+ rowModel.options.push(option);
+ }
+
+ // Get the accessibility label.
+ const accessibilityLabel = columns[1].querySelector('label.accesshide');
+ rowModel.accessibilityLabel = accessibilityLabel && accessibilityLabel.innerHTML;
+
+ this.question.rows.push(rowModel);
+ }
+
+ this.question.loaded = true;
+ }
+
+ return questionDiv;
+ }
+
+ /**
+ * Initialize a question component with a multiple choice (checkbox) or single choice (radio).
+ *
+ * @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
+ */
+ initMultichoiceComponent(): void | HTMLElement {
+ const questionDiv = this.initComponent();
+
+ if (questionDiv) {
+ // Create the model for radio buttons.
+ this.question.singleChoiceModel = {};
+
+ // Get the prompt.
+ this.question.prompt = this.domUtils.getContentsOfElement(questionDiv, '.prompt');
+
+ // Search radio buttons first (single choice).
+ let options = Array.from(questionDiv.querySelectorAll('input[type="radio"]'));
+ if (!options || !options.length) {
+ // Radio buttons not found, it should be a multi answer. Search for checkbox.
+ this.question.multi = true;
+ options = Array.from(questionDiv.querySelectorAll('input[type="checkbox"]'));
+
+ if (!options || !options.length) {
+ // No checkbox found either. Abort.
+ this.logger.warn('Aborting because of no radio and checkbox found.', this.question.name);
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+ }
+
+ this.question.options = [];
+
+ for (const i in options) {
+ const element = options[i],
+ option: any = {
+ id: element.id,
+ name: element.name,
+ value: element.value,
+ checked: element.checked,
+ disabled: element.disabled
+ },
+ parent = element.parentElement;
+
+ this.question.optionsName = option.name;
+
+ // Get the label with the question text.
+ const label = questionDiv.querySelector('label[for="' + option.id + '"]');
+ if (label) {
+ option.text = label.innerHTML;
+
+ // Check that we were able to successfully extract options required data.
+ if (typeof option.name != 'undefined' && typeof option.value != 'undefined' &&
+ typeof option.text != 'undefined') {
+
+ if (element.checked) {
+ // If the option is checked and it's a single choice we use the model to select the one.
+ if (!this.question.multi) {
+ this.question.singleChoiceModel = option.value;
+ }
+
+ if (parent) {
+ // Check if answer is correct.
+ if (parent && parent.className.indexOf('incorrect') >= 0) {
+ option.isCorrect = 0;
+ } else if (parent && parent.className.indexOf('correct') >= 0) {
+ option.isCorrect = 1;
+ }
+
+ // Search the feedback.
+ const feedback = parent.querySelector('.specificfeedback');
+ if (feedback) {
+ option.feedback = feedback.innerHTML;
+ }
+ }
+ }
+
+ this.question.options.push(option);
+ continue;
+ }
+ }
+
+ // Something went wrong when extracting the questions data. Abort.
+ this.logger.warn('Aborting because of an error parsing options.', this.question.name, option.name);
+
+ return this.questionHelper.showComponentError(this.onAbort);
+ }
+ }
+
+ return questionDiv;
+ }
+}
diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts
index 5ffaf0c0e..f6d317dd2 100644
--- a/src/core/question/providers/helper.ts
+++ b/src/core/question/providers/helper.ts
@@ -13,6 +13,8 @@
// limitations under the License.
import { Injectable, EventEmitter } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
+import { CoreSitesProvider } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreQuestionProvider } from './question';
@@ -26,7 +28,8 @@ export class CoreQuestionHelperProvider {
protected div = document.createElement('div'); // A div element to search in HTML code.
constructor(private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider,
- private questionProvider: CoreQuestionProvider) { }
+ private questionProvider: CoreQuestionProvider, private sitesProvider: CoreSitesProvider,
+ private translate: TranslateService) { }
/**
* Add a behaviour button to the question's "behaviourButtons" property.
@@ -266,6 +269,69 @@ export class CoreQuestionHelperProvider {
}
}
+ /**
+ * Get the names of all the inputs inside an HTML code.
+ * This function will return an object where the keys are the input names. The values will always be true.
+ * This is in order to make this function compatible with other functions like CoreQuestionProvider.getBasicAnswers.
+ *
+ * @param {string} html HTML code.
+ * @return {any} Object where the keys are the names.
+ */
+ getAllInputNamesFromHtml(html: string): any {
+ const form = document.createElement('form'),
+ answers = {};
+
+ form.innerHTML = html;
+
+ // Search all input elements.
+ Array.from(form.elements).forEach((element: HTMLInputElement) => {
+ const name = element.name || '';
+
+ // Ignore flag and submit inputs.
+ if (!name || name.match(/_:flagged$/) || element.type == 'submit' || element.tagName == 'BUTTON') {
+ return;
+ }
+
+ answers[this.questionProvider.removeQuestionPrefix(name)] = true;
+ });
+
+ return answers;
+ }
+
+ /**
+ * Given an HTML code with list of attachments, returns the list of attached files (filename and fileurl).
+ * Please take into account that this function will treat all the anchors in the HTML, you should provide
+ * an HTML containing only the attachments anchors.
+ *
+ * @param {String} html HTML code to search in.
+ * @return {Object[]} Attachments.
+ */
+ getQuestionAttachmentsFromHtml(html: string): any[] {
+ this.div.innerHTML = html;
+
+ // Remove the filemanager (area to attach files to a question).
+ this.domUtils.removeElement(this.div, 'div[id*=filemanager]');
+
+ // Search the anchors.
+ const anchors = Array.from(this.div.querySelectorAll('a')),
+ attachments = [];
+
+ anchors.forEach((anchor) => {
+ let content = anchor.innerHTML;
+
+ // Check anchor is valid.
+ if (anchor.href && content) {
+ content = this.textUtils.cleanTags(content, true).trim();
+ attachments.push({
+ filename: content,
+ fileurl: anchor.href
+ });
+ }
+ });
+
+ return attachments;
+ }
+
/**
* Get the sequence check from a question HTML.
*
@@ -299,6 +365,22 @@ export class CoreQuestionHelperProvider {
return this.domUtils.getContentsOfElement(this.div, '.validationerror');
}
+ /**
+ * Check if some HTML contains draft file URLs for the current site.
+ *
+ * @param {string} html Question's HTML.
+ * @return {boolean} Whether it contains draft files URLs.
+ */
+ hasDraftFileUrls(html: string): boolean {
+ let url = this.sitesProvider.getCurrentSite().getURL();
+ if (url.slice(-1) != '/') {
+ url = url += '/';
+ }
+ url += 'draftfile.php';
+
+ return html.indexOf(url) != -1;
+ }
+
/**
* For each input element found in the HTML, search if there's a local answer stored and
* override the HTML's value with the local one.
@@ -346,6 +428,30 @@ export class CoreQuestionHelperProvider {
question.html = form.innerHTML;
}
+ /**
+ * Replace Moodle's correct/incorrect classes with the Mobile ones.
+ *
+ * @param {HTMLElement} element DOM element.
+ */
+ replaceCorrectnessClasses(element: HTMLElement): void {
+ this.domUtils.replaceClassesInElement(element, {
+ correct: 'core-question-answer-correct',
+ incorrect: 'core-question-answer-incorrect'
+ });
+ }
+
+ /**
+ * Replace Moodle's feedback classes with the Mobile ones.
+ *
+ * @param {HTMLElement} element DOM element.
+ */
+ replaceFeedbackClasses(element: HTMLElement): void {
+ this.domUtils.replaceClassesInElement(element, {
+ outcome: 'core-question-feedback-container core-question-feedback-padding',
+ specificfeedback: 'core-question-feedback-container core-question-feedback-inline'
+ });
+ }
+
/**
* Search a behaviour button in a certain question property containing HTML.
*
@@ -392,4 +498,55 @@ export class CoreQuestionHelperProvider {
onAbort && onAbort.emit();
}
+
+ /**
+ * Treat correctness icons, replacing them with local icons and setting click events to show the feedback if needed.
+ *
+ * @param {HTMLElement} element DOM element.
+ */
+ treatCorrectnessIcons(element: HTMLElement, component?: string, componentId?: number): void {
+
+ const icons = Array.from(element.querySelectorAll('img.icon, img.questioncorrectnessicon'));
+ icons.forEach((icon) => {
+ // Replace the icon with the font version.
+ if (icon.src) {
+ const newIcon: any = document.createElement('i');
+
+ if (icon.src.indexOf('incorrect') > -1) {
+ newIcon.className = 'icon fa fa-remove text-danger fa-fw questioncorrectnessicon';
+ } else if (icon.src.indexOf('correct') > -1) {
+ newIcon.className = 'icon fa fa-check text-success fa-fw questioncorrectnessicon';
+ } else {
+ return;
+ }
+
+ newIcon.title = icon.title;
+ newIcon.ariaLabel = icon.title;
+ icon.parentNode.replaceChild(newIcon, icon);
+ }
+ });
+
+ const spans = Array.from(element.querySelectorAll('.feedbackspan.accesshide'));
+ spans.forEach((span) => {
+ // Search if there's a hidden feedback for this element.
+ const icon = span.previousSibling;
+ if (!icon) {
+ return;
+ }
+
+ if (!icon.classList.contains('icon') && !icon.classList.contains('questioncorrectnessicon')) {
+ return;
+ }
+
+ icon.classList.add('questioncorrectnessicon');
+
+ if (span.innerHTML) {
+ // There's a hidden feedback, show it when the icon is clicked.
+ icon.addEventListener('click', (event) => {
+ const title = this.translate.instant('core.question.feedback');
+ this.textUtils.expandText(title, span.innerHTML, component, componentId);
+ });
+ }
+ });
+ }
}