Commit 5e7d832356 for woocommerce

commit 5e7d832356371a7620543bbd299b7556c501c087
Author: Amit Raj <77401999+amitraj2203@users.noreply.github.com>
Date:   Mon Jan 19 20:44:06 2026 +0530

    Add `Products by Brand` Collection to Product Collection Block (#62817)

    * Add BY_BRAND option to CoreCollectionNames enum for product collections

    * Add new BY_BRAND collection block for displaying products by brand

    * Add BY_BRAND collection to product collections

    * Update CollectionSpecificControls to include BY_BRAND in visibility checks

    * Refactor useTaxonomyControls to include BY_BRAND filtering logic

    * Add handler for BY_BRAND product collection to return empty result if no brand is selected

    * Add changelog file

    * Add unit test for filtering products by brand in QueryBuilder

    * Update const name

    * Fix lint error

    ---------

    Co-authored-by: Karol Manijak <20098064+kmanijak@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/62368-add-products-by-brand-collection b/plugins/woocommerce/changelog/62368-add-products-by-brand-collection
new file mode 100644
index 0000000000..45fedc71ff
--- /dev/null
+++ b/plugins/woocommerce/changelog/62368-add-products-by-brand-collection
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add "Products by Brand" collection to Product Collection block.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-brand.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-brand.tsx
new file mode 100644
index 0000000000..2acabdfd5d
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-brand.tsx
@@ -0,0 +1,57 @@
+/**
+ * External dependencies
+ */
+import type {
+	InnerBlockTemplate,
+	BlockVariationScope,
+} from '@wordpress/blocks';
+import { __ } from '@wordpress/i18n';
+import { Icon, store } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import {
+	INNER_BLOCKS_PRODUCT_TEMPLATE,
+	INNER_BLOCKS_PAGINATION_TEMPLATE,
+} from '../constants';
+import { CoreCollectionNames, CoreFilterNames } from '../types';
+
+const collection = {
+	name: CoreCollectionNames.BY_BRAND,
+	title: __( 'Products by Brand', 'woocommerce' ),
+	icon: <Icon icon={ store } />,
+	description: __( 'Display products from specific brands.', 'woocommerce' ),
+	scope: [ 'inserter', 'block' ] as BlockVariationScope[],
+};
+
+const attributes = {
+	displayLayout: {
+		type: 'flex',
+		columns: 5,
+		shrinkColumns: true,
+	},
+	hideControls: [ CoreFilterNames.HAND_PICKED, CoreFilterNames.FILTERABLE ],
+};
+
+const heading: InnerBlockTemplate = [
+	'core/heading',
+	{
+		textAlign: 'center',
+		level: 2,
+		content: __( 'Products by Brand', 'woocommerce' ),
+		style: { spacing: { margin: { bottom: '1rem' } } },
+	},
+];
+
+const innerBlocks: InnerBlockTemplate[] = [
+	heading,
+	INNER_BLOCKS_PRODUCT_TEMPLATE,
+	INNER_BLOCKS_PAGINATION_TEMPLATE,
+];
+
+export default {
+	...collection,
+	attributes,
+	innerBlocks,
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/index.tsx
index 12532f81b1..d92ccaebdb 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/index.tsx
@@ -26,6 +26,7 @@ import topRated from './top-rated';
 import upsells from './upsells';
 import byCategory from './by-category';
 import byTag from './by-tag';
+import byBrand from './by-brand';
 import cartContents from './cart-contents';

 // Order in here is reflected in the Collection Chooser in Editor.
@@ -39,6 +40,7 @@ const collections: BlockVariation[] = [
 	handPicked,
 	byCategory,
 	byTag,
+	byBrand,
 	related,
 	upsells,
 	crossSells,
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 5fb53c88d1..34ab885334 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
@@ -287,9 +287,10 @@ const CollectionSpecificControls = (
 		query: props.attributes.query,
 	};

-	const isByCategoryOrTag =
+	const isByTaxonomy =
 		collection === CoreCollectionNames.BY_CATEGORY ||
-		collection === CoreCollectionNames.BY_TAG;
+		collection === CoreCollectionNames.BY_TAG ||
+		collection === CoreCollectionNames.BY_BRAND;

 	return (
 		<InspectorControls>
@@ -315,9 +316,9 @@ const CollectionSpecificControls = (
 			}
 			{
 				/**
-				 * "Category and Tag" collection-specific controls.
+				 * "By Taxonomy" collection-specific controls.
 				 */
-				isByCategoryOrTag && (
+				isByTaxonomy && (
 					<PanelBody>
 						<TaxonomyControls
 							{ ...queryControlProps }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/taxonomy-controls/use-taxonomy-controls.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/taxonomy-controls/use-taxonomy-controls.tsx
index de450d3efb..f5454f61ab 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/taxonomy-controls/use-taxonomy-controls.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/taxonomy-controls/use-taxonomy-controls.tsx
@@ -51,6 +51,15 @@ function useTaxonomyControls( {
 					: taxonomy.slug === 'product_tag'
 			);
 		}
+		if ( collection === CoreCollectionNames.BY_BRAND ) {
+			return taxonomies.filter( ( taxonomy ) =>
+				// If it's in filter panel, we want to show everything BUT the brand control.
+				// Otherwise, it's a collection specific filter and we want to show ONLY the brand control.
+				isFiltersPanel
+					? taxonomy.slug !== 'product_brand'
+					: taxonomy.slug === 'product_brand'
+			);
+		}

 		return isFiltersPanel ? taxonomies : [];
 	}, [ taxonomies, collection, isFiltersPanel ] );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
index 0fbdf87f90..655f5e116d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
@@ -190,6 +190,7 @@ export enum CoreCollectionNames {
 	CROSS_SELLS = 'woocommerce/product-collection/cross-sells',
 	BY_CATEGORY = 'woocommerce/product-collection/by-category',
 	BY_TAG = 'woocommerce/product-collection/by-tag',
+	BY_BRAND = 'woocommerce/product-collection/by-brand',
 	CART_CONTENTS = 'woocommerce/product-collection/cart-contents',
 }

diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
index 92dc339aec..d145e60c7f 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
@@ -86,6 +86,18 @@ class HandlerRegistry {
 			}
 		);

+		$this->register_collection_handlers(
+			'woocommerce/product-collection/by-brand',
+			function ( $collection_args, $common_query_values, $query ) {
+				// For Products by Brand collection, if no brand is selected, we should return an empty result set.
+				if ( empty( $query['taxonomies_query'] ) ) {
+					return array(
+						'post__in' => array( -1 ),
+					);
+				}
+			}
+		);
+
 		$this->register_collection_handlers(
 			'woocommerce/product-collection/related',
 			function ( $collection_args ) {
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index 326dd3b5bc..568470b118 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -919,4 +919,144 @@ class QueryBuilder extends \WP_UnitTestCase {
 		wp_delete_term( $featured_tag_id, 'product_tag' );
 		wp_delete_term( $sale_tag_id, 'product_tag' );
 	}
+
+	/**
+	 * Tests that the by-brand collection handler works as expected.
+	 */
+	public function test_collection_by_brand() {
+		// Create test brands.
+		$nike_brand    = wp_create_term( 'Nike', 'product_brand' );
+		$nike_brand_id = $nike_brand['term_id'];
+
+		$adidas_brand    = wp_create_term( 'Adidas', 'product_brand' );
+		$adidas_brand_id = $adidas_brand['term_id'];
+
+		// Create test products.
+		$nike_shoes = WC_Helper_Product::create_simple_product();
+		$nike_shoes->set_name( 'Nike Shoes' );
+		$nike_shoes->save();
+
+		$nike_shirt = WC_Helper_Product::create_simple_product();
+		$nike_shirt->set_name( 'Nike Shirt' );
+		$nike_shirt->save();
+
+		$adidas_shoes = WC_Helper_Product::create_simple_product();
+		$adidas_shoes->set_name( 'Adidas Shoes' );
+		$adidas_shoes->save();
+
+		$unbranded_product = WC_Helper_Product::create_simple_product();
+		$unbranded_product->set_name( 'Unbranded Product' );
+		$unbranded_product->save();
+
+		// Assign products to brands.
+		wp_set_object_terms( $nike_shoes->get_id(), $nike_brand_id, 'product_brand' );
+		wp_set_object_terms( $nike_shirt->get_id(), $nike_brand_id, 'product_brand' );
+		wp_set_object_terms( $adidas_shoes->get_id(), $adidas_brand_id, 'product_brand' );
+		// unbranded_product has no brand.
+
+		// Test filtering by Nike brand - Frontend.
+		$merged_query = Utils::initialize_merged_query(
+			$this->block_instance,
+			null,
+			array(
+				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+				'tax_query' => array(
+					array(
+						'taxonomy'         => 'product_brand',
+						'terms'            => array( $nike_brand_id ),
+						'include_children' => false,
+					),
+				),
+			)
+		);
+
+		$query             = new WP_Query( $merged_query );
+		$found_product_ids = wp_list_pluck( $query->posts, 'ID' );
+
+		// Should return Nike shoes and Nike shirt.
+		$this->assertContains( $nike_shoes->get_id(), $found_product_ids );
+		$this->assertContains( $nike_shirt->get_id(), $found_product_ids );
+		$this->assertNotContains( $adidas_shoes->get_id(), $found_product_ids );
+		$this->assertNotContains( $unbranded_product->get_id(), $found_product_ids );
+
+		// Test filtering by Nike brand - Editor.
+		$args    = array(
+			'posts_per_page' => 10,
+			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+			'tax_query'      => array(
+				array(
+					'taxonomy'         => 'product_brand',
+					'terms'            => array( $nike_brand_id ),
+					'include_children' => false,
+				),
+			),
+		);
+		$request = Utils::build_request();
+
+		$updated_query    = $this->block_instance->update_rest_query_in_editor( $args, $request );
+		$editor_query     = new WP_Query( $updated_query );
+		$editor_found_ids = wp_list_pluck( $editor_query->posts, 'ID' );
+
+		// Should return Nike shoes and Nike shirt in editor as well.
+		$this->assertContains( $nike_shoes->get_id(), $editor_found_ids );
+		$this->assertContains( $nike_shirt->get_id(), $editor_found_ids );
+		$this->assertNotContains( $adidas_shoes->get_id(), $editor_found_ids );
+		$this->assertNotContains( $unbranded_product->get_id(), $editor_found_ids );
+
+		// Test filtering by Adidas brand - Frontend.
+		$merged_query_adidas = Utils::initialize_merged_query(
+			$this->block_instance,
+			null,
+			array(
+				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+				'tax_query' => array(
+					array(
+						'taxonomy'         => 'product_brand',
+						'terms'            => array( $adidas_brand_id ),
+						'include_children' => false,
+					),
+				),
+			)
+		);
+
+		$query_adidas     = new WP_Query( $merged_query_adidas );
+		$found_adidas_ids = wp_list_pluck( $query_adidas->posts, 'ID' );
+
+		// Should return only Adidas shoes.
+		$this->assertNotContains( $nike_shoes->get_id(), $found_adidas_ids );
+		$this->assertNotContains( $nike_shirt->get_id(), $found_adidas_ids );
+		$this->assertContains( $adidas_shoes->get_id(), $found_adidas_ids );
+		$this->assertNotContains( $unbranded_product->get_id(), $found_adidas_ids );
+
+		// Test filtering by Adidas brand - Editor.
+		$args_adidas    = array(
+			'posts_per_page' => 10,
+			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+			'tax_query'      => array(
+				array(
+					'taxonomy'         => 'product_brand',
+					'terms'            => array( $adidas_brand_id ),
+					'include_children' => false,
+				),
+			),
+		);
+		$request_adidas = Utils::build_request();
+
+		$updated_query_adidas = $this->block_instance->update_rest_query_in_editor( $args_adidas, $request_adidas );
+		$editor_query_adidas  = new WP_Query( $updated_query_adidas );
+		$editor_adidas_ids    = wp_list_pluck( $editor_query_adidas->posts, 'ID' );
+
+		// Should return only Adidas shoes in editor as well.
+		$this->assertNotContains( $nike_shoes->get_id(), $editor_adidas_ids );
+		$this->assertNotContains( $nike_shirt->get_id(), $editor_adidas_ids );
+		$this->assertContains( $adidas_shoes->get_id(), $editor_adidas_ids );
+		$this->assertNotContains( $unbranded_product->get_id(), $editor_adidas_ids );
+
+		$nike_shoes->delete();
+		$nike_shirt->delete();
+		$adidas_shoes->delete();
+		$unbranded_product->delete();
+		wp_delete_term( $nike_brand_id, 'product_brand' );
+		wp_delete_term( $adidas_brand_id, 'product_brand' );
+	}
 }