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