CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m57s
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
CI / Build Plugin Zip (pull_request) Skipped
Replaces the single "I'm registering as a parent or guardian" tick with "Just myself" / "On behalf of one or more students" / "Both". Radios, not checkboxes as the feedback put it: the three answers are mutually exclusive, and "both" only means anything as a third choice alongside the other two. The tick could only ever say whether there were children to add. It could not say whether the account holder was a student, so bookableStudents() always offered them their own name and any guardian could book themselves a lesson nobody meant to sell. "On behalf of" now records us_guardian_only and leaves them out of the picker. That flag is stored as the negative on purpose. Every account predating this choice is a bookable student, and absence has to keep meaning exactly that, or the picker would quietly stop offering people themselves on upgrade. setGuardianOnly() clears the key rather than writing 0, so "not set" stays the single spelling of "yes, a student". A guardian-only account with nobody linked to it is still offered itself — an empty picker is no way to book at all, and they can put the account right from the profile page. An unrecognised or absent value reads as "just myself": the choice that collects the least and grants the least. A missing radio must never be taken as "register these children". Bumps to 1.4.0. The account holder's own questions stay out of play whenever students are being added, "both" included — asking them there is #146. Verified the form in a headless browser across all three choices: which blocks show, which fields carry `required`, whether the account holder's question panel is disabled, which submit is offered, and that switching back to "just myself" leaves no hidden required field blocking submit. Closes #145 Co-Authored-By: Claude Opus 5 <[email protected]>
298 lines
8.7 KiB
JavaScript
298 lines
8.7 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. **Who are you registering?** The student section is hidden until the
|
|
* choice is "on behalf of students" or "both", and "Add another student"
|
|
* clones the student block. Either of those choices also takes the account
|
|
* holder's *own* question panel out of play — the questions are then asked
|
|
* per student, so the server ignores those answers and the browser must not
|
|
* demand them.
|
|
* 3. **Password strength.** The password is scored with zxcvbn (via WordPress's
|
|
* own `wp.passwordStrength`) and a weak one is refused. The server applies
|
|
* its own, coarser rule regardless — see `Auth\PasswordPolicy`.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var PASSWORD = window.usSchedulerPassword || {};
|
|
|
|
/**
|
|
* Gate the form on password strength.
|
|
*
|
|
* The verdict is attached to the field with `setCustomValidity()` rather than
|
|
* by disabling the submit button: the form has up to three submits (the plain
|
|
* one, the guardian-mode early one, and step two's) plus a "Next" that
|
|
* already gates on `checkValidity()`, and an invalid field blocks all of them
|
|
* at once without any of them having to know why.
|
|
*/
|
|
function enhancePassword(form) {
|
|
var field = form.querySelector('#us-reg-pass');
|
|
var output = form.querySelector('#us-reg-pass-strength');
|
|
var strings = PASSWORD.strings || {};
|
|
|
|
if (!field || !PASSWORD.minScore) {
|
|
return;
|
|
}
|
|
|
|
// What the password must not simply repeat back. Mirrors the identity
|
|
// check PasswordPolicy makes server-side.
|
|
function identity() {
|
|
var out = [];
|
|
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
|
|
|
|
for (var i = 0; i < sources.length; i++) {
|
|
var value = (sources[i].value || '').trim();
|
|
if (value) {
|
|
out.push(value);
|
|
if (value.indexOf('@') > 0) {
|
|
out.push(value.split('@')[0]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function assess() {
|
|
var value = field.value || '';
|
|
|
|
if (!value) {
|
|
report('', '');
|
|
return;
|
|
}
|
|
|
|
if (value.length < (PASSWORD.minLength || 8)) {
|
|
report(strings.short, 'short');
|
|
return;
|
|
}
|
|
|
|
// zxcvbn's dictionary is fetched after load, and wp.passwordStrength
|
|
// reports -1 until it arrives. Say nothing and allow the submit in that
|
|
// window — the server still checks, and the next keystroke re-runs this
|
|
// once the dictionary is in.
|
|
if (!window.wp || !window.wp.passwordStrength || typeof window.zxcvbn === 'undefined') {
|
|
report('', '');
|
|
return;
|
|
}
|
|
|
|
var score = window.wp.passwordStrength.meter(value, identity(), '');
|
|
|
|
if (score < 0) {
|
|
report('', '');
|
|
return;
|
|
}
|
|
|
|
if (score >= 3) {
|
|
report(strings.strong, 'strong');
|
|
} else if (score >= PASSWORD.minScore) {
|
|
report(strings.medium, 'medium');
|
|
} else {
|
|
report(score <= 0 ? strings.veryWeak : strings.weak, 'weak');
|
|
}
|
|
}
|
|
|
|
/** Show the verdict, and make it the field's validity at the same time. */
|
|
function report(message, level) {
|
|
var acceptable = '' === level || 'medium' === level || 'strong' === level;
|
|
|
|
if (output) {
|
|
output.textContent = message || '';
|
|
output.className = 'us-password-strength' + (level ? ' is-' + level : '');
|
|
}
|
|
|
|
field.setCustomValidity(acceptable ? '' : message || '');
|
|
}
|
|
|
|
field.addEventListener('input', assess);
|
|
field.addEventListener('blur', assess);
|
|
|
|
// The identity check depends on these, so a password typed first and an
|
|
// email typed second is still caught.
|
|
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
|
|
for (var i = 0; i < sources.length; i++) {
|
|
sources[i].addEventListener('change', assess);
|
|
}
|
|
}
|
|
|
|
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 choices = form.querySelectorAll('.us-registering-for');
|
|
var children = form.querySelector('#us-children');
|
|
|
|
if (!choices.length || !children) {
|
|
return;
|
|
}
|
|
|
|
var addButton = children.querySelector('.us-add-child');
|
|
var nextIndex = 1;
|
|
|
|
/** The selected "who are you registering?" value; 'self' if somehow none is. */
|
|
function mode() {
|
|
for (var i = 0; i < choices.length; i++) {
|
|
if (choices[i].checked) return choices[i].value;
|
|
}
|
|
return 'self';
|
|
}
|
|
|
|
/**
|
|
* Keep the form in step with the choice.
|
|
*
|
|
* Student blocks appear for "students" and "both". The account holder's
|
|
* own question panel is the mirror image: the studio's questions describe
|
|
* a student, so whenever students are being added they are asked per
|
|
* student instead, and the account holder's copy goes out of play.
|
|
* Disabling it rather than hiding it is what stops a `required` question
|
|
* the server will ignore from blocking submit.
|
|
*/
|
|
function sync() {
|
|
var wantsStudents = mode() !== 'self';
|
|
|
|
children.hidden = !wantsStudents;
|
|
|
|
// 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 = wantsStudents;
|
|
}
|
|
|
|
if (!steps) {
|
|
return;
|
|
}
|
|
|
|
var fields = steps.step2.querySelectorAll('input, select, textarea');
|
|
for (var i = 0; i < fields.length; i++) {
|
|
fields[i].disabled = wantsStudents;
|
|
}
|
|
|
|
// 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 = wantsStudents;
|
|
|
|
if (steps.earlySubmit) {
|
|
steps.earlySubmit.hidden = !wantsStudents;
|
|
}
|
|
}
|
|
|
|
for (var c = 0; c < choices.length; c++) {
|
|
choices[c].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);
|
|
enhancePassword(forms[i]);
|
|
}
|
|
});
|
|
})();
|