Skip to content

Commit

Permalink
feat: replace HTML validation attrs with soft validation
Browse files Browse the repository at this point in the history
We mustn't prevent form submission, but we still want to give some feedback to the user.

Closes #57
  • Loading branch information
MHajoha committed Sep 14, 2023
1 parent a78bf06 commit ef20453
Show file tree
Hide file tree
Showing 13 changed files with 312 additions and 37 deletions.
3 changes: 3 additions & 0 deletions amd/build/view_question.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions amd/build/view_question.min.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

101 changes: 101 additions & 0 deletions amd/src/view_question.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* 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/>.
*/

import $ from "jquery";
import "theme_boost/bootstrap/popover";

/**
*
* @param {HTMLInputElement} element
* @param {boolean} ariaInvalid
*/
function markInvalid(element, ariaInvalid = true) {
element.classList.add("qpy-invalid");
if (ariaInvalid) {
element.setAttribute("aria-invalid", "true");
} else {
element.removeAttribute("aria-invalid");
}

// See https://getbootstrap.com/docs/4.0/components/popovers/.
element.dataset.toggle = "popover";
element.dataset.trigger = "hover";
element.dataset.content = "TODO: strings";
$(element).popover();
}

/**
*
* @param {HTMLInputElement} element
*/
function unmarkInvalid(element) {
element.classList.remove("qpy-invalid");
element.removeAttribute("aria-invalid");

delete element.dataset.toggle;
delete element.dataset.trigger;
delete element.dataset.content;
$(element).popover("dispose");
}

/**
* Softly (i.e. without preventing form submission) validates required and/or pattern conditions on the given element.
*
* @param {HTMLInputElement} element
*/
function checkConditions(element) {
if (element.dataset.qpy_required !== undefined) {
if (element.value === null || element.value === "") {
markInvalid(element, false);
return;
}
}

const pattern = element.dataset.qpy_pattern;
if (pattern !== undefined && element.value !== null && element.value !== ""
&& !element.value.match(`^(?:${pattern})$`)) {
markInvalid(element);
return;
}

const minLength = element.dataset.qpy_minlength;
if (minLength !== undefined && element.value !== null && element.value !== ""
&& element.value.length < parseInt(minLength)) {
markInvalid(element);
return;
}

const maxLength = element.dataset.qpy_maxlength;
if (maxLength !== undefined && element.value !== null && element.value !== ""
&& element.value.length > parseInt(maxLength)) {
markInvalid(element);
return;
}

unmarkInvalid(element);
}

/**
* Adds change event handlers for soft validation.
*/
export function init() {
document.querySelectorAll("[data-qpy_required], [data-qpy_pattern], [data-qpy_minlength], [data-qpy_maxlength]")
.forEach(element => {
checkConditions(element);
element.addEventListener("change", event => checkConditions(event.target));
});
}
31 changes: 30 additions & 1 deletion classes/question_metadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,44 @@ class question_metadata {
*/
public array $expecteddata = [];

/**
* @var string[] an array of required field names
* @see \question_manually_gradable::is_complete_response()
* @see \question_manually_gradable::is_gradable_response()
*/
public array $requiredfields = [];

/**
* @var int[] a mapping of field names to minimum string length
* @see \question_manually_gradable::is_gradable_response()
* @link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/minlength
*/
public array $minlengths = [];

/**
* @var int[] a mapping of field names to maximum string length
* @see \question_manually_gradable::is_gradable_response()
* @link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/maxlength
*/
public array $maxlengths = [];

/**
* Initializes a new instance.
*
* @param array|null $correctresponse if known, an array of `name => correct_value` entries for the expected
* response fields
* @param array $expecteddata an array of `name => PARAM_X` entries for the expected response fields
* @param string[] $requiredfields an array of required field names
* @param int[] $minlengths a mapping of field names to minimum string length
* @param int[] $maxlengths a mapping of field names to maximum string length
*/
public function __construct(?array $correctresponse = null, array $expecteddata = []) {
public function __construct(?array $correctresponse = null, array $expecteddata = [],
array $requiredfields = [], array $minlengths = [], array $maxlengths = [])
{
$this->correctresponse = $correctresponse;
$this->expecteddata = $expecteddata;
$this->requiredfields = $requiredfields;
$this->minlengths = $minlengths;
$this->maxlengths = $maxlengths;
}
}
53 changes: 53 additions & 0 deletions classes/question_ui_renderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ public function get_metadata(): question_metadata {
$name = $element->getAttribute("name");
if ($name) {
$this->metadata->expecteddata[$name] = PARAM_RAW;

if ($element->hasAttribute("required")) {
$this->metadata->requiredfields[] = $name;
}

$minlength = $element->getAttribute("minlength");
if (is_numeric($minlength)) {
$this->metadata->minlengths[$name] = intval($minlength);
}

$maxlength = $element->getAttribute("maxlength");
if (is_numeric($maxlength)) {
$this->metadata->maxlengths[$name] = intval($maxlength);
}
}
}
}
Expand Down Expand Up @@ -215,6 +229,7 @@ private function render_part(DOMNode $part, question_attempt $qa, ?question_disp
try {
$this->hide_unwanted_feedback($xpath, $options);
$this->set_input_values_and_readonly($xpath, $qa, $options);
$this->soften_validation($xpath);
$this->shuffle_contents($xpath);
$this->mangle_ids_and_names($xpath, $qa);
$this->clean_up($xpath);
Expand Down Expand Up @@ -449,4 +464,42 @@ private function resolve_placeholders(DOMXPath $xpath): void {
}
}
}

/**
* Replaces the HTML attributes `pattern`, `required`, `minlength`, `maxlength` so that submission is not prevented.
*
* The standard attributes are replaced with `data-qpy_X`, which are then evaluated in JS.
*
* @param DOMXPath $xpath
* @return void
*/
private function soften_validation(DOMXPath $xpath): void
{
/** @var DOMElement $element */
foreach ($xpath->query("//xhtml:input[@pattern]") as $element) {
$pattern = $element->getAttribute("pattern");
$element->removeAttribute("pattern");
$element->setAttribute("data-qpy_pattern", $pattern);
}

foreach ($xpath->query("(//xhtml:input | //xhtml:select | //xhtml:textarea)[@required]") as $element) {
$element->removeAttribute("required");
$element->setAttribute("data-qpy_required", "data-qpy_required");
$element->setAttribute("aria-required", "true");
}

foreach ($xpath->query("(//xhtml:input | //xhtml:textarea)[@minlength or @maxlength]") as $element) {
$minlength = $element->getAttribute("minlength");
if ($minlength !== "") {
$element->removeAttribute("minlength");
$element->setAttribute("data-qpy_minlength", $minlength);
}

$maxlength = $element->getAttribute("maxlength");
if ($maxlength !== "") {
$element->removeAttribute("maxlength");
$element->setAttribute("data-qpy_maxlength", $maxlength);
}
}
}
}
13 changes: 10 additions & 3 deletions question.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,13 @@ public function __construct(string $packagehash, string $questionstate) {
public function start_attempt(question_attempt_step $step, $variant): void {
$attempt = $this->api->start_attempt($this->packagehash, $this->questionstate, $variant);

$step->set_qt_var(self::QT_VAR_ATTEMPT_STATE, $attempt->attemptstate);

// We generate a fixed seed to be used during every render of the attempt, to keep shuffles deterministic.
$mtseed = mt_rand();
$step->set_qt_var(self::QT_VAR_MT_SEED, $mtseed);

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

/**
Expand Down Expand Up @@ -154,7 +155,13 @@ public function get_correct_response(): ?array {
* {@see question_attempt_step::get_qt_data()}.
* @return bool whether this response is a complete answer to this question.
*/
public function is_complete_response(array $response) {
public function is_complete_response(array $response): bool
{
foreach ($this->ui->get_metadata()->requiredfields as $requiredfield) {
if (!isset($response[$requiredfield]) || $response[$requiredfield] === "") {
return false;
}
}
return true;
}

Expand All @@ -165,7 +172,7 @@ public function is_complete_response(array $response) {
*
* @param array $prevresponse the responses previously recorded for this question,
* as returned by {@see question_attempt_step::get_qt_data()}
* @param array $newresponse the new responses, in the same format.
* @param array $newresponse the new responses, in the same format.
* @return bool whether the two sets of responses are the same - that is
* whether the new set of responses can safely be discarded.
*/
Expand Down
14 changes: 13 additions & 1 deletion renderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,25 @@
*/
class qtype_questionpy_renderer extends qtype_renderer {

/**
* Return any HTML that needs to be included in the page's <head> when this
* question is used.
* @param question_attempt $qa the question attempt that will be displayed on the page.
* @return string HTML fragment.
*/
public function head_code(question_attempt $qa) {
global $PAGE;
$PAGE->requires->js_call_amd("qtype_questionpy/view_question", "init");
return parent::head_code($qa);
}

/**
* Generate the display of the formulation part of the question. This is the
* area that contains the quetsion text, and the controls for students to
* input their answers. Some question types also embed bits of feedback, for
* example ticks and crosses, in this area.
*
* @param question_attempt $qa the question attempt to display.
* @param question_attempt $qa the question attempt to display.
* @param question_display_options $options controls what should and should not be displayed.
* @return string HTML fragment.
* @throws coding_exception
Expand Down
4 changes: 4 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,7 @@
.qpy-repetition-remove {
margin: .5em;
}

.qpy-invalid {
border-color: red;
}
Loading

0 comments on commit ef20453

Please sign in to comment.