From 2f172f77dd649e5f12a543f87f5bf370277a2868 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 16 Mar 2018 13:20:03 +0100 Subject: [PATCH 1/6] 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; + } +} From be573d240b080b64ec27e6efad9a60282976f846 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 19 Mar 2018 12:13:45 +0100 Subject: [PATCH 2/6] MOBILE-2389 qtype: Implement description and multichoices types --- .../calculatedmulti/calculatedmulti.module.ts | 30 ++++ .../calculatedmulti/providers/handler.ts | 90 ++++++++++ .../description/component/description.html | 7 + .../description/component/description.ts | 50 ++++++ .../qtype/description/description.module.ts | 46 ++++++ .../qtype/description/providers/handler.ts | 77 +++++++++ .../multichoice/component/multichoice.html | 25 +++ .../multichoice/component/multichoice.ts | 40 +++++ .../qtype/multichoice/multichoice.module.ts | 46 ++++++ .../qtype/multichoice/providers/handler.ts | 154 ++++++++++++++++++ src/addon/qtype/qtype.module.ts | 10 +- .../qtype/truefalse/providers/handler.ts | 87 ++++++++++ src/addon/qtype/truefalse/truefalse.module.ts | 30 ++++ .../classes/base-question-component.ts | 91 +++++++++++ 14 files changed, 782 insertions(+), 1 deletion(-) create mode 100644 src/addon/qtype/calculatedmulti/calculatedmulti.module.ts create mode 100644 src/addon/qtype/calculatedmulti/providers/handler.ts create mode 100644 src/addon/qtype/description/component/description.html create mode 100644 src/addon/qtype/description/component/description.ts create mode 100644 src/addon/qtype/description/description.module.ts create mode 100644 src/addon/qtype/description/providers/handler.ts create mode 100644 src/addon/qtype/multichoice/component/multichoice.html create mode 100644 src/addon/qtype/multichoice/component/multichoice.ts create mode 100644 src/addon/qtype/multichoice/multichoice.module.ts create mode 100644 src/addon/qtype/multichoice/providers/handler.ts create mode 100644 src/addon/qtype/truefalse/providers/handler.ts create mode 100644 src/addon/qtype/truefalse/truefalse.module.ts 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/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..f08b7dc82 --- /dev/null +++ b/src/addon/qtype/description/component/description.ts @@ -0,0 +1,50 @@ +// (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 description question. + */ +@Component({ + selector: 'addon-qtype-description', + templateUrl: 'description.html' +}) +export class AddonQtypeDescriptionComponent extends CoreQuestionBaseComponent implements OnInit { + + constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { + super(logger, 'AddonQtypeDescriptionComponent', questionHelper, domUtils); + } + + /** + * 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/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..add6a7af7 --- /dev/null +++ b/src/addon/qtype/multichoice/component/multichoice.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 multichoice question. + */ +@Component({ + selector: 'addon-qtype-multichoice', + templateUrl: 'multichoice.html' +}) +export class AddonQtypeMultichoiceComponent extends CoreQuestionBaseComponent implements OnInit { + + constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { + super(logger, 'AddonQtypeMultichoiceComponent', questionHelper, domUtils); + } + + /** + * 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/qtype.module.ts b/src/addon/qtype/qtype.module.ts index ee6fd2406..c7ab3ab46 100644 --- a/src/addon/qtype/qtype.module.ts +++ b/src/addon/qtype/qtype.module.ts @@ -13,14 +13,22 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { AddonQtypeCalculatedMultiModule } from './calculatedmulti/calculatedmulti.module'; +import { AddonQtypeDescriptionModule } from './description/description.module'; +import { AddonQtypeMultichoiceModule } from './multichoice/multichoice.module'; import { AddonQtypeNumericalModule } from './numerical/numerical.module'; import { AddonQtypeShortAnswerModule } from './shortanswer/shortanswer.module'; +import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; @NgModule({ declarations: [], imports: [ + AddonQtypeCalculatedMultiModule, + AddonQtypeDescriptionModule, + AddonQtypeMultichoiceModule, AddonQtypeNumericalModule, - AddonQtypeShortAnswerModule + AddonQtypeShortAnswerModule, + AddonQtypeTrueFalseModule ], providers: [ ], 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/core/question/classes/base-question-component.ts b/src/core/question/classes/base-question-component.ts index b13404330..859c14a67 100644 --- a/src/core/question/classes/base-question-component.ts +++ b/src/core/question/classes/base-question-component.ts @@ -95,4 +95,95 @@ export class CoreQuestionBaseComponent { 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; + } } From a89737a5d33142845d58cb28036b94437669114c Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 19 Mar 2018 14:31:18 +0100 Subject: [PATCH 3/6] MOBILE-2389 qtype: Implement match and randomsamatch types --- src/addon/qtype/match/component/match.html | 19 +++ src/addon/qtype/match/component/match.ts | 40 ++++++ src/addon/qtype/match/match.module.ts | 46 +++++++ src/addon/qtype/match/providers/handler.ts | 118 ++++++++++++++++++ src/addon/qtype/qtype.module.ts | 4 + .../qtype/randomsamatch/providers/handler.ts | 90 +++++++++++++ .../randomsamatch/randomsamatch.module.ts | 30 +++++ .../classes/base-question-component.ts | 92 ++++++++++++++ 8 files changed, 439 insertions(+) create mode 100644 src/addon/qtype/match/component/match.html create mode 100644 src/addon/qtype/match/component/match.ts create mode 100644 src/addon/qtype/match/match.module.ts create mode 100644 src/addon/qtype/match/providers/handler.ts create mode 100644 src/addon/qtype/randomsamatch/providers/handler.ts create mode 100644 src/addon/qtype/randomsamatch/randomsamatch.module.ts 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..117e59af5 --- /dev/null +++ b/src/addon/qtype/match/component/match.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 match question. + */ +@Component({ + selector: 'addon-qtype-match', + templateUrl: 'match.html' +}) +export class AddonQtypeMatchComponent extends CoreQuestionBaseComponent implements OnInit { + + constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { + super(logger, 'AddonQtypeMatchComponent', questionHelper, domUtils); + } + + /** + * 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/qtype.module.ts b/src/addon/qtype/qtype.module.ts index c7ab3ab46..2f735f24d 100644 --- a/src/addon/qtype/qtype.module.ts +++ b/src/addon/qtype/qtype.module.ts @@ -15,8 +15,10 @@ import { NgModule } from '@angular/core'; import { AddonQtypeCalculatedMultiModule } from './calculatedmulti/calculatedmulti.module'; import { AddonQtypeDescriptionModule } from './description/description.module'; +import { AddonQtypeMatchModule } from './match/match.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'; @@ -25,8 +27,10 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; imports: [ AddonQtypeCalculatedMultiModule, AddonQtypeDescriptionModule, + AddonQtypeMatchModule, AddonQtypeMultichoiceModule, AddonQtypeNumericalModule, + AddonQtypeRandomSaMatchModule, AddonQtypeShortAnswerModule, AddonQtypeTrueFalseModule ], 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/core/question/classes/base-question-component.ts b/src/core/question/classes/base-question-component.ts index 859c14a67..1d9f29a04 100644 --- a/src/core/question/classes/base-question-component.ts +++ b/src/core/question/classes/base-question-component.ts @@ -96,6 +96,98 @@ export class CoreQuestionBaseComponent { 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). * From 38184308a3d072d08162edf03f34c1e8412d3417 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 19 Mar 2018 16:35:19 +0100 Subject: [PATCH 4/6] MOBILE-2389 qtype: Implement calculated and calculatedsimple types --- .../qtype/calculated/calculated.module.ts | 46 +++++++ .../calculated/component/calculated.html | 55 ++++++++ .../qtype/calculated/component/calculated.ts | 40 ++++++ .../qtype/calculated/providers/handler.ts | 128 ++++++++++++++++++ .../calculatedsimple.module.ts | 30 ++++ .../calculatedsimple/providers/handler.ts | 90 ++++++++++++ src/addon/qtype/qtype.module.ts | 4 + .../classes/base-question-component.ts | 118 ++++++++++++++++ 8 files changed, 511 insertions(+) create mode 100644 src/addon/qtype/calculated/calculated.module.ts create mode 100644 src/addon/qtype/calculated/component/calculated.html create mode 100644 src/addon/qtype/calculated/component/calculated.ts create mode 100644 src/addon/qtype/calculated/providers/handler.ts create mode 100644 src/addon/qtype/calculatedsimple/calculatedsimple.module.ts create mode 100644 src/addon/qtype/calculatedsimple/providers/handler.ts 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. * From 70414853599c61c712348b6da53a181f953d4395 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 20 Mar 2018 09:45:15 +0100 Subject: [PATCH 5/6] MOBILE-2389 qtype: Implement essay type --- .../qtype/calculated/component/calculated.ts | 8 +- .../description/component/description.ts | 8 +- src/addon/qtype/essay/component/essay.html | 42 +++++ src/addon/qtype/essay/component/essay.ts | 38 +++++ src/addon/qtype/essay/essay.module.ts | 48 ++++++ src/addon/qtype/essay/providers/handler.ts | 159 ++++++++++++++++++ src/addon/qtype/match/component/match.ts | 8 +- .../multichoice/component/multichoice.ts | 8 +- src/addon/qtype/qtype.module.ts | 2 + .../shortanswer/component/shortanswer.ts | 8 +- .../classes/base-question-component.ts | 56 +++++- src/core/question/providers/helper.ts | 53 +++++- 12 files changed, 409 insertions(+), 29 deletions(-) create mode 100644 src/addon/qtype/essay/component/essay.html create mode 100644 src/addon/qtype/essay/component/essay.ts create mode 100644 src/addon/qtype/essay/essay.module.ts create mode 100644 src/addon/qtype/essay/providers/handler.ts diff --git a/src/addon/qtype/calculated/component/calculated.ts b/src/addon/qtype/calculated/component/calculated.ts index 585d317ff..e42c6625c 100644 --- a/src/addon/qtype/calculated/component/calculated.ts +++ b/src/addon/qtype/calculated/component/calculated.ts @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Injector } 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'; /** @@ -27,8 +25,8 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question- }) export class AddonQtypeCalculatedComponent extends CoreQuestionBaseComponent implements OnInit { - constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { - super(logger, 'AddonQtypeCalculatedComponent', questionHelper, domUtils); + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeCalculatedComponent', injector); } /** diff --git a/src/addon/qtype/description/component/description.ts b/src/addon/qtype/description/component/description.ts index f08b7dc82..d54c64a05 100644 --- a/src/addon/qtype/description/component/description.ts +++ b/src/addon/qtype/description/component/description.ts @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Injector } 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'; /** @@ -27,8 +25,8 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question- }) export class AddonQtypeDescriptionComponent extends CoreQuestionBaseComponent implements OnInit { - constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { - super(logger, 'AddonQtypeDescriptionComponent', questionHelper, domUtils); + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeDescriptionComponent', injector); } /** 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 @@ +
+ + +

+
+ + + + + + + {{question.textarea.text}} + + + + + + + + +

{{ 'core.question.errorinlinefilesnotsupported' | translate }}

+
+ +

+
+
+ + + +

{{ 'core.question.errorattachmentsnotsupported' | translate }}

+
+ + + +

+
+ + + + +
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/match/component/match.ts b/src/addon/qtype/match/component/match.ts index 117e59af5..9a2eb4ff6 100644 --- a/src/addon/qtype/match/component/match.ts +++ b/src/addon/qtype/match/component/match.ts @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Injector } 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'; /** @@ -27,8 +25,8 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question- }) export class AddonQtypeMatchComponent extends CoreQuestionBaseComponent implements OnInit { - constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { - super(logger, 'AddonQtypeMatchComponent', questionHelper, domUtils); + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeMatchComponent', injector); } /** diff --git a/src/addon/qtype/multichoice/component/multichoice.ts b/src/addon/qtype/multichoice/component/multichoice.ts index add6a7af7..395c4eabd 100644 --- a/src/addon/qtype/multichoice/component/multichoice.ts +++ b/src/addon/qtype/multichoice/component/multichoice.ts @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Injector } 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'; /** @@ -27,8 +25,8 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question- }) export class AddonQtypeMultichoiceComponent extends CoreQuestionBaseComponent implements OnInit { - constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { - super(logger, 'AddonQtypeMultichoiceComponent', questionHelper, domUtils); + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeMultichoiceComponent', injector); } /** diff --git a/src/addon/qtype/qtype.module.ts b/src/addon/qtype/qtype.module.ts index a3a7fd3a8..96df1cebb 100644 --- a/src/addon/qtype/qtype.module.ts +++ b/src/addon/qtype/qtype.module.ts @@ -17,6 +17,7 @@ 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 { AddonQtypeMatchModule } from './match/match.module'; import { AddonQtypeMultichoiceModule } from './multichoice/multichoice.module'; import { AddonQtypeNumericalModule } from './numerical/numerical.module'; @@ -31,6 +32,7 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; AddonQtypeCalculatedMultiModule, AddonQtypeCalculatedSimpleModule, AddonQtypeDescriptionModule, + AddonQtypeEssayModule, AddonQtypeMatchModule, AddonQtypeMultichoiceModule, AddonQtypeNumericalModule, diff --git a/src/addon/qtype/shortanswer/component/shortanswer.ts b/src/addon/qtype/shortanswer/component/shortanswer.ts index 77f2af456..152710d68 100644 --- a/src/addon/qtype/shortanswer/component/shortanswer.ts +++ b/src/addon/qtype/shortanswer/component/shortanswer.ts @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Injector } 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'; /** @@ -27,8 +25,8 @@ import { CoreQuestionBaseComponent } from '@core/question/classes/base-question- }) export class AddonQtypeShortAnswerComponent extends CoreQuestionBaseComponent implements OnInit { - constructor(logger: CoreLoggerProvider, questionHelper: CoreQuestionHelperProvider, domUtils: CoreDomUtilsProvider) { - super(logger, 'AddonQtypeShortAnswerComponent', questionHelper, domUtils); + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeShortAnswerComponent', injector); } /** diff --git a/src/core/question/classes/base-question-component.ts b/src/core/question/classes/base-question-component.ts index cce52c112..6daac231d 100644 --- a/src/core/question/classes/base-question-component.ts +++ b/src/core/question/classes/base-question-component.ts @@ -12,9 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Input, EventEmitter } from '@angular/core'; +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'; /** @@ -30,10 +31,17 @@ export class CoreQuestionBaseComponent { @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 questionHelper: CoreQuestionHelperProvider, - protected domUtils: CoreDomUtilsProvider) { + 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); } /** @@ -180,6 +188,48 @@ export class CoreQuestionBaseComponent { 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 has an input of type "text". * diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts index 5ffaf0c0e..61b67c5a8 100644 --- a/src/core/question/providers/helper.ts +++ b/src/core/question/providers/helper.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injectable, EventEmitter } from '@angular/core'; +import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreQuestionProvider } from './question'; @@ -26,7 +27,7 @@ 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) { } /** * Add a behaviour button to the question's "behaviourButtons" property. @@ -266,6 +267,40 @@ export class CoreQuestionHelperProvider { } } + /** + * 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 +334,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. From cd68db376adc86e4a46de06813a9ceb4a4a905a7 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 20 Mar 2018 11:02:41 +0100 Subject: [PATCH 6/6] MOBILE-2389 qtype: Implement multianswer and gapselect types --- .../qtype/gapselect/component/gapselect.html | 5 + .../qtype/gapselect/component/gapselect.ts | 38 +++++ src/addon/qtype/gapselect/gapselect.module.ts | 46 ++++++ .../qtype/gapselect/providers/handler.ts | 118 +++++++++++++++ .../multianswer/component/multianswer.html | 5 + .../multianswer/component/multianswer.ts | 38 +++++ .../qtype/multianswer/multianswer.module.ts | 46 ++++++ .../qtype/multianswer/providers/handler.ts | 142 ++++++++++++++++++ src/addon/qtype/qtype.module.ts | 4 + .../classes/base-question-component.ts | 39 +++++ src/core/question/providers/helper.ts | 108 ++++++++++++- 11 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 src/addon/qtype/gapselect/component/gapselect.html create mode 100644 src/addon/qtype/gapselect/component/gapselect.ts create mode 100644 src/addon/qtype/gapselect/gapselect.module.ts create mode 100644 src/addon/qtype/gapselect/providers/handler.ts create mode 100644 src/addon/qtype/multianswer/component/multianswer.html create mode 100644 src/addon/qtype/multianswer/component/multianswer.ts create mode 100644 src/addon/qtype/multianswer/multianswer.module.ts create mode 100644 src/addon/qtype/multianswer/providers/handler.ts 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/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/qtype.module.ts b/src/addon/qtype/qtype.module.ts index 96df1cebb..a8563022c 100644 --- a/src/addon/qtype/qtype.module.ts +++ b/src/addon/qtype/qtype.module.ts @@ -18,7 +18,9 @@ import { AddonQtypeCalculatedMultiModule } from './calculatedmulti/calculatedmul 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'; @@ -33,7 +35,9 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; AddonQtypeCalculatedSimpleModule, AddonQtypeDescriptionModule, AddonQtypeEssayModule, + AddonQtypeGapSelectModule, AddonQtypeMatchModule, + AddonQtypeMultiAnswerModule, AddonQtypeMultichoiceModule, AddonQtypeNumericalModule, AddonQtypeRandomSaMatchModule, diff --git a/src/core/question/classes/base-question-component.ts b/src/core/question/classes/base-question-component.ts index 6daac231d..5befc6391 100644 --- a/src/core/question/classes/base-question-component.ts +++ b/src/core/question/classes/base-question-component.ts @@ -230,6 +230,45 @@ export class CoreQuestionBaseComponent { } } + /** + * 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". * diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts index 61b67c5a8..f6d317dd2 100644 --- a/src/core/question/providers/helper.ts +++ b/src/core/question/providers/helper.ts @@ -13,6 +13,7 @@ // 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'; @@ -27,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 sitesProvider: CoreSitesProvider) { } + private questionProvider: CoreQuestionProvider, private sitesProvider: CoreSitesProvider, + private translate: TranslateService) { } /** * Add a behaviour button to the question's "behaviourButtons" property. @@ -267,6 +269,35 @@ 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 @@ -397,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. * @@ -443,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); + }); + } + }); + } }