MOBILE-2389 qtype: Implement multianswer and gapselect types

main
Dani Palou 2018-03-20 11:02:41 +01:00
parent 7041485359
commit cd68db376a
11 changed files with 588 additions and 1 deletions

View File

@ -0,0 +1,5 @@
<section ion-list class="addon-qtype-gapselect-container" *ngIf="question.text || question.text === ''">
<ion-item text-wrap>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>
</ion-item>
</section>

View File

@ -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');
}
}

View File

@ -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);
}
}

View File

@ -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<any>} The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(injector: Injector, question: any): any | Promise<any> {
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<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
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);
}
}

View File

@ -0,0 +1,5 @@
<section ion-list class="addon-qtype-multianswer-container" *ngIf="question.text || question.text === ''">
<ion-item text-wrap>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>
</ion-item>
</section>

View File

@ -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');
}
}

View File

@ -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);
}
}

View File

@ -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<any>} The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(injector: Injector, question: any): any | Promise<any> {
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<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
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;
}
}

View File

@ -18,7 +18,9 @@ import { AddonQtypeCalculatedMultiModule } from './calculatedmulti/calculatedmul
import { AddonQtypeCalculatedSimpleModule } from './calculatedsimple/calculatedsimple.module'; import { AddonQtypeCalculatedSimpleModule } from './calculatedsimple/calculatedsimple.module';
import { AddonQtypeDescriptionModule } from './description/description.module'; import { AddonQtypeDescriptionModule } from './description/description.module';
import { AddonQtypeEssayModule } from './essay/essay.module'; import { AddonQtypeEssayModule } from './essay/essay.module';
import { AddonQtypeGapSelectModule } from './gapselect/gapselect.module';
import { AddonQtypeMatchModule } from './match/match.module'; import { AddonQtypeMatchModule } from './match/match.module';
import { AddonQtypeMultiAnswerModule } from './multianswer/multianswer.module';
import { AddonQtypeMultichoiceModule } from './multichoice/multichoice.module'; import { AddonQtypeMultichoiceModule } from './multichoice/multichoice.module';
import { AddonQtypeNumericalModule } from './numerical/numerical.module'; import { AddonQtypeNumericalModule } from './numerical/numerical.module';
import { AddonQtypeRandomSaMatchModule } from './randomsamatch/randomsamatch.module'; import { AddonQtypeRandomSaMatchModule } from './randomsamatch/randomsamatch.module';
@ -33,7 +35,9 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module';
AddonQtypeCalculatedSimpleModule, AddonQtypeCalculatedSimpleModule,
AddonQtypeDescriptionModule, AddonQtypeDescriptionModule,
AddonQtypeEssayModule, AddonQtypeEssayModule,
AddonQtypeGapSelectModule,
AddonQtypeMatchModule, AddonQtypeMatchModule,
AddonQtypeMultiAnswerModule,
AddonQtypeMultichoiceModule, AddonQtypeMultichoiceModule,
AddonQtypeNumericalModule, AddonQtypeNumericalModule,
AddonQtypeRandomSaMatchModule, AddonQtypeRandomSaMatchModule,

View File

@ -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 = <HTMLElement> 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". * Initialize a question component that has an input of type "text".
* *

View File

@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
import { Injectable, EventEmitter } from '@angular/core'; import { Injectable, EventEmitter } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { CoreSitesProvider } from '@providers/sites'; import { CoreSitesProvider } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text'; 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. protected div = document.createElement('div'); // A div element to search in HTML code.
constructor(private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, 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. * 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). * 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 * 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; 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. * Search a behaviour button in a certain question property containing HTML.
* *
@ -443,4 +498,55 @@ export class CoreQuestionHelperProvider {
onAbort && onAbort.emit(); 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 = <HTMLImageElement[]> 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 = <HTMLElement> 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);
});
}
});
}
} }