From 87cfe921a93ddce4906d7d66bbac82bee5871952 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Thu, 23 Jul 2026 17:50:34 -0300 Subject: [PATCH] Show instructor real name or nickname in group-class views, not the login Add Auth\UserName::format(), which prefers a user's first + last name, then their nickname, avoiding display_name (which can be the login/username). Route the instructor name through it in both the front-end offerings response (instructor_name) and the back-end group-class summary and details views. Tests: composer test (513), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- docs/features/group-classes.md | 6 ++- src/Auth/UserName.php | 35 ++++++++++++++ src/GroupClass/GroupClassController.php | 20 +++++--- src/Offering/OfferingEndpoint.php | 8 ++-- tests/Unit/Auth/UserNameTest.php | 47 +++++++++++++++++++ .../GroupClass/GroupClassControllerTest.php | 32 ++++++++++--- tests/Unit/Offering/OfferingEndpointTest.php | 6 ++- 8 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 src/Auth/UserName.php create mode 100644 tests/Unit/Auth/UserNameTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c158a58..0d6260a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ each change under the current top section as you work. - Cancellation cutoff that limits how close to a lesson a student can cancel. - Studio-defined account-registration questions collected during student sign-up. - Group classes now carry a specific class time (alongside the date and duration), and studio admins can assign the teaching instructor. Assigning an instructor clears their open booking slots at the class time and flags any already-booked lesson that clashes. -- Students see who teaches each group class and when it meets on the enrolment page. +- Students see who teaches each group class and when it meets on the enrolment page. Instructor names in the group-class views (front and back end) show the instructor's real name (first + last) or nickname, never their login/username. ### Changed - Plugin metadata links now point at Unsupervised and the Gitea repository. diff --git a/docs/features/group-classes.md b/docs/features/group-classes.md index 5506236..d0f5b8f 100644 --- a/docs/features/group-classes.md +++ b/docs/features/group-classes.md @@ -22,8 +22,10 @@ A group class offering carries `term_start`/`term_end` plus a `class_time` and a owning `instructor_id` (see `offerings.md`): one-off classes end the day they start; weekly classes run a set number of sessions, all at `class_time`. The class card on the enrolment page shows **when** the class meets (the date or date range -plus the start time) and **who** teaches it (the assigned instructor's display -name, surfaced as `instructor_name` on the `GET /offerings` response). +plus the start time) and **who** teaches it (the assigned instructor's name, +surfaced as `instructor_name` on the `GET /offerings` response). Instructor names +in the group-class views (front and back end) use the instructor's real name +(first + last) or nickname, never their login — see `Auth\UserName::format()`. Assigning an instructor to a scheduled class removes that instructor's open booking slots at the class time and flags any already-booked lesson that clashes; diff --git a/src/Auth/UserName.php b/src/Auth/UserName.php new file mode 100644 index 0000000..186bad1 --- /dev/null +++ b/src/Auth/UserName.php @@ -0,0 +1,35 @@ + 0 ? (string) $fallbackId : ''; + } + + $full = trim( $user->first_name . ' ' . $user->last_name ); + if ( '' !== $full ) { + return $full; + } + + $nickname = trim( $user->nickname ); + if ( '' !== $nickname ) { + return $nickname; + } + + return $fallbackId > 0 ? (string) $fallbackId : ''; + } +} diff --git a/src/GroupClass/GroupClassController.php b/src/GroupClass/GroupClassController.php index 86b5ec5..335a370 100644 --- a/src/GroupClass/GroupClassController.php +++ b/src/GroupClass/GroupClassController.php @@ -8,6 +8,7 @@ use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\RegistrationController; use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Auth\UserName; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\Payment; @@ -73,12 +74,10 @@ class GroupClassController { $rows = array_map( function ( Offering $offering ): array { - $instructor = get_userdata( $offering->instructorId ); - return [ 'id' => $offering->id, 'title' => $offering->title, - 'instructor' => $instructor ? $instructor->display_name : (string) $offering->instructorId, + 'instructor' => $this->instructorName( $offering ), 'when' => $this->whenLabel( $offering ), 'capacity' => $offering->capacity, 'enrolled' => $this->enrollments->countActiveForOffering( (int) $offering->id ), @@ -196,10 +195,8 @@ class GroupClassController { ]; } - $instructor = get_userdata( $offering->instructorId ); - return $this->classSummary( $offering, $enrollments ) + [ - 'instructor' => $instructor ? $instructor->display_name : (string) $offering->instructorId, + 'instructor' => $this->instructorName( $offering ), 'price' => $offering->price, 'currency' => $offering->currency, 'duration' => $offering->durationMinutes, @@ -211,6 +208,17 @@ class GroupClassController { ]; } + /** + * The teaching instructor's display name — their real name or nickname, never + * the login. Falls back to the numeric id when the account is gone. See + * {@see UserName::format()}. + */ + private function instructorName( Offering $offering ): string { + $user = get_userdata( $offering->instructorId ); + + return UserName::format( $user instanceof \WP_User ? $user : null, $offering->instructorId ); + } + /** * Human-readable "when" label for a class: the class date (or weekly date * range) and, when set, the start time. Empty when the class has no date. diff --git a/src/Offering/OfferingEndpoint.php b/src/Offering/OfferingEndpoint.php index 8904a5f..1737df4 100644 --- a/src/Offering/OfferingEndpoint.php +++ b/src/Offering/OfferingEndpoint.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Offering; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Auth\UserName; use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\Val; @@ -84,8 +85,9 @@ class OfferingEndpoint { } /** - * A public-facing offering array with the assigned instructor's display name - * added (empty when the instructor account no longer exists). + * A public-facing offering array with the assigned instructor's name added — + * their real name or nickname, never the login (empty when the instructor + * account no longer exists). See {@see UserName::format()}. * * @return array */ @@ -93,7 +95,7 @@ class OfferingEndpoint { $out = $offering->toArray( includeEtransferEmail: false ); $user = get_userdata( $offering->instructorId ); - $out['instructor_name'] = $user instanceof \WP_User ? $user->display_name : ''; + $out['instructor_name'] = UserName::format( $user instanceof \WP_User ? $user : null ); return $out; } diff --git a/tests/Unit/Auth/UserNameTest.php b/tests/Unit/Auth/UserNameTest.php new file mode 100644 index 0000000..8522b25 --- /dev/null +++ b/tests/Unit/Auth/UserNameTest.php @@ -0,0 +1,47 @@ +first_name = $first; + $user->last_name = $last; + $user->nickname = $nickname; + + return $user; + } + + public function testPrefersFirstAndLastName(): void + { + self::assertSame('Ada Lovelace', UserName::format($this->user('Ada', 'Lovelace', 'ada_login'))); + } + + public function testUsesFirstNameAloneWhenLastNameMissing(): void + { + self::assertSame('Ada', UserName::format($this->user('Ada', '', 'ada_login'))); + } + + public function testFallsBackToNicknameWhenNoRealName(): void + { + self::assertSame('Countess', UserName::format($this->user('', '', 'Countess'))); + } + + public function testFallsBackToIdWhenNothingSet(): void + { + self::assertSame('42', UserName::format($this->user('', '', ''), 42)); + } + + public function testReturnsFallbackIdWhenUserMissing(): void + { + self::assertSame('42', UserName::format(null, 42)); + self::assertSame('', UserName::format(null)); + } +} diff --git a/tests/Unit/GroupClass/GroupClassControllerTest.php b/tests/Unit/GroupClass/GroupClassControllerTest.php index d795926..aa17e14 100644 --- a/tests/Unit/GroupClass/GroupClassControllerTest.php +++ b/tests/Unit/GroupClass/GroupClassControllerTest.php @@ -69,6 +69,24 @@ class GroupClassControllerTest extends TestCase $_GET = []; } + /** + * A WP_User whose real name (and display name) is the given full name, so + * both instructor resolution (first + last) and roster display (display name) + * render it. + */ + private function userNamed(string $full): \WP_User + { + [$first, $last] = array_pad(explode(' ', $full, 2), 2, ''); + + $user = Mockery::mock(\WP_User::class); + $user->first_name = $first; + $user->last_name = $last; + $user->nickname = $full; + $user->display_name = $full; + + return $user; + } + private function offering(int $id, string $title, ?int $capacity): Offering { return new Offering( @@ -109,7 +127,7 @@ class GroupClassControllerTest extends TestCase public function testClassDetailListsRosterWithPaymentStatus(): void { - Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']); + Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); $_GET = ['class_id' => '8']; $offering = $this->offering(8, 'Choir', 10); @@ -131,7 +149,7 @@ class GroupClassControllerTest extends TestCase public function testClassDetailShowsClassSettingsAndInviteControlsForInviteOnly(): void { - Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']); + Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); $_GET = ['class_id' => '8']; $offering = new Offering( @@ -167,7 +185,7 @@ class GroupClassControllerTest extends TestCase public function testClassDetailEnrolmentCountExcludesCancelledButRosterKeepsThem(): void { - Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Grace Hopper']); + Functions\when('get_userdata')->justReturn($this->userNamed('Grace Hopper')); $_GET = ['class_id' => '8']; $offering = $this->offering(8, 'Band', null); @@ -187,7 +205,7 @@ class GroupClassControllerTest extends TestCase public function testClassDetailFreeEnrolmentShowsDashForPayment(): void { - Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Alan Turing']); + Functions\when('get_userdata')->justReturn($this->userNamed('Alan Turing')); $_GET = ['class_id' => '8']; $offering = $this->offering(8, 'Theory', 5); @@ -218,7 +236,7 @@ class GroupClassControllerTest extends TestCase public function testClassDetailWithNoEnrolmentsShowsEmptyMessage(): void { - Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']); + Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); $_GET = ['class_id' => '8']; $offering = $this->offering(8, 'Jazz', 5); @@ -243,7 +261,7 @@ class GroupClassControllerTest extends TestCase public function testStudioAdminPageSummarisesClassesNotStudents(): void { - Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']); + Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); $offering = new Offering( instructorId: 3, @@ -272,7 +290,7 @@ class GroupClassControllerTest extends TestCase public function testStudioAdminCanOpenClassDetailWithInviteControls(): void { // A studio admin (view_all_lessons) opens a class taught by instructor 7. - Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']); + Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace')); $_GET = ['class_id' => '8']; $offering = new Offering( diff --git a/tests/Unit/Offering/OfferingEndpointTest.php b/tests/Unit/Offering/OfferingEndpointTest.php index 2beab89..6fbb350 100644 --- a/tests/Unit/Offering/OfferingEndpointTest.php +++ b/tests/Unit/Offering/OfferingEndpointTest.php @@ -92,7 +92,10 @@ class OfferingEndpointTest extends TestCase public function testIndexIncludesInstructorNameForEachOffering(): void { $instructor = Mockery::mock(\WP_User::class); - $instructor->display_name = 'Ada Lovelace'; + $instructor->first_name = 'Ada'; + $instructor->last_name = 'Lovelace'; + $instructor->nickname = 'ada_login'; + $instructor->display_name = 'ada_login'; Functions\when('get_userdata')->justReturn($instructor); $this->repository->shouldReceive('findAll')->andReturn([$this->group(1, Offering::ACCESS_PUBLIC)]); @@ -100,6 +103,7 @@ class OfferingEndpointTest extends TestCase $data = $this->endpoint->index(new \WP_REST_Request())->get_data(); + // Real name is shown, not the login-style display name. self::assertSame('Ada Lovelace', $data[0]['instructor_name']); }