MOBILE-2272 quiz: Support display options in questions
parent
f144a29fee
commit
5b0059a617
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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" >
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue