Commit fee2f5e918c for woocommerce
commit fee2f5e918c72ef19b5f55cfb8cdfcba339d2cd0
Author: Luigi Teschio <gigitux@gmail.com>
Date: Thu May 21 14:11:22 2026 +0200
Fix variation image save payload (#65240)
* Fix variation image save payload
* Add changelog entry for variation image save fix
* Limit variation image field to one image
* lint code
diff --git a/packages/js/experimental-products-app/changelog/fix-variation-image-save b/packages/js/experimental-products-app/changelog/fix-variation-image-save
new file mode 100644
index 00000000000..acda80f00f6
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/fix-variation-image-save
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix variation image updates in the experimental products app.
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
index 00d1f26b5d2..f158ff4ee4e 100644
--- a/packages/js/experimental-products-app/src/fields/images/field.test.tsx
+++ b/packages/js/experimental-products-app/src/fields/images/field.test.tsx
@@ -55,15 +55,40 @@ describe( 'images field', () => {
...overrides,
} as ProductEntityRecord );
+ const renderImagesEdit = (
+ data: ProductEntityRecord,
+ onChange = jest.fn()
+ ) => {
+ if ( ! fieldExtensions.Edit ) {
+ throw new Error( 'images edit not implemented' );
+ }
+
+ const Edit = fieldExtensions.Edit as React.ComponentType<
+ DataFormControlProps< ProductEntityRecord >
+ >;
+
+ render(
+ <Edit
+ data={ data }
+ field={
+ {
+ ...fieldExtensions,
+ id: 'images',
+ label: 'Images',
+ } as DataFormControlProps< ProductEntityRecord >[ 'field' ]
+ }
+ onChange={ onChange }
+ />
+ );
+
+ return onChange;
+ };
+
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,
@@ -78,30 +103,18 @@ describe( 'images field', () => {
},
];
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={
+ renderImagesEdit(
+ buildProduct( {
+ images: [
{
- ...fieldExtensions,
- id: 'images',
- label: 'Images',
- } as DataFormControlProps< ProductEntityRecord >[ 'field' ]
- }
- onChange={ onChange }
- />
+ id: 15,
+ src: 'old-image.jpg',
+ alt: 'Old image',
+ } as ProductEntityRecord[ 'images' ][ number ],
+ ],
+ } ),
+ onChange
);
expect( mockMediaUpload ).toHaveBeenCalledWith(
@@ -146,4 +159,104 @@ describe( 'images field', () => {
],
} );
} );
+
+ it( 'limits variations to a single selected image', () => {
+ const attachments = [
+ {
+ id: 34,
+ url: 'new-variation-image.jpg',
+ alt: 'New variation image',
+ title: 'New variation image title',
+ sizes: {
+ thumbnail: {
+ url: 'new-variation-image-thumbnail.jpg',
+ },
+ },
+ },
+ {
+ id: 35,
+ url: 'extra-variation-image.jpg',
+ alt: 'Extra variation image',
+ title: 'Extra variation image title',
+ },
+ ];
+ const onChange = jest.fn();
+
+ renderImagesEdit(
+ buildProduct( {
+ type: 'variation',
+ images: [
+ {
+ id: 15,
+ src: 'old-variation-image.jpg',
+ alt: 'Old variation image',
+ } as ProductEntityRecord[ 'images' ][ number ],
+ {
+ id: 16,
+ src: 'second-variation-image.jpg',
+ alt: 'Second variation image',
+ } as ProductEntityRecord[ 'images' ][ number ],
+ ],
+ } ),
+ onChange
+ );
+
+ expect( mockMediaUpload ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ allowedTypes: [ 'image' ],
+ multiple: false,
+ title: 'Add image',
+ value: [ 15 ],
+ } )
+ );
+
+ expect(
+ screen.getByRole( 'img', {
+ name: 'Old variation image',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'img', {
+ name: 'Second variation image',
+ } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'button', {
+ name: 'Drag to reorder',
+ } )
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: 'Add image',
+ } )
+ );
+ expect( mockOpenMediaUploadModal ).toHaveBeenCalled();
+
+ act( () => {
+ mockMediaUpload.mock.calls[ 0 ][ 0 ].onSelect( attachments );
+ } );
+
+ expect(
+ screen.getByRole( 'img', {
+ name: 'New variation image',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'img', {
+ name: 'Extra variation image',
+ } )
+ ).not.toBeInTheDocument();
+ expect( onChange ).toHaveBeenCalledWith( {
+ images: [
+ expect.objectContaining( {
+ id: 34,
+ src: 'new-variation-image.jpg',
+ alt: 'New variation image',
+ name: 'New variation image title',
+ thumbnail: 'new-variation-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 0a623db7cdb..6799747622e 100644
--- a/packages/js/experimental-products-app/src/fields/images/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/images/field.tsx
@@ -150,8 +150,16 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
);
},
Edit: ( { data, onChange } ) => {
- const dataImages = useMemo( () => data.images ?? [], [ data.images ] );
+ const isVariation = data.type === 'variation';
+ const dataImages = useMemo( () => {
+ const nextImages = data.images ?? [];
+
+ return isVariation ? nextImages.slice( 0, 1 ) : nextImages;
+ }, [ data.images, isVariation ] );
const [ images, setImages ] = useState( dataImages );
+ const uploadLabel = isVariation
+ ? __( 'Add image', 'woocommerce' )
+ : __( 'Add images', 'woocommerce' );
useEffect( () => {
setImages( dataImages );
@@ -174,9 +182,11 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
: [ selection ];
const mappedImages = attachments.map( toProductImage );
- commitImages( mappedImages );
+ commitImages(
+ isVariation ? mappedImages.slice( 0, 1 ) : mappedImages
+ );
},
- [ commitImages ]
+ [ commitImages, isVariation ]
);
const handleRemoveImage = useCallback(
@@ -253,7 +263,9 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
index={ index }
alt={ image.alt || data.name }
onRemove={ onRemove }
- showDragHandle={ images.length > 1 }
+ showDragHandle={
+ ! isVariation && images.length > 1
+ }
/>
);
} ) }
@@ -261,18 +273,15 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
<div className="woocommerce-fields-control__featured-image-actions">
<MediaUpload
allowedTypes={ [ 'image' ] }
- multiple="add"
+ multiple={ isVariation ? false : 'add' }
onSelect={ handleSelect }
- title={ __( 'Add images', 'woocommerce' ) }
+ title={ uploadLabel }
value={ images.map( ( image ) => image.id ) }
render={ ( { open }: { open: () => void } ) => (
<IconButton
variant="minimal"
icon={ upload }
- label={ __(
- 'Add images',
- 'woocommerce'
- ) }
+ label={ uploadLabel }
onClick={ open }
/>
) }
diff --git a/packages/js/experimental-products-app/src/product-edit/save.test.ts b/packages/js/experimental-products-app/src/product-edit/save.test.ts
index 085e99782c8..8216736bb54 100644
--- a/packages/js/experimental-products-app/src/product-edit/save.test.ts
+++ b/packages/js/experimental-products-app/src/product-edit/save.test.ts
@@ -58,6 +58,137 @@ describe( 'saveSelectedProducts', () => {
jest.clearAllMocks();
} );
+ it( 'sends variation image updates using the variation REST image field', async () => {
+ const editedVariation = buildVariation( {
+ id: 101,
+ images: [
+ {
+ id: 55,
+ src: 'https://example.com/blue.jpg',
+ alt: 'Blue',
+ name: 'Blue image',
+ thumbnail: 'https://example.com/blue-thumbnail.jpg',
+ date_created: '',
+ date_created_gmt: '',
+ date_modified: '',
+ date_modified_gmt: '',
+ },
+ ],
+ } );
+ const editedParent = buildProduct( {
+ id: 10,
+ type: 'variable',
+ _embedded: {
+ variations: [ editedVariation ],
+ },
+ } );
+ const editEntityRecord = jest.fn(
+ (
+ _kind,
+ _name,
+ _recordId,
+ edits: Partial< ProductEntityRecord >
+ ) => {
+ Object.assign( editedParent, edits );
+ }
+ );
+ const saveEditedEntityRecord = jest.fn( async () => editedParent );
+
+ mockGetEditedEntityRecord.mockImplementation( ( _kind, _name, id ) =>
+ id === editedParent.id ? editedParent : undefined
+ );
+ ( apiFetch as unknown as jest.Mock ).mockResolvedValueOnce( {
+ id: 101,
+ parent_id: 10,
+ name: 'Blue saved',
+ image: {
+ id: 55,
+ src: 'https://example.com/blue.jpg',
+ alt: 'Blue',
+ name: 'Blue image',
+ },
+ manage_stock: false,
+ } );
+
+ await saveSelectedProducts( {
+ selectedProducts: [ editedVariation ],
+ editEntityRecord,
+ saveEditedEntityRecord,
+ } );
+
+ const request = ( apiFetch as unknown as jest.Mock ).mock
+ .calls[ 0 ][ 0 ];
+
+ expect( request ).toEqual(
+ expect.objectContaining( {
+ path: '/wc/v3/products/10/variations/101',
+ method: 'PUT',
+ data: expect.objectContaining( {
+ image: expect.objectContaining( {
+ id: 55,
+ src: 'https://example.com/blue.jpg',
+ alt: 'Blue',
+ name: 'Blue image',
+ } ),
+ } ),
+ } )
+ );
+ expect( request.data.images ).toBeUndefined();
+ expect( request.data.image.thumbnail ).toBeUndefined();
+ } );
+
+ it( 'sends an empty variation image object when removing a variation image', async () => {
+ const editedVariation = buildVariation( {
+ id: 101,
+ images: [],
+ } );
+ const editedParent = buildProduct( {
+ id: 10,
+ type: 'variable',
+ _embedded: {
+ variations: [ editedVariation ],
+ },
+ } );
+ const editEntityRecord = jest.fn(
+ (
+ _kind,
+ _name,
+ _recordId,
+ edits: Partial< ProductEntityRecord >
+ ) => {
+ Object.assign( editedParent, edits );
+ }
+ );
+ const saveEditedEntityRecord = jest.fn( async () => editedParent );
+
+ mockGetEditedEntityRecord.mockImplementation( ( _kind, _name, id ) =>
+ id === editedParent.id ? editedParent : undefined
+ );
+ ( apiFetch as unknown as jest.Mock ).mockResolvedValueOnce( {
+ id: 101,
+ parent_id: 10,
+ name: 'Blue saved',
+ image: null,
+ manage_stock: false,
+ } );
+
+ await saveSelectedProducts( {
+ selectedProducts: [ editedVariation ],
+ editEntityRecord,
+ saveEditedEntityRecord,
+ } );
+
+ const request = ( apiFetch as unknown as jest.Mock ).mock
+ .calls[ 0 ][ 0 ];
+
+ expect( request.data ).toEqual(
+ expect.objectContaining( {
+ image: {},
+ } )
+ );
+ expect( request.data.images ).toBeUndefined();
+ } );
+
it( 'keeps edits for selected variations that failed after another variation saved', async () => {
const originalSavedVariation = buildVariation( {
id: 101,
diff --git a/packages/js/experimental-products-app/src/product-edit/save.ts b/packages/js/experimental-products-app/src/product-edit/save.ts
index 68053e9c1c6..3daa8f09f93 100644
--- a/packages/js/experimental-products-app/src/product-edit/save.ts
+++ b/packages/js/experimental-products-app/src/product-edit/save.ts
@@ -21,6 +21,15 @@ type ProductVariationEntityRecord = ProductEntityRecord & {
parent_id: number;
};
+type ProductVariationSaveData = Omit<
+ Partial< ProductEntityRecord >,
+ 'images'
+> & {
+ image?:
+ | NonNullable< ProductVariation[ 'image' ] >
+ | Record< string, never >;
+};
+
type ProductSaveResult = PromiseSettledResult<
ProductEntityRecord | ProductVariation
>;
@@ -50,6 +59,29 @@ function getEditedProduct( productId: number ) {
return product !== false ? product : undefined;
}
+function getVariationImageSaveData(
+ image: ProductEntityRecord[ 'images' ][ number ] | undefined
+) {
+ if ( ! image ) {
+ return {};
+ }
+
+ const { thumbnail, ...variationImage } = image;
+
+ return variationImage;
+}
+
+function getVariationSaveData(
+ variation: ProductEntityRecord
+): ProductVariationSaveData {
+ const { images, ...data } = variation;
+
+ return {
+ ...data,
+ image: getVariationImageSaveData( images?.[ 0 ] ),
+ };
+}
+
async function saveVariation(
product: ProductVariationEntityRecord,
editEntityRecord: EditProductRecord
@@ -62,7 +94,7 @@ async function saveVariation(
const savedVariation = await apiFetch< ProductVariation >( {
path: getProductVariationUpdatePath( product ),
method: 'PUT',
- data: editedVariation,
+ data: getVariationSaveData( editedVariation ),
} );
if ( parentProduct ) {