Commit 16e44eafe0d for woocommerce

commit 16e44eafe0d5a7c9b3b4b6374752accef56d13bc
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Tue May 12 22:55:49 2026 +0200

    Fix products app image field selection (#64782)

    * Fix products app image field selection

    * Use MediaUpload for products app images

diff --git a/packages/js/experimental-products-app/changelog/fix-product-image-field-selection b/packages/js/experimental-products-app/changelog/fix-product-image-field-selection
new file mode 100644
index 00000000000..e42e0971014
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/fix-product-image-field-selection
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix product image selection in the experimental products app quick edit form.
diff --git a/packages/js/experimental-products-app/package.json b/packages/js/experimental-products-app/package.json
index cb875196bc4..05b498b5d42 100644
--- a/packages/js/experimental-products-app/package.json
+++ b/packages/js/experimental-products-app/package.json
@@ -50,6 +50,7 @@
 		"@wordpress/html-entities": "catalog:wp-min",
 		"@wordpress/i18n": "catalog:wp-min",
 		"@wordpress/icons": "10.6.0",
+		"@wordpress/media-utils": "catalog:wp-min",
 		"@wordpress/private-apis": "1.44.0",
 		"@wordpress/router": "1.11.0",
 		"@wordpress/ui": "0.12.0",
diff --git a/packages/js/experimental-products-app/src/fields/images/field.test.tsx b/packages/js/experimental-products-app/src/fields/images/field.test.tsx
new file mode 100644
index 00000000000..00d1f26b5d2
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/images/field.test.tsx
@@ -0,0 +1,149 @@
+/**
+ * External dependencies
+ */
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import type { DataFormControlProps } from '@wordpress/dataviews';
+import React from 'react';
+
+type MockMediaUploadProps = {
+	allowedTypes?: string[];
+	multiple?: boolean | string;
+	onSelect: ( attachments: unknown ) => void;
+	render: ( args: { open: () => void } ) => React.ReactNode;
+	title?: string;
+	value?: number[];
+};
+
+const mockOpenMediaUploadModal = jest.fn();
+const mockMediaUpload = jest.fn( ( props: MockMediaUploadProps ) =>
+	props.render( { open: mockOpenMediaUploadModal } )
+);
+
+jest.mock( '@wordpress/media-utils', () => ( {
+	MediaUpload: ( props: MockMediaUploadProps ) => mockMediaUpload( props ),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { fieldExtensions } from './field';
+
+jest.mock( '@dnd-kit/react', () => ( {
+	DragDropProvider: ( { children }: { children: React.ReactNode } ) =>
+		children,
+} ) );
+
+jest.mock( '@dnd-kit/react/sortable', () => ( {
+	isSortable: () => false,
+	useSortable: () => ( {
+		ref: () => undefined,
+		handleRef: () => undefined,
+		isDragging: false,
+	} ),
+} ) );
+
+describe( 'images field', () => {
+	const buildProduct = (
+		overrides: Partial< ProductEntityRecord > = {}
+	): ProductEntityRecord =>
+		( {
+			id: 12,
+			name: 'Beanie',
+			images: [],
+			...overrides,
+		} as ProductEntityRecord );
+
+	afterEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'replaces the current images with the selected media attachments', () => {
+		if ( ! fieldExtensions.Edit ) {
+			throw new Error( 'images edit not implemented' );
+		}
+
+		const attachments = [
+			{
+				id: 34,
+				url: 'new-image.jpg',
+				alt: 'New image',
+				title: 'New image title',
+				sizes: {
+					thumbnail: {
+						url: 'new-image-thumbnail.jpg',
+					},
+				},
+			},
+		];
+		const onChange = jest.fn();
+		const Edit = fieldExtensions.Edit as React.ComponentType<
+			DataFormControlProps< ProductEntityRecord >
+		>;
+
+		render(
+			<Edit
+				data={ buildProduct( {
+					images: [
+						{
+							id: 15,
+							src: 'old-image.jpg',
+							alt: 'Old image',
+						} as ProductEntityRecord[ 'images' ][ number ],
+					],
+				} ) }
+				field={
+					{
+						...fieldExtensions,
+						id: 'images',
+						label: 'Images',
+					} as DataFormControlProps< ProductEntityRecord >[ 'field' ]
+				}
+				onChange={ onChange }
+			/>
+		);
+
+		expect( mockMediaUpload ).toHaveBeenCalledWith(
+			expect.objectContaining( {
+				allowedTypes: [ 'image' ],
+				multiple: 'add',
+				title: 'Add images',
+				value: [ 15 ],
+			} )
+		);
+
+		fireEvent.click(
+			screen.getByRole( 'button', {
+				name: 'Add images',
+			} )
+		);
+		expect( mockOpenMediaUploadModal ).toHaveBeenCalled();
+
+		act( () => {
+			mockMediaUpload.mock.calls[ 0 ][ 0 ].onSelect( attachments );
+		} );
+
+		expect(
+			screen.getByRole( 'img', {
+				name: 'New image',
+			} )
+		).toBeInTheDocument();
+		expect(
+			screen.queryByRole( 'img', {
+				name: 'Old image',
+			} )
+		).not.toBeInTheDocument();
+		expect( onChange ).toHaveBeenCalledWith( {
+			images: [
+				expect.objectContaining( {
+					id: 34,
+					src: 'new-image.jpg',
+					alt: 'New image',
+					name: 'New image title',
+					thumbnail: 'new-image-thumbnail.jpg',
+				} ),
+			],
+		} );
+	} );
+} );
diff --git a/packages/js/experimental-products-app/src/fields/images/field.tsx b/packages/js/experimental-products-app/src/fields/images/field.tsx
index 0e8f2791293..a5edcb38351 100644
--- a/packages/js/experimental-products-app/src/fields/images/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/images/field.tsx
@@ -2,11 +2,12 @@
  * External dependencies
  */
 import { __ } from '@wordpress/i18n';
-import { useMemo, useCallback } from '@wordpress/element';
+import { useMemo, useCallback, useEffect, useState } from '@wordpress/element';
 import { Fieldset, IconButton } from '@wordpress/ui';
 import clsx from 'clsx';
 import type { Field } from '@wordpress/dataviews';
 import { upload, closeSmall, dragHandle } from '@wordpress/icons';
+import { MediaUpload } from '@wordpress/media-utils';
 import { DragDropProvider, type DragEndEvent } from '@dnd-kit/react';
 import { isSortable, useSortable } from '@dnd-kit/react/sortable';

@@ -24,36 +25,21 @@ type Attachment = {
 	date_gmt?: string;
 	modified?: string;
 	modified_gmt?: string;
+	sizes?: Record< string, { source_url?: string; url?: string } >;
 	media_details?: {
-		sizes?: Record< string, { source_url: string } >;
+		sizes?: Record< string, { source_url?: string; url?: string } >;
 	};
 };

-declare global {
-	interface Window {
-		wp?: {
-			media?: ( options: Record< string, unknown > ) => {
-				on: ( event: 'select', callback: () => void ) => void;
-				open: () => void;
-				state: () => {
-					get: ( key: string ) => {
-						toJSON: () => Attachment[];
-					};
-				};
-			};
-		};
-	}
-}
-
 const toProductImage = (
 	att: Attachment
 ): ProductEntityRecord[ 'images' ][ number ] => {
-	const sizes = att.media_details?.sizes as
-		| Record< string, { source_url: string } >
-		| undefined;
+	const sizes = att.media_details?.sizes || att.sizes;
 	const thumbnailUrl =
 		sizes?.woocommerce_thumbnail?.source_url ||
+		sizes?.woocommerce_thumbnail?.url ||
 		sizes?.thumbnail?.source_url ||
+		sizes?.thumbnail?.url ||
 		'';

 	return {
@@ -164,7 +150,22 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 		);
 	},
 	Edit: ( { data, onChange, field } ) => {
-		const images = useMemo( () => data.images ?? [], [ data.images ] );
+		const dataImages = useMemo( () => data.images ?? [], [ data.images ] );
+		const [ images, setImages ] = useState( dataImages );
+
+		useEffect( () => {
+			setImages( dataImages );
+		}, [ dataImages ] );
+
+		const commitImages = useCallback(
+			( nextImages: ProductEntityRecord[ 'images' ] ) => {
+				setImages( nextImages );
+				onChange( {
+					images: nextImages,
+				} );
+			},
+			[ onChange ]
+		);

 		const handleSelect = useCallback(
 			( selection: Attachment | Attachment[] ) => {
@@ -172,60 +173,19 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 					? selection
 					: [ selection ];
 				const mappedImages = attachments.map( toProductImage );
-				const selectedIds = new Set(
-					mappedImages.map( ( image ) => image.id )
-				);
-				const existingImages = images.filter( ( image ) =>
-					selectedIds.has( image.id )
-				);
-				const existingIds = new Set(
-					images.map( ( image ) => image.id )
-				);
-				const newImages = mappedImages.filter(
-					( image ) => ! existingIds.has( image.id )
-				);

-				onChange( {
-					images: [ ...existingImages, ...newImages ],
-				} );
+				commitImages( mappedImages );
 			},
-			[ images, onChange ]
+			[ commitImages ]
 		);

-		const handleOpenMediaLibrary = useCallback( () => {
-			const media = window.wp?.media;
-
-			if ( ! media ) {
-				return;
-			}
-
-			const frame = media( {
-				title: __( 'Add images', 'woocommerce' ),
-				button: {
-					text: __( 'Use images', 'woocommerce' ),
-				},
-				multiple: true,
-				library: {
-					type: 'image',
-				},
-			} );
-
-			frame.on( 'select', () => {
-				handleSelect( frame.state().get( 'selection' ).toJSON() );
-			} );
-
-			frame.open();
-		}, [ handleSelect ] );
-
 		const handleRemoveImage = useCallback(
 			( imageToRemove: ProductEntityRecord[ 'images' ][ number ] ) => {
-				onChange( {
-					images: images.filter(
-						( image ) => image.id !== imageToRemove.id
-					),
-				} );
+				commitImages(
+					images.filter( ( image ) => image.id !== imageToRemove.id )
+				);
 			},
-			[ images, onChange ]
+			[ commitImages, images ]
 		);

 		const handleDragEnd = useCallback(
@@ -259,11 +219,9 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 				);
 				reorderedImages.splice( index, 0, movedImage );

-				onChange( {
-					images: reorderedImages,
-				} );
+				commitImages( reorderedImages );
 			},
-			[ images, onChange ]
+			[ commitImages, images ]
 		);

 		const removeCallbacks = useMemo( () => {
@@ -302,11 +260,23 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 							} ) }
 						</div>
 						<div className="woocommerce-fields-control__featured-image-actions">
-							<IconButton
-								variant="minimal"
-								icon={ upload }
-								label={ __( 'Add images', 'woocommerce' ) }
-								onClick={ handleOpenMediaLibrary }
+							<MediaUpload
+								allowedTypes={ [ 'image' ] }
+								multiple="add"
+								onSelect={ handleSelect }
+								title={ __( 'Add images', 'woocommerce' ) }
+								value={ images.map( ( image ) => image.id ) }
+								render={ ( { open }: { open: () => void } ) => (
+									<IconButton
+										variant="minimal"
+										icon={ upload }
+										label={ __(
+											'Add images',
+											'woocommerce'
+										) }
+										onClick={ open }
+									/>
+								) }
 							/>
 						</div>
 					</div>
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 482e092f727..9b4089128c2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1749,6 +1749,9 @@ importers:
       '@wordpress/icons':
         specifier: 10.6.0
         version: 10.6.0(react@18.3.1)
+      '@wordpress/media-utils':
+        specifier: catalog:wp-min
+        version: 5.19.2
       '@wordpress/notices':
         specifier: 5.44.0
         version: 5.44.0(@emotion/is-prop-valid@1.4.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)