From 3a954bac57bb7c454785f8f209b380c7fd9c0a1d Mon Sep 17 00:00:00 2001 From: James Griffin Date: Tue, 28 Jul 2026 17:05:00 -0300 Subject: [PATCH] View a policy version's content, and make policy text readable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Policies admin page listed versions but never showed what any of them said, so revising a policy meant retyping it blind into an empty draft box. Each version row now has a View action that renders that version's text on the page, editable in place. A draft is saved back to itself; editing a published or archived version branches a new draft and leaves the original alone, because acceptances are recorded against policy_version_id and text a student agreed to must stay exactly as they saw it. That viewer also exposed why a studio reported the acceptance box as unreadable — one squashed line, overlapping words, a horizontal scrollbar. Bodies are typed into a bare textarea, so most carry no markup, and the raw text was emitted with its blank lines intact but nothing to turn them into paragraphs. PolicyVersion::bodyHtml() now renders every body the way WordPress renders post content (kses, then wpautop) and feeds all three consumers: the booking/enrolment JSON, the signup form, and the new viewer. Bodies written with markup are unaffected. The other half was that .us-policy-body had no CSS whatsoever and inherited whatever the theme did with an unstyled block in a form. It is now a bounded reading box that scrolls vertically and breaks long tokens, so a pasted URL cannot force the page sideways and a long policy cannot push the accept checkbox out of view. RegistrationPage was also never enqueueing the plugin stylesheet, which is why the signup gate looked worst of all. Closes #126 Closes #127 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 + assets/css/frontend.css | 55 ++++ docs/features/policies.md | 12 +- src/Auth/RegistrationPage.php | 4 + src/Policy/PolicyController.php | 76 +++++- src/Policy/PolicyEndpoint.php | 6 +- src/Policy/PolicyVersion.php | 13 + templates/admin/policies.php | 61 +++++ templates/frontend/register-page.php | 2 +- tests/Unit/Auth/RegistrationPageTest.php | 2 + tests/Unit/Policy/PolicyControllerTest.php | 256 +++++++++++++++++++ tests/Unit/Policy/PolicyEndpointTest.php | 31 ++- tests/Unit/Policy/PolicyValueObjectsTest.php | 10 + tests/Unit/TestCase.php | 13 + 14 files changed, 533 insertions(+), 12 deletions(-) create mode 100644 tests/Unit/Policy/PolicyControllerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2931fbb..28dc082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,12 @@ each change under the current top section as you work. ### Added - Every price a student sees now says **when** it is due. Lesson types in the booking form read `50.00 CAD at booking`, and group-class cards read `120.00 CAD up front`, `40.00 CAD weekly` or `40.00 CAD monthly` — the offering's billing mode, in the student's words. A monthly **private lesson** is quoted per lesson (`50.00 CAD per lesson monthly`), since its monthly charge covers every lesson booked that month; a monthly group class is quoted as the monthly figure it is. A free offering still just reads **Free**. +- The **Policies** admin page can now **show you what is actually in a version**. Every row in the versions table has a **View** button that opens that version's text below the table, rendered exactly as students see it at booking and signup, whether the version is the published one, an old archived one, or a draft nobody has seen yet. The text is editable straight from the viewer, and what happens when you save depends on the version: a draft is simply updated in place, while editing a **published or archived version saves your text as a new draft version** and leaves the original exactly as students accepted it. The new draft then opens in the viewer ready to publish. Nothing a student has agreed to is ever rewritten. - Booking a lesson and enrolling in a class now take a **second confirmation that the student agrees to pay**. Above the Confirm button the form restates the price with its cadence, spells out how it is collected ("Charged on the 1st of each month, for that month's lessons"), adds the studio's HST so the figure matches the total actually billed, and requires a tick on "I agree to pay 56.50 CAD at booking." before it will submit — separate from, and in addition to, the studio policies the student accepts above it. Reserving a time weekly quotes the per-lesson fee and the most it can add up to ("up to 12 lessons, 678.00 CAD in total"), since a week another student takes first is simply not booked. Free offerings have nothing to agree to and show no price block. +### Fixed +- Policies are **readable where students have to accept them**. A policy typed as plain paragraphs — the normal way to write one, with no HTML — was being dropped into the booking, enrolment, and signup forms unformatted, collapsing the whole document into a single squashed line with a horizontal scrollbar and words piling on top of each other. Policy text is now formatted the same way WordPress formats post content, so blank lines become real paragraphs, and the acceptance box is styled as a proper bounded reading panel: long policies scroll vertically instead of running off the side of the page, long pasted links wrap rather than forcing the page sideways, and the "I have read and agree" tick stays in view. Policies written with HTML are unaffected. The studio registration page was also missing the plugin's stylesheet entirely, which is why the problem was at its worst there. + ## [1.2.2] ### Added diff --git a/assets/css/frontend.css b/assets/css/frontend.css index c22cf69..99a6b5a 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -282,6 +282,61 @@ font-weight: 600; } +/* Policy acceptance — booking, enrolment, and signup all render this markup. */ +.us-policy { + margin: 16px 0; +} + +.us-policy h4 { + margin: 0 0 6px; +} + +/* + * The body is admin-authored HTML sitting inside whatever layout the theme + * provides, so it gets an explicit reading box rather than inheriting one. + * `overflow-wrap` breaks pasted URLs instead of letting one long token force + * the horizontal scrollbar, and the bounded height keeps a long policy from + * pushing the accept checkbox off the screen. + */ +.us-policy-body { + box-sizing: border-box; + max-width: 100%; + max-height: 260px; + overflow-y: auto; + overflow-x: hidden; + padding: 12px 14px; + margin-bottom: 8px; + border: 1px solid #ddd; + border-radius: 4px; + background: #fafafa; + white-space: normal; + overflow-wrap: break-word; + word-break: break-word; + line-height: 1.5; + text-align: left; +} + +.us-policy-body p, +.us-policy-body ul, +.us-policy-body ol { + margin: 0 0 0.75em; + max-width: 100%; +} + +.us-policy-body ul, +.us-policy-body ol { + padding-left: 1.5em; +} + +.us-policy-body > :last-child { + margin-bottom: 0; +} + +.us-policy-accept, +.us-policies input[type="checkbox"] { + margin-right: 6px; +} + @media (max-width: 640px) { .us-week-grid { grid-template-columns: 1fr; diff --git a/docs/features/policies.md b/docs/features/policies.md index 80b0a0b..c7d1fe2 100644 --- a/docs/features/policies.md +++ b/docs/features/policies.md @@ -40,14 +40,22 @@ The studio admin drafts, versions, and publishes policies (e.g. cancellation, pa ## Versioning & Acceptance Rules - Editing a published policy creates a new `draft` version; the old version stays `published` until the draft is published. +- Editing a `draft` version rewrites it in place — nobody has accepted it yet, so there is nothing to preserve and no new version is created. `PATCH /policies/{id}/versions/{vid}` allows only this case; the admin page also accepts an edit to a `published` or `archived` version and branches a new draft from it. - Publishing a draft sets it `published`, stamps `published_at`, archives the prior version, and points `us_policies.current_version_id` at it. - The registration gate requires acceptance of the `current_version_id` of every policy. Because acceptance is tied to `policy_version_id`, a newly published version is unaccepted and must be re-accepted at the student's next booking. ## Admin Interface **Policies** in wp-admin (`manage_policies`, studio admin only): -- Create a policy; draft and edit version bodies +- Create a policy; draft version bodies +- View the content of any version (`?page=us-policies&policy_id={id}&version_id={vid}`), whatever its status +- Edit from the viewer: a draft is saved in place; editing a published or archived version instead saves the text as a **new draft version** (the viewer follows to it), so text students have already accepted is never rewritten - Publish a draft version; view acceptance history per version +## Rendering a Policy Body +Bodies are typed into a plain textarea, so most are written as blank-line-separated prose with no markup. `PolicyVersion::bodyHtml()` is the single render path — `wp_kses_post()` then `wpautop()`, the same treatment WordPress gives post content — so unmarked-up text arrives as real paragraphs and bodies that do carry markup are left alone. It feeds the booking/enrolment JSON (`GET /policies`), the signup form, and the admin version viewer, which therefore previews exactly what students see. + +The acceptance markup (`.us-policy` / `.us-policy-body`) is styled in `assets/css/frontend.css` as a bounded, vertically scrolling reading box with `overflow-wrap: break-word`, so a long policy or a pasted URL cannot force a horizontal scrollbar or push the accept checkbox out of view. `RegistrationPage` enqueues that stylesheet for the signup gate; `BookingPage` and `GroupClassPage` already did. + ## REST API | Method | Endpoint | Permission | |----------|-----------------------------------------------------------------|-------------------| @@ -74,3 +82,5 @@ cover every policy's current version or the registration is rejected. - `tests/Unit/Policy/PolicyVersionRepositoryTest.php` - `tests/Unit/Policy/AcceptanceRepositoryTest.php` - `tests/Unit/Policy/PolicyServiceTest.php` +- `tests/Unit/Policy/PolicyControllerTest.php` +- `tests/Unit/Policy/PolicyEndpointTest.php` diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php index 5623675..831de7e 100644 --- a/src/Auth/RegistrationPage.php +++ b/src/Auth/RegistrationPage.php @@ -116,6 +116,10 @@ class RegistrationPage { $canRegister = $open || $inviteValid; $inviteOnlyMessage = $this->inviteOnlyMessage( $atts ); + // The signup form carries the same policy-acceptance markup as the booking + // gate, so it needs the plugin stylesheet that formats it. + wp_enqueue_style( 'us-scheduler' ); + // The two-step script only matters when there is a second step to reveal. if ( $canRegister && '' === $successType && [] !== $accountQuestions ) { wp_enqueue_script( 'us-scheduler-register' ); diff --git a/src/Policy/PolicyController.php b/src/Policy/PolicyController.php index 66a2eea..ed62822 100644 --- a/src/Policy/PolicyController.php +++ b/src/Policy/PolicyController.php @@ -19,20 +19,35 @@ class PolicyController { wp_die( esc_html__( 'You do not have permission to manage policies.', 'unsupervised-schedular' ) ); } + $notice = ''; + $viewVersionId = 0; + if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_policy_action' ) ) { - $this->handleFormAction(); + [ $notice, $viewVersionId ] = $this->handleFormAction(); } - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only policy selector. - $policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) ); + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only policy/version selectors. + $policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) ); + if ( 0 === $viewVersionId ) { + $viewVersionId = absint( Val::int( $_GET['version_id'] ?? 0 ) ); + } + // phpcs:enable WordPress.Security.NonceVerification.Recommended + $policyList = $this->policies->findAll(); $selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null; $policyVersions = null !== $selectedPolicy ? $this->versions->findByPolicy( (int) $selectedPolicy->id ) : null; + $viewedVersion = null !== $selectedPolicy ? $this->loadVersionForPolicy( (int) $selectedPolicy->id, $viewVersionId ) : null; include USC_PLUGIN_DIR . 'templates/admin/policies.php'; } - private function handleFormAction(): void { + /** + * Process the posted action. + * + * @return array{string, int} Status notice, and the version to open in the + * viewer (0 to leave the current selection alone). + */ + private function handleFormAction(): array { // Nonce is verified by the caller (renderPage) before this method runs. // phpcs:disable WordPress.Security.NonceVerification.Missing $action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) ); @@ -53,12 +68,12 @@ class PolicyController { $this->service->createPolicy( $title, $slug, $scope ); } - return; + return [ '', 0 ]; } $policyId = absint( Val::int( $_POST['policy_id'] ?? 0 ) ); if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) { - return; + return [ '', 0 ]; } if ( 'add_version' === $action ) { @@ -66,6 +81,40 @@ class PolicyController { $this->service->addDraftVersion( $policyId, $body ); } + if ( 'edit_version' === $action ) { + $source = $this->loadVersionForPolicy( $policyId, absint( Val::int( $_POST['version_id'] ?? 0 ) ) ); + if ( null === $source ) { + return [ '', 0 ]; + } + + $body = wp_kses_post( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) ); + + // A draft has never been shown to a student, so it is edited in place. + // A published (or archived) version is what students accepted, so an + // edit branches a new draft and leaves the original untouched. + if ( PolicyVersion::STATUS_DRAFT === $source->status ) { + $this->versions->updateBody( (int) $source->id, $body ); + + return [ + sprintf( + /* translators: %d: the edited version number. */ + __( 'Draft version %d was updated.', 'unsupervised-schedular' ), + $source->versionNumber + ), + (int) $source->id, + ]; + } + + return [ + sprintf( + /* translators: %d: the version number the edit was based on. */ + __( 'Your changes to version %d were saved as a new draft version.', 'unsupervised-schedular' ), + $source->versionNumber + ), + $this->service->addDraftVersion( $policyId, $body ), + ]; + } + if ( 'publish_version' === $action ) { $versionId = absint( Val::int( $_POST['version_id'] ?? 0 ) ); if ( $versionId > 0 ) { @@ -73,5 +122,20 @@ class PolicyController { } } // phpcs:enable WordPress.Security.NonceVerification.Missing + + return [ '', 0 ]; + } + + /** + * Load a version by id, confirming it belongs to the given policy. + */ + private function loadVersionForPolicy( int $policyId, int $versionId ): ?PolicyVersion { + if ( $versionId <= 0 ) { + return null; + } + + $version = $this->versions->findById( $versionId ); + + return null !== $version && $version->policyId === $policyId ? $version : null; } } diff --git a/src/Policy/PolicyEndpoint.php b/src/Policy/PolicyEndpoint.php index d99c217..f07ce05 100644 --- a/src/Policy/PolicyEndpoint.php +++ b/src/Policy/PolicyEndpoint.php @@ -104,9 +104,9 @@ class PolicyEndpoint { 'policy_version_id' => $version->id, 'version_number' => $version->versionNumber, // Bodies are kses'd on every write path, but the booking JS renders - // this HTML raw — sanitise at output too so a missed write path can - // never become stored XSS. - 'body' => wp_kses_post( (string) $version->body ), + // this HTML raw — bodyHtml() sanitises at output too, so a missed + // write path can never become stored XSS. + 'body' => $version->bodyHtml(), ]; } diff --git a/src/Policy/PolicyVersion.php b/src/Policy/PolicyVersion.php index e402756..b80a923 100644 --- a/src/Policy/PolicyVersion.php +++ b/src/Policy/PolicyVersion.php @@ -42,6 +42,19 @@ class PolicyVersion { return self::STATUS_PUBLISHED === $this->status; } + /** + * The body as display-ready HTML. + * + * Policy bodies are typed into a plain textarea, so most are written as + * blank-line-separated prose with no markup at all — dropped into a page + * as-is that collapses into one unreadable run of text. Running the same + * `wpautop()` WordPress applies to post content turns those breaks into + * paragraphs, and leaves bodies that do carry markup alone. + */ + public function bodyHtml(): string { + return wpautop( wp_kses_post( (string) $this->body ) ); + } + /** * Returns a plain array representation of the version. * diff --git a/templates/admin/policies.php b/templates/admin/policies.php index 1d5e3e9..e6f3c09 100644 --- a/templates/admin/policies.php +++ b/templates/admin/policies.php @@ -12,11 +12,17 @@ if (! defined('ABSPATH')) { * @var list<\Unsupervised\Schedular\Policy\Policy> $policyList * @var \Unsupervised\Schedular\Policy\Policy|null $selectedPolicy * @var list<\Unsupervised\Schedular\Policy\PolicyVersion>|null $policyVersions + * @var \Unsupervised\Schedular\Policy\PolicyVersion|null $viewedVersion Version opened in the viewer, if any. + * @var string $notice Status message from the last save. */ ?>

+ +

+ +

@@ -98,6 +104,18 @@ if (! defined('ABSPATH')) { status); ?> publishedAt ? esc_html($version->publishedAt) : '—'; ?> + + + status) : ?> @@ -117,5 +135,48 @@ if (! defined('ABSPATH')) { + + +
+

+ versionNumber, + $viewedVersion->status + )); ?> +

+ + +
+ bodyHtml()); ?> +
+ + status; ?> +

+

+ +

+ + + + + + + +
+
diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php index 9d605b5..5ba1376 100644 --- a/templates/frontend/register-page.php +++ b/templates/frontend/register-page.php @@ -108,7 +108,7 @@ $renderQuestionField = static function (Question $question): void {

title); ?>

-
body); ?>
+
bodyHtml()); ?>