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 }
+ />
+ ),
+};