// (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, Injector, ElementRef } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSites } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreUrlUtils } from '@providers/utils/url'; 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. @Input() contextLevel?: string; // The context level. @Input() contextInstanceId?: number; // The instance ID related to the context. @Input() courseId?: number; // The course the question belongs to (if any). @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 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); return questionEl; } } /** * Initialize the component and the question text. * * @return 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 Element containing the question HTML, void if the data is not valid. */ initEssayComponent(): void | HTMLElement { const questionEl = this.initComponent(); if (questionEl) { 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'); this.question.hasDraftFiles = this.question.allowsAnswerFiles && this.questionHelper.hasDraftFileUrls(questionEl.innerHTML); if (!textarea && !this.question.allowsAttachments) { // Textarea and filemanager 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')); return questionEl; } if (textarea) { const input = questionEl.querySelector('input[type="hidden"][name*=answerformat]'); let content = this.textUtils.decodeHTML(textarea.innerHTML || ''); if (this.question.hasDraftFiles && this.question.responsefileareas) { content = this.textUtils.replaceDraftfileUrls(CoreSites.instance.getCurrentSite().getURL(), content, this.questionHelper.getResponseFileAreaFiles(this.question, 'answer')).text; } this.question.textarea = { id: textarea.id, name: textarea.name, text: content, }; if (input) { this.question.formatInput = { name: input.name, value: input.value }; } } if (answerDraftIdInput) { this.question.answerDraftIdInput = { name: answerDraftIdInput.name, value: Number(answerDraftIdInput.value), }; } if (this.question.allowsAttachments) { const attachmentsInput = questionEl.querySelector('.attachments input[name*=_attachments]'); const objectElement = questionEl.querySelector('.attachments object'); const fileManagerUrl = objectElement && objectElement.data; if (attachmentsInput) { this.question.attachmentsDraftIdInput = { name: attachmentsInput.name, value: Number(attachmentsInput.value), }; } 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); } } return questionEl; } } /** * Initialize a question component that uses the original question text with some basic treatment. * * @param contentSelector The selector to find the question content (text). * @return 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; return element; } /** * Initialize a question component that has an input of type "text". * * @return 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 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 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 = []; this.question.disabled = true; 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; if (option.value == '-1') { // It's the clear choice option, ignore it. continue; } this.question.optionsName = option.name; this.question.disabled = this.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 + '"]'); } 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; } }