forked from EVOgeek/Vmeda.Online
510 lines
21 KiB
TypeScript
510 lines
21 KiB
TypeScript
// (C) Copyright 2015 Martin Dougiamas
|
|
//
|
|
// 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, Injector, ElementRef } from '@angular/core';
|
|
import { CoreLoggerProvider } from '@providers/logger';
|
|
import { CoreDomUtilsProvider } from '@providers/utils/dom';
|
|
import { CoreTextUtilsProvider } from '@providers/utils/text';
|
|
import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
|
|
|
|
/**
|
|
* Base class for components to render a question.
|
|
*/
|
|
export class CoreQuestionBaseComponent {
|
|
@Input() question: any; // 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.
|
|
@Output() buttonClicked: EventEmitter<any>; // Should emit an event when a behaviour button is clicked.
|
|
@Output() onAbort: EventEmitter<void>; // Should emit an event if the question should be aborted.
|
|
|
|
protected logger;
|
|
protected questionHelper: CoreQuestionHelperProvider;
|
|
protected domUtils: CoreDomUtilsProvider;
|
|
protected textUtils: CoreTextUtilsProvider;
|
|
protected realElement: HTMLElement;
|
|
|
|
constructor(logger: CoreLoggerProvider, logName: string, protected injector: Injector) {
|
|
this.logger = logger.getInstance(logName);
|
|
|
|
// Use an injector to get the providers to prevent having to modify all subclasses if a new provider is needed.
|
|
this.questionHelper = injector.get(CoreQuestionHelperProvider);
|
|
this.domUtils = injector.get(CoreDomUtilsProvider);
|
|
this.textUtils = injector.get(CoreTextUtilsProvider);
|
|
this.realElement = injector.get(ElementRef).nativeElement;
|
|
}
|
|
|
|
/**
|
|
* Initialize a question component of type calculated or calculated simple.
|
|
*
|
|
* @return {void|HTMLElement} 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) {
|
|
|
|
// Check if the question has a select for units.
|
|
const selectModel: any = {},
|
|
select = <HTMLSelectElement> questionEl.querySelector('select[name*=unit]'),
|
|
options = select && Array.from(select.querySelectorAll('option'));
|
|
|
|
if (select && options && options.length) {
|
|
|
|
selectModel.id = select.id;
|
|
selectModel.name = select.name;
|
|
selectModel.disabled = select.disabled;
|
|
selectModel.options = [];
|
|
|
|
// Treat each option.
|
|
for (const i in options) {
|
|
const optionEl = options[i];
|
|
|
|
if (typeof optionEl.value == 'undefined') {
|
|
this.logger.warn('Aborting because couldn\'t find input.', this.question.name);
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
const option = {
|
|
value: optionEl.value,
|
|
label: optionEl.innerHTML
|
|
};
|
|
|
|
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('label[for="' + select.id + '"]');
|
|
selectModel.accessibilityLabel = accessibilityLabel && accessibilityLabel.innerHTML;
|
|
|
|
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);
|
|
|
|
return questionEl;
|
|
}
|
|
|
|
// Check if the question has radio buttons for units.
|
|
const radios = <HTMLInputElement[]> Array.from(questionEl.querySelectorAll('input[type="radio"]'));
|
|
if (!radios.length) {
|
|
// No select and no radio buttons. The units need to be entered in the input text.
|
|
return questionEl;
|
|
}
|
|
|
|
this.question.options = [];
|
|
|
|
for (const i in radios) {
|
|
const radioEl = radios[i],
|
|
option: any = {
|
|
id: radioEl.id,
|
|
name: radioEl.name,
|
|
value: radioEl.value,
|
|
checked: radioEl.checked,
|
|
disabled: radioEl.disabled
|
|
},
|
|
// Get the label with the question text.
|
|
label = <HTMLElement> questionEl.querySelector('label[for="' + option.id + '"]');
|
|
|
|
this.question.optionsName = option.name;
|
|
|
|
if (label) {
|
|
option.text = label.innerText;
|
|
|
|
// Check that we were able to successfully extract options required data.
|
|
if (typeof option.name != 'undefined' && typeof option.value != 'undefined' &&
|
|
typeof option.text != 'undefined') {
|
|
|
|
if (radioEl.checked) {
|
|
// If the option is checked we use the model to select the one.
|
|
this.question.unit = option.value;
|
|
}
|
|
|
|
this.question.options.push(option);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Something went wrong when extracting the questions data. Abort.
|
|
this.logger.warn('Aborting because of an error parsing options.', this.question.name, option.name);
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the component and the question text.
|
|
*
|
|
* @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initComponent(): void | HTMLElement {
|
|
if (!this.question) {
|
|
this.logger.warn('Aborting because of no question received.');
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
this.realElement.classList.add('core-question-container');
|
|
|
|
const element = this.domUtils.convertToElement(this.question.html);
|
|
|
|
// Extract question text.
|
|
this.question.text = this.domUtils.getContentsOfElement(element, '.qtext');
|
|
if (typeof this.question.text == 'undefined') {
|
|
this.logger.warn('Aborting because of an error parsing question.', this.question.name);
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* Initialize a question component of type essay.
|
|
*
|
|
* @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initEssayComponent(): void | HTMLElement {
|
|
const questionEl = this.initComponent();
|
|
|
|
if (questionEl) {
|
|
// First search the textarea.
|
|
const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]');
|
|
this.question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]');
|
|
this.question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced');
|
|
this.question.isPlainText = this.question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain');
|
|
this.question.hasDraftFiles = this.questionHelper.hasDraftFileUrls(questionEl.innerHTML);
|
|
|
|
if (!textarea) {
|
|
// Textarea not found, we might be in review. Search the answer and the attachments.
|
|
this.question.answer = this.domUtils.getContentsOfElement(questionEl, '.qtype_essay_response');
|
|
this.question.attachments = this.questionHelper.getQuestionAttachmentsFromHtml(
|
|
this.domUtils.getContentsOfElement(questionEl, '.attachments'));
|
|
} else {
|
|
// Textarea found.
|
|
const input = <HTMLInputElement> questionEl.querySelector('input[type="hidden"][name*=answerformat]'),
|
|
content = textarea.innerHTML;
|
|
|
|
this.question.textarea = {
|
|
id: textarea.id,
|
|
name: textarea.name,
|
|
text: content ? this.textUtils.decodeHTML(content) : ''
|
|
};
|
|
|
|
if (input) {
|
|
this.question.formatInput = {
|
|
name: input.name,
|
|
value: input.value
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize a question component that uses the original question text with some basic treatment.
|
|
*
|
|
* @param {string} contentSelector The selector to find the question content (text).
|
|
* @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initOriginalTextComponent(contentSelector: string): void | HTMLElement {
|
|
if (!this.question) {
|
|
this.logger.warn('Aborting because of no question received.');
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
const element = this.domUtils.convertToElement(this.question.html);
|
|
|
|
// Get question content.
|
|
const content = <HTMLElement> element.querySelector(contentSelector);
|
|
if (!content) {
|
|
this.logger.warn('Aborting because of an error parsing question.', this.question.name);
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
// Remove sequencecheck and validation error.
|
|
this.domUtils.removeElement(content, 'input[name*=sequencecheck]');
|
|
this.domUtils.removeElement(content, '.validationerror');
|
|
|
|
// Replace Moodle's correct/incorrect and feedback classes with our own.
|
|
this.questionHelper.replaceCorrectnessClasses(element);
|
|
this.questionHelper.replaceFeedbackClasses(element);
|
|
|
|
// Treat the correct/incorrect icons.
|
|
this.questionHelper.treatCorrectnessIcons(element);
|
|
|
|
// Set the question text.
|
|
this.question.text = content.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Initialize a question component that has an input of type "text".
|
|
*
|
|
* @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initInputTextComponent(): void | HTMLElement {
|
|
const questionEl = this.initComponent();
|
|
if (questionEl) {
|
|
// Get the input element.
|
|
const input = <HTMLInputElement> questionEl.querySelector('input[type="text"][name*=answer]');
|
|
if (!input) {
|
|
this.logger.warn('Aborting because couldn\'t find input.', this.question.name);
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
this.question.input = {
|
|
id: input.id,
|
|
name: input.name,
|
|
value: input.value,
|
|
readOnly: input.readOnly,
|
|
isInline: !!this.domUtils.closest(input, '.qtext') // The answer can be inside the question text.
|
|
};
|
|
|
|
// Check if question is marked as correct.
|
|
if (input.classList.contains('incorrect')) {
|
|
this.question.input.correctClass = 'core-question-incorrect';
|
|
this.question.input.correctIcon = 'fa-remove';
|
|
this.question.input.correctIconColor = 'danger';
|
|
} else if (input.classList.contains('correct')) {
|
|
this.question.input.correctClass = 'core-question-correct';
|
|
this.question.input.correctIcon = 'fa-check';
|
|
this.question.input.correctIconColor = 'success';
|
|
} else if (input.classList.contains('partiallycorrect')) {
|
|
this.question.input.correctClass = 'core-question-partiallycorrect';
|
|
this.question.input.correctIcon = 'fa-check-square';
|
|
this.question.input.correctIconColor = 'warning';
|
|
} else {
|
|
this.question.input.correctClass = '';
|
|
this.question.input.correctIcon = '';
|
|
this.question.input.correctIconColor = '';
|
|
}
|
|
|
|
if (this.question.input.isInline) {
|
|
// Handle correct/incorrect classes and icons.
|
|
const content = <HTMLElement> questionEl.querySelector('.qtext');
|
|
|
|
this.questionHelper.replaceCorrectnessClasses(content);
|
|
this.questionHelper.treatCorrectnessIcons(content);
|
|
|
|
this.question.text = content.innerHTML;
|
|
}
|
|
}
|
|
|
|
return questionEl;
|
|
}
|
|
|
|
/**
|
|
* Initialize a question component with a "match" behaviour.
|
|
*
|
|
* @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initMatchComponent(): void | HTMLElement {
|
|
const questionEl = this.initComponent();
|
|
|
|
if (questionEl) {
|
|
// Find rows.
|
|
const rows = Array.from(questionEl.querySelectorAll('table.answer tr'));
|
|
if (!rows || !rows.length) {
|
|
this.logger.warn('Aborting because couldn\'t find any row.', this.question.name);
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
this.question.rows = [];
|
|
|
|
for (const i in rows) {
|
|
const row = rows[i],
|
|
rowModel: any = {},
|
|
columns = Array.from(row.querySelectorAll('td'));
|
|
|
|
if (!columns || columns.length < 2) {
|
|
this.logger.warn('Aborting because couldn\'t the right columns.', this.question.name);
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
// Get the row's text. It should be in the first column.
|
|
rowModel.text = columns[0].innerHTML;
|
|
|
|
// Get the select and the options.
|
|
const select = columns[1].querySelector('select'),
|
|
options = Array.from(columns[1].querySelectorAll('option'));
|
|
|
|
if (!select || !options || !options.length) {
|
|
this.logger.warn('Aborting because couldn\'t find select or options.', this.question.name);
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
rowModel.id = select.id;
|
|
rowModel.name = select.name;
|
|
rowModel.disabled = select.disabled;
|
|
rowModel.selected = false;
|
|
rowModel.options = [];
|
|
|
|
// 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 (typeof optionEl.value == 'undefined') {
|
|
this.logger.warn('Aborting because couldn\'t find the value of an option.', this.question.name);
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
|
|
const option = {
|
|
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('label.accesshide');
|
|
rowModel.accessibilityLabel = accessibilityLabel && accessibilityLabel.innerHTML;
|
|
|
|
this.question.rows.push(rowModel);
|
|
}
|
|
|
|
this.question.loaded = true;
|
|
}
|
|
|
|
return questionEl;
|
|
}
|
|
|
|
/**
|
|
* Initialize a question component with a multiple choice (checkbox) or single choice (radio).
|
|
*
|
|
* @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid.
|
|
*/
|
|
initMultichoiceComponent(): void | HTMLElement {
|
|
const questionEl = this.initComponent();
|
|
|
|
if (questionEl) {
|
|
|
|
// Get the prompt.
|
|
this.question.prompt = this.domUtils.getContentsOfElement(questionEl, '.prompt');
|
|
|
|
// Search radio buttons first (single choice).
|
|
let options = <HTMLInputElement[]> Array.from(questionEl.querySelectorAll('input[type="radio"]'));
|
|
if (!options || !options.length) {
|
|
// Radio buttons not found, it should be a multi answer. Search for checkbox.
|
|
this.question.multi = true;
|
|
options = <HTMLInputElement[]> Array.from(questionEl.querySelectorAll('input[type="checkbox"]'));
|
|
|
|
if (!options || !options.length) {
|
|
// No checkbox found either. Abort.
|
|
this.logger.warn('Aborting because of no radio and checkbox found.', this.question.name);
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
}
|
|
|
|
this.question.options = [];
|
|
|
|
for (const i in options) {
|
|
const element = options[i],
|
|
option: any = {
|
|
id: element.id,
|
|
name: element.name,
|
|
value: element.value,
|
|
checked: element.checked,
|
|
disabled: element.disabled
|
|
},
|
|
parent = element.parentElement;
|
|
|
|
this.question.optionsName = option.name;
|
|
|
|
// Get the label with the question text.
|
|
const label = questionEl.querySelector('label[for="' + option.id + '"]');
|
|
if (label) {
|
|
option.text = label.innerHTML;
|
|
|
|
// Check that we were able to successfully extract options required data.
|
|
if (typeof option.name != 'undefined' && typeof option.value != 'undefined' &&
|
|
typeof option.text != 'undefined') {
|
|
|
|
if (element.checked) {
|
|
// If the option is checked and it's a single choice we use the model to select the one.
|
|
if (!this.question.multi) {
|
|
this.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;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.question.options.push(option);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Something went wrong when extracting the questions data. Abort.
|
|
this.logger.warn('Aborting because of an error parsing options.', this.question.name, option.name);
|
|
|
|
return this.questionHelper.showComponentError(this.onAbort);
|
|
}
|
|
}
|
|
|
|
return questionEl;
|
|
}
|
|
}
|