From a11f0c0098e4ea1f9f94fee0db77dbcda78b12a2 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 26 Oct 2026 23:29:26 +0800 Subject: [PATCH 1/8] feat(newsletter-publishing): convert top-level Columns blocks to beehiiv columns Feature: newsletter-publishing/block-conversion/structured-content-blocks Implements: - US-006: A Columns block in a post becomes one beehiiv columns block Verified: - AC-008, AC-011 to AC-019 covered by PHPUnit tests - Up to 3 columns per block; more columns, or columns nested in a column, put their inner blocks at the top level - px/em/rem widths become a share of the row; percent widths round per column --- includes/Newsletter/BlockConverter.php | 127 ++++++-- .../Converters/ColumnsBlockConverter.php | 119 +++++++ includes/Newsletter/SupportedBlocks.php | 7 +- tests/phpunit/BlockConverterColumnsTest.php | 303 ++++++++++++++++++ 4 files changed, 529 insertions(+), 27 deletions(-) create mode 100644 tests/phpunit/BlockConverterColumnsTest.php diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 32a1f94..1b28485 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -32,15 +32,17 @@ * - `core/quote` — `convert_quote_block()` * - `core/pullquote` — `convert_pullquote_block()` * - `core/embed` — `convert_embed_block()` + * - `core/columns` — `convert_columns_block_to_blocks()` (beehiiv `columns` block, up to 3 columns) * - `core/media-text` — `convert_media_text_block()` (beehiiv `columns` block) * - `core/buttons` / inner `core/button` — `convert_buttons_block()`, `convert_button_block()` * - `core/separator` — beehiiv `content_break` * - `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. - * `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`. + * Layout blocks such as `core/group` are skipped at the top level. A `core/columns` + * block with more than 3 columns, or one nested inside a column, is not converted to a + * columns block. Its inner blocks are emitted at the top level instead. Snippet mode + * stops at `core/more`. * * @since 1.0.0 */ @@ -127,6 +129,11 @@ private static function convert_blocks( continue; } + if ( 'core/columns' === $block_name ) { + $beehiiv_blocks = array_merge( $beehiiv_blocks, self::convert_columns_block_to_blocks( $wp_block ) ); + continue; + } + $beehiiv_block = self::convert_block( $wp_block ); if ( ! empty( $beehiiv_block ) ) { @@ -520,64 +527,118 @@ public static function convert_list_block( array $wp_block ): array { /** * Convert a core/columns block to a beehiiv columns block. * - * Not included in the top-level newsletter block walk yet. Intended for - * internal reuse by `convert_media_text_block()` and future direct columns support. + * Returns an empty array when the block is not converted to a columns block, + * including when it has more columns than beehiiv accepts. Use + * `convert_columns_block_to_blocks()` to also get the fallback blocks. * * @param array $wp_block Parsed block. * @return array * @since 1.0.0 */ public static function convert_columns_block( array $wp_block ): array { + foreach ( self::convert_columns_block_to_blocks( $wp_block ) as $beehiiv_block ) { + if ( 'columns' === ( $beehiiv_block['type'] ?? '' ) ) { + return $beehiiv_block; + } + } + + return []; + } + + /** + * Convert a core/columns block to beehiiv blocks. + * + * A block with up to 3 columns becomes one beehiiv columns block. A block with + * more columns is not converted to a columns block. Each column's inner blocks + * are emitted at the top level in order instead, and the row is never split. A + * core/columns block nested inside a column is handled the same way: its inner + * blocks follow the outer columns block at the top level. + * + * @param array $wp_block Parsed block. + * @return array> + * @since 1.2.0 + */ + public static function convert_columns_block_to_blocks( array $wp_block ): array { if ( 'core/columns' !== ( $wp_block['blockName'] ?? '' ) ) { return []; } $attrs = $wp_block['attrs'] ?? []; $inner_html = (string) ( $wp_block['innerHTML'] ?? '' ); - $inner_blocks = $wp_block['innerBlocks'] ?? []; - $stack_on_mobile = ColumnsBlockConverter::resolve_stack_on_mobile( $attrs, $inner_html ); $parent_vertical_align = isset( $attrs['verticalAlignment'] ) && is_string( $attrs['verticalAlignment'] ) ? $attrs['verticalAlignment'] : null; - $beehiiv_columns = []; + $column_blocks = []; + $converted_columns = []; + $nested_blocks = []; - foreach ( $inner_blocks as $column_block ) { + foreach ( $wp_block['innerBlocks'] ?? [] as $column_block ) { if ( 'core/column' !== ( $column_block['blockName'] ?? '' ) ) { continue; } - $column_inner_blocks = $column_block['innerBlocks'] ?? []; - $column_blocks = self::convert_layout_block_inner_blocks( $column_inner_blocks ); + $column_blocks[] = $column_block; + $converted_columns[] = self::convert_layout_block_inner_blocks( + $column_block['innerBlocks'] ?? [], + $nested_blocks + ); + } + + if ( count( $column_blocks ) > ColumnsBlockConverter::MAX_COLUMNS ) { + return array_merge( array_merge( ...$converted_columns ), $nested_blocks ); + } - if ( empty( $column_blocks ) ) { - continue; + $kept_blocks = []; + $kept_blocks_by_column = []; + + foreach ( $column_blocks as $index => $column_block ) { + if ( ! empty( $converted_columns[ $index ] ) ) { + $kept_blocks[] = $column_block; + $kept_blocks_by_column[] = $converted_columns[ $index ]; } + } - $beehiiv_column = ColumnsBlockConverter::build_beehiiv_column( - $column_blocks, - $column_block, - $parent_vertical_align - ); + $widths = ColumnsBlockConverter::resolve_row_widths( $kept_blocks ); + $columns = []; - if ( ! empty( $beehiiv_column ) ) { - $beehiiv_columns[] = $beehiiv_column; - } + foreach ( $kept_blocks as $index => $column_block ) { + $columns[] = ColumnsBlockConverter::build_beehiiv_column_from_definition( + $kept_blocks_by_column[ $index ], + $widths[ $index ], + ColumnsBlockConverter::resolve_column_vertical_alignment( + $column_block['attrs'] ?? [], + (string) ( $column_block['innerHTML'] ?? '' ), + $parent_vertical_align + ) + ); } - return ColumnsBlockConverter::build_beehiiv_columns_block( $beehiiv_columns, $stack_on_mobile ); + $columns_block = ColumnsBlockConverter::build_beehiiv_columns_block( + $columns, + ColumnsBlockConverter::resolve_stack_on_mobile( $attrs, $inner_html ) + ); + + return array_values( array_filter( array_merge( [ $columns_block ], $nested_blocks ) ) ); } /** * Convert nested inner blocks inside a layout block to beehiiv blocks. * * Used by layout blocks such as columns and media-text. Unsupported blocks are - * skipped; snippet-only blocks are omitted. + * skipped; snippet-only blocks are omitted. A nested core/columns block is never + * converted inside a column. When `$nested_blocks` is passed, its inner blocks are + * added to it for the caller to emit at the top level. Without it, the nested block + * is omitted. * - * @param array> $wp_blocks Parsed inner blocks. + * @param array> $wp_blocks Parsed inner blocks. + * @param array>|null $nested_blocks Receives the inner blocks of nested columns. * @return array> * @since 1.0.0 */ - private static function convert_layout_block_inner_blocks( array $wp_blocks ): array { + private static function convert_layout_block_inner_blocks( + array $wp_blocks, + ?array &$nested_blocks = null + ): array { $beehiiv_blocks = []; foreach ( $wp_blocks as $wp_block ) { @@ -587,6 +648,22 @@ private static function convert_layout_block_inner_blocks( array $wp_blocks ): a $block_name = (string) $wp_block['blockName']; + if ( 'core/columns' === $block_name ) { + if ( null !== $nested_blocks ) { + foreach ( $wp_block['innerBlocks'] ?? [] as $nested_column ) { + if ( 'core/column' === ( $nested_column['blockName'] ?? '' ) ) { + $nested_converted = self::convert_layout_block_inner_blocks( + $nested_column['innerBlocks'] ?? [], + $nested_blocks + ); + $nested_blocks = array_merge( $nested_blocks, $nested_converted ); + } + } + } + + continue; + } + if ( 'core/buttons' === $block_name ) { $beehiiv_blocks = array_merge( $beehiiv_blocks, self::convert_buttons_block( $wp_block ) ); continue; diff --git a/includes/Newsletter/Converters/ColumnsBlockConverter.php b/includes/Newsletter/Converters/ColumnsBlockConverter.php index 1c8f510..9ddf1c1 100644 --- a/includes/Newsletter/Converters/ColumnsBlockConverter.php +++ b/includes/Newsletter/Converters/ColumnsBlockConverter.php @@ -16,6 +16,13 @@ */ final class ColumnsBlockConverter { + /** + * Most columns a single beehiiv columns block accepts. + * + * @var int + */ + public const MAX_COLUMNS = 3; + /** * Build a beehiiv columns block from converted column definitions. * @@ -219,6 +226,118 @@ public static function resolve_column_width( array $attrs, string $inner_html ): return null; } + /** + * Resolve the width of every column in one row. + * + * Percent (or unitless) widths are rounded to a whole percent one column at a + * time, and a column with no width stays unset. When the widths use px, em, rem + * or another non-percent unit, each width becomes that column's share of the + * combined widths. If any column in such a row has no usable width, or the row + * mixes units, no column gets a width. + * + * @param array> $column_blocks Parsed core/column blocks in the row. + * @return array Width per column, in the same order. + * @since 1.2.0 + */ + public static function resolve_row_widths( array $column_blocks ): array { + $parsed = []; + + foreach ( $column_blocks as $column_block ) { + $parsed[] = self::parse_raw_width( + self::resolve_column_raw_width( + $column_block['attrs'] ?? [], + (string) ( $column_block['innerHTML'] ?? '' ) + ) + ); + } + + $units = []; + + foreach ( $parsed as $width ) { + if ( null !== $width ) { + $units[ $width['unit'] ] = true; + } + } + + $only_percent = array_diff_key( $units, [ '%' => true ] ); + + if ( empty( $only_percent ) ) { + return array_map( + static function ( $width ): ?int { + return null === $width ? null : self::parse_width_percentage( $width['value'] ); + }, + $parsed + ); + } + + $none = array_fill( 0, count( $parsed ), null ); + + if ( 1 !== count( $units ) || in_array( null, $parsed, true ) ) { + return $none; + } + + $total = array_sum( array_column( $parsed, 'value' ) ); + + if ( $total <= 0 ) { + return $none; + } + + return array_map( + static function ( array $width ) use ( $total ): ?int { + $percentage = (int) round( $width['value'] / $total * 100 ); + + return self::is_valid_width_percentage( $percentage ) ? $percentage : null; + }, + $parsed + ); + } + + /** + * Read a column's raw width text from attrs or saved HTML. + * + * @param array $attrs Parsed core/column attrs. + * @param string $inner_html Saved column HTML. + * @return string|null Raw width such as 33.33% or 200px, or null when unset. + * @since 1.2.0 + */ + private static function resolve_column_raw_width( array $attrs, string $inner_html ): ?string { + if ( isset( $attrs['width'] ) && ( is_string( $attrs['width'] ) || is_numeric( $attrs['width'] ) ) ) { + return trim( (string) $attrs['width'] ); + } + + $processor = new \WP_HTML_Tag_Processor( $inner_html ); + + if ( $processor->next_tag( 'DIV' ) ) { + $style = $processor->get_attribute( 'style' ); + + if ( is_string( $style ) && preg_match( '/flex-basis\s*:\s*([^;]+)/i', $style, $matches ) ) { + return trim( $matches[1] ); + } + } + + return null; + } + + /** + * Split a raw width into its number and unit. + * + * @param string|null $raw Raw width text. + * @return array{value: float, unit: string}|null Null when empty or not a number with an optional unit. + * @since 1.2.0 + */ + private static function parse_raw_width( ?string $raw ): ?array { + if ( null === $raw || ! preg_match( '/^(\d*\.?\d+)\s*([a-z%]*)$/i', trim( $raw ), $matches ) ) { + return null; + } + + $unit = strtolower( $matches[2] ); + + return [ + 'value' => (float) $matches[1], + 'unit' => '' === $unit ? '%' : $unit, + ]; + } + /** * Parse a width value into an integer percentage. * diff --git a/includes/Newsletter/SupportedBlocks.php b/includes/Newsletter/SupportedBlocks.php index ba93a96..9bd2a63 100644 --- a/includes/Newsletter/SupportedBlocks.php +++ b/includes/Newsletter/SupportedBlocks.php @@ -12,8 +12,9 @@ /** * 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. + * Layout blocks such as `core/group` are not supported. They and all of their + * inner blocks are omitted from the newsletter payload. `core/columns` is supported, + * but only up to 3 columns; see `BlockConverter::convert_columns_block_to_blocks()`. * * @since 1.0.0 */ @@ -34,6 +35,7 @@ final class SupportedBlocks { 'core/pullquote', 'core/embed', 'core/media-text', + 'core/columns', 'core/buttons', 'core/separator', 'beehiiv/advertisement', @@ -46,6 +48,7 @@ final class SupportedBlocks { */ public const NESTED_ONLY = [ 'core/button', + 'core/column', 'core/list-item', ]; diff --git a/tests/phpunit/BlockConverterColumnsTest.php b/tests/phpunit/BlockConverterColumnsTest.php new file mode 100644 index 0000000..bd125bd --- /dev/null +++ b/tests/phpunit/BlockConverterColumnsTest.php @@ -0,0 +1,303 @@ +\n
{$inner}
\n\n"; + } + + /** + * Build the saved markup for a core/columns block. + * + * @param array $columns Column markup. + * @param array $attrs Columns block attributes. + * @return string + */ + private function columns( array $columns, array $attrs = array() ): string { + $json = empty( $attrs ) ? '' : ' ' . wp_json_encode( $attrs ); + + $open = "\n
"; + $close = "
\n\n"; + + return $open . implode( '', $columns ) . $close; + } + + /** + * Saved markup for a paragraph block. + * + * @param string $text Paragraph text. + * @return string + */ + private function paragraph( string $text ): string { + return "\n

{$text}

\n"; + } + + /** + * Convert post content through the full newsletter conversion. + * + * @param string $content Post content. + * @return array> + */ + private function convert( string $content ): array { + $post = self::factory()->post->create_and_get( array( 'post_content' => $content ) ); + + return BlockConverter::convert_supported_blocks( $post ); + } + + /** + * AC-011, AC-012: one columns block, one column per column, inner blocks in order. + */ + public function test_columns_block_converts_to_one_columns_block_in_order(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'One' ) . $this->paragraph( 'Two' ) ), + $this->column( $this->paragraph( 'Three' ) ), + ) + ) + ); + + $this->assertCount( 1, $blocks ); + $this->assertSame( 'columns', $blocks[0]['type'] ); + $this->assertCount( 2, $blocks[0]['columns'] ); + $this->assertCount( 2, $blocks[0]['columns'][0]['blocks'] ); + $this->assertCount( 1, $blocks[0]['columns'][1]['blocks'] ); + $this->assertSame( 'paragraph', $blocks[0]['columns'][0]['blocks'][0]['type'] ); + } + + /** + * AC-012: blocks without a beehiiv equivalent are omitted from the column. + */ + public function test_unsupported_inner_block_is_omitted_from_column(): void { + $spacer = "\n
\n"; + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'Kept' ) . $spacer ), + ) + ) + ); + + $this->assertCount( 1, $blocks[0]['columns'][0]['blocks'] ); + } + + /** + * AC-013: percent widths become whole percents, and a missing width stays unset. + */ + public function test_percent_widths_round_per_column_and_missing_width_stays_unset(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'A' ), '33.33%' ), + $this->column( $this->paragraph( 'B' ), '33.33%' ), + $this->column( $this->paragraph( 'C' ) ), + ) + ) + ); + + $columns = $blocks[0]['columns']; + + $this->assertSame( 33, $columns[0]['width'] ); + $this->assertSame( 33, $columns[1]['width'] ); + $this->assertArrayNotHasKey( 'width', $columns[2] ); + } + + /** + * AC-014: px widths become each column's share of the row. + */ + public function test_pixel_widths_become_share_of_the_row(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'A' ), '100px' ), + $this->column( $this->paragraph( 'B' ), '300px' ), + ) + ) + ); + + $this->assertSame( 25, $blocks[0]['columns'][0]['width'] ); + $this->assertSame( 75, $blocks[0]['columns'][1]['width'] ); + } + + /** + * AC-014: with a non-percent width and one column missing a width, no column gets a width. + */ + public function test_pixel_widths_with_a_missing_width_leave_the_row_unset(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'A' ), '100px' ), + $this->column( $this->paragraph( 'B' ) ), + ) + ) + ); + + $this->assertArrayNotHasKey( 'width', $blocks[0]['columns'][0] ); + $this->assertArrayNotHasKey( 'width', $blocks[0]['columns'][1] ); + } + + /** + * AC-015: vertical alignment on a column or the Columns block is carried over. + */ + public function test_vertical_alignment_is_carried_over(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'A' ), null, 'center' ), + $this->column( $this->paragraph( 'B' ) ), + ), + array( 'verticalAlignment' => 'bottom' ) + ) + ); + + $this->assertSame( 'middle', $blocks[0]['columns'][0]['verticalAlign'] ); + $this->assertSame( 'bottom', $blocks[0]['columns'][1]['verticalAlign'] ); + } + + /** + * AC-016: columns stack on mobile by default and not when the block says so. + */ + public function test_stack_on_mobile_defaults_to_true(): void { + $stacked = $this->convert( $this->columns( array( $this->column( $this->paragraph( 'A' ) ) ) ) ); + $unstacked = $this->convert( + $this->columns( + array( $this->column( $this->paragraph( 'A' ) ) ), + array( 'isStackedOnMobile' => false ) + ) + ); + + $this->assertTrue( $stacked[0]['stackOnMobile'] ); + $this->assertFalse( $unstacked[0]['stackOnMobile'] ); + } + + /** + * AC-017: more than 3 columns fall back to top-level blocks, in order, with no columns block. + */ + public function test_more_than_three_columns_fall_back_to_top_level_blocks(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'A' ) ), + $this->column( $this->paragraph( 'B' ) ), + $this->column( $this->paragraph( 'C' ) ), + $this->column( $this->paragraph( 'D' ) ), + ) + ) + ); + + $this->assertCount( 4, $blocks ); + + foreach ( $blocks as $block ) { + $this->assertSame( 'paragraph', $block['type'] ); + } + } + + /** + * AC-017: exactly three columns still convert to a columns block. + */ + public function test_three_columns_convert_to_a_columns_block(): void { + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'A' ) ), + $this->column( $this->paragraph( 'B' ) ), + $this->column( $this->paragraph( 'C' ) ), + ) + ) + ); + + $this->assertCount( 1, $blocks ); + $this->assertCount( 3, $blocks[0]['columns'] ); + } + + /** + * AC-018: nested columns put their inner blocks at the top level after the outer block. + */ + public function test_nested_columns_fall_back_to_top_level_blocks(): void { + $nested = $this->columns( + array( + $this->column( $this->paragraph( 'Nested one' ) ), + $this->column( $this->paragraph( 'Nested two' ) ), + ) + ); + + $blocks = $this->convert( + $this->columns( + array( + $this->column( $this->paragraph( 'Outer' ) . $nested ), + ) + ) + ); + + $this->assertCount( 3, $blocks ); + $this->assertSame( 'columns', $blocks[0]['type'] ); + $this->assertCount( 1, $blocks[0]['columns'][0]['blocks'] ); + $this->assertSame( 'paragraph', $blocks[1]['type'] ); + $this->assertSame( 'paragraph', $blocks[2]['type'] ); + } + + /** + * AC-019: a three-column layout with an image, a heading plus paragraph, and a button. + */ + public function test_three_column_layout_with_image_heading_paragraph_and_button(): void { + $image = "\n
" + . "\"\"/
\n"; + $heading = "\n

Title

\n"; + $button = "\n
\n" + . '\n
\n"; + + $blocks = $this->convert( + $this->columns( + array( + $this->column( $image, '33.33%' ), + $this->column( $heading . $this->paragraph( 'Body' ), '33.33%' ), + $this->column( $button, '33.33%' ), + ) + ) + ); + + $this->assertCount( 1, $blocks ); + $this->assertCount( 3, $blocks[0]['columns'] ); + $this->assertSame( 'image', $blocks[0]['columns'][0]['blocks'][0]['type'] ); + $this->assertSame( 'heading', $blocks[0]['columns'][1]['blocks'][0]['type'] ); + $this->assertSame( 'paragraph', $blocks[0]['columns'][1]['blocks'][1]['type'] ); + $this->assertSame( 'button', $blocks[0]['columns'][2]['blocks'][0]['type'] ); + } +} From caa9ecd88367387ef3f5f9163898976432375319 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 26 Oct 2026 23:46:06 +0800 Subject: [PATCH 2/8] test(newsletter-publishing): add E2E specs for block conversion PRDs Covers structured-content-blocks (AC-001 to AC-019) and block-conversion-framework (AC-001 to AC-008). Two specs assert known gaps and currently fail: table background color (AC-010) and a non-breaking-space paragraph (AC-005). --- .../specs/block-conversion-framework.spec.js | 351 ++++++++++ .../specs/structured-content-blocks.spec.js | 645 ++++++++++++++++++ 2 files changed, 996 insertions(+) create mode 100644 tests/e2e/specs/block-conversion-framework.spec.js create mode 100644 tests/e2e/specs/structured-content-blocks.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..0288340 --- /dev/null +++ b/tests/e2e/specs/block-conversion-framework.spec.js @@ -0,0 +1,351 @@ +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 walk, snippet mode, + * featured image, advertisement validation. + * - includes/Newsletter/SupportedBlocks.php -- which blocks are converted. + * + * Each test publishes a post through the REST endpoint the block editor uses + * (POST /wp/v2/posts/{id}, cookie + nonce), with "Send to Newsletter" on. + * The test-only `pre_http_request` mock in tests/e2e/plugins/beehiiv-options.php + * answers the beehiiv calls and records them, so each test asserts on the + * exact `blocks` payload the plugin sent. Nothing reaches the live API. + */ + +const META_SEND = '_beehiiv_send_to_newsletter'; +const META_SNIPPET = '_beehiiv_send_to_newsletter_snippet'; +const PUBLICATION_ID = 'pub_qa_e2e_bcf'; +const TEMPLATE_ID = 'tpl_qa_e2e_bcf'; +const IMG = 'https://example.com/qa-image.jpg'; + +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 connected, ready-to-send site and mocks the beehiiv endpoints. + * + * @param {string[]} activeAdIds Ad opportunity ids the mock reports as active. + */ +function seed( activeAdIds = [] ) { + wpEvalWith( + { ads: activeAdIds.map( ( id ) => ( { id } ) ) }, + `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" ] ] ); + beehiiv_e2e_mock_http( "/advertisement_opportunities", [ "body" => [ "data" => $a["ads"] ] ] ); + beehiiv_e2e_mock_http( "/posts", [ "body" => [ "data" => [ "id" => "post_qa_e2e_bcf" ] ] ] ); + beehiiv_e2e_clear_http_log();` + ); +} + +/** + * Logs in as admin and returns a REST caller bound to that session. + * + * @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 post and returns the blocks sent to beehiiv. + * + * @param {import('@playwright/test').Page} page Playwright page. + * @param {string} content Serialized block markup. + * @param {Object} [options] Post options. + * @param {boolean} [options.snippet] Turn snippet mode on. + * @param {boolean} [options.thumbnail] Attach a featured image. + * @param {string[]} [options.activeAdIds] Ad ids the mock reports as active. + * @param {boolean} [options.allowNoSend] Return null instead of failing when no create request is sent. + * @return {Promise} The `blocks` array of the create-post request. + */ +async function convert( + page, + content, + { + snippet = false, + thumbnail = false, + activeAdIds = [], + allowNoSend = false, + } = {} +) { + seed( activeAdIds ); + const id = wpEvalWith( + { content, snippet, thumbnail }, + `$id = wp_insert_post( [ "post_type" => "post", "post_title" => "QA E2E block conversion", "post_content" => $a["content"], "post_status" => "draft", "post_author" => 1 ] ); + update_post_meta( $id, "${ META_SEND }", "1" ); + if ( $a["snippet"] ) { update_post_meta( $id, "${ META_SNIPPET }", "1" ); } + if ( $a["thumbnail"] ) { + require_once ABSPATH . "wp-admin/includes/image.php"; + $upload = wp_upload_bits( "qa-e2e-bcf-" . $id . ".png", null, base64_decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" ) ); + $att = wp_insert_attachment( [ "post_mime_type" => "image/png", "post_title" => "QA featured", "post_status" => "inherit" ], $upload["file"], $id ); + wp_update_attachment_metadata( $att, wp_generate_attachment_metadata( $att, $upload["file"] ) ); + set_post_thumbnail( $id, $att ); + } + beehiiv_e2e_clear_http_log(); + echo $id . "\\n";` + ); + createdPosts.push( id ); + const rest = await restAs( page ); + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + status: 'publish', + } ); + expect( res.status ).toBe( 200 ); + const log = JSON.parse( + wpCli( `eval 'beehiiv_e2e_print_http_log();'` ) || '[]' + ); + const create = log.find( + ( r ) => + r.method === 'POST' && /\/publications\/[^/]+\/posts$/.test( r.url ) + ); + if ( allowNoSend && ! create ) { + return null; + } + expect( create, 'beehiiv create-post request was sent' ).toBeTruthy(); + return create.body.blocks || []; +} + +// --------------------------------------------------------------------------- +// Block markup builders. +// --------------------------------------------------------------------------- + +const p = ( text ) => + `

${ text }

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

${ text }

`; +const img = () => + `
`; +const group = ( inner ) => + `
${ inner }
`; +const more = () => ``; +const ad = ( adId ) => + ``; + +const types = ( blocks ) => blocks.map( ( b ) => b.type ); +const textOf = ( block ) => + block.text || + ( block.formattedText || [] ).map( ( r ) => r.text ).join( '' ) || + block.plaintext || + ''; + +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, "_beehiiv_post_id" ); wp_delete_post( $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 blocks = await convert( + page, + h( 'QA Heading Text' ) + p( 'QA paragraph body text.' ) + img() + ); + expect( types( blocks ) ).toEqual( [ + 'heading', + 'paragraph', + 'image', + ] ); + expect( blocks[ 0 ].text ).toBe( 'QA Heading Text' ); + expect( textOf( blocks[ 1 ] ) ).toBe( 'QA paragraph body text.' ); + expect( blocks[ 2 ].imageUrl ).toBe( IMG ); + } ); + + test( 'AC-002: separator block converts to a content break', async ( { + page, + } ) => { + const blocks = await convert( + page, + p( 'Before' ) + + '
' + + p( 'After' ) + ); + expect( types( blocks ) ).toEqual( [ + 'paragraph', + 'content_break', + 'paragraph', + ] ); + } ); + + test( 'AC-003: embed block converts to a link to the embedded content', async ( { + page, + } ) => { + const url = 'https://www.youtube.com/watch?v=qa-e2e'; + const blocks = await convert( + page, + `
\n${ url }\n
` + ); + expect( blocks ).toHaveLength( 1 ); + expect( blocks[ 0 ].type ).toBe( 'embed_link' ); + expect( blocks[ 0 ].url ).toBe( url ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-002: skip unsupported blocks', () => { + test( 'AC-004: blocks without a beehiiv equivalent are silently omitted', async ( { + page, + } ) => { + const blocks = await convert( + page, + p( 'Kept before' ) + + group( p( 'Inside group' ) + h( 'Group heading' ) ) + + '' + + p( 'Kept after' ) + ); + expect( types( blocks ) ).toEqual( [ 'paragraph', 'paragraph' ] ); + expect( blocks.map( textOf ) ).toEqual( [ + 'Kept before', + 'Kept after', + ] ); + } ); + + test( 'AC-005: empty and whitespace-only blocks are omitted', async ( { + page, + } ) => { + const blocks = await convert( + page, + p( 'Real text' ) + + '

' + + '

 

' + + '

' + + p( 'More text' ) + ); + expect( types( blocks ) ).toEqual( [ 'paragraph', 'paragraph' ] ); + expect( blocks.map( textOf ) ).toEqual( [ 'Real text', 'More text' ] ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-003: snippet mode', () => { + test( 'AC-006: snippet mode stops at the more block and adds a Read More button to the permalink in a new tab', async ( { + page, + } ) => { + const blocks = await convert( + page, + p( 'Teaser' ) + more() + p( 'Hidden after more' ), + { snippet: true } + ); + expect( types( blocks ) ).toEqual( [ 'paragraph', 'button' ] ); + expect( blocks[ 1 ].text ).toBe( 'Read More' ); + expect( blocks[ 1 ].target ).toBe( '_blank' ); + const id = createdPosts[ createdPosts.length - 1 ]; + const permalink = wpCli( `post get ${ id } --field=url` ); + expect( blocks[ 1 ].href ).toBe( permalink ); + expect( JSON.stringify( blocks ) ).not.toContain( 'Hidden after more' ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-004: featured image', () => { + test( 'AC-007: featured image is prepended and set to show in email only', async ( { + page, + } ) => { + const blocks = await convert( page, p( 'Body' ), { thumbnail: true } ); + expect( types( blocks ) ).toEqual( [ 'image', 'paragraph' ] ); + expect( blocks[ 0 ].imageUrl ).toContain( 'qa-e2e-bcf-' ); + expect( blocks[ 0 ].visibility_settings ).toEqual( { + show_on_email: true, + show_on_web: false, + } ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-005: advertisement validation', () => { + test( 'AC-008: advertisement blocks are included only for a valid, active ad', async ( { + page, + } ) => { + // Active ad: kept, with its opportunity id. + let blocks = await convert( page, p( 'Body' ) + ad( 'ad_active' ), { + activeAdIds: [ 'ad_active' ], + } ); + const kept = blocks.filter( ( b ) => b.type === 'advertisement' ); + expect( kept ).toHaveLength( 1 ); + expect( kept[ 0 ].opportunity_id ).toBe( 'ad_active' ); + + // Ad no longer active (deleted or expired): the block is never + // included. The send path rejects the post before conversion, so no + // create-post request goes out at all. + blocks = await convert( page, p( 'Body' ) + ad( 'ad_gone' ), { + activeAdIds: [ 'ad_other' ], + allowNoSend: true, + } ); + expect( blocks ).toBeNull(); + + // No ad selected: the block is never included either. + blocks = await convert( page, p( 'Body' ) + ad( '' ), { + activeAdIds: [ 'ad_active' ], + allowNoSend: true, + } ); + expect( blocks ).toBeNull(); + } ); +} ); diff --git a/tests/e2e/specs/structured-content-blocks.spec.js b/tests/e2e/specs/structured-content-blocks.spec.js new file mode 100644 index 0000000..d69d075 --- /dev/null +++ b/tests/e2e/specs/structured-content-blocks.spec.js @@ -0,0 +1,645 @@ +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/structured-content-blocks.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 walk, columns routing. + * - includes/Newsletter/Converters/ColumnsBlockConverter.php -- widths, + * vertical alignment, mobile stacking. + * - includes/Newsletter/SupportedBlocks.php -- which blocks are converted. + * + * Each test publishes a post through the REST endpoint the block editor uses + * (POST /wp/v2/posts/{id}, cookie + nonce), with "Send to Newsletter" on. + * The test-only `pre_http_request` mock in tests/e2e/plugins/beehiiv-options.php + * answers the beehiiv call and records it, so each test asserts on the exact + * `blocks` payload the plugin sent. Nothing reaches the live API. + */ + +const META_SEND = '_beehiiv_send_to_newsletter'; +const PUBLICATION_ID = 'pub_qa_e2e_scb'; +const TEMPLATE_ID = 'tpl_qa_e2e_scb'; +const IMG = 'https://example.com/qa-image.jpg'; + +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 connected, ready-to-send site and mocks the beehiiv posts endpoint. + */ +function seed() { + wpEvalWith( + {}, + `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" ] ] ); + beehiiv_e2e_mock_http( "/posts", [ "body" => [ "data" => [ "id" => "post_qa_e2e_scb" ] ] ] ); + beehiiv_e2e_clear_http_log();` + ); +} + +/** + * Logs in as admin and returns a REST caller bound to that session. + * + * @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 post with the given content and returns the blocks sent to beehiiv. + * + * @param {import('@playwright/test').Page} page Playwright page. + * @param {string} content Serialized block markup. + * @return {Promise} The `blocks` array of the create-post request. + */ +async function convert( page, content ) { + seed(); + const id = wpEvalWith( + { content }, + `$id = wp_insert_post( [ "post_type" => "post", "post_title" => "QA E2E structured blocks", "post_content" => $a["content"], "post_status" => "draft", "post_author" => 1 ] ); + update_post_meta( $id, "${ META_SEND }", "1" ); + beehiiv_e2e_clear_http_log(); + echo $id . "\\n";` + ); + createdPosts.push( id ); + const rest = await restAs( page ); + const res = await rest( 'POST', `/wp/v2/posts/${ id }`, { + status: 'publish', + } ); + expect( res.status ).toBe( 200 ); + const log = JSON.parse( + wpCli( `eval 'beehiiv_e2e_print_http_log();'` ) || '[]' + ); + const create = log.find( + ( r ) => + r.method === 'POST' && /\/publications\/[^/]+\/posts$/.test( r.url ) + ); + expect( create, 'beehiiv create-post request was sent' ).toBeTruthy(); + return create.body.blocks || []; +} + +// --------------------------------------------------------------------------- +// Block markup builders. +// --------------------------------------------------------------------------- + +const p = ( text ) => + `

${ text }

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

${ text }

`; +const img = () => + `
`; +const btn = ( text ) => + ``; +const group = ( inner ) => + `
${ inner }
`; + +/** + * Builds one core/column block. + * + * @param {string} inner Inner block markup. + * @param {Object} [options] Column options. + * @param {string} [options.width] Width attribute and flex-basis, e.g. "20%". + * @param {string} [options.valign] verticalAlignment attribute value. + * @return {string} Serialized column. + */ +function col( inner, { width, valign } = {} ) { + const attrs = {}; + if ( width ) { + attrs.width = width; + } + if ( valign ) { + attrs.verticalAlignment = valign; + } + const json = Object.keys( attrs ).length + ? ` ${ JSON.stringify( attrs ) }` + : ''; + const cls = valign + ? `wp-block-column is-vertically-aligned-${ valign }` + : 'wp-block-column'; + const style = width ? ` style="flex-basis:${ width }"` : ''; + return `
${ inner }
`; +} + +/** + * Builds one core/columns block. + * + * @param {string[]} cols Serialized columns. + * @param {Object} [options] Columns options. + * @param {string} [options.valign] Block-level verticalAlignment. + * @param {boolean} [options.stacked] isStackedOnMobile value. + * @return {string} Serialized columns block. + */ +function columns( cols, { valign, stacked } = {} ) { + const attrs = {}; + if ( valign ) { + attrs.verticalAlignment = valign; + } + if ( stacked !== undefined ) { + attrs.isStackedOnMobile = stacked; + } + const json = Object.keys( attrs ).length + ? ` ${ JSON.stringify( attrs ) }` + : ''; + const cls = + 'wp-block-columns' + + ( stacked === false ? ' is-not-stacked-on-mobile' : '' ); + return `
${ cols.join( + '' + ) }
`; +} + +const types = ( blocks ) => blocks.map( ( b ) => b.type ); +const columnsBlocks = ( blocks ) => + blocks.filter( ( b ) => b.type === 'columns' ); + +/** + * Flattens the plain text of a list item's formatted text runs. + * + * @param {Object} item List item from the payload. + * @return {string} Item text. + */ +const itemText = ( item ) => + typeof item === 'string' + ? item + : ( item.formattedText || [] ).map( ( r ) => r.text ).join( '' ); + +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, "_beehiiv_post_id" ); wp_delete_post( $id, true ); } + delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();'` + ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-001: nested lists', () => { + const nested = ( parentAttrs, childAttrs ) => + `
  • Parent
    • Child
      • Grand
`; + + test( 'AC-001: nested lists flatten into one list with indentation per level', async ( { + page, + } ) => { + const blocks = await convert( page, nested( '', '' ) ); + const lists = blocks.filter( ( b ) => b.type === 'list' ); + expect( lists ).toHaveLength( 1 ); + const texts = lists[ 0 ].items.map( itemText ); + expect( texts ).toHaveLength( 3 ); + expect( texts[ 0 ] ).toBe( 'Parent' ); + const indent = ( s ) => s.match( /^(\s*-\s*)*/ )[ 0 ].length; + expect( texts[ 1 ] ).toContain( 'Child' ); + expect( texts[ 2 ] ).toContain( 'Grand' ); + expect( indent( texts[ 1 ] ) ).toBeGreaterThan( indent( texts[ 0 ] ) ); + expect( indent( texts[ 2 ] ) ).toBeGreaterThan( indent( texts[ 1 ] ) ); + } ); + + test( 'AC-002: parent text color cascades to children unless a child overrides it', async ( { + page, + } ) => { + const cascade = await convert( + page, + nested( ' {"textColor":"vivid-red"}', '' ) + ); + const colors = cascade[ 0 ].items.map( + ( i ) => i.formattedText[ 0 ].text_color + ); + expect( colors[ 0 ] ).toBeTruthy(); + expect( colors[ 1 ] ).toBe( colors[ 0 ] ); + expect( colors[ 2 ] ).toBe( colors[ 0 ] ); + + const override = await convert( + page, + nested( + ' {"textColor":"vivid-red"}', + ' {"textColor":"luminous-vivid-amber"}' + ) + ); + const oc = override[ 0 ].items.map( + ( i ) => i.formattedText[ 0 ].text_color + ); + expect( oc[ 1 ] ).toBeTruthy(); + expect( oc[ 1 ] ).not.toBe( oc[ 0 ] ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-002: tables', () => { + const table = ( extraAttrs = '', extraStyle = '' ) => + `
NameQtyApple3Total3
`; + + test( 'AC-003: header rows are marked and the footer is omitted', async ( { + page, + } ) => { + const blocks = await convert( page, table() ); + const t = blocks.find( ( b ) => b.type === 'table' ); + expect( t ).toBeTruthy(); + expect( t.headerRow ).toBe( true ); + expect( t.rows[ 0 ].map( ( c ) => c.text ) ).toEqual( [ + 'Name', + 'Qty', + ] ); + const all = JSON.stringify( t.rows ); + expect( all ).not.toContain( 'Total' ); + expect( t.rows ).toHaveLength( 2 ); + } ); + + test( 'AC-004: table columns and cell alignment are preserved', async ( { + page, + } ) => { + const blocks = await convert( page, table() ); + const t = blocks.find( ( b ) => b.type === 'table' ); + expect( t.rows.every( ( r ) => r.length === 2 ) ).toBe( true ); + expect( t.rows[ 0 ][ 0 ].alignment ).toBe( 'left' ); + expect( t.rows[ 0 ][ 1 ].alignment ).toBe( 'right' ); + expect( t.rows[ 1 ][ 1 ].alignment ).toBe( 'right' ); + } ); + + test( 'AC-010: block background colors are applied to lists and tables', async ( { + page, + } ) => { + const listBlocks = await convert( + page, + `
  • Item
` + ); + const list = listBlocks.find( ( b ) => b.type === 'list' ); + expect( JSON.stringify( list ).toLowerCase() ).toContain( '#ffeeaa' ); + + const tableBlocks = await convert( + page, + table( + ' {"style":{"color":{"background":"#eeeeee"}}}', + ' class="has-background" style="background-color:#eeeeee"' + ) + ); + const t = tableBlocks.find( ( b ) => b.type === 'table' ); + expect( t ).toBeTruthy(); + expect( JSON.stringify( t ).toLowerCase() ).toContain( '#eeeeee' ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-003: media-text', () => { + const mediaText = ( { + position = 'left', + width = 30, + type = 'image', + src = IMG, + inner = p( 'Side text' ), + } = {} ) => { + const attrs = JSON.stringify( { + mediaPosition: position, + mediaId: 1, + mediaType: type, + mediaWidth: width, + } ); + let mediaInner = ''; + if ( src && type === 'video' ) { + mediaInner = ``; + } else if ( src ) { + mediaInner = `a`; + } + const media = `
${ mediaInner }
`; + const content = `
${ inner }
`; + const right = position === 'right'; + return `
${ + right ? content + media : media + content + }
`; + }; + + test( 'AC-005: media-text converts to two columns with the configured widths', async ( { + page, + } ) => { + const blocks = await convert( page, mediaText( { width: 30 } ) ); + const cols = columnsBlocks( blocks ); + expect( cols ).toHaveLength( 1 ); + expect( cols[ 0 ].columns ).toHaveLength( 2 ); + expect( cols[ 0 ].columns.map( ( c ) => c.width ) ).toEqual( [ + 30, 70, + ] ); + expect( cols[ 0 ].columns[ 0 ].blocks[ 0 ].type ).toBe( 'image' ); + expect( cols[ 0 ].columns[ 1 ].blocks[ 0 ].type ).toBe( 'paragraph' ); + } ); + + test( 'AC-006: media on the right reorders the columns', async ( { + page, + } ) => { + const blocks = await convert( + page, + mediaText( { position: 'right', width: 30 } ) + ); + const cols = columnsBlocks( blocks )[ 0 ].columns; + expect( cols[ 0 ].blocks[ 0 ].type ).toBe( 'paragraph' ); + expect( cols[ 1 ].blocks[ 0 ].type ).toBe( 'image' ); + expect( cols.map( ( c ) => c.width ) ).toEqual( [ 70, 30 ] ); + } ); + + test( 'AC-007: video media-text is omitted entirely', async ( { + page, + } ) => { + const blocks = await convert( + page, + mediaText( { type: 'video', src: 'https://example.com/v.mp4' } ) + + p( 'After video' ) + ); + expect( types( blocks ) ).toEqual( [ 'paragraph' ] ); + expect( JSON.stringify( blocks ) ).not.toContain( 'Side text' ); + } ); + + test( 'AC-009: media-text with no valid image URL is omitted', async ( { + page, + } ) => { + const blocks = await convert( + page, + mediaText( { src: '' } ) + p( 'After media-text' ) + ); + expect( columnsBlocks( blocks ) ).toHaveLength( 0 ); + expect( JSON.stringify( blocks ) ).not.toContain( 'Side text' ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-004: columns block widths, alignment and stacking', () => { + test( 'AC-008: columns keep widths, vertical alignment and the stacking setting', async ( { + page, + } ) => { + const blocks = await convert( + page, + columns( + [ + col( p( 'A' ), { width: '30%', valign: 'bottom' } ), + col( p( 'B' ), { width: '70%', valign: 'top' } ), + ], + { stacked: false } + ) + ); + const block = columnsBlocks( blocks )[ 0 ]; + expect( block.columns.map( ( c ) => c.width ) ).toEqual( [ 30, 70 ] ); + expect( block.columns.map( ( c ) => c.verticalAlign ) ).toEqual( [ + 'bottom', + 'top', + ] ); + expect( block.stackOnMobile ).toBe( false ); + } ); +} ); + +// --------------------------------------------------------------------------- +test.describe( 'US-006: top-level Columns blocks', () => { + test( 'AC-011: a top-level Columns block becomes one columns block, one column each, in order', async ( { + page, + } ) => { + const blocks = await convert( + page, + columns( [ col( p( 'One' ) ), col( p( 'Two' ) ) ] ) + ); + expect( types( blocks ) ).toEqual( [ 'columns' ] ); + const cols = blocks[ 0 ].columns; + expect( cols ).toHaveLength( 2 ); + expect( cols[ 0 ].blocks[ 0 ].plaintext ).toBe( 'One' ); + expect( cols[ 1 ].blocks[ 0 ].plaintext ).toBe( 'Two' ); + } ); + + test( 'AC-012: a column keeps its inner blocks in order and omits blocks beehiiv does not accept', async ( { + page, + } ) => { + const blocks = await convert( + page, + columns( [ + col( h( 'Title' ) + p( 'Body' ) + btn( 'Go' ) ), + col( group( p( 'Inside group' ) ) ), + col( p( 'Kept' ) ), + ] ) + ); + const cols = columnsBlocks( blocks )[ 0 ].columns; + expect( types( cols[ 0 ].blocks ) ).toEqual( [ + 'heading', + 'paragraph', + 'button', + ] ); + // The column holding only an unsupported group is omitted. + expect( cols ).toHaveLength( 2 ); + expect( JSON.stringify( blocks ) ).not.toContain( 'Inside group' ); + expect( cols[ 1 ].blocks[ 0 ].plaintext ).toBe( 'Kept' ); + } ); + + test( 'AC-013: percent widths become whole percents and a missing width stays unset', async ( { + page, + } ) => { + const withWidths = await convert( + page, + columns( [ + col( p( 'A' ), { width: '33.33%' } ), + col( p( 'B' ), { width: '66.67%' } ), + ] ) + ); + expect( + columnsBlocks( withWidths )[ 0 ].columns.map( ( c ) => c.width ) + ).toEqual( [ 33, 67 ] ); + + const noWidths = await convert( + page, + columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ] ) + ); + for ( const c of columnsBlocks( noWidths )[ 0 ].columns ) { + expect( c ).not.toHaveProperty( 'width' ); + } + } ); + + test( "AC-014: px widths become each column's share of the row, and one missing width clears the row", async ( { + page, + } ) => { + const px = await convert( + page, + columns( [ + col( p( 'A' ), { width: '100px' } ), + col( p( 'B' ), { width: '300px' } ), + ] ) + ); + expect( + columnsBlocks( px )[ 0 ].columns.map( ( c ) => c.width ) + ).toEqual( [ 25, 75 ] ); + + const rem = await convert( + page, + columns( [ + col( p( 'A' ), { width: '10rem' } ), + col( p( 'B' ), { width: '10rem' } ), + col( p( 'C' ), { width: '20rem' } ), + ] ) + ); + expect( + columnsBlocks( rem )[ 0 ].columns.map( ( c ) => c.width ) + ).toEqual( [ 25, 25, 50 ] ); + + const partial = await convert( + page, + columns( [ col( p( 'A' ), { width: '100px' } ), col( p( 'B' ) ) ] ) + ); + for ( const c of columnsBlocks( partial )[ 0 ].columns ) { + expect( c ).not.toHaveProperty( 'width' ); + } + } ); + + test( 'AC-015: vertical alignment from the column or the Columns block becomes top, middle or bottom', async ( { + page, + } ) => { + const blocks = await convert( + page, + columns( + [ + col( p( 'A' ), { valign: 'center' } ), + col( p( 'B' ), { valign: 'bottom' } ), + col( p( 'C' ) ), + ], + { valign: 'top' } + ) + ); + expect( + columnsBlocks( blocks )[ 0 ].columns.map( ( c ) => c.verticalAlign ) + ).toEqual( [ 'middle', 'bottom', 'top' ] ); + } ); + + test( 'AC-016: columns stack on mobile by default and not when the block opts out', async ( { + page, + } ) => { + const def = await convert( + page, + columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ] ) + ); + expect( columnsBlocks( def )[ 0 ].stackOnMobile ).toBe( true ); + + const off = await convert( + page, + columns( [ col( p( 'A' ) ), col( p( 'B' ) ) ], { stacked: false } ) + ); + expect( columnsBlocks( off )[ 0 ].stackOnMobile ).toBe( false ); + } ); + + test( 'AC-017: a Columns block with more than 3 columns becomes top-level blocks', async ( { + page, + } ) => { + const four = await convert( + page, + columns( [ + col( p( 'A' ) ), + col( p( 'B' ) ), + col( p( 'C' ) ), + col( p( 'D' ) ), + ] ) + ); + expect( columnsBlocks( four ) ).toHaveLength( 0 ); + expect( four.map( ( b ) => b.plaintext ) ).toEqual( [ + 'A', + 'B', + 'C', + 'D', + ] ); + + // Control: exactly 3 columns still converts to one columns block. + const three = await convert( + page, + columns( [ col( p( 'A' ) ), col( p( 'B' ) ), col( p( 'C' ) ) ] ) + ); + expect( columnsBlocks( three ) ).toHaveLength( 1 ); + expect( columnsBlocks( three )[ 0 ].columns ).toHaveLength( 3 ); + } ); + + test( 'AC-018: a Columns block nested in a column becomes top-level blocks', async ( { + page, + } ) => { + const nested = columns( [ col( p( 'N1' ) ), col( p( 'N2' ) ) ] ); + const blocks = await convert( + page, + columns( [ col( p( 'Outer A' ) + nested ), col( p( 'Outer B' ) ) ] ) + ); + const cols = columnsBlocks( blocks ); + expect( cols ).toHaveLength( 1 ); + expect( JSON.stringify( cols[ 0 ].columns ) ).not.toContain( 'N1' ); + expect( JSON.stringify( cols[ 0 ].columns ) ).not.toContain( + '"columns":[{"blocks":[{"type":"paragraph","plaintext":"N1"' + ); + const top = blocks.slice( 1 ); + expect( top.map( ( b ) => b.plaintext ) ).toEqual( [ 'N1', 'N2' ] ); + } ); + + test( 'AC-019: a 3-column layout with an image, heading plus paragraph, and a button converts to one columns block', async ( { + page, + } ) => { + const blocks = await convert( + page, + columns( [ + col( img() ), + col( h( 'Head' ) + p( 'Para' ) ), + col( btn( 'Click' ) ), + ] ) + ); + expect( types( blocks ) ).toEqual( [ 'columns' ] ); + const cols = blocks[ 0 ].columns; + expect( cols ).toHaveLength( 3 ); + expect( types( cols[ 0 ].blocks ) ).toEqual( [ 'image' ] ); + expect( types( cols[ 1 ].blocks ) ).toEqual( [ + 'heading', + 'paragraph', + ] ); + expect( types( cols[ 2 ].blocks ) ).toEqual( [ 'button' ] ); + } ); +} ); From eb847c0ab39ddbdc23cbac285407d0242c344c74 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 27 Oct 2026 01:18:13 +0800 Subject: [PATCH 3/8] fix(newsletter-publishing): send table block background color to beehiiv A table with a block background color sent no color. The color is now read from the block attributes, the table tag, or the figure wrapper, and sent as visual settings the same way lists and paragraphs send it. --- includes/Newsletter/BlockConverter.php | 15 +++- includes/Newsletter/FormattedTextParser.php | 26 ++++++ .../BlockConverterTableBackgroundTest.php | 87 +++++++++++++++++++ 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tests/phpunit/BlockConverterTableBackgroundTest.php diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 1b28485..123cedb 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -694,7 +694,8 @@ private static function convert_layout_block_inner_blocks( * Maps attrs.head into rows[0] with headerRow when the Header section is enabled * in the block editor. attrs.body supplies remaining rows. When attrs are empty * (common for query-sourced table attributes in PHP parse_blocks), rows are read - * from innerHTML instead. The table is omitted when every cell is empty. + * from innerHTML instead. The table is omitted when every cell is empty. A block + * background colour is sent as `visual_settings.background_color`. * Footer rows are omitted; beehiiv has no table footer support. * * @param array $wp_block Parsed block. @@ -751,11 +752,21 @@ public static function convert_table_block( array $wp_block ): array { return []; } - return [ + $table_block = [ 'type' => 'table', 'rows' => $rows, 'headerRow' => $header_row, ]; + + $background_color = FormattedTextParser::resolve_table_block_background_color( $inner_html, $attrs ); + + if ( null !== $background_color && '' !== $background_color ) { + $table_block['visual_settings'] = [ + 'background_color' => $background_color, + ]; + } + + return $table_block; } /** diff --git a/includes/Newsletter/FormattedTextParser.php b/includes/Newsletter/FormattedTextParser.php index 389f675..07b208b 100644 --- a/includes/Newsletter/FormattedTextParser.php +++ b/includes/Newsletter/FormattedTextParser.php @@ -269,6 +269,32 @@ public static function resolve_list_block_background_color( string $inner_html, return self::resolve_list_wrapper_color_from_html( $inner_html, $attrs, 'background' ); } + /** + * Resolve a table block's background colour from attrs or saved HTML. + * + * Reads the `` tag first, then the `
` wrapper. + * + * @param string $inner_html Saved table HTML. + * @param array $attrs Parsed block attrs. + * @return string|null + * @since 1.2.0 + */ + public static function resolve_table_block_background_color( string $inner_html, array $attrs ): ?string { + $from_attrs = self::resolve_block_background_color_from_attrs( $attrs ); + + if ( null !== $from_attrs ) { + return $from_attrs; + } + + $from_table = self::resolve_wrapper_color_from_html_tag( $inner_html, 'table', 'background' ); + + if ( null !== $from_table ) { + return $from_table; + } + + return self::resolve_wrapper_color_from_html_tag( $inner_html, 'figure', 'background' ); + } + /** * Walk HTML and collect formatted text segments. * diff --git a/tests/phpunit/BlockConverterTableBackgroundTest.php b/tests/phpunit/BlockConverterTableBackgroundTest.php new file mode 100644 index 0000000..21247b3 --- /dev/null +++ b/tests/phpunit/BlockConverterTableBackgroundTest.php @@ -0,0 +1,87 @@ + $attrs Block attributes. + * @param string $table_tag Opening table tag. + * @param string $figure_tag Opening figure tag. + * @return array + */ + private function table_block( + array $attrs, + string $table_tag = '
', + string $figure_tag = '
' + ): array { + return array( + 'blockName' => 'core/table', + 'attrs' => $attrs, + 'innerBlocks' => array(), + 'innerHTML' => $figure_tag . $table_tag + . '
Apple3
', + ); + } + + /** + * A colour set in the block attributes is sent. + */ + public function test_background_color_from_block_attributes(): void { + $block = BlockConverter::convert_table_block( + $this->table_block( array( 'style' => array( 'color' => array( 'background' => '#eeeeee' ) ) ) ) + ); + + $this->assertSame( '#eeeeee', $block['visual_settings']['background_color'] ); + } + + /** + * A colour on the table tag is sent. + */ + public function test_background_color_from_table_tag(): void { + $block = BlockConverter::convert_table_block( + $this->table_block( array(), '' ) + ); + + $this->assertSame( '#ffeeaa', $block['visual_settings']['background_color'] ); + } + + /** + * A colour on the figure wrapper is sent. + */ + public function test_background_color_from_figure_wrapper(): void { + $block = BlockConverter::convert_table_block( + $this->table_block( + array(), + '
', + '
' + ) + ); + + $this->assertSame( '#112233', $block['visual_settings']['background_color'] ); + } + + /** + * A table without a background colour sends no visual settings. + */ + public function test_no_background_color_sends_no_visual_settings(): void { + $block = BlockConverter::convert_table_block( $this->table_block( array() ) ); + + $this->assertArrayNotHasKey( 'visual_settings', $block ); + } +} From 711d803162c9f9fac15732cbcd3c5a99f5086e06 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 27 Oct 2026 01:21:13 +0800 Subject: [PATCH 4/8] fix(newsletter-publishing): omit paragraphs that hold only a non-breaking space A paragraph with only a non-breaking space was still sent as a text block. The check now treats non-breaking spaces as blank. --- includes/Newsletter/BlockConverter.php | 2 +- tests/phpunit/BlockConverterParagraphTest.php | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/phpunit/BlockConverterParagraphTest.php diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 123cedb..fe92532 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -342,7 +342,7 @@ public static function convert_paragraph_block( array $wp_block ): array { $block_background_color = FormattedTextParser::resolve_paragraph_block_background_color( $inner_html, $attrs ); $parsed = FormattedTextParser::parse( $inline_html, $block_text_color ); - if ( '' === trim( $parsed['plaintext'] ) ) { + if ( '' === preg_replace( '/[\s\x{00A0}]+/u', '', $parsed['plaintext'] ) ) { return []; } diff --git a/tests/phpunit/BlockConverterParagraphTest.php b/tests/phpunit/BlockConverterParagraphTest.php new file mode 100644 index 0000000..62e3edc --- /dev/null +++ b/tests/phpunit/BlockConverterParagraphTest.php @@ -0,0 +1,68 @@ + + */ + private function paragraph_block( string $inner_html ): array { + return array( + 'blockName' => 'core/paragraph', + 'attrs' => array(), + 'innerBlocks' => array(), + 'innerHTML' => $inner_html, + ); + } + + /** + * Paragraphs with no visible text are omitted. + * + * @return array> + */ + public function blank_paragraph_provider(): array { + return array( + 'empty' => array( '

' ), + 'spaces' => array( '

' ), + 'non-breaking space' => array( '

 

' ), + 'spaces and nbsp' => array( '

 

' ), + 'several nbsp' => array( '

  

' ), + ); + } + + /** + * A paragraph with no visible text is omitted. + * + * @dataProvider blank_paragraph_provider + * + * @param string $inner_html Saved paragraph HTML. + */ + public function test_blank_paragraph_is_omitted( string $inner_html ): void { + $this->assertSame( array(), BlockConverter::convert_paragraph_block( $this->paragraph_block( $inner_html ) ) ); + } + + /** + * A paragraph with real text is kept. + */ + public function test_paragraph_with_text_is_kept(): void { + $block = BlockConverter::convert_paragraph_block( $this->paragraph_block( '

Real text

' ) ); + + $this->assertSame( 'paragraph', $block['type'] ); + } +} From 238514a7a0735fba658b57df4a60d0951db5f61a Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Tue, 27 Oct 2026 01:24:46 +0800 Subject: [PATCH 5/8] fix(newsletter-publishing): omit Media & Text blocks that have no valid image The PRD says a Media & Text block with no valid image URL is omitted. The converter kept the text column instead. The whole block is now omitted. --- includes/Newsletter/BlockConverter.php | 12 +++- tests/phpunit/BlockConverterMediaTextTest.php | 65 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 tests/phpunit/BlockConverterMediaTextTest.php diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index fe92532..6df79c2 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -1046,8 +1046,9 @@ public static function convert_embed_block( array $wp_block ): array { * Convert a core/media-text block to a beehiiv columns block. * * Maps the media side to an image column and the content side to converted - * inner blocks. Blocks with video media are omitted entirely. Respects media - * position, column widths, vertical alignment, and mobile stacking. + * inner blocks. Blocks with video media, or with no valid image, are omitted + * entirely. Respects media position, column widths, vertical alignment, and + * mobile stacking. * * @param array $wp_block Parsed block. * @return array @@ -1067,7 +1068,12 @@ public static function convert_media_text_block( array $wp_block ): array { $media_width = MediaTextBlockConverter::resolve_media_width( $attrs, $inner_html ); $content_width = max( 1, min( 100, 100 - $media_width ) ); - $media_blocks = self::convert_media_text_media_blocks( $attrs, $inner_html ); + $media_blocks = self::convert_media_text_media_blocks( $attrs, $inner_html ); + + if ( empty( $media_blocks ) ) { + return []; + } + $content_blocks = self::convert_layout_block_inner_blocks( $inner_blocks ); if ( empty( $content_blocks ) && '' !== $inner_html ) { diff --git a/tests/phpunit/BlockConverterMediaTextTest.php b/tests/phpunit/BlockConverterMediaTextTest.php new file mode 100644 index 0000000..ee91fcd --- /dev/null +++ b/tests/phpunit/BlockConverterMediaTextTest.php @@ -0,0 +1,65 @@ + + */ + private function media_text_block( string $media_html ): array { + $html = '
' + . $media_html . '
'; + + return array( + 'blockName' => 'core/media-text', + 'attrs' => array( + 'mediaType' => 'image', + 'mediaWidth' => 30, + ), + 'innerBlocks' => array( + array( + 'blockName' => 'core/paragraph', + 'attrs' => array(), + 'innerBlocks' => array(), + 'innerHTML' => '

Side text

', + ), + ), + 'innerHTML' => $html, + ); + } + + /** + * With no image URL the whole block is omitted, text included. + */ + public function test_media_text_without_image_url_is_omitted(): void { + $this->assertSame( array(), BlockConverter::convert_media_text_block( $this->media_text_block( '' ) ) ); + } + + /** + * With a valid image URL the block converts to a columns block. + */ + public function test_media_text_with_image_url_converts(): void { + $block = BlockConverter::convert_media_text_block( + $this->media_text_block( '' ) + ); + + $this->assertSame( 'columns', $block['type'] ); + $this->assertCount( 2, $block['columns'] ); + } +} From 1ad7097e6d9b3f5989234581ccfae9d4ac2a0f86 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 19:17:14 +0800 Subject: [PATCH 6/8] feat(editor): mark omitted blocks on the block itself while the beehiiv sidebar is open The marker adds a class and tooltip to the block's own element instead of wrapping the block, shows only while the beehiiv sidebar is open, keeps the hidden-eye icon visible, and also covers posts linked to a scheduled newsletter. --- .../components/omitted-blocks-notice.js | 23 ++ src/js/editor/post-settings/constants.js | 11 + .../post-settings/css/_omitted-blocks.scss | 44 +-- src/js/editor/post-settings/css/_sidebar.scss | 1 - .../filters/with-omitted-block-indicator.js | 78 ++--- .../hooks/use-is-sidebar-open.js | 18 ++ src/js/editor/post-settings/index.js | 9 +- .../e2e/specs/omitted-block-indicator.spec.js | 267 ++++++++++++++++++ 8 files changed, 382 insertions(+), 69 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 create mode 100644 tests/e2e/specs/omitted-block-indicator.spec.js 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/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/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 { 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..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,51 +6,63 @@ 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(); - 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; + + // 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 636895f..7d1659d 100644 --- a/src/js/editor/post-settings/index.js +++ b/src/js/editor/post-settings/index.js @@ -26,18 +26,20 @@ 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'; 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() { @@ -214,6 +216,7 @@ function BeehiivPostSettingsPanel() { { newsletterAlreadySent && isNewsletterReady && ( <> + { ! newsletterPublished && } + 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 connected site that is ready to send. + */ +function seed() { + wpEvalWith( + {}, + `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" ] ] ); + beehiiv_e2e_clear_http_log();` + ); +} + +/** + * Creates a post fixture in the DB. + * + * @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 {string} [options.status] Post status. + * @return {string} Post ID. + */ +function createPost( { title, content, meta = {}, status = 'draft' } ) { + const post = { + post_type: 'post', + post_title: title, + post_content: content, + post_status: status, + post_author: 1, + }; + const id = wpEvalWith( + { post, meta }, + `$id = wp_insert_post( $a["post"] ); + foreach ( $a["meta"] as $k => $v ) { update_post_meta( $id, $k, $v ); } + echo $id . "\\n";` + ); + createdPosts.push( id ); + return id; +} + +/** + * 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(); + } +} + +/** + * Meta for a post linked to a beehiiv newsletter that sends at the given time. + * + * @param {string} scheduledAt UTC ISO 8601 send time. + * @return {Object} Post meta. + */ +function linkedMeta( scheduledAt ) { + return { + [ META.postId ]: 'post_qa_e2e_omitted', + _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 p = ( text ) => + `

${ text }

`; +// A Code block has no beehiiv equivalent, so the newsletter leaves it out. +const code = ( text ) => + `
${ text }
`; + +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 ); } + delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();'` + ); +} ); + +test.describe( 'Omitted block marks in the editor', () => { + test( 'a queued post marks the omitted block on the block itself, only while the sidebar is open', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E omitted block', + content: p( 'Kept text' ) + code( 'Left out' ), + meta: { [ META.send ]: '1' }, + } ); + await loginAsAdmin( page ); + await openPostEditor( page, id ); + await setBeehiivSidebar( page, true ); + + 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"]' ); + const marked = canvas.locator( '.beehiiv-omitted-block' ); + await expect( marked ).toHaveCount( 1 ); + + // The class sits on the Code block's own element; no wrapper element is added. + await expect( + canvas.locator( '.wp-block-code.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 marked.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( marked ).toHaveCount( 0 ); + await setBeehiivSidebar( page, true ); + await expect( marked ).toHaveCount( 1 ); + } ); + + test( 'a scheduled, linked post shows the notice and marks, and a sent one does not', async ( { + page, + } ) => { + seed(); + const content = p( 'Kept text' ) + code( 'Left out' ); + const scheduledId = createPost( { + title: 'QA E2E scheduled omitted block', + content, + status: 'publish', + meta: linkedMeta( '2031-01-01T10:00:00Z' ), + } ); + const sentId = createPost( { + title: 'QA E2E sent omitted block', + content, + status: 'publish', + meta: linkedMeta( '2020-01-01T10:00:00Z' ), + } ); + await loginAsAdmin( page ); + const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); + + await openPostEditor( page, scheduledId ); + await setBeehiivSidebar( page, true ); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toContainText( '1 block will be omitted from this newsletter.' ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 1 + ); + + await openPostEditor( page, sentId ); + await setBeehiivSidebar( page, true ); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCount( 0 ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 0 + ); + } ); + + test( 'a post whose blocks are all supported shows no notice or marks', async ( { + page, + } ) => { + seed(); + const id = createPost( { + title: 'QA E2E no omitted blocks', + content: p( 'Only supported text' ), + meta: { [ META.send ]: '1' }, + } ); + await loginAsAdmin( page ); + await openPostEditor( page, id ); + await setBeehiivSidebar( page, true ); + + await expect( + page.locator( '.beehiiv-post-settings-notice__text' ) + ).toBeVisible(); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCount( 0 ); + await expect( + page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '.beehiiv-omitted-block' ) + ).toHaveCount( 0 ); + } ); +} ); From f76e0540a41e87f95690bda6776d4369f235b29a Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 20:18:30 +0800 Subject: [PATCH 7/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/omitted-block-indicator.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/omitted-block-indicator.spec.js b/tests/e2e/specs/omitted-block-indicator.spec.js index 6240228..99aa03d 100644 --- a/tests/e2e/specs/omitted-block-indicator.spec.js +++ b/tests/e2e/specs/omitted-block-indicator.spec.js @@ -173,6 +173,10 @@ test.describe( 'Omitted block marks in the editor', () => { 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"]' ); const marked = canvas.locator( '.beehiiv-omitted-block' ); await expect( marked ).toHaveCount( 1 ); @@ -225,6 +229,10 @@ test.describe( 'Omitted block marks in the editor', () => { 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' ); await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( 1 ); From 20aea9033d1b8a305222d1e7a70b2207fa0e7cab Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 4 Nov 2026 20:40:20 +0800 Subject: [PATCH 8/8] feat(newsletter-publishing): omit Columns blocks with more than 3 columns and mark them in the editor A Columns block with more than 3 columns was sent as top-level blocks. It is now omitted from the newsletter, and the editor marks it and counts it in the omitted blocks notice, the same as other blocks the newsletter leaves out. The column limit is shared with the editor through the block support config. --- includes/Newsletter/BlockConverter.php | 16 +++--- includes/Newsletter/SupportedBlocks.php | 8 ++- src/js/shared/supported-blocks.js | 32 +++++++++++- .../e2e/specs/omitted-block-indicator.spec.js | 50 +++++++++++++++++++ .../specs/structured-content-blocks.spec.js | 22 ++++---- tests/phpunit/BlockConverterColumnsTest.php | 28 +++++------ 6 files changed, 119 insertions(+), 37 deletions(-) diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 6df79c2..9ed2efa 100644 --- a/includes/Newsletter/BlockConverter.php +++ b/includes/Newsletter/BlockConverter.php @@ -40,9 +40,9 @@ * - `beehiiv/advertisement` — beehiiv `advertisement` block via `convert_advertisement_block()` * * Layout blocks such as `core/group` are skipped at the top level. A `core/columns` - * block with more than 3 columns, or one nested inside a column, is not converted to a - * columns block. Its inner blocks are emitted at the top level instead. Snippet mode - * stops at `core/more`. + * block with more than 3 columns is omitted. A `core/columns` block nested inside a + * column is not converted to a columns block; its inner blocks are emitted at the top + * level instead. Snippet mode stops at `core/more`. * * @since 1.0.0 */ @@ -528,8 +528,7 @@ public static function convert_list_block( array $wp_block ): array { * Convert a core/columns block to a beehiiv columns block. * * Returns an empty array when the block is not converted to a columns block, - * including when it has more columns than beehiiv accepts. Use - * `convert_columns_block_to_blocks()` to also get the fallback blocks. + * including when it has more columns than beehiiv accepts. * * @param array $wp_block Parsed block. * @return array @@ -549,9 +548,8 @@ public static function convert_columns_block( array $wp_block ): array { * Convert a core/columns block to beehiiv blocks. * * A block with up to 3 columns becomes one beehiiv columns block. A block with - * more columns is not converted to a columns block. Each column's inner blocks - * are emitted at the top level in order instead, and the row is never split. A - * core/columns block nested inside a column is handled the same way: its inner + * more columns is omitted entirely, and the row is never split. A core/columns + * block nested inside a column is not converted to a columns block: its inner * blocks follow the outer columns block at the top level. * * @param array $wp_block Parsed block. @@ -585,7 +583,7 @@ public static function convert_columns_block_to_blocks( array $wp_block ): array } if ( count( $column_blocks ) > ColumnsBlockConverter::MAX_COLUMNS ) { - return array_merge( array_merge( ...$converted_columns ), $nested_blocks ); + return []; } $kept_blocks = []; diff --git a/includes/Newsletter/SupportedBlocks.php b/includes/Newsletter/SupportedBlocks.php index 9bd2a63..9007365 100644 --- a/includes/Newsletter/SupportedBlocks.php +++ b/includes/Newsletter/SupportedBlocks.php @@ -7,6 +7,8 @@ namespace Beehiiv\Newsletter; +use Beehiiv\Newsletter\Converters\ColumnsBlockConverter; + defined( 'ABSPATH' ) || exit; /** @@ -14,7 +16,8 @@ * * Layout blocks such as `core/group` are not supported. They and all of their * inner blocks are omitted from the newsletter payload. `core/columns` is supported, - * but only up to 3 columns; see `BlockConverter::convert_columns_block_to_blocks()`. + * but a Columns block with more than 3 columns is omitted; see + * `BlockConverter::convert_columns_block_to_blocks()`. * * @since 1.0.0 */ @@ -64,7 +67,7 @@ final class SupportedBlocks { /** * Block support config for the block editor (localized to JavaScript). * - * @return array> + * @return array|int> * @since 1.0.0 */ public static function get_editor_config(): array { @@ -72,6 +75,7 @@ public static function get_editor_config(): array { 'supported' => self::BLOCKS, 'nestedOnly' => self::NESTED_ONLY, 'snippetOnly' => self::SNIPPET_ONLY, + 'maxColumns' => ColumnsBlockConverter::MAX_COLUMNS, ]; } diff --git a/src/js/shared/supported-blocks.js b/src/js/shared/supported-blocks.js index 62b175f..c2268dd 100644 --- a/src/js/shared/supported-blocks.js +++ b/src/js/shared/supported-blocks.js @@ -9,6 +9,7 @@ * @property {string[]} supported Blocks converted to beehiiv newsletter blocks. * @property {string[]} nestedOnly Inner blocks handled by a parent. * @property {string[]} snippetOnly Blocks only used in snippet newsletter mode. + * @property {number} maxColumns Most columns a Columns block can hold before it is omitted. */ /** @type {BeehiivBlockSupportConfig} */ @@ -16,6 +17,7 @@ const DEFAULT_CONFIG = { supported: [], nestedOnly: [], snippetOnly: [], + maxColumns: 3, }; /** @@ -92,10 +94,34 @@ export function isMediaTextWithUnsupportedVideo( block ) { return 'video' === block.attributes?.mediaType; } +const COLUMNS_BLOCK = 'core/columns'; +const COLUMN_BLOCK = 'core/column'; + +/** + * Whether a Columns block has more columns than beehiiv accepts. + * + * The entire block is omitted from the beehiiv payload when it has. + * + * @param {{ name?: string, innerBlocks?: Array<{ name?: string }> }} block Parsed block. + * @return {boolean} True when the block has too many columns. + */ +export function isColumnsWithTooManyColumns( block ) { + if ( block?.name !== COLUMNS_BLOCK ) { + return false; + } + + const { maxColumns } = getConfig(); + const columns = ( block.innerBlocks ?? [] ).filter( + ( inner ) => inner?.name === COLUMN_BLOCK + ); + + return columns.length > ( maxColumns ?? DEFAULT_CONFIG.maxColumns ); +} + /** * Whether a block is omitted from the beehiiv newsletter payload. * - * @param {{ name?: string, attributes?: { mediaType?: string } }} block Parsed block. + * @param {{ name?: string, attributes?: { mediaType?: string }, innerBlocks?: Array<{ name?: string }> }} block Parsed block. * @return {boolean} True when the block will not be sent. */ export function isOmittedFromNewsletter( block ) { @@ -107,5 +133,9 @@ export function isOmittedFromNewsletter( block ) { return true; } + if ( isColumnsWithTooManyColumns( block ) ) { + return true; + } + return shouldWarnUnsupportedBlock( block.name ); } diff --git a/tests/e2e/specs/omitted-block-indicator.spec.js b/tests/e2e/specs/omitted-block-indicator.spec.js index 99aa03d..3acc6e7 100644 --- a/tests/e2e/specs/omitted-block-indicator.spec.js +++ b/tests/e2e/specs/omitted-block-indicator.spec.js @@ -138,6 +138,16 @@ const p = ( text ) => const code = ( text ) => `
${ text }
`; +// A Columns block holds at most 3 columns; beehiiv leaves out a wider one. +const columns = ( count ) => + `
${ Array.from( + { length: count }, + ( _, i ) => + `
${ p( + `Column ${ i + 1 }` + ) }
` + ).join( '' ) }
`; + test.describe.configure( { timeout: 150 * 1000 } ); test.beforeAll( async () => { @@ -247,6 +257,46 @@ test.describe( 'Omitted block marks in the editor', () => { ); } ); + test( 'a Columns block with more than 3 columns is marked as omitted, and one with 3 is not', async ( { + page, + } ) => { + seed(); + const wideId = createPost( { + title: 'QA E2E four columns', + content: p( 'Kept text' ) + columns( 4 ), + meta: { [ META.send ]: '1' }, + } ); + const narrowId = createPost( { + title: 'QA E2E three columns', + content: p( 'Kept text' ) + columns( 3 ), + meta: { [ META.send ]: '1' }, + } ); + await loginAsAdmin( page ); + const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' ); + + await openPostEditor( page, wideId ); + await setBeehiivSidebar( page, true ); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toContainText( '1 block will be omitted from this newsletter.' ); + // Only the Columns block itself is marked, not its columns or their content. + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 1 + ); + await expect( + canvas.locator( '.wp-block-columns.beehiiv-omitted-block' ) + ).toHaveCount( 1 ); + + await openPostEditor( page, narrowId ); + await setBeehiivSidebar( page, true ); + await expect( + page.locator( '.beehiiv-post-settings-notice__omitted' ) + ).toHaveCount( 0 ); + await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount( + 0 + ); + } ); + test( 'a post whose blocks are all supported shows no notice or marks', async ( { page, } ) => { diff --git a/tests/e2e/specs/structured-content-blocks.spec.js b/tests/e2e/specs/structured-content-blocks.spec.js index d69d075..faf68de 100644 --- a/tests/e2e/specs/structured-content-blocks.spec.js +++ b/tests/e2e/specs/structured-content-blocks.spec.js @@ -574,24 +574,24 @@ test.describe( 'US-006: top-level Columns blocks', () => { expect( columnsBlocks( off )[ 0 ].stackOnMobile ).toBe( false ); } ); - test( 'AC-017: a Columns block with more than 3 columns becomes top-level blocks', async ( { + test( 'AC-017: a Columns block with more than 3 columns is omitted', async ( { page, } ) => { const four = await convert( page, - columns( [ - col( p( 'A' ) ), - col( p( 'B' ) ), - col( p( 'C' ) ), - col( p( 'D' ) ), - ] ) + p( 'Before' ) + + columns( [ + col( p( 'A' ) ), + col( p( 'B' ) ), + col( p( 'C' ) ), + col( p( 'D' ) ), + ] ) + + p( 'After' ) ); expect( columnsBlocks( four ) ).toHaveLength( 0 ); expect( four.map( ( b ) => b.plaintext ) ).toEqual( [ - 'A', - 'B', - 'C', - 'D', + 'Before', + 'After', ] ); // Control: exactly 3 columns still converts to one columns block. diff --git a/tests/phpunit/BlockConverterColumnsTest.php b/tests/phpunit/BlockConverterColumnsTest.php index bd125bd..011c956 100644 --- a/tests/phpunit/BlockConverterColumnsTest.php +++ b/tests/phpunit/BlockConverterColumnsTest.php @@ -207,25 +207,25 @@ public function test_stack_on_mobile_defaults_to_true(): void { } /** - * AC-017: more than 3 columns fall back to top-level blocks, in order, with no columns block. + * AC-017: more than 3 columns are omitted entirely, with no columns block and no top-level blocks. */ - public function test_more_than_three_columns_fall_back_to_top_level_blocks(): void { + public function test_more_than_three_columns_are_omitted(): void { $blocks = $this->convert( - $this->columns( - array( - $this->column( $this->paragraph( 'A' ) ), - $this->column( $this->paragraph( 'B' ) ), - $this->column( $this->paragraph( 'C' ) ), - $this->column( $this->paragraph( 'D' ) ), + $this->paragraph( 'Before' ) + . $this->columns( + array( + $this->column( $this->paragraph( 'A' ) ), + $this->column( $this->paragraph( 'B' ) ), + $this->column( $this->paragraph( 'C' ) ), + $this->column( $this->paragraph( 'D' ) ), + ) ) - ) + . $this->paragraph( 'After' ) ); - $this->assertCount( 4, $blocks ); - - foreach ( $blocks as $block ) { - $this->assertSame( 'paragraph', $block['type'] ); - } + $this->assertCount( 2, $blocks ); + $this->assertSame( 'Before', $blocks[0]['plaintext'] ); + $this->assertSame( 'After', $blocks[1]['plaintext'] ); } /**