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..585d317ff
--- /dev/null
+++ b/src/addon/qtype/calculated/component/calculated.ts
@@ -0,0 +1,40 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Component, OnInit } from '@angular/core';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreDomUtilsProvider } from '@providers/utils/dom';
+import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
+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, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) {
+ super(logger, 'AddonQtypeCalculatedComponent', questionHelper, domUtils);
+ }
+
+ /**
+ * 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/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/qtype.module.ts b/src/addon/qtype/qtype.module.ts
index 2f735f24d..a3a7fd3a8 100644
--- a/src/addon/qtype/qtype.module.ts
+++ b/src/addon/qtype/qtype.module.ts
@@ -13,7 +13,9 @@
// 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 { AddonQtypeMatchModule } from './match/match.module';
import { AddonQtypeMultichoiceModule } from './multichoice/multichoice.module';
@@ -25,7 +27,9 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module';
@NgModule({
declarations: [],
imports: [
+ AddonQtypeCalculatedModule,
AddonQtypeCalculatedMultiModule,
+ AddonQtypeCalculatedSimpleModule,
AddonQtypeDescriptionModule,
AddonQtypeMatchModule,
AddonQtypeMultichoiceModule,
diff --git a/src/core/question/classes/base-question-component.ts b/src/core/question/classes/base-question-component.ts
index 1d9f29a04..cce52c112 100644
--- a/src/core/question/classes/base-question-component.ts
+++ b/src/core/question/classes/base-question-component.ts
@@ -36,6 +36,124 @@ export class CoreQuestionBaseComponent {
this.logger = logger.getInstance(logName);
}
+ /**
+ * 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.
*