Skip to content

Commit aeae678

Browse files
committed
feat: put formulation/feedback in iframe and allow JS
1 parent 4cad5e9 commit aeae678

17 files changed

+748
-128
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: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
import $ from "jquery";
1919
import "theme_boost/bootstrap/popover";
2020

21+
/**
22+
* @type {?Attempt} Attempt object that is passed to the question package.
23+
*/
24+
let attempt = null;
25+
2126
/**
2227
* If the given input(-like) element is labelled, returns the label element. Returns null otherwise.
2328
*
@@ -78,7 +83,8 @@ function markInvalid(element, message, ariaInvalid = true) {
7883
$(popoverTarget).popover({
7984
toggle: "popover",
8085
trigger: "hover",
81-
content: message
86+
placement: "bottom",
87+
content: message,
8288
});
8389
}
8490

@@ -132,9 +138,15 @@ async function checkConstraints(element) {
132138
}
133139

134140
/**
135-
* Adds change event handlers for soft validation.
141+
* Initializes the question.
142+
*
143+
* This function must be called within the iframe.
144+
*
145+
* @param {string} autoSaveHintInputId
146+
* @param {string[]} roles QPy role names that the user has.
136147
*/
137-
export async function init() {
148+
export async function init(autoSaveHintInputId, roles) {
149+
// Add change event handlers for soft validation.
138150
for (const element of document.querySelectorAll(`
139151
[data-qpy_required], [data-qpy_pattern],
140152
[data-qpy_minlength], [data-qpy_maxlength],
@@ -143,4 +155,141 @@ export async function init() {
143155
await checkConstraints(element);
144156
element.addEventListener("change", event => checkConstraints(event.target));
145157
}
158+
159+
const form = window.document.getElementById("qpy-formulation");
160+
if (form) {
161+
// On form submit, submit the quiz's main form in the parent window instead.
162+
form.addEventListener("submit", event => {
163+
event.preventDefault();
164+
window.frameElement.closest("form").submit();
165+
});
166+
167+
// Modify a field in the main form in order to tell the Quiz's autosaver that the user changed an answer.
168+
const autoSaveHintElement = parent.document.getElementById(autoSaveHintInputId);
169+
if (autoSaveHintElement) {
170+
form.addEventListener("change", function() {
171+
autoSaveHintElement.value = parseInt(autoSaveHintElement.value) + 1;
172+
});
173+
}
174+
}
175+
176+
// Attempt object that is passed to the question package.
177+
attempt = new Attempt(
178+
window.document.getElementById("qpy-formulation"),
179+
window.document.getElementById("qpy-general-feedback"),
180+
window.document.getElementById("qpy-specific-feedback"),
181+
window.document.getElementById("qpy-right-answer"),
182+
roles
183+
);
184+
}
185+
186+
/**
187+
* Get a QuestionPy attempt.
188+
*
189+
* @returns {Attempt}
190+
*/
191+
export function getAttempt() {
192+
if (attempt === null) {
193+
throw new Error("Attempt not initialized");
194+
}
195+
return attempt;
196+
}
197+
198+
class Attempt {
199+
#formulation;
200+
#generalFeedback;
201+
#specificFeedback;
202+
#rightAnswer;
203+
#roles;
204+
205+
/**
206+
* @param {Element} formulationElement
207+
* @param {?Element} generalFeedbackElement
208+
* @param {?Element} specificFeedbackElement
209+
* @param {?Element} rightAnswer
210+
* @param {string[]} roles
211+
*/
212+
constructor(formulationElement, generalFeedbackElement, specificFeedbackElement, rightAnswer, roles) {
213+
this.#formulation = formulationElement;
214+
this.#generalFeedback = generalFeedbackElement;
215+
this.#specificFeedback = specificFeedbackElement;
216+
this.#rightAnswer = rightAnswer;
217+
this.#roles = roles;
218+
}
219+
220+
/**
221+
* Get the top html element where the question's formulation xhtml was inserted.
222+
*
223+
* @returns {Element}
224+
*/
225+
get formulationElement() {
226+
return this.#formulation;
227+
}
228+
229+
/**
230+
* Get the top html element where the question's general feedback xhtml was inserted (if available).
231+
*
232+
* @returns {?Element}
233+
*/
234+
get generalFeedbackElement() {
235+
return this.#generalFeedback;
236+
}
237+
238+
/**
239+
* Get the top html element where the question's specific feedback xhtml was inserted (if available).
240+
*
241+
* @returns {?Element}
242+
*/
243+
get specificFeedbackElement() {
244+
return this.#specificFeedback;
245+
}
246+
247+
/**
248+
* Get the top html element where the question's right answer xhtml was inserted (if available).
249+
*
250+
* @returns {?Element}
251+
*/
252+
get rightAnswerElement() {
253+
return this.#rightAnswer;
254+
}
255+
256+
/**
257+
* Get the names of the roles that the current user has.
258+
*
259+
* @typedef {'teacher' | 'developer' | 'scorer' | 'proctor'} roleName
260+
* @returns {roleName[]}
261+
*/
262+
get userRoles() {
263+
return this.#roles;
264+
}
265+
}
266+
267+
/**
268+
* Add the question's form data located in the iframe to the main form when it is submitted.
269+
*
270+
* This function must be called outside the iframe, on the parent window.
271+
*
272+
* @param {string} iframeId - The ID of the question's iframe.
273+
* @param {string} fieldPrefix - The prefix to add to the field names, for Moodle to recognize the fields belonging to a question.
274+
* @return {void} This function does not return a value.
275+
*/
276+
export function addIframeFormDataOnSubmit(iframeId, fieldPrefix) {
277+
const iframe = window.document.getElementById(iframeId);
278+
if (iframe === null) {
279+
window.console.error(`Could not find question iframe ${iframeId}. Cannot save answers.`);
280+
return;
281+
}
282+
283+
const form = iframe.closest("form");
284+
form.addEventListener("formdata", event => {
285+
const iframeForm = iframe.contentDocument.getElementById("qpy-formulation");
286+
if (iframeForm === null) {
287+
window.console.error("Could not find form in question iframe " + iframeId);
288+
return;
289+
}
290+
const iframeFormData = new FormData(iframeForm);
291+
for (const [key, value] of iframeFormData) {
292+
event.formData.append(fieldPrefix + key, value);
293+
}
294+
});
146295
}

classes/api/attempt.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
namespace qtype_questionpy\api;
1818

19+
use qtype_questionpy\array_converter\attributes\array_element_class;
20+
use qtype_questionpy\array_converter\attributes\array_key;
21+
1922
/**
2023
* An attempt at a QuestionPy question.
2124
*
@@ -31,14 +34,21 @@ class attempt {
3134
/** @var attempt_ui */
3235
public attempt_ui $ui;
3336

37+
/** @var package_dependency[] */
38+
#[array_key('package_dependencies')]
39+
#[array_element_class(package_dependency::class)]
40+
public array $packagedependencies;
41+
3442
/**
3543
* Initializes a new instance.
3644
*
3745
* @param int $variant
3846
* @param attempt_ui $ui
47+
* @param package_dependency[] $packagedependencies
3948
*/
40-
public function __construct(int $variant, attempt_ui $ui) {
49+
public function __construct(int $variant, attempt_ui $ui, array $packagedependencies) {
4150
$this->variant = $variant;
4251
$this->ui = $ui;
52+
$this->packagedependencies = $packagedependencies;
4353
}
4454
}

classes/api/attempt_scored.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,11 @@ class attempt_scored extends attempt {
4545
* @param attempt_ui $ui
4646
* @param scoring_code $scoringcode
4747
* @param string|null $scoringstate
48+
* @param package_dependency[] $packagedependencies
4849
*/
49-
public function __construct(int $variant, attempt_ui $ui, scoring_code $scoringcode, ?string $scoringstate = null) {
50-
parent::__construct($variant, $ui);
50+
public function __construct(int $variant, attempt_ui $ui, scoring_code $scoringcode, ?string $scoringstate,
51+
array $packagedependencies) {
52+
parent::__construct($variant, $ui, $packagedependencies);
5153

5254
$this->scoringstate = $scoringstate;
5355
$this->scoringcode = $scoringcode;

classes/api/attempt_started.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ class attempt_started extends attempt {
3737
* @param int $variant
3838
* @param attempt_ui $ui
3939
* @param string $attemptstate
40+
* @param array $packagedependencies
4041
*/
41-
public function __construct(int $variant, attempt_ui $ui, string $attemptstate) {
42-
parent::__construct($variant, $ui);
42+
public function __construct(int $variant, attempt_ui $ui, string $attemptstate, array $packagedependencies) {
43+
parent::__construct($variant, $ui, $packagedependencies);
4344
$this->attemptstate = $attemptstate;
4445
}
4546
}

classes/api/attempt_ui.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ class attempt_ui {
5050
#[array_key('css_files')]
5151
public ?array $cssfiles = null;
5252

53+
/** @var js_module_call[]|null */
54+
#[array_key('javascript_calls')]
55+
#[array_element_class(js_module_call::class)]
56+
public array $javascriptcalls;
57+
5358
/** @var array<string, attempt_file> specifics TBD */
5459
#[array_element_class(attempt_file::class)]
5560
public array $files = [];

classes/api/display_role.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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\api;
18+
19+
// phpcs:ignore moodle.Commenting.InlineComment.DocBlock
20+
/**
21+
* Possible display roles.
22+
*
23+
* @package qtype_questionpy
24+
* @author Martin Gauk
25+
* @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org}
26+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
27+
*/
28+
enum display_role: string {
29+
case developer = 'DEVELOPER';
30+
case proctor = 'PROCTOR';
31+
case scorer = 'SCORER';
32+
case teacher = 'TEACHER';
33+
}

classes/api/feedback_type.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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\api;
18+
19+
// phpcs:ignore moodle.Commenting.InlineComment.DocBlock
20+
/**
21+
* Possible feedback types.
22+
*
23+
* @package qtype_questionpy
24+
* @author Martin Gauk
25+
* @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org}
26+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
27+
*/
28+
enum feedback_type: string {
29+
case general_feedback = 'GENERAL_FEEDBACK';
30+
case specific_feedback = 'SPECIFIC_FEEDBACK';
31+
case right_answer = 'RIGHT_ANSWER';
32+
case hint = 'HINT';
33+
}

classes/api/js_module_call.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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\api;
18+
19+
use qtype_questionpy\array_converter\attributes\array_key;
20+
21+
/**
22+
* Model defining what JavaScript functions need to be called.
23+
*
24+
* @package qtype_questionpy
25+
* @author Martin Gauk
26+
* @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org}
27+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28+
*/
29+
class js_module_call {
30+
/** @var string */
31+
public string $module;
32+
33+
/** @var string */
34+
public string $function;
35+
36+
/** @var string|null */
37+
public ?string $data;
38+
39+
/** @var display_role|null */
40+
#[array_key('if_role')]
41+
public ?string $ifrole;
42+
43+
/** @var feedback_type|null */
44+
#[array_key('if_feedback_type')]
45+
public ?string $iffeedbacktype;
46+
}

0 commit comments

Comments
 (0)