Skip to content

Commit 1d2ecfc

Browse files
committed
feat: replace HTML validation attrs with soft validation
We mustn't prevent form submission, but we still want to give some feedback to the user. Closes #57
1 parent a78bf06 commit 1d2ecfc

14 files changed

+324
-37
lines changed

amd/build/view_question.min.js

+3
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

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

amd/src/view_question.js

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
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+
18+
import $ from "jquery";
19+
import "theme_boost/bootstrap/popover";
20+
import {get_string} from "core/str";
21+
22+
/**
23+
*
24+
* @param {HTMLInputElement} element
25+
* @param {string} message validation message to show
26+
* @param {boolean} ariaInvalid
27+
*/
28+
function markInvalid(element, message, ariaInvalid = true) {
29+
element.classList.add("qpy-invalid");
30+
if (ariaInvalid) {
31+
element.setAttribute("aria-invalid", "true");
32+
} else {
33+
element.removeAttribute("aria-invalid");
34+
}
35+
36+
// See https://getbootstrap.com/docs/4.0/components/popovers/.
37+
element.dataset.toggle = "popover";
38+
element.dataset.trigger = "hover";
39+
element.dataset.content = message;
40+
$(element).popover();
41+
}
42+
43+
/**
44+
*
45+
* @param {HTMLInputElement} element
46+
*/
47+
function unmarkInvalid(element) {
48+
element.classList.remove("qpy-invalid");
49+
element.removeAttribute("aria-invalid");
50+
51+
delete element.dataset.toggle;
52+
delete element.dataset.trigger;
53+
delete element.dataset.content;
54+
$(element).popover("dispose");
55+
}
56+
57+
/**
58+
* Softly (i.e. without preventing form submission) validates required and/or pattern conditions on the given element.
59+
*
60+
* @param {HTMLInputElement} element
61+
*/
62+
async function checkConditions(element) {
63+
if (element.dataset.qpy_required !== undefined) {
64+
if (element.value === null || element.value === "") {
65+
const message = await get_string("input_missing", "qtype_questionpy");
66+
markInvalid(element, message, false);
67+
return;
68+
}
69+
}
70+
71+
const pattern = element.dataset.qpy_pattern;
72+
if (pattern !== undefined && element.value !== null && element.value !== ""
73+
&& !element.value.match(`^(?:${pattern})$`)) {
74+
const message = await get_string("input_invalid", "qtype_questionpy");
75+
markInvalid(element, message);
76+
return;
77+
}
78+
79+
const minLength = element.dataset.qpy_minlength;
80+
if (minLength !== undefined && element.value !== null && element.value !== ""
81+
&& element.value.length < parseInt(minLength)) {
82+
const message = await get_string("input_too_short", "qtype_questionpy", minLength);
83+
markInvalid(element, message);
84+
return;
85+
}
86+
87+
const maxLength = element.dataset.qpy_maxlength;
88+
if (maxLength !== undefined && element.value !== null && element.value !== ""
89+
&& element.value.length > parseInt(maxLength)) {
90+
const message = await get_string("input_too_long", "qtype_questionpy", maxLength);
91+
markInvalid(element, message);
92+
return;
93+
}
94+
95+
unmarkInvalid(element);
96+
}
97+
98+
/**
99+
* Adds change event handlers for soft validation.
100+
*/
101+
export async function init() {
102+
for (const element of document
103+
.querySelectorAll("[data-qpy_required], [data-qpy_pattern], [data-qpy_minlength], [data-qpy_maxlength]")) {
104+
await checkConditions(element);
105+
element.addEventListener("change", event => checkConditions(event.target));
106+
}
107+
}

classes/question_metadata.php

+30-1
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,44 @@ class question_metadata {
3838
*/
3939
public array $expecteddata = [];
4040

41+
/**
42+
* @var string[] an array of required field names
43+
* @see \question_manually_gradable::is_complete_response()
44+
* @see \question_manually_gradable::is_gradable_response()
45+
*/
46+
public array $requiredfields = [];
47+
48+
/**
49+
* @var int[] a mapping of field names to minimum string length
50+
* @see \question_manually_gradable::is_gradable_response()
51+
* @link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/minlength
52+
*/
53+
public array $minlengths = [];
54+
55+
/**
56+
* @var int[] a mapping of field names to maximum string length
57+
* @see \question_manually_gradable::is_gradable_response()
58+
* @link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/maxlength
59+
*/
60+
public array $maxlengths = [];
61+
4162
/**
4263
* Initializes a new instance.
4364
*
4465
* @param array|null $correctresponse if known, an array of `name => correct_value` entries for the expected
4566
* response fields
4667
* @param array $expecteddata an array of `name => PARAM_X` entries for the expected response fields
68+
* @param string[] $requiredfields an array of required field names
69+
* @param int[] $minlengths a mapping of field names to minimum string length
70+
* @param int[] $maxlengths a mapping of field names to maximum string length
4771
*/
48-
public function __construct(?array $correctresponse = null, array $expecteddata = []) {
72+
public function __construct(?array $correctresponse = null, array $expecteddata = [],
73+
array $requiredfields = [], array $minlengths = [], array $maxlengths = [])
74+
{
4975
$this->correctresponse = $correctresponse;
5076
$this->expecteddata = $expecteddata;
77+
$this->requiredfields = $requiredfields;
78+
$this->minlengths = $minlengths;
79+
$this->maxlengths = $maxlengths;
5180
}
5281
}

classes/question_ui_renderer.php

+53
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,20 @@ public function get_metadata(): question_metadata {
182182
$name = $element->getAttribute("name");
183183
if ($name) {
184184
$this->metadata->expecteddata[$name] = PARAM_RAW;
185+
186+
if ($element->hasAttribute("required")) {
187+
$this->metadata->requiredfields[] = $name;
188+
}
189+
190+
$minlength = $element->getAttribute("minlength");
191+
if (is_numeric($minlength)) {
192+
$this->metadata->minlengths[$name] = intval($minlength);
193+
}
194+
195+
$maxlength = $element->getAttribute("maxlength");
196+
if (is_numeric($maxlength)) {
197+
$this->metadata->maxlengths[$name] = intval($maxlength);
198+
}
185199
}
186200
}
187201
}
@@ -215,6 +229,7 @@ private function render_part(DOMNode $part, question_attempt $qa, ?question_disp
215229
try {
216230
$this->hide_unwanted_feedback($xpath, $options);
217231
$this->set_input_values_and_readonly($xpath, $qa, $options);
232+
$this->soften_validation($xpath);
218233
$this->shuffle_contents($xpath);
219234
$this->mangle_ids_and_names($xpath, $qa);
220235
$this->clean_up($xpath);
@@ -449,4 +464,42 @@ private function resolve_placeholders(DOMXPath $xpath): void {
449464
}
450465
}
451466
}
467+
468+
/**
469+
* Replaces the HTML attributes `pattern`, `required`, `minlength`, `maxlength` so that submission is not prevented.
470+
*
471+
* The standard attributes are replaced with `data-qpy_X`, which are then evaluated in JS.
472+
*
473+
* @param DOMXPath $xpath
474+
* @return void
475+
*/
476+
private function soften_validation(DOMXPath $xpath): void
477+
{
478+
/** @var DOMElement $element */
479+
foreach ($xpath->query("//xhtml:input[@pattern]") as $element) {
480+
$pattern = $element->getAttribute("pattern");
481+
$element->removeAttribute("pattern");
482+
$element->setAttribute("data-qpy_pattern", $pattern);
483+
}
484+
485+
foreach ($xpath->query("(//xhtml:input | //xhtml:select | //xhtml:textarea)[@required]") as $element) {
486+
$element->removeAttribute("required");
487+
$element->setAttribute("data-qpy_required", "data-qpy_required");
488+
$element->setAttribute("aria-required", "true");
489+
}
490+
491+
foreach ($xpath->query("(//xhtml:input | //xhtml:textarea)[@minlength or @maxlength]") as $element) {
492+
$minlength = $element->getAttribute("minlength");
493+
if ($minlength !== "") {
494+
$element->removeAttribute("minlength");
495+
$element->setAttribute("data-qpy_minlength", $minlength);
496+
}
497+
498+
$maxlength = $element->getAttribute("maxlength");
499+
if ($maxlength !== "") {
500+
$element->removeAttribute("maxlength");
501+
$element->setAttribute("data-qpy_maxlength", $maxlength);
502+
}
503+
}
504+
}
452505
}

lang/en/qtype_questionpy.php

+6
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@
5656
// Question management.
5757
$string['package_not_found'] = 'The requested package {$a->packagehash} does not exist.';
5858

59+
// Question UI.
60+
$string['input_missing'] = 'This field is required.';
61+
$string['input_invalid'] = 'Invalid input.';
62+
$string['input_too_long'] = 'Please enter at most {$a} characters.';
63+
$string['input_too_short'] = 'Please enter at least {$a} characters.';
64+
5965
// Connector.
6066
$string['curl_init_error'] = 'Could not initialize cURL. Error number: {$a}';
6167
$string['curl_exec_error'] = 'Error while fetching from server. Error number: {$a}';

question.php

+10-3
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,13 @@ public function __construct(string $packagehash, string $questionstate) {
8383
public function start_attempt(question_attempt_step $step, $variant): void {
8484
$attempt = $this->api->start_attempt($this->packagehash, $this->questionstate, $variant);
8585

86+
$step->set_qt_var(self::QT_VAR_ATTEMPT_STATE, $attempt->attemptstate);
87+
8688
// We generate a fixed seed to be used during every render of the attempt, to keep shuffles deterministic.
8789
$mtseed = mt_rand();
8890
$step->set_qt_var(self::QT_VAR_MT_SEED, $mtseed);
8991

9092
$this->ui = new question_ui_renderer($attempt->ui->content, $attempt->ui->parameters, $mtseed);
91-
$step->set_qt_var(self::QT_VAR_ATTEMPT_STATE, $attempt->attemptstate);
9293
}
9394

9495
/**
@@ -154,7 +155,13 @@ public function get_correct_response(): ?array {
154155
* {@see question_attempt_step::get_qt_data()}.
155156
* @return bool whether this response is a complete answer to this question.
156157
*/
157-
public function is_complete_response(array $response) {
158+
public function is_complete_response(array $response): bool
159+
{
160+
foreach ($this->ui->get_metadata()->requiredfields as $requiredfield) {
161+
if (!isset($response[$requiredfield]) || $response[$requiredfield] === "") {
162+
return false;
163+
}
164+
}
158165
return true;
159166
}
160167

@@ -165,7 +172,7 @@ public function is_complete_response(array $response) {
165172
*
166173
* @param array $prevresponse the responses previously recorded for this question,
167174
* as returned by {@see question_attempt_step::get_qt_data()}
168-
* @param array $newresponse the new responses, in the same format.
175+
* @param array $newresponse the new responses, in the same format.
169176
* @return bool whether the two sets of responses are the same - that is
170177
* whether the new set of responses can safely be discarded.
171178
*/

renderer.php

+13-1
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,25 @@
3030
*/
3131
class qtype_questionpy_renderer extends qtype_renderer {
3232

33+
/**
34+
* Return any HTML that needs to be included in the page's <head> when this
35+
* question is used.
36+
* @param question_attempt $qa the question attempt that will be displayed on the page.
37+
* @return string HTML fragment.
38+
*/
39+
public function head_code(question_attempt $qa) {
40+
global $PAGE;
41+
$PAGE->requires->js_call_amd("qtype_questionpy/view_question", "init");
42+
return parent::head_code($qa);
43+
}
44+
3345
/**
3446
* Generate the display of the formulation part of the question. This is the
3547
* area that contains the quetsion text, and the controls for students to
3648
* input their answers. Some question types also embed bits of feedback, for
3749
* example ticks and crosses, in this area.
3850
*
39-
* @param question_attempt $qa the question attempt to display.
51+
* @param question_attempt $qa the question attempt to display.
4052
* @param question_display_options $options controls what should and should not be displayed.
4153
* @return string HTML fragment.
4254
* @throws coding_exception

styles.css

+4
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,7 @@
7373
.qpy-repetition-remove {
7474
margin: .5em;
7575
}
76+
77+
.qpy-invalid {
78+
border-color: red;
79+
}

0 commit comments

Comments
 (0)