Commit 36ea0f7df9c for woocommerce

commit 36ea0f7df9cb6fcf5ed89f56172c4043abed7cd4
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Wed Apr 22 05:04:57 2026 +0200

    [DataViews - All Products] Add media and SEO field modules (#64272)

    * Add shared controls for products app fields

    * Add pricing fields to products app

    * Add inventory fields to products app

    * Add organization fields to products app

    * Add media and SEO fields to products app

    * remove not necessary code

    * clean up code

    * Update packages/js/experimental-products-app/tsconfig.json

    Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

    * Update packages/js/experimental-products-app/src/fields/components/date-picker.tsx

    Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

    * Fix list-item: import style.scss and default alt to empty string

    Agent-Logs-Url: https://github.com/woocommerce/woocommerce/sessions/f3cf7f22-fc05-4c03-a256-0b68bdb81cd5

    Co-authored-by: gigitux <4463174+gigitux@users.noreply.github.com>

    * fix build

    * fix build

    * lint code

    * fix tsconfig

    * lint code

    * Apply suggestion from @Copilot

    Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

    * lint code

    * fix ts errors

    * fix ts errors

    * fix types

    * fix naming

    * remove test

    * remove not necessary field

    ---------

    Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
    Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
    Co-authored-by: gigitux <4463174+gigitux@users.noreply.github.com>

diff --git a/packages/js/experimental-products-app/changelog/add-products-fields-media-seo b/packages/js/experimental-products-app/changelog/add-products-fields-media-seo
new file mode 100644
index 00000000000..792dbf3d60f
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-products-fields-media-seo
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add media, relationship, and SEO field modules for the experimental products app
diff --git a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx
index 0cc4156f9fd..888c65a7d42 100644
--- a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx
+++ b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/index.tsx
@@ -411,18 +411,18 @@ export function TaxonomyEdit< T extends Record< string, unknown > >( {
 					direction="row"
 					align="center"
 					style={ { gap: '12px' } }
-					className="woocommerce-next-taxonomy-edit__option"
+					className="woocommerce-taxonomy-edit__option"
 				>
 					{ suggestionItem.image?.src ? (
 						<img
 							src={ suggestionItem.image.src }
 							alt={ suggestionItem.image.alt ?? '' }
-							className="woocommerce-next-taxonomy-edit__option-thumbnail"
+							className="woocommerce-taxonomy-edit__option-thumbnail"
 						/>
 					) : (
-						<span className="woocommerce-next-taxonomy-edit__option-thumbnail woocommerce-next-taxonomy-edit__option-thumbnail--empty" />
+						<span className="woocommerce-taxonomy-edit__option-thumbnail woocommerce-taxonomy-edit__option-thumbnail--empty" />
 					) }
-					<span className="woocommerce-next-taxonomy-edit__option-label">
+					<span className="woocommerce-taxonomy-edit__option-label">
 						{ suggestionItem.label }
 					</span>
 				</Stack>
@@ -432,7 +432,7 @@ export function TaxonomyEdit< T extends Record< string, unknown > >( {
 	);

 	return (
-		<div className="woocommerce-next-taxonomy-edit">
+		<div className="woocommerce-taxonomy-edit">
 			<FormTokenField
 				__next40pxDefaultSize
 				__experimentalAutoSelectFirstMatch
diff --git a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/style.scss b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/style.scss
index b379cb07866..cab17664b4e 100644
--- a/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/style.scss
+++ b/packages/js/experimental-products-app/src/fields/components/taxonomy-edit/style.scss
@@ -1,4 +1,4 @@
-.woocommerce-next-taxonomy-edit {
+.woocommerce-taxonomy-edit {
 	&__chip-thumbnail {
 		width: 19px;
 		height: 19px;
@@ -7,7 +7,7 @@
 		margin-left: 1px;

 		&--empty {
-			background-color: var( --wp-components-color-gray-100 );
+			background-color: var(--wp-components-color-gray-100);
 		}
 	}

@@ -21,6 +21,6 @@
 		flex-shrink: 0;
 		border-radius: 4px;
 		object-fit: cover;
-		background-color: var( --wp-components-color-gray-100 );
+		background-color: var(--wp-components-color-gray-100);
 	}
 }
diff --git a/packages/js/experimental-products-app/src/fields/cross_sell_ids/field.tsx b/packages/js/experimental-products-app/src/fields/cross_sell_ids/field.tsx
new file mode 100644
index 00000000000..93515c38792
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/cross_sell_ids/field.tsx
@@ -0,0 +1,43 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord, ProductStatus } from '../types';
+
+import { ProductSelector } from '../components/product-selector';
+
+const LINKED_PRODUCT_STATUSES: ProductStatus[] = [ 'publish', 'draft' ];
+
+const fieldDefinition = {
+	label: __( 'Cross-sells', 'woocommerce' ),
+	description: __(
+		"Recommend related or complementary items to encourage additional purchases. These will be shown in the customer's shopping cart.",
+		'woocommerce'
+	),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	Edit: ( { data, onChange, field } ) => (
+		<ProductSelector
+			label={ field.label }
+			selectedProductIds={
+				( data.cross_sell_ids as number[] | undefined ) ?? []
+			}
+			onSelectedProductIdsChange={ ( ids ) =>
+				onChange( { cross_sell_ids: ids } )
+			}
+			excludeProductIds={ data.id ? [ data.id ] : undefined }
+			includeProductStatuses={ LINKED_PRODUCT_STATUSES }
+		/>
+	),
+};
diff --git a/packages/js/experimental-products-app/src/fields/images/field.tsx b/packages/js/experimental-products-app/src/fields/images/field.tsx
new file mode 100644
index 00000000000..f935973eda3
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/images/field.tsx
@@ -0,0 +1,328 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useMemo, useCallback, useState } from '@wordpress/element';
+import { IconButton } from '@wordpress/ui';
+import clsx from 'clsx';
+import type { Field } from '@wordpress/dataviews';
+import { upload, closeSmall, dragHandle } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+type Attachment = {
+	id: number;
+	url?: string;
+	alt?: string;
+	title?: string;
+	date?: string;
+	date_gmt?: string;
+	modified?: string;
+	modified_gmt?: string;
+	media_details?: {
+		sizes?: Record< string, { source_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 thumbnailUrl =
+		sizes?.woocommerce_thumbnail?.source_url ||
+		sizes?.thumbnail?.source_url ||
+		'';
+
+	return {
+		id: att.id,
+		src: att.url || '',
+		alt: att.alt || '',
+		name: att.title || '',
+		thumbnail: thumbnailUrl,
+		date_created: att.date || '',
+		date_created_gmt: att.date_gmt || '',
+		date_modified: att.modified || '',
+		date_modified_gmt: att.modified_gmt || '',
+	};
+};
+
+function moveItem< T >( items: T[], fromIndex: number, toIndex: number ) {
+	const nextItems = [ ...items ];
+	const [ movedItem ] = nextItems.splice( fromIndex, 1 );
+	nextItems.splice( toIndex, 0, movedItem );
+	return nextItems;
+}
+
+interface SortableImageProps {
+	image: ProductEntityRecord[ 'images' ][ number ];
+	alt: string;
+	onRemove: () => void;
+	isFeatured: boolean;
+	showDragHandle: boolean;
+	isDragging: boolean;
+	onDragStart: ( id: number ) => void;
+	onDragEnd: () => void;
+	onDropOn: ( id: number ) => void;
+}
+
+function SortableImage( {
+	image,
+	alt,
+	onRemove,
+	isFeatured,
+	showDragHandle,
+	isDragging,
+	onDragStart,
+	onDragEnd,
+	onDropOn,
+}: SortableImageProps ) {
+	const previewSrc = image.thumbnail || image.src;
+
+	const stopPropagation = useCallback( ( event: React.SyntheticEvent ) => {
+		event.stopPropagation();
+	}, [] );
+
+	return (
+		<div
+			role="group"
+			aria-label={ image.name }
+			onDragOver={ ( event ) => {
+				if ( showDragHandle ) {
+					event.preventDefault();
+				}
+			} }
+			onDrop={ ( event ) => {
+				event.preventDefault();
+				onDropOn( image.id );
+			} }
+			className={ clsx( 'woocommerce-fields-controls__image-wrapper', {
+				'is-dragging': isDragging,
+				'is-featured': isFeatured,
+			} ) }
+		>
+			<img className="product-image" src={ previewSrc } alt={ alt } />
+			<div className="woocommerce-fields-controls__image-overlay" />
+			{ showDragHandle && (
+				<div className="woocommerce-fields-controls__image-drag-handle-container">
+					<IconButton
+						draggable
+						icon={ dragHandle }
+						label={ __( 'Drag to reorder', 'woocommerce' ) }
+						className="woocommerce-fields-controls__image-drag-handle"
+						variant="minimal"
+						size="small"
+						tone="neutral"
+						onDragStart={ () => onDragStart( image.id ) }
+						onDragEnd={ onDragEnd }
+					/>
+				</div>
+			) }
+			{ ! isDragging && (
+				<IconButton
+					icon={ closeSmall }
+					label={ __( 'Remove image', 'woocommerce' ) }
+					onClick={ onRemove }
+					onPointerDown={ stopPropagation }
+					onKeyDown={ stopPropagation }
+					className="woocommerce-fields-controls__image-remove-button"
+					variant="minimal"
+					size="small"
+					tone="neutral"
+				/>
+			) }
+		</div>
+	);
+}
+
+const fieldDefinition = {
+	label: __( 'Featured Image', 'woocommerce' ),
+	enableSorting: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	render: ( { item } ) => {
+		const featuredImage = item.images?.at( 0 );
+
+		if ( ! featuredImage ) {
+			return null;
+		}
+
+		return (
+			<img
+				className="product-image"
+				src={ featuredImage.src }
+				alt={ featuredImage.alt || featuredImage.name || item.name }
+				style={ {
+					objectFit: 'cover',
+					borderRadius: 8,
+				} }
+			/>
+		);
+	},
+	Edit: ( { data, onChange } ) => {
+		const images = useMemo( () => data.images ?? [], [ data.images ] );
+		const [ draggedImageId, setDraggedImageId ] = useState< number | null >(
+			null
+		);
+
+		const handleSelect = useCallback(
+			( selection: Attachment | Attachment[] ) => {
+				const attachments = Array.isArray( selection )
+					? 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 ],
+				} );
+			},
+			[ images, onChange ]
+		);
+
+		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
+					),
+				} );
+			},
+			[ images, onChange ]
+		);
+
+		const handleDropOnImage = useCallback(
+			( targetImageId: number ) => {
+				if (
+					draggedImageId === null ||
+					draggedImageId === targetImageId
+				) {
+					setDraggedImageId( null );
+					return;
+				}
+
+				const sourceIndex = images.findIndex(
+					( image ) => image.id === draggedImageId
+				);
+				const targetIndex = images.findIndex(
+					( image ) => image.id === targetImageId
+				);
+
+				if ( sourceIndex < 0 || targetIndex < 0 ) {
+					setDraggedImageId( null );
+					return;
+				}
+
+				onChange( {
+					images: moveItem( images, sourceIndex, targetIndex ),
+				} );
+				setDraggedImageId( null );
+			},
+			[ draggedImageId, images, onChange ]
+		);
+
+		const removeCallbacks = useMemo( () => {
+			const callbacks = new Map< number | string, () => void >();
+			images.forEach( ( image ) => {
+				callbacks.set( image.id, () => handleRemoveImage( image ) );
+			} );
+			return callbacks;
+		}, [ images, handleRemoveImage ] );
+
+		return (
+			<div className="woocommerce-fields-control__featured-image">
+				<div className="woocommerce-fields-controls__featured-image-uploaded-images">
+					{ images.map( ( image, index ) => {
+						const onRemove = removeCallbacks.get( image.id );
+
+						if ( ! onRemove ) {
+							return null;
+						}
+
+						return (
+							<SortableImage
+								key={ image.id }
+								image={ image }
+								alt={ image.alt || data.name }
+								onRemove={ onRemove }
+								isFeatured={ index === 0 }
+								showDragHandle={ images.length > 1 }
+								isDragging={ draggedImageId === image.id }
+								onDragStart={ setDraggedImageId }
+								onDragEnd={ () => setDraggedImageId( null ) }
+								onDropOn={ handleDropOnImage }
+							/>
+						);
+					} ) }
+				</div>
+				<div className="woocommerce-fields-control__featured-image-actions">
+					<IconButton
+						variant="minimal"
+						icon={ upload }
+						label={ __( 'Add images', 'woocommerce' ) }
+						onClick={ handleOpenMediaLibrary }
+					/>
+				</div>
+			</div>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/fields/images/style.scss b/packages/js/experimental-products-app/src/fields/images/style.scss
new file mode 100644
index 00000000000..fe1b79ba7c6
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/images/style.scss
@@ -0,0 +1,149 @@
+@use "@wordpress/base-styles/variables";
+
+.woocommerce-fields-control__featured-image {
+	display: grid;
+	gap: 10px;
+	grid-template-columns: repeat( auto-fill, 108px );
+	grid-template-rows: repeat( auto-fill, 108px );
+}
+
+.woocommerce-fields-controls__featured-image-uploaded-images {
+	display: contents;
+}
+
+.woocommerce-fields-controls__image-wrapper {
+	position: relative;
+	width: 100%;
+	height: 100%;
+	border-radius: var( --wpds-border-radius-lg );
+	display: flex;
+	box-sizing: border-box;
+	transform-origin: 0 0;
+	touch-action: manipulation;
+}
+
+.woocommerce-fields-controls__image-wrapper.is-dragging {
+	transform-origin: 50% 50%;
+
+	.is-keyboard-dragging & {
+		transform-origin: 0 0;
+	}
+
+	.woocommerce-fields-controls__image-drag-handle-container {
+		opacity: 1;
+		z-index: 2;
+	}
+
+	.woocommerce-fields-controls__image-overlay {
+		opacity: 1;
+		background-color: rgba( 0, 0, 0, 0.5 );
+	}
+}
+
+.woocommerce-fields-controls__image-wrapper.is-featured {
+	grid-column: span 2;
+	grid-row: span 2;
+}
+
+.woocommerce-fields-controls__image-overlay {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: rgba( 0, 0, 0, 0.3 );
+	border-radius: var( --wpds-border-radius-lg );
+	z-index: 1;
+	opacity: 0;
+	transition: opacity 0.2s ease-in-out;
+	pointer-events: none;
+}
+
+.woocommerce-fields-controls__image-drag-handle-container {
+	position: absolute;
+	top: 50%;
+	left: 50%;
+	transform: translate( -50%, -50% );
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	opacity: 0;
+	transition: opacity 0.2s ease-in-out;
+	pointer-events: none;
+	z-index: 1;
+
+	&:has( .woocommerce-fields-controls__image-drag-handle:focus ) {
+		opacity: 1;
+	}
+}
+
+.woocommerce-fields-controls__image-drag-handle {
+	pointer-events: auto;
+	cursor: grab;
+	color: #fff;
+	border-radius: 3px;
+	background: transparent;
+
+	svg {
+		fill: #fff;
+	}
+
+	&:active {
+		cursor: grabbing;
+	}
+
+	&:hover {
+		border: none;
+	}
+}
+
+.woocommerce-fields-controls__image-remove-button {
+	position: absolute;
+	top: calc( 2 * var( --wpds-dimension-base ) );
+	right: calc( 2 * var( --wpds-dimension-base ) );
+	border-radius: 50%;
+	border: 1px solid var( --wpds-color-stroke-interactive-neutral );
+	background-color: var( --wpds-color-bg-surface-neutral-strong );
+	overflow: hidden;
+	width: variables.$button-size-small;
+	height: variables.$button-size-small;
+	opacity: 0;
+	transition: opacity 0.2s ease-in-out;
+	z-index: 1;
+}
+
+.woocommerce-fields-controls__image-wrapper {
+	&:hover,
+	&:focus-within {
+		.woocommerce-fields-controls__image-drag-handle-container,
+		.woocommerce-fields-controls__image-remove-button {
+			opacity: 1;
+		}
+
+		.woocommerce-fields-controls__image-overlay {
+			opacity: 1;
+		}
+	}
+}
+
+.woocommerce-fields-controls__image-wrapper img {
+	position: relative;
+	object-fit: cover;
+	border-radius: var( --wpds-border-radius-lg );
+	width: 100%;
+	height: 100%;
+}
+
+.woocommerce-fields-control__featured-image-actions {
+	width: 108px;
+	height: 108px;
+	border: 1px dashed var( --wpds-color-stroke-interactive-neutral );
+	border-radius: var( --wpds-border-radius-lg );
+
+	button {
+		width: 100%;
+		height: 100%;
+		cursor: pointer;
+		border-radius: var( --wpds-border-radius-lg );
+	}
+}
diff --git a/packages/js/experimental-products-app/src/fields/images_count/field.test.tsx b/packages/js/experimental-products-app/src/fields/images_count/field.test.tsx
new file mode 100644
index 00000000000..9d52d62788c
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/images_count/field.test.tsx
@@ -0,0 +1,45 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+import { fieldExtensions } from './field';
+
+const renderSummary = ( item: Partial< ProductEntityRecord > ) => {
+	if ( ! fieldExtensions.render ) {
+		throw new Error( 'images_count render not implemented' );
+	}
+
+	const Render = fieldExtensions.render as React.ComponentType< {
+		item: Partial< ProductEntityRecord >;
+	} >;
+
+	return render( React.createElement( Render, { item } ) );
+};
+
+describe( 'images_count field', () => {
+	it( 'renders the total number of images when present', () => {
+		renderSummary( {
+			images: [
+				{ id: 1 } as ProductEntityRecord[ 'images' ][ number ],
+				{ id: 2 } as ProductEntityRecord[ 'images' ][ number ],
+				{ id: 3 } as ProductEntityRecord[ 'images' ][ number ],
+			],
+		} );
+
+		expect( screen.getByText( '3' ) ).toBeInTheDocument();
+	} );
+
+	it( 'renders nothing when there are no images', () => {
+		const { container } = renderSummary( { images: [] } );
+
+		expect( container ).toBeEmptyDOMElement();
+	} );
+} );
diff --git a/packages/js/experimental-products-app/src/fields/images_count/field.tsx b/packages/js/experimental-products-app/src/fields/images_count/field.tsx
new file mode 100644
index 00000000000..24e42e7ad5e
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/images_count/field.tsx
@@ -0,0 +1,29 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	render: ( { item } ) => {
+		const images = Array.isArray( item.images ) ? item.images : [];
+		const count = images.length;
+
+		if ( count === 0 ) {
+			return null;
+		}
+
+		return <span>{ count }</span>;
+	},
+};
diff --git a/packages/js/experimental-products-app/src/fields/linked_products_count/field.tsx b/packages/js/experimental-products-app/src/fields/linked_products_count/field.tsx
new file mode 100644
index 00000000000..61eeba18989
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/linked_products_count/field.tsx
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	render: ( { item } ) => {
+		const upsellCount = item.upsell_ids?.length || 0;
+		const crossSellCount = item.cross_sell_ids?.length || 0;
+		const total = upsellCount + crossSellCount;
+		if ( total === 0 ) {
+			return null;
+		}
+		return <span>{ total }</span>;
+	},
+};
diff --git a/packages/js/experimental-products-app/src/fields/types.ts b/packages/js/experimental-products-app/src/fields/types.ts
index 7c2b61c1c55..6f526f3ba28 100644
--- a/packages/js/experimental-products-app/src/fields/types.ts
+++ b/packages/js/experimental-products-app/src/fields/types.ts
@@ -26,6 +26,17 @@ export type ProductEntityRecord = Omit< Product, 'categories' | 'tags' > & {
 	seo_title?: string;
 	seo_description?: string;
 	visible_in_pos?: boolean;
+	images: Array< {
+		alt: string;
+		date_created: string;
+		date_created_gmt: string;
+		date_modified: string;
+		date_modified_gmt: string;
+		id: number;
+		name: string;
+		src: string;
+		thumbnail: string;
+	} >;
 };

 export type ProductStatus = WooProductStatus;
diff --git a/packages/js/experimental-products-app/src/fields/upsell_ids/field.tsx b/packages/js/experimental-products-app/src/fields/upsell_ids/field.tsx
new file mode 100644
index 00000000000..2c57482ca2f
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/upsell_ids/field.tsx
@@ -0,0 +1,43 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord, ProductStatus } from '../types';
+
+import { ProductSelector } from '../components/product-selector';
+
+const LINKED_PRODUCT_STATUSES: ProductStatus[] = [ 'publish', 'draft' ];
+
+const fieldDefinition = {
+	label: __( 'Upsells', 'woocommerce' ),
+	description: __(
+		'Suggest higher-value or premium versions of this item to encourage upgrades. These will be shown on the same page as this product.',
+		'woocommerce'
+	),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	Edit: ( { data, onChange, field } ) => (
+		<ProductSelector
+			label={ field.label }
+			selectedProductIds={
+				( data.upsell_ids as number[] | undefined ) ?? []
+			}
+			onSelectedProductIdsChange={ ( ids ) =>
+				onChange( { upsell_ids: ids } )
+			}
+			excludeProductIds={ data.id ? [ data.id ] : undefined }
+			includeProductStatuses={ LINKED_PRODUCT_STATUSES }
+		/>
+	),
+};