CI / Tests (PHP 8.1) (pull_request) Successful in 1m0s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m0s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m8s
CI / Build Plugin Zip (pull_request) Skipped
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
Five items from the latest demo pass: - A policy's title can be edited from the Policies screen. Only the title moves; the slug is what the gates resolve policies by, so a rename can never detach a policy from acceptances already recorded against it. - Signup is one page again. The studio's registration questions move from a second step behind "Next" onto the main form, in an "About you" panel above the students being added, and that panel also asks an adult student for their birth year (the same us_birth_year meta a child's uses). register.js disables and hides the whole panel for a pure guardian, since the questions describe a student. - The password is re-scored on submit, not only as it is typed. zxcvbn's dictionary arrives after page load, so a password typed straight away was never scored at all and the first the student heard of it was the server rejecting the whole form. - Group-class sessions appear alongside lessons wherever upcoming lessons are listed: the [us_scheduler] panel (students and instructors) and the admin student detail page. GroupClass\SessionSchedule derives them from Offering::sessionWindows(), the same derivation the billing scan uses. They carry kind = 'group_class' and no Cancel action - a session is one date in a term, not a booked slot. - Deleting a user releases what the account was holding: each upcoming lesson is cancelled, its slot freed for rebooking, its pending payment voided, and active class enrolments cancelled. Past lessons and paid history are left alone. Tests: composer test (851), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 5 <[email protected]>
259 lines
7.9 KiB
JavaScript
259 lines
7.9 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. **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. "On behalf of students" *alone* also takes the
|
|
* account holder's own **About you** panel out of play — they are not a
|
|
* student in that case, so the server ignores their birth year and answers
|
|
* and the browser must not demand them. Under "both" they are a student and
|
|
* do fill it in.
|
|
* 2. **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: an invalid field blocks the submit without
|
|
* the button having to know why.
|
|
*
|
|
* It is also re-scored on submit, which is the case the input handler alone
|
|
* misses. zxcvbn's dictionary arrives after page load, and until it does the
|
|
* meter has no opinion and the field is left valid — so a password typed in
|
|
* the first second and submitted straight away would otherwise never be
|
|
* scored at all, and the first the student heard of it would be the server
|
|
* rejecting the whole form.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
// Native validation has already run by the time `submit` fires, so a
|
|
// verdict reached here has to stop the submit by hand.
|
|
form.addEventListener('submit', function (event) {
|
|
assess();
|
|
|
|
if (!field.checkValidity()) {
|
|
event.preventDefault();
|
|
field.reportValidity();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
var choices = form.querySelectorAll('.us-registering-for');
|
|
var children = form.querySelector('#us-children');
|
|
var self = form.querySelector('#us-reg-self');
|
|
|
|
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.
|
|
*
|
|
* Two independent questions, which is why "both" needs its own answer to
|
|
* each:
|
|
*
|
|
* - Are student blocks in play? For "students" and "both".
|
|
* - Is the account holder a student themselves? For "self" and "both" —
|
|
* only then are they asked for their own birth year and answers. A pure
|
|
* guardian gives those per student instead.
|
|
*
|
|
* Each panel is disabled as well as hidden. Disabling is what actually
|
|
* settles it: a `required` field inside a hidden container makes the form
|
|
* unsubmittable with no way to reach the offending control, and a disabled
|
|
* fieldset is neither validated nor submitted. The server enforces the
|
|
* same rules either way.
|
|
*/
|
|
function sync() {
|
|
var current = mode();
|
|
var wantsStudents = current !== 'self';
|
|
var asksSelf = current !== 'students';
|
|
|
|
children.hidden = !wantsStudents;
|
|
children.disabled = !wantsStudents;
|
|
|
|
// Belt and braces alongside the disabled fieldset, so the required
|
|
// state is right if a browser ever renders the block on its own.
|
|
var required = children.querySelectorAll('[data-us-child-required]');
|
|
for (var r = 0; r < required.length; r++) {
|
|
required[r].required = wantsStudents;
|
|
}
|
|
|
|
if (self) {
|
|
self.hidden = !asksSelf;
|
|
self.disabled = !asksSelf;
|
|
}
|
|
}
|
|
|
|
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++) {
|
|
enhanceGuardian(forms[i]);
|
|
enhancePassword(forms[i]);
|
|
}
|
|
});
|
|
})();
|