From fc70cde9d5f14eb486c2784bff050b58160498ca Mon Sep 17 00:00:00 2001 From: James Griffin Date: Fri, 12 Jun 2026 12:03:27 -0300 Subject: [PATCH] Add Gutenberg dynamic-block wrappers for the front-end shortcodes Wrap the four shortcodes (us_booking, us_student_login, us_student_register, us_group_classes) in dynamic blocks so pages can be previewed and styled in the block editor. Front-end rendering delegates to the same page objects the shortcodes use; in the editor's block-renderer REST preview a static, script-free BlockPreview is rendered instead (no live REST calls, redirects, or Stripe.js). The editor script (vanilla JS, no build step) registers each block with wp.serverSideRender previews and shortcode transforms; frontend.css is attached as the block style so previews pick up theme styling. Resolves #44 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 + assets/css/frontend.css | 7 ++ assets/js/blocks.js | 65 ++++++++++++ docs/features/editor-blocks.md | 66 ++++++++++++ src/BlockPreview.php | 114 ++++++++++++++++++++ src/BlockRegistrar.php | 126 ++++++++++++++++++++++ src/Plugin.php | 14 ++- src/ShortcodeRegistrar.php | 24 +---- tests/Unit/BlockPreviewTest.php | 57 ++++++++++ tests/Unit/BlockRegistrarTest.php | 167 ++++++++++++++++++++++++++++++ 10 files changed, 624 insertions(+), 20 deletions(-) create mode 100644 assets/js/blocks.js create mode 100644 docs/features/editor-blocks.md create mode 100644 src/BlockPreview.php create mode 100644 src/BlockRegistrar.php create mode 100644 tests/Unit/BlockPreviewTest.php create mode 100644 tests/Unit/BlockRegistrarTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 451d211..4fd3e2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,8 @@ src/ — All plugin PHP (PSR-4 namespace: Unsupervised\Schedula AdminMenu.php — Registers wp-admin menu pages RestRegistrar.php — Registers all REST routes under us-scheduler/v1 ShortcodeRegistrar.php — Registers [us_booking] and [us_student_login] shortcodes + BlockRegistrar.php — Registers Gutenberg dynamic-block wrappers for the shortcodes + BlockPreview.php — Static editor-preview markup for the blocks templates/ — PHP view files included by controllers/shortcodes assets/ — CSS and JS (vanilla JS, no build step) tests/Unit/ — PHPUnit unit tests (PSR-4: Unsupervised\Schedular\Tests\) @@ -66,6 +68,8 @@ All database access goes through repository classes within their domain package. | `AdminMenu` | Registers wp-admin menu pages | | `RestRegistrar` | Registers all REST routes under `us-scheduler/v1` | | `ShortcodeRegistrar` | Registers `[us_booking]` and `[us_student_login]` shortcodes | +| `BlockRegistrar` | Registers Gutenberg dynamic-block wrappers for the shortcodes | +| `BlockPreview` | Static editor-preview markup for the blocks | | `Auth\RoleManager` | Registers `us_instructor` and `us_student` roles with custom caps | | `Auth\LoginPage` | Renders front-end student login form | | `Availability\AvailabilitySlot` | Immutable value object for a slot row | diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 4978dce..27f1a91 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -33,3 +33,10 @@ color: #c00; margin-top: 8px; } + +/* Shown only in block-editor previews (see BlockPreview). */ +.us-editor-note { + font-size: 0.85em; + font-style: italic; + opacity: 0.7; +} diff --git a/assets/js/blocks.js b/assets/js/blocks.js new file mode 100644 index 0000000..b391388 --- /dev/null +++ b/assets/js/blocks.js @@ -0,0 +1,65 @@ +/* global wp */ +(function () { + 'use strict'; + + const { registerBlockType } = wp.blocks; + const { createElement: el } = wp.element; + const { useBlockProps } = wp.blockEditor; + const ServerSideRender = wp.serverSideRender; + const { __ } = wp.i18n; + + const blocks = [ + { + name: 'us-scheduler/booking', + title: __('Lesson Booking', 'unsupervised-schedular'), + description: __('Lets students browse availability and book lessons. Shows a styled preview in the editor.', 'unsupervised-schedular'), + icon: 'calendar-alt', + keywords: ['booking', 'lesson', 'schedule'], + shortcode: 'us_booking', + }, + { + name: 'us-scheduler/student-login', + title: __('Student Login', 'unsupervised-schedular'), + description: __('The front-end login form for students.', 'unsupervised-schedular'), + icon: 'admin-users', + keywords: ['login', 'student', 'sign in'], + shortcode: 'us_student_login', + }, + { + name: 'us-scheduler/student-register', + title: __('Student Registration', 'unsupervised-schedular'), + description: __('The invite-only student registration form.', 'unsupervised-schedular'), + icon: 'welcome-add-page', + keywords: ['register', 'student', 'invite'], + shortcode: 'us_student_register', + }, + { + name: 'us-scheduler/group-classes', + title: __('Group Classes', 'unsupervised-schedular'), + description: __('Lets students browse and enrol in group classes. Shows a styled preview in the editor.', 'unsupervised-schedular'), + icon: 'groups', + keywords: ['group', 'class', 'enrol'], + shortcode: 'us_group_classes', + }, + ]; + + blocks.forEach((def) => { + registerBlockType(def.name, { + apiVersion: 3, + title: def.title, + description: def.description, + icon: def.icon, + category: 'widgets', + keywords: def.keywords, + supports: { html: false, multiple: false }, + example: {}, + edit: function Edit() { + return el('div', useBlockProps(), el(ServerSideRender, { block: def.name })); + }, + save: () => null, + transforms: { + from: [{ type: 'shortcode', tag: def.shortcode }], + }, + }); + }); +}()); diff --git a/docs/features/editor-blocks.md b/docs/features/editor-blocks.md new file mode 100644 index 0000000..2a5080e --- /dev/null +++ b/docs/features/editor-blocks.md @@ -0,0 +1,66 @@ +# Editor Blocks + +Gutenberg dynamic-block wrappers for the plugin's four front-end shortcodes, +so the pages can be previewed and styled inside the block editor instead of +appearing as grey shortcode text. + +## Blocks + +| Block | Wraps shortcode | Front-end renderer | +|---|---|---| +| `us-scheduler/booking` | `[us_booking]` | `Booking\BookingPage::render()` | +| `us-scheduler/student-login` | `[us_student_login]` | `Auth\LoginPage::render()` | +| `us-scheduler/student-register` | `[us_student_register]` | `Auth\RegistrationPage::render()` | +| `us-scheduler/group-classes` | `[us_group_classes]` | `GroupClass\GroupClassPage::render()` | + +The shortcodes remain registered for back-compat; blocks and shortcodes share +the same page objects (constructed once in `Plugin::boot()`), so front-end +output is identical either way. Pasting a shortcode into the block editor +auto-converts it to the matching block via a `transforms.from` shortcode +transform. + +## How it works + +- **`BlockRegistrar`** (`src/BlockRegistrar.php`) hooks `init` and registers + each block with `register_block_type()`: a `render_callback` per block, the + shared editor script (`assets/js/blocks.js`, handle + `us-scheduler-blocks`), and the front-end stylesheet + (`assets/css/frontend.css`, handle `us-scheduler`) as the block `style` so + it also loads inside the editor and previews pick up theme styling. +- **`assets/js/blocks.js`** (vanilla JS, no build step) registers the client + side of each block — title, icon, keywords, shortcode transform — and + renders the editor preview with `wp.serverSideRender`, which fetches the + server-rendered markup via the `/wp/v2/block-renderer` REST route. +- **`BlockPreview`** (`src/BlockPreview.php`) supplies static, script-free + markup for editor previews. `BlockRegistrar::isEditorPreview()` detects the + block-renderer context via the `REST_REQUEST` constant (front-end template + rendering never happens inside a REST request) and renders the preview + instead of the live page. + +## Editor preview behaviour + +Live pages cannot run in the editor: booking and group classes are populated +by JavaScript making authenticated REST calls (and may load Stripe.js), +registration requires a valid invite token, and login short-circuits for +logged-in users (the editing admin always is). Each preview therefore +reproduces the live wrapper elements and CSS classes with representative +placeholder content: + +- **Booking** — `#us-booking-app` with sample `.us-day` / `.us-slot` rows and + disabled Book buttons. +- **Group classes** — `#us-group-app` with a sample `.us-class` card and a + disabled Enrol button. +- **Login** — the real `templates/frontend/login-page.php` template (it has + no request-state dependencies). +- **Registration** — a disabled sample of the `.us-register-form` fields. + +Each preview starts with a `.us-editor-note` paragraph explaining what the +published page shows instead. The note class only appears in editor previews. + +## Tests + +- `tests/Unit/BlockRegistrarTest.php` — hook registration, block/asset + registration, front-end delegation to the page objects, preview-mode + routing. +- `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS + classes/ids and includes the editor note. diff --git a/src/BlockPreview.php b/src/BlockPreview.php new file mode 100644 index 0000000..1fc6e13 --- /dev/null +++ b/src/BlockPreview.php @@ -0,0 +1,114 @@ + __( 'Monday', 'unsupervised-schedular' ), + 'slots' => [ + [ '16:00–16:30', 30 ], + [ '16:30–17:00', 30 ], + ], + ], + [ + 'label' => __( 'Wednesday', 'unsupervised-schedular' ), + 'slots' => [ + [ '17:00–17:45', 45 ], + ], + ], + ]; + + $dayHtml = ''; + foreach ( $days as $day ) { + $slotHtml = ''; + foreach ( $day['slots'] as $slot ) { + $slotHtml .= sprintf( + '
%s (%d min)
', + esc_html( $slot[0] ), + (int) $slot[1], + esc_html__( 'Book', 'unsupervised-schedular' ) + ); + } + + $dayHtml .= sprintf( + '

%s

%s
', + esc_html( $day['label'] ), + $slotHtml + ); + } + + return sprintf( + '
%s
%s
', + self::note( __( 'Editor preview — students see live availability on the published page.', 'unsupervised-schedular' ) ), + $dayHtml + ); + } + + public static function groupClasses(): string { + return sprintf( + '
%s

%s

%s

%s

25.00 CAD

', + self::note( __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' ) ), + esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ), + esc_html__( 'Saturdays 10:00–11:00', 'unsupervised-schedular' ), + esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ), + esc_html__( 'Enrol', 'unsupervised-schedular' ) + ); + } + + /** + * The live login form renders fine without any request state, so the + * preview includes the real template (the editing user is logged in, which + * would otherwise short-circuit to an "already logged in" message). + */ + public static function login(): string { + $error = ''; + + ob_start(); + include USC_PLUGIN_DIR . 'templates/frontend/login-page.php'; + + return self::note( __( 'Editor preview — logged-in visitors are offered a link to the booking page instead.', 'unsupervised-schedular' ) ) . (string) ob_get_clean(); + } + + public static function registration(): string { + $fields = sprintf( + '

', + esc_html__( 'Email', 'unsupervised-schedular' ) + ); + $fields .= sprintf( + '

', + esc_html__( 'Your name', 'unsupervised-schedular' ) + ); + $fields .= sprintf( + '

', + esc_html__( 'Password', 'unsupervised-schedular' ) + ); + $fields .= sprintf( + '

', + esc_attr__( 'Create Account', 'unsupervised-schedular' ) + ); + + return sprintf( + '
%s
%s
', + self::note( __( 'Editor preview — the live form requires a valid invite link and lists signup policies.', 'unsupervised-schedular' ) ), + $fields + ); + } + + private static function note( string $text ): string { + return '

' . esc_html( $text ) . '

'; + } +} diff --git a/src/BlockRegistrar.php b/src/BlockRegistrar.php new file mode 100644 index 0000000..a4da54e --- /dev/null +++ b/src/BlockRegistrar.php @@ -0,0 +1,126 @@ +blocks() as $name => $renderCallback ) { + register_block_type( + $name, + [ + 'api_version' => '3', + 'editor_script' => self::SCRIPT_HANDLE, + 'style' => self::STYLE_HANDLE, + 'render_callback' => $renderCallback, + ] + ); + } + } + + /** + * Block names mapped to their render callbacks. + * + * @return array): string> + */ + private function blocks(): array { + return [ + 'us-scheduler/booking' => [ $this, 'renderBooking' ], + 'us-scheduler/student-login' => [ $this, 'renderLogin' ], + 'us-scheduler/student-register' => [ $this, 'renderRegistration' ], + 'us-scheduler/group-classes' => [ $this, 'renderGroupClasses' ], + ]; + } + + /** + * Renders the booking block. + * + * @param array $attributes Block attributes (unused — the blocks have none yet). + */ + public function renderBooking( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + return $this->isEditorPreview() ? BlockPreview::booking() : $this->bookingPage->render( [] ); + } + + /** + * Renders the student-login block. + * + * @param array $attributes Block attributes (unused — the blocks have none yet). + */ + public function renderLogin( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + return $this->isEditorPreview() ? BlockPreview::login() : $this->loginPage->render( [] ); + } + + /** + * Renders the student-registration block. + * + * @param array $attributes Block attributes (unused — the blocks have none yet). + */ + public function renderRegistration( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + return $this->isEditorPreview() ? BlockPreview::registration() : $this->registrationPage->render( [] ); + } + + /** + * Renders the group-classes block. + * + * @param array $attributes Block attributes (unused — the blocks have none yet). + */ + public function renderGroupClasses( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + return $this->isEditorPreview() ? BlockPreview::groupClasses() : $this->groupClassPage->render( [] ); + } + + /** + * Whether this render is the editor's block-renderer REST preview rather + * than a real front-end page render. Front-end template rendering never + * happens inside a REST request, so REST_REQUEST is a reliable signal. + */ + protected function isEditorPreview(): bool { + return defined( 'REST_REQUEST' ) && (bool) constant( 'REST_REQUEST' ); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index bbe3cef..41124e4 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -4,10 +4,14 @@ declare(strict_types=1); namespace Unsupervised\Schedular; use Unsupervised\Schedular\Auth\InviteRepository; +use Unsupervised\Schedular\Auth\LoginPage; +use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Booking\BookingRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository; +use Unsupervised\Schedular\GroupClass\GroupClassPage; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\PaymentRepository; @@ -48,9 +52,17 @@ class Plugin { $stripe = new StripeGateway( $settings ); $paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe ); + // The shortcode and block wrappers share the same page objects so + // front-end output is identical whichever way a page embeds them. + $bookingPage = new BookingPage(); + $loginPage = new LoginPage(); + $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances ); + $groupClassPage = new GroupClassPage(); + ( new RoleManager() )->register(); ( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register(); ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->register(); - ( new ShortcodeRegistrar( $invites, $policies, $policyVersions, $acceptances ) )->register(); + ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); + ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register(); } } diff --git a/src/ShortcodeRegistrar.php b/src/ShortcodeRegistrar.php index 74942fb..7a2bb15 100644 --- a/src/ShortcodeRegistrar.php +++ b/src/ShortcodeRegistrar.php @@ -3,34 +3,20 @@ declare(strict_types=1); namespace Unsupervised\Schedular; -use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\LoginPage; use Unsupervised\Schedular\Auth\RegistrationPage; use Unsupervised\Schedular\Booking\BookingPage; use Unsupervised\Schedular\GroupClass\GroupClassPage; use Unsupervised\Schedular\Payment\StudioSettings; -use Unsupervised\Schedular\Policy\AcceptanceRepository; -use Unsupervised\Schedular\Policy\PolicyRepository; -use Unsupervised\Schedular\Policy\PolicyVersionRepository; class ShortcodeRegistrar { - private BookingPage $bookingPage; - private LoginPage $loginPage; - private RegistrationPage $registrationPage; - private GroupClassPage $groupClassPage; - public function __construct( - InviteRepository $invites, - PolicyRepository $policies, - PolicyVersionRepository $policyVersions, - AcceptanceRepository $acceptances, - ) { - $this->bookingPage = new BookingPage(); - $this->loginPage = new LoginPage(); - $this->registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances ); - $this->groupClassPage = new GroupClassPage(); - } + private BookingPage $bookingPage, + private LoginPage $loginPage, + private RegistrationPage $registrationPage, + private GroupClassPage $groupClassPage, + ) {} public function register(): void { add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] ); diff --git a/tests/Unit/BlockPreviewTest.php b/tests/Unit/BlockPreviewTest.php new file mode 100644 index 0000000..165d17f --- /dev/null +++ b/tests/Unit/BlockPreviewTest.php @@ -0,0 +1,57 @@ +justReturn(''); + + $html = BlockPreview::login(); + + self::assertStringContainsString('class="us-login-form"', $html); + self::assertStringContainsString('name="log"', $html); + self::assertStringContainsString('name="pwd"', $html); + self::assertStringContainsString('us-editor-note', $html); + } + + public function testRegistrationPreviewShowsADisabledSampleForm(): void + { + $html = BlockPreview::registration(); + + self::assertStringContainsString('class="us-register-form"', $html); + self::assertStringContainsString('id="us-reg-email"', $html); + self::assertStringContainsString('id="us-reg-name"', $html); + self::assertStringContainsString('id="us-reg-pass"', $html); + self::assertStringContainsString('disabled', $html); + self::assertStringContainsString('us-editor-note', $html); + } +} diff --git a/tests/Unit/BlockRegistrarTest.php b/tests/Unit/BlockRegistrarTest.php new file mode 100644 index 0000000..9d4080a --- /dev/null +++ b/tests/Unit/BlockRegistrarTest.php @@ -0,0 +1,167 @@ +preview; + } +} + +class BlockRegistrarTest extends TestCase +{ + private BookingPage&Mockery\MockInterface $bookingPage; + private LoginPage&Mockery\MockInterface $loginPage; + private RegistrationPage&Mockery\MockInterface $registrationPage; + private GroupClassPage&Mockery\MockInterface $groupClassPage; + private TestableBlockRegistrar $registrar; + + protected function setUp(): void + { + parent::setUp(); + + $this->bookingPage = Mockery::mock(BookingPage::class); + $this->loginPage = Mockery::mock(LoginPage::class); + $this->registrationPage = Mockery::mock(RegistrationPage::class); + $this->groupClassPage = Mockery::mock(GroupClassPage::class); + + $this->registrar = new TestableBlockRegistrar( + $this->bookingPage, + $this->loginPage, + $this->registrationPage, + $this->groupClassPage, + ); + } + + public function testRegisterHooksBlockRegistrationOntoInit(): void + { + Actions\expectAdded('init')->once()->with([$this->registrar, 'registerBlocks']); + + $this->registrar->register(); + } + + public function testRegisterBlocksRegistersAllFourBlocksWithAssets(): void + { + Functions\expect('wp_register_script') + ->once() + ->with( + BlockRegistrar::SCRIPT_HANDLE, + Mockery::pattern('~assets/js/blocks\.js$~'), + Mockery::type('array'), + USC_VERSION, + true + ); + Functions\when('wp_style_is')->justReturn(false); + Functions\expect('wp_register_style') + ->once() + ->with( + BlockRegistrar::STYLE_HANDLE, + Mockery::pattern('~assets/css/frontend\.css$~'), + [], + USC_VERSION + ); + + $registered = []; + Functions\when('register_block_type')->alias( + static function (string $name, array $args) use (&$registered): bool { + $registered[$name] = $args; + return true; + } + ); + + $this->registrar->registerBlocks(); + + self::assertSame( + [ + 'us-scheduler/booking', + 'us-scheduler/student-login', + 'us-scheduler/student-register', + 'us-scheduler/group-classes', + ], + array_keys($registered) + ); + + foreach ($registered as $args) { + self::assertSame(BlockRegistrar::SCRIPT_HANDLE, $args['editor_script']); + self::assertSame(BlockRegistrar::STYLE_HANDLE, $args['style']); + self::assertIsCallable($args['render_callback']); + } + } + + public function testRegisterBlocksDoesNotReRegisterAnAlreadyRegisteredStyle(): void + { + Functions\when('wp_register_script')->justReturn(true); + Functions\when('wp_style_is')->justReturn(true); + Functions\expect('wp_register_style')->never(); + Functions\when('register_block_type')->justReturn(true); + + $this->registrar->registerBlocks(); + } + + public function testFrontEndRenderDelegatesToThePageObjects(): void + { + $this->registrar->preview = false; + + $this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('booking-html'); + $this->loginPage->shouldReceive('render')->once()->with([])->andReturn('login-html'); + $this->registrationPage->shouldReceive('render')->once()->with([])->andReturn('register-html'); + $this->groupClassPage->shouldReceive('render')->once()->with([])->andReturn('group-html'); + + self::assertSame('booking-html', $this->registrar->renderBooking()); + self::assertSame('login-html', $this->registrar->renderLogin()); + self::assertSame('register-html', $this->registrar->renderRegistration()); + self::assertSame('group-html', $this->registrar->renderGroupClasses()); + } + + public function testEditorPreviewRendersStaticMarkupWithoutTouchingThePages(): void + { + $this->registrar->preview = true; + + Functions\when('wp_nonce_field')->justReturn(''); + + $this->bookingPage->shouldNotReceive('render'); + $this->loginPage->shouldNotReceive('render'); + $this->registrationPage->shouldNotReceive('render'); + $this->groupClassPage->shouldNotReceive('render'); + + self::assertStringContainsString('us-booking-app', $this->registrar->renderBooking()); + self::assertStringContainsString('us-login-form', $this->registrar->renderLogin()); + self::assertStringContainsString('us-register-form', $this->registrar->renderRegistration()); + self::assertStringContainsString('us-group-app', $this->registrar->renderGroupClasses()); + } + + public function testIsEditorPreviewIsFalseOutsideRestRequests(): void + { + // REST_REQUEST is undefined in the test process, so the real + // registrar must take the front-end path. + $registrar = new BlockRegistrar( + $this->bookingPage, + $this->loginPage, + $this->registrationPage, + $this->groupClassPage, + ); + + $this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live'); + + self::assertSame('live', $registrar->renderBooking()); + } +}