859 lines
32 KiB
TypeScript
859 lines
32 KiB
TypeScript
// (C) Copyright 2015 Moodle Pty Ltd.
|
|
//
|
|
// 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 { Input, Output, EventEmitter, Component, Optional, Inject, ElementRef, OnInit } from '@angular/core';
|
|
import { CoreFileHelper } from '@services/file-helper';
|
|
|
|
import { CoreSites } from '@services/sites';
|
|
import { CoreDomUtils } from '@services/utils/dom';
|
|
import { CoreTextUtils } from '@services/utils/text';
|
|
import { CoreUrlUtils } from '@services/utils/url';
|
|
import { CoreWSFile } from '@services/ws';
|
|
import { CoreIonicColorNames } from '@singletons/colors';
|
|
import { CoreLogger } from '@singletons/logger';
|
|
import { CoreQuestionBehaviourButton, CoreQuestionHelper, CoreQuestionQuestion } from '../services/question-helper';
|
|
import { ContextLevel } from '@/core/constants';
|
|
|
|
/**
|
|
* Base class for components to render a question.
|
|
*/
|
|
@Component({
|
|
template: '',
|
|
})
|
|
export class CoreQuestionBaseComponent<T extends AddonModQuizQuestion = AddonModQuizQuestion> implements OnInit {
|
|
|
|
@Input() question?: T; // The question to render.
|
|
@Input() component?: string; // The component the question belongs to.
|
|
@Input() componentId?: number; // ID of the component the question belongs to.
|
|
@Input() attemptId?: number; // Attempt ID.
|
|
@Input() offlineEnabled?: boolean | string; // Whether the question can be answered in offline.
|
|
@Input() contextLevel?: ContextLevel; // The context level.
|
|
@Input() contextInstanceId?: number; // The instance ID related to the context.
|
|
@Input() courseId?: number; // The course the question belongs to (if any).
|
|
@Input() review?: boolean; // Whether the user is in review mode.
|
|
@Input() preferredBehaviour?: string; // Preferred behaviour.
|
|
@Output() buttonClicked = new EventEmitter<CoreQuestionBehaviourButton>(); // Will emit when a behaviour button is clicked.
|
|
@Output() onAbort = new EventEmitter<void>(); // Should emit an event if the question should be aborted.
|
|
|
|
protected logger: CoreLogger;
|
|
protected hostElement: HTMLElement;
|
|
|
|
constructor(@Optional() @Inject('') logName: string, elementRef: ElementRef) {
|
|
this.logger = CoreLogger.getInstance(logName);
|
|
this.hostElement = elementRef.nativeElement;
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ngOnInit(): void {
|
|
if (!this.question) {
|
|
this.logger.warn('Aborting because of no question received.');
|
|
|
|
return CoreQuestionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
this.init();
|
|
}
|
|
|
|
/**
|
|
* Initialize the question component, override it if needed.
|
|
*/
|
|
init(): void {
|
|
this.initComponent();
|
|
}
|
|
|
|
/**
|
|
* Initialize the component and the question text.
|
|
*
|
|
* @returns Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initComponent(): void | HTMLElement {
|
|
if (!this.question) {
|
|
return;
|
|
}
|
|
|
|
this.hostElement.classList.add('core-question-container');
|
|
|
|
const questionElement = CoreDomUtils.convertToElement(this.question.html);
|
|
|
|
// Extract question text.
|
|
this.question.text = CoreDomUtils.getContentsOfElement(questionElement, '.qtext');
|
|
if (this.question.text === undefined) {
|
|
this.logger.warn('Aborting because of an error parsing question.', this.question.slot);
|
|
|
|
return CoreQuestionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
return questionElement;
|
|
}
|
|
|
|
/**
|
|
* Initialize a question component of type calculated or calculated simple.
|
|
*
|
|
* @returns Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initCalculatedComponent(): void | HTMLElement {
|
|
// Treat the input text first.
|
|
const questionEl = this.initInputTextComponent();
|
|
if (!questionEl) {
|
|
return;
|
|
}
|
|
|
|
// Check if the question has a select for units.
|
|
if (this.treatCalculatedSelectUnits(questionEl)) {
|
|
return questionEl;
|
|
}
|
|
|
|
// Check if the question has radio buttons for units.
|
|
if (this.treatCalculatedRadioUnits(questionEl)) {
|
|
return questionEl;
|
|
}
|
|
|
|
return questionEl;
|
|
}
|
|
|
|
/**
|
|
* Treat a calculated question units in case they use radio buttons.
|
|
*
|
|
* @param questionEl Question HTML element.
|
|
* @returns True if question has units using radio buttons.
|
|
*/
|
|
protected treatCalculatedRadioUnits(questionEl: HTMLElement): boolean {
|
|
// Check if the question has radio buttons for units.
|
|
const radios = Array.from(questionEl.querySelectorAll<HTMLInputElement>('input[type="radio"]'));
|
|
if (!radios.length || !this.question) {
|
|
return false;
|
|
}
|
|
|
|
const question: AddonModQuizCalculatedQuestion = this.question;
|
|
question.options = [];
|
|
|
|
for (const i in radios) {
|
|
const radioEl = radios[i];
|
|
const option: AddonModQuizQuestionRadioOption = {
|
|
id: radioEl.id,
|
|
class: '',
|
|
name: radioEl.name,
|
|
value: radioEl.value,
|
|
checked: radioEl.checked,
|
|
disabled: radioEl.disabled,
|
|
};
|
|
// Get the label with the question text.
|
|
const label = questionEl.querySelector<HTMLLabelElement>('label[for="' + option.id + '"]');
|
|
|
|
question.optionsName = option.name;
|
|
|
|
if (!label || option.name === undefined || option.value === undefined) {
|
|
// Something went wrong when extracting the questions data. Abort.
|
|
this.logger.warn('Aborting because of an error parsing options.', question.slot, option.name);
|
|
CoreQuestionHelper.showComponentError(this.onAbort);
|
|
|
|
return true;
|
|
}
|
|
|
|
option.text = label.innerText;
|
|
if (radioEl.checked) {
|
|
// If the option is checked we use the model to select the one.
|
|
question.unit = option.value;
|
|
}
|
|
|
|
question.options.push(option);
|
|
}
|
|
|
|
// Check which one should be displayed first: the options or the input.
|
|
if (question.parsedSettings && question.parsedSettings.unitsleft !== null) {
|
|
question.optionsFirst = question.parsedSettings.unitsleft == '1';
|
|
} else {
|
|
const input = questionEl.querySelector<HTMLInputElement>('input[type="text"][name*=answer]');
|
|
question.optionsFirst =
|
|
questionEl.innerHTML.indexOf(input?.outerHTML || '') > questionEl.innerHTML.indexOf(radios[0].outerHTML);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Treat a calculated question units in case they use a select.
|
|
*
|
|
* @param questionEl Question HTML element.
|
|
* @returns True if question has units using a select.
|
|
*/
|
|
protected treatCalculatedSelectUnits(questionEl: HTMLElement): boolean {
|
|
// Check if the question has a select for units.
|
|
const select = questionEl.querySelector<HTMLSelectElement>('select[name*=unit]');
|
|
const options = select && Array.from(select.querySelectorAll('option'));
|
|
|
|
if (!select || !options?.length || !this.question) {
|
|
return false;
|
|
}
|
|
|
|
const question: AddonModQuizCalculatedQuestion = this.question;
|
|
const selectModel: AddonModQuizQuestionSelect = {
|
|
id: select.id,
|
|
name: select.name,
|
|
disabled: select.disabled,
|
|
options: [],
|
|
};
|
|
|
|
// Treat each option.
|
|
for (const i in options) {
|
|
const optionEl = options[i];
|
|
|
|
if (optionEl.value === undefined) {
|
|
this.logger.warn('Aborting because couldn\'t find input.', this.question?.slot);
|
|
CoreQuestionHelper.showComponentError(this.onAbort);
|
|
|
|
return true;
|
|
}
|
|
|
|
const option: AddonModQuizQuestionSelectOption = {
|
|
value: optionEl.value,
|
|
label: optionEl.innerHTML,
|
|
selected: optionEl.selected,
|
|
};
|
|
|
|
if (optionEl.selected) {
|
|
selectModel.selected = option.value;
|
|
}
|
|
|
|
selectModel.options.push(option);
|
|
}
|
|
|
|
if (!selectModel.selected) {
|
|
// No selected option, select the first one.
|
|
selectModel.selected = selectModel.options[0].value;
|
|
}
|
|
|
|
// Get the accessibility label.
|
|
const accessibilityLabel = questionEl.querySelector<HTMLLabelElement>('label[for="' + select.id + '"]');
|
|
selectModel.accessibilityLabel = accessibilityLabel?.innerHTML;
|
|
|
|
question.select = selectModel;
|
|
|
|
// Check which one should be displayed first: the select or the input.
|
|
if (question.parsedSettings && question.parsedSettings.unitsleft !== null) {
|
|
question.selectFirst = question.parsedSettings.unitsleft == '1';
|
|
} else {
|
|
const input = questionEl.querySelector<HTMLInputElement>('input[type="text"][name*=answer]');
|
|
question.selectFirst =
|
|
questionEl.innerHTML.indexOf(input?.outerHTML || '') > questionEl.innerHTML.indexOf(select.outerHTML);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Initialize a question component of type essay.
|
|
*
|
|
* @param review Whether we're in review mode.
|
|
* @returns Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initEssayComponent(review?: boolean): void | HTMLElement {
|
|
const questionEl = this.initComponent();
|
|
if (!questionEl || !this.question) {
|
|
return;
|
|
}
|
|
|
|
const question: AddonModQuizEssayQuestion = this.question;
|
|
const answerDraftIdInput = questionEl.querySelector<HTMLInputElement>('input[name*="_answer:itemid"]');
|
|
|
|
if (question.parsedSettings) {
|
|
question.allowsAttachments = question.parsedSettings.attachments != '0';
|
|
question.allowsAnswerFiles = question.parsedSettings.responseformat == 'editorfilepicker';
|
|
question.isMonospaced = question.parsedSettings.responseformat == 'monospaced';
|
|
question.isPlainText = question.isMonospaced || question.parsedSettings.responseformat == 'plain';
|
|
question.hasInlineText = question.parsedSettings.responseformat != 'noinline';
|
|
} else {
|
|
question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]');
|
|
question.allowsAnswerFiles = !!answerDraftIdInput;
|
|
question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced');
|
|
question.isPlainText = question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain');
|
|
}
|
|
|
|
if (review) {
|
|
// Search the answer and the attachments.
|
|
question.answer = CoreDomUtils.getContentsOfElement(questionEl, '.qtype_essay_response');
|
|
question.wordCountInfo = questionEl.querySelector('.answer > p')?.innerHTML;
|
|
|
|
if (question.parsedSettings) {
|
|
question.attachments = Array.from(
|
|
CoreQuestionHelper.getResponseFileAreaFiles(question, 'attachments'),
|
|
);
|
|
} else {
|
|
question.attachments = CoreQuestionHelper.getQuestionAttachmentsFromHtml(
|
|
CoreDomUtils.getContentsOfElement(questionEl, '.attachments') || '',
|
|
);
|
|
}
|
|
|
|
// Treat plagiarism.
|
|
this.handleEssayPlagiarism(questionEl);
|
|
|
|
return questionEl;
|
|
}
|
|
|
|
const textarea = questionEl.querySelector<HTMLTextAreaElement>('textarea[name*=_answer]');
|
|
question.hasDraftFiles = question.allowsAnswerFiles && CoreQuestionHelper.hasDraftFileUrls(questionEl.innerHTML);
|
|
|
|
if (!textarea && (question.hasInlineText || !question.allowsAttachments)) {
|
|
// Textarea not found, we might be in review. Search the answer and the attachments.
|
|
question.answer = CoreDomUtils.getContentsOfElement(questionEl, '.qtype_essay_response');
|
|
question.attachments = CoreQuestionHelper.getQuestionAttachmentsFromHtml(
|
|
CoreDomUtils.getContentsOfElement(questionEl, '.attachments') || '',
|
|
);
|
|
|
|
return questionEl;
|
|
}
|
|
|
|
if (textarea) {
|
|
const input = questionEl.querySelector<HTMLInputElement>('input[type="hidden"][name*=answerformat]');
|
|
let content = CoreTextUtils.decodeHTML(textarea.innerHTML || '');
|
|
|
|
if (question.hasDraftFiles && question.responsefileareas) {
|
|
content = CoreTextUtils.replaceDraftfileUrls(
|
|
CoreSites.getRequiredCurrentSite().getURL(),
|
|
content,
|
|
CoreQuestionHelper.getResponseFileAreaFiles(question, 'answer'),
|
|
).text;
|
|
}
|
|
|
|
question.textarea = {
|
|
id: textarea.id,
|
|
name: textarea.name,
|
|
text: content,
|
|
};
|
|
|
|
if (input) {
|
|
question.formatInput = {
|
|
name: input.name,
|
|
value: input.value,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (answerDraftIdInput) {
|
|
question.answerDraftIdInput = {
|
|
name: answerDraftIdInput.name,
|
|
value: Number(answerDraftIdInput.value),
|
|
};
|
|
}
|
|
|
|
if (question.allowsAttachments) {
|
|
const attachmentsInput = questionEl.querySelector<HTMLInputElement>('.attachments input[name*=_attachments]');
|
|
const objectElement = questionEl.querySelector<HTMLObjectElement>('.attachments object');
|
|
const fileManagerUrl = objectElement && objectElement.data;
|
|
|
|
if (attachmentsInput) {
|
|
question.attachmentsDraftIdInput = {
|
|
name: attachmentsInput.name,
|
|
value: Number(attachmentsInput.value),
|
|
};
|
|
}
|
|
|
|
if (question.parsedSettings) {
|
|
question.attachmentsMaxFiles = Number(question.parsedSettings.attachments);
|
|
question.attachmentsAcceptedTypes = (<string[] | undefined> question.parsedSettings.filetypeslist)?.join(',');
|
|
}
|
|
|
|
if (fileManagerUrl) {
|
|
const params = CoreUrlUtils.extractUrlParams(fileManagerUrl);
|
|
const maxBytes = Number(params.maxbytes);
|
|
const areaMaxBytes = Number(params.areamaxbytes);
|
|
|
|
question.attachmentsMaxBytes = maxBytes === -1 || areaMaxBytes === -1 ?
|
|
Math.max(maxBytes, areaMaxBytes) : Math.min(maxBytes, areaMaxBytes);
|
|
}
|
|
}
|
|
|
|
return questionEl;
|
|
}
|
|
|
|
/**
|
|
* Handle plagiarism in an essay question.
|
|
*
|
|
* @param questionEl Element with the question html.
|
|
*/
|
|
protected handleEssayPlagiarism(questionEl: HTMLElement): void {
|
|
if (!this.question) {
|
|
return;
|
|
}
|
|
|
|
const question: AddonModQuizEssayQuestion = this.question;
|
|
const answerPlagiarism = questionEl.querySelector<HTMLSpanElement>('.answer .core_plagiarism_links');
|
|
if (answerPlagiarism) {
|
|
question.answerPlagiarism = answerPlagiarism.innerHTML;
|
|
}
|
|
|
|
const attachments = question.attachments;
|
|
if (!attachments?.length) {
|
|
return;
|
|
}
|
|
|
|
const attachmentsPlagiarisms = Array.from(
|
|
questionEl.querySelectorAll<HTMLSpanElement>('.attachments .core_plagiarism_links'),
|
|
);
|
|
const questionAttachmentsPlagiarisms: string[] = [];
|
|
|
|
attachmentsPlagiarisms.forEach((plagiarism) => {
|
|
// Search the URL of the attachment it affects.
|
|
const attachmentUrl = plagiarism.parentElement?.querySelector('a')?.href;
|
|
if (!attachmentUrl) {
|
|
return;
|
|
}
|
|
|
|
const position = attachments.findIndex((file) => CoreFileHelper.getFileUrl(file) === attachmentUrl);
|
|
if (position >= 0) {
|
|
questionAttachmentsPlagiarisms[position] = plagiarism.innerHTML;
|
|
}
|
|
});
|
|
|
|
question.attachmentsPlagiarisms = questionAttachmentsPlagiarisms;
|
|
}
|
|
|
|
/**
|
|
* Initialize a question component that uses the original question text with some basic treatment.
|
|
*
|
|
* @param contentSelector The selector to find the question content (text).
|
|
* @returns Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initOriginalTextComponent(contentSelector: string): void | HTMLElement {
|
|
if (!this.question) {
|
|
return;
|
|
}
|
|
|
|
const element = CoreDomUtils.convertToElement(this.question.html);
|
|
|
|
// Get question content.
|
|
const content = element.querySelector<HTMLElement>(contentSelector);
|
|
if (!content) {
|
|
this.logger.warn('Aborting because of an error parsing question.', this.question.slot);
|
|
|
|
return CoreQuestionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
// Remove sequencecheck and validation error.
|
|
CoreDomUtils.removeElement(content, 'input[name*=sequencecheck]');
|
|
CoreDomUtils.removeElement(content, '.validationerror');
|
|
|
|
// Replace Moodle's correct/incorrect and feedback classes with our own.
|
|
CoreQuestionHelper.replaceCorrectnessClasses(element);
|
|
CoreQuestionHelper.replaceFeedbackClasses(element);
|
|
|
|
// Treat the correct/incorrect icons.
|
|
CoreQuestionHelper.treatCorrectnessIcons(element);
|
|
|
|
// Set the question text.
|
|
this.question.text = content.innerHTML;
|
|
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* Initialize a question component that has an input of type "text".
|
|
*
|
|
* @returns Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initInputTextComponent(): void | HTMLElement {
|
|
const questionEl = this.initComponent();
|
|
if (!questionEl || !this.question) {
|
|
return;
|
|
}
|
|
|
|
// Get the input element.
|
|
const question: AddonModQuizTextQuestion = this.question;
|
|
const input = questionEl.querySelector<HTMLInputElement>('input[type="text"][name*=answer]');
|
|
if (!input) {
|
|
this.logger.warn('Aborting because couldn\'t find input.', this.question.slot);
|
|
|
|
return CoreQuestionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
question.input = {
|
|
id: input.id,
|
|
name: input.name,
|
|
value: input.value,
|
|
readOnly: input.readOnly,
|
|
isInline: !!input.closest('.qtext'), // The answer can be inside the question text.
|
|
};
|
|
|
|
// Check if question is marked as correct.
|
|
if (input.classList.contains('incorrect')) {
|
|
question.input.correctClass = 'core-question-incorrect';
|
|
question.input.correctIcon = 'fas-xmark';
|
|
question.input.correctIconColor = CoreIonicColorNames.DANGER;
|
|
} else if (input.classList.contains('correct')) {
|
|
question.input.correctClass = 'core-question-correct';
|
|
question.input.correctIcon = 'fas-check';
|
|
question.input.correctIconColor = CoreIonicColorNames.SUCCESS;
|
|
} else if (input.classList.contains('partiallycorrect')) {
|
|
question.input.correctClass = 'core-question-partiallycorrect';
|
|
question.input.correctIcon = 'fas-square-check';
|
|
question.input.correctIconColor = CoreIonicColorNames.WARNING;
|
|
} else {
|
|
question.input.correctClass = '';
|
|
question.input.correctIcon = '';
|
|
question.input.correctIconColor = '';
|
|
}
|
|
|
|
if (question.input.isInline) {
|
|
// Handle correct/incorrect classes and icons.
|
|
const content = questionEl.querySelector<HTMLElement>('.qtext');
|
|
if (content) {
|
|
CoreQuestionHelper.replaceCorrectnessClasses(content);
|
|
CoreQuestionHelper.treatCorrectnessIcons(content);
|
|
|
|
question.text = content.innerHTML;
|
|
}
|
|
}
|
|
|
|
return questionEl;
|
|
}
|
|
|
|
/**
|
|
* Initialize a question component with a "match" behaviour.
|
|
*
|
|
* @returns Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initMatchComponent(): void | HTMLElement {
|
|
const questionEl = this.initComponent();
|
|
if (!questionEl || !this.question) {
|
|
return;
|
|
}
|
|
|
|
// Find rows.
|
|
const question: AddonModQuizMatchQuestion = this.question;
|
|
const rows = Array.from(questionEl.querySelectorAll<HTMLTableRowElement>('table.answer tr'));
|
|
if (!rows || !rows.length) {
|
|
this.logger.warn('Aborting because couldn\'t find any row.', question.slot);
|
|
|
|
return CoreQuestionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
question.rows = [];
|
|
|
|
for (const i in rows) {
|
|
const row = rows[i];
|
|
const columns = Array.from(row.querySelectorAll<HTMLTableCellElement>('td'));
|
|
|
|
if (!columns || columns.length < 2) {
|
|
this.logger.warn('Aborting because couldn\'t the right columns.', question.slot);
|
|
|
|
return CoreQuestionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
// Get the select and the options.
|
|
const select = columns[1].querySelector<HTMLSelectElement>('select');
|
|
const options = Array.from(columns[1].querySelectorAll<HTMLOptionElement>('option'));
|
|
|
|
if (!select || !options || !options.length) {
|
|
this.logger.warn('Aborting because couldn\'t find select or options.', question.slot);
|
|
|
|
return CoreQuestionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
const rowModel: AddonModQuizQuestionMatchSelect = {
|
|
id: select.id.replace(/:/g, '\\:'),
|
|
name: select.name,
|
|
disabled: select.disabled,
|
|
options: [],
|
|
text: columns[0].innerHTML, // Row's text should be in the first column.
|
|
};
|
|
|
|
// Check if answer is correct.
|
|
if (columns[1].className.indexOf('incorrect') >= 0) {
|
|
rowModel.isCorrect = 0;
|
|
} else if (columns[1].className.indexOf('correct') >= 0) {
|
|
rowModel.isCorrect = 1;
|
|
}
|
|
|
|
// Treat each option.
|
|
for (const j in options) {
|
|
const optionEl = options[j];
|
|
|
|
if (optionEl.value === undefined) {
|
|
this.logger.warn('Aborting because couldn\'t find the value of an option.', question.slot);
|
|
|
|
return CoreQuestionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
const option: AddonModQuizQuestionSelectOption = {
|
|
value: optionEl.value,
|
|
label: optionEl.innerHTML,
|
|
selected: optionEl.selected,
|
|
};
|
|
|
|
if (option.selected) {
|
|
rowModel.selected = option.value;
|
|
}
|
|
|
|
rowModel.options.push(option);
|
|
}
|
|
|
|
// Get the accessibility label.
|
|
const accessibilityLabel = columns[1].querySelector<HTMLLabelElement>('label.accesshide');
|
|
rowModel.accessibilityLabel = accessibilityLabel?.innerHTML;
|
|
|
|
question.rows.push(rowModel);
|
|
}
|
|
|
|
question.loaded = true;
|
|
|
|
return questionEl;
|
|
}
|
|
|
|
/**
|
|
* Initialize a question component with a multiple choice (checkbox) or single choice (radio).
|
|
*
|
|
* @returns Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initMultichoiceComponent(): void | HTMLElement {
|
|
const questionEl = this.initComponent();
|
|
if (!questionEl || !this.question) {
|
|
return;
|
|
}
|
|
|
|
// Get the prompt.
|
|
const question: AddonModQuizMultichoiceQuestion = this.question;
|
|
question.prompt = CoreDomUtils.getContentsOfElement(questionEl, '.prompt');
|
|
|
|
// Search radio buttons first (single choice).
|
|
let options = Array.from(questionEl.querySelectorAll<HTMLInputElement>('input[type="radio"]'));
|
|
if (!options || !options.length) {
|
|
// Radio buttons not found, it should be a multi answer. Search for checkbox.
|
|
question.multi = true;
|
|
options = Array.from(questionEl.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'));
|
|
|
|
if (!options || !options.length) {
|
|
// No checkbox found either. Abort.
|
|
this.logger.warn('Aborting because of no radio and checkbox found.', question.slot);
|
|
|
|
return CoreQuestionHelper.showComponentError(this.onAbort);
|
|
}
|
|
}
|
|
|
|
question.options = [];
|
|
question.disabled = true;
|
|
|
|
for (const i in options) {
|
|
const element = options[i];
|
|
const option: AddonModQuizQuestionRadioOption = {
|
|
id: element.id,
|
|
class: '',
|
|
name: element.name,
|
|
value: element.value,
|
|
checked: element.checked,
|
|
disabled: element.disabled,
|
|
};
|
|
const parent = element.parentElement;
|
|
|
|
if (option.value == '-1') {
|
|
// It's the clear choice option, ignore it.
|
|
continue;
|
|
}
|
|
|
|
question.optionsName = option.name;
|
|
question.disabled = question.disabled && element.disabled;
|
|
|
|
// Get the label with the question text. Try the new format first.
|
|
const labelId = element.getAttribute('aria-labelledby');
|
|
let label = labelId ? questionEl.querySelector('#' + labelId.replace(/:/g, '\\:')) : undefined;
|
|
if (!label) {
|
|
// Not found, use the old format.
|
|
label = questionEl.querySelector('label[for="' + option.id + '"]');
|
|
}
|
|
option.class = label?.className || option.class;
|
|
|
|
// Check that we were able to successfully extract options required data.
|
|
if (!label || option.name === undefined || option.value === undefined) {
|
|
// Something went wrong when extracting the questions data. Abort.
|
|
this.logger.warn('Aborting because of an error parsing options.', question.slot, option.name);
|
|
|
|
return CoreQuestionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
option.text = label.innerHTML;
|
|
|
|
if (element.checked) {
|
|
// If the option is checked and it's a single choice we use the model to select the one.
|
|
if (!question.multi) {
|
|
question.singleChoiceModel = option.value;
|
|
}
|
|
|
|
if (parent) {
|
|
// Check if answer is correct.
|
|
if (parent && parent.className.indexOf('incorrect') >= 0) {
|
|
option.isCorrect = 0;
|
|
} else if (parent && parent.className.indexOf('correct') >= 0) {
|
|
option.isCorrect = 1;
|
|
}
|
|
|
|
// Search the feedback.
|
|
const feedback = parent.querySelector('.specificfeedback');
|
|
if (feedback) {
|
|
option.feedback = feedback.innerHTML;
|
|
}
|
|
}
|
|
}
|
|
|
|
question.options.push(option);
|
|
}
|
|
|
|
return questionEl;
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Any possible types of question.
|
|
*/
|
|
export type AddonModQuizQuestion = AddonModQuizCalculatedQuestion | AddonModQuizEssayQuestion | AddonModQuizTextQuestion |
|
|
AddonModQuizMatchQuestion | AddonModQuizMultichoiceQuestion;
|
|
|
|
/**
|
|
* Basic data for question.
|
|
*/
|
|
export type AddonModQuizQuestionBasicData = CoreQuestionQuestion & {
|
|
text?: string;
|
|
};
|
|
|
|
/**
|
|
* Data for calculated question.
|
|
*/
|
|
export type AddonModQuizCalculatedQuestion = AddonModQuizTextQuestion & {
|
|
select?: AddonModQuizQuestionSelect; // Select data if units use a select.
|
|
selectFirst?: boolean; // Whether the select is first or after the input.
|
|
options?: AddonModQuizQuestionRadioOption[]; // Options if units use radio buttons.
|
|
optionsName?: string; // Options name (for radio buttons).
|
|
unit?: string; // Option selected (for radio buttons).
|
|
optionsFirst?: boolean; // Whether the radio buttons are first or after the input.
|
|
};
|
|
|
|
/**
|
|
* Data for a select.
|
|
*/
|
|
export type AddonModQuizQuestionSelect = {
|
|
id: string;
|
|
name: string;
|
|
disabled: boolean;
|
|
options: AddonModQuizQuestionSelectOption[];
|
|
selected?: string;
|
|
accessibilityLabel?: string;
|
|
};
|
|
|
|
/**
|
|
* Data for each option in a select.
|
|
*/
|
|
export type AddonModQuizQuestionSelectOption = {
|
|
value: string;
|
|
label: string;
|
|
selected: boolean;
|
|
};
|
|
|
|
/**
|
|
* Data for radio button.
|
|
*/
|
|
export type AddonModQuizQuestionRadioOption = {
|
|
id: string;
|
|
name: string;
|
|
class: string;
|
|
value: string;
|
|
disabled: boolean;
|
|
checked: boolean;
|
|
text?: string;
|
|
isCorrect?: number;
|
|
feedback?: string;
|
|
};
|
|
|
|
/**
|
|
* Data for essay question.
|
|
*/
|
|
export type AddonModQuizEssayQuestion = AddonModQuizQuestionBasicData & {
|
|
allowsAttachments?: boolean; // Whether the question allows attachments.
|
|
allowsAnswerFiles?: boolean; // Whether the question allows adding files in the answer.
|
|
isMonospaced?: boolean; // Whether the answer is monospaced.
|
|
isPlainText?: boolean; // Whether the answer is plain text.
|
|
hasInlineText?: boolean; // // Whether the answer has inline text
|
|
answer?: string; // Question answer text.
|
|
attachments?: CoreWSFile[]; // Question answer attachments.
|
|
hasDraftFiles?: boolean; // Whether the question has draft files.
|
|
textarea?: AddonModQuizQuestionTextarea; // Textarea data.
|
|
formatInput?: { name: string; value: string }; // Format input data.
|
|
answerDraftIdInput?: { name: string; value: number }; // Answer draft id input data.
|
|
attachmentsDraftIdInput?: { name: string; value: number }; // Attachments draft id input data.
|
|
attachmentsMaxFiles?: number; // Max number of attachments.
|
|
attachmentsAcceptedTypes?: string; // Attachments accepted file types.
|
|
attachmentsMaxBytes?: number; // Max bytes for attachments.
|
|
answerPlagiarism?: string; // Plagiarism HTML for the answer.
|
|
attachmentsPlagiarisms?: string[]; // Plagiarism HTML for each attachment.
|
|
wordCountInfo?: string; // Info about word count.
|
|
};
|
|
|
|
/**
|
|
* Data for textarea.
|
|
*/
|
|
export type AddonModQuizQuestionTextarea = {
|
|
id: string;
|
|
name: string;
|
|
text: string;
|
|
};
|
|
|
|
/**
|
|
* Data for text question.
|
|
*/
|
|
export type AddonModQuizTextQuestion = AddonModQuizQuestionBasicData & {
|
|
input?: AddonModQuizQuestionTextInput;
|
|
};
|
|
|
|
/**
|
|
* Data for text input.
|
|
*/
|
|
export type AddonModQuizQuestionTextInput = {
|
|
id: string;
|
|
name: string;
|
|
value: string;
|
|
readOnly: boolean;
|
|
isInline: boolean;
|
|
correctClass?: string;
|
|
correctIcon?: string;
|
|
correctIconColor?: string;
|
|
};
|
|
|
|
/**
|
|
* Data for match question.
|
|
*/
|
|
export type AddonModQuizMatchQuestion = AddonModQuizQuestionBasicData & {
|
|
loaded?: boolean; // Whether the question is loaded.
|
|
rows?: AddonModQuizQuestionMatchSelect[]; // Data for each row.
|
|
};
|
|
|
|
/**
|
|
* Each select data for match questions.
|
|
*/
|
|
export type AddonModQuizQuestionMatchSelect = AddonModQuizQuestionSelect & {
|
|
text: string;
|
|
isCorrect?: number;
|
|
};
|
|
|
|
/**
|
|
* Data for multichoice question.
|
|
*/
|
|
export type AddonModQuizMultichoiceQuestion = AddonModQuizQuestionBasicData & {
|
|
prompt?: string; // Question prompt.
|
|
multi?: boolean; // Whether the question allows more than one selected answer.
|
|
options?: AddonModQuizQuestionRadioOption[]; // List of options.
|
|
disabled?: boolean; // Whether the question is disabled.
|
|
optionsName?: string; // Name to use for the options in single choice.
|
|
singleChoiceModel?: string; // Model for single choice.
|
|
};
|