Commit 302b7c16241 for woocommerce
commit 302b7c16241d4d94c1e6e25f9a37e2044ba69ecc
Author: Bhavik Tank <53536925+bhavz-10@users.noreply.github.com>
Date: Tue Oct 27 09:31:57 2026 +0200
Refactor/63049 unify product taxonomy controls (#67976)
* Refactor product taxonomy controls to use shared component
Introduce ProductTaxonomyControl as the shared rendering layer for
category, tag, brand, and attribute selectors.
Centralize count labels, accessible names, operator configuration, and
common list behavior while retaining taxonomy-specific adapters,
attribute hierarchy rendering, raw selected-ID semantics, and legacy
CSS hooks.
Display product counts for tag rows and include those counts in
checkbox accessible names. Add DOM, adapter, error, and integration
coverage for the shared behavior.
* Add changelog entry for unified product taxonomy controls
Document the shared editor controls, visible tag product counts, and
count-aware tag accessible names.
* Fix encoded taxonomy names in accessible labels
* Refactor taxonomy adapter tests to assert rendered behavior
Replace shared-control prop mocks with rendered adapter coverage for counts, selections, errors, tag search and attribute ID collisions.
Amp-Thread-ID: https://ampcode.com/threads/T-01a175c9-c8e8-706d-ae1a-caa205c5e83d
Co-authored-by: Amp <amp@ampcode.com>
---------
Co-authored-by: Tung Du <dinhtungdu@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
diff --git a/plugins/woocommerce/changelog/tweak-unify-product-taxonomy-controls b/plugins/woocommerce/changelog/tweak-unify-product-taxonomy-controls
new file mode 100644
index 00000000000..8f917bd795b
--- /dev/null
+++ b/plugins/woocommerce/changelog/tweak-unify-product-taxonomy-controls
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Unify product taxonomy editor controls, display product counts for tags, and include counts in tag accessible names.
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-attribute-term-control/index.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-attribute-term-control/index.tsx
index 09bd2f46e5c..b487b479a14 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-attribute-term-control/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-attribute-term-control/index.tsx
@@ -3,15 +3,11 @@
*/
import clsx from 'clsx';
import { __, _n, sprintf } from '@wordpress/i18n';
-import {
- SearchListControl,
- SearchListItem,
-} from '@woocommerce/editor-components/search-list-control';
-import { SelectControl } from '@wordpress/components';
+import { SearchListItem } from '@woocommerce/editor-components/search-list-control';
import { withInstanceId } from '@wordpress/compose';
import useProductAttributes from '@woocommerce/base-context/hooks/use-product-attributes';
-import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message';
import ExpandableSearchListItem from '@woocommerce/editor-components/expandable-search-list-item/expandable-search-list-item';
+import ProductTaxonomyControl from '@woocommerce/editor-components/product-taxonomy-control';
import {
RenderItemArgs,
SearchListControlProps,
@@ -181,65 +177,52 @@ const ProductAttributeTermControl = ( {
...messages,
};
- if ( errorLoadingAttributes ) {
- return <ErrorMessage error={ errorLoadingAttributes } />;
- }
-
return (
- <>
- <SearchListControl
- className="woocommerce-product-attributes"
- isCompact={ isCompact }
- isHierarchical
- isLoading={ isLoadingAttributes }
- isSingle={ false }
- list={ list }
- messages={ messages }
- onChange={ onChange }
- renderItem={ renderItem }
- selected={
- selected
- .map( ( { id } ) =>
- list.find( ( term ) => term.id === id )
- )
- .filter( Boolean ) as SearchListItemProps[]
- }
- type={ type }
- />
- { !! onOperatorChange && (
- <div hidden={ selected.length < 2 }>
- <SelectControl
- className="woocommerce-product-attributes__operator"
- label={ __(
- 'Display products matching',
- 'woocommerce'
- ) }
- help={ __(
- 'Pick at least two attributes to use this setting.',
- 'woocommerce'
- ) }
- value={ operator }
- onChange={ onOperatorChange }
- options={ [
- {
- label: __(
+ <ProductTaxonomyControl
+ className="woocommerce-product-attributes"
+ error={ errorLoadingAttributes }
+ isCompact={ isCompact }
+ isHierarchical
+ isLoading={ isLoadingAttributes }
+ isSingle={ false }
+ list={ list }
+ messages={ messages }
+ onChange={ onChange }
+ operator={
+ onOperatorChange
+ ? {
+ className:
+ 'woocommerce-product-attributes__operator',
+ labels: {
+ all: __(
+ 'All selected attributes',
+ 'woocommerce'
+ ),
+ any: __(
'Any selected attributes',
'woocommerce'
),
- value: 'any',
- },
- {
- label: __(
- 'All selected attributes',
+ help: __(
+ 'Pick at least two attributes to use this setting.',
'woocommerce'
),
- value: 'all',
},
- ] }
- />
- </div>
- ) }
- </>
+ onChange: onOperatorChange,
+ selectedCount: selected.length,
+ value: operator,
+ }
+ : undefined
+ }
+ renderItem={ renderItem }
+ selected={
+ selected
+ .map( ( { id } ) =>
+ list.find( ( term ) => term.id === id )
+ )
+ .filter( Boolean ) as SearchListItemProps[]
+ }
+ type={ type }
+ />
);
};
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-attribute-term-control/style.scss b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-attribute-term-control/style.scss
index aa3d6878de0..75292cf6e56 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-attribute-term-control/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-attribute-term-control/style.scss
@@ -1,14 +1,3 @@
-.woocommerce-product-attributes__operator {
- .components-base-control__help {
- @include visually-hidden;
- }
-
- .components-base-control__label {
- margin-bottom: 0;
- margin-right: 0.5em;
- }
-}
-
.woocommerce-search-list__item.woocommerce-product-attributes__item {
&.is-searching,
&.is-skip-level {
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-brand-control/index.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-brand-control/index.tsx
index be705fae752..cced1523376 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-brand-control/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-brand-control/index.tsx
@@ -2,18 +2,8 @@
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
-import {
- SearchListControl,
- SearchListItem,
-} from '@woocommerce/editor-components/search-list-control';
-import { SelectControl } from '@wordpress/components';
import { withSearchedBrands } from '@woocommerce/block-hocs';
-import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message';
-import clsx from 'clsx';
-import type {
- RenderItemArgs,
- SearchListItem as SearchListItemProps,
-} from '@woocommerce/editor-components/search-list-control/types';
+import ProductTaxonomyControl from '@woocommerce/editor-components/product-taxonomy-control';
import type {
ProductBrandResponseItem,
WithInjectedSearchedBrands,
@@ -23,7 +13,7 @@ import { convertProductBrandResponseItemToSearchItem } from '@woocommerce/utils'
/**
* Internal dependencies
*/
-import './style.scss';
+import type { SearchListItem as SearchListItemProps } from '../search-list-control/types';
interface ProductBrandControlProps {
/**
@@ -64,76 +54,6 @@ const ProductBrandControl = ( {
isSingle = false,
showReviewCount,
}: ProductBrandControlProps & WithInjectedSearchedBrands ) => {
- const renderItem = ( args: RenderItemArgs< ProductBrandResponseItem > ) => {
- const { item, search, depth = 0 } = args;
-
- const accessibleName = ! item.breadcrumbs.length
- ? item.name
- : `${ item.breadcrumbs.join( ', ' ) }, ${ item.name }`;
-
- const listItemAriaLabel = showReviewCount
- ? sprintf(
- /* translators: %1$s is the item name, %2$d is the count of reviews for the item. */
- _n(
- '%1$s, has %2$d review',
- '%1$s, has %2$d reviews',
- item.details?.review_count || 0,
- 'woocommerce'
- ),
- accessibleName,
- item.details?.review_count || 0
- )
- : sprintf(
- /* translators: %1$s is the item name, %2$d is the count of products for the item. */
- _n(
- '%1$s, has %2$d product',
- '%1$s, has %2$d products',
- item.details?.count || 0,
- 'woocommerce'
- ),
- accessibleName,
- item.details?.count || 0
- );
-
- const listItemCountLabel = showReviewCount
- ? sprintf(
- /* translators: %d is the count of reviews. */
- _n(
- '%d review',
- '%d reviews',
- item.details?.review_count || 0,
- 'woocommerce'
- ),
- item.details?.review_count || 0
- )
- : sprintf(
- /* translators: %d is the count of products. */
- _n(
- '%d product',
- '%d products',
- item.details?.count || 0,
- 'woocommerce'
- ),
- item.details?.count || 0
- );
-
- return (
- <SearchListItem
- className={ clsx(
- 'woocommerce-product-brands__item',
- 'has-count',
- {
- 'is-searching': search.length > 0,
- 'is-skip-level': depth === 0 && item.parent !== 0,
- }
- ) }
- { ...args }
- countLabel={ listItemCountLabel }
- aria-label={ listItemAriaLabel }
- />
- );
- };
-
const messages = {
clear: __( 'Clear all product brands', 'woocommerce' ),
list: __( 'Product Brands', 'woocommerce' ),
@@ -155,65 +75,43 @@ const ProductBrandControl = ( {
),
updated: __( 'Brand search results updated.', 'woocommerce' ),
};
-
- if ( error ) {
- return <ErrorMessage error={ error } />;
- }
-
- const currentList = brands.map(
- convertProductBrandResponseItemToSearchItem
- );
+ const list = brands.map( convertProductBrandResponseItemToSearchItem );
return (
- <>
- <SearchListControl
- className="woocommerce-product-brands"
- list={ currentList }
- isLoading={ isLoading }
- selected={ currentList.filter( ( { id } ) =>
- selected.includes( Number( id ) )
- ) }
- onChange={ onChange }
- renderItem={ renderItem }
- messages={ messages }
- isCompact={ isCompact }
- isHierarchical
- isSingle={ isSingle }
- />
- { !! onOperatorChange && (
- <div hidden={ selected.length < 2 }>
- <SelectControl
- className="woocommerce-product-brands__operator"
- label={ __(
- 'Display products matching',
- 'woocommerce'
- ) }
- help={ __(
- 'Pick at least two brands to use this setting.',
- 'woocommerce'
- ) }
- value={ operator }
- onChange={ onOperatorChange }
- options={ [
- {
- label: __(
- 'Any selected brands',
- 'woocommerce'
- ),
- value: 'any',
- },
- {
- label: __(
- 'All selected brands',
+ <ProductTaxonomyControl< ProductBrandResponseItem >
+ className="woocommerce-product-brands"
+ countType={ showReviewCount ? 'review' : 'product' }
+ error={ error }
+ isCompact={ isCompact }
+ isHierarchical
+ isLoading={ isLoading }
+ isSingle={ isSingle }
+ itemClassName="woocommerce-product-brands__item"
+ list={ list }
+ messages={ messages }
+ onChange={ onChange }
+ operator={
+ onOperatorChange
+ ? {
+ className: 'woocommerce-product-brands__operator',
+ labels: {
+ all: __( 'All selected brands', 'woocommerce' ),
+ any: __( 'Any selected brands', 'woocommerce' ),
+ help: __(
+ 'Pick at least two brands to use this setting.',
'woocommerce'
),
- value: 'all',
},
- ] }
- />
- </div>
+ onChange: onOperatorChange,
+ selectedCount: selected.length,
+ value: operator,
+ }
+ : undefined
+ }
+ selected={ list.filter( ( { id } ) =>
+ selected.includes( Number( id ) )
) }
- </>
+ />
);
};
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-brand-control/style.scss b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-brand-control/style.scss
deleted file mode 100644
index 5800d2762cf..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-brand-control/style.scss
+++ /dev/null
@@ -1,10 +0,0 @@
-.woocommerce-product-brands__operator {
- .components-base-control__help {
- @include visually-hidden;
- }
-
- .components-base-control__label {
- margin-bottom: 0;
- margin-right: 0.5em;
- }
-}
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-category-control/index.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-category-control/index.tsx
index dfc72f592bf..81d1fc34bed 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-category-control/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-category-control/index.tsx
@@ -2,26 +2,14 @@
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
-import {
- SearchListControl,
- SearchListItem,
-} from '@woocommerce/editor-components/search-list-control';
-import { SelectControl } from '@wordpress/components';
import { withSearchedCategories } from '@woocommerce/block-hocs';
-import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message';
-import clsx from 'clsx';
-import type { RenderItemArgs } from '@woocommerce/editor-components/search-list-control/types';
+import ProductTaxonomyControl from '@woocommerce/editor-components/product-taxonomy-control';
import type {
ProductCategoryResponseItem,
WithInjectedSearchedCategories,
} from '@woocommerce/types';
import { convertProductCategoryResponseItemToSearchItem } from '@woocommerce/utils';
-/**
- * Internal dependencies
- */
-import './style.scss';
-
interface ProductCategoryControlProps {
/**
* Callback to update the selected product categories.
@@ -61,78 +49,6 @@ const ProductCategoryControl = ( {
isSingle = false,
showReviewCount,
}: ProductCategoryControlProps & WithInjectedSearchedCategories ) => {
- const renderItem = (
- args: RenderItemArgs< ProductCategoryResponseItem >
- ) => {
- const { item, search, depth = 0 } = args;
-
- const accessibleName = ! item.breadcrumbs.length
- ? item.name
- : `${ item.breadcrumbs.join( ', ' ) }, ${ item.name }`;
-
- const listItemAriaLabel = showReviewCount
- ? sprintf(
- /* translators: %1$s is the item name, %2$d is the count of reviews for the item. */
- _n(
- '%1$s, has %2$d review',
- '%1$s, has %2$d reviews',
- item.details?.review_count || 0,
- 'woocommerce'
- ),
- accessibleName,
- item.details?.review_count || 0
- )
- : sprintf(
- /* translators: %1$s is the item name, %2$d is the count of products for the item. */
- _n(
- '%1$s, has %2$d product',
- '%1$s, has %2$d products',
- item.details?.count || 0,
- 'woocommerce'
- ),
- accessibleName,
- item.details?.count || 0
- );
-
- const listItemCountLabel = showReviewCount
- ? sprintf(
- /* translators: %d is the count of reviews. */
- _n(
- '%d review',
- '%d reviews',
- item.details?.review_count || 0,
- 'woocommerce'
- ),
- item.details?.review_count || 0
- )
- : sprintf(
- /* translators: %d is the count of products. */
- _n(
- '%d product',
- '%d products',
- item.details?.count || 0,
- 'woocommerce'
- ),
- item.details?.count || 0
- );
-
- return (
- <SearchListItem
- className={ clsx(
- 'woocommerce-product-categories__item',
- 'has-count',
- {
- 'is-searching': search.length > 0,
- 'is-skip-level': depth === 0 && item.parent !== 0,
- }
- ) }
- { ...args }
- countLabel={ listItemCountLabel }
- aria-label={ listItemAriaLabel }
- />
- );
- };
-
const messages = {
clear: __( 'Clear all product categories', 'woocommerce' ),
list: __( 'Product Categories', 'woocommerce' ),
@@ -154,65 +70,52 @@ const ProductCategoryControl = ( {
),
updated: __( 'Category search results updated.', 'woocommerce' ),
};
-
- if ( error ) {
- return <ErrorMessage error={ error } />;
- }
-
- const currentList = categories.map(
+ const list = categories.map(
convertProductCategoryResponseItemToSearchItem
);
return (
- <>
- <SearchListControl
- className="woocommerce-product-categories"
- list={ currentList }
- isLoading={ isLoading }
- selected={ currentList.filter( ( { id } ) =>
- selected.includes( Number( id ) )
- ) }
- onChange={ onChange }
- renderItem={ renderItem }
- messages={ messages }
- isCompact={ isCompact }
- isHierarchical
- isSingle={ isSingle }
- />
- { !! onOperatorChange && (
- <div hidden={ selected.length < 2 }>
- <SelectControl
- className="woocommerce-product-categories__operator"
- label={ __(
- 'Display products matching',
- 'woocommerce'
- ) }
- help={ __(
- 'Pick at least two categories to use this setting.',
- 'woocommerce'
- ) }
- value={ operator }
- onChange={ onOperatorChange }
- options={ [
- {
- label: __(
+ <ProductTaxonomyControl< ProductCategoryResponseItem >
+ className="woocommerce-product-categories"
+ countType={ showReviewCount ? 'review' : 'product' }
+ error={ error }
+ isCompact={ isCompact }
+ isHierarchical
+ isLoading={ isLoading }
+ isSingle={ isSingle }
+ itemClassName="woocommerce-product-categories__item"
+ list={ list }
+ messages={ messages }
+ onChange={ onChange }
+ operator={
+ onOperatorChange
+ ? {
+ className:
+ 'woocommerce-product-categories__operator',
+ labels: {
+ all: __(
+ 'All selected categories',
+ 'woocommerce'
+ ),
+ any: __(
'Any selected categories',
'woocommerce'
),
- value: 'any',
- },
- {
- label: __(
- 'All selected categories',
+ help: __(
+ 'Pick at least two categories to use this setting.',
'woocommerce'
),
- value: 'all',
},
- ] }
- />
- </div>
+ onChange: onOperatorChange,
+ selectedCount: selected.length,
+ value: operator,
+ }
+ : undefined
+ }
+ selected={ list.filter( ( { id } ) =>
+ selected.includes( Number( id ) )
) }
- </>
+ />
);
};
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-category-control/style.scss b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-category-control/style.scss
deleted file mode 100644
index e0516b1388e..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-category-control/style.scss
+++ /dev/null
@@ -1,10 +0,0 @@
-.woocommerce-product-categories__operator {
- .components-base-control__help {
- @include visually-hidden;
- }
-
- .components-base-control__label {
- margin-bottom: 0;
- margin-right: 0.5em;
- }
-}
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-tag-control/index.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-tag-control/index.tsx
index 6b99d14538f..21010778ffa 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-tag-control/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-tag-control/index.tsx
@@ -3,8 +3,7 @@
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { useState, useEffect, useCallback, useMemo } from '@wordpress/element';
-import { SearchListControl } from '@woocommerce/editor-components/search-list-control';
-import { SelectControl } from '@wordpress/components';
+import ProductTaxonomyControl from '@woocommerce/editor-components/product-taxonomy-control';
import { getSetting } from '@woocommerce/settings';
import { useDebouncedCallback } from 'use-debounce';
@@ -12,10 +11,8 @@ import { useDebouncedCallback } from 'use-debounce';
* Internal dependencies
*/
import type { SearchListItem as SearchListItemProps } from '../search-list-control/types';
-import ProductTagItem from './product-tag-item';
import type { ProductTagControlProps } from './types';
import { getProductTags } from '../utils';
-import './style.scss';
/**
* Component to handle searching and selecting product tags.
@@ -80,48 +77,37 @@ const ProductTagControl = ( {
};
return (
- <>
- <SearchListControl
- className="woocommerce-product-tags"
- list={ list }
- isLoading={ loading }
- selected={ selectedTags }
- onChange={ onChange }
- onSearch={ limitTags ? debouncedOnSearch : undefined }
- renderItem={ ProductTagItem }
- messages={ messages }
- isCompact={ isCompact }
- isHierarchical
- isSingle={ false }
- />
- { !! onOperatorChange && (
- <div hidden={ selected.length < 2 }>
- <SelectControl
- className="woocommerce-product-tags__operator"
- label={ __(
- 'Display products matching',
- 'woocommerce'
- ) }
- help={ __(
- 'Pick at least two tags to use this setting.',
- 'woocommerce'
- ) }
- value={ operator }
- onChange={ onOperatorChange }
- options={ [
- {
- label: __( 'Any selected tags', 'woocommerce' ),
- value: 'any',
+ <ProductTaxonomyControl
+ className="woocommerce-product-tags"
+ isCompact={ isCompact }
+ isHierarchical
+ isLoading={ loading }
+ isSingle={ false }
+ itemClassName="woocommerce-product-tags__item"
+ list={ list }
+ messages={ messages }
+ onChange={ onChange }
+ onSearch={ limitTags ? debouncedOnSearch : undefined }
+ operator={
+ onOperatorChange
+ ? {
+ className: 'woocommerce-product-tags__operator',
+ labels: {
+ all: __( 'All selected tags', 'woocommerce' ),
+ any: __( 'Any selected tags', 'woocommerce' ),
+ help: __(
+ 'Pick at least two tags to use this setting.',
+ 'woocommerce'
+ ),
},
- {
- label: __( 'All selected tags', 'woocommerce' ),
- value: 'all',
- },
- ] }
- />
- </div>
- ) }
- </>
+ onChange: onOperatorChange,
+ selectedCount: selected.length,
+ value: operator,
+ }
+ : undefined
+ }
+ selected={ selectedTags }
+ />
);
};
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-tag-control/product-tag-item.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-tag-control/product-tag-item.tsx
deleted file mode 100644
index 2a6a9cbeff2..00000000000
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-tag-control/product-tag-item.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * External dependencies
- */
-import { _n, sprintf } from '@wordpress/i18n';
-import { SearchListItem } from '@woocommerce/editor-components/search-list-control';
-import clsx from 'clsx';
-
-/**
- * Internal dependencies
- */
-import type { RenderItemArgs } from '../search-list-control/types';
-
-export const ProductTagItem = ( {
- item,
- search,
- depth = 0,
- ...rest
-}: RenderItemArgs ): JSX.Element => {
- const accessibleName = ! item.breadcrumbs.length
- ? item.name
- : `${ item.breadcrumbs.join( ', ' ) }, ${ item.name }`;
-
- return (
- <SearchListItem
- className={ clsx( 'woocommerce-product-tags__item', 'has-count', {
- 'is-searching': search.length > 0,
- 'is-skip-level': depth === 0 && item.parent !== 0,
- } ) }
- item={ item }
- search={ search }
- depth={ depth }
- { ...rest }
- ariaLabel={ sprintf(
- /* translators: %1$d is the count of products, %2$s is the name of the tag. */
- _n(
- '%1$d product tagged as %2$s',
- '%1$d products tagged as %2$s',
- item.count,
- 'woocommerce'
- ),
- item.count,
- accessibleName
- ) }
- />
- );
-};
-
-export default ProductTagItem;
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-taxonomy-control/index.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-taxonomy-control/index.tsx
new file mode 100644
index 00000000000..8e0db43bb1c
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-taxonomy-control/index.tsx
@@ -0,0 +1,182 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+import type { JSX } from 'react';
+import { SelectControl } from '@wordpress/components';
+import { decodeEntities } from '@wordpress/html-entities';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import ErrorMessage from '@woocommerce/editor-components/error-placeholder/error-message';
+import {
+ SearchListControl,
+ SearchListItem,
+} from '@woocommerce/editor-components/search-list-control';
+import type { ErrorObject } from '@woocommerce/editor-components/error-placeholder';
+import type {
+ RenderItemArgs,
+ SearchListControlProps,
+ SearchListItem as SearchListItemProps,
+} from '@woocommerce/editor-components/search-list-control/types';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+interface OperatorConfig {
+ className?: string;
+ labels: {
+ all: string;
+ any: string;
+ help: string;
+ };
+ onChange: ( operator: string ) => void;
+ /**
+ * Number of selected taxonomy IDs, including IDs whose list items have not
+ * been resolved yet.
+ */
+ selectedCount: number;
+ value: string;
+}
+
+interface ProductTaxonomyControlProps<
+ T extends object = object,
+> extends SearchListControlProps< T > {
+ countType?: 'product' | 'review';
+ error?: ErrorObject | null;
+ itemClassName?: string;
+ operator?: OperatorConfig | undefined;
+}
+
+interface CountDetails {
+ count?: number;
+ review_count?: number;
+}
+
+const getItemCount = (
+ item: SearchListItemProps,
+ countType: 'product' | 'review'
+) => {
+ const details = item.details as CountDetails | undefined;
+
+ return countType === 'review'
+ ? ( details?.review_count ?? 0 )
+ : ( details?.count ?? item.count ?? 0 );
+};
+
+const getCountLabel = ( count: number, countType: 'product' | 'review' ) =>
+ countType === 'review'
+ ? sprintf(
+ /* translators: %d is the count of reviews. */
+ _n( '%d review', '%d reviews', count, 'woocommerce' ),
+ count
+ )
+ : sprintf(
+ /* translators: %d is the count of products. */
+ _n( '%d product', '%d products', count, 'woocommerce' ),
+ count
+ );
+
+const getAriaLabel = (
+ item: SearchListItemProps,
+ count: number,
+ countType: 'product' | 'review'
+) => {
+ const name = decodeEntities(
+ item.breadcrumbs.length
+ ? `${ item.breadcrumbs.join( ', ' ) }, ${ item.name }`
+ : item.name
+ );
+
+ return countType === 'review'
+ ? sprintf(
+ /* translators: %1$s is the item name, %2$d is the count of reviews for the item. */
+ _n(
+ '%1$s, has %2$d review',
+ '%1$s, has %2$d reviews',
+ count,
+ 'woocommerce'
+ ),
+ name,
+ count
+ )
+ : sprintf(
+ /* translators: %1$s is the item name, %2$d is the count of products for the item. */
+ _n(
+ '%1$s, has %2$d product',
+ '%1$s, has %2$d products',
+ count,
+ 'woocommerce'
+ ),
+ name,
+ count
+ );
+};
+
+const ProductTaxonomyControl = < T extends object = object >( {
+ countType = 'product',
+ error = null,
+ itemClassName,
+ operator,
+ renderItem,
+ ...searchListProps
+}: ProductTaxonomyControlProps< T > ): JSX.Element => {
+ if ( error ) {
+ return <ErrorMessage error={ error } />;
+ }
+
+ const renderTaxonomyItem = ( args: RenderItemArgs< T > ) => {
+ const { item, search, depth = 0 } = args;
+ const count = getItemCount( item, countType );
+
+ return (
+ <SearchListItem
+ { ...args }
+ className={ clsx( itemClassName, 'has-count', {
+ 'is-searching': search.length > 0,
+ 'is-skip-level': depth === 0 && item.parent !== 0,
+ } ) }
+ countLabel={ getCountLabel( count, countType ) }
+ aria-label={ getAriaLabel( item, count, countType ) }
+ />
+ );
+ };
+
+ return (
+ <>
+ <SearchListControl
+ { ...searchListProps }
+ renderItem={ renderItem || renderTaxonomyItem }
+ />
+ { operator && (
+ <div hidden={ operator.selectedCount < 2 }>
+ <SelectControl< string >
+ className={ clsx(
+ 'woocommerce-product-taxonomy-control__operator',
+ operator.className
+ ) }
+ label={ __(
+ 'Display products matching',
+ 'woocommerce'
+ ) }
+ help={ operator.labels.help }
+ value={ operator.value }
+ onChange={ operator.onChange }
+ options={ [
+ {
+ label: operator.labels.any,
+ value: 'any',
+ },
+ {
+ label: operator.labels.all,
+ value: 'all',
+ },
+ ] }
+ />
+ </div>
+ ) }
+ </>
+ );
+};
+
+export default ProductTaxonomyControl;
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-tag-control/style.scss b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-taxonomy-control/style.scss
similarity index 51%
rename from plugins/woocommerce/client/blocks/assets/js/editor-components/product-tag-control/style.scss
rename to plugins/woocommerce/client/blocks/assets/js/editor-components/product-taxonomy-control/style.scss
index caf5fb397ac..fb4671e3111 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-tag-control/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-taxonomy-control/style.scss
@@ -1,3 +1,7 @@
+.woocommerce-product-taxonomy-control__operator,
+.woocommerce-product-attributes__operator,
+.woocommerce-product-brands__operator,
+.woocommerce-product-categories__operator,
.woocommerce-product-tags__operator {
.components-base-control__help {
@include visually-hidden;
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-taxonomy-control/test/index.test.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-taxonomy-control/test/index.test.tsx
new file mode 100644
index 00000000000..182a6a835b9
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-taxonomy-control/test/index.test.tsx
@@ -0,0 +1,291 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+import type { ComponentProps } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import ProductTaxonomyControl from '..';
+import type {
+ RenderItemArgs,
+ SearchListItem,
+} from '../../search-list-control/types';
+
+type ConsoleMatchers = { toHaveWarned: () => void };
+
+const list: SearchListItem[] = [
+ {
+ breadcrumbs: [],
+ children: [],
+ count: 2,
+ id: 1,
+ name: 'Clothing',
+ parent: 0,
+ value: 'clothing',
+ },
+];
+
+const messages = {
+ clear: 'Clear all product terms',
+ noItems: 'No product terms.',
+ search: 'Search for product terms',
+ selected: ( count: number ) => `${ count } selected`,
+ updated: 'Product term search results updated.',
+};
+
+const renderControl = (
+ overrides: Partial< ComponentProps< typeof ProductTaxonomyControl > > = {}
+) =>
+ render(
+ <ProductTaxonomyControl
+ isCompact={ false }
+ isHierarchical
+ isSingle={ false }
+ list={ list }
+ messages={ messages }
+ onChange={ jest.fn() }
+ selected={ [] }
+ { ...overrides }
+ />
+ );
+
+describe( 'ProductTaxonomyControl', () => {
+ test( 'renders product counts and accessible labels', () => {
+ renderControl( {
+ countType: 'product',
+ itemClassName: 'woocommerce-product-terms__item',
+ } );
+
+ expect( screen.getByText( '2 products' ) ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'checkbox', {
+ name: 'Clothing, has 2 products',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ document.querySelector( '.woocommerce-product-terms__item' )
+ ).toBeInTheDocument();
+ } );
+
+ test( 'decodes HTML entities in accessible labels', () => {
+ renderControl( {
+ list: [
+ {
+ ...list[ 0 ],
+ count: 3,
+ name: 'Rock & Roll',
+ },
+ ],
+ } );
+
+ expect( screen.getByText( 'Rock & Roll' ) ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'checkbox', {
+ name: 'Rock & Roll, has 3 products',
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ test( 'renders review counts from item details', () => {
+ renderControl( {
+ countType: 'review',
+ list: [
+ {
+ ...list[ 0 ],
+ details: { review_count: 3 },
+ },
+ ],
+ } );
+
+ expect( screen.getByText( '3 reviews' ) ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'checkbox', {
+ name: 'Clothing, has 3 reviews',
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ test( 'includes breadcrumbs in accessible labels', () => {
+ renderControl( {
+ countType: 'product',
+ isHierarchical: false,
+ list: [
+ {
+ ...list[ 0 ],
+ breadcrumbs: [ 'Clothing' ],
+ name: 'Shirts',
+ },
+ ],
+ } );
+
+ expect(
+ screen.getByRole( 'checkbox', {
+ name: 'Clothing, Shirts, has 2 products',
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ test( 'renders loading and empty states', () => {
+ const { container, rerender } = renderControl( { isLoading: true } );
+
+ expect(
+ container.querySelector( '.components-spinner' )
+ ).toBeInTheDocument();
+
+ rerender(
+ <ProductTaxonomyControl
+ isCompact={ false }
+ isHierarchical
+ isSingle={ false }
+ list={ [] }
+ messages={ messages }
+ onChange={ jest.fn() }
+ selected={ [] }
+ />
+ );
+
+ expect( screen.getByText( 'No product terms.' ) ).toBeInTheDocument();
+ } );
+
+ test( 'forwards selection changes', () => {
+ const onChange = jest.fn();
+ renderControl( { countType: 'product', onChange } );
+
+ fireEvent.click(
+ screen.getByRole( 'checkbox', {
+ name: 'Clothing, has 2 products',
+ } )
+ );
+
+ expect( onChange ).toHaveBeenCalledWith( [ list[ 0 ] ] );
+ } );
+
+ test( 'supports a custom item renderer', () => {
+ const renderItem = jest.fn( ( { item }: RenderItemArgs ) => (
+ <span>Custom { item.name }</span>
+ ) );
+ renderControl( { renderItem } );
+
+ expect( screen.getByText( 'Custom Clothing' ) ).toBeInTheDocument();
+ expect( renderItem ).toHaveBeenCalled();
+ } );
+
+ test( 'forwards compact, single, and hierarchical settings', () => {
+ const hierarchicalList: SearchListItem[] = [
+ list[ 0 ],
+ {
+ ...list[ 0 ],
+ count: 1,
+ id: 2,
+ name: 'Shirts',
+ parent: 1,
+ value: 'shirts',
+ },
+ ];
+ const { container } = renderControl( {
+ isCompact: true,
+ isSingle: true,
+ list: hierarchicalList,
+ } );
+
+ expect(
+ container.querySelector( '.woocommerce-search-list.is-compact' )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'radio', {
+ name: 'Clothing, has 2 products',
+ } )
+ ).toBeInTheDocument();
+ expect( screen.queryByText( 'Shirts' ) ).not.toBeInTheDocument();
+
+ fireEvent.click( screen.getByRole( 'treeitem' ) );
+
+ expect( screen.getByText( 'Shirts' ) ).toBeInTheDocument();
+ } );
+
+ test( 'forwards server search changes', () => {
+ const onSearch = jest.fn();
+ renderControl( { onSearch } );
+ onSearch.mockClear();
+
+ fireEvent.change( screen.getByLabelText( 'Search for product terms' ), {
+ target: { value: 'shirt' },
+ } );
+
+ expect( onSearch ).toHaveBeenCalledWith( 'shirt' );
+ } );
+
+ test( 'renders errors instead of the taxonomy list', () => {
+ renderControl( {
+ error: { message: 'Unable to load terms', type: 'general' },
+ } );
+
+ expect(
+ screen.getByText( 'Unable to load terms' )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByLabelText( 'Search for product terms' )
+ ).not.toBeInTheDocument();
+ } );
+
+ test( 'uses the raw selected count to show the operator', () => {
+ const onChange = jest.fn();
+ const operator = {
+ className: 'woocommerce-product-terms__operator',
+ labels: {
+ all: 'All selected terms',
+ any: 'Any selected terms',
+ help: 'Select two terms.',
+ },
+ onChange,
+ selectedCount: 1,
+ value: 'any',
+ };
+ const { container, rerender } = renderControl( { operator } );
+ // eslint-disable-next-line jest/valid-expect -- The WordPress matcher has no local type declaration.
+ ( expect( console ) as unknown as ConsoleMatchers ).toHaveWarned();
+ const select = screen.getByLabelText( 'Display products matching' );
+ const options = screen.getAllByRole( 'option', { hidden: true } );
+ const operatorControl = container.querySelector(
+ '.woocommerce-product-taxonomy-control__operator'
+ );
+
+ expect( select ).toHaveValue( 'any' );
+ expect( select.closest( '[hidden]' ) ).toBeInTheDocument();
+ expect( options ).toHaveLength( 2 );
+ expect( options[ 0 ] ).toHaveValue( 'any' );
+ expect( options[ 0 ] ).toHaveTextContent( 'Any selected terms' );
+ expect( options[ 1 ] ).toHaveValue( 'all' );
+ expect( options[ 1 ] ).toHaveTextContent( 'All selected terms' );
+ expect( screen.getByText( 'Select two terms.' ) ).toBeInTheDocument();
+ expect( operatorControl ).toHaveClass(
+ 'woocommerce-product-taxonomy-control__operator',
+ 'woocommerce-product-terms__operator'
+ );
+
+ rerender(
+ <ProductTaxonomyControl
+ isCompact={ false }
+ isHierarchical
+ isSingle={ false }
+ list={ [] }
+ messages={ messages }
+ onChange={ jest.fn() }
+ operator={ { ...operator, selectedCount: 2 } }
+ selected={ [] }
+ />
+ );
+
+ expect( select.closest( '[hidden]' ) ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole( 'combobox', {
+ name: 'Display products matching',
+ } )
+ ).toBe( select );
+ fireEvent.change( select, { target: { value: 'all' } } );
+ expect( onChange ).toHaveBeenCalledTimes( 1 );
+ expect( onChange.mock.calls[ 0 ][ 0 ] ).toBe( 'all' );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-taxonomy-control/test/integration.test.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-taxonomy-control/test/integration.test.tsx
new file mode 100644
index 00000000000..e4b3a0636b7
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-taxonomy-control/test/integration.test.tsx
@@ -0,0 +1,425 @@
+/**
+ * External dependencies
+ */
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { getSetting } from '@woocommerce/settings';
+import useProductAttributes from '@woocommerce/base-context/hooks/use-product-attributes';
+import type { ProductCategoryResponseItem } from '@woocommerce/types';
+
+/**
+ * Internal dependencies
+ */
+import ProductCategoryControl from '../../product-category-control';
+import ProductBrandControl from '../../product-brand-control';
+import ProductTagControl from '../../product-tag-control';
+import ProductAttributeTermControl from '../../product-attribute-term-control';
+import { getBrands, getCategories, getProductTags } from '../../utils';
+
+type ConsoleMatchers = { toHaveWarned: () => void };
+
+jest.mock( '@woocommerce/base-context/hooks/use-product-attributes', () => ( {
+ __esModule: true,
+ default: jest.fn(),
+} ) );
+
+jest.mock( '@woocommerce/settings', () => ( {
+ ...jest.requireActual( '@woocommerce/settings' ),
+ getSetting: jest.fn(
+ ( _key: string, defaultValue: unknown ) => defaultValue
+ ),
+ getSettingWithCoercion: jest.fn(
+ ( _key: string, defaultValue: unknown ) => defaultValue
+ ),
+} ) );
+
+jest.mock( '../../utils', () => ( {
+ getBrands: jest.fn(),
+ getCategories: jest.fn(),
+ getProductTags: jest.fn(),
+} ) );
+
+const mockGetSetting = jest.mocked( getSetting );
+const mockUseProductAttributes = jest.mocked( useProductAttributes );
+const mockGetProductTags = jest.mocked( getProductTags );
+
+const taxonomyItems: ProductCategoryResponseItem[] = [
+ {
+ count: 2,
+ description: '',
+ id: 1,
+ image: null,
+ name: 'First',
+ parent: 0,
+ permalink: 'https://example.com/first',
+ review_count: 7,
+ slug: 'first',
+ },
+ {
+ count: 5,
+ description: '',
+ id: 2,
+ image: null,
+ name: 'Selected',
+ parent: 0,
+ permalink: 'https://example.com/selected',
+ review_count: 11,
+ slug: 'selected',
+ },
+];
+
+const taxonomyControls = [
+ {
+ Control: ProductCategoryControl,
+ fetchTerms: jest.mocked( getCategories ),
+ label: 'categories',
+ showReviewCount: true,
+ firstCount: '7 reviews',
+ selectedCount: '11 reviews',
+ },
+ {
+ Control: ProductBrandControl,
+ fetchTerms: jest.mocked( getBrands ),
+ label: 'brands',
+ showReviewCount: false,
+ firstCount: '2 products',
+ selectedCount: '5 products',
+ },
+];
+
+describe( 'product taxonomy adapter integration', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ mockGetSetting.mockImplementation(
+ ( _key: string, defaultValue: unknown ) => defaultValue
+ );
+ mockUseProductAttributes.mockReturnValue( {
+ errorLoadingAttributes: null,
+ isLoadingAttributes: false,
+ productsAttributes: [],
+ } );
+ } );
+
+ afterEach( () => {
+ jest.useRealTimers();
+ } );
+
+ it.each( taxonomyControls )(
+ 'renders $label counts and updates single selections',
+ async ( {
+ Control,
+ fetchTerms,
+ showReviewCount,
+ firstCount,
+ selectedCount,
+ } ) => {
+ fetchTerms.mockResolvedValue( [
+ ...taxonomyItems,
+ {
+ ...taxonomyItems[ 0 ],
+ id: 3,
+ name: 'Child',
+ parent: 1,
+ slug: 'child',
+ },
+ ] );
+ const onChange = jest.fn();
+ const { rerender } = render(
+ <Control
+ isSingle
+ onChange={ onChange }
+ selected={ [ 2 ] }
+ showReviewCount={ showReviewCount }
+ />
+ );
+
+ const first = await screen.findByRole( 'radio', {
+ name: `First, has ${ firstCount }`,
+ } );
+ const selected = screen.getByRole( 'radio', {
+ name: `Selected, has ${ selectedCount }`,
+ } );
+ expect( screen.getByText( firstCount ) ).toBeInTheDocument();
+ expect( first ).not.toBeChecked();
+ expect( selected ).toBeChecked();
+ expect( screen.queryByText( 'Child' ) ).not.toBeInTheDocument();
+
+ fireEvent.click( first );
+ expect( onChange ).toHaveBeenCalledWith( [
+ expect.objectContaining( {
+ id: 1,
+ name: 'First',
+ value: 'first',
+ details: taxonomyItems[ 0 ],
+ } ),
+ ] );
+ expect(
+ screen.getByRole( 'radio', {
+ name: `First, Child, has ${ firstCount }`,
+ } )
+ ).toBeInTheDocument();
+
+ rerender(
+ <Control
+ isSingle
+ onChange={ onChange }
+ selected={ [ 1 ] }
+ showReviewCount={ showReviewCount }
+ />
+ );
+ expect( first ).toBeChecked();
+ expect( selected ).not.toBeChecked();
+ }
+ );
+
+ it.each( taxonomyControls )(
+ 'shows $label fetch errors instead of the picker',
+ async ( { Control, fetchTerms, label } ) => {
+ fetchTerms.mockRejectedValue( new Error( 'Unable to load terms' ) );
+ render( <Control onChange={ jest.fn() } selected={ [] } /> );
+
+ expect(
+ await screen.findByText( 'Unable to load terms' )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByLabelText( `Search for product ${ label }` )
+ ).not.toBeInTheDocument();
+ }
+ );
+
+ it( 'renders tag counts and searches locally when tag limits are disabled', async () => {
+ const onChange = jest.fn();
+ mockGetProductTags.mockResolvedValue( [
+ {
+ breadcrumbs: [],
+ children: [],
+ count: 3,
+ id: 9,
+ name: 'Sale',
+ parent: 0,
+ value: 'sale',
+ },
+ ] );
+
+ render(
+ <ProductTagControl onChange={ onChange } selected={ [ 9 ] } />
+ );
+
+ const tag = await screen.findByRole( 'checkbox', {
+ name: 'Sale, has 3 products',
+ } );
+
+ expect( screen.getByText( '3 products' ) ).toBeInTheDocument();
+ fireEvent.click( tag );
+ expect( onChange ).toHaveBeenCalledWith( [] );
+
+ fireEvent.change( screen.getByLabelText( 'Search for product tags' ), {
+ target: { value: 'sal' },
+ } );
+ expect( mockGetProductTags ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'keeps the tag operator available for unresolved selections and coalesces server search', async () => {
+ jest.useFakeTimers();
+ mockGetSetting.mockImplementation( ( key, defaultValue ) =>
+ key === 'limitTags' ? true : defaultValue
+ );
+ mockGetProductTags.mockResolvedValue( [
+ {
+ breadcrumbs: [],
+ children: [],
+ count: 3,
+ id: 9,
+ name: 'Sale',
+ parent: 0,
+ value: 'sale',
+ },
+ ] );
+ const onOperatorChange = jest.fn();
+ const { rerender } = render(
+ <ProductTagControl
+ onChange={ jest.fn() }
+ onOperatorChange={ onOperatorChange }
+ operator="all"
+ selected={ [ 9, 10 ] }
+ />
+ );
+ // eslint-disable-next-line jest/valid-expect -- The WordPress matcher has no local type declaration.
+ ( expect( console ) as unknown as ConsoleMatchers ).toHaveWarned();
+
+ const tag = await screen.findByRole( 'checkbox', {
+ name: 'Sale, has 3 products',
+ } );
+ expect( tag ).toBeChecked();
+ const operator = screen.getByRole( 'combobox', {
+ name: 'Display products matching',
+ } );
+ expect( operator ).toHaveValue( 'all' );
+ fireEvent.change( operator, { target: { value: 'any' } } );
+ expect( onOperatorChange ).toHaveBeenCalledTimes( 1 );
+ expect( onOperatorChange.mock.calls[ 0 ][ 0 ] ).toBe( 'any' );
+
+ const search = screen.getByLabelText( 'Search for product tags' );
+ fireEvent.change( search, { target: { value: 's' } } );
+ fireEvent.change( search, { target: { value: 'su' } } );
+ fireEvent.change( search, { target: { value: 'sum' } } );
+ await act( async () => {
+ jest.advanceTimersByTime( 399 );
+ } );
+ expect( mockGetProductTags ).toHaveBeenCalledTimes( 1 );
+
+ await act( async () => {
+ jest.advanceTimersByTime( 1 );
+ } );
+ expect( mockGetProductTags ).toHaveBeenLastCalledWith( {
+ search: 'sum',
+ selected: [ 9, 10 ],
+ } );
+ expect( mockGetProductTags ).toHaveBeenCalledTimes( 2 );
+
+ rerender(
+ <ProductTagControl
+ onChange={ jest.fn() }
+ onOperatorChange={ onOperatorChange }
+ operator="any"
+ selected={ [ 9 ] }
+ />
+ );
+ expect( operator ).not.toBeVisible();
+ } );
+
+ it( 'clears tag loading after a failed request', async () => {
+ mockGetProductTags.mockRejectedValue( new Error( 'Request failed' ) );
+ const { container } = render(
+ <ProductTagControl onChange={ jest.fn() } selected={ [] } />
+ );
+
+ expect(
+ container.querySelector( '.components-spinner' )
+ ).toBeInTheDocument();
+
+ await waitFor( () => {
+ expect(
+ container.querySelector( '.components-spinner' )
+ ).not.toBeInTheDocument();
+ } );
+ expect(
+ screen.getByText(
+ 'You have not set up any product tags on your store.'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders attribute groups and selects terms even when their IDs collide', () => {
+ const onChange = jest.fn();
+ mockUseProductAttributes.mockReturnValue( {
+ errorLoadingAttributes: null,
+ isLoadingAttributes: false,
+ productsAttributes: [
+ {
+ count: 1,
+ has_archives: true,
+ id: 12,
+ label: 'Color',
+ name: 'Color',
+ orderby: 'name',
+ parent: 0,
+ taxonomy: 'pa_color',
+ terms: [
+ {
+ attr_slug: 'pa_color',
+ count: 3,
+ description: '',
+ id: 12,
+ name: 'Blue',
+ parent: 12,
+ slug: 'blue',
+ },
+ ],
+ type: 'select',
+ },
+ {
+ count: 0,
+ has_archives: true,
+ id: 8,
+ label: 'Size',
+ name: 'Size',
+ orderby: 'name',
+ parent: 0,
+ taxonomy: 'pa_size',
+ terms: [],
+ type: 'select',
+ },
+ ],
+ } );
+
+ const { rerender } = render(
+ <ProductAttributeTermControl
+ isCompact={ false }
+ onChange={ onChange }
+ operator="any"
+ selected={ [] }
+ />
+ );
+
+ expect( screen.getByText( '1 term' ) ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'checkbox', { name: /Size/ } )
+ ).toBeDisabled();
+
+ fireEvent.click( screen.getByRole( 'treeitem', { name: /Color/ } ) );
+ const blue = screen.getByRole( 'checkbox', {
+ name: 'Color: Blue, has 3 products',
+ } );
+ expect( screen.getByText( '3 products' ) ).toBeInTheDocument();
+ fireEvent.click( blue );
+ expect( onChange ).toHaveBeenCalledWith( [
+ expect.objectContaining( {
+ id: 12,
+ name: 'Blue',
+ value: 'pa_color',
+ } ),
+ ] );
+ rerender(
+ <ProductAttributeTermControl
+ isCompact={ false }
+ onChange={ onChange }
+ operator="any"
+ selected={ [ { id: 12 } ] }
+ />
+ );
+ expect( blue ).toBeChecked();
+ fireEvent.click( blue );
+ expect( onChange ).toHaveBeenLastCalledWith( [] );
+ } );
+
+ it( 'shows attribute fetch errors instead of the picker', () => {
+ mockUseProductAttributes.mockReturnValue( {
+ errorLoadingAttributes: {
+ message: 'Unable to load attributes',
+ type: 'general',
+ },
+ isLoadingAttributes: false,
+ productsAttributes: [],
+ } );
+ render(
+ <ProductAttributeTermControl
+ isCompact={ false }
+ onChange={ jest.fn() }
+ operator="any"
+ selected={ [] }
+ />
+ );
+ expect(
+ screen.getByText( 'Unable to load attributes' )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByLabelText( 'Search for product attributes' )
+ ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/types.ts b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/types.ts
index b8134b7bd4a..98788d88ba0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/types.ts
@@ -108,8 +108,6 @@ export interface RenderItemArgs< T extends object = object >
* If not provided, a default name will be generated using the controlId.
*/
name?: string;
- // Aria label for the input. If not provided, a default label will be generated using the item name.
- ariaLabel?: string;
}
export interface SearchListControlProps< T extends object = object > {