From cde87042673553340ac96245569f4a21973bea4a Mon Sep 17 00:00:00 2001 From: James Griffin Date: Sun, 5 Jul 2026 23:18:59 -0300 Subject: [PATCH] Group class term dates, single-class embed mode, and offering editing Group class offerings now carry real dates: the add/edit form takes a start date plus a sessions control (one-off, or weekly for N sessions; the end date is computed as start + (N-1) weeks via Offering::weeklyTermEnd). Dates are validated strictly (Y-m-d) and shown in the offerings list and on the student-facing class card, including the weekly session count. [us_group_classes offering=""] (block attribute offeringId, chosen from a dropdown of active classes fetched from the public offerings endpoint) restricts the page to a single class so the enrolment flow can be embedded on a page dedicated to that class; a pinned class that is no longer offered reports itself closed instead of falling back to the catalog. Offerings are now editable from the admin screen: an Edit button prefills the shared add/edit form and saving posts usc_action=update. Updates always preserve the original owner and currency, and non-admin instructors can only load and update their own offerings. The form also gains the previously missing description field and an Active toggle (the admin-UI counterpart of the REST is_active flag) so an edit cannot wipe data the form never collected. Closes #59 Co-Authored-By: Claude Fable 5 --- assets/js/blocks.js | 56 +++- assets/js/group-classes.js | 31 +- docs/features/editor-blocks.md | 10 +- docs/features/group-classes.md | 9 +- docs/features/offerings.md | 14 + src/BlockRegistrar.php | 9 +- src/GroupClass/GroupClassPage.php | 12 +- src/Offering/Offering.php | 21 ++ src/Offering/OfferingController.php | 83 ++++-- templates/admin/offerings.php | 86 +++++- templates/frontend/group-classes-page.php | 4 +- tests/Unit/BlockRegistrarTest.php | 5 +- tests/Unit/GroupClass/GroupClassPageTest.php | 67 +++++ .../Unit/Offering/OfferingControllerTest.php | 264 ++++++++++++++++++ tests/Unit/Offering/OfferingTest.php | 24 ++ 15 files changed, 653 insertions(+), 42 deletions(-) create mode 100644 tests/Unit/GroupClass/GroupClassPageTest.php create mode 100644 tests/Unit/Offering/OfferingControllerTest.php diff --git a/assets/js/blocks.js b/assets/js/blocks.js index 4a0f096..7093251 100644 --- a/assets/js/blocks.js +++ b/assets/js/blocks.js @@ -3,10 +3,11 @@ 'use strict'; const { registerBlockType } = wp.blocks; - const { createElement: el } = wp.element; + const { createElement: el, useState, useEffect } = wp.element; const { useBlockProps, InspectorControls } = wp.blockEditor; const { PanelBody, SelectControl, ToggleControl } = wp.components; const { useSelect } = wp.data; + const apiFetch = wp.apiFetch; const ServerSideRender = wp.serverSideRender; const { __ } = wp.i18n; @@ -42,6 +43,46 @@ }); } + /** + * Dropdown of active group classes fetched from the plugin's public + * offerings endpoint. Values are offering IDs; 0 means all classes. + */ + function GroupClassSelect(props) { + const [offerings, setOfferings] = useState(null); + + useEffect(() => { + apiFetch({ path: '/us-scheduler/v1/offerings?kind=group_class' }) + .then(setOfferings) + .catch(() => setOfferings([])); + }, []); + + const options = [{ label: __('All classes', 'unsupervised-schedular'), value: '0' }].concat( + (offerings || []).map((o) => ({ + label: o.title || __('(no title)', 'unsupervised-schedular'), + value: String(o.id), + })) + ); + + // A previously chosen class that is no longer offered (deleted or + // deactivated) keeps its stored id visible instead of silently + // pretending "All classes" is selected. + const value = String(props.value || 0); + if (offerings !== null && !options.some((opt) => opt.value === value)) { + options.push({ + label: __('Unavailable class #', 'unsupervised-schedular') + value, + value: value, + }); + } + + return el(SelectControl, { + label: props.label, + help: props.help, + value: value, + options: options, + onChange: (newValue) => props.onChange(parseInt(newValue, 10) || 0), + }); + } + const blocks = [ { name: 'us-scheduler/booking', @@ -116,6 +157,19 @@ icon: 'groups', keywords: ['group', 'class', 'enrol'], shortcode: 'us_group_classes', + attributes: { + offeringId: { type: 'number', default: 0 }, + }, + inspector: (attributes, setAttributes) => el( + PanelBody, + { title: __('Classes shown', 'unsupervised-schedular') }, + el(GroupClassSelect, { + label: __('Class', 'unsupervised-schedular'), + help: __('Show only one group class, for embedding on a page dedicated to it.', 'unsupervised-schedular'), + value: attributes.offeringId, + onChange: (offeringId) => setAttributes({ offeringId }), + }) + ), }, ]; diff --git a/assets/js/group-classes.js b/assets/js/group-classes.js index 375ddda..b28661d 100644 --- a/assets/js/group-classes.js +++ b/assets/js/group-classes.js @@ -10,6 +10,10 @@ const errorBox = document.getElementById('us-group-error'); const { restUrl, nonce } = usScheduler; + // When the shortcode/block pins a single offering, only that class is + // shown, so the page can be embedded alongside a full class description. + const singleOfferingId = Number(app.dataset.offering || 0); + function apiFetch(path, options = {}) { return fetch(restUrl + path, { ...options, @@ -68,16 +72,39 @@ `; } + // Parse a Y-m-d date into local time; new Date('Y-m-d') would parse as + // UTC midnight and can display as the previous day in western timezones. + function formatDate(ymd) { + const [y, m, d] = ymd.split('-').map(Number); + return new Date(y, m - 1, d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + } + + function termLabel(o) { + if (!o.term_start) return ''; + if (!o.term_end || o.term_end === o.term_start) { + return formatDate(o.term_start); + } + const weekMs = 7 * 24 * 60 * 60 * 1000; + const sessions = Math.round((new Date(o.term_end) - new Date(o.term_start)) / weekMs) + 1; + return `${formatDate(o.term_start)} – ${formatDate(o.term_end)} (${sessions} weekly sessions)`; + } + function renderClasses(offerings) { - const groups = offerings.filter((o) => o.kind === 'group_class'); + let groups = offerings.filter((o) => o.kind === 'group_class'); + if (singleOfferingId) { + groups = groups.filter((o) => Number(o.id) === singleOfferingId); + } if (!groups.length) { - list.innerHTML = '

No group classes are open for enrolment right now.

'; + list.innerHTML = singleOfferingId + ? '

This class is not open for enrolment right now.

' + : '

No group classes are open for enrolment right now.

'; return; } list.innerHTML = groups.map((o) => `

${escHtml(o.title)}

+ ${termLabel(o) ? `

${escHtml(termLabel(o))}

` : ''} ${o.schedule_note ? `

${escHtml(o.schedule_note)}

` : ''} ${o.description ? `

${escHtml(o.description)}

` : ''}

${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}

diff --git a/docs/features/editor-blocks.md b/docs/features/editor-blocks.md index a2af7ea..0f39d51 100644 --- a/docs/features/editor-blocks.md +++ b/docs/features/editor-blocks.md @@ -21,8 +21,7 @@ transform. ## Block options -Two blocks have sidebar (inspector) options controlling where their -logged-in/logged-out link sends the visitor: +Three blocks have sidebar (inspector) options: | Block | Attribute | Default | Effect | |---|---|---|---| @@ -30,9 +29,14 @@ logged-in/logged-out link sends the visitor: | `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. | | `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. | | `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. | +| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. `0` = browse all classes. Shortcode equivalent: `[us_group_classes offering="…"]`. | The page selects list all published pages; if a chosen page is later deleted, -the blocks fall back to their defaults. The link targets are also available +the blocks fall back to their defaults. The group-classes block's class +select is a dropdown of active group classes fetched from +`GET /us-scheduler/v1/offerings?kind=group_class`; a stored class that is no +longer offered shows as "Unavailable class #N" rather than silently falling +back to all classes. The link targets are also available to the shortcodes as `[us_booking login_page_id="…"]` and `[us_student_login booking_page_id="…"]`; auto-redirect is block-only. diff --git a/docs/features/group-classes.md b/docs/features/group-classes.md index fc60c49..def60c4 100644 --- a/docs/features/group-classes.md +++ b/docs/features/group-classes.md @@ -15,6 +15,12 @@ Students enrol in a group class — an offering of kind `group_class` — as a c | `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` | | `enrolled_at` | DATETIME | Insertion time | +## Class Dates +A group class offering carries `term_start`/`term_end` (see `offerings.md`): +one-off classes end the day they start; weekly classes run a set number of +sessions. The class card on the enrolment page shows the date or date range +with the session count. + ## Enrolment Flow 1. Student opens a group class from the offering catalog. 2. Student answers the offering's questions (`GET /offerings/{id}/questions`). @@ -50,7 +56,7 @@ instructor's group classes if the caller has `view_own_lessons` on those offerin - Model: `Unsupervised\Schedular\GroupClass\Enrollment` - Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_lessons`) - REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint` -- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode) +- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute) - Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`) > **Payment seam:** payment is deferred to #7. An enrolment is created with @@ -62,3 +68,4 @@ instructor's group classes if the caller has `view_own_lessons` on those offerin ## Tests - `tests/Unit/GroupClass/EnrollmentTest.php` - `tests/Unit/GroupClass/EnrollmentRepositoryTest.php` +- `tests/Unit/GroupClass/GroupClassPageTest.php` diff --git a/docs/features/offerings.md b/docs/features/offerings.md index be2770e..6a03f7d 100644 --- a/docs/features/offerings.md +++ b/docs/features/offerings.md @@ -28,11 +28,24 @@ An offering is anything a student can register for: a private-lesson type (30 or - `one_time` — charged once at booking (a single private lesson). - `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`. +## Term Dates +Group classes carry a term: `term_start` is the date of the first class and +`term_end` the last. The add-offering form takes a start date plus a sessions +control — **one-off** (the term ends the day it starts) or **weekly for N +sessions** (`term_end = term_start + (N−1) weeks`, computed by +`Offering::weeklyTermEnd()`). Dates are validated by `Offering::normalizeDate()` +(strict `Y-m-d`); an invalid start date leaves both term columns NULL. The +student-facing class card shows the date (one-off) or the date range with the +weekly session count. + ## Admin Interface Studio admin and instructors manage offerings under **Offerings** in wp-admin. - Studio admin (`manage_offerings`) manages offerings for any instructor. - Instructor (`manage_offerings`) manages only their own. - Each offering's intake questions are edited from the offering screen (see `registration-questions.md`). +- The offerings list shows each offering's ID (needed for `[us_group_classes offering="…"]`) and its term dates. +- **Edit** on a row reloads the page (`?usc_edit=`) with the form prefilled; saving posts `usc_action=update`. Owner and currency are always preserved on update, so a form submission can never reassign an offering. Non-admin instructors can only load and update their own offerings. +- The form includes a **description** textarea and an **Active — open for registration** checkbox (unchecking hides the offering from students without deleting it — the admin-UI counterpart of the REST `is_active` flag). ## REST API | Method | Endpoint | Permission | @@ -51,5 +64,6 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin. - REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` ## Tests +- `tests/Unit/Offering/OfferingControllerTest.php` - `tests/Unit/Offering/OfferingRepositoryTest.php` - `tests/Unit/Offering/OfferingTest.php` diff --git a/src/BlockRegistrar.php b/src/BlockRegistrar.php index 217f7aa..fad99d7 100644 --- a/src/BlockRegistrar.php +++ b/src/BlockRegistrar.php @@ -42,7 +42,7 @@ class BlockRegistrar { wp_register_script( self::SCRIPT_HANDLE, USC_PLUGIN_URL . 'assets/js/blocks.js', - [ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-core-data', 'wp-server-side-render', 'wp-i18n' ], + [ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-core-data', 'wp-server-side-render', 'wp-i18n', 'wp-api-fetch' ], USC_VERSION, true ); @@ -108,7 +108,12 @@ class BlockRegistrar { ], 'us-scheduler/group-classes' => [ 'render' => [ $this, 'renderGroupClasses' ], - 'attributes' => [], + 'attributes' => [ + 'offeringId' => [ + 'type' => 'number', + 'default' => 0, + ], + ], ], ]; } diff --git a/src/GroupClass/GroupClassPage.php b/src/GroupClass/GroupClassPage.php index 2e8c3d7..a6edbc7 100644 --- a/src/GroupClass/GroupClassPage.php +++ b/src/GroupClass/GroupClassPage.php @@ -4,15 +4,21 @@ declare(strict_types=1); namespace Unsupervised\Schedular\GroupClass; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Val; class GroupClassPage { /** * Renders the group-class enrolment shortcode output. * - * @param array $atts Shortcode attributes (unused — reserved for future options). + * Supported attributes: `offering` (shortcode) / `offeringId` (block) — an + * offering id that restricts the page to a single class, so the shortcode + * can be embedded on a page dedicated to that class. 0 or absent shows the + * full browsable catalog. + * + * @param array $atts Shortcode or block attributes. */ - public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + public function render( array $atts ): string { if ( ! is_user_logged_in() ) { $permalink = get_permalink(); @@ -31,6 +37,8 @@ class GroupClassPage { wp_enqueue_style( 'us-scheduler' ); wp_enqueue_script( 'us-scheduler-group' ); + $offeringId = absint( Val::int( $atts['offering'] ?? $atts['offeringId'] ?? 0 ) ); + ob_start(); include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php'; return (string) ob_get_clean(); diff --git a/src/Offering/Offering.php b/src/Offering/Offering.php index 313c174..c128488 100644 --- a/src/Offering/Offering.php +++ b/src/Offering/Offering.php @@ -46,6 +46,27 @@ class Offering { public readonly ?int $id = null, ) {} + /** + * Normalise a submitted term date to canonical `Y-m-d`, or null when it is + * not a real calendar date. Round-trips through DateTimeImmutable so + * strings PHP would silently coerce (e.g. `2026-02-30`) are rejected. + */ + public static function normalizeDate( string $value ): ?string { + $date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value ); + + return false !== $date && $date->format( 'Y-m-d' ) === $value ? $date->format( 'Y-m-d' ) : null; + } + + /** + * Last class date of a weekly term: the start date plus `$occurrences - 1` + * weeks. A one-off class (one occurrence) ends the day it starts. + */ + public static function weeklyTermEnd( string $termStart, int $occurrences ): string { + $weeks = max( 1, $occurrences ) - 1; + + return ( new \DateTimeImmutable( $termStart ) )->modify( '+' . ( 7 * $weeks ) . ' days' )->format( 'Y-m-d' ); + } + public static function fromRow( \stdClass $row ): self { return new self( instructorId: Val::int( $row->instructor_id ), diff --git a/src/Offering/OfferingController.php b/src/Offering/OfferingController.php index 87e90cb..406b675 100644 --- a/src/Offering/OfferingController.php +++ b/src/Offering/OfferingController.php @@ -22,6 +22,20 @@ class OfferingController { $this->handleFormAction( $instructorId, $manageAll ); } + // View-state query param only (which offering the form is editing) — + // nothing is mutated from it, so no nonce applies. + // phpcs:disable WordPress.Security.NonceVerification.Recommended + $editId = absint( Val::int( $_GET['usc_edit'] ?? 0 ) ); + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + $editing = null; + if ( $editId > 0 ) { + $candidate = $this->repository->findById( $editId ); + if ( $candidate && ( $manageAll || $candidate->instructorId === $instructorId ) ) { + $editing = $candidate; + } + } + $offerings = $manageAll ? $this->repository->findAll() : $this->repository->findAll( $instructorId ); @@ -35,7 +49,23 @@ class OfferingController { $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); if ( 'add' === $action ) { - $this->addOffering( $instructorId ); + $offering = $this->offeringFromPost( $instructorId ); + if ( null !== $offering ) { + $this->repository->insert( $offering ); + } + } + + if ( 'update' === $action ) { + $offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) ); + if ( $offeringId > 0 ) { + $existing = $this->repository->findById( $offeringId ); + if ( $existing && ( $manageAll || $existing->instructorId === $instructorId ) ) { + $offering = $this->offeringFromPost( $instructorId, $existing ); + if ( null !== $offering ) { + $this->repository->update( $offeringId, $offering ); + } + } + } } if ( 'delete' === $action ) { @@ -50,13 +80,20 @@ class OfferingController { // phpcs:enable WordPress.Security.NonceVerification.Missing } - private function addOffering( int $instructorId ): void { + /** + * Build an offering from the submitted add/edit form, or null when the + * submission is invalid. When `$existing` is given the result is an edit: + * it keeps the existing id, owner, and currency so an update can never + * reassign an offering to whoever happens to submit the form. + */ + private function offeringFromPost( int $instructorId, ?Offering $existing = null ): ?Offering { + // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing $title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) ); $kind = sanitize_key( Val::string( wp_unslash( $_POST['kind'] ?? '' ) ) ); if ( '' === $title || ! in_array( $kind, Offering::VALID_KINDS, true ) ) { - return; + return null; } $billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) ); @@ -67,19 +104,33 @@ class OfferingController { $duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) ); $capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) ); - $this->repository->insert( - new Offering( - instructorId: $instructorId, - kind: $kind, - title: $title, - price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ), - billingMode: $billingMode, - durationMinutes: $duration > 0 ? $duration : null, - allowWeekly: isset( $_POST['allow_weekly'] ), - capacity: $capacity > 0 ? $capacity : null, - scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ), - etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ), - ) + // Term dates: a class either meets once (term ends the day it starts) + // or repeats weekly for a set number of sessions. + $termStart = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['term_start'] ?? '' ) ) ) ); + $termEnd = null; + if ( null !== $termStart ) { + $recurrence = sanitize_key( Val::string( wp_unslash( $_POST['term_recurrence'] ?? 'single' ) ) ); + $sessions = absint( Val::int( $_POST['term_sessions'] ?? 1 ) ); + $termEnd = 'weekly' === $recurrence ? Offering::weeklyTermEnd( $termStart, $sessions ) : $termStart; + } + + return new Offering( + instructorId: null !== $existing ? $existing->instructorId : $instructorId, + kind: $kind, + title: $title, + price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ), + currency: null !== $existing ? $existing->currency : 'CAD', + billingMode: $billingMode, + description: $this->nullableText( sanitize_textarea_field( Val::string( wp_unslash( $_POST['description'] ?? '' ) ) ) ), + durationMinutes: $duration > 0 ? $duration : null, + allowWeekly: isset( $_POST['allow_weekly'] ), + capacity: $capacity > 0 ? $capacity : null, + termStart: $termStart, + termEnd: $termEnd, + scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ), + etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ), + isActive: isset( $_POST['is_active'] ), + id: $existing?->id, ); // phpcs:enable WordPress.Security.NonceVerification.Missing } diff --git a/templates/admin/offerings.php b/templates/admin/offerings.php index 6245f04..3a4f4cd 100644 --- a/templates/admin/offerings.php +++ b/templates/admin/offerings.php @@ -7,64 +7,111 @@ if (! defined('ABSPATH')) { exit; } -/** @var list<\Unsupervised\Schedular\Offering\Offering> $offerings */ +/** + * @var list<\Unsupervised\Schedular\Offering\Offering> $offerings + * @var \Unsupervised\Schedular\Offering\Offering|null $editing Offering loaded into the form, or null when adding. + */ + +$baseUrl = admin_url('admin.php?page=us-offerings'); + +// Prefill the sessions control from the stored term dates: a term longer than +// one day was created as weekly sessions one week apart. +$termRecurrence = 'single'; +$termSessions = 10; +if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $editing->termEnd !== $editing->termStart) { + $termRecurrence = 'weekly'; + $termSessions = (int) round(((int) strtotime($editing->termEnd) - (int) strtotime($editing->termStart)) / 604800) + 1; +} ?>

-

+

- + + + + + + - + + + + + - + - + - + - + + + + + + + + + - + - + + + + +
+ + +
+ +   + + +

+
- + + +

+

@@ -75,11 +122,13 @@ if (! defined('ABSPATH')) { + + @@ -87,13 +136,24 @@ if (! defined('ABSPATH')) { + + ', $html); + self::assertStringContainsString('Sep 8, 2026 – Nov 10, 2026', $html); + } + + public function testUpdateAppliesChangesButPreservesOwnerAndCurrency(): void + { + $existing = new Offering( + instructorId: 9, + kind: Offering::KIND_GROUP_CLASS, + title: 'Ballet Beginners', + currency: 'USD', + id: 42, + ); + + $_POST = [ + 'usc_action' => 'update', + 'offering_id' => '42', + 'title' => 'Ballet Intermediate', + 'kind' => Offering::KIND_GROUP_CLASS, + 'description' => 'A step up.', + 'capacity' => '6', + 'is_active' => '1', + ]; + + $this->repository->shouldReceive('findById')->once()->with(42)->andReturn($existing); + $this->repository->shouldReceive('update')->once()->with(42, Mockery::on( + static fn (Offering $o) => 9 === $o->instructorId + && 'USD' === $o->currency + && 'Ballet Intermediate' === $o->title + && 'A step up.' === $o->description + && 6 === $o->capacity + && $o->isActive + ))->andReturn(true); + $this->repository->shouldReceive('findAll')->andReturn([]); + + $this->render(); + } + + public function testUpdateWithoutActiveCheckboxDeactivatesTheOffering(): void + { + $existing = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 42); + + $_POST = [ + 'usc_action' => 'update', + 'offering_id' => '42', + 'title' => 'Choir', + 'kind' => Offering::KIND_GROUP_CLASS, + ]; + + $this->repository->shouldReceive('findById')->once()->with(42)->andReturn($existing); + $this->repository->shouldReceive('update')->once()->with(42, Mockery::on( + static fn (Offering $o) => ! $o->isActive + ))->andReturn(true); + $this->repository->shouldReceive('findAll')->andReturn([]); + + $this->render(); + } + + public function testNonAdminCannotUpdateAnotherInstructorsOffering(): void + { + Functions\when('current_user_can')->alias( + static fn (string $cap) => 'manage_instructors' !== $cap + ); + + $foreign = new Offering(instructorId: 4, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 42); + + $_POST = [ + 'usc_action' => 'update', + 'offering_id' => '42', + 'title' => 'Hijacked', + 'kind' => Offering::KIND_GROUP_CLASS, + ]; + + $this->repository->shouldReceive('findById')->once()->with(42)->andReturn($foreign); + $this->repository->shouldNotReceive('update'); + $this->repository->shouldReceive('findAll')->andReturn([]); + + $this->render(); + } + + public function testEditQueryParamPrefillsTheForm(): void + { + $editing = new Offering( + instructorId: 3, + kind: Offering::KIND_GROUP_CLASS, + title: 'Ballet Beginners', + description: 'For new dancers.', + capacity: 8, + termStart: '2026-09-08', + termEnd: '2026-11-10', + id: 42, + ); + + $_GET = ['usc_edit' => '42']; + + $this->repository->shouldReceive('findById')->once()->with(42)->andReturn($editing); + $this->repository->shouldReceive('findAll')->andReturn([$editing]); + + $html = $this->render(); + + self::assertStringContainsString('Edit Offering', $html); + self::assertStringContainsString('value="update"', $html); + self::assertStringContainsString('name="offering_id" value="42"', $html); + self::assertStringContainsString('value="Ballet Beginners"', $html); + self::assertStringContainsString('For new dancers.', $html); + self::assertStringContainsString('value="2026-09-08"', $html); + // 2026-09-08 → 2026-11-10 is ten weekly sessions. + self::assertStringContainsString('name="term_sessions" min="1" max="52" value="10"', $html); + self::assertStringContainsString('value="weekly" checked', $html); + self::assertStringContainsString('Update Offering', $html); + } + + public function testNonAdminCannotLoadAnotherInstructorsOfferingIntoTheForm(): void + { + Functions\when('current_user_can')->alias( + static fn (string $cap) => 'manage_instructors' !== $cap + ); + + $foreign = new Offering(instructorId: 4, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 42); + + $_GET = ['usc_edit' => '42']; + + $this->repository->shouldReceive('findById')->once()->with(42)->andReturn($foreign); + $this->repository->shouldReceive('findAll')->andReturn([]); + + $html = $this->render(); + + self::assertStringContainsString('Add Offering', $html); + self::assertStringNotContainsString('Edit Offering', $html); + } + + private function render(): string + { + ob_start(); + $this->controller->renderPage(); + + return (string) ob_get_clean(); + } +} diff --git a/tests/Unit/Offering/OfferingTest.php b/tests/Unit/Offering/OfferingTest.php index 6a04aca..e2c7258 100644 --- a/tests/Unit/Offering/OfferingTest.php +++ b/tests/Unit/Offering/OfferingTest.php @@ -28,6 +28,30 @@ class OfferingTest extends TestCase self::assertSame(42, $offering->id); } + public function testNormalizeDateAcceptsRealDates(): void + { + self::assertSame('2026-09-08', Offering::normalizeDate('2026-09-08')); + } + + public function testNormalizeDateRejectsGarbage(): void + { + self::assertNull(Offering::normalizeDate('')); + self::assertNull(Offering::normalizeDate('not-a-date')); + self::assertNull(Offering::normalizeDate('2026-02-30')); + self::assertNull(Offering::normalizeDate('2026-09-08 10:00')); + } + + public function testWeeklyTermEndAddsOneWeekPerExtraSession(): void + { + self::assertSame('2026-11-10', Offering::weeklyTermEnd('2026-09-08', 10)); + } + + public function testWeeklyTermEndOfSingleSessionIsTheStartDate(): void + { + self::assertSame('2026-09-08', Offering::weeklyTermEnd('2026-09-08', 1)); + self::assertSame('2026-09-08', Offering::weeklyTermEnd('2026-09-08', 0)); + } + public function testDefaults(): void { $offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir'); -- 2.54.0
id); ?> title); ?> kind); ?> durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '—'; ?> price, 2) . ' ' . $offering->currency); ?> billingMode); ?> + termStart) : ?> + — + termEnd || $offering->termEnd === $offering->termStart) : ?> + termStart)); ?> + + termStart) . ' – ' . (string) mysql2date('M j, Y', $offering->termEnd)); ?> + + isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?> +
diff --git a/templates/frontend/group-classes-page.php b/templates/frontend/group-classes-page.php index 7e9a2cf..6c3c2bf 100644 --- a/templates/frontend/group-classes-page.php +++ b/templates/frontend/group-classes-page.php @@ -4,8 +4,10 @@ declare(strict_types=1); if (! defined('ABSPATH')) { exit; } + +/** @var int $offeringId Offering id when the page is restricted to a single class; 0 for the full catalog. */ ?> -
+
0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>

diff --git a/tests/Unit/BlockRegistrarTest.php b/tests/Unit/BlockRegistrarTest.php index 8e12610..5a6f1af 100644 --- a/tests/Unit/BlockRegistrarTest.php +++ b/tests/Unit/BlockRegistrarTest.php @@ -126,7 +126,10 @@ class BlockRegistrarTest extends TestCase array_keys($registered['us-scheduler/student-login']['attributes']) ); self::assertSame([], $registered['us-scheduler/student-register']['attributes']); - self::assertSame([], $registered['us-scheduler/group-classes']['attributes']); + self::assertSame( + ['offeringId'], + array_keys($registered['us-scheduler/group-classes']['attributes']) + ); } public function testRegisterBlocksDoesNotReRegisterAnAlreadyRegisteredStyle(): void diff --git a/tests/Unit/GroupClass/GroupClassPageTest.php b/tests/Unit/GroupClass/GroupClassPageTest.php new file mode 100644 index 0000000..a11ce29 --- /dev/null +++ b/tests/Unit/GroupClass/GroupClassPageTest.php @@ -0,0 +1,67 @@ +page = new GroupClassPage(); + + Functions\when('is_user_logged_in')->justReturn(true); + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_enqueue_style')->justReturn(null); + Functions\when('wp_enqueue_script')->justReturn(null); + Functions\when('absint')->alias(static fn ($value) => abs((int) $value)); + } + + public function testDefaultRenderHasNoOfferingRestriction(): void + { + $html = $this->page->render([]); + + self::assertStringContainsString('id="us-group-app"', $html); + self::assertStringNotContainsString('data-offering', $html); + } + + public function testShortcodeOfferingAttributePinsASingleClass(): void + { + $html = $this->page->render(['offering' => '12']); + + self::assertStringContainsString('data-offering="12"', $html); + } + + public function testBlockOfferingIdAttributePinsASingleClass(): void + { + $html = $this->page->render(['offeringId' => 7]); + + self::assertStringContainsString('data-offering="7"', $html); + } + + public function testGarbageOfferingAttributeIsIgnored(): void + { + $html = $this->page->render(['offering' => 'banana']); + + self::assertStringNotContainsString('data-offering', $html); + } + + public function testLoggedOutVisitorGetsLoginPrompt(): void + { + Functions\when('is_user_logged_in')->justReturn(false); + Functions\when('get_permalink')->justReturn('http://example.com/classes/'); + Functions\when('wp_login_url')->justReturn('http://example.com/wp-login.php'); + + $html = $this->page->render([]); + + self::assertStringContainsString('log in to enrol in a class', $html); + self::assertStringNotContainsString('us-group-app', $html); + } +} diff --git a/tests/Unit/Offering/OfferingControllerTest.php b/tests/Unit/Offering/OfferingControllerTest.php new file mode 100644 index 0000000..79a7057 --- /dev/null +++ b/tests/Unit/Offering/OfferingControllerTest.php @@ -0,0 +1,264 @@ +repository = Mockery::mock(OfferingRepository::class); + $this->controller = new OfferingController($this->repository); + + $_POST = []; + $_GET = []; + + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(3); + Functions\when('check_admin_referer')->justReturn(true); + Functions\when('admin_url')->justReturn('admin.php?page=us-offerings'); + Functions\when('add_query_arg')->alias( + static fn ($key, $value, $url) => $url . '&' . $key . '=' . $value + ); + Functions\when('wp_unslash')->returnArg(); + Functions\when('sanitize_text_field')->returnArg(); + Functions\when('sanitize_textarea_field')->returnArg(); + Functions\when('sanitize_email')->returnArg(); + Functions\when('sanitize_key')->alias( + static fn ($key) => strtolower((string) preg_replace('/[^a-zA-Z0-9_\-]/', '', (string) $key)) + ); + Functions\when('absint')->alias(static fn ($value) => abs((int) $value)); + Functions\when('wp_nonce_field')->justReturn(''); + Functions\when('submit_button')->alias(static function (string $text = ''): void { + echo $text; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- test stub + }); + Functions\when('mysql2date')->alias( + static fn (string $format, string $date) => date($format, (int) strtotime($date)) + ); + } + + public function testAddGroupClassWithWeeklyTermComputesEndDate(): void + { + $_POST = [ + 'usc_action' => 'add', + 'title' => 'Ballet Beginners', + 'kind' => Offering::KIND_GROUP_CLASS, + 'billing_mode' => Offering::BILLING_FULL_TERM, + 'capacity' => '8', + 'term_start' => '2026-09-08', + 'term_recurrence' => 'weekly', + 'term_sessions' => '10', + ]; + + $this->repository->shouldReceive('insert')->once()->with(Mockery::on( + static fn (Offering $o) => '2026-09-08' === $o->termStart && '2026-11-10' === $o->termEnd + ))->andReturn(1); + $this->repository->shouldReceive('findAll')->andReturn([]); + + $this->render(); + } + + public function testAddOneOffGroupClassEndsOnItsStartDate(): void + { + $_POST = [ + 'usc_action' => 'add', + 'title' => 'Recital Workshop', + 'kind' => Offering::KIND_GROUP_CLASS, + 'term_start' => '2026-09-08', + 'term_recurrence' => 'single', + 'term_sessions' => '10', + ]; + + $this->repository->shouldReceive('insert')->once()->with(Mockery::on( + static fn (Offering $o) => '2026-09-08' === $o->termStart && '2026-09-08' === $o->termEnd + ))->andReturn(1); + $this->repository->shouldReceive('findAll')->andReturn([]); + + $this->render(); + } + + public function testInvalidTermStartLeavesTermDatesNull(): void + { + $_POST = [ + 'usc_action' => 'add', + 'title' => 'Choir', + 'kind' => Offering::KIND_GROUP_CLASS, + 'term_start' => 'not-a-date', + 'term_recurrence' => 'weekly', + 'term_sessions' => '10', + ]; + + $this->repository->shouldReceive('insert')->once()->with(Mockery::on( + static fn (Offering $o) => null === $o->termStart && null === $o->termEnd + ))->andReturn(1); + $this->repository->shouldReceive('findAll')->andReturn([]); + + $this->render(); + } + + public function testOfferingListShowsIdAndTermRange(): void + { + $offering = new Offering( + instructorId: 3, + kind: Offering::KIND_GROUP_CLASS, + title: 'Ballet Beginners', + termStart: '2026-09-08', + termEnd: '2026-11-10', + id: 42, + ); + + $this->repository->shouldReceive('findAll')->andReturn([$offering]); + + $html = $this->render(); + + self::assertStringContainsString('
42