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