From 45285345dca012f5cf9865908318f34bef23ea75 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 2 Nov 2026 21:28:32 +0800 Subject: [PATCH 1/8] feat(newsletter-publishing): convert Group blocks to beehiiv sections Feature: newsletter-publishing/block-conversion/block-conversion-framework Implements: - US-006: A Group block becomes a beehiiv section holding its converted inner blocks Posts first synced with this change keep sections on later syncs. Posts synced before it keep omitting Group blocks. --- includes/Editor/Meta.php | 9 + includes/Newsletter/BlockConverter.php | 61 +++++- includes/Newsletter/PostSettingsBuilder.php | 26 ++- includes/Newsletter/Sender.php | 2 + includes/Newsletter/SupportedBlocks.php | 7 +- tests/phpunit/BlockConverterGroupTest.php | 228 ++++++++++++++++++++ 6 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 tests/phpunit/BlockConverterGroupTest.php diff --git a/includes/Editor/Meta.php b/includes/Editor/Meta.php index 8dfc86a..ca9deb2 100644 --- a/includes/Editor/Meta.php +++ b/includes/Editor/Meta.php @@ -72,6 +72,15 @@ final class Meta { */ public const BEEHIIV_POST_ID = '_beehiiv_post_id'; + /** + * Set when the post was first synced with Group blocks converted to beehiiv sections. + * + * Posts synced before section support lack it and keep omitting Group blocks on re-sync. + * + * @since x.x.x + */ + public const BEEHIIV_GROUP_SECTIONS = '_beehiiv_group_sections'; + /** * UTC ISO 8601 datetime last synced to beehiiv `scheduled_at` for a linked post. * diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 32a1f94..86d68d2 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -38,7 +38,11 @@ * - `core/more` — snippet newsletters only; beehiiv Read More `button` via `convert_more_block()` * - `beehiiv/advertisement` — beehiiv `advertisement` block via `convert_advertisement_block()` * - * Layout blocks (`core/group`, `core/columns`, etc.) are skipped at the top level. + * - `core/group` — `convert_group_block()` (beehiiv `section` block holding the converted inner + * blocks; only for posts whose first sync used sections, see `PostSettingsBuilder`) + * + * Other layout blocks (`core/columns`, etc.) are skipped at the top level, and so is + * `core/group` for posts first synced before section support. * `convert_columns_block()` exists for internal reuse (e.g. `core/media-text`) and is * not wired into the main block walk yet. Snippet mode stops at `core/more`. * @@ -49,11 +53,13 @@ final class BlockConverter { /** * Convert a WordPress post's blocks to beehiiv blocks. * - * @param WP_Post $post_object Post object. + * @param WP_Post $post_object Post object. + * @param bool $convert_groups Whether `core/group` blocks become beehiiv sections. When false, + * Group blocks are omitted. * @return array> * @since 1.0.0 */ - public static function convert_supported_blocks( WP_Post $post_object ): array { + public static function convert_supported_blocks( WP_Post $post_object, bool $convert_groups = false ): array { $beehiiv_blocks = []; $wp_blocks = parse_blocks( $post_object->post_content ); @@ -73,7 +79,8 @@ public static function convert_supported_blocks( WP_Post $post_object ): array { $converted_blocks = self::convert_blocks( $wp_blocks, $post_object, - $is_send_newsletter_snippet + $is_send_newsletter_snippet, + $convert_groups ); return array_values( @@ -89,13 +96,15 @@ public static function convert_supported_blocks( WP_Post $post_object ): array { * @param array> $wp_blocks Parsed blocks. * @param WP_Post $post_object Post object. * @param bool $is_send_newsletter_snippet Whether snippet mode is enabled. + * @param bool $convert_groups Whether Group blocks become sections. * @return array> * @since 1.0.0 */ private static function convert_blocks( array $wp_blocks, WP_Post $post_object, - bool $is_send_newsletter_snippet + bool $is_send_newsletter_snippet, + bool $convert_groups = false ): array { $beehiiv_blocks = []; @@ -111,6 +120,16 @@ private static function convert_blocks( break; } + if ( 'core/group' === $block_name ) { + $section_block = $convert_groups ? self::convert_group_block( $wp_block, $post_object ) : []; + + if ( ! empty( $section_block ) ) { + $beehiiv_blocks[] = $section_block; + } + + continue; + } + if ( ! SupportedBlocks::is_supported( $block_name, $is_send_newsletter_snippet ) ) { continue; } @@ -137,6 +156,38 @@ private static function convert_blocks( return $beehiiv_blocks; } + /** + * Convert a core/group block to a beehiiv section block. + * + * The Group's inner blocks are converted with the same rules as top-level blocks and + * placed in the section in their original order. A nested Group becomes a nested + * section. Section settings are left at beehiiv defaults. Returns an empty array when + * no inner block converts, so no empty section is sent. Snippet-only blocks are + * omitted inside a section. + * + * @param array $wp_block Parsed core/group block. + * @param WP_Post $post_object Post object. + * @return array + * @since x.x.x + */ + private static function convert_group_block( array $wp_block, WP_Post $post_object ): array { + $inner_blocks = self::convert_blocks( + $wp_block['innerBlocks'] ?? [], + $post_object, + false, + true + ); + + if ( empty( $inner_blocks ) ) { + return []; + } + + return [ + 'type' => 'section', + 'blocks' => $inner_blocks, + ]; + } + /** * Convert a single supported WordPress block. * diff --git a/includes/Newsletter/PostSettingsBuilder.php b/includes/Newsletter/PostSettingsBuilder.php index df74b14..de73b5f 100644 --- a/includes/Newsletter/PostSettingsBuilder.php +++ b/includes/Newsletter/PostSettingsBuilder.php @@ -94,7 +94,10 @@ public static function get_post_settings( int $post_id, bool $for_update = false ); } - $beehiiv_blocks = BlockConverter::convert_supported_blocks( $post_object ); + $beehiiv_blocks = BlockConverter::convert_supported_blocks( + $post_object, + self::should_convert_groups( $post_id ) + ); if ( empty( $beehiiv_blocks ) ) { return new WP_Error( @@ -264,6 +267,27 @@ public static function get_update_payload( int $post_id ) { return $update['payload']; } + /** + * Whether Group blocks become beehiiv sections for this post. + * + * A post that has no beehiiv post yet is on its first sync, so Groups convert. After + * that, only a post whose first sync converted them keeps converting them; a post + * synced before section support keeps omitting Groups so its beehiiv layout does not change. + * + * @param int $post_id Post ID. + * @return bool + * @since x.x.x + */ + private static function should_convert_groups( int $post_id ): bool { + $beehiiv_post_id = get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ); + + if ( ! is_string( $beehiiv_post_id ) || '' === trim( $beehiiv_post_id ) ) { + return true; + } + + return (bool) get_post_meta( $post_id, Meta::BEEHIIV_GROUP_SECTIONS, true ); + } + /** * Whether a non-empty subtitle was last synced to the linked beehiiv post. * diff --git a/includes/Newsletter/Sender.php b/includes/Newsletter/Sender.php index accbe8a..3a45795 100644 --- a/includes/Newsletter/Sender.php +++ b/includes/Newsletter/Sender.php @@ -536,6 +536,8 @@ public static function send( int $post_id ): void { (string) ( $beehiiv_post_data['post_template_id'] ?? '' ) ); update_post_meta( $post_id, Meta::BEEHIIV_POST_ID, $result['post_id'] ); + // This first sync converted Group blocks to sections; later syncs of this post must too. + update_post_meta( $post_id, Meta::BEEHIIV_GROUP_SECTIONS, true ); update_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, false ); delete_post_meta( $post_id, Meta::BEEHIIV_NEWSLETTER_SENT ); self::persist_synced_subtitle( $post_id, $beehiiv_post_data ); diff --git a/includes/Newsletter/SupportedBlocks.php b/includes/Newsletter/SupportedBlocks.php index ba93a96..80ba90a 100644 --- a/includes/Newsletter/SupportedBlocks.php +++ b/includes/Newsletter/SupportedBlocks.php @@ -12,8 +12,10 @@ /** * Single source of truth for which WordPress blocks are included in newsletters. * - * Layout blocks such as `core/group` and `core/columns` are not supported. They - * and all of their inner blocks are omitted from the newsletter payload. + * `core/group` is supported as a beehiiv section for posts first synced with section + * support; see `BlockConverter::convert_group_block()`. Other layout blocks such as + * `core/columns` are not supported. They and all of their inner blocks are omitted + * from the newsletter payload. * * @since 1.0.0 */ @@ -34,6 +36,7 @@ final class SupportedBlocks { 'core/pullquote', 'core/embed', 'core/media-text', + 'core/group', 'core/buttons', 'core/separator', 'beehiiv/advertisement', diff --git a/tests/phpunit/BlockConverterGroupTest.php b/tests/phpunit/BlockConverterGroupTest.php new file mode 100644 index 0000000..78b3a78 --- /dev/null +++ b/tests/phpunit/BlockConverterGroupTest.php @@ -0,0 +1,228 @@ + 'pub_test', + 'post_template_id' => 'tmpl_test', + ) + ); + } + + /** + * Build a paragraph block's markup. + * + * @param string $text Paragraph text. + * @return string + */ + private function paragraph( string $text ): string { + return "\n

{$text}

\n"; + } + + /** + * Wrap block markup in a Group block. + * + * @param string $inner Inner block markup. + * @return string + */ + private function group( string $inner ): string { + return "\n
{$inner}
\n"; + } + + /** + * Create a published post with the given content. + * + * @param string $content Block markup. + * @return WP_Post + */ + private function create_post( string $content ): WP_Post { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Group post', + 'post_status' => 'publish', + 'post_content' => $content, + ) + ); + + return get_post( $post_id ); + } + + /** + * AC-009 and AC-013: a Group with a heading and two paragraphs is one section with three children. + */ + public function test_group_becomes_one_section_with_children_in_order(): void { + $heading = "\n

Title

\n"; + $post = $this->create_post( + $this->group( $heading . $this->paragraph( 'First' ) . $this->paragraph( 'Second' ) ) + ); + + $blocks = BlockConverter::convert_supported_blocks( $post, true ); + + $this->assertCount( 1, $blocks ); + $this->assertSame( 'section', $blocks[0]['type'] ); + $this->assertCount( 3, $blocks[0]['blocks'] ); + $this->assertSame( + array( 'heading', 'paragraph', 'paragraph' ), + array_column( $blocks[0]['blocks'], 'type' ) + ); + } + + /** + * AC-009: blocks around a Group stay in place and the section keeps their order. + */ + public function test_section_sits_between_sibling_blocks(): void { + $post = $this->create_post( + $this->paragraph( 'Before' ) . $this->group( $this->paragraph( 'Inside' ) ) . $this->paragraph( 'After' ) + ); + + $blocks = BlockConverter::convert_supported_blocks( $post, true ); + + $this->assertSame( + array( 'paragraph', 'section', 'paragraph' ), + array_column( $blocks, 'type' ) + ); + } + + /** + * AC-010: a Group inside a Group becomes a nested section. + */ + public function test_nested_group_becomes_nested_section(): void { + $inner = $this->group( $this->paragraph( 'Deep' ) ); + $post = $this->create_post( $this->group( $this->paragraph( 'Top' ) . $inner ) ); + + $blocks = BlockConverter::convert_supported_blocks( $post, true ); + + $this->assertCount( 1, $blocks ); + $this->assertSame( 'section', $blocks[0]['type'] ); + $this->assertSame( + array( 'paragraph', 'section' ), + array_column( $blocks[0]['blocks'], 'type' ) + ); + $this->assertCount( 1, $blocks[0]['blocks'][1]['blocks'] ); + } + + /** + * AC-011: a section carries only its type and blocks, never Group styling. + */ + public function test_section_uses_default_settings(): void { + $attrs = '{"backgroundColor":"vivid-red","style":{"spacing":{"padding":{"top":"40px"}}}}'; + $content = "\n" + . '
' + . $this->paragraph( 'Styled' ) + . "
\n"; + + $blocks = BlockConverter::convert_supported_blocks( $this->create_post( $content ), true ); + + $this->assertSame( array( 'type', 'blocks' ), array_keys( $blocks[0] ) ); + } + + /** + * AC-012: a Group with no blocks, or no convertible blocks, produces no section. + */ + public function test_group_without_convertible_blocks_produces_no_section(): void { + $unsupported = "\n
x
\n"; + + $empty = BlockConverter::convert_supported_blocks( + $this->create_post( $this->paragraph( 'Kept' ) . $this->group( '' ) ), + true + ); + $only_unsupported = BlockConverter::convert_supported_blocks( + $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $unsupported ) ), + true + ); + + $this->assertSame( array( 'paragraph' ), array_column( $empty, 'type' ) ); + $this->assertSame( array( 'paragraph' ), array_column( $only_unsupported, 'type' ) ); + } + + /** + * AC-012: a Group that mixes convertible and unconvertible blocks keeps only the convertible ones. + */ + public function test_mixed_group_keeps_only_convertible_blocks(): void { + $unsupported = "\n
x
\n"; + $post = $this->create_post( + $this->group( $this->paragraph( 'One' ) . $unsupported . $this->paragraph( 'Two' ) ) + ); + + $blocks = BlockConverter::convert_supported_blocks( $post, true ); + + $this->assertSame( 'section', $blocks[0]['type'] ); + $this->assertCount( 2, $blocks[0]['blocks'] ); + } + + /** + * Without the flag a Group and its inner blocks are omitted, as before section support. + */ + public function test_group_is_omitted_when_conversion_is_off(): void { + $post = $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $this->paragraph( 'Lost' ) ) ); + + $blocks = BlockConverter::convert_supported_blocks( $post ); + + $this->assertSame( array( 'paragraph' ), array_column( $blocks, 'type' ) ); + } + + /** + * AC-014: a post with no beehiiv post yet converts Groups. + */ + public function test_first_sync_converts_groups(): void { + $post = $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $this->paragraph( 'Inside' ) ) ); + $settings = PostSettingsBuilder::get_post_settings( $post->ID ); + + $this->assertIsArray( $settings ); + $this->assertSame( array( 'paragraph', 'section' ), array_column( $settings['blocks'], 'type' ) ); + } + + /** + * AC-014: a post first synced before section support keeps omitting Groups on re-sync. + */ + public function test_post_synced_before_section_support_keeps_omitting_groups(): void { + $post = $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $this->paragraph( 'Inside' ) ) ); + update_post_meta( $post->ID, Meta::BEEHIIV_POST_ID, 'post_legacy' ); + + $settings = PostSettingsBuilder::get_post_settings( $post->ID, true ); + + $this->assertIsArray( $settings ); + $this->assertSame( array( 'paragraph' ), array_column( $settings['blocks'], 'type' ) ); + } + + /** + * AC-014: a post first synced with sections keeps its sections when it is synced again. + */ + public function test_post_first_synced_with_sections_keeps_them_on_resync(): void { + $post = $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $this->paragraph( 'Inside' ) ) ); + update_post_meta( $post->ID, Meta::BEEHIIV_POST_ID, 'post_new' ); + update_post_meta( $post->ID, Meta::BEEHIIV_GROUP_SECTIONS, true ); + + $settings = PostSettingsBuilder::get_post_settings( $post->ID, true ); + + $this->assertIsArray( $settings ); + $this->assertSame( array( 'paragraph', 'section' ), array_column( $settings['blocks'], 'type' ) ); + } +} From f65f06fd82f824932974ece51bdabbf18f9b79ce Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 2 Nov 2026 21:43:28 +0800 Subject: [PATCH 2/8] test(e2e): cover block conversion framework acceptance criteria --- .../specs/block-conversion-framework.spec.js | 643 ++++++++++++++++++ 1 file changed, 643 insertions(+) create mode 100644 tests/e2e/specs/block-conversion-framework.spec.js diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js new file mode 100644 index 0000000..948136e --- /dev/null +++ b/tests/e2e/specs/block-conversion-framework.spec.js @@ -0,0 +1,643 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { + wpCli, + wpCliSafe, + ensurePluginActive, + ensurePrettyPermalinks, +} = require( '../utils/wp-cli' ); + +/** + * PRD: requirements/07-newsletter-publishing/2-block-conversion/block-conversion-framework.prd.md (v1.0) + * + * Independent behavioral coverage (qa-e2e-author), written from the PRD's + * Acceptance Criteria plus the real code at: + * - includes/Newsletter/BlockConverter.php -- block routing, Group to section. + * - includes/Newsletter/SupportedBlocks.php -- supported block registry. + * - includes/Newsletter/PostSettingsBuilder.php -- Group gate (first sync marker). + * - includes/Newsletter/Sender.php -- sets the first-sync marker on create. + * - includes/Editor/Meta.php -- post meta keys. + * + * Every save goes through the REST endpoint the block editor uses + * (POST /wp/v2/posts/{id}, cookie + nonce), which fires the newsletter sync. + * 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, so specs assert on the exact `blocks` payload + * the plugin sent to beehiiv. Nothing reaches the live API. + */ + +const META = { + send: '_beehiiv_send_to_newsletter', + snippet: '_beehiiv_send_to_newsletter_snippet', + postId: '_beehiiv_post_id', + groupSections: '_beehiiv_group_sections', +}; + +const PUBLICATION_ID = 'pub_qa_e2e_blocks'; +const TEMPLATE_ID = 'tpl_qa_e2e_blocks'; +const MOCK_CREATED_ID = 'post_qa_e2e_blocks_created'; +const LEGACY_ID = 'post_qa_e2e_blocks_legacy'; +const ACTIVE_AD = 'ad_qa_active'; +const EXPIRED_AD = 'ad_qa_expired'; +const PNG_B64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +const createdPosts = []; +const createdAttachments = []; + +const b64 = ( value ) => + Buffer.from( JSON.stringify( value ) ).toString( 'base64' ); + +/** + * Runs PHP inside the tests env; the payload travels base64-encoded so no quoting issues. + * + * @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 }'` + ); +} + +/** + * Resets beehiiv seams and seeds a "ready to send" site with an active ad list. + */ +function seed() { + const mocks = [ + [ + '/advertisement_opportunities', + { + body: { + data: [ + { + id: ACTIVE_AD, + advertiser_name: 'QA Advertiser', + payout_rate: '1.00', + advertisement_kind: 'cpc', + }, + ], + }, + }, + ], + [ '/posts', { body: { data: { id: MOCK_CREATED_ID } } } ], + ]; + wpEvalWith( + { 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" => "${ TEMPLATE_ID }" ] ); + beehiiv_e2e_seed_publications( [ [ "id" => "${ PUBLICATION_ID }", "name" => "QA E2E Publication" ] ] ); + beehiiv_e2e_seed_post_templates( "${ PUBLICATION_ID }", [ [ "id" => "${ TEMPLATE_ID }", "name" => "QA E2E Template" ] ] ); + foreach ( $a["mocks"] as $m ) { beehiiv_e2e_mock_http( $m[0], $m[1] ); } + beehiiv_e2e_clear_http_log();` + ); +} + +/** + * Creates a draft post fixture in the DB and empties the request log. + * + * @param {Object} options Post fields. + * @param {string} options.title Post title. + * @param {string} options.content Post content (block markup). + * @param {Object} [options.meta] Post meta to set. + * @param {number} [options.thumb] Featured image attachment ID. + * @param {string} [options.status] Post status. + * @return {string} Post ID. + */ +function createPost( { + title, + content, + meta = {}, + thumb = 0, + status = 'draft', +} ) { + const post = { + post_type: 'post', + post_title: title, + post_content: content, + post_status: status, + post_author: 1, + }; + const id = wpEvalWith( + { post, meta, thumb }, + `$id = wp_insert_post( $a["post"] ); + foreach ( $a["meta"] as $k => $v ) { update_post_meta( $id, $k, $v ); } + if ( $a["thumb"] ) { set_post_thumbnail( $id, $a["thumb"] ); } + beehiiv_e2e_clear_http_log(); + echo $id . "\\n";` + ); + createdPosts.push( id ); + return id; +} + +/** + * Creates an image attachment in the tests env's media library. + * + * @return {string} Attachment ID. + */ +function createAttachment() { + const id = wpEvalWith( + { png: PNG_B64 }, + `$up = wp_upload_bits( "qa-e2e-featured.png", null, base64_decode( $a["png"] ) ); + $att = wp_insert_attachment( [ "post_mime_type" => "image/png", "post_title" => "QA featured", "post_status" => "inherit" ], $up["file"] ); + require_once ABSPATH . "wp-admin/includes/image.php"; + wp_update_attachment_metadata( $att, wp_generate_attachment_metadata( $att, $up["file"] ) ); + echo $att . "\\n";` + ); + createdAttachments.push( id ); + return id; +} + +/** + * All meta for a post, single values. + * + * @param {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 ); +} + +/** + * Returns the recorded outbound beehiiv requests. + * + * @return {Array} Request log entries. + */ +function getLog() { + return JSON.parse( + wpCli( `eval 'beehiiv_e2e_print_http_log();'` ) || '[]' + ); +} + +/** + * Clears the recorded request log. + */ +function clearLog() { + wpCli( `eval 'beehiiv_e2e_clear_http_log();'` ); +} + +const creates = ( log ) => + log.filter( + ( r ) => + r.method === 'POST' && /\/publications\/[^/]+\/posts$/.test( r.url ) + ); +const updates = ( log ) => log.filter( ( r ) => r.method === 'PATCH' ); + +/** + * Logs in as admin and returns a REST caller bound to that session (cookie + nonce). + * + * @param {import('@playwright/test').Page} page Playwright page. + * @return {Promise} `( method, path, data ) => { status, body }`. + */ +async function restAs( page ) { + 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 }; + }; +} + +/** + * Publishes a new post with "Send to Newsletter" on and returns the `blocks` + * beehiiv received in the create request. + * + * @param {import('@playwright/test').Page} page Playwright page. + * @param {string} content Post content (block markup). + * @param {Object} [options] Extra fixture options. + * @return {Promise<{id: string, blocks: Array, rest: Function}>} Result. + */ +async function sendNew( page, content, options = {} ) { + seed(); + const id = createPost( { + title: 'QA E2E block conversion', + content, + meta: { [ META.send ]: '1', ...( options.meta || {} ) }, + thumb: options.thumb || 0, + } ); + const rest = await restAs( page ); + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + status: 'publish', + } ); + expect( res.status ).toBe( 200 ); + const created = creates( getLog() ); + expect( created ).toHaveLength( 1 ); + return { id, blocks: created[ 0 ].body.blocks, rest }; +} + +const p = ( text ) => + `

${ text }

`; +const h = ( text ) => + `

${ text }

`; +const group = ( inner ) => + `
${ inner }
`; +const types = ( blocks ) => blocks.map( ( b ) => b.type ); +const text = ( blocks ) => JSON.stringify( blocks ); + +test.describe.configure( { timeout: 150 * 1000 } ); + +test.beforeAll( async () => { + ensurePluginActive(); + ensurePrettyPermalinks(); +} ); + +test.afterAll( async () => { + wpCliSafe( + `eval 'foreach ( array( ${ createdPosts.join( + ', ' + ) } ) as $id ) { delete_post_meta( $id, "${ + META.postId + }" ); wp_delete_post( $id, true ); } + foreach ( array( ${ createdAttachments.join( + ', ' + ) } ) as $id ) { wp_delete_attachment( $id, true ); } + delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();'` + ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-001: convert post blocks', () => { + test( 'AC-001: heading, paragraph and image blocks convert without content loss', async ( { + page, + } ) => { + const image = + '
QA alt
'; + const { blocks } = await sendNew( + page, + h( 'QA heading text' ) + p( 'QA paragraph text.' ) + image + ); + expect( types( blocks ) ).toEqual( [ + 'heading', + 'paragraph', + 'image', + ] ); + expect( blocks[ 0 ].text ).toBe( 'QA heading text' ); + expect( blocks[ 1 ].plaintext ).toBe( 'QA paragraph text.' ); + expect( blocks[ 2 ].imageUrl ).toBe( + 'https://example.com/qa-photo.jpg' + ); + expect( blocks[ 2 ].alt_text ).toBe( 'QA alt' ); + } ); + + test( 'AC-002: separator blocks convert to a content break', async ( { + page, + } ) => { + const { blocks } = await sendNew( + page, + p( 'Before' ) + + '
' + + p( 'After' ) + ); + expect( types( blocks ) ).toEqual( [ + 'paragraph', + 'content_break', + 'paragraph', + ] ); + } ); + + test( 'AC-003: embed blocks convert to a link to the embedded content', async ( { + page, + } ) => { + const { blocks } = await sendNew( + page, + '
https://www.youtube.com/watch?v=qa123
' + + p( 'Anchor paragraph' ) + ); + const embed = blocks.find( ( b ) => b.type === 'embed_link' ); + expect( embed ).toBeTruthy(); + expect( embed.url ).toBe( 'https://www.youtube.com/watch?v=qa123' ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-002: skip unsupported blocks', () => { + test( 'AC-004: blocks without beehiiv equivalents are silently omitted', async ( { + page, + } ) => { + const { id, blocks } = await sendNew( + page, + p( 'Kept paragraph' ) + + '
QA_UNSUPPORTED_CODE
' + + '
QA_UNSUPPORTED_VERSE
' + + '' + ); + expect( types( blocks ) ).toEqual( [ 'paragraph' ] ); + expect( text( blocks ) ).not.toContain( 'QA_UNSUPPORTED' ); + // The send still succeeds without an error recorded. + expect( getMeta( id )[ META.postId ] ).toBe( MOCK_CREATED_ID ); + expect( getMeta( id )._beehiiv_newsletter_error ).toBeUndefined(); + } ); + + test( 'AC-005: empty and whitespace-only blocks are omitted', async ( { + page, + } ) => { + const { blocks } = await sendNew( + page, + p( 'Real content' ) + + '

' + + '

' + + '

' + ); + expect( types( blocks ) ).toEqual( [ 'paragraph' ] ); + expect( blocks[ 0 ].plaintext ).toBe( 'Real content' ); + } ); + + // Known gap (QA finding): a block holding only a non-breaking space + // ( , which the editor produces for "blank" lines) is converted to a + // paragraph/heading with a nbsp instead of being omitted. test.fail() + // records the gap without breaking CI and starts failing once it is fixed. + test( 'AC-005: blocks holding only a non-breaking space are omitted', async ( { + page, + } ) => { + test.fail( + true, + 'Known gap:  -only paragraph and heading blocks are still sent.' + ); + const { blocks } = await sendNew( + page, + p( 'Real content' ) + + '

 

' + + '

 

' + ); + expect( types( blocks ) ).toEqual( [ 'paragraph' ] ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-003: snippet mode with Read More button', () => { + test( 'AC-006: snippet mode stops at core/more and adds a Read More button to the permalink in a new tab', async ( { + page, + } ) => { + const content = + p( 'Teaser paragraph' ) + + '' + + p( 'HIDDEN_AFTER_MORE' ); + const { id, blocks } = await sendNew( page, content, { + meta: { [ META.snippet ]: '1' }, + } ); + expect( types( blocks ) ).toEqual( [ 'paragraph', 'button' ] ); + expect( text( blocks ) ).not.toContain( 'HIDDEN_AFTER_MORE' ); + const button = blocks[ 1 ]; + expect( button.text ).toBe( 'Read More' ); + expect( button.target ).toBe( '_blank' ); + const permalink = wpCli( `eval 'echo get_permalink( ${ id } );'` ); + expect( button.href ).toBe( permalink ); + expect( button.href ).toContain( 'qa-e2e-block-conversion' ); + + // Edge case: snippet mode with no core/more converts every block. + const noMore = await sendNew( page, p( 'One' ) + p( 'Two' ), { + meta: { [ META.snippet ]: '1' }, + } ); + expect( types( noMore.blocks ) ).toEqual( [ + 'paragraph', + 'paragraph', + ] ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-004: featured image', () => { + test( 'AC-007: the featured image leads the newsletter and is configured for email only', async ( { + page, + } ) => { + const thumb = createAttachment(); + const { blocks } = await sendNew( page, p( 'Body text' ), { thumb } ); + expect( types( blocks ) ).toEqual( [ 'image', 'paragraph' ] ); + expect( blocks[ 0 ].imageUrl ).toContain( 'qa-e2e-featured' ); + expect( blocks[ 0 ].visibility_settings ).toEqual( { + show_on_email: true, + show_on_web: false, + } ); + + // Edge case: no featured image is omitted without error. + const none = await sendNew( page, p( 'No image' ) ); + expect( types( none.blocks ) ).toEqual( [ 'paragraph' ] ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-005: advertisement validation', () => { + test( 'AC-008: advertisement blocks are included only for a valid, active ad', async ( { + page, + } ) => { + const ad = ( attrs ) => + ``; + + // Active ad: included with its opportunity id. + const active = await sendNew( + page, + p( 'Intro' ) + ad( { adId: ACTIVE_AD } ) + ); + const ads = active.blocks.filter( ( b ) => b.type === 'advertisement' ); + expect( ads ).toHaveLength( 1 ); + expect( ads[ 0 ].opportunity_id ).toBe( ACTIVE_AD ); + + // Expired/unknown ad: never reaches beehiiv. (The sender rejects the + // whole send with an error instead of silently dropping the block, so + // no create request is made at all.) + seed(); + const id = createPost( { + title: 'QA E2E block conversion expired ad', + content: p( 'Intro' ) + ad( { adId: EXPIRED_AD } ), + meta: { [ META.send ]: '1' }, + } ); + const rest = await restAs( page ); + await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } ); + const log = getLog(); + expect( JSON.stringify( log ) ).not.toContain( + `"opportunity_id":"${ EXPIRED_AD }"` + ); + expect( creates( log ) ).toHaveLength( 0 ); + expect( getMeta( id )._beehiiv_newsletter_error ).toContain( + 'no longer available' + ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-006: Group blocks become sections', () => { + test( 'AC-009: a Group becomes one section with its inner blocks in original order', async ( { + page, + } ) => { + const { blocks } = await sendNew( + page, + p( 'Outside before' ) + + group( + h( 'Inside heading' ) + + p( 'Inside one' ) + + p( 'Inside two' ) + ) + + p( 'Outside after' ) + ); + expect( types( blocks ) ).toEqual( [ + 'paragraph', + 'section', + 'paragraph', + ] ); + const section = blocks[ 1 ]; + expect( types( section.blocks ) ).toEqual( [ + 'heading', + 'paragraph', + 'paragraph', + ] ); + expect( section.blocks[ 0 ].text ).toBe( 'Inside heading' ); + expect( section.blocks[ 1 ].plaintext ).toBe( 'Inside one' ); + expect( section.blocks[ 2 ].plaintext ).toBe( 'Inside two' ); + } ); + + test( 'AC-010: a Group nested in a Group becomes a nested section', async ( { + page, + } ) => { + const { blocks } = await sendNew( + page, + group( p( 'Outer text' ) + group( p( 'Nested text' ) ) ) + ); + expect( types( blocks ) ).toEqual( [ 'section' ] ); + expect( types( blocks[ 0 ].blocks ) ).toEqual( [ + 'paragraph', + 'section', + ] ); + const nested = blocks[ 0 ].blocks[ 1 ]; + expect( types( nested.blocks ) ).toEqual( [ 'paragraph' ] ); + expect( nested.blocks[ 0 ].plaintext ).toBe( 'Nested text' ); + } ); + + test( 'AC-011: sections use beehiiv defaults; Group background and padding are not carried over', async ( { + page, + } ) => { + const styled = + '
' + + p( 'Styled group text' ) + + '
'; + const { blocks } = await sendNew( page, styled ); + expect( blocks ).toHaveLength( 1 ); + expect( Object.keys( blocks[ 0 ] ).sort() ).toEqual( [ + 'blocks', + 'type', + ] ); + expect( blocks[ 0 ].type ).toBe( 'section' ); + expect( text( blocks ) ).not.toContain( '#ff0000' ); + expect( text( blocks ) ).not.toContain( '40px' ); + } ); + + test( 'AC-012: an empty or unconvertible Group produces no section; a mixed Group keeps only convertible blocks', async ( { + page, + } ) => { + const empty = await sendNew( + page, + p( 'Kept outside' ) + + group( '' ) + + group( + '
QA_CODE_ONLY
' + ) + ); + expect( types( empty.blocks ) ).toEqual( [ 'paragraph' ] ); + + const mixed = await sendNew( + page, + group( + p( 'Mixed one' ) + + '
QA_CODE_DROPPED
' + + p( 'Mixed two' ) + ) + ); + expect( types( mixed.blocks ) ).toEqual( [ 'section' ] ); + expect( types( mixed.blocks[ 0 ].blocks ) ).toEqual( [ + 'paragraph', + 'paragraph', + ] ); + expect( text( mixed.blocks ) ).not.toContain( 'QA_CODE_DROPPED' ); + } ); + + // The "shows as one grouped section in the beehiiv editor" half of this AC + // depends on beehiiv's own editor and cannot be driven here; the payload + // half (one section, three children) is what the plugin controls. + test( 'AC-013: a Group with a heading and two paragraphs produces one section with those three children', async ( { + page, + } ) => { + const { blocks } = await sendNew( + page, + group( + h( 'Group heading' ) + p( 'Group para A' ) + p( 'Group para B' ) + ) + ); + const sections = blocks.filter( ( b ) => b.type === 'section' ); + expect( blocks ).toHaveLength( 1 ); + expect( sections ).toHaveLength( 1 ); + expect( types( sections[ 0 ].blocks ) ).toEqual( [ + 'heading', + 'paragraph', + 'paragraph', + ] ); + } ); + + test( 'AC-014: Group conversion applies to posts first synced after the change, including re-syncs; older posts keep omitting Group content', async ( { + page, + } ) => { + const content = + p( 'Top level text' ) + group( p( 'GROUPED_CONTENT_TEXT' ) ); + + // Post first synced now: sections on create, marker stored, kept on re-sync. + const fresh = await sendNew( page, content ); + expect( types( fresh.blocks ) ).toEqual( [ 'paragraph', 'section' ] ); + expect( getMeta( fresh.id )[ META.groupSections ] ).toBeTruthy(); + clearLog(); + await fresh.rest( 'POST', `/wp/v2/posts/${ fresh.id }`, { + content: content + p( 'Edited later' ), + } ); + const resynced = updates( getLog() ); + expect( resynced ).toHaveLength( 1 ); + expect( types( resynced[ 0 ].body.blocks ) ).toEqual( [ + 'paragraph', + 'section', + 'paragraph', + ] ); + expect( text( resynced[ 0 ].body.blocks ) ).toContain( + 'GROUPED_CONTENT_TEXT' + ); + + // Post first synced before the change (linked, no marker): Group stays omitted. + seed(); + const legacy = createPost( { + title: 'QA E2E block conversion legacy', + content, + status: 'publish', + meta: { [ META.postId ]: LEGACY_ID }, + } ); + const rest = await restAs( page ); + await rest( 'POST', `/wp/v2/posts/${ legacy }`, { + content: content + p( 'Edited legacy' ), + } ); + const legacyUpdates = updates( getLog() ); + expect( legacyUpdates ).toHaveLength( 1 ); + expect( types( legacyUpdates[ 0 ].body.blocks ) ).toEqual( [ + 'paragraph', + 'paragraph', + ] ); + expect( text( legacyUpdates[ 0 ].body.blocks ) ).not.toContain( + 'GROUPED_CONTENT_TEXT' + ); + expect( getMeta( legacy )[ META.groupSections ] ).toBeUndefined(); + } ); +} ); From 8ccbab905688a5a0d61f78d72cc6a3885d22a60d Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 18:50:03 +0800 Subject: [PATCH 3/8] fix(newsletter-publishing): omit Groups nested in a Group from the beehiiv payload beehiiv rejects a section inside a section, so the post failed to sync. A Group inside a Group is now omitted with its inner blocks, and the editor counts it in the omitted-block notice and marks it with the hidden icon. --- includes/Newsletter/BlockConverter.php | 14 +++++++----- .../post-settings/hooks/use-omitted-blocks.js | 21 +++++++++++++++++- .../specs/block-conversion-framework.spec.js | 14 +++++++----- tests/phpunit/BlockConverterGroupTest.php | 22 ++++++++++++++----- 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 86d68d2..6be6a24 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -39,7 +39,8 @@ * - `beehiiv/advertisement` — beehiiv `advertisement` block via `convert_advertisement_block()` * * - `core/group` — `convert_group_block()` (beehiiv `section` block holding the converted inner - * blocks; only for posts whose first sync used sections, see `PostSettingsBuilder`) + * blocks; only for posts whose first sync used sections, see `PostSettingsBuilder`; a Group + * inside a Group is omitted because beehiiv sections cannot hold sections) * * Other layout blocks (`core/columns`, etc.) are skipped at the top level, and so is * `core/group` for posts first synced before section support. @@ -160,10 +161,11 @@ private static function convert_blocks( * Convert a core/group block to a beehiiv section block. * * The Group's inner blocks are converted with the same rules as top-level blocks and - * placed in the section in their original order. A nested Group becomes a nested - * section. Section settings are left at beehiiv defaults. Returns an empty array when - * no inner block converts, so no empty section is sent. Snippet-only blocks are - * omitted inside a section. + * placed in the section in their original order. A Group nested inside a Group is + * omitted with its inner blocks, because a beehiiv section cannot hold another section + * and sending one makes beehiiv reject the whole post. Section settings are left at + * beehiiv defaults. Returns an empty array when no inner block converts, so no empty + * section is sent. Snippet-only blocks are omitted inside a section. * * @param array $wp_block Parsed core/group block. * @param WP_Post $post_object Post object. @@ -175,7 +177,7 @@ private static function convert_group_block( array $wp_block, WP_Post $post_obje $wp_block['innerBlocks'] ?? [], $post_object, false, - true + false ); if ( empty( $inner_blocks ) ) { diff --git a/src/js/editor/post-settings/hooks/use-omitted-blocks.js b/src/js/editor/post-settings/hooks/use-omitted-blocks.js index 5edd154..4251333 100644 --- a/src/js/editor/post-settings/hooks/use-omitted-blocks.js +++ b/src/js/editor/post-settings/hooks/use-omitted-blocks.js @@ -33,6 +33,22 @@ function resolveOmittedBlockTarget( ancestors, blockClientId ) { return blockClientId; } +/** + * Whether a Group block sits inside another Group block. + * + * beehiiv sections cannot hold sections, so the inner Group is omitted. + * + * @param {WPBlock} block Block being visited. + * @param {BlockAncestor[]} ancestors Root-to-parent chain for the block. + * @return {boolean} True when the block is a Group nested in a Group. + */ +function isGroupInsideGroup( block, ancestors ) { + return ( + 'core/group' === block.name && + ancestors.some( ( ancestor ) => 'core/group' === ancestor.name ) + ); +} + /** * Collect client IDs for blocks omitted from the newsletter. * @@ -49,7 +65,10 @@ export function collectOmittedBlockClientIds( blocks ) { */ function visit( blockList, ancestors = [] ) { for ( const block of blockList ) { - if ( isOmittedFromNewsletter( block ) ) { + if ( + isOmittedFromNewsletter( block ) || + isGroupInsideGroup( block, ancestors ) + ) { targets.add( resolveOmittedBlockTarget( ancestors, block.clientId ) ); diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js index 948136e..f0cf344 100644 --- a/tests/e2e/specs/block-conversion-framework.spec.js +++ b/tests/e2e/specs/block-conversion-framework.spec.js @@ -506,21 +506,23 @@ test.describe( 'US-006: Group blocks become sections', () => { expect( section.blocks[ 2 ].plaintext ).toBe( 'Inside two' ); } ); - test( 'AC-010: a Group nested in a Group becomes a nested section', async ( { + test( 'AC-010: a Group nested in a Group is omitted, so no section holds a section', async ( { page, } ) => { const { blocks } = await sendNew( page, - group( p( 'Outer text' ) + group( p( 'Nested text' ) ) ) + group( + p( 'Outer text' ) + + group( p( 'Nested text' ) ) + + p( 'Last text' ) + ) ); expect( types( blocks ) ).toEqual( [ 'section' ] ); expect( types( blocks[ 0 ].blocks ) ).toEqual( [ 'paragraph', - 'section', + 'paragraph', ] ); - const nested = blocks[ 0 ].blocks[ 1 ]; - expect( types( nested.blocks ) ).toEqual( [ 'paragraph' ] ); - expect( nested.blocks[ 0 ].plaintext ).toBe( 'Nested text' ); + expect( JSON.stringify( blocks ) ).not.toContain( 'Nested text' ); } ); test( 'AC-011: sections use beehiiv defaults; Group background and padding are not carried over', async ( { diff --git a/tests/phpunit/BlockConverterGroupTest.php b/tests/phpunit/BlockConverterGroupTest.php index 78b3a78..e3b8b96 100644 --- a/tests/phpunit/BlockConverterGroupTest.php +++ b/tests/phpunit/BlockConverterGroupTest.php @@ -111,21 +111,33 @@ public function test_section_sits_between_sibling_blocks(): void { } /** - * AC-010: a Group inside a Group becomes a nested section. + * AC-010: a Group inside a Group is omitted, because a beehiiv section cannot hold a section. */ - public function test_nested_group_becomes_nested_section(): void { + public function test_nested_group_is_omitted_with_its_content(): void { $inner = $this->group( $this->paragraph( 'Deep' ) ); - $post = $this->create_post( $this->group( $this->paragraph( 'Top' ) . $inner ) ); + $post = $this->create_post( $this->group( $this->paragraph( 'Top' ) . $inner . $this->paragraph( 'Last' ) ) ); $blocks = BlockConverter::convert_supported_blocks( $post, true ); $this->assertCount( 1, $blocks ); $this->assertSame( 'section', $blocks[0]['type'] ); $this->assertSame( - array( 'paragraph', 'section' ), + array( 'paragraph', 'paragraph' ), array_column( $blocks[0]['blocks'], 'type' ) ); - $this->assertCount( 1, $blocks[0]['blocks'][1]['blocks'] ); + $this->assertStringNotContainsString( 'Deep', wp_json_encode( $blocks ) ); + } + + /** + * AC-010: no section is nested at any depth, and a Group holding only a nested Group sends nothing. + */ + public function test_no_section_is_nested_at_any_depth(): void { + $deep = $this->group( $this->group( $this->group( $this->paragraph( 'Deep' ) ) ) ); + $post = $this->create_post( $this->paragraph( 'Kept' ) . $deep ); + + $blocks = BlockConverter::convert_supported_blocks( $post, true ); + + $this->assertSame( array( 'paragraph' ), array_column( $blocks, 'type' ) ); } /** From 54d4cb89d89e3cd9bc9609c006777be1bbe16070 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 18:57:58 +0800 Subject: [PATCH 4/8] test(e2e): cover the editor notice for a Group nested in a Group --- .../specs/block-conversion-framework.spec.js | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js index f0cf344..e0dc8ad 100644 --- a/tests/e2e/specs/block-conversion-framework.spec.js +++ b/tests/e2e/specs/block-conversion-framework.spec.js @@ -1,5 +1,6 @@ const { test, expect } = require( '@playwright/test' ); const { loginAsAdmin } = require( '../utils/auth' ); +const { openPostEditor, openBeehiivSidebar } = require( '../utils/editor' ); const { wpCli, wpCliSafe, @@ -525,6 +526,49 @@ test.describe( 'US-006: Group blocks become sections', () => { expect( JSON.stringify( blocks ) ).not.toContain( 'Nested text' ); } ); + test( 'AC-010: the editor notice counts a nested Group as omitted and marks it', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E nested group notice', + content: group( p( 'Outer text' ) + group( p( 'Nested text' ) ) ), + meta: { [ META.send ]: '1' }, + } ); + await loginAsAdmin( page ); + await openPostEditor( page, id ); + await openBeehiivSidebar( page ); + + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toContainText( '1 block will be omitted from this newsletter.' ); + const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 1 + ); + } ); + + test( 'AC-010: the editor shows no omitted notice for a single, non-nested Group', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E single group notice', + content: group( p( 'Only text' ) ), + meta: { [ META.send ]: '1' }, + } ); + await loginAsAdmin( page ); + await openPostEditor( page, id ); + await openBeehiivSidebar( page ); + + await expect( + page.locator( '.beehiiv-post-settings-notice__text' ) + ).toBeVisible(); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCount( 0 ); + } ); + test( 'AC-011: sections use beehiiv defaults; Group background and padding are not carried over', async ( { page, } ) => { From 2f27426f4cbbe710ff086d714381178f06cae268 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 19:00:56 +0800 Subject: [PATCH 5/8] fix(editor): show the omitted-block notice on scheduled posts linked to beehiiv A linked post turns Send to newsletter off, which hid the notice and the hidden-block icon while its newsletter was still scheduled and syncing edits. --- .../components/omitted-blocks-notice.js | 23 ++++++++ .../filters/with-omitted-block-indicator.js | 14 +++-- src/js/editor/post-settings/index.js | 6 ++- .../specs/block-conversion-framework.spec.js | 53 +++++++++++++++++++ 4 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/js/editor/post-settings/components/omitted-blocks-notice.js b/src/js/editor/post-settings/components/omitted-blocks-notice.js index 0eaf0bd..bbf2948 100644 --- a/src/js/editor/post-settings/components/omitted-blocks-notice.js +++ b/src/js/editor/post-settings/components/omitted-blocks-notice.js @@ -3,6 +3,7 @@ */ import { _n, sprintf } from '@wordpress/i18n'; +import PostSettingsNotice from './post-settings-notice'; import { useOmittedBlockCount } from '../hooks/use-omitted-blocks'; /** @@ -35,3 +36,25 @@ export function OmittedBlocksNoticeMessage() {

); } + +/** + * Standalone warning for a linked post whose newsletter is still scheduled. + * + * A linked post has no send warning to hold the message, so it gets its own + * notice, shown only when a block will be omitted. + * + * @return {import('react').JSX.Element|null} Warning notice or null. + */ +export function OmittedBlocksNotice() { + const omittedBlockCount = useOmittedBlockCount(); + + if ( omittedBlockCount < 1 ) { + return null; + } + + return ( + + + + ); +} diff --git a/src/js/editor/post-settings/filters/with-omitted-block-indicator.js b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js index e81ef86..61f3a85 100644 --- a/src/js/editor/post-settings/filters/with-omitted-block-indicator.js +++ b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js @@ -13,11 +13,15 @@ function useShowOmittedBlockIndicators( clientId ) { const beehiivMeta = useBeehiivPostMeta(); const isOmittedBlock = useIsOmittedBlock( clientId ); - return ( - !! beehiivMeta?.sendToNewsletter && - ! beehiivMeta.newsletterAlreadySent && - isOmittedBlock - ); + // A linked newsletter that is still scheduled keeps syncing edits, so its + // omitted blocks are marked too. A sent newsletter no longer changes. + const isQueued = + !! beehiivMeta?.sendToNewsletter && ! beehiivMeta.newsletterAlreadySent; + const isScheduled = + !! beehiivMeta?.newsletterAlreadySent && + ! beehiivMeta.newsletterPublished; + + return ( isQueued || isScheduled ) && isOmittedBlock; } // Higher order component to add a visual indicator to blocks that are omitted from the newsletter. diff --git a/src/js/editor/post-settings/index.js b/src/js/editor/post-settings/index.js index 636895f..44c1337 100644 --- a/src/js/editor/post-settings/index.js +++ b/src/js/editor/post-settings/index.js @@ -26,7 +26,10 @@ import PostSettingsNotice from './components/post-settings-notice'; import SendNewsletterToggle from './components/send-newsletter-toggle'; import TestEmailSend from './components/test-email-send'; import IncompleteAdvertisementNotice from './components/incomplete-advertisement-notice'; -import { OmittedBlocksNoticeMessage } from './components/omitted-blocks-notice'; +import { + OmittedBlocksNotice, + OmittedBlocksNoticeMessage, +} from './components/omitted-blocks-notice'; import { useBeehiivEditorConfig } from './hooks/use-beehiiv-editor-config'; import { useBeehiivPostMeta } from './hooks/use-beehiiv-post-meta'; import { usePostPublication } from './hooks/use-post-publication'; @@ -214,6 +217,7 @@ function BeehiivPostSettingsPanel() { { newsletterAlreadySent && isNewsletterReady && ( <> + { ! newsletterPublished && } { ); } ); + test( 'AC-010: a scheduled, linked post shows the omitted notice for a nested Group, and a sent one does not', async ( { + page, + } ) => { + seed(); + const linked = ( scheduledAt ) => ( { + [ META.postId ]: 'post_qa_e2e_scheduled', + _beehiiv_publication_id: PUBLICATION_ID, + _beehiiv_post_template_id: TEMPLATE_ID, + _beehiiv_linked_publication_id: PUBLICATION_ID, + _beehiiv_linked_post_template_id: TEMPLATE_ID, + [ META.send ]: '', + _beehiiv_scheduled_at: scheduledAt, + } ); + const content = group( + p( 'Outer text' ) + group( p( 'Nested text' ) ) + ); + const scheduledId = createPost( { + title: 'QA E2E scheduled nested group', + content, + status: 'publish', + meta: linked( '2031-01-01T10:00:00Z' ), + } ); + const sentId = createPost( { + title: 'QA E2E sent nested group', + content, + status: 'publish', + meta: linked( '2020-01-01T10:00:00Z' ), + } ); + await loginAsAdmin( page ); + + await openPostEditor( page, scheduledId ); + await openBeehiivSidebar( page ); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toContainText( '1 block will be omitted from this newsletter.' ); + const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 1 + ); + + await openPostEditor( page, sentId ); + await openBeehiivSidebar( page ); + await expect( + page.locator( '.beehiiv-post-settings-content' ) + ).toBeVisible(); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCount( 0 ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 0 + ); + } ); + test( 'AC-010: the editor shows no omitted notice for a single, non-nested Group', async ( { page, } ) => { From 9351fd66ec50640fd74b4fdb9a5e61241996042f Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 19:02:56 +0800 Subject: [PATCH 6/8] style(editor): remove the top margin on the omitted-block notice --- src/js/editor/post-settings/css/_sidebar.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/src/js/editor/post-settings/css/_sidebar.scss b/src/js/editor/post-settings/css/_sidebar.scss index 7a46f98..ed10cb8 100644 --- a/src/js/editor/post-settings/css/_sidebar.scss +++ b/src/js/editor/post-settings/css/_sidebar.scss @@ -34,7 +34,6 @@ align-items: flex-start; display: flex; gap: 6px; - margin-top: 10px !important; } .beehiiv-post-settings-notice__omitted-icon { From 718b6dac485b24af6ca5ff6b5342c62cf7a68641 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 19:12:27 +0800 Subject: [PATCH 7/8] fix(editor): mark omitted blocks on the block element and only while the beehiiv sidebar is open The marker no longer wraps the block in an extra element. The hidden-eye icon is drawn on the block itself and stays visible without hovering. --- src/js/editor/post-settings/constants.js | 11 ++++ .../post-settings/css/_omitted-blocks.scss | 44 ++++--------- .../filters/with-omitted-block-indicator.js | 66 +++++++++++-------- .../hooks/use-is-sidebar-open.js | 18 +++++ src/js/editor/post-settings/index.js | 3 +- .../specs/block-conversion-framework.spec.js | 63 ++++++++++++++++-- 6 files changed, 137 insertions(+), 68 deletions(-) create mode 100644 src/js/editor/post-settings/constants.js create mode 100644 src/js/editor/post-settings/hooks/use-is-sidebar-open.js diff --git a/src/js/editor/post-settings/constants.js b/src/js/editor/post-settings/constants.js new file mode 100644 index 0000000..77ca223 --- /dev/null +++ b/src/js/editor/post-settings/constants.js @@ -0,0 +1,11 @@ +/** + * Names of the beehiiv editor plugin and its sidebar. + */ + +export const PLUGIN_NAME = 'beehiiv-post-settings'; +export const SIDEBAR_NAME = 'beehiiv-post-settings'; + +/** + * Complementary area id WordPress reports while the beehiiv sidebar is open. + */ +export const SIDEBAR_AREA = `${ PLUGIN_NAME }/${ SIDEBAR_NAME }`; diff --git a/src/js/editor/post-settings/css/_omitted-blocks.scss b/src/js/editor/post-settings/css/_omitted-blocks.scss index 9062b92..9ed1697 100644 --- a/src/js/editor/post-settings/css/_omitted-blocks.scss +++ b/src/js/editor/post-settings/css/_omitted-blocks.scss @@ -12,41 +12,21 @@ box-sizing: border-box; min-height: 24px; position: relative; - width: 100%; - // Invisible hit area so the icon above the block stays hoverable. - &::before { + // Hidden-eye icon above the block's top right corner, drawn on the block itself. + &::after { bottom: 100%; - content: ""; - height: 24px; + color: beehiiv.$beehiiv-status-disconnected; + content: "\f530"; + font-family: dashicons, sans-serif; + font-size: 16px; + height: 20px; + line-height: 18px; + pointer-events: none; position: absolute; - right: 0; - width: 28px; + right: 2px; + width: 20px; + z-index: 30; } } - -.editor-styles-wrapper .beehiiv-omitted-block__icon { - bottom: 100%; - color: beehiiv.$beehiiv-status-disconnected; - cursor: default; - display: block; - font-family: dashicons, sans-serif; - font-size: 16px; - height: 20px; - line-height: 18px; - opacity: 0; - pointer-events: auto; - position: absolute; - right: 2px; - transition: opacity 0.15s ease; - width: 20px; - z-index: 30; -} - -.editor-styles-wrapper .beehiiv-omitted-block__icon:hover, -.editor-styles-wrapper - .beehiiv-omitted-block:has(:hover) - .beehiiv-omitted-block__icon { - opacity: 1; -} /* stylelint-enable selector-class-pattern */ diff --git a/src/js/editor/post-settings/filters/with-omitted-block-indicator.js b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js index 61f3a85..10b8275 100644 --- a/src/js/editor/post-settings/filters/with-omitted-block-indicator.js +++ b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js @@ -6,12 +6,14 @@ import { createHigherOrderComponent } from '@wordpress/compose'; import { addFilter } from '@wordpress/hooks'; import { useBeehiivPostMeta } from '../hooks/use-beehiiv-post-meta'; +import { useIsBeehiivSidebarOpen } from '../hooks/use-is-sidebar-open'; import { useIsOmittedBlock } from '../hooks/use-omitted-blocks'; // Custom hook to determine if a block should show an omitted block indicator. function useShowOmittedBlockIndicators( clientId ) { const beehiivMeta = useBeehiivPostMeta(); const isOmittedBlock = useIsOmittedBlock( clientId ); + const isSidebarOpen = useIsBeehiivSidebarOpen(); // A linked newsletter that is still scheduled keeps syncing edits, so its // omitted blocks are marked too. A sent newsletter no longer changes. @@ -21,40 +23,46 @@ function useShowOmittedBlockIndicators( clientId ) { !! beehiivMeta?.newsletterAlreadySent && ! beehiivMeta.newsletterPublished; - return ( isQueued || isScheduled ) && isOmittedBlock; + // The marks only show while the beehiiv sidebar is open. + return isSidebarOpen && ( isQueued || isScheduled ) && isOmittedBlock; } -// Higher order component to add a visual indicator to blocks that are omitted from the newsletter. -const withOmittedBlockIndicator = createHigherOrderComponent( ( BlockEdit ) => { - return function BeehiivOmittedBlockEdit( props ) { - const showIndicator = useShowOmittedBlockIndicators( props.clientId ); - - if ( ! showIndicator ) { - return ; - } - - return ( -
- - - { + return function BeehiivOmittedBlockListBlock( props ) { + const showIndicator = useShowOmittedBlockIndicators( + props.clientId + ); + + if ( ! showIndicator ) { + return ; + } + + return ( + -
- ); - }; -}, 'withOmittedBlockIndicator' ); + ); + }; + }, + 'withOmittedBlockIndicator' +); addFilter( - 'editor.BlockEdit', + 'editor.BlockListBlock', 'beehiiv/omitted-block-indicator', withOmittedBlockIndicator ); diff --git a/src/js/editor/post-settings/hooks/use-is-sidebar-open.js b/src/js/editor/post-settings/hooks/use-is-sidebar-open.js new file mode 100644 index 0000000..54ccf13 --- /dev/null +++ b/src/js/editor/post-settings/hooks/use-is-sidebar-open.js @@ -0,0 +1,18 @@ +/** + * Whether the beehiiv sidebar panel is the open editor sidebar. + */ +import { useSelect } from '@wordpress/data'; + +import { SIDEBAR_AREA } from '../constants'; + +/** + * @return {boolean} True while the beehiiv sidebar is open. + */ +export function useIsBeehiivSidebarOpen() { + return useSelect( + ( select ) => + SIDEBAR_AREA === + select( 'core/interface' )?.getActiveComplementaryArea( 'core' ), + [] + ); +} diff --git a/src/js/editor/post-settings/index.js b/src/js/editor/post-settings/index.js index 44c1337..7d1659d 100644 --- a/src/js/editor/post-settings/index.js +++ b/src/js/editor/post-settings/index.js @@ -36,11 +36,10 @@ import { usePostPublication } from './hooks/use-post-publication'; import './filters/with-omitted-block-indicator'; import './editor.scss'; +import { PLUGIN_NAME, SIDEBAR_NAME } from './constants'; const sidebarIcon = ; -const PLUGIN_NAME = 'beehiiv-post-settings'; -const SIDEBAR_NAME = 'beehiiv-post-settings'; const PRE_PUBLISH_PANEL_NAME = 'beehiiv-send-newsletter'; function usePostPublishDate() { diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js index fb82429..e849e57 100644 --- a/tests/e2e/specs/block-conversion-framework.spec.js +++ b/tests/e2e/specs/block-conversion-framework.spec.js @@ -1,6 +1,6 @@ const { test, expect } = require( '@playwright/test' ); const { loginAsAdmin } = require( '../utils/auth' ); -const { openPostEditor, openBeehiivSidebar } = require( '../utils/editor' ); +const { openPostEditor } = require( '../utils/editor' ); const { wpCli, wpCliSafe, @@ -247,6 +247,31 @@ async function sendNew( page, content, options = {} ) { return { id, blocks: created[ 0 ].body.blocks, rest }; } +/** + * Opens or closes the beehiiv sidebar, whatever state the editor remembered. + * + * @param {import('@playwright/test').Page} page Editor page. + * @param {boolean} open Whether the sidebar should be open. + */ +async function setBeehiivSidebar( page, open ) { + const content = page.locator( '.beehiiv-post-settings-content' ); + const remembered = await content + .waitFor( { state: 'visible', timeout: 3000 } ) + .then( () => true ) + .catch( () => false ); + if ( remembered !== open ) { + await page + .getByRole( 'button', { name: 'beehiiv', exact: true } ) + .first() + .click(); + } + if ( open ) { + await expect( content ).toBeVisible(); + } else { + await expect( content ).toBeHidden(); + } +} + const p = ( text ) => `

${ text }

`; const h = ( text ) => @@ -537,7 +562,7 @@ test.describe( 'US-006: Group blocks become sections', () => { } ); await loginAsAdmin( page ); await openPostEditor( page, id ); - await openBeehiivSidebar( page ); + await setBeehiivSidebar( page, true ); await expect( page.locator( '.beehiiv-post-settings-notice__omitted' ) @@ -546,6 +571,34 @@ test.describe( 'US-006: Group blocks become sections', () => { await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( 1 ); + // The class sits on the Group block itself; no wrapper element is added. + await expect( + canvas.locator( + '.wp-block-group .wp-block-group.beehiiv-omitted-block' + ) + ).toHaveCount( 1 ); + await expect( + canvas.locator( '.beehiiv-omitted-block:not(.wp-block)' ) + ).toHaveCount( 0 ); + // The hidden-eye icon is always drawn, without hovering the block. + const icon = await canvas + .locator( '.beehiiv-omitted-block' ) + .evaluate( ( el ) => { + const style = window.getComputedStyle( el, '::after' ); + return { content: style.content, opacity: style.opacity }; + } ); + expect( icon.content ).not.toBe( 'none' ); + expect( icon.opacity ).toBe( '1' ); + + // Closing the beehiiv sidebar removes the marks from the canvas. + await setBeehiivSidebar( page, false ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 0 + ); + await setBeehiivSidebar( page, true ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 1 + ); } ); test( 'AC-010: a scheduled, linked post shows the omitted notice for a nested Group, and a sent one does not', async ( { @@ -579,7 +632,7 @@ test.describe( 'US-006: Group blocks become sections', () => { await loginAsAdmin( page ); await openPostEditor( page, scheduledId ); - await openBeehiivSidebar( page ); + await setBeehiivSidebar( page, true ); await expect( page.locator( '.beehiiv-post-settings-notice__omitted' ) ).toContainText( '1 block will be omitted from this newsletter.' ); @@ -589,7 +642,7 @@ test.describe( 'US-006: Group blocks become sections', () => { ); await openPostEditor( page, sentId ); - await openBeehiivSidebar( page ); + await setBeehiivSidebar( page, true ); await expect( page.locator( '.beehiiv-post-settings-content' ) ).toBeVisible(); @@ -612,7 +665,7 @@ test.describe( 'US-006: Group blocks become sections', () => { } ); await loginAsAdmin( page ); await openPostEditor( page, id ); - await openBeehiivSidebar( page ); + await setBeehiivSidebar( page, true ); await expect( page.locator( '.beehiiv-post-settings-notice__text' ) From f69d8b8a0c71847e7452a5ffc30bbf2dc847dc9a Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 20:17:02 +0800 Subject: [PATCH 8/8] style(editor): add a gap above the omitted-blocks line only when it follows the send warning text --- src/js/editor/post-settings/css/_sidebar.scss | 5 +++++ tests/e2e/specs/block-conversion-framework.spec.js | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/src/js/editor/post-settings/css/_sidebar.scss b/src/js/editor/post-settings/css/_sidebar.scss index ed10cb8..0e707c2 100644 --- a/src/js/editor/post-settings/css/_sidebar.scss +++ b/src/js/editor/post-settings/css/_sidebar.scss @@ -36,6 +36,11 @@ gap: 6px; } + // Gap only when the omitted-blocks line follows the send warning text. + .beehiiv-post-settings-notice__text + .beehiiv-post-settings-notice__omitted { + margin-top: 10px; + } + .beehiiv-post-settings-notice__omitted-icon { flex-shrink: 0; font-size: 18px; diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js index e849e57..e215f96 100644 --- a/tests/e2e/specs/block-conversion-framework.spec.js +++ b/tests/e2e/specs/block-conversion-framework.spec.js @@ -567,6 +567,10 @@ test.describe( 'US-006: Group blocks become sections', () => { await expect( page.locator( '.beehiiv-post-settings-notice__omitted' ) ).toContainText( '1 block will be omitted from this newsletter.' ); + // It follows the send warning text, so there is a gap above it. + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCSS( 'margin-top', '10px' ); const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( 1 @@ -636,6 +640,10 @@ test.describe( 'US-006: Group blocks become sections', () => { await expect( page.locator( '.beehiiv-post-settings-notice__omitted' ) ).toContainText( '1 block will be omitted from this newsletter.' ); + // Alone in its notice, so no gap above it. + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCSS( 'margin-top', '0px' ); const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( 1