Skip to content

Commit 571967c

Browse files
committed
feat: render qpy:rich-text-editor and handle their uploads
Closes: #218
1 parent 17fdb5a commit 571967c

19 files changed

+595
-103
lines changed

amd/build/view_question.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/build/view_question.min.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/src/view_question.js

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ function validateInput(element) {
6363
* @param {boolean} showSpecificFeedback
6464
* @param {boolean} showRightAnswer
6565
* @param {boolean} showCorrectness
66-
* @param {string} responseId
66+
* @param {string} responseId Id of the hidden input containing the JSON-encoded main response.
67+
* @param {string} editorsId Id of the hidden input containing the JSON-encoded WYSIWYG editors data.
68+
* @param {string[]} editorNames Names of the WYSIWYG editors.
6769
* @param {string[]} roles QPy role names that the user has.
6870
* @param {Object.<string, any>} data Dynamic data.
6971
* @param {Number} environmentVersion
@@ -75,6 +77,8 @@ export async function init(
7577
showRightAnswer,
7678
showCorrectness,
7779
responseId,
80+
editorsId,
81+
editorNames,
7882
roles,
7983
data,
8084
environmentVersion,
@@ -102,10 +106,17 @@ export async function init(
102106

103107
// Modify a field in the main form in order to tell the Quiz's autosaver that the user changed an answer.
104108
const responseElement = parent.document.getElementById(responseId);
105-
if (responseElement) {
109+
const editorsElement = parent.document.getElementById(editorsId);
110+
if (responseElement || editorsElement) {
106111
// We throttle here, as `JSON.stringify` might affect the performance.
107112
form.addEventListener("change", throttle(() => {
108-
responseElement.value = createJsonFromFormData(form);
113+
const [responseData, editorsData] = collectFormData(form, editorNames);
114+
if (responseElement) {
115+
responseElement.value = responseData;
116+
}
117+
if (editorsElement) {
118+
editorsElement.value = editorsData;
119+
}
109120
}, 250));
110121
}
111122

@@ -403,29 +414,73 @@ class Attempt {
403414
}
404415
}
405416

417+
function buildDraftFileUrlRegex() {
418+
const wwwrootWithoutScheme = M.cfg.wwwroot.replace(/^https?:\/\//, "");
419+
420+
return new RegExp(
421+
// Phpcs:disable -- phpcs is massively confused by this.
422+
String.raw`https?://${wwwrootWithoutScheme}/draftfile\.php/(?<contextid>\d+)`
423+
+ String.raw`/user/draft/(?<itemid>\d+)/(?<filename>[^\'\",&<>|\`\s:\\\\]+)`
424+
// Phpcs:enable
425+
)
426+
}
427+
406428
/**
407429
* Creates JSON from the FormData of the given form.
408430
*
409431
* @param {HTMLFormElement} form
410-
* @returns {string}
432+
* @param {string[]} editorNames
433+
* @returns {[string, string]}
411434
*/
412-
function createJsonFromFormData(form) {
435+
function collectFormData(form, editorNames) {
413436
const iframeFormData = new FormData(form);
414-
const iframeObject = Object.fromEntries(iframeFormData);
437+
438+
const editorData = {};
439+
for (const name of editorNames) {
440+
// TODO: Turn draftfile.php-URLs into @@PLUGINFILE@@-URLs.
441+
442+
const textKey = `${name}[text]`;
443+
const formatKey = `${name}[format]`;
444+
const itemidKey = `${name}[itemid]`;
445+
446+
const text = iframeFormData.get(textKey);
447+
if (text === null) {
448+
continue;
449+
}
450+
451+
// TODO: Handle content pasted from other editors, where the draft item id would be different. We'd probably
452+
// need to pass the encountered foreign files somewhere and copy them to our area in qbehaviour_questionpy.
453+
const replacedText = text.replaceAll(buildDraftFileUrlRegex(), "@@PLUGINFILE@@/$<filename>");
454+
455+
editorData[name] = {text: replacedText};
456+
iframeFormData.delete(textKey);
457+
458+
const format = iframeFormData.get(formatKey);
459+
if (format !== null) {
460+
editorData[name].format = format;
461+
iframeFormData.delete(formatKey);
462+
}
463+
464+
// The itemid is added by qpy_rich_text_editor to the list that gets sent from outside the iframe.
465+
// No need to duplicate it here.
466+
iframeFormData.delete(itemidKey);
467+
}
468+
469+
const responseObject = Object.fromEntries(iframeFormData);
415470
for (const name of iframeFormData.keys()) {
416471
const values = iframeFormData.getAll(name);
417472
if (values.length > 1) {
418-
iframeObject[name] = values;
473+
responseObject[name] = values;
419474
}
420475
}
421476

422-
if (iframeObject.data) {
423-
iframeObject.data = JSON.parse(iframeObject.data);
477+
if (responseObject.data) {
478+
responseObject.data = JSON.parse(responseObject.data);
424479
} else {
425480
window.console.warn("The form data field 'data' is missing in the question iframe form.");
426481
}
427482

428-
return JSON.stringify(iframeObject);
483+
return [JSON.stringify(responseObject), JSON.stringify(editorData)];
429484
}
430485

431486
/**
@@ -435,8 +490,10 @@ function createJsonFromFormData(form) {
435490
*
436491
* @param {string} iframeId - The ID of the question's iframe.
437492
* @param {string} responseFieldName - The complete field name for the JSON-encoded iframe form data.
493+
* @param {string} editorsFieldName - The complete field name for the JSON-encoded WYSIWYG editors data.
494+
* @param {string[]} editorNames - The input names that are WYSIWYG editors.
438495
*/
439-
export function addIframeFormDataOnSubmit(iframeId, responseFieldName) {
496+
export function addIframeFormDataOnSubmit(iframeId, responseFieldName, editorsFieldName, editorNames) {
440497
const iframe = window.document.getElementById(iframeId);
441498
if (iframe === null) {
442499
window.console.error(`Could not find question iframe ${iframeId}. Cannot save answers.`);
@@ -450,9 +507,11 @@ export function addIframeFormDataOnSubmit(iframeId, responseFieldName) {
450507
window.console.error("Could not find form in question iframe " + iframeId);
451508
return;
452509
}
510+
453511
// Since we are throttling the updating process of the response element on a change, it might happen that the
454512
// value is outdated - this is why we get the data again.
455-
const jsonFormData = createJsonFromFormData(iframeForm);
456-
event.formData.set(responseFieldName, jsonFormData);
513+
const [responseData, editorsData] = collectFormData(iframeForm, editorNames);
514+
event.formData.set(responseFieldName, responseData);
515+
event.formData.set(editorsFieldName, editorsData);
457516
});
458517
}

classes/constants.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ class constants {
4747
public const FILEAREA_RESPONSE_FILES = 'response_files';
4848
/** @var string */
4949
public const QT_VAR_RESPONSE_FILES = 'files';
50-
50+
/** @var string */
51+
public const QT_VAR_EDITORS = 'editors';
5152

5253
/** @var string */
5354
public const FILEAREA_OPTIONS = 'options';

classes/local/api/package_api.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,18 +134,20 @@ public function start_attempt(string $questionstate, int $variant, ?array $attri
134134
* @param string $attemptstate the attempt state previously returned from {@see start_attempt()}
135135
* @param string|null $scoringstate the last scoring state if this attempt has already been scored
136136
* @param object|null $response data currently entered by the student
137+
* @param array|null $editors
137138
* @return attempt the attempt's metadata. The state is not returned since it never changes.
138139
* @throws GuzzleException
139-
* @throws request_error
140140
* @throws moodle_exception
141+
* @throws request_error
141142
*/
142143
public function view_attempt(string $questionstate, ?array $attributes, string $attemptstate, ?string $scoringstate = null,
143-
?object $response = null): attempt {
144+
?object $response = null, ?array $editors = null): attempt {
144145
$options['multipart'] = $this->transform_to_multipart(
145146
[
146147
'attempt_state' => $attemptstate,
147148
'scoring_state' => $scoringstate,
148149
'response' => $response,
150+
'editors' => $editors === null ? null : (object) $editors,
149151
'context' => $this->get_context_id(),
150152
'lms_provided_attributes' => $attributes,
151153
],
@@ -163,18 +165,20 @@ public function view_attempt(string $questionstate, ?array $attributes, string $
163165
* @param string $attemptstate the attempt state previously returned from {@see start_attempt()}
164166
* @param string|null $scoringstate the last scoring state if this attempt had been scored before
165167
* @param object $response data submitted by the student
168+
* @param wysiwyg_editor_data[] $editors
166169
* @return attempt_scored the attempt's metadata. The state is not returned since it never changes.
167170
* @throws GuzzleException
168-
* @throws request_error
169171
* @throws moodle_exception
172+
* @throws request_error
170173
*/
171174
public function score_attempt(string $questionstate, ?array $attributes, string $attemptstate, ?string $scoringstate,
172-
object $response): attempt_scored {
175+
object $response, array $editors): attempt_scored {
173176
$options['multipart'] = $this->transform_to_multipart(
174177
[
175178
'attempt_state' => $attemptstate,
176179
'scoring_state' => $scoringstate,
177180
'response' => $response,
181+
'editors' => (object) $editors,
178182
'generate_hint' => false,
179183
'context' => $this->get_context_id(),
180184
'lms_provided_attributes' => $attributes,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
// This file is part of the QuestionPy Moodle plugin - https://questionpy.org
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
namespace qtype_questionpy\local\attempt_ui;
18+
19+
20+
use core\exception\coding_exception;
21+
use DOMElement;
22+
use DOMNode;
23+
use file_exception;
24+
use moodle_exception;
25+
use question_attempt;
26+
use stored_file_creation_exception;
27+
28+
/**
29+
* Represents a `<qpy:X/>` element in the question UI XML.
30+
*
31+
* @package qtype_questionpy
32+
* @author Maximilian Haye
33+
* @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org}
34+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35+
*/
36+
interface custom_xhtml_element {
37+
/**
38+
* Parses the given DOMElement if possible.
39+
*
40+
* @param DOMElement $element
41+
* @return static|null
42+
*/
43+
public static function from_element(DOMElement $element): ?static;
44+
45+
/**
46+
* Renders this element to a DOMNode.
47+
*
48+
* @param question_attempt $qa
49+
* @param question_ui_renderer $renderer
50+
* @return DOMNode
51+
* @throws coding_exception
52+
* @throws file_exception
53+
* @throws moodle_exception
54+
* @throws stored_file_creation_exception
55+
*/
56+
public function render(question_attempt $qa, question_ui_renderer $renderer): DOMNode;
57+
}

classes/local/attempt_ui/qpy_file_upload.php

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
* @copyright 2025 TU Berlin, innoCampus {@link https://www.questionpy.org}
4040
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
4141
*/
42-
class qpy_file_upload {
42+
class qpy_file_upload implements custom_xhtml_element {
4343
/**
4444
* Trivial private constructor. Use {@see from_element()}.
4545
* @param DOMElement $element
@@ -81,12 +81,12 @@ public function get_limits_in(context $context): validatable_upload_limits {
8181
}
8282

8383
/**
84-
* Creates a new {@see qpy_file_upload} from a given {@see DOMElement}.
84+
* Parses the given DOMElement if possible.
8585
*
8686
* @param DOMElement $element
87-
* @return self|null
87+
* @return static|null
8888
*/
89-
public static function from_element(DOMElement $element): ?self {
89+
public static function from_element(DOMElement $element): ?static {
9090
$name = $element->getAttribute('name');
9191
if (!$name) {
9292
debugging('qpy:file-upload without a name');
@@ -102,10 +102,6 @@ public static function from_element(DOMElement $element): ?self {
102102
* @param question_attempt $qa
103103
* @param question_ui_renderer $renderer
104104
* @return DOMNode
105-
* @throws coding_exception
106-
* @throws file_exception
107-
* @throws moodle_exception
108-
* @throws stored_file_creation_exception
109105
*/
110106
public function render(question_attempt $qa, question_ui_renderer $renderer): DOMNode {
111107
if ($renderer->options->readonly) {

0 commit comments

Comments
 (0)