962 lines
35 KiB
TypeScript
962 lines
35 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, EventEmitter } from '@angular/core';
|
|
import { FileEntry, DirectoryEntry } from '@awesome-cordova-plugins/file/ngx';
|
|
|
|
import { CoreFile } from '@services/file';
|
|
import { CoreFileHelper } from '@services/file-helper';
|
|
import { CoreFilepool } from '@services/filepool';
|
|
import { CoreSites } from '@services/sites';
|
|
import { CoreDomUtils } from '@services/utils/dom';
|
|
import { CoreTextUtils } from '@services/utils/text';
|
|
import { CoreUtils } from '@services/utils/utils';
|
|
import { CoreWSFile } from '@services/ws';
|
|
import { makeSingleton, Translate } from '@singletons';
|
|
import { CoreQuestion, CoreQuestionProvider, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from './question';
|
|
import { CoreQuestionDelegate } from './question-delegate';
|
|
import { CoreIcons } from '@singletons/icons';
|
|
import { CoreUrlUtils } from '@services/utils/url';
|
|
import { ContextLevel } from '@/core/constants';
|
|
|
|
/**
|
|
* Service with some common functions to handle questions.
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class CoreQuestionHelperProvider {
|
|
|
|
protected lastErrorShown = 0;
|
|
|
|
/**
|
|
* Add a behaviour button to the question's "behaviourButtons" property.
|
|
*
|
|
* @param question Question.
|
|
* @param button Behaviour button (DOM element).
|
|
*/
|
|
protected addBehaviourButton(question: CoreQuestionQuestion, button: HTMLElement): void {
|
|
if (!button || !question) {
|
|
return;
|
|
}
|
|
|
|
question.behaviourButtons = question.behaviourButtons || [];
|
|
|
|
// Extract the data we want.
|
|
if (button instanceof HTMLInputElement) {
|
|
// Old behaviour that changed in 4.2 because of MDL-78874.
|
|
question.behaviourButtons.push({
|
|
id: button.id,
|
|
name: button.name,
|
|
value: button.value,
|
|
disabled: button.disabled,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (!(button instanceof HTMLButtonElement)) {
|
|
return;
|
|
}
|
|
|
|
question.behaviourButtons.push({
|
|
id: button.id,
|
|
name: button.name,
|
|
value: button.innerHTML,
|
|
disabled: button.disabled,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear questions temporary data after the data has been saved.
|
|
*
|
|
* @param questions The list of questions.
|
|
* @param component The component the question is related to.
|
|
* @param componentId Component ID.
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
async clearTmpData(questions: CoreQuestionQuestionParsed[], component: string, componentId: string | number): Promise<void> {
|
|
questions = questions || [];
|
|
|
|
await Promise.all(questions.map(async (question) => {
|
|
await CoreQuestionDelegate.clearTmpData(question, component, componentId);
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Delete files stored for a question.
|
|
*
|
|
* @param question Question.
|
|
* @param component The component the question is related to.
|
|
* @param componentId Component ID.
|
|
* @param siteId Site ID. If not defined, current site.
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
async deleteStoredQuestionFiles(
|
|
question: CoreQuestionQuestionParsed,
|
|
component: string,
|
|
componentId: string | number,
|
|
siteId?: string,
|
|
): Promise<void> {
|
|
const questionComponentId = CoreQuestion.getQuestionComponentId(question, componentId);
|
|
const folderPath = CoreQuestion.getQuestionFolder(question.type, component, questionComponentId, siteId);
|
|
|
|
// Ignore errors, maybe the folder doesn't exist.
|
|
await CoreUtils.ignoreErrors(CoreFile.removeDir(folderPath));
|
|
}
|
|
|
|
/**
|
|
* Extract question behaviour submit buttons from the question's HTML and add them to "behaviourButtons" property.
|
|
* The buttons aren't deleted from the content because all the im-controls block will be removed afterwards.
|
|
*
|
|
* @param question Question to treat.
|
|
* @param selector Selector to search the buttons. By default, '.im-controls [type="submit"]'.
|
|
*/
|
|
extractQbehaviourButtons(question: CoreQuestionQuestionParsed, selector?: string): void {
|
|
if (CoreQuestionDelegate.getPreventSubmitMessage(question)) {
|
|
// The question is not fully supported, don't extract the buttons.
|
|
return;
|
|
}
|
|
|
|
selector = selector || '.im-controls [type="submit"]';
|
|
|
|
const element = CoreDomUtils.convertToElement(question.html);
|
|
|
|
// Search the buttons.
|
|
const buttons = <HTMLInputElement[]> Array.from(element.querySelectorAll(selector));
|
|
buttons.forEach((button) => {
|
|
this.addBehaviourButton(question, button);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if the question has CBM and, if so, extract the certainty options and add them to a new
|
|
* "behaviourCertaintyOptions" property.
|
|
* The value of the selected option is stored in question.behaviourCertaintySelected.
|
|
* We don't remove them from HTML because the whole im-controls block will be removed afterwards.
|
|
*
|
|
* @param question Question to treat.
|
|
* @returns Wether the certainty is found.
|
|
*/
|
|
extractQbehaviourCBM(question: CoreQuestionQuestion): boolean {
|
|
const element = CoreDomUtils.convertToElement(question.html);
|
|
|
|
const labels = Array.from(element.querySelectorAll('.im-controls .certaintychoices label[for*="certainty"]'));
|
|
question.behaviourCertaintyOptions = [];
|
|
|
|
labels.forEach((label) => {
|
|
// Search the radio button inside this certainty and add its data to the options array.
|
|
const input = <HTMLInputElement> label.querySelector('input[type="radio"]');
|
|
if (input) {
|
|
question.behaviourCertaintyOptions?.push({
|
|
id: input.id,
|
|
name: input.name,
|
|
value: input.value,
|
|
text: CoreTextUtils.cleanTags(label.innerHTML),
|
|
disabled: input.disabled,
|
|
});
|
|
|
|
if (input.checked) {
|
|
question.behaviourCertaintySelected = input.value;
|
|
}
|
|
}
|
|
});
|
|
|
|
// If we have a certainty value stored in local we'll use that one.
|
|
if (question.localAnswers && question.localAnswers['-certainty'] !== undefined) {
|
|
question.behaviourCertaintySelected = question.localAnswers['-certainty'];
|
|
}
|
|
|
|
return labels.length > 0;
|
|
}
|
|
|
|
/**
|
|
* Check if the question has a redo button and, if so, add it to "behaviourButtons" property
|
|
* and remove it from the HTML.
|
|
*
|
|
* @param question Question to treat.
|
|
*/
|
|
extractQbehaviourRedoButton(question: CoreQuestionQuestion): void {
|
|
// Create a fake div element so we can search using querySelector.
|
|
const redoSelector = '[type="submit"][name*=redoslot], [type="submit"][name*=tryagain]';
|
|
|
|
// Search redo button in feedback.
|
|
if (!this.searchBehaviourButton(question, 'html', '.outcome ' + redoSelector)) {
|
|
// Not found in question HTML.
|
|
if (question.feedbackHtml) {
|
|
// We extracted the feedback already, search it in there.
|
|
if (this.searchBehaviourButton(question, 'feedbackHtml', redoSelector)) {
|
|
// Button found, stop.
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Button still not found. Now search in the info box if it exists.
|
|
if (question.infoHtml) {
|
|
this.searchBehaviourButton(question, 'infoHtml', redoSelector);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the question contains a "seen" input.
|
|
* If so, add the name and value to a "behaviourSeenInput" property and remove the input.
|
|
*
|
|
* @param question Question to treat.
|
|
* @returns Whether the seen input is found.
|
|
*/
|
|
extractQbehaviourSeenInput(question: CoreQuestionQuestion): boolean {
|
|
const element = CoreDomUtils.convertToElement(question.html);
|
|
|
|
// Search the "seen" input.
|
|
const seenInput = <HTMLInputElement> element.querySelector('input[type="hidden"][name*=seen]');
|
|
if (!seenInput) {
|
|
return false;
|
|
}
|
|
|
|
// Get the data and remove the input.
|
|
question.behaviourSeenInput = {
|
|
name: seenInput.name,
|
|
value: seenInput.value,
|
|
};
|
|
seenInput.parentElement?.removeChild(seenInput);
|
|
question.html = element.innerHTML;
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Removes the comment from the question HTML code and adds it in a new "commentHtml" property.
|
|
*
|
|
* @param question Question.
|
|
*/
|
|
extractQuestionComment(question: CoreQuestionQuestion): void {
|
|
this.extractQuestionLastElementNotInContent(question, '.comment', 'commentHtml');
|
|
}
|
|
|
|
/**
|
|
* Removes the feedback from the question HTML code and adds it in a new "feedbackHtml" property.
|
|
*
|
|
* @param question Question.
|
|
*/
|
|
extractQuestionFeedback(question: CoreQuestionQuestion): void {
|
|
this.extractQuestionLastElementNotInContent(question, '.outcome', 'feedbackHtml');
|
|
}
|
|
|
|
/**
|
|
* Extracts the info box from a question and add it to an "infoHtml" property.
|
|
*
|
|
* @param question Question.
|
|
* @param selector Selector to search the element.
|
|
*/
|
|
extractQuestionInfoBox(question: CoreQuestionQuestion, selector: string): void {
|
|
this.extractQuestionLastElementNotInContent(question, selector, 'infoHtml');
|
|
}
|
|
|
|
/**
|
|
* Searches the last occurrence of a certain element and check it's not in the question contents.
|
|
* If found, removes it from the question HTML and adds it to a new property inside question.
|
|
*
|
|
* @param question Question.
|
|
* @param selector Selector to search the element.
|
|
* @param attrName Name of the attribute to store the HTML in.
|
|
*/
|
|
protected extractQuestionLastElementNotInContent(question: CoreQuestionQuestion, selector: string, attrName: string): void {
|
|
const element = CoreDomUtils.convertToElement(question.html);
|
|
const matches = <HTMLElement[]> Array.from(element.querySelectorAll(selector));
|
|
|
|
// Get the last element and check it's not in the question contents.
|
|
let last = matches.pop();
|
|
while (last) {
|
|
if (!last.closest('.formulation')) {
|
|
// Not in question contents. Add it to a separate attribute and remove it from the HTML.
|
|
question[attrName] = last.innerHTML;
|
|
last.parentElement?.removeChild(last);
|
|
question.html = element.innerHTML;
|
|
|
|
return;
|
|
}
|
|
|
|
// It's inside the question content, treat next element.
|
|
last = matches.pop();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes the scripts from a question's HTML and adds it in a new 'scriptsCode' property.
|
|
* It will also search for init_question functions of the question type and add the object to an 'initObjects' property.
|
|
*
|
|
* @param question Question.
|
|
* @param usageId Usage ID.
|
|
*/
|
|
extractQuestionScripts(question: CoreQuestionQuestion, usageId?: number): void {
|
|
question.scriptsCode = '';
|
|
question.initObjects = undefined;
|
|
question.amdArgs = undefined;
|
|
|
|
// Search the scripts.
|
|
const matches = question.html?.match(/<script[^>]*>[\s\S]*?<\/script>/mg);
|
|
if (!matches) {
|
|
// No scripts, stop.
|
|
return;
|
|
}
|
|
|
|
matches.forEach((scriptCode) => {
|
|
if (scriptCode.match(/<script[^>]+type="math\/tex"/m)) {
|
|
// Don't remove math/tex scripts, they're needed to render the math expressions.
|
|
return;
|
|
}
|
|
|
|
// Add the script to scriptsCode and remove it from html.
|
|
question.scriptsCode += scriptCode;
|
|
question.html = question.html.replace(scriptCode, '');
|
|
|
|
// Search init_question functions for this type.
|
|
const initMatches = scriptCode.match(new RegExp('M.qtype_' + question.type + '.init_question\\(.*?}\\);', 'mg'));
|
|
if (initMatches) {
|
|
let initMatch = initMatches.pop();
|
|
|
|
if (initMatch) {
|
|
// Remove start and end of the match, we only want the object.
|
|
initMatch = initMatch.replace('M.qtype_' + question.type + '.init_question(', '');
|
|
initMatch = initMatch.substring(0, initMatch.length - 2);
|
|
|
|
// Try to convert it to an object and add it to the question.
|
|
question.initObjects = CoreTextUtils.parseJSON(initMatch, null);
|
|
}
|
|
}
|
|
|
|
const amdRegExp = new RegExp('require\\(\\[["\']qtype_' + question.type + '/question["\']\\],[^f]*' +
|
|
'function\\(amd\\)[^\\{]*\\{[^a]*amd\\.init\\((["\'](q|question-' + usageId + '-)' + question.slot +
|
|
'["\'].*?)\\);', 'm');
|
|
const amdMatch = scriptCode.match(amdRegExp);
|
|
|
|
if (amdMatch) {
|
|
// Try to convert the arguments to an array and add them to the question.
|
|
question.amdArgs = CoreTextUtils.parseJSON('[' + amdMatch[1] + ']', null);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the names of all the inputs inside an HTML code.
|
|
* This function will return an object where the keys are the input names. The values will always be true.
|
|
* This is in order to make this function compatible with other functions like CoreQuestionProvider.getBasicAnswers.
|
|
*
|
|
* @param html HTML code.
|
|
* @returns Object where the keys are the names.
|
|
*/
|
|
getAllInputNamesFromHtml(html: string): Record<string, boolean> {
|
|
const element = CoreDomUtils.convertToElement('<form>' + html + '</form>');
|
|
const form = <HTMLFormElement> element.children[0];
|
|
const answers: Record<string, boolean> = {};
|
|
|
|
// Search all input elements.
|
|
Array.from(form.elements).forEach((element: HTMLInputElement) => {
|
|
const name = element.name || '';
|
|
|
|
// Ignore flag and submit inputs.
|
|
if (!name || name.match(/_:flagged$/) || element.type == 'submit' || element.tagName == 'BUTTON') {
|
|
return;
|
|
}
|
|
|
|
answers[CoreQuestion.removeQuestionPrefix(name)] = true;
|
|
});
|
|
|
|
return answers;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the answers entered in a form.
|
|
* We don't use ngModel because it doesn't detect changes done by JavaScript and some questions might do that.
|
|
*
|
|
* @param form Form.
|
|
* @returns Object with the answers.
|
|
*/
|
|
getAnswersFromForm(form: HTMLFormElement): CoreQuestionsAnswers {
|
|
if (!form || !form.elements) {
|
|
return {};
|
|
}
|
|
|
|
const answers: CoreQuestionsAnswers = {};
|
|
const elements = Array.from(form.elements);
|
|
|
|
elements.forEach((element: HTMLInputElement) => {
|
|
const name = element.name || element.getAttribute('ng-reflect-name') || '';
|
|
|
|
// Ignore flag and submit inputs.
|
|
if (!name || name.match(/_:flagged$/) || element.type == 'submit' || element.tagName == 'BUTTON') {
|
|
return;
|
|
}
|
|
|
|
// Get the value.
|
|
if (element.type == 'checkbox') {
|
|
answers[name] = !!element.checked;
|
|
} else if (element.type == 'radio') {
|
|
if (element.checked) {
|
|
answers[name] = element.value;
|
|
}
|
|
} else {
|
|
answers[name] = element.value;
|
|
}
|
|
});
|
|
|
|
return answers;
|
|
}
|
|
|
|
/**
|
|
* Given an HTML code with list of attachments, returns the list of attached files (filename and fileurl).
|
|
* Please take into account that this function will treat all the anchors in the HTML, you should provide
|
|
* an HTML containing only the attachments anchors.
|
|
*
|
|
* @param html HTML code to search in.
|
|
* @returns Attachments.
|
|
*/
|
|
getQuestionAttachmentsFromHtml(html: string): CoreWSFile[] {
|
|
const element = CoreDomUtils.convertToElement(html);
|
|
|
|
// Remove the filemanager (area to attach files to a question).
|
|
CoreDomUtils.removeElement(element, 'div[id*=filemanager]');
|
|
|
|
// Search the anchors.
|
|
const anchors = Array.from(element.querySelectorAll('a'));
|
|
const attachments: CoreWSFile[] = [];
|
|
|
|
anchors.forEach((anchor) => {
|
|
let content = anchor.innerHTML;
|
|
|
|
// Check anchor is valid.
|
|
if (anchor.href && content) {
|
|
content = CoreTextUtils.cleanTags(content, { singleLine: true, trim: true });
|
|
attachments.push({
|
|
filename: content,
|
|
fileurl: anchor.href,
|
|
});
|
|
}
|
|
});
|
|
|
|
return attachments;
|
|
}
|
|
|
|
/**
|
|
* Get the sequence check from a question HTML.
|
|
*
|
|
* @param html Question's HTML.
|
|
* @returns Object with the sequencecheck name and value.
|
|
*/
|
|
getQuestionSequenceCheckFromHtml(html: string): { name: string; value: string } | undefined {
|
|
if (!html) {
|
|
return;
|
|
}
|
|
|
|
// Search the input holding the sequencecheck.
|
|
const element = CoreDomUtils.convertToElement(html);
|
|
const input = <HTMLInputElement> element.querySelector('input[name*=sequencecheck]');
|
|
|
|
if (!input || input.name === undefined || input.value === undefined) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
name: input.name,
|
|
value: input.value,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the CSS class for a question based on its state.
|
|
*
|
|
* @param name Question's state name.
|
|
* @returns State class.
|
|
*/
|
|
getQuestionStateClass(name: string): string {
|
|
const state = CoreQuestion.getState(name);
|
|
|
|
return state ? state.class : '';
|
|
}
|
|
|
|
/**
|
|
* Return the files of a certain response file area.
|
|
*
|
|
* @param question Question.
|
|
* @param areaName Name of the area, e.g. 'attachments'.
|
|
* @returns List of files.
|
|
*/
|
|
getResponseFileAreaFiles(question: CoreQuestionQuestion, areaName: string): CoreWSFile[] {
|
|
if (!question.responsefileareas) {
|
|
return [];
|
|
}
|
|
|
|
const area = question.responsefileareas.find((area) => area.area == areaName);
|
|
|
|
return area?.files || [];
|
|
}
|
|
|
|
/**
|
|
* Get files stored for a question.
|
|
*
|
|
* @param question Question.
|
|
* @param component The component the question is related to.
|
|
* @param componentId Component ID.
|
|
* @param siteId Site ID. If not defined, current site.
|
|
* @returns Promise resolved with the files.
|
|
*/
|
|
getStoredQuestionFiles(
|
|
question: CoreQuestionQuestion,
|
|
component: string,
|
|
componentId: string | number,
|
|
siteId?: string,
|
|
): Promise<(FileEntry | DirectoryEntry)[]> {
|
|
const questionComponentId = CoreQuestion.getQuestionComponentId(question, componentId);
|
|
const folderPath = CoreQuestion.getQuestionFolder(question.type, component, questionComponentId, siteId);
|
|
|
|
return CoreFile.getDirectoryContents(folderPath);
|
|
}
|
|
|
|
/**
|
|
* Get the validation error message from a question HTML if it's there.
|
|
*
|
|
* @param html Question's HTML.
|
|
* @returns Validation error message if present.
|
|
*/
|
|
getValidationErrorFromHtml(html: string): string | undefined {
|
|
const element = CoreDomUtils.convertToElement(html);
|
|
|
|
return CoreDomUtils.getContentsOfElement(element, '.validationerror');
|
|
}
|
|
|
|
/**
|
|
* Check if some HTML contains draft file URLs for the current site.
|
|
*
|
|
* @param html Question's HTML.
|
|
* @returns Whether it contains draft files URLs.
|
|
*/
|
|
hasDraftFileUrls(html: string): boolean {
|
|
let url = CoreSites.getCurrentSite()?.getURL();
|
|
if (!url) {
|
|
return false;
|
|
}
|
|
|
|
if (url.slice(-1) != '/') {
|
|
url = url += '/';
|
|
}
|
|
url += 'draftfile.php';
|
|
|
|
return html.indexOf(url) != -1;
|
|
}
|
|
|
|
/**
|
|
* Load local answers of a question.
|
|
*
|
|
* @param question Question.
|
|
* @param component Component.
|
|
* @param attemptId Attempt ID.
|
|
* @returns Promise resolved when done.
|
|
*/
|
|
async loadLocalAnswers(question: CoreQuestionQuestion, component: string, attemptId: number): Promise<void> {
|
|
const answers = await CoreUtils.ignoreErrors(
|
|
CoreQuestion.getQuestionAnswers(component, attemptId, question.slot),
|
|
);
|
|
|
|
if (answers) {
|
|
question.localAnswers = CoreQuestion.convertAnswersArrayToObject(answers, true);
|
|
} else {
|
|
question.localAnswers = {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* For each input element found in the HTML, search if there's a local answer stored and
|
|
* override the HTML's value with the local one.
|
|
*
|
|
* @param question Question.
|
|
*/
|
|
loadLocalAnswersInHtml(question: CoreQuestionQuestion): void {
|
|
const element = CoreDomUtils.convertToElement('<form>' + question.html + '</form>');
|
|
const form = <HTMLFormElement> element.children[0];
|
|
|
|
// Search all input elements.
|
|
Array.from(form.elements).forEach((element: HTMLInputElement | HTMLButtonElement) => {
|
|
let name = element.name || '';
|
|
// Ignore flag and submit inputs.
|
|
if (!name || name.match(/_:flagged$/) || element.type == 'submit' || element.tagName == 'BUTTON' ||
|
|
!question.localAnswers) {
|
|
return;
|
|
}
|
|
|
|
// Search if there's a local answer.
|
|
name = CoreQuestion.removeQuestionPrefix(name);
|
|
if (question.localAnswers[name] === undefined) {
|
|
if (Object.keys(question.localAnswers).length && element.type == 'radio') {
|
|
// No answer stored, but there is a sequencecheck or similar. This means the user cleared his choice.
|
|
element.removeAttribute('checked');
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (element.tagName == 'TEXTAREA') {
|
|
// Just put the answer inside the textarea.
|
|
element.innerHTML = question.localAnswers[name];
|
|
} else if (element.tagName == 'SELECT') {
|
|
// Search the selected option and select it.
|
|
const selected = element.querySelector('option[value="' + question.localAnswers[name] + '"]');
|
|
if (selected) {
|
|
selected.setAttribute('selected', 'selected');
|
|
}
|
|
} else if (element.type == 'radio') {
|
|
// Check if this radio is selected.
|
|
if (element.value == question.localAnswers[name]) {
|
|
element.setAttribute('checked', 'checked');
|
|
} else {
|
|
element.removeAttribute('checked');
|
|
}
|
|
} else if (element.type == 'checkbox') {
|
|
// Check if this checkbox is checked.
|
|
if (CoreUtils.isTrueOrOne(question.localAnswers[name])) {
|
|
element.setAttribute('checked', 'checked');
|
|
} else {
|
|
element.removeAttribute('checked');
|
|
}
|
|
} else {
|
|
// Put the answer in the value.
|
|
element.setAttribute('value', question.localAnswers[name]);
|
|
}
|
|
});
|
|
|
|
// Update the question HTML.
|
|
question.html = form.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Prefetch the files in a question HTML.
|
|
*
|
|
* @param question Question.
|
|
* @param component The component to link the files to. If not defined, question component.
|
|
* @param componentId An ID to use in conjunction with the component. If not defined, question ID.
|
|
* @param siteId Site ID. If not defined, current site.
|
|
* @param usageId Usage ID. Required in Moodle 3.7+.
|
|
* @returns Promise resolved when all the files have been downloaded.
|
|
*/
|
|
async prefetchQuestionFiles(
|
|
question: CoreQuestionQuestion,
|
|
component?: string,
|
|
componentId?: string | number,
|
|
siteId?: string,
|
|
usageId?: number,
|
|
): Promise<void> {
|
|
if (!component) {
|
|
component = CoreQuestionProvider.COMPONENT;
|
|
componentId = question.questionnumber;
|
|
}
|
|
|
|
const files = CoreQuestionDelegate.getAdditionalDownloadableFiles(question, usageId) || [];
|
|
|
|
files.push(...CoreFilepool.extractDownloadableFilesFromHtmlAsFakeFileObjects(question.html));
|
|
|
|
const site = await CoreSites.getSite(siteId);
|
|
|
|
const treated: Record<string, boolean> = {};
|
|
|
|
await Promise.all(files.map(async (file) => {
|
|
const timemodified = file.timemodified || 0;
|
|
const fileUrl = CoreFileHelper.getFileUrl(file);
|
|
|
|
if (treated[fileUrl]) {
|
|
return;
|
|
}
|
|
treated[fileUrl] = true;
|
|
|
|
if (!site.canDownloadFiles() && site.isSitePluginFileUrl(fileUrl)) {
|
|
return;
|
|
}
|
|
|
|
if (CoreUrlUtils.isThemeImageUrl(fileUrl) && fileUrl.indexOf('flagged') > -1) {
|
|
// Ignore flag images.
|
|
return;
|
|
}
|
|
|
|
await CoreFilepool.addToQueueByUrl(site.getId(), fileUrl, component, componentId, timemodified);
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Prepare and return the answers.
|
|
*
|
|
* @param questions The list of questions.
|
|
* @param answers The input data.
|
|
* @param offline True if data should be saved in offline.
|
|
* @param component The component the question is related to.
|
|
* @param componentId Component ID.
|
|
* @param siteId Site ID. If not defined, current site.
|
|
* @returns Promise resolved with answers to send to server.
|
|
*/
|
|
async prepareAnswers(
|
|
questions: CoreQuestionQuestion[],
|
|
answers: CoreQuestionsAnswers,
|
|
offline: boolean,
|
|
component: string,
|
|
componentId: string | number,
|
|
siteId?: string,
|
|
): Promise<CoreQuestionsAnswers> {
|
|
await CoreUtils.allPromises(questions.map(async (question) => {
|
|
await CoreQuestionDelegate.prepareAnswersForQuestion(
|
|
question,
|
|
answers,
|
|
offline,
|
|
component,
|
|
componentId,
|
|
siteId,
|
|
);
|
|
}));
|
|
|
|
return answers;
|
|
}
|
|
|
|
/**
|
|
* Replace Moodle's correct/incorrect classes with the Mobile ones.
|
|
*
|
|
* @param element DOM element.
|
|
*/
|
|
replaceCorrectnessClasses(element: HTMLElement): void {
|
|
CoreDomUtils.replaceClassesInElement(element, {
|
|
correct: 'core-question-answer-correct',
|
|
incorrect: 'core-question-answer-incorrect',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Replace Moodle's feedback classes with the Mobile ones.
|
|
*
|
|
* @param element DOM element.
|
|
*/
|
|
replaceFeedbackClasses(element: HTMLElement): void {
|
|
CoreDomUtils.replaceClassesInElement(element, {
|
|
outcome: 'core-question-feedback-container core-question-feedback-padding',
|
|
specificfeedback: 'core-question-feedback-container core-question-feedback-inline',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Search a behaviour button in a certain question property containing HTML.
|
|
*
|
|
* @param question Question.
|
|
* @param htmlProperty The name of the property containing the HTML to search.
|
|
* @param selector The selector to find the button.
|
|
* @returns Whether the button is found.
|
|
*/
|
|
protected searchBehaviourButton(question: CoreQuestionQuestion, htmlProperty: string, selector: string): boolean {
|
|
const element = CoreDomUtils.convertToElement(question[htmlProperty]);
|
|
|
|
const button = element.querySelector<HTMLElement>(selector);
|
|
if (!button) {
|
|
return false;
|
|
}
|
|
|
|
// Add a behaviour button to the question's "behaviourButtons" property.
|
|
this.addBehaviourButton(question, button);
|
|
|
|
// Remove the button from the HTML.
|
|
button.parentElement?.removeChild(button);
|
|
|
|
// Update the question's html.
|
|
question[htmlProperty] = element.innerHTML;
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Convenience function to show a parsing error and abort.
|
|
*
|
|
* @param onAbort If supplied, will emit an event.
|
|
* @param error Error to show.
|
|
*/
|
|
showComponentError(onAbort: EventEmitter<void>, error?: string): void {
|
|
// Prevent consecutive errors.
|
|
const now = Date.now();
|
|
if (now - this.lastErrorShown > 500) {
|
|
this.lastErrorShown = now;
|
|
CoreDomUtils.showErrorModalDefault(error || '', 'addon.mod_quiz.errorparsequestions', true);
|
|
}
|
|
|
|
onAbort?.emit();
|
|
}
|
|
|
|
/**
|
|
* Treat correctness icons, replacing them with local icons and setting click events to show the feedback if needed.
|
|
*
|
|
* @param element DOM element.
|
|
*/
|
|
treatCorrectnessIcons(element: HTMLElement): void {
|
|
const icons = <HTMLElement[]> Array.from(element.querySelectorAll('img.icon, img.questioncorrectnessicon, i.icon'));
|
|
icons.forEach((icon) => {
|
|
let correct = false;
|
|
|
|
if ('src' in icon) {
|
|
if ((icon as HTMLImageElement).src.indexOf('correct') >= 0) {
|
|
correct = true;
|
|
} else if ((icon as HTMLImageElement).src.indexOf('incorrect') < 0 ) {
|
|
return;
|
|
}
|
|
} else {
|
|
const classList = icon.classList.toString();
|
|
if (classList.indexOf('fa-check') >= 0) {
|
|
correct = true;
|
|
} else if (classList.indexOf('fa-xmark') < 0 && classList.indexOf('fa-remove') < 0) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Replace the icon with the font version.
|
|
const newIcon: HTMLIonIconElement = document.createElement('ion-icon');
|
|
|
|
if (correct) {
|
|
const iconName = 'check';
|
|
newIcon.setAttribute('name', `fas-${iconName}`);
|
|
newIcon.setAttribute('src', CoreIcons.getIconSrc('font-awesome', 'solid', iconName));
|
|
newIcon.className = 'core-correct-icon ion-color ion-color-success questioncorrectnessicon';
|
|
} else {
|
|
const iconName = 'xmark';
|
|
newIcon.setAttribute('name', `fas-${iconName}`);
|
|
newIcon.setAttribute('src', CoreIcons.getIconSrc('font-awesome', 'solid', iconName));
|
|
newIcon.className = 'core-correct-icon ion-color ion-color-danger questioncorrectnessicon';
|
|
}
|
|
|
|
newIcon.title = icon.title;
|
|
newIcon.setAttribute('aria-label', icon.title);
|
|
icon.parentNode?.replaceChild(newIcon, icon);
|
|
});
|
|
|
|
// Treat legacy markup used before MDL-77856 (4.2).
|
|
const spans = Array.from(element.querySelectorAll('.feedbackspan.accesshide'));
|
|
spans.forEach((span) => {
|
|
// Search if there's a hidden feedback for this element.
|
|
const icon = <HTMLElement> span.previousSibling;
|
|
if (!icon || !icon.classList.contains('icon') && !icon.classList.contains('questioncorrectnessicon')) {
|
|
return;
|
|
}
|
|
|
|
icon.classList.add('questioncorrectnessicon');
|
|
|
|
if (span.innerHTML) {
|
|
// There's a hidden feedback. Mark the icon as tappable.
|
|
// The click listener is only added if treatCorrectnessIconsClicks is called.
|
|
icon.setAttribute('tappable', '');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add click listeners to all tappable correctness icons.
|
|
*
|
|
* @param element DOM element.
|
|
* @param component The component to use when viewing the feedback.
|
|
* @param componentId An ID to use in conjunction with the component.
|
|
* @param contextLevel The context level.
|
|
* @param contextInstanceId Instance ID related to the context.
|
|
* @param courseId Course ID the text belongs to. It can be used to improve performance with filters.
|
|
*/
|
|
treatCorrectnessIconsClicks(
|
|
element: HTMLElement,
|
|
component?: string,
|
|
componentId?: number,
|
|
contextLevel?: ContextLevel,
|
|
contextInstanceId?: number,
|
|
courseId?: number,
|
|
): void {
|
|
const icons = <HTMLElement[]> Array.from(element.querySelectorAll('ion-icon.questioncorrectnessicon'));
|
|
const title = Translate.instant('core.question.feedback');
|
|
const getClickableFeedback = (icon: HTMLElement) => {
|
|
const parentElement = icon.parentElement;
|
|
const parentIsClickable = parentElement instanceof HTMLButtonElement || parentElement instanceof HTMLAnchorElement;
|
|
|
|
if (parentElement && parentIsClickable && parentElement.dataset.toggle === 'popover') {
|
|
return {
|
|
element: parentElement,
|
|
html: parentElement?.dataset.content,
|
|
};
|
|
}
|
|
|
|
// Support legacy icons used before MDL-77856 (4.2).
|
|
if (icon.hasAttribute('tappable')) {
|
|
return {
|
|
element: icon,
|
|
html: parentElement?.querySelector('.feedbackspan.accesshide')?.innerHTML,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
icons.forEach(icon => {
|
|
const target = getClickableFeedback(icon);
|
|
|
|
if (!target || !target.html) {
|
|
return;
|
|
}
|
|
|
|
// There's a hidden feedback, show it when the icon is clicked.
|
|
target.element.dataset.disabledA11yClicks = 'true';
|
|
target.element.addEventListener('click', event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
CoreTextUtils.viewText(title, target.html ?? '', {
|
|
component: component,
|
|
componentId: componentId,
|
|
filter: true,
|
|
contextLevel: contextLevel,
|
|
instanceId: contextInstanceId,
|
|
courseId: courseId,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
export const CoreQuestionHelper = makeSingleton(CoreQuestionHelperProvider);
|
|
|
|
/**
|
|
* Question with calculated data.
|
|
*/
|
|
export type CoreQuestionQuestion = CoreQuestionQuestionParsed & {
|
|
localAnswers?: Record<string, string>;
|
|
commentHtml?: string;
|
|
feedbackHtml?: string;
|
|
infoHtml?: string;
|
|
behaviourButtons?: CoreQuestionBehaviourButton[];
|
|
behaviourCertaintyOptions?: CoreQuestionBehaviourCertaintyOption[];
|
|
behaviourCertaintySelected?: string;
|
|
behaviourSeenInput?: { name: string; value: string };
|
|
scriptsCode?: string;
|
|
initObjects?: Record<string, unknown> | null;
|
|
amdArgs?: unknown[] | null;
|
|
};
|
|
|
|
/**
|
|
* Question behaviour button.
|
|
*/
|
|
export type CoreQuestionBehaviourButton = {
|
|
id: string;
|
|
name: string;
|
|
value: string;
|
|
disabled: boolean;
|
|
};
|
|
|
|
/**
|
|
* Question behaviour certainty option.
|
|
*/
|
|
export type CoreQuestionBehaviourCertaintyOption = CoreQuestionBehaviourButton & {
|
|
text: string;
|
|
};
|