From 2f172f77dd649e5f12a543f87f5bf370277a2868 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 16 Mar 2018 13:20:03 +0100 Subject: [PATCH] MOBILE-2389 qtype: Implement shortanswer and numerical question types --- src/addon/qtype/numerical/numerical.module.ts | 30 +++++ .../qtype/numerical/providers/handler.ts | 125 ++++++++++++++++++ src/addon/qtype/qtype.module.ts | 29 ++++ .../shortanswer/component/shortanswer.html | 7 + .../shortanswer/component/shortanswer.ts | 40 ++++++ .../qtype/shortanswer/providers/handler.ts | 86 ++++++++++++ .../qtype/shortanswer/shortanswer.module.ts | 46 +++++++ src/app/app.module.ts | 4 +- .../classes/base-question-component.ts | 98 ++++++++++++++ 9 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 src/addon/qtype/numerical/numerical.module.ts create mode 100644 src/addon/qtype/numerical/providers/handler.ts create mode 100644 src/addon/qtype/qtype.module.ts create mode 100644 src/addon/qtype/shortanswer/component/shortanswer.html create mode 100644 src/addon/qtype/shortanswer/component/shortanswer.ts create mode 100644 src/addon/qtype/shortanswer/providers/handler.ts create mode 100644 src/addon/qtype/shortanswer/shortanswer.module.ts create mode 100644 src/core/question/classes/base-question-component.ts 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..ee6fd2406 --- /dev/null +++ b/src/addon/qtype/qtype.module.ts @@ -0,0 +1,29 @@ +// (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 { AddonQtypeNumericalModule } from './numerical/numerical.module'; +import { AddonQtypeShortAnswerModule } from './shortanswer/shortanswer.module'; + +@NgModule({ + declarations: [], + imports: [ + AddonQtypeNumericalModule, + AddonQtypeShortAnswerModule + ], + providers: [ + ], + exports: [] +}) +export class AddonQtypeModule { } 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..77f2af456 --- /dev/null +++ b/src/addon/qtype/shortanswer/component/shortanswer.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 short answer question. + */ +@Component({ + selector: 'addon-qtype-shortanswer', + templateUrl: 'shortanswer.html' +}) +export class AddonQtypeShortAnswerComponent extends CoreQuestionBaseComponent implements OnInit { + + constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { + super(logger, 'AddonQtypeShortAnswerComponent', questionHelper, domUtils); + } + + /** + * 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/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..b13404330 --- /dev/null +++ b/src/core/question/classes/base-question-component.ts @@ -0,0 +1,98 @@ +// (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 } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +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; + + constructor(logger: CoreLoggerProvider, logName: string, protected questionHelper: CoreQuestionHelperProvider, + protected domUtils: CoreDomUtilsProvider) { + this.logger = logger.getInstance(logName); + } + + /** + * 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 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; + } +}