Group class term dates, single-class embed mode, and offering editing
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 1m14s
CI / PHPStan (pull_request) Successful in 1m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 37s
CI / Build Plugin Zip (pull_request) Has been skipped

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="<id>"] (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 <[email protected]>
This commit is contained in:
2026-07-05 23:18:59 -03:00
co-authored by Claude Fable 5
parent 9d8924132c
commit cde8704267
15 changed files with 653 additions and 42 deletions
+55 -1
View File
@@ -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 }),
})
),
},
];
+29 -2
View File
@@ -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 @@
</div>`;
}
// 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 = '<p>No group classes are open for enrolment right now.</p>';
list.innerHTML = singleOfferingId
? '<p>This class is not open for enrolment right now.</p>'
: '<p>No group classes are open for enrolment right now.</p>';
return;
}
list.innerHTML = groups.map((o) => `
<div class="us-class">
<h3>${escHtml(o.title)}</h3>
${termLabel(o) ? `<p>${escHtml(termLabel(o))}</p>` : ''}
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
+7 -3
View File
@@ -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.
+8 -1
View File
@@ -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`
+14
View File
@@ -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 + (N1) 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=<id>`) 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`
+7 -2
View File
@@ -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,
],
],
],
];
}
+10 -2
View File
@@ -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<int|string, mixed> $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<int|string, mixed> $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();
+21
View File
@@ -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 ),
+67 -16
View File
@@ -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
}
+73 -13
View File
@@ -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;
}
?>
<div class="wrap">
<h1><?php esc_html_e('Offerings', 'unsupervised-schedular'); ?></h1>
<h2><?php esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
<h2><?php $editing ? esc_html_e('Edit Offering', 'unsupervised-schedular') : esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
<form method="post">
<?php wp_nonce_field('usc_offering_action'); ?>
<input type="hidden" name="usc_action" value="add">
<?php if ($editing) : ?>
<input type="hidden" name="usc_action" value="update">
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $editing->id); ?>">
<?php else : ?>
<input type="hidden" name="usc_action" value="add">
<?php endif; ?>
<table class="form-table">
<tr>
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="title" id="title" class="regular-text" required></td>
<td><input type="text" name="title" id="title" class="regular-text" required value="<?php echo esc_attr($editing->title ?? ''); ?>"></td>
</tr>
<tr>
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
<td>
<select name="kind" id="kind">
<option value="<?php echo esc_attr(Offering::KIND_PRIVATE_LESSON); ?>"><?php esc_html_e('Private lesson', 'unsupervised-schedular'); ?></option>
<option value="<?php echo esc_attr(Offering::KIND_GROUP_CLASS); ?>"><?php esc_html_e('Group class', 'unsupervised-schedular'); ?></option>
<option value="<?php echo esc_attr(Offering::KIND_GROUP_CLASS); ?>" <?php echo $editing && Offering::KIND_GROUP_CLASS === $editing->kind ? 'selected' : ''; ?>><?php esc_html_e('Group class', 'unsupervised-schedular'); ?></option>
</select>
</td>
</tr>
<tr>
<th><label for="description"><?php esc_html_e('Description', 'unsupervised-schedular'); ?></label></th>
<td><textarea name="description" id="description" class="large-text" rows="4"><?php echo esc_textarea($editing->description ?? ''); ?></textarea></td>
</tr>
<tr>
<th><label for="duration_minutes"><?php esc_html_e('Duration (minutes)', 'unsupervised-schedular'); ?></label></th>
<td><input type="number" name="duration_minutes" id="duration_minutes" min="0" step="1"></td>
<td><input type="number" name="duration_minutes" id="duration_minutes" min="0" step="1" value="<?php echo esc_attr((string) ($editing->durationMinutes ?? '')); ?>"></td>
</tr>
<tr>
<th><label for="price"><?php esc_html_e('Price (dollars)', 'unsupervised-schedular'); ?></label></th>
<td><input type="number" name="price" id="price" min="0" step="0.01" value="0.00"></td>
<td><input type="number" name="price" id="price" min="0" step="0.01" value="<?php echo esc_attr(number_format($editing->price ?? 0.0, 2, '.', '')); ?>"></td>
</tr>
<tr>
<th><label for="billing_mode"><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></label></th>
<td>
<select name="billing_mode" id="billing_mode">
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>"><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
<option value="<?php echo esc_attr(Offering::BILLING_FULL_TERM); ?>"><?php esc_html_e('Full term upfront', 'unsupervised-schedular'); ?></option>
<option value="<?php echo esc_attr(Offering::BILLING_FULL_TERM); ?>" <?php echo $editing && Offering::BILLING_FULL_TERM === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Full term upfront', 'unsupervised-schedular'); ?></option>
</select>
</td>
</tr>
<tr>
<th><?php esc_html_e('Weekly reservation', 'unsupervised-schedular'); ?></th>
<td><label><input type="checkbox" name="allow_weekly" value="1"> <?php esc_html_e('Allow weekly recurring reservation (private)', 'unsupervised-schedular'); ?></label></td>
<td><label><input type="checkbox" name="allow_weekly" value="1" <?php echo $editing && $editing->allowWeekly ? 'checked' : ''; ?>> <?php esc_html_e('Allow weekly recurring reservation (private)', 'unsupervised-schedular'); ?></label></td>
</tr>
<tr>
<th><label for="capacity"><?php esc_html_e('Capacity', 'unsupervised-schedular'); ?></label></th>
<td><input type="number" name="capacity" id="capacity" min="0" step="1"> <span class="description"><?php esc_html_e('Group classes only', 'unsupervised-schedular'); ?></span></td>
<td><input type="number" name="capacity" id="capacity" min="0" step="1" value="<?php echo esc_attr((string) ($editing->capacity ?? '')); ?>"> <span class="description"><?php esc_html_e('Group classes only', 'unsupervised-schedular'); ?></span></td>
</tr>
<tr>
<th><label for="term_start"><?php esc_html_e('Start date', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="term_start" id="term_start" value="<?php echo esc_attr($editing->termStart ?? ''); ?>">
<span class="description"><?php esc_html_e('Group classes only — date of the first class', 'unsupervised-schedular'); ?></span>
</td>
</tr>
<tr>
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
<td>
<label><input type="radio" name="term_recurrence" value="single" <?php echo 'single' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label>
&nbsp;
<label><input type="radio" name="term_recurrence" value="weekly" <?php echo 'weekly' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('Weekly for', 'unsupervised-schedular'); ?></label>
<input type="number" name="term_sessions" min="1" max="52" value="<?php echo esc_attr((string) $termSessions); ?>" style="width:5em;"> <?php esc_html_e('sessions', 'unsupervised-schedular'); ?>
<p class="description"><?php esc_html_e('The end date is calculated from the start date and the number of weekly sessions.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr>
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>"></td>
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->scheduleNote ?? ''); ?>"></td>
</tr>
<tr>
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>"></td>
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
</tr>
<tr>
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
<td><label><input type="checkbox" name="is_active" value="1" <?php echo null === $editing || $editing->isActive ? 'checked' : ''; ?>> <?php esc_html_e('Open for registration', 'unsupervised-schedular'); ?></label></td>
</tr>
</table>
<?php submit_button(esc_html__('Add Offering', 'unsupervised-schedular')); ?>
<?php submit_button($editing ? esc_html__('Update Offering', 'unsupervised-schedular') : esc_html__('Add Offering', 'unsupervised-schedular')); ?>
<?php if ($editing) : ?>
<p><a href="<?php echo esc_url($baseUrl); ?>"><?php esc_html_e('Cancel editing', 'unsupervised-schedular'); ?></a></p>
<?php endif; ?>
</form>
<h2><?php esc_html_e('Current Offerings', 'unsupervised-schedular'); ?></h2>
@@ -75,11 +122,13 @@ if (! defined('ABSPATH')) {
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th style="width:4em;"><?php esc_html_e('ID', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Title', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Duration', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Term', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
@@ -87,13 +136,24 @@ if (! defined('ABSPATH')) {
<tbody>
<?php foreach ($offerings as $offering) : ?>
<tr>
<td><?php echo esc_html((string) $offering->id); ?></td>
<td><?php echo esc_html($offering->title); ?></td>
<td><?php echo esc_html($offering->kind); ?></td>
<td><?php echo $offering->durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '&mdash;'; ?></td>
<td><?php echo esc_html(number_format($offering->price, 2) . ' ' . $offering->currency); ?></td>
<td><?php echo esc_html($offering->billingMode); ?></td>
<td>
<?php if (null === $offering->termStart) : ?>
&mdash;
<?php elseif (null === $offering->termEnd || $offering->termEnd === $offering->termStart) : ?>
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart)); ?>
<?php else : ?>
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart) . ' ' . (string) mysql2date('M j, Y', $offering->termEnd)); ?>
<?php endif; ?>
</td>
<td><?php echo $offering->isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
<td>
<a class="button button-small" href="<?php echo esc_url(add_query_arg('usc_edit', (int) $offering->id, $baseUrl)); ?>"><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></a>
<form method="post" style="display:inline;">
<?php wp_nonce_field('usc_offering_action'); ?>
<input type="hidden" name="usc_action" value="delete">
+3 -1
View File
@@ -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. */
?>
<div id="us-group-app">
<div id="us-group-app"<?php echo $offeringId > 0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>
<div id="us-group-list">
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
</div>
+4 -1
View File
@@ -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
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GroupClassPageTest extends TestCase
{
private GroupClassPage $page;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}
@@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Offering;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class OfferingControllerTest extends TestCase
{
private OfferingRepository&Mockery\MockInterface $repository;
private OfferingController $controller;
protected function setUp(): void
{
parent::setUp();
$this->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('<td>42</td>', $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();
}
}
+24
View File
@@ -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');