// (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; // Should emit an event when a behaviour button is clicked. @Output() onAbort: EventEmitter; // 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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; } }