CI / Tests (PHP 8.2) (push) Failing after 43s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m55s
CI / Coding Standards (push) Successful in 2m56s
CI / Tests (PHP 8.3) (push) Failing after 2m44s
CI / Build Plugin Zip (push) Skipped
CI / Tests (PHP 8.1) (push) Failing after 50s
Reviewed-on: #155
278 lines
8.1 KiB
JavaScript
278 lines
8.1 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.
|
|
* 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 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);
|
|
enhancePassword(forms[i]);
|
|
}
|
|
});
|
|
})();
|