Demo follow-ups: editable policy name, one-page signup, group classes in upcoming lessons, deletion cleanup
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]>
This commit is contained in:
2026-07-30 11:45:04 -03:00
co-authored by Claude Opus 5
parent 258468093b
commit cb347ffca0
33 changed files with 1419 additions and 291 deletions
+40 -85
View File
@@ -4,18 +4,14 @@
* 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
* 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 question panel out of play — they are not a student
* in that case, so the server ignores those answers and the browser must not
* demand them. Under "both" they are a student and do answer them.
* 3. **Password strength.** The password is scored with zxcvbn (via WordPress's
* 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`.
*/
@@ -28,10 +24,15 @@
* 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.
* 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');
@@ -120,49 +121,17 @@
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');
// 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 (!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;
}
if (!field.checkValidity()) {
event.preventDefault();
field.reportValidity();
}
show(2);
});
if (back) {
back.addEventListener('click', function () {
show(1);
});
}
return {
step2: step2,
next: next,
earlySubmit: form.querySelector('.us-reg-submit-early'),
};
}
/**
@@ -198,9 +167,10 @@
}
}
function enhanceGuardian(form, steps) {
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;
@@ -224,12 +194,15 @@
* each:
*
* - Are student blocks in play? For "students" and "both".
* - Does the account holder answer the studio's questions themselves? For
* "self" and "both" — they are a student in those two, and the questions
* describe a student. A pure guardian answers them per student instead,
* so their own copy goes out of play; disabling it rather than hiding it
* is what stops a `required` question the server will ignore from
* blocking submit.
* - 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();
@@ -237,32 +210,18 @@
var asksSelf = current !== 'students';
children.hidden = !wantsStudents;
children.disabled = !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.
// 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 (!steps) {
return;
}
var fields = steps.step2.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) {
fields[i].disabled = !asksSelf;
}
// 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 = !asksSelf;
if (steps.earlySubmit) {
steps.earlySubmit.hidden = asksSelf;
if (self) {
self.hidden = !asksSelf;
self.disabled = !asksSelf;
}
}
@@ -292,11 +251,7 @@
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);
enhanceGuardian(forms[i]);
enhancePassword(forms[i]);
}
});