Commit abb450e34ca for woocommerce
commit abb450e34ca8ccb79776b472f108ccd40426afc4
Author: verofasulo <98944206+verofasulo@users.noreply.github.com>
Date: Wed May 20 16:14:19 2026 +0200
Replace Upsells with Grouped products in grouped product edit form (#65173)
* Replace Upsells with Grouped products in grouped product edit form
* Use root/product entity and drop helper text in Grouped products field
* Show catalog products in Grouped products dropdown by default
* Match categories dropdown treatment in Grouped products field
* Add selected indicator and explicit option spacing
* Reserve indicator slot for unselected items too
* Keep selected products in regular weight in the dropdown
* fix build
* fix test
* refactor code
---------
Co-authored-by: Luigi Teschio <gigitux@gmail.com>
diff --git a/packages/js/experimental-products-app/changelog/update-products-app-grouped-products-field b/packages/js/experimental-products-app/changelog/update-products-app-grouped-products-field
new file mode 100644
index 00000000000..1d5c89706c6
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/update-products-app-grouped-products-field
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Replace the Upsells field in the grouped product quick-edit form with a Grouped products chip-select field that searches products and shows each child with its thumbnail.
diff --git a/packages/js/experimental-products-app/src/fields/grouped_products/field.tsx b/packages/js/experimental-products-app/src/fields/grouped_products/field.tsx
new file mode 100644
index 00000000000..6b5a9fc4fe6
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/grouped_products/field.tsx
@@ -0,0 +1,290 @@
+/**
+ * External dependencies
+ */
+import { Combobox as BaseCombobox } from '@base-ui/react/combobox';
+import { Spinner } from '@wordpress/components';
+import { store as coreStore } from '@wordpress/core-data';
+import { resolveSelect } from '@wordpress/data';
+import type { DataFormControlProps, Field } from '@wordpress/dataviews';
+import { useEffect, useMemo, useRef, useState } from '@wordpress/element';
+import { decodeEntities } from '@wordpress/html-entities';
+import { __ } from '@wordpress/i18n';
+import type { Product } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import {
+ Combobox,
+ SearchableChipSelectControl,
+ type SearchableChipSelectItem,
+} from '../components/searchable-chip-select';
+
+const SEARCH_DEBOUNCE_MS = 300;
+const SEARCH_PER_PAGE = 20;
+
+type Item = SearchableChipSelectItem & {
+ image?: { src: string; alt: string };
+};
+
+function productToItem(
+ product: Pick< Product, 'id' | 'name' > & {
+ images?: { src: string; alt: string }[];
+ }
+): Item {
+ const thumbnail = product.images?.[ 0 ];
+ return {
+ value: product.id.toString(),
+ label:
+ decodeEntities( product.name ?? '' ) ||
+ __( '(Untitled product)', 'woocommerce' ),
+ image: thumbnail?.src
+ ? { src: thumbnail.src, alt: thumbnail.alt ?? '' }
+ : undefined,
+ };
+}
+
+function GroupedProductsEdit( {
+ data,
+ field,
+ onChange,
+}: DataFormControlProps< ProductEntityRecord > ) {
+ const selectedIds = useMemo( () => {
+ const ids = data?.grouped_products;
+ return Array.isArray( ids ) ? ids : [];
+ }, [ data?.grouped_products ] );
+
+ const [ inputValue, setInputValue ] = useState( '' );
+ const [ selectedProducts, setSelectedProducts ] = useState< Item[] >( [] );
+ const [ suggestions, setSuggestions ] = useState< Item[] >( [] );
+ const [ isLoadingSelected, setIsLoadingSelected ] = useState( false );
+ const [ isSearching, setIsSearching ] = useState( false );
+ const searchRequestIdRef = useRef( 0 );
+
+ // Load full records for the currently selected ids so we can show their
+ // names and thumbnails in the chips.
+ useEffect( () => {
+ if ( selectedIds.length === 0 ) {
+ setSelectedProducts( [] );
+ return;
+ }
+
+ let cancelled = false;
+ setIsLoadingSelected( true );
+
+ void resolveSelect( coreStore )
+ .getEntityRecords( 'root', 'product', {
+ include: selectedIds,
+ per_page: selectedIds.length,
+ } )
+ .then( ( records: unknown ) => {
+ if ( cancelled || ! Array.isArray( records ) ) {
+ return;
+ }
+ const byId = new Map(
+ ( records as Product[] ).map( ( p ) => [ p.id, p ] )
+ );
+ setSelectedProducts(
+ selectedIds
+ .map( ( id ) => byId.get( id ) )
+ .filter( ( p ): p is Product => p !== undefined )
+ .map( productToItem )
+ );
+ } )
+ .finally( () => {
+ if ( ! cancelled ) {
+ setIsLoadingSelected( false );
+ }
+ } );
+
+ return () => {
+ cancelled = true;
+ };
+ }, [ selectedIds ] );
+
+ // Browseable list of products in the dropdown. Empty input loads the
+ // first page of the catalog; typing debounces a server-side search.
+ useEffect( () => {
+ const query = inputValue.trim();
+
+ setIsSearching( true );
+ const requestId = ++searchRequestIdRef.current;
+
+ // Exclude only the current product itself; already-selected products
+ // stay in the dropdown so the chip-select can mark them as selected
+ // (checkmark + highlight), matching the categories treatment.
+ const excludeIds = data?.id ? [ data.id ] : [];
+
+ const queryParams: Record< string, unknown > = {
+ per_page: SEARCH_PER_PAGE,
+ orderby: 'title',
+ order: 'asc',
+ };
+ if ( excludeIds.length > 0 ) {
+ queryParams.exclude = excludeIds;
+ }
+ if ( query ) {
+ queryParams.search = query;
+ }
+
+ const delay = query ? SEARCH_DEBOUNCE_MS : 0;
+ const timer = window.setTimeout( () => {
+ void resolveSelect( coreStore )
+ .getEntityRecords( 'root', 'product', queryParams )
+ .then( ( records: unknown ) => {
+ if ( requestId !== searchRequestIdRef.current ) {
+ return;
+ }
+ setSuggestions(
+ Array.isArray( records )
+ ? ( records as Product[] ).map( productToItem )
+ : []
+ );
+ } )
+ .finally( () => {
+ if ( requestId === searchRequestIdRef.current ) {
+ setIsSearching( false );
+ }
+ } );
+ }, delay );
+
+ return () => {
+ window.clearTimeout( timer );
+ };
+ }, [ inputValue, selectedIds, data?.id ] );
+
+ // Browseable catalog first, then
+ // selected products appended so the chips can resolve their labels.
+ const items = useMemo( () => {
+ const byValue = new Map< string, Item >();
+ for ( const item of suggestions ) {
+ byValue.set( item.value, item );
+ }
+ for ( const item of selectedProducts ) {
+ if ( ! byValue.has( item.value ) ) {
+ byValue.set( item.value, item );
+ }
+ }
+ return Array.from( byValue.values() );
+ }, [ selectedProducts, suggestions ] );
+
+ const value = useMemo( () => {
+ const byValue = new Map(
+ items.map( ( item ) => [ item.value, item ] )
+ );
+ return selectedIds
+ .map( ( id ) => byValue.get( id.toString() ) )
+ .filter( ( item ): item is Item => item !== undefined );
+ }, [ items, selectedIds ] );
+
+ const handleValueChange = ( newItems: Item[] ) => {
+ const newIds = newItems
+ .map( ( item ) => parseInt( item.value, 10 ) )
+ .filter( ( id ) => ! Number.isNaN( id ) );
+ onChange( { grouped_products: newIds } );
+ };
+
+ const emptyContent = isSearching ? (
+ <Spinner />
+ ) : (
+ __( 'No products found.', 'woocommerce' )
+ );
+
+ return (
+ <SearchableChipSelectControl
+ label={ field.label }
+ description={ field.description }
+ items={ items }
+ value={ value }
+ onValueChange={ handleValueChange }
+ inputValue={ inputValue }
+ onInputValueChange={ setInputValue }
+ searchPlaceholder={ __( 'Search', 'woocommerce' ) }
+ disabled={ isLoadingSelected }
+ emptyContent={ emptyContent }
+ placeholderChip={
+ value.length === 0 ? field.placeholder : undefined
+ }
+ // Always server-side search.
+ filter={ null }
+ chipsContent={ ( selectedItems: Item[] ) =>
+ selectedItems.map( ( item ) => (
+ <Combobox.ChipWithRemove
+ key={ item.value }
+ prefix={
+ item.image?.src ? (
+ <img
+ src={ item.image.src }
+ alt={ item.image.alt ?? '' }
+ className="woocommerce-grouped-products-edit__chip-thumbnail"
+ />
+ ) : (
+ <span className="woocommerce-grouped-products-edit__chip-thumbnail woocommerce-grouped-products-edit__chip-thumbnail--empty" />
+ )
+ }
+ >
+ { item.label }
+ </Combobox.ChipWithRemove>
+ ) )
+ }
+ >
+ { ( item: Item ) => (
+ <Combobox.Item
+ key={ item.value }
+ value={ item }
+ disabled={ item.disabled }
+ >
+ <div className="woocommerce-grouped-products-edit__option">
+ <span
+ className="woocommerce-grouped-products-edit__option-indicator"
+ aria-hidden="true"
+ >
+ <BaseCombobox.ItemIndicator>
+ <svg
+ width="16"
+ height="16"
+ viewBox="0 0 24 24"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="M5 13l4 4L19 7"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </BaseCombobox.ItemIndicator>
+ </span>
+ { item.image?.src ? (
+ <img
+ src={ item.image.src }
+ alt={ item.image.alt ?? '' }
+ className="woocommerce-grouped-products-edit__option-thumbnail"
+ />
+ ) : (
+ <span className="woocommerce-grouped-products-edit__option-thumbnail woocommerce-grouped-products-edit__option-thumbnail--empty" />
+ ) }
+ <span className="woocommerce-grouped-products-edit__option-label">
+ { item.label }
+ </span>
+ </div>
+ </Combobox.Item>
+ ) }
+ </SearchableChipSelectControl>
+ );
+}
+
+const fieldDefinition = {
+ label: __( 'Grouped products', 'woocommerce' ),
+ enableSorting: false,
+ enableHiding: false,
+ filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+ ...fieldDefinition,
+ Edit: GroupedProductsEdit,
+};
diff --git a/packages/js/experimental-products-app/src/fields/grouped_products/style.scss b/packages/js/experimental-products-app/src/fields/grouped_products/style.scss
new file mode 100644
index 00000000000..e70d64bc1d9
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/grouped_products/style.scss
@@ -0,0 +1,54 @@
+.woocommerce-grouped-products-edit {
+ &__chip-thumbnail {
+ width: 19px;
+ height: 19px;
+ border-radius: 50%;
+ object-fit: cover;
+ margin-left: 1px;
+
+ &--empty {
+ background-color: var(--wp-components-color-gray-100);
+ }
+ }
+
+ &__option {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ // Override the base searchable-chip-select rule that bolds selected
+ // items; grouped products should keep regular weight across all rows.
+ font-weight: var(--wpds-typography-font-weight-regular, 400);
+ }
+
+ // Always-present placeholder that reserves space for the checkmark, so the
+ // thumbnail / label columns stay aligned whether or not the row is
+ // selected. base-ui's <ItemIndicator> only renders its DOM when selected,
+ // so the placeholder wrapper handles the slot layout.
+ &__option-indicator {
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ color: var(--wp-components-color-foreground, #1e1e1e);
+ }
+
+ &__option-thumbnail {
+ width: 32px;
+ height: 32px;
+ flex-shrink: 0;
+ border-radius: 4px;
+ object-fit: cover;
+ background-color: var(--wp-components-color-gray-100);
+ }
+
+ &__option-label {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
diff --git a/packages/js/experimental-products-app/src/fields/registry.tsx b/packages/js/experimental-products-app/src/fields/registry.tsx
index da831517110..449d1fa5f0a 100644
--- a/packages/js/experimental-products-app/src/fields/registry.tsx
+++ b/packages/js/experimental-products-app/src/fields/registry.tsx
@@ -17,6 +17,7 @@ import { fieldExtensions as dateOnSaleFromFieldExtensions } from './date_on_sale
import { fieldExtensions as dateOnSaleToFieldExtensions } from './date_on_sale_to/field';
import { fieldExtensions as descriptionFieldExtensions } from './description/field';
import { fieldExtensions as globalUniqueIdFieldExtensions } from './global_unique_id/field';
+import { fieldExtensions as groupedProductsFieldExtensions } from './grouped_products/field';
import { fieldExtensions as downloadableFieldExtensions } from './downloadable/field';
import { fieldExtensions as downloadableCountFieldExtensions } from './downloadable_count/field';
import { fieldExtensions as externalUrlFieldExtensions } from './external_url/field';
@@ -81,6 +82,7 @@ export const PRODUCT_FIELD_IDS = [
'brands',
'date',
'global_unique_id',
+ 'grouped_products',
'organization_summary',
'type',
'featured',
@@ -133,6 +135,7 @@ const PRODUCT_FIELD_EXTENSIONS: Record<
brands: brandsFieldExtensions,
date: dateFieldExtensions,
global_unique_id: globalUniqueIdFieldExtensions,
+ grouped_products: groupedProductsFieldExtensions,
organization_summary: organizationSummaryFieldExtensions,
type: typeFieldExtensions,
featured: featuredFieldExtensions,
diff --git a/packages/js/experimental-products-app/src/fields/types.ts b/packages/js/experimental-products-app/src/fields/types.ts
index 2356e484226..8e3388a0874 100644
--- a/packages/js/experimental-products-app/src/fields/types.ts
+++ b/packages/js/experimental-products-app/src/fields/types.ts
@@ -35,6 +35,7 @@ export type ProductEntityRecord = Omit< Product, 'categories' | 'tags' > & {
global_unique_id?: string;
cross_sell_ids?: number[];
upsell_ids?: number[];
+ grouped_products?: number[];
date_on_sale_from?: string | null;
date_on_sale_to?: string | null;
parent_id?: number;
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.test.ts b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
index 4bdfd736377..f206be358bd 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.test.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
@@ -191,6 +191,34 @@ describe( 'product edit utils', () => {
} );
} );
+ it( 'returns a mixed bulk field state for different grouped products', () => {
+ const products = [
+ buildProduct( {
+ id: 1,
+ type: 'grouped',
+ grouped_products: [ 10, 11 ],
+ } ),
+ buildProduct( {
+ id: 2,
+ type: 'grouped',
+ grouped_products: [ 12 ],
+ } ),
+ ];
+
+ const bulkData = buildProductBulkEditData(
+ products,
+ getProductEditFields( productFields )
+ );
+
+ expect( bulkData.data.grouped_products ).toEqual( [] );
+ expect( bulkData.fieldStates.grouped_products ).toEqual( {
+ isEmpty: false,
+ isMixed: true,
+ placeholder: 'Mixed',
+ value: undefined,
+ } );
+ } );
+
it( 'returns bulk field state for shared and empty values', () => {
const products = [
buildProduct( {
@@ -750,7 +778,7 @@ describe( 'product edit utils', () => {
'name',
'product_status',
'catalog_visibility',
- 'upsell_ids',
+ 'grouped_products',
'images',
'sku',
'categories',
@@ -1397,7 +1425,7 @@ describe( 'product edit utils', () => {
'name',
'product_status',
'catalog_visibility',
- 'upsell_ids',
+ 'grouped_products',
],
},
{
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.ts b/packages/js/experimental-products-app/src/product-edit/utils.ts
index ca4bda3921d..b0ec4f27f82 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.ts
@@ -81,6 +81,7 @@ const PRODUCT_EDIT_FIELD_IDS = [
'tax_status',
'upsell_ids',
'cross_sell_ids',
+ 'grouped_products',
'linked_products_count',
] as const;
@@ -246,7 +247,7 @@ const GROUPED_PRODUCT_EDIT_FORM_FIELDS = [
createProductEditFormGroup(
'general-fields',
__( 'General', 'woocommerce' ),
- [ 'name', 'product_status', 'catalog_visibility', 'upsell_ids' ]
+ [ 'name', 'product_status', 'catalog_visibility', 'grouped_products' ]
),
createProductEditFormGroup( 'image-fields', __( 'Images', 'woocommerce' ), [
'images',
@@ -283,6 +284,7 @@ const PARENT_OWNED_PRODUCT_EDIT_FIELD_ID_SET = new Set< ProductEditFieldId >( [
'featured',
'upsell_ids',
'cross_sell_ids',
+ 'grouped_products',
'external_url',
'button_text',
] );
diff --git a/packages/js/experimental-products-app/src/style.scss b/packages/js/experimental-products-app/src/style.scss
index 65d5168ec69..10fd41274e4 100644
--- a/packages/js/experimental-products-app/src/style.scss
+++ b/packages/js/experimental-products-app/src/style.scss
@@ -23,6 +23,7 @@
.product_page_woocommerce-products-dashboard,
#woocommerce-variations-classic-root,
.woocommerce-product-edit__drawer-portal {
+ @import "./fields/grouped_products/style.scss";
@import "./fields/components/list-item/style.scss";
@import "./fields/components/searchable-chip-select/style.scss";
@import "./fields/components/taxonomy-edit/style.scss";