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,
},
} );
}