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 @@ +
+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + {{option.label}} + + + + + + + +
+ +

{{option.text}}

+
+
+
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. *