Files
unsupervised-scheduler/assets/js/register.js
T
thatguygriffandClaude Opus 5 1d2f95d388
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Coding Standards (pull_request) Successful in 2m56s
CI / PHPStan (pull_request) Successful in 2m56s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m45s
CI / Build Plugin Zip (pull_request) Skipped
Require a name and birth year for every student
Both fields are marked in their labels the same way a required registration
question is, and enforced on the server whichever form they arrive from:
GuardianService::createChild() and updateChild() now refuse a blank name or
an unusable birth year, and the signup form checks the same rule up front,
before it creates a single user, so a bad block never leaves a
half-registered family behind. normaliseBirthYear() became public and static
so both paths share one definition of what a usable year is.

The signup form cannot lean on the browser here. Its child blocks are hidden
until the parent/guardian box is ticked, and a `required` field inside a
hidden container makes the whole form unsubmittable with no control the user
can reach to fix — the same trap the guardian's own question panel already
sidesteps by disabling rather than hiding. So register.js puts `required` on
and takes it off along with the block itself, and the server is what makes
the rule hold with JavaScript off. The profile screen has no such problem:
its forms are always visible, so the attribute is static there.

One behaviour change beyond the requirement: a child block with anything
typed into it is now reported back instead of dropped. Previously any block
without a name was silently discarded, which would now mean losing a birth
year the guardian had filled in. A wholly untouched spare block — the one
the form always renders for "add another" — is still ignored.

Verified the required-toggling in a headless browser: unticked submits,
ticked blocks an empty block, a cloned block inherits the requirement, and
re-unticking leaves nothing behind to block a non-guardian signup.

Closes #148

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 21:00:45 -03:00

174 lines
5.0 KiB
JavaScript

/**
* Progressive enhancement for the student registration form.
*
* Two independent behaviours, both optional — without JS every panel stays
* visible and the single submit still works:
*
* 1. **Two steps.** When account-signup questions are configured the form
* renders two panels (`[data-step="1"]` account details, `[data-step="2"]`
* the questions) inside a form marked `data-steps="1"`. Step two is hidden
* behind a "Next" button that only advances once step one passes native
* validation.
* 2. **Parent/guardian.** The children section is hidden until the
* parent/guardian box is ticked, and "Add another child" clones the child
* block. Ticking the box also takes the guardian's *own* question panel out
* of play — in guardian mode the questions are asked per child, so the
* server ignores those answers and the browser must not demand them.
*/
(function () {
'use strict';
function enhanceSteps(form) {
var step1 = form.querySelector('[data-step="1"]');
var step2 = form.querySelector('[data-step="2"]');
var next = form.querySelector('.us-reg-next');
var back = form.querySelector('.us-reg-back');
if (!step1 || !step2 || !next) {
return null;
}
function show(step) {
step1.hidden = step !== 1;
step2.hidden = step !== 2;
}
show(1);
next.addEventListener('click', function () {
var fields = step1.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) {
if (!fields[i].checkValidity()) {
fields[i].reportValidity();
return;
}
}
show(2);
});
if (back) {
back.addEventListener('click', function () {
show(1);
});
}
return {
step2: step2,
next: next,
earlySubmit: form.querySelector('.us-reg-submit-early'),
};
}
/**
* Rewrite a cloned child block's `children[0][…]` names and ids to the new
* index, and clear the values carried over from the block it was cloned from.
*/
function reindex(block, index) {
block.setAttribute('data-child-index', String(index));
var fields = block.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) {
var field = fields[i];
if (field.name) {
field.name = field.name.replace(/^children\[\d+\]/, 'children[' + index + ']');
}
var oldId = field.id;
if (oldId) {
field.id = oldId.replace(/^us-child-\d+-/, 'us-child-' + index + '-');
var label = block.querySelector('label[for="' + oldId + '"]');
if (label) {
label.setAttribute('for', field.id);
}
}
if (field.type === 'checkbox' || field.type === 'radio') {
field.checked = false;
} else {
field.value = '';
}
}
}
function enhanceGuardian(form, steps) {
var toggle = form.querySelector('#us-is-guardian');
var children = form.querySelector('#us-children');
if (!toggle || !children) {
return;
}
var addButton = children.querySelector('.us-add-child');
var nextIndex = 1;
// The guardian's own question panel is only meaningful when they are
// registering for themselves. Disabling it (rather than hiding it) is what
// stops a `required` question the server will ignore from blocking submit.
function sync() {
children.hidden = !toggle.checked;
// Each student's name and birth year are required, but only once the
// block is in play: a `required` field inside a hidden container makes
// the form unsubmittable with no way to reach the offending control, so
// the attribute goes on and comes off with the block itself. The server
// enforces the same rule either way.
var required = children.querySelectorAll('[data-us-child-required]');
for (var r = 0; r < required.length; r++) {
required[r].required = toggle.checked;
}
if (!steps) {
return;
}
var fields = steps.step2.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) {
fields[i].disabled = toggle.checked;
}
// With the questions out of play there is no second step to advance to,
// so "Next" would be a dead end — swap it for the submit.
steps.next.hidden = toggle.checked;
if (steps.earlySubmit) {
steps.earlySubmit.hidden = !toggle.checked;
}
}
toggle.addEventListener('change', sync);
sync();
if (addButton) {
addButton.addEventListener('click', function () {
var blocks = children.querySelectorAll('.us-child');
var clone = blocks[blocks.length - 1].cloneNode(true);
reindex(clone, nextIndex);
nextIndex += 1;
children.insertBefore(clone, addButton.parentNode);
// The clone carries the data attribute but not necessarily the
// current required state, so settle it the same way as the rest.
sync();
});
}
}
document.addEventListener('DOMContentLoaded', function () {
var forms = document.querySelectorAll('.us-register-form form');
for (var i = 0; i < forms.length; i++) {
var steps = forms[i].getAttribute('data-steps') === '1'
? enhanceSteps(forms[i])
: null;
enhanceGuardian(forms[i], steps);
}
});
})();