From 5ef37a9f88edbc4120b8c293c89aa97637770858 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 21 Oct 2026 19:53:54 +0800 Subject: [PATCH 1/8] feat(admin-interface): add require-template-choice setting Feature: admin-interface/settings-page/publication-template-config Implements: - US-007: Require editors to choose a template (setting only; enforcement and editor display live in other PRDs) Verified: - AC-014: toggle with the agreed label - AC-015: off by default - AC-016: saved state persists - AC-017: help text covers the three points - AC-018: shown in the existing connected-only section - AC-019: publication and template selections unchanged --- includes/Admin/Options.php | 13 ++- includes/Admin/Registrar.php | 40 ++++++++ tests/phpunit/AdminOptionsTest.php | 147 +++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 tests/phpunit/AdminOptionsTest.php diff --git a/includes/Admin/Options.php b/includes/Admin/Options.php index a48d048..ca549f7 100644 --- a/includes/Admin/Options.php +++ b/includes/Admin/Options.php @@ -25,8 +25,9 @@ final class Options { * @var array */ private const DEFAULTS = [ - 'publication_id' => '', - 'post_template_id' => '', + 'publication_id' => '', + 'post_template_id' => '', + 'require_template_choice' => false, ]; /** @@ -81,6 +82,9 @@ public static function sanitize( $input ): array { ? sanitize_text_field( (string) $input['post_template_id'] ) : ''; + // An unchecked checkbox is missing from the form, so absence means off. + $require_template_choice = ! empty( $input['require_template_choice'] ); + // Saving settings clears the cached template list for the selected // publication so the dropdown reflects any beehiiv-side changes. if ( '' !== $publication_id ) { @@ -88,8 +92,9 @@ public static function sanitize( $input ): array { } return [ - 'publication_id' => $publication_id, - 'post_template_id' => $post_template_id, + 'publication_id' => $publication_id, + 'post_template_id' => $post_template_id, + 'require_template_choice' => $require_template_choice, ]; } } diff --git a/includes/Admin/Registrar.php b/includes/Admin/Registrar.php index 8b831b4..3b1fd93 100644 --- a/includes/Admin/Registrar.php +++ b/includes/Admin/Registrar.php @@ -86,6 +86,17 @@ private static function register_page_settings_section( string $page_slug ): voi 'label_for' => 'beehiiv_post_template_id', ] ); + + add_settings_field( + 'beehiiv_require_template_choice', + __( 'Template choice', 'beehiiv' ), + [ self::class, 'render_require_template_choice_field' ], + $page_slug, + self::PAGE_SETTINGS_SECTION_ID, + [ + 'label_for' => 'beehiiv_require_template_choice', + ] + ); } /** @@ -244,4 +255,33 @@ class="button button-secondary beehiiv-refresh-button"

+ + +

+ +

+ assertFalse( Options::get()['require_template_choice'] ); + } + + /** + * AC-015: a saved option without the new key still reads as off. + */ + public function test_saved_option_without_key_reads_as_off(): void { + update_option( + Config::OPTION_NAME, + array( + 'publication_id' => 'pub_1', + 'post_template_id' => 'tpl_1', + ) + ); + + $this->assertFalse( Options::get()['require_template_choice'] ); + } + + /** + * AC-016: checking the box saves the setting as on. + */ + public function test_sanitize_saves_checked_box_as_on(): void { + $saved = Options::sanitize( + array( + 'publication_id' => 'pub_1', + 'post_template_id' => 'tpl_1', + 'require_template_choice' => '1', + ) + ); + + $this->assertTrue( $saved['require_template_choice'] ); + } + + /** + * AC-016: an unchecked box (hidden 0, or missing) saves as off. + */ + public function test_sanitize_saves_unchecked_box_as_off(): void { + $with_zero = Options::sanitize( + array( + 'publication_id' => 'pub_1', + 'require_template_choice' => '0', + ) + ); + $missing = Options::sanitize( array( 'publication_id' => 'pub_1' ) ); + + $this->assertFalse( $with_zero['require_template_choice'] ); + $this->assertFalse( $missing['require_template_choice'] ); + } + + /** + * AC-016: the saved value persists through the option. + */ + public function test_saved_value_persists(): void { + update_option( + Config::OPTION_NAME, + Options::sanitize( array( 'require_template_choice' => '1' ) ) + ); + + $this->assertTrue( Options::get()['require_template_choice'] ); + } + + /** + * AC-019: toggling the setting leaves publication and template untouched. + */ + public function test_toggle_does_not_change_other_selections(): void { + $on = Options::sanitize( + array( + 'publication_id' => 'pub_1', + 'post_template_id' => 'tpl_1', + 'require_template_choice' => '1', + ) + ); + $off = Options::sanitize( + array( + 'publication_id' => 'pub_1', + 'post_template_id' => 'tpl_1', + 'require_template_choice' => '0', + ) + ); + + $this->assertSame( 'pub_1', $on['publication_id'] ); + $this->assertSame( 'tpl_1', $on['post_template_id'] ); + $this->assertSame( 'pub_1', $off['publication_id'] ); + $this->assertSame( 'tpl_1', $off['post_template_id'] ); + } + + /** + * AC-014, AC-017: the field shows the toggle label and the help text. + */ + public function test_field_renders_label_and_help_text(): void { + ob_start(); + Registrar::render_require_template_choice_field(); + $html = (string) ob_get_clean(); + + $this->assertStringContainsString( 'Require editors to choose a post template', $html ); + $this->assertStringContainsString( 'editors must pick a template for each new post', $html ); + $this->assertStringContainsString( 'default template is not applied automatically', $html ); + $this->assertStringContainsString( 'sending is blocked until a template is chosen', $html ); + $this->assertStringNotContainsString( 'checked', $html ); + } + + /** + * AC-016: a saved on state shows the box checked. + */ + public function test_field_renders_checked_when_on(): void { + update_option( + Config::OPTION_NAME, + Options::sanitize( array( 'require_template_choice' => '1' ) ) + ); + + ob_start(); + Registrar::render_require_template_choice_field(); + $html = (string) ob_get_clean(); + + $this->assertStringContainsString( "checked='checked'", $html ); + } +} From 892a31c640604bd10dfde5d8b1810a215360630f Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 21 Oct 2026 20:02:05 +0800 Subject: [PATCH 2/8] test(admin-interface): cover require-template-choice setting in e2e --- .../specs/publication-template-config.spec.js | 130 +++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/tests/e2e/specs/publication-template-config.spec.js b/tests/e2e/specs/publication-template-config.spec.js index 65a7989..1a6d01e 100644 --- a/tests/e2e/specs/publication-template-config.spec.js +++ b/tests/e2e/specs/publication-template-config.spec.js @@ -72,6 +72,7 @@ const SETTINGS_PATH = '/wp-admin/admin.php?page=beehiiv'; const PUB_SELECT = '#beehiiv_publication_id'; const TPL_SELECT = '#beehiiv_post_template_id'; const REFRESH_BTN = '#beehiiv_refresh_post_templates'; +const REQUIRE_TOGGLE = '#beehiiv_require_template_choice'; /** * Whether a response is the post-templates REST call containing `needle`. @@ -523,7 +524,7 @@ test.describe( 'Publication and Template Configuration', () => { await page.locator( '#submit' ).click(); await page.waitForURL( /page=beehiiv/ ); - expect( readSettingsOption() ).toEqual( { + expect( readSettingsOption() ).toMatchObject( { publication_id: pubId, post_template_id: tplId, } ); @@ -578,4 +579,131 @@ test.describe( 'Publication and Template Configuration', () => { 'qa-e2e-tpl-ac013-new', ] ); } ); + + test( 'AC-014: settings form includes a toggle labeled "Require editors to choose a post template"', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + seedPublications( [ { id: 'qa-e2e-pub-ac014', name: 'QA Pub 14' } ] ); + setSettingsOption( '', '' ); + + await page.goto( SETTINGS_PATH ); + + const toggle = page.locator( REQUIRE_TOGGLE ); + await expect( toggle ).toBeVisible(); + await expect( toggle ).toHaveAttribute( 'type', 'checkbox' ); + await expect( + page.getByLabel( 'Require editors to choose a post template' ) + ).toHaveCount( 1 ); + } ); + + test( 'AC-015: toggle is off by default for a site that never saved the setting', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + seedPublications( [ { id: 'qa-e2e-pub-ac015', name: 'QA Pub 15' } ] ); + wpCliSafe( `option delete ${ SETTINGS_OPTION }` ); + + await page.goto( SETTINGS_PATH ); + + await expect( page.locator( REQUIRE_TOGGLE ) ).not.toBeChecked(); + } ); + + test( 'AC-016: saving stores the toggle state and it persists across sessions', async ( { + page, + browser, + } ) => { + seedConnectedAndAuthorized(); + const pubId = 'qa-e2e-pub-ac016'; + seedPublications( [ { id: pubId, name: 'QA Pub 16' } ] ); + setSettingsOption( pubId, '' ); + + await page.goto( SETTINGS_PATH ); + await page.locator( REQUIRE_TOGGLE ).check(); + await page.locator( '#submit' ).click(); + await page.waitForURL( /page=beehiiv/ ); + + expect( readSettingsOption().require_template_choice ).toBe( true ); + + // A brand-new browser context (fresh login) still sees it checked. + const context = await browser.newContext(); + const fresh = await context.newPage(); + await loginAsAdmin( fresh ); + await fresh.goto( SETTINGS_PATH ); + await expect( fresh.locator( REQUIRE_TOGGLE ) ).toBeChecked(); + await context.close(); + + // Turning it off and saving stores false. + await page.goto( SETTINGS_PATH ); + await page.locator( REQUIRE_TOGGLE ).uncheck(); + await page.locator( '#submit' ).click(); + await page.waitForURL( /page=beehiiv/ ); + expect( readSettingsOption().require_template_choice ).toBe( false ); + await expect( page.locator( REQUIRE_TOGGLE ) ).not.toBeChecked(); + } ); + + test( 'AC-017: help text under the toggle explains the required-template behavior', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + seedPublications( [ { id: 'qa-e2e-pub-ac017', name: 'QA Pub 17' } ] ); + setSettingsOption( '', '' ); + + await page.goto( SETTINGS_PATH ); + + const help = page + .locator( 'td' ) + .filter( { has: page.locator( REQUIRE_TOGGLE ) } ) + .locator( 'p.description' ); + await expect( help ).toBeVisible(); + await expect( help ).toContainText( 'editors must pick a template' ); + await expect( help ).toContainText( + 'default template is not applied automatically' + ); + await expect( help ).toContainText( + 'sending is blocked until a template is chosen' + ); + } ); + + test( 'AC-018: toggle is hidden for users on plans that cannot write posts', async ( { + page, + } ) => { + wpCli( + 'eval \'beehiiv_e2e_seed_connection(); beehiiv_e2e_mock_permissions( [ "posts" => [ "read" ] ] );\'' + ); + setSettingsOption( '', '' ); + + await page.goto( SETTINGS_PATH ); + + await expect( page.locator( PUB_SELECT ) ).toHaveCount( 0 ); + await expect( page.locator( REQUIRE_TOGGLE ) ).toHaveCount( 0 ); + } ); + + test( 'AC-019: changing the toggle does not change publication or default template selection', async ( { + page, + } ) => { + seedConnectedAndAuthorized(); + const pubId = 'qa-e2e-pub-ac019'; + const tplId = 'qa-e2e-tpl-ac019'; + seedPublications( [ { id: pubId, name: 'QA Publication AC019' } ] ); + seedTemplates( pubId, [ { id: tplId, name: 'QA Template AC019' } ] ); + setSettingsOption( pubId, tplId ); + + await page.goto( SETTINGS_PATH ); + await expect( page.locator( PUB_SELECT ) ).toHaveValue( pubId ); + await expect( page.locator( TPL_SELECT ) ).toHaveValue( tplId ); + + await page.locator( REQUIRE_TOGGLE ).check(); + await expect( page.locator( PUB_SELECT ) ).toHaveValue( pubId ); + await expect( page.locator( TPL_SELECT ) ).toHaveValue( tplId ); + + await page.locator( '#submit' ).click(); + await page.waitForURL( /page=beehiiv/ ); + + expect( readSettingsOption() ).toEqual( { + publication_id: pubId, + post_template_id: tplId, + require_template_choice: true, + } ); + } ); } ); From 4beac307eaa21e19ca9a8f9fc2647f580d1d5e88 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 21 Oct 2026 20:03:37 +0800 Subject: [PATCH 3/8] feat(newsletter-publishing): block sending until an editor chooses a template Feature: newsletter-publishing/newsletter-template-selection/post-template-selection Implements: - US-004: Require an editor-chosen template (send-time rule) Verified: - AC-009: default used when setting off or post already sent - AC-012: no automatic template when the rule applies - AC-013: send blocked even with a site default - AC-014: agreed message, no beehiiv request - AC-015: all roles - AC-016: sent posts exempt - AC-017: chosen template continues - AC-018: setting off behaves as before --- includes/Newsletter/PostSettingsBuilder.php | 28 +++++ includes/Newsletter/Sender.php | 2 + .../NewsletterPostSettingsBuilderTest.php | 107 ++++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/includes/Newsletter/PostSettingsBuilder.php b/includes/Newsletter/PostSettingsBuilder.php index df74b14..b9fa9bb 100644 --- a/includes/Newsletter/PostSettingsBuilder.php +++ b/includes/Newsletter/PostSettingsBuilder.php @@ -59,6 +59,13 @@ public static function get_post_settings( int $post_id, bool $for_update = false $post_template_id = self::resolve_post_template_id( $post_id, $publication_id ); + if ( '' === $post_template_id && self::is_template_choice_required( $post_id ) ) { + return new WP_Error( + 'beehiiv_post_template_choice_required', + sprintf( 'An editor must choose a post template before post ID: %d is sent.', $post_id ) + ); + } + if ( '' === $post_template_id && PublicationResolver::get_default_publication_id() !== $publication_id ) { return new WP_Error( 'beehiiv_post_template_required', @@ -457,6 +464,11 @@ public static function resolve_post_template_id( int $post_id, string $publicati return $post_template_id; } + // With the required-template rule on, a never-sent post gets no automatic template. + if ( self::is_template_choice_required( $post_id ) ) { + return ''; + } + if ( '' !== $publication_id && PublicationResolver::get_default_publication_id() === $publication_id ) { return self::get_site_post_template_id(); } @@ -464,6 +476,22 @@ public static function resolve_post_template_id( int $post_id, string $publicati return ''; } + /** + * Whether an editor must choose this post's template before it can be sent. + * + * True when the "require template choice" setting is on and the post has never + * been sent to beehiiv. Applies to every user role. + * + * @param int $post_id Post ID. + * @return bool + * @since x.x.x + */ + public static function is_template_choice_required( int $post_id ): bool { + $settings = Options::get(); + + return ! empty( $settings['require_template_choice'] ) && ! Sender::has_beehiiv_post_id( $post_id ); + } + /** * Email subject line for the post: its newsletter title, or the post title when empty. * diff --git a/includes/Newsletter/Sender.php b/includes/Newsletter/Sender.php index accbe8a..506cbf3 100644 --- a/includes/Newsletter/Sender.php +++ b/includes/Newsletter/Sender.php @@ -1017,6 +1017,8 @@ public static function format_save_error_message( \WP_Error $error ): string { 'Choose a default post template in beehiiv settings, or pick one for this post in the beehiiv sidebar.', 'beehiiv' ); + case 'beehiiv_post_template_choice_required': + return __( 'Choose a post template before sending this post to beehiiv.', 'beehiiv' ); case 'beehiiv_post_template_required': return __( "Pick a post template for this post's publication in the beehiiv sidebar.", 'beehiiv' ); case 'beehiiv_publication_missing': diff --git a/tests/phpunit/NewsletterPostSettingsBuilderTest.php b/tests/phpunit/NewsletterPostSettingsBuilderTest.php index 5298671..0673e0f 100644 --- a/tests/phpunit/NewsletterPostSettingsBuilderTest.php +++ b/tests/phpunit/NewsletterPostSettingsBuilderTest.php @@ -8,9 +8,11 @@ * @package beehiiv */ +use Beehiiv\API\Cache; use Beehiiv\Config; use Beehiiv\Editor\Meta; use Beehiiv\Newsletter\PostSettingsBuilder; +use Beehiiv\Newsletter\Sender; /** * Payload coverage for newsletter title and subtitle. @@ -217,4 +219,109 @@ public function test_byline_stays_hidden_in_email(): void { $settings = PostSettingsBuilder::get_post_settings( $post_id ); $this->assertFalse( $settings['email_settings']['display_byline_in_email'] ); } + + /** + * Turn the required-template setting on, keeping the site default template. + */ + private function require_template_choice(): void { + update_option( + Config::OPTION_NAME, + array( + 'publication_id' => 'pub_test', + 'post_template_id' => 'tmpl_test', + 'require_template_choice' => true, + ) + ); + } + + /** + * Post Template Selection PRD AC-012, AC-013: with the rule on, a never-sent post + * gets no automatic template and cannot be sent, even with a site default set. + */ + public function test_required_choice_blocks_never_sent_post_without_template(): void { + $this->require_template_choice(); + $post_id = $this->create_sendable_post(); + + $this->assertSame( '', PostSettingsBuilder::resolve_post_template_id( $post_id, 'pub_test' ) ); + + $settings = PostSettingsBuilder::get_post_settings( $post_id ); + + $this->assertWPError( $settings ); + $this->assertSame( 'beehiiv_post_template_choice_required', $settings->get_error_code() ); + } + + /** + * AC-014: the blocked send shows the agreed message. + */ + public function test_required_choice_error_has_agreed_message(): void { + $this->require_template_choice(); + $settings = PostSettingsBuilder::get_post_settings( $this->create_sendable_post() ); + + $this->assertSame( + 'Choose a post template before sending this post to beehiiv.', + Sender::format_save_error_message( $settings ) + ); + } + + /** + * AC-015: no user role skips the rule. + */ + public function test_required_choice_applies_to_every_role(): void { + $this->require_template_choice(); + $post_id = $this->create_sendable_post(); + + foreach ( array( 'administrator', 'editor', 'author' ) as $role ) { + wp_set_current_user( self::factory()->user->create( array( 'role' => $role ) ) ); + + $this->assertWPError( PostSettingsBuilder::get_post_settings( $post_id ), $role ); + } + } + + /** + * AC-016, AC-009: a post already sent to beehiiv keeps using the default. + */ + public function test_required_choice_does_not_block_sent_post(): void { + $this->require_template_choice(); + $post_id = $this->create_sendable_post(); + update_post_meta( $post_id, Meta::BEEHIIV_POST_ID, 'post_existing' ); + + $this->assertSame( 'tmpl_test', PostSettingsBuilder::resolve_post_template_id( $post_id, 'pub_test' ) ); + $this->assertIsArray( PostSettingsBuilder::get_post_settings( $post_id, true ) ); + } + + /** + * AC-017: once an editor chooses a template, the send continues with it. + */ + public function test_required_choice_passes_with_chosen_template(): void { + $this->require_template_choice(); + Cache::set_post_templates( + 'pub_test', + array( + array( + 'id' => 'tmpl_chosen', + 'name' => 'Chosen', + ), + ) + ); + $post_id = $this->create_sendable_post(); + update_post_meta( $post_id, Meta::BEEHIIV_POST_TEMPLATE_ID, 'tmpl_chosen' ); + + $settings = PostSettingsBuilder::get_post_settings( $post_id ); + + $this->assertIsArray( $settings ); + $this->assertSame( 'tmpl_chosen', $settings['post_template_id'] ); + + Cache::flush_all(); + } + + /** + * AC-018, AC-009: with the setting off, the site default is used as today. + */ + public function test_setting_off_keeps_site_default(): void { + $post_id = $this->create_sendable_post(); + + $this->assertFalse( PostSettingsBuilder::is_template_choice_required( $post_id ) ); + $this->assertSame( 'tmpl_test', PostSettingsBuilder::resolve_post_template_id( $post_id, 'pub_test' ) ); + $this->assertIsArray( PostSettingsBuilder::get_post_settings( $post_id ) ); + } } From ad29c758107b2cd1f9279e7439819ddefa1efd50 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 21 Oct 2026 20:13:19 +0800 Subject: [PATCH 4/8] test(newsletter-publishing): cover required-template rule in e2e --- .../e2e/specs/post-template-selection.spec.js | 708 ++++++++++++++++++ 1 file changed, 708 insertions(+) create mode 100644 tests/e2e/specs/post-template-selection.spec.js diff --git a/tests/e2e/specs/post-template-selection.spec.js b/tests/e2e/specs/post-template-selection.spec.js new file mode 100644 index 0000000..05912db --- /dev/null +++ b/tests/e2e/specs/post-template-selection.spec.js @@ -0,0 +1,708 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAs, loginAsAdmin } = require( '../utils/auth' ); +const { + openPostEditor, + openBeehiivSidebar, + saveDraft, +} = require( '../utils/editor' ); +const { + wpCli, + wpCliSafe, + ensurePluginActive, + ensurePrettyPermalinks, +} = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/07-newsletter-publishing/3-newsletter-template-selection/post-template-selection.prd.md (v1.0) + * + * Independent behavioral coverage (qa-e2e-author), written from the PRD's + * Acceptance Criteria plus the real code at: + * - src/js/editor/post-settings/components/newsletter-template-select.js + * - includes/Newsletter/PostSettingsBuilder.php + * - includes/Newsletter/Sender.php + * - includes/REST/PostTemplatesController.php + * + * Every outbound beehiiv request is answered by the test-only + * `pre_http_request` mock in tests/e2e/plugins/beehiiv-options.php and + * recorded in its request log. Nothing reaches the live API. + */ + +const META = { + send: '_beehiiv_send_to_newsletter', + postId: '_beehiiv_post_id', + template: '_beehiiv_post_template_id', + error: '_beehiiv_newsletter_error', +}; + +const PUBLICATION_ID = 'pub_qa_e2e_ptsel'; +const DEFAULT_TEMPLATE = 'tpl_qa_e2e_default'; +const OTHER_TEMPLATE = 'tpl_qa_e2e_other'; +const NEW_TEMPLATE = 'tpl_qa_e2e_brand_new'; +const MOCK_CREATED_ID = 'post_qa_e2e_ptsel_created'; +const LINKED_ID = 'post_qa_e2e_ptsel_linked'; +const REQUIRED_MESSAGE = + 'Choose a post template before sending this post to beehiiv.'; +const SIDEBAR = '.beehiiv-post-settings'; +const CONTENT = + '

QA E2E template selection body.

'; + +const USERS = { + editor: { name: 'qa_e2e_ptsel_editor', pass: 'qa-e2e-ptsel-editor-1!' }, + author: { name: 'qa_e2e_ptsel_author', pass: 'qa-e2e-ptsel-author-1!' }, +}; + +const createdPosts = []; + +const b64 = ( value ) => + Buffer.from( JSON.stringify( value ) ).toString( 'base64' ); + +/** + * Runs PHP inside the tests env; the payload travels base64-encoded. + * + * @param {Object} payload Values exposed to the PHP snippet as `$a`. + * @param {string} php PHP code to evaluate. + * @return {string} wp-cli output. + */ +function wpEvalWith( payload, php ) { + return wpCli( + `eval '$a = json_decode( base64_decode( "${ b64( + payload + ) }" ), true ); ${ php }'` + ); +} + +/** + * Seeds a "ready to send" site with templates, plus optional settings. + * + * @param {Object} [options] Seed options. + * @param {boolean} [options.requireTemplate] Turn the required-template setting on. + * @param {string} [options.defaultTemplate] Site default template ID, or empty for none. + * @param {Array} [options.mocks] Extra `[ needle, response ]` HTTP mocks, matched first. + */ +function seed( { + requireTemplate = false, + defaultTemplate = DEFAULT_TEMPLATE, + mocks = [], +} = {} ) { + wpEvalWith( + { requireTemplate, defaultTemplate, mocks }, + `beehiiv_e2e_reset_all(); + beehiiv_e2e_seed_connection(); + beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] ); + update_option( "beehiiv_settings", [ + "publication_id" => "${ PUBLICATION_ID }", + "post_template_id" => $a["defaultTemplate"], + "require_template_choice" => (bool) $a["requireTemplate"], + ] ); + beehiiv_e2e_seed_publications( [ [ "id" => "${ PUBLICATION_ID }", "name" => "QA E2E Publication" ] ] ); + beehiiv_e2e_seed_post_templates( "${ PUBLICATION_ID }", [ + [ "id" => "${ DEFAULT_TEMPLATE }", "name" => "QA Default Template" ], + [ "id" => "${ OTHER_TEMPLATE }", "name" => "QA Other Template" ], + ] ); + foreach ( $a["mocks"] as $m ) { beehiiv_e2e_mock_http( $m[0], $m[1] ); } + beehiiv_e2e_mock_http( "/posts", [ "body" => [ "data" => [ "id" => "${ MOCK_CREATED_ID }" ] ] ] ); + beehiiv_e2e_clear_http_log();` + ); +} + +/** + * Creates a draft post fixture and empties the request log. + * + * @param {string} title Post title. + * @param {Object} [options] Options. + * @param {Object} [options.meta] Post meta. + * @param {string} [options.status] Post status. + * @param {number} [options.author] Author user ID. + * @return {string} Post ID. + */ +function createPost( title, { meta = {}, status = 'draft', author = 1 } = {} ) { + const id = wpEvalWith( + { + post: { + post_type: 'post', + post_title: title, + post_content: CONTENT, + post_status: status, + post_author: author, + }, + meta: { [ META.send ]: '1', ...meta }, + }, + `$id = wp_insert_post( $a["post"] ); + foreach ( $a["meta"] as $k => $v ) { update_post_meta( $id, $k, $v ); } + beehiiv_e2e_clear_http_log(); + echo $id . "\\n";` + ); + createdPosts.push( id ); + return id; +} + +function getLog() { + return JSON.parse( + wpCli( `eval 'beehiiv_e2e_print_http_log();'` ) || '[]' + ); +} + +/** + * All meta for a post, single values. + * + * @param {number|string} id Post ID. + * @return {Object} Meta keyed by meta key. + */ +function getMeta( id ) { + const raw = wpCli( + `eval '$m = get_post_meta( ${ id } ); $o = []; foreach ( $m as $k => $v ) { $o[ $k ] = $v[0]; } echo wp_json_encode( (object) $o ) . "\\n";'` + ); + return JSON.parse( raw ); +} + +const creates = ( log ) => + log.filter( + ( r ) => + r.method === 'POST' && /\/publications\/[^/]+\/posts$/.test( r.url ) + ); +const postsRequests = ( log ) => + log.filter( ( r ) => /\/publications\/[^/]+\/posts/.test( r.url ) ); + +/** + * Finds the template ID in a logged create request, wherever it is nested. + * + * @param {Object} request Logged request. + * @return {string|undefined} Template ID sent to beehiiv. + */ +function sentTemplate( request ) { + let body = request.body; + if ( typeof body === 'string' ) { + try { + body = JSON.parse( body ); + } catch { + return undefined; + } + } + return body?.post_template_id; +} + +/** + * Logs in as a user and returns a REST caller bound to that session. + * + * @param {import('@playwright/test').Page} page Playwright page. + * @param {Object} [user] `{ name, pass }`, defaults to admin. + * @return {Promise} `( method, path, data ) => { status, body }`. + */ +async function restAs( page, user ) { + if ( user ) { + await loginAs( page, user.name, user.pass ); + } else { + await loginAsAdmin( page ); + } + const nonceRes = await page.request.get( + '/wp-admin/admin-ajax.php?action=rest-nonce' + ); + const nonce = ( await nonceRes.text() ).trim(); + return async ( method, path, data ) => { + const res = await page.request.fetch( `/wp-json${ path }`, { + method, + headers: { 'X-WP-Nonce': nonce }, + data, + } ); + let body = null; + try { + body = await res.json(); + } catch { + // Non-JSON response; leave body null. + } + return { status: res.status(), body }; + }; +} + +function ensureUser( { name, pass }, role ) { + let id = wpCliSafe( `user get ${ name } --field=ID` ); + if ( ! id ) { + id = wpCli( + `user create ${ name } ${ name }@example.test --role=${ role } --user_pass="${ pass }" --porcelain` + ); + } else { + wpCli( `user update ${ id } --user_pass="${ pass }" --role=${ role }` ); + } + return id; +} + +async function openSidebarFor( page, postId ) { + await openPostEditor( page, postId ); + await openBeehiivSidebar( page ); + const sidebar = page.locator( SIDEBAR ); + await expect( sidebar.getByLabel( 'Post template' ) ).toBeVisible( { + timeout: 20000, + } ); + return sidebar; +} + +test.describe.configure( { timeout: 150 * 1000 } ); + +let editorId; +let authorId; + +test.beforeAll( async () => { + ensurePluginActive(); + ensurePrettyPermalinks(); + editorId = ensureUser( USERS.editor, 'editor' ); + authorId = ensureUser( USERS.author, 'author' ); +} ); + +test.afterAll( async () => { + wpCliSafe( + `eval 'foreach ( array( ${ createdPosts.join( + ', ' + ) } ) as $id ) { delete_post_meta( $id, "${ + META.postId + }" ); wp_delete_post( $id, true ); } + delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();'` + ); + wpCliSafe( `user delete ${ editorId } --yes` ); + wpCliSafe( `user delete ${ authorId } --yes` ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-001: select a custom template per post', () => { + test( 'AC-001: a post template dropdown appears in the editor post settings', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E ptsel AC-001' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + const select = sidebar.getByLabel( 'Post template' ); + await expect( select ).toBeVisible(); + await expect( select ).toBeEnabled(); + } ); + + test( 'AC-002: the dropdown lists every beehiiv template for the configured publication', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E ptsel AC-002' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + const options = sidebar + .getByLabel( 'Post template' ) + .locator( 'option' ); + await expect( options ).toHaveCount( 2 ); + await expect( options.nth( 0 ) ).toHaveText( + 'QA Default Template (default)' + ); + await expect( options.nth( 1 ) ).toHaveText( 'QA Other Template' ); + } ); + + test( 'AC-003: an editor can pick a template and the selection is saved to the post', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E ptsel AC-003' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await sidebar + .getByLabel( 'Post template' ) + .selectOption( OTHER_TEMPLATE ); + await saveDraft( page ); + + expect( getMeta( id )[ META.template ] ).toBe( OTHER_TEMPLATE ); + + // The saved choice survives a reload of the editor. + const reloaded = await openSidebarFor( page, id ); + await expect( reloaded.getByLabel( 'Post template' ) ).toHaveValue( + OTHER_TEMPLATE + ); + } ); + + test( 'AC-004: the selected template is included in the payload sent to beehiiv', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E ptsel AC-004', { + meta: { [ META.template ]: OTHER_TEMPLATE }, + } ); + const rest = await restAs( page ); + + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + status: 'publish', + } ); + expect( res.status ).toBe( 200 ); + + const sent = creates( getLog() ); + expect( sent ).toHaveLength( 1 ); + expect( sentTemplate( sent[ 0 ] ) ).toBe( OTHER_TEMPLATE ); + expect( getMeta( id )[ META.postId ] ).toBe( MOCK_CREATED_ID ); + } ); + + test( 'AC-010: the dropdown shows which template is used when no selection is made', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E ptsel AC-010' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + // No template chosen on the post: the picker points at the site default. + const select = sidebar.getByLabel( 'Post template' ); + await expect( select ).toHaveValue( DEFAULT_TEMPLATE ); + await expect( select.locator( 'option:checked' ) ).toHaveText( + 'QA Default Template (default)' + ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-002: refresh the template list', () => { + test( 'AC-005: a Refresh templates button appears next to the dropdown', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E ptsel AC-005' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await expect( + sidebar.getByRole( 'button', { name: 'Refresh templates' } ) + ).toBeVisible(); + } ); + + test( 'AC-006: clicking Refresh fetches the latest templates from beehiiv, bypassing the cache', async ( { + page, + } ) => { + seed( { + mocks: [ + [ + '/post_templates', + { + body: { + data: [ + { + id: DEFAULT_TEMPLATE, + name: 'QA Default Template', + }, + { + id: OTHER_TEMPLATE, + name: 'QA Other Template', + }, + { + id: NEW_TEMPLATE, + name: 'QA Brand New Template', + }, + ], + }, + }, + ], + ], + } ); + const id = createPost( 'QA E2E ptsel AC-006' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + const select = sidebar.getByLabel( 'Post template' ); + + // The cached list does not know the new template, and no beehiiv call was needed. + await expect( select.locator( 'option' ) ).toHaveCount( 2 ); + expect( + getLog().filter( ( r ) => r.url.includes( '/post_templates' ) ) + ).toHaveLength( 0 ); + + const refreshRequest = page.waitForRequest( + ( req ) => + req.url().includes( 'post-templates' ) && + req.url().includes( 'refresh=1' ) + ); + await sidebar + .getByRole( 'button', { name: 'Refresh templates' } ) + .click(); + await refreshRequest; + + await expect( select.locator( 'option' ) ).toHaveCount( 3 ); + await expect( + select.locator( `option[value="${ NEW_TEMPLATE }"]` ) + ).toHaveText( 'QA Brand New Template' ); + expect( + getLog().filter( ( r ) => r.url.includes( '/post_templates' ) ) + .length + ).toBeGreaterThan( 0 ); + } ); + + test( 'AC-007: a success message appears briefly after templates are refreshed', async ( { + page, + } ) => { + seed( { + mocks: [ + [ + '/post_templates', + { + body: { + data: [ + { + id: DEFAULT_TEMPLATE, + name: 'QA Default Template', + }, + ], + }, + }, + ], + ], + } ); + const id = createPost( 'QA E2E ptsel AC-007' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await sidebar + .getByRole( 'button', { name: 'Refresh templates' } ) + .click(); + const notice = sidebar.getByText( 'Templates updated from beehiiv.' ); + await expect( notice ).toBeVisible(); + // "Briefly": it goes away on its own. + await expect( notice ).toBeHidden( { timeout: 10000 } ); + } ); + + test( 'AC-008: a loading state is shown while templates are being fetched', async ( { + page, + } ) => { + seed( { + mocks: [ + [ + '/post_templates', + { + delay_ms: 3000, + body: { + data: [ + { + id: DEFAULT_TEMPLATE, + name: 'QA Default Template', + }, + { + id: OTHER_TEMPLATE, + name: 'QA Other Template', + }, + ], + }, + }, + ], + ], + } ); + const id = createPost( 'QA E2E ptsel AC-008' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await sidebar + .getByRole( 'button', { name: 'Refresh templates' } ) + .click(); + const busy = sidebar.getByRole( 'button', { name: 'Refreshing…' } ); + await expect( busy ).toBeVisible(); + await expect( busy ).toBeDisabled(); + await expect( sidebar.getByLabel( 'Post template' ) ).toBeDisabled(); + + // Once the fetch finishes the normal controls return. + await expect( + sidebar.getByRole( 'button', { name: 'Refresh templates' } ) + ).toBeEnabled( { timeout: 15000 } ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-003: site default template', () => { + test( 'AC-009: with no custom template and the required setting off, the site default is used for sending', async ( { + page, + } ) => { + seed( { requireTemplate: false } ); + const id = createPost( 'QA E2E ptsel AC-009 off' ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + const sent = creates( getLog() ); + expect( sent ).toHaveLength( 1 ); + expect( sentTemplate( sent[ 0 ] ) ).toBe( DEFAULT_TEMPLATE ); + } ); + + test( 'AC-009: with the required setting on, a post already sent to beehiiv is not blocked', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E ptsel AC-009 sent', { + status: 'publish', + meta: { [ META.postId ]: LINKED_ID, [ META.send ]: '' }, + } ); + const rest = await restAs( page ); + + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E ptsel AC-009 sent (edited)', + } ); + expect( res.status ).toBe( 200 ); + + const meta = getMeta( id ); + expect( meta[ META.error ] ).toBeUndefined(); + const log = getLog(); + expect( log.some( ( r ) => r.method === 'PATCH' ) ).toBe( true ); + // If the update payload carries a template, it is the site default. + for ( const r of log.filter( ( x ) => x.method === 'PATCH' ) ) { + const t = sentTemplate( r ); + if ( t !== undefined ) { + expect( t ).toBe( DEFAULT_TEMPLATE ); + } + } + } ); + + test( 'AC-011: a post fails to send with a clear error when no template is available', async ( { + page, + } ) => { + seed( { requireTemplate: false, defaultTemplate: '' } ); + const id = createPost( 'QA E2E ptsel AC-011' ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + expect( creates( getLog() ) ).toHaveLength( 0 ); + const meta = getMeta( id ); + expect( meta[ META.postId ] ).toBeUndefined(); + expect( meta[ META.error ] ).toContain( + 'Choose a default post template' + ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-004: require an editor-chosen template', () => { + test( 'AC-012: with the setting on and a never-sent post, no template is chosen automatically and the default is not a fallback', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E ptsel AC-012' ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + + const meta = getMeta( id ); + // Nothing was stored for the post, and nothing carrying the default went out. + expect( meta[ META.template ] ).toBeUndefined(); + expect( postsRequests( getLog() ) ).toHaveLength( 0 ); + expect( meta[ META.postId ] ).toBeUndefined(); + } ); + + test( 'AC-013: sending is blocked until an editor chooses a template, even with a default configured', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E ptsel AC-013' ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + expect( creates( getLog() ) ).toHaveLength( 0 ); + expect( getMeta( id )[ META.postId ] ).toBeUndefined(); + } ); + + test( 'AC-014: a blocked send shows the required message and makes no beehiiv request', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E ptsel AC-014' ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + + expect( getMeta( id )[ META.error ] ).toContain( REQUIRED_MESSAGE ); + // No request of any kind went to beehiiv's posts endpoints. + expect( postsRequests( getLog() ) ).toHaveLength( 0 ); + } ); + + for ( const role of [ 'administrator', 'editor', 'author' ] ) { + test( `AC-015: the rule applies to the ${ role } role`, async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const user = role === 'administrator' ? null : USERS[ role ]; + const userId = { + administrator: 1, + editor: editorId, + author: authorId, + }[ role ]; + const id = createPost( `QA E2E ptsel AC-015 ${ role }`, { + author: userId, + } ); + const rest = await restAs( page, user ); + + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + status: 'publish', + } ); + expect( res.status ).toBe( 200 ); + expect( creates( getLog() ) ).toHaveLength( 0 ); + expect( getMeta( id )[ META.error ] ).toContain( REQUIRED_MESSAGE ); + } ); + } + + test( 'AC-016: posts already sent to beehiiv keep their template and are not blocked', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E ptsel AC-016', { + status: 'publish', + meta: { + // Template first: once linked and sent, the template meta is locked. + [ META.template ]: OTHER_TEMPLATE, + [ META.postId ]: LINKED_ID, + [ META.send ]: '', + }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { + title: 'QA E2E ptsel AC-016 (edited)', + } ); + + const meta = getMeta( id ); + expect( meta[ META.error ] ).toBeUndefined(); + expect( meta[ META.template ] ).toBe( OTHER_TEMPLATE ); + expect( getLog().some( ( r ) => r.method === 'PATCH' ) ).toBe( true ); + } ); + + test( 'AC-017: once an editor chooses a template the send continues, and an unknown template is still rejected', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const chosen = createPost( 'QA E2E ptsel AC-017 chosen', { + meta: { [ META.template ]: OTHER_TEMPLATE }, + } ); + const unknown = createPost( 'QA E2E ptsel AC-017 unknown', { + meta: { [ META.template ]: 'tpl_not_in_beehiiv' }, + } ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ chosen }`, { + status: 'publish', + } ); + const sent = creates( getLog() ); + expect( sent ).toHaveLength( 1 ); + expect( sentTemplate( sent[ 0 ] ) ).toBe( OTHER_TEMPLATE ); + expect( getMeta( chosen )[ META.postId ] ).toBe( MOCK_CREATED_ID ); + + // Existing send-time validation still applies to the chosen template. + wpCli( `eval 'beehiiv_e2e_clear_http_log();'` ); + await rest( 'POST', `/wp/v2/posts/${ unknown }`, { + status: 'publish', + } ); + expect( creates( getLog() ) ).toHaveLength( 0 ); + const meta = getMeta( unknown ); + expect( meta[ META.postId ] ).toBeUndefined(); + expect( meta[ META.error ] ).toBeTruthy(); + } ); + + test( 'AC-018: with the setting off, sending behaves as before (default used, no block)', async ( { + page, + } ) => { + seed( { requireTemplate: false } ); + const id = createPost( 'QA E2E ptsel AC-018' ); + const rest = await restAs( page ); + + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + + const sent = creates( getLog() ); + expect( sent ).toHaveLength( 1 ); + expect( sentTemplate( sent[ 0 ] ) ).toBe( DEFAULT_TEMPLATE ); + const meta = getMeta( id ); + expect( meta[ META.error ] ).toBeUndefined(); + expect( meta[ META.postId ] ).toBe( MOCK_CREATED_ID ); + } ); +} ); From 2d530473a789059c4361e43e7056f0b1d0e1aa98 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 21 Oct 2026 20:15:16 +0800 Subject: [PATCH 5/8] feat(editor-integration): ask editors to choose a template when required Feature: editor-integration/editor-sidebar/newsletter-template-selection Implements: - US-007: Required template prompt in the editor sidebar Verified: - AC-002: default behavior unchanged when the setting is off or the post was sent - AC-014: empty field with the Choose a template prompt - AC-015: no pre-selected template and no default label - AC-016: help text under the empty field - AC-017: blocked send shows the message through the existing error notice - AC-018: prompt and help clear once a template is chosen - AC-019: unchanged when the setting is off or the post was sent --- includes/Editor/PostSettings.php | 1 + .../components/newsletter-status-notices.js | 7 ++- .../components/newsletter-template-select.js | 51 ++++++++++++++----- .../hooks/use-beehiiv-editor-config.js | 1 + .../hooks/use-post-publication.js | 26 ++++++++-- src/js/editor/post-settings/index.js | 1 + tests/phpunit/EditorPostSettingsTest.php | 18 +++++++ 7 files changed, 85 insertions(+), 20 deletions(-) diff --git a/includes/Editor/PostSettings.php b/includes/Editor/PostSettings.php index bc09e81..dc0d3e2 100644 --- a/includes/Editor/PostSettings.php +++ b/includes/Editor/PostSettings.php @@ -404,6 +404,7 @@ private static function get_editor_config(): array { 'hasPostTemplate' => '' !== trim( (string) ( $settings['post_template_id'] ?? '' ) ), 'publicationId' => trim( (string) ( $settings['publication_id'] ?? '' ) ), 'defaultPostTemplateId' => trim( (string) ( $settings['post_template_id'] ?? '' ) ), + 'requireTemplateChoice' => ! empty( $settings['require_template_choice'] ), 'canPublishPosts' => current_user_can( 'publish_posts' ), 'defaultPublicationId' => $default_pub_id, 'publications' => $is_connected ? PublicationResolver::get_editor_publications() : [], diff --git a/src/js/editor/post-settings/components/newsletter-status-notices.js b/src/js/editor/post-settings/components/newsletter-status-notices.js index 777194b..08482b1 100644 --- a/src/js/editor/post-settings/components/newsletter-status-notices.js +++ b/src/js/editor/post-settings/components/newsletter-status-notices.js @@ -131,7 +131,12 @@ function PostPublicationNotices( { beehiivMeta, postPublication } ) { if ( missingTemplate ) { return ( - { __( 'Pick a post template for this post.', 'beehiiv' ) } + { postPublication.requireChoice + ? __( + 'Choose a post template before sending this post to beehiiv.', + 'beehiiv' + ) + : __( 'Pick a post template for this post.', 'beehiiv' ) } ); } diff --git a/src/js/editor/post-settings/components/newsletter-template-select.js b/src/js/editor/post-settings/components/newsletter-template-select.js index 8453cfc..8e9b7de 100644 --- a/src/js/editor/post-settings/components/newsletter-template-select.js +++ b/src/js/editor/post-settings/components/newsletter-template-select.js @@ -21,6 +21,7 @@ import { useBeehiivEditorConfig } from '../hooks/use-beehiiv-editor-config'; * @param {string} props.publicationId Publication whose templates are listed. * @param {boolean} props.isDefaultPublication Whether that is the site-wide default publication. * @param {boolean} [props.disabled] Whether the choice is locked. + * @param {boolean} [props.requireChoice] Whether the editor must choose a template (no automatic default). */ export default function NewsletterTemplateSelect( { value, @@ -28,13 +29,16 @@ export default function NewsletterTemplateSelect( { publicationId, isDefaultPublication, disabled = false, + requireChoice = false, } ) { const { defaultPostTemplateId: siteDefaultPostTemplateId } = useBeehiivEditorConfig(); - // Only the site-wide default publication has a default template. - const defaultPostTemplateId = isDefaultPublication - ? siteDefaultPostTemplateId - : ''; + // Only the site-wide default publication has a default template, and + // it is not applied automatically when the editor must choose. + const defaultPostTemplateId = + isDefaultPublication && ! requireChoice + ? siteDefaultPostTemplateId + : ''; const [ templates, setTemplates ] = useState( [] ); const [ isLoading, setIsLoading ] = useState( false ); const [ justRefreshed, setJustRefreshed ] = useState( false ); @@ -153,12 +157,38 @@ export default function NewsletterTemplateSelect( { if ( ! selectedValue ) { opts.unshift( { value: '', - label: __( 'Select a template', 'beehiiv' ), + label: requireChoice + ? __( 'Choose a template', 'beehiiv' ) + : __( 'Select a template', 'beehiiv' ), } ); } return opts; - }, [ templates, value, selectedValue, defaultPostTemplateId ] ); + }, [ + templates, + value, + selectedValue, + defaultPostTemplateId, + requireChoice, + ] ); + + const getHelpText = () => { + if ( disabled ) { + return __( + "The template can't be changed once the newsletter is in beehiiv.", + 'beehiiv' + ); + } + + if ( requireChoice && ! selectedValue ) { + return __( + 'Choose a post template before sending this post to beehiiv.', + 'beehiiv' + ); + } + + return undefined; + }; if ( isLoading && options.length === 0 ) { return ( @@ -179,14 +209,7 @@ export default function NewsletterTemplateSelect( { value={ selectedValue } options={ options } onChange={ onChange } - help={ - disabled - ? __( - "The template can't be changed once the newsletter is in beehiiv.", - 'beehiiv' - ) - : undefined - } + help={ getHelpText() } disabled={ disabled || isLoading } __nextHasNoMarginBottom /> diff --git a/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js b/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js index 100bed7..fcad20b 100644 --- a/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js +++ b/src/js/editor/post-settings/hooks/use-beehiiv-editor-config.js @@ -12,6 +12,7 @@ const DEFAULT_CONFIG = { hasPostTemplate: false, publicationId: '', defaultPostTemplateId: '', + requireTemplateChoice: false, canPublishPosts: false, defaultPublicationId: '', publications: [], diff --git a/src/js/editor/post-settings/hooks/use-post-publication.js b/src/js/editor/post-settings/hooks/use-post-publication.js index 18886ed..fe25f33 100644 --- a/src/js/editor/post-settings/hooks/use-post-publication.js +++ b/src/js/editor/post-settings/hooks/use-post-publication.js @@ -16,6 +16,7 @@ import { useEditorPublications } from './use-editor-publications'; * @property {boolean} isDefaultPublication Whether the effective publication is the default. * @property {boolean} isFallback Whether the saved publication is no longer connected. * @property {boolean} missingPublication Whether no publication can be used yet. + * @property {boolean} requireChoice Whether an editor must choose this post's template. * @property {boolean} missingTemplate Whether the post still needs a template. * @property {boolean} isPostReady Whether the post has a publication and a template. */ @@ -25,8 +26,11 @@ import { useEditorPublications } from './use-editor-publications'; * @return {PostPublication} Per-post publication state. */ export function usePostPublication( beehiivMeta ) { - const { defaultPublicationId, defaultPostTemplateId } = - useBeehiivEditorConfig(); + const { + defaultPublicationId, + defaultPostTemplateId, + requireTemplateChoice, + } = useBeehiivEditorConfig(); const publications = useEditorPublications().filter( ( item ) => item?.id ); const storedPublicationId = beehiivMeta?.beehiivPublicationId || ''; const templateId = beehiivMeta?.beehiivPostTemplateId || ''; @@ -45,10 +49,21 @@ export function usePostPublication( beehiivMeta ) { !! effectivePublicationId && effectivePublicationId === defaultPublicationId; + // With the required-template setting on, a never-sent post has no automatic template. + const requireChoice = + requireTemplateChoice && ! beehiivMeta?.newsletterAlreadySent; + // On fallback the server uses the default template only. - const hasTemplate = isFallback - ? !! defaultPostTemplateId - : !! templateId || ( isDefaultPublication && !! defaultPostTemplateId ); + let hasTemplate; + if ( requireChoice ) { + hasTemplate = ! isFallback && !! templateId; + } else if ( isFallback ) { + hasTemplate = !! defaultPostTemplateId; + } else { + hasTemplate = + !! templateId || + ( isDefaultPublication && !! defaultPostTemplateId ); + } return { publications, @@ -57,6 +72,7 @@ export function usePostPublication( beehiivMeta ) { effectivePublicationId, isDefaultPublication, isFallback, + requireChoice, missingPublication: ! effectivePublicationId, missingTemplate: !! effectivePublicationId && ! hasTemplate, isPostReady: !! effectivePublicationId && hasTemplate, diff --git a/src/js/editor/post-settings/index.js b/src/js/editor/post-settings/index.js index 636895f..ced2806 100644 --- a/src/js/editor/post-settings/index.js +++ b/src/js/editor/post-settings/index.js @@ -90,6 +90,7 @@ function NewsletterPublicationAndTemplate( { beehiivMeta, postPublication } ) { onChange={ setBeehiivPostTemplateId } publicationId={ effectivePublicationId } isDefaultPublication={ isDefaultPublication } + requireChoice={ postPublication.requireChoice } // Sync never changes a newsletter's template, so it is locked once linked. disabled={ newsletterAlreadySent } /> diff --git a/tests/phpunit/EditorPostSettingsTest.php b/tests/phpunit/EditorPostSettingsTest.php index ece00f1..f2a4017 100644 --- a/tests/phpunit/EditorPostSettingsTest.php +++ b/tests/phpunit/EditorPostSettingsTest.php @@ -10,6 +10,7 @@ * @package beehiiv */ +use Beehiiv\Config; use Beehiiv\Editor\Meta; use Beehiiv\Editor\PostSettings; @@ -322,4 +323,21 @@ public function test_editor_save_with_readonly_meta_is_not_rejected(): void { $this->assertSame( 'pub_qa', get_post_meta( $post_id, Meta::BEEHIIV_PUBLICATION_ID, true ) ); $this->assertSame( '', get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ) ); } + + /** + * Newsletter Template Selection PRD AC-014, AC-019: the editor learns whether + * the required-template setting is on, and reads off when never saved. + */ + public function test_editor_config_exposes_require_template_choice(): void { + $method = new ReflectionMethod( PostSettings::class, 'get_editor_config' ); + $method->setAccessible( true ); + + delete_option( Config::OPTION_NAME ); + $this->assertFalse( $method->invoke( null )['requireTemplateChoice'] ); + + update_option( Config::OPTION_NAME, array( 'require_template_choice' => true ) ); + $this->assertTrue( $method->invoke( null )['requireTemplateChoice'] ); + + delete_option( Config::OPTION_NAME ); + } } From 4900e243b01c64ac60d22ecba1dff3d1e6d21270 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 21 Oct 2026 20:33:11 +0800 Subject: [PATCH 6/8] test(editor-integration): cover required-template prompt in e2e --- .../newsletter-template-selection.spec.js | 590 ++++++++++++++++++ 1 file changed, 590 insertions(+) create mode 100644 tests/e2e/specs/newsletter-template-selection.spec.js diff --git a/tests/e2e/specs/newsletter-template-selection.spec.js b/tests/e2e/specs/newsletter-template-selection.spec.js new file mode 100644 index 0000000..728e647 --- /dev/null +++ b/tests/e2e/specs/newsletter-template-selection.spec.js @@ -0,0 +1,590 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { openPostEditor, openBeehiivSidebar } = require( '../utils/editor' ); +const { wpCli, wpCliSafe, ensurePluginActive } = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/06-editor-integration/2-editor-sidebar/newsletter-template-selection.prd.md (v1.0) + * + * Independent behavioral coverage (qa-e2e-author), written from the PRD's + * Acceptance Criteria plus the real code at: + * - src/js/editor/post-settings/components/newsletter-template-select.js + * - src/js/editor/post-settings/components/newsletter-status-notices.js + * - src/js/editor/post-settings/hooks/use-post-publication.js + * - src/js/editor/post-settings/index.js + * - includes/Editor/PostSettings.php + * + * Every outbound beehiiv request is answered by the test-only + * `pre_http_request` mock in tests/e2e/plugins/beehiiv-options.php. + * Nothing reaches the live API. + */ + +const META = { + send: '_beehiiv_send_to_newsletter', + postId: '_beehiiv_post_id', + template: '_beehiiv_post_template_id', + snippet: '_beehiiv_send_to_newsletter_snippet', +}; + +const PUBLICATION_ID = 'pub_qa_e2e_nts'; +const DEFAULT_TEMPLATE = 'tpl_qa_e2e_nts_default'; +const OTHER_TEMPLATE = 'tpl_qa_e2e_nts_other'; +const NEW_TEMPLATE = 'tpl_qa_e2e_nts_brand_new'; +const GONE_TEMPLATE = 'tpl_qa_e2e_nts_deleted'; +const LINKED_ID = 'post_qa_e2e_nts_linked'; +const REQUIRED_MESSAGE = + 'Choose a post template before sending this post to beehiiv.'; +const SIDEBAR = '.beehiiv-post-settings'; +const CONTENT = + '

QA E2E newsletter template selection body.

'; + +const createdPosts = []; + +const b64 = ( value ) => + Buffer.from( JSON.stringify( value ) ).toString( 'base64' ); + +/** + * Runs PHP inside the tests env; the payload travels base64-encoded. + * + * @param {Object} payload Values exposed to the PHP snippet as `$a`. + * @param {string} php PHP code to evaluate. + * @return {string} wp-cli output. + */ +function wpEvalWith( payload, php ) { + return wpCli( + `eval '$a = json_decode( base64_decode( "${ b64( + payload + ) }" ), true ); ${ php }'` + ); +} + +/** + * Seeds a "ready to send" site with templates, plus optional settings. + * + * @param {Object} [options] Seed options. + * @param {boolean} [options.requireTemplate] Turn the required-template setting on. + * @param {Array} [options.mocks] Extra `[ needle, response ]` HTTP mocks. + */ +function seed( { requireTemplate = false, mocks = [] } = {} ) { + wpEvalWith( + { requireTemplate, mocks }, + `beehiiv_e2e_reset_all(); + beehiiv_e2e_seed_connection(); + beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] ); + update_option( "beehiiv_settings", [ + "publication_id" => "${ PUBLICATION_ID }", + "post_template_id" => "${ DEFAULT_TEMPLATE }", + "require_template_choice" => (bool) $a["requireTemplate"], + ] ); + beehiiv_e2e_seed_publications( [ [ "id" => "${ PUBLICATION_ID }", "name" => "QA E2E Publication" ] ] ); + beehiiv_e2e_seed_post_templates( "${ PUBLICATION_ID }", [ + [ "id" => "${ DEFAULT_TEMPLATE }", "name" => "QA Default Template" ], + [ "id" => "${ OTHER_TEMPLATE }", "name" => "QA Other Template" ], + ] ); + foreach ( $a["mocks"] as $m ) { beehiiv_e2e_mock_http( $m[0], $m[1] ); } + beehiiv_e2e_mock_http( "/posts", [ "body" => [ "data" => [ "id" => "post_qa_e2e_nts_created" ] ] ] ); + beehiiv_e2e_clear_http_log();` + ); +} + +/** + * Creates a post fixture with "Send to newsletter" on. + * + * @param {string} title Post title. + * @param {Object} [options] Options. + * @param {Object} [options.meta] Post meta. + * @param {string} [options.status] Post status. + * @return {string} Post ID. + */ +function createPost( title, { meta = {}, status = 'draft' } = {} ) { + const id = wpEvalWith( + { + post: { + post_type: 'post', + post_title: title, + post_content: CONTENT, + post_status: status, + post_author: 1, + }, + meta: { [ META.send ]: '1', ...meta }, + }, + `$id = wp_insert_post( $a["post"] ); + foreach ( $a["meta"] as $k => $v ) { update_post_meta( $id, $k, $v ); } + beehiiv_e2e_clear_http_log(); + echo $id . "\\n";` + ); + createdPosts.push( id ); + return id; +} + +/** + * Opens the editor sidebar for a post and waits for the template picker. + * + * @param {import('@playwright/test').Page} page Playwright page. + * @param {string} postId Post ID. + * @return {Promise} Sidebar locator. + */ +async function openSidebarFor( page, postId ) { + await openPostEditor( page, postId ); + await openBeehiivSidebar( page ); + const sidebar = page.locator( SIDEBAR ); + await expect( sidebar.getByLabel( 'Post template' ) ).toBeVisible( { + timeout: 20000, + } ); + return sidebar; +} + +test.describe.configure( { timeout: 150 * 1000 } ); + +test.beforeAll( async () => { + ensurePluginActive(); +} ); + +test.afterAll( async () => { + if ( createdPosts.length ) { + wpCliSafe( + `eval 'foreach ( array( ${ createdPosts.join( + ', ' + ) } ) as $id ) { wp_delete_post( $id, true ); }'` + ); + } + wpCliSafe( + `eval 'delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();'` + ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-001: select a custom template for a post', () => { + test( 'AC-001: the Post template select appears when newsletter delivery is on and templates exist', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E nts AC-001' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + const select = sidebar.getByLabel( 'Post template' ); + await expect( select ).toBeVisible(); + await expect( select ).toBeEnabled(); + } ); + + test( 'AC-002: with the setting off, the site default is preselected and labeled "(default)"', async ( { + page, + } ) => { + seed( { requireTemplate: false } ); + const id = createPost( 'QA E2E nts AC-002 off' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + const select = sidebar.getByLabel( 'Post template' ); + await expect( select ).toHaveValue( DEFAULT_TEMPLATE ); + await expect( select.locator( 'option:checked' ) ).toHaveText( + 'QA Default Template (default)' + ); + } ); + + test( 'AC-002: with the setting on but the post already sent to beehiiv, the site default is preselected and labeled "(default)"', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E nts AC-002 sent', { + status: 'publish', + meta: { [ META.postId ]: LINKED_ID }, + } ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + const select = sidebar.getByLabel( 'Post template' ); + await expect( select ).toHaveValue( DEFAULT_TEMPLATE ); + await expect( select.locator( 'option:checked' ) ).toHaveText( + 'QA Default Template (default)' + ); + } ); + + test( 'AC-003: help text below the dropdown explains which template is used when Default is selected', async ( { + page, + } ) => { + seed( { requireTemplate: false } ); + const id = createPost( 'QA E2E nts AC-003' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + const help = sidebar + .locator( '.beehiiv-newsletter-template' ) + .locator( '.components-base-control__help' ); + await expect( help ).toBeVisible( { timeout: 3000 } ); + await expect( help ).toContainText( /default/i ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-002: view templates from the beehiiv account', () => { + test( 'AC-004: templates are fetched from the REST API when the picker mounts', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E nts AC-004' ); + await loginAsAdmin( page ); + + const request = page.waitForRequest( + ( req ) => + decodeURIComponent( req.url() ).includes( + 'beehiiv/v1/post-templates' + ) && + req.url().includes( `publication_id=${ PUBLICATION_ID }` ) && + ! req.url().includes( 'refresh=1' ), + { timeout: 30000 } + ); + await openPostEditor( page, id ); + await openBeehiivSidebar( page ); + await request; + + const sidebar = page.locator( SIDEBAR ); + const options = sidebar + .getByLabel( 'Post template' ) + .locator( 'option' ); + await expect( options ).toHaveCount( 2 ); + } ); + + test( 'AC-005: the dropdown is disabled while templates are loading', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E nts AC-005' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + // Slow the next fetch so the loading window is observable. + await page.route( /post-templates/, async ( route ) => { + await new Promise( ( resolve ) => setTimeout( resolve, 3000 ) ); + await route.continue(); + } ); + await sidebar + .getByRole( 'button', { name: 'Refresh templates' } ) + .click(); + + await expect( sidebar.getByLabel( 'Post template' ) ).toBeDisabled(); + await expect( sidebar.getByLabel( 'Post template' ) ).toBeEnabled( { + timeout: 15000, + } ); + } ); + + test( 'AC-006: a loading spinner appears while templates are being fetched', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E nts AC-006' ); + await loginAsAdmin( page ); + + await page.route( /post-templates/, async ( route ) => { + await new Promise( ( resolve ) => setTimeout( resolve, 4000 ) ); + await route.continue(); + } ); + await openPostEditor( page, id ); + await openBeehiivSidebar( page ); + + const sidebar = page.locator( SIDEBAR ); + await expect( + sidebar.locator( + '.beehiiv-newsletter-template .components-spinner' + ) + ).toBeVisible( { timeout: 10000 } ); + await expect( sidebar.getByLabel( 'Post template' ) ).toBeVisible( { + timeout: 20000, + } ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-003: refresh templates without reloading', () => { + test( 'AC-007: Refresh templates reloads the list from beehiiv', async ( { + page, + } ) => { + seed( { + mocks: [ + [ + '/post_templates', + { + body: { + data: [ + { + id: DEFAULT_TEMPLATE, + name: 'QA Default Template', + }, + { + id: OTHER_TEMPLATE, + name: 'QA Other Template', + }, + { + id: NEW_TEMPLATE, + name: 'QA Brand New Template', + }, + ], + }, + }, + ], + ], + } ); + const id = createPost( 'QA E2E nts AC-007' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + const select = sidebar.getByLabel( 'Post template' ); + await expect( select.locator( 'option' ) ).toHaveCount( 2 ); + + await sidebar + .getByRole( 'button', { name: 'Refresh templates' } ) + .click(); + + await expect( select.locator( 'option' ) ).toHaveCount( 3 ); + await expect( + select.locator( `option[value="${ NEW_TEMPLATE }"]` ) + ).toHaveText( 'QA Brand New Template' ); + } ); + + test( 'AC-008: "Templates updated from beehiiv." appears after refresh and disappears after about 4 seconds', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E nts AC-008' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await sidebar + .getByRole( 'button', { name: 'Refresh templates' } ) + .click(); + const notice = sidebar.getByText( 'Templates updated from beehiiv.' ); + await expect( notice ).toBeVisible(); + const shownAt = Date.now(); + await expect( notice ).toBeHidden( { timeout: 8000 } ); + const visibleMs = Date.now() - shownAt; + expect( visibleMs ).toBeGreaterThan( 2500 ); + expect( visibleMs ).toBeLessThan( 6500 ); + } ); + + test( 'AC-009: explanatory text below the refresh button notes that templates are cached', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E nts AC-009' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await expect( + sidebar.locator( '.beehiiv-newsletter-template__refresh-help' ) + ).toContainText( 'Templates are cached.' ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-004 / US-005: snippet mode', () => { + test( 'AC-010: the Snippet newsletter toggle appears below the template selector', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E nts AC-010' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + const toggle = sidebar.getByLabel( 'Snippet newsletter' ); + await expect( toggle ).toBeVisible(); + const selectBox = await sidebar + .getByLabel( 'Post template' ) + .boundingBox(); + const toggleBox = await toggle.boundingBox(); + expect( toggleBox.y ).toBeGreaterThan( selectBox.y ); + } ); + + test( 'AC-011: enabling snippet mode reveals help text about inserting a More block', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E nts AC-011' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + const moreHelp = sidebar.getByText( + 'Insert the "More" block in your post to mark where the snippet ends.' + ); + await expect( moreHelp ).toHaveCount( 0 ); + + await sidebar.getByLabel( 'Snippet newsletter' ).check(); + await expect( moreHelp ).toBeVisible(); + } ); + + test( 'AC-012: the help text explains the More block marks the snippet boundary', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E nts AC-012', { + meta: { [ META.snippet ]: '1' }, + } ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + const help = sidebar.locator( '.beehiiv-post-settings-snippet' ); + await expect( help ).toContainText( '"More" block' ); + await expect( help ).toContainText( 'where the snippet ends' ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-006: verify template assignment', () => { + test( 'AC-013: a saved template no longer available in beehiiv is still listed and labeled as unavailable', async ( { + page, + } ) => { + seed(); + const id = createPost( 'QA E2E nts AC-013', { + meta: { [ META.template ]: GONE_TEMPLATE }, + } ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + const select = sidebar.getByLabel( 'Post template' ); + await expect( select ).toHaveValue( GONE_TEMPLATE ); + const option = select.locator( `option[value="${ GONE_TEMPLATE }"]` ); + await expect( option ).toHaveCount( 1 ); + // The PRD says the option is labeled as unavailable, not just shown as a bare ID. + await expect( option ).toHaveText( /unavailable/i ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-007: required template prompt', () => { + test( 'AC-014: with the setting on and a never-sent post, the field starts empty with "Choose a template"', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E nts AC-014' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + const select = sidebar.getByLabel( 'Post template' ); + await expect( select ).toHaveValue( '' ); + await expect( select.locator( 'option:checked' ) ).toHaveText( + 'Choose a template' + ); + } ); + + test( 'AC-015: no template is pre-selected and the "Default (from beehiiv settings)" option is not offered', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E nts AC-015' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + const select = sidebar.getByLabel( 'Post template' ); + await expect( select ).not.toHaveValue( DEFAULT_TEMPLATE ); + await expect( + select.locator( 'option', { + hasText: 'Default (from beehiiv settings)', + } ) + ).toHaveCount( 0 ); + await expect( + select.locator( 'option', { hasText: '(default)' } ) + ).toHaveCount( 0 ); + // Real templates stay selectable. + await expect( + select.locator( `option[value="${ DEFAULT_TEMPLATE }"]` ) + ).toHaveText( 'QA Default Template' ); + } ); + + test( 'AC-016: help text under the empty field asks the editor to choose a post template', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E nts AC-016' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await expect( + sidebar + .locator( '.beehiiv-newsletter-template' ) + .locator( '.components-base-control__help' ) + ).toHaveText( REQUIRED_MESSAGE ); + } ); + + test( 'AC-017: a send blocked for a missing template shows an editor error notice with the same message', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E nts AC-017' ); + + // Publish through the real save path; the send is blocked server-side. + await loginAsAdmin( page ); + const nonceRes = await page.request.get( + '/wp-admin/admin-ajax.php?action=rest-nonce' + ); + const nonce = ( await nonceRes.text() ).trim(); + const res = await page.request.fetch( `/wp-json/wp/v2/posts/${ id }`, { + method: 'POST', + headers: { 'X-WP-Nonce': nonce }, + data: { status: 'publish' }, + } ); + expect( res.status() ).toBe( 200 ); + + const sidebar = await openSidebarFor( page, id ); + const errorNotice = sidebar + .locator( '.components-notice.is-error' ) + .filter( { hasText: REQUIRED_MESSAGE } ); + await expect( errorNotice ).toBeVisible(); + } ); + + test( 'AC-018: the prompt and help text clear once the editor chooses a template', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E nts AC-018' ); + await loginAsAdmin( page ); + const sidebar = await openSidebarFor( page, id ); + + await expect( + sidebar.getByText( REQUIRED_MESSAGE ).first() + ).toBeVisible(); + + const select = sidebar.getByLabel( 'Post template' ); + await select.selectOption( OTHER_TEMPLATE ); + + await expect( select ).toHaveValue( OTHER_TEMPLATE ); + await expect( sidebar.getByText( REQUIRED_MESSAGE ) ).toHaveCount( 0 ); + await expect( + select.locator( 'option', { hasText: 'Choose a template' } ) + ).toHaveCount( 0 ); + } ); + + test( 'AC-019: with the setting off, or the post already sent, there is no prompt, help text or notice', async ( { + page, + } ) => { + // Setting off, never sent. + seed( { requireTemplate: false } ); + const offId = createPost( 'QA E2E nts AC-019 off' ); + await loginAsAdmin( page ); + let sidebar = await openSidebarFor( page, offId ); + await expect( sidebar.getByText( REQUIRED_MESSAGE ) ).toHaveCount( 0 ); + await expect( + sidebar + .getByLabel( 'Post template' ) + .locator( 'option', { hasText: 'Choose a template' } ) + ).toHaveCount( 0 ); + await expect( sidebar.getByLabel( 'Post template' ) ).toHaveValue( + DEFAULT_TEMPLATE + ); + + // Setting on, already sent. + seed( { requireTemplate: true } ); + const sentId = createPost( 'QA E2E nts AC-019 sent', { + status: 'publish', + meta: { [ META.postId ]: LINKED_ID }, + } ); + sidebar = await openSidebarFor( page, sentId ); + await expect( sidebar.getByText( REQUIRED_MESSAGE ) ).toHaveCount( 0 ); + await expect( + sidebar + .getByLabel( 'Post template' ) + .locator( 'option', { hasText: 'Choose a template' } ) + ).toHaveCount( 0 ); + await expect( sidebar.getByLabel( 'Post template' ) ).toHaveValue( + DEFAULT_TEMPLATE + ); + } ); +} ); From 1d5e032e233ab72c5cd84d8c3cb075c546962889 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 21 Oct 2026 22:41:04 +0800 Subject: [PATCH 7/8] fix(editor-integration): show required-template notice as an error in the publish panel Open the pre-publish panel when the newsletter still needs a template, show the required-template message as an error, and add top spacing to beehiiv notices outside the sidebar. --- .../components/newsletter-status-notices.js | 4 +++- src/js/editor/post-settings/css/_sidebar.scss | 5 +++++ src/js/editor/post-settings/index.js | 9 +++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/js/editor/post-settings/components/newsletter-status-notices.js b/src/js/editor/post-settings/components/newsletter-status-notices.js index 08482b1..90fc671 100644 --- a/src/js/editor/post-settings/components/newsletter-status-notices.js +++ b/src/js/editor/post-settings/components/newsletter-status-notices.js @@ -130,7 +130,9 @@ function PostPublicationNotices( { beehiivMeta, postPublication } ) { if ( missingTemplate ) { return ( - + { postPublication.requireChoice ? __( 'Choose a post template before sending this post to beehiiv.', diff --git a/src/js/editor/post-settings/css/_sidebar.scss b/src/js/editor/post-settings/css/_sidebar.scss index 7a46f98..7438533 100644 --- a/src/js/editor/post-settings/css/_sidebar.scss +++ b/src/js/editor/post-settings/css/_sidebar.scss @@ -8,6 +8,11 @@ ); } +// Also applies in the pre-publish panel, outside the sidebar content wrapper. +.beehiiv-post-settings-notice { + margin-top: 12px; +} + .beehiiv-post-settings-content { /* stylelint-disable selector-class-pattern -- @wordpress/components */ .components-panel__body { diff --git a/src/js/editor/post-settings/index.js b/src/js/editor/post-settings/index.js index ced2806..77af8a3 100644 --- a/src/js/editor/post-settings/index.js +++ b/src/js/editor/post-settings/index.js @@ -368,6 +368,14 @@ function BeehiivSendNewsletterPrePublishPanel() { postPublishDate ); + // Pre-publish panels start collapsed. Open this one when the newsletter + // still needs a template, so the warning is visible before publishing. + const needsTemplate = + sendToNewsletter && + ! newsletterAlreadySent && + isNewsletterReady && + postPublication.missingTemplate; + const sendToNewsletterStatus = sendToNewsletter && canWritePosts ? __( 'Yes', 'beehiiv' ) @@ -385,6 +393,7 @@ function BeehiivSendNewsletterPrePublishPanel() { } icon={ false } + initialOpen={ needsTemplate } > { showSendToggle && ( Date: Mon, 26 Oct 2026 22:51:47 +0800 Subject: [PATCH 8/8] fix(editor-integration): meet template picker ACs and update tests for the new setting Show help text for the default template, a loading spinner while templates load, and an unavailable label for deleted templates. Add publish-panel e2e tests, and update the settings storage and validation e2e specs for the new require-template setting. --- .../components/newsletter-template-select.js | 29 +++++++++++- .../newsletter-template-selection.spec.js | 46 ++++++++++++++++++- .../specs/settings-storage-retrieval.spec.js | 2 + .../specs/settings-validation-cache.spec.js | 2 + 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/js/editor/post-settings/components/newsletter-template-select.js b/src/js/editor/post-settings/components/newsletter-template-select.js index 8e9b7de..06d84bb 100644 --- a/src/js/editor/post-settings/components/newsletter-template-select.js +++ b/src/js/editor/post-settings/components/newsletter-template-select.js @@ -148,9 +148,19 @@ export default function NewsletterTemplateSelect( { } if ( value && ! opts.some( ( option ) => option.value === value ) ) { + // Only call it unavailable once the list has loaded with templates in it, + // so a slow or failed fetch does not mislabel a good template. + const isUnavailable = ! isLoading && templates.length > 0; + opts.push( { value, - label: value, + label: isUnavailable + ? sprintf( + /* translators: %s: post template ID */ + __( '%s (unavailable)', 'beehiiv' ), + value + ) + : value, } ); } @@ -170,6 +180,7 @@ export default function NewsletterTemplateSelect( { selectedValue, defaultPostTemplateId, requireChoice, + isLoading, ] ); const getHelpText = () => { @@ -187,6 +198,21 @@ export default function NewsletterTemplateSelect( { ); } + if ( ! value && defaultPostTemplateId ) { + const defaultTemplate = templates.find( + ( item ) => item?.id === defaultPostTemplateId + ); + + return sprintf( + /* translators: %s: default post template name */ + __( + 'This post uses the default template, "%s", unless you choose another.', + 'beehiiv' + ), + defaultTemplate?.name || defaultPostTemplateId + ); + } + return undefined; }; @@ -204,6 +230,7 @@ export default function NewsletterTemplateSelect( { return (
+ { isLoading && } { const sidebar = await openSidebarFor( page, id ); const errorNotice = sidebar .locator( '.components-notice.is-error' ) - .filter( { hasText: REQUIRED_MESSAGE } ); + .filter( { hasText: REQUIRED_MESSAGE } ) + .first(); await expect( errorNotice ).toBeVisible(); } ); @@ -587,4 +588,47 @@ test.describe( 'US-007: required template prompt', () => { DEFAULT_TEMPLATE ); } ); + + test( 'publish panel: opens with a spaced error notice when a template is still required', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E nts publish panel' ); + await loginAsAdmin( page ); + await openPostEditor( page, id ); + + await page + .getByRole( 'button', { name: 'Publish', exact: true } ) + .first() + .click(); + + // The panel opens by itself, so the warning is visible without expanding it. + const notice = page + .locator( '.editor-post-publish-panel' ) + .locator( '.components-notice.is-error' ) + .filter( { hasText: REQUIRED_MESSAGE } ); + await expect( notice ).toBeVisible( { timeout: 20000 } ); + await expect( notice ).toHaveCSS( 'margin-top', '12px' ); + } ); + + test( 'publish panel: no required-template error once a template is chosen', async ( { + page, + } ) => { + seed( { requireTemplate: true } ); + const id = createPost( 'QA E2E nts publish panel chosen', { + meta: { [ META.template ]: OTHER_TEMPLATE }, + } ); + await loginAsAdmin( page ); + await openPostEditor( page, id ); + + await page + .getByRole( 'button', { name: 'Publish', exact: true } ) + .first() + .click(); + + await expect( + page.locator( '.editor-post-publish-panel' ) + ).toBeVisible( { timeout: 20000 } ); + await expect( page.getByText( REQUIRED_MESSAGE ) ).toHaveCount( 0 ); + } ); } ); diff --git a/tests/e2e/specs/settings-storage-retrieval.spec.js b/tests/e2e/specs/settings-storage-retrieval.spec.js index 4138737..df7a3be 100644 --- a/tests/e2e/specs/settings-storage-retrieval.spec.js +++ b/tests/e2e/specs/settings-storage-retrieval.spec.js @@ -45,6 +45,8 @@ const EXPECTED_KEYS = [ // Added by PRD-06.8.02 (per-post publication selector). 'defaultPublicationId', 'publications', + // Added by the required-template setting (Publication and Template Configuration). + 'requireTemplateChoice', ].sort(); let fixturePostId; diff --git a/tests/e2e/specs/settings-validation-cache.spec.js b/tests/e2e/specs/settings-validation-cache.spec.js index d20c35e..72a6430 100644 --- a/tests/e2e/specs/settings-validation-cache.spec.js +++ b/tests/e2e/specs/settings-validation-cache.spec.js @@ -206,6 +206,8 @@ test.describe( 'Settings validation & cache management', () => { expect( baseline ).toEqual( { publication_id: 'qa-e2e-pub-baseline', post_template_id: 'qa-e2e-tmpl-baseline', + // The required-template setting is off unless its box is checked. + require_template_choice: false, } ); // Submit `beehiiv_settings` as a flat (non-bracketed) field, so PHP