From 5b0059a617894aabe5a86a8bb94746acf90367b9 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 1 Oct 2020 10:18:09 +0200 Subject: [PATCH] MOBILE-2272 quiz: Support display options in questions --- src/addon/mod/quiz/providers/quiz.ts | 29 +++- .../qtype/calculated/providers/handler.ts | 124 +++++++++++++----- .../essay/component/addon-qtype-essay.html | 2 +- src/addon/qtype/essay/providers/handler.ts | 46 +++++-- .../classes/base-question-component.ts | 40 ++++-- 5 files changed, 179 insertions(+), 62 deletions(-) diff --git a/src/addon/mod/quiz/providers/quiz.ts b/src/addon/mod/quiz/providers/quiz.ts index 6caa9148e..a99e3a2cd 100644 --- a/src/addon/mod/quiz/providers/quiz.ts +++ b/src/addon/mod/quiz/providers/quiz.ts @@ -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. * diff --git a/src/addon/qtype/calculated/providers/handler.ts b/src/addon/qtype/calculated/providers/handler.ts index bc5d7fdc9..605842935 100644 --- a/src/addon/qtype/calculated/providers/handler.ts +++ b/src/addon/qtype/calculated/providers/handler.ts @@ -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,15 +76,42 @@ 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)) { - return this.isValidValue(answers['unit']) ? 1 : 0; + const parsedAnswer = this.parseAnswer(question, answers['answer']); + 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. */ 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(',', '.'); } - // 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; + let unitsLeft = false; + let match = null; + + 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}; } } diff --git a/src/addon/qtype/essay/component/addon-qtype-essay.html b/src/addon/qtype/essay/component/addon-qtype-essay.html index bc7ff9a2b..bfdcf2329 100644 --- a/src/addon/qtype/essay/component/addon-qtype-essay.html +++ b/src/addon/qtype/essay/component/addon-qtype-essay.html @@ -27,7 +27,7 @@ - + diff --git a/src/addon/qtype/essay/providers/handler.ts b/src/addon/qtype/essay/providers/handler.ts index 79a972a30..cee903848 100644 --- a/src/addon/qtype/essay/providers/handler.ts +++ b/src/addon/qtype/essay/providers/handler.ts @@ -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; } diff --git a/src/core/question/classes/base-question-component.ts b/src/core/question/classes/base-question-component.ts index f51f53eee..160ffa50c 100644 --- a/src/core/question/classes/base-question-component.ts +++ b/src/core/question/classes/base-question-component.ts @@ -107,9 +107,13 @@ export class CoreQuestionBaseComponent { this.question.select = selectModel; // Check which one should be displayed first: the select or the input. - const input = questionEl.querySelector('input[type="text"][name*=answer]'); - this.question.selectFirst = - questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(select.outerHTML); + 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. - const input = questionEl.querySelector('input[type="text"][name*=answer]'); - this.question.optionsFirst = - questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(radios[0].outerHTML); + 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 = questionEl.querySelector('textarea[name*=_answer]'); const answerDraftIdInput = questionEl.querySelector('input[name*="_answer:itemid"]'); - 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'); + 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); }