MOBILE-2272 quiz: Support display options in questions

main
Dani Palou 2020-10-01 10:18:09 +02:00
parent f144a29fee
commit 5b0059a617
5 changed files with 179 additions and 62 deletions

View File

@ -215,6 +215,8 @@ export class AddonModQuizProvider {
}; };
return site.read('mod_quiz_get_attempt_data', params, preSets); return site.read('mod_quiz_get_attempt_data', params, preSets);
}).then((result) => {
return this.parseQuestions(result);
}); });
} }
@ -389,7 +391,9 @@ export class AddonModQuizProvider {
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. ...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
}; };
return site.read('mod_quiz_get_attempt_review', params, preSets); return site.read('mod_quiz_get_attempt_review', params, preSets).then((result) => {
return this.parseQuestions(result);
});
}); });
} }
@ -427,6 +431,8 @@ export class AddonModQuizProvider {
return site.read('mod_quiz_get_attempt_summary', params, preSets).then((response) => { return site.read('mod_quiz_get_attempt_summary', params, preSets).then((response) => {
if (response && response.questions) { if (response && response.questions) {
response = this.parseQuestions(response);
if (options.loadLocal) { if (options.loadLocal) {
return this.quizOfflineProvider.loadQuestionsLocalStates(attemptId, response.questions, site.getId()); return this.quizOfflineProvider.loadQuestionsLocalStates(attemptId, response.questions, site.getId());
} }
@ -1560,6 +1566,27 @@ export class AddonModQuizProvider {
siteId); siteId);
} }
/**
* Parse questions of a WS response.
*
* @param result Result to parse.
* @return Parsed result.
*/
parseQuestions(result: any): any {
for (let i = 0; i < result.questions.length; i++) {
const question = result.questions[i];
if (!question.displayoptions) {
// Site doesn't return displayoptions, stop.
break;
}
question.displayoptions = this.utils.objectToKeyValueMap(question.displayoptions, 'name', 'value');
}
return result;
}
/** /**
* Process an attempt, saving its data. * Process an attempt, saving its data.
* *

View File

@ -24,6 +24,14 @@ import { AddonQtypeCalculatedComponent } from '../component/calculated';
*/ */
@Injectable() @Injectable()
export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
static UNITINPUT = '0';
static UNITRADIO = '1';
static UNITSELECT = '2';
static UNITNONE = '3';
static UNITGRADED = '1';
static UNITOPTIONAL = '0';
name = 'AddonQtypeCalculated'; name = 'AddonQtypeCalculated';
type = 'qtype_calculated'; type = 'qtype_calculated';
@ -41,6 +49,23 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
return AddonQtypeCalculatedComponent; return AddonQtypeCalculatedComponent;
} }
/**
* Check if the units are in a separate field for the question.
*
* @param question Question.
* @return Whether units are in a separate field.
*/
hasSeparateUnitField(question: any): boolean {
if (!question.displayoptions) {
const element = this.domUtils.convertToElement(question.html);
return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]'));
}
return question.displayoptions.showunits === AddonQtypeCalculatedHandler.UNITRADIO ||
question.displayoptions.showunits === AddonQtypeCalculatedHandler.UNITSELECT;
}
/** /**
* Check if a response is complete. * Check if a response is complete.
* *
@ -51,15 +76,42 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
if (this.isGradableResponse(question, answers) === 0 || !this.validateUnits(answers['answer'])) { if (!this.isGradableResponse(question, answers)) {
return 0; return 0;
} }
if (this.requiresUnits(question)) { const parsedAnswer = this.parseAnswer(question, answers['answer']);
return this.isValidValue(answers['unit']) ? 1 : 0; if (parsedAnswer.answer === null) {
return 0;
} }
return -1; if (!question.displayoptions) {
if (this.hasSeparateUnitField(question)) {
return this.isValidValue(answers['unit']) ? 1 : 0;
}
// We cannot know if the answer should contain units or not.
return -1;
}
if (question.displayoptions.showunits != AddonQtypeCalculatedHandler.UNITINPUT && parsedAnswer.unit) {
// There should be no units or be outside of the input, not valid.
return 0;
}
if (this.hasSeparateUnitField(question) && !this.isValidValue(answers['unit'])) {
// Unit not supplied as a separate field and it's required.
return 0;
}
if (question.displayoptions.showunits == AddonQtypeCalculatedHandler.UNITINPUT &&
question.displayoptions.unitgradingtype == AddonQtypeCalculatedHandler.UNITGRADED &&
!this.isValidValue(parsedAnswer.unit)) {
// Unit not supplied inside the input and it's required.
return 0;
}
return 1;
} }
/** /**
@ -80,13 +132,7 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any): number {
let isGradable = this.isValidValue(answers['answer']); return this.isValidValue(answers['answer']) ? 1 : 0;
if (isGradable && this.requiresUnits(question)) {
// The question requires a unit.
isGradable = this.isValidValue(answers['unit']);
}
return isGradable ? 1 : 0;
} }
/** /**
@ -115,36 +161,24 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
} }
/** /**
* Check if a question requires units in a separate input. * Parse an answer string.
*
* @param question The question.
* @return Whether the question requires units.
*/
requiresUnits(question: any): boolean {
const element = this.domUtils.convertToElement(question.html);
return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]'));
}
/**
* 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 question Question.
* @param answer Answer. * @param answer Answer.
* @return False if answer isn't valid, true if we aren't sure if it's valid. * @return 0 if answer isn't valid, 1 if answer is valid, -1 if we aren't sure if it's valid.
*/ */
validateUnits(answer: string): boolean { parseAnswer(question: any, answer: string): {answer: number, unit: string} {
if (!answer) { if (!answer) {
return false; return {answer: null, unit: null};
} }
const regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?'; let regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?';
// Strip spaces (which may be thousands separators) and change other forms of writing e to e. // Strip spaces (which may be thousands separators) and change other forms of writing e to e.
answer = answer.replace(' ', ''); answer = answer.replace(/ /g, '');
answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1'); 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. // If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and strip it.
// Else assume it is a decimal separator, and change it to '.'. // Else assume it is a decimal separator, and change it to '.'.
if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) { if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) {
answer = answer.replace(',', ''); answer = answer.replace(',', '');
@ -152,11 +186,31 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
answer = answer.replace(',', '.'); answer = answer.replace(',', '.');
} }
// We don't know if units should be before or after so we check both. let unitsLeft = false;
if (answer.match(new RegExp('^' + regexString)) === null || answer.match(new RegExp(regexString + '$')) === null) { let match = null;
return false;
if (!question.displayoptions) {
// We don't know if units should be before or after so we check both.
match = answer.match(new RegExp('^' + regexString));
if (!match) {
unitsLeft = true;
match = answer.match(new RegExp(regexString + '$'));
}
} else {
unitsLeft = question.displayoptions.unitsleft == '1';
regexString = unitsLeft ? regexString + '$' : '^' + regexString;
match = answer.match(new RegExp(regexString));
} }
return true; if (!match) {
return {answer: null, unit: null};
}
const numberString = match[0];
const unit = unitsLeft ? answer.substr(0, answer.length - match[0].length) : answer.substr(match[0].length);
// No need to calculate the multiplier.
return {answer: Number(numberString), unit: unit};
} }
} }

View File

@ -27,7 +27,7 @@
<!-- Attachments. --> <!-- Attachments. -->
<ng-container *ngIf="question.allowsAttachments"> <ng-container *ngIf="question.allowsAttachments">
<core-attachments *ngIf="uploadFilesSupported" [files]="attachments" [component]="component" [componentId]="componentId" [maxSize]="question.attachmentsMaxBytes" [maxSubmissions]="question.attachmentsMaxFiles" [allowOffline]="offlineEnabled"></core-attachments> <core-attachments *ngIf="uploadFilesSupported" [files]="attachments" [component]="component" [componentId]="componentId" [maxSize]="question.attachmentsMaxBytes" [maxSubmissions]="question.attachmentsMaxFiles" [allowOffline]="offlineEnabled" [acceptedTypes]="question.attachmentsAcceptedTypes"></core-attachments>
<input item-content *ngIf="uploadFilesSupported" type="hidden" [name]="question.attachmentsDraftIdInput.name" [value]="question.attachmentsDraftIdInput.value" > <input item-content *ngIf="uploadFilesSupported" type="hidden" [name]="question.attachmentsDraftIdInput.name" [value]="question.attachmentsDraftIdInput.value" >

View File

@ -67,6 +67,28 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
return this.questionHelper.deleteStoredQuestionFiles(question, component, componentId, siteId); return this.questionHelper.deleteStoredQuestionFiles(question, component, componentId, siteId);
} }
/**
* Check whether the question allows text and/or attachments.
*
* @param question Question to check.
* @return Allowed options.
*/
protected getAllowedOptions(question: any): {text: boolean, attachments: boolean} {
if (question.displayoptions) {
return {
text: question.displayoptions.responseformat != 'noinline',
attachments: question.displayoptions.attachments != '0',
};
} else {
const element = this.domUtils.convertToElement(question.html);
return {
text: !!element.querySelector('textarea[name*=_answer]'),
attachments: !!element.querySelector('div[id*=filemanager]'),
};
}
}
/** /**
* Return the name of the behaviour to use for the question. * 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. * If the question should use the default behaviour you shouldn't implement this function.
@ -122,15 +144,13 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
const element = this.domUtils.convertToElement(question.html);
const hasInlineText = answers['answer'] && answers['answer'] !== ''; const hasTextAnswer = answers['answer'] && answers['answer'] !== '';
const allowsInlineText = !!element.querySelector('textarea[name*=_answer]');
const allowsAttachments = !!element.querySelector('div[id*=filemanager]');
const uploadFilesSupported = typeof question.responsefileareas != 'undefined'; const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
const allowedOptions = this.getAllowedOptions(question);
if (!allowsAttachments) { if (!allowedOptions.attachments) {
return hasInlineText ? 1 : 0; return hasTextAnswer ? 1 : 0;
} }
if (!uploadFilesSupported) { if (!uploadFilesSupported) {
@ -141,12 +161,12 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
if (!allowsInlineText) { if (!allowedOptions.text) {
return attachments && attachments.length > 0 ? 1 : 0; return attachments && attachments.length > 0 ? 1 : 0;
} }
// If any of the fields is missing return -1 because we can't know if they're required or not. return (hasTextAnswer || question.displayoptions.responserequired == '0') &&
return hasInlineText && attachments && attachments.length > 0 ? 1 : -1; ((attachments && attachments.length > 0) || question.displayoptions.attachmentsrequired == '0') ? 1 : 0;
} }
/** /**
@ -181,15 +201,13 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
const element = this.domUtils.convertToElement(question.html);
const allowsInlineText = !!element.querySelector('textarea[name*=_answer]');
const allowsAttachments = !!element.querySelector('div[id*=filemanager]');
const uploadFilesSupported = typeof question.responsefileareas != 'undefined'; const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
const allowedOptions = this.getAllowedOptions(question);
// First check the inline text. // First check the inline text.
const answerIsEqual = allowsInlineText ? this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') : true; const answerIsEqual = allowedOptions.text ? this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') : true;
if (!allowsAttachments || !uploadFilesSupported || !answerIsEqual) { if (!allowedOptions.attachments || !uploadFilesSupported || !answerIsEqual) {
// No need to check attachments. // No need to check attachments.
return answerIsEqual; return answerIsEqual;
} }

View File

@ -107,9 +107,13 @@ export class CoreQuestionBaseComponent {
this.question.select = selectModel; this.question.select = selectModel;
// Check which one should be displayed first: the select or the input. // Check which one should be displayed first: the select or the input.
const input = questionEl.querySelector('input[type="text"][name*=answer]'); if (this.question.displayoptions) {
this.question.selectFirst = this.question.selectFirst = this.question.displayoptions.unitsleft == '1';
questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(select.outerHTML); } else {
const input = questionEl.querySelector('input[type="text"][name*=answer]');
this.question.selectFirst =
questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(select.outerHTML);
}
return questionEl; return questionEl;
} }
@ -161,9 +165,13 @@ export class CoreQuestionBaseComponent {
} }
// Check which one should be displayed first: the options or the input. // Check which one should be displayed first: the options or the input.
const input = questionEl.querySelector('input[type="text"][name*=answer]'); if (this.question.displayoptions) {
this.question.optionsFirst = this.question.optionsFirst = this.question.displayoptions.unitsleft == '1';
questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(radios[0].outerHTML); } else {
const input = questionEl.querySelector('input[type="text"][name*=answer]');
this.question.optionsFirst =
questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(radios[0].outerHTML);
}
return questionEl; return questionEl;
} }
@ -208,10 +216,18 @@ export class CoreQuestionBaseComponent {
const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]'); const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]');
const answerDraftIdInput = <HTMLInputElement> questionEl.querySelector('input[name*="_answer:itemid"]'); const answerDraftIdInput = <HTMLInputElement> questionEl.querySelector('input[name*="_answer:itemid"]');
this.question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]'); if (this.question.displayoptions) {
this.question.allowsAnswerFiles = !!answerDraftIdInput; this.question.allowsAttachments = this.question.displayoptions.attachments != '0';
this.question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced'); this.question.allowsAnswerFiles = this.question.displayoptions.responseformat == 'editorfilepicker';
this.question.isPlainText = this.question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain'); this.question.isMonospaced = this.question.displayoptions.responseformat == 'monospaced';
this.question.isPlainText = this.question.isMonospaced || this.question.displayoptions.responseformat == 'plain';
} else {
this.question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]');
this.question.allowsAnswerFiles = !!answerDraftIdInput;
this.question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced');
this.question.isPlainText = this.question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain');
}
this.question.hasDraftFiles = this.question.allowsAnswerFiles && this.question.hasDraftFiles = this.question.allowsAnswerFiles &&
this.questionHelper.hasDraftFileUrls(questionEl.innerHTML); this.questionHelper.hasDraftFileUrls(questionEl.innerHTML);
@ -266,12 +282,14 @@ export class CoreQuestionBaseComponent {
}; };
} }
this.question.attachmentsMaxFiles = Number(this.question.displayoptions.attachments);
this.question.attachmentsAcceptedTypes = this.question.displayoptions.filetypeslist;
if (fileManagerUrl) { if (fileManagerUrl) {
const params = CoreUrlUtils.instance.extractUrlParams(fileManagerUrl); const params = CoreUrlUtils.instance.extractUrlParams(fileManagerUrl);
const maxBytes = Number(params.maxbytes); const maxBytes = Number(params.maxbytes);
const areaMaxBytes = Number(params.areamaxbytes); const areaMaxBytes = Number(params.areamaxbytes);
this.question.attachmentsMaxFiles = Number(params.maxfiles);
this.question.attachmentsMaxBytes = maxBytes === -1 || areaMaxBytes === -1 ? this.question.attachmentsMaxBytes = maxBytes === -1 || areaMaxBytes === -1 ?
Math.max(maxBytes, areaMaxBytes) : Math.min(maxBytes, areaMaxBytes); Math.max(maxBytes, areaMaxBytes) : Math.min(maxBytes, areaMaxBytes);
} }