Group class term dates, single-class embed mode, and offering editing #60
+55
-1
@@ -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 }),
|
||||
})
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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=<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`
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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') : '—'; ?></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) : ?>
|
||||
—
|
||||
<?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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user