diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88d5115..dc8c6fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,8 @@ each change under the current top section as you work.
### 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.
+- **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]
diff --git a/assets/css/frontend.css b/assets/css/frontend.css
index 73da434..7a49aa6 100644
--- a/assets/css/frontend.css
+++ b/assets/css/frontend.css
@@ -521,6 +521,32 @@
}
/* 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 {
margin: 0 0 20px;
padding: 0;
diff --git a/docs/features/parent-guardian-accounts.md b/docs/features/parent-guardian-accounts.md
index 445540b..626f5bc 100644
--- a/docs/features/parent-guardian-accounts.md
+++ b/docs/features/parent-guardian-accounts.md
@@ -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
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:
- **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
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.
- **Add** creates another accountless child user and links it. Account-scope
@@ -366,7 +409,8 @@ need — via `GuardianService::contactFor()`.
- Models: `Unsupervised\Schedular\Guardian\GuardianLink`
- Repository: `Unsupervised\Schedular\Guardian\GuardianRepository`
- 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`
- Frontend: `Unsupervised\Schedular\Guardian\FamilyPage` (`[us_family]`)
- Shared question field: `Unsupervised\Schedular\Registration\QuestionField`
diff --git a/src/BlockPreview.php b/src/BlockPreview.php
index c1c3f65..a88f185 100644
--- a/src/BlockPreview.php
+++ b/src/BlockPreview.php
@@ -176,10 +176,26 @@ class BlockPreview {
}
/**
- * Sample family (manage-children) page: two representative children and the
- * add form, with the controls inert so the editor preview cannot post.
+ * Sample family page: the account holder's own details, two representative
+ * children and the add form, with the controls inert so the editor preview
+ * cannot post.
*/
public static function family(): string {
+ $self = sprintf(
+ '
',
- self::note( __( 'Editor preview — signed-in guardians see and manage their own students here.', 'unsupervised-schedular' ) ),
+ '
%s
%s
'
+ . '
%s
%s
',
+ self::note( __( 'Editor preview — signed-in visitors see and manage their own details and students here.', 'unsupervised-schedular' ) ),
esc_html__( 'Your profile', 'unsupervised-schedular' ),
+ $self,
+ esc_html__( 'Your students', 'unsupervised-schedular' ),
$children,
$add
);
diff --git a/src/Guardian/FamilyPage.php b/src/Guardian/FamilyPage.php
index a1c1628..8b7c5f3 100644
--- a/src/Guardian/FamilyPage.php
+++ b/src/Guardian/FamilyPage.php
@@ -10,8 +10,8 @@ use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Val;
/**
- * The guardian's "my family" screen (`[us_family]`): list, add, edit and remove
- * the children they book for.
+ * The guardian's "my family" screen (`[us_family]`): their own details, plus
+ * list, add, edit and remove the children they book for.
*
* 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
@@ -23,6 +23,7 @@ class FamilyPage {
private const RESULT_ADDED = 'added';
private const RESULT_UPDATED = 'updated';
private const RESULT_REMOVED = 'removed';
+ private const RESULT_SELF = 'self';
/**
* Error from the most recent submission processed on `template_redirect`,
@@ -58,6 +59,7 @@ class FamilyPage {
$userId = get_current_user_id();
+ $self = $this->guardians->accountHolder( $userId );
$children = $this->guardians->children( $userId );
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
$error = $this->submitError;
@@ -99,6 +101,7 @@ class FamilyPage {
'add' => $this->handleAdd( $userId ),
'edit' => $this->handleEdit( $userId ),
'remove' => $this->handleRemove( $userId ),
+ 'self' => $this->handleSelf( $userId ),
default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ),
};
@@ -149,6 +152,21 @@ class FamilyPage {
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 {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
@@ -244,6 +262,7 @@ class FamilyPage {
self::RESULT_ADDED => __( 'Student added.', 'unsupervised-schedular' ),
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
self::RESULT_REMOVED => __( 'Student removed.', 'unsupervised-schedular' ),
+ self::RESULT_SELF => __( 'Your details have been updated.', 'unsupervised-schedular' ),
default => '',
};
}
diff --git a/src/Guardian/GuardianService.php b/src/Guardian/GuardianService.php
index 48b4e22..3311624 100644
--- a/src/Guardian/GuardianService.php
+++ b/src/Guardian/GuardianService.php
@@ -163,6 +163,52 @@ class GuardianService {
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
* lesson or enrolment history: their id is referenced by lessons, payments and
@@ -319,6 +365,26 @@ class GuardianService {
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.
*
diff --git a/templates/frontend/family-page.php b/templates/frontend/family-page.php
index d5f26ae..482739e 100644
--- a/templates/frontend/family-page.php
+++ b/templates/frontend/family-page.php
@@ -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 $children
* @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.
@@ -26,6 +27,52 @@ if (! defined('ABSPATH')) {
+
+
+
+
diff --git a/tests/Unit/BlockPreviewTest.php b/tests/Unit/BlockPreviewTest.php
index 5ddfbb0..dfaacdd 100644
--- a/tests/Unit/BlockPreviewTest.php
+++ b/tests/Unit/BlockPreviewTest.php
@@ -74,6 +74,23 @@ class BlockPreviewTest extends TestCase
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
{
$html = BlockPreview::registration();
diff --git a/tests/Unit/Guardian/FamilyPageTest.php b/tests/Unit/Guardian/FamilyPageTest.php
index 608ed26..2ce8cb8 100644
--- a/tests/Unit/Guardian/FamilyPageTest.php
+++ b/tests/Unit/Guardian/FamilyPageTest.php
@@ -76,6 +76,21 @@ class FamilyPageTest extends TestCase
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' => 'grace@example.com',
+ 'birth_year' => '1984',
+ 'is_student' => true,
+ ]);
+ }
+
/**
* 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
@@ -105,6 +120,7 @@ class FamilyPageTest extends TestCase
public function testRenderListsTheGuardiansChildren(): void
{
+ $this->expectAccountHolder();
$this->guardians->shouldReceive('children')->once()->with(5)->andReturn([
['id' => 42, 'name' => 'Ada', 'birth_year' => '2015', 'relationship' => 'Parent'],
]);
@@ -117,6 +133,101 @@ class FamilyPageTest extends TestCase
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('grace@example.com', $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('/]*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
{
$_POST = [
@@ -347,6 +458,7 @@ class FamilyPageTest extends TestCase
{
$_GET = ['us_family' => 'added'];
+ $this->expectAccountHolder();
$this->guardians->shouldReceive('children')->andReturn([]);
$this->questions->shouldReceive('findByScope')->andReturn([]);
diff --git a/tests/Unit/Guardian/GuardianServiceTest.php b/tests/Unit/Guardian/GuardianServiceTest.php
index 76e9ceb..817d4ff 100644
--- a/tests/Unit/Guardian/GuardianServiceTest.php
+++ b/tests/Unit/Guardian/GuardianServiceTest.php
@@ -321,6 +321,87 @@ class GuardianServiceTest extends TestCase
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: 'grace@example.test'));
+
+ self::assertSame(
+ ['name' => 'Grace Hopper', 'email' => 'grace@example.test', '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: 'grace@example.test'));
+
+ self::assertFalse($this->service->accountHolder(5)['is_student']);
+ }
+
public function testRemoveChildUnlinksAndDeletesAChildWithNoHistory(): void
{
$this->guardians->shouldReceive('isGuardianOf')->with(5, 42)->andReturn(true);