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);
}).then((result) => {
return this.parseQuestions(result);
});
}
@ -389,7 +391,9 @@ export class AddonModQuizProvider {
...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) => {
if (response && response.questions) {
response = this.parseQuestions(response);
if (options.loadLocal) {
return this.quizOfflineProvider.loadQuestionsLocalStates(attemptId, response.questions, site.getId());
}
@ -1560,6 +1566,27 @@ export class AddonModQuizProvider {
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.
*

View File

@ -24,6 +24,14 @@ import { AddonQtypeCalculatedComponent } from '../component/calculated';
*/
@Injectable()
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';
type = 'qtype_calculated';
@ -41,6 +49,23 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
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.
*
@ -51,17 +76,44 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
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;
}
if (this.requiresUnits(question)) {
const parsedAnswer = this.parseAnswer(question, answers['answer']);
if (parsedAnswer.answer === null) {
return 0;
}
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;
}
/**
* Whether or not the handler is enabled on a site level.
*
@ -80,13 +132,7 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(question: any, answers: any): number {
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;
return this.isValidValue(answers['answer']) ? 1 : 0;
}
/**
@ -115,36 +161,24 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
}
/**
* Check if a question requires units in a separate input.
*
* @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.
* Parse an answer string.
*
* @param question Question.
* @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) {
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.
answer = answer.replace(' ', '');
answer = answer.replace(/ /g, '');
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 '.'.
if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) {
answer = answer.replace(',', '');
@ -152,11 +186,31 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
answer = answer.replace(',', '.');
}
let unitsLeft = false;
let match = null;
if (!question.displayoptions) {
// 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;
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. -->
<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" >

View File

@ -67,6 +67,28 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
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.
* 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.
*/
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 allowsInlineText = !!element.querySelector('textarea[name*=_answer]');
const allowsAttachments = !!element.querySelector('div[id*=filemanager]');
const hasTextAnswer = answers['answer'] && answers['answer'] !== '';
const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
const allowedOptions = this.getAllowedOptions(question);
if (!allowsAttachments) {
return hasInlineText ? 1 : 0;
if (!allowedOptions.attachments) {
return hasTextAnswer ? 1 : 0;
}
if (!uploadFilesSupported) {
@ -141,12 +161,12 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
if (!allowsInlineText) {
if (!allowedOptions.text) {
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 hasInlineText && attachments && attachments.length > 0 ? 1 : -1;
return (hasTextAnswer || question.displayoptions.responserequired == '0') &&
((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.
*/
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 allowedOptions = this.getAllowedOptions(question);
// 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.
return answerIsEqual;
}

View File

@ -107,9 +107,13 @@ export class CoreQuestionBaseComponent {
this.question.select = selectModel;
// Check which one should be displayed first: the select or the input.
if (this.question.displayoptions) {
this.question.selectFirst = this.question.displayoptions.unitsleft == '1';
} 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;
}
@ -161,9 +165,13 @@ export class CoreQuestionBaseComponent {
}
// Check which one should be displayed first: the options or the input.
if (this.question.displayoptions) {
this.question.optionsFirst = this.question.displayoptions.unitsleft == '1';
} 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;
}
@ -208,10 +216,18 @@ export class CoreQuestionBaseComponent {
const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]');
const answerDraftIdInput = <HTMLInputElement> questionEl.querySelector('input[name*="_answer:itemid"]');
if (this.question.displayoptions) {
this.question.allowsAttachments = this.question.displayoptions.attachments != '0';
this.question.allowsAnswerFiles = this.question.displayoptions.responseformat == 'editorfilepicker';
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.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) {
const params = CoreUrlUtils.instance.extractUrlParams(fileManagerUrl);
const maxBytes = Number(params.maxbytes);
const areaMaxBytes = Number(params.areamaxbytes);
this.question.attachmentsMaxFiles = Number(params.maxfiles);
this.question.attachmentsMaxBytes = maxBytes === -1 || areaMaxBytes === -1 ?
Math.max(maxBytes, areaMaxBytes) : Math.min(maxBytes, areaMaxBytes);
}