Vmeda.Online/src/addons/mod/lesson/services/lesson-helper.ts

740 lines
24 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 { 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 = <HTMLInputElement> 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 = <HTMLInputElement | HTMLButtonElement> 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 = <HTMLElement> element.querySelector('.fcontainer');
// Get hidden inputs and add their data to the form group.
const hiddenInputs = <HTMLInputElement[]> 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 = <HTMLInputElement> 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 = <AddonModLessonMultichoiceQuestion> {
...question,
template: 'multichoice',
options: [],
multi: false,
};
// Get all the inputs. Search radio first.
let inputs = <HTMLInputElement[]> 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 = <HTMLInputElement[]> 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 = <AddonModLessonInputQuestion> question;
inputQuestion.template = 'shortanswer';
// Get the input.
const input = <HTMLInputElement> 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 = <AddonModLessonEssayQuestion> 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 = <AddonModLessonMatchingQuestion> {
...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 = <HTMLInputElement> 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 = <HTMLInputElement> 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<string, unknown>): Record<string, unknown> {
if (question.template == 'essay') {
const textarea = (<AddonModLessonEssayQuestion> question).textarea;
// Add some HTML to the answer if needed.
if (textarea) {
data[textarea.name] = CoreTextUtils.instance.formatHtmlLines(<string> data[textarea.name]);
}
} else if (question.template == 'multichoice' && (<AddonModLessonMultichoiceQuestion> 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<string, string>;
};
/**
* 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<AddonModLessonMultichoiceQuestion> &
Partial<AddonModLessonInputQuestion> & Partial<AddonModLessonEssayQuestion> & Partial<AddonModLessonMatchingQuestion>;
/**
* Activity link data.
*/
export type AddonModLessonActivityLink = {
formatted: boolean;
label: string;
href: string;
};