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)