Skip to content

Commit 2f6e99b

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 8866f97 commit 2f6e99b

13 files changed

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

+157
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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+
* If the given input(-like) element is labelled, returns the label element. Returns null otherwise.
24+
*
25+
* @param {HTMLElement} input
26+
* @return {HTMLLabelElement | null}
27+
* @see {@link https://html.spec.whatwg.org/multipage/forms.html#the-label-element}
28+
*/
29+
function getLabelFor(input) {
30+
// A label can reference its labeled control in its for attribute.
31+
const id = input.id;
32+
if (id !== "") {
33+
const label = document.querySelector(`label[for='${id}']`);
34+
if (label) {
35+
return label;
36+
}
37+
}
38+
39+
// Or the labeled control can be a descendant of the label.
40+
const label = input.closest("label");
41+
if (label) {
42+
return label;
43+
}
44+
45+
return null;
46+
}
47+
48+
/**
49+
* Marks the given input element as invalid.
50+
*
51+
* @param {HTMLElement} element
52+
* @param {string} message validation message to show
53+
* @param {boolean} ariaInvalid
54+
*/
55+
function markInvalid(element, message, ariaInvalid = true) {
56+
element.classList.add("is-invalid");
57+
if (ariaInvalid) {
58+
element.setAttribute("aria-invalid", "true");
59+
} else {
60+
element.removeAttribute("aria-invalid");
61+
}
62+
63+
let popoverTarget = element;
64+
if (element.type === "checkbox" || element.type === "radio") {
65+
// Checkboxes and radios make for a very small hit area for the popover, so we attach the popover to the label.
66+
const label = getLabelFor(element);
67+
if (!label) {
68+
// No label -> Add the popover just to the checkbox.
69+
popoverTarget = element;
70+
} else if (label.contains(element)) {
71+
// Label contains checkbox -> Add the popover just to the label.
72+
popoverTarget = label;
73+
} else {
74+
// Separate label and checkbox -> Add the popover to both.
75+
popoverTarget = [element, label];
76+
}
77+
}
78+
79+
$(popoverTarget).popover({
80+
toggle: "popover",
81+
trigger: "hover",
82+
content: message
83+
});
84+
}
85+
86+
/**
87+
* Undoes what {@link markInvalid} did.
88+
*
89+
* @param {HTMLInputElement} element
90+
*/
91+
function unmarkInvalid(element) {
92+
element.classList.remove("is-invalid");
93+
element.removeAttribute("aria-invalid");
94+
95+
$([element, getLabelFor(element)]).popover("dispose");
96+
}
97+
98+
/**
99+
* Softly (i.e. without preventing form submission) validates required and/or pattern conditions on the given element.
100+
*
101+
* @param {HTMLInputElement} element
102+
*/
103+
async function checkConditions(element) {
104+
if (element.dataset.qpy_required !== undefined) {
105+
let isPresent;
106+
if (element.type === "checkbox" || element.type === "radio") {
107+
isPresent = element.checked;
108+
} else {
109+
isPresent = element.value !== null && element.value !== "";
110+
}
111+
112+
if (!isPresent) {
113+
const message = await get_string("input_missing", "qtype_questionpy");
114+
// aria-invalid shouldn't be set for missing inputs until the user has tried to submit them.
115+
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-invalid
116+
markInvalid(element, message, false);
117+
return;
118+
}
119+
}
120+
121+
const pattern = element.dataset.qpy_pattern;
122+
if (pattern !== undefined && element.value !== null && element.value !== ""
123+
&& !element.value.match(`^(?:${pattern})$`)) {
124+
const message = await get_string("input_invalid", "qtype_questionpy");
125+
markInvalid(element, message);
126+
return;
127+
}
128+
129+
const minLength = element.dataset.qpy_minlength;
130+
if (minLength !== undefined && element.value !== null && element.value !== ""
131+
&& element.value.length < parseInt(minLength)) {
132+
const message = await get_string("input_too_short", "qtype_questionpy", minLength);
133+
markInvalid(element, message);
134+
return;
135+
}
136+
137+
const maxLength = element.dataset.qpy_maxlength;
138+
if (maxLength !== undefined && element.value !== null && element.value !== ""
139+
&& element.value.length > parseInt(maxLength)) {
140+
const message = await get_string("input_too_long", "qtype_questionpy", maxLength);
141+
markInvalid(element, message);
142+
return;
143+
}
144+
145+
unmarkInvalid(element);
146+
}
147+
148+
/**
149+
* Adds change event handlers for soft validation.
150+
*/
151+
export async function init() {
152+
for (const element of document
153+
.querySelectorAll("[data-qpy_required], [data-qpy_pattern], [data-qpy_minlength], [data-qpy_maxlength]")) {
154+
await checkConditions(element);
155+
element.addEventListener("change", event => checkConditions(event.target));
156+
}
157+
}

classes/question_metadata.php

+12-1
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,26 @@ 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+
4148
/**
4249
* Initializes a new instance.
4350
*
4451
* @param array|null $correctresponse if known, an array of `name => correct_value` entries for the expected
4552
* response fields
4653
* @param array $expecteddata an array of `name => PARAM_X` entries for the expected response fields
54+
* @param string[] $requiredfields an array of required field names
4755
*/
48-
public function __construct(?array $correctresponse = null, array $expecteddata = []) {
56+
public function __construct(?array $correctresponse = null, array $expecteddata = [],
57+
array $requiredfields = [])
58+
{
4959
$this->correctresponse = $correctresponse;
5060
$this->expecteddata = $expecteddata;
61+
$this->requiredfields = $requiredfields;
5162
}
5263
}

classes/question_ui_renderer.php

+43
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ 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+
}
185189
}
186190
}
187191
}
@@ -215,6 +219,7 @@ private function render_part(DOMNode $part, question_attempt $qa, ?question_disp
215219
try {
216220
$this->hide_unwanted_feedback($xpath, $options);
217221
$this->set_input_values_and_readonly($xpath, $qa, $options);
222+
$this->soften_validation($xpath);
218223
$this->shuffle_contents($xpath);
219224
$this->add_bootstrap_classes($xpath);
220225
$this->mangle_ids_and_names($xpath, $qa);
@@ -451,6 +456,44 @@ private function resolve_placeholders(DOMXPath $xpath): void {
451456
}
452457
}
453458

459+
/**
460+
* Replaces the HTML attributes `pattern`, `required`, `minlength`, `maxlength` so that submission is not prevented.
461+
*
462+
* The standard attributes are replaced with `data-qpy_X`, which are then evaluated in JS.
463+
* Ideally we'd also want to handle min and max here, but their evaluation in JS would be quite complicated.
464+
*
465+
* @param DOMXPath $xpath
466+
* @return void
467+
*/
468+
private function soften_validation(DOMXPath $xpath): void {
469+
/** @var DOMElement $element */
470+
foreach ($xpath->query("//xhtml:input[@pattern]") as $element) {
471+
$pattern = $element->getAttribute("pattern");
472+
$element->removeAttribute("pattern");
473+
$element->setAttribute("data-qpy_pattern", $pattern);
474+
}
475+
476+
foreach ($xpath->query("(//xhtml:input | //xhtml:select | //xhtml:textarea)[@required]") as $element) {
477+
$element->removeAttribute("required");
478+
$element->setAttribute("data-qpy_required", "data-qpy_required");
479+
$element->setAttribute("aria-required", "true");
480+
}
481+
482+
foreach ($xpath->query("(//xhtml:input | //xhtml:textarea)[@minlength or @maxlength]") as $element) {
483+
$minlength = $element->getAttribute("minlength");
484+
if ($minlength !== "") {
485+
$element->removeAttribute("minlength");
486+
$element->setAttribute("data-qpy_minlength", $minlength);
487+
}
488+
489+
$maxlength = $element->getAttribute("maxlength");
490+
if ($maxlength !== "") {
491+
$element->removeAttribute("maxlength");
492+
$element->setAttribute("data-qpy_maxlength", $maxlength);
493+
}
494+
}
495+
}
496+
454497
private function add_bootstrap_classes(DOMXPath $xpath): void {
455498
/** @var DOMElement $element */
456499
foreach ($xpath->query("

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}';

0 commit comments

Comments
 (0)