Commit 4ed87b5508 for woocommerce

commit 4ed87b55080b9123bdb6a617c0e376d64269be3e
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date:   Fri Feb 6 10:17:18 2026 -0500

    Improve Product Collection block in email editor (#63122)

    * Improve Product Collection block in email editor

    - Rename "Products per page" to "Number of products" in email editor context
    - Add support for two-column layout in email rendering
    - Hide product images when no image is set (instead of showing broken placeholder)
    - Limit columns to max 2 for email compatibility

    Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

    * Address code review feedback for Product Collection email improvements

    - Consolidate redundant ColumnsControl conditionals in inspector controls
    - Calculate cell width dynamically from layout width instead of hardcoding 290px
    - Revert placeholder image change to keep PR focused on two-column layout

    Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

    * Fix spacing and simplify two-column grid in Product Collection email renderer

    - Add spacer wrapper to two-column layout to match single-column spacing behavior
    - Remove redundant MSO conditional comments (inner table already has all needed attributes)
    - Guard against zero/small layout width to ensure cell width is always positive

    Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

    * Fix edge case in layout width guard for two-column grid

    Change condition from $layout_width <= $gap to $layout_width < $gap + 2
    to catch the case where $layout_width === $gap + 1 would yield $cell_width === 0.

    Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

diff --git a/packages/php/email-editor/changelog/update-product-collection-two-columns b/packages/php/email-editor/changelog/update-product-collection-two-columns
new file mode 100644
index 0000000000..b1de0bc33d
--- /dev/null
+++ b/packages/php/email-editor/changelog/update-product-collection-two-columns
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Add two-column grid layout support for Product Collection block in email rendering.
diff --git a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-collection.php b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-collection.php
index 866fc30d78..838a057073 100644
--- a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-collection.php
+++ b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-collection.php
@@ -30,12 +30,15 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 		// Get collection type to pass to child blocks.
 		$collection_type = $parsed_block['attrs']['collection'] ?? '';

+		// Get column count from display layout attributes.
+		$columns = (int) ( $parsed_block['attrs']['displayLayout']['columns'] ?? 1 );
+
 		$content = '';

 		foreach ( $parsed_block['innerBlocks'] as $inner_block ) {
 			switch ( $inner_block['blockName'] ) {
 				case 'woocommerce/product-template':
-					$content .= $this->render_product_template( $inner_block, $query, $collection_type );
+					$content .= $this->render_product_template( $inner_block, $query, $collection_type, $columns, $rendering_context );
 					break;
 				default:
 					$content .= render_block( $inner_block );
@@ -51,12 +54,14 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 	/**
 	 * Render the product template block.
 	 *
-	 * @param array     $inner_block Inner block data.
-	 * @param \WP_Query $query WP_Query object.
-	 * @param string    $collection_type Collection type identifier.
+	 * @param array             $inner_block Inner block data.
+	 * @param \WP_Query         $query WP_Query object.
+	 * @param string            $collection_type Collection type identifier.
+	 * @param int               $columns Number of columns for the grid layout.
+	 * @param Rendering_Context $rendering_context Rendering context.
 	 * @return string
 	 */
-	private function render_product_template( array $inner_block, \WP_Query $query, string $collection_type ): string {
+	private function render_product_template( array $inner_block, \WP_Query $query, string $collection_type, int $columns, Rendering_Context $rendering_context ): string {
 		if ( ! $query->have_posts() ) {
 			return $this->render_no_results_message();
 		}
@@ -76,27 +81,101 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 				$posts
 			)
 		);
-		return $this->render_product_grid( $products, $inner_block, $collection_type );
+		return $this->render_product_grid( $products, $inner_block, $collection_type, $columns, $rendering_context );
 	}

 	/**
 	 * Render product grid using HTML table structure for email compatibility.
 	 *
-	 * @param array  $products Array of WC_Product objects.
-	 * @param array  $inner_block Inner block data.
-	 * @param string $collection_type Collection type identifier.
+	 * @param array             $products Array of WC_Product objects.
+	 * @param array             $inner_block Inner block data.
+	 * @param string            $collection_type Collection type identifier.
+	 * @param int               $columns Number of columns for the grid layout.
+	 * @param Rendering_Context $rendering_context Rendering context.
 	 * @return string
 	 */
-	private function render_product_grid( array $products, array $inner_block, string $collection_type ): string {
-		// We start with supporting 1 product per row.
+	private function render_product_grid( array $products, array $inner_block, string $collection_type, int $columns, Rendering_Context $rendering_context ): string {
+		// Limit columns to max 2 for email compatibility.
+		$columns = min( max( $columns, 1 ), 2 );
+
+		if ( 1 === $columns ) {
+			// Single column layout - render products vertically.
+			$content = '';
+			foreach ( $products as $product ) {
+				$content .= $this->add_spacer(
+					$this->render_product_content( $product, $inner_block, $collection_type ),
+					$inner_block['email_attrs'] ?? array()
+				);
+			}
+			return $content;
+		}
+
+		// Two-column layout using HTML tables for email compatibility.
+		// Wrap with add_spacer to match single-column spacing behavior.
+		return $this->add_spacer(
+			$this->render_two_column_grid( $products, $inner_block, $collection_type, $rendering_context ),
+			$inner_block['email_attrs'] ?? array()
+		);
+	}
+
+	/**
+	 * Render products in a two-column grid layout using HTML tables.
+	 *
+	 * @param array             $products Array of WC_Product objects.
+	 * @param array             $inner_block Inner block data.
+	 * @param string            $collection_type Collection type identifier.
+	 * @param Rendering_Context $rendering_context Rendering context.
+	 * @return string
+	 */
+	private function render_two_column_grid( array $products, array $inner_block, string $collection_type, Rendering_Context $rendering_context ): string {
 		$content = '';
-		foreach ( $products as $product ) {
-			$content .= $this->add_spacer(
-				$this->render_product_content( $product, $inner_block, $collection_type ),
-				$inner_block['email_attrs'] ?? array()
-			);
+
+		// Calculate the cell width from the actual layout width.
+		// Subtract 20px total gap (10px padding on each side of the gap between columns),
+		// then divide by 2 for two columns.
+		$layout_width = (int) $rendering_context->get_layout_width_without_padding();
+		$gap          = 20;
+
+		// Guard against zero or very small layout width to ensure $cell_width is always positive.
+		if ( $layout_width < $gap + 2 ) {
+			$layout_width = $gap + 2;
+		}
+
+		$cell_width = (int) ( ( $layout_width - $gap ) / 2 );
+
+		$content .= '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="width: 100%; border-collapse: collapse;">';
+
+		$product_chunks = array_chunk( $products, 2 );
+
+		foreach ( $product_chunks as $row_index => $row_products ) {
+			$content .= '<tr>';
+
+			foreach ( $row_products as $col_index => $product ) {
+				$cell_style  = 'width: 50%; vertical-align: top; padding: 0;';
+				$cell_style .= 0 === $col_index ? ' padding-right: 10px;' : ' padding-left: 10px;';
+
+				$content .= sprintf(
+					'<td style="%s">%s</td>',
+					esc_attr( $cell_style ),
+					$this->render_product_content( $product, $inner_block, $collection_type, $cell_width )
+				);
+			}
+
+			// If odd number of products, add empty cell to complete the row.
+			if ( 1 === count( $row_products ) ) {
+				$content .= '<td style="width: 50%; vertical-align: top; padding: 0; padding-left: 10px;"></td>';
+			}
+
+			$content .= '</tr>';
+
+			// Add spacing between rows (except after the last row).
+			if ( $row_index < count( $product_chunks ) - 1 ) {
+				$content .= '<tr><td colspan="2" style="height: 20px;"></td></tr>';
+			}
 		}

+		$content .= '</table>';
+
 		return $content;
 	}

@@ -106,9 +185,10 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 	 * @param \WC_Product|null $product Product object.
 	 * @param array            $template_block Inner block data.
 	 * @param string           $collection_type Collection type identifier.
+	 * @param int|null         $cell_width Optional cell width for multi-column layouts.
 	 * @return string
 	 */
-	private function render_product_content( ?\WC_Product $product, array $template_block, string $collection_type ): string {
+	private function render_product_content( ?\WC_Product $product, array $template_block, string $collection_type, ?int $cell_width = null ): string {
 		$content = '';

 		if ( ! $product ) {
@@ -116,6 +196,12 @@ class Product_Collection extends Abstract_Product_Block_Renderer {
 		}

 		foreach ( $template_block['innerBlocks'] as $inner_block ) {
+			// Set cell width context for multi-column layouts.
+			if ( null !== $cell_width ) {
+				$inner_block['email_attrs']          = $inner_block['email_attrs'] ?? array();
+				$inner_block['email_attrs']['width'] = $cell_width . 'px';
+			}
+
 			switch ( $inner_block['blockName'] ) {
 				case 'woocommerce/product-price':
 				case 'woocommerce/product-button':
diff --git a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-image.php b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-image.php
index 9c8319c30f..ac868e7a34 100644
--- a/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-image.php
+++ b/packages/php/email-editor/src/Integrations/WooCommerce/Renderer/Blocks/class-product-image.php
@@ -271,7 +271,9 @@ class Product_Image extends Abstract_Product_Block_Renderer {
 			return $parsed_block;
 		}

-		$parsed_block['attrs']['width'] = $rendering_context->get_layout_width_without_padding();
+		// Use the email_attrs width if set (e.g., for multi-column layouts),
+		// otherwise fall back to the rendering context layout width.
+		$parsed_block['attrs']['width'] = $parsed_block['email_attrs']['width'];

 		return $parsed_block;
 	}
diff --git a/plugins/woocommerce/changelog/update-product-collection-email-editor b/plugins/woocommerce/changelog/update-product-collection-email-editor
new file mode 100644
index 0000000000..b8c14505a5
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-product-collection-email-editor
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Improve Product Collection block in email editor: rename "Products per page" to "Number of products" and add support for two-column layout.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/columns-control.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/columns-control.tsx
index 896e53efdc..1426aa6691 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/columns-control.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/columns-control.tsx
@@ -23,7 +23,13 @@ const toggleHelp = __(
 	'woocommerce'
 );

-const ColumnsControl = ( props: DisplayLayoutControlProps ) => {
+interface ColumnsControlProps extends DisplayLayoutControlProps {
+	maxColumns?: number;
+	hideResponsiveToggle?: boolean;
+}
+
+const ColumnsControl = ( props: ColumnsControlProps ) => {
+	const { maxColumns, hideResponsiveToggle } = props;
 	const { type, columns, shrinkColumns } = props.displayLayout;
 	const showColumnsControl = type === 'flex';

@@ -44,13 +50,20 @@ const ColumnsControl = ( props: DisplayLayoutControlProps ) => {
 		} );
 	};

-	const onColumnsChange = ( value: number ) =>
+	const onColumnsChange = ( value?: number ) => {
+		if ( value === undefined ) {
+			return;
+		}
 		props.setAttributes( {
 			displayLayout: {
 				...props.displayLayout,
 				columns: value,
 			},
 		} );
+	};
+
+	const defaultMaxColumns = 6;
+	const effectiveMaxColumns = maxColumns ?? defaultMaxColumns;

 	return showColumnsControl ? (
 		<>
@@ -67,25 +80,27 @@ const ColumnsControl = ( props: DisplayLayoutControlProps ) => {
 					onChange={ onColumnsChange }
 					value={ columns }
 					min={ 1 }
-					max={ Math.max( 6, columns ) }
+					max={ Math.max( effectiveMaxColumns, columns ) }
 				/>
 			</ToolsPanelItem>
-			<ToolsPanelItem
-				label={ toggleLabel }
-				hasValue={ () =>
-					defaultLayout?.shrinkColumns !== shrinkColumns
-				}
-				isShownByDefault
-				onDeselect={ onPanelDeselect }
-			>
-				<ToggleControl
-					__nextHasNoMarginBottom
-					checked={ !! shrinkColumns }
+			{ ! hideResponsiveToggle && (
+				<ToolsPanelItem
 					label={ toggleLabel }
-					help={ toggleHelp }
-					onChange={ onShrinkColumnsToggleChange }
-				/>
-			</ToolsPanelItem>
+					hasValue={ () =>
+						defaultLayout?.shrinkColumns !== shrinkColumns
+					}
+					isShownByDefault
+					onDeselect={ onPanelDeselect }
+				>
+					<ToggleControl
+						__nextHasNoMarginBottom
+						checked={ !! shrinkColumns }
+						label={ toggleLabel }
+						help={ toggleHelp }
+						onChange={ onShrinkColumnsToggleChange }
+					/>
+				</ToolsPanelItem>
+			) }
 		</>
 	) : null;
 };
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx
index f2c8e8f4b3..ec8625823f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx
@@ -227,8 +227,14 @@ const ProductCollectionInspectorControls = (
 						carouselVariant={ isCarouselLayout }
 					/>
 				) }
-				{ ! isEmailEditor && showColumnsControl && (
-					<ColumnsControl { ...displayControlProps } />
+				{ showColumnsControl && (
+					<ColumnsControl
+						{ ...displayControlProps }
+						{ ...( isEmailEditor && {
+							maxColumns: 2,
+							hideResponsiveToggle: true,
+						} ) }
+					/>
 				) }
 				{ ! isEmailEditor && showOffsetControl && (
 					<OffsetControl { ...queryControlProps } />
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/products-per-page-control.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/products-per-page-control.tsx
index c279e01b1f..5dd9348df5 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/products-per-page-control.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/products-per-page-control.tsx
@@ -1,6 +1,7 @@
 /**
  * External dependencies
  */
+import { useIsEmailEditor } from '@woocommerce/email-editor';
 import { __ } from '@wordpress/i18n';
 import {
 	RangeControl,
@@ -21,8 +22,12 @@ const CAROUSEL_PERFORMANCE_WARNING_THRESHOLD = 30;

 const defaultLabel = __( 'Products per page', 'woocommerce' );
 const carouselLabel = __( 'Products in carousel', 'woocommerce' );
+const emailLabel = __( 'Number of products', 'woocommerce' );

-const getLabel = ( carouselVariant: boolean ) => {
+const getLabel = ( carouselVariant: boolean, isEmailEditor: boolean ) => {
+	if ( isEmailEditor ) {
+		return emailLabel;
+	}
 	return carouselVariant ? carouselLabel : defaultLabel;
 };

@@ -32,12 +37,13 @@ const ProductsPerPageControl = ( {
 	trackInteraction,
 	carouselVariant,
 }: QueryControlProps & { carouselVariant: boolean } ) => {
+	const isEmailEditor = useIsEmailEditor();
 	const deselectCallback = () => {
 		setQueryAttribute( { perPage: DEFAULT_QUERY.perPage } );
 		trackInteraction( CoreFilterNames.PRODUCTS_PER_PAGE );
 	};

-	const label = getLabel( carouselVariant );
+	const label = getLabel( carouselVariant, isEmailEditor );
 	const perPage = query.perPage || DEFAULT_QUERY.perPage;
 	const showPerformanceWarning =
 		carouselVariant && perPage > CAROUSEL_PERFORMANCE_WARNING_THRESHOLD;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/use-email-column-adjustments.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/use-email-column-adjustments.ts
index b042918a0a..9f26638178 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/use-email-column-adjustments.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/use-email-column-adjustments.ts
@@ -9,8 +9,11 @@ import { useEffect } from '@wordpress/element';
  */
 import { type ProductCollectionAttributes } from '../../types';

+const MAX_EMAIL_COLUMNS = 2;
+
 /**
- * Custom hook to adjust columns to 1 when in email editor.
+ * Custom hook to adjust columns when in email editor.
+ * Limits columns to a maximum of 2 for email compatibility.
  *
  * @param {ProductCollectionAttributes} attributes    - The attributes of the product collection block.
  * @param {Function}                    setAttributes - Function to set block attributes.
@@ -29,12 +32,15 @@ const useEmailColumnAdjustments = (
 			return;
 		}

-		// Only adjust columns if currently more than 1 and not already 1
-		if ( displayLayout.columns && displayLayout.columns > 1 ) {
+		// Only adjust columns if currently more than the max allowed for email
+		if (
+			displayLayout.columns &&
+			displayLayout.columns > MAX_EMAIL_COLUMNS
+		) {
 			setAttributes( {
 				displayLayout: {
 					...displayLayout,
-					columns: 1,
+					columns: MAX_EMAIL_COLUMNS,
 				},
 			} );
 		}