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";