// (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 { Injectable } from '@angular/core'; import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { CoreTimeUtils } from '@services/utils/time'; import { makeSingleton, Translate } from '@singletons'; import { AddonModLesson, AddonModLessonAttemptsOverviewsAttemptWSData, AddonModLessonGetPageDataWSResponse, AddonModLessonProvider, } from './lesson'; /** * Helper service that provides some features for quiz. */ @Injectable({ providedIn: 'root' }) export class AddonModLessonHelperProvider { constructor( protected formBuilder: FormBuilder, ) {} /** * Given the HTML of next activity link, format it to extract the href and the text. * * @param activityLink HTML of the activity link. * @return Formatted data. */ formatActivityLink(activityLink: string): AddonModLessonActivityLink { const element = CoreDomUtils.instance.convertToElement(activityLink); const anchor = element.querySelector('a'); if (!anchor) { // Anchor not found, return the original HTML. return { formatted: false, label: activityLink, href: '', }; } return { formatted: true, label: anchor.innerHTML, href: anchor.href, }; } /** * Given the HTML of an answer from a content page, extract the data to render the answer. * * @param html Answer's HTML. * @return Data to render the answer. */ getContentPageAnswerDataFromHtml(html: string): {buttonText: string; content: string} { const data = { buttonText: '', content: '', }; const element = CoreDomUtils.instance.convertToElement(html); // Search the input button. const button = element.querySelector('input[type="button"]'); if (button) { // Extract the button content and remove it from the HTML. data.buttonText = button.value; button.remove(); } data.content = element.innerHTML.trim(); return data; } /** * Get the buttons to change pages. * * @param html Page's HTML. * @return List of buttons. */ getPageButtonsFromHtml(html: string): AddonModLessonPageButton[] { const buttons: AddonModLessonPageButton[] = []; const element = CoreDomUtils.instance.convertToElement(html); // Get the container of the buttons if it exists. let buttonsContainer = element.querySelector('.branchbuttoncontainer'); if (!buttonsContainer) { // Button container not found, might be a legacy lesson (from 1.9). if (!element.querySelector('form input[type="submit"]')) { // No buttons found. return buttons; } buttonsContainer = element; } const forms = Array.from(buttonsContainer.querySelectorAll('form')); forms.forEach((form) => { const buttonSelector = 'input[type="submit"], button[type="submit"]'; const buttonEl = form.querySelector(buttonSelector); const inputs = Array.from(form.querySelectorAll('input')); if (!buttonEl || !inputs || !inputs.length) { // Button not found or no inputs, ignore it. return; } const button: AddonModLessonPageButton = { id: buttonEl.id, title: buttonEl.title || buttonEl.value, content: buttonEl.tagName == 'INPUT' ? buttonEl.value : buttonEl.innerHTML.trim(), data: {}, }; inputs.forEach((input) => { if (input.type != 'submit') { button.data[input.name] = input.value; } }); buttons.push(button); }); return buttons; } /** * Given a page data, get the page contents. * * @param data Page data. * @return Page contents. */ getPageContentsFromPageData(data: AddonModLessonGetPageDataWSResponse): string { // Search the page contents inside the whole page HTML. Use data.pagecontent because it's filtered. const element = CoreDomUtils.instance.convertToElement(data.pagecontent || ''); const contents = element.querySelector('.contents'); if (contents) { return contents.innerHTML.trim(); } // Cannot find contents element. if (AddonModLesson.instance.isQuestionPage(data.page?.type || -1) || data.page?.qtype == AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE) { // Return page.contents to prevent having duplicated elements (some elements like videos might not work). return data.page?.contents || ''; } else { // It's an end of cluster, end of branch, etc. Return the whole pagecontent to match what's displayed in web. return data.pagecontent || ''; } } /** * Get a question and all the data required to render it from the page data. * * @param questionForm The form group where to add the controls. * @param pageData Page data. * @return Question data. */ getQuestionFromPageData(questionForm: FormGroup, pageData: AddonModLessonGetPageDataWSResponse): AddonModLessonQuestion { const element = CoreDomUtils.instance.convertToElement(pageData.pagecontent || ''); // Get the container of the question answers if it exists. const fieldContainer = element.querySelector('.fcontainer'); // Get hidden inputs and add their data to the form group. const hiddenInputs = Array.from(element.querySelectorAll('input[type="hidden"]')); hiddenInputs.forEach((input) => { questionForm.addControl(input.name, this.formBuilder.control(input.value)); }); // Get the submit button and extract its value. const submitButton = element.querySelector('input[type="submit"]'); const question: AddonModLessonQuestion = { template: '', submitLabel: submitButton ? submitButton.value : Translate.instance.instant('addon.mod_lesson.submit'), }; if (!fieldContainer) { // Element not found, return. return question; } let type = 'text'; switch (pageData.page?.qtype) { case AddonModLessonProvider.LESSON_PAGE_TRUEFALSE: case AddonModLessonProvider.LESSON_PAGE_MULTICHOICE: return this.getMultiChoiceQuestionData(questionForm, question, fieldContainer); case AddonModLessonProvider.LESSON_PAGE_NUMERICAL: type = 'number'; case AddonModLessonProvider.LESSON_PAGE_SHORTANSWER: return this.getInputQuestionData(questionForm, question, fieldContainer, type); case AddonModLessonProvider.LESSON_PAGE_ESSAY: { return this.getEssayQuestionData(questionForm, question, fieldContainer); } case AddonModLessonProvider.LESSON_PAGE_MATCHING: { return this.getMatchingQuestionData(questionForm, question, fieldContainer); } } return question; } /** * Get a multichoice question data. * * @param questionForm The form group where to add the controls. * @param question Basic question data. * @param fieldContainer HTMLElement containing the data. * @return Question data. */ protected getMultiChoiceQuestionData( questionForm: FormGroup, question: AddonModLessonQuestion, fieldContainer: HTMLElement, ): AddonModLessonMultichoiceQuestion { const multiChoiceQuestion = { ...question, template: 'multichoice', options: [], multi: false, }; // Get all the inputs. Search radio first. let inputs = Array.from(fieldContainer.querySelectorAll('input[type="radio"]')); if (!inputs || !inputs.length) { // Radio buttons not found, it might be a multi answer. Search for checkbox. multiChoiceQuestion.multi = true; inputs = Array.from(fieldContainer.querySelectorAll('input[type="checkbox"]')); if (!inputs || !inputs.length) { // No checkbox found either. Stop. return multiChoiceQuestion; } } let controlAdded = false; inputs.forEach((input) => { const parent = input.parentElement; const option: AddonModLessonMultichoiceOption = { id: input.id, name: input.name, value: input.value, checked: !!input.checked, disabled: !!input.disabled, text: '', }; if (option.checked || multiChoiceQuestion.multi) { // Add the control. const value = multiChoiceQuestion.multi ? { value: option.checked, disabled: option.disabled } : option.value; questionForm.addControl(option.name, this.formBuilder.control(value)); controlAdded = true; } // Remove the input and use the rest of the parent contents as the label. input.remove(); option.text = parent?.innerHTML.trim() || ''; multiChoiceQuestion.options!.push(option); }); if (!multiChoiceQuestion.multi) { multiChoiceQuestion.controlName = inputs[0].name; if (!controlAdded) { // No checked option for single choice, add the control with an empty value. questionForm.addControl(multiChoiceQuestion.controlName, this.formBuilder.control('')); } } return multiChoiceQuestion; } /** * Get an input question data. * * @param questionForm The form group where to add the controls. * @param question Basic question data. * @param fieldContainer HTMLElement containing the data. * @param type Type of the input. * @return Question data. */ protected getInputQuestionData( questionForm: FormGroup, question: AddonModLessonQuestion, fieldContainer: HTMLElement, type: string, ): AddonModLessonInputQuestion { const inputQuestion = question; inputQuestion.template = 'shortanswer'; // Get the input. const input = fieldContainer.querySelector('input[type="text"], input[type="number"]'); if (!input) { return inputQuestion; } inputQuestion.input = { id: input.id, name: input.name, maxlength: input.maxLength, type, }; // Init the control. questionForm.addControl(input.name, this.formBuilder.control({ value: input.value, disabled: input.readOnly })); return inputQuestion; } /** * Get an essay question data. * * @param questionForm The form group where to add the controls. * @param question Basic question data. * @param fieldContainer HTMLElement containing the data. * @return Question data. */ protected getEssayQuestionData( questionForm: FormGroup, question: AddonModLessonQuestion, fieldContainer: HTMLElement, ): AddonModLessonEssayQuestion { const essayQuestion = question; essayQuestion.template = 'essay'; // Get the textarea. const textarea = fieldContainer.querySelector('textarea'); if (!textarea) { // Textarea not found, probably review mode. const answerEl = fieldContainer.querySelector('.reviewessay'); if (!answerEl) { // Answer not found, stop. return essayQuestion; } essayQuestion.useranswer = answerEl.innerHTML; } else { essayQuestion.textarea = { id: textarea.id, name: textarea.name || 'answer[text]', }; // Init the control. essayQuestion.control = this.formBuilder.control(''); questionForm.addControl(essayQuestion.textarea.name, essayQuestion.control); } return essayQuestion; } /** * Get a matching question data. * * @param questionForm The form group where to add the controls. * @param question Basic question data. * @param fieldContainer HTMLElement containing the data. * @return Question data. */ protected getMatchingQuestionData( questionForm: FormGroup, question: AddonModLessonQuestion, fieldContainer: HTMLElement, ): AddonModLessonMatchingQuestion { const matchingQuestion = { ...question, template: 'matching', rows: [], }; const rows = Array.from(fieldContainer.querySelectorAll('.answeroption')); rows.forEach((row) => { const label = row.querySelector('label'); const select = row.querySelector('select'); const options = Array.from(row.querySelectorAll('option')); if (!label || !select || !options || !options.length) { return; } // Get the row's text (label). const rowData: AddonModLessonMatchingRow = { text: label.innerHTML.trim(), id: select.id, name: select.name, options: [], }; // Treat each option. let controlAdded = false; options.forEach((option) => { if (typeof option.value == 'undefined') { // Option not valid, ignore it. return; } const optionData: AddonModLessonMatchingRowOption = { value: option.value, label: option.innerHTML.trim(), selected: option.selected, }; if (optionData.selected) { controlAdded = true; questionForm.addControl( rowData.name, this.formBuilder.control({ value: optionData.value, disabled: !!select.disabled }), ); } rowData.options.push(optionData); }); if (!controlAdded) { // No selected option, add the control with an empty value. questionForm.addControl(rowData.name, this.formBuilder.control({ value: '', disabled: !!select.disabled })); } matchingQuestion.rows.push(rowData); }); return matchingQuestion; } /** * Given the HTML of an answer from a question page, extract the data to render the answer. * * @param html Answer's HTML. * @return Object with the data to render the answer. If the answer doesn't require any parsing, return a string with the HTML. */ getQuestionPageAnswerDataFromHtml(html: string): AddonModLessonAnswerData { const element = CoreDomUtils.instance.convertToElement(html); // Check if it has a checkbox. let input = element.querySelector('input[type="checkbox"][name*="answer"]'); if (input) { // Truefalse or multichoice. const data: AddonModLessonCheckboxAnswerData = { isCheckbox: true, checked: !!input.checked, name: input.name, highlight: !!element.querySelector('.highlight'), content: '', }; input.remove(); data.content = element.innerHTML.trim(); return data; } // Check if it has an input text or number. input = element.querySelector('input[type="number"],input[type="text"]'); if (input) { // Short answer or numeric. return { isText: true, value: input.value, }; } // Check if it has a select. const select = element.querySelector('select'); if (select?.options) { // Matching. const selectedOption = select.options[select.selectedIndex]; const data: AddonModLessonSelectAnswerData = { isSelect: true, id: select.id, value: selectedOption ? selectedOption.value : '', content: '', }; select.remove(); data.content = element.innerHTML.trim(); return data; } // The answer doesn't need any parsing, return the HTML as it is. return html; } /** * Get a label to identify a retake (lesson attempt). * * @param retake Retake object. * @param includeDuration Whether to include the duration of the retake. * @return Retake label. */ getRetakeLabel(retake: AddonModLessonAttemptsOverviewsAttemptWSData, includeDuration?: boolean): string { const data = { retake: retake.try + 1, grade: '', timestart: '', duration: '', }; const hasGrade = retake.grade != null; if (hasGrade || retake.end) { // Retake finished with or without grade (if the lesson only has content pages, it has no grade). if (hasGrade) { data.grade = Translate.instance.instant('core.percentagenumber', { $a: retake.grade }); } data.timestart = CoreTimeUtils.instance.userDate(retake.timestart * 1000); if (includeDuration) { data.duration = CoreTimeUtils.instance.formatTime(retake.timeend - retake.timestart); } } else { // The user has not completed the retake. data.grade = Translate.instance.instant('addon.mod_lesson.notcompleted'); if (retake.timestart) { data.timestart = CoreTimeUtils.instance.userDate(retake.timestart * 1000); } } return Translate.instance.instant('addon.mod_lesson.retakelabel' + (includeDuration ? 'full' : 'short'), data); } /** * Prepare the question data to be sent to server. * * @param question Question to prepare. * @param data Data to prepare. * @return Data to send. */ prepareQuestionData(question: AddonModLessonQuestion, data: Record): Record { if (question.template == 'essay') { const textarea = ( question).textarea; // Add some HTML to the answer if needed. if (textarea) { data[textarea.name] = CoreTextUtils.instance.formatHtmlLines( data[textarea.name]); } } else if (question.template == 'multichoice' && ( question).multi) { // Only send the options with value set to true. for (const name in data) { if (name.match(/answer\[\d+\]/) && data[name] == false) { delete data[name]; } } } return data; } /** * Given the feedback of a process page in HTML, remove the question text. * * @param html Feedback's HTML. * @return Feedback without the question text. */ removeQuestionFromFeedback(html: string): string { const element = CoreDomUtils.instance.convertToElement(html); // Remove the question text. CoreDomUtils.instance.removeElement(element, '.generalbox:not(.feedback):not(.correctanswer)'); return element.innerHTML.trim(); } } export class AddonModLessonHelper extends makeSingleton(AddonModLessonHelperProvider) {} /** * Page button data. */ export type AddonModLessonPageButton = { id: string; title: string; content: string; data: Record; }; /** * Generic question data. */ export type AddonModLessonQuestionBasicData = { template: string; // Name of the template to use. submitLabel: string; // Text to display in submit. }; /** * Multichoice question data. */ export type AddonModLessonMultichoiceQuestion = AddonModLessonQuestionBasicData & { multi: boolean; // Whether it allows multiple answers. options: AddonModLessonMultichoiceOption[]; // Options for multichoice question. controlName?: string; // Name of the form control, for single choice. }; /** * Short answer or numeric question data. */ export type AddonModLessonInputQuestion = AddonModLessonQuestionBasicData & { input?: AddonModLessonQuestionInput; // Text input for text/number questions. }; /** * Essay question data. */ export type AddonModLessonEssayQuestion = AddonModLessonQuestionBasicData & { useranswer?: string; // User answer, for reviewing. textarea?: AddonModLessonTextareaData; // Data for the textarea. control?: FormControl; // Form control. }; /** * Matching question data. */ export type AddonModLessonMatchingQuestion = AddonModLessonQuestionBasicData & { rows: AddonModLessonMatchingRow[]; }; /** * Data for each option in a multichoice question. */ export type AddonModLessonMultichoiceOption = { id: string; name: string; value: string; checked: boolean; disabled: boolean; text: string; }; /** * Input data for text/number questions. */ export type AddonModLessonQuestionInput = { id: string; name: string; maxlength: number; type: string; }; /** * Textarea data for essay questions. */ export type AddonModLessonTextareaData = { id: string; name: string; }; /** * Data for each row in a matching question. */ export type AddonModLessonMatchingRow = { id: string; name: string; text: string; options: AddonModLessonMatchingRowOption[]; }; /** * Data for each option in a row in a matching question. */ export type AddonModLessonMatchingRowOption = { value: string; label: string; selected: boolean; }; /** * Checkbox answer. */ export type AddonModLessonCheckboxAnswerData = { isCheckbox: true; checked: boolean; name: string; highlight: boolean; content: string; }; /** * Text answer. */ export type AddonModLessonTextAnswerData = { isText: true; value: string; }; /** * Select answer. */ export type AddonModLessonSelectAnswerData = { isSelect: true; id: string; value: string; content: string; }; /** * Any possible answer data. */ export type AddonModLessonAnswerData = AddonModLessonCheckboxAnswerData | AddonModLessonTextAnswerData | AddonModLessonSelectAnswerData | string; /** * Any possible question data. */ export type AddonModLessonQuestion = AddonModLessonQuestionBasicData & Partial & Partial & Partial & Partial; /** * Activity link data. */ export type AddonModLessonActivityLink = { formatted: boolean; label: string; href: string; };