Let the account holder edit their own profile details
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 55s
CI / PHPStan (pull_request) Successful in 2m57s
CI / Coding Standards (pull_request) Successful in 3m3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 55s
CI / PHPStan (pull_request) Successful in 2m57s
CI / Coding Standards (pull_request) Successful in 3m3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
The Profile block is headed "Your profile", but the one person on it you could not change was yourself: your name, your birth year, and whether you take lessons yourself were fixed at whatever signup recorded, and correcting any of them meant asking a studio admin. A "Your details" section now opens the page, saved through the same nonce-checked template_redirect post/redirect/get path the child rows use: - Your name, written to display_name and nickname together, for the reason updateChild() does — UserName reads the nickname first, and leaving it behind would put the account's email address back on every screen that names a person. - "I take lessons myself", the positive of us_guardian_only. This makes good on the claim already in bookableStudents() and the feature doc that a guardian-only account can put itself right from the profile page. - Your birth year, held to the same normaliseBirthYear() rule as every other student. The email is shown but not editable: it is the account's user_login as well as its address, so changing it stays a studio-side job. The birth-year field deliberately carries no `required` attribute. It is asked of a student only, and this page loads no JavaScript, so a browser-enforced `required` would leave a guardian who books solely for other people unable to submit the form at all; handleSelf() enforces it against the checkbox instead. Unticking the box does not clear a stored birth year — it says who books, not "forget what is on file". Closes #165 Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -15,6 +15,8 @@ each change under the current top section as you work.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **A registration question can now be asked of students only, and can be required of a student without being required of the account holder.** Every account-signup question was asked of everybody who registered, on the same terms — so "School and grade" had to be put to the adult signing themselves up, and a question a studio needed answered for a child could only be made required by demanding it of everyone. Each question now says who it is asked of — everyone, or only the students you register on behalf of — and carries its own **Required** setting for each: optional for you, required for every student you enrol, is now a thing a studio can ask for. Existing questions are untouched: they stay asked of everyone, and one that was required stays required of everyone.
|
- **A registration question can now be asked of students only, and can be required of a student without being required of the account holder.** Every account-signup question was asked of everybody who registered, on the same terms — so "School and grade" had to be put to the adult signing themselves up, and a question a studio needed answered for a child could only be made required by demanding it of everyone. Each question now says who it is asked of — everyone, or only the students you register on behalf of — and carries its own **Required** setting for each: optional for you, required for every student you enrol, is now a thing a studio can ask for. Existing questions are untouched: they stay asked of everyone, and one that was required stays required of everyone.
|
||||||
|
- **You can now edit your own details on the profile page**, not just your students'. The page is called **Your profile**, and until now the one person on it you could not change was yourself: a mistyped name at signup, or a name that had since changed, meant asking the studio to fix it. **Your details** now sits at the top of the page with your name, your birth year, and whether you take lessons yourself. Your email address is shown but not editable — it is also how you sign in, so changing it stays a studio-side job.
|
||||||
|
- **"I take lessons myself" can be corrected after signup.** Signup asks whether you are registering just yourself, only on behalf of students, or both, and the answer decides whether you are offered as a student when booking. Choosing wrongly — or taking up lessons later alongside the children you book for — used to leave you asking the studio to change it. Ticking the box makes you bookable again and asks for your birth year like any other student; unticking it takes you back off the list without discarding the birth year you already gave, so ticking it back on costs you nothing.
|
||||||
|
|
||||||
## [1.4.1]
|
## [1.4.1]
|
||||||
|
|
||||||
|
|||||||
@@ -521,6 +521,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The guardian's manage-children screen ([us_family]). */
|
/* The guardian's manage-children screen ([us_family]). */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The account holder's own details, set off from the students below so the two
|
||||||
|
* halves of the page do not read as one long form.
|
||||||
|
*/
|
||||||
|
.us-family-self {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-family-self-email span {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Guidance under a control, not a label: it explains when a field matters
|
||||||
|
* rather than naming it, so it is sized down and reads after the input.
|
||||||
|
*/
|
||||||
|
.us-field-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
.us-family-list {
|
.us-family-list {
|
||||||
margin: 0 0 20px;
|
margin: 0 0 20px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -187,7 +187,8 @@ than writing `0`, so "not set" stays the one spelling of "yes, a student".
|
|||||||
|
|
||||||
One guard: a guardian-only account with **nobody linked to it** is still offered
|
One guard: a guardian-only account with **nobody linked to it** is still offered
|
||||||
itself, because an empty picker is no way to book at all. They can put the
|
itself, because an empty picker is no way to book at all. They can put the
|
||||||
account right from the profile page.
|
account right from the profile page — see **Your details** below, where the flag
|
||||||
|
is editable as "I take lessons myself".
|
||||||
|
|
||||||
Per child the form collects:
|
Per child the form collects:
|
||||||
- **Name** (required)
|
- **Name** (required)
|
||||||
@@ -267,9 +268,51 @@ Booking-scope policies are accepted at booking time by whoever is signed in;
|
|||||||
`BookingEndpoint` passes the same `accepted_by` when a guardian books for a
|
`BookingEndpoint` passes the same `accepted_by` when a guardian books for a
|
||||||
child.
|
child.
|
||||||
|
|
||||||
## Managing children
|
## Managing the account
|
||||||
|
|
||||||
`[us_family]` (block: **Profile**) renders the guardian's manage-children screen:
|
### Your details
|
||||||
|
|
||||||
|
The profile screen opens with the account holder's own record, because the
|
||||||
|
alternative was a page called **Your profile** on which the one person who could
|
||||||
|
not be edited was you. The form saves through
|
||||||
|
`GuardianService::updateSelf()` and holds:
|
||||||
|
|
||||||
|
- **Your name** — `display_name` and `nickname`, written together for the reason
|
||||||
|
`updateChild()` does: `UserName` reads the nickname first, and leaving it
|
||||||
|
behind would put the account's email address back on every screen that names a
|
||||||
|
person.
|
||||||
|
- **I take lessons myself** — the positive of `us_guardian_only`, so the form
|
||||||
|
asks the question the way a person answers it and `updateSelf()` is the one
|
||||||
|
place the sense is flipped. This is what makes good on "they can put the
|
||||||
|
account right from the profile page": an account that registered as a pure
|
||||||
|
guardian and later took up lessons — or ticked the wrong radio at signup — can
|
||||||
|
now correct itself instead of asking the studio to.
|
||||||
|
- **Your birth year** — `us_birth_year`, the same meta and the same
|
||||||
|
`normaliseBirthYear()` rule every student is held to.
|
||||||
|
|
||||||
|
Two decisions worth keeping:
|
||||||
|
|
||||||
|
- The birth-year field carries **no `required` attribute**. It is asked of a
|
||||||
|
student only, and the family screen loads no JavaScript, so a browser-enforced
|
||||||
|
`required` would leave a guardian who books solely for other people unable to
|
||||||
|
submit the form at all. `FamilyPage::handleSelf()` enforces it against the
|
||||||
|
checkbox instead, which is where the condition actually lives.
|
||||||
|
- Unticking **I take lessons myself** does **not** clear a stored birth year.
|
||||||
|
The box says who books, not "forget what you know about me", and someone who
|
||||||
|
ticks it back on the next visit should find their details as they left them.
|
||||||
|
|
||||||
|
The **email** is shown but not editable: it is the account's `user_login` as
|
||||||
|
well as its address, so changing it is a studio-side job rather than a
|
||||||
|
profile-screen one.
|
||||||
|
|
||||||
|
The account-scope questions are not re-asked here, in either direction — the
|
||||||
|
child rows do not offer them on edit either, and a studio that needs a newly
|
||||||
|
self-declared student's answers asks for them the same way it would for any
|
||||||
|
other change of circumstance.
|
||||||
|
|
||||||
|
### Managing children
|
||||||
|
|
||||||
|
The rest of `[us_family]` (block: **Profile**) is the manage-children screen:
|
||||||
list the children, add one, edit a name/birth year, remove one.
|
list the children, add one, edit a name/birth year, remove one.
|
||||||
|
|
||||||
- **Add** creates another accountless child user and links it. Account-scope
|
- **Add** creates another accountless child user and links it. Account-scope
|
||||||
@@ -366,7 +409,8 @@ need — via `GuardianService::contactFor()`.
|
|||||||
- Models: `Unsupervised\Schedular\Guardian\GuardianLink`
|
- Models: `Unsupervised\Schedular\Guardian\GuardianLink`
|
||||||
- Repository: `Unsupervised\Schedular\Guardian\GuardianRepository`
|
- Repository: `Unsupervised\Schedular\Guardian\GuardianRepository`
|
||||||
- Service: `Unsupervised\Schedular\Guardian\GuardianService` (child creation,
|
- Service: `Unsupervised\Schedular\Guardian\GuardianService` (child creation,
|
||||||
`canActFor()`, `payerFor()`, `contactFor()`, removal rules)
|
`canActFor()`, `payerFor()`, `contactFor()`, removal rules, and the account
|
||||||
|
holder's own record via `accountHolder()`/`updateSelf()`)
|
||||||
- Login block: `Unsupervised\Schedular\Guardian\ChildLoginGate`
|
- Login block: `Unsupervised\Schedular\Guardian\ChildLoginGate`
|
||||||
- Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`)
|
- Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`)
|
||||||
- Shared question field: `Unsupervised\Schedular\Registration\QuestionField`
|
- Shared question field: `Unsupervised\Schedular\Registration\QuestionField`
|
||||||
|
|||||||
+23
-4
@@ -176,10 +176,26 @@ class BlockPreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sample family (manage-children) page: two representative children and the
|
* Sample family page: the account holder's own details, two representative
|
||||||
* add form, with the controls inert so the editor preview cannot post.
|
* children and the add form, with the controls inert so the editor preview
|
||||||
|
* cannot post.
|
||||||
*/
|
*/
|
||||||
public static function family(): string {
|
public static function family(): string {
|
||||||
|
$self = sprintf(
|
||||||
|
'<h4>%s</h4><p class="us-family-self-email">%s <span>[email protected]</span></p>'
|
||||||
|
. '<p><label for="us-own-name">%s' . self::REQUIRED_MARK . '</label><input type="text" id="us-own-name" value="%s"></p>'
|
||||||
|
. '<p><label><input type="checkbox" checked disabled> %s</label></p>'
|
||||||
|
. '<p><label for="us-own-birth-year">%s</label><input type="number" id="us-own-birth-year" placeholder="YYYY"></p>'
|
||||||
|
. '<p><button type="button" disabled>%s</button></p>',
|
||||||
|
esc_html__( 'Your details', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Email', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Your name', 'unsupervised-schedular' ),
|
||||||
|
esc_attr__( 'Grace Hopper', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'I take lessons myself', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Your birth year', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Save my details', 'unsupervised-schedular' )
|
||||||
|
);
|
||||||
|
|
||||||
$children = '';
|
$children = '';
|
||||||
foreach ( [ 'Ada Lovelace', 'Alan Turing' ] as $name ) {
|
foreach ( [ 'Ada Lovelace', 'Alan Turing' ] as $name ) {
|
||||||
$children .= sprintf(
|
$children .= sprintf(
|
||||||
@@ -202,9 +218,12 @@ class BlockPreview {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'<div class="us-family">%s<h3>%s</h3><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
|
'<div class="us-family">%s<h3>%s</h3><form class="us-family-self">%s</form>'
|
||||||
self::note( __( 'Editor preview — signed-in guardians see and manage their own students here.', 'unsupervised-schedular' ) ),
|
. '<h4>%s</h4><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
|
||||||
|
self::note( __( 'Editor preview — signed-in visitors see and manage their own details and students here.', 'unsupervised-schedular' ) ),
|
||||||
esc_html__( 'Your profile', 'unsupervised-schedular' ),
|
esc_html__( 'Your profile', 'unsupervised-schedular' ),
|
||||||
|
$self,
|
||||||
|
esc_html__( 'Your students', 'unsupervised-schedular' ),
|
||||||
$children,
|
$children,
|
||||||
$add
|
$add
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ use Unsupervised\Schedular\Registration\QuestionRepository;
|
|||||||
use Unsupervised\Schedular\Val;
|
use Unsupervised\Schedular\Val;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The guardian's "my family" screen (`[us_family]`): list, add, edit and remove
|
* The guardian's "my family" screen (`[us_family]`): their own details, plus
|
||||||
* the children they book for.
|
* list, add, edit and remove the children they book for.
|
||||||
*
|
*
|
||||||
* Submissions are processed on `template_redirect` — before any output — and
|
* Submissions are processed on `template_redirect` — before any output — and
|
||||||
* post/redirect/get back to the page, so a refresh cannot resubmit and add the
|
* post/redirect/get back to the page, so a refresh cannot resubmit and add the
|
||||||
@@ -23,6 +23,7 @@ class FamilyPage {
|
|||||||
private const RESULT_ADDED = 'added';
|
private const RESULT_ADDED = 'added';
|
||||||
private const RESULT_UPDATED = 'updated';
|
private const RESULT_UPDATED = 'updated';
|
||||||
private const RESULT_REMOVED = 'removed';
|
private const RESULT_REMOVED = 'removed';
|
||||||
|
private const RESULT_SELF = 'self';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error from the most recent submission processed on `template_redirect`,
|
* Error from the most recent submission processed on `template_redirect`,
|
||||||
@@ -58,6 +59,7 @@ class FamilyPage {
|
|||||||
|
|
||||||
$userId = get_current_user_id();
|
$userId = get_current_user_id();
|
||||||
|
|
||||||
|
$self = $this->guardians->accountHolder( $userId );
|
||||||
$children = $this->guardians->children( $userId );
|
$children = $this->guardians->children( $userId );
|
||||||
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||||
$error = $this->submitError;
|
$error = $this->submitError;
|
||||||
@@ -99,6 +101,7 @@ class FamilyPage {
|
|||||||
'add' => $this->handleAdd( $userId ),
|
'add' => $this->handleAdd( $userId ),
|
||||||
'edit' => $this->handleEdit( $userId ),
|
'edit' => $this->handleEdit( $userId ),
|
||||||
'remove' => $this->handleRemove( $userId ),
|
'remove' => $this->handleRemove( $userId ),
|
||||||
|
'self' => $this->handleSelf( $userId ),
|
||||||
default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ),
|
default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,6 +152,21 @@ class FamilyPage {
|
|||||||
return $error ?? self::RESULT_UPDATED;
|
return $error ?? self::RESULT_UPDATED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the account holder's own details. The birth year is only asked of a
|
||||||
|
* student, so it is the checkbox — not the browser — that decides whether one
|
||||||
|
* is required; the field carries no `required` attribute, or a guardian who
|
||||||
|
* books only for other people could never submit the form at all.
|
||||||
|
*/
|
||||||
|
private function handleSelf( int $userId ): string|\WP_Error {
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||||
|
$isStudent = isset( $_POST['is_student'] );
|
||||||
|
|
||||||
|
$error = $this->guardians->updateSelf( $userId, $this->postString( 'own_name' ), $this->postString( 'own_birth_year' ), $isStudent );
|
||||||
|
|
||||||
|
return $error ?? self::RESULT_SELF;
|
||||||
|
}
|
||||||
|
|
||||||
private function handleRemove( int $guardianId ): string|\WP_Error {
|
private function handleRemove( int $guardianId ): string|\WP_Error {
|
||||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||||
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
||||||
@@ -244,6 +262,7 @@ class FamilyPage {
|
|||||||
self::RESULT_ADDED => __( 'Student added.', 'unsupervised-schedular' ),
|
self::RESULT_ADDED => __( 'Student added.', 'unsupervised-schedular' ),
|
||||||
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
|
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
|
||||||
self::RESULT_REMOVED => __( 'Student removed.', 'unsupervised-schedular' ),
|
self::RESULT_REMOVED => __( 'Student removed.', 'unsupervised-schedular' ),
|
||||||
|
self::RESULT_SELF => __( 'Your details have been updated.', 'unsupervised-schedular' ),
|
||||||
default => '',
|
default => '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,52 @@ class GuardianService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the account holder's own details from the profile screen: their
|
||||||
|
* name, whether they are a student in their own right, and — when they are —
|
||||||
|
* their birth year.
|
||||||
|
*
|
||||||
|
* `$isStudent` is the positive of what {@see META_GUARDIAN_ONLY} stores, so
|
||||||
|
* the form can ask the question the way a person would answer it and this is
|
||||||
|
* the single place the sense is flipped.
|
||||||
|
*
|
||||||
|
* Returns null on success, mirroring {@see updateChild()}.
|
||||||
|
*/
|
||||||
|
public function updateSelf( int $userId, string $name, string $birthYear, bool $isStudent ): ?\WP_Error {
|
||||||
|
$name = trim( $name );
|
||||||
|
if ( '' === $name ) {
|
||||||
|
return new \WP_Error( 'missing_name', __( 'Please give your name.', 'unsupervised-schedular' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $isStudent && 0 === self::normaliseBirthYear( $birthYear ) ) {
|
||||||
|
return new \WP_Error( 'missing_birth_year', self::ownBirthYearError() );
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = wp_update_user(
|
||||||
|
[
|
||||||
|
'ID' => $userId,
|
||||||
|
'display_name' => $name,
|
||||||
|
'nickname' => $name,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $result ) ) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setGuardianOnly( $userId, ! $isStudent );
|
||||||
|
|
||||||
|
// Only written when they are a student. Saying "I only book for other
|
||||||
|
// people" is a statement about who books, not an instruction to forget a
|
||||||
|
// year already on file — and someone who ticks the box back on the next
|
||||||
|
// visit should find their own details as they left them.
|
||||||
|
if ( $isStudent ) {
|
||||||
|
$this->setBirthYear( $userId, $birthYear );
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unlink a child and delete their account. Refused once the child has any
|
* Unlink a child and delete their account. Refused once the child has any
|
||||||
* lesson or enrolment history: their id is referenced by lessons, payments and
|
* lesson or enrolment history: their id is referenced by lessons, payments and
|
||||||
@@ -319,6 +365,26 @@ class GuardianService {
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account holder's own details, as the profile screen's form needs them.
|
||||||
|
* The counterpart to {@see children()} for the person reading the page.
|
||||||
|
*
|
||||||
|
* `is_student` is the positive of {@see META_GUARDIAN_ONLY} — see
|
||||||
|
* {@see updateSelf()}, which reads it back the same way round.
|
||||||
|
*
|
||||||
|
* @return array{name: string, email: string, birth_year: string, is_student: bool}
|
||||||
|
*/
|
||||||
|
public function accountHolder( int $userId ): array {
|
||||||
|
$user = get_userdata( $userId );
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $userId ),
|
||||||
|
'email' => $user instanceof \WP_User ? $user->user_email : '',
|
||||||
|
'birth_year' => $this->birthYear( $userId ),
|
||||||
|
'is_student' => ! self::isGuardianOnly( $userId ),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The guardian behind a child, or null when the student books for themselves.
|
* The guardian behind a child, or null when the student books for themselves.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ if (! defined('ABSPATH')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @var array{name: string, email: string, birth_year: string, is_student: bool} $self The account holder's own details.
|
||||||
* @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children
|
* @var list<array{id: int, name: string, birth_year: string, relationship: string}> $children
|
||||||
* @var list<\Unsupervised\Schedular\Registration\Question> $questions Account-scope questions, asked once per child.
|
* @var list<\Unsupervised\Schedular\Registration\Question> $questions Account-scope questions, asked once per child.
|
||||||
* @var string $error Validation error from the last submission, if any.
|
* @var string $error Validation error from the last submission, if any.
|
||||||
@@ -26,6 +27,52 @@ if (! defined('ABSPATH')) {
|
|||||||
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
|
<p class="us-error" role="alert"><?php echo esc_html($error); ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="" class="us-family-self">
|
||||||
|
<?php wp_nonce_field('us_family'); ?>
|
||||||
|
<input type="hidden" name="us_family_action" value="self">
|
||||||
|
|
||||||
|
<h4><?php esc_html_e('Your details', 'unsupervised-schedular'); ?></h4>
|
||||||
|
|
||||||
|
<?php if ($self['email'] !== '') : ?>
|
||||||
|
<?php /* Shown, not editable: the address is the account's login, and changing it is a studio-side job. */ ?>
|
||||||
|
<p class="us-family-self-email">
|
||||||
|
<?php esc_html_e('Email', 'unsupervised-schedular'); ?>
|
||||||
|
<span><?php echo esc_html($self['email']); ?></span>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<label for="us-own-name"><?php esc_html_e('Your name', 'unsupervised-schedular'); ?> <span class="us-required" aria-hidden="true">*</span></label>
|
||||||
|
<input type="text" name="own_name" id="us-own-name" autocomplete="name" required value="<?php echo esc_attr($self['name']); ?>">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="is_student" value="1"<?php checked($self['is_student']); ?>>
|
||||||
|
<?php esc_html_e('I take lessons myself', 'unsupervised-schedular'); ?>
|
||||||
|
</label>
|
||||||
|
<span class="us-field-hint"><?php esc_html_e('Leave this unticked if you only book for the students below — you will not be offered as a student yourself.', 'unsupervised-schedular'); ?></span>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Asked of a student only, so `required` is deliberately absent: the box
|
||||||
|
* above is what decides, and the browser cannot be told to enforce a
|
||||||
|
* field conditionally without JavaScript this page does not load.
|
||||||
|
* `handleSelf()` enforces it on the server either way.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<p>
|
||||||
|
<label for="us-own-birth-year"><?php esc_html_e('Your birth year', 'unsupervised-schedular'); ?></label>
|
||||||
|
<input type="number" name="own_birth_year" id="us-own-birth-year" value="<?php echo esc_attr($self['birth_year']); ?>" min="1900" max="<?php echo esc_attr(current_time('Y')); ?>" step="1" inputmode="numeric" autocomplete="bday-year" placeholder="<?php esc_attr_e('YYYY', 'unsupervised-schedular'); ?>" aria-describedby="us-own-birth-year-hint">
|
||||||
|
<span class="us-field-hint" id="us-own-birth-year-hint"><?php esc_html_e('Needed only if you take lessons yourself.', 'unsupervised-schedular'); ?></span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<button type="submit"><?php esc_html_e('Save my details', 'unsupervised-schedular'); ?></button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h4><?php esc_html_e('Your students', 'unsupervised-schedular'); ?></h4>
|
||||||
|
|
||||||
<?php if (empty($children)) : ?>
|
<?php if (empty($children)) : ?>
|
||||||
<p><?php esc_html_e('You have not added any students yet. Add one below to start booking lessons for them.', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('You have not added any students yet. Add one below to start booking lessons for them.', 'unsupervised-schedular'); ?></p>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ class BlockPreviewTest extends TestCase
|
|||||||
self::assertStringContainsString('us-editor-note', $html);
|
self::assertStringContainsString('us-editor-note', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview is what someone placing the block styles against, so it has to
|
||||||
|
* show both halves of the page — your own details as well as your students'.
|
||||||
|
*/
|
||||||
|
public function testFamilyPreviewShowsTheAccountHoldersDetailsAndTheirStudents(): void
|
||||||
|
{
|
||||||
|
$html = BlockPreview::family();
|
||||||
|
|
||||||
|
self::assertStringContainsString('class="us-family-self"', $html);
|
||||||
|
self::assertStringContainsString('id="us-own-name"', $html);
|
||||||
|
self::assertStringContainsString('id="us-own-birth-year"', $html);
|
||||||
|
self::assertStringContainsString('I take lessons myself', $html);
|
||||||
|
self::assertStringContainsString('class="us-family-list"', $html);
|
||||||
|
self::assertStringContainsString('class="us-family-add"', $html);
|
||||||
|
self::assertStringContainsString('us-editor-note', $html);
|
||||||
|
}
|
||||||
|
|
||||||
public function testRegistrationPreviewShowsADisabledSampleForm(): void
|
public function testRegistrationPreviewShowsADisabledSampleForm(): void
|
||||||
{
|
{
|
||||||
$html = BlockPreview::registration();
|
$html = BlockPreview::registration();
|
||||||
|
|||||||
@@ -76,6 +76,21 @@ class FamilyPageTest extends TestCase
|
|||||||
return $page;
|
return $page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account holder's own details, which every render reads.
|
||||||
|
*
|
||||||
|
* @param array{name?: string, email?: string, birth_year?: string, is_student?: bool} $overrides
|
||||||
|
*/
|
||||||
|
private function expectAccountHolder(array $overrides = []): void
|
||||||
|
{
|
||||||
|
$this->guardians->shouldReceive('accountHolder')->with(5)->andReturn($overrides + [
|
||||||
|
'name' => 'Grace',
|
||||||
|
'email' => '[email protected]',
|
||||||
|
'birth_year' => '1984',
|
||||||
|
'is_student' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A question required of everyone, or of nobody — the shape every question
|
* A question required of everyone, or of nobody — the shape every question
|
||||||
* had before the account holder and the students could differ, and the shape
|
* had before the account holder and the students could differ, and the shape
|
||||||
@@ -105,6 +120,7 @@ class FamilyPageTest extends TestCase
|
|||||||
|
|
||||||
public function testRenderListsTheGuardiansChildren(): void
|
public function testRenderListsTheGuardiansChildren(): void
|
||||||
{
|
{
|
||||||
|
$this->expectAccountHolder();
|
||||||
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
|
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
|
||||||
['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'],
|
['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'],
|
||||||
]);
|
]);
|
||||||
@@ -117,6 +133,101 @@ class FamilyPageTest extends TestCase
|
|||||||
self::assertStringContainsString('Add a student', $html);
|
self::assertStringContainsString('Add a student', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRenderShowsTheAccountHoldersOwnDetails(): void
|
||||||
|
{
|
||||||
|
$this->expectAccountHolder();
|
||||||
|
$this->guardians->shouldReceive('children')->andReturn([]);
|
||||||
|
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->page->render([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('Your details', $html);
|
||||||
|
self::assertStringContainsString('value="Grace"', $html);
|
||||||
|
self::assertStringContainsString('[email protected]', $html);
|
||||||
|
self::assertStringContainsString('value="1984"', $html);
|
||||||
|
// A student in their own right has the box ticked.
|
||||||
|
self::assertStringContainsString("checked='checked'", $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAGuardianOnlyAccountRendersTheStudentBoxUnticked(): void
|
||||||
|
{
|
||||||
|
$this->expectAccountHolder(['is_student' => false]);
|
||||||
|
$this->guardians->shouldReceive('children')->andReturn([]);
|
||||||
|
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->page->render([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('name="is_student"', $html);
|
||||||
|
self::assertStringNotContainsString("checked='checked'", $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The birth-year field must not carry `required`: it is asked of a student
|
||||||
|
* only, and the browser would otherwise block a guardian who books solely
|
||||||
|
* for other people from ever saving the form.
|
||||||
|
*/
|
||||||
|
public function testTheOwnBirthYearFieldIsNotBrowserRequired(): void
|
||||||
|
{
|
||||||
|
$this->expectAccountHolder(['is_student' => false, 'birth_year' => '']);
|
||||||
|
$this->guardians->shouldReceive('children')->andReturn([]);
|
||||||
|
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->page->render([]);
|
||||||
|
|
||||||
|
self::assertMatchesRegularExpression('/<input[^>]*name="own_birth_year"(?![^>]*\brequired\b)[^>]*>/', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSavingOwnDetailsDelegatesToTheServiceAndRedirects(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'us_family_action' => 'self',
|
||||||
|
'own_name' => 'Grace H',
|
||||||
|
'own_birth_year' => '1984',
|
||||||
|
'is_student' => '1',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->guardians->shouldReceive('updateSelf')->once()->with(5, 'Grace H', '1984', true)->andReturn(null);
|
||||||
|
|
||||||
|
$captured = null;
|
||||||
|
$this->capturingPage($captured)->maybeHandleSubmit();
|
||||||
|
|
||||||
|
self::assertSame('https://studio.test/family/?us_family=self', $captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unticked checkbox is simply absent from the post — that is the "no". */
|
||||||
|
public function testAnAbsentStudentBoxSavesTheAccountAsGuardianOnly(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'us_family_action' => 'self',
|
||||||
|
'own_name' => 'Grace H',
|
||||||
|
'own_birth_year' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->guardians->shouldReceive('updateSelf')->once()->with(5, 'Grace H', '', false)->andReturn(null);
|
||||||
|
|
||||||
|
$captured = null;
|
||||||
|
$this->capturingPage($captured)->maybeHandleSubmit();
|
||||||
|
|
||||||
|
self::assertSame('https://studio.test/family/?us_family=self', $captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOwnDetailsRefusalIsShownRatherThanRedirected(): void
|
||||||
|
{
|
||||||
|
$_POST = ['us_family_action' => 'self', 'own_name' => 'Grace', 'is_student' => '1'];
|
||||||
|
|
||||||
|
$this->guardians->shouldReceive('updateSelf')->once()->andReturn(
|
||||||
|
new \WP_Error('missing_birth_year', 'Please give your birth year.')
|
||||||
|
);
|
||||||
|
|
||||||
|
$captured = null;
|
||||||
|
$page = $this->capturingPage($captured);
|
||||||
|
$page->shouldNotReceive('redirect');
|
||||||
|
|
||||||
|
$page->maybeHandleSubmit();
|
||||||
|
|
||||||
|
self::assertNull($captured);
|
||||||
|
}
|
||||||
|
|
||||||
public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void
|
public function testAddCreatesTheChildRecordsItsAnswersAndRedirects(): void
|
||||||
{
|
{
|
||||||
$_POST = [
|
$_POST = [
|
||||||
@@ -347,6 +458,7 @@ class FamilyPageTest extends TestCase
|
|||||||
{
|
{
|
||||||
$_GET = ['us_family' => 'added'];
|
$_GET = ['us_family' => 'added'];
|
||||||
|
|
||||||
|
$this->expectAccountHolder();
|
||||||
$this->guardians->shouldReceive('children')->andReturn([]);
|
$this->guardians->shouldReceive('children')->andReturn([]);
|
||||||
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
$this->questions->shouldReceive('findByScope')->andReturn([]);
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,87 @@ class GuardianServiceTest extends TestCase
|
|||||||
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testUpdateSelfRenamesAndStoresTheBirthYear(): void
|
||||||
|
{
|
||||||
|
$this->meta[5][GuardianService::META_GUARDIAN_ONLY] = '1';
|
||||||
|
|
||||||
|
Functions\expect('wp_update_user')
|
||||||
|
->once()
|
||||||
|
->with(['ID' => 5, 'display_name' => 'Grace H', 'nickname' => 'Grace H'])
|
||||||
|
->andReturn(5);
|
||||||
|
|
||||||
|
self::assertNull($this->service->updateSelf(5, 'Grace H', '1984', true));
|
||||||
|
|
||||||
|
self::assertSame('1984', $this->meta[5][GuardianService::META_BIRTH_YEAR]);
|
||||||
|
// Saying they take lessons makes them a bookable student again.
|
||||||
|
self::assertFalse(GuardianService::isGuardianOnly(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateSelfMarksTheAccountGuardianOnly(): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_update_user')->justReturn(5);
|
||||||
|
|
||||||
|
self::assertNull($this->service->updateSelf(5, 'Grace H', '', false));
|
||||||
|
|
||||||
|
self::assertSame('1', $this->meta[5][GuardianService::META_GUARDIAN_ONLY]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "I only book for other people" says who books, not "forget my birth year" —
|
||||||
|
* ticking the box back on should not have cost them what was on file.
|
||||||
|
*/
|
||||||
|
public function testUpdateSelfKeepsAStoredBirthYearWhenTheyAreNoLongerAStudent(): void
|
||||||
|
{
|
||||||
|
$this->meta[5][GuardianService::META_BIRTH_YEAR] = '1984';
|
||||||
|
|
||||||
|
Functions\when('wp_update_user')->justReturn(5);
|
||||||
|
|
||||||
|
self::assertNull($this->service->updateSelf(5, 'Grace H', '', false));
|
||||||
|
|
||||||
|
self::assertSame('1984', $this->meta[5][GuardianService::META_BIRTH_YEAR]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateSelfRejectsABlankName(): void
|
||||||
|
{
|
||||||
|
Functions\expect('wp_update_user')->never();
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $this->service->updateSelf(5, ' ', '1984', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser cannot enforce the year conditionally, so the server is the
|
||||||
|
* only thing standing between a student and a nonsense age on their record.
|
||||||
|
*
|
||||||
|
* @dataProvider unusableBirthYears
|
||||||
|
*/
|
||||||
|
public function testUpdateSelfRefusesAnUnusableBirthYearFromAStudent(string $submitted): void
|
||||||
|
{
|
||||||
|
Functions\expect('wp_update_user')->never();
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $this->service->updateSelf(5, 'Grace H', $submitted, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAccountHolderReportsTheirOwnDetails(): void
|
||||||
|
{
|
||||||
|
$this->meta[5][GuardianService::META_BIRTH_YEAR] = '1984';
|
||||||
|
|
||||||
|
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['name' => 'Grace Hopper', 'email' => '[email protected]', 'birth_year' => '1984', 'is_student' => true],
|
||||||
|
$this->service->accountHolder(5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAccountHolderReportsAGuardianOnlyAccountAsNotAStudent(): void
|
||||||
|
{
|
||||||
|
$this->meta[5][GuardianService::META_GUARDIAN_ONLY] = '1';
|
||||||
|
|
||||||
|
Functions\when('get_userdata')->justReturn($this->user(5, 'Grace', 'Hopper', email: '[email protected]'));
|
||||||
|
|
||||||
|
self::assertFalse($this->service->accountHolder(5)['is_student']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void
|
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void
|
||||||
{
|
{
|
||||||
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user