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 ) {