diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php index 32a1f94..9ed2efa 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 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 */ @@ -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 ) ) { @@ -335,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 []; } @@ -520,64 +527,116 @@ 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. * * @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 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. + * @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 ( empty( $column_blocks ) ) { - continue; - } + if ( count( $column_blocks ) > ColumnsBlockConverter::MAX_COLUMNS ) { + return []; + } - $beehiiv_column = ColumnsBlockConverter::build_beehiiv_column( - $column_blocks, - $column_block, - $parent_vertical_align - ); + $kept_blocks = []; + $kept_blocks_by_column = []; - if ( ! empty( $beehiiv_column ) ) { - $beehiiv_columns[] = $beehiiv_column; + foreach ( $column_blocks as $index => $column_block ) { + if ( ! empty( $converted_columns[ $index ] ) ) { + $kept_blocks[] = $column_block; + $kept_blocks_by_column[] = $converted_columns[ $index ]; } } - return ColumnsBlockConverter::build_beehiiv_columns_block( $beehiiv_columns, $stack_on_mobile ); + $widths = ColumnsBlockConverter::resolve_row_widths( $kept_blocks ); + $columns = []; + + 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 + ) + ); + } + + $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 +646,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; @@ -617,7 +692,8 @@ private static function convert_layout_block_inner_blocks( array $wp_blocks ): a * 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. @@ -674,11 +750,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; } /** @@ -958,8 +1044,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 @@ -979,7 +1066,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/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/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/includes/Newsletter/SupportedBlocks.php b/includes/Newsletter/SupportedBlocks.php index ba93a96..9007365 100644 --- a/includes/Newsletter/SupportedBlocks.php +++ b/includes/Newsletter/SupportedBlocks.php @@ -7,13 +7,17 @@ namespace Beehiiv\Newsletter; +use Beehiiv\Newsletter\Converters\ColumnsBlockConverter; + defined( 'ABSPATH' ) || exit; /** * 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 a Columns block with more than 3 columns is omitted; see + * `BlockConverter::convert_columns_block_to_blocks()`. * * @since 1.0.0 */ @@ -34,6 +38,7 @@ final class SupportedBlocks { 'core/pullquote', 'core/embed', 'core/media-text', + 'core/columns', 'core/buttons', 'core/separator', 'beehiiv/advertisement', @@ -46,6 +51,7 @@ final class SupportedBlocks { */ public const NESTED_ONLY = [ 'core/button', + 'core/column', 'core/list-item', ]; @@ -61,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 { @@ -69,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/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..0e707c2 100644 --- a/src/js/editor/post-settings/css/_sidebar.scss +++ b/src/js/editor/post-settings/css/_sidebar.scss @@ -34,7 +34,11 @@ align-items: flex-start; display: flex; gap: 6px; - margin-top: 10px !important; + } + + // 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 { 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 && } }} 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/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/omitted-block-indicator.spec.js b/tests/e2e/specs/omitted-block-indicator.spec.js new file mode 100644 index 0000000..3acc6e7 --- /dev/null +++ b/tests/e2e/specs/omitted-block-indicator.spec.js @@ -0,0 +1,325 @@ +const { test, expect } = require( '@playwright/test' ); +const { loginAsAdmin } = require( '../utils/auth' ); +const { openPostEditor } = require( '../utils/editor' ); +const { + wpCli, + wpCliSafe, + ensurePluginActive, + ensurePrettyPermalinks, +} = require( '../utils/wp-cli' ); + +/** + * Editor marks for blocks the beehiiv newsletter leaves out. + * + * An omitted block gets a class on its own element (no wrapper element), a + * hidden-eye icon, and a count in the sidebar notice. The marks show only while + * the beehiiv sidebar is open, for a post that is queued to send or linked to a + * newsletter that has not been sent yet. + */ + +const META = { + send: '_beehiiv_send_to_newsletter', + postId: '_beehiiv_post_id', +}; + +const PUBLICATION_ID = 'pub_qa_e2e_omitted'; +const TEMPLATE_ID = 'tpl_qa_e2e_omitted'; + +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 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 }
`; + +// 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 () => { + 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.' ); + // 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 ); + + // 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.' ); + // 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 + ); + + 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 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, + } ) => { + 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 ); + } ); +} ); 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..faf68de --- /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 = '' ) => + `
NameQty
Apple3
Total3
`; + + 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 is omitted', async ( { + page, + } ) => { + const four = await convert( + page, + 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( [ + 'Before', + 'After', + ] ); + + // 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' ] ); + } ); +} ); diff --git a/tests/phpunit/BlockConverterColumnsTest.php b/tests/phpunit/BlockConverterColumnsTest.php new file mode 100644 index 0000000..011c956 --- /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 are omitted entirely, with no columns block and no top-level blocks. + */ + public function test_more_than_three_columns_are_omitted(): void { + $blocks = $this->convert( + $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( 2, $blocks ); + $this->assertSame( 'Before', $blocks[0]['plaintext'] ); + $this->assertSame( 'After', $blocks[1]['plaintext'] ); + } + + /** + * 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'] ); + } +} 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'] ); + } +} 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'] ); + } +} 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 ); + } +}