Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion amd/build/view_question.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion amd/build/view_question.min.js.map

Large diffs are not rendered by default.

89 changes: 76 additions & 13 deletions amd/src/view_question.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ function validateInput(element) {
* @param {boolean} showSpecificFeedback
* @param {boolean} showRightAnswer
* @param {boolean} showCorrectness
* @param {string} responseId
* @param {string} responseId Id of the hidden input containing the JSON-encoded main response.
* @param {string} editorsId Id of the hidden input containing the JSON-encoded WYSIWYG editors data.
* @param {string[]} editorNames Names of the WYSIWYG editors.
* @param {string[]} roles QPy role names that the user has.
* @param {Object.<string, any>} data Dynamic data.
* @param {Number} environmentVersion
Expand All @@ -75,6 +77,8 @@ export async function init(
showRightAnswer,
showCorrectness,
responseId,
editorsId,
editorNames,
roles,
data,
environmentVersion,
Expand Down Expand Up @@ -102,10 +106,17 @@ export async function init(

// Modify a field in the main form in order to tell the Quiz's autosaver that the user changed an answer.
const responseElement = parent.document.getElementById(responseId);
if (responseElement) {
const editorsElement = parent.document.getElementById(editorsId);
if (responseElement || editorsElement) {
// We throttle here, as `JSON.stringify` might affect the performance.
form.addEventListener("change", throttle(() => {
responseElement.value = createJsonFromFormData(form);
const [responseData, editorsData] = collectFormData(form, editorNames);
if (responseElement) {
responseElement.value = responseData;
}
if (editorsElement) {
editorsElement.value = editorsData;
}
}, 250));
}

Expand Down Expand Up @@ -403,29 +414,77 @@ class Attempt {
}
}

/**
* Builds a regular expression that matches draftfile.php URLs.
*
* @returns {RegExp}
*/
function buildDraftFileUrlRegex() {
const wwwrootWithoutScheme = M.cfg.wwwroot.replace(/^https?:\/\//, "");

return new RegExp(
// Phpcs:disable -- phpcs is massively confused by this.
String.raw`https?://${wwwrootWithoutScheme}/draftfile\.php/(?<contextid>\d+)`
+ String.raw`/user/draft/(?<itemid>\d+)/(?<filename>[^\'\",&<>|\`\s:\\\\]+)`,
// Phpcs:enable
"g",
);
}

/**
* Creates JSON from the FormData of the given form.
*
* @param {HTMLFormElement} form
* @returns {string}
* @param {string[]} editorNames
* @returns {[string, string]}
*/
function createJsonFromFormData(form) {
function collectFormData(form, editorNames) {
const iframeFormData = new FormData(form);
const iframeObject = Object.fromEntries(iframeFormData);

const editorData = {};
for (const name of editorNames) {
const textKey = `${name}[text]`;
const formatKey = `${name}[format]`;
const itemidKey = `${name}[itemid]`;

const text = iframeFormData.get(textKey);
if (text === null) {
continue;
}

// TODO: Handle content pasted from other editors, where the draft item id would be different. We'd probably
// need to pass the encountered foreign files somewhere and copy them to our area in qbehaviour_questionpy.
const replacedText = text.replaceAll(buildDraftFileUrlRegex(), "@@PLUGINFILE@@/$<filename>");

editorData[name] = {text: replacedText};
iframeFormData.delete(textKey);

const format = iframeFormData.get(formatKey);
if (format !== null) {
editorData[name].format = format;
iframeFormData.delete(formatKey);
}

// The itemid is added by qpy_rich_text_editor to the list that gets sent from outside the iframe.
// No need to duplicate it here.
iframeFormData.delete(itemidKey);
}

const responseObject = Object.fromEntries(iframeFormData);
for (const name of iframeFormData.keys()) {
const values = iframeFormData.getAll(name);
if (values.length > 1) {
iframeObject[name] = values;
responseObject[name] = values;
}
}

if (iframeObject.data) {
iframeObject.data = JSON.parse(iframeObject.data);
if (responseObject.data) {
responseObject.data = JSON.parse(responseObject.data);
} else {
window.console.warn("The form data field 'data' is missing in the question iframe form.");
}

return JSON.stringify(iframeObject);
return [JSON.stringify(responseObject), JSON.stringify(editorData)];
}

/**
Expand All @@ -435,8 +494,10 @@ function createJsonFromFormData(form) {
*
* @param {string} iframeId - The ID of the question's iframe.
* @param {string} responseFieldName - The complete field name for the JSON-encoded iframe form data.
* @param {string} editorsFieldName - The complete field name for the JSON-encoded WYSIWYG editors data.
* @param {string[]} editorNames - The input names that are WYSIWYG editors.
*/
export function addIframeFormDataOnSubmit(iframeId, responseFieldName) {
export function addIframeFormDataOnSubmit(iframeId, responseFieldName, editorsFieldName, editorNames) {
const iframe = window.document.getElementById(iframeId);
if (iframe === null) {
window.console.error(`Could not find question iframe ${iframeId}. Cannot save answers.`);
Expand All @@ -450,9 +511,11 @@ export function addIframeFormDataOnSubmit(iframeId, responseFieldName) {
window.console.error("Could not find form in question iframe " + iframeId);
return;
}

// Since we are throttling the updating process of the response element on a change, it might happen that the
// value is outdated - this is why we get the data again.
const jsonFormData = createJsonFromFormData(iframeForm);
event.formData.set(responseFieldName, jsonFormData);
const [responseData, editorsData] = collectFormData(iframeForm, editorNames);
event.formData.set(responseFieldName, responseData);
event.formData.set(editorsFieldName, editorsData);
});
}
3 changes: 2 additions & 1 deletion classes/constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ class constants {
public const FILEAREA_RESPONSE_FILES = 'response_files';
/** @var string */
public const QT_VAR_RESPONSE_FILES = 'files';

/** @var string */
public const QT_VAR_EDITORS = 'editors';

/** @var string */
public const FILEAREA_OPTIONS = 'options';
Expand Down
12 changes: 8 additions & 4 deletions classes/local/api/package_api.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,18 +134,20 @@ public function start_attempt(string $questionstate, int $variant, ?array $attri
* @param string $attemptstate the attempt state previously returned from {@see start_attempt()}
* @param string|null $scoringstate the last scoring state if this attempt has already been scored
* @param object|null $response data currently entered by the student
* @param array|null $editors
* @return attempt the attempt's metadata. The state is not returned since it never changes.
* @throws GuzzleException
* @throws request_error
* @throws moodle_exception
* @throws request_error
*/
public function view_attempt(string $questionstate, ?array $attributes, string $attemptstate, ?string $scoringstate = null,
?object $response = null): attempt {
?object $response = null, ?array $editors = null): attempt {
$options['multipart'] = $this->transform_to_multipart(
[
'attempt_state' => $attemptstate,
'scoring_state' => $scoringstate,
'response' => $response,
'editors' => $editors === null ? null : (object) $editors,
'context' => $this->get_context_id(),
'lms_provided_attributes' => $attributes,
],
Expand All @@ -163,18 +165,20 @@ public function view_attempt(string $questionstate, ?array $attributes, string $
* @param string $attemptstate the attempt state previously returned from {@see start_attempt()}
* @param string|null $scoringstate the last scoring state if this attempt had been scored before
* @param object $response data submitted by the student
* @param wysiwyg_editor_data[] $editors
* @return attempt_scored the attempt's metadata. The state is not returned since it never changes.
* @throws GuzzleException
* @throws request_error
* @throws moodle_exception
* @throws request_error
*/
public function score_attempt(string $questionstate, ?array $attributes, string $attemptstate, ?string $scoringstate,
object $response): attempt_scored {
object $response, array $editors): attempt_scored {
$options['multipart'] = $this->transform_to_multipart(
[
'attempt_state' => $attemptstate,
'scoring_state' => $scoringstate,
'response' => $response,
'editors' => (object) $editors,
'generate_hint' => false,
'context' => $this->get_context_id(),
'lms_provided_attributes' => $attributes,
Expand Down
18 changes: 17 additions & 1 deletion classes/local/api/wysiwyg_editor_data.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@

namespace qtype_questionpy\local\api;

use coding_exception;
use JsonSerializable;
use qtype_questionpy\local\array_converter\array_converter;
use qtype_questionpy\local\array_converter\attributes\array_element_class;
use qtype_questionpy\local\array_converter\attributes\array_key;
use qtype_questionpy\local\array_converter\conversion_exception;
use qtype_questionpy\local\files\file_metadata;

/**
Expand All @@ -28,7 +32,7 @@
* @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class wysiwyg_editor_data {
class wysiwyg_editor_data implements JsonSerializable {
/**
* Initialize a new WYSIWYG editor data instance.
*
Expand All @@ -47,4 +51,16 @@ public function __construct(
public array $files = [],
) {
}

/**
* Specify data which should be serialized to JSON
* @link https://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @throws coding_exception
* @throws conversion_exception
*/
public function jsonSerialize(): mixed {
return array_converter::to_array($this);
}
}
67 changes: 67 additions & 0 deletions classes/local/attempt_ui/custom_xhtml_element.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php
// This file is part of the QuestionPy Moodle plugin - https://questionpy.org
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.

namespace qtype_questionpy\local\attempt_ui;


use core\exception\coding_exception;
use DOMElement;
use DOMNode;
use DOMXPath;
use file_exception;
use Iterator;
use moodle_exception;
use question_attempt;
use stored_file_creation_exception;

/**
* Represents a `<qpy:X/>` element in the question UI XML.
*
* @package qtype_questionpy
* @author Maximilian Haye
* @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
interface custom_xhtml_element {
/**
* Finds all matching elements within the provided DOMXPath.
*
* @param DOMXPath $xpath
* @return Iterator<static>
*/
public static function find_all_in(DOMXPath $xpath): Iterator;

/**
* Parses the given DOMElement if possible.
*
* @param DOMElement $element
* @return static|null
*/
public static function from_element(DOMElement $element): ?static;

/**
* Renders this element to a DOMNode.
*
* @param question_attempt $qa
* @param question_ui_renderer $renderer
* @return DOMNode
* @throws coding_exception
* @throws file_exception
* @throws moodle_exception
* @throws stored_file_creation_exception
*/
public function render(question_attempt $qa, question_ui_renderer $renderer): DOMNode;
}
34 changes: 25 additions & 9 deletions classes/local/attempt_ui/qpy_file_upload.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;
use file_exception;
use form_filemanager;
use Iterator;
use moodle_exception;
use qtype_questionpy\local\files\response_file_service;
use qtype_questionpy\local\files\validatable_upload_limits;
Expand All @@ -39,15 +41,15 @@
* @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class qpy_file_upload {
class qpy_file_upload implements custom_xhtml_element {
/**
* Trivial private constructor. Use {@see from_element()}.
* @param DOMElement $element
* @param string $name
*/
private function __construct(
/** @var DOMElement */
private readonly DOMElement $element,
public readonly DOMElement $element,
/** @var string */
public readonly string $name,
) {
Expand Down Expand Up @@ -81,12 +83,12 @@ public function get_limits_in(context $context): validatable_upload_limits {
}

/**
* Creates a new {@see qpy_file_upload} from a given {@see DOMElement}.
* Parses the given DOMElement if possible.
*
* @param DOMElement $element
* @return self|null
* @return static|null
*/
public static function from_element(DOMElement $element): ?self {
public static function from_element(DOMElement $element): ?static {
$name = $element->getAttribute('name');
if (!$name) {
debugging('qpy:file-upload without a name');
Expand All @@ -102,10 +104,6 @@ public static function from_element(DOMElement $element): ?self {
* @param question_attempt $qa
* @param question_ui_renderer $renderer
* @return DOMNode
* @throws coding_exception
* @throws file_exception
* @throws moodle_exception
* @throws stored_file_creation_exception
*/
public function render(question_attempt $qa, question_ui_renderer $renderer): DOMNode {
if ($renderer->options->readonly) {
Expand Down Expand Up @@ -160,4 +158,22 @@ private function render_writable(question_attempt $qa, question_ui_renderer $ren
$html = $filesrenderer->render($fm);
return dom_utils::html_to_fragment($this->element->ownerDocument, $html);
}

/**
* Finds all matching elements within the provided DOMXPath.
*
* @param DOMXPath $xpath
* @return Iterator<static>
*/
public static function find_all_in(DOMXPath $xpath): Iterator {
/** @var DOMElement $element */
foreach ($xpath->query('//qpy:file-upload') as $element) {
$upload = self::from_element($element);
if (!$upload) {
continue;
}

yield $upload;
}
}
}
Loading